fix: gate public page download links before Portal release
Add a MindSpace public HTML checker for missing companion docx/PDF paths, wire it into runtime packaging and release-portal-runtime-prod.sh, and document the workflow for agents and ops. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -36,8 +36,11 @@ rm -f /Users/john/PycharmProjects/test/test-memind-release-goosed-mcp/node_modul
|
||||
|
||||
```bash
|
||||
node --test db.test.mjs capabilities.test.mjs llm-providers.test.mjs wechat-mp.test.mjs
|
||||
npm run check:mindspace-public-links
|
||||
```
|
||||
|
||||
6. `public/*.html` 里的**下载/附件**相对链接(如 `report.docx`)必须在同目录真实存在。发版脚本默认只查这类链接(`--downloads-only`);全量资源检查用 `npm run check:mindspace-public-links:all`。失败会阻断发版;确知要带着已知坏链上线时,才可临时 `ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1`。
|
||||
|
||||
## 2. Portal runtime 发布唯一流程
|
||||
|
||||
只使用:
|
||||
@@ -55,6 +58,7 @@ bash scripts/release-portal-runtime-prod.sh --skip-tests --yes
|
||||
- 原子切换 live 目录。
|
||||
- 更新 LaunchAgent 指向 `/Users/john/Project/Memind/scripts/run-memind-portal-prod.sh`。
|
||||
- 健康检查 `http://127.0.0.1:8081/api/status` 返回 200。
|
||||
- 校验 `MindSpace/*/public/*.html` 下载/附件相对链接均已落盘(`check-mindspace-public-links.mjs --downloads-only`)。
|
||||
|
||||
发布成功后,记录脚本输出里的:
|
||||
|
||||
@@ -86,6 +90,7 @@ ssh john@58.38.22.103 '
|
||||
printf "goosed_${p}="
|
||||
curl -k -s -o /dev/null -w "%{http_code}\n" https://127.0.0.1:${p}/status
|
||||
done
|
||||
cd /Users/john/Project/Memind && node scripts/check-mindspace-public-links.mjs --root MindSpace --downloads-only
|
||||
pid=$(pgrep -f "node .*server.mjs" | head -1)
|
||||
echo "portal_pid=${pid}"
|
||||
ps eww -p "$pid" | tr " " "\n" | awk -F= "/^(DATABASE_URL|H5_PORT|TKMIND_API_TARGETS|GOOSED_MCP_NODE_PATH|GOOSED_MCP_SERVER_PATH)=/ {print \$1\"=SET\"}"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_REFERENCE = /^(?:https?:|mailto:|javascript:|data:|tel:|#|\/\/)/i;
|
||||
const DOWNLOAD_EXTENSIONS = /\.(?:docx|doc|pdf|xlsx|xls|pptx|ppt|zip|rar|7z|csv)$/i;
|
||||
|
||||
export function isFilesystemPublicReference(raw) {
|
||||
const value = String(raw ?? '').trim();
|
||||
if (!value) return false;
|
||||
if (SKIP_REFERENCE.test(value)) return false;
|
||||
// /api/... and /MindSpace/... are server routes, not on-disk siblings of public HTML.
|
||||
if (value.startsWith('/')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @deprecated use isFilesystemPublicReference */
|
||||
export function isRelativePublicReference(raw) {
|
||||
return isFilesystemPublicReference(raw);
|
||||
}
|
||||
|
||||
export function isDownloadLikeReference(raw, source = '') {
|
||||
if (!isFilesystemPublicReference(raw)) return false;
|
||||
const ref = normalizeReferencePath(raw);
|
||||
if (DOWNLOAD_EXTENSIONS.test(ref)) return true;
|
||||
return /\bdownload\b/i.test(String(source ?? ''));
|
||||
}
|
||||
|
||||
export function normalizeReferencePath(raw) {
|
||||
const trimmed = String(raw ?? '')
|
||||
.trim()
|
||||
.split('#')[0]
|
||||
.split('?')[0]
|
||||
.trim();
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
return decodeURIComponent(trimmed);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveReferencePath(htmlDir, raw) {
|
||||
const ref = normalizeReferencePath(raw);
|
||||
if (!ref) return null;
|
||||
return path.resolve(htmlDir, ref);
|
||||
}
|
||||
|
||||
function parseMindspaceCoverPaths(contentAttr) {
|
||||
const paths = [];
|
||||
if (!contentAttr) return paths;
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(contentAttr);
|
||||
} catch {
|
||||
for (const key of ['cover', 'image']) {
|
||||
const match = contentAttr.match(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`));
|
||||
if (match?.[1]) paths.push(match[1]);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
for (const key of ['cover', 'image']) {
|
||||
if (parsed?.[key]) paths.push(String(parsed[key]));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function collectPublicHtmlReferences(html, htmlDir, { downloadsOnly = false } = {}) {
|
||||
const refs = [];
|
||||
const seen = new Set();
|
||||
|
||||
const add = (raw, source) => {
|
||||
if (downloadsOnly && !isDownloadLikeReference(raw, source)) return;
|
||||
if (!downloadsOnly && !isFilesystemPublicReference(raw)) return;
|
||||
const ref = normalizeReferencePath(raw);
|
||||
if (!ref) return;
|
||||
const key = `${source}\0${ref}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
refs.push({
|
||||
ref,
|
||||
source,
|
||||
resolvedPath: resolveReferencePath(htmlDir, ref),
|
||||
});
|
||||
};
|
||||
|
||||
for (const match of String(html ?? '').matchAll(/\b(?:href|src)\s*=\s*["']([^"']+)["']/gi)) {
|
||||
add(match[1], match[0]);
|
||||
}
|
||||
|
||||
if (!downloadsOnly) {
|
||||
for (const match of String(html ?? '').matchAll(
|
||||
/<meta\b[^>]*\bname\s*=\s*["']mindspace-cover["'][^>]*>/gi,
|
||||
)) {
|
||||
const tag = match[0];
|
||||
const contentMatch =
|
||||
tag.match(/\bcontent\s*=\s*"([^"]*)"/i) ?? tag.match(/\bcontent\s*=\s*'([^']*)'/i);
|
||||
for (const coverPath of parseMindspaceCoverPaths(contentMatch?.[1])) {
|
||||
add(coverPath, 'mindspace-cover');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
export function findMissingPublicHtmlReferences(htmlPath, html = null, options = {}) {
|
||||
const resolvedHtmlPath = path.resolve(htmlPath);
|
||||
const content = html ?? fs.readFileSync(resolvedHtmlPath, 'utf8');
|
||||
const htmlDir = path.dirname(resolvedHtmlPath);
|
||||
const missing = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const item of collectPublicHtmlReferences(content, htmlDir, options)) {
|
||||
const key = item.ref;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (!item.resolvedPath || !fs.existsSync(item.resolvedPath)) {
|
||||
missing.push({ htmlPath: resolvedHtmlPath, ...item });
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function listPublicHtmlFiles(publishRoot, { userId = null } = {}) {
|
||||
const root = path.resolve(publishRoot);
|
||||
if (!fs.existsSync(root)) return [];
|
||||
|
||||
const userDirs = userId
|
||||
? [path.join(root, userId)]
|
||||
: fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => path.join(root, d.name));
|
||||
|
||||
const files = [];
|
||||
for (const userDir of userDirs) {
|
||||
const publicDir = path.join(userDir, 'public');
|
||||
if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) continue;
|
||||
for (const name of fs.readdirSync(publicDir)) {
|
||||
if (!name.toLowerCase().endsWith('.html')) continue;
|
||||
files.push(path.join(publicDir, name));
|
||||
}
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
export function scanPublicHtmlLinks(publishRoot, options = {}) {
|
||||
const issues = [];
|
||||
for (const htmlPath of listPublicHtmlFiles(publishRoot, options)) {
|
||||
issues.push(...findMissingPublicHtmlReferences(htmlPath, null, options));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
collectPublicHtmlReferences,
|
||||
findMissingPublicHtmlReferences,
|
||||
isDownloadLikeReference,
|
||||
isFilesystemPublicReference,
|
||||
scanPublicHtmlLinks,
|
||||
} from './mindspace-public-links.mjs';
|
||||
|
||||
test('isFilesystemPublicReference skips external and absolute server paths', () => {
|
||||
assert.equal(isFilesystemPublicReference('duck.docx'), true);
|
||||
assert.equal(isFilesystemPublicReference('assets/hero.jpg'), true);
|
||||
assert.equal(isFilesystemPublicReference('https://example.com/a.docx'), false);
|
||||
assert.equal(isFilesystemPublicReference('/api/mindspace/v1/assets/a/download'), false);
|
||||
assert.equal(isFilesystemPublicReference('/MindSpace/u/public/page.html'), false);
|
||||
});
|
||||
|
||||
test('isDownloadLikeReference matches download attr and attachment extensions', () => {
|
||||
assert.equal(isDownloadLikeReference('report.docx', 'href="report.docx" download'), true);
|
||||
assert.equal(isDownloadLikeReference('assets/chart.png', 'src="assets/chart.png"'), false);
|
||||
assert.equal(isDownloadLikeReference('data/report.pdf', 'href="data/report.pdf"'), true);
|
||||
});
|
||||
|
||||
test('collectPublicHtmlReferences downloads-only ignores api src and cover', () => {
|
||||
const html = `<!doctype html>
|
||||
<head>
|
||||
<meta name="mindspace-cover" content='{"cover":"assets/hero.jpg"}'>
|
||||
</head>
|
||||
<body>
|
||||
<a href="report.docx" download>下载</a>
|
||||
<img src="/api/mindspace/v1/assets/a/download?inline=1">
|
||||
<a href="assets/chart.png">chart</a>
|
||||
</body>`;
|
||||
const all = collectPublicHtmlReferences(html, '/tmp/public');
|
||||
const downloads = collectPublicHtmlReferences(html, '/tmp/public', { downloadsOnly: true });
|
||||
assert.deepEqual(
|
||||
all.map((item) => item.ref).sort(),
|
||||
['assets/chart.png', 'assets/hero.jpg', 'report.docx'],
|
||||
);
|
||||
assert.deepEqual(downloads.map((item) => item.ref), ['report.docx']);
|
||||
});
|
||||
|
||||
test('findMissingPublicHtmlReferences reports missing sibling files', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-links-'));
|
||||
const htmlPath = path.join(dir, 'report.html');
|
||||
fs.writeFileSync(htmlPath, '<a href="report.docx" download>下载</a>');
|
||||
const missing = findMissingPublicHtmlReferences(htmlPath, null, { downloadsOnly: true });
|
||||
assert.equal(missing.length, 1);
|
||||
assert.equal(missing[0].ref, 'report.docx');
|
||||
|
||||
fs.writeFileSync(path.join(dir, 'report.docx'), 'doc');
|
||||
assert.deepEqual(findMissingPublicHtmlReferences(htmlPath, null, { downloadsOnly: true }), []);
|
||||
});
|
||||
|
||||
test('normalizeReferencePath decodes percent-encoded filenames', () => {
|
||||
const html = '<a href="%E5%85%BB%E7%8C%AA.docx" download>x</a>';
|
||||
const refs = collectPublicHtmlReferences(html, '/tmp/public', { downloadsOnly: true });
|
||||
assert.equal(refs[0]?.ref, '养猪.docx');
|
||||
});
|
||||
|
||||
test('scanPublicHtmlLinks walks MindSpace user public html files', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ms-root-'));
|
||||
const publicDir = path.join(root, 'user-a', 'public');
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'page.html'), '<a href="missing.pdf" download>pdf</a>');
|
||||
const issues = scanPublicHtmlLinks(root, { downloadsOnly: true });
|
||||
assert.equal(issues.length, 1);
|
||||
assert.match(issues[0].htmlPath, /page\.html$/);
|
||||
assert.equal(issues[0].ref, 'missing.pdf');
|
||||
});
|
||||
+3
-1
@@ -36,7 +36,9 @@
|
||||
"enrich:plaza": "node scripts/enrich-plaza-posts.mjs",
|
||||
"build": "vite build",
|
||||
"build:portal-runtime": "node scripts/build-portal-runtime.mjs",
|
||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs",
|
||||
"check:mindspace-public-links": "node scripts/check-mindspace-public-links.mjs --downloads-only",
|
||||
"check:mindspace-public-links:all": "node scripts/check-mindspace-public-links.mjs --all-links",
|
||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs",
|
||||
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
|
||||
"verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs",
|
||||
"verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs",
|
||||
|
||||
@@ -184,6 +184,19 @@ async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir, sourceNodeModul
|
||||
await visit(runtimeNodeModulesDir);
|
||||
}
|
||||
|
||||
async function copyMindspacePublicLinkTools() {
|
||||
console.log('==> 拷贝 MindSpace 公开页链接检查工具');
|
||||
await fs.copyFile(
|
||||
path.join(root, 'mindspace-public-links.mjs'),
|
||||
path.join(runtimeRoot, 'mindspace-public-links.mjs'),
|
||||
);
|
||||
await fs.mkdir(path.join(runtimeRoot, 'scripts'), { recursive: true });
|
||||
await fs.copyFile(
|
||||
path.join(root, 'scripts', 'check-mindspace-public-links.mjs'),
|
||||
path.join(runtimeRoot, 'scripts', 'check-mindspace-public-links.mjs'),
|
||||
);
|
||||
}
|
||||
|
||||
async function writeMetadata() {
|
||||
const packageJson = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'));
|
||||
const runtimeDependencies = Object.fromEntries(
|
||||
@@ -272,6 +285,9 @@ async function writeMetadata() {
|
||||
'Bundled alongside server.mjs (required for sandbox-fs MCP):',
|
||||
' mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)',
|
||||
'',
|
||||
'Post-deploy validation (scripts/check-mindspace-public-links.mjs):',
|
||||
' Scans MindSpace/*/public/*.html for missing relative href/src/cover targets.',
|
||||
'',
|
||||
`Runtime install mode: ${runtimeInstallMode}`,
|
||||
`Bundle target: ${runtimeNodeTarget}`,
|
||||
'',
|
||||
@@ -302,6 +318,7 @@ async function main() {
|
||||
if (runtimeInstallMode === 'bundle-node-modules' && !skipNodeModules) {
|
||||
await copyNodeModules();
|
||||
}
|
||||
await copyMindspacePublicLinkTools();
|
||||
await writeMetadata();
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), 0o755);
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'wechat-mp-menu.mjs'), 0o755);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Scan MindSpace public/*.html for broken relative links.
|
||||
*
|
||||
* Default (--downloads-only): attachment / download links only (release gate).
|
||||
* --all-links: also check img/src and mindspace-cover assets.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/check-mindspace-public-links.mjs
|
||||
* node scripts/check-mindspace-public-links.mjs --user <uuid>
|
||||
* node scripts/check-mindspace-public-links.mjs --root /path/to/MindSpace
|
||||
* node scripts/check-mindspace-public-links.mjs --all-links
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { scanPublicHtmlLinks } from '../mindspace-public-links.mjs';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DEFAULT_PUBLISH_ROOT = 'MindSpace';
|
||||
|
||||
function parseArgs(argv) {
|
||||
let root = path.join(repoRoot, DEFAULT_PUBLISH_ROOT);
|
||||
let userId = null;
|
||||
let allLinks = false;
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
if (argv[i] === '--root' && argv[i + 1]) {
|
||||
root = path.resolve(argv[i + 1]);
|
||||
i += 1;
|
||||
} else if (argv[i] === '--user' && argv[i + 1]) {
|
||||
userId = argv[i + 1];
|
||||
i += 1;
|
||||
} else if (argv[i] === '--all-links') {
|
||||
allLinks = true;
|
||||
} else if (argv[i] === '--downloads-only') {
|
||||
allLinks = false;
|
||||
} else if (argv[i] === '--help' || argv[i] === '-h') {
|
||||
console.log(
|
||||
`Usage: node scripts/check-mindspace-public-links.mjs [--root ${DEFAULT_PUBLISH_ROOT}] [--user <uuid>] [--downloads-only|--all-links]`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return { root, userId, downloadsOnly: !allLinks };
|
||||
}
|
||||
|
||||
const { root, userId, downloadsOnly } = parseArgs(process.argv);
|
||||
const issues = scanPublicHtmlLinks(root, { userId, downloadsOnly });
|
||||
const modeLabel = downloadsOnly ? 'download/attachment links' : 'all relative links';
|
||||
|
||||
if (issues.length === 0) {
|
||||
console.log(`OK: no missing ${modeLabel} under ${root}${userId ? ` (user ${userId})` : ''}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`Found ${issues.length} missing ${modeLabel} under ${root}:`);
|
||||
for (const issue of issues) {
|
||||
const relHtml = path.relative(root, issue.htmlPath);
|
||||
console.error(`- ${relHtml}: ${issue.ref} (${issue.source})`);
|
||||
}
|
||||
process.exit(1);
|
||||
@@ -151,7 +151,7 @@ fi
|
||||
|
||||
verify_runtime_artifact() {
|
||||
local missing=0
|
||||
for required in server.mjs mindspace-sandbox-mcp.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
|
||||
for required in server.mjs mindspace-sandbox-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
|
||||
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
|
||||
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
|
||||
missing=1
|
||||
@@ -171,6 +171,29 @@ say "验证 MindSpace 发布与聊天 Finish 回归守卫"
|
||||
npm run verify:mindspace-publish-guards:full
|
||||
)
|
||||
|
||||
verify_mindspace_public_links() {
|
||||
local target_root="${1:-${ROOT}/MindSpace}"
|
||||
local label="${2:-本地 MindSpace}"
|
||||
if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" == "1" ]]; then
|
||||
say "跳过 ${label} 公开页链接检查(ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1)"
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -d "${target_root}" ]]; then
|
||||
say "跳过 ${label} 公开页链接检查(目录不存在: ${target_root})"
|
||||
return 0
|
||||
fi
|
||||
say "检查 ${label} 公开页相对链接"
|
||||
if ! node "${ROOT}/scripts/check-mindspace-public-links.mjs" --root "${target_root}" --downloads-only; then
|
||||
echo "MindSpace 公开页存在缺失的相对下载/资源链接。" >&2
|
||||
echo "修复 public/*.html 中的 href/src/cover 路径,或临时设置 ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1 跳过。" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${DRY_RUN}" -ne 1 ]]; then
|
||||
verify_mindspace_public_links "${ROOT}/MindSpace" "本地"
|
||||
fi
|
||||
|
||||
verify_remote_goosed_dependency() {
|
||||
say "检查 103 goosed 依赖"
|
||||
ssh -o BatchMode=yes "${HOST}" 'bash -s' <<'REMOTE'
|
||||
@@ -500,6 +523,19 @@ if [[ "${portal_code}" != "200" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" != "1" && -d "${APP_DIR}/MindSpace" && -f "${APP_DIR}/scripts/check-mindspace-public-links.mjs" ]]; then
|
||||
say "检查 MindSpace 公开页相对链接"
|
||||
node_bin="/opt/homebrew/opt/node@24/bin/node"
|
||||
if [[ ! -x "${node_bin}" ]]; then
|
||||
node_bin="$(command -v node)"
|
||||
fi
|
||||
if ! "${node_bin}" "${APP_DIR}/scripts/check-mindspace-public-links.mjs" --root "${APP_DIR}/MindSpace" --downloads-only; then
|
||||
echo "MindSpace public link check failed: broken relative download/asset links under public/*.html" >&2
|
||||
echo "Set ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1 only if you accept shipping with known broken links." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
say "重启 m.tkmind.cn 反向隧道"
|
||||
ensure_portal_tunnel
|
||||
|
||||
@@ -507,6 +543,7 @@ say "检查 live 目录中不再保留源码树"
|
||||
allowed_live_mjs=(
|
||||
"${APP_DIR}/server.mjs"
|
||||
"${APP_DIR}/mindspace-sandbox-mcp.mjs"
|
||||
"${APP_DIR}/mindspace-public-links.mjs"
|
||||
)
|
||||
extra_files=""
|
||||
while IFS= read -r file; do
|
||||
|
||||
@@ -21,6 +21,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
5. 公网链接**仅**用于让用户浏览器打开已发布的 HTML,不能用来列目录或读数据文件
|
||||
6. 静态文件保存即可访问,**无需重启**
|
||||
7. 页面需提供 **Word/PDF 等二进制下载** 时:文件单独落盘(如 `public/方案.docx`),链接用相对路径;**禁止**在 HTML 内用 `data:...;base64,...` 嵌入 docx(易截断损坏)
|
||||
8. 页面若提供 **Word/PDF/附件下载**,相对链接指向的文件必须与 HTML **同目录(或子目录)且真实存在**;改 HTML 文件名时同步 **rename/copy** 伴生文件
|
||||
|
||||
详细约束以工作区内的 `.goosehints` 与 `.agents/skills/static-page-publish/SKILL.md` 为准。
|
||||
|
||||
@@ -31,6 +32,14 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
3. 在 `<head>` 写入 **mindspace-cover**(必须与页面主题一致,见下文)
|
||||
4. 保存后服务端**立即**生成 `<文件名>.thumbnail.svg`(Agent 交互阶段即生效)
|
||||
5. 按「回复格式」返回**可点击**公网链接
|
||||
6. 若页面含下载按钮,确认 `public/<同名>.docx`(或链接中的相对路径)已落盘
|
||||
|
||||
## 伴生下载文件(必须)
|
||||
|
||||
- `<a href="report.docx" download>` 等相对下载链接,目标文件必须已在 HTML 同目录或子目录
|
||||
- 推荐 `public/report.html` + `public/report.docx`;**禁止** HTML 链接名与磁盘文件名不一致
|
||||
- 从 `oa/` 引用文档时,先 **复制** 到 `public/` 再写链接
|
||||
- 交付前 `list_dir public/` 自检;可跑 `npm run check:mindspace-public-links`
|
||||
|
||||
## 回复格式(必须)
|
||||
|
||||
|
||||
+10
-1
@@ -155,7 +155,14 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
3. 在 \`<head>\` 写入 **mindspace-cover** 元数据(见下文,必须与页面主题一致)
|
||||
4. 页面内资源使用**相对路径**(\`assets/foo.png\`),不要用磁盘绝对路径
|
||||
5. 保存 HTML 后,服务端会**立即**生成同名预览图 \`<文件名>.thumbnail.svg\`(Agent 交互阶段即生效,无需等用户保存到「我的空间」)
|
||||
6. 完成后按「回复格式」返回可点击链接
|
||||
6. 下载按钮的相对链接目标(如 \`public/report.docx\`)必须已落盘且 basename 与 HTML 一致
|
||||
7. 完成后按「回复格式」返回可点击链接
|
||||
|
||||
## 伴生下载文件(必须)
|
||||
|
||||
- 相对路径下载链接(\`.docx\` / \`.pdf\` 等)必须在 HTML 同目录或子目录真实存在
|
||||
- 改 HTML 文件名时同步 rename/copy 伴生文件;从 \`oa/\` 复制到 \`public/\` 再链接
|
||||
- 可跑 \`node scripts/check-mindspace-public-links.mjs\` 自检
|
||||
|
||||
## Word 下载页(必须)
|
||||
|
||||
@@ -317,6 +324,7 @@ export function buildSandboxSessionConstraints({ baseConstraints, developerTools
|
||||
'- **禁止**用 shell / cat / heredoc / echo / cp 写入 HTML;shell 在容器内执行,文件不会出现在公网 MindSpace 路径',
|
||||
'- **禁止**让用户手动保存到 public 或说无法生成页面(除非 write_file 调用失败)',
|
||||
'- 完成后回复 `[页面标题](公网URL)` 可点击链接;写入 `public/` 时 URL 必须含 `/public/` 路径段',
|
||||
'- 下载附件:相对链接文件必须已在 `public/` 落盘,basename 与 HTML 一致',
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -339,6 +347,7 @@ export function buildPublishConstraints({ slug, username, publicBaseUrl, publish
|
||||
'- **用户可见回复**:不要向用户复述 load_skill、技能更新、页脚标记、mindspace-cover 等内部实现;完成后直接给出页面链接或结果',
|
||||
'- **禁止**用 shell 写入 HTML;**禁止**让用户「手动保存到 public 目录」或说「我无法生成页面」——除非 write_file 已失败并报告错误',
|
||||
'- 完成后给出 Markdown 可点击公网链接 `[标题](URL)`;写入 `public/页面.html` 时 URL 为 `.../MindSpace/<用户ID>/public/页面.html`',
|
||||
'- 下载链接(如 `report.docx`)必须与 HTML 同目录且文件名一致',
|
||||
`- 发布技能:\`${PUBLISH_SKILL_NAME}\`(生成页面前应 load_skill)`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ test('publish dir and public url use stable user id', () => {
|
||||
assert.match(skillText, /\.thumbnail\.svg/);
|
||||
assert.match(skillText, /docx-generate/);
|
||||
assert.match(docxSkillText, /public\/文件名\.docx/);
|
||||
assert.match(skillText, /report\.docx|伴生下载/);
|
||||
});
|
||||
|
||||
test('migrateUserPublishDir merges legacy username directory', () => {
|
||||
|
||||
Reference in New Issue
Block a user