Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 预设 CSS 主题契约校验 / Preset CSS theme contract checker
|
||||
* 校验 ui/src/renderer/pages/settings/DisplaySettings/presets/*.css(default.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
@@ -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
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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.');
|
||||
Reference in New Issue
Block a user