Skill: magicscape/mofang-page-builder (version 1.1.0)

--- name: mofang-page-builder description: Build Magicflu/Mofang web-table custom pages from the jsonv2 records API. Use when users ask for Magicflu/Mofang forms, records, lists, create/edit/detail/admin CRUD pages, H5 extension pages, React/Vue/vanilla JS examples, local jsonv2 mock/proxy debugging, or same-origin publishing to Magicflu websites. Works as a standalone skill for Codex/OpenAI, OpenClaw, Claude Code, Trae, and Trae CN. metadata: version: 1.1.0 author: magicscape platforms: - Codex - OpenAI - OpenClaw - Claude Code - Trae - Trae CN compatibility: node: ">=18" --- # 魔方网表页面生成 Use this skill to generate and publish Magicflu/Mofang web-table pages. The safe path is: 1. Confirm `spaceId`, `formId`, and page type. 2. Fetch or import the real `fielddef`. 3. Generate page code using only field `name` values from `fielddef.fields[]`. 4. Test with local `mock-jsonv2` or proxy. 5. Set `CONFIG.apiBase` to `''` before same-origin publishing. ## Install Locations Copy the whole `mofang-page-builder/` folder, not only `SKILL.md`. | Client | Project install | Global install | |--------|-----------------|----------------| | Claude Code | `.claude/skills/mofang-page-builder/` | `~/.claude/skills/mofang-page-builder/` | | Trae | `.trae/skills/mofang-page-builder/` | `~/.trae/skills/mofang-page-builder/` | | Trae CN | `.trae/skills/mofang-page-builder/` | `~/.trae-cn/skills/mofang-page-builder/` | Keep these directories with the skill: `examples/`, `assets/`, `references/`, and `scripts/`. ## Required Workflow Never guess real form field names from Chinese labels. For real data: 1. Ask for or identify `spaceId` and `formId`. - If only a space name is known, resolve it with [REDACTED_SECRET]. - To traverse all spaces, use `/magicflu/service/json/spaces/feed?start=0&limit=-1&bq=(created,orderby,desc)`. - If only a form label is known, resolve it with `/magicflu/service/s/json/{spaceId}/forms/feed`. 2. Run `scripts/fetch-form-spec.mjs`, or ask the user for an exported fielddef JSON and import it. 3. Use only `fielddef.fields[].name` as API keys, submit keys, and filter field names. 4. Use `fielddef.fields[].label` only for UI text. 5. Test locally before deployment. Example fields in `examples/` and `assets/mock-data/` are demos only. ## Page Contract Generated pages must include: ```js const CONFIG = { apiBase: '', // local debug: 'http://127.0.0.1:3847'; same-origin production: '' spaceId: 'YOUR_SPACE_ID', formId: 'YOUR_FORM_ID', }; function apiUrl(path) { const origin = CONFIG.apiBase ? CONFIG.apiBase.replace(/\/$/, '') : ''; return `${origin}${path}`; } ``` Same-origin pages rely on browser Cookie auth. Do not set an `Authorization` header in page code. ## API Summary Read `references/api-summary.md` when you need endpoint details, bq syntax, response shape, or field value formats. Core paths: | Capability | Method | Path | |------------|--------|------| | List all spaces | GET | `/magicflu/service/json/spaces/feed?start=0&limit=-1&bq=(created,orderby,desc)` | | Find spaces by label | GET | `/magicflu/service/json/spaces/feed?start=0&limit=10&bq=(label,eq,{spaceLabel})` | | List forms in a space | GET | `/magicflu/service/s/json/{spaceId}/forms/feed?start=0&limit=-1` | | Find forms by label | GET | `/magicflu/service/s/json/{spaceId}/forms/feed?start=0&limit=10&bq=(label,eq,{formLabel})` | | Field definition | GET | `/magicflu/service/s/jsonv2/{spaceId}/forms/{formId}?selector=fielddef&lng=en` | | List records | GET | `/magicflu/service/s/jsonv2/{spaceId}/forms/{formId}/records/entry` | | Create record | POST | `/magicflu/service/s/jsonv2/{spaceId}/forms/{formId}/records` | | Update record | PUT | `/magicflu/service/s/jsonv2/{spaceId}/forms/{formId}/records/entry/{recordId}` | | Delete record | DELETE | `/magicflu/service/s/jsonv2/{spaceId}/forms/{formId}/records/entry/{recordId}` | Use the spaces endpoint when the user gives only a space name. The returned space id is `items[].id`; the display name is `items[].label`. Use the form-list endpoint when the user gives only a `spaceId` or form label. The returned form id is `feed.entry[].id`; the display name is `feed.entry[].content.form.label`; the English form key is `feed.entry[].content.form.name`. Field rules: - API payload keys must be `name`, not `label`. - Omit empty values before submit. - Never submit `""` for number fields. - Do not submit system, serial, secondary reference, image, attachment, location, webpage, comment, or external field-group fields. ## Fetch Field Definitions Account/password: ```bash BASE_URL="http://appdev.com.magicflu.com:9999" \ MOFANG_USERNAME="你的账号" \ MOFANG_PASSWORD='你的密码' \ node scripts/fetch-form-spec.mjs \ --spaceId <空间UUID> \ --formId <表单UUID> \ --out ./mock-data ``` If only names are known, resolve and fetch in one command: ```bash BASE_URL="http://appdev.com.magicflu.com:9999" \ MOFANG_USERNAME="你的账号" \ MOFANG_PASSWORD='你的密码' \ node scripts/fetch-form-spec.mjs \ --spaceLabel "空间名称" \ --formLabel "表单名称" \ --out ./mock-data ``` Cookie: ```bash node scripts/fetch-form-spec.mjs \ --baseUrl https://你的魔方域名 \ --spaceId <空间UUID> \ --formId <表单UUID> \ --out ./mock-data \ --cookie "浏览器 Cookie" ``` Offline import: ```bash node scripts/fetch-form-spec.mjs \ --spaceId <空间UUID> \ --formId <表单UUID> \ --out ./mock-data \ --import-json ./fielddef-export.json ``` Output: `mock-data/manifest.json`, `mock-data/<formId>/fielddef.json`, `records.seed.json`, `typesnippets.md`, and `api-outline.md`. ## Local Debug Use the bundled demo data: ```bash cp -R assets/mock-data ./mock-data node scripts/mock-jsonv2.mjs --port 3847 --dir ./mock-data ``` Set page config: ```js CONFIG.apiBase = 'http://127.0.0.1:3847'; ``` Proxy to a real environment only when the user explicitly wants real data behavior: ```bash BASE_URL="http://appdev.com.magicflu.com:9999" \ MOFANG_USERNAME="你的账号" \ MOFANG_PASSWORD='你的密码' \ node scripts/mock-jsonv2.mjs --mode proxy --port 3847 ``` Proxy mode forwards supported space, `json`, and `jsonv2` calls to the real environment. `POST`, `PUT`, and `DELETE` will change real records. ## Publish Same-Origin Pages Before publishing, set page `CONFIG.apiBase` to `''`. ```bash BASE_URL="http://appdev.com.magicflu.com:9999" \ MOFANG_USERNAME="你的账号" \ MOFANG_PASSWORD='你的密码' \ node scripts/deploy.mjs \ --spaceId <空间UUID> \ --label "站点名称" \ --shortcut custom-page \ --files index.html ``` The deploy script logs in, creates a website, warms the session, uploads files through `filemanager.jsp`, and prints the final URL: ```text {BASE_URL}/magicflu/html/sites/userfiles/{spaceId}/{websiteId}/index.html ``` Read `references/design.md`, `references/requirements.md`, and `references/filemanager.js` only when debugging implementation details. ## Examples | Stack | File | Purpose | |-------|------|---------| | Vanilla HTML+JS | `examples/vanilla/list-page.html` | Same-origin list page | | Vanilla HTML+JS | `examples/vanilla/list-page-with-apibase.html` | List page wired to local mock | | Vanilla HTML+JS | `examples/vanilla/form-page.html` | Create-record form | | React | `examples/react/MagicfluList.tsx` | List component | | Vue | `examples/vue/MagicfluForm.md` | Text-safe create-record form component | ## Validation Run these from the skill root: ```bash npm run check:scripts npm run smoke:fetch-import npm run smoke:mock npm run smoke:upload ```

