Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/electron/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Electron desktop app for macOS, Windows, and Linux -- the primary development ta
```bash
pnpm dev # Start hub + app concurrently
pnpm dev:both # Two instances for sync testing
pnpm run deps:node # Rebuild native deps for plain Node tests
pnpm run deps:electron # Rebuild native deps for Electron runtime
```

## Build
Expand Down Expand Up @@ -48,3 +50,26 @@ Dev server runs at `http://localhost:5177`. Connect with Playwright MCP for auto
# Enable sync debug logs in the browser console
localStorage.setItem('xnet:sync:debug', 'true')
```

## Coding Workspace Shell

The Electron coding workspace shell is the dogfood target for the self-editing MVP.

- Left rail: xNet-backed session summaries, dirty-state badges, and worktree selection
- Center panel: shared OpenCode Web host
- Right panel: preview, diff, files, markdown, screenshots, and PR draft flows

### Local dependencies

- `git` on PATH for worktrees, diffs, and cleanup
- `pnpm` on PATH for preview runtimes
- `gh` on PATH plus `gh auth login` for PR creation
- `opencode` on PATH, or `XNET_OPENCODE_BINARY=/absolute/path/to/opencode`

### Recovery flows

- OpenCode missing: install from [OpenCode docs](https://opencode.ai/docs/install), then refresh the center panel
- Preview startup failure: run `pnpm install`, then `pnpm run deps:electron`, then restart the preview from the right panel
- If `pnpm run deps:electron` fails inside `@electron/rebuild` with `util.styleText is not a function`, rebuild the native module directly with `npm rebuild better-sqlite3 --runtime=electron --target=33.4.11 --arch=arm64 --dist-url=https://electronjs.org/headers`
Comment on lines +71 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

pkg="$(fd '^package\.json$' apps/electron | head -n1)"
echo "package.json: $pkg"
echo "Electron version in package.json:"
jq -r '.devDependencies.electron // .dependencies.electron // "missing"' "$pkg"

echo
echo "Hard-coded rebuild fallback in README:"
rg -n -- '--target=|--arch=' apps/electron/README.md

Repository: crs48/xNet

Length of output: 443


🏁 Script executed:

# Check if there are other version/arch-specific values in the README
echo "=== Full context around line 73 ==="
sed -n '65,80p' apps/electron/README.md

echo -e "\n=== Check for other hard-coded versions in README ==="
rg -n '(\d+\.\d+\.\d+|arm64|x64|--arch)' apps/electron/README.md | head -20

echo -e "\n=== Check package.json for electron and better-sqlite3 versions ==="
jq '.devDependencies | with_entries(select(.key | test("electron|sqlite")))' apps/electron/package.json

Repository: crs48/xNet

Length of output: 1494


Replace hard-coded Electron target and arch with dynamic or placeholder values.

