mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +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>
|
<style>
|
||||||
/* Prevent a warm flash before the theme is hydrated. */
|
/* Prevent a warm flash before the theme is hydrated. */
|
||||||
html { background: #FFFFFF; }
|
html { background: #FFFFFF; }
|
||||||
|
html, body, #root { min-height: 100%; margin: 0; }
|
||||||
</style>
|
</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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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 { initializeAppZoom } from './lib/appZoom'
|
||||||
import { runDesktopPersistenceMigrations } from './lib/persistenceMigrations'
|
import { runDesktopPersistenceMigrations } from './lib/persistenceMigrations'
|
||||||
|
|
||||||
runDesktopPersistenceMigrations()
|
declare global {
|
||||||
void initializeAppZoom()
|
interface Window {
|
||||||
const [{ App }, { ErrorBoundary }, { installClientDiagnosticsCapture }, { initializeTheme }] = await Promise.all([
|
__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('./App'),
|
||||||
import('./components/ErrorBoundary'),
|
import('./components/ErrorBoundary'),
|
||||||
import('./lib/diagnosticsCapture'),
|
import('./lib/diagnosticsCapture'),
|
||||||
import('./stores/uiStore'),
|
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()
|
initializeTheme()
|
||||||
installClientDiagnosticsCapture()
|
installClientDiagnosticsCapture()
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
if (!root) {
|
||||||
|
throw new Error('Desktop root element not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(root).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</React.StrictMode>,
|
</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()
|
||||||
|
|
||||||
|
void bootstrapDesktopApp()
|
||||||
|
|||||||
@ -306,6 +306,16 @@
|
|||||||
--color-window-close-hover: #E81123;
|
--color-window-close-hover: #E81123;
|
||||||
--color-selection-bg: rgba(255, 219, 208, 0.9);
|
--color-selection-bg: rgba(255, 219, 208, 0.9);
|
||||||
--color-selection-fg: #390C00;
|
--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 */
|
/* Radii */
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
@ -477,6 +487,16 @@
|
|||||||
--color-window-close-hover: #E5484D;
|
--color-window-close-hover: #E5484D;
|
||||||
--color-selection-bg: rgba(255, 219, 208, 0.9);
|
--color-selection-bg: rgba(255, 219, 208, 0.9);
|
||||||
--color-selection-fg: #390C00;
|
--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"] {
|
[data-theme="dark"] {
|
||||||
@ -642,6 +662,16 @@
|
|||||||
--color-window-close-hover: #C63B44;
|
--color-window-close-hover: #C63B44;
|
||||||
--color-selection-bg: rgba(255, 181, 159, 0.32);
|
--color-selection-bg: rgba(255, 181, 159, 0.32);
|
||||||
--color-selection-fg: #FBE7E1;
|
--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 ─────────────────────────────────────────── */
|
/* ─── Material Symbols ─────────────────────────────────────────── */
|
||||||
@ -790,7 +820,7 @@ button, input, textarea, select, a, [role="button"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-toggle-button {
|
.sidebar-toggle-button {
|
||||||
color: color-mix(in srgb, var(--color-text-secondary) 72%, transparent);
|
color: var(--color-text-secondary-a72);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
transition:
|
transition:
|
||||||
color 180ms ease,
|
color 180ms ease,
|
||||||
@ -801,25 +831,25 @@ button, input, textarea, select, a, [role="button"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-toggle-button:hover {
|
.sidebar-toggle-button:hover {
|
||||||
color: color-mix(in srgb, var(--color-text-primary) 88%, transparent);
|
color: var(--color-text-primary-a88);
|
||||||
background: color-mix(in srgb, var(--color-surface-hover) 34%, transparent);
|
background: var(--color-surface-hover-a34);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-toggle-button:active {
|
.sidebar-toggle-button:active {
|
||||||
transform: translateY(0.5px);
|
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 {
|
.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 {
|
.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 {
|
.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 {
|
.sidebar-toggle-button--collapsed:hover {
|
||||||
@ -938,7 +968,7 @@ button, input, textarea, select, a, [role="button"] {
|
|||||||
|
|
||||||
.sidebar-scroll-area {
|
.sidebar-scroll-area {
|
||||||
scrollbar-width: thin;
|
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 {
|
.sidebar-scroll-area::-webkit-scrollbar {
|
||||||
@ -950,12 +980,12 @@ button, input, textarea, select, a, [role="button"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-scroll-area::-webkit-scrollbar-thumb {
|
.sidebar-scroll-area::-webkit-scrollbar-thumb {
|
||||||
background: color-mix(in srgb, var(--color-outline) 78%, transparent);
|
background: var(--color-outline-a78);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-scroll-area::-webkit-scrollbar-thumb:hover {
|
.sidebar-scroll-area::-webkit-scrollbar-thumb:hover {
|
||||||
background: color-mix(in srgb, var(--color-outline) 92%, transparent);
|
background: var(--color-outline-a92);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
|
|||||||
@ -41,6 +41,16 @@ describe('desktop theme tokens', () => {
|
|||||||
'--color-info',
|
'--color-info',
|
||||||
'--color-info-container',
|
'--color-info-container',
|
||||||
'--color-warning-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', () => {
|
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({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
build: {
|
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,
|
chunkSizeWarningLimit: 2200,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
onwarn(warning, warn) {
|
onwarn(warning, warn) {
|
||||||
|
|||||||
@ -58,7 +58,7 @@
|
|||||||
- worktree 启动模式现在可以在 UI 中直接切换,不再需要手动修改配置。
|
- worktree 启动模式现在可以在 UI 中直接切换,不再需要手动修改配置。
|
||||||
- GitHub Release 正文继续以 `release-notes/v0.2.2.md` 作为来源,发布时无需再手动复制 Markdown。
|
- GitHub Release 正文继续以 `release-notes/v0.2.2.md` 作为来源,发布时无需再手动复制 Markdown。
|
||||||
- Bearer provider auth token 隔离修复属于**安全改进**,升级后本地 API Key 不会再意外被转发给第三方 provider endpoint。
|
- 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%'])
|
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', () => {
|
test('reports minimum threshold failures', () => {
|
||||||
const failures = evaluateThresholds([
|
const failures = evaluateThresholds([
|
||||||
{
|
{
|
||||||
|
|||||||
@ -141,7 +141,7 @@ const DESKTOP_SCOPE: CoverageScope = {
|
|||||||
'desktop/src/mocks/',
|
'desktop/src/mocks/',
|
||||||
'desktop/src/types/',
|
'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 = [
|
const CHANGED_LINE_SCOPES = [
|
||||||
@ -725,7 +725,7 @@ export async function runCoverageGate(options: {
|
|||||||
const suites: SuiteCoverage[] = []
|
const suites: SuiteCoverage[] = []
|
||||||
const coverageByFile = new Map<string, FileLineCoverage>()
|
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')
|
const rootLogPath = join(outputDir, 'root-server', 'coverage.log')
|
||||||
mkdirSync(join(outputDir, 'root-server'), { recursive: true })
|
mkdirSync(join(outputDir, 'root-server'), { recursive: true })
|
||||||
const rootResult = await runCommand(rootCommand, rootDir, rootLogPath)
|
const rootResult = await runCommand(rootCommand, rootDir, rootLogPath)
|
||||||
|
|||||||
@ -88,6 +88,120 @@ describe('persistent storage upgrade migrations', () => {
|
|||||||
expect(rewritten.providers?.[0]?.extraFutureField).toBe('keep-me')
|
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 () => {
|
test('does not write repo-owned schema metadata into shared user settings', async () => {
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(tempDir, 'settings.json'),
|
path.join(tempDir, 'settings.json'),
|
||||||
|
|||||||
@ -11,6 +11,19 @@ type MigrationReport = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type JsonObject = Record<string, unknown>
|
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 migrationPromise: Promise<MigrationReport> | null = null
|
||||||
let migrationConfigDir: string | 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 {
|
function errnoCode(error: unknown): string | undefined {
|
||||||
return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
|
return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
|
||||||
? error.code
|
? 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> {
|
async function runPersistentStorageMigrations(configDir: string): Promise<MigrationReport> {
|
||||||
const report: MigrationReport = { migratedEntries: [], failures: [] }
|
const report: MigrationReport = { migratedEntries: [], failures: [] }
|
||||||
const ccHahaDir = path.join(configDir, 'cc-haha')
|
const ccHahaDir = path.join(configDir, 'cc-haha')
|
||||||
|
|
||||||
|
await migrateLegacyRootProviders(configDir, ccHahaDir, report)
|
||||||
|
|
||||||
await migrateJsonEntry(
|
await migrateJsonEntry(
|
||||||
path.join(ccHahaDir, 'providers.json'),
|
path.join(ccHahaDir, 'providers.json'),
|
||||||
'cc-haha/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).
|
* plugins active).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { dirname, join } from 'path'
|
import { dirname, join, resolve, sep } from 'path'
|
||||||
import { logForDebugging } from '../debug.js'
|
import { logForDebugging } from '../debug.js'
|
||||||
import { errorMessage, isENOENT, toError } from '../errors.js'
|
import { errorMessage, isENOENT, toError } from '../errors.js'
|
||||||
import { getFsImplementation } from '../fsOperations.js'
|
import { getFsImplementation } from '../fsOperations.js'
|
||||||
@ -962,10 +962,24 @@ export function removeInstalledPlugin(
|
|||||||
*/
|
*/
|
||||||
export { getGitCommitSha }
|
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 {
|
export function deletePluginCache(installPath: string): void {
|
||||||
const fs = getFsImplementation()
|
const fs = getFsImplementation()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
assertPluginCacheDeletionPath(installPath)
|
||||||
fs.rmSync(installPath, { recursive: true, force: true })
|
fs.rmSync(installPath, { recursive: true, force: true })
|
||||||
logForDebugging(`Deleted plugin cache at ${installPath}`)
|
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')
|
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.
|
* Memoized inner function to get marketplace data.
|
||||||
* This caches the marketplace in memory after loading from disk or network.
|
* 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
|
// any) is harmless, and blocking the re-add would prevent the user from
|
||||||
// fixing the corruption.
|
// fixing the corruption.
|
||||||
if (!isLocalMarketplaceSource(oldEntry.source)) {
|
if (!isLocalMarketplaceSource(oldEntry.source)) {
|
||||||
const cacheDir = resolve(getMarketplacesCacheDir())
|
|
||||||
const resolvedOld = resolve(oldEntry.installLocation)
|
const resolvedOld = resolve(oldEntry.installLocation)
|
||||||
const resolvedNew = resolve(cachePath)
|
const resolvedNew = resolve(cachePath)
|
||||||
if (resolvedOld === resolvedNew) {
|
if (resolvedOld === resolvedNew) {
|
||||||
// Same dir — loadAndCacheMarketplace already overwrote in place.
|
// Same dir — loadAndCacheMarketplace already overwrote in place.
|
||||||
// Nothing to clean.
|
// Nothing to clean.
|
||||||
} else if (
|
} else if (isStrictMarketplaceCachePath(oldEntry.installLocation)) {
|
||||||
resolvedOld === cacheDir ||
|
|
||||||
resolvedOld.startsWith(cacheDir + sep)
|
|
||||||
) {
|
|
||||||
const fs = getFsImplementation()
|
const fs = getFsImplementation()
|
||||||
await fs.rm(oldEntry.installLocation, { recursive: true, force: true })
|
await fs.rm(oldEntry.installLocation, { recursive: true, force: true })
|
||||||
} else {
|
} else {
|
||||||
|
const cacheDir = resolve(getMarketplacesCacheDir())
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Skipping cleanup of old installLocation (${oldEntry.installLocation}) — ` +
|
`Skipping cleanup of old installLocation (${oldEntry.installLocation}) — ` +
|
||||||
`outside ${cacheDir}. The path is corrupted; leaving it alone and ` +
|
`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.
|
// Refuse instead of auto-fixing so the user knows their state is corrupted.
|
||||||
if (!isLocalMarketplaceSource(source)) {
|
if (!isLocalMarketplaceSource(source)) {
|
||||||
const cacheDir = resolve(getMarketplacesCacheDir())
|
const cacheDir = resolve(getMarketplacesCacheDir())
|
||||||
const resolvedLoc = resolve(installLocation)
|
if (!isStrictMarketplaceCachePath(installLocation)) {
|
||||||
if (resolvedLoc !== cacheDir && !resolvedLoc.startsWith(cacheDir + sep)) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Marketplace '${name}' has a corrupted installLocation ` +
|
`Marketplace '${name}' has a corrupted installLocation ` +
|
||||||
`(${installLocation}) — expected a path inside ${cacheDir}. ` +
|
`(${installLocation}) — expected a path inside ${cacheDir}. ` +
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user