Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,288 @@
#!/usr/bin/env node
/**
* 预设 CSS 主题契约校验 / Preset CSS theme contract checker
* 校验 ui/src/renderer/pages/settings/DisplaySettings/presets/*.cssdefault.css 除外)
* 是否符合 presets/README.md 的主题契约:
* - 双块结构(:root,body 亮块 + [data-theme='dark'],[data-theme='dark'] body 暗块,暗块在后)
* - A 表 + B 表变量全量覆盖,且亮/暗两块变量集合对称
* - --primary-rgb / --primary-1..7 为 RGB 三元组
* - 无布局属性、无 @import / 外联 url、变量不进 @media、大括号配平
* - 内容弹层背景不过透明、消息排版外层不套主题背景
* - 预览缩略图取色键存在
*
* 用法 / Usage: node scripts/check-theme-contract.mjs (或 bun)
*/
import { readdirSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const PRESETS_DIR = join(
dirname(fileURLToPath(import.meta.url)),
'..',
'ui/src/renderer/pages/settings/DisplaySettings/presets'
);
const range = (prefix, from, to) => Array.from({ length: to - from + 1 }, (_, i) => `${prefix}${from + i}`);
/** 契约 A 表:App 变量 / Contract table A: app variables */
const APP_VARS = [
'color-primary',
'primary',
...range('color-primary-light-', 1, 3),
'color-primary-dark-1',
'primary-rgb',
'brand',
'brand-light',
'brand-hover',
'color-brand-fill',
'color-brand-bg',
...range('aou-', 1, 10),
'bg-base',
...range('bg-', 1, 6),
'bg-8',
'bg-9',
'bg-10',
'bg-hover',
'bg-active',
'fill',
'color-fill',
'fill-0',
'fill-white-to-black',
'dialog-fill-0',
'inverse',
'text-primary',
'text-secondary',
'text-disabled',
'text-0',
'text-white',
'border-base',
'border-light',
'border-special',
'success',
'warning',
'danger',
'info',
'message-user-bg',
'message-tips-bg',
'workspace-btn-bg',
'color-guid-agent-bar',
'terminal-surface-bg',
'terminal-border',
];
/** 契约 B 表:Arco token(必须打在含 body 的选择器组) / Contract table B: Arco tokens */
const ARCO_VARS = [
...range('color-bg-', 1, 5),
'color-bg-popup',
'color-bg-white',
...range('color-text-', 1, 4),
...range('color-fill-', 1, 4),
'color-border',
...range('color-border-', 1, 4),
'color-primary-light-4',
...range('primary-', 1, 7),
'color-secondary',
'color-secondary-hover',
'color-secondary-active',
'color-secondary-disabled',
'color-tooltip-bg',
'color-mask-bg',
'color-spin-layer-bg',
'color-menu-light-bg',
'color-menu-dark-bg',
];
const REQUIRED = [...APP_VARS, ...ARCO_VARS];
const TRIPLET_VARS = ['primary-rgb', ...range('primary-', 1, 7)];
const PREVIEW_KEYS = ['bg-1', 'bg-2', 'bg-3', 'color-primary', 'color-text-3', 'color-fill-2', 'color-primary-light-3'];
const LAYOUT_PROPS = new Set([
'display',
'position',
'overflow',
'overflow-x',
'overflow-y',
'z-index',
'width',
'height',
'min-width',
'max-width',
'min-height',
'max-height',
'margin',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
]);
const MESSAGE_ITEM_FORBIDDEN_PROPS = new Set([
'background',
'background-color',
'background-image',
'backdrop-filter',
'-webkit-backdrop-filter',
]);
const CONTENT_POPOVER_SELECTORS = ['.arco-popover-content', '.arco-dropdown-menu', '.arco-select-popup'];
const MIN_CONTENT_SURFACE_ALPHA = 0.86;
const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
/** 顶层块扫描(@media/@keyframes 整体视作一个块) / Top-level block scan */
const topLevelBlocks = (css) => {
const blocks = [];
let i = 0;
while (i < css.length) {
const open = css.indexOf('{', i);
if (open === -1) break;
const prevClose = css.lastIndexOf('}', open);
const selector = css
.slice(prevClose === -1 ? 0 : prevClose + 1, open)
.trim()
.replace(/\s+/g, ' ');
let depth = 1;
let j = open + 1;
while (j < css.length && depth > 0) {
if (css[j] === '{') depth++;
else if (css[j] === '}') depth--;
j++;
}
blocks.push({ selector, body: css.slice(open + 1, j - 1), start: open, end: j });
i = j;
}
return blocks;
};
const collectVars = (body) => {
const map = new Map();
const re = /--([a-zA-Z0-9-_]+)\s*:\s*([^;]+);/g;
let m;
while ((m = re.exec(body)) !== null) map.set(m[1], m[2].trim());
return map;
};
const isTriplet = (value) => /^\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}$/.test(value.replace(/\s*!important\s*/i, ''));
const declarationEntries = (body) => {
const entries = [];
const re = /(?:^|[{;])\s*([-\w]+)\s*:\s*([^;{}]+);/g;
let m;
while ((m = re.exec(body)) !== null) entries.push({ prop: m[1], value: m[2].trim() });
return entries;
};
const rgbaAlphaValues = (value) => {
const alphas = [];
const re = /rgba\(\s*[^,]+\s*,\s*[^,]+\s*,\s*[^,]+\s*,\s*([0-9.]+)\s*\)/gi;
let m;
while ((m = re.exec(value)) !== null) alphas.push(Number(m[1]));
return alphas.filter((n) => Number.isFinite(n));
};
const checkTheme = (file, css) => {
const problems = [];
const cleaned = stripComments(css);
// 大括号配平 / brace balance
const opens = (cleaned.match(/\{/g) || []).length;
const closes = (cleaned.match(/\}/g) || []).length;
if (opens !== closes) problems.push(`大括号不配平: { x${opens} vs } x${closes}`);
// 外联资源 / external resources
if (/@import\b/.test(cleaned)) problems.push('包含 @import');
if (/url\(\s*['"]?https?:/i.test(cleaned)) problems.push('包含外联 url(http...)');
const blocks = topLevelBlocks(cleaned);
const lightBlocks = blocks.filter((b) => /:root/.test(b.selector) && /(^|[,\s])body\b/.test(b.selector));
const darkBlocks = blocks.filter(
(b) => /\[data-theme=["']?dark["']?\]/.test(b.selector) && /\[data-theme=["']?dark["']?\]\s+body\b/.test(b.selector)
);
if (lightBlocks.length === 0) problems.push('缺少亮色块(选择器需同时含 :root 与 body');
if (darkBlocks.length === 0) problems.push("缺少暗色块选择器需含 [data-theme='dark'] [data-theme='dark'] body");
if (lightBlocks.length && darkBlocks.length && darkBlocks[0].start < lightBlocks[0].start) {
problems.push('暗色块出现在亮色块之前');
}
const lightVars = new Map();
for (const b of lightBlocks) for (const [k, v] of collectVars(b.body)) lightVars.set(k, v);
const darkVars = new Map();
for (const b of darkBlocks) for (const [k, v] of collectVars(b.body)) darkVars.set(k, v);
for (const v of REQUIRED) {
if (!lightVars.has(v)) problems.push(`亮色块缺变量 --${v}`);
if (!darkVars.has(v)) problems.push(`暗色块缺变量 --${v}`);
}
// 对称性(除契约清单外的自定义变量也要求对称,--sider-section-title-color 例外)
const symmetricExempt = new Set(['sider-section-title-color']);
for (const k of lightVars.keys()) {
if (!darkVars.has(k) && !symmetricExempt.has(k)) problems.push(`变量 --${k} 只在亮色块出现(不对称)`);
}
for (const k of darkVars.keys()) {
if (!lightVars.has(k) && !symmetricExempt.has(k)) problems.push(`变量 --${k} 只在暗色块出现(不对称)`);
}
for (const v of TRIPLET_VARS) {
for (const [mode, vars] of [
['亮', lightVars],
['暗', darkVars],
]) {
const value = vars.get(v);
if (value && !isTriplet(value)) problems.push(`${mode}色块 --${v} 不是 RGB 三元组: "${value}"`);
}
}
for (const key of PREVIEW_KEYS) {
if (!lightVars.has(key)) problems.push(`预览取色键 --${key} 在亮色块缺失`);
}
// 布局属性(@keyframes 块内豁免——其内是动画帧)/ layout props outside keyframes
for (const b of blocks) {
if (/@(?:-webkit-)?keyframes\b/.test(b.selector)) continue;
for (const { prop, value } of declarationEntries(b.body)) {
if (LAYOUT_PROPS.has(prop)) problems.push(`布局属性 "${prop}" 出现在选择器 "${b.selector.slice(0, 60)}"`);
if (b.selector.includes('.message-item') && MESSAGE_ITEM_FORBIDDEN_PROPS.has(prop)) {
problems.push(`禁止给 .message-item 设置 "${prop}"(消息排版外层不能套主题背景)`);
}
const isContentPopover = CONTENT_POPOVER_SELECTORS.some((selector) => b.selector.includes(selector));
if (isContentPopover && (prop === 'background' || prop === 'background-color')) {
const lowAlpha = rgbaAlphaValues(value).find((alpha) => alpha < MIN_CONTENT_SURFACE_ALPHA);
if (lowAlpha != null) {
problems.push(
`内容弹层 "${b.selector.slice(0, 60)}" ${prop} 透明度 ${lowAlpha} 过低 >= ${MIN_CONTENT_SURFACE_ALPHA}`
);
}
}
}
// 变量不进 @media / vars must not live inside @media
if (/^@media\b/.test(b.selector) && /--[a-zA-Z0-9-_]+\s*:/.test(b.body)) {
problems.push('@media 块内定义了 CSS 变量(预览解析不到)');
}
}
return problems;
};
const files = readdirSync(PRESETS_DIR).filter((f) => f.endsWith('.css') && f !== 'default.css');
let failed = false;
for (const file of files) {
const css = readFileSync(join(PRESETS_DIR, file), 'utf8');
const problems = checkTheme(file, css);
if (problems.length) {
failed = true;
console.log(` ${file}`);
for (const p of problems) console.log(` - ${p}`);
} else {
console.log(` ${file}`);
}
}
if (!files.length) {
console.log('presets 目录下没有非 default 主题)');
}
process.exit(failed ? 1 : 0);
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# ============================================================================
# 出「带 Developer ID 签名 + 公证」的 macOS 安装包。
#
# bun run build:signed # 等价于带签名的 build
# bun run build:signed --config '{"bundle":{"createUpdaterArtifacts":true}}'
# # 额外产出 updater 的 .sig(需另配 updater 密钥)
#
# 密钥/口令全部来自本地 apps/desktop/signing/.env.signing(已 gitignore,绝不入库)。
# 该文件不存在时直接报错并提示如何创建。
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
ENV_FILE="$ROOT/apps/desktop/signing/.env.signing"
if [[ ! -f "$ENV_FILE" ]]; then
cat >&2 <<EOF
❌ 找不到本地签名配置: $ENV_FILE
请先创建它(不会入库):
cp apps/desktop/signing/.env.signing.example apps/desktop/signing/.env.signing
然后按文件内注释 / apps/desktop/signing/README.md 填入你的签名 + 公证信息。
EOF
exit 1
fi
# 加载本地密钥环境变量(set -a 让 source 进来的变量自动 export 给子进程)
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
# notarytool 要求 .p8 用绝对路径;这里把相对仓库根的路径补成绝对路径,方便填写。
if [[ -n "${APPLE_API_KEY_PATH:-}" && "${APPLE_API_KEY_PATH:0:1}" != "/" ]]; then
export APPLE_API_KEY_PATH="$ROOT/$APPLE_API_KEY_PATH"
fi
# ── 基本校验:必须有签名身份 ───────────────────────────────────────────────
if [[ -z "${APPLE_SIGNING_IDENTITY:-}" && -z "${APPLE_CERTIFICATE:-}" ]]; then
echo "❌ 既没设 APPLE_SIGNING_IDENTITY,也没设 APPLE_CERTIFICATE,无法签名。" >&2
exit 1
fi
# ── 提醒:没配公证只能解决一半 ─────────────────────────────────────────────
HAS_NOTARY=0
if [[ -n "${APPLE_API_KEY:-}" && -n "${APPLE_API_ISSUER:-}" && -n "${APPLE_API_KEY_PATH:-}" ]]; then
HAS_NOTARY=1
if [[ ! -f "$APPLE_API_KEY_PATH" ]]; then
echo "❌ 找不到 App Store Connect API Key: $APPLE_API_KEY_PATH" >&2
exit 1
fi
if [[ "$APPLE_API_KEY_PATH" != *.p8 ]]; then
echo "❌ APPLE_API_KEY_PATH 必须指向 AuthKey_*.p8,当前是: $APPLE_API_KEY_PATH" >&2
exit 1
fi
elif [[ -n "${APPLE_ID:-}" && -n "${APPLE_PASSWORD:-}" && -n "${APPLE_TEAM_ID:-}" ]]; then
HAS_NOTARY=1
fi
if [[ "$HAS_NOTARY" -eq 0 ]]; then
echo "⚠️ 未配置公证(notarization)变量:会签名但不公证。" >&2
echo " 别人下载后仍会被 Gatekeeper 拦(提示「无法验证开发者」)。" >&2
fi
echo "▶ 签名身份: ${APPLE_SIGNING_IDENTITY:-(用 .p12: APPLE_CERTIFICATE)}"
[[ "$HAS_NOTARY" -eq 1 ]] && echo "▶ 公证: 已启用,构建末尾会自动提交 Apple 公证并 staple"
echo
submit_for_notarization() {
local artifact="$1"
if [[ -n "${APPLE_API_KEY:-}" && -n "${APPLE_API_ISSUER:-}" && -n "${APPLE_API_KEY_PATH:-}" ]]; then
xcrun notarytool submit "$artifact" \
--key "$APPLE_API_KEY_PATH" \
--key-id "$APPLE_API_KEY" \
--issuer "$APPLE_API_ISSUER" \
--wait
else
xcrun notarytool submit "$artifact" \
--apple-id "$APPLE_ID" \
--password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
fi
}
notarize_dmg_artifacts() {
if [[ "$(uname -s)" != "Darwin" || "$HAS_NOTARY" -eq 0 ]]; then
return
fi
local dmg_dir="$ROOT/target/release/bundle/dmg"
if [[ ! -d "$dmg_dir" ]]; then
return
fi
local found=0
while IFS= read -r -d '' dmg; do
found=1
if xcrun stapler validate "$dmg" >/dev/null 2>&1; then
echo "▶ DMG 已有公证票据: $dmg"
continue
fi
echo "▶ 公证 DMG: $dmg"
submit_for_notarization "$dmg"
echo "▶ Staple DMG: $dmg"
xcrun stapler staple "$dmg"
xcrun stapler validate "$dmg"
done < <(find "$dmg_dir" -maxdepth 1 -type f -name '*.dmg' -print0)
if [[ "$found" -eq 0 ]]; then
echo "️ 未找到 DMG 产物,跳过 DMG 公证。"
fi
}
# 复用既有的 build 脚本;额外参数透传(例如 --config 开 updater 产物)
bun run build "$@"
notarize_dmg_artifacts
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bun
/**
* ensure-ui-dist — dev preflight that guarantees ui/dist exists.
*
* Why this exists:
* tauri.conf.json declares `bundle.resources: ["../../ui/dist"]`. tauri-build
* resolves & VALIDATES every resource path at compile time — even for
* `tauri dev`, which otherwise serves the UI live from the Vite dev server
* (devUrl :5173) and never reads ui/dist at all. So on a fresh clone (where
* ui/dist has never been built) `bun run dev` dies in the build script with:
* resource path `..\..\ui\dist` doesn't exist
* The fix used to be a hidden manual step: run `bun run build:ui` once before
* the first `bun run dev`. This script removes that footgun.
*
* What it does:
* If ui/dist is missing (or empty), create it with a tiny placeholder
* index.html so the resource path resolves. We DON'T do a real `vite build`
* here: in dev the page comes from Vite, so the contents are irrelevant — only
* the path's existence matters. A real production build still happens via
* tauri's beforeBuildCommand (`bun run build:ui`), which overwrites this
* placeholder. ui/dist is gitignored, so this is a local, regenerable artifact.
*
* Invariants: idempotent (no-op when a populated ui/dist already exists), cross-
* platform (node:fs only, no shell), and never fatal to the dev chain.
*/
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const DIST = join(ROOT, 'ui', 'dist');
const TAG = '[ensure-ui-dist]';
const log = (msg) => console.log(`${TAG} ${msg}`);
/** True when DIST exists and has at least one entry. */
function populated() {
try {
return existsSync(DIST) && readdirSync(DIST).length > 0;
} catch {
return false;
}
}
const PLACEHOLDER = `<!doctype html>
<meta charset="utf-8">
<title>NomiFun — dev placeholder</title>
<!--
Auto-generated by scripts/ensure-ui-dist.mjs so tauri-build's resource-path
validation passes during \`tauri dev\`. In dev the real UI is served by Vite
(http://localhost:5173); this file is never loaded. \`bun run build:ui\`
(and \`bun run build\`) overwrite ui/dist with the real production bundle.
-->
<body>NomiFun dev placeholder — run <code>bun run build:ui</code> for the real bundle.</body>
`;
try {
if (populated()) {
log('ui/dist present — ok');
} else {
mkdirSync(DIST, { recursive: true });
writeFileSync(join(DIST, 'index.html'), PLACEHOLDER);
log('ui/dist was missing — created placeholder (real bundle comes from `bun run build:ui`)');
}
} catch (e) {
// Never block the dev chain; surface the reason so a real failure is visible.
log(`WARN: could not ensure ui/dist (${e.message}) — continuing`);
}
process.exit(0);
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bun
/**
* free-ports — kill whatever process is LISTENING on the given TCP port(s).
*
* Used as a preflight for the dev commands (`dev`, `dev:web`, `serve:web`).
* On Windows especially, Ctrl-C'ing `tauri dev` / `concurrently` often leaves the
* spawned Vite (`node vite.js`) or backend child orphaned, still holding 5173 /
* 8787. Because Vite is pinned with `strictPort: true` (it MUST match Tauri's
* fixed `devUrl` :5173) and `dev:web` binds both processes with `concurrently -k`,
* a single stale listener makes the whole command fail with "Port already in use".
* Clearing the port first makes the next start self-healing.
*
* Cross-platform: Windows (netstat + taskkill), macOS/Linux (lsof + kill).
* Usage: bun scripts/free-ports.mjs 5173 8787
*/
import { execSync } from 'node:child_process';
const isWin = process.platform === 'win32';
const ports = process.argv.slice(2).map((p) => p.trim()).filter(Boolean);
if (ports.length === 0) {
console.log('[free-ports] no ports given, nothing to do');
process.exit(0);
}
/** PIDs (as strings) of processes LISTENING on `port`, excluding this script. */
function pidsOnPort(port) {
const self = String(process.pid);
try {
if (isWin) {
// Lines look like: " TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 13880"
// (UDP rows have no LISTENING state, so the filter naturally excludes them.)
const out = execSync('netstat -ano', { encoding: 'utf8' });
const pids = new Set();
for (const line of out.split(/\r?\n/)) {
if (!line.includes('LISTENING')) continue;
const cols = line.trim().split(/\s+/);
const local = cols[1] || '';
const pid = cols[cols.length - 1];
// `:5173` (with the colon) anchors the match so :35173 won't false-hit.
if (local.endsWith(`:${port}`) && /^\d+$/.test(pid)) pids.add(pid);
}
return [...pids].filter((p) => p !== self);
}
// macOS / Linux. lsof exits non-zero when nothing matches → caught below.
const out = execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`, { encoding: 'utf8' });
return out
.split(/\r?\n/)
.map((s) => s.trim())
.filter((p) => p && p !== self);
} catch {
return [];
}
}
function kill(pid) {
try {
// /T also takes the process tree, matching the orphaned-child case.
if (isWin) execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' });
else execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
let killedAny = false;
for (const port of ports) {
const pids = pidsOnPort(port);
if (pids.length === 0) {
console.log(`[free-ports] ${port}: already free`);
continue;
}
for (const pid of pids) {
const ok = kill(pid);
killedAny = true;
console.log(`[free-ports] ${port}: ${ok ? 'freed (killed' : 'FAILED to kill'} PID ${pid}${ok ? ')' : ''}`);
}
}
// Give the OS a beat to release the socket before the dev server tries to bind.
if (killedAny && isWin) execSync('powershell -NoProfile -Command "Start-Sleep -Milliseconds 400"', { stdio: 'ignore' });
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* generate-i18n-types.mjs — regenerate ui/src/renderer/services/i18n/i18n-keys.d.ts
* from the en-US locale JSON files (source of truth).
*
* Usage:
* node scripts/generate-i18n-types.mjs # write the d.ts
* node scripts/generate-i18n-types.mjs --check # no write; exit 1 if the
* # committed d.ts drifts from
* # the locale key set
*
* No dependencies. Node >= 16.
*
* Rules (mirrors the historical generator output):
* - Namespaces and their order come from locales/en-US/index.ts (runtime truth).
* - Keys are the dot-flattened paths of every leaf value, prefixed with the
* namespace; arrays flatten to numeric indices (e.g. `a.list.0`).
* - I18nKey union is sorted by UTF-16 code units; I18nModule keeps index.ts order.
* - Output uses LF line endings (repo-wide `.gitattributes`: `* text=auto eol=lf`).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const i18nDir = path.join(repoRoot, 'ui', 'src', 'renderer', 'services', 'i18n');
const localeDir = path.join(i18nDir, 'locales', 'en-US');
const outFile = path.join(i18nDir, 'i18n-keys.d.ts');
const checkMode = process.argv.includes('--check');
/** Parse locales/en-US/index.ts: namespace export order + json file per namespace. */
function readNamespaces() {
const src = fs.readFileSync(path.join(localeDir, 'index.ts'), 'utf8');
const importMap = new Map(); // identifier -> json filename
const importRe = /import\s+(\w+)\s+from\s+'\.\/([\w.-]+)\.json'/g;
for (let m; (m = importRe.exec(src)); ) importMap.set(m[1], `${m[2]}.json`);
const block = src.match(/export\s+default\s*\{([\s\S]*?)\}/);
if (!block) throw new Error(`export default block not found in ${path.join(localeDir, 'index.ts')}`);
const names = block[1]
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const namespaces = names.map((name) => {
// supports shorthand (`common`) and aliased (`starOffice: starOffice`) entries
const [exportName, ident = exportName] = name.split(':').map((s) => s.trim());
const file = importMap.get(ident);
if (!file) throw new Error(`namespace '${exportName}' in index.ts has no matching JSON import`);
return { name: exportName, file };
});
// Orphan JSON files (present on disk, not exported) are drift the runtime
// cannot see — surface them loudly but do not include their keys.
const referenced = new Set(namespaces.map((n) => n.file));
const orphans = fs
.readdirSync(localeDir)
.filter((f) => f.endsWith('.json') && !referenced.has(f));
for (const f of orphans) {
process.stderr.write(`warning: ${f} exists in en-US but is not exported by index.ts (keys excluded)\n`);
}
return namespaces;
}
/** Dot-flatten a JSON value into `out`; arrays become numeric segments. */
function flatten(value, prefix, out) {
if (Array.isArray(value)) {
value.forEach((v, i) => flatten(v, `${prefix}.${i}`, out));
} else if (value !== null && typeof value === 'object') {
for (const [k, v] of Object.entries(value)) flatten(v, `${prefix}.${k}`, out);
} else {
out.push(prefix);
}
}
function collectKeys(namespaces) {
const keys = [];
for (const { name, file } of namespaces) {
const json = JSON.parse(fs.readFileSync(path.join(localeDir, file), 'utf8'));
flatten(json, name, keys);
}
// Some locale files (e.g. settings.json) contain both a flat dotted key
// ("assistant.botToken") and a nested object ("assistant": { "botToken" })
// that flatten to the same path. The union type lists each key once, so we
// dedupe — but surface the collisions as a lint warning.
const seen = new Set();
const dupes = new Set();
for (const k of keys) (seen.has(k) ? dupes : seen).add(k);
if (dupes.size) {
process.stderr.write(
`warning: ${dupes.size} flattened key collisions (flat dotted key + nested object), deduped:\n ${[...dupes].join('\n ')}\n`,
);
}
return [...seen].sort(); // UTF-16 code unit order, matches historical output
}
const quote = (s) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
const union = (items) => items.map((k) => ` | ${quote(k)}`).join('\n');
function render(namespaces, keys) {
return [
'/* eslint-disable */',
'/**',
' * AUTO-GENERATED FILE - DO NOT EDIT',
' * Generated by scripts/generate-i18n-types.mjs',
' */',
'',
'export type I18nKey =',
`${union(keys)};`,
'',
'export type I18nModule =',
`${union(namespaces.map((n) => n.name))};`,
'',
].join('\n');
}
const normalize = (s) => s.replace(/\r\n/g, '\n');
function main() {
const namespaces = readNamespaces();
const keys = collectKeys(namespaces);
const generated = render(namespaces, keys);
const existing = fs.existsSync(outFile) ? normalize(fs.readFileSync(outFile, 'utf8')) : null;
if (checkMode) {
if (existing === generated) {
console.log(`i18n-keys.d.ts is up to date (${keys.length} keys, ${namespaces.length} modules)`);
return;
}
// Report drift at key granularity, then fall back to a text-level hint.
const extractKeys = (text) => {
const section = text.split('export type I18nModule')[0];
return new Set([...section.matchAll(/\|\s+'((?:[^'\\]|\\.)*)'/g)].map((m) => m[1]));
};
const oldKeys = existing ? extractKeys(existing) : new Set();
const newKeys = new Set(keys);
const missing = keys.filter((k) => !oldKeys.has(k)); // in locales, not in d.ts
const stale = [...oldKeys].filter((k) => !newKeys.has(k)); // in d.ts, not in locales
if (missing.length) console.error(`missing from d.ts (${missing.length}):\n ${missing.join('\n ')}`);
if (stale.length) console.error(`stale in d.ts (${stale.length}):\n ${stale.join('\n ')}`);
if (!missing.length && !stale.length) console.error('key sets match but file text differs (ordering/header/EOL)');
console.error('\ni18n-keys.d.ts is out of date — run: node scripts/generate-i18n-types.mjs');
process.exitCode = 1;
return;
}
if (existing === generated) {
console.log(`i18n-keys.d.ts already up to date (${keys.length} keys)`);
return;
}
fs.writeFileSync(outFile, generated, 'utf8');
console.log(`wrote ${path.relative(repoRoot, outFile)} (${keys.length} keys, ${namespaces.length} modules)`);
}
main();
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bun
/**
* help — 脚本目录的单一真相源呈现层。
*
* bun run help 按分组彩色打印脚本目录
* bun run help --check 校验 package.json 的 scripts 与 scripts.json 双向对齐
* (有脚本没说明 / 有说明没脚本 / group 未定义 → 退出 1)
* bun run help --readme 用 scripts.json 重新生成 README 的「## Scripts」表
* (在一对 HTML 注释锚点之间幂等替换)
*
* 描述来自 scripts/scripts.json(唯一真相源);实际 shell 命令只存在于
* package.json(不在此重复,避免双写漂移)。新增脚本的契约:package.json 加键
* + scripts.json 加一行说明,二者必须对齐(--check 守门),README 表 --readme 再生。
*
* 纯 node:fs,无第三方依赖;非 TTY 或设置 NO_COLOR 时自动降级为无色。
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PKG = join(ROOT, 'package.json');
const MANIFEST = join(ROOT, 'scripts', 'scripts.json');
const README = join(ROOT, 'README.md');
const BEGIN = '<!-- BEGIN GENERATED SCRIPTS (bun run help --readme) -->';
const END = '<!-- END GENERATED SCRIPTS -->';
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
const paint = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
const bold = (s) => paint('1', s);
const cyan = (s) => paint('36', s);
const dim = (s) => paint('2', s);
const red = (s) => paint('31', s);
const green = (s) => paint('32', s);
const pkg = JSON.parse(readFileSync(PKG, 'utf8'));
const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'));
const scripts = pkg.scripts ?? {};
const groups = manifest.groups ?? [];
const entries = manifest.scripts ?? {};
/** 返回对齐问题列表(空 = 对齐)。 */
function alignmentProblems() {
const inPkg = Object.keys(scripts);
const inManifest = Object.keys(entries);
const manifestSet = new Set(inManifest);
const pkgSet = new Set(inPkg);
const groupIds = new Set(groups.map((g) => g.id));
const missingDesc = inPkg.filter((k) => !manifestSet.has(k));
const orphanDesc = inManifest.filter((k) => !pkgSet.has(k));
const badGroup = inManifest.filter((k) => !groupIds.has(entries[k].group));
const problems = [];
if (missingDesc.length)
problems.push(`package.json 脚本缺 scripts.json 说明: ${missingDesc.join(', ')}`);
if (orphanDesc.length)
problems.push(`scripts.json 有说明但 package.json 无脚本: ${orphanDesc.join(', ')}`);
if (badGroup.length)
problems.push(`scripts.json 脚本 group 未在 groups 定义: ${badGroup.join(', ')}`);
return problems;
}
/** 按 groups 顺序分组;组内按 scripts.json 键序。 */
function groupedRows() {
const rows = [];
for (const g of groups) {
const keys = Object.keys(entries).filter((k) => entries[k].group === g.id);
if (keys.length) rows.push({ group: g, keys });
}
return rows;
}
function printList() {
const width = Object.keys(entries).reduce((m, k) => Math.max(m, k.length), 0);
console.log('\n' + bold('NomiFun 脚本目录') + dim(' bun run <script>') + '\n');
for (const { group, keys } of groupedRows()) {
console.log(bold(group.title));
for (const k of keys) {
console.log(' ' + cyan(k.padEnd(width)) + ' ' + dim('— ' + entries[k].desc));
}
console.log('');
}
console.log(
dim(`${Object.keys(entries).length} 个脚本。bun run help --check 校验登记完整性。`) + '\n'
);
}
function readmeTable() {
const lines = ['| 脚本 | 说明 |', '| --- | --- |'];
for (const { group, keys } of groupedRows()) {
lines.push(`| **${group.title}** | |`);
for (const k of keys) lines.push(`| \`bun run ${k}\` | ${entries[k].desc} |`);
}
return lines.join('\n');
}
function writeReadme() {
const md = readFileSync(README, 'utf8');
const b = md.indexOf(BEGIN);
const e = md.indexOf(END);
if (b === -1 || e === -1) {
console.error(red(`README 缺少锚点。请在 README.md 中加入一对锚点:\n ${BEGIN}\n ${END}`));
process.exit(1);
}
if (e < b) {
console.error(red('README 锚点顺序颠倒(END 在 BEGIN 之前)。'));
process.exit(1);
}
const next = md.slice(0, b + BEGIN.length) + '\n\n' + readmeTable() + '\n\n' + md.slice(e);
if (next === md) {
console.log(green('README「## Scripts」表已是最新(无变化)。'));
return;
}
writeFileSync(README, next);
console.log(green('README「## Scripts」表已更新。'));
}
const arg = process.argv[2];
if (arg === '--check') {
const problems = alignmentProblems();
if (problems.length) {
console.error(red('✗ 脚本登记未对齐:'));
for (const p of problems) console.error(' - ' + p);
process.exit(1);
}
console.log(green('✓ package.json 与 scripts.json 对齐。'));
} else if (arg === '--readme') {
const problems = alignmentProblems();
if (problems.length) {
console.error(red('请先修复对齐再生成 README'));
for (const p of problems) console.error(' - ' + p);
process.exit(1);
}
writeReadme();
} else {
printList();
}
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bun
/**
* kill-stale-dev — kill leftover dev binaries running out of this repo's
* `target/` directory.
*
* Why: agent sessions spawn CLI trees (e.g. `bunx → codex-acp → MCP stdio
* bridges`), and the stdio bridges are the desktop binary itself
* (`nomifun-desktop.exe mcp-*-stdio`). If the dev app dies without cleanup
* (tauri dev rebuild, Ctrl+C, crash), the orphaned tree survives — and on
* Windows a running image locks its exe, so the next `cargo build` fails
* with `failed to remove file ... os error 5`. The in-process fix is the
* Job Object in nomifun-runtime (src/job.rs); this script is the preflight
* safety net that also clears stale processes left by builds that predate
* the fix, or by any path the job cannot cover.
*
* Cross-platform; never fails the dev command (always exits 0).
* Usage: bun scripts/kill-stale-dev.mjs [binary-name ...] (default: nomifun-desktop)
*/
import { execSync } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const isWin = process.platform === 'win32';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
// Binary names come from package.json — keep them shell/WQL/regex-inert.
const names = process.argv.slice(2).filter((n) => /^[\w.-]+$/.test(n));
if (names.length === 0) names.push('nomifun-desktop');
/** Escape a literal string for use inside an extended regex (pgrep -f). */
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** [{pid, path}] of live processes whose executable lives under `<repo>/target/`. */
function staleProcesses(name) {
try {
if (isWin) {
const ps = `Get-CimInstance Win32_Process -Filter "Name='${name}.exe'" | Select-Object ProcessId,ExecutablePath | ConvertTo-Json -Compress`;
const out = execSync(`powershell -NoProfile -Command "${ps.replace(/"/g, '\\"')}"`, {
encoding: 'utf8',
}).trim();
if (!out) return [];
const rows = JSON.parse(out);
const list = Array.isArray(rows) ? rows : [rows];
const prefix = `${repoRoot.toLowerCase()}\\target\\`;
return list
.filter((r) => (r.ExecutablePath || '').toLowerCase().startsWith(prefix))
.map((r) => ({ pid: String(r.ProcessId), path: r.ExecutablePath }));
}
// macOS / Linux: pgrep -f matches the WHOLE command line, so anchor to
// argv[0] — otherwise a debugger/editor/tail whose arguments merely
// mention the path would be SIGKILLed too.
// pgrep exits non-zero when nothing matches → caught below.
const pattern = `^${escapeRegex(repoRoot)}/target/.*${escapeRegex(name)}`;
const out = execSync(`pgrep -f "${pattern}"`, { encoding: 'utf8' });
return out
.split(/\r?\n/)
.map((s) => s.trim())
.filter((p) => p && p !== String(process.pid))
.map((pid) => ({ pid, path: pattern }));
} catch {
return [];
}
}
function kill(pid) {
try {
// /T also takes the process tree — bridges hang off third-party CLIs.
if (isWin) execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' });
else execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
return true;
} catch (e) {
// taskkill 128 = "not found": an earlier /T tree-kill in this loop
// already took this pid down with its ancestor. That's success.
return isWin && e.status === 128;
}
}
let killedAny = false;
for (const name of names) {
const procs = staleProcesses(name);
if (procs.length === 0) {
console.log(`[kill-stale-dev] ${name}: no stale processes`);
continue;
}
for (const { pid, path } of procs) {
const ok = kill(pid);
killedAny = true;
console.log(`[kill-stale-dev] ${name}: ${ok ? 'killed' : 'FAILED to kill'} PID ${pid} (${path})`);
}
}
// Give the OS a beat to release file locks before cargo tries to relink.
// Best-effort like everything here — this script must never break the chain.
if (killedAny && isWin) {
try {
execSync('powershell -NoProfile -Command "Start-Sleep -Milliseconds 400"', { stdio: 'ignore' });
} catch {
/* ignore */
}
}
+439
View File
@@ -0,0 +1,439 @@
#!/usr/bin/env bun
/**
* prune-build -- self-cleaning preflight for every build/dev/test entry point.
*
* Keeps build artifact directories bounded WITHOUT a scheduled/cron job.
* "Cleanup cadence = build cadence": this runs once at the START of every build,
* before cargo touches anything, so the previous session's cruft is reclaimed
* exactly when the next session begins. The growth driver IS the build, so
* hooking cleanup to the build makes the two cadences match by construction.
*
* What it does (dev/test preflight, in order):
* 1. GC the incremental cache PER UNIT: inside each incremental/<crate>-<hash>/
* dir, keep only the newest finalized session (the one rustc would load)
* and delete older/interrupted sessions + orphan locks. This preserves
* cross-session warmth (zero rebuild-speed regression) while removing the
* dead sessions that ballooned this to 82G/241k-files in 2 days.
* 2. Remove leftover junk in build.noindex root (_*.log/_*.json/_*.out/_*.err)
* and the empty tmp/ dir.
* 3. Size-gated cap: only if build.noindex / target exceed their cap, run
* `cargo sweep --maxsize` to trim the OLDEST artifacts back under the cap.
* `--maxsize` is a no-op when already small (unlike `--time 1`, which would
* wrongly delete still-valid deps that simply haven't changed in a day).
* 4. Hard backstop: if build.noindex STILL exceeds CAP_GB, nuke debug/ + release/
* intermediates wholesale (all-or-nothing on Windows; see invariants below).
* This is the "can never silently balloon" guarantee.
*
* Release build split (so the heavy reclaim never delays compile start):
* --pre cheap, output-cleaning preflight run by tauri's beforeBuildCommand
* BEFORE the release compile: drop the stale bundle (old installers) +
* junk. Runs in seconds, so cargo starts compiling immediately.
* --post heavy reclaim run AFTER a successful release build: nuke the debug
* (dev) profile + flycheck — dead weight for a release. NEVER touches
* release/ intermediates or the freshly-built bundle. NOTE: the next
* dev build is therefore a cold rebuild (debug was reclaimed).
* --release full reclaim = --pre + --post in one shot. This is `bun run clean`
* (reclaim everything on demand, without building).
*
* Design invariants:
* - NEVER fails the build chain (always exits 0).
* - Cross-platform (macOS / Linux / Windows): all paths via node:path, all
* deletes via node:fs; size uses `du` on unix with a pure-Node walk fallback.
* `cargo sweep` is optional — without it the size cap falls back to dropping
* the regenerable incremental cache, and the GC + hard backstop still bound
* the dir. (`cargo install cargo-sweep` enables surgical oldest-artifact trim.)
* - Windows specifics (win32-only branches; mac/linux paths are untouched):
* * a running image LOCKS its .exe, so before any WHOLESALE profile delete
* we kill stale dev binaries (kill-stale-dev.mjs) to release locks;
* * wholesale deletes are ALL-OR-NOTHING: a lock-induced partial delete
* that pruned deps/ and .fingerprint/ to different extents could make
* cargo link a stale/missing dep, so on residue we retry then drop
* .fingerprint (forcing a loud recompile over a silent wrong build);
* * big trees are cleared with `robocopy` empty-mirror (fast, long-path-
* safe, /XJ so it never purges the D: cache-junction targets).
* - Fast in the normal case: GC + du checks only; cargo-sweep runs ONLY when
* a dir is genuinely over cap.
* - Safe: only ever deletes regenerable build artifacts, never source code.
* Does NOT touch <target-triple> dirs (e.g. an intended Linux/cross build).
* - Idempotent: a second run in a row is a near no-op.
*
* Usage (always via package.json / tauri beforeBuildCommand, never by hand):
* bun scripts/prune-build.mjs # dev/test preflight (GC + caps)
* bun scripts/prune-build.mjs --pre # release pre-step (stale bundle + junk)
* bun scripts/prune-build.mjs --post # release post-step (reclaim debug)
* bun scripts/prune-build.mjs --release # full reclaim on demand (`bun run clean`)
* bun scripts/prune-build.mjs --cap 30 # override hard cap (GB)
*/
import { execSync, spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readdirSync, rmSync, statSync, statfsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const BUILD_DIR = join(ROOT, 'build.noindex');
const TARGET_DIR = join(ROOT, 'target');
const isWin = process.platform === 'win32';
// ── Tunables ───────────────────────────────────────────────────────────────
// Steady state after GC is ~3-4G, so these caps leave generous headroom and
// only ever fire on genuine dep/feature churn.
const BUILD_MAXSIZE_GB = 10; // cargo-sweep trims build.noindex back under this
const TARGET_MAXSIZE_GB = 5; // cargo-sweep trims target/ back under this
// ── Parse flags ──────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const isRelease = args.includes('--release');
const isPre = args.includes('--pre');
const isPost = args.includes('--post');
const capIdx = args.indexOf('--cap');
const CAP_GB = capIdx >= 0 && args[capIdx + 1] ? Number(args[capIdx + 1]) : 25;
const TAG = '[prune-build]';
function log(msg) {
console.log(`${TAG} ${msg}`);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Sum file sizes under a dir with a pure-Node walk. Cross-platform, no shell. */
function dirSizeBytes(dir) {
let total = 0;
const stack = [dir];
while (stack.length) {
const d = stack.pop();
let ents;
try { ents = readdirSync(d, { withFileTypes: true }); } catch { continue; }
for (const ent of ents) {
const p = join(d, ent.name);
if (ent.isDirectory()) stack.push(p); // Dirent.isDirectory() is false for symlinks → no loops
else { try { total += statSync(p).size; } catch { /* skip */ } }
}
}
return total;
}
/**
* Directory size in GB. 0 if absent.
* Fast path: `du -sk` on macOS/Linux (metadata-only, fast even on huge trees).
* Universal fallback: a pure-Node walk (Windows, or wherever `du` is missing) —
* never the fragile cmd.exe→PowerShell quoting, so the size-gated cap and the
* hard backstop work identically on all three platforms.
*/
function dirSizeGB(dir) {
if (!existsSync(dir)) return 0;
if (process.platform !== 'win32') {
try {
const kb = parseInt(execSync(`du -sk "${dir}" 2>/dev/null`, { encoding: 'utf8' }).trim().split(/\s/)[0], 10);
if (Number.isFinite(kb)) return kb / (1024 * 1024);
} catch { /* fall through to the pure-Node walk */ }
}
try { return dirSizeBytes(dir) / 1024 ** 3; } catch { return 0; }
}
/** Format GB for display. */
function fmtGB(gb) {
if (gb < 1) return `${(gb * 1024).toFixed(0)}M`;
return `${gb.toFixed(1)}G`;
}
/**
* Windows: empty-mirror a scratch dir over `dir` with robocopy, then the caller
* drops the emptied shell. robocopy is the fastest reliable way to clear huge
* many-file trees on Windows and is long-path-safe (deep NTFS paths that choke
* rmSync). Flags: /XJ — do NOT descend junctions (an empty mirror would else
* purge the junction TARGET's real contents; this repo junctions caches onto D:);
* /R:0 /W:0 — never retry/wait on a locked file (leave it; caller detects residue).
* robocopy exit codes: 1/2/3 == SUCCESS, >=8 == failure, status null == missing.
* Returns true iff robocopy ran without a hard error (status < 8).
*/
function robocopyEmptyMirror(dir) {
let scratch;
try {
scratch = mkdtempSync(join(tmpdir(), 'nomi-empty-'));
const r = spawnSync(
'robocopy',
[scratch, dir, '/MIR', '/XJ', '/R:0', '/W:0', '/MT:16', '/NFL', '/NDL', '/NJH', '/NJS', '/NC', '/NS', '/NP'],
{ stdio: 'ignore', timeout: 120_000 },
);
return r.status !== null && r.status < 8;
} catch {
return false;
} finally {
if (scratch) { try { rmSync(scratch, { recursive: true, force: true }); } catch { /* ignore */ } }
}
}
/** Remove a directory tree. Silent on failure. Lock-resilient on Windows. */
function rmDir(dir, label) {
if (!existsSync(dir)) return;
try {
// Windows: empty the tree with robocopy first (fast + long-path-safe), then
// rmSync drops the emptied shell. If robocopy is absent, rmSync alone.
if (isWin) robocopyEmptyMirror(dir);
rmSync(dir, { recursive: true, force: true });
log(` removed ${label || dir}`);
} catch (e) {
log(` WARN: could not remove ${label || dir}: ${e.message}`);
}
}
/**
* Kill leftover dev binaries that lock files under target/ or build.noindex/.
* Windows only: a running image locks its .exe, so a wholesale delete would
* otherwise be a no-op or a torn partial. Reuses the battle-tested
* kill-stale-dev.mjs (taskkill /T tree-kill + lock-release sleep). Best-effort.
* Called ONLY before wholesale-profile deletes — never during routine per-unit
* incremental GC (those are independent units, non-fatal on a locked file).
*/
function killStaleLockers() {
if (!isWin) return;
try {
execSync(`"${process.execPath}" "${join(ROOT, 'scripts', 'kill-stale-dev.mjs')}"`, {
stdio: 'ignore',
timeout: 30_000,
});
} catch { /* best effort — must never block the build */ }
}
/**
* All-or-nothing wholesale delete of a build profile dir (e.g. build.noindex/
* debug). Hazard on Windows: a held lock makes the delete PARTIAL, and a tree
* where deps/*.rlib and .fingerprint/ were pruned to different extents can make
* cargo link a stale/missing dep — a WRONG build, not just a cold one. The
* caller kills lockers first; here we delete, and on residue retry once (after
* another kill), then as a last resort drop .fingerprint so cargo cannot trust
* the torn deps/ (a loud recompile beats a silent wrong build) and warn loudly.
*/
function nukeProfileAllOrNothing(profileDir, label) {
if (!existsSync(profileDir)) return;
rmDir(profileDir, label);
if (!existsSync(profileDir)) return; // fully removed
killStaleLockers();
rmDir(profileDir, `${label} (retry)`);
if (!existsSync(profileDir)) return;
try { rmSync(join(profileDir, '.fingerprint'), { recursive: true, force: true }); } catch { /* ignore */ }
log(` WARN: ${label} only partially removed (a process still holds a lock).`);
log(' WARN: close the dev app / editor and rebuild; run `cargo clean` if the build errors.');
}
/** Drop the regenerable incremental cache on both profiles (warmth-only lever). */
function dropIncrementalCaches(reason) {
log(` ${reason} — dropping incremental cache (regenerable; costs only rebuild warmth)`);
rmDir(join(BUILD_DIR, 'debug', 'incremental'), 'build.noindex/debug/incremental');
rmDir(join(BUILD_DIR, 'release', 'incremental'), 'build.noindex/release/incremental');
rmDir(join(TARGET_DIR, 'debug', 'incremental'), 'target/debug/incremental');
}
/**
* Cheap pre-release preflight (tauri beforeBuildCommand): drop the stale bundle
* (old installers from a previous build/version) + leftover junk, so the produced
* dist is clean. Runs in seconds and touches NOTHING the compile needs, so cargo
* starts immediately. The heavy debug reclaim is deferred to --post (after build).
*/
function preReleaseClean() {
log('pre-release: dropping stale bundle + junk (fast — compile starts now)...');
rmDir(join(TARGET_DIR, 'release', 'bundle'), 'target/release/bundle (stale installers)');
rmGlob(BUILD_DIR, /^_.*\.(log|json|out|err)$/, 'leftover log/json');
rmDir(join(BUILD_DIR, 'tmp'), 'build.noindex/tmp');
}
/**
* Heavy reclaim of the debug (dev) profile — dead weight for a release build. Run
* AFTER a successful release build (so it never delays compile start) or on demand
* via `bun run clean`. NEVER touches release/ intermediates or the freshly-built
* bundle, so the just-finished build's outputs are safe. The next dev build is a
* cold rebuild (debug was reclaimed) — the intended trade for bounded disk.
*/
function reclaimDebugDeadWeight() {
log('reclaiming debug dead weight (debug profile + flycheck)...');
killStaleLockers(); // release locks before wholesale deletes (Windows)
nukeProfileAllOrNothing(join(BUILD_DIR, 'debug'), 'build.noindex/debug');
nukeProfileAllOrNothing(join(TARGET_DIR, 'debug'), 'target/debug');
rmDir(join(TARGET_DIR, 'flycheck0'), 'target/flycheck0');
}
/** Is cargo-sweep on PATH? Its --maxsize cap is warn-only (a no-op) without it. */
function cargoSweepInstalled() {
try {
execSync(isWin ? 'where cargo-sweep' : 'command -v cargo-sweep', { stdio: 'ignore' });
return true;
} catch { return false; }
}
/** Windows: warn (never auto-delete) when the build drive is running low. */
function freeSpaceWarn() {
if (!isWin) return;
try {
const s = statfsSync(ROOT);
const freeGB = (s.bsize * s.bavail) / 1024 ** 3;
if (freeGB < 50) {
log(`WARN: only ${fmtGB(freeGB)} free on the build drive — run a --release build to reclaim, or 'cargo clean'`);
}
} catch { /* statfsSync unavailable — skip */ }
}
/** Remove files matching a pattern in a directory (non-recursive). */
function rmGlob(dir, pattern, label) {
if (!existsSync(dir)) return;
let count = 0;
try {
for (const entry of readdirSync(dir)) {
if (!pattern.test(entry)) continue;
const full = join(dir, entry);
try {
if (statSync(full).isFile()) {
rmSync(full, { force: true });
count++;
}
} catch { /* skip */ }
}
if (count > 0) log(` removed ${count} ${label} files`);
} catch { /* dir might have vanished */ }
}
/**
* Per-unit incremental GC.
*
* Layout: incremental/<crate>-<hash>/s-<id>-<svh>/ (+ a 0-byte s-<id>.lock)
* rustc loads only the newest *finalized* session per unit; older sessions and
* leftover "-working" dirs from interrupted compiles are dead and never GC'd by
* cargo during fast dev iteration. We keep the newest finalized session per unit
* (warmth preserved) and delete the rest. NEVER groups across units — every
* <crate>-<hash> dir (incl. each per-crate build_script_build-*) is independent.
*/
function pruneIncrementalSessions(incrDir) {
let units;
try { units = readdirSync(incrDir); } catch { return; }
let pruned = 0;
for (const unit of units) {
const unitPath = join(incrDir, unit);
try { if (!statSync(unitPath).isDirectory()) continue; } catch { continue; }
let entries;
try { entries = readdirSync(unitPath); } catch { continue; }
const sessions = [];
for (const e of entries) {
if (!e.startsWith('s-')) continue;
const p = join(unitPath, e);
try {
const s = statSync(p);
if (s.isDirectory()) {
sessions.push({ name: e, mtime: s.mtimeMs, working: e.endsWith('-working') });
}
} catch { /* skip */ }
}
if (sessions.length === 0) continue;
// Prefer the newest finalized session; fall back to newest overall.
const finals = sessions.filter((s) => !s.working);
const pool = (finals.length ? finals : sessions).sort((a, b) => b.mtime - a.mtime);
const keptDir = pool[0].name;
// Session dir is s-<id>-<svh>-<random>; its lock file is s-<id>-<svh>.lock.
// Strip the trailing "-<random>" segment to recover the lock name.
const keptLock = `${keptDir.replace(/-[^-]+$/, '')}.lock`;
for (const e of entries) {
if (e === keptDir || e === keptLock) continue;
try {
rmSync(join(unitPath, e), { recursive: true, force: true });
pruned++;
} catch { /* best effort */ }
}
}
if (pruned > 0) log(` GC'd ${pruned} stale incremental entries (kept newest session per unit)`);
}
/**
* Trim a target dir back under maxGB using cargo-sweep --maxsize (removes oldest
* artifacts first). Only call when the dir is actually over cap. Never fatal.
*/
function cargoSweepMaxsize(targetDir, maxGB, label) {
if (!existsSync(targetDir)) return;
try {
const env = { ...process.env, CARGO_TARGET_DIR: targetDir, CARGO_NET_OFFLINE: 'true' };
execSync(`cargo sweep --maxsize ${maxGB}GB "${ROOT}"`, {
encoding: 'utf8',
stdio: 'pipe',
env,
timeout: 60_000,
});
log(` capped ${label} at ${maxGB}GB`);
} catch (e) {
log(` WARN: cargo sweep --maxsize on ${label} failed (${e.message?.split('\n')[0] || 'cargo-sweep missing?'})`);
}
}
// ── Main ─────────────────────────────────────────────────────────────────────
try {
const beforeGB = dirSizeGB(BUILD_DIR) + dirSizeGB(TARGET_DIR);
const mode = isPre ? ' [pre]' : isPost ? ' [post]' : isRelease ? ' [release]' : '';
log(`start: ${fmtGB(beforeGB)} total (build.noindex + target)${mode}`);
if (isPre) {
// Cheap pre-build step (tauri beforeBuildCommand): clean output + junk only,
// so the release compile starts immediately. Heavy reclaim is deferred to --post.
preReleaseClean();
} else if (isPost) {
// Heavy reclaim AFTER a successful release build — never delays compile start.
reclaimDebugDeadWeight();
} else if (isRelease) {
// Full reclaim on demand (`bun run clean`): output-clean + heavy reclaim.
preReleaseClean();
reclaimDebugDeadWeight();
} else {
// 1) Per-unit incremental GC — keeps warmth, drops dead sessions.
// Covers the split build-dir AND the target/ fallback (older cargo that
// ignores the build-dir key puts intermediates under target/ instead).
for (const incrDir of [join(BUILD_DIR, 'debug', 'incremental'), join(TARGET_DIR, 'debug', 'incremental')]) {
if (!existsSync(incrDir)) continue;
const incrGB = dirSizeGB(incrDir);
pruneIncrementalSessions(incrDir);
const after = dirSizeGB(incrDir);
if (incrGB - after > 0.05) log(` incremental: ${fmtGB(incrGB)} -> ${fmtGB(after)}`);
}
// 2) Junk files + empty tmp.
rmGlob(BUILD_DIR, /^_.*\.(log|json|out|err)$/, 'leftover log/json');
rmDir(join(BUILD_DIR, 'tmp'), 'build.noindex/tmp');
// 3) Size-gated cap (no-op unless genuinely over cap). cargo-sweep trims the
// OLDEST artifacts surgically; without it (common on Windows) fall back to
// dropping the regenerable incremental cache — a warmth-only lever that
// never risks deps/*.rlib correctness.
const haveSweep = cargoSweepInstalled();
if (dirSizeGB(BUILD_DIR) > BUILD_MAXSIZE_GB) {
if (haveSweep) cargoSweepMaxsize(BUILD_DIR, BUILD_MAXSIZE_GB, 'build.noindex');
else dropIncrementalCaches('build.noindex over soft cap, cargo-sweep absent');
}
if (dirSizeGB(TARGET_DIR) > TARGET_MAXSIZE_GB && haveSweep) {
cargoSweepMaxsize(TARGET_DIR, TARGET_MAXSIZE_GB, 'target');
}
// 4) Hard backstop — the "can never silently balloon" guarantee. Covers BOTH
// profiles: a prior --release build leaves build.noindex/release/* (~5G)
// that no other dev/test path reclaims.
const nowGB = dirSizeGB(BUILD_DIR);
if (nowGB > CAP_GB) {
log(`WARN: build.noindex is ${fmtGB(nowGB)} > cap ${CAP_GB}G — nuking debug/+release/ as last resort`);
killStaleLockers(); // release locks before the wholesale deletes (Windows)
nukeProfileAllOrNothing(join(BUILD_DIR, 'debug'), 'build.noindex/debug (cap exceeded)');
// release/ holds only intermediates here (final binaries land in target/),
// so dropping it is safe; the next release build is simply a cold one.
nukeProfileAllOrNothing(join(BUILD_DIR, 'release'), 'build.noindex/release (cap exceeded)');
}
}
const afterGB = dirSizeGB(BUILD_DIR) + dirSizeGB(TARGET_DIR);
const freed = beforeGB - afterGB;
log(freed > 0.01 ? `done: freed ${fmtGB(freed)} (${fmtGB(beforeGB)} -> ${fmtGB(afterGB)})` : `done: ${fmtGB(afterGB)} total (already clean)`);
freeSpaceWarn();
} catch (e) {
// NEVER fail the build chain.
log(`WARN: prune failed (${e.message}) — continuing build`);
}
process.exit(0);
+34
View File
@@ -0,0 +1,34 @@
{
"groups": [
{ "id": "dev", "title": "开发(热重载)" },
{ "id": "build", "title": "构建(出制品)" },
{ "id": "serve", "title": "运行(组装好的应用)" },
{ "id": "test", "title": "测试" },
{ "id": "check", "title": "静态检查 / 门禁" },
{ "id": "fmt", "title": "格式化" },
{ "id": "gen", "title": "代码生成" },
{ "id": "maint", "title": "维护 / 工具" }
],
"scripts": {
"dev": { "group": "dev", "desc": "启动桌面应用开发(tauri dev,热重载)" },
"dev:web": { "group": "dev", "desc": "启动 Web 全栈开发(后端 API + 前端 vite" },
"dev:ui": { "group": "dev", "desc": "仅启动前端开发服务器(纯 vite,无后端)" },
"build": { "group": "build", "desc": "为当前操作系统打桌面安装包" },
"build:signed": { "group": "build", "desc": "打桌面包并签名+公证(仅 macOS" },
"build:updater": { "group": "build", "desc": "打桌面包并产出自更新 .sig 制品" },
"build:ui": { "group": "build", "desc": "前端生产构建 → ui/dist" },
"serve:web": { "group": "serve", "desc": "启动 Web 服务器,托管已构建的前端" },
"test": { "group": "test", "desc": "运行全部 Rust 测试(含 doctest" },
"test:fast": { "group": "test", "desc": "用 nextest 快速跑 Rust 测试(日常)" },
"check": { "group": "check", "desc": "聚合静态门禁:typecheck + i18n + 主题契约 + 脚本登记" },
"typecheck": { "group": "check", "desc": "前端 TypeScript 类型检查(tsc --noEmit" },
"check:i18n": { "group": "check", "desc": "校验 i18n 类型与 locale 键是否一致" },
"check:theme": { "group": "check", "desc": "校验预设 CSS 主题契约" },
"fmt": { "group": "fmt", "desc": "格式化 Rust 代码(cargo fmt" },
"fmt:check": { "group": "fmt", "desc": "校验 Rust 代码格式(cargo fmt --check" },
"gen:i18n": { "group": "gen", "desc": "由 locale 重新生成 i18n 类型声明" },
"clean": { "group": "maint", "desc": "深度回收构建空间(debug 产物 + flycheck + 旧安装包)" },
"seed:dev": { "group": "maint", "desc": "用生产数据目录播种 dev 数据目录" },
"help": { "group": "maint", "desc": "打印脚本目录(--check 校验登记 / --readme 生成 README 表)" }
}
}
@@ -0,0 +1,70 @@
#!/usr/bin/env bun
/**
* Seed the dev-channel data dir (…/NomiFun/Nomi-dev) from production
* (…/NomiFun/Nomi), so an auto-isolated dev build can reproduce prod state.
*
* Auto-isolation (NOMI_CHANNEL=dev → `Nomi-dev`) gives a dev build its own empty
* DB. This is the escape hatch for when you need prod's conversations /
* providers / login in dev to reproduce a bug — it restores the "troubleshoot
* one place" convenience that channel isolation otherwise trades away.
*
* SAFETY: close ALL NomiFun instances (the installed app, `bun run serve:web`,
* `nomicore`, and any running dev build) before seeding — copying a live SQLite
* database yields a torn snapshot. Lock and runtime files are never copied.
*
* Usage: bun scripts/seed-dev-from-prod.mjs [--force]
* --force overwrite an existing non-empty Nomi-dev (its state is discarded)
*/
import { cpSync, existsSync, readdirSync, rmSync } from 'node:fs';
import { homedir, platform } from 'node:os';
import { basename, join } from 'node:path';
/** Mirror `nomifun_app::cli::default_data_dir`'s vendor base, per-OS. */
function nomifunBase() {
const home = homedir();
switch (platform()) {
case 'darwin':
return join(home, 'Library', 'Application Support', 'NomiFun');
case 'win32':
return join(process.env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'), 'NomiFun');
default:
return join(process.env.XDG_DATA_HOME ?? join(home, '.local', 'share'), 'NomiFun');
}
}
// Lock + runtime artifacts that must never be copied (mirrors relocate.rs's
// EXCLUDED_ENTRIES intent: the lock lives on the handle, not the file).
const EXCLUDED = new Set(['server.lock', 'server.lock.info', 'port.json', '.relocating.lock', '.relocating']);
const force = process.argv.includes('--force');
const base = nomifunBase();
const prod = join(base, 'Nomi');
const dev = join(base, 'Nomi-dev');
if (!existsSync(prod)) {
console.error(`✗ prod data dir not found: ${prod}`);
console.error(' Nothing to seed from — launch the installed app once to create it.');
process.exit(1);
}
if (existsSync(dev) && readdirSync(dev).length > 0) {
if (!force) {
console.error(`✗ dev data dir already exists and is non-empty: ${dev}`);
console.error(' Re-run with --force to overwrite it (the current dev state is discarded).');
process.exit(1);
}
console.warn(`! --force: removing existing dev data dir ${dev}`);
rmSync(dev, { recursive: true, force: true });
}
console.log('Seeding dev from prod:');
console.log(` ${prod}`);
console.log(`${dev}`);
console.log(' Ensure ALL NomiFun instances are closed; lock/runtime files are skipped.');
cpSync(prod, dev, {
recursive: true,
filter: (src) => !EXCLUDED.has(basename(src)),
});
console.log('✓ done. Run `bun run dev` to launch the dev build on the seeded state.');