Line 73 hard-codes --target=33.4.11 and --arch=arm64 in the recovery fallback. The --arch=arm64 will fail silently or mislead developers on x64 and other architectures. The --target=33.4.11 is currently compatible with the app's configured ^33.0.0 but will drift once Electron is upgraded to 34.x or later. Extract or document these as placeholders (e.g., --arch=$(uname -m) or reference the actual Electron version from package.json).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/electron/README.md` around lines 71 - 73, Update the README recovery
command that runs `npm rebuild better-sqlite3 --runtime=electron
--target=33.4.11 --arch=arm64` to avoid hard-coded values: replace the fixed
`--target=33.4.11` and `--arch=arm64` with dynamic/placeholder references (e.g.,
document using the project's Electron version and the system architecture) and
show how to obtain them (for example, reference reading Electron version from
package.json or using a shell substitution like uname -m for arch) so the `npm
rebuild better-sqlite3` fallback is correct across Electron upgrades and CPU
architectures.

- PR creation failure: ensure GitHub CLI is installed and authenticated with `gh auth login`
- Worktree removal blocked: review the diff, commit, or revert local changes before removing the session
10 changes: 6 additions & 4 deletions apps/electron/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ function stripCspInDev(): Plugin {
// Support running multiple instances with different ports
const rendererPort = parseInt(process.env.VITE_PORT || '5177', 10)

const xnetPluginNodeAlias = {
find: '@xnetjs/plugins/node',
replacement: resolve(__dirname, '../../packages/plugins/src/services/node.ts')
}

// Common xNet packages to bundle (not externalize)
const xnetPackages = [
'@xnetjs/sdk',
Expand Down Expand Up @@ -72,10 +77,7 @@ export default defineConfig({
}
},
resolve: {
alias: {
// Resolve better-sqlite3 to local rebuilt version during bundling
'better-sqlite3': betterSqlite3Path
}
alias: [xnetPluginNodeAlias, { find: 'better-sqlite3', replacement: betterSqlite3Path }]
}
},
preload: {
Expand Down
157 changes: 157 additions & 0 deletions apps/electron/src/__tests__/opencode-host-controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import type { ServiceDefinition, ServiceStatus } from '@xnetjs/plugins/node'
import { describe, expect, it, vi } from 'vitest'
import { createOpenCodeHostController } from '../main/opencode-host-controller'
import {
OPENCODE_SERVICE_ID,
createOpenCodeHostConfig,
type OpenCodeBinaryResolution,
type OpenCodeHostConfig,
type OpenCodeHostStatus
} from '../shared/opencode-host'

const createServiceStatus = (
config: OpenCodeHostConfig,
overrides: Partial<ServiceStatus> = {}
): ServiceStatus => ({
id: OPENCODE_SERVICE_ID,
state: 'running',
port: config.port,
pid: 4242,
startedAt: 1,
restartCount: 0,
...overrides
})

const createBinaryResolution = (path: string): OpenCodeBinaryResolution => ({
found: true,
path,
source: 'path'
})

describe('createOpenCodeHostController', () => {
it('should return a missing-binary status without starting a service', async () => {
const config = createOpenCodeHostConfig({
XNET_OPENCODE_PORT: '4100'
})

const startService = vi.fn<(_: ServiceDefinition) => Promise<ServiceStatus>>()
const publishStatus = vi.fn<(status: OpenCodeHostStatus) => void>()

const controller = createOpenCodeHostController({
getConfig: () => config,
getServiceStatus: () => undefined,
startService,
restartService: vi.fn(),
stopService: vi.fn(),
resolveBinary: vi.fn(async () => ({
found: false,
checkedPaths: [],
error: 'OpenCode CLI was not found on PATH',
recovery: 'Install OpenCode'
})),
probeHealth: vi.fn(async () => null),
publishStatus
})

const status = await controller.ensure()

expect(status.state).toBe('missing-binary')
expect(startService).not.toHaveBeenCalled()
expect(publishStatus).toHaveBeenCalledWith(status)
})

it('should dedupe concurrent ensure calls', async () => {
const config = createOpenCodeHostConfig({
XNET_OPENCODE_PORT: '4101'
})

let serviceStatus: ServiceStatus | undefined
let resolveStart: ((status: ServiceStatus) => void) | null = null

const startService = vi.fn(async (_definition: ServiceDefinition) => {
const nextStatus = await new Promise<ServiceStatus>((resolve) => {
resolveStart = resolve
})
serviceStatus = nextStatus
return nextStatus
})

const controller = createOpenCodeHostController({
getConfig: () => config,
getServiceStatus: () => serviceStatus,
startService,
restartService: vi.fn(),
stopService: vi.fn(),
resolveBinary: vi.fn(async () => createBinaryResolution('/usr/local/bin/opencode')),
probeHealth: vi.fn(async () => ({ healthy: true, version: '1.0.0' })),
publishStatus: vi.fn()
})

const first = controller.ensure()
const second = controller.ensure()

await new Promise((resolve) => setTimeout(resolve, 0))

expect(startService).toHaveBeenCalledTimes(1)

resolveStart?.(createServiceStatus(config))

const [firstStatus, secondStatus] = await Promise.all([first, second])

expect(firstStatus.state).toBe('ready')
expect(secondStatus).toEqual(firstStatus)
expect(startService).toHaveBeenCalledTimes(1)
})

it('should reuse an existing running service', async () => {
const config = createOpenCodeHostConfig({
XNET_OPENCODE_PORT: '4102'
})

const serviceStatus = createServiceStatus(config)
const startService = vi.fn()
const restartService = vi.fn()

const controller = createOpenCodeHostController({
getConfig: () => config,
getServiceStatus: () => serviceStatus,
startService,
restartService,
stopService: vi.fn(),
resolveBinary: vi.fn(async () => createBinaryResolution('/usr/local/bin/opencode')),
probeHealth: vi.fn(async () => ({ healthy: true, version: '1.2.3' })),
publishStatus: vi.fn()
})

const status = await controller.ensure()

expect(status.state).toBe('ready')
expect(status.version).toBe('1.2.3')
expect(startService).not.toHaveBeenCalled()
expect(restartService).not.toHaveBeenCalled()
})

it('should restart a stopped service instead of returning an idle status', async () => {
const config = createOpenCodeHostConfig({
XNET_OPENCODE_PORT: '4103'
})

const restartService = vi.fn(async () => createServiceStatus(config))

const controller = createOpenCodeHostController({
getConfig: () => config,
getServiceStatus: () => createServiceStatus(config, { state: 'stopped' }),
startService: vi.fn(),
restartService,
stopService: vi.fn(),
resolveBinary: vi.fn(async () => createBinaryResolution('/usr/local/bin/opencode')),
probeHealth: vi.fn(async () => ({ healthy: true, version: '1.2.4' })),
publishStatus: vi.fn()
})

const status = await controller.ensure()

expect(status.state).toBe('ready')
expect(restartService).toHaveBeenCalledTimes(1)
})
})
13 changes: 13 additions & 0 deletions apps/electron/src/__tests__/service-ipc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { SERVICE_IPC_CHANNELS } from '@xnetjs/plugins'
import { describe, expect, it } from 'vitest'
import { ALLOWED_SERVICE_CHANNELS, isAllowedServiceChannel } from '../shared/service-ipc'

describe('service IPC allowlist', () => {
it('should expose the full shared service contract', () => {
expect([...ALLOWED_SERVICE_CHANNELS].sort()).toEqual(Object.values(SERVICE_IPC_CHANNELS).sort())
})

it('should reject stale channel names', () => {
expect(isAllowedServiceChannel('xnet:service:list')).toBe(false)
})
})
18 changes: 18 additions & 0 deletions apps/electron/src/main/command-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { formatCommandFailure } from './command-errors'

describe('command-errors', () => {
it('formats missing command failures with recovery guidance', () => {
const error = Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' })

expect(formatCommandFailure('git', ['status'], '/tmp/xnet', error)).toContain('Install Git')
})

it('preserves the original failure message for non-ENOENT errors', () => {
const error = new Error('fatal: not a git repository')

expect(formatCommandFailure('git', ['status'], '/tmp/xnet', error)).toContain(
'fatal: not a git repository'
)
})
})
40 changes: 40 additions & 0 deletions apps/electron/src/main/command-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Shared command-failure formatting for Electron workspace services.
*/

const COMMAND_RECOVERY_HINTS: Record<string, string> = {
git: 'Install Git and ensure `git` is available on PATH before using the coding workspace shell.',
gh: 'Install GitHub CLI (`gh`) and run `gh auth login` before creating pull requests from the workspace shell.',
pnpm: 'Install pnpm and run `pnpm install` in this repository before starting worktree previews.'
}

function getErrorCode(error: unknown): string | null {
if (!error || typeof error !== 'object' || !('code' in error)) {
return null
}

const code = Reflect.get(error, 'code')
return typeof code === 'string' ? code : null
}

export function formatCommandFailure(
command: string,
args: readonly string[],
cwd: string,
error: unknown
): string {
const errorCode = getErrorCode(error)
const fallbackMessage = error instanceof Error ? error.message : String(error)
const commandLabel = `${command} ${args.join(' ')}`.trim()
const recovery = COMMAND_RECOVERY_HINTS[command]

if (errorCode === 'ENOENT') {
return recovery
? `${command} is required but was not found while running ${commandLabel} in ${cwd}. ${recovery}`
: `${command} is required but was not found while running ${commandLabel} in ${cwd}.`
}

return recovery
? `${commandLabel} failed in ${cwd}: ${fallbackMessage}. ${recovery}`
: `${commandLabel} failed in ${cwd}: ${fallbackMessage}`
}
105 changes: 105 additions & 0 deletions apps/electron/src/main/git-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import {
deriveWorktreeContainerPath,
deriveWorktreeName,
isManagedWorktreePath,
parseGitStatusSummary,
parseWorktreeListOutput
} from './git-service'

describe('git-service', () => {
describe('parseWorktreeListOutput', () => {
it('parses porcelain worktree output', () => {
const output = [
'worktree /tmp/xnet',
'HEAD abc123',
'branch refs/heads/main',
'',
'worktree /tmp/xnet-feature',
'HEAD def456',
'branch refs/heads/codex/layout-pass',
'locked',
''
].join('\n')

expect(parseWorktreeListOutput(output)).toEqual([
{
path: '/tmp/xnet',
head: 'abc123',
branch: 'main',
bare: false,
detached: false,
locked: false,
prunable: false
},
{
path: '/tmp/xnet-feature',
head: 'def456',
branch: 'codex/layout-pass',
bare: false,
detached: false,
locked: true,
prunable: false
}
])
})

it('ignores unexpected branch formats instead of slicing corrupt names', () => {
const output = ['worktree /tmp/xnet', 'HEAD abc123', 'branch detached-head', ''].join('\n')

expect(parseWorktreeListOutput(output)).toEqual([
{
path: '/tmp/xnet',
head: 'abc123',
branch: null,
bare: false,
detached: false,
locked: false,
prunable: false
}
])
})
})

describe('parseGitStatusSummary', () => {
it('counts unique changed files', () => {
const output = [
'M apps/electron/src/main/index.ts',
'?? docs/notes.md',
'M docs/notes.md'
].join('\n')

expect(parseGitStatusSummary(output)).toEqual({
changedFilesCount: 2,
isDirty: true,
files: ['apps/electron/src/main/index.ts', 'docs/notes.md']
})
})
})

describe('deriveWorktreeName', () => {
it('builds a stable worktree name from the branch and session id', () => {
expect(deriveWorktreeName('codex/layout-pass', 'xnet:workspace-session:abc123')).toBe(
'layout-pass-xnet-wor'
)
})
})

describe('managed worktree paths', () => {
it('derives the managed worktree container from the repo root', () => {
expect(deriveWorktreeContainerPath('/Users/crs/src/xNet')).toBe(
'/Users/crs/src/.xnet-worktrees/xnet'
)
})

it('accepts only worktrees inside the managed container', () => {
const repoRoot = '/Users/crs/src/xNet'

expect(
isManagedWorktreePath(repoRoot, '/Users/crs/src/.xnet-worktrees/xnet/layout-pass-xnet-wor')
).toBe(true)
expect(isManagedWorktreePath(repoRoot, '/Users/crs/src/xNet')).toBe(false)
expect(isManagedWorktreePath(repoRoot, '/tmp/layout-pass-xnet-wor')).toBe(false)
})
})
})
Loading
Loading