mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
Make upgrade failures diagnosable without deleting user state
Desktop startup can fail before React mounts on older WebViews, so an HTML-level watchdog now renders startup diagnostics even when the module bundle never reaches the app code. Persistent provider migration now imports legacy root provider config into cc-haha-owned storage without deleting the old source file, and plugin marketplace cleanup refuses obvious corrupted cache roots or outside paths. Constraint: User explicitly accepted the current reviewed state for landing despite remaining review concerns. Constraint: Global ~/.claude state is user-owned and protected; automatic repair must avoid deleting shared config, transcripts, skills, MCP, plugins, OAuth, adapters, and teams. Rejected: Tell users to delete ~/.claude or ~/.claude/cc-haha | unsafe because it can destroy user-owned Claude state and still may not fix WebView compatibility failures. Confidence: medium Scope-risk: moderate Directive: Do not weaken protected-path checks; future deletion paths should validate real paths and symlink behavior before recursive rm. Tested: bun test src/utils/plugins/installedPluginsManager.test.ts src/utils/plugins/marketplaceManager.test.ts Tested: bun run check:server Tested: cd desktop && bun run test -- --run src/main.test.tsx index-html.test.ts vite-config.test.ts src/theme/globals.test.ts Tested: cd desktop && bun run build Tested: bun run check:coverage Not-tested: live macOS 12/Safari 15 WKWebView startup on an affected machine. Not-tested: H5 diagnostic URL redaction and symlink-escape hardening are known follow-up risks from review.
This commit is contained in:
parent
188214918b
commit
e070c4a0d0
22
desktop/index-html.test.ts
Normal file
22
desktop/index-html.test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const html = readFileSync(join(__dirname, 'index.html'), 'utf-8')
|
||||
|
||||
describe('desktop index startup diagnostics', () => {
|
||||
it('installs a non-module startup watchdog before the app module loads', () => {
|
||||
const watchdogIndex = html.indexOf('__CC_HAHA_SHOW_STARTUP_ERROR__')
|
||||
const moduleIndex = html.indexOf('type="module"')
|
||||
|
||||
expect(watchdogIndex).toBeGreaterThan(0)
|
||||
expect(moduleIndex).toBeGreaterThan(watchdogIndex)
|
||||
expect(html).toContain('__CC_HAHA_BOOTSTRAPPED__')
|
||||
expect(html).toContain('Desktop startup failed')
|
||||
})
|
||||
|
||||
it('diagnoses module resource failures and boot timeouts outside React', () => {
|
||||
expect(html).toContain('Startup resource failed to load:')
|
||||
expect(html).toContain('Desktop app did not finish bootstrapping within')
|
||||
})
|
||||
})
|
||||
@ -8,7 +8,96 @@
|
||||
<style>
|
||||
/* Prevent a warm flash before the theme is hydrated. */
|
||||
html { background: #FFFFFF; }
|
||||
html, body, #root { min-height: 100%; margin: 0; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var bootDeadlineMs = 8000
|
||||
|
||||
function summarize(value) {
|
||||
if (!value) return 'Unknown startup error'
|
||||
if (typeof value === 'string') return value
|
||||
if (value.message) return value.message
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (_) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function renderStartupError(reason) {
|
||||
if (window.__CC_HAHA_BOOTSTRAPPED__) return
|
||||
var root = document.getElementById('root')
|
||||
if (!root) return
|
||||
|
||||
var summary = summarize(reason)
|
||||
var details = [
|
||||
summary,
|
||||
'',
|
||||
'URL: ' + window.location.href,
|
||||
'User Agent: ' + navigator.userAgent,
|
||||
'Time: ' + new Date().toISOString()
|
||||
].join('\n')
|
||||
|
||||
root.innerHTML = ''
|
||||
var shell = document.createElement('main')
|
||||
shell.style.cssText = [
|
||||
'box-sizing:border-box',
|
||||
'min-height:100vh',
|
||||
'padding:32px',
|
||||
'background:#fff',
|
||||
'color:#151515',
|
||||
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
||||
'display:flex',
|
||||
'align-items:center',
|
||||
'justify-content:center'
|
||||
].join(';')
|
||||
|
||||
var panel = document.createElement('section')
|
||||
panel.style.cssText = 'width:min(760px,100%);border:1px solid #d8d8d8;border-radius:8px;padding:24px;background:#fff'
|
||||
|
||||
var title = document.createElement('h1')
|
||||
title.textContent = 'Desktop startup failed'
|
||||
title.style.cssText = 'margin:0 0 8px;font-size:20px;line-height:1.3;font-weight:700;color:#111'
|
||||
|
||||
var body = document.createElement('p')
|
||||
body.textContent = 'The app could not finish booting. Keep this text when reporting the issue.'
|
||||
body.style.cssText = 'margin:0 0 16px;font-size:14px;line-height:1.6;color:#555'
|
||||
|
||||
var pre = document.createElement('pre')
|
||||
pre.textContent = details
|
||||
pre.style.cssText = 'box-sizing:border-box;max-height:360px;overflow:auto;white-space:pre-wrap;word-break:break-word;margin:0;padding:14px;border-radius:6px;background:#f6f6f6;color:#222;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace'
|
||||
|
||||
panel.appendChild(title)
|
||||
panel.appendChild(body)
|
||||
panel.appendChild(pre)
|
||||
shell.appendChild(panel)
|
||||
root.appendChild(shell)
|
||||
}
|
||||
|
||||
window.__CC_HAHA_SHOW_STARTUP_ERROR__ = renderStartupError
|
||||
|
||||
window.addEventListener('error', function (event) {
|
||||
var target = event.target
|
||||
if (target && target !== window) {
|
||||
var tagName = target.tagName
|
||||
if (tagName === 'SCRIPT' || tagName === 'LINK') {
|
||||
renderStartupError('Startup resource failed to load: ' + (target.src || target.href || tagName))
|
||||
}
|
||||
return
|
||||
}
|
||||
renderStartupError(event.message || event.error || 'Startup script error')
|
||||
}, true)
|
||||
|
||||
window.addEventListener('unhandledrejection', function (event) {
|
||||
renderStartupError(event.reason || 'Unhandled startup promise rejection')
|
||||
})
|
||||
|
||||
window.setTimeout(function () {
|
||||
renderStartupError('Desktop app did not finish bootstrapping within ' + bootDeadlineMs + 'ms. This usually means the WebView could not execute the app bundle or startup code stopped before React mounted.')
|
||||
}, bootDeadlineMs)
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
83
desktop/src/main.test.tsx
Normal file
83
desktop/src/main.test.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import React from 'react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, screen } from '@testing-library/react'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runDesktopPersistenceMigrations: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./lib/persistenceMigrations', () => ({
|
||||
runDesktopPersistenceMigrations: mocks.runDesktopPersistenceMigrations,
|
||||
}))
|
||||
|
||||
vi.mock('./theme/globals.css', () => ({}))
|
||||
|
||||
vi.mock('./App', () => ({
|
||||
App: () => <div>Auto boot app</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./components/ErrorBoundary', () => ({
|
||||
ErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock('./lib/diagnosticsCapture', () => ({
|
||||
installClientDiagnosticsCapture: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./stores/uiStore', () => ({
|
||||
initializeTheme: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('desktop bootstrap', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.body.innerHTML = ''
|
||||
delete window.__CC_HAHA_BOOTSTRAPPED__
|
||||
delete window.__CC_HAHA_SHOW_STARTUP_ERROR__
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('runs startup migrations and renders the app without top-level await', async () => {
|
||||
document.body.innerHTML = '<div id="root"></div>'
|
||||
|
||||
await act(async () => {
|
||||
await import('./main')
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Auto boot app')).toBeInTheDocument()
|
||||
expect(mocks.runDesktopPersistenceMigrations).toHaveBeenCalledTimes(1)
|
||||
expect(window.__CC_HAHA_BOOTSTRAPPED__).toBe(true)
|
||||
})
|
||||
|
||||
it('surfaces bootstrap failures in the root element', async () => {
|
||||
const { bootstrapDesktopApp } = await import('./main')
|
||||
const root = document.createElement('div')
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await bootstrapDesktopApp(root, async () => {
|
||||
throw new Error('bootstrap failed')
|
||||
})
|
||||
|
||||
expect(root.textContent).toBe('bootstrap failed')
|
||||
expect(consoleError).toHaveBeenCalledWith('[desktop] Failed to bootstrap app', expect.any(Error))
|
||||
})
|
||||
|
||||
it('delegates bootstrap failures to the HTML startup watchdog when available', async () => {
|
||||
const { bootstrapDesktopApp } = await import('./main')
|
||||
const root = document.createElement('div')
|
||||
const showStartupError = vi.fn()
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
window.__CC_HAHA_SHOW_STARTUP_ERROR__ = showStartupError
|
||||
|
||||
await bootstrapDesktopApp(root, async () => {
|
||||
throw new Error('module failed')
|
||||
})
|
||||
|
||||
expect(showStartupError).toHaveBeenCalledWith(expect.any(Error))
|
||||
expect(root.textContent).toBe('')
|
||||
expect(consoleError).toHaveBeenCalledWith('[desktop] Failed to bootstrap app', expect.any(Error))
|
||||
})
|
||||
})
|
||||
@ -4,21 +4,63 @@ import './theme/globals.css'
|
||||
import { initializeAppZoom } from './lib/appZoom'
|
||||
import { runDesktopPersistenceMigrations } from './lib/persistenceMigrations'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__CC_HAHA_BOOTSTRAPPED__?: boolean
|
||||
__CC_HAHA_SHOW_STARTUP_ERROR__?: (reason: unknown) => void
|
||||
}
|
||||
}
|
||||
|
||||
type DesktopBootstrapModules = [
|
||||
{ App: React.ComponentType },
|
||||
{ ErrorBoundary: React.ComponentType<{ children: React.ReactNode }> },
|
||||
{ installClientDiagnosticsCapture: () => void },
|
||||
{ initializeTheme: () => void },
|
||||
]
|
||||
|
||||
function loadDesktopBootstrapModules() {
|
||||
return Promise.all([
|
||||
import('./App'),
|
||||
import('./components/ErrorBoundary'),
|
||||
import('./lib/diagnosticsCapture'),
|
||||
import('./stores/uiStore'),
|
||||
])
|
||||
}
|
||||
|
||||
export async function bootstrapDesktopApp(
|
||||
root: HTMLElement | null = document.getElementById('root'),
|
||||
loadModules: () => Promise<DesktopBootstrapModules> = loadDesktopBootstrapModules,
|
||||
) {
|
||||
try {
|
||||
const [{ App }, { ErrorBoundary }, { installClientDiagnosticsCapture }, { initializeTheme }] = await loadModules()
|
||||
initializeTheme()
|
||||
installClientDiagnosticsCapture()
|
||||
|
||||
if (!root) {
|
||||
throw new Error('Desktop root element not found')
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
window.__CC_HAHA_BOOTSTRAPPED__ = true
|
||||
} catch (error) {
|
||||
console.error('[desktop] Failed to bootstrap app', error)
|
||||
if (root) {
|
||||
if (window.__CC_HAHA_SHOW_STARTUP_ERROR__) {
|
||||
window.__CC_HAHA_SHOW_STARTUP_ERROR__(error)
|
||||
} else {
|
||||
root.textContent = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runDesktopPersistenceMigrations()
|
||||
void initializeAppZoom()
|
||||
const [{ App }, { ErrorBoundary }, { installClientDiagnosticsCapture }, { initializeTheme }] = await Promise.all([
|
||||
import('./App'),
|
||||
import('./components/ErrorBoundary'),
|
||||
import('./lib/diagnosticsCapture'),
|
||||
import('./stores/uiStore'),
|
||||
])
|
||||
initializeTheme()
|
||||
installClientDiagnosticsCapture()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
void bootstrapDesktopApp()
|
||||
|
||||
@ -306,6 +306,16 @@
|
||||
--color-window-close-hover: #E81123;
|
||||
--color-selection-bg: rgba(255, 219, 208, 0.9);
|
||||
--color-selection-fg: #390C00;
|
||||
--color-text-secondary-a72: rgba(84, 67, 62, 0.72);
|
||||
--color-text-secondary-a68: rgba(84, 67, 62, 0.68);
|
||||
--color-text-primary-a88: rgba(27, 28, 26, 0.88);
|
||||
--color-text-primary-a82: rgba(27, 28, 26, 0.82);
|
||||
--color-text-primary-a78: rgba(27, 28, 26, 0.78);
|
||||
--color-surface-hover-a34: rgba(233, 232, 228, 0.34);
|
||||
--color-surface-hover-a54: rgba(233, 232, 228, 0.54);
|
||||
--color-outline-a72: rgba(135, 115, 109, 0.72);
|
||||
--color-outline-a78: rgba(135, 115, 109, 0.78);
|
||||
--color-outline-a92: rgba(135, 115, 109, 0.92);
|
||||
|
||||
/* Radii */
|
||||
--radius-sm: 4px;
|
||||
@ -477,6 +487,16 @@
|
||||
--color-window-close-hover: #E5484D;
|
||||
--color-selection-bg: rgba(255, 219, 208, 0.9);
|
||||
--color-selection-fg: #390C00;
|
||||
--color-text-secondary-a72: rgba(75, 85, 99, 0.72);
|
||||
--color-text-secondary-a68: rgba(75, 85, 99, 0.68);
|
||||
--color-text-primary-a88: rgba(17, 24, 39, 0.88);
|
||||
--color-text-primary-a82: rgba(17, 24, 39, 0.82);
|
||||
--color-text-primary-a78: rgba(17, 24, 39, 0.78);
|
||||
--color-surface-hover-a34: rgba(241, 244, 247, 0.34);
|
||||
--color-surface-hover-a54: rgba(241, 244, 247, 0.54);
|
||||
--color-outline-a72: rgba(139, 152, 167, 0.72);
|
||||
--color-outline-a78: rgba(139, 152, 167, 0.78);
|
||||
--color-outline-a92: rgba(139, 152, 167, 0.92);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
@ -642,6 +662,16 @@
|
||||
--color-window-close-hover: #C63B44;
|
||||
--color-selection-bg: rgba(255, 181, 159, 0.32);
|
||||
--color-selection-fg: #FBE7E1;
|
||||
--color-text-secondary-a72: rgba(183, 170, 165, 0.72);
|
||||
--color-text-secondary-a68: rgba(183, 170, 165, 0.68);
|
||||
--color-text-primary-a88: rgba(229, 226, 225, 0.88);
|
||||
--color-text-primary-a82: rgba(229, 226, 225, 0.82);
|
||||
--color-text-primary-a78: rgba(229, 226, 225, 0.78);
|
||||
--color-surface-hover-a34: rgba(53, 53, 52, 0.34);
|
||||
--color-surface-hover-a54: rgba(53, 53, 52, 0.54);
|
||||
--color-outline-a72: rgba(141, 127, 122, 0.72);
|
||||
--color-outline-a78: rgba(141, 127, 122, 0.78);
|
||||
--color-outline-a92: rgba(141, 127, 122, 0.92);
|
||||
}
|
||||
|
||||
/* ─── Material Symbols ─────────────────────────────────────────── */
|
||||
@ -790,7 +820,7 @@ button, input, textarea, select, a, [role="button"] {
|
||||
}
|
||||
|
||||
.sidebar-toggle-button {
|
||||
color: color-mix(in srgb, var(--color-text-secondary) 72%, transparent);
|
||||
color: var(--color-text-secondary-a72);
|
||||
background: transparent;
|
||||
transition:
|
||||
color 180ms ease,
|
||||
@ -801,25 +831,25 @@ button, input, textarea, select, a, [role="button"] {
|
||||
}
|
||||
|
||||
.sidebar-toggle-button:hover {
|
||||
color: color-mix(in srgb, var(--color-text-primary) 88%, transparent);
|
||||
background: color-mix(in srgb, var(--color-surface-hover) 34%, transparent);
|
||||
color: var(--color-text-primary-a88);
|
||||
background: var(--color-surface-hover-a34);
|
||||
}
|
||||
|
||||
.sidebar-toggle-button:active {
|
||||
transform: translateY(0.5px);
|
||||
background: color-mix(in srgb, var(--color-surface-hover) 54%, transparent);
|
||||
background: var(--color-surface-hover-a54);
|
||||
}
|
||||
|
||||
.sidebar-toggle-button--open {
|
||||
color: color-mix(in srgb, var(--color-text-secondary) 68%, transparent);
|
||||
color: var(--color-text-secondary-a68);
|
||||
}
|
||||
|
||||
.sidebar-toggle-button--open:hover {
|
||||
color: color-mix(in srgb, var(--color-text-primary) 82%, transparent);
|
||||
color: var(--color-text-primary-a82);
|
||||
}
|
||||
|
||||
.sidebar-toggle-button--collapsed {
|
||||
color: color-mix(in srgb, var(--color-text-primary) 78%, transparent);
|
||||
color: var(--color-text-primary-a78);
|
||||
}
|
||||
|
||||
.sidebar-toggle-button--collapsed:hover {
|
||||
@ -938,7 +968,7 @@ button, input, textarea, select, a, [role="button"] {
|
||||
|
||||
.sidebar-scroll-area {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--color-outline) 72%, transparent) transparent;
|
||||
scrollbar-color: var(--color-outline-a72) transparent;
|
||||
}
|
||||
|
||||
.sidebar-scroll-area::-webkit-scrollbar {
|
||||
@ -950,12 +980,12 @@ button, input, textarea, select, a, [role="button"] {
|
||||
}
|
||||
|
||||
.sidebar-scroll-area::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--color-outline) 78%, transparent);
|
||||
background: var(--color-outline-a78);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.sidebar-scroll-area::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--color-outline) 92%, transparent);
|
||||
background: var(--color-outline-a92);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
|
||||
@ -41,6 +41,16 @@ describe('desktop theme tokens', () => {
|
||||
'--color-info',
|
||||
'--color-info-container',
|
||||
'--color-warning-container',
|
||||
'--color-text-secondary-a72',
|
||||
'--color-text-secondary-a68',
|
||||
'--color-text-primary-a88',
|
||||
'--color-text-primary-a82',
|
||||
'--color-text-primary-a78',
|
||||
'--color-surface-hover-a34',
|
||||
'--color-surface-hover-a54',
|
||||
'--color-outline-a72',
|
||||
'--color-outline-a78',
|
||||
'--color-outline-a92',
|
||||
]
|
||||
|
||||
it('defines activity and status tokens for every supported theme', () => {
|
||||
@ -52,4 +62,8 @@ describe('desktop theme tokens', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('avoids color-mix in startup-critical shell chrome for Safari 15 WebView support', () => {
|
||||
expect(css).not.toContain('color-mix(')
|
||||
})
|
||||
})
|
||||
|
||||
22
desktop/vite-config.test.ts
Normal file
22
desktop/vite-config.test.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const desktopRoot = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
describe('desktop build compatibility', () => {
|
||||
it('keeps production bundles loadable in the macOS 12 Safari 15 WebView', () => {
|
||||
const config = readFileSync(join(desktopRoot, 'vite.config.ts'), 'utf8')
|
||||
|
||||
expect(config).toContain("target: ['es2021', 'safari15']")
|
||||
})
|
||||
|
||||
it('does not rely on CSS color-mix for startup-critical shell chrome', () => {
|
||||
const css = readFileSync(join(desktopRoot, 'src', 'theme', 'globals.css'), 'utf8')
|
||||
|
||||
expect(css).not.toContain('color-mix(')
|
||||
expect(css).toContain('--color-text-secondary-a72')
|
||||
expect(css).toContain('--color-outline-a92')
|
||||
})
|
||||
})
|
||||
@ -8,6 +8,9 @@ const host = process.env.TAURI_DEV_HOST
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
// Vite 8 defaults to baseline-widely-available (safari16.4+), which
|
||||
// requires macOS 13+. Tauri on macOS 12 uses Safari 15 WebView.
|
||||
target: ['es2021', 'safari15'],
|
||||
chunkSizeWarningLimit: 2200,
|
||||
rollupOptions: {
|
||||
onwarn(warning, warn) {
|
||||
|
||||
@ -58,7 +58,7 @@
|
||||
- worktree 启动模式现在可以在 UI 中直接切换,不再需要手动修改配置。
|
||||
- GitHub Release 正文继续以 `release-notes/v0.2.2.md` 作为来源,发布时无需再手动复制 Markdown。
|
||||
- Bearer provider auth token 隔离修复属于**安全改进**,升级后本地 API Key 不会再意外被转发给第三方 provider endpoint。
|
||||
- 如果升级后遇到应用无法启动或持续白屏,可以尝试删除 `~/.claude/cc-haha/` 目录后重启(注意:这会清除本地 Provider 配置,聊天记录和项目文件不受影响);如果问题由 localStorage 导致,可以在 DevTools Console 执行 `localStorage.clear()` 后刷新。
|
||||
- 如果升级后遇到应用无法启动或持续白屏,请优先使用桌面端 Doctor/恢复入口处理可再生的本地 UI 状态;不要手动删除 `~/.claude` 或 `~/.claude/cc-haha/` 目录,避免误删 Provider、OAuth、插件、技能或会话相关配置。
|
||||
|
||||
## 安装说明
|
||||
|
||||
|
||||
@ -112,6 +112,50 @@ describe('coverage gate helpers', () => {
|
||||
expect(failures).toEqual(['changed-lines: coverage 50% is below minimum 90%'])
|
||||
})
|
||||
|
||||
test('excludes non-instrumented desktop styles from changed-line coverage', () => {
|
||||
const changedLines = parseChangedLinesFromDiff([
|
||||
'diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css',
|
||||
'--- a/desktop/src/theme/globals.css',
|
||||
'+++ b/desktop/src/theme/globals.css',
|
||||
'@@ -10,0 +11,2 @@',
|
||||
'+.sidebar {',
|
||||
'+ color: var(--color-text-primary);',
|
||||
'+}',
|
||||
'diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx',
|
||||
'--- a/desktop/src/main.tsx',
|
||||
'+++ b/desktop/src/main.tsx',
|
||||
'@@ -20,0 +21,1 @@',
|
||||
'+bootstrapDesktopApp()',
|
||||
].join('\n'))
|
||||
|
||||
const result = evaluateChangedLineCoverage(
|
||||
changedLines,
|
||||
new Map([
|
||||
['desktop/src/main.tsx', {
|
||||
suiteId: 'desktop',
|
||||
executableLines: new Set([21]),
|
||||
coveredLines: new Set([21]),
|
||||
}],
|
||||
]),
|
||||
[{
|
||||
id: 'desktop',
|
||||
title: 'Desktop',
|
||||
includePrefixes: ['desktop/src/'],
|
||||
excludeSuffixes: ['.css'],
|
||||
}],
|
||||
90,
|
||||
)
|
||||
|
||||
expect(result.files).toEqual([{
|
||||
file: 'desktop/src/main.tsx',
|
||||
suiteId: 'desktop',
|
||||
covered: 1,
|
||||
total: 1,
|
||||
pct: 100,
|
||||
}])
|
||||
expect(result.failures).toEqual([])
|
||||
})
|
||||
|
||||
test('reports minimum threshold failures', () => {
|
||||
const failures = evaluateThresholds([
|
||||
{
|
||||
|
||||
@ -141,7 +141,7 @@ const DESKTOP_SCOPE: CoverageScope = {
|
||||
'desktop/src/mocks/',
|
||||
'desktop/src/types/',
|
||||
],
|
||||
excludeSuffixes: ['.test.ts', '.test.tsx', '.d.ts', 'vite-env.d.ts'],
|
||||
excludeSuffixes: ['.test.ts', '.test.tsx', '.d.ts', 'vite-env.d.ts', '.css'],
|
||||
}
|
||||
|
||||
const CHANGED_LINE_SCOPES = [
|
||||
@ -725,7 +725,7 @@ export async function runCoverageGate(options: {
|
||||
const suites: SuiteCoverage[] = []
|
||||
const coverageByFile = new Map<string, FileLineCoverage>()
|
||||
|
||||
const rootCommand = ['bun', 'test', '--coverage', '--coverage-reporter=lcov', '--coverage-reporter=text', '--coverage-dir', join(outputDir, 'root-server'), ...serverFiles]
|
||||
const rootCommand = ['bun', 'test', '--timeout=20000', '--coverage', '--coverage-reporter=lcov', '--coverage-reporter=text', '--coverage-dir', join(outputDir, 'root-server'), ...serverFiles]
|
||||
const rootLogPath = join(outputDir, 'root-server', 'coverage.log')
|
||||
mkdirSync(join(outputDir, 'root-server'), { recursive: true })
|
||||
const rootResult = await runCommand(rootCommand, rootDir, rootLogPath)
|
||||
|
||||
@ -88,6 +88,120 @@ describe('persistent storage upgrade migrations', () => {
|
||||
expect(rewritten.providers?.[0]?.extraFutureField).toBe('keep-me')
|
||||
})
|
||||
|
||||
test('imports legacy root providers config into cc-haha storage without deleting the source', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'providers.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
activeModel: 'legacy-sonnet',
|
||||
providers: [{
|
||||
id: 'legacy-provider',
|
||||
name: 'Legacy Root Provider',
|
||||
baseUrl: 'https://legacy.example.test',
|
||||
apiKey: 'legacy-token',
|
||||
models: [
|
||||
{ id: 'legacy-haiku', name: 'Legacy Haiku' },
|
||||
{ id: 'legacy-sonnet', name: 'Legacy Sonnet' },
|
||||
],
|
||||
isActive: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
notes: 'keep note',
|
||||
}],
|
||||
}, null, 2),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const report = await ensurePersistentStorageUpgraded()
|
||||
|
||||
expect(report.failures).toEqual([])
|
||||
expect(report.migratedEntries).toContain('providers.json -> cc-haha/providers.json')
|
||||
expect(report.migratedEntries).toContain('providers.json -> cc-haha/settings.json')
|
||||
expect(JSON.parse(await fs.readFile(path.join(tempDir, 'providers.json'), 'utf-8'))).toMatchObject({
|
||||
version: 1,
|
||||
activeModel: 'legacy-sonnet',
|
||||
})
|
||||
|
||||
const migrated = JSON.parse(await fs.readFile(path.join(tempDir, 'cc-haha', 'providers.json'), 'utf-8')) as {
|
||||
activeId?: string | null
|
||||
providers?: Array<{
|
||||
id?: string
|
||||
presetId?: string
|
||||
apiFormat?: string
|
||||
models?: Record<string, string>
|
||||
notes?: string
|
||||
}>
|
||||
}
|
||||
expect(migrated.activeId).toBe('legacy-provider')
|
||||
expect(migrated.providers?.[0]).toMatchObject({
|
||||
id: 'legacy-provider',
|
||||
presetId: 'custom',
|
||||
apiFormat: 'anthropic',
|
||||
notes: 'keep note',
|
||||
models: {
|
||||
main: 'legacy-sonnet',
|
||||
haiku: 'legacy-sonnet',
|
||||
sonnet: 'legacy-sonnet',
|
||||
opus: 'legacy-sonnet',
|
||||
},
|
||||
})
|
||||
|
||||
const managedSettings = JSON.parse(await fs.readFile(path.join(tempDir, 'cc-haha', 'settings.json'), 'utf-8')) as {
|
||||
env?: Record<string, string>
|
||||
}
|
||||
expect(managedSettings.env).toMatchObject({
|
||||
ANTHROPIC_BASE_URL: 'https://legacy.example.test',
|
||||
ANTHROPIC_AUTH_TOKEN: 'legacy-token',
|
||||
ANTHROPIC_MODEL: 'legacy-sonnet',
|
||||
})
|
||||
|
||||
const service = new ProviderService()
|
||||
const { providers, activeId } = await service.listProviders()
|
||||
expect(activeId).toBe('legacy-provider')
|
||||
expect(providers[0]?.models.main).toBe('legacy-sonnet')
|
||||
})
|
||||
|
||||
test('does not overwrite current cc-haha provider storage with a legacy root config', async () => {
|
||||
const ccHahaDir = path.join(tempDir, 'cc-haha')
|
||||
await fs.mkdir(ccHahaDir, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'providers.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
activeModel: 'legacy-model',
|
||||
providers: [{
|
||||
id: 'legacy-provider',
|
||||
name: 'Legacy Root Provider',
|
||||
baseUrl: 'https://legacy.example.test',
|
||||
apiKey: 'legacy-token',
|
||||
models: [{ id: 'legacy-model' }],
|
||||
isActive: true,
|
||||
}],
|
||||
}, null, 2),
|
||||
'utf-8',
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(ccHahaDir, 'providers.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
|
||||
activeId: null,
|
||||
providers: [],
|
||||
}, null, 2),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const report = await ensurePersistentStorageUpgraded()
|
||||
|
||||
expect(report.failures).toEqual([])
|
||||
expect(report.migratedEntries).not.toContain('providers.json -> cc-haha/providers.json')
|
||||
const current = JSON.parse(await fs.readFile(path.join(ccHahaDir, 'providers.json'), 'utf-8')) as {
|
||||
activeId?: string | null
|
||||
providers?: unknown[]
|
||||
}
|
||||
expect(current.activeId).toBeNull()
|
||||
expect(current.providers).toEqual([])
|
||||
})
|
||||
|
||||
test('does not write repo-owned schema metadata into shared user settings', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'settings.json'),
|
||||
|
||||
@ -11,6 +11,19 @@ type MigrationReport = {
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
type LegacyProviderModel = {
|
||||
id: string
|
||||
name?: string
|
||||
}
|
||||
type LegacyRootProvider = {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
models: LegacyProviderModel[]
|
||||
isActive?: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
let migrationPromise: Promise<MigrationReport> | null = null
|
||||
let migrationConfigDir: string | null = null
|
||||
@ -45,6 +58,22 @@ function isSavedProvider(value: unknown): value is JsonObject {
|
||||
)
|
||||
}
|
||||
|
||||
function isLegacyProviderModel(value: unknown): value is LegacyProviderModel {
|
||||
return isRecord(value) && typeof value.id === 'string'
|
||||
}
|
||||
|
||||
function isLegacyRootProvider(value: unknown): value is LegacyRootProvider {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.name === 'string' &&
|
||||
typeof value.baseUrl === 'string' &&
|
||||
typeof value.apiKey === 'string' &&
|
||||
Array.isArray(value.models) &&
|
||||
value.models.every(isLegacyProviderModel)
|
||||
)
|
||||
}
|
||||
|
||||
function errnoCode(error: unknown): string | undefined {
|
||||
return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
|
||||
? error.code
|
||||
@ -159,10 +188,145 @@ async function migrateJsonEntry(
|
||||
}
|
||||
}
|
||||
|
||||
function legacyProviderModelId(
|
||||
provider: LegacyRootProvider,
|
||||
preferredModelId: unknown,
|
||||
): string {
|
||||
if (
|
||||
typeof preferredModelId === 'string' &&
|
||||
provider.models.some((model) => model.id === preferredModelId)
|
||||
) {
|
||||
return preferredModelId
|
||||
}
|
||||
|
||||
return provider.models[0]?.id ?? ''
|
||||
}
|
||||
|
||||
function migrateLegacyRootProvidersConfig(value: unknown): JsonObject | null {
|
||||
if (!isRecord(value) || !Array.isArray(value.providers)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const providers = value.providers
|
||||
.filter(isLegacyRootProvider)
|
||||
.map((provider) => {
|
||||
const main = legacyProviderModelId(provider, value.activeModel)
|
||||
return {
|
||||
id: provider.id,
|
||||
presetId: 'custom',
|
||||
name: provider.name,
|
||||
apiKey: provider.apiKey,
|
||||
baseUrl: provider.baseUrl,
|
||||
apiFormat: 'anthropic',
|
||||
models: {
|
||||
main,
|
||||
haiku: main,
|
||||
sonnet: main,
|
||||
opus: main,
|
||||
},
|
||||
...(provider.notes !== undefined && { notes: provider.notes }),
|
||||
}
|
||||
})
|
||||
|
||||
if (providers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const activeLegacyProvider = value.providers
|
||||
.filter(isLegacyRootProvider)
|
||||
.find((provider) =>
|
||||
provider.isActive === true ||
|
||||
(typeof value.activeModel === 'string' &&
|
||||
provider.models.some((model) => model.id === value.activeModel)),
|
||||
)
|
||||
const activeId =
|
||||
activeLegacyProvider && providers.some((provider) => provider.id === activeLegacyProvider.id)
|
||||
? activeLegacyProvider.id
|
||||
: null
|
||||
|
||||
return {
|
||||
schemaVersion: CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
|
||||
activeId,
|
||||
providers,
|
||||
}
|
||||
}
|
||||
|
||||
function buildManagedSettingsForMigratedProvider(provider: JsonObject | undefined): JsonObject | null {
|
||||
if (!provider || !isProviderModels(provider.models)) return null
|
||||
const apiKey = typeof provider.apiKey === 'string' ? provider.apiKey : ''
|
||||
const baseUrl = typeof provider.baseUrl === 'string' ? provider.baseUrl : ''
|
||||
if (!apiKey || !baseUrl) return null
|
||||
|
||||
return {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
ANTHROPIC_MODEL: provider.models.main,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.opus,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateLegacyRootProviders(
|
||||
configDir: string,
|
||||
ccHahaDir: string,
|
||||
report: MigrationReport,
|
||||
): Promise<void> {
|
||||
const targetPath = path.join(ccHahaDir, 'providers.json')
|
||||
try {
|
||||
await fs.access(targetPath)
|
||||
return
|
||||
} catch (error) {
|
||||
if (errnoCode(error) !== 'ENOENT') {
|
||||
report.failures.push(`cc-haha/providers.json: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const legacyPath = path.join(configDir, 'providers.json')
|
||||
|
||||
try {
|
||||
const legacy = await readJsonFile(legacyPath)
|
||||
if (legacy.missing) return
|
||||
|
||||
const migrated = migrateLegacyRootProvidersConfig(legacy.value)
|
||||
if (!migrated) return
|
||||
|
||||
await writeJsonFile(targetPath, migrated)
|
||||
report.migratedEntries.push('providers.json -> cc-haha/providers.json')
|
||||
|
||||
const settingsPath = path.join(ccHahaDir, 'settings.json')
|
||||
const settings = await readJsonFile(settingsPath).catch(() => ({ missing: false, value: undefined, raw: '' }))
|
||||
if (!settings.missing) return
|
||||
|
||||
const activeId = typeof migrated.activeId === 'string' ? migrated.activeId : null
|
||||
const activeProvider = Array.isArray(migrated.providers)
|
||||
? migrated.providers.find((provider) => isRecord(provider) && provider.id === activeId)
|
||||
: undefined
|
||||
const managedSettings = buildManagedSettingsForMigratedProvider(
|
||||
isRecord(activeProvider) ? activeProvider : undefined,
|
||||
)
|
||||
if (managedSettings) {
|
||||
await writeJsonFile(settingsPath, managedSettings)
|
||||
report.migratedEntries.push('providers.json -> cc-haha/settings.json')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
report.failures.push(`providers.json: ${error.message}`)
|
||||
return
|
||||
}
|
||||
report.failures.push(`providers.json: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function runPersistentStorageMigrations(configDir: string): Promise<MigrationReport> {
|
||||
const report: MigrationReport = { migratedEntries: [], failures: [] }
|
||||
const ccHahaDir = path.join(configDir, 'cc-haha')
|
||||
|
||||
await migrateLegacyRootProviders(configDir, ccHahaDir, report)
|
||||
|
||||
await migrateJsonEntry(
|
||||
path.join(ccHahaDir, 'providers.json'),
|
||||
'cc-haha/providers.json',
|
||||
|
||||
66
src/utils/plugins/installedPluginsManager.test.ts
Normal file
66
src/utils/plugins/installedPluginsManager.test.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { deletePluginCache } from './installedPluginsManager.js'
|
||||
|
||||
describe('deletePluginCache', () => {
|
||||
let tempDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
let originalPluginCacheDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-cache-delete-'))
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
originalPluginCacheDir = process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude')
|
||||
delete process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
if (originalPluginCacheDir === undefined) {
|
||||
delete process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR = originalPluginCacheDir
|
||||
}
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('refuses to delete paths outside the managed plugin cache', async () => {
|
||||
const protectedDir = path.join(tempDir, '.claude')
|
||||
const sentinel = path.join(protectedDir, 'settings.json')
|
||||
await fs.mkdir(protectedDir, { recursive: true })
|
||||
await fs.writeFile(sentinel, '{"keep":true}', 'utf-8')
|
||||
|
||||
expect(() => deletePluginCache(protectedDir)).toThrow(
|
||||
'Refusing to delete plugin cache outside managed cache directory',
|
||||
)
|
||||
|
||||
await expect(fs.readFile(sentinel, 'utf-8')).resolves.toBe('{"keep":true}')
|
||||
})
|
||||
|
||||
test('deletes only versioned directories under the managed plugin cache', async () => {
|
||||
const versionDir = path.join(
|
||||
tempDir,
|
||||
'.claude',
|
||||
'plugins',
|
||||
'cache',
|
||||
'marketplace',
|
||||
'plugin',
|
||||
'1.0.0',
|
||||
)
|
||||
const sentinel = path.join(versionDir, 'plugin.json')
|
||||
await fs.mkdir(versionDir, { recursive: true })
|
||||
await fs.writeFile(sentinel, '{"name":"plugin"}', 'utf-8')
|
||||
|
||||
deletePluginCache(versionDir)
|
||||
|
||||
await expect(fs.stat(versionDir)).rejects.toThrow()
|
||||
await expect(fs.stat(path.join(tempDir, '.claude'))).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
@ -13,7 +13,7 @@
|
||||
* plugins active).
|
||||
*/
|
||||
|
||||
import { dirname, join } from 'path'
|
||||
import { dirname, join, resolve, sep } from 'path'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { errorMessage, isENOENT, toError } from '../errors.js'
|
||||
import { getFsImplementation } from '../fsOperations.js'
|
||||
@ -962,10 +962,24 @@ export function removeInstalledPlugin(
|
||||
*/
|
||||
export { getGitCommitSha }
|
||||
|
||||
function assertPluginCacheDeletionPath(installPath: string): void {
|
||||
const cachePath = resolve(getPluginCachePath())
|
||||
const resolvedInstallPath = resolve(installPath)
|
||||
if (
|
||||
resolvedInstallPath === cachePath ||
|
||||
!resolvedInstallPath.startsWith(cachePath + sep)
|
||||
) {
|
||||
throw new Error(
|
||||
`Refusing to delete plugin cache outside managed cache directory: ${installPath}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function deletePluginCache(installPath: string): void {
|
||||
const fs = getFsImplementation()
|
||||
|
||||
try {
|
||||
assertPluginCacheDeletionPath(installPath)
|
||||
fs.rmSync(installPath, { recursive: true, force: true })
|
||||
logForDebugging(`Deleted plugin cache at ${installPath}`)
|
||||
|
||||
|
||||
108
src/utils/plugins/marketplaceManager.test.ts
Normal file
108
src/utils/plugins/marketplaceManager.test.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import {
|
||||
addMarketplaceSource,
|
||||
clearMarketplacesCache,
|
||||
getMarketplacesCacheDir,
|
||||
isStrictMarketplaceCachePath,
|
||||
refreshMarketplace,
|
||||
} from './marketplaceManager.js'
|
||||
|
||||
describe('marketplace cache deletion safety', () => {
|
||||
let tempDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
let originalPluginCacheDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'marketplace-cache-safe-'))
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
originalPluginCacheDir = process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude')
|
||||
delete process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR
|
||||
clearMarketplacesCache()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearMarketplacesCache()
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
if (originalPluginCacheDir === undefined) {
|
||||
delete process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR = originalPluginCacheDir
|
||||
}
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('treats only strict children of the marketplace cache as removable', async () => {
|
||||
const cacheDir = getMarketplacesCacheDir()
|
||||
expect(isStrictMarketplaceCachePath(cacheDir)).toBe(false)
|
||||
expect(isStrictMarketplaceCachePath(path.join(cacheDir, 'official'))).toBe(true)
|
||||
expect(isStrictMarketplaceCachePath(path.join(tempDir, '.claude'))).toBe(false)
|
||||
})
|
||||
|
||||
test('does not delete the marketplace cache root from corrupted stored state', async () => {
|
||||
const cacheDir = getMarketplacesCacheDir()
|
||||
const sentinel = path.join(cacheDir, 'sentinel.txt')
|
||||
await fs.mkdir(cacheDir, { recursive: true })
|
||||
await fs.writeFile(sentinel, 'keep', 'utf-8')
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(process.env.CLAUDE_CONFIG_DIR!, 'plugins', 'known_marketplaces.json'),
|
||||
JSON.stringify({
|
||||
'safe-marketplace': {
|
||||
source: { source: 'github', repo: 'owner/repo' },
|
||||
installLocation: cacheDir,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const marketplaceRoot = path.join(tempDir, 'local-marketplace')
|
||||
const marketplaceJson = path.join(marketplaceRoot, '.claude-plugin', 'marketplace.json')
|
||||
await fs.mkdir(path.dirname(marketplaceJson), { recursive: true })
|
||||
await fs.writeFile(
|
||||
marketplaceJson,
|
||||
JSON.stringify({
|
||||
name: 'safe-marketplace',
|
||||
owner: { name: 'Test' },
|
||||
plugins: [],
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
await addMarketplaceSource({ source: 'file', path: marketplaceJson })
|
||||
|
||||
await expect(fs.readFile(sentinel, 'utf-8')).resolves.toBe('keep')
|
||||
})
|
||||
|
||||
test('refuses to refresh a remote marketplace whose installLocation is the cache root', async () => {
|
||||
const cacheDir = getMarketplacesCacheDir()
|
||||
const sentinel = path.join(cacheDir, 'sentinel.txt')
|
||||
await fs.mkdir(cacheDir, { recursive: true })
|
||||
await fs.writeFile(sentinel, 'keep', 'utf-8')
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(process.env.CLAUDE_CONFIG_DIR!, 'plugins', 'known_marketplaces.json'),
|
||||
JSON.stringify({
|
||||
unsafe: {
|
||||
source: { source: 'github', repo: 'owner/repo' },
|
||||
installLocation: cacheDir,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
await expect(refreshMarketplace('unsafe')).rejects.toThrow(
|
||||
'has a corrupted installLocation',
|
||||
)
|
||||
await expect(fs.readFile(sentinel, 'utf-8')).resolves.toBe('keep')
|
||||
})
|
||||
})
|
||||
@ -111,6 +111,12 @@ export function getMarketplacesCacheDir(): string {
|
||||
return join(getPluginsDirectory(), 'marketplaces')
|
||||
}
|
||||
|
||||
export function isStrictMarketplaceCachePath(targetPath: string): boolean {
|
||||
const cacheDir = resolve(getMarketplacesCacheDir())
|
||||
const resolvedTarget = resolve(targetPath)
|
||||
return resolvedTarget.startsWith(cacheDir + sep)
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized inner function to get marketplace data.
|
||||
* This caches the marketplace in memory after loading from disk or network.
|
||||
@ -1887,19 +1893,16 @@ export async function addMarketplaceSource(
|
||||
// any) is harmless, and blocking the re-add would prevent the user from
|
||||
// fixing the corruption.
|
||||
if (!isLocalMarketplaceSource(oldEntry.source)) {
|
||||
const cacheDir = resolve(getMarketplacesCacheDir())
|
||||
const resolvedOld = resolve(oldEntry.installLocation)
|
||||
const resolvedNew = resolve(cachePath)
|
||||
if (resolvedOld === resolvedNew) {
|
||||
// Same dir — loadAndCacheMarketplace already overwrote in place.
|
||||
// Nothing to clean.
|
||||
} else if (
|
||||
resolvedOld === cacheDir ||
|
||||
resolvedOld.startsWith(cacheDir + sep)
|
||||
) {
|
||||
} else if (isStrictMarketplaceCachePath(oldEntry.installLocation)) {
|
||||
const fs = getFsImplementation()
|
||||
await fs.rm(oldEntry.installLocation, { recursive: true, force: true })
|
||||
} else {
|
||||
const cacheDir = resolve(getMarketplacesCacheDir())
|
||||
logForDebugging(
|
||||
`Skipping cleanup of old installLocation (${oldEntry.installLocation}) — ` +
|
||||
`outside ${cacheDir}. The path is corrupted; leaving it alone and ` +
|
||||
@ -2413,8 +2416,7 @@ export async function refreshMarketplace(
|
||||
// Refuse instead of auto-fixing so the user knows their state is corrupted.
|
||||
if (!isLocalMarketplaceSource(source)) {
|
||||
const cacheDir = resolve(getMarketplacesCacheDir())
|
||||
const resolvedLoc = resolve(installLocation)
|
||||
if (resolvedLoc !== cacheDir && !resolvedLoc.startsWith(cacheDir + sep)) {
|
||||
if (!isStrictMarketplaceCachePath(installLocation)) {
|
||||
throw new Error(
|
||||
`Marketplace '${name}' has a corrupted installLocation ` +
|
||||
`(${installLocation}) — expected a path inside ${cacheDir}. ` +
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user