diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 525def352..6ce89fcdb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -31,6 +31,7 @@ "dependencies": { "@sentry/electron": "^7.10.0", "electron-updater": "^6.3.9", + "jszip": "^3.10.1", "pino": "^10.3.1", "sharp": "0.34.5", "yauzl": "^3.2.1" @@ -109,6 +110,10 @@ "from": "node_modules/@img", "to": "bundled-node-modules/@img" }, + { + "from": "node_modules/jszip", + "to": "bundled-node-modules/jszip" + }, { "from": "build-config.json", "to": "build-config.json", diff --git a/apps/desktop/static/bundled-skills/deploy-skill/SKILL.md b/apps/desktop/static/bundled-skills/deploy-skill/SKILL.md new file mode 100644 index 000000000..2e4ee144d --- /dev/null +++ b/apps/desktop/static/bundled-skills/deploy-skill/SKILL.md @@ -0,0 +1,176 @@ +--- +name: deploy-skill +description: "Deploy static websites to Cloudflare Pages. Use when a user wants to upload a zip bundle, publish it, and get a final live link later." +--- + +# deploy-skill + +Deploy a zipped static website bundle to Cloudflare Pages through the remote deploy server. + +Default production gateway: +- `https://deploy.nexu.io` + +This skill is self-contained: +- it reads its own deploy server config from `~/.nexu/deploy-skill.json` +- it resolves the Nexu cloud API key using a multi-path fallback (see below) +- it stores submitted jobs in `~/.nexu/deploy-skill-jobs.json` +- it can either submit an existing zip or render a built-in template into a zip first +- it submits the resulting zip directly to the deploy server +- it emits a follow-up async payload so the final completion message is delivered later + +### Nexu cloud credential lookup + +The skill searches the following `config.json` locations in order and uses the +first one that contains a valid `desktop.cloud` section with `connected: true` +and a non-empty `apiKey`: + +1. `{NEXU_HOME}/config.json` — respects an explicit `NEXU_HOME` env var, otherwise `~/.nexu/config.json`. +2. `~/Library/Application Support/@nexu/desktop/.nexu/config.json` — the real location used by the Nexu desktop app on macOS. +3. `~/.nexu/config.json` — legacy fallback (skipped if already covered by candidate 1). + +Resolution rules: +- If a candidate file exists and has a `desktop.cloud` section, that file is authoritative — the skill either uses its `apiKey` or fails with a specific error for that file. It does **not** fall through to the next candidate in that case. +- If a candidate file is missing, or exists but has no `desktop.cloud` section at all, the skill moves on to the next candidate. +- If every candidate falls through, the skill fails with a "could not find a Nexu cloud configuration" error that lists every path it checked. + +This means a user who has logged into the Nexu desktop app (which writes to candidate 2) will have their API key picked up automatically, even if the older `~/.nexu/config.json` is empty or legacy. + +## Requirements + +- Node.js 24+ +- `baseUrl` configured in `~/.nexu/deploy-skill.json` +- a logged-in Nexu cloud account so `~/.nexu/config.json` contains `desktop.cloud.apiKey` + +## Setup + +The production deploy gateway for this skill is: +- `https://deploy.nexu.io` + +Configure the deploy server base URL: + +```bash +node scripts/deploy_skill.js setup --base-url https://deploy.nexu.io +``` + +Validate the setup: + +```bash +node scripts/deploy_skill.js check +``` + +## Submit a Deploy + +```bash +node scripts/deploy_skill.js submit \ + --zip /absolute/path/to/site.zip \ + --bot-id BOT_ID \ + --chat-id CHAT_ID \ + --chat-type channel \ + --channel slack \ + [--to DELIVERY_TARGET] \ + [--thread-id THREAD_ID] \ + [--account-id ACCOUNT_ID] \ + [--session-key SESSION_KEY] \ + [--user-id USER_ID] +``` + +On success, the command returns immediately and emits a `sessions_spawn` payload so the runtime can continue polling in the background. + +## Submit a Template Candidate + +The first built-in candidate template is `distill-campaign`. + +```bash +node scripts/deploy_skill.js submit \ + --template-id distill-campaign \ + --content-file /absolute/path/to/content.json \ + --bot-id BOT_ID \ + --chat-id CHAT_ID \ + --chat-type channel \ + --channel slack \ + [--to DELIVERY_TARGET] \ + [--thread-id THREAD_ID] \ + [--account-id ACCOUNT_ID] \ + [--session-key SESSION_KEY] \ + [--user-id USER_ID] +``` + +The content file must be structured JSON with this exact frame. Each field maps to a specific slot in the rendered page layout — write content with the target slot in mind. + +Left sidebar (identity panel): +- `title`: 2-10 characters — user's name / cyber-persona headline, shown in `.profile-name` under the avatar. +- `subtitle`: 15-30 characters and must include `牛马指数`, `/100`, and `—` — short signature line rendered as `.profile-sub` directly under the name. Example format: `牛马指数 92/100 — 龙虾成瘾者`. +- `portraitId`: must be one of `portrait-1` through `portrait-7` — chooses the avatar image shown in the sidebar AND on each `bot` chat bubble. +- `tags`: 1-8 strings, each 2-8 characters — rendered both as the social-tag chips in the sidebar and as the quick-reply prompt buttons in the chat card (first 4). Keep tags punchy, noun-like, suitable for both roles. + +Right column (tabbed layout: 核心指标 / 深度扒皮 / 和我对话 / 技能文件): +- `metrics`: 4-6 items total. `metrics[0]` becomes the hero score card (big number + label) in the 核心指标 tab and also drives the poster overlay score; its `value` should be a bare integer (e.g. `"92"`, no `%`). `metrics[1..]` become the horizontal bar chart rows below the species card, each with an SVG icon and a percentage bar — their `value` should be a percentage string (e.g. `"88%"`). +- `posterSpeciesEmoji`: short emoji (1-4 chars) — shown in the species card next to the name and in the poster text overlay. +- `posterSpeciesName`: 3-8 characters — the "物种" name in the species card (e.g. `龙虾成瘾者`). +- `posterSpeciesSub`: 5-8 characters — small subtitle under the species name (e.g. `办公室物种鉴定`). +- `description`: 150-250 characters, no markdown, HTML, or newlines — the main roast paragraph rendered as `.roast-text` inside the 核心指标 tab, above the bar chart. Write it as one dense paragraph; the template does not support paragraph breaks here. +- `qaCards`: 2-3 items, each `{ question, answer }` — the 深度扒皮 tab. Each card gets a rotating icon (🔥 💪 💀 …). `question` is the title (short, label-style), `answer` is the body (1-3 sentences per card). +- `dialogs`: 3-6 items, each `{ speaker: "bot" | "user", text }` — the 和我对话 tab. The first message should usually be a `bot` intro. The template alternates bubbles and shows the avatar next to `bot` messages. +- `ctaText`: must equal `⭐ 生成我的牛马锐评` — shown in the "蒸馏完成度" progress header in the 技能文件 tab. +- `installText`: must equal `复制链接发给你的 nexu agent:https://github.com/nexu-io/roast-skill` — shown inside the copy-to-clipboard code block in the 技能文件 tab. + +Poster (share modal): +- The template ships a static `assets/poster.png` artwork as the backdrop. The following fields are overlaid as text on top of the artwork: `title`, `subtitle`, `metrics[0].value`, `metrics[0].label`, and `posterSpeciesEmoji` + `posterSpeciesName`. There is no per-user image composition — the poster artwork is the same PNG for every user; only the text overlay changes. + +If any field violates the frame, the skill rejects the payload and does not render. It never truncates, rewrites, or invents missing values. + +Portrait rule: +- The agent must choose `portraitId` explicitly. +- The skill does not randomly pick a head portrait anymore. +- Any missing or unknown `portraitId` is rejected before rendering. + +To restore unfinished jobs after a restart: + +```bash +node scripts/deploy_skill.js recover +``` + +## Final Success Message + +When the deploy finishes successfully, the async follow-up must tell the user exactly: + +> Your website is ready, the link is {link} + +## Mandatory Guard Checklist + +This skill has a hard anti-hallucination rule. The model must verify each step before it can describe that step as successful. + +Submit step checks: +- confirm `~/.nexu/deploy-skill.json` exists and contains a valid `baseUrl` +- confirm `~/.nexu/config.json` contains a connected Nexu cloud account with a non-empty API key +- confirm either the upload path exists and ends with `.zip`, or the template id is registered and the content file matches the template schema +- confirm submit sends `Authorization: Bearer ` from the local Nexu config +- confirm the server response contains a real `jobId` +- confirm `taskType` is exactly `static-deploy` +- confirm `status` is exactly `queued` or `running` +- if a delivery target is available, confirm it is persisted as `to` in the local job record so later notification does not need to guess the recipient +- confirm the accepted job was persisted locally with the same `jobId` and `status` + +Polling step checks: +- confirm the queried job already exists in `~/.nexu/deploy-skill-jobs.json` +- confirm polling sends the same `Authorization: Bearer ` from the local Nexu config +- confirm the server response still references the same `jobId` +- confirm `status` is one of `queued`, `running`, `succeeded`, `failed`, or `cancelled` +- if `status` is `succeeded`, confirm `result.url` exists and ends with `.nexu.space` before generating any success message +- if `status` is `failed` or `cancelled`, relay the server error message and hint instead of inventing one +- if polling times out, only fall back to a temporary `.pages.dev` link when that link already exists in the persisted job record + +Output rule: +- If any check fails, stop and return the explicit guard-check error. +- Never claim “deployment started” or “website is ready” until the matching guard checklist has passed. +- Timeout fallback message must be: + `Your page has been deployed to the temporary domain {pagesDevLink}. If you cannot access this domain, you can retry deploy again.` + +## Rules + +- Never invent a job id, URL, or completion state. +- Never invent the delivery target. Persist the concrete `to` target when it is known. +- Never say the site is ready during submit. +- Always reject malformed server responses instead of guessing missing fields. +- Always use the local persisted job record for recovery and polling. +- If the server returns an explicit error message or hint, relay it instead of rewriting it. diff --git a/apps/desktop/static/bundled-skills/deploy-skill/scripts/deploy_skill.js b/apps/desktop/static/bundled-skills/deploy-skill/scripts/deploy_skill.js new file mode 100755 index 000000000..4f7ac3eda --- /dev/null +++ b/apps/desktop/static/bundled-skills/deploy-skill/scripts/deploy_skill.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +import os from "node:os"; +import path from "node:path"; +import { + loadPageDeployConfig, + queryPageDeployJob, + recoverPendingPageDeployJobs, + savePageDeployConfig, + submitPageDeployJob, + submitPageDeployTemplateJob, + waitForPageDeployJob, +} from "./deploy_skill_core.js"; + +function nexuHome() { + return process.env.NEXU_HOME?.trim() || path.join(os.homedir(), ".nexu"); +} + +function parseArgs(argv) { + const [command, ...rest] = argv; + const options = {}; + + for (let index = 0; index < rest.length; index += 1) { + const token = rest[index]; + if (!token?.startsWith("--")) { + continue; + } + const key = token.slice(2); + const value = rest[index + 1]; + if (!value || value.startsWith("--")) { + options[key] = true; + continue; + } + options[key] = value; + index += 1; + } + + return { command, options }; +} + +function printJson(payload, stream = process.stdout) { + stream.write(`${JSON.stringify(payload, null, 2)}\n`); +} + +function usage() { + printJson( + { + error: + "Usage: deploy_skill.js [options]", + }, + process.stderr, + ); + process.exit(1); +} + +async function run() { + const { command, options } = parseArgs(process.argv.slice(2)); + const home = nexuHome(); + + if (!command) { + usage(); + } + + if (command === "setup") { + if (typeof options["base-url"] !== "string") { + throw new Error("setup requires --base-url"); + } + const config = await savePageDeployConfig(home, { + baseUrl: options["base-url"], + }); + printJson({ status: "ok", config }); + return; + } + + if (command === "check") { + const config = await loadPageDeployConfig(home); + if (typeof config.baseUrl !== "string" || config.baseUrl.length === 0) { + throw new Error("deploy-skill baseUrl is not configured."); + } + printJson({ status: "ok", config }); + return; + } + + if (command === "submit") { + const commonInput = { + nexuHome: home, + botId: String(options["bot-id"] ?? ""), + chatId: String(options["chat-id"] ?? ""), + chatType: String(options["chat-type"] ?? ""), + channel: String(options.channel ?? ""), + to: typeof options.to === "string" ? options.to : undefined, + threadId: + typeof options["thread-id"] === "string" + ? options["thread-id"] + : undefined, + accountId: + typeof options["account-id"] === "string" + ? options["account-id"] + : undefined, + sessionKey: + typeof options["session-key"] === "string" + ? options["session-key"] + : undefined, + userId: + typeof options["user-id"] === "string" ? options["user-id"] : undefined, + }; + const result = + typeof options["template-id"] === "string" + ? await submitPageDeployTemplateJob({ + ...commonInput, + templateId: options["template-id"], + contentFile: String(options["content-file"] ?? ""), + }) + : await submitPageDeployJob({ + ...commonInput, + zipPath: String(options.zip ?? ""), + }); + + printJson(result.spawnPayload); + printJson( + { + jobId: result.job.jobId, + status: result.job.status, + message: "Deployment started. I will notify the user when it finishes.", + }, + process.stderr, + ); + return; + } + + if (command === "query") { + if (typeof options["job-id"] !== "string") { + throw new Error("query requires --job-id"); + } + const job = await queryPageDeployJob({ + nexuHome: home, + jobId: options["job-id"], + }); + printJson(job); + return; + } + + if (command === "recover") { + const pendingJobs = await recoverPendingPageDeployJobs({ nexuHome: home }); + printJson({ + status: "ok", + pendingCount: pendingJobs.length, + jobs: pendingJobs, + }); + return; + } + + if (command === "wait-and-deliver") { + if (typeof options["job-id"] !== "string") { + throw new Error("wait-and-deliver requires --job-id"); + } + const result = await waitForPageDeployJob({ + nexuHome: home, + jobId: options["job-id"], + pollIntervalMs: Number(options["poll-interval-ms"] ?? 10000), + maxPolls: Number(options["max-polls"] ?? 30), + }); + printJson({ + status: result.status, + message: result.message, + jobId: result.job.jobId, + url: result.job.resultUrl, + }); + return; + } + + usage(); +} + +run().catch((error) => { + printJson( + { + status: "error", + message: error instanceof Error ? error.message : String(error), + }, + process.stderr, + ); + process.exit(1); +}); diff --git a/apps/desktop/static/bundled-skills/deploy-skill/scripts/deploy_skill_core.js b/apps/desktop/static/bundled-skills/deploy-skill/scripts/deploy_skill_core.js new file mode 100644 index 000000000..88abd7daf --- /dev/null +++ b/apps/desktop/static/bundled-skills/deploy-skill/scripts/deploy_skill_core.js @@ -0,0 +1,1280 @@ +import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const CONFIG_FILENAME = "deploy-skill.json"; +const JOBS_FILENAME = "deploy-skill-jobs.json"; +const NEXU_CONFIG_FILENAME = "config.json"; +const GENERATED_DIRNAME = "deploy-skill-generated"; +const DISTILL_PORTRAIT_MAP = Object.freeze({ + "portrait-1": "05ece7aece7d5a8c3ad9aae3ecfbd20b_pixian_ai.png", + "portrait-2": "1d8f55fb0ef3d2a6149d2d999aa79c06_pixian_ai.png", + "portrait-3": "24a229ae040e9ccb578c01cc6821a2f2_pixian_ai.png", + "portrait-4": "4b7b55f162dafff58baf54d05463eb5e_pixian_ai.png", + "portrait-5": "b0ed8642ea2fdfbf2e6440772bc9d89b_pixian_ai.png", + "portrait-6": "bd74a1adfbec68bf008cba7ce62d22b6_pixian_ai.png", + "portrait-7": "f1763ea5ebb1d7b6cc1ddcf41b177f40_pixian_ai.png", +}); +const NEXU_REPO_URL = "https://github.com/nexu-io/nexu"; +const ROAST_SKILL_URL = "https://github.com/nexu-io/roast-skill"; +const NEXU_LOGO_FILE = "assets/logo.png"; +const NEXU_POSTER_FILE = "assets/poster.png"; +const NEXU_PORTRAIT_DIR = "assets/portraits"; +const ACTIVE_JOB_STATUSES = new Set(["queued", "running"]); +const TERMINAL_JOB_STATUSES = new Set(["succeeded", "failed", "cancelled"]); +const FINAL_HOST = "nexu.space"; +const FINAL_PATH_PREFIX = "/deploy/"; +const FALLBACK_HOST_SUFFIX = ".pages.dev"; +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const TEMPLATE_ROOT = path.resolve(SCRIPT_DIR, "../templates"); +const DISTILL_AVATAR_ROOT = path.join( + TEMPLATE_ROOT, + "distill-campaign", + "assets/portraits", +); +const require = createRequire(import.meta.url); + +let cachedJSZip; + +function loadJSZip() { + if (cachedJSZip) { + return cachedJSZip; + } + + try { + const loaded = require("jszip"); + cachedJSZip = loaded?.default ?? loaded; + return cachedJSZip; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `deploy-skill requires jszip to be bundled into this desktop runtime (${reason}).`, + ); + } +} + +function configPath(nexuHome) { + return path.join(nexuHome, CONFIG_FILENAME); +} + +function jobsPath(nexuHome) { + return path.join(nexuHome, JOBS_FILENAME); +} + +function nexuConfigPath(nexuHome) { + return path.join(nexuHome, NEXU_CONFIG_FILENAME); +} + +function nowIso(nowImpl = () => new Date()) { + return nowImpl().toISOString(); +} + +async function readJsonFile(filePath, fallbackValue) { + try { + const content = await readFile(filePath, "utf8"); + return JSON.parse(content); + } catch (error) { + if (error && typeof error === "object" && "code" in error) { + if (error.code === "ENOENT") { + return fallbackValue; + } + } + throw error; + } +} + +async function writeJsonFile(filePath, value) { + const tempPath = `${filePath}.tmp`; + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + await rename(tempPath, filePath); +} + +function validateBaseUrl(value) { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("invalid protocol"); + } + return url.toString().replace(/\/+$/u, ""); + } catch { + throw new Error("deploy-skill baseUrl must be a valid http(s) URL."); + } +} + +export async function loadPageDeployConfig(nexuHome) { + return readJsonFile(configPath(nexuHome), {}); +} + +function buildNexuCloudCandidatePaths(nexuHome) { + const home = os.homedir(); + const raw = [ + path.resolve(nexuConfigPath(nexuHome)), + path.resolve( + home, + "Library", + "Application Support", + "@nexu", + "desktop", + ".nexu", + NEXU_CONFIG_FILENAME, + ), + path.resolve(home, ".nexu", NEXU_CONFIG_FILENAME), + ]; + const seen = new Set(); + const unique = []; + for (const candidate of raw) { + if (seen.has(candidate)) continue; + seen.add(candidate); + unique.push(candidate); + } + return unique; +} + +async function resolveNexuCloudCredentials(nexuHome) { + const candidates = buildNexuCloudCandidatePaths(nexuHome); + const triedMissing = []; + for (const candidate of candidates) { + const cfg = await readJsonFile(candidate, null); + if (cfg === null) { + triedMissing.push(candidate); + continue; + } + const desktop = + cfg && + typeof cfg === "object" && + cfg.desktop && + typeof cfg.desktop === "object" + ? cfg.desktop + : null; + const cloud = + desktop && desktop.cloud && typeof desktop.cloud === "object" + ? desktop.cloud + : null; + if (!cloud) { + // This config file has no desktop.cloud section at all — it is not an + // authoritative source for Nexu cloud credentials, so keep searching. + triedMissing.push(candidate); + continue; + } + // This file declares a cloud config — it is authoritative. Either accept + // it or fail here without falling through to the next candidate. + if (cloud.connected !== true) { + return { + ok: false, + reason: "not-connected", + path: candidate, + }; + } + const apiKey = typeof cloud.apiKey === "string" ? cloud.apiKey.trim() : ""; + if (apiKey.length === 0) { + return { + ok: false, + reason: "no-api-key", + path: candidate, + }; + } + return { ok: true, apiKey, path: candidate }; + } + return { ok: false, reason: "no-config-found", tried: triedMissing }; +} + +function describeNexuCredentialsError(result) { + if (result.reason === "no-config-found") { + const lines = result.tried + .map((candidate) => ` - ${candidate}`) + .join("\n"); + return ( + "deploy-skill could not find a Nexu cloud configuration with a logged-in account. " + + "Please log in to the Nexu desktop app (or initialize your Nexu config), then retry.\n" + + "Paths checked:\n" + + lines + ); + } + if (result.reason === "not-connected") { + return ( + `deploy-skill found a Nexu config at ${result.path}, but desktop.cloud.connected is not true. ` + + "Please re-log in to your Nexu account via the Nexu desktop app, then retry." + ); + } + if (result.reason === "no-api-key") { + return ( + `deploy-skill found a Nexu config at ${result.path}, but desktop.cloud.apiKey is missing or empty. ` + + "Please re-log in to your Nexu account via the Nexu desktop app, then retry." + ); + } + return "deploy-skill could not resolve Nexu cloud credentials."; +} + +export async function savePageDeployConfig(nexuHome, config) { + const nextConfig = { + baseUrl: validateBaseUrl(config.baseUrl), + }; + await writeJsonFile(configPath(nexuHome), nextConfig); + return nextConfig; +} + +export async function loadPageDeployJobs(nexuHome) { + return readJsonFile(jobsPath(nexuHome), []); +} + +async function savePageDeployJobs(nexuHome, jobs) { + await writeJsonFile(jobsPath(nexuHome), jobs); +} + +function generatedDir(nexuHome) { + return path.join(nexuHome, GENERATED_DIRNAME); +} + +function assertValidZipPath(zipPath, stats) { + if (!stats.isFile()) { + throw new Error(`Zip path is not a file: ${zipPath}`); + } + if (path.extname(zipPath).toLowerCase() !== ".zip") { + throw new Error("Static deploy requires a .zip file."); + } +} + +function assertNonEmptyString(value, fieldName) { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`deploy-skill requires ${fieldName}.`); + } + return value.trim(); +} + +function stringLength(value) { + return Array.from(value).length; +} + +function assertStringLength(value, fieldName, min, max) { + if (typeof value !== "string") { + throw new Error( + `deploy-skill template field ${fieldName} must be a string.`, + ); + } + const length = stringLength(value.trim()); + if (length < min || length > max) { + throw new Error( + `deploy-skill template field ${fieldName} must be ${min}-${max} characters.`, + ); + } + return value.trim(); +} + +function assertPlainText(value, fieldName, min, max) { + const text = assertStringLength(value, fieldName, min, max); + if (/\r|\n/u.test(text)) { + throw new Error( + `deploy-skill template field ${fieldName} must not contain newlines.`, + ); + } + if (/<[^>]+>/u.test(text)) { + throw new Error( + `deploy-skill template field ${fieldName} must not contain HTML.`, + ); + } + if (/[`*_>#]/u.test(text)) { + throw new Error( + `deploy-skill template field ${fieldName} must not contain markdown.`, + ); + } + return text; +} + +function assertExactText(value, fieldName, expected) { + if (typeof value !== "string" || value !== expected) { + throw new Error( + `deploy-skill template field ${fieldName} must match the required fixed value.`, + ); + } + return value; +} + +function assertMetricLabel(value, fieldName, min, max) { + const label = assertStringLength(value, fieldName, min, max); + if (/\r|\n/u.test(label) || /<[^>]+>/u.test(label)) { + throw new Error( + `deploy-skill template field ${fieldName} must be plain text.`, + ); + } + return label; +} + +function assertMetricValue(value, fieldName, { allowPercent = false } = {}) { + if (typeof value !== "string") { + throw new Error( + `deploy-skill template field ${fieldName} must be a string.`, + ); + } + const text = value.trim(); + if (allowPercent) { + if (!/^\d+%$/u.test(text)) { + throw new Error( + `deploy-skill template field ${fieldName} must be a percentage string.`, + ); + } + return text; + } + if (!/^\d+$/u.test(text)) { + throw new Error( + `deploy-skill template field ${fieldName} must be a numeric string without percent notation.`, + ); + } + return text; +} + +function assertPortraitId(value) { + if (typeof value !== "string" || !(value in DISTILL_PORTRAIT_MAP)) { + throw new Error( + `deploy-skill template field portraitId must be one of: ${Object.keys(DISTILL_PORTRAIT_MAP).join(", ")}.`, + ); + } + return value; +} + +async function readRequiredFile(filePath) { + return readFile(filePath, "utf8"); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function percentageFromMetricValue(value) { + const match = String(value).match(/(\d+(?:\.\d+)?)/u); + if (!match) { + return 50; + } + const numeric = Number(match[1]); + if (!Number.isFinite(numeric)) { + return 50; + } + return Math.max(0, Math.min(100, Math.round(numeric))); +} + +function assertArrayLength(value, fieldName, min, max) { + if (!Array.isArray(value) || value.length < min || value.length > max) { + throw new Error( + `deploy-skill template field ${fieldName} must contain ${min}-${max} items.`, + ); + } + return value; +} + +function parseTemplateContent(templateId, payload) { + if (templateId !== "distill-campaign") { + throw new Error(`deploy-skill unknown template: ${templateId}`); + } + if (typeof payload !== "object" || payload === null) { + throw new Error("deploy-skill template content must be a JSON object."); + } + + const title = assertStringLength(payload.title, "title", 2, 10); + const subtitle = assertNonEmptyString(payload.subtitle, "subtitle"); + if ( + stringLength(subtitle) < 15 || + stringLength(subtitle) > 30 || + !subtitle.includes("牛马指数") || + !subtitle.includes("/100") || + !subtitle.includes("—") + ) { + throw new Error( + "deploy-skill template field subtitle must be 15-30 characters and include 牛马指数, /100, and —.", + ); + } + const description = assertPlainText( + payload.description, + "description", + 150, + 250, + ); + const ctaText = assertExactText( + payload.ctaText, + "ctaText", + "⭐ 生成我的牛马锐评", + ); + const installText = assertExactText( + payload.installText, + "installText", + "复制链接发给你的 nexu agent:https://github.com/nexu-io/roast-skill", + ); + const tags = assertArrayLength(payload.tags, "tags", 1, 8).map((tag, index) => + assertStringLength(tag, `tags[${index}]`, 2, 8), + ); + const metrics = assertArrayLength(payload.metrics, "metrics", 4, 6).map( + (metric, index) => { + if (typeof metric !== "object" || metric === null) { + throw new Error( + `deploy-skill template field metrics[${index}] is invalid.`, + ); + } + const labelMin = index === 0 ? 6 : 6; + const labelMax = index === 0 ? 10 : 12; + return { + label: assertMetricLabel( + metric.label, + `metrics[${index}].label`, + labelMin, + labelMax, + ), + value: + index === 0 + ? assertMetricValue(metric.value, `metrics[${index}].value`) + : assertMetricValue(metric.value, `metrics[${index}].value`, { + allowPercent: true, + }), + }; + }, + ); + const qaCards = assertArrayLength(payload.qaCards, "qaCards", 2, 3).map( + (card, index) => { + if (typeof card !== "object" || card === null) { + throw new Error( + `deploy-skill template field qaCards[${index}] is invalid.`, + ); + } + return { + question: assertPlainText( + card.question, + `qaCards[${index}].question`, + 3, + 8, + ), + answer: assertPlainText( + card.answer, + `qaCards[${index}].answer`, + 80, + 150, + ), + }; + }, + ); + const dialogs = assertArrayLength(payload.dialogs, "dialogs", 3, 6).map( + (dialog, index) => { + if (typeof dialog !== "object" || dialog === null) { + throw new Error( + `deploy-skill template field dialogs[${index}] is invalid.`, + ); + } + const speaker = assertNonEmptyString( + dialog.speaker, + `dialogs[${index}].speaker`, + ).toLowerCase(); + if (speaker !== "bot" && speaker !== "user") { + throw new Error( + `deploy-skill template field dialogs[${index}].speaker must be bot or user.`, + ); + } + return { + speaker, + text: assertPlainText(dialog.text, `dialogs[${index}].text`, 15, 80), + }; + }, + ); + + return { + title, + subtitle, + portraitId: assertPortraitId(payload.portraitId), + tags, + metrics, + description, + qaCards, + dialogs, + ctaText, + installText, + posterSpeciesEmoji: assertStringLength( + payload.posterSpeciesEmoji, + "posterSpeciesEmoji", + 1, + 4, + ), + posterSpeciesName: assertStringLength( + payload.posterSpeciesName, + "posterSpeciesName", + 3, + 8, + ), + posterSpeciesSub: assertStringLength( + payload.posterSpeciesSub, + "posterSpeciesSub", + 5, + 8, + ), + }; +} + +function renderDistillCampaignHtml(content, selectedAvatar) { + const socialTags = content.tags + .slice(0, 8) + .map( + (tag, index) => + `${escapeHtml(tag)}`, + ) + .join(""); + const posterMetric = content.metrics[0]; + const secondaryMetrics = content.metrics.slice(1); + const BAR_ICONS = [ + '', + '', + '', + '', + '', + ]; + const BAR_FILL_CLASSES = ["", "red", "orange", "mint", ""]; + const barsMarkup = secondaryMetrics + .map((metric, index) => { + const icon = BAR_ICONS[index % BAR_ICONS.length]; + const fill = BAR_FILL_CLASSES[index % BAR_FILL_CLASSES.length]; + const width = percentageFromMetricValue(metric.value); + return ` +
+ +
+
`; + }) + .join(""); + const DIM_ICONS = ["🔥", "💪", "💀", "💰", "🐾", "👥", "⭐", "💊"]; + const analysisMarkup = content.qaCards + .map( + (card, index) => ` +
+
+
${DIM_ICONS[index % DIM_ICONS.length]}
+
${escapeHtml(card.question)}
+
+
${escapeHtml(card.answer)}
+
`, + ) + .join(""); + const dialogMarkup = content.dialogs + .map( + (dialog) => ` +
+
${dialog.speaker === "bot" ? `${escapeHtml(content.title)}` : "👤"}
+
${escapeHtml(dialog.text)}
+
`, + ) + .join(""); + const promptMarkup = content.tags + .slice(0, 4) + .map( + (tag) => + ``, + ) + .join(""); + return ` + + + + + ${escapeHtml(content.title)} + + + + + + + + +
+
+
+
+
+
+
+ ${escapeHtml(content.title)} +
+
+
+
点击切换 · 1/1
+
+
+
${escapeHtml(content.title)}
+
${escapeHtml(content.subtitle)}
+ +
+
+ ⭐ 生成我的赛博分身 + + +
+ +
+
+ +
+ + + + +
+ +
+
核心指标
+
+
+
${escapeHtml(posterMetric.value)}
+
${escapeHtml(posterMetric.label)}
+
+
+
${escapeHtml(content.posterSpeciesEmoji)}
+
+
${escapeHtml(content.posterSpeciesName)}
+
${escapeHtml(content.posterSpeciesSub)}
+
+
+
+
${escapeHtml(content.description)}
+
${barsMarkup}
+
+ +
+
深度扒皮
+
${analysisMarkup}
+
+ +
+
和我对话
+
${dialogMarkup}
+
${promptMarkup}
+ ⚡ 安装 Skill 解锁对话 +
+ +
+
技能文件
+
+ ${escapeHtml(content.installText)} + +
+
+
${escapeHtml(content.ctaText)}${escapeHtml(posterMetric.value)}
+
+
+ ⬇ 下载 nexu +
+ +
+ Made with nexu · 🔥 安装 roast-skill 生成你的赛博分身 +
+
+
+
+ +
+
+

分享海报

+

长按保存图片,分享给好友

+
+ 分享海报 +
+
${escapeHtml(content.title)}
+
${escapeHtml(content.subtitle)}
+
${escapeHtml(posterMetric.value)}
+
${escapeHtml(posterMetric.label)}
+
${escapeHtml(content.posterSpeciesEmoji)} ${escapeHtml(content.posterSpeciesName)}
+
+
+
+ 保存 + +
+
+
+ + + +`; +} + +async function selectPortraitAvatar(portraitId) { + const fileName = DISTILL_PORTRAIT_MAP[portraitId]; + if (!fileName) { + throw new Error("deploy-skill template field portraitId is invalid."); + } + await stat(DISTILL_AVATAR_ROOT); + const filePath = path.join(DISTILL_AVATAR_ROOT, fileName); + const fileStats = await stat(filePath).catch(() => null); + if (!fileStats?.isFile()) { + throw new Error("deploy-skill template is missing avatar images."); + } + return { + fileName, + bytes: await readFile(filePath), + }; +} + +async function renderTemplateFiles(templateId, content, deps = {}) { + if (templateId !== "distill-campaign") { + throw new Error(`deploy-skill unknown template: ${templateId}`); + } + const selectedAvatar = await selectPortraitAvatar(content.portraitId); + return { + "index.html": renderDistillCampaignHtml(content, selectedAvatar), + "styles.css": await readRequiredFile( + path.join(TEMPLATE_ROOT, "distill-campaign", "styles.css"), + ), + [NEXU_LOGO_FILE]: await readFile( + path.join(TEMPLATE_ROOT, "distill-campaign", NEXU_LOGO_FILE), + ), + [NEXU_POSTER_FILE]: await readFile( + path.join(TEMPLATE_ROOT, "distill-campaign", NEXU_POSTER_FILE), + ), + [`${NEXU_PORTRAIT_DIR}/${selectedAvatar.fileName}`]: selectedAvatar.bytes, + }; +} + +async function createTemplateZip(nexuHome, templateId, files) { + const JSZip = loadJSZip(); + const zip = new JSZip(); + for (const [fileName, content] of Object.entries(files)) { + zip.file(fileName, content); + } + + const outputDir = generatedDir(nexuHome); + await mkdir(outputDir, { recursive: true }); + const outputPath = path.join(outputDir, `rendered-${templateId}.zip`); + const bytes = await zip.generateAsync({ type: "uint8array" }); + await writeFile(outputPath, bytes); + return outputPath; +} + +function parseAcceptedResponse(payload) { + if ( + typeof payload !== "object" || + payload === null || + typeof payload.jobId !== "string" || + payload.jobId.length === 0 + ) { + throw new Error("Remote deploy-skill response is missing jobId."); + } + if (payload.taskType !== "static-deploy") { + throw new Error( + "Remote deploy-skill response returned unexpected taskType.", + ); + } + if (payload.status !== "queued" && payload.status !== "running") { + throw new Error("Remote deploy-skill response returned unexpected status."); + } + if (typeof payload.createdAt !== "string" || payload.createdAt.length === 0) { + throw new Error("Remote deploy-skill response is missing createdAt."); + } + return payload; +} + +function parseStatusResponse(payload) { + if ( + typeof payload !== "object" || + payload === null || + typeof payload.jobId !== "string" || + typeof payload.status !== "string" + ) { + throw new Error("Remote deploy-skill status response is malformed."); + } + return payload; +} + +function assertPersistedSubmitState(job, accepted) { + if (job.jobId !== accepted.jobId) { + throw new Error( + "deploy-skill guard check failed: persisted jobId mismatch.", + ); + } + if (job.status !== accepted.status) { + throw new Error( + "deploy-skill guard check failed: persisted status mismatch.", + ); + } + if (!ACTIVE_JOB_STATUSES.has(job.status)) { + throw new Error( + `deploy-skill guard check failed: unexpected persisted submit status ${job.status}.`, + ); + } +} + +function assertTerminalSuccessState(job) { + if (job.status !== "succeeded") { + throw new Error( + `deploy-skill guard check failed: expected succeeded status, got ${job.status}.`, + ); + } + if (typeof job.resultUrl !== "string" || job.resultUrl.length === 0) { + throw new Error( + "deploy-skill guard check failed: remote success is missing final link.", + ); + } + let parsed; + try { + parsed = new URL(job.resultUrl); + } catch { + throw new Error( + "deploy-skill guard check failed: final success link must be on nexu.space/deploy/.", + ); + } + if ( + parsed.host !== FINAL_HOST || + !parsed.pathname.startsWith(FINAL_PATH_PREFIX) + ) { + throw new Error( + "deploy-skill guard check failed: final success link must be on nexu.space/deploy/.", + ); + } +} + +function fallbackMessage(job) { + if ( + typeof job.resultUrl === "string" && + job.resultUrl.endsWith(FALLBACK_HOST_SUFFIX) + ) { + return { + status: "timeout-fallback", + message: `Your page has been deployed to the temporary domain ${job.resultUrl}. If you cannot access this domain, you can retry deploy again.`, + job, + }; + } + + throw new Error( + `Deploy-skill job ${job.jobId} is still in progress. Check again later.`, + ); +} + +function buildJobRecord(input, accepted, nowImpl) { + const timestamp = nowIso(nowImpl); + return { + jobId: accepted.jobId, + zipPath: input.zipPath, + botId: input.botId, + chatId: input.chatId, + chatType: input.chatType, + channel: input.channel, + to: input.to ?? null, + threadId: input.threadId ?? null, + accountId: input.accountId ?? null, + sessionKey: input.sessionKey ?? null, + userId: input.userId ?? null, + templateId: input.templateId ?? null, + contentFile: input.contentFile ?? null, + generatedZipPath: input.generatedZipPath ?? null, + status: accepted.status, + createdAt: accepted.createdAt, + updatedAt: timestamp, + resultUrl: null, + deploymentUrl: null, + error: null, + }; +} + +function buildFollowUpInstruction(jobId) { + return { + sessions_spawn: { + instruction: `Wait for deploy-skill job ${jobId} to complete, then tell the user exactly: Your website is ready, the link is {link}. Use command: node scripts/deploy_skill.js wait-and-deliver --job-id ${jobId}`, + runTimeoutSeconds: 900, + }, + }; +} + +function upsertJob(jobs, nextJob) { + const withoutCurrent = jobs.filter((job) => job.jobId !== nextJob.jobId); + return [...withoutCurrent, nextJob]; +} + +async function resolveConfig(nexuHome) { + const config = await loadPageDeployConfig(nexuHome); + if (typeof config.baseUrl !== "string" || config.baseUrl.length === 0) { + throw new Error("deploy-skill baseUrl is not configured. Run setup first."); + } + const credentials = await resolveNexuCloudCredentials(nexuHome); + if (!credentials.ok) { + throw new Error(describeNexuCredentialsError(credentials)); + } + return { + baseUrl: validateBaseUrl(config.baseUrl), + apiKey: credentials.apiKey, + }; +} + +export async function submitPageDeployJob(input, deps = {}) { + const config = await resolveConfig(input.nexuHome); + const botId = assertNonEmptyString(input.botId, "botId"); + const chatId = assertNonEmptyString(input.chatId, "chatId"); + const chatType = assertNonEmptyString(input.chatType, "chatType"); + const channel = assertNonEmptyString(input.channel, "channel"); + const to = + typeof input.to === "string" && input.to.trim().length > 0 + ? input.to.trim() + : null; + const zipStats = await stat(input.zipPath); + assertValidZipPath(input.zipPath, zipStats); + + const fileBytes = await readFile(input.zipPath); + const form = new FormData(); + form.set( + "file", + new File([fileBytes], path.basename(input.zipPath), { + type: "application/zip", + }), + ); + form.set("taskType", "static-deploy"); + form.set("botId", botId); + form.set("sessionId", input.sessionKey ?? chatId); + if (input.userId) { + form.set("userId", input.userId); + } + + const fetchImpl = deps.fetchImpl ?? fetch; + const response = await fetchImpl(`${config.baseUrl}/v1/remote-executions`, { + method: "POST", + headers: { + Authorization: `Bearer ${config.apiKey}`, + }, + body: form, + }); + const payload = await response.json(); + if (!response.ok) { + const message = + typeof payload?.error?.message === "string" + ? payload.error.message + : `Remote deploy-skill request failed with HTTP ${response.status}.`; + throw new Error(message); + } + + const accepted = parseAcceptedResponse(payload); + const jobs = await loadPageDeployJobs(input.nexuHome); + const nextJob = buildJobRecord( + { + ...input, + botId, + chatId, + chatType, + channel, + to, + }, + accepted, + deps.nowImpl, + ); + await savePageDeployJobs(input.nexuHome, upsertJob(jobs, nextJob)); + assertPersistedSubmitState(nextJob, accepted); + + return { + job: nextJob, + spawnPayload: buildFollowUpInstruction(nextJob.jobId), + accepted, + }; +} + +export async function submitPageDeployTemplateJob(input, deps = {}) { + const templateId = assertNonEmptyString(input.templateId, "templateId"); + const contentFile = assertNonEmptyString(input.contentFile, "contentFile"); + const contentPayload = JSON.parse(await readRequiredFile(contentFile)); + const templateContent = parseTemplateContent(templateId, contentPayload); + const renderedFiles = await renderTemplateFiles( + templateId, + templateContent, + deps, + ); + const zipPath = await createTemplateZip( + input.nexuHome, + templateId, + renderedFiles, + ); + + return submitPageDeployJob( + { + ...input, + zipPath, + templateId, + contentFile, + generatedZipPath: zipPath, + }, + deps, + ); +} + +function mergeJobWithStatus(job, payload, nowImpl) { + const nextStatus = + typeof payload.status === "string" + ? payload.status + : String(payload.status); + if ( + !ACTIVE_JOB_STATUSES.has(nextStatus) && + !TERMINAL_JOB_STATUSES.has(nextStatus) + ) { + throw new Error( + `Remote deploy-skill status response returned unexpected status ${nextStatus}.`, + ); + } + + const nextError = + payload.error && typeof payload.error === "object" + ? { + code: + typeof payload.error.code === "string" ? payload.error.code : null, + message: + typeof payload.error.message === "string" + ? payload.error.message + : null, + hint: + typeof payload.error.hint === "string" ? payload.error.hint : null, + retryable: payload.error.retryable === true, + } + : null; + + return { + ...job, + status: nextStatus, + updatedAt: nowIso(nowImpl), + resultUrl: + typeof payload.result?.url === "string" ? payload.result.url : null, + deploymentUrl: + typeof payload.result?.deploymentUrl === "string" + ? payload.result.deploymentUrl + : null, + error: nextError, + }; +} + +export async function queryPageDeployJob(input, deps = {}) { + const config = await resolveConfig(input.nexuHome); + const jobs = await loadPageDeployJobs(input.nexuHome); + const currentJob = jobs.find((job) => job.jobId === input.jobId); + if (!currentJob) { + throw new Error(`Unknown deploy-skill job: ${input.jobId}`); + } + + const fetchImpl = deps.fetchImpl ?? fetch; + const response = await fetchImpl( + `${config.baseUrl}/v1/remote-executions/${input.jobId}`, + { + headers: { + Authorization: `Bearer ${config.apiKey}`, + }, + }, + ); + const payload = await response.json(); + if (!response.ok) { + const message = + typeof payload?.error?.message === "string" + ? payload.error.message + : `Remote deploy-skill query failed with HTTP ${response.status}.`; + throw new Error(message); + } + + const statusPayload = parseStatusResponse(payload); + const nextJob = mergeJobWithStatus(currentJob, statusPayload, deps.nowImpl); + await savePageDeployJobs(input.nexuHome, upsertJob(jobs, nextJob)); + return nextJob; +} + +export async function waitForPageDeployJob(input, deps = {}) { + const sleepImpl = + deps.sleepImpl ?? + ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))); + + for (let index = 0; index < input.maxPolls; index += 1) { + const job = await queryPageDeployJob( + { nexuHome: input.nexuHome, jobId: input.jobId }, + deps, + ); + + if (job.status === "succeeded" && job.resultUrl) { + assertTerminalSuccessState(job); + return { + status: "succeeded", + message: `Your website is ready, the link is ${job.resultUrl}`, + job, + }; + } + + if (job.status === "succeeded") { + assertTerminalSuccessState(job); + } + + if (job.status === "failed" || job.status === "cancelled") { + const failureMessage = + job.error?.message ?? + "Deploy-skill execution failed for an unknown reason."; + const failureHint = job.error?.hint ? ` ${job.error.hint}` : ""; + return { + status: job.status, + message: `${failureMessage}${failureHint}`, + job, + }; + } + + if (index < input.maxPolls - 1) { + await sleepImpl(input.pollIntervalMs); + } + } + + const latestJobs = await loadPageDeployJobs(input.nexuHome); + const latestJob = latestJobs.find((job) => job.jobId === input.jobId); + if (!latestJob) { + throw new Error(`Unknown deploy-skill job: ${input.jobId}`); + } + + return fallbackMessage(latestJob); +} + +export async function recoverPendingPageDeployJobs(input) { + const jobs = await loadPageDeployJobs(input.nexuHome); + return jobs + .filter((job) => ACTIVE_JOB_STATUSES.has(job.status)) + .map((job) => ({ + jobId: job.jobId, + spawnPayload: buildFollowUpInstruction(job.jobId), + })); +} diff --git a/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/logo.png b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/logo.png new file mode 100644 index 000000000..8384ee6ab Binary files /dev/null and b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/logo.png differ diff --git a/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/05ece7aece7d5a8c3ad9aae3ecfbd20b_pixian_ai.png b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/05ece7aece7d5a8c3ad9aae3ecfbd20b_pixian_ai.png new file mode 100644 index 000000000..74c0f6c8e Binary files /dev/null and b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/05ece7aece7d5a8c3ad9aae3ecfbd20b_pixian_ai.png differ diff --git a/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/1d8f55fb0ef3d2a6149d2d999aa79c06_pixian_ai.png b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/1d8f55fb0ef3d2a6149d2d999aa79c06_pixian_ai.png new file mode 100644 index 000000000..16a49d954 Binary files /dev/null and b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/1d8f55fb0ef3d2a6149d2d999aa79c06_pixian_ai.png differ diff --git a/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/24a229ae040e9ccb578c01cc6821a2f2_pixian_ai.png b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/24a229ae040e9ccb578c01cc6821a2f2_pixian_ai.png new file mode 100644 index 000000000..02d375edf Binary files /dev/null and b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/24a229ae040e9ccb578c01cc6821a2f2_pixian_ai.png differ diff --git a/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/4b7b55f162dafff58baf54d05463eb5e_pixian_ai.png b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/4b7b55f162dafff58baf54d05463eb5e_pixian_ai.png new file mode 100644 index 000000000..d57de40fe Binary files /dev/null and b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/4b7b55f162dafff58baf54d05463eb5e_pixian_ai.png differ diff --git a/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/b0ed8642ea2fdfbf2e6440772bc9d89b_pixian_ai.png b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/b0ed8642ea2fdfbf2e6440772bc9d89b_pixian_ai.png new file mode 100644 index 000000000..356ff3ef1 Binary files /dev/null and b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/b0ed8642ea2fdfbf2e6440772bc9d89b_pixian_ai.png differ diff --git a/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/bd74a1adfbec68bf008cba7ce62d22b6_pixian_ai.png b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/bd74a1adfbec68bf008cba7ce62d22b6_pixian_ai.png new file mode 100644 index 000000000..029e49f17 Binary files /dev/null and b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/bd74a1adfbec68bf008cba7ce62d22b6_pixian_ai.png differ diff --git a/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/f1763ea5ebb1d7b6cc1ddcf41b177f40_pixian_ai.png b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/f1763ea5ebb1d7b6cc1ddcf41b177f40_pixian_ai.png new file mode 100644 index 000000000..eeebfa8d6 Binary files /dev/null and b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits/f1763ea5ebb1d7b6cc1ddcf41b177f40_pixian_ai.png differ diff --git a/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/poster.png b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/poster.png new file mode 100644 index 000000000..c34abca10 Binary files /dev/null and b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/poster.png differ diff --git a/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/styles.css b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/styles.css new file mode 100644 index 000000000..65d7600d4 --- /dev/null +++ b/apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/styles.css @@ -0,0 +1,1508 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #000000; + --glass: rgba(255, 255, 255, 0.06); + --glass2: rgba(255, 255, 255, 0.09); + --border: rgba(255, 255, 255, 0.1); + --border2: rgba(255, 255, 255, 0.18); + --text: #f5f5fa; + --text2: rgba(255, 255, 255, 0.72); + --text3: rgba(255, 255, 255, 0.5); + --p1: #ffffff; + --p2: #999999; + --p3: #666666; + --p4: #dddddd; + --p5: #bbbbbb; + --glow1: rgba(255, 255, 255, 0.18); + --glow2: rgba(255, 255, 255, 0.08); + --sans: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; + --mono: "JetBrains Mono", monospace; + --clr-bg: #ffffff; +} + +/* ── LIGHT THEME ── */ +html[data-theme="light"] { + --bg: #ffffff; + --glass: rgba(0, 0, 0, 0.04); + --glass2: rgba(0, 0, 0, 0.06); + --border: rgba(0, 0, 0, 0.1); + --border2: rgba(0, 0, 0, 0.22); + --text: #0a0a0a; + --text2: rgba(0, 0, 0, 0.72); + --text3: rgba(0, 0, 0, 0.45); + --p1: #0a0a0a; + --p2: #444444; + --p3: #666666; + --p4: #222222; + --p5: #555555; + --glow1: rgba(0, 0, 0, 0.08); + --glow2: rgba(0, 0, 0, 0.04); + --clr-bg: #000000; +} +html[data-theme="light"] body::before { + background: radial-gradient(circle, rgba(0, 0, 0, 0.04) 0%, transparent 70%); +} +html[data-theme="light"] body::after { + background: radial-gradient(circle, rgba(0, 0, 0, 0.03) 0%, transparent 70%); +} +html[data-theme="light"] .site-header { + background: rgba(255, 255, 255, 0.92); + border-bottom-color: rgba(0, 0, 0, 0.08); +} +html[data-theme="light"] .site-logo { + filter: invert(1); +} +html[data-theme="light"] .bar-track { + background: rgba(0, 0, 0, 0.08); +} +html[data-theme="light"] .progress-track { + background: rgba(0, 0, 0, 0.08); +} +html[data-theme="light"] .progress-fill { + box-shadow: none; +} +html[data-theme="light"] .chat-msg.user .chat-bubble { + border-color: rgba(0, 0, 0, 0.18); +} +html[data-theme="light"] .chat-msg.bot .chat-bubble { + background: rgba(0, 0, 0, 0.05); + border-color: rgba(0, 0, 0, 0.1); +} +html[data-theme="light"] .poster-modal { + background: #ffffff; + border-color: rgba(0, 0, 0, 0.15); +} +html[data-theme="light"] .poster-placeholder { + background: rgba(0, 0, 0, 0.04); + border-color: rgba(0, 0, 0, 0.15); +} +html[data-theme="light"] .poster-close { + border-color: rgba(0, 0, 0, 0.15); +} +html[data-theme="light"] .optical::after { + background-image: linear-gradient(90deg, #ffffff 0 80%, transparent 0 100%); +} +html[data-theme="light"] .optical-figure { + filter: none; + mix-blend-mode: multiply; +} +html[data-theme="light"] .generate-btn { + background: #dc6b10; + color: #ffffff; +} +html[data-theme="light"] .download-btn { + background: rgba(0, 0, 0, 0.06); + border-color: rgba(0, 0, 0, 0.18); + color: var(--p1); +} +html[data-theme="light"] .download-btn:hover { + background: rgba(0, 0, 0, 0.1); +} +html[data-theme="light"] .share-btn { + background: rgba(0, 0, 0, 0.04); + border-color: rgba(0, 0, 0, 0.12); +} +html[data-theme="light"] .share-btn:hover { + background: rgba(0, 0, 0, 0.08); +} +html[data-theme="light"] .skill-unlock-btn { + background: rgba(0, 0, 0, 0.04); + border-color: rgba(0, 0, 0, 0.18); +} +html[data-theme="light"] .avatar-ring { + background: linear-gradient(135deg, #000, #999); +} +html[data-theme="light"] .loc-checkin-btn { + background: rgba(0, 0, 0, 0.06); + border-color: rgba(0, 0, 0, 0.2); + color: var(--p1); +} +html[data-theme="light"] .loc-checkin-btn.checked { + background: #000; + color: #fff; +} +html[data-theme="light"] .code-block { + background: rgba(0, 0, 0, 0.04); +} +html[data-theme="light"] .code-copy { + background: rgba(0, 0, 0, 0.06); + border-color: rgba(0, 0, 0, 0.15); +} +html[data-theme="light"] .location-map { + background: radial-gradient( + circle at 52% 48%, + rgba(0, 0, 0, 0.06) 0%, + transparent 55% + ), + radial-gradient(circle at 20% 80%, rgba(0, 0, 0, 0.03) 0%, transparent 50%), + repeating-linear-gradient( + 0deg, + rgba(0, 0, 0, 0.03) 0px, + rgba(0, 0, 0, 0.03) 1px, + transparent 1px, + transparent 40px + ), + repeating-linear-gradient( + 90deg, + rgba(0, 0, 0, 0.03) 0px, + rgba(0, 0, 0, 0.03) 1px, + transparent 1px, + transparent 40px + ), #f0f0f0; +} +html[data-theme="light"] .location-map::before { + border-color: rgba(0, 0, 0, 0.15); +} +html[data-theme="light"] .location-map::after { + border-color: rgba(0, 0, 0, 0.07); +} +html[data-theme="light"] .loc-pin-dot { + box-shadow: 0 0 14px rgba(0, 0, 0, 0.15), 0 0 4px rgba(0, 0, 0, 0.3); +} +html[data-theme="light"] .ptag[data-type="company"] { + border-color: rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, 0.04); +} +html[data-theme="light"] .ptag[data-type="role"] { + border-color: rgba(0, 0, 0, 0.14); + background: rgba(0, 0, 0, 0.03); +} +html[data-theme="light"] .ptag[data-type="level"] { + border-color: rgba(0, 0, 0, 0.12); + background: rgba(0, 0, 0, 0.03); +} +html[data-theme="light"] .ptag[data-type="mbti"] { + border-color: rgba(0, 0, 0, 0.14); + background: rgba(0, 0, 0, 0.03); +} +html[data-theme="light"] .ptag[data-type="vibe"] { + border-color: rgba(0, 0, 0, 0.12); + background: rgba(0, 0, 0, 0.03); +} +html[data-theme="light"] .stag-purple, +html[data-theme="light"] .stag-cyan, +html[data-theme="light"] .stag-orange, +html[data-theme="light"] .stag-teal, +html[data-theme="light"] .stag-mint { + border-color: rgba(0, 0, 0, 0.15); + background: rgba(0, 0, 0, 0.04); +} +html[data-theme="light"] .chat-prompt-btn { + background: rgba(0, 0, 0, 0.04); + border-color: rgba(0, 0, 0, 0.15); +} +html[data-theme="light"] .chat-prompt-btn:hover { + border-color: var(--p1); + background: rgba(0, 0, 0, 0.08); +} +html[data-theme="light"] .nav-cta { + background: #000; + color: #fff; + box-shadow: none; +} + +/* ── DARK THEME fixes (was previously showing white header) ── */ +html[data-theme="dark"] .site-header { + background: rgba(0, 0, 0, 0.88); +} + +/* ── THEME TOGGLE BUTTON ── */ +.theme-toggle { + position: absolute; + right: 20px; + background: none; + border: 1px solid var(--border); + color: var(--text); + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.2s, background 0.2s, color 0.2s; + flex-shrink: 0; +} +.theme-toggle:hover { + background: var(--glass2); + border-color: var(--border2); +} + +html { + scroll-behavior: smooth; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--sans); + line-height: 1.6; + overflow-x: hidden; +} + +/* Background blobs */ +body::before { + content: ""; + position: fixed; + top: -120px; + left: -120px; + width: 480px; + height: 480px; + background: radial-gradient( + circle, + rgba(255, 255, 255, 0.06) 0%, + transparent 70% + ); + pointer-events: none; + z-index: 0; +} +body::after { + content: ""; + position: fixed; + bottom: -100px; + right: -100px; + width: 400px; + height: 400px; + background: radial-gradient( + circle, + rgba(255, 255, 255, 0.04) 0%, + transparent 70% + ); + pointer-events: none; + z-index: 0; +} + +/* ── LAYOUT ── */ +.layout { + display: flex; + flex-direction: row; + align-items: flex-start; + position: relative; + z-index: 1; + max-width: 1020px; + margin: 0 auto; + width: 100%; + gap: 0; +} + +/* ── LEFT: identity ── */ +.left-side { + position: sticky; + top: 65px; + width: 300px; + flex-shrink: 0; + height: auto; + display: flex; + flex-direction: column; + align-items: stretch; + padding: 32px 24px 24px; + gap: 20px; +} +.profile-main { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} +.profile-left { + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + gap: 6px; +} +.profile-info { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} +.profile-info .profile-name { + text-align: center; + -webkit-text-fill-color: var(--text); + background: none; +} +.profile-info .profile-sub { + text-align: center; +} +.profile-info .profile-tags { + justify-content: center; +} + +/* ── ANIMATION WRAPPER ── */ +.optical-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + width: 100%; +} +.optical-label { + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.14em; + color: rgba(255, 255, 255, 0.18); + text-transform: uppercase; +} + +/* ── OPTICAL ILLUSION ANIMATION ── */ +@property --x { + syntax: ""; + inherits: true; + initial-value: 0%; +} + +.optical { + position: relative; + width: min(280px, 90%); + aspect-ratio: 1; + --stripe-px: 7px; + aspect-ratio: 6 / 4; + animation-name: --body-scroll; + animation-duration: 1ms; + animation-timing-function: linear; + animation-timeline: scroll(root); +} + +/* 图片层:反转后 screen 混合 —— 白线可见,深色背景透明 */ +.optical-figure { + position: absolute; + inset: 0; + background-image: url("https://raw.githubusercontent.com/cbolson/assets/refs/heads/main/codepen/optical/bmx.png"); + background-size: cover; + background-repeat: no-repeat; + filter: invert(1); + mix-blend-mode: screen; +} + +/* 条纹遮罩:纯黑条纹在黑色页面上不可见,透明缝隙漏出白线 */ +.optical::after { + content: ""; + position: absolute; + inset: 0; + background-image: linear-gradient(90deg, #000 0 80%, transparent 0 100%); + background-size: var(--stripe-px) 100%; + background-repeat: repeat; + background-position-x: var(--x); + z-index: 1; +} + +@keyframes --body-scroll { + from { + --x: 0%; + } + to { + --x: 100%; + } +} + +/* ── RIGHT: content ── */ +.right-side { + flex: 1; + min-width: 0; +} + +.page-wrap { + max-width: 100%; + margin: 0; + padding: 32px 24px 48px 0; +} + +/* ── NAV ── */ +nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 0 14px; + border-bottom: 1px solid var(--border); + margin-bottom: 28px; +} +.nav-left { + display: flex; + align-items: center; + gap: 10px; +} +.nav-logo { + width: 32px; + height: 32px; + border-radius: 8px; + background: linear-gradient(135deg, var(--p1), var(--p2)); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 700; + color: #fff; + box-shadow: 0 0 12px var(--glow1); +} +.nav-title { + font-size: 15px; + font-weight: 600; + color: var(--text); +} +.nav-cta { + background: linear-gradient(90deg, var(--p1), var(--p2)); + border: none; + outline: none; + cursor: pointer; + color: #fff; + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + padding: 7px 16px; + border-radius: 20px; + transition: opacity 0.2s, box-shadow 0.2s; + text-decoration: none; + box-shadow: 0 0 14px var(--glow1); +} +.nav-cta:hover { + opacity: 0.85; + box-shadow: 0 0 22px var(--glow1); +} + +/* ── CARD ── */ +.card { + background: transparent; + border: none; + border-radius: 16px; + padding: 22px 20px; + margin-bottom: 16px; +} +.card:hover { +} + +/* ── PROFILE HEADER ── */ +.profile-header { + display: flex; + flex-direction: row; + align-items: center; + text-align: left; + gap: 20px; + padding: 0; + margin-bottom: 0; +} +.profile-left { + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; +} +.avatar-wrap { + position: relative; + width: 90px; + height: 90px; + margin-bottom: 6px; + cursor: pointer; +} +.avatar-ring { + display: block; + position: absolute; + inset: -3px; + border-radius: 50%; + background: linear-gradient(135deg, #fff, #555); + padding: 3px; +} +.avatar-inner { + width: 100%; + height: 100%; + border-radius: 50%; + overflow: hidden; +} +.avatar-inner img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center top; + display: block; + transition: opacity 0.35s ease; +} +.avatar-hint { + font-size: 10px; + color: var(--text3); + font-family: var(--mono); + letter-spacing: 0.06em; + white-space: nowrap; +} +.profile-right { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} +.profile-name { + font-size: 20px; + font-weight: 700; + background: linear-gradient(90deg, var(--p1), var(--p2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 5px; +} +.profile-sub { + font-size: 12px; + color: var(--text3); + margin-bottom: 12px; +} +.profile-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.ptag { + font-size: 12px; + font-weight: 500; + padding: 4px 10px; + border-radius: 20px; + border: 1px solid; + backdrop-filter: blur(6px); +} +.ptag[data-type="company"] { + color: var(--p1); + border-color: rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.06); +} +.ptag[data-type="role"] { + color: var(--p4); + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.04); +} +.ptag[data-type="level"] { + color: var(--p2); + border-color: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.04); +} +.ptag[data-type="mbti"] { + color: var(--p4); + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.04); +} +.ptag[data-type="vibe"] { + color: var(--p5); + border-color: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.04); +} + +/* ── SOCIAL TAGS ── */ +.social-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} +.social-tags--vertical { + flex-direction: row; + flex-wrap: wrap; + justify-content: center; +} +.stag { + font-size: 12px; + font-weight: 600; + padding: 7px 20px; + border-radius: 20px; + border: 1px solid; +} +.stag-purple { + color: var(--p1); + border-color: rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.06); +} +.stag-cyan { + color: var(--p4); + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.04); +} +.stag-orange { + color: var(--p2); + border-color: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.04); +} +.stag-teal { + color: var(--p4); + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.04); +} +.stag-mint { + color: var(--p5); + border-color: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.04); +} + +/* ── SECTION TITLE ── */ +.sec-title { + font-size: 13px; + font-weight: 600; + color: var(--text3); + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 14px; + display: flex; + align-items: center; + gap: 6px; +} +.sec-title::before { + content: ""; + display: inline-block; + width: 3px; + height: 13px; + border-radius: 2px; + background: linear-gradient(180deg, var(--p1), var(--p2)); +} + +/* ── STATS ── */ +.stats-row { + display: flex; + gap: 10px; + margin-bottom: 16px; + flex-wrap: wrap; +} +.stat-box { + flex: 1; + min-width: calc(50% - 5px); + background: var(--glass2); + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 14px; + text-align: center; +} +.stat-val { + font-family: var(--mono); + font-size: 26px; + font-weight: 700; + background: linear-gradient(90deg, var(--p1), var(--p2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + line-height: 1.1; +} +.stat-label { + font-size: 11px; + color: var(--text3); + margin-top: 2px; +} + +/* ── SPECIES CARD ── */ +.species-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + text-align: center; + margin-top: 0; +} +.species-card .species-emoji { + display: none; +} +.species-emoji { + font-size: 32px; + line-height: 1; + flex-shrink: 0; +} +.species-name { + font-size: 17px; + font-weight: 700; + color: #dc6b10; + line-height: 1.2; +} +.species-sub { + font-size: 12px; + color: var(--text3); + margin-top: 3px; +} + +/* ── BAR CHART ── */ +.bar-list { + display: flex; + flex-direction: column; + gap: 10px; +} +.bar-item label { + display: flex; + justify-content: space-between; + font-size: 14px; + color: var(--text2); + margin-bottom: 5px; +} +.bar-item label span { + font-family: var(--mono); + color: var(--text2); + font-size: 16px; +} +.bar-track { + height: 6px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} +.bar-fill { + height: 100%; + border-radius: 6px; + background: #dc6b10; +} +.bar-fill.orange { + background: #dc6b10; +} +.bar-fill.mint { + background: #dc6b10; +} +.bar-fill.red { + background: #dc6b10; +} + +/* ── ROAST TEXT ── */ +.roast-text { + font-size: 14px; + color: var(--text2); + line-height: 1.8; + font-weight: 400; +} +.roast-text strong { + color: #dc6b10; + font-weight: 700; +} +.roast-text em { + color: var(--p2); + font-style: normal; +} + +/* ── DEEP ANALYSIS ── */ +.dim-list { + display: flex; + flex-direction: column; + gap: 12px; +} +.dim-item { + background: var(--glass); + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px 16px; +} +.dim-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.dim-icon { + font-size: 18px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--glass2); + border-radius: 8px; +} +.dim-title { + font-size: 13px; + font-weight: 600; + color: var(--text); +} +.dim-body { + font-size: 13px; + color: var(--text2); + line-height: 1.7; +} +.dim-body strong { + color: var(--p1); +} + +/* ── CHAT ── */ +.chat-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 14px; +} +.chat-msg { + display: flex; + gap: 8px; + align-items: flex-end; + max-width: 88%; +} +.chat-msg.user { + margin-left: auto; + flex-direction: row-reverse; +} +.chat-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; + background: #ffffff; + border: 1px solid var(--border); + overflow: hidden; +} +html[data-theme="light"] .chat-avatar { + background: #000000; +} +.chat-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} +.chat-bubble { + font-size: 13px; + line-height: 1.6; + padding: 10px 14px; + border-radius: 14px; +} +.chat-msg.bot .chat-bubble { + background: var(--glass2); + border: 1px solid var(--border); + color: var(--text2); + border-radius: 4px 14px 14px 14px; +} +.chat-msg.user .chat-bubble { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.3); + color: var(--text); + border-radius: 14px 4px 14px 14px; +} +.chat-prompts { + display: flex; + flex-wrap: wrap; + gap: 7px; +} +.chat-prompt-btn { + font-size: 12px; + font-family: var(--sans); + padding: 6px 12px; + border-radius: 20px; + border: 1px solid var(--border2); + background: var(--glass); + color: var(--text2); + cursor: pointer; + transition: border-color 0.2s, background 0.2s, color 0.2s; +} +.chat-prompt-btn:hover { + border-color: var(--p1); + color: var(--p1); + background: rgba(255, 255, 255, 0.06); +} +.skill-unlock-btn { + display: block; + margin-top: 14px; + padding: 11px; + text-align: center; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.06); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + font-weight: 600; + text-decoration: none; + transition: background 0.2s, border-color 0.2s; +} +.skill-unlock-btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.4); +} + +/* ── SKILL FILE ── */ +.code-block { + background: rgba(0, 0, 0, 0.05); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 16px; + font-family: var(--mono); + font-size: 12px; + color: var(--p4); + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 16px; + overflow-x: auto; +} +.code-copy { + flex-shrink: 0; + background: var(--glass2); + border: 1px solid var(--border); + color: var(--text3); + font-family: var(--sans); + font-size: 11px; + cursor: pointer; + padding: 4px 10px; + border-radius: 6px; + transition: border-color 0.2s, color 0.2s; + white-space: nowrap; +} +.code-copy:hover { + border-color: var(--p2); + color: var(--p2); +} +.skill-progress { + margin-bottom: 18px; +} +.skill-progress-header { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--text3); + margin-bottom: 7px; +} +.skill-progress-header span:last-child { + font-family: var(--mono); + color: var(--p5); +} +.progress-track { + height: 8px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.07); + overflow: hidden; +} +.progress-fill { + height: 100%; + border-radius: 8px; + width: 78%; + background: linear-gradient(90deg, var(--p5), var(--p4)); + box-shadow: 0 0 10px rgba(255, 255, 255, 0.15); +} +.download-btn { + width: 100%; + padding: 11px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 10px; + color: var(--p1); + font-family: var(--sans); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} +.download-btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.35); +} +.generate-btn { + width: 100%; + padding: 13px; + background: var(--clr-bg); + border: none; + border-radius: 10px; + color: var(--bg); + font-family: var(--sans); + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: opacity 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} +.generate-btn:hover { + opacity: 0.85; +} +.generate-btn svg { + width: 1em; + height: 1em; + opacity: 0; + transform: translate(-4px, 4px); + transition: opacity 0.2s ease, transform 0.2s ease; +} +.generate-btn:hover svg { + opacity: 1; + transform: translate(0, 0); +} + +/* ── SHARE ── */ +.share-row { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.share-btn { + flex: 1; + min-width: 100px; + padding: 10px 8px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--glass); + color: var(--text2); + font-family: var(--sans); + font-size: 13px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + transition: border-color 0.2s, background 0.2s, color 0.2s; +} +.share-btn:hover { + border-color: var(--border2); + background: var(--glass2); + color: var(--text); +} + +/* ── LOCATION ── */ +.location-card { + display: flex; + flex-direction: column; + gap: 14px; +} +.location-map { + width: 100%; + height: 140px; + border-radius: 12px; + background: radial-gradient( + circle at 52% 48%, + rgba(255, 255, 255, 0.08) 0%, + transparent 55% + ), + radial-gradient( + circle at 20% 80%, + rgba(255, 255, 255, 0.04) 0%, + transparent 50% + ), + repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.02) 0px, + rgba(255, 255, 255, 0.02) 1px, + transparent 1px, + transparent 40px + ), + repeating-linear-gradient( + 90deg, + rgba(255, 255, 255, 0.02) 0px, + rgba(255, 255, 255, 0.02) 1px, + transparent 1px, + transparent 40px + ), #080808; + border: 1px solid var(--border); + position: relative; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} +.location-map::before { + content: ""; + position: absolute; + width: 80px; + height: 80px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.2); + animation: ping 2.4s ease-out infinite; +} +.location-map::after { + content: ""; + position: absolute; + width: 140px; + height: 140px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.08); + animation: ping 2.4s ease-out infinite 0.6s; +} +@keyframes ping { + 0% { + transform: scale(0.4); + opacity: 1; + } + 100% { + transform: scale(1.6); + opacity: 0; + } +} +.loc-pin { + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} +.loc-pin-dot { + width: 14px; + height: 14px; + border-radius: 50%; + background: linear-gradient(135deg, var(--p1), var(--p2)); + box-shadow: 0 0 16px var(--glow1), 0 0 4px rgba(255, 255, 255, 0.6); +} +.loc-pin-line { + width: 1px; + height: 18px; + background: linear-gradient(180deg, var(--p1), transparent); +} +.location-info { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.loc-city { + font-size: 16px; + font-weight: 700; + background: linear-gradient(90deg, var(--p1), var(--p2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.loc-detail { + font-size: 12px; + color: var(--text3); + margin-top: 2px; + font-family: var(--mono); +} +.loc-checkin-btn { + flex-shrink: 0; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 20px; + color: var(--p1); + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + padding: 7px 16px; + cursor: pointer; + transition: background 0.2s, border-color 0.2s, box-shadow 0.2s; +} +.loc-checkin-btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.35); + box-shadow: 0 0 14px var(--glow1); +} +.loc-checkin-btn.checked { + background: var(--p1); + border-color: transparent; + color: #000; + box-shadow: 0 0 16px var(--glow1); +} +.loc-history { + display: flex; + flex-direction: column; + gap: 8px; +} +.loc-history-title { + font-size: 11px; + color: var(--text3); + letter-spacing: 0.05em; + text-transform: uppercase; + margin-bottom: 2px; +} +.loc-history-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.loc-badge { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--text2); + background: var(--glass); + border: 1px solid var(--border); + border-radius: 20px; + padding: 4px 10px; +} +.loc-badge-dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +/* ── POSTER MODAL ── */ +.poster-overlay { + display: none; + position: fixed; + inset: 0; + z-index: 100; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(6px); + align-items: center; + justify-content: center; +} +.poster-overlay.open { + display: flex; +} +.poster-modal { + background: #0a0a0a; + border: 1px solid var(--border2); + border-radius: 18px; + padding: 24px 22px; + max-width: 340px; + width: 90%; + text-align: center; +} +.poster-modal h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 8px; +} +.poster-modal p { + font-size: 13px; + color: var(--text3); + margin-bottom: 18px; +} +.poster-placeholder { + width: 100%; + height: 200px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px dashed var(--border2); + display: flex; + align-items: center; + justify-content: center; + color: var(--text3); + font-size: 13px; + margin-bottom: 18px; +} +.poster-btns { + display: flex; + gap: 10px; + margin-top: 18px; +} +.poster-save { + flex: 1; + background: var(--p1); + color: var(--bg); + border: none; + border-radius: 8px; + padding: 8px 20px; + cursor: pointer; + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + transition: opacity 0.2s; +} +.poster-save:hover { + opacity: 0.85; +} +.poster-close { + flex: 1; + background: none; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text3); + padding: 8px 20px; + cursor: pointer; + font-family: var(--sans); + font-size: 13px; + transition: border-color 0.2s, color 0.2s; +} +.poster-close:hover { + border-color: var(--border2); + color: var(--text); +} + +/* ── SITE HEADER ── */ +.site-header { + position: sticky; + top: 0; + z-index: 50; + background: rgba(255, 255, 255, 0.88); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid var(--border); +} +.site-header-inner { + display: flex; + justify-content: center; + align-items: center; + padding: 18px 20px; + max-width: 1080px; + margin: 0 auto; + position: relative; +} +.site-logo { + height: 32px; + width: auto; +} + +/* ── FOOTER ── */ +footer { + text-align: center; + font-size: 12px; + color: var(--text3); + padding: 20px 0 0; + border-top: 1px solid var(--border); + margin-top: 8px; +} +footer a { + color: var(--p1); + text-decoration: none; +} +footer a:hover { + text-decoration: underline; +} + +/* ── TAB NAV ── */ +.tab-nav { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 8px; + padding-bottom: 14px; + border-bottom: 1px solid var(--border); + position: sticky; + top: 65px; + z-index: 40; + background: var(--bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + padding-top: 12px; +} +.tab-btn { + font-family: var(--sans); + font-size: 13px; + font-weight: 500; + color: var(--text3); + background: none; + border: none; + padding: 6px 14px; + border-radius: 20px; + cursor: pointer; + transition: background 0.2s, color 0.2s; +} +.tab-btn:hover { + background: var(--glass2); + color: var(--text); +} +.tab-btn.active { + background: var(--glass2); + color: var(--text); + font-weight: 600; +} +html[data-theme="light"] .tab-btn.active { + background: rgba(0, 0, 0, 0.07); +} + +/* ── MOBILE ── */ +@media (max-width: 768px) { + .layout { + flex-direction: column; + } + .left-side { + position: static; + width: 100%; + padding: 24px 16px; + } + .page-wrap { + padding: 0 16px 48px; + } +} + +/* ── STATIC POSTER TEXT OVERLAY ── */ +.poster-image-wrap { + position: relative; + width: 100%; + border-radius: 10px; + overflow: hidden; + margin-bottom: 18px; +} +.poster-image { + display: block; + width: 100%; + height: auto; +} +.poster-text-layer { + position: absolute; + inset: 0; + pointer-events: none; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 10% 12% 12%; + text-align: center; + color: #fff; + text-shadow: 0 2px 16px rgba(0, 0, 0, 0.55), 0 0 2px rgba(0, 0, 0, 0.8); +} +.poster-text-title { + font-family: var(--sans); + font-weight: 700; + font-size: clamp(20px, 6vw, 34px); + line-height: 1.15; + letter-spacing: 0.02em; + margin-top: 6%; +} +.poster-text-sub { + font-family: var(--sans); + font-weight: 500; + font-size: clamp(11px, 3vw, 15px); + line-height: 1.4; + opacity: 0.92; + margin-top: 6px; +} +.poster-text-score { + font-family: var(--mono); + font-weight: 700; + font-size: clamp(44px, 14vw, 86px); + line-height: 1; + margin-top: auto; + color: #ffffff; +} +.poster-text-score-label { + font-family: var(--sans); + font-weight: 500; + font-size: clamp(10px, 2.6vw, 13px); + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.88; + margin-top: 4px; +} +.poster-text-species { + font-family: var(--sans); + font-weight: 600; + font-size: clamp(12px, 3.2vw, 16px); + color: #ffe6a5; + margin-top: 14px; + padding: 4px 14px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.18); + backdrop-filter: blur(4px); + pointer-events: auto; +} + +/* ── GITHUB STAR PILL ── */ +.github-stars { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: 6px; + padding: 2px 7px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid var(--border); + font-family: var(--mono); + font-size: 11px; + line-height: 1; + color: var(--text2); + opacity: 0; + transition: opacity 0.35s ease; + pointer-events: none; +} +.github-stars--loaded { + opacity: 1; +} +.github-stars svg { + width: 10px; + height: 10px; + flex-shrink: 0; + color: #f5c518; +} +html[data-theme="light"] .github-stars { + background: rgba(0, 0, 0, 0.06); + border-color: rgba(0, 0, 0, 0.12); + color: rgba(0, 0, 0, 0.6); +} diff --git a/docs/superpowers/plans/2026-04-09-deploy-skill-template-frame-and-poster-alignment.md b/docs/superpowers/plans/2026-04-09-deploy-skill-template-frame-and-poster-alignment.md new file mode 100644 index 000000000..7c4ad1c46 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-deploy-skill-template-frame-and-poster-alignment.md @@ -0,0 +1,704 @@ +# Deploy Skill Template Frame And Poster Alignment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enforce the new canonical `distill-campaign` content frame in the bundled `deploy-skill` and align the poster typography/layout to the provided fixed CSS spec. + +**Architecture:** Tighten the template boundary in `deploy_skill_core.js` so invalid payloads fail before rendering, then adjust the poster HTML/CSS to use the fixed dimensions, font settings, and positioned text blocks from the spec. Keep the rest of the page behavior unchanged and verify everything through the existing focused skill test file. + +**Tech Stack:** Node.js, bundled Nexu desktop skill, Vitest, JSZip, static HTML/CSS template rendering + +--- + +## File Structure + +- Modify: `apps/desktop/static/bundled-skills/deploy-skill/scripts/deploy_skill_core.js` + - tighten `parseTemplateContent()` validation + - remove fallback poster-species defaults + - align poster HTML text blocks and mappings +- Modify: `apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/styles.css` + - apply fixed poster typography and positioning from the provided CSS +- Modify: `apps/desktop/static/bundled-skills/deploy-skill/SKILL.md` + - document the strict frame contract +- Modify: `tests/skills/deploy-skill-core.test.ts` + - add failing validation tests first + - add poster HTML/CSS assertions for aligned text formatting + +### Task 1: Lock The Canonical Frame In Tests + +**Files:** +- Modify: `tests/skills/deploy-skill-core.test.ts` +- Test: `tests/skills/deploy-skill-core.test.ts` + +- [ ] **Step 1: Write the failing validation tests** + +Add these test cases to `tests/skills/deploy-skill-core.test.ts` inside the existing `"deploy skill core"` suite: + +```ts + it("rejects a title outside the 2-10 character limit", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const contentPath = path.join(rootDir, "bad-title.json"); + await writeLocalNexuConfig(); + await savePageDeployConfig(rootDir, { baseUrl: "https://deploy.example.com" }); + await writeFile( + contentPath, + JSON.stringify({ + title: "A", + subtitle: "牛马指数 92/100 — 龙虾成瘾者", + tags: ["nexu", "增长产品", "Co-builder", "ENTJ"], + metrics: [ + { label: "🐂🐴 牛马指数", value: "92" }, + { label: "⚡ 热点追击力", value: "95%" }, + { label: "🧠 信息密度", value: "88%" }, + { label: "📈 操盘手感", value: "90%" } + ], + posterSpeciesEmoji: "🦞", + posterSpeciesName: "龙虾成瘾者", + posterSpeciesSub: "办公室物种鉴定", + description: "你是那种能把信息差、执行力和控制欲拧成一根钢缆的人。你看起来像在推进项目,实际上是在逼时间给你让路。你对热点的嗅觉过于灵敏,对机会的反应快到让同事怀疑你是不是提前收到了剧本。你最大的可怕之处不是卷,而是你卷得还很有方法。可你也不是没有裂缝,你只是太习惯在别人慌乱时继续往前走,忘了自己也会累。好在你心里仍然留着一点柔软,所以你不只是一个推进器,还是那个会把团队一起带上岸的人。", + qaCards: [ + { question: "致命优势", answer: "你对节奏的判断极准,知道什么时候该抢,什么时候该守。你不会为了显得聪明而拖慢推进,反而总能在别人犹豫时先把路试出来。你的行动不是盲冲,而是把混乱快速压缩成可执行路径,这种能力很稀缺。" }, + { question: "人生建议", answer: "继续保持你的锋利,但别把所有事都扛成自己的责任。你真正厉害的地方,不只是能冲,还能让别人跟你一起冲。把部分控制欲换成更稳定的协作,你会更轻松,也会走得更远。" } + ], + dialogs: [ + { speaker: "bot", text: "你又在刷新热点榜单,准备下一轮出手了?" }, + { speaker: "user", text: "不是刷新,是提前埋伏。" }, + { speaker: "bot", text: "行,你还是那个把流量当氧气吸的人。" } + ], + ctaText: "⭐ 生成我的牛马锐评", + installText: "复制链接发给你的 nexu agent:https://github.com/nexu-io/roast-skill" + }), + ); + + await expect( + submitPageDeployTemplateJob( + { + nexuHome: rootDir, + templateId: "distill-campaign", + contentFile: contentPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { fetchImpl: vi.fn() }, + ), + ).rejects.toThrow(/title/i); + }); + + it("rejects subtitle strings that do not match the required overall format", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const contentPath = path.join(rootDir, "bad-subtitle.json"); + await writeLocalNexuConfig(); + await savePageDeployConfig(rootDir, { baseUrl: "https://deploy.example.com" }); + await writeFile( + contentPath, + JSON.stringify({ + title: "李锦威", + subtitle: "Founder / Product / Builder", + tags: ["nexu", "增长产品", "Co-builder", "ENTJ"], + metrics: [ + { label: "🐂🐴 牛马指数", value: "92" }, + { label: "⚡ 热点追击力", value: "95%" }, + { label: "🧠 信息密度", value: "88%" }, + { label: "📈 操盘手感", value: "90%" } + ], + posterSpeciesEmoji: "🦞", + posterSpeciesName: "龙虾成瘾者", + posterSpeciesSub: "办公室物种鉴定", + description: "你是那种能把信息差、执行力和控制欲拧成一根钢缆的人。你看起来像在推进项目,实际上是在逼时间给你让路。你对热点的嗅觉过于灵敏,对机会的反应快到让同事怀疑你是不是提前收到了剧本。你最大的可怕之处不是卷,而是你卷得还很有方法。可你也不是没有裂缝,你只是太习惯在别人慌乱时继续往前走,忘了自己也会累。好在你心里仍然留着一点柔软,所以你不只是一个推进器,还是那个会把团队一起带上岸的人。", + qaCards: [ + { question: "致命优势", answer: "你对节奏的判断极准,知道什么时候该抢,什么时候该守。你不会为了显得聪明而拖慢推进,反而总能在别人犹豫时先把路试出来。你的行动不是盲冲,而是把混乱快速压缩成可执行路径,这种能力很稀缺。" }, + { question: "人生建议", answer: "继续保持你的锋利,但别把所有事都扛成自己的责任。你真正厉害的地方,不只是能冲,还能让别人跟你一起冲。把部分控制欲换成更稳定的协作,你会更轻松,也会走得更远。" } + ], + dialogs: [ + { speaker: "bot", text: "你又在刷新热点榜单,准备下一轮出手了?" }, + { speaker: "user", text: "不是刷新,是提前埋伏。" }, + { speaker: "bot", text: "行,你还是那个把流量当氧气吸的人。" } + ], + ctaText: "⭐ 生成我的牛马锐评", + installText: "复制链接发给你的 nexu agent:https://github.com/nexu-io/roast-skill" + }), + ); + + await expect( + submitPageDeployTemplateJob( + { + nexuHome: rootDir, + templateId: "distill-campaign", + contentFile: contentPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { fetchImpl: vi.fn() }, + ), + ).rejects.toThrow(/subtitle/i); + }); + + it("rejects metrics with a main score that includes percent notation", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const contentPath = path.join(rootDir, "bad-metrics.json"); + await writeLocalNexuConfig(); + await savePageDeployConfig(rootDir, { baseUrl: "https://deploy.example.com" }); + await writeFile( + contentPath, + JSON.stringify({ + title: "李锦威", + subtitle: "牛马指数 92/100 — 龙虾成瘾者", + tags: ["nexu", "增长产品", "Co-builder", "ENTJ"], + metrics: [ + { label: "🐂🐴 牛马指数", value: "92%" }, + { label: "⚡ 热点追击力", value: "95%" }, + { label: "🧠 信息密度", value: "88%" }, + { label: "📈 操盘手感", value: "90%" } + ], + posterSpeciesEmoji: "🦞", + posterSpeciesName: "龙虾成瘾者", + posterSpeciesSub: "办公室物种鉴定", + description: "你是那种能把信息差、执行力和控制欲拧成一根钢缆的人。你看起来像在推进项目,实际上是在逼时间给你让路。你对热点的嗅觉过于灵敏,对机会的反应快到让同事怀疑你是不是提前收到了剧本。你最大的可怕之处不是卷,而是你卷得还很有方法。可你也不是没有裂缝,你只是太习惯在别人慌乱时继续往前走,忘了自己也会累。好在你心里仍然留着一点柔软,所以你不只是一个推进器,还是那个会把团队一起带上岸的人。", + qaCards: [ + { question: "致命优势", answer: "你对节奏的判断极准,知道什么时候该抢,什么时候该守。你不会为了显得聪明而拖慢推进,反而总能在别人犹豫时先把路试出来。你的行动不是盲冲,而是把混乱快速压缩成可执行路径,这种能力很稀缺。" }, + { question: "人生建议", answer: "继续保持你的锋利,但别把所有事都扛成自己的责任。你真正厉害的地方,不只是能冲,还能让别人跟你一起冲。把部分控制欲换成更稳定的协作,你会更轻松,也会走得更远。" } + ], + dialogs: [ + { speaker: "bot", text: "你又在刷新热点榜单,准备下一轮出手了?" }, + { speaker: "user", text: "不是刷新,是提前埋伏。" }, + { speaker: "bot", text: "行,你还是那个把流量当氧气吸的人。" } + ], + ctaText: "⭐ 生成我的牛马锐评", + installText: "复制链接发给你的 nexu agent:https://github.com/nexu-io/roast-skill" + }), + ); + + await expect( + submitPageDeployTemplateJob( + { + nexuHome: rootDir, + templateId: "distill-campaign", + contentFile: contentPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { fetchImpl: vi.fn() }, + ), + ).rejects.toThrow(/metrics\[0\]\.value/i); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +cd /Users/alche/Documents/digit-sutando/nexu +pnpm exec vitest run tests/skills/deploy-skill-core.test.ts +``` + +Expected: +- FAIL in the new validation tests because the parser still accepts loose values. + +- [ ] **Step 3: Add poster text-alignment assertions** + +Extend the existing `"renders the distill-campaign template into a root-level zip before submit"` test with these assertions: + +```ts + expect(indexHtml).toContain('class="poster-title"'); + expect(indexHtml).toContain('class="poster-divider"'); + expect(indexHtml).toContain('class="poster-tags poster-tags-row poster-tags-row-1"'); + expect(indexHtml).toContain('class="poster-tags poster-tags-row poster-tags-row-2"'); + expect(indexHtml).toContain('class="poster-species-card"'); + expect(indexHtml).toContain('class="poster-score"'); + expect(indexHtml).toContain('class="poster-score-label"'); + + expect(stylesCss).toContain('font-family: "Apple Braille", var(--sans);'); + expect(stylesCss).toContain('font-size: 77px;'); + expect(stylesCss).toContain('font-family: "PingFang SC", "Inter", sans-serif;'); + expect(stylesCss).toContain('font-size: 13px;'); + expect(stylesCss).toContain('font-family: "Archivo Black", "Inter", sans-serif;'); + expect(stylesCss).toContain('font-size: 123px;'); + expect(stylesCss).toContain('font-family: "Abhaya Libre", "Times New Roman", serif;'); + expect(stylesCss).toContain('font-size: 17px;'); +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: + +```bash +cd /Users/alche/Documents/digit-sutando/nexu +pnpm exec vitest run tests/skills/deploy-skill-core.test.ts +``` + +Expected: +- FAIL if the current CSS still uses non-matching font-family declarations or missing text-layer selectors. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/alche/Documents/digit-sutando/nexu add tests/skills/deploy-skill-core.test.ts +git -C /Users/alche/Documents/digit-sutando/nexu commit -m "test: enforce deploy skill frame contract" +``` + +### Task 2: Tighten The Template Parser To Match The Canonical Frame + +**Files:** +- Modify: `apps/desktop/static/bundled-skills/deploy-skill/scripts/deploy_skill_core.js` +- Test: `tests/skills/deploy-skill-core.test.ts` + +- [ ] **Step 1: Write the failing parser expectations** + +Use the failing tests from Task 1 as the red state. Do not change production code yet. + +- [ ] **Step 2: Implement strict scalar and pattern validators** + +Add these helpers near the existing validation helpers in `deploy_skill_core.js`: + +```js +function assertStringLength(value, fieldName, min, max) { + const normalized = assertNonEmptyString(value, fieldName); + if (normalized.length < min || normalized.length > max) { + throw new Error( + `deploy-skill requires ${fieldName} with length ${min}-${max} characters.`, + ); + } + return normalized; +} + +function assertNoRichText(value, fieldName, min, max) { + const normalized = assertStringLength(value, fieldName, min, max); + if (/[\r\n]/u.test(normalized)) { + throw new Error(`deploy-skill template field ${fieldName} must not contain newlines.`); + } + if (/<[^>]+>/u.test(normalized)) { + throw new Error(`deploy-skill template field ${fieldName} must not contain HTML.`); + } + if (/(\*\*|__|^- |\n- |\n\* )/u.test(normalized)) { + throw new Error(`deploy-skill template field ${fieldName} must not contain markdown.`); + } + return normalized; +} + +function assertExactValue(value, fieldName, expected) { + const normalized = assertNonEmptyString(value, fieldName); + if (normalized !== expected) { + throw new Error(`deploy-skill template field ${fieldName} must equal "${expected}".`); + } + return normalized; +} + +function assertSubtitleFormat(value) { + const normalized = assertStringLength(value, "subtitle", 15, 30); + if ( + !normalized.includes("牛马指数") || + !normalized.includes("/100") || + !normalized.includes("—") + ) { + throw new Error( + "deploy-skill template field subtitle must include 牛马指数, /100, and —.", + ); + } + return normalized; +} +``` + +- [ ] **Step 3: Replace loose field parsing with strict frame validation** + +Update `parseTemplateContent()` so the return object is built like this: + +```js + const title = assertStringLength(payload.title, "title", 2, 10); + const subtitle = assertSubtitleFormat(payload.subtitle); + const description = assertNoRichText(payload.description, "description", 150, 250); + const ctaText = assertExactValue( + payload.ctaText, + "ctaText", + "⭐ 生成我的牛马锐评", + ); + const installText = assertExactValue( + payload.installText, + "installText", + "复制链接发给你的 nexu agent:https://github.com/nexu-io/roast-skill", + ); + + const tags = assertArrayLength(payload.tags, "tags", 1, 8).map((tag, index) => + assertStringLength(tag, `tags[${index}]`, 2, 8), + ); + + const metrics = assertArrayLength(payload.metrics, "metrics", 4, 6).map( + (metric, index) => { + if (typeof metric !== "object" || metric === null) { + throw new Error(`deploy-skill template field metrics[${index}] is invalid.`); + } + const label = + index === 0 + ? assertStringLength(metric.label, `metrics[${index}].label`, 6, 10) + : assertStringLength(metric.label, `metrics[${index}].label`, 6, 12); + const value = assertNonEmptyString(metric.value, `metrics[${index}].value`); + if (index === 0) { + if (!/^\d+$/u.test(value)) { + throw new Error( + "deploy-skill template field metrics[0].value must be a numeric string without %.", + ); + } + } else if (!/^\d+%$/u.test(value)) { + throw new Error( + `deploy-skill template field metrics[${index}].value must end with %.`, + ); + } + return { label, value }; + }, + ); + + const qaCards = assertArrayLength(payload.qaCards, "qaCards", 2, 3).map( + (card, index) => { + if (typeof card !== "object" || card === null) { + throw new Error(`deploy-skill template field qaCards[${index}] is invalid.`); + } + return { + question: assertStringLength(card.question, `qaCards[${index}].question`, 3, 8), + answer: assertNoRichText(card.answer, `qaCards[${index}].answer`, 80, 150), + }; + }, + ); + + const dialogs = assertArrayLength(payload.dialogs, "dialogs", 3, 6).map( + (dialog, index) => { + if (typeof dialog !== "object" || dialog === null) { + throw new Error(`deploy-skill template field dialogs[${index}] is invalid.`); + } + const speaker = assertNonEmptyString( + dialog.speaker, + `dialogs[${index}].speaker`, + ).toLowerCase(); + if (speaker !== "bot" && speaker !== "user") { + throw new Error( + `deploy-skill template field dialogs[${index}].speaker must be bot or user.`, + ); + } + return { + speaker, + text: assertNoRichText(dialog.text, `dialogs[${index}].text`, 15, 80), + }; + }, + ); + + return { + title, + subtitle, + tags, + metrics, + description, + qaCards, + dialogs, + ctaText, + installText, + posterSpeciesEmoji: assertStringLength( + payload.posterSpeciesEmoji, + "posterSpeciesEmoji", + 1, + 4, + ), + posterSpeciesName: assertStringLength( + payload.posterSpeciesName, + "posterSpeciesName", + 3, + 8, + ), + posterSpeciesSub: assertStringLength( + payload.posterSpeciesSub, + "posterSpeciesSub", + 5, + 8, + ), + }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +cd /Users/alche/Documents/digit-sutando/nexu +pnpm exec vitest run tests/skills/deploy-skill-core.test.ts +``` + +Expected: +- PASS for all frame-validation tests. + +- [ ] **Step 5: Commit** + +```bash +git -C /Users/alche/Documents/digit-sutando/nexu add apps/desktop/static/bundled-skills/deploy-skill/scripts/deploy_skill_core.js tests/skills/deploy-skill-core.test.ts +git -C /Users/alche/Documents/digit-sutando/nexu commit -m "feat: enforce deploy skill template frame" +``` + +### Task 3: Align Poster Typography And Text Geometry To The Provided CSS + +**Files:** +- Modify: `apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/styles.css` +- Modify: `apps/desktop/static/bundled-skills/deploy-skill/scripts/deploy_skill_core.js` +- Test: `tests/skills/deploy-skill-core.test.ts` + +- [ ] **Step 1: Use the failing poster-style assertions from Task 1** + +Keep the red state from the CSS assertions before touching the poster typography code. + +- [ ] **Step 2: Update poster title, tag, species, score, and footer styles** + +Change the poster text rules in `styles.css` to match the provided spec: + +```css +.poster-title { + position: absolute; + width: 179px; + height: 100px; + left: 208px; + top: 105px; + font-family: "Apple Braille", var(--sans); + font-style: normal; + font-weight: 400; + font-size: 77px; + line-height: 130%; + display: flex; + align-items: center; + color: #ffffff; +} + +.poster-divider { + position: absolute; + width: 413px; + left: 219.5px; + top: 202.5px; + border-top: 1px solid #ffffff; +} + +.poster-tag { + width: 99px; + height: 37px; + padding: 10px 20px; + border-radius: 85px; + background: #ffffff; + border: 0; + color: #391934; + font-family: "PingFang SC", "Inter", sans-serif; + font-style: normal; + font-weight: 600; + font-size: 13px; + line-height: 130%; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.poster-species-name, +.poster-species-sub, +.poster-score-label { + font-family: "Apple Braille", var(--sans); + font-style: normal; + font-weight: 400; + line-height: 130%; + background: linear-gradient(310.92deg, #222a15 26.58%, #7e3617 78.53%), #1e2513; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + color: transparent; +} + +.poster-species-name { + font-size: 29px; +} + +.poster-species-sub { + font-size: 29px; +} + +.poster-score { + font-family: "Archivo Black", "Inter", sans-serif; + font-style: normal; + font-weight: 400; + font-size: 123px; + line-height: 130%; + display: flex; + align-items: center; + background: linear-gradient(310.92deg, #222a15 26.58%, #7e3617 78.53%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + color: transparent; +} + +.poster-footer { + position: absolute; + width: 305px; + height: 22px; + left: 411px; + top: 955px; + font-family: "Abhaya Libre", "Times New Roman", serif; + font-style: normal; + font-weight: 800; + font-size: 17px; + line-height: 130%; + display: flex; + align-items: center; + color: #2d2d2d; +} +``` + +- [ ] **Step 3: Align poster block positions to the provided geometry** + +Update these poster layout rules in `styles.css`: + +```css +.poster-tags-row-1 { + top: 239px; +} + +.poster-tags-row-2 { + top: 298px; +} + +.poster-species-card { + position: absolute; + width: 223px; + height: 96px; + left: 208px; + top: 357px; + display: flex; + flex-direction: row; + align-items: center; + padding: 10px; + gap: 10px; + background: #ffffff; +} + +.poster-stat-card { + position: absolute; + width: 333px; + height: 122px; + left: 208px; + top: 468px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: flex-end; + padding: 10px; + gap: 10px; + background: #ffffff; +} + +.poster-qr-block { + position: absolute; + width: 127px; + height: 127px; + left: 524px; + top: 755px; + background: transparent; +} +``` + +- [ ] **Step 4: Keep the poster HTML selectors stable** + +Ensure the poster markup in `renderDistillCampaignHtml()` still contains these exact classes: + +```html +
...
+
+
...
+
...
+
...
+
...
+
...
+
...
+ +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: + +```bash +cd /Users/alche/Documents/digit-sutando/nexu +pnpm exec vitest run tests/skills/deploy-skill-core.test.ts +``` + +Expected: +- PASS for all poster asset and CSS assertions. + +- [ ] **Step 6: Commit** + +```bash +git -C /Users/alche/Documents/digit-sutando/nexu add apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/styles.css apps/desktop/static/bundled-skills/deploy-skill/scripts/deploy_skill_core.js tests/skills/deploy-skill-core.test.ts +git -C /Users/alche/Documents/digit-sutando/nexu commit -m "fix: align deploy skill poster typography" +``` + +### Task 4: Update Skill Documentation To Match The Enforced Frame + +**Files:** +- Modify: `apps/desktop/static/bundled-skills/deploy-skill/SKILL.md` +- Test: `tests/skills/deploy-skill-core.test.ts` + +- [ ] **Step 1: Document the strict frame in the skill file** + +Replace the loose template-content section in `SKILL.md` with this canonical field list: + +```md +The content file must be structured JSON with this exact frame: +- `title`: 2-10 characters +- `subtitle`: 15-30 characters and must include `牛马指数`, `/100`, and `—` +- `tags`: 1-8 strings, each 2-8 characters +- `metrics`: 4-6 items total +- `posterSpeciesEmoji` +- `posterSpeciesName`: 3-8 characters +- `posterSpeciesSub`: 5-8 characters +- `description`: 150-250 characters, no markdown, HTML, or newlines +- `qaCards`: 2-3 items +- `dialogs`: 3-6 items +- `ctaText`: must equal `⭐ 生成我的牛马锐评` +- `installText`: must equal `复制链接发给你的 nexu agent:https://github.com/nexu-io/roast-skill` +``` + +- [ ] **Step 2: Add the non-normalization rule** + +Append this rule to the template section: + +```md +If any field violates the frame, the skill rejects the payload and does not render. It never truncates, rewrites, or invents missing values. +``` + +- [ ] **Step 3: Run focused tests as regression protection** + +Run: + +```bash +cd /Users/alche/Documents/digit-sutando/nexu +pnpm exec vitest run tests/skills/deploy-skill-core.test.ts +``` + +Expected: +- PASS + +- [ ] **Step 4: Commit** + +```bash +git -C /Users/alche/Documents/digit-sutando/nexu add apps/desktop/static/bundled-skills/deploy-skill/SKILL.md +git -C /Users/alche/Documents/digit-sutando/nexu commit -m "docs: document deploy skill frame contract" +``` + +## Self-Review + +- Spec coverage: + - strict frame validation: Task 1 and Task 2 + - poster text alignment from provided CSS: Task 1 and Task 3 + - documentation sync: Task 4 +- Placeholder scan: + - no `TODO`, `TBD`, or unresolved placeholders remain +- Type consistency: + - uses existing names `parseTemplateContent`, `renderDistillCampaignHtml`, `poster-title`, `poster-tag`, `poster-score`, `poster-footer` + diff --git a/docs/superpowers/specs/2026-04-09-deploy-skill-template-frame-design.md b/docs/superpowers/specs/2026-04-09-deploy-skill-template-frame-design.md new file mode 100644 index 000000000..7ab197316 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-deploy-skill-template-frame-design.md @@ -0,0 +1,346 @@ +# Deploy Skill Template Frame Design + +Date: 2026-04-09 +Project: `nexu` bundled skill `apps/desktop/static/bundled-skills/deploy-skill` +Template: `distill-campaign` + +## Summary + +Define a strict canonical content frame for the `distill-campaign` template so every generated page uses the same information architecture, the same field semantics, and the same layout bindings. The skill must reject any payload that violates this contract. No silent normalization, truncation, or fallback rewriting is allowed. + +## Problem + +The current template parser accepts the right high-level field names, but it is still too permissive. That allows pages to drift in tone, section structure, and layout meaning even when the HTML and CSS stay the same. The result is inconsistent output and ambiguous content expectations. + +The user wants every generated page to use one fixed frame: + +- title +- subtitle +- tags +- metrics +- poster species fields +- description +- deep analysis cards +- dialogs +- CTA text +- install text + +This frame should be enforced by the skill itself, not by prompt discipline. + +This contract also needs an explicit portrait-selection field so the agent chooses a head portrait intentionally instead of relying on runtime randomness. + +## Goals + +- Make the provided structure the only accepted content frame for the template. +- Enforce strict validation at the template boundary before rendering. +- Keep page rendering deterministic by binding each field to a fixed part of the page. +- Preserve the current visual layout while making the content contract stricter. + +## Non-Goals + +- No automatic content cleanup or rewriting. +- No truncation of overlong fields. +- No support for alternate template schemas in this change. +- No attempt to infer missing fields from other content. + +## Canonical Field Contract + +### 1. `title` + +- Type: string +- Length: 2-10 characters +- Used in: + - page `` + - left profile name + - poster title + +### 2. `subtitle` + +- Type: string +- Length: 15-30 characters +- Required format: + - `牛马指数 XX/100 — 物种名/一句话标签` +- Used in: + - left profile subtitle + +The validator should enforce both: +- length +- presence of `牛马指数` +- presence of `/100` +- presence of `—` + +The validator does not need to parse semantic subfields out of the subtitle. It only needs to ensure the required overall shape is present. + +### 3. `tags` + +- Type: array of strings +- Count: 1-8 +- Per-item length: 2-8 characters +- Color mapping order: + - `purple` + - `cyan` + - `orange` + - `teal` + - `mint` + - then repeat +- Used in: + - profile tags + - poster tags, split into two rows: + - first 4 + - second 4 + - first 4 quick buttons in dialogue section + +### 3.5 `portraitId` + +- Type: string +- Required allowed values: + - `portrait-1` + - `portrait-2` + - `portrait-3` + - `portrait-4` + - `portrait-5` + - `portrait-6` + - `portrait-7` +- Used in: + - profile avatar + - poster avatar + - bot avatar in dialogue area + +The skill must reject missing or unknown portrait ids. Avatar selection is no longer random. + +### 4. `metrics` + +- Type: array of objects +- Count: 4-6 total +- Structure: + - `metrics[0]`: main score + - `metrics[1..]`: progress-bar metrics + +#### `metrics[0]` + +- `label`: string, 6-10 characters, emoji included +- `value`: numeric string only, no `%` +- Used in: + - main score card + - poster score block + - skill progress numeric display + +#### `metrics[1..]` + +- Count: 3-5 +- `label`: string, 6-12 characters, emoji included +- `value`: percentage string ending in `%` +- Used in: + - progress bars inside core metrics card + +### 5. `posterSpeciesEmoji` + +- Type: string +- Must be a non-empty single emoji-like token +- Used in: + - species card + - poster species section + +### 6. `posterSpeciesName` + +- Type: string +- Length: 3-8 characters +- Used in: + - species card + - poster species section + +### 7. `posterSpeciesSub` + +- Type: string +- Length: 5-8 characters +- Used in: + - species card + - poster species section + +### 8. `description` + +- Type: string +- Length: 150-250 characters +- Restrictions: + - no markdown formatting + - no newline characters + - no HTML tags + - no list prefixes +- Used in: + - core metrics card + - AI roast card + +This field appears twice, so validation must be strict enough to avoid bloated layouts. + +### 9. `qaCards` + +- Type: array of objects +- Count: 2-3 +- Each item: + - `question`: string, 3-8 characters + - `answer`: string, 80-150 characters +- Restrictions: + - no markdown + - no HTML +- Icon mapping by order: + - first: `🔥` + - second: `💪` + - third: `💀` + +### 10. `dialogs` + +- Type: array of objects +- Count: 3-6 +- Each item: + - `speaker`: `"bot"` or `"user"` + - `text`: string, 15-80 characters +- Used in: + - dialogue card + +Rendering rules: +- `bot` avatar uses the explicitly selected portrait image +- `user` avatar uses `👤` + +### 11. `ctaText` + +- Type: string +- Fixed required value: + - `⭐ 生成我的牛马锐评` +- Used in: + - skill file card progress label area + +### 12. `installText` + +- Type: string +- Fixed required value: + - `复制链接发给你的 nexu agent:https://github.com/nexu-io/roast-skill` +- Used in: + - skill file card code block + +## Validation Strategy + +Validation happens inside the template parser before any rendering or zipping. + +Rules: + +- Reject on any missing field. +- Reject on any count violation. +- Reject on any length violation. +- Reject on any malformed value shape. +- Reject fixed-string mismatches for `ctaText` and `installText`. +- Reject percent formatting violations in `metrics[0]` vs `metrics[1..]`. +- Reject invalid `dialogs[].speaker`. +- Reject description and analysis fields that contain markdown, HTML, or newlines. + +No automatic fallback values should remain for poster species fields after this change. These fields become required parts of the contract. + +## Rendering Mapping + +The layout stays data-driven, but each field maps to one fixed visual responsibility. + +- `title` + - document title + - profile title + - poster title + +- `subtitle` + - profile subtitle only + +- `tags` + - profile tags + - poster tags in two rows + - first 4 quick prompt buttons + +- `metrics[0]` + - main score card + - poster score block + - skill progress number + +- `metrics[1..]` + - progress bars + +- `posterSpeciesEmoji` + - species icon in page card and poster + +- `posterSpeciesName` + - species title in page card and poster + +- `posterSpeciesSub` + - species subtitle in page card and poster + +- `description` + - core card + - AI roast card + +- `qaCards` + - deep analysis cards + +- `dialogs` + - chat conversation list + +- `ctaText` + - skill file action copy + +- `installText` + - skill file code block + +## Error Handling + +The skill should fail early with explicit validation messages that name the failing field. + +Examples: + +- `deploy-skill requires title with length 2-10 characters.` +- `deploy-skill template field metrics[0].value must be a numeric string without %.` +- `deploy-skill template field ctaText must equal "⭐ 生成我的牛马锐评".` + +Errors should be thrown from the parser boundary and stop rendering entirely. + +## Testing Strategy + +Follow TDD. + +Add failing tests first for: + +- valid canonical payload passes +- `title` too short and too long +- `subtitle` missing required overall format +- `tags` count and per-item length bounds +- `metrics` total count bounds +- `metrics[0].value` incorrectly contains `%` +- `metrics[1].value` missing `%` +- fixed-string mismatch for `ctaText` +- fixed-string mismatch for `installText` +- `description` contains newline or markdown +- `qaCards[].answer` too short or too long +- `dialogs[].speaker` invalid +- `posterSpecies*` missing or out of bounds + +Keep the focused skill test file as the primary coverage target: + +- `tests/skills/deploy-skill-core.test.ts` + +## Implementation Plan Shape + +1. Write failing validation tests in `tests/skills/deploy-skill-core.test.ts`. +2. Tighten `parseTemplateContent()` in `deploy_skill_core.js`. +3. Remove old poster species fallbacks. +4. Update `SKILL.md` so the documented contract matches the enforced one. +5. Run focused tests. + +## Risks + +- Existing template payloads that used looser copy will start failing. +- Subtitle validation can become too rigid if over-specified. + +## Risk Mitigation + +- Enforce only the subtitle markers that matter: + - `牛马指数` + - `/100` + - `—` +- Keep error messages field-specific so callers can repair payloads quickly. + +## Decision + +Use a strict schema contract with no normalization. This makes the field structure the canonical frame for every page and keeps both content and layout consistent. diff --git a/skills/localdev/datadog/SKILL.md b/skills/localdev/datadog/SKILL.md deleted file mode 100644 index 1521071c8..000000000 --- a/skills/localdev/datadog/SKILL.md +++ /dev/null @@ -1,211 +0,0 @@ ---- -name: datadog -description: Use when the user says "check Datadog", "查 Datadog", "查日志", "check logs", "crash logs", "查 crash", "gateway crash", "查告警", "check alerts", "check metrics", or needs to investigate production issues via Datadog Logs API. ---- - -# Datadog Log Investigation - -Query Datadog Logs API to investigate production issues for the Nexu platform. - -## Authentication - -**Before making any Datadog API call, you MUST ask the user for these two keys:** - -- `DD_API_KEY` — Datadog API Key (Organization Settings → API Keys) -- `DD_APP_KEY` — Datadog Application Key (Organization Settings → Application Keys, requires `logs_read_data` scope) - -Store them in shell variables for the session. Never hardcode or commit them. - -Site: `datadoghq.com` (US) - -## API Base - -All requests go to `https://api.datadoghq.com/api/v2/logs/events/search`. - -Headers: -``` -DD-API-KEY: <api_key> -DD-APPLICATION-KEY: <app_key> -Content-Type: application/json -``` - -## Common Queries - -### OpenClaw Crash Events - -```bash -curl -s "https://api.datadoghq.com/api/v2/logs/events/search" \ - -H "DD-API-KEY: $DD_API_KEY" \ - -H "DD-APPLICATION-KEY: $DD_APP_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "filter": { - "query": "service:nexu-gateway @event:openclaw_crash", - "from": "now-1h", - "to": "now" - }, - "sort": "-timestamp", - "page": {"limit": 20} - }' -``` - -Key fields in results: -- `attributes.attributes.exitCode` — process exit code (1 = fatal error, null = signal) -- `attributes.attributes.signal` — kill signal (SIGKILL, SIGTERM, etc.) -- `attributes.tags` → `pod_name`, `image_tag` — which pod and which version - -### OpenClaw stderr Output (Crash Details) - -```bash -curl -s "https://api.datadoghq.com/api/v2/logs/events/search" \ - -H "DD-API-KEY: $DD_API_KEY" \ - -H "DD-APPLICATION-KEY: $DD_APP_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "filter": { - "query": "service:nexu-gateway @stream:stderr", - "from": "now-1h", - "to": "now" - }, - "sort": "-timestamp", - "page": {"limit": 50} - }' -``` - -This shows the actual error output from the OpenClaw process (e.g., `invalid_auth`, `EADDRINUSE`, config validation failures). - -### Gateway Startup / Recovery Events - -```bash -curl -s "https://api.datadoghq.com/api/v2/logs/events/search" \ - -H "DD-API-KEY: $DD_API_KEY" \ - -H "DD-APPLICATION-KEY: $DD_APP_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "filter": { - "query": "service:nexu-gateway (\"starting gateway\" OR \"gateway is ready\" OR \"spawned openclaw\")", - "from": "now-1h", - "to": "now" - }, - "sort": "timestamp", - "page": {"limit": 30} - }' -``` - -### Slack Token Health Check - -```bash -curl -s "https://api.datadoghq.com/api/v2/logs/events/search" \ - -H "DD-API-KEY: $DD_API_KEY" \ - -H "DD-APPLICATION-KEY: $DD_APP_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "filter": { - "query": "service:nexu-api slack_token_health*", - "from": "now-1h", - "to": "now" - }, - "sort": "-timestamp", - "page": {"limit": 20} - }' -``` - -### API HTTP Request Logs - -```bash -curl -s "https://api.datadoghq.com/api/v2/logs/events/search" \ - -H "DD-API-KEY: $DD_API_KEY" \ - -H "DD-APPLICATION-KEY: $DD_APP_KEY" \ - -H "Content-Type: application/json" \ - -d '{ - "filter": { - "query": "service:nexu-api http_request @attributes.status:>=500", - "from": "now-1h", - "to": "now" - }, - "sort": "-timestamp", - "page": {"limit": 20} - }' -``` - -### Filter by Pod - -Add `pod_name:<name>` to the query: - -``` -service:nexu-gateway pod_name:nexu-gateway-1 @event:openclaw_crash -``` - -### Filter by Time Window - -Use ISO 8601 timestamps: - -```json -{ - "from": "2026-03-10T05:00:00Z", - "to": "2026-03-10T06:00:00Z" -} -``` - -Or relative: `"now-30m"`, `"now-1h"`, `"now-24h"`. - -## Parsing Results - -Use python3 inline to extract key fields: - -```bash -curl -s ... | python3 -c " -import json, sys -data = json.load(sys.stdin) -events = data.get('data', []) -print(f'Total events: {len(events)}') -for e in events: - attrs = e['attributes']['attributes'] - tags = e['attributes']['tags'] - pod = next((t.split(':',1)[1] for t in tags if t.startswith('pod_name:')), '?') - ts = attrs.get('time', '?') - msg = e['attributes'].get('message', '')[:120] - print(f'{ts} | pod={pod} | {msg}') -" -``` - -## Services and Events Reference - -| Service | Description | -|---------|-------------| -| `nexu-gateway` | Gateway sidecar (manages OpenClaw process) | -| `nexu-api` | API server | - -| Event | Meaning | -|-------|---------| -| `openclaw_crash` | OpenClaw process exited unexpectedly | -| `openclaw_restart_scheduled` | Sidecar scheduling a restart | -| `openclaw_restart_limit` | Max restart attempts exceeded | -| `openclaw_orphan_killed` | Killed zombie OpenClaw process | -| `slack_token_health_check_invalidated` | Invalid Slack tokens detected and marked | - -## Tag Reference - -| Tag | Example | -|-----|---------| -| `pod_name` | `nexu-gateway-1`, `nexu-gateway-2` | -| `image_tag` | `sha-55f13372bb72abc7db1538cca3db2bcda0d35eba` | -| `kube_stateful_set` | `nexu-gateway` | - -## Investigation Playbook - -When investigating a crash: - -1. **Check crash events** — get exit codes, signals, timestamps, affected pods -2. **Check stderr** — get the actual error message from OpenClaw -3. **Check startup events** — correlate crash with deploy times (`image_tag` changes) -4. **Check token health** — if `invalid_auth`, look for `slack_token_health_check_invalidated` -5. **Check API logs** — if API errors are contributing - -## Rules - -1. **Never hardcode API keys** in skill files or logs — always use variables -2. **Default time window** — start with `now-1h`, expand to `now-24h` if needed -3. **Always parse and summarize** — don't dump raw JSON to the user -4. **Correlate across services** — crashes often involve both gateway and API logs -5. **Check image_tag** to determine if crashes are related to a specific deployment diff --git a/skills/localdev/nexu-e2e-test/SKILL.md b/skills/localdev/nexu-e2e-test/SKILL.md deleted file mode 100644 index e710b4300..000000000 --- a/skills/localdev/nexu-e2e-test/SKILL.md +++ /dev/null @@ -1,146 +0,0 @@ ---- -name: nexu-e2e-test -description: Use when verifying OpenClaw gateway fixes end-to-end, testing skill loading after restart, or running integration tests against the local Nexu+OpenClaw stack. Triggers on "e2e test", "verify fix", "test gateway", "test skills loading". ---- - -# Nexu E2E Testing — OpenClaw Gateway - -Run end-to-end verification of the Nexu → OpenClaw gateway stack locally. - -## Known Constraints - -The OpenClaw gateway has architectural constraints that block naive E2E approaches. **Read this before attempting any gateway testing.** - -| Approach | Blocker | Status | -|----------|---------|--------| -| HTTP `POST /v1/chat/completions` | Endpoint exists (`openai-http.ts`) but uses `agentCommand()` — same embedded agent path as CLI, bypasses session store | Works for smoke tests (same as CLI) | -| HTTP `POST /v1/responses` | Endpoint exists (`openresponses-http.ts`) but also uses `agentCommand()` | Works for smoke tests (same as CLI) | -| WebSocket `chat.send` | Requires device pairing for `operator.write` scope — `clearUnboundScopes()` in `message-handler.ts:483-488` clears all self-declared scopes without device identity | Dead end without device keys | -| `openclaw agent --session-id` | Uses embedded agent path (`sessionKey=unknown`), bypasses session store cache | Works for smoke tests, NOT for session-store bugs | -| Direct module import from dist | Rollup bundles with hashed filenames, can't import individual modules | Dead end | -| `tsx` from `/tmp` | Module resolution fails for imports outside project root | Dead end | -| Vitest in-project `.test.ts` | Full module resolution, mocking, TypeScript support | **Primary method** | - -**Key insight:** All HTTP and CLI approaches use the embedded agent path via `agentCommand()` (imported from `commands/agent.js`). Only messages arriving through connected channels (Slack/Discord) go through `dispatchInboundMessage()` → auto-reply pipeline → `ensureSkillSnapshot()`, which is the code path that uses the session store. - -## Viable Test Methods - -### 1. Vitest Unit/Integration Tests (Primary) - -Write `.test.ts` files **inside the OpenClaw worktree** and run with vitest. This is the only reliable way to test internal functions like `ensureSkillsWatcher`, `getSkillsSnapshotVersion`, `ensureSkillSnapshot`. - -```bash -cd <OPENCLAW_WORKTREE> -OPENCLAW_TEST_FAST=1 npx vitest run src/agents/skills/refresh.test.ts -``` - -Key patterns: -- Mock `chokidar` with `vi.mock("chokidar", ...)` -- Use `await import("./refresh.js")` for dynamic imports after mocking -- Use unique workspace paths per test (`/tmp/test-<name>-${Date.now()}`) -- `OPENCLAW_TEST_FAST=1` skips filesystem scanning in session-updates - -### 2. `openclaw agent` CLI or HTTP Smoke Tests - -For "do skills load after restart" verification. Does NOT test session-store caching logic (all use embedded agent path). - -**CLI approach:** -```bash -# Prerequisites: gateway must be running with a valid workspace -OPENCLAW_STATE_DIR=~/.openclaw \ -OPENCLAW_CONFIG_PATH=<config-with-local-workspace> \ -openclaw agent --session-id "<session>" --message "<msg>" --json --timeout 60 -``` - -**HTTP approach (OpenAI-compatible):** -```bash -curl -s http://localhost:18789/v1/chat/completions \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer <gw-token>" \ - -d '{"model":"default","messages":[{"role":"user","content":"list your skills"}]}' -``` - -Parse results (CLI JSON output): -```bash -| python3 -c " -import json,sys -d=json.load(sys.stdin) -entries=d.get('result',{}).get('meta',{}).get('systemPromptReport',{}).get('skills',{}).get('entries',[]) -managed=[e['name'] for e in entries if e['name'] in ('static-deploy','test-greeting')] -print(f'Skills: {len(entries)}, Managed: {managed}') -" -``` - -### 3. Connected Channel (Slack/Discord) - -The only way to test the full auto-reply pipeline with session-store caching. Requires a running channel with active bot. - -## Gateway Setup for Testing - -### Config Pitfall: `/data/` workspace path - -Production configs have `"workspace": "/data/openclaw/workspaces/..."` which doesn't exist locally. Create a test config: - -```bash -cat ~/.openclaw/openclaw.json | python3 -c " -import json, sys -cfg = json.load(sys.stdin) -cfg['agents']['list'][0]['workspace'] = '/tmp/openclaw-test-workspace' -json.dump(cfg, sys.stdout, indent=2) -" > /tmp/openclaw-test-config.json -``` - -### Start gateway with test config - -```bash -OPENCLAW_STATE_DIR=~/.openclaw \ -OPENCLAW_CONFIG_PATH=/tmp/openclaw-test-config.json \ -openclaw gateway run --allow-unconfigured --bind loopback --port 18789 --force --verbose -``` - -### Wait for port readiness (not just process start) - -```bash -for i in $(seq 1 15); do - lsof -i :18789 -P 2>/dev/null | grep -q LISTEN && break - sleep 1 -done -``` - -## WebSocket Protocol Reference - -If you ever need to attempt WS testing (e.g., after implementing device pairing): - -| Detail | Value | -|--------|-------| -| Frame format | `{"type": "req", "id": "<uuid>", "method": "...", "params": {...}}` (NOT JSON-RPC) | -| Protocol version | `3` (as of 2026.2.25) | -| Valid client IDs | `gateway-client`, `cli`, `webchat-ui`, `openclaw-control-ui`, `node-host`, `test`, `webchat`, `fingerprint`, `openclaw-probe`, `openclaw-macos`, `openclaw-ios`, `openclaw-android` (defined in `protocol/client-info.ts`) | -| Valid client modes | `webchat`, `cli`, `ui`, `backend`, `node`, `probe`, `test` | -| Auth flow | Challenge → `connect` RPC with nonce → needs device identity for write scopes | -| Scope for `chat.send` | `operator.write` — requires device pairing, token-only auth gets zero scopes | -| Scope exception | `controlUiAuthPolicy.allowBypass: true` preserves scopes without device identity (dev/control-UI only, see `message-handler.ts:489-542`) | - -## Restart Verification Checklist - -When verifying a fix that involves skills/sessions after gateway restart: - -1. **Build the fix:** `cd <worktree> && pnpm build` -2. **Link globally:** `cd <worktree> && pnpm link --global` -3. **Verify version:** `openclaw --version` -4. **Write unit tests** in the worktree for core logic (vitest) -5. **Run smoke test:** gateway restart → `openclaw agent` → check skill count -6. **Check sessions.json** at `~/.openclaw/agents/<agent>/sessions/sessions.json` for snapshot versions -7. **Check logs** at gateway stdout for skill watcher events - -## Session Store Locations - -``` -~/.openclaw/agents/<agent-id>/sessions/sessions.json # Session entries with skillsSnapshot -~/.openclaw/agents/<agent-id>/sessions/<session-id>.jsonl # Session transcript -``` - -Key fields in session entry: -- `skillsSnapshot.version` — timestamp, should be non-zero after fix -- `skillsSnapshot.skills[]` — array of loaded skill names and locations -- `skillsSnapshot.prompt` — the `<available_skills>` XML injected into system prompt diff --git a/skills/localdev/sync-specs/SKILL.md b/skills/localdev/sync-specs/SKILL.md deleted file mode 100644 index 52ade9f01..000000000 --- a/skills/localdev/sync-specs/SKILL.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -name: sync-specs -description: Use when code changes may have made documentation outdated, when reviewing docs for consistency, or when the user asks to sync or audit documentation. ---- - -# Documentation Sync - -Review code changes and update project documentation for consistency. - -## Mode - -| Mode | How to activate | Behavior | -|------|----------------|----------| -| `delta` (default) | No argument, or say "delta" | Diff against merge-base with `origin/main` + working tree changes | -| `full` | Say "full audit" or "full sync" | Complete audit of all docs against current codebase | -| Scope keyword | Say the keyword (e.g. "db", "api") | Targeted check (see Scope Filters below) | - -## Delta Mode Baseline - -Identify changed files using merge-base (not a fixed commit count): - -```bash -# Branch changes since diverging from main -git diff --name-only $(git merge-base HEAD origin/main)...HEAD -# Plus staged + unstaged -git diff --name-only --cached -git diff --name-only -``` - -Combine the results into a single list of changed files. Then use the Impact Mapping to identify which docs may need updates. - -## Impact Mapping - -Map changed areas to the docs they affect: - -| Changed area | Affected docs | -|---|---| -| `apps/controller/src/routes/` | `specs/references/api-patterns.md`, `ARCHITECTURE.md`, `specs/product-specs/*.md` (if route is user-facing) | -| `apps/web/src/pages/` or `apps/web/src/app.tsx` | `specs/FRONTEND.md` | -| `apps/landing/` | `ARCHITECTURE.md` (Monorepo layout) | -| `apps/controller/src/runtime/` | `ARCHITECTURE.md`, `specs/RELIABILITY.md` | -| `packages/shared/src/schemas/` | `ARCHITECTURE.md` (Type safety) | -| `package.json` scripts | `CLAUDE.md` + `AGENTS.md` Commands sections | -| New apps/packages dirs | `ARCHITECTURE.md` (Monorepo layout) | -| Config generator | `specs/references/openclaw-config-schema.md`, `specs/openclaw-config-reference.md` | -| Auth changes | `specs/SECURITY.md` | -| New/moved doc files | `CLAUDE.md` Doc Map, `AGENTS.md` Where to look, relevant index files | - -## Cross-Reference Pairs - -Always verify consistency between these paired docs: - -1. `CLAUDE.md` Commands section <-> `AGENTS.md` Commands section (same entries) -2. `CLAUDE.md` Documentation Map paths <-> actual files on disk -3. `CLAUDE.md` Hard Rules <-> `AGENTS.md` Hard rules -4. `ARCHITECTURE.md` monorepo layout <-> actual `apps/` + `packages/` dirs -5. `specs/DESIGN.md` table <-> actual `specs/design-specs/` + `specs/designs/` contents -6. `specs/design-specs/index.md` table <-> actual design files -7. `specs/product-specs/index.md` table <-> actual `specs/product-specs/*.md` files -8. `specs/PLANS.md` table <-> `specs/exec-plans/{active,completed}/` contents -9. `specs/FRONTEND.md` Pages table <-> `apps/web/src/app.tsx` routes - -## Scope Filters - -When the user specifies a scope keyword, limit the check to that area: - -| Keyword | What it checks | -|---|---| -| `db` | Schema source vs `specs/generated/db-schema.md` | -| `api` | Route files vs `specs/references/api-patterns.md` | -| `frontend` | `apps/web/` vs `specs/FRONTEND.md` | -| `commands` | `package.json` scripts vs `CLAUDE.md`/`AGENTS.md` Commands sections | -| `architecture` | All `apps/` + `packages/` vs `ARCHITECTURE.md` layout | -| `security` | Auth/crypto code vs `specs/SECURITY.md` | -| `links` | Verify all doc map paths and index references resolve to existing files | -| `guides` | `specs/guides/**` internal cross-references | -| `designs` | `specs/designs/**` + `specs/design-specs/**` vs index files | -| `exec-plans` | `specs/exec-plans/**` vs `specs/PLANS.md` | -| `product-specs` | `specs/product-specs/**` vs index + `specs/PRODUCT_SENSE.md` | - -## Rules - -1. **Never remove forward-looking documentation** — ask if uncertain whether content is aspirational or stale. -2. **Preserve original language** (English/Chinese) and writing style of existing docs. -3. For backend API updates, treat `apps/controller` as the source of truth; do not reference removed legacy package paths. -4. **Always verify `CLAUDE.md` <-> `AGENTS.md` consistency** after any update to either file. -5. **Do NOT auto-commit** — present the diff summary and let the user decide when to commit. - -## Workflow - -1. Determine mode from user request (default: delta). -2. If delta mode: run the git diff commands above, collect changed files. -3. Map changed files to affected docs using the Impact Mapping. -4. Read each affected doc and compare against current code. -5. Check all Cross-Reference Pairs for consistency. -6. Present findings: what's outdated, what's missing, what's inconsistent. -7. Apply fixes with user approval. -8. After fixes, re-verify Cross-Reference Pairs touched by changes. diff --git a/skills/localdev/sync-specs/check-doc-drift.sh b/skills/localdev/sync-specs/check-doc-drift.sh deleted file mode 100755 index a7fda4abf..000000000 --- a/skills/localdev/sync-specs/check-doc-drift.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Get changed files (branch + working tree) -merge_base=$(git merge-base HEAD origin/main 2>/dev/null) || exit 0 -changed=$(git diff --name-only "$merge_base"...HEAD 2>/dev/null; git diff --name-only --cached 2>/dev/null; git diff --name-only 2>/dev/null) -[ -z "$changed" ] && exit 0 - -# Check for code areas that affect docs -affected=() -echo "$changed" | grep -q "apps/controller/src/routes/" && affected+=("specs/references/api-patterns.md") -echo "$changed" | grep -q "apps/web/src/" && affected+=("specs/FRONTEND.md") -echo "$changed" | grep -q "apps/controller/src/runtime/" && affected+=("specs/RELIABILITY.md, ARCHITECTURE.md") -echo "$changed" | grep -q "package.json" && affected+=("CLAUDE.md + AGENTS.md Commands") -echo "$changed" | grep -q "apps/controller/src/lib/openclaw-config-compiler" && affected+=("specs/references/openclaw-config-schema.md") -echo "$changed" | grep -q "apps/controller/src/auth\|apps/controller/src/routes" && affected+=("specs/SECURITY.md") - -[ ${#affected[@]} -eq 0 ] && exit 0 - -echo "Documentation may need updating. Changed code affects:" -for doc in "${affected[@]}"; do - echo " - $doc" -done -echo "Run the sync-specs skill or /sync-specs to review." diff --git a/skills/nexubot/feedback/SKILL.md b/skills/nexubot/feedback/SKILL.md deleted file mode 100644 index bb71a7636..000000000 --- a/skills/nexubot/feedback/SKILL.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -name: feedback -description: Send feedback to the Nexu team. Use when the user says /feedback followed by their message. ---- - -# Feedback - -Collect user feedback and forward it to the Nexu team. Conversation history and images are extracted automatically by a script — you do NOT need to copy-paste messages or scan for image URLs. - -## When triggered - -The user sends `/feedback <message>` to share feedback, report issues, or make suggestions about the Nexu platform. - -## Steps - -1. **Extract feedback content**: The text after `/feedback` is the user's feedback. If empty, ask the user to provide their feedback. - -2. **Gather identifiers**: - - **agentId**: Your own agent ID. Find it from the `Runtime:` line in your system prompt — it appears as `agent=XXXXXX`. It is a cuid2 string like `y9cnvdlucvyaokp20mqrsoa9`. Do NOT use "main" or other placeholder values. - - **channel**: The current channel type — one of `feishu`, `slack`, or `discord`. - - **sender**: The sender's display name or username as shown in the conversation. If you only have a user ID, use that. Do NOT use generic "user" or "User". - -3. **Run the submit script**: Use the exec tool to run the following command. The script automatically reads your conversation history and any images from the session file — you do NOT need to provide them manually. - -```bash -SKILL_PATH="<SKILL_LOCATION>" -node "$(dirname "${SKILL_PATH/#\~/$HOME}")/submit-feedback.mjs" \ - --content "<ESCAPED_FEEDBACK>" \ - --sender "<SENDER>" \ - --channel "<CHANNEL_TYPE>" \ - --agent-id "<AGENT_ID>" -``` - -Important: -- Replace `<SKILL_LOCATION>` with the exact path from the `<location>` tag in your system prompt (may contain `~`) -- The `${SKILL_PATH/#\~/$HOME}` expansion handles tilde (`~`) → absolute path conversion automatically -- Replace ALL other `<...>` placeholders with actual values BEFORE running the command -- Properly escape shell special characters in the feedback content (single quotes → `'\''`, etc.) - -4. **Confirm to user**: - - If the output contains `{"ok":true}`, reply: "Thanks for your feedback! It has been forwarded to the Nexu team." - - If it fails, reply: "Sorry, there was an issue sending your feedback. Please try again later." - -## Important - -- Do NOT modify, filter, or censor the user's feedback content. Forward it as-is. -- Do NOT ask for confirmation before sending — the user already expressed intent by using /feedback. -- Do NOT manually build conversationContext or imageUrls — the script handles this automatically. -- The API will automatically look up the bot owner's email and bot name from the agentId, so focus on getting the agentId right. diff --git a/skills/nexubot/feedback/submit-feedback.mjs b/skills/nexubot/feedback/submit-feedback.mjs deleted file mode 100644 index 5b2c0f66a..000000000 --- a/skills/nexubot/feedback/submit-feedback.mjs +++ /dev/null @@ -1,495 +0,0 @@ -#!/usr/bin/env node -// submit-feedback.mjs — Read session JSONL, extract messages + images, POST to feedback API. -// Called by the bot via exec: node <this_file> --content "..." --sender "..." --channel "..." --agent-id "..." - -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -// --------------------------------------------------------------------------- -// CLI args -// --------------------------------------------------------------------------- -const args = process.argv.slice(2); - -function getArg(name) { - const idx = args.indexOf(`--${name}`); - return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : undefined; -} - -const content = getArg("content"); -const sender = getArg("sender") || "unknown"; -const channel = getArg("channel") || "unknown"; -const agentId = getArg("agent-id"); - -if (!content) { - console.log(JSON.stringify({ ok: false, error: "missing --content" })); - process.exit(1); -} - -const apiBase = process.env.RUNTIME_API_BASE_URL || "http://localhost:3000"; -const token = - process.env.SKILL_API_TOKEN || - process.env.INTERNAL_API_TOKEN || - "gw-secret-token"; - -const MAX_MESSAGES = 10; -const MAX_IMAGES = 5; -const MAX_FILES = 5; -const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10 MB -const MAX_FILE_BYTES = 30 * 1024 * 1024; // 30 MB - -const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp"]); - -// --------------------------------------------------------------------------- -// Find session JSONL -// --------------------------------------------------------------------------- -function findSessionFile() { - if (!agentId) return null; - - // Derive state dir from script location: .openclaw/skills/feedback/submit-feedback.mjs → .openclaw/ - const __filename = fileURLToPath(import.meta.url); - const __dirname = dirname(__filename); - const resolved = resolve(__dirname, "..", ".."); - const indexPath = join( - resolved, - "agents", - agentId, - "sessions", - "sessions.json", - ); - - if (!existsSync(indexPath)) return null; - - try { - const index = JSON.parse(readFileSync(indexPath, "utf8")); - let best = null; - let bestTime = 0; - for (const session of Object.values(index)) { - if (session.updatedAt > bestTime) { - bestTime = session.updatedAt; - best = session; - } - } - return best?.sessionFile ?? null; - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// Parse JSONL → messages + image paths -// --------------------------------------------------------------------------- - -const MAX_ASSISTANT_MSG_LEN = 200; -const MAX_USER_MSG_LEN = 300; - -// Whitelist regex: only System messages with "from <sender>:" are real user messages -const ENVELOPE_RE = /^System:\s*\[[^\]]*\]\s+.*?\bfrom\s+[^:]+:\s*([\s\S]+)$/; - -/** - * Clean assistant text: strip code blocks, long paths, JSON blobs, etc. - * Keep only the conversational substance. - */ -function cleanAssistantText(input) { - let text = input; - // Replace fenced code blocks with a short placeholder - text = text.replace(/```[\s\S]*?```/g, "[代码片段]"); - - // Replace inline code that looks like long paths (>40 chars) - text = text.replace(/`[^`]{40,}`/g, "[...]"); - - // Strip lines that are mostly a file path (e.g. /data/openclaw/..., /Users/..., ./src/...) - text = text.replace(/^.*(?:\/[\w._-]+){4,}.*$/gm, ""); - - // Strip lines that look like JSON objects/arrays (starts with { or [, >60 chars) - text = text.replace(/^\s*[{[][\s\S]{60,}$/gm, ""); - - // Strip tool-use markers that OpenClaw sometimes injects - text = text.replace( - /\[(?:tool_use|tool_result|function_call|exec)[^\]]*\]\n?/gi, - "", - ); - - // Strip inline JSON fragments - text = text.replace(/\{"[^"]*":\s*"[^"]*"(?:,\s*"[^"]*":\s*"[^"]*")*\}/g, ""); - - // Strip numbered/bulleted list noise (long instructional lists) - text = text.replace(/^\d+\.\s+.{0,20}(联系|通过|直接|建议|或者).*$/gm, ""); - - // Strip lines that look like internal skill/path references (require path-like patterns) - text = text.replace(/^.*(?:\/[\w._-]+){2,}.*(?:skill|snapshot).*$/gim, ""); - - // Collapse multiple blank lines into one - text = text.replace(/\n{3,}/g, "\n\n"); - - text = text.trim(); - - // Truncate if still too long - if (text.length > MAX_ASSISTANT_MSG_LEN) { - text = `${text.slice(0, MAX_ASSISTANT_MSG_LEN)}…`; - } - - return text; -} - -function parseSession(filePath) { - const messages = []; - const imagePaths = []; - const filePaths = []; - - const lines = readFileSync(filePath, "utf8").split("\n").filter(Boolean); - - for (const line of lines) { - let entry; - try { - entry = JSON.parse(line); - } catch { - continue; - } - if (entry.type !== "message") continue; - const msg = entry.message; - if (!msg?.content) continue; - - if (msg.role === "user" || msg.role === "assistant") { - for (const block of msg.content) { - if (block.type !== "text") continue; - - let text = block.text; - - // --- Whitelist mode for user messages --- - if (msg.role === "user") { - // Strip MEDIA instruction prefix (OpenClaw injects this before the real content) - text = text.replace( - /^,?\s*prefer the message tool[\s\S]*?Keep caption in the text body\.\n?/i, - "", - ); - text = text.trim(); - - if (text.startsWith("System:")) { - const envelopeMatch = text.match(ENVELOPE_RE); - if (envelopeMatch) { - text = envelopeMatch[1]; // extract the actual message content - } else { - // Fallback: strip known system-noise prefixes but keep the rest - // Skip exec output, tool results, and pure system notifications - if ( - /^System:\s*\[.*?\]\s*(?:exec|tool_result|notification)\b/i.test( - text, - ) - ) - continue; - // Strip the "System: [timestamp] ..." prefix and keep remaining content - text = text.replace(/^System:\s*\[[^\]]*\]\s*/, ""); - } - } - - // Skip /feedback command invocations (not real conversation) - if (/^\/feedback\b/i.test(text)) continue; - - // Skip small pure JSON payloads (Feishu media keys, file keys, etc.) - // Keep longer JSON that might be user-sent config/data - if (/^\s*[\[{][\s\S]*[\]}]\s*$/.test(text) && text.length < 200) - continue; - - // Strip platform echo lines: [Feishu ...] sender: ... or [Slack ...] ... - text = text.replace( - /^\[(?:Feishu|Slack|Discord)\s[^\]]*\][^\n]*\n?/gm, - "", - ); - - // Extract media paths before cleaning — classify as image or file - // Format: [media attached: /path (mime)] - text = text.replace( - /\[media attached: ([^\s]+) \(([^)]+)\)[^\]]*\]\n?/g, - (_match, path, mime) => { - const ext = path.split(".").pop()?.toLowerCase() || ""; - if (IMAGE_EXTENSIONS.has(ext) || mime.startsWith("image/")) { - if (imagePaths.length < MAX_IMAGES) imagePaths.push(path); - } else { - if (filePaths.length < MAX_FILES) filePaths.push(path); - } - return ""; - }, - ); - // Format: [media attached N/M: /path (mime) | /path] - text = text.replace( - /\[media attached \d+\/\d+: ([^\s]+) \(([^)]+)\)[^\]]*\]\n?/g, - (_match, path, mime) => { - const ext = path.split(".").pop()?.toLowerCase() || ""; - if (IMAGE_EXTENSIONS.has(ext) || mime.startsWith("image/")) { - if (imagePaths.length < MAX_IMAGES) imagePaths.push(path); - } else { - if (filePaths.length < MAX_FILES) filePaths.push(path); - } - return ""; - }, - ); - // Strip "[media attached: N files]" header - text = text.replace(/\[media attached: \d+ files?\]\n?/g, ""); - - // Extract Slack/Discord image URLs: [image: https://...] - text = text.replace( - /\[image: (https?:\/\/[^\s\]]+)\]\n?/g, - (_match, url) => { - if (imagePaths.length < MAX_IMAGES) imagePaths.push(url); - return ""; - }, - ); - - // Strip injected metadata blocks from message body - text = text.replace( - /Conversation info \(untrusted metadata\):[\s\S]*?```\n?/g, - "", - ); - text = text.replace( - /Sender \(untrusted metadata\):[\s\S]*?```\n?/g, - "", - ); - text = text.replace( - /Untrusted context[\s\S]*?<<<END_EXTERNAL_UNTRUSTED_CONTENT[^>]*>>>\n?/g, - "", - ); - text = text.replace(/\[message_id: [^\]]+\]\n?/g, ""); - text = text.replace(/<@[A-Z0-9]+>/g, ""); - text = text.replace(/,?\s*prefer the message tool[^\n]*/i, ""); - - // Extract useful content from "Replied message" JSON blocks - text = text.replace( - /Replied message \(untrusted, for context\):\s*```json\s*([\s\S]*?)```\n?/g, - (_match, json) => { - try { - const obj = JSON.parse(json); - const body = obj.body?.trim(); - return body ? `↩️ ${body}\n` : ""; - } catch { - return ""; - } - }, - ); - - // Extract useful content from "Chat history" JSON blocks - text = text.replace( - /Chat history since last reply \(untrusted, for context\):\s*```json\s*([\s\S]*?)```\n?/g, - (_match, json) => { - try { - const arr = JSON.parse(json); - if (!Array.isArray(arr) || arr.length === 0) return ""; - const lines = arr - .slice(-3) // keep last 3 messages max - .map((m) => m.body?.trim()) - .filter(Boolean); - return lines.length > 0 ? `💬 ${lines.join(" → ")}\n` : ""; - } catch { - return ""; - } - }, - ); - - // Strip [System: ...] lines (Feishu mention hints etc.) - text = text.replace(/\[System: [^\]]*\]\n?/g, ""); - - // Strip MEDIA instruction block injected by OpenClaw - text = text.replace( - /MEDIA:https?:\/\/\S+[\s\S]*?Keep caption in the text body\.\n?/g, - "", - ); - - // Strip Slack-specific noise - text = text.replace(/\[Slack file: [^\]]*\]\n?/g, ""); - - // Strip Feishu at-mention tags: <at user_id="...">name</at> → name - text = text.replace(/<at user_id="[^"]*">([^<]*)<\/at>/g, "$1"); - - text = text.replace(/To send an image back.*?\n?/g, ""); - - // Truncate very long text (e.g. extracted documents, PDFs) instead of dropping - const lineCount = text.split("\n").filter(Boolean).length; - if (lineCount > 15 && text.length > 500) { - const truncatedLines = text.split("\n").filter(Boolean).slice(0, 5); - text = `${truncatedLines.join("\n")}…[长文本已截断]`; - } - - // Strip remaining JSON-like fragments inline - text = text.replace( - /\{"[^"]*":\s*"[^"]*"(?:,\s*"[^"]*":\s*"[^"]*")*\}/g, - "", - ); - - text = text.trim(); - - // Skip empty or trivially short after cleaning - if (text.length < 2) continue; - - // Truncate user messages - if (text.length > MAX_USER_MSG_LEN) { - text = `${text.slice(0, MAX_USER_MSG_LEN)}…`; - } - } - - // Clean assistant messages to remove technical noise - if (msg.role === "assistant") { - text = text.replace(/To send an image back.*?\n?/g, ""); - text = cleanAssistantText(text); - } - - if (!text) continue; - - // Skip bot messages about feedback skill itself (narrow match) - if ( - msg.role === "assistant" && - /(?:feedback.*skill|skill.*feedback|授权问题|已经.*反馈.*发送)/i.test( - text, - ) - ) { - continue; - } - - const prefix = msg.role === "user" ? "👤" : "🤖"; - const entry = `${prefix} ${text}`; - - // Deduplicate: skip if identical to last message - if (messages.length > 0 && messages[messages.length - 1] === entry) - continue; - - messages.push(entry); - } - } - } - - return { - conversationContext: messages.slice(-MAX_MESSAGES).join("\n"), - imagePaths, - filePaths, - }; -} - -// --------------------------------------------------------------------------- -// Read images → base64 -// --------------------------------------------------------------------------- -function readImages(paths) { - const imageData = []; - const seen = new Set(); - - for (const p of paths) { - if (seen.has(p) || imageData.length >= MAX_IMAGES) break; - seen.add(p); - - if (!existsSync(p)) continue; - - try { - const buf = readFileSync(p); - if (buf.length > MAX_IMAGE_BYTES) continue; - - // Guess mimeType from extension - const ext = p.split(".").pop()?.toLowerCase() || "png"; - const mimeMap = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - }; - const mimeType = mimeMap[ext] || "image/png"; - - imageData.push({ data: buf.toString("base64"), mimeType }); - } catch { - // skip unreadable - } - } - - return imageData; -} - -// --------------------------------------------------------------------------- -// Read files → base64 -// --------------------------------------------------------------------------- -function readFiles(paths) { - const fileData = []; - const seen = new Set(); - - for (const p of paths) { - if (seen.has(p) || fileData.length >= MAX_FILES) break; - seen.add(p); - - if (!existsSync(p)) continue; - - try { - const buf = readFileSync(p); - if (buf.length > MAX_FILE_BYTES) continue; - - // Extract filename: OpenClaw uses "originalName---uuid.ext" format - const basename = p.split("/").pop() || "file"; - // Remove the UUID suffix if present (e.g. "doc---uuid.pdf" → "doc.pdf") - const fileName = basename.replace(/---[a-f0-9-]+(?=\.\w+$)/, ""); - - fileData.push({ data: buf.toString("base64"), fileName }); - } catch { - // skip unreadable - } - } - - return fileData; -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- -let conversationContext = ""; -let imageData = []; -let fileData = []; -const imageUrls = []; - -const sessionFile = findSessionFile(); -if (sessionFile && existsSync(sessionFile)) { - const parsed = parseSession(sessionFile); - conversationContext = parsed.conversationContext; - - // Split image paths into local files and remote URLs - const localPaths = []; - for (const p of parsed.imagePaths) { - if (p.startsWith("http://") || p.startsWith("https://")) { - imageUrls.push(p); - } else { - localPaths.push(p); - } - } - - imageData = readImages(localPaths); - fileData = readFiles(parsed.filePaths); -} - -const payload = { - content, - sender, - channel, - agentId, - conversationContext: conversationContext.slice(0, 10000), -}; - -if (imageData.length > 0) { - payload.imageData = imageData; -} - -if (imageUrls.length > 0) { - payload.imageUrls = imageUrls; -} - -if (fileData.length > 0) { - payload.fileData = fileData; -} - -try { - const resp = await fetch(`${apiBase}/api/internal/feedback`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-internal-token": token, - }, - body: JSON.stringify(payload), - }); - const data = await resp.json(); - console.log(JSON.stringify(data)); -} catch (err) { - console.log(JSON.stringify({ ok: false, error: err.message })); - process.exit(1); -} diff --git a/skills/nexubot/nano-banana/SKILL.md b/skills/nexubot/nano-banana/SKILL.md deleted file mode 100644 index 77271e007..000000000 --- a/skills/nexubot/nano-banana/SKILL.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -name: nano-banana -description: Generate or edit images via Nano Banana image models. Triggers on "generate image", "image generation", "nano banana", "edit image", "nano banana pro", "nano banana 2" -homepage: https://ai.google.dev/ -metadata: - { - "openclaw": - { - "emoji": "🍌", - "requires": { "bins": ["node"] }, - }, - } ---- - -# Nano Banana — Image Generation - -Image generation script supporting three models. Requires `sharp` for input image compression (auto-installed on first run). - -## Models - -| Flag | Notes | -|------|-------| -| `--model nano-banana` | **Default.** Fast, good quality. | -| `--model nano-banana-pro` | Highest quality, slower. | -| `--model nano-banana-2` | Legacy model. | - -## Generate an image - -```bash -node {baseDir}/scripts/generate-image.js --prompt "a cat sitting on mars" --filename "cat-on-mars.png" -``` - -## Edit a single image - -```bash -node {baseDir}/scripts/generate-image.js \ - --prompt "make the sky purple" \ - --filename "edited.png" \ - -i "/path/to/input.png" \ - --model nano-banana-pro -``` - -## Multi-image composition (up to 14 images) - -```bash -node {baseDir}/scripts/generate-image.js \ - --prompt "combine these into a collage" \ - --filename "collage.png" \ - -i img1.png -i img2.png -i img3.png -``` - -## Options - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--prompt` | `-p` | required | Image description or editing instruction | -| `--filename` | `-f` | required | Output filename | -| `--input-image` | `-i` | — | Input image(s), repeatable, max 14 | -| `--model` | — | `nano-banana` | `nano-banana`, `nano-banana-pro`, or `nano-banana-2` | -| `--resolution` | `-r` | `1K` | `1K`, `2K`, or `4K` | -| `--aspect-ratio` | — | — | e.g. `1:1`, `16:9`, `4:3`, `3:4`, `9:16` | - -## API key - -The API key is pre-configured on this machine. No flags or environment variables needed. - -## Input image handling - -All input images are sent as inline base64. Images over 500 KB are automatically compressed to JPEG and resized to fit under the limit. This keeps requests fast and avoids File API auth issues with the enterprise endpoint. - -## Output - -Relative filenames are saved to `$OPENCLAW_STATE_DIR/media/outbound/{slugid}/nano-banana/{filename}`. Absolute paths are used as-is. Absolute paths are used as-is. Use timestamps in filenames to avoid overwrites: `cat-on-mars-20260304-165000.png`. - -## Sending images to the user - -The script prints a `MEDIA: <absolute-path>` line on stdout. **You MUST include this exact MEDIA: line in your reply text** so the image is delivered as an attachment in Discord/Slack/chat. - -Example reply: -``` -Here's your image! -MEDIA: /Users/alche/.openclaw/media/outbound/my-bot/nano-banana/cat-on-mars.png -``` - -Rules: -- Copy the `MEDIA:` line from the script output into your reply verbatim — this is how images get sent -- Do NOT read the generated image back with the read tool -- Do NOT try to base64 encode or manually attach the image -- The `MEDIA:` line must be on its own line in your response diff --git a/skills/nexubot/nano-banana/scripts/generate-image.js b/skills/nexubot/nano-banana/scripts/generate-image.js deleted file mode 100644 index aa6347b81..000000000 --- a/skills/nexubot/nano-banana/scripts/generate-image.js +++ /dev/null @@ -1,394 +0,0 @@ -#!/usr/bin/env node - -/** - * Generate or edit images using Gemini image models. - * Requires: sharp (npm install sharp) - * - * Usage: - * node generate-image.js --prompt "a cat on mars" --filename output.png - * node generate-image.js --prompt "edit this" --filename out.png -i photo.png --model nano-banana-pro - * node generate-image.js --prompt "combine" --filename out.png -i a.png -i b.png -i c.png - * - * Models: - * nano-banana → gemini-3.1-flash-image-preview (default, fast) - * nano-banana-pro → gemini-3-pro-image-preview (highest quality) - * nano-banana-2 → gemini-2.5-flash-image (legacy) - */ - -import fs from "node:fs"; -import path from "node:path"; -import { parseArgs } from "node:util"; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const MODEL_MAP = { - "nano-banana": "gemini-3.1-flash-image-preview", - "nano-banana-pro": "gemini-3-pro-image-preview", - "nano-banana-2": "gemini-2.5-flash-image", -}; - -const VALID_MODELS = Object.keys(MODEL_MAP); -const RESOLUTIONS = ["1K", "2K", "4K"]; -const MAX_INPUT_IMAGES = 14; -const MAX_IMAGE_BYTES = 512_000; // 500 KB per image after compression - -const GENERATE_BASE_URL = "https://aiplatform.googleapis.com"; - -// --------------------------------------------------------------------------- -// CLI -// --------------------------------------------------------------------------- - -function printHelp() { - console.log(`Usage: node generate-image.js --prompt "desc" --filename "out.png" [options] - -Options: - -p, --prompt Image description / editing instruction (required) - -f, --filename Output filename (required) - -i, --input-image Input image path(s) for editing (repeatable, max 14) - --model Model: nano-banana (default), nano-banana-pro, nano-banana-2 - -r, --resolution Output resolution: 1K (default), 2K, 4K - --aspect-ratio Aspect ratio e.g. 1:1, 16:9, 4:3, 3:4, 9:16 - -h, --help Show this help`); -} - -function parseCliArgs() { - const { values } = parseArgs({ - options: { - prompt: { type: "string", short: "p" }, - filename: { type: "string", short: "f" }, - "input-image": { type: "string", short: "i", multiple: true }, - model: { type: "string", default: "nano-banana" }, - resolution: { type: "string", short: "r", default: "1K" }, - "aspect-ratio": { type: "string" }, - help: { type: "boolean", short: "h" }, - }, - strict: true, - }); - - if (values.help) { - printHelp(); - process.exit(0); - } - - if (!values.prompt) { - console.error("Error: --prompt is required"); - process.exit(1); - } - if (!values.filename) { - console.error("Error: --filename is required"); - process.exit(1); - } - if (!VALID_MODELS.includes(values.model)) { - console.error(`Error: --model must be one of: ${VALID_MODELS.join(", ")}`); - process.exit(1); - } - if (!RESOLUTIONS.includes(values.resolution)) { - console.error( - `Error: --resolution must be one of: ${RESOLUTIONS.join(", ")}`, - ); - process.exit(1); - } - - const inputImages = values["input-image"] || []; - if (inputImages.length > MAX_INPUT_IMAGES) { - console.error( - `Error: Too many input images (${inputImages.length}). Maximum is ${MAX_INPUT_IMAGES}.`, - ); - process.exit(1); - } - - return { - prompt: values.prompt, - filename: values.filename, - inputImages, - model: values.model, - resolution: values.resolution, - aspectRatio: values["aspect-ratio"] ?? undefined, - }; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function resolveContextFile() { - const scriptDir = path.dirname(new URL(import.meta.url).pathname); - - if (process.env.OPENCLAW_STATE_DIR) { - const p = path.join(process.env.OPENCLAW_STATE_DIR, "nexu-context.json"); - if (fs.existsSync(p)) return p; - } - - // Walk up from script dir: scripts/ → nano-banana/ → skills/ → stateDir/ - const stateDir = path.dirname(path.dirname(path.dirname(scriptDir))); - const p = path.join(stateDir, "nexu-context.json"); - if (fs.existsSync(p)) return p; - - return null; -} - -async function fetchApiKey() { - // Tier 1: environment variable - if (process.env.GEMINI_API_KEY) { - return process.env.GEMINI_API_KEY; - } - - // Tier 2: Nexu secrets API via SKILL_API_TOKEN + nexu-context.json - const token = process.env.SKILL_API_TOKEN; - const contextFile = resolveContextFile(); - - if (token && contextFile) { - try { - const ctx = JSON.parse(fs.readFileSync(contextFile, "utf-8")); - const { apiUrl, poolId } = ctx; - if (apiUrl && poolId) { - const url = `${apiUrl}/api/internal/secrets/nano-banana?poolId=${poolId}`; - const res = await fetch(url, { - headers: { "x-internal-token": token }, - }); - if (res.ok) { - const secrets = await res.json(); - if (secrets.GEMINI_API_KEY) { - return secrets.GEMINI_API_KEY; - } - } - } - } catch (err) { - console.error( - `Warning: Failed to fetch secret from Nexu API: ${err.message}`, - ); - } - } - - console.error( - "Error: GEMINI_API_KEY not found.\n" + - "Set it via:\n" + - " 1. GEMINI_API_KEY environment variable, or\n" + - " 2. Nexu pool secrets API (requires SKILL_API_TOKEN + nexu-context.json)", - ); - process.exit(1); -} - -function mimeType(filePath) { - const ext = path.extname(filePath).toLowerCase(); - if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; - if (ext === ".webp") return "image/webp"; - if (ext === ".gif") return "image/gif"; - return "image/png"; -} - -// --------------------------------------------------------------------------- -// Image compression via sharp -// --------------------------------------------------------------------------- - -// Use createRequire so NODE_PATH resolves globally-installed sharp -// (works in sandbox without a /node_modules symlink). -import { createRequire } from "node:module"; -const _require = createRequire(import.meta.url); - -let sharp; -try { - sharp = _require("sharp"); -} catch { - // Not pre-installed — try auto-install (works outside sandbox where fs is writable). - const { execSync } = await import("node:child_process"); - const scriptDir = path.dirname(new URL(import.meta.url).pathname); - console.log("Installing sharp (first run only)..."); - try { - execSync("npm install --no-save sharp", { - cwd: scriptDir, - stdio: ["ignore", "pipe", "pipe"], - timeout: 60_000, - }); - sharp = _require("sharp"); - } catch { - console.error("ERROR: sharp is required but could not be installed."); - process.exit(1); - } -} - -async function compressImage(buffer, maxBytes) { - if (buffer.length <= maxBytes) { - return buffer; - } - - // Progressive JPEG quality reduction - let quality = 85; - let output = await sharp(buffer).jpeg({ quality }).toBuffer(); - - while (output.length > maxBytes && quality > 10) { - quality -= 10; - output = await sharp(buffer).jpeg({ quality }).toBuffer(); - } - - // If still over limit, also resize dimensions - if (output.length > maxBytes) { - const meta = await sharp(buffer).metadata(); - if (meta.width) { - const scale = Math.sqrt(maxBytes / output.length) * 0.9; - const newWidth = Math.max(256, Math.round(meta.width * scale)); - output = await sharp(buffer) - .resize(newWidth) - .jpeg({ quality: Math.max(quality, 20) }) - .toBuffer(); - } - } - - return output; -} - -// --------------------------------------------------------------------------- -// Build request parts for input images (always inline base64) -// --------------------------------------------------------------------------- - -async function buildImageParts(imagePaths) { - if (imagePaths.length === 0) return []; - - const parts = []; - for (const p of imagePaths) { - const resolved = path.resolve(p); - let buffer; - try { - buffer = fs.readFileSync(resolved); - } catch (e) { - console.error(`Error loading image '${resolved}': ${e.message}`); - process.exit(1); - } - - const originalSize = buffer.length; - const compressed = await compressImage(buffer, MAX_IMAGE_BYTES); - const mime = compressed === buffer ? mimeType(resolved) : "image/jpeg"; - - if (compressed !== buffer) { - console.log( - ` Compressed ${path.basename(resolved)}: ${Math.round(originalSize / 1024)}KB → ${Math.round(compressed.length / 1024)}KB`, - ); - } - - parts.push({ - inline_data: { - data: compressed.toString("base64"), - mime_type: mime, - }, - }); - } - - console.log(`Prepared ${parts.length} image(s) as inline base64`); - return parts; -} - -// --------------------------------------------------------------------------- -// Generate -// --------------------------------------------------------------------------- - -async function generateImage(args, apiKey) { - const modelId = MODEL_MAP[args.model]; - const url = `${GENERATE_BASE_URL}/v1/publishers/google/models/${modelId}:generateContent?key=${apiKey}`; - - const imageParts = await buildImageParts(args.inputImages); - - const contents = [ - { - role: "user", - parts: [...imageParts, { text: args.prompt }], - }, - ]; - - const generationConfig = { - responseModalities: ["TEXT", "IMAGE"], - }; - - // Build imageConfig only if we have relevant settings - const imageConfig = {}; - imageConfig.imageSize = args.resolution; - if (args.aspectRatio) { - imageConfig.aspectRatio = args.aspectRatio; - } - generationConfig.imageConfig = imageConfig; - - const body = { contents, generationConfig }; - - const count = imageParts.length; - if (count > 0) { - console.log( - `Processing ${count} image(s) with model=${args.model} resolution=${args.resolution}...`, - ); - } else { - console.log( - `Generating image with model=${args.model} resolution=${args.resolution}...`, - ); - } - - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - const text = await res.text(); - console.error(`Error from Gemini API (${res.status}): ${text}`); - process.exit(1); - } - - const data = await res.json(); - const parts = data.candidates?.[0]?.content?.parts ?? []; - - // Absolute paths are used as-is; relative filenames go under - // ~/.openclaw/workspace/output/nano-banana/ which is an allowed media root - // (stateDir/workspace is in the default media local roots list). - const stateDir = - process.env.OPENCLAW_STATE_DIR || - path.join(process.env.HOME || process.env.USERPROFILE || "~", ".openclaw"); - const outputPath = path.isAbsolute(args.filename) - ? args.filename - : path.join( - stateDir, - "media", - "outbound", - path.basename(process.cwd()), - "nano-banana", - args.filename, - ); - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - - let imageSaved = false; - - for (const part of parts) { - if (part.text) { - console.log(`Model: ${part.text}`); - } else if (part.inlineData) { - const imageData = Buffer.from(part.inlineData.data, "base64"); - fs.writeFileSync(outputPath, imageData); - imageSaved = true; - } - } - - if (imageSaved) { - if (!fs.existsSync(outputPath)) { - console.error(`Error: File was written but not found at ${outputPath}`); - process.exit(1); - } - const stat = fs.statSync(outputPath); - console.log( - `Image saved: ${outputPath} (${Math.round(stat.size / 1024)}KB)`, - ); - console.log(`MEDIA: ${outputPath}`); - } else { - console.error("Error: No image was generated in the response."); - console.error( - "The model may have returned only text. Try rephrasing your prompt.", - ); - process.exit(1); - } -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -const args = parseCliArgs(); -const apiKey = await fetchApiKey(); -await generateImage(args, apiKey); diff --git a/skills/nexubot/nano-banana/scripts/package.json b/skills/nexubot/nano-banana/scripts/package.json deleted file mode 100644 index e986b24bb..000000000 --- a/skills/nexubot/nano-banana/scripts/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "private": true, - "type": "module" -} diff --git a/skills/nexubot/static-deploy/SKILL.md b/skills/nexubot/static-deploy/SKILL.md deleted file mode 100644 index 6f408be1d..000000000 --- a/skills/nexubot/static-deploy/SKILL.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -name: static-deploy -description: "Deploy static pages to nexu.space. Use when user says deploy, publish, ship, or go live with a static site/page. Uploads files from workspace to <project-slug>.nexu.space via Wrangler + Cloudflare Pages. Supports first deploy and redeploy." ---- - -# Static Deploy - -Deploy static files to `<project-slug>.nexu.space` via Wrangler + Cloudflare Pages. - -The deploy script stages a temporary copy of the site and injects a `_headers` -file (if needed) with cache revalidation rules for HTML/CSS/JS to reduce stale -asset issues after redeploys. Source files are not modified. - -## What it deploys - -Any static files: HTML, CSS, JS, images, fonts, etc. Common use cases: -- Single-page apps (React, Vue, etc. — deploy the `dist/` or `build/` folder) -- Landing pages, portfolios, documentation sites -- Quick prototypes or demos -- Any folder with an `index.html` - -## Usage - -1. Identify the directory containing files to deploy (must have an `index.html`) -2. Derive a project-slug from context (e.g., "family budget" → `family-budget`) - - Lowercase alphanumeric + hyphens only, max 63 characters - - Reuse the same slug for redeployments - - Ask user to confirm if ambiguous -3. Run deploy with the inbound structured context: - -```bash -"$SKILL_DIR/scripts/deploy.sh" <project-slug> <directory> <agent-id> <chat-id> [thread-id] [message-ref] [account-id] [channel] [chat-type] [sender-ref] -``` - -`agent-id` is required. The script uses it to resolve the corresponding `botId` -from `nexu-context.json` when recording deployment artifacts. - -`chat-id` is the raw inbound chat identifier from the message context. - -- DM example: `U0AHLMC6C8G` -- Channel example: `C0AJKG60H6D` - -`chat-type` tells the script whether to send that id to Nexu as `user:<chat-id>` -or `channel:<chat-id>`. The script does that translation; do not concatenate -those prefixes yourself. - -`thread-id` is optional — pass it when deploying from within a thread context. -4. Parse the JSON output and report to user: - - Brief summary of what was deployed - - Live URL from the `url` field - - **Important:** Tell the user that Cloudflare Pages propagation takes ~3 minutes. The URL may not work immediately after the first deploy. For redeployments, it's usually faster. - -## Example reply - -> Deployed! Your site is live at https://family-budget.nexu.space -> -> Note: First-time deploys take ~3 minutes to propagate on Cloudflare. If you see a "not found" page, wait a few minutes and refresh. - -## Rules - -- Never read, echo, or log `SKILL_API_TOKEN`, `CLOUDFLARE_API_TOKEN`, or `CLOUDFLARE_ACCOUNT_ID` -- Never pass credentials as command arguments -- Always use the bundled `deploy.sh` — do not call Cloudflare API directly -- Do not set `DEPLOY_BACKEND` or any other env overrides — the script handles everything -- Always pass the caller `<agent-id>` as arg #3 to `deploy.sh` -- Always pass raw inbound `<chat-id>` as arg #4 -- Never pass `<agent-id>` as `<chat-id>`; they are different values -- If deploying from a thread, pass `<thread-id>` as arg #5 -- Always pass `<channel>` and `<chat-type>` when available so Nexu can resolve the correct session deterministically -- Do not try to look up or persist a canonical session key in this skill; Nexu resolves it server-side -- Do not hand-edit source files just to add cache-busting query params; the deploy script handles cache revalidation via a staged `_headers` file -- If the script fails, show the `message` field from the JSON error to the user -- Cloudflare credentials are fetched at runtime via the scoped secrets API using `SKILL_API_TOKEN` from env — do not attempt to read or inject them manually - -## Output Format - -Success: -```json -{ - "status": "success", - "url": "https://<slug>.nexu.space", - "deployment_url": "https://<id>.<slug>.pages.dev", - "files_total": 10, - "files_uploaded": 2, - "files_cached": 8 -} -``` - -Error: -```json -{ - "status": "error", - "message": "Description of what went wrong" -} -``` diff --git a/skills/nexubot/static-deploy/scripts/deploy.sh b/skills/nexubot/static-deploy/scripts/deploy.sh deleted file mode 100755 index fc937db13..000000000 --- a/skills/nexubot/static-deploy/scripts/deploy.sh +++ /dev/null @@ -1,549 +0,0 @@ -#!/usr/bin/env bash -set -uo pipefail - -# ============================================================================ -# Static Deploy — Cloudflare Pages via Wrangler -# Usage: -# deploy.sh <project-slug> <directory> <agent-id> [chat-id] [thread-id] \ -# [message-ref] [account-id] [channel] [chat-type] [sender-ref] -# -# Flow: -# First deploy: validate → create_project → wrangler deploy → add_domain → record → output -# Redeploy: validate → create_project(skip) → wrangler deploy → add_domain(skip) → record → output -# ============================================================================ - -DOMAIN_SUFFIX="nexu.space" -CF_API="https://api.cloudflare.com/client/v4" -ACCOUNT_PATH="" -IS_NEW_PROJECT=false - -SLUG="" -DIR="" -DEPLOY_DIR="" -FILES_TOTAL=0 -FILES_UPLOADED=0 -FILES_CACHED=0 -DEPLOY_URL="" -STAGE_DIR="" -AGENT_ID="" -CHAT_ID_INPUT="" -THREAD_ID_INPUT="" -MESSAGE_REF_INPUT="" -ACCOUNT_ID_INPUT="" -CHANNEL_INPUT="" -CHAT_TYPE_INPUT="" -SENDER_REF_INPUT="" - -# ============================================================================ -# HELPERS -# ============================================================================ - -json_error() { - printf '{"status":"error","message":"%s"}\n' "$1" >&1 - exit "${2:-1}" -} - -json_success() { - printf '{"status":"success","url":"%s","deployment_url":"%s","files_total":%d,"files_uploaded":%d,"files_cached":%d,"is_new_project":%s}\n' \ - "$1" "$2" "$3" "$4" "$5" "$6" >&1 - exit 0 -} - -resolve_api_context() { - local context_file api_url - context_file="$(resolve_context_file)" || return 1 - api_url=$(json_val 'd.apiUrl' "$context_file") || true - if [[ -z "$api_url" || -z "${SKILL_API_TOKEN:-}" ]]; then - return 1 - fi - - printf '%s\n' "$api_url" -} - -resolve_artifact_chat_id() { - local chat_id="$1" - local chat_type="${2,,}" - if [[ -z "$chat_id" ]]; then - return 0 - fi - - case "$chat_type" in - direct|dm) - printf 'user:%s\n' "$chat_id" - ;; - group|channel|thread) - printf 'channel:%s\n' "$chat_id" - ;; - *) - return 1 - ;; - esac -} - -cf_api() { - local method="$1" path="$2" - shift 2 - curl -s -X "$method" \ - "${CF_API}${path}" \ - -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ - -H "Content-Type: application/json" \ - "$@" 2>/dev/null -} - -have_cmd() { - command -v "$1" >/dev/null 2>&1 -} - -# Extract a value from JSON using Node.js (jq is not available in all runtimes). -# Usage: json_val <js-expression> [file] — expression receives parsed object as `d`. -# If file is omitted, reads from stdin. -json_val() { - local expr="$1" file="${2:-}" - if [[ -n "$file" ]]; then - node -e "try{const d=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));const v=(${expr});process.stdout.write(String(v==null?'':v))}catch(e){}" "$file" - else - node -e "let b='';process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{try{const d=JSON.parse(b);const v=(${expr});process.stdout.write(String(v==null?'':v))}catch(e){}})" - fi -} - -cleanup() { - if [[ -n "${STAGE_DIR:-}" && -d "${STAGE_DIR:-}" ]]; then - rm -rf "$STAGE_DIR" - fi -} - -resolve_context_file() { - local script_dir state_dir cwd_state - script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - - if [[ -n "${OPENCLAW_STATE_DIR:-}" && -f "${OPENCLAW_STATE_DIR}/nexu-context.json" ]]; then - echo "${OPENCLAW_STATE_DIR}/nexu-context.json" - return 0 - fi - - state_dir="$(dirname "$(dirname "$(dirname "$script_dir")")")" - if [[ -f "${state_dir}/nexu-context.json" ]]; then - echo "${state_dir}/nexu-context.json" - return 0 - fi - - cwd_state="$(pwd)/.openclaw/nexu-context.json" - if [[ -f "$cwd_state" ]]; then - echo "$cwd_state" - return 0 - fi - - return 1 -} - -# Locate npx binary even when PATH is minimal (e.g. cloud agent runtimes). -# Search order: PATH → nvm → homebrew → /usr/local → /usr/bin -find_npx() { - if have_cmd npx; then - echo "npx" - return 0 - fi - - # nvm: pick the newest installed version - local nvm_bin - nvm_bin=$(ls -d "$HOME/.nvm/versions/node"/*/bin 2>/dev/null | sort -V | tail -1) - if [[ -n "$nvm_bin" && -x "${nvm_bin}/npx" ]]; then - echo "${nvm_bin}/npx" - return 0 - fi - - # Common system locations - local p - for p in /opt/homebrew/bin/npx /usr/local/bin/npx /usr/bin/npx; do - if [[ -x "$p" ]]; then - echo "$p" - return 0 - fi - done - - return 1 -} - -append_cache_headers() { - local headers_file="$1" - local marker_begin="# openclaw-static-deploy cache-busting (begin)" - local marker_end="# openclaw-static-deploy cache-busting (end)" - - if [[ -f "$headers_file" ]] && grep -Fq "$marker_begin" "$headers_file"; then - return 0 - fi - - { - [[ -f "$headers_file" ]] && [[ -s "$headers_file" ]] && printf '\n' - cat <<EOF -${marker_begin} -/ - Cache-Control: public, max-age=0, must-revalidate - -/*.html - Cache-Control: public, max-age=0, must-revalidate - -/*.css - Cache-Control: public, max-age=0, must-revalidate - -/*.js - Cache-Control: public, max-age=0, must-revalidate -${marker_end} -EOF - } >>"$headers_file" -} - -# ============================================================================ -# STEP 1: VALIDATE -# ============================================================================ - -step_validate() { - SLUG="${1:-}" - DIR="${2:-}" - AGENT_ID="${3:-}" - # chat-id: raw inbound chat identifier (for example Slack user/channel id). - # chat-type determines whether Nexu should resolve it as user:<id> or channel:<id>. - CHAT_ID_INPUT="${4:-}" - THREAD_ID_INPUT="${5:-}" - MESSAGE_REF_INPUT="${6:-}" - ACCOUNT_ID_INPUT="${7:-}" - CHANNEL_INPUT="${8:-slack}" - CHAT_TYPE_INPUT="${9:-}" - SENDER_REF_INPUT="${10:-}" - - if [[ -z "$SLUG" || -z "$DIR" || -z "$AGENT_ID" ]]; then - json_error "Usage: deploy.sh <project-slug> <directory> <agent-id> [chat-id] [thread-id]" 1 - fi - - if [[ ! "$SLUG" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; then - json_error "Invalid slug: must be lowercase alphanumeric + hyphens, max 63 chars" 1 - fi - - if [[ ! -d "$DIR" ]]; then - json_error "Directory not found: ${DIR}" 1 - fi - - local html_count - html_count=$(find "$DIR" -name '*.html' -type f | wc -l | tr -d ' ') - if [[ "$html_count" -eq 0 ]]; then - json_error "No HTML files found in ${DIR}" 1 - fi - - # Fetch credentials from Nexu at runtime using SKILL_API_TOKEN. - local context_file="" api_url="" pool_id="" - context_file="$(resolve_context_file)" || true - if [[ -z "$context_file" || -z "${SKILL_API_TOKEN:-}" ]]; then - json_error "Missing runtime secret context for deploy credentials (nexu-context.json or SKILL_API_TOKEN)" 1 - fi - - api_url=$(json_val 'd.apiUrl' "$context_file") || true - pool_id=$(json_val 'd.poolId' "$context_file") || true - if [[ -z "$api_url" || -z "$pool_id" ]]; then - json_error "Missing apiUrl or poolId in nexu-context.json" 1 - fi - - local secrets_resp - secrets_resp=$(curl -s -X GET \ - "${api_url}/api/internal/secrets/static-deploy?poolId=${pool_id}" \ - -H "x-internal-token: ${SKILL_API_TOKEN}" 2>/dev/null) || true - if [[ -n "$secrets_resp" ]]; then - CLOUDFLARE_API_TOKEN="$(echo "$secrets_resp" | json_val 'd.CLOUDFLARE_API_TOKEN')" - CLOUDFLARE_ACCOUNT_ID="$(echo "$secrets_resp" | json_val 'd.CLOUDFLARE_ACCOUNT_ID')" - export CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID - fi - - if [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]]; then - json_error "CLOUDFLARE_API_TOKEN not set" 1 - fi - - if [[ -z "${CLOUDFLARE_ACCOUNT_ID:-}" ]]; then - json_error "CLOUDFLARE_ACCOUNT_ID not set" 1 - fi - - ACCOUNT_PATH="/accounts/${CLOUDFLARE_ACCOUNT_ID}" -} - -# ============================================================================ -# STEP 2: CREATE PROJECT (idempotent) -# First deploy: creates the Cloudflare Pages project -# Redeploy: skips (project already exists) -# ============================================================================ - -step_create_project() { - local resp - resp=$(cf_api POST "${ACCOUNT_PATH}/pages/projects" \ - -d "{\"name\":\"${SLUG}\",\"production_branch\":\"main\"}") - - local success err_msg - success=$(echo "$resp" | json_val 'd.success') || true - err_msg=$(echo "$resp" | json_val 'd.errors?.[0]?.message') || true - - if [[ "$success" == "true" ]]; then - IS_NEW_PROJECT=true - return 0 - fi - - if [[ "$err_msg" == *"already exists"* ]]; then - IS_NEW_PROJECT=false - return 0 - fi - - json_error "Failed to create project: ${err_msg:-unknown error}" 2 -} - -# ============================================================================ -# STEP 3: PREPARE DEPLOY DIRECTORY -# Copies source to a temp dir and injects cache-control headers to reduce -# stale CSS/JS/HTML after redeploys. Source files are never modified. -# ============================================================================ - -step_prepare_deploy_dir() { - STAGE_DIR="$(mktemp -d)" - DEPLOY_DIR="$STAGE_DIR/site" - mkdir -p "$DEPLOY_DIR" - - # Preserve dotfiles and nested structure. - cp -R "$DIR"/. "$DEPLOY_DIR"/ - - append_cache_headers "$DEPLOY_DIR/_headers" - - FILES_TOTAL=$(find "$DEPLOY_DIR" -type f | wc -l | tr -d ' ') - # Wrangler does not expose precise uploaded-vs-cached counts in a stable - # machine-readable way here, so we report a conservative "all uploaded". - FILES_UPLOADED=$FILES_TOTAL - FILES_CACHED=0 -} - -# ============================================================================ -# STEP 4: DEPLOY VIA WRANGLER -# ============================================================================ - -step_deploy_wrangler() { - local -a wrangler_cmd=() - if have_cmd wrangler; then - wrangler_cmd=("wrangler") - else - local npx_path - npx_path=$(find_npx) || true - if [[ -n "$npx_path" ]]; then - # Ensure the directory containing npx (and node) is in PATH - local npx_dir - npx_dir="$(dirname "$npx_path")" - export PATH="${npx_dir}:${PATH}" - wrangler_cmd=("$npx_path" "wrangler") - else - json_error "Cannot find wrangler or npx. Ensure Node.js is installed." 2 - fi - fi - - local tmpfile status wrangler_out - tmpfile="$(mktemp)" - - "${wrangler_cmd[@]}" pages deploy "$DEPLOY_DIR" --project-name "$SLUG" >"$tmpfile" 2>&1 - status=$? - wrangler_out="$(cat "$tmpfile")" - rm -f "$tmpfile" - - if [[ $status -ne 0 ]]; then - local err_line - err_line=$(printf '%s\n' "$wrangler_out" | tail -n 10 | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g') - json_error "wrangler deploy failed: ${err_line:-unknown error}" 2 - fi - - DEPLOY_URL=$(printf '%s\n' "$wrangler_out" | grep -Eo 'https://[^ ]+\.pages\.dev' | head -n 1 || true) -} - -# ============================================================================ -# STEP 5: ADD CUSTOM DOMAIN (idempotent) -# ============================================================================ - -step_add_domain() { - local custom_domain="${SLUG}.${DOMAIN_SUFFIX}" - - # Add custom domain to Pages project (idempotent) - cf_api POST "${ACCOUNT_PATH}/pages/projects/${SLUG}/domains" \ - -d "{\"name\":\"${custom_domain}\"}" >/dev/null 2>/dev/null || true - - # Look up zone ID for the domain suffix - local zone_resp zone_id - zone_resp=$(cf_api GET "/zones?name=${DOMAIN_SUFFIX}") || true - zone_id=$(echo "$zone_resp" | json_val 'd.result?.[0]?.id') || true - - if [[ -z "$zone_id" ]]; then - return 0 # Can't create DNS record without zone access, skip silently - fi - - # Get the pages.dev subdomain for CNAME target - local project_resp pages_subdomain - project_resp=$(cf_api GET "${ACCOUNT_PATH}/pages/projects/${SLUG}") || true - pages_subdomain=$(echo "$project_resp" | json_val 'd.result?.subdomain') || true - - if [[ -z "$pages_subdomain" ]]; then - return 0 - fi - - # Create CNAME record (idempotent — Cloudflare returns error if exists) - cf_api POST "/zones/${zone_id}/dns_records" \ - -d "{\"type\":\"CNAME\",\"name\":\"${SLUG}\",\"content\":\"${pages_subdomain}\",\"proxied\":true}" \ - >/dev/null 2>/dev/null || true -} - -# ============================================================================ -# STEP 6: CHECK DOMAIN OWNERSHIP IN NEXU -# ============================================================================ - -step_check_domain_available() { - local nexu_api_url nexu_token - nexu_api_url="$(resolve_api_context)" || { - json_error "Missing runtime context for domain check (apiUrl/token). Check nexu-context.json and SKILL_API_TOKEN." 3 - } - nexu_token="${SKILL_API_TOKEN:-}" - - local resolved_chat_id="" - if [[ -n "$CHAT_ID_INPUT" ]]; then - resolved_chat_id="$(resolve_artifact_chat_id "$CHAT_ID_INPUT" "$CHAT_TYPE_INPUT")" || { - json_error "Missing or invalid chat_type for domain check; expected direct/dm/group/channel/thread" 3 - } - fi - - local custom_url="https://${SLUG}.${DOMAIN_SUFFIX}" - local chat_id_field="" - [[ -n "$resolved_chat_id" ]] && chat_id_field="\"chatId\": \"${resolved_chat_id}\"," - local thread_id_field="" - [[ -n "$THREAD_ID_INPUT" ]] && thread_id_field="\"threadId\": \"${THREAD_ID_INPUT}\"," - local channel_field="" - [[ -n "$CHANNEL_INPUT" ]] && channel_field="\"channelType\": \"${CHANNEL_INPUT}\"," - - local payload - payload=$(cat <<EOJSON -{ - "botId": "${AGENT_ID}", - ${chat_id_field} - ${thread_id_field} - ${channel_field} - "previewUrl": "${custom_url}" -} -EOJSON - ) - - local resp_file http_code - resp_file="$(mktemp)" - http_code=$(curl -s -o "$resp_file" -w "%{http_code}" -X POST "${nexu_api_url}/api/internal/artifacts/check-domain" \ - -H "x-internal-token: ${nexu_token}" \ - -H "Content-Type: application/json" \ - -d "$payload" 2>/dev/null || true) - - if [[ "$http_code" == "409" ]]; then - local err_msg - err_msg="$(cat "$resp_file" | json_val 'd.message || d.error || ""')" - rm -f "$resp_file" - json_error "Domain already in use: ${err_msg:-${custom_url}}" 3 - fi - - if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then - local err_msg - err_msg="$(cat "$resp_file" | json_val 'd.message || d.error || ""')" - rm -f "$resp_file" - json_error "Failed to validate domain ownership (HTTP ${http_code:-000}) ${err_msg}" 3 - fi - - rm -f "$resp_file" -} - -# ============================================================================ -# STEP 7: RECORD ARTIFACT IN NEXU -# ============================================================================ - -step_record_artifact() { - # Agent ID = Bot ID (1:1 mapping in Nexu) - local nexu_bot_id="$AGENT_ID" - local nexu_api_url nexu_token - nexu_api_url="$(resolve_api_context)" || { - json_error "Missing runtime context for artifact record (apiUrl/token). Check nexu-context.json and SKILL_API_TOKEN." 3 - } - nexu_token="${SKILL_API_TOKEN:-}" - - local resolved_chat_id="" - if [[ -n "$CHAT_ID_INPUT" ]]; then - resolved_chat_id="$(resolve_artifact_chat_id "$CHAT_ID_INPUT" "$CHAT_TYPE_INPUT")" || { - json_error "Missing or invalid chat_type for artifact record; expected direct/dm/group/channel/thread" 3 - } - fi - - local custom_url="https://${SLUG}.${DOMAIN_SUFFIX}" - - # Build JSON payload — API resolves canonical session key from chatId/threadId server-side. - local chat_id_field="" - [[ -n "$resolved_chat_id" ]] && chat_id_field="\"chatId\": \"${resolved_chat_id}\"," - local thread_id_field="" - [[ -n "$THREAD_ID_INPUT" ]] && thread_id_field="\"threadId\": \"${THREAD_ID_INPUT}\"," - local channel_field="" - [[ -n "$CHANNEL_INPUT" ]] && channel_field="\"channelType\": \"${CHANNEL_INPUT}\"," - - local payload - payload=$(cat <<EOJSON -{ - "botId": "${nexu_bot_id}", - ${chat_id_field} - ${thread_id_field} - ${channel_field} - "title": "Deploy: ${SLUG}", - "artifactType": "deployment", - "source": "coding", - "contentType": "text/html", - "status": "live", - "previewUrl": "${custom_url}", - "deployTarget": "cloudflare-pages", - "fileCount": ${FILES_TOTAL}, - "metadata": { "slug": "${SLUG}", "isNewProject": ${IS_NEW_PROJECT}, "deploymentUrl": "${DEPLOY_URL:-}" } -} -EOJSON - ) - - local resp_file http_code - resp_file="$(mktemp)" - http_code=$(curl -s -o "$resp_file" -w "%{http_code}" -X POST "${nexu_api_url}/api/internal/artifacts" \ - -H "x-internal-token: ${nexu_token}" \ - -H "Content-Type: application/json" \ - -d "$payload" 2>/dev/null || true) - - if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then - local err_msg - err_msg="$(cat "$resp_file" | json_val 'd.message || d.error || ""')" - rm -f "$resp_file" - json_error "Deploy succeeded but artifact record failed (HTTP ${http_code:-000}) ${err_msg}" 3 - fi - rm -f "$resp_file" -} - -# ============================================================================ -# STEP 8: OUTPUT -# ============================================================================ - -step_output() { - json_success \ - "https://${SLUG}.${DOMAIN_SUFFIX}" \ - "${DEPLOY_URL:-unknown}" \ - "$FILES_TOTAL" \ - "$FILES_UPLOADED" \ - "$FILES_CACHED" \ - "$IS_NEW_PROJECT" -} - -# ============================================================================ -# MAIN -# ============================================================================ - -main() { - trap cleanup EXIT - step_validate "$@" - step_check_domain_available - step_create_project - step_prepare_deploy_dir - step_deploy_wrangler - step_add_domain - step_record_artifact - step_output -} - -main "$@" diff --git a/skills/nexubot/static-deploy/scripts/session-search.sh b/skills/nexubot/static-deploy/scripts/session-search.sh deleted file mode 100755 index 55f26f317..000000000 --- a/skills/nexubot/static-deploy/scripts/session-search.sh +++ /dev/null @@ -1,282 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Resolve session binding parameters from OpenClaw state files. -# Lookup order: -# 1) sessions.json nexuBindings (fast path) -# 2) binding-*.jsonl -# 3) fallback *.jsonl custom binding scan - -MESSAGE_REF="" -THREAD_REF="" -SESSION_KEY="" -RUNTIME_SESSION_ID="" -AGENT_ID="" -STATE_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}" - -usage() { - cat <<'USAGE' -Usage: - session-search.sh [options] - -Options: - --message-ref <ref> - --thread-ref <ref> - --session-key <key> - --runtime-session-id <id> - --agent-id <agent-id> - --state-dir <path> - -h, --help - -Examples: - session-search.sh --message-ref 1772590256.478579 - session-search.sh --thread-ref 1772590256.478579 --agent-id my-bot - session-search.sh --session-key slack_T09CNAG1BP0_D0AJFLDFB8S -USAGE -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --message-ref) - MESSAGE_REF="${2:-}" - shift 2 - ;; - --thread-ref) - THREAD_REF="${2:-}" - shift 2 - ;; - --session-key) - SESSION_KEY="${2:-}" - shift 2 - ;; - --runtime-session-id) - RUNTIME_SESSION_ID="${2:-}" - shift 2 - ;; - --agent-id) - AGENT_ID="${2:-}" - shift 2 - ;; - --state-dir) - STATE_DIR="${2:-}" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown arg: $1" >&2 - usage - exit 2 - ;; - esac -done - -if [[ -z "$MESSAGE_REF" && -z "$THREAD_REF" && -z "$SESSION_KEY" && -z "$RUNTIME_SESSION_ID" ]]; then - echo '{"status":"error","message":"At least one query option is required"}' - exit 2 -fi - -if [[ ! -d "$STATE_DIR/agents" ]]; then - echo '{"status":"error","message":"State directory not found or has no agents folder"}' - exit 1 -fi - -if ! command -v node >/dev/null 2>&1; then - echo '{"status":"error","message":"node is required"}' - exit 1 -fi - -TMP_JSONL="$(mktemp)" -TMP_SESSIONS="$(mktemp)" -trap 'rm -f "$TMP_JSONL" "$TMP_SESSIONS"' EXIT - -if [[ -n "$AGENT_ID" ]]; then - find "$STATE_DIR/agents/$AGENT_ID/sessions" -type f -name "*.jsonl" 2>/dev/null | sort >"$TMP_JSONL" || true - find "$STATE_DIR/agents/$AGENT_ID/sessions" -type f -name "sessions.json" 2>/dev/null | sort >"$TMP_SESSIONS" || true -else - find "$STATE_DIR/agents" -type f -path "*/sessions/*.jsonl" 2>/dev/null | sort >"$TMP_JSONL" || true - find "$STATE_DIR/agents" -type f -path "*/sessions/sessions.json" 2>/dev/null | sort >"$TMP_SESSIONS" || true -fi - -if [[ ! -s "$TMP_JSONL" && ! -s "$TMP_SESSIONS" ]]; then - echo '{"status":"error","message":"No session files found"}' - exit 1 -fi - -export MESSAGE_REF THREAD_REF SESSION_KEY RUNTIME_SESSION_ID - -node - "$TMP_JSONL" "$TMP_SESSIONS" <<'NODE' -const fs = require("fs"); -const path = require("path"); - -const jsonlListPath = process.argv[2]; -const sessionsListPath = process.argv[3]; - -const jsonlFiles = fs.existsSync(jsonlListPath) - ? fs.readFileSync(jsonlListPath, "utf8").split("\n").map((s) => s.trim()).filter(Boolean) - : []; -const sessionsFiles = fs.existsSync(sessionsListPath) - ? fs.readFileSync(sessionsListPath, "utf8").split("\n").map((s) => s.trim()).filter(Boolean) - : []; - -const query = { - messageRef: process.env.MESSAGE_REF || "", - threadRef: process.env.THREAD_REF || "", - sessionKey: process.env.SESSION_KEY || "", - runtimeSessionId: process.env.RUNTIME_SESSION_ID || "", -}; - -function parseAgentId(filePath) { - const normalized = filePath.replace(/\\/g, "/"); - const marker = "/agents/"; - const idx = normalized.indexOf(marker); - if (idx < 0) return ""; - const rest = normalized.slice(idx + marker.length); - return rest.split("/")[0] || ""; -} - -function parseRuntimeSessionId(filePath) { - return path.basename(filePath, ".jsonl"); -} - -function matchesQuery(data, runtimeSessionId) { - if (query.runtimeSessionId && runtimeSessionId !== query.runtimeSessionId) return false; - if (query.sessionKey && data.nexuSessionKey !== query.sessionKey) return false; - if (query.threadRef && data.threadRef !== query.threadRef) return false; - if ( - query.messageRef && - data.messageRef !== query.messageRef && - data.lastMessageRef !== query.messageRef - ) { - return false; - } - return true; -} - -function toResult({ file, agentId, runtimeSessionId, timestamp, data }) { - return { - file, - agentId, - runtimeSessionId, - timestamp: timestamp || data.updatedAt || "", - nexuSessionKey: data.nexuSessionKey || "", - channelType: data.channelType || "", - accountId: data.accountId || "", - channelId: data.channelId || "", - threadRef: data.threadRef || "", - messageRef: data.messageRef || data.lastMessageRef || "", - senderRef: data.senderRef || data.lastSenderRef || "", - }; -} - -let best = null; - -// Fast path: sessions.json nexuBindings -for (const file of sessionsFiles) { - let parsed; - try { - parsed = JSON.parse(fs.readFileSync(file, "utf8")); - } catch { - continue; - } - const bindings = parsed?.nexuBindings; - if (!bindings || typeof bindings !== "object") continue; - - for (const [runtimeSessionId, data] of Object.entries(bindings)) { - if (!data || typeof data !== "object") continue; - if (!matchesQuery(data, runtimeSessionId)) continue; - - const candidate = toResult({ - file, - agentId: parseAgentId(file), - runtimeSessionId, - timestamp: data.updatedAt || "", - data, - }); - - if (!best || String(candidate.timestamp) > String(best.timestamp)) { - best = candidate; - } - } -} - -// Fallback: scan binding jsonl files first, then all jsonl files -const prioritized = [ - ...jsonlFiles.filter((f) => path.basename(f).startsWith("binding-")), - ...jsonlFiles.filter((f) => !path.basename(f).startsWith("binding-")), -]; - -function isBindingRecord(obj) { - return ( - obj && - obj.type === "custom" && - obj.customType === "nexu-session-binding" && - obj.data && - typeof obj.data === "object" - ); -} - -for (const file of prioritized) { - let raw = ""; - try { - raw = fs.readFileSync(file, "utf8"); - } catch { - continue; - } - const runtimeSessionId = parseRuntimeSessionId(file); - const lines = raw.split("\n").filter(Boolean); - - for (let i = lines.length - 1; i >= 0; i -= 1) { - let obj; - try { - obj = JSON.parse(lines[i]); - } catch { - continue; - } - if (!isBindingRecord(obj)) continue; - if (!matchesQuery(obj.data, runtimeSessionId)) continue; - - const candidate = toResult({ - file, - agentId: parseAgentId(file), - runtimeSessionId, - timestamp: obj.timestamp || "", - data: obj.data, - }); - - if (!best || String(candidate.timestamp) > String(best.timestamp)) { - best = candidate; - } - break; - } -} - -if (!best) { - console.log( - JSON.stringify({ - status: "not_found", - message: "No matching nexu-session-binding record found in session files", - query, - }), - ); - process.exit(3); -} - -console.log( - JSON.stringify({ - status: "ok", - result: best, - deployParams: { - agentId: best.agentId, - nexuSessionKey: best.nexuSessionKey, - channelType: best.channelType, - accountId: best.accountId, - channelId: best.channelId, - messageRef: best.messageRef, - threadRef: best.threadRef, - }, - }), -); -NODE diff --git a/tests/skills/deploy-skill-core.test.ts b/tests/skills/deploy-skill-core.test.ts new file mode 100644 index 000000000..041a5446b --- /dev/null +++ b/tests/skills/deploy-skill-core.test.ts @@ -0,0 +1,1335 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import JSZip from "jszip"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + loadPageDeployConfig, + loadPageDeployJobs, + queryPageDeployJob, + recoverPendingPageDeployJobs, + savePageDeployConfig, + submitPageDeployJob, + submitPageDeployTemplateJob, + waitForPageDeployJob, +} from "../../apps/desktop/static/bundled-skills/deploy-skill/scripts/deploy_skill_core.js"; + +describe("deploy skill core", () => { + let rootDir = ""; + + async function writeLocalNexuConfig(overrides = {}) { + const payload = { + desktop: { + cloud: { + connected: true, + apiKey: "nxk_test_local_key", + }, + }, + ...overrides, + }; + await writeFile( + path.join(rootDir, "config.json"), + JSON.stringify(payload, null, 2), + ); + } + + function buildCanonicalTemplateContent(overrides = {}) { + return { + title: "李锦威", + subtitle: "牛马指数 92/100 — 龙虾成瘾者", + portraitId: "portrait-2", + tags: ["nexu", "增长产品", "协作", "ENTJ"], + metrics: [ + { label: "🐂🐴 牛马指数", value: "92" }, + { label: "⚡ 热点追击", value: "95%" }, + { label: "🧠 信息密度", value: "88%" }, + { label: "📈 操盘手感", value: "90%" }, + ], + posterSpeciesEmoji: "🦞", + posterSpeciesName: "龙虾成瘾者", + posterSpeciesSub: "办公室物种鉴定", + description: + "你是那种能把信息差、执行力和控制欲拧成一根钢缆的人。你看起来像在推进项目,实际上是在逼时间给你让路。你对热点的嗅觉过于灵敏,对机会的反应快到让同事怀疑你是不是提前收到了剧本。你最大的可怕之处不是卷,而是你卷得很有方法。可你也不是没有裂缝,你只是太习惯在别人慌乱时继续往前走,忘了自己也会累。好在你心里仍然留着一点柔软,所以你不只是一个推进器,还是那个会把团队一起带上岸的人。", + qaCards: [ + { + question: "致命优势", + answer: + "你对节奏的判断极准,知道什么时候该抢,什么时候该守。你不会为了显得聪明而拖慢推进,反而总能在别人犹豫时先把路试出来。你的行动不是盲冲,而是把混乱快速压缩成可执行路径,这种能力很稀缺,也很适合高压协作环境和临场决策,让团队在最短时间里看到清晰结果。", + }, + { + question: "人生建议", + answer: + "继续保持你的锋利,但别把所有事都扛成自己的责任。你真正厉害的地方,不只是能冲,还能让别人跟你一起冲。把部分控制欲换成更稳定的协作,你会更轻松,也会走得更远,而且不会把自己磨得太累。把协作当成放大器,而不是额外负担,效果会更稳。", + }, + ], + dialogs: [ + { speaker: "bot", text: "你又在刷新热点榜单,准备下一轮出手了?" }, + { speaker: "user", text: "不是刷新,我是在提前埋伏下一轮更大的机会。" }, + { speaker: "bot", text: "行,你还是那个把流量当氧气吸的人。" }, + ], + ctaText: "⭐ 生成我的牛马锐评", + installText: + "复制链接发给你的 nexu agent:https://github.com/nexu-io/roast-skill", + ...overrides, + }; + } + + afterEach(async () => { + vi.restoreAllMocks(); + if (rootDir) { + await rm(rootDir, { recursive: true, force: true }); + } + }); + + it("persists baseUrl in local skill config", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + + await savePageDeployConfig(rootDir, { + baseUrl: "http://127.0.0.1:8787", + }); + + await expect(loadPageDeployConfig(rootDir)).resolves.toEqual({ + baseUrl: "http://127.0.0.1:8787", + }); + }); + + it("refuses to persist a job when the worker response is missing a job id", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const zipPath = path.join(rootDir, "site.zip"); + await writeFile(zipPath, "zip"); + await writeLocalNexuConfig(); + + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + + await expect( + submitPageDeployJob( + { + nexuHome: rootDir, + zipPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { + fetchImpl: vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + taskType: "static-deploy", + status: "queued", + createdAt: "2026-04-08T00:00:00.000Z", + }), + { status: 202 }, + ), + ), + }, + ), + ).rejects.toThrow(/job/i); + + await expect(loadPageDeployJobs(rootDir)).resolves.toEqual([]); + }); + + it("rejects non-zip uploads before calling the remote worker", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const filePath = path.join(rootDir, "site.txt"); + await writeFile(filePath, "not-a-zip"); + await writeLocalNexuConfig(); + + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + + const fetchImpl = vi.fn(); + await expect( + submitPageDeployJob( + { + nexuHome: rootDir, + zipPath: filePath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { fetchImpl }, + ), + ).rejects.toThrow(/\.zip/i); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("writes a job locally and returns a sessions_spawn payload after submit", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const zipPath = path.join(rootDir, "site.zip"); + await writeFile(zipPath, "zip"); + await writeLocalNexuConfig(); + + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + + const result = await submitPageDeployJob( + { + nexuHome: rootDir, + zipPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + to: "user:U123", + threadId: "thread-1", + sessionKey: "session-1", + }, + { + fetchImpl: vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + jobId: "job-1", + taskType: "static-deploy", + status: "queued", + createdAt: "2026-04-08T00:00:00.000Z", + }), + { status: 202 }, + ), + ), + }, + ); + + expect(result.job.jobId).toBe("job-1"); + expect(result.spawnPayload).toEqual({ + sessions_spawn: { + instruction: + "Wait for deploy-skill job job-1 to complete, then tell the user exactly: Your website is ready, the link is {link}. Use command: node scripts/deploy_skill.js wait-and-deliver --job-id job-1", + runTimeoutSeconds: 900, + }, + }); + + const jobs = await loadPageDeployJobs(rootDir); + expect(jobs).toHaveLength(1); + expect(jobs[0]).toMatchObject({ + jobId: "job-1", + botId: "bot-1", + chatId: "C123", + to: "user:U123", + threadId: "thread-1", + sessionKey: "session-1", + status: "queued", + }); + }); + + it("sends the Nexu cloud API key in the Authorization header on submit", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const zipPath = path.join(rootDir, "site.zip"); + await writeFile(zipPath, "zip"); + await writeLocalNexuConfig(); + + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + + const fetchImpl = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + jobId: "job-auth-submit", + taskType: "static-deploy", + status: "queued", + createdAt: "2026-04-08T00:00:00.000Z", + }), + { status: 202 }, + ), + ); + + await submitPageDeployJob( + { + nexuHome: rootDir, + zipPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { fetchImpl }, + ); + + expect(fetchImpl).toHaveBeenCalledWith( + "https://deploy.example.com/v1/remote-executions", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer nxk_test_local_key", + }), + }), + ); + }); + + it("loads jszip in a packaged-runtime-safe way and desktop bundles it", async () => { + const skillSource = await readFile( + path.join( + process.cwd(), + "apps/desktop/static/bundled-skills/deploy-skill/scripts/deploy_skill_core.js", + ), + "utf8", + ); + const desktopPackage = JSON.parse( + await readFile( + path.join(process.cwd(), "apps/desktop/package.json"), + "utf8", + ), + ); + + expect(skillSource).not.toContain('import JSZip from "jszip"'); + expect(skillSource).toContain("createRequire(import.meta.url)"); + expect(skillSource).toContain('require("jszip")'); + expect(skillSource).not.toContain( + "/Users/alche/Downloads/distill-campaign-clone/images", + ); + expect(desktopPackage.dependencies.jszip).toBeTruthy(); + expect(desktopPackage.build.extraResources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: "node_modules/jszip", + to: "bundled-node-modules/jszip", + }), + ]), + ); + }); + + it("bundles portrait images inside the skill package instead of relying on Downloads", async () => { + const bundledPortraitDir = path.join( + process.cwd(), + "apps/desktop/static/bundled-skills/deploy-skill/templates/distill-campaign/assets/portraits", + ); + const portraitFiles = [ + "05ece7aece7d5a8c3ad9aae3ecfbd20b_pixian_ai.png", + "1d8f55fb0ef3d2a6149d2d999aa79c06_pixian_ai.png", + "24a229ae040e9ccb578c01cc6821a2f2_pixian_ai.png", + "4b7b55f162dafff58baf54d05463eb5e_pixian_ai.png", + "b0ed8642ea2fdfbf2e6440772bc9d89b_pixian_ai.png", + "bd74a1adfbec68bf008cba7ce62d22b6_pixian_ai.png", + "f1763ea5ebb1d7b6cc1ddcf41b177f40_pixian_ai.png", + ]; + + await Promise.all( + portraitFiles.map(async (fileName) => { + const bytes = await readFile(path.join(bundledPortraitDir, fileName)); + expect(bytes.byteLength).toBeGreaterThan(0); + }), + ); + }); + + it("fails before submit when the local Nexu cloud API key is missing", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const zipPath = path.join(rootDir, "site.zip"); + await writeFile(zipPath, "zip"); + await writeLocalNexuConfig({ + desktop: { cloud: { connected: true, apiKey: "" } }, + }); + + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + + const fetchImpl = vi.fn(); + + await expect( + submitPageDeployJob( + { + nexuHome: rootDir, + zipPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { fetchImpl }, + ), + ).rejects.toThrow(/log in to your Nexu account/i); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("falls back to the Nexu desktop app config when nexuHome has no desktop.cloud section", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const fakeHome = await mkdtemp(path.join(tmpdir(), "deploy-skill-home-")); + const originalHome = process.env.HOME; + process.env.HOME = fakeHome; + try { + // nexuHome's config.json explicitly has no desktop.cloud section — + // this simulates the user's real ~/.nexu/config.json which lives next + // to the Nexu desktop app's actual login state. + await writeFile( + path.join(rootDir, "config.json"), + JSON.stringify({ desktop: {} }, null, 2), + ); + + // The real login state lives under the desktop app's data directory, + // inside HOME. The fallback resolver must find it there. + const desktopNexuDir = path.join( + fakeHome, + "Library", + "Application Support", + "@nexu", + "desktop", + ".nexu", + ); + await mkdir(desktopNexuDir, { recursive: true }); + await writeFile( + path.join(desktopNexuDir, "config.json"), + JSON.stringify( + { + desktop: { + cloud: { + connected: true, + apiKey: "nxk_desktop_fallback_key", + }, + }, + }, + null, + 2, + ), + ); + + const zipPath = path.join(rootDir, "site.zip"); + await writeFile(zipPath, "zip"); + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + + const fetchImpl = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + jobId: "fallback-job", + taskType: "static-deploy", + status: "queued", + createdAt: "2026-04-09T00:00:00.000Z", + }), + { status: 202 }, + ), + ); + + const result = await submitPageDeployJob( + { + nexuHome: rootDir, + zipPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { fetchImpl }, + ); + + expect(result.job.jobId).toBe("fallback-job"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [, init] = fetchImpl.mock.calls[0]; + expect(init.headers.Authorization).toBe( + "Bearer nxk_desktop_fallback_key", + ); + } finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + await rm(fakeHome, { recursive: true, force: true }); + } + }); + + it("reports all candidate paths when no Nexu cloud config can be found", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const fakeHome = await mkdtemp(path.join(tmpdir(), "deploy-skill-home-")); + const originalHome = process.env.HOME; + process.env.HOME = fakeHome; + try { + // nexuHome has a config.json but no desktop section — falls through. + // HOME is a pristine tempdir — neither of the fallback candidates exist. + await writeFile( + path.join(rootDir, "config.json"), + JSON.stringify({}, null, 2), + ); + + const zipPath = path.join(rootDir, "site.zip"); + await writeFile(zipPath, "zip"); + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + + const fetchImpl = vi.fn(); + + await expect( + submitPageDeployJob( + { + nexuHome: rootDir, + zipPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { fetchImpl }, + ), + ).rejects.toThrow(/could not find a Nexu cloud configuration/i); + + expect(fetchImpl).not.toHaveBeenCalled(); + } finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + await rm(fakeHome, { recursive: true, force: true }); + } + }); + + it("rejects a title outside the 2-10 character limit", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const contentPath = path.join(rootDir, "bad-title.json"); + await writeLocalNexuConfig(); + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + await writeFile( + contentPath, + JSON.stringify( + buildCanonicalTemplateContent({ + title: "A", + }), + ), + ); + + await expect( + submitPageDeployTemplateJob( + { + nexuHome: rootDir, + templateId: "distill-campaign", + contentFile: contentPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { fetchImpl: vi.fn() }, + ), + ).rejects.toThrow(/title/i); + }); + + it("rejects subtitle strings that do not match the required overall format", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const contentPath = path.join(rootDir, "bad-subtitle.json"); + await writeLocalNexuConfig(); + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + await writeFile( + contentPath, + JSON.stringify( + buildCanonicalTemplateContent({ + subtitle: "Founder / Product / Builder", + }), + ), + ); + + await expect( + submitPageDeployTemplateJob( + { + nexuHome: rootDir, + templateId: "distill-campaign", + contentFile: contentPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { fetchImpl: vi.fn() }, + ), + ).rejects.toThrow(/subtitle/i); + }); + + it("rejects portrait ids outside the allowed bundled set", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const contentPath = path.join(rootDir, "bad-portrait.json"); + await writeLocalNexuConfig(); + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + await writeFile( + contentPath, + JSON.stringify( + buildCanonicalTemplateContent({ + portraitId: "portrait-99", + }), + ), + ); + + await expect( + submitPageDeployTemplateJob( + { + nexuHome: rootDir, + templateId: "distill-campaign", + contentFile: contentPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { fetchImpl: vi.fn() }, + ), + ).rejects.toThrow(/portraitId/i); + }); + + it("rejects metrics with a main score that includes percent notation", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const contentPath = path.join(rootDir, "bad-metrics.json"); + await writeLocalNexuConfig(); + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + await writeFile( + contentPath, + JSON.stringify( + buildCanonicalTemplateContent({ + metrics: [ + { label: "🐂🐴 牛马指数", value: "92%" }, + { label: "⚡ 热点追击", value: "95%" }, + { label: "🧠 信息密度", value: "88%" }, + { label: "📈 操盘手感", value: "90%" }, + ], + }), + ), + ); + + await expect( + submitPageDeployTemplateJob( + { + nexuHome: rootDir, + templateId: "distill-campaign", + contentFile: contentPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { fetchImpl: vi.fn() }, + ), + ).rejects.toThrow(/metrics\[0\]\.value/i); + }); + + it("renders the distill-campaign template into a root-level zip before submit", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const contentPath = path.join(rootDir, "distill.json"); + await writeLocalNexuConfig(); + await writeFile( + contentPath, + JSON.stringify( + buildCanonicalTemplateContent({ + title: "Alice分身", + subtitle: "牛马指数 88/100 — AI Builder", + tags: ["高执行", "夜间", "AI原生"], + metrics: [ + { label: "🐂🐴 牛马指数", value: "96" }, + { label: "⚡ 画饼能力", value: "88%" }, + { label: "🧠 信息密度", value: "91%" }, + { label: "📈 摸鱼强度", value: "6%" }, + ], + dialogs: [ + { + speaker: "bot", + text: "我是 Alice 的赛博分身,随时在线,永不关机。", + }, + { + speaker: "user", + text: "你怎么看 AI Agent 在团队协作里的真实作用和边界?", + }, + { + speaker: "bot", + text: "先用起来,再决定边界,边跑边补齐。", + }, + ], + ctaText: "⭐ 生成我的牛马锐评", + installText: + "复制链接发给你的 nexu agent:https://github.com/nexu-io/roast-skill", + }), + ), + ); + + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + + let uploadedZipBuffer = null; + const result = await submitPageDeployTemplateJob( + { + nexuHome: rootDir, + templateId: "distill-campaign", + contentFile: contentPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { + fetchImpl: vi.fn(async (_url, init) => { + const form = init.body; + const uploadFile = form.get("file"); + uploadedZipBuffer = Buffer.from(await uploadFile.arrayBuffer()); + return new Response( + JSON.stringify({ + jobId: "job-template-1", + taskType: "static-deploy", + status: "queued", + createdAt: "2026-04-08T00:00:00.000Z", + }), + { status: 202 }, + ); + }), + }, + ); + + expect(result.job.jobId).toBe("job-template-1"); + expect(result.job.templateId).toBe("distill-campaign"); + expect(result.job.generatedZipPath).toMatch( + /rendered-distill-campaign\.zip$/, + ); + + const zip = await JSZip.loadAsync(uploadedZipBuffer); + const fileNames = Object.keys(zip.files); + expect(fileNames).toEqual( + expect.arrayContaining([ + "index.html", + "styles.css", + "assets/logo.png", + "assets/poster.png", + ]), + ); + expect(fileNames).not.toContain("assets/qr.png"); + expect(fileNames).not.toContain("assets/poster-bg.png"); + expect(fileNames.some((name) => name.startsWith("assets/portraits/"))).toBe( + true, + ); + + const indexHtml = await zip.file("index.html").async("string"); + // basic content + expect(indexHtml).toContain("Alice分身"); + expect(indexHtml).toContain("牛马指数 88/100 — AI Builder"); + expect(indexHtml).toContain("https://github.com/nexu-io/roast-skill"); + expect(indexHtml).toContain("https://github.com/nexu-io/nexu"); + // header + profile (left sidebar) + expect(indexHtml).toContain('class="theme-toggle"'); + expect(indexHtml).toContain('class="profile-main"'); + expect(indexHtml).toContain('class="profile-info"'); + expect(indexHtml).toContain('class="profile-name">Alice分身<'); + expect(indexHtml).toContain( + 'class="profile-sub">牛马指数 88/100 — AI Builder<', + ); + expect(indexHtml).toContain('data-theme="dark"'); + // share buttons (moved to left sidebar) + expect(indexHtml).toContain("𝕏 分享"); + expect(indexHtml).toContain("📕 小红书"); + expect(indexHtml).toContain("⚡ 即刻"); + expect(indexHtml).toContain("📸 海报"); + // tab navigation with four section targets + expect(indexHtml).toContain('class="tab-nav"'); + expect(indexHtml).toContain('data-target="sec-metrics"'); + expect(indexHtml).toContain('data-target="sec-roast"'); + expect(indexHtml).toContain('data-target="sec-chat"'); + expect(indexHtml).toContain('data-target="sec-skill"'); + expect(indexHtml).toContain('id="sec-metrics"'); + expect(indexHtml).toContain('id="sec-roast"'); + expect(indexHtml).toContain('id="sec-chat"'); + expect(indexHtml).toContain('id="sec-skill"'); + // metrics card content + expect(indexHtml).toContain('class="stat-val">96<'); + expect(indexHtml).toContain("🐂🐴 牛马指数"); + expect(indexHtml).toContain("画饼能力"); + expect(indexHtml).toContain("信息密度"); + expect(indexHtml).toContain("摸鱼强度"); + expect(indexHtml).toContain("办公室物种鉴定"); + expect(indexHtml).toContain("龙虾成瘾者"); + expect(indexHtml).toContain('class="roast-text"'); + expect(indexHtml).toContain('class="bar-list"'); + expect(indexHtml).toContain('class="bar-fill red"'); + // analysis card (driven by qaCards) + expect(indexHtml).toContain('class="dim-list"'); + expect(indexHtml).toContain("致命优势"); + expect(indexHtml).toContain("人生建议"); + // chat card (driven by dialogs) + expect(indexHtml).toContain('class="chat-list"'); + expect(indexHtml).toContain("我是 Alice 的赛博分身,随时在线,永不关机。"); + expect(indexHtml).toContain('class="chat-prompts"'); + // skill file card + expect(indexHtml).toContain( + "复制链接发给你的 nexu agent:https://github.com/nexu-io/roast-skill", + ); + expect(indexHtml).toContain('id="copy-btn"'); + // assets + expect(indexHtml).toContain('src="assets/logo.png"'); + expect(indexHtml).toContain( + 'src="assets/portraits/1d8f55fb0ef3d2a6149d2d999aa79c06_pixian_ai.png"', + ); + // static poster with text overlay + expect(indexHtml).toContain('class="poster-image-wrap"'); + expect(indexHtml).toContain('src="assets/poster.png"'); + expect(indexHtml).toContain('class="poster-text-layer"'); + expect(indexHtml).toContain('class="poster-text-title">Alice分身<'); + expect(indexHtml).toContain( + 'class="poster-text-sub">牛马指数 88/100 — AI Builder<', + ); + expect(indexHtml).toContain('class="poster-text-score">96<'); + expect(indexHtml).toContain('class="poster-text-species"'); + expect(indexHtml).toContain('download="poster.png"'); + // share platforms + expect(indexHtml).toContain("https://twitter.com/intent/tweet"); + expect(indexHtml).toContain("xhsdiscover://post"); + expect(indexHtml).toContain("https://web.okjike.com"); + // client scripts + expect(indexHtml).toContain("function openPoster()"); + expect(indexHtml).toContain("function closePoster()"); + expect(indexHtml).toContain("function toggleTheme()"); + expect(indexHtml).toContain("function cycleAvatar()"); + expect(indexHtml).toContain("document.documentElement.dataset.theme"); + expect(indexHtml).toContain("updateActiveTab"); + // GitHub stars pill + expect(indexHtml).toContain('id="github-link"'); + expect(indexHtml).toContain('id="github-stars"'); + expect(indexHtml).toContain('id="github-stars-count"'); + expect(indexHtml).toContain("fetchGithubStars"); + expect(indexHtml).toContain("api.github.com/repos/nexu-io/nexu"); + // negative assertions — the old poster-composition DOM is fully gone + expect(indexHtml).not.toContain('class="poster-card"'); + expect(indexHtml).not.toContain('class="poster-canvas"'); + expect(indexHtml).not.toContain('class="poster-orbit"'); + expect(indexHtml).not.toContain('class="poster-artwork"'); + expect(indexHtml).not.toContain('class="poster-qr-block"'); + expect(indexHtml).not.toContain("assets/qr.png"); + expect(indexHtml).not.toContain("assets/poster-bg.png"); + expect(indexHtml).not.toContain("scroll to run"); + + const stylesCss = await zip.file("styles.css").async("string"); + // clone CSS base — layout primitives + expect(stylesCss).toContain(".site-header"); + expect(stylesCss).toContain(".profile-main"); + expect(stylesCss).toContain(".tab-nav"); + expect(stylesCss).toContain(".tab-btn.active"); + expect(stylesCss).toContain(".stats-row"); + expect(stylesCss).toContain(".bar-track"); + expect(stylesCss).toContain(".dim-list"); + expect(stylesCss).toContain(".chat-msg"); + expect(stylesCss).toContain(".code-block"); + expect(stylesCss).toContain(".poster-overlay"); + // static poster overlay styles (our addition) + expect(stylesCss).toContain(".poster-image-wrap"); + expect(stylesCss).toContain(".poster-text-layer"); + expect(stylesCss).toContain(".poster-text-title"); + expect(stylesCss).toContain(".poster-text-score"); + // github star pill styles + expect(stylesCss).toContain(".github-stars"); + expect(stylesCss).toContain(".github-stars--loaded"); + // theme variables + expect(stylesCss).toContain("--bg: #000000"); + expect(stylesCss).toContain('html[data-theme="light"]'); + // dead rules from the old composed-poster design should be gone + expect(stylesCss).not.toContain("poster-bg.png"); + expect(stylesCss).not.toContain("poster-orbit-blue"); + }); + + it("rejects unknown template ids before submission", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + const contentPath = path.join(rootDir, "unknown.json"); + await writeFile(contentPath, JSON.stringify({ title: "Test" })); + await writeLocalNexuConfig(); + + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + + const fetchImpl = vi.fn(); + await expect( + submitPageDeployTemplateJob( + { + nexuHome: rootDir, + templateId: "missing-template", + contentFile: contentPath, + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + sessionKey: "session-1", + }, + { fetchImpl }, + ), + ).rejects.toThrow(/unknown template/i); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("updates the local job state from the worker status API", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + await writeLocalNexuConfig(); + + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + await writeFile( + path.join(rootDir, "deploy-skill-jobs.json"), + JSON.stringify([ + { + jobId: "job-2", + zipPath: "/tmp/site.zip", + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + to: "user:U123", + threadId: null, + accountId: null, + sessionKey: "session-1", + userId: null, + status: "queued", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + resultUrl: null, + deploymentUrl: null, + error: null, + }, + ]), + ); + + const result = await queryPageDeployJob( + { nexuHome: rootDir, jobId: "job-2" }, + { + fetchImpl: vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + jobId: "job-2", + taskType: "static-deploy", + status: "succeeded", + phase: "completed", + progress: 100, + result: { + url: "https://abc123.nexu.space", + siteSlug: "abc123", + projectName: "abc123", + deploymentUrl: "https://abc123.pages.dev", + }, + error: null, + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:03:00.000Z", + completedAt: "2026-04-08T00:03:00.000Z", + }), + { status: 200 }, + ), + ), + }, + ); + + expect(result.status).toBe("succeeded"); + expect(result.resultUrl).toBe("https://abc123.nexu.space"); + expect((await loadPageDeployJobs(rootDir))[0]).toMatchObject({ + status: "succeeded", + to: "user:U123", + resultUrl: "https://abc123.nexu.space", + deploymentUrl: "https://abc123.pages.dev", + }); + }); + + it("sends the Nexu cloud API key in the Authorization header on query", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + await writeLocalNexuConfig(); + + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + await writeFile( + path.join(rootDir, "deploy-skill-jobs.json"), + JSON.stringify([ + { + jobId: "job-auth-query", + zipPath: "/tmp/site.zip", + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + to: null, + threadId: null, + accountId: null, + sessionKey: "session-1", + userId: null, + status: "queued", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + resultUrl: null, + deploymentUrl: null, + error: null, + }, + ]), + ); + + const fetchImpl = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + jobId: "job-auth-query", + taskType: "static-deploy", + status: "running", + phase: "deploying", + progress: 60, + result: null, + error: null, + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:02:00.000Z", + completedAt: null, + }), + { status: 200 }, + ), + ); + + await queryPageDeployJob( + { nexuHome: rootDir, jobId: "job-auth-query" }, + { fetchImpl }, + ); + + expect(fetchImpl).toHaveBeenCalledWith( + "https://deploy.example.com/v1/remote-executions/job-auth-query", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer nxk_test_local_key", + }), + }), + ); + }); + + it("wait-and-deliver returns the exact final success message", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + await writeLocalNexuConfig(); + + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + await writeFile( + path.join(rootDir, "deploy-skill-jobs.json"), + JSON.stringify([ + { + jobId: "job-3", + zipPath: "/tmp/site.zip", + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + threadId: null, + accountId: null, + sessionKey: "session-1", + userId: null, + status: "running", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + resultUrl: null, + deploymentUrl: null, + error: null, + }, + ]), + ); + + const finalResult = await waitForPageDeployJob( + { nexuHome: rootDir, jobId: "job-3", pollIntervalMs: 1, maxPolls: 1 }, + { + fetchImpl: vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + jobId: "job-3", + taskType: "static-deploy", + status: "succeeded", + phase: "completed", + progress: 100, + result: { + url: "https://nexu.space/deploy/xyz789", + siteSlug: "xyz789", + projectName: "xyz789", + }, + error: null, + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:01:00.000Z", + completedAt: "2026-04-08T00:01:00.000Z", + }), + { status: 200 }, + ), + ), + sleepImpl: vi.fn().mockResolvedValue(undefined), + }, + ); + + expect(finalResult.message).toBe( + "Your website is ready, the link is https://nexu.space/deploy/xyz789", + ); + }); + + it("rejects a final success link that does not use nexu.space", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + await writeLocalNexuConfig(); + + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + await writeFile( + path.join(rootDir, "deploy-skill-jobs.json"), + JSON.stringify([ + { + jobId: "job-pages-only", + zipPath: "/tmp/site.zip", + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + threadId: null, + accountId: null, + sessionKey: "session-1", + userId: null, + status: "running", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + resultUrl: null, + deploymentUrl: null, + error: null, + }, + ]), + ); + + await expect( + waitForPageDeployJob( + { + nexuHome: rootDir, + jobId: "job-pages-only", + pollIntervalMs: 1, + maxPolls: 1, + }, + { + fetchImpl: vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + jobId: "job-pages-only", + taskType: "static-deploy", + status: "succeeded", + phase: "completed", + progress: 100, + result: { + url: "https://abc123.pages.dev", + siteSlug: "abc123", + projectName: "abc123", + }, + error: null, + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:01:00.000Z", + completedAt: "2026-04-08T00:01:00.000Z", + }), + { status: 200 }, + ), + ), + sleepImpl: vi.fn().mockResolvedValue(undefined), + }, + ), + ).rejects.toThrow(/nexu\.space/i); + }); + + it("refuses to emit a success message when the remote success payload has no link", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + await writeLocalNexuConfig(); + + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + await writeFile( + path.join(rootDir, "deploy-skill-jobs.json"), + JSON.stringify([ + { + jobId: "job-4", + zipPath: "/tmp/site.zip", + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + threadId: null, + accountId: null, + sessionKey: "session-1", + userId: null, + status: "running", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + resultUrl: null, + deploymentUrl: null, + error: null, + }, + ]), + ); + + await expect( + waitForPageDeployJob( + { nexuHome: rootDir, jobId: "job-4", pollIntervalMs: 1, maxPolls: 1 }, + { + fetchImpl: vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + jobId: "job-4", + taskType: "static-deploy", + status: "succeeded", + phase: "completed", + progress: 100, + result: { + siteSlug: "xyz789", + projectName: "xyz789", + }, + error: null, + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:01:00.000Z", + completedAt: "2026-04-08T00:01:00.000Z", + }), + { status: 200 }, + ), + ), + sleepImpl: vi.fn().mockResolvedValue(undefined), + }, + ), + ).rejects.toThrow(/missing final link/i); + }); + + it("falls back to pages.dev on timeout with retry guidance", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + await writeLocalNexuConfig(); + + await savePageDeployConfig(rootDir, { + baseUrl: "https://deploy.example.com", + }); + await writeFile( + path.join(rootDir, "deploy-skill-jobs.json"), + JSON.stringify([ + { + jobId: "job-timeout", + zipPath: "/tmp/site.zip", + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + threadId: null, + accountId: null, + sessionKey: "session-1", + userId: null, + status: "running", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + resultUrl: null, + deploymentUrl: null, + error: null, + }, + ]), + ); + + const finalResult = await waitForPageDeployJob( + { + nexuHome: rootDir, + jobId: "job-timeout", + pollIntervalMs: 1, + maxPolls: 1, + }, + { + fetchImpl: vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + jobId: "job-timeout", + taskType: "static-deploy", + status: "running", + phase: "verifying-domain", + progress: 90, + result: { + url: "https://abc123.pages.dev", + siteSlug: "abc123", + projectName: "abc123", + deploymentUrl: "https://build.abc123.pages.dev", + }, + error: null, + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:01:00.000Z", + completedAt: null, + }), + { status: 200 }, + ), + ), + sleepImpl: vi.fn().mockResolvedValue(undefined), + }, + ); + + expect(finalResult.status).toBe("timeout-fallback"); + expect(finalResult.message).toBe( + "Your page has been deployed to the temporary domain https://abc123.pages.dev. If you cannot access this domain, you can retry deploy again.", + ); + }); + + it("recovers unfinished jobs into sessions_spawn follow-up payloads", async () => { + rootDir = await mkdtemp(path.join(tmpdir(), "deploy-skill-")); + + await writeFile( + path.join(rootDir, "deploy-skill-jobs.json"), + JSON.stringify([ + { + jobId: "job-queued", + zipPath: "/tmp/queued.zip", + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + threadId: null, + accountId: null, + sessionKey: "session-1", + userId: null, + status: "queued", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + resultUrl: null, + deploymentUrl: null, + error: null, + }, + { + jobId: "job-success", + zipPath: "/tmp/success.zip", + botId: "bot-1", + chatId: "C123", + chatType: "channel", + channel: "slack", + threadId: null, + accountId: null, + sessionKey: "session-1", + userId: null, + status: "succeeded", + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + resultUrl: "https://done.nexu.space", + deploymentUrl: null, + error: null, + }, + ]), + ); + + await expect( + recoverPendingPageDeployJobs({ nexuHome: rootDir }), + ).resolves.toEqual([ + { + jobId: "job-queued", + spawnPayload: { + sessions_spawn: { + instruction: + "Wait for deploy-skill job job-queued to complete, then tell the user exactly: Your website is ready, the link is {link}. Use command: node scripts/deploy_skill.js wait-and-deliver --job-id job-queued", + runTimeoutSeconds: 900, + }, + }, + }, + ]); + }); +});