path: .gitignore
content: # skill 根目录下拉取的 fielddef 工作副本，勿提交；不影响 assets/mock-data 模板
/mock-data/

# 打包和跨平台噪音
.DS_Store
__MACOSX/
*.zip
publish/

# 另存 TRAE 文档产生的静态资源体积大，可不纳入版本库
references/*_files/


path: package.json
content: {
  "name": "mofang-page-builder",
  "version": "1.1.0",
  "private": true,
  "description": "Standalone Magicflu/Mofang page-builder skill for Codex/OpenAI, OpenClaw, Claude Code, Trae, and Trae CN",
  "type": "module",
  "scripts": {
    "deploy": "node scripts/deploy.mjs",
    "fetch-spec": "node scripts/fetch-form-spec.mjs",
    "mock": "node scripts/mock-jsonv2.mjs --port 3847 --dir ./assets/mock-data",
    "mock:proxy": "node scripts/mock-jsonv2.mjs --mode proxy --port 3847",
    "check:scripts": "node --check scripts/deploy.mjs && node --check scripts/fetch-form-spec.mjs && node --check scripts/mock-jsonv2.mjs && node --check scripts/pack-skill.mjs && node --check scripts/lib/magicflu-session.mjs && node --check scripts/upload-flow-smoke.mjs && node --check scripts/mock-jsonv2-smoke.mjs && node --check scripts/fetch-import-smoke.mjs",
    "smoke:upload": "node scripts/upload-flow-smoke.mjs",
    "smoke:mock": "node scripts/mock-jsonv2-smoke.mjs",
    "smoke:fetch-import": "node scripts/fetch-import-smoke.mjs",
    "pack": "node scripts/pack-skill.mjs"
  },
  "engines": {
    "node": ">=18"
  }
}


path: scripts/deploy.mjs
content: #!/usr/bin/env node
/**
 * 魔方网表页面部署脚本
 *
 * 流程：创建站点 → 上传文件
 *
 * 鉴权：
 *   - 推荐：--username + --password（或 MOFANG_USERNAME + MOFANG_PASSWORD）
 *     先 POST /magicflu/jwt 取得 token 与 Cookie；**创建站点** 请求会带 Cookie + Authorization: Bearer（与 UI 一致）。
 *     **上传文件** 则 POST 到 /magicflu/html/sites/connectors/jsp/filemanager.jsp?spaceId=…，**仅 Cookie**、Referer 为 site.jsp（与浏览器里站点文件上传一致，不用 websites/upload）。
 *   - 备选：--cookie / COOKIE — 可单独或与账号密码合并（Cookie 头会与 JWT 登录结果合并）
 *
 * 用法：
 *   node scripts/deploy.mjs --spaceId xxx --label "我的站点" --shortcut mysite --files index.html \\
 *     --username u --password 'p'
 *   node scripts/deploy.mjs --spaceId xxx --label "站点" --shortcut s1 --files a.html,b.css --cookie "JSESSIONID=xxx"
 *
 * 环境变量：BASE_URL、SPACE_ID、COOKIE、MOFANG_USERNAME、MOFANG_PASSWORD（亦支持 MAGICFLU_USERNAME / MAGICFLU_PASSWORD）
 *          FETCH_TIMEOUT_MS（可选，默认 120000）：单次 HTTP 超时毫秒数，避免服务无响应时长时间卡住
 */

import { readFileSync } from 'fs';
import { resolve, basename } from 'path';
import {
  normalizeBaseUrl,
  fetchWithTimeout,
  pairsFromSetCookieResponse,
  mergeCookieHeader,
  obtainSessionCookieFromCredentials,
} from './lib/magicflu-session.mjs';

/** 打开站点页（与实际上传时的 Referer 一致），合并 Set-Cookie，利于 filemanager 上传识别会话 */
async function warmSessionViaSiteJsp(baseUrl, spaceId, websiteId, cookie, bearerToken) {
  const root = normalizeBaseUrl(baseUrl);
  const url = `${root}/magicflu/html/sites/site.jsp?spaceId=${encodeURIComponent(spaceId)}&websiteId=${encodeURIComponent(websiteId)}`;
  const headers = {
    Cookie: cookie,
    Authorization: `Bearer ${bearerToken}`,
  };
  const res = await fetchWithTimeout(url, { headers });
  const merged = mergeCookieHeader(cookie, pairsFromSetCookieResponse(res).join('; '));
  await res.text().catch(() => '');
  return merged;
}

/** multipart 中文件部分的 Content-Type，与浏览器 DevTools 中常见类型对齐 */
function mimeForUploadFilename(name) {
  const ext = String(name).toLowerCase().split('.').pop() || '';
  const map = {
    html: 'text/html',
    htm: 'text/html',
    css: 'text/css',
    js: 'application/javascript',
    mjs: 'application/javascript',
    json: 'application/json',
    png: 'image/png',
    jpg: 'image/jpeg',
    jpeg: 'image/jpeg',
    gif: 'image/gif',
    svg: 'image/svg+xml',
    woff: 'font/woff',
    woff2: 'font/woff2',
  };
  return map[ext] || 'application/octet-stream';
}

// ========== 解析参数 ==========
function parseArgs() {
  const args = process.argv.slice(2);
  const opts = {
    spaceId: process.env.SPACE_ID,
    baseUrl: process.env.BASE_URL || 'http://localhost:8080',
    label: '魔方定制页',
    shortcut: 'custom',
    desc: '',
    files: [],
    cookie: process.env.COOKIE || null,
    username: process.env.MOFANG_USERNAME || process.env.MAGICFLU_USERNAME || null,
    [REDACTED_SECRET] || process.env.MAGICFLU_PASSWORD || null,
  };

  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--spaceId' && args[i + 1]) opts.spaceId = args[++i];
    else if (args[i] === '--baseUrl' && args[i + 1]) opts.baseUrl = args[++i];
    else if (args[i] === '--label' && args[i + 1]) opts.label = args[++i];
    else if (args[i] === '--shortcut' && args[i + 1]) opts.shortcut = args[++i];
    else if (args[i] === '--desc' && args[i + 1]) opts.desc = args[++i];
    else if (args[i] === '--files' && args[i + 1]) opts.files = args[++i].split(',').map((f) => f.trim()).filter(Boolean);
    else if (args[i] === '--cookie' && args[i + 1]) opts.cookie = args[++i];
    else if (args[i] === '--username' && args[i + 1]) opts.username = args[++i];
    else if (args[i] === '--password' && args[i + 1]) opts.[REDACTED_SECRET];
  }

  return opts;
}

