diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index fc26d13e..e822547e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -443,6 +443,20 @@ jobs:
NPM_TAG="rc"
fi
+ RELEASE_URL="https://github.com/${GH_REPO}/releases/tag/${TAG}"
+ RELEASE_DOWNLOAD_BASE="https://github.com/${GH_REPO}/releases/download/${TAG}"
+ NPM_PACKAGE_URL="https://www.npmjs.com/package/clawmaster/v/${VERSION}"
+ NPM_DIST_URL="https://www.npmjs.com/package/clawmaster?activeTab=versions"
+
+ # Build direct asset URLs deterministically from the release tag.
+ linux_appimage_url="${RELEASE_DOWNLOAD_BASE}/ClawMaster_${VERSION}_amd64.AppImage"
+ linux_deb_url="${RELEASE_DOWNLOAD_BASE}/ClawMaster_${VERSION}_amd64.deb"
+ macos_intel_url="${RELEASE_DOWNLOAD_BASE}/ClawMaster_${VERSION}_x64.dmg"
+ macos_arm64_url="${RELEASE_DOWNLOAD_BASE}/ClawMaster_${VERSION}_aarch64.dmg"
+ windows_msi_url="${RELEASE_DOWNLOAD_BASE}/ClawMaster_${VERSION}_x64_en-US.msi"
+ windows_exe_url="${RELEASE_DOWNLOAD_BASE}/ClawMaster_${VERSION}_x64-setup.exe"
+ checksums_url="${RELEASE_DOWNLOAD_BASE}/SHA256SUMS.txt"
+
# Use printf instead of heredoc because YAML's `run: |` indentation
# leaks into the heredoc content — turning every heading into an
# indented code block in the rendered markdown.
@@ -450,22 +464,23 @@ jobs:
printf '## Install\n\n'
printf '### CLI + Web Console (Recommended)\n\n'
printf '```bash\n'
- printf 'npm install -g clawmaster@%s\n' "$NPM_TAG"
+ printf 'npm install -g clawmaster\n'
printf 'clawmaster\n'
printf '```\n\n'
- printf 'Open http://localhost:16223 — the setup wizard guides you through OpenClaw engine detection and LLM provider configuration.\n\n'
+ printf '`clawmaster` starts the local service and opens the landing page in your browser by default.\n\n'
+ printf 'If the browser does not open automatically, use the backup local address: http://localhost:16223\n\n'
+ printf -- '- npm package: [%s](%s)\n' "clawmaster@${VERSION}" "$NPM_PACKAGE_URL"
+ printf -- '- all published versions: [%s](%s)\n\n' "npm versions" "$NPM_DIST_URL"
printf '> To pin this exact version: `npm install -g clawmaster@%s`\n\n' "$VERSION"
printf '### Desktop App (Beta)\n\n'
- printf 'Download the installer for your platform from the Assets section below.\n\n'
- printf '| Platform | Files |\n'
+ printf 'Direct downloads for this release. Checksums: [`SHA256SUMS.txt`](%s)\n\n' "$checksums_url"
+ printf '| Platform | Downloads |\n'
printf '|---|---|\n'
- printf '| Linux x64 | `.AppImage`, `.deb` |\n'
- printf '| macOS Intel | `.dmg` |\n'
- printf '| macOS Apple Silicon | `.dmg` |\n'
- printf '| Windows x64 | `.msi`, `.exe` |\n\n'
- printf '> **Note:** Desktop builds are in beta. The CLI + Web Console is the recommended install method.\n\n'
- printf '## Checksums\n\n'
- printf 'Verify downloads with `SHA256SUMS.txt` attached to this release.\n\n'
+ printf '| Linux x64 | [AppImage](%s) · [deb](%s) |\n' "$linux_appimage_url" "$linux_deb_url"
+ printf '| macOS Intel | [dmg](%s) |\n' "$macos_intel_url"
+ printf '| macOS Apple Silicon | [dmg](%s) |\n' "$macos_arm64_url"
+ printf '| Windows x64 | [msi](%s) · [exe](%s) |\n\n' "$windows_msi_url" "$windows_exe_url"
+ printf '> Desktop builds are in beta. The CLI + Web Console is the recommended install method.\n\n'
printf "## What's Changed\n\n"
printf '%s\n' "$GENERATED_BODY"
} > RELEASE_NOTES.md
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 43479837..7e1fc733 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -441,17 +441,30 @@ jobs:
await skipLink.first().click();
const skipGatewayStep = page.locator('text=网关').or(page.locator('text=Gateway'));
await skipGatewayStep.first().waitFor({ timeout: 5000 });
- await page.locator('text=网关已启动').or(page.locator('text=Gateway started')).first()
- .waitFor({ timeout: 10000 })
- .catch(() => page.locator('text=已就绪').or(page.locator('text=Ready')).first().waitFor({ timeout: 2000 }));
+ const startGatewayBtn = page.locator('text=启动网关').or(page.locator('text=Start Gateway'));
+ const checkGatewayBtn = page.locator('text=重新检查网关').or(page.locator('text=Check Again'));
const skipEnterBtn = page.locator('text=进入管理大师').or(page.locator('text=Enter ClawMaster'));
- await skipEnterBtn.first().waitFor({ timeout: 5000 });
- await skipEnterBtn.first().click();
- await page.waitForTimeout(2000);
- const skipMainVisible = await page.locator('.app-layout, [class*="Layout"], nav').first()
- .isVisible().catch(() => false);
- if (!skipMainVisible) bail('Main app not visible after skip');
- ok('Skip flow → main app rendered');
+ // Skip only needs to prove the wizard lands on the mandatory gateway step
+ // and offers at least one valid next action from there.
+ let skipNextAction = await skipEnterBtn.first().isVisible().catch(() => false);
+ if (!skipNextAction) {
+ const canStartGateway = await startGatewayBtn.first().isVisible().catch(() => false);
+ if (canStartGateway) {
+ await startGatewayBtn.first().click();
+ } else {
+ const canCheckGateway = await checkGatewayBtn.first().isVisible().catch(() => false);
+ if (canCheckGateway) {
+ await checkGatewayBtn.first().click();
+ }
+ }
+ await page.waitForTimeout(2000);
+ const enterVisible = await skipEnterBtn.first().isVisible().catch(() => false);
+ const startVisible = await startGatewayBtn.first().isVisible().catch(() => false);
+ const checkVisible = await checkGatewayBtn.first().isVisible().catch(() => false);
+ skipNextAction = enterVisible || startVisible || checkVisible;
+ }
+ if (!skipNextAction) bail('Skip flow reached gateway step but exposed no valid next action');
+ ok('Skip flow reached mandatory gateway step');
console.log(`\nAll ${step} wizard E2E checks passed`);
await browser.close();
diff --git a/package-lock.json b/package-lock.json
index 07d870bc..10a24328 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "clawmaster",
- "version": "0.3.0",
+ "version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "clawmaster",
- "version": "0.3.0",
+ "version": "0.3.1",
"license": "Apache-2.0",
"workspaces": [
"packages/*"
@@ -7732,7 +7732,7 @@
},
"packages/backend": {
"name": "@openclaw-manager/backend",
- "version": "0.3.0",
+ "version": "0.3.1",
"dependencies": {
"@langchain/core": "^1.1.38",
"cors": "^2.8.5",
@@ -7750,7 +7750,7 @@
},
"packages/web": {
"name": "@openclaw-manager/web",
- "version": "0.3.0",
+ "version": "0.3.1",
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/package.json b/package.json
index d3feb7bf..39c1c4e7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "clawmaster",
- "version": "0.3.0",
+ "version": "0.3.1",
"description": "龙虾管理大师——OpenClaw 生态的六边形战士",
"bin": {
"clawmaster": "bin/clawmaster.mjs"
@@ -37,7 +37,7 @@
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"prepack": "npm run build:backend && npm run build",
- "test:cli": "node --test bin/*.test.mjs tests/desktop/harness.unit.test.mjs",
+ "test:cli": "node --test bin/*.test.mjs scripts/*.test.mjs tests/desktop/harness.unit.test.mjs",
"test:plugins": "npx tsx --test plugins/memory-clawmaster-powermem/*.test.ts",
"test:desktop": "node --test tests/desktop/smoke.test.mjs",
"test:wizard-smoke": "node tests/install/wizard-install-smoke.mjs",
diff --git a/packages/backend/package.json b/packages/backend/package.json
index ed84d4fb..d8d8331c 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw-manager/backend",
- "version": "0.3.0",
+ "version": "0.3.1",
"private": true,
"type": "module",
"scripts": {
diff --git a/packages/web/package.json b/packages/web/package.json
index b60e56db..3a439f49 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw-manager/web",
- "version": "0.3.0",
+ "version": "0.3.1",
"private": true,
"type": "module",
"scripts": {
diff --git a/packages/web/src/app/Layout.tsx b/packages/web/src/app/Layout.tsx
index ebb373bd..5444f068 100644
--- a/packages/web/src/app/Layout.tsx
+++ b/packages/web/src/app/Layout.tsx
@@ -13,8 +13,8 @@ import { getCommandDescriptors } from './commandRegistry'
import { getCommandShortcutLabel, isAppleClientPlatform } from './commandShortcut'
import { resolveIcon } from './iconRegistry'
import { NAV_SECTIONS, PAGE_META } from './navigationMeta'
+import { BrandMark } from '@/shared/components/BrandMark'
import {
- Shell,
Sun,
Moon,
Menu,
@@ -376,9 +376,7 @@ export default function Layout({ children }: LayoutProps) {
<>
-
-
-
+
{t('layout.appName')}
{t('layout.appSub')}
@@ -389,18 +387,6 @@ export default function Layout({ children }: LayoutProps) {
-
-
-
{currentSection ? t(currentSection.labelKey) : t('layout.appName')}
-
{pageTitle}
-
{pageDescription}
-
-
- {gwStatus?.running ? t('layout.status.gatewayRunning') : t('layout.status.gatewayStopped')}
-
-
-
-
-
+
{t('layout.update.action')}
-
-
-
{t('dashboard.gatewayStatus')}
-
- {status?.running ? t('dashboard.running') : t('dashboard.stopped')}
-
-
{gatewayUrl}
-
-
-
{t('gateway.port')}
-
{gatewayPort}
-
{t('gateway.bind')}: {gatewayBind}
-
-
-
{t('gateway.auth')}
-
{gatewayAuthMode}
-
- {gatewayToken ? (
-
- {t('gateway.copyToken')}
-
- ) : 'Token -'}
-
-
-
-
-
+
{loading ? (
) : (
-
-
-
-
-
{t('dashboard.manage')}
-
{gatewayUrl}
-
-
-
- {status?.running
- ? t('gateway.openInBrowser')
- : t('gateway.editConfigHint')}
+ <>
+
+
+
{t('dashboard.manage')}
+
{gatewayUrl}
-
{gatewayUrl}
-
- {operating ? (
-
{operating}
- ) : statusLoading ? (
-
{t('common.loading')}
- ) : status?.running ? (
- <>
-
handleGatewayAction('stop')}
- className="button-danger"
- >
- {t('gateway.stop')}
-
-
handleGatewayAction('restart')}
- className="button-secondary"
- >
- {t('gateway.restart')}
+
+
+
+
+
+
+ {t('dashboard.gatewayStatus')}
+
+
+
+
+ {status?.running ? t('dashboard.running') : t('dashboard.stopped')}
+
+
+
+ {gatewayUrl}
+
+
+
+
+
+ {t('gateway.openInBrowser')}
+
+
+
+
+
+
{t('gateway.port')}
+
{gatewayPort}
+
+
+
{t('gateway.bind')}
+
{gatewayBind}
+
+
+
{t('gateway.auth')}
+
{gatewayAuthMode}
+
+
+
+
+
+
+
+
+
+ {t('dashboard.quickActions')}
+
+
+
+ {operating ? (
+
{operating}
+ ) : statusLoading ? (
+
{t('common.loading')}
+ ) : status?.running ? (
+ <>
+
handleGatewayAction('stop')}
+ className="button-danger justify-center"
+ >
+
+ {t('gateway.stop')}
+
+
handleGatewayAction('restart')}
+ className="button-secondary justify-center"
+ >
+
+ {t('gateway.restart')}
+
+ >
+ ) : (
+
handleGatewayAction('start')}
+ className="button-primary justify-center"
+ >
+
+ {t('gateway.start')}
+
+ )}
+
setLogsOpen(true)} className="button-secondary justify-center">
+
+ {t('logs.openRecent')}
- >
- ) : (
-
handleGatewayAction('start')}
- className="button-primary"
- >
- {t('gateway.start')}
-
- )}
-
- {t('gateway.openInBrowser')}
-
-
setLogsOpen(true)} className="button-secondary">
-
- {t('logs.openRecent')}
-
-
- {t('logs.moreDiagnostics')}
-
+
+ {t('logs.moreDiagnostics')}
+
+
+
-
+
+
+ {status?.running ? t('gateway.openInBrowser') : t('gateway.editConfigHint')}
+
+ >
)}
-
+
{t('gateway.config')}
@@ -222,29 +243,32 @@ export default function Gateway() {
) : (
<>
-
-
-
{t('gateway.port')}
-
{gatewayPort}
+
+
+
{t('gateway.port')}
+
{gatewayPort}
-
-
{t('gateway.bind')}
-
{gatewayBind}
+
+
{t('gateway.bind')}
+
{gatewayBind}
-
-
{t('gateway.auth')}
-
{gatewayAuthMode}
+
+
{t('gateway.auth')}
+
{gatewayAuthMode}
{gatewayToken && (
-
-
Token
-
{t('gateway.copyToken')}
+
+
Token
+
{t('gateway.copyToken')}
)}
-
- {t('gateway.editConfigHint')}
-
+
+ {t('gateway.editConfigHint')}
+
+ {t('config.title')}
+
+
>
)}
diff --git a/packages/web/src/modules/settings/CapabilitiesSection.tsx b/packages/web/src/modules/settings/CapabilitiesSection.tsx
index b17ee0e5..1aa2cecf 100644
--- a/packages/web/src/modules/settings/CapabilitiesSection.tsx
+++ b/packages/web/src/modules/settings/CapabilitiesSection.tsx
@@ -181,10 +181,86 @@ function CapabilityRow({
)
}
+function NpmProxyToggle({
+ checked,
+ busy,
+ registryUrl,
+ label,
+ onToggle,
+}: {
+ checked: boolean
+ busy: boolean
+ registryUrl: string
+ label: string
+ onToggle: (nextValue: boolean) => void
+}) {
+ const { t } = useTranslation()
+
+ return (
+
onToggle(!checked)}
+ disabled={busy}
+ className={[
+ 'group flex min-w-[13rem] max-w-full items-center justify-between gap-3 rounded-2xl border px-3 py-3 text-left transition-colors',
+ checked
+ ? 'border-emerald-500/30 bg-emerald-500/10 text-foreground'
+ : 'border-border bg-background/80 text-muted-foreground hover:border-border/80 hover:bg-background',
+ busy ? 'cursor-wait opacity-80' : 'cursor-pointer',
+ ].join(' ')}
+ >
+
+
+ {label}
+ {busy ? (
+
+ ) : null}
+
+
+
+ {checked ? t('settings.capabilities.statusInstalled') : t('settings.capabilities.statusNotInstalled')}
+
+
+ {registryUrl}
+
+
+
+
+
+
+
+
+ )
+}
+
export function CapabilitiesSection() {
const { t } = useTranslation()
const { capabilities, installProgress, detecting, installing, error, detect, install } =
useCapabilityManager()
+ const visibleCapabilities = capabilities.filter((capability) => capability.id !== 'agent')
const [confirmReinstallId, setConfirmReinstallId] = useState
(null)
const [npmProxyEnabled, setNpmProxyEnabled] = useState(false)
@@ -346,11 +422,11 @@ export function CapabilitiesSection() {
}, [t])
const confirmCapability = confirmReinstallId
- ? capabilities.find((c) => c.id === confirmReinstallId)
+ ? visibleCapabilities.find((c) => c.id === confirmReinstallId)
: null
return (
-
+
{t('settings.capabilities.title')}
@@ -376,28 +452,32 @@ export function CapabilitiesSection() {
)}
-
-
-
-
{t('settings.capabilities.npmProxyTitle')}
-
- {t('settings.capabilities.npmProxyDesc', {
- registry: effectiveNpmProxyRegistryUrl ?? DEFAULT_NPM_PROXY_REGISTRY_URL,
- })}
-
+
+
+
+
+
{t('settings.capabilities.npmProxyTitle')}
+
+ {t('settings.capabilities.npmProxyDesc', {
+ registry: effectiveNpmProxyRegistryUrl ?? DEFAULT_NPM_PROXY_REGISTRY_URL,
+ })}
+
+
-
+
+
{npmProxyError ? (
{npmProxyError}
@@ -405,14 +485,14 @@ export function CapabilitiesSection() {
) : null}
-
- {capabilities.length === 0 && detecting ? (
+
+ {visibleCapabilities.length === 0 && detecting ? (
{t('settings.capabilities.statusChecking')}
) : (
- capabilities.map((capability) => (
+ visibleCapabilities.map((capability) => (
(null)
@@ -772,7 +786,7 @@ export default function Settings() {
{localData && (
-
+
{t('settings.localDataTitle')}
@@ -780,8 +794,8 @@ export default function Settings() {
-
-
+
+
{t(localDataStateLabelKey(localData.state))}
@@ -790,74 +804,12 @@ export default function Settings() {
{t(localDataEngineLabelKey(localData.engine))}
-
-
-
-
-
-
- {t(localDataSummaryLabelKey(localData))}
-
- {localDataEffectiveReasonLabelKey(localData) ? (
-
- {t(localDataEffectiveReasonLabelKey(localData)!)}
-
- ) : (
-
- {t('settings.localDataNoPythonHint')}
-
- )}
-
-
-
-
-
-
{t('settings.localDataRuntime')}
-
- {localData.runtimeTarget === 'wsl2' ? t('settings.runtimeWsl2') : t('settings.runtimeNative')}
-
-
-
-
{t('settings.localDataProfile')}
-
- {resolvedProfileMode === 'named'
- ? `${t('settings.profileNamed')} · ${resolvedProfileName}`
- : resolvedProfileMode === 'dev'
- ? t('settings.profileDev')
- : t('settings.profileDefault')}
-
-
-
-
{t('settings.localDataEmbeddedSupport')}
-
- {localData.supportsEmbedded ? t('settings.localDataSupported') : t('settings.localDataUnavailable')}
-
-
-
-
-
-
-
{t('settings.localDataDocuments')}
-
{localDataStats?.documentCount ?? 0}
-
-
-
{t('settings.localDataDocsModule')}
-
{localDataStats?.moduleCounts.docs ?? 0}
-
-
-
{t('settings.localDataUpdatedAt')}
-
- {localDataStats?.updatedAt ? new Date(localDataStats.updatedAt).toLocaleString() : t('common.notSet')}
-
-
-
-
-
+
void rebuildLocalData()}
disabled={localDataActionsDisabled}
- className="button-secondary"
+ className="button-secondary whitespace-nowrap"
>
{localDataBusy ? : }
{t('settings.localDataRebuild')}
@@ -866,30 +818,111 @@ export default function Settings() {
type="button"
onClick={() => setConfirmAction('local-data-reset')}
disabled={localDataActionsDisabled}
- className="button-danger"
+ className="button-danger whitespace-nowrap"
>
{t('settings.localDataReset')}
- {isTauri() ? (
-
- {t('settings.localDataDesktopPending')}
+
+
+
+
+
+
+
+
+ {t(localDataSummaryLabelKey(localData))}
+
+
+ {localDataEffectiveReasonLabelKey(localData)
+ ? t(localDataEffectiveReasonLabelKey(localData)!)
+ : t('settings.localDataNoPythonHint')}
- ) : null}
+ {isTauri() ? (
+
+ {t('settings.localDataDesktopPending')}
+
+ ) : null}
+
-
-
- {t('settings.localDataResolved')}
-
-
-
-
-
-
-
+
+
+
{t('settings.localDataDocuments')}
+
{localDataStats?.documentCount ?? 0}
+
+
+
{t('settings.localDataDocsModule')}
+
{localDataStats?.moduleCounts.docs ?? 0}
+
+
{t('settings.localDataUpdatedAt')}
+
+ {formatLocalDataUpdatedAt(localDataStats?.updatedAt, i18n.resolvedLanguage ?? i18n.language, t('common.notSet'))}
+
+
+
+
+
+
+ {t('settings.localDataRuntime')}: {localData.runtimeTarget === 'wsl2' ? t('settings.runtimeWsl2') : t('settings.runtimeNative')}
+
+
+ {t('settings.localDataProfile')}:
+ {resolvedProfileMode === 'named'
+ ? `${t('settings.profileNamed')} · ${resolvedProfileName}`
+ : resolvedProfileMode === 'dev'
+ ? t('settings.profileDev')
+ : t('settings.profileDefault')}
+
+
+
+ {t('settings.localDataEmbeddedSupport')}:
+ {localData.supportsEmbedded ? t('settings.localDataSupported') : t('settings.localDataUnavailable')}
+
+
+
+
+
+ {t('settings.localDataResolved')}
+ {t(localDataEngineLabelKey(localData.engine))}
+
+
+
+
+ {t('settings.localDataEngine')}
+ {t(localDataEngineLabelKey(localData.engine))}
+
+
+ {t('settings.localDataTarget')}
+ {`${localData.targetPlatform} · ${localData.targetArch}`}
+
+
+ {t('settings.localDataNodeRequirement')}
+ {localData.nodeRequirement}
+
+
+
+
+
+ {t('settings.localDataRoot')}
+
+
+ {localData.dataRoot ?? t('common.notSet')}
+
+
+
+
+ {t('settings.localDataEngineRoot')}
+
+
+ {localData.engineRoot ?? t('common.notSet')}
+
+
+
+
+
)}
@@ -946,13 +979,17 @@ export default function Settings() {
{t('logs.systemCardDescription')}
-
-
setLogsOpen('all')}>
+
+
setLogsOpen('all')}>
{t('logs.openSystemLogs')}
-
- {t('logs.gotoSystemInfo')}
+
+
{t('logs.gotoSystemInfo')}
+
@@ -966,13 +1003,17 @@ export default function Settings() {
{t('logs.gatewayCardDescription')}
-
-
setLogsOpen('gateway')}>
+
+
setLogsOpen('gateway')}>
{t('logs.openGatewayLogs')}
-
- {t('logs.gotoGatewayPage')}
+
+
{t('logs.gotoGatewayPage')}
+
@@ -986,13 +1027,17 @@ export default function Settings() {
{t('logs.channelsCardDescription')}
-
-
setLogsOpen('channels')}>
+
+
setLogsOpen('channels')}>
{t('logs.openChannelLogs')}
-
- {t('logs.gotoChannelsPage')}
+
+
{t('logs.gotoChannelsPage')}
+
@@ -1025,27 +1070,45 @@ export default function Settings() {
{/* 关于 */}
-
+
{t('settings.about')}
- {t('settings.aboutName')}
- {t('settings.aboutDesc')}
- {t('settings.aboutCommunity')}
-
-
+
+
{t('settings.aboutName')}
+
+ {t('settings.aboutDesc')} · {t('settings.aboutCommunity')}
+
+
+
{/* Acknowledgments */}
-
+
{t('settings.acknowledgments')}
{[
@@ -1060,7 +1123,7 @@ export default function Settings() {
href={p.url}
target="_blank"
rel="noopener noreferrer"
- className="rounded-xl border border-transparent px-3 py-2 transition hover:border-primary/20 hover:bg-card/70 hover:text-primary"
+ className="rounded-xl border border-transparent px-3 py-2 leading-5 transition hover:border-primary/20 hover:bg-card/70 hover:text-primary"
title={p.desc}
>
{p.name} {p.desc}
@@ -1127,17 +1190,6 @@ export default function Settings() {
)
}
-function LocalDataRow({ label, value, monospace = false }: { label: string; value: string; monospace?: boolean }) {
- return (
-
- {label}
-
- {value}
-
-
- )
-}
-
// ─── Update Section ───
type UpdateState = 'idle' | 'checking' | 'up-to-date' | 'available' | 'error'
@@ -1186,6 +1238,7 @@ function UpdateSection({
onUpdated: () => void
}) {
const { t } = useTranslation()
+ const location = useLocation()
const [state, setState] = useState
('idle')
const [versions, setVersions] = useState(null)
const [channel, setChannel] = useState('stable')
@@ -1193,6 +1246,7 @@ function UpdateSection({
const [error, setError] = useState(null)
const [releases, setReleases] = useState([])
const [changelogOpen, setChangelogOpen] = useState(false)
+ const autoCheckTriggeredRef = useRef(false)
const updateTask = useInstallTask()
const channelVersion = versions
@@ -1244,8 +1298,19 @@ function UpdateSection({
// Recent versions for dropdown (max 20)
const recentVersions = versions?.versions.slice(0, 20) ?? []
+ useEffect(() => {
+ const shouldAutoCheck = location.hash === '#settings-update'
+ if (!shouldAutoCheck) {
+ autoCheckTriggeredRef.current = false
+ return
+ }
+ if (autoCheckTriggeredRef.current || state !== 'idle' || updateTask.status !== 'idle') return
+ autoCheckTriggeredRef.current = true
+ void handleCheck()
+ }, [handleCheck, location.hash, state, updateTask.status])
+
return (
-
+
{t('settings.update')}
diff --git a/packages/web/src/modules/settings/__tests__/CapabilitiesSection.test.tsx b/packages/web/src/modules/settings/__tests__/CapabilitiesSection.test.tsx
index 85cd6ca4..dc64746d 100644
--- a/packages/web/src/modules/settings/__tests__/CapabilitiesSection.test.tsx
+++ b/packages/web/src/modules/settings/__tests__/CapabilitiesSection.test.tsx
@@ -247,9 +247,9 @@ describe('CapabilitiesSection', () => {
renderSection()
- const checkbox = await screen.findByRole('checkbox')
+ const checkbox = await screen.findByRole('switch', { name: '使用 npm 镜像' })
await waitFor(() => {
- expect(checkbox).toBeChecked()
+ expect(checkbox).toHaveAttribute('aria-checked', 'true')
})
fireEvent.click(checkbox)
@@ -295,9 +295,9 @@ describe('CapabilitiesSection', () => {
renderSection()
- const checkbox = await screen.findByRole('checkbox')
+ const checkbox = await screen.findByRole('switch', { name: '使用 npm 镜像' })
await waitFor(() => {
- expect(checkbox).toBeChecked()
+ expect(checkbox).toHaveAttribute('aria-checked', 'true')
})
fireEvent.click(checkbox)
diff --git a/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx b/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx
index 94b3e812..9d2e86f9 100644
--- a/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx
+++ b/packages/web/src/modules/settings/__tests__/UpdateSection.test.tsx
@@ -89,16 +89,16 @@ vi.mock('react-i18next', () => ({
'settings.localDataStateReady': 'Ready',
'settings.localDataStateDegraded': 'Fallback',
'settings.localDataStateBlocked': 'Blocked',
- 'settings.localDataEngineEmbedded': 'SeekDB embedded',
+ 'settings.localDataEngineEmbedded': 'seekdb',
'settings.localDataEngineFallback': 'Fallback store',
'settings.localDataEngineUnavailable': 'Unavailable',
- 'settings.localDataReadySummary': 'SeekDB embedded can run for this runtime target.',
- 'settings.localDataFallbackSummary': 'ClawMaster will keep working through a file-backed fallback until SeekDB embedded is available.',
+ 'settings.localDataReadySummary': 'seekdb can run for this runtime target.',
+ 'settings.localDataFallbackSummary': 'ClawMaster will keep working through a file-backed fallback until this target supports seekdb.',
'settings.localDataBlockedSummary': 'Local data cannot pick a safe target yet.',
'settings.localDataNoPythonHint': 'This foundation uses JavaScript/TypeScript runtime paths only; no Python package or Python-based skill is required.',
- 'settings.localDataReasonNodeMissing': 'Node.js is missing in the selected runtime. SeekDB requires Node 20 or newer, so the fallback store is selected.',
- 'settings.localDataReasonNodeTooOld': 'The selected runtime uses Node.js below 20. Upgrade Node.js to enable SeekDB embedded where the platform supports it.',
- 'settings.localDataReasonUnsupportedPlatform': 'SeekDB embedded bindings are not available on this target yet. Use WSL2, server mode later, or fallback storage.',
+ 'settings.localDataReasonNodeMissing': 'Node.js is missing in the selected runtime. seekdb requires Node 20 or newer, so the fallback store is selected.',
+ 'settings.localDataReasonNodeTooOld': 'The selected runtime uses Node.js below 20. Upgrade Node.js to enable seekdb where the platform supports it.',
+ 'settings.localDataReasonUnsupportedPlatform': 'seekdb bindings are not available on this target yet. Use WSL2, server mode later, or fallback storage.',
'settings.localDataReasonWslDistroMissing': 'WSL2 mode is selected, but the saved distro is missing. Re-select the distro before Local Data can safely resolve a target.',
'settings.localDataRuntime': 'Runtime',
'settings.localDataProfile': 'Profile',
@@ -112,7 +112,7 @@ vi.mock('react-i18next', () => ({
'settings.localDataRoot': 'Data root',
'settings.localDataEngineRoot': 'Engine root',
'settings.localDataDocuments': 'Documents',
- 'settings.localDataDocsModule': 'Docs indexed',
+ 'settings.localDataDocsModule': 'Docs index',
'settings.localDataUpdatedAt': 'Updated',
'settings.localDataRebuild': 'Rebuild Index',
'settings.localDataReset': 'Reset Store',
@@ -286,9 +286,9 @@ vi.mock('@/modules/setup/adapters', () => ({
import Settings from '../SettingsPage'
import { platform } from '@/adapters'
-function renderSettings() {
+function renderSettings(initialEntries?: string[]) {
return render(
-
+
,
)
@@ -393,6 +393,23 @@ describe('UpdateSection', () => {
expect(screen.getAllByText(/v2026\.3\.28/).length).toBeGreaterThanOrEqual(1)
})
+ it('auto-checks updates when opened from the update banner hash', async () => {
+ mockListVersions.mockResolvedValue({
+ success: true,
+ data: {
+ distTags: { latest: '2026.4.23', beta: '2026.4.24-beta.1', dev: '2026.4.25-dev.1' },
+ versions: ['2026.4.23', '2026.4.22'],
+ },
+ error: null,
+ })
+
+ renderSettings(['/settings#settings-update'])
+
+ expect(await screen.findByText('Version:')).toBeInTheDocument()
+ expect(mockListVersions).toHaveBeenCalledTimes(1)
+ expect(screen.getByRole('button', { name: 'Update to 2026.4.23' })).toBeInTheDocument()
+ })
+
it('opens recent diagnostics logs from settings and keeps the full log stream', async () => {
renderSettings()
await waitFor(() => {
@@ -448,11 +465,11 @@ describe('UpdateSection', () => {
expect(localData.getByText('Ready')).toBeInTheDocument()
expect(localData.getAllByText('Fallback store').length).toBeGreaterThan(0)
- expect(localData.getByText('ClawMaster will keep working through a file-backed fallback until SeekDB embedded is available.')).toBeInTheDocument()
+ expect(localData.getByText('ClawMaster will keep working through a file-backed fallback until this target supports seekdb.')).toBeInTheDocument()
expect(localData.getByText('This foundation uses JavaScript/TypeScript runtime paths only; no Python package or Python-based skill is required.')).toBeInTheDocument()
expect(localData.getByText('/home/.clawmaster/data/default/fallback')).toBeInTheDocument()
expect(localData.getAllByText('12')).toHaveLength(2)
- expect(localData.queryByText('SeekDB embedded can run for this runtime target.')).not.toBeInTheDocument()
+ expect(localData.queryByText('seekdb can run for this runtime target.')).not.toBeInTheDocument()
})
it('shows fallback summary and unsupported target guidance when embedded support is unavailable', async () => {
@@ -480,9 +497,9 @@ describe('UpdateSection', () => {
expect(section).not.toBeNull()
const localData = within(section!)
- expect(localData.getByText('ClawMaster will keep working through a file-backed fallback until SeekDB embedded is available.')).toBeInTheDocument()
- expect(localData.getByText('SeekDB embedded bindings are not available on this target yet. Use WSL2, server mode later, or fallback storage.')).toBeInTheDocument()
- expect(localData.queryByText('SeekDB embedded can run for this runtime target.')).not.toBeInTheDocument()
+ expect(localData.getByText('ClawMaster will keep working through a file-backed fallback until this target supports seekdb.')).toBeInTheDocument()
+ expect(localData.getByText('seekdb bindings are not available on this target yet. Use WSL2, server mode later, or fallback storage.')).toBeInTheDocument()
+ expect(localData.queryByText('seekdb can run for this runtime target.')).not.toBeInTheDocument()
})
it('clears stale local data stats when a refresh fails', async () => {
diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs
old mode 100755
new mode 100644
index 8fb1bb35..53fcb845
--- a/scripts/release-notes.mjs
+++ b/scripts/release-notes.mjs
@@ -2,96 +2,364 @@
// Emits a Markdown "What's Changed" section for a tag, grouping user-facing
// conventional commits into a few release-note buckets. CI / release
// housekeeping and unprefixed commits are intentionally excluded. Designed for GitHub
-// releases where `gh api .../releases/generate-notes` falls short because the
-// tagged branch only contains release-merge commits (git-flow pattern).
+// releases where git-flow tags land on merge commits and the release notes should
+// cover the full release window, not just the nearest preceding tag.
//
// Usage: node scripts/release-notes.mjs
// Env: GH_REPO (owner/repo) — required; GH_TOKEN/GITHUB_TOKEN for PR lookup.
-import { execSync } from 'node:child_process'
+import { execFileSync, execSync } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
-const tag = process.argv[2]
-if (!tag) {
- console.error('usage: release-notes.mjs ')
- process.exit(1)
-}
-
-const repo =
- process.env.GH_REPO ||
- execSync('gh repo view --json nameWithOwner --jq .nameWithOwner', { encoding: 'utf8' }).trim()
+export const BUCKETS = [
+ { id: 'features', title: 'Features & Polish', emoji: '✨', types: ['feat', 'polish'] },
+ { id: 'fixes', title: 'Fixes', emoji: '🐛', types: ['fix'] },
+ { id: 'misc', title: 'Misc', emoji: '📝', types: [] },
+]
-const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
+const EXCLUDED_TYPES = new Set(['ci', 'test', 'build', 'perf', 'refactor', 'chore', 'docs', 'style'])
+const PRE_RELEASE_RE = /-(rc|beta|alpha)(?:\.\d+)?$/i
+const STABLE_TAG_RE = /^v\d+\.\d+\.\d+$/
-let prevTag = ''
-try {
- prevTag = execSync(`git describe --tags --abbrev=0 --match 'v*' ${tag}^`, {
+function runGit(command, options = {}) {
+ return execSync(command, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
+ ...options,
}).trim()
-} catch {
- // No previous tag — this is the first release.
}
-const range = prevTag ? `${prevTag}..${tag}` : tag
-const log = execSync(`git log --no-merges --pretty=format:%H%x09%s ${range}`, {
- encoding: 'utf8',
-}).trim()
+export function parseRemoteTags(rawRefs) {
+ return rawRefs
+ .split('\n')
+ .filter(Boolean)
+ .map((line) => {
+ const parts = line.trim().split(/\s+/)
+ return parts[1] || ''
+ })
+ .filter((ref) => ref.startsWith('refs/tags/'))
+ .map((ref) => ref.replace(/^refs\/tags\//, '').replace(/\^\{\}$/, ''))
+ .filter(Boolean)
+}
-const commits = log
- .split('\n')
- .filter(Boolean)
- .map((line) => {
- const [sha, ...rest] = line.split('\t')
- return { sha, subject: rest.join('\t') }
- })
+export function parseTagVersion(tag) {
+ const match = tag.match(/^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/)
+ if (!match) return null
+ return {
+ major: Number(match[1]),
+ minor: Number(match[2]),
+ patch: Number(match[3]),
+ prerelease: match[4] ?? '',
+ }
+}
-const BUCKETS = [
- { id: 'features', title: '### ✨ Features & Polish', types: ['feat', 'polish'] },
- { id: 'fixes', title: '### 🐛 Fixes', types: ['fix'] },
- { id: 'misc', title: '### 📝 Misc', types: [] },
-]
+function compareIdentifiers(a, b) {
+ const aIsNumeric = /^\d+$/.test(a)
+ const bIsNumeric = /^\d+$/.test(b)
+ if (aIsNumeric && bIsNumeric) return Number(a) - Number(b)
+ if (aIsNumeric) return -1
+ if (bIsNumeric) return 1
+ return a.localeCompare(b)
+}
-const EXCLUDED_TYPES = new Set(['ci', 'test', 'build', 'perf', 'refactor', 'chore', 'docs', 'style'])
+export function compareTagVersions(leftTag, rightTag) {
+ const left = parseTagVersion(leftTag)
+ const right = parseTagVersion(rightTag)
+ if (!left || !right) return leftTag.localeCompare(rightTag)
+ for (const key of ['major', 'minor', 'patch']) {
+ if (left[key] !== right[key]) return left[key] - right[key]
+ }
+ if (!left.prerelease && !right.prerelease) return 0
+ if (!left.prerelease) return 1
+ if (!right.prerelease) return -1
+ const leftParts = left.prerelease.split('.')
+ const rightParts = right.prerelease.split('.')
+ const max = Math.max(leftParts.length, rightParts.length)
+ for (let i = 0; i < max; i += 1) {
+ if (leftParts[i] == null) return -1
+ if (rightParts[i] == null) return 1
+ const cmp = compareIdentifiers(leftParts[i], rightParts[i])
+ if (cmp !== 0) return cmp
+ }
+ return 0
+}
+
+export function isStableReleaseTag(tag) {
+ return STABLE_TAG_RE.test(tag)
+}
-function bucketOf(subject) {
- const m = subject.match(/^([a-z]+)(?:\([^)]+\))?:/)
- if (!m) return null
- const type = m[1]
+export function bucketOf(subject) {
+ const match = subject.match(/^([a-z]+)(?:\([^)]+\))?:/)
+ if (!match) return null
+ const type = match[1]
if (EXCLUDED_TYPES.has(type)) return null
- return BUCKETS.find((b) => b.types.includes(type))?.id ?? 'misc'
+ return BUCKETS.find((bucket) => bucket.types.includes(type))?.id ?? 'misc'
+}
+
+export function parseGitLog(rawLog) {
+ return rawLog
+ .split('\n')
+ .filter(Boolean)
+ .map((line) => {
+ const [sha, authorName, authorEmail, ...rest] = line.split('\t')
+ return {
+ sha,
+ authorName,
+ authorEmail,
+ subject: rest.join('\t'),
+ }
+ })
+}
+
+export function selectReleaseBaseTag({ tag, previousAncestorTag = '', allTags = [] }) {
+ if (!isStableReleaseTag(tag)) return previousAncestorTag
+ const currentVersion = parseTagVersion(tag)
+ if (!currentVersion) return previousAncestorTag
+ const matchingTags = allTags
+ .filter(Boolean)
+ .sort(compareTagVersions)
+ const previousStable = matchingTags
+ .filter((candidate) => candidate !== tag && isStableReleaseTag(candidate))
+ .filter((candidate) => compareTagVersions(candidate, tag) < 0)
+ .at(-1)
+ const firstTag = matchingTags.at(0) || ''
+ return previousStable || firstTag || previousAncestorTag
+}
+
+function normalizeIdentityPart(value) {
+ return value.trim().toLowerCase()
+}
+
+function addIdentityKeys(target, contributor) {
+ if (contributor.login) target.add(`login:${normalizeIdentityPart(contributor.login)}`)
+ if (contributor.authorEmail) target.add(`email:${normalizeIdentityPart(contributor.authorEmail)}`)
+ if (contributor.authorName) target.add(`name:${normalizeIdentityPart(contributor.authorName)}`)
+}
+
+function contributorIdentityKeys(contributor) {
+ const keys = new Set()
+ addIdentityKeys(keys, contributor)
+ return keys
}
-async function prFor(sha) {
- const res = await fetch(`https://api.github.com/repos/${repo}/commits/${sha}/pulls`, {
- headers: {
- Accept: 'application/vnd.github+json',
- 'X-GitHub-Api-Version': '2022-11-28',
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
- },
+function isBotContributor({ login, authorName, authorEmail }) {
+ const values = [login, authorName, authorEmail].filter(Boolean).map((value) => value.toLowerCase())
+ return values.some((value) => value.includes('[bot]') || value.endsWith('bot') || value.includes('bot@'))
+}
+
+export function displayContributor(contributor) {
+ if (contributor.login) {
+ const avatar = contributor.avatarUrl ? `
` : ''
+ return `${avatar}[@${contributor.login}](https://github.com/${contributor.login})`
+ }
+ return contributor.authorName || contributor.authorEmail
+}
+
+export function joinHumanList(values) {
+ if (values.length === 0) return ''
+ if (values.length === 1) return values[0]
+ if (values.length === 2) return `${values[0]} and ${values[1]}`
+ return `${values.slice(0, -1).join(', ')}, and ${values.at(-1)}`
+}
+
+export function summarizeContributors(commits, priorContributorKeys) {
+ const contributors = []
+ for (const commit of commits) {
+ if (isBotContributor(commit)) continue
+ const candidate = {
+ login: commit.login || '',
+ authorName: commit.authorName || '',
+ authorEmail: commit.authorEmail || '',
+ }
+ const candidateKeys = contributorIdentityKeys(candidate)
+ const existing = contributors.find((entry) => {
+ const entryKeys = contributorIdentityKeys(entry)
+ return [...candidateKeys].some((key) => entryKeys.has(key))
+ })
+ if (!existing) {
+ contributors.push(candidate)
+ } else {
+ if (!existing.login && candidate.login) existing.login = candidate.login
+ if (!existing.authorName && candidate.authorName) existing.authorName = candidate.authorName
+ if (!existing.authorEmail && candidate.authorEmail) existing.authorEmail = candidate.authorEmail
+ }
+ }
+
+ return contributors
+ .map((contributor) => {
+ const keys = contributorIdentityKeys(contributor)
+ const firstTime = ![...keys].some((key) => priorContributorKeys.has(key))
+ return { ...contributor, firstTime }
+ })
+ .sort((left, right) => displayContributor(left).localeCompare(displayContributor(right)))
+}
+
+export function buildReleaseNotesBody({ repo, tag, baseTag, commits, contributors }) {
+ const lines = []
+
+ for (const bucket of BUCKETS) {
+ const items = commits.filter((commit) => commit.bucket === bucket.id)
+ if (!items.length) continue
+ lines.push(`### ${bucket.emoji} ${bucket.title}`, '')
+ lines.push(`Summary: ${items.length} ${items.length === 1 ? 'change' : 'changes'} shipped in this area.`)
+ lines.push('')
+ lines.push('')
+ lines.push(`Show ${bucket.title.toLowerCase()}
`, '')
+ for (const commit of items) {
+ const hasInlinePrRef = commit.pr ? new RegExp(`(^|[^\\w])#${commit.pr}(?!\\d)`).test(commit.subject) : false
+ const prLink = commit.pr && !hasInlinePrRef ? ` ([#${commit.pr}](https://github.com/${repo}/pull/${commit.pr}))` : ''
+ const authorLink = commit.login ? ` by [@${commit.login}](https://github.com/${commit.login})` : ''
+ lines.push(`- ${commit.subject}${authorLink}${prLink}`)
+ }
+ lines.push('', ' ', '')
+ }
+
+ if (contributors.length) {
+ const contributorLinks = contributors.map(displayContributor)
+ const firstTimers = contributors.filter((contributor) => contributor.firstTime).map(displayContributor)
+ lines.push('### 🙌 Contributors', '')
+ lines.push(`Thanks ${joinHumanList(contributorLinks)} for the commits that shipped in this release.`)
+ if (firstTimers.length === 1) {
+ lines.push(`Welcome to our first-time contributor ${firstTimers[0]}!`)
+ } else if (firstTimers.length > 1) {
+ lines.push(`Welcome to our first-time contributors ${joinHumanList(firstTimers)}!`)
+ }
+ lines.push('')
+ }
+
+ if (baseTag) {
+ lines.push(`**Full Changelog**: https://github.com/${repo}/compare/${baseTag}...${tag}`)
+ }
+
+ return lines.join('\n').trim() + '\n'
+}
+
+async function fetchJson(url, token, accept = 'application/vnd.github+json') {
+ const pathname = new URL(url).pathname.replace(/^\/+/, '')
+ try {
+ const ghArgs = ['api', pathname, '-H', `Accept: ${accept}`, '-H', 'X-GitHub-Api-Version: 2022-11-28']
+ if (token) ghArgs.push('-H', `Authorization: Bearer ${token}`)
+ const output = execFileSync('gh', ghArgs, {
+ encoding: 'utf8',
+ stdio: ['pipe', 'pipe', 'ignore'],
+ }).trim()
+ return output ? JSON.parse(output) : null
+ } catch {
+ // Fall through to fetch fallback below.
+ }
+ try {
+ const response = await fetch(url, {
+ headers: {
+ Accept: accept,
+ 'X-GitHub-Api-Version': '2022-11-28',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ })
+ if (response.ok) return response.json()
+ } catch {
+ // ignore
+ }
+ return null
+}
+
+export function selectGithubIdentity({ commitAuthor = null, commitCommitter = null, pullAuthor = null }) {
+ const candidate = commitAuthor || commitCommitter || pullAuthor || null
+ return {
+ login: candidate?.login || '',
+ avatarUrl: candidate?.avatar_url || '',
+ }
+}
+
+async function resolveCommitMeta({ repo, sha, token }) {
+ const [commitBody, pullBody] = await Promise.all([
+ fetchJson(`https://api.github.com/repos/${repo}/commits/${sha}`, token),
+ fetchJson(`https://api.github.com/repos/${repo}/commits/${sha}/pulls`, token),
+ ])
+ const firstPull = Array.isArray(pullBody) ? pullBody[0] ?? null : null
+ const identity = selectGithubIdentity({
+ commitAuthor: commitBody?.author ?? null,
+ commitCommitter: commitBody?.committer ?? null,
+ pullAuthor: firstPull?.user ?? null,
})
- if (!res.ok) return null
- const body = await res.json()
- return Array.isArray(body) && body[0]?.number ? body[0].number : null
+ return {
+ ...identity,
+ pr: firstPull?.number || null,
+ }
+}
+
+function loadPriorContributorKeys(baseTag) {
+ if (!baseTag) return new Set()
+ const raw = runGit(`git log --no-merges --pretty=format:%an%x09%ae ${baseTag}`)
+ const keys = new Set()
+ for (const line of raw.split('\n').filter(Boolean)) {
+ const [authorName = '', authorEmail = ''] = line.split('\t')
+ if (authorName) keys.add(`name:${normalizeIdentityPart(authorName)}`)
+ if (authorEmail) keys.add(`email:${normalizeIdentityPart(authorEmail)}`)
+ }
+ return keys
}
-const withPr = await Promise.all(
- commits.map(async (c) => ({ ...c, pr: await prFor(c.sha), bucket: bucketOf(c.subject) })),
-)
+export async function generateReleaseNotes(tag, env = process.env) {
+ const repo =
+ env.GH_REPO ||
+ runGit('gh repo view --json nameWithOwner --jq .nameWithOwner')
+ const token = env.GH_TOKEN || env.GITHUB_TOKEN || ''
+
+ let previousAncestorTag = ''
+ try {
+ previousAncestorTag = runGit(`git describe --tags --abbrev=0 --match 'v*' ${tag}^`)
+ } catch {
+ previousAncestorTag = ''
+ }
-const lines = []
-for (const bucket of BUCKETS) {
- const items = withPr.filter((c) => c.bucket === bucket.id)
- if (!items.length) continue
- lines.push(bucket.title, '')
- for (const c of items) {
- const prLink = c.pr ? ` ([#${c.pr}](https://github.com/${repo}/pull/${c.pr}))` : ''
- lines.push(`- ${c.subject}${prLink}`)
+ let allTags = []
+ try {
+ const rawRemoteTags = runGit(`git ls-remote --tags origin 'v*'`)
+ allTags = [...new Set(parseRemoteTags(rawRemoteTags))]
+ } catch {
+ allTags = []
}
- lines.push('')
+
+ const baseTag = selectReleaseBaseTag({ tag, previousAncestorTag, allTags })
+ const range = baseTag ? `${baseTag}..${tag}` : tag
+ const rawLog = runGit(`git log --no-merges --pretty=format:%H%x09%an%x09%ae%x09%s ${range}`)
+ const commits = parseGitLog(rawLog)
+
+ const enrichedCommits = await Promise.all(
+ commits.map(async (commit) => {
+ const meta = await resolveCommitMeta({ repo, sha: commit.sha, token })
+ return {
+ ...commit,
+ ...meta,
+ bucket: bucketOf(commit.subject),
+ }
+ }),
+ )
+
+ const priorContributorKeys = loadPriorContributorKeys(baseTag)
+ const contributors = summarizeContributors(enrichedCommits, priorContributorKeys)
+ const changelogCommits = enrichedCommits.filter((commit) => commit.bucket)
+
+ return buildReleaseNotesBody({
+ repo,
+ tag,
+ baseTag,
+ commits: changelogCommits,
+ contributors,
+ })
}
-if (prevTag) {
- lines.push(`**Full Changelog**: https://github.com/${repo}/compare/${prevTag}...${tag}`)
+async function main() {
+ const tag = process.argv[2]
+ if (!tag) {
+ console.error('usage: release-notes.mjs ')
+ process.exit(1)
+ }
+
+ const notes = await generateReleaseNotes(tag)
+ process.stdout.write(notes)
}
-process.stdout.write(lines.join('\n').trim() + '\n')
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
+ await main()
+}
diff --git a/scripts/release-notes.test.mjs b/scripts/release-notes.test.mjs
new file mode 100644
index 00000000..28e84b3b
--- /dev/null
+++ b/scripts/release-notes.test.mjs
@@ -0,0 +1,259 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+ buildReleaseNotesBody,
+ displayContributor,
+ joinHumanList,
+ parseRemoteTags,
+ selectGithubIdentity,
+ selectReleaseBaseTag,
+ summarizeContributors,
+} from './release-notes.mjs'
+
+test('parseRemoteTags uses remote tags only and strips peeled refs', () => {
+ const tags = parseRemoteTags(`
+abc123\trefs/tags/v0.2.0
+def456\trefs/tags/v0.2.0^{}
+789abc\trefs/tags/v0.3.0-rc.1
+`)
+
+ assert.deepEqual(tags, ['v0.2.0', 'v0.2.0', 'v0.3.0-rc.1'])
+})
+
+test('selectReleaseBaseTag uses the previous official release for official tags', () => {
+ const baseTag = selectReleaseBaseTag({
+ tag: 'v0.3.0',
+ previousAncestorTag: 'v0.3.0-rc.3',
+ allTags: ['v0.2.0', 'v0.3.0-rc.1', 'v0.3.0-rc.2', 'v0.3.0-rc.3', 'v0.3.0'],
+ })
+
+ assert.equal(baseTag, 'v0.2.0')
+})
+
+test('selectReleaseBaseTag falls back to the first tag when no prior official release exists', () => {
+ const baseTag = selectReleaseBaseTag({
+ tag: 'v0.3.0',
+ previousAncestorTag: 'v0.3.0-rc.3',
+ allTags: ['v0.3.0-rc.1', 'v0.3.0-rc.2', 'v0.3.0-rc.3', 'v0.3.0'],
+ })
+
+ assert.equal(baseTag, 'v0.3.0-rc.1')
+})
+
+test('selectReleaseBaseTag keeps the nearest previous tag for prereleases', () => {
+ const baseTag = selectReleaseBaseTag({
+ tag: 'v0.3.0-rc.3',
+ previousAncestorTag: 'v0.3.0-rc.2',
+ allTags: ['v0.2.0', 'v0.3.0-rc.1', 'v0.3.0-rc.2', 'v0.3.0-rc.3'],
+ })
+
+ assert.equal(baseTag, 'v0.3.0-rc.2')
+})
+
+test('summarizeContributors welcomes only first-time human contributors', () => {
+ const contributors = summarizeContributors(
+ [
+ {
+ login: 'repeat-dev',
+ authorName: 'Repeat Dev',
+ authorEmail: 'repeat@example.com',
+ },
+ {
+ login: 'new-dev',
+ authorName: 'New Dev',
+ authorEmail: 'new@example.com',
+ },
+ {
+ login: 'github-actions[bot]',
+ authorName: 'github-actions[bot]',
+ authorEmail: '41898282+github-actions[bot]@users.noreply.github.com',
+ },
+ ],
+ new Set(['email:repeat@example.com']),
+ )
+
+ assert.deepEqual(
+ contributors.map((entry) => ({ login: entry.login, firstTime: entry.firstTime })),
+ [
+ { login: 'new-dev', firstTime: true },
+ { login: 'repeat-dev', firstTime: false },
+ ],
+ )
+})
+
+test('summarizeContributors merges the same contributor across name, email, and login variants', () => {
+ const contributors = summarizeContributors(
+ [
+ {
+ login: '',
+ authorName: 'Haili Zhang',
+ authorEmail: 'haili@example.com',
+ },
+ {
+ login: 'haili',
+ authorName: 'Haili Zhang',
+ authorEmail: '',
+ },
+ {
+ login: '',
+ authorName: '',
+ authorEmail: 'haili@example.com',
+ },
+ ],
+ new Set(['email:haili@example.com']),
+ )
+
+ assert.equal(contributors.length, 1)
+ assert.deepEqual(contributors[0], {
+ login: 'haili',
+ authorName: 'Haili Zhang',
+ authorEmail: 'haili@example.com',
+ firstTime: false,
+ })
+})
+
+test('buildReleaseNotesBody includes contributor thanks, first-time welcome, and release compare link', () => {
+ const body = buildReleaseNotesBody({
+ repo: 'openmaster-ai/clawmaster',
+ tag: 'v0.3.0',
+ baseTag: 'v0.2.0',
+ commits: [
+ {
+ subject: 'feat(models): improve default provider tiers',
+ login: 'alice',
+ pr: 101,
+ bucket: 'features',
+ },
+ {
+ subject: 'fix(setup): wait for gateway readiness in skip flow',
+ login: 'bob',
+ pr: 102,
+ bucket: 'fixes',
+ },
+ ],
+ contributors: [
+ {
+ login: 'alice',
+ authorName: 'Alice',
+ authorEmail: 'alice@example.com',
+ avatarUrl: 'https://avatars.example/alice.png',
+ firstTime: false,
+ },
+ {
+ login: 'bob',
+ authorName: 'Bob',
+ authorEmail: 'bob@example.com',
+ avatarUrl: 'https://avatars.example/bob.png',
+ firstTime: true,
+ },
+ ],
+ })
+
+ assert.match(body, /### ✨ Features & Polish/)
+ assert.match(body, /### 🐛 Fixes/)
+ assert.match(body, /Summary: 1 change shipped in this area\./)
+ assert.match(body, //)
+ assert.match(body, /Show features & polish<\/summary>/)
+ assert.match(body, /feat\(models\): improve default provider tiers by \[@alice\]\(https:\/\/github\.com\/alice\) \(\[#101\]/)
+ assert.match(body, /fix\(setup\): wait for gateway readiness in skip flow by \[@bob\]\(https:\/\/github\.com\/bob\) \(\[#102\]/)
+ assert.match(body, /Thanks
{
+ assert.equal(joinHumanList([]), '')
+ assert.equal(joinHumanList(['A']), 'A')
+ assert.equal(joinHumanList(['A', 'B']), 'A and B')
+ assert.equal(joinHumanList(['A', 'B', 'C']), 'A, B, and C')
+})
+
+test('displayContributor prefers GitHub handle and avatar when available', () => {
+ const rendered = displayContributor({
+ login: 'alice',
+ authorName: 'Alice',
+ authorEmail: 'alice@example.com',
+ avatarUrl: 'https://avatars.example/alice.png',
+ })
+
+ assert.equal(
+ rendered,
+ '
[@alice](https://github.com/alice)',
+ )
+})
+
+test('selectGithubIdentity falls back from author to committer to PR author', () => {
+ assert.deepEqual(
+ selectGithubIdentity({
+ commitAuthor: null,
+ commitCommitter: { login: 'committer-user', avatar_url: 'https://avatars.example/committer.png' },
+ pullAuthor: { login: 'pr-user', avatar_url: 'https://avatars.example/pr.png' },
+ }),
+ {
+ login: 'committer-user',
+ avatarUrl: 'https://avatars.example/committer.png',
+ },
+ )
+
+ assert.deepEqual(
+ selectGithubIdentity({
+ commitAuthor: null,
+ commitCommitter: null,
+ pullAuthor: { login: 'pr-user', avatar_url: 'https://avatars.example/pr.png' },
+ }),
+ {
+ login: 'pr-user',
+ avatarUrl: 'https://avatars.example/pr.png',
+ },
+ )
+})
+
+test('buildReleaseNotesBody keeps GitHub handles visible in commit lines and contributor thanks', () => {
+ const body = buildReleaseNotesBody({
+ repo: 'openmaster-ai/clawmaster',
+ tag: 'v0.3.0',
+ baseTag: 'v0.2.0',
+ commits: [
+ {
+ subject: 'feat(setup): require gateway check before finishing onboarding',
+ login: 'webup',
+ pr: 93,
+ bucket: 'features',
+ },
+ ],
+ contributors: [
+ {
+ login: 'webup',
+ authorName: 'Haili Zhang',
+ authorEmail: 'haili.zhang@outlook.com',
+ avatarUrl: 'https://avatars.example/webup.png',
+ firstTime: false,
+ },
+ ],
+ })
+
+ assert.match(body, /by \[@webup\]\(https:\/\/github\.com\/webup\)/)
+ assert.match(body, /Thanks
{
+ const body = buildReleaseNotesBody({
+ repo: 'openmaster-ai/clawmaster',
+ tag: 'v0.3.0',
+ baseTag: 'v0.2.0',
+ commits: [
+ {
+ subject: 'feat: add content drafts viewer (#67)',
+ login: 'webup',
+ pr: 67,
+ bucket: 'features',
+ },
+ ],
+ contributors: [],
+ })
+
+ assert.match(body, /feat: add content drafts viewer \(#67\) by \[@webup\]/)
+ assert.doesNotMatch(body, /\(\[#67\]\(https:\/\/github\.com\/openmaster-ai\/clawmaster\/pull\/67\)\)/)
+})
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 21326f63..702899e0 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "app"
-version = "0.3.0"
+version = "0.3.1"
description = "A Tauri App"
authors = ["openmaster-ai"]
license = "Apache-2.0"
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index b7dc424d..ca879744 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "ClawMaster",
- "version": "0.3.0",
+ "version": "0.3.1",
"identifier": "ai.openclaw.manager",
"build": {
"frontendDist": "../packages/web/dist",