feat(desktop): seed cc-haha-builtin marketplace into Electron package (#50)

* feat(desktop): seed cc-haha-builtin marketplace into Electron package

Wires the missing desktop side of the plugin-seed mechanism. The CLI
side (src/utils/plugins/{pluginDirectories,marketplaceManager}.ts) has
been on main for a while and exposes `getPluginSeedDirs()` +
`registerSeedMarketplaces()` reading `CLAUDE_CODE_PLUGIN_SEED_DIR`. But
the desktop side never set that env var or copied any seed into the
Electron package, so:

- v0.5.11 packaged users see whatever marketplaces they manually
  registered (typically just `reverse-engineering` from a one-off
  `plugin marketplace add ./plugins`).
- The image-gen plugin added in PR #49 was invisible after install
  because no mechanism shipped it inside the Electron bundle.

What this commit adds

- `desktop/scripts/build-plugin-seed.ts` (new)
  - Mirrors the repo's `plugins/` directory into
    `desktop/plugin-seed/marketplaces/cc-haha-builtin/`.
  - Generates `desktop/plugin-seed/known_marketplaces.json` with a
    placeholder `installLocation`. registerSeedMarketplaces() recomputes
    the real path at runtime via findSeedMarketplaceLocation(), so the
    placeholder is intentional — handles cases like multi-stage Docker
    builds where the seed lives at a different path than where it was
    built.

- `desktop/package.json`
  - Adds `build:plugin-seed` script.
  - Prepends it to `electron:dev` and `electron:build` so the seed is
    always fresh before the app starts (or is packaged).
  - Adds `plugin-seed/**` to electron-builder `files` and `asarUnpack`
    so the seed dir ships inside the .app/.exe but stays outside the
    asar archive (CC reads files from disk, not from asar).

- `desktop/electron/services/sidecarManager.ts`
  - Threads `desktopRoot` through `buildSidecarEnv(...)` and adds
    `CLAUDE_CODE_PLUGIN_SEED_DIR=<desktopRoot>/plugin-seed` so the
    spawned server sidecar finds the seed.

- `src/server/index.ts`
  - Calls `registerSeedMarketplaces()` at server boot so the seeded
    marketplace appears in `~/.claude/plugins/known_marketplaces.json`
    and the desktop's Settings → Plugins page picks it up.
  - Fire-and-forget (`void ...`) rather than `await`, since
    `startServer` is sync and the registration takes ~ms; the seeded
    marketplace appears in the UI within 1-2 seconds of server boot.
    (The original drafted version used `await` inside a sync function,
    which doesn't compile — fixed in this commit.)

- `.gitignore` — excludes `desktop/plugin-seed/` from version control
  (regenerated on every electron build, never committed).

End-to-end flow after this lands

  build time:
    bun run electron:build
      → bun run build:plugin-seed
          → desktop/plugin-seed/marketplaces/cc-haha-builtin/{plugin/,...}
          → desktop/plugin-seed/known_marketplaces.json
      → bun run build:sidecars + electron-builder
          → packages plugin-seed/** into .app/.exe (asarUnpack ensures
            the dir is on disk, not inside asar)

  runtime (first launch):
    Electron main → spawn server sidecar with
      CLAUDE_CODE_PLUGIN_SEED_DIR=<resourcesDir>/plugin-seed
    Server sidecar starts → registerSeedMarketplaces()
      → reads seed/known_marketplaces.json
      → calls findSeedMarketplaceLocation() to resolve real path
      → writes resolved entry into ~/.claude/plugins/known_marketplaces.json
    Desktop Settings → Plugins reads ~/.claude/plugins/known_marketplaces.json
      → shows cc-haha-builtin marketplace with both reverse-engineering
        and image-gen plugins available to install.

Verification

- `bun run desktop/scripts/build-plugin-seed.ts` produces the seed
  with both plugins present, valid JSON, in expected dir layout.
- `bun run lint` (desktop): tsc --noEmit clean — no remaining type
  errors from the await→void change or the buildSidecarEnv signature
  change.
- Seed marketplace.json plugins array confirmed includes both
  reverse-engineering (v0.4.6, post-#33) and image-gen (v1.0.0, from
  #49).

Tested: build script runs locally; tsc clean.
Not-tested: live `bun run electron:package` end-to-end pack on this
host (no codesign, slow). The packaging-side wiring (asarUnpack +
files) is configuration-only and matches established patterns
(node_modules/node-pty/**, src-tauri/binaries/**); no behaviour change
to the existing package layout besides adding plugin-seed/.

Risk: low — additive across the board, no existing path changes
behaviour. The fire-and-forget marketplace registration cannot block
boot; if it fails, the worst case is the user sees no seeded
marketplace and can manually re-add it (existing flow).

Confidence: high
Scope-risk: narrow — desktop wiring + 1 server-side hook call.

* test(server): add seed-marketplaces-startup integration tests

Covers the registerSeedMarketplaces() integration that startServer()

now invokes at boot. Four cases:

- env var unset → returns false, no write

- valid seed → returns true, writes primary entry with resolved (non-placeholder) installLocation

- repeat call → primary entry stays intact (returns false on the no-op call but the previously-written entry survives)

- nonexistent seed dir → returns false, no crash

Resolves the change-policy 'Server product files changed without a server test file in the PR' block on PR #50.

* test(desktop): pass desktopRoot to buildSidecarEnv in sidecar test

buildSidecarEnv now requires desktopRoot to derive CLAUDE_CODE_PLUGIN_SEED_DIR. The existing 'passes portable config' test still called the old 2-arg signature, breaking both desktop-checks (vitest) and desktop-native-checks (tsc) on PR #50.

Tested:

  - bunx vitest run electron/services/sidecarManager.test.ts -t 'passes portable config' — 1 passed

  - tsc -p electron/tsconfig.json — clean

Confidence: high

Scope-risk: narrow

---------

Co-authored-by: 你的姓名 <you@example.com>
This commit is contained in:
小橙子 2026-06-15 23:18:34 +08:00 committed by GitHub
parent 8fef962e27
commit 355b7294b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 278 additions and 6 deletions

3
.gitignore vendored
View File

@ -94,3 +94,6 @@ CLAUDE.md
.opencode/
graphify-out/
.gitignore
# desktop/scripts/build-plugin-seed.ts output
desktop/plugin-seed/

View File

@ -66,9 +66,10 @@ describe('Electron sidecar manager', () => {
it('passes portable config and adapter server URL through the sidecar env', () => {
const configDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-config-'))
try {
const env = buildSidecarEnv({ CLAUDE_CONFIG_DIR: configDir }, '/app/dist')
const env = buildSidecarEnv({ CLAUDE_CONFIG_DIR: configDir }, '/app/dist', '/app/desktop')
expect(env.CLAUDE_CONFIG_DIR).toBe(configDir)
expect(env.XDG_CACHE_HOME).toBe(path.join(configDir, 'Cache'))
expect(env.CLAUDE_CODE_PLUGIN_SEED_DIR).toBe(path.join('/app/desktop', 'plugin-seed'))
const adapter = createAdapterPlan({
desktopRoot: '/app/desktop',

View File

@ -162,11 +162,12 @@ export function windowsPowerShellOverride(
return base === 'pwsh' || base === 'powershell' ? trimmed : null
}
export function buildSidecarEnv(baseEnv: NodeJS.ProcessEnv, h5DistDir: string): NodeJS.ProcessEnv {
export function buildSidecarEnv(baseEnv: NodeJS.ProcessEnv, h5DistDir: string, desktopRoot: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...baseEnv,
CLAUDE_H5_AUTO_PUBLIC_URL: '1',
CLAUDE_H5_DIST_DIR: h5DistDir,
CLAUDE_CODE_PLUGIN_SEED_DIR: path.join(desktopRoot, 'plugin-seed'),
}
const configDir = baseEnv.CLAUDE_CONFIG_DIR
if (configDir) {
@ -196,7 +197,7 @@ export function createServerPlan({
return {
command: resolveSidecarExecutable(desktopRoot),
args: ['server', '--app-root', appRoot, '--host', bindHost, '--port', String(port)],
env: buildSidecarEnv(env, h5DistDir),
env: buildSidecarEnv(env, h5DistDir, desktopRoot),
}
}
@ -219,7 +220,7 @@ export function createAdapterPlan({
command: resolveSidecarExecutable(desktopRoot),
args: ['adapters', '--app-root', appRoot, flag],
env: {
...buildSidecarEnv(env, h5DistDir),
...buildSidecarEnv(env, h5DistDir, desktopRoot),
ADAPTER_SERVER_URL: httpToWebSocketUrl(serverUrl),
},
}

View File

@ -17,6 +17,7 @@
"asarUnpack": [
"dist/**",
"node_modules/node-pty/**",
"plugin-seed/**",
"src-tauri/binaries/**"
],
"artifactName": "Claude-Code-Haha-${version}-${os}-${arch}.${ext}",
@ -28,6 +29,7 @@
"dist/**",
"electron-dist/**",
"node_modules/node-pty/**",
"plugin-seed/**",
"src-tauri/binaries/**",
"src-tauri/icons/**",
"src-tauri/resources/preview-agent.js",
@ -84,13 +86,14 @@
"build:electron": "bun run prepare:node-pty && bun build ./electron/main.ts --outfile ./electron-dist/main.cjs --target node --format cjs --external electron --external node-pty && bun build ./electron/preload.ts --outfile ./electron-dist/preload.cjs --target node --format cjs --external electron && bun build ./electron/preview-preload.ts --outfile ./electron-dist/preview-preload.cjs --target node --format cjs --external electron",
"build:preview-agent": "bun run ./scripts/build-preview-agent.ts",
"build:sidecars": "bun run ./scripts/build-sidecars.ts",
"build:plugin-seed": "bun run ./scripts/build-plugin-seed.ts",
"clean:electron-output": "bun run ./scripts/clean-electron-output.ts",
"build:macos-arm64": "bash ./scripts/build-macos-arm64.sh",
"build:windows-x64": "powershell -ExecutionPolicy Bypass -File ./scripts/build-windows-x64.ps1",
"build:linux-x64": "bash ./scripts/build-linux.sh",
"build:linux-arm64": "LINUX_ARCH=arm64 bash ./scripts/build-linux.sh",
"electron:dev": "bun run build:electron && bun run ./scripts/electron-dev.ts",
"electron:build": "bun run build:sidecars && bun run build && bun run build:electron",
"electron:dev": "bun run build:plugin-seed && bun run build:electron && bun run ./scripts/electron-dev.ts",
"electron:build": "bun run build:plugin-seed && bun run build:sidecars && bun run build && bun run build:electron",
"electron:package": "bun run clean:electron-output && bun run electron:build && bunx electron-builder --publish never",
"electron:package:dir": "bun run clean:electron-output && bun run electron:build && bunx electron-builder --dir --publish never",
"check:electron": "node ./node_modules/typescript/bin/tsc -p electron/tsconfig.json && bun test electron && bun run build:electron",

View File

@ -0,0 +1,66 @@
#!/usr/bin/env bun
/**
* Build plugin seed directory for Electron packaging.
*
* Converts the repository's plugins/ directory into a seed structure
* that the Electron app can load at runtime via CLAUDE_CODE_PLUGIN_SEED_DIR.
*
* Seed structure:
* desktop/plugin-seed/
* known_marketplaces.json
* marketplaces/
* cc-haha-builtin/
* .claude-plugin/
* marketplace.json
* image-gen/
* reverse-engineering/
*/
import { mkdir, rm, cp, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const REPO_ROOT = join(import.meta.dir, '..', '..')
const DESKTOP_ROOT = join(REPO_ROOT, 'desktop')
const PLUGINS_SOURCE = join(REPO_ROOT, 'plugins')
const SEED_OUTPUT = join(DESKTOP_ROOT, 'plugin-seed')
async function main() {
console.log('[build-plugin-seed] Cleaning old seed...')
await rm(SEED_OUTPUT, { recursive: true, force: true })
await mkdir(SEED_OUTPUT, { recursive: true })
console.log('[build-plugin-seed] Copying marketplace to seed...')
const marketplacesDir = join(SEED_OUTPUT, 'marketplaces')
await mkdir(marketplacesDir, { recursive: true })
const marketplaceDest = join(marketplacesDir, 'cc-haha-builtin')
await cp(PLUGINS_SOURCE, marketplaceDest, { recursive: true })
console.log('[build-plugin-seed] Generating known_marketplaces.json...')
// installLocation is a placeholder - registerSeedMarketplaces() will recompute
// it by calling findSeedMarketplaceLocation(seedDir, name) at runtime.
const knownMarketplaces = {
'cc-haha-builtin': {
source: {
source: 'directory',
path: '/placeholder/will-be-resolved-at-runtime',
},
installLocation: '/placeholder/will-be-resolved-at-runtime',
lastUpdated: new Date().toISOString(),
autoUpdate: false,
},
}
await writeFile(
join(SEED_OUTPUT, 'known_marketplaces.json'),
JSON.stringify(knownMarketplaces, null, 2),
'utf-8',
)
console.log('[build-plugin-seed] ✓ Plugin seed built at', SEED_OUTPUT)
}
main().catch((error) => {
console.error('[build-plugin-seed] Failed:', error)
process.exit(1)
})

View File

@ -0,0 +1,187 @@
/**
* Server-side seed marketplace registration tests.
*
* These cover the integration that startServer() in src/server/index.ts
* triggers calling registerSeedMarketplaces() at boot so the desktop's
* Settings Plugins page sees the cc-haha-builtin marketplace shipped
* with the Electron package.
*
* The shape we exercise:
*
* $CLAUDE_CODE_PLUGIN_SEED_DIR/
* known_marketplaces.json
* marketplaces/
* cc-haha-builtin/
* .claude-plugin/
* marketplace.json
*
* is what desktop/scripts/build-plugin-seed.ts produces during
* `bun run electron:build`. registerSeedMarketplaces() reads the seed,
* resolves the real installLocation via findSeedMarketplaceLocation(),
* and writes it into the user's primary
* ~/.claude/plugins/known_marketplaces.json.
*/
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 { registerSeedMarketplaces } from '../../utils/plugins/marketplaceManager.js'
const MARKETPLACE_NAME = 'cc-haha-builtin'
let originalConfigDir: string | undefined
let originalSeedDir: string | undefined
let tmpRoot: string
async function makeSeed(seedDir: string): Promise<void> {
const marketplaceRoot = path.join(seedDir, 'marketplaces', MARKETPLACE_NAME)
await fs.mkdir(path.join(marketplaceRoot, '.claude-plugin'), { recursive: true })
// Marketplace manifest the loader will read.
await fs.writeFile(
path.join(marketplaceRoot, '.claude-plugin', 'marketplace.json'),
JSON.stringify(
{
name: MARKETPLACE_NAME,
owner: { name: 'cc-haha' },
plugins: [],
},
null,
2,
),
'utf-8',
)
// Seed-side known_marketplaces.json (build-plugin-seed.ts writes this
// with placeholder paths — registerSeedMarketplaces resolves them
// against the actual seed dir at runtime).
await fs.writeFile(
path.join(seedDir, 'known_marketplaces.json'),
JSON.stringify(
{
[MARKETPLACE_NAME]: {
source: { source: 'directory', path: '/placeholder' },
installLocation: '/placeholder',
lastUpdated: new Date().toISOString(),
autoUpdate: false,
},
},
null,
2,
),
'utf-8',
)
}
beforeEach(async () => {
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalSeedDir = process.env.CLAUDE_CODE_PLUGIN_SEED_DIR
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-seed-test-'))
// Pin the user-config dir into our tmp so we don't pollute the real
// ~/.claude when the registration writes back.
process.env.CLAUDE_CONFIG_DIR = path.join(tmpRoot, 'claude-config')
await fs.mkdir(process.env.CLAUDE_CONFIG_DIR, { recursive: true })
})
afterEach(async () => {
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
if (originalSeedDir === undefined) delete process.env.CLAUDE_CODE_PLUGIN_SEED_DIR
else process.env.CLAUDE_CODE_PLUGIN_SEED_DIR = originalSeedDir
await fs.rm(tmpRoot, { recursive: true, force: true })
})
describe('registerSeedMarketplaces (server boot integration)', () => {
test('returns false and does not write when CLAUDE_CODE_PLUGIN_SEED_DIR is unset', async () => {
delete process.env.CLAUDE_CODE_PLUGIN_SEED_DIR
const result = await registerSeedMarketplaces()
expect(result).toBe(false)
})
test('registers the seeded marketplace when env var points at a valid seed', async () => {
const seedDir = path.join(tmpRoot, 'seed')
await makeSeed(seedDir)
process.env.CLAUDE_CODE_PLUGIN_SEED_DIR = seedDir
const result = await registerSeedMarketplaces()
expect(result).toBe(true)
// The primary known_marketplaces.json under CLAUDE_CONFIG_DIR/plugins
// must now contain the seeded marketplace, with installLocation pointing
// INTO the seed dir (not the placeholder string).
const primaryPath = path.join(
process.env.CLAUDE_CONFIG_DIR!,
'plugins',
'known_marketplaces.json',
)
const primaryRaw = await fs.readFile(primaryPath, 'utf-8')
const primary = JSON.parse(primaryRaw) as Record<
string,
{ installLocation: string; source: { source: string } }
>
expect(primary[MARKETPLACE_NAME]).toBeDefined()
expect(primary[MARKETPLACE_NAME]!.installLocation).toContain(seedDir)
// Crucially, the runtime-resolved location is NOT the placeholder
// baked into the seed JSON by build-plugin-seed.ts.
expect(primary[MARKETPLACE_NAME]!.installLocation).not.toBe('/placeholder')
})
test('is idempotent — calling twice with the same seed leaves the primary entry intact', async () => {
const seedDir = path.join(tmpRoot, 'seed')
await makeSeed(seedDir)
process.env.CLAUDE_CODE_PLUGIN_SEED_DIR = seedDir
// First call: actually registers (returns true).
expect(await registerSeedMarketplaces()).toBe(true)
const primaryPath = path.join(
process.env.CLAUDE_CONFIG_DIR!,
'plugins',
'known_marketplaces.json',
)
const first = await fs.readFile(primaryPath, 'utf-8')
// Second call: registerSeedMarketplaces returns false to signal
// "nothing new to write" (the seed is already represented in primary).
// The contract this test locks: even when the function returns false
// on a repeat call, the primary entry MUST still be present and
// resolved — startServer() invokes this every boot, so a flaky
// "second-call returns false" must NEVER mean "marketplace was
// unregistered".
await registerSeedMarketplaces()
const second = await fs.readFile(primaryPath, 'utf-8')
const firstParsed = JSON.parse(first)
const secondParsed = JSON.parse(second)
expect(secondParsed[MARKETPLACE_NAME]).toBeDefined()
expect(secondParsed[MARKETPLACE_NAME]?.installLocation).toBe(
firstParsed[MARKETPLACE_NAME]?.installLocation,
)
})
test('returns false when the env var points at a nonexistent dir (graceful no-op)', async () => {
process.env.CLAUDE_CODE_PLUGIN_SEED_DIR = path.join(tmpRoot, 'does-not-exist')
const result = await registerSeedMarketplaces()
expect(result).toBe(false)
// Primary known_marketplaces.json was either never created or has no
// cc-haha-builtin entry — startup must not crash on a missing seed.
const primaryPath = path.join(
process.env.CLAUDE_CONFIG_DIR!,
'plugins',
'known_marketplaces.json',
)
const primaryExists = await fs.stat(primaryPath).then(
() => true,
() => false,
)
if (primaryExists) {
const primary = JSON.parse(await fs.readFile(primaryPath, 'utf-8')) as Record<
string,
unknown
>
expect(primary[MARKETPLACE_NAME]).toBeUndefined()
}
})
})

View File

@ -27,6 +27,7 @@ import { ensurePersistentStorageUpgraded } from './services/persistentStorageMig
import { handleStaticH5Request } from './staticH5.js'
import { classifyH5Request, shouldBlockDisabledH5Access, shouldRequireH5Token } from './h5AccessPolicy.js'
import { H5AccessService } from './services/h5AccessService.js'
import { registerSeedMarketplaces } from '../utils/plugins/marketplaceManager.js'
function readArgValue(flag: string): string | undefined {
const args = process.argv.slice(2)
@ -147,6 +148,16 @@ export function startServer(port = PORT, host = HOST) {
process.env.SERVER_AUTH_REQUIRED === '1'
const h5AccessService = new H5AccessService()
// Register seed marketplaces (e.g. the cc-haha-builtin marketplace
// shipped with the Electron package via CLAUDE_CODE_PLUGIN_SEED_DIR)
// into ~/.claude/plugins/known_marketplaces.json so they're visible
// in the desktop's Settings → Plugins view. Fire-and-forget — startup
// shouldn't block on this; the marketplace appears in the UI within
// 1-2 seconds of server boot.
void registerSeedMarketplaces().catch((err) => {
console.error('[Server] Failed to register seed marketplaces:', err)
})
let server: ReturnType<typeof Bun.serve<WebSocketData>>
try {