// ========== 创建站点 XML ==========
function buildCreateSiteXml(spaceId, label, shortcut, desc) {
  return `<entry><id></id><content><website><label>${escapeXml(label)}</label><shortcut>${escapeXml(shortcut)}</shortcut><homePage>index.html</homePage><loginPage>undefined</loginPage><encoding>UTF-8</encoding><desc>${escapeXml(desc)}</desc><defaulte>0</defaulte><classid></classid><aclLevel>0</aclLevel><publick>0</publick><spaceId>${escapeXml(spaceId)}</spaceId></website></content></entry>`;
}

function escapeXml(s) {
  return String(s)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;');
}

// ========== 步骤 1: 创建站点 ==========
async function createSite(baseUrl, spaceId, label, shortcut, desc, cookie, bearerToken) {
  const root = normalizeBaseUrl(baseUrl);
  const url = `${root}/magicflu/service/s/${spaceId}/websites`;
  const xml = buildCreateSiteXml(spaceId, label, shortcut, desc);

  const headers = {
    'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
    'X-Requested-With': 'XMLHttpRequest',
    Referer: `${root}/magicflu/html/form/newwebsite.jsp?spaceId=${encodeURIComponent(spaceId)}`,
  };
  if (cookie) headers['Cookie'] = cookie;
  if (bearerToken) headers['Authorization'] = `Bearer ${bearerToken}`;

  const res = await fetchWithTimeout(url, {
    method: 'POST',
    headers,
    body: xml,
  });

  const setCookieFromCreate = pairsFromSetCookieResponse(res).join('; ');

  if (res.status !== 201) {
    const text = await res.text();
    throw new Error(`创建站点失败 HTTP ${res.status}: ${text.slice(0, 200)}`);
  }

  const websiteId = (await res.text()).trim();
  if (!websiteId) throw new Error('创建站点成功但未返回 websiteId');
  return { websiteId, setCookieFromCreate };
}

// ========== 步骤 2: 上传文件（与 site.jsp 内文件管理器一致：connectors/jsp/filemanager.jsp） ==========
async function uploadFile(baseUrl, spaceId, websiteId, filePath, content, cookie, usedJwtLogin) {
  const root = normalizeBaseUrl(baseUrl);
  const qp = new URLSearchParams({ spaceId });
  const url = `${root}/magicflu/html/sites/connectors/jsp/filemanager.jsp?${qp.toString()}`;
  const filename = basename(filePath);
  const currentpath = `/magicflu/html/sites/userfiles/${websiteId}/`;

  const form = new FormData();
  form.append('mode', 'add');
  form.append('currentpath', currentpath);
  form.append('newfile', new Blob([content], { type: mimeForUploadFilename(filename) }), filename);
  form.append('upload', 'Upload');

  const refererSite = `${root}/magicflu/html/sites/site.jsp?spaceId=${encodeURIComponent(spaceId)}&websiteId=${encodeURIComponent(websiteId)}`;
  const headers = {
    'X-Requested-With': 'XMLHttpRequest',
    Referer: refererSite,
    Origin: root,
  };
  if (cookie) headers['Cookie'] = cookie;

  const res = await fetchWithTimeout(url, {
    method: 'POST',
    headers,
    body: form,
  });

  const text = await res.text();
  let json;
  const m = text.match(/\{[\s\S]*\}/);
  if (m) {
    try {
      json = JSON.parse(m[0]);
    } catch (_) {}
  }

  if (json && json.Code === 0) {
    return { success: true, filename };
  }
  const errPreview = json?.Error || text.slice(0, 200);
  const cookieHint =
    usedJwtLogin && /未登录|重新登录/.test(String(errPreview + text))
      ? ' 若仍无法上传，请从已打开站点页的浏览器复制完整 Cookie，通过 --cookie / COOKIE 与账号登录结果合并后再试。'
      : '';
  throw new Error(`上传 ${filename} 失败: ${errPreview}${cookieHint}`);
}

// ========== 主流程 ==========
async function main() {
  const opts = parseArgs();

  if (!opts.spaceId) {
    console.error('错误：请提供 --spaceId 或设置环境变量 SPACE_ID');
    process.exit(1);
  }
  if (!opts.files.length) {
    console.error('错误：请提供 --files index.html[,style.css,...]');
    process.exit(1);
  }

  let cookie = opts.cookie;
  /** 账号密码登录得到的 JWT，建站/上传需与 Cookie 一并携带（与同域 UI 一致） */
  let bearerToken = null;
  const hasUserPass = Boolean(opts.username && opts.password);

  if (hasUserPass) {
    console.log('正在使用账号密码登录并获取会话 Cookie...');
    const session = await obtainSessionCookieFromCredentials(opts.baseUrl, opts.username, opts.password);
    cookie = mergeCookieHeader(session.cookie, cookie || '');
    bearerToken = session.token;
  }

  if (!cookie) {
    console.error('错误：请提供 --cookie / COOKIE，或 --username + --password（或 MOFANG_USERNAME + MOFANG_PASSWORD）');
    process.exit(1);
  }

  const cwd = process.cwd();

  try {
    console.log('正在创建站点...');
    const created = await createSite(
      opts.baseUrl,
      opts.spaceId,
      opts.label,
      opts.shortcut,
      opts.desc,
      cookie,
      bearerToken
    );
    const websiteId = created.websiteId;
    if (created.setCookieFromCreate) {
      cookie = mergeCookieHeader(cookie, created.setCookieFromCreate);
    }
    console.log('站点已创建，websiteId:', websiteId);

    if (bearerToken) {
      console.log('正在打开站点页以同步会话...');
      try {
        cookie = await warmSessionViaSiteJsp(opts.baseUrl, opts.spaceId, websiteId, cookie, bearerToken);
      } catch (e) {
        console.warn('站点页预热跳过:', e.message);
      }
    }

    for (const f of opts.files) {
      const filePath = resolve(cwd, f);
      const content = readFileSync(filePath);
      await uploadFile(opts.baseUrl, opts.spaceId, websiteId, f, content, cookie, hasUserPass);
      console.log('已上传:', f);
    }

    const indexFile = opts.files.find((f) => f.toLowerCase().endsWith('index.html')) || opts.files[0];
    const visitPath = `/magicflu/html/sites/userfiles/${opts.spaceId}/${websiteId}/${basename(indexFile)}`;
    const visitUrl = `${normalizeBaseUrl(opts.baseUrl)}${visitPath}`;

    console.log('\n部署完成！');
    console.log('访问地址:', visitUrl);
  } catch (e) {
    const msg = e?.cause?.message ? `${e.message}（${e.cause.message}）` : e.message;
    console.error('部署失败:', msg);
    process.exit(1);
  }
}

main();


path: scripts/fetch-form-spec.mjs
content: #!/usr/bin/env node
/**
 * 拉取 fielddef（或从 JSON 导入）并写入 mock-data 目录，供 mock-jsonv2 使用。
 *
 * 鉴权（任选其一，按优先级）：
 *   1) --username + --password，或环境变量 MOFANG_USERNAME + MOFANG_PASSWORD
 *      → POST /magicflu/jwt，复用公共会话逻辑拿到 Bearer + Cookie 后调 fielddef
 *   2) --cookie 或 COOKIE → 浏览器会话 Cookie
 *   3) --import-json → 不访问网络
 *
 * 注意：Windows 下环境变量 USERNAME 常为系统登录名，本脚本不读取 USERNAME，请用 MOFANG_USERNAME 或命令行传参。
 *
 * 用法：
 *   node scripts/fetch-form-spec.mjs --baseUrl http://host:9999 --spaceId <UUID> --formId <UUID> --out ./mock-data --username aitools --password 'xxx'
 *   node scripts/fetch-form-spec.mjs --baseUrl http://host:9999 --spaceLabel "空间名称" --formLabel "表单名称" --out ./mock-data --cookie "JSESSIONID=..."
 *   BASE_URL=http://host:9999 MOFANG_USERNAME=u MOFANG_PASSWORD=p node scripts/fetch-form-spec.mjs --spaceId ... --formId ... --out ./mock-data
 *   node scripts/fetch-form-spec.mjs --baseUrl https://host --spaceId <UUID> --formId <UUID> --out ./mock-data --cookie "JSESSIONID=..."
 *   node scripts/fetch-form-spec.mjs --spaceId <UUID> --formId <UUID> --out ./mock-data --import-json ./fielddef-export.json
 *
 * 环境变量：BASE_URL、SPACE_ID、SPACE_LABEL、FORM_LABEL、COOKIE、MOFANG_USERNAME、MOFANG_PASSWORD、FETCH_TIMEOUT_MS
 */

import { mkdirSync, writeFileSync, readFileSync } from 'fs';
import { resolve, join } from 'path';
import {
  normalizeBaseUrl,
  fetchWithTimeout,
  mergeCookieHeader,
  obtainSessionCookieFromCredentials,
} from './lib/magicflu-session.mjs';

function parseArgs() {
  const args = process.argv.slice(2);
  const opts = {
    baseUrl: process.env.BASE_URL || 'http://localhost:8080',
    spaceId: process.env.SPACE_ID || null,
    spaceLabel: process.env.SPACE_LABEL || null,
    formIds: [],
    formLabels: process.env.FORM_LABEL ? process.env.FORM_LABEL.split(',').map((s) => s.trim()).filter(Boolean) : [],
    out: './mock-data',
    cookie: process.env.COOKIE || null,
    username: process.env.MOFANG_USERNAME || process.env.MAGICFLU_USERNAME || null,
    [REDACTED_SECRET] || process.env.MAGICFLU_PASSWORD || null,
    importJson: null,
  };
  for (let i = 0; i < args.length; i++) {
    const a = args[i];
    if (a === '--baseUrl' && args[i + 1]) opts.baseUrl = args[++i];
    else if (a === '--spaceId' && args[i + 1]) opts.spaceId = args[++i];
    else if (a === '--spaceLabel' && args[i + 1]) opts.spaceLabel = args[++i];
    else if (a === '--formId' && args[i + 1]) {
      const v = args[++i];
      v.split(',').forEach((id) => {
        const t = id.trim();
        if (t) opts.formIds.push(t);
      });
    } else if (a === '--formLabel' && args[i + 1]) {
      const v = args[++i];
      v.split(',').forEach((label) => {
        const t = label.trim();
        if (t) opts.formLabels.push(t);
      });
    } else if (a === '--out' && args[i + 1]) opts.out = args[++i];
    else if (a === '--cookie' && args[i + 1]) opts.cookie = args[++i];
    else if (a === '--username' && args[i + 1]) opts.username = args[++i];
    else if (a === '--password' && args[i + 1]) opts.[REDACTED_SECRET];
    else if (a === '--import-json' && args[i + 1]) opts.importJson = args[++i];
  }
  return opts;
}

function authHeaders(auth) {
  const headers = {};
  if (auth.bearerToken) {
    headers['Authorization'] = `Bearer ${auth.bearerToken}`;
  }
  if (auth.cookie) {
    headers['Cookie'] = auth.cookie;
  }
  return headers;
}

async function fetchJson(baseUrl, path, auth, label) {
  const url = `${normalizeBaseUrl(baseUrl)}${path}`;
  const res = await fetchWithTimeout(url, { headers: authHeaders(auth) });
  if (!res.ok) {
    const errBody = await res.text();
    throw new Error(`${label} HTTP ${res.status}: ${errBody.slice(0, 500)}`);
  }
  const raw = await res.text();
  try {
    return JSON.parse(raw);
  } catch {
    throw new Error(`${label} 响应非 JSON: ${raw.slice(0, 300)}`);
  }
}

function withQuery(path, params) {
  const url = new URL(path, 'http://local');
  for (const [k, v] of Object.entries(params)) {
    if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
  }
  return `${url.pathname}${url.search}`;
}

async function fetchFielddef(baseUrl, spaceId, formId, auth) {
  const path = withQuery(`/magicflu/service/s/jsonv2/${encodeURIComponent(spaceId)}/forms/${encodeURIComponent(formId)}`, {
    selector: 'fielddef',
    lng: 'en',
  });
  return fetchJson(baseUrl, path, auth, 'fielddef');
}

async function querySpaces(baseUrl, spaceLabel, auth, op) {
  const path = withQuery('/magicflu/service/json/spaces/feed', {
    start: 0,
    limit: 10,
    bq: `(label,${op},${spaceLabel})`,
  });
  const data = await fetchJson(baseUrl, path, auth, 'spaces/feed');
  return Array.isArray(data.items) ? data.items : [];
}

async function resolveSpaceId(baseUrl, spaceLabel, auth) {
  for (const op of ['eq', 'like_and']) {
    const rows = await querySpaces(baseUrl, spaceLabel, auth, op);
    if (rows.length) {
      if (rows.length > 1) {
        console.warn(`空间名称匹配到 ${rows.length} 个候选，默认使用第一个：${rows[0].label} (${rows[0].id})`);
      }
      return { spaceId: rows[0].id, spaceLabel: rows[0].label || spaceLabel };
    }
  }
  throw new Error(`未找到空间: ${spaceLabel}`);
}

async function queryForms(baseUrl, spaceId, formLabel, auth, op) {
  const path = withQuery(`/magicflu/service/s/json/${encodeURIComponent(spaceId)}/forms/feed`, {
    start: 0,
    limit: 10,
    bq: `(label,${op},${formLabel})`,
  });
  const data = await fetchJson(baseUrl, path, auth, 'forms/feed');
  return Array.isArray(data.feed?.entry) ? data.feed.entry : [];
}

async function resolveFormId(baseUrl, spaceId, formLabel, auth) {
  for (const op of ['eq', 'like_and']) {
    const rows = await queryForms(baseUrl, spaceId, formLabel, auth, op);
    if (rows.length) {
      const first = rows[0];
      const label = first.content?.form?.label || formLabel;
      if (rows.length > 1) {
        console.warn(`表单名称匹配到 ${rows.length} 个候选，默认使用第一个：${label} (${first.id})`);
      }
      return { formId: first.id, formLabel: label };
    }
  }
  throw new Error(`未找到表单: ${formLabel}`);
}

function formLabelFromFielddef(data) {
  return data?.label || data?.form?.label || '';
}

function usage() {
  return (
    '用法: --spaceId <UUID>|--spaceLabel <名称> --formId <UUID>|--formLabel <名称> [--formId/--formLabel ...] ' +
    '--out ./mock-data [--baseUrl URL] [--username u --password p | --cookie ... | --import-json file]'
  );
}

function unique(values) {
  return [...new Set(values.filter(Boolean))];
}

function ensureDir(p) {
  mkdirSync(p, { recursive: true });
}

async function main() {
  const opts = parseArgs();
  if (opts.importJson && (!opts.spaceId || opts.formIds.length !== 1)) {
    console.error('使用 --import-json 时请指定 --spaceId，并且只指定一个 --formId');
    process.exit(1);
  }
  if (!opts.importJson && (!opts.spaceId && !opts.spaceLabel)) {
    console.error(usage());
    process.exit(1);
  }
  if (!opts.importJson && !opts.formIds.length && !opts.formLabels.length) {
    console.error(usage());
    process.exit(1);
  }

  const out = resolve(process.cwd(), opts.out);
  ensureDir(out);

  let auth = { bearerToken: null, cookie: null };
  if (!opts.importJson) {
    const hasUserPass = Boolean(opts.username && opts.password);
    if (hasUserPass) {
      const session = await obtainSessionCookieFromCredentials(opts.baseUrl, opts.username, opts.password);
      auth.bearerToken = session.token;
      auth.cookie = mergeCookieHeader(session.cookie, opts.cookie || '');
    } else if (opts.cookie) {
      auth.cookie = opts.cookie;
    } else {
      console.error(
        '拉取 fielddef 需要鉴权，请选择其一：\n' +
          '  --username / --password（或 MOFANG_USERNAME + MOFANG_PASSWORD，推荐）\n' +
          '  --cookie 或环境变量 COOKIE（浏览器已登录会话）\n' +
          '  --import-json（离线，不访问网络）'
      );
      process.exit(1);
    }

    if (!opts.spaceId && opts.spaceLabel) {
      const resolved = await resolveSpaceId(opts.baseUrl, opts.spaceLabel, auth);
      opts.spaceId = resolved.spaceId;
      opts.spaceLabel = resolved.spaceLabel;
      console.log(`已解析空间: ${opts.spaceLabel} (${opts.spaceId})`);
    }

    for (const formLabel of opts.formLabels) {
      const resolved = await resolveFormId(opts.baseUrl, opts.spaceId, formLabel, auth);
      opts.formIds.push(resolved.formId);
      console.log(`已解析表单: ${resolved.formLabel} (${resolved.formId})`);
    }
    opts.formIds = unique(opts.formIds);
  }

  const manifest = {
    spaceId: opts.spaceId,
    ...(opts.spaceLabel ? { spaceLabel: opts.spaceLabel } : {}),
    generatedAt: new Date().toISOString(),
    forms: [],
  };

  for (const formId of opts.formIds) {
    let data;
    if (opts.importJson) {
      const raw = readFileSync(resolve(process.cwd(), opts.importJson), 'utf8');
      data = JSON.parse(raw);
    } else {
      data = await fetchFielddef(opts.baseUrl, opts.spaceId, formId, auth);
    }
    const dir = join(out, formId);
    ensureDir(dir);
    writeFileSync(join(dir, 'fielddef.json'), JSON.stringify(data, null, 2), 'utf8');
    writeFileSync(join(dir, 'records.seed.json'), JSON.stringify([], null, 2), 'utf8');
    manifest.forms.push({ formId, label: formLabelFromFielddef(data) });
  }

  writeFileSync(join(out, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
  writeFileSync(
    join(out, 'typesnippets.md'),
    '# Type snippets\n\n可根据 fielddef 手工补充类型片段。\n',
    'utf8'
  );
  writeFileSync(join(out, 'api-outline.md'), '# API outline\n\n', 'utf8');

  console.log('已写入', out);
  console.log('下一步: node scripts/mock-jsonv2.mjs --port 3847 --dir', opts.out);
}

main().catch((e) => {
  const msg = e?.cause?.message ? `${e.message}（${e.cause.message}）` : e.message;
  console.error(msg || e);
  process.exit(1);
});


path: scripts/fetch-import-smoke.mjs
content: #!/usr/bin/env node
/**
 * 验证 fetch-form-spec --import-json 离线输出结构，以及按空间/表单名称解析真实接口的路径。
 * 不连接真实魔方环境。
 */

import { mkdtempSync, readFileSync, rmSync, existsSync } from 'fs';
import { tmpdir } from 'os';
import { dirname, join, resolve } from 'path';
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
const script = resolve(__dirname, 'fetch-form-spec.mjs');
const mockScript = resolve(__dirname, 'mock-jsonv2.mjs');
const mockDir = resolve(root, 'assets/mock-data');
const spaceId = '00000000-0000-0000-0000-000000000001';
const formId = '00000000-0000-0000-0000-000000000002';
const fielddef = resolve(root, 'assets/mock-data', formId, 'fielddef.json');

function runNode(args, cwd) {
  return new Promise((resolveRun, rejectRun) => {
    const child = spawn(process.execPath, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
    let stdout = '';
    let stderr = '';
    child.stdout.on('data', (b) => {
      stdout += b.toString('utf8');
    });
    child.stderr.on('data', (b) => {
      stderr += b.toString('utf8');
    });
    child.on('exit', (code) => {
      if (code === 0) resolveRun({ stdout, stderr });
      else rejectRun(new Error(`命令失败 ${code}\n${stdout}\n${stderr}`));
    });
  });
}

async function waitForReady(baseUrl) {
  const deadline = Date.now() + 10_000;
  while (Date.now() < deadline) {
    try {
      const res = await fetch(`${baseUrl}/magicflu/service/json/spaces/feed?start=0&limit=1`);
      if (res.ok) return;
    } catch {
      /* retry */
    }
    await new Promise((r) => setTimeout(r, 100));
  }
  throw new Error('mock-jsonv2 未在 10s 内就绪');
}

async function main() {
  const work = mkdtempSync(join(tmpdir(), 'mofang-fetch-import-smoke-'));
  const labelWork = mkdtempSync(join(tmpdir(), 'mofang-fetch-label-smoke-'));
  const port = 3861;
  const baseUrl = `http://127.0.0.1:${port}`;
  const child = spawn(process.execPath, [mockScript, '--port', String(port), '--dir', mockDir], {
    stdio: ['ignore', 'pipe', 'pipe'],
  });
  let mockStderr = '';
  child.stderr.on('data', (buf) => {
    mockStderr += buf.toString('utf8');
  });

  try {
    await runNode(
      [
        script,
        '--spaceId',
        spaceId,
        '--formId',
        formId,
        '--out',
        './mock-data',
        '--import-json',
        fielddef,
      ],
      work
    );

    const out = join(work, 'mock-data');
    const expected = [
      'manifest.json',
      'typesnippets.md',
      'api-outline.md',
      join(formId, 'fielddef.json'),
      join(formId, 'records.seed.json'),
    ];
    for (const rel of expected) {
      if (!existsSync(join(out, rel))) throw new Error(`缺少输出文件: ${rel}`);
    }

    const manifest = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8'));
    if (manifest.spaceId !== spaceId || manifest.forms?.[0]?.formId !== formId) {
      throw new Error(`manifest 内容异常: ${JSON.stringify(manifest)}`);
    }

    const records = JSON.parse(readFileSync(join(out, formId, 'records.seed.json'), 'utf8'));
    if (!Array.isArray(records)) throw new Error('records.seed.json 不是数组');

    await waitForReady(baseUrl);
    await runNode(
      [
        script,
        '--baseUrl',
        baseUrl,
        '--spaceLabel',
        '示例空间',
        '--formLabel',
        '示例表',
        '--out',
        './mock-data',
        '--cookie',
        'mock=1',
      ],
      labelWork
    );

    const labelOut = join(labelWork, 'mock-data');
    const labelManifest = JSON.parse(readFileSync(join(labelOut, 'manifest.json'), 'utf8'));
    if (
      labelManifest.spaceId !== spaceId ||
      labelManifest.spaceLabel !== '示例空间' ||
      labelManifest.forms?.[0]?.formId !== formId
    ) {
      throw new Error(`名称解析 manifest 异常: ${JSON.stringify(labelManifest)}`);
    }

    console.log('fetch-form-spec import smoke passed');
  } finally {
    child.kill('SIGTERM');
    rmSync(work, { recursive: true, force: true });
    rmSync(labelWork, { recursive: true, force: true });
  }
}

main().catch((e) => {
  console.error(e.message || e);
  process.exit(1);
});


path: scripts/mock-jsonv2-smoke.mjs
content: #!/usr/bin/env node
/**
 * 验证 mock-jsonv2 mock 模式的基础路由：spaces/feed、forms/feed、fielddef、list、create、update、delete。
 * 不连接真实魔方环境。
 */

import { spawn } from 'child_process';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
const script = resolve(__dirname, 'mock-jsonv2.mjs');
const mockDir = resolve(root, 'assets/mock-data');
const spaceId = '00000000-0000-0000-0000-000000000001';
const formId = '00000000-0000-0000-0000-000000000002';

function once(emitter, event) {
  return new Promise((resolveOnce) => emitter.once(event, resolveOnce));
}

async function waitForReady(baseUrl) {
  const deadline = Date.now() + 10_000;
  while (Date.now() < deadline) {
    try {
      const res = await fetch(`${baseUrl}/magicflu/service/s/jsonv2/${spaceId}/forms/${formId}?selector=fielddef&lng=en`);
      if (res.ok) return;
    } catch {
      /* retry */
    }
    await new Promise((r) => setTimeout(r, 100));
  }
  throw new Error('mock-jsonv2 未在 10s 内就绪');
}

async function expectJson(res, label) {
  const text = await res.text();
  if (!res.ok) throw new Error(`${label} HTTP ${res.status}: ${text.slice(0, 300)}`);
  try {
    return JSON.parse(text);
  } catch {
    throw new Error(`${label} 响应非 JSON: ${text.slice(0, 300)}`);
  }
}

async function main() {
  const port = 3857;
  const baseUrl = `http://127.0.0.1:${port}`;
  const child = spawn(process.execPath, [script, '--port', String(port), '--dir', mockDir], {
    stdio: ['ignore', 'pipe', 'pipe'],
  });

  let stderr = '';
  child.stderr.on('data', (buf) => {
    stderr += buf.toString('utf8');
  });

  try {
    const earlyExit = Promise.race([
      waitForReady(baseUrl).then(() => null),
      once(child, 'exit').then((code) => ({ code })),
    ]);
    const exited = await earlyExit;
    if (exited) throw new Error(`mock-jsonv2 提前退出: ${exited.code}\n${stderr}`);

    const spaces = await expectJson(
      await fetch(`${baseUrl}/magicflu/service/json/spaces/feed?start=0&limit=10&bq=(label,eq,示例空间)`),
      'spaces/feed'
    );
    if (spaces.items?.[0]?.id !== spaceId || spaces.items[0].label !== '示例空间') {
      throw new Error(`spaces/feed 返回异常: ${JSON.stringify(spaces)}`);
    }

    const forms = await expectJson(
      await fetch(`${baseUrl}/magicflu/service/s/json/${spaceId}/forms/feed?start=0&limit=-1`),
      'forms/feed'
    );
    if (forms.feed?.entry?.[0]?.id !== formId || forms.feed.entry[0].content?.form?.label !== '示例表') {
      throw new Error(`forms/feed 返回异常: ${JSON.stringify(forms)}`);
    }

    const fielddef = await expectJson(
      await fetch(`${baseUrl}/magicflu/service/s/jsonv2/${spaceId}/forms/${formId}?selector=fielddef&lng=en`),
      'fielddef'
    );
    if (!Array.isArray(fielddef.fields) || !fielddef.fields.some((f) => f.name === 'jine')) {
      throw new Error('fielddef 未包含模板字段 jine');
    }

    const list1 = await expectJson(
      await fetch(`${baseUrl}/magicflu/service/s/jsonv2/${spaceId}/forms/${formId}/records/entry?start=0&limit=10`),
      'list'
    );
    if (!Array.isArray(list1.entry) || list1.totalCount !== 2) {
      throw new Error(`list 返回异常: ${JSON.stringify(list1)}`);
    }

    const listAll = await expectJson(
      await fetch(`${baseUrl}/magicflu/service/s/jsonv2/${spaceId}/forms/${formId}/records/entry?start=0&limit=-1`),
      'list all'
    );
    if (!Array.isArray(listAll.entry) || listAll.entry.length !== 2) {
      throw new Error(`list all 返回异常: ${JSON.stringify(listAll)}`);
    }

    const created = await expectJson(
      await fetch(`${baseUrl}/magicflu/service/s/jsonv2/${spaceId}/forms/${formId}/records`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ mingcheng: 'Smoke', jine: 300 }),
      }),
      'create'
    );
    if (!created.id || created.mingcheng !== 'Smoke') {
      throw new Error(`create 返回异常: ${JSON.stringify(created)}`);
    }

    const updated = await expectJson(
      await fetch(`${baseUrl}/magicflu/service/s/jsonv2/${spaceId}/forms/${formId}/records/entry/${created.id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jine: 301 }),
      }),
      'update'
    );
    if (updated.jine !== 301) throw new Error(`update 返回异常: ${JSON.stringify(updated)}`);

    const deleted = await expectJson(
      await fetch(`${baseUrl}/magicflu/service/s/jsonv2/${spaceId}/forms/${formId}/records/entry/${created.id}`, {
        method: 'DELETE',
      }),
      'delete'
    );
    if (deleted.ok !== true) throw new Error(`delete 返回异常: ${JSON.stringify(deleted)}`);

    console.log('mock-jsonv2 smoke passed');
  } finally {
    child.kill('SIGTERM');
  }
}

main().catch((e) => {
  console.error(e.message || e);
  process.exit(1);
});


path: scripts/pack-skill.mjs
content: #!/usr/bin/env node
/**
 * Pack this skill directory into publish/<name>-<version>.zip.
 *
 * Writes a plain ZIP archive directly so Windows Explorer, PowerShell
 * Expand-Archive, and agent installers all see the same contents.
 */

import {
  copyFileSync,
  existsSync,
  mkdirSync,
  readdirSync,
  readFileSync,
  renameSync,
  statSync,
  unlinkSync,
  writeFileSync,
} from 'fs';
import { join, relative, resolve, sep } from 'path';
import { tmpdir } from 'os';

const skillRoot = resolve(process.cwd());
const pkgPath = join(skillRoot, 'package.json');

if (!existsSync(pkgPath)) {
  console.error(`pack-skill: run from the skill root containing package.json (cwd=${skillRoot})`);
  process.exit(1);
}

const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
const { name, version } = pkg;

if (!name || !version) {
  console.error('pack-skill: package.json must contain name and version');
  process.exit(1);
}

const publishDir = join(skillRoot, 'publish');
const zipFile = `${name}-${version}.zip`;
const zipPath = join(publishDir, zipFile);
const tmpZip = join(tmpdir(), `mofang-skill-pack-${name}-${version}-${process.pid}.zip`);

const excludedDirs = new Set(['.git', 'node_modules', 'docs', 'publish', 'skillsbackup']);
const excludedFiles = new Set(['.env', '.DS_Store', 'tsconfig.json']);

function toZipPath(absPath) {
  return relative(skillRoot, absPath).split(sep).join('/');
}

function shouldSkip(relPath, isDir) {
  const parts = relPath.split('/');
  if (parts.some((part) => excludedDirs.has(part) || part === '__MACOSX')) return true;
  const base = parts[parts.length - 1];
  if (excludedFiles.has(base)) return true;
  if (!isDir && (base.endsWith('.zip') || base.endsWith('.map') || base.endsWith('.vue'))) return true;
  return false;
}

function collectEntries(dir) {
  const entries = [];
  for (const name of readdirSync(dir).sort((a, b) => a.localeCompare(b))) {
    const absPath = join(dir, name);
    const relPath = toZipPath(absPath);
    const st = statSync(absPath);
    if (shouldSkip(relPath, st.isDirectory())) continue;
    if (st.isDirectory()) {
      entries.push({ name: `${relPath}/`, data: Buffer.alloc(0), isDir: true });
      entries.push(...collectEntries(absPath));
    } else if (st.isFile()) {
      entries.push({ name: relPath, data: readFileSync(absPath), isDir: false });
    }
  }
  return entries;
}

const crcTable = (() => {
  const table = new Uint32Array(256);
  for (let i = 0; i < 256; i++) {
    let c = i;
    for (let k = 0; k < 8; k++) {
      c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
    }
    table[i] = c >>> 0;
  }
  return table;
})();

function crc32(buf) {
  let c = 0xffffffff;
  for (const byte of buf) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
  return (c ^ 0xffffffff) >>> 0;
}

function dosTimeDate(date = new Date()) {
  const year = Math.max(1980, date.getFullYear());
  return {
    dosTime: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
    dosDate: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
  };
}

function u16(n) {
  const b = Buffer.alloc(2);
  b.writeUInt16LE(n & 0xffff, 0);
  return b;
}

function u32(n) {
  const b = Buffer.alloc(4);
  b.writeUInt32LE(n >>> 0, 0);
  return b;
}

function makeZip(entries) {
  const chunks = [];
  const central = [];
  let offset = 0;
  const { dosTime, dosDate } = dosTimeDate();

  for (const entry of entries) {
    const nameBuf = Buffer.from(entry.name, 'utf8');
    const data = entry.data;
    const crc = entry.isDir ? 0 : crc32(data);
    const size = data.length;

    const local = Buffer.concat([
      u32(0x04034b50),
      u16(20),
      u16(0x0800),
      u16(0),
      u16(dosTime),
      u16(dosDate),
      u32(crc),
      u32(size),
      u32(size),
      u16(nameBuf.length),
      u16(0),
      nameBuf,
    ]);
    chunks.push(local, data);

    central.push(Buffer.concat([
      u32(0x02014b50),
      u16(20),
      u16(20),
      u16(0x0800),
      u16(0),
      u16(dosTime),
      u16(dosDate),
      u32(crc),
      u32(size),
      u32(size),
      u16(nameBuf.length),
      u16(0),
      u16(0),
      u16(0),
      u16(0),
      u32(entry.isDir ? 0x10 : 0),
      u32(offset),
      nameBuf,
    ]));

    offset += local.length + data.length;
  }

  const centralStart = offset;
  const centralDir = Buffer.concat(central);
  const eocd = Buffer.concat([
    u32(0x06054b50),
    u16(0),
    u16(0),
    u16(entries.length),
    u16(entries.length),
    u32(centralDir.length),
    u32(centralStart),
    u16(0),
  ]);

  return Buffer.concat([...chunks, centralDir, eocd]);
}

try {
  const entries = collectEntries(skillRoot);
  const required = ['SKILL.md', 'package.json', 'scripts/', 'references/', 'assets/', 'examples/', 'agents/'];
  const names = new Set(entries.map((entry) => entry.name));
  const missing = required.filter((entry) => !names.has(entry));
  if (missing.length) {
    throw new Error(`required package entries missing: ${missing.join(', ')}`);
  }

  mkdirSync(publishDir, { recursive: true });
  writeFileSync(tmpZip, makeZip(entries));
  if (existsSync(zipPath)) unlinkSync(zipPath);
  try {
    renameSync(tmpZip, zipPath);
  } catch (e) {
    if (e && e.code === 'EXDEV') {
      copyFileSync(tmpZip, zipPath);
      unlinkSync(tmpZip);
    } else {
      throw e;
    }
  }
  console.log(`pack-skill: wrote ${zipPath}`);
} catch (e) {
  if (existsSync(tmpZip)) {
    try {
      unlinkSync(tmpZip);
    } catch {
      /* ignore */
    }
  }
  console.error('pack-skill: failed to create zip');
  console.error(e.message || e);
  process.exit(1);
}


path: scripts/upload-flow-smoke.mjs
content: #!/usr/bin/env node
/**
 * 本地 mock 魔方建站 + 上传接口，用于验证 deploy.mjs 全流程（不上生产）。
 * 用法：node scripts/upload-flow-smoke.mjs
 */
import { createServer } from 'http';
import { spawn } from 'child_process';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { dirname, join, resolve } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const SPACE_ID = '00000000-0000-0000-0000-000000000099';
const WEBSITE_ID = '11111111-1111-1111-1111-111111111111';

const server = createServer((req, res) => {
  const host = req.headers.host || '127.0.0.1';
  const u = new URL(req.url || '/', `http://${host}`);

  if (req.method === 'POST' && u.pathname === '/magicflu/jwt') {
    res.writeHead(200, {
      'Content-Type': 'application/json; charset=utf-8',
      'Set-Cookie': 'JSESSIONID=smoke-mock; Path=/',
    });
    res.end(JSON.stringify({ [REDACTED_SECRET]' }));
    return;
  }

  if (req.method === 'GET' && u.pathname === '/magicflu/service/json/spaces/feed') {
    res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': 'JSESSIONID=smoke-probe; Path=/' });
    res.end('{}');
    return;
  }

  if (req.method === 'POST' && u.pathname === `/magicflu/service/s/${SPACE_ID}/websites`) {
    let raw = '';
    req.setEncoding('utf8');
    req.on('data', (c) => {
      raw += c;
    });
    req.on('end', () => {
      if (!raw.includes('<website>')) {
        res.writeHead(400);
        res.end('expected website xml');
        return;
      }
      res.writeHead(201, { 'Content-Type': 'text/plain; charset=utf-8' });
      res.end(WEBSITE_ID);
    });
    return;
  }

  if (
    req.method === 'POST' &&
    u.pathname === '/magicflu/html/sites/connectors/jsp/filemanager.jsp' &&
    u.searchParams.get('spaceId') === SPACE_ID
  ) {
    req.on('data', () => {});
    req.on('end', () => {
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
      res.end(`<textarea>${JSON.stringify({ Code: 0, Path: '/x/', Name: 'index.html' })}</textarea>`);
    });
    return;
  }

  if (req.method === 'GET' && u.pathname === '/magicflu/html/sites/site.jsp') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end('<html></html>');
    return;
  }

  res.writeHead(404, { 'Content-Type': 'text/plain' });
  res.end(`not found: ${req.method} ${u.pathname}`);
});

server.listen(0, '127.0.0.1', () => {
  const addr = server.address();
  const port = typeof addr === 'object' && addr ? addr.port : 0;
  const baseUrl = `http://127.0.0.1:${port}`;
  const work = mkdtempSync(join(tmpdir(), 'mofang-upload-smoke-'));
  const indexPath = join(work, 'index.html');
  writeFileSync(indexPath, '<!doctype html><html><body>smoke upload ok</body></html>\n', 'utf8');

  console.log('mock server:', baseUrl);
  const deploy = spawn(
    process.execPath,
    [
      resolve(__dirname, 'deploy.mjs'),
      '--baseUrl',
      baseUrl,
      '--spaceId',
      SPACE_ID,
      '--label',
      'smoke',
      '--shortcut',
      'smoke',
      '--files',
      indexPath,
      '--username',
      'any',
      '--password',
      'any',
    ],
    { stdio: 'inherit', env: { ...process.env, FETCH_TIMEOUT_MS: '10000' } }
  );

  deploy.on('exit', (code) => {
    server.close();
    try {
      rmSync(work, { recursive: true, force: true });
    } catch {
      /* ignore */
    }
    process.exit(code ?? 1);
  });
});


path: scripts/lib/magicflu-session.mjs
content: /**
 * 魔方会话：JWT + Cookie、超时 fetch（deploy / mock-jsonv2 proxy 共用）
 */

export function normalizeBaseUrl(url) {
  return String(url || '').replace(/\/+$/, '');
}

export function getFetchTimeoutMs() {
  const n = Number(process.env.FETCH_TIMEOUT_MS);
  if (Number.isFinite(n) && n > 0) return n;
  return 120_000;
}

/** 带超时的 fetch，避免对端不返回时脚本无限挂起 */
export async function fetchWithTimeout(url, init = {}) {
  const ms = getFetchTimeoutMs();
  try {
    return await fetch(url, { ...init, signal: AbortSignal.timeout(ms) });
  } catch (e) {
    if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
      throw new Error(`请求超时（${ms / 1000}s 内无响应），请检查 BASE_URL 是否可达、网络/VPN：${url}`);
    }
    throw e;
  }
}

function parseResponseJson(text) {
  const trimmed = String(text || '').replace(/^\uFEFF/, '').trim();
  if (!trimmed) return {};
  return JSON.parse(trimmed);
}

/** 从响应头取出 Set-Cookie 各行，解析为 name=value（忽略属性） */
export function pairsFromSetCookieResponse(res) {
  const pairs = [];
  const h = res.headers;
  if (typeof h.getSetCookie === 'function') {
    for (const line of h.getSetCookie()) {
      const part = line.split(';')[0].trim();
      if (part && part.includes('=')) pairs.push(part);
    }
    return pairs;
  }
  const single = h.get('set-cookie');
  if (!single) return [];
  const part = single.split(';')[0].trim();
  return part && part.includes('=') ? [part] : [];
}

/** 合并两段 Cookie 请求头（后者覆盖同名） */
export function mergeCookieHeader(a, b) {
  const jar = new Map();
  const ingest = (hdr) => {
    if (!hdr) return;
    for (const piece of hdr.split(';')) {
      const t = piece.trim();
      if (!t || !t.includes('=')) continue;
      const eq = t.indexOf('=');
      const name = t.slice(0, eq).trim();
      const val = t.slice(eq + 1).trim();
      if (name) jar.set(name, val);
    }
  };
  ingest(a);
  ingest(b);
  return [...jar.entries()].map(([n, v]) => `${n}=${v}`).join('; ');
}

/**
 * JWT 登录并尽量拿到可用于业务接口的 Cookie。
 * @returns {{ cookie: string, [REDACTED_SECRET] }}
 */
export async function obtainSessionCookieFromCredentials(baseUrl, username, password) {
  const root = normalizeBaseUrl(baseUrl);
  const jwtUrl = `${root}/magicflu/jwt`;
  const body = `j_username=${encodeURIComponent(username)}&j_password=${encodeURIComponent(password)}`;

  const jwtRes = await fetchWithTimeout(jwtUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  });

  if (!jwtRes.ok) {
    const t = await jwtRes.text();
    throw new Error(`JWT 登录失败 HTTP ${jwtRes.status}: ${t.slice(0, 300)}`);
  }

  const raw = await jwtRes.text();
  let data;
  try {
    data = parseResponseJson(raw);
  } catch {
    throw new Error(`JWT 响应非 JSON: ${raw.slice(0, 200)}`);
  }
  if (!data.token) {
    throw new Error(`JWT 未返回 [REDACTED_SECRET])}`);
  }

  let cookie = mergeCookieHeader('', pairsFromSetCookieResponse(jwtRes).join('; '));

  if (!cookie) {
    const probeUrl = `${root}/magicflu/service/json/spaces/feed?start=0&limit=1`;
    const probeRes = await fetchWithTimeout(probeUrl, {
      headers: { Authorization: `Bearer ${data.token}` },
    });
    const fromProbe = pairsFromSetCookieResponse(probeRes).join('; ');
    cookie = mergeCookieHeader(cookie, fromProbe);
  }

  if (!cookie) {
    throw new Error(
      'JWT 已成功但未能从 Set-Cookie 得到会话。请改用 --cookie 传入浏览器 Cookie，或升级 Node 至 20+ 以正确解析多 Set-Cookie。',
    );
  }

  return { cookie, [REDACTED_SECRET] };
}


path: assets/mock-data/manifest.json
content: {
  "spaceId": "00000000-0000-0000-0000-000000000001",
  "spaceLabel": "示例空间",
  "forms": [
    {
      "formId": "00000000-0000-0000-0000-000000000002",
      "label": "示例表"
    }
  ]
}


path: assets/mock-data/README.md
content: # mock-data 模板

将本目录**整份**复制到用户项目下的 `./mock-data`（与 [`SKILL.md`](../../SKILL.md) 中本地调试流程一致），然后：

```bash
# 在 skill 根目录
node scripts/mock-jsonv2.mjs --port 3847 --dir /path/to/your/mock-data
```

浏览器打开示例页时设置 `CONFIG.apiBase = 'http://127.0.0.1:3847'`（参见 `examples/vanilla/list-page-with-apibase.html`）。

也可以用 `scripts/fetch-form-spec.mjs` 从真实环境拉取 fielddef 覆盖子目录中的 `fielddef.json`。


path: assets/mock-data/00000000-0000-0000-0000-000000000002/fielddef.json
content: {
  "label": "示例表",
  "fields": [
    { "type": "serial", "name": "id", "label": "序号", "required": "false" },
    { "type": "text", "name": "mingcheng", "label": "名称", "required": "false", "editable": "true" },
    { "type": "number", "name": "jine", "label": "金额", "required": "false", "editable": "true" }
  ]
}


path: assets/mock-data/00000000-0000-0000-0000-000000000002/records.seed.json
content: id: 1
mingcheng: 示例记录 A
jine: 100

id: 2
mingcheng: 示例记录 B
jine: 200

Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (HIGH)