* fix(docker): support multi-platform builds and fix monorepo paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * perf(renderer): cache pre-rasterized paragraph images to avoid per-frame glyph rasterization (#76) * fix(canvas): stabilize frame label size during zoom Draw frame labels in screen-space after the viewport transform restore, converting scene coords manually. Previously fontSize=12/zoom fed into Math.ceil caused integer-boundary jumps that made labels flicker during zoom. Also skip shadow rendering while actively zooming for smoother performance. * perf(renderer): cache pre-rasterized paragraph images to avoid per-frame glyph rasterization - Add paraImageCache (SkImage, 128 MB LRU limit) keyed on the same key as paraCache - Use drawImageRect instead of drawParagraph on cache hit, skipping per-frame glyph shaping and rasterization - Fall back to direct drawParagraph only when off-screen surface creation (MakeSurface) fails - Extract _dpr getter to deduplicate device-pixel-ratio resolution logic across draw paths - Evict oldest entries when cache exceeds byte limit; delete SkImage on eviction and dispose() * feat(cli): introduce OpenPencil CLI for terminal control of the design tool - Added a new CLI application under `apps/cli` to manage OpenPencil from the terminal. - Implemented commands for app control (`start`, `stop`, `status`), document operations (`open`, `save`, `get`, `selection`), and design manipulation (`design`, `import`). - Enhanced documentation with usage instructions and platform support details. - Updated build scripts to include CLI compilation and publishing processes. - Introduced a new GitHub Actions workflow for publishing the CLI to npm. - Updated existing workflows to integrate CLI build steps and ensure proper versioning across packages. * docs: update README files to include CLI tool details and multi-platform code export - Added CLI section to README files in multiple languages, detailing commands for terminal control of the design tool. - Included instructions for global installation and usage examples for the CLI. - Expanded documentation on multi-platform code export capabilities from a single `.op` file to various frameworks. - Updated CLAUDE.md to reference the new CLI documentation and its integration with the design tool. * chore(bun.lock): update package dependencies to specific versions - Removed workspace references for several packages in the bun.lock file. - Updated dependencies for `@zseven-w/pen-core`, `@zseven-w/pen-types`, `@zseven-w/pen-codegen`, `@zseven-w/pen-figma`, and `@zseven-w/pen-renderer` to version `0.5.1-beta.1`. - Ensured consistency in dependency management across the project. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: leinaldo <60176594+leinaldo@users.noreply.github.com>
270 lines
9 KiB
TypeScript
270 lines
9 KiB
TypeScript
import { spawn } from 'node:child_process'
|
|
import { mkdirSync, readFileSync, writeFileSync, existsSync, appendFileSync } from 'node:fs'
|
|
import { homedir, tmpdir, platform } from 'node:os'
|
|
import { join } from 'node:path'
|
|
|
|
const IS_WIN = platform() === 'win32'
|
|
|
|
type EnvLike = Record<string, string | undefined>
|
|
|
|
interface ClaudeSettings {
|
|
env?: Record<string, unknown>
|
|
}
|
|
|
|
function normalizeEnvValue(key: string, value: unknown): string | undefined {
|
|
if (value == null) return undefined
|
|
if (typeof value === 'string') {
|
|
// Filter out empty strings - they cause issues
|
|
if (value.trim() === '') return undefined
|
|
return value
|
|
}
|
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
return String(value)
|
|
}
|
|
// ANTHROPIC_CUSTOM_HEADERS can be an object in settings.json — serialize it.
|
|
// Other object values are skipped to prevent "Invalid header name" errors.
|
|
if (typeof value === 'object') {
|
|
if (key === 'ANTHROPIC_CUSTOM_HEADERS') {
|
|
try { return JSON.stringify(value) } catch { return undefined }
|
|
}
|
|
return undefined
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function readSingleSettingsFile(filePath: string): EnvLike {
|
|
try {
|
|
const raw = readFileSync(filePath, 'utf-8')
|
|
const parsed = JSON.parse(raw) as ClaudeSettings
|
|
if (!parsed.env || typeof parsed.env !== 'object') return {}
|
|
|
|
const env: EnvLike = {}
|
|
for (const [key, value] of Object.entries(parsed.env)) {
|
|
const normalized = normalizeEnvValue(key, value)
|
|
if (normalized !== undefined) {
|
|
env[key] = normalized
|
|
}
|
|
}
|
|
return env
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read env from ~/.claude/settings.json and ~/.claude/settings.local.json.
|
|
* Local settings take priority (same as Claude Code's own precedence).
|
|
*/
|
|
function readClaudeSettingsEnv(): EnvLike {
|
|
const claudeDir = join(homedir(), '.claude')
|
|
const base = readSingleSettingsFile(join(claudeDir, 'settings.json'))
|
|
const local = readSingleSettingsFile(join(claudeDir, 'settings.local.json'))
|
|
return { ...base, ...local }
|
|
}
|
|
|
|
/**
|
|
* Validate if a string is valid JSON (for ANTHROPIC_CUSTOM_HEADERS).
|
|
*/
|
|
function isValidJson(str: string): boolean {
|
|
try {
|
|
JSON.parse(str)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* On Windows, Claude Code SDK may fail with EPERM when writing to ~/.claude.json
|
|
* or ~/.claude/ config files. Ensure the directory and config file exist and are writable.
|
|
*/
|
|
function ensureClaudeConfigWritable(): void {
|
|
if (!IS_WIN) return
|
|
try {
|
|
const claudeDir = join(homedir(), '.claude')
|
|
mkdirSync(claudeDir, { recursive: true })
|
|
// Ensure .claude.json exists — Claude SDK crashes if it can't write/lock it
|
|
const configFile = join(homedir(), '.claude.json')
|
|
if (!existsSync(configFile)) {
|
|
writeFileSync(configFile, '{}', 'utf-8')
|
|
}
|
|
// Ensure credentials.json exists — SDK may crash trying to read/write it
|
|
const credFile = join(claudeDir, 'credentials.json')
|
|
if (!existsSync(credFile)) {
|
|
writeFileSync(credFile, '{}', 'utf-8')
|
|
}
|
|
// Ensure statsig/ cache dir exists — SDK crashes writing feature gate cache
|
|
const statsigDir = join(claudeDir, 'statsig')
|
|
mkdirSync(statsigDir, { recursive: true })
|
|
} catch {
|
|
// Best effort — if we can't fix it, the SDK error hint will guide the user
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build env passed to Claude Agent SDK.
|
|
* Priority: current process env > ~/.claude/settings.json env.
|
|
*/
|
|
export function buildClaudeAgentEnv(): EnvLike {
|
|
// On Windows, pre-create config files to avoid EPERM errors
|
|
ensureClaudeConfigWritable()
|
|
|
|
const fromSettings = readClaudeSettingsEnv()
|
|
const fromProcess = process.env as EnvLike
|
|
|
|
const merged: EnvLike = {
|
|
...fromSettings,
|
|
...fromProcess,
|
|
}
|
|
|
|
// Validate ANTHROPIC_CUSTOM_HEADERS if it exists - must be valid JSON
|
|
// If invalid, delete it to prevent "Invalid header name" errors
|
|
if (merged.ANTHROPIC_CUSTOM_HEADERS) {
|
|
if (!isValidJson(merged.ANTHROPIC_CUSTOM_HEADERS)) {
|
|
delete merged.ANTHROPIC_CUSTOM_HEADERS
|
|
}
|
|
}
|
|
|
|
// Compatibility: use ANTHROPIC_AUTH_TOKEN as ANTHROPIC_API_KEY if no API key is set
|
|
const authToken = merged.ANTHROPIC_AUTH_TOKEN
|
|
if (authToken && !merged.ANTHROPIC_API_KEY) {
|
|
merged.ANTHROPIC_API_KEY = authToken
|
|
}
|
|
|
|
// Running inside Claude terminal can break nested Claude invocations.
|
|
delete merged.CLAUDECODE
|
|
|
|
// Remove Electron-specific env vars that may confuse spawned CLI processes
|
|
delete merged.ELECTRON_RUN_AS_NODE
|
|
delete merged.ELECTRON_RESOURCES_PATH
|
|
delete merged.CHROME_CRASHPAD_PIPE_NAME
|
|
|
|
// Enable Agent SDK debug stderr so we can capture CLI crash diagnostics.
|
|
// Without this, the SDK sets stderr to "ignore" and crash output is lost.
|
|
if (!merged.DEBUG_CLAUDE_AGENT_SDK) {
|
|
merged.DEBUG_CLAUDE_AGENT_SDK = '1'
|
|
}
|
|
|
|
// Apply NODE_TLS_REJECT_UNAUTHORIZED to the current process as well,
|
|
// so Node.js HTTP/TLS in this process (used by the SDK internals) respects it.
|
|
if (merged.NODE_TLS_REJECT_UNAUTHORIZED && !process.env.NODE_TLS_REJECT_UNAUTHORIZED) {
|
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = merged.NODE_TLS_REJECT_UNAUTHORIZED
|
|
}
|
|
|
|
if (IS_WIN) {
|
|
// Redirect Claude debug output to temp to avoid write permission issues
|
|
if (!merged.CLAUDE_DEBUG_FILE) {
|
|
const debugPath = getClaudeAgentDebugFilePath()
|
|
if (debugPath) merged.CLAUDE_DEBUG_FILE = debugPath
|
|
}
|
|
// Set CLAUDE_CONFIG_DIR to a writable temp location as fallback
|
|
// if the default ~/.claude directory is not writable (common in Windows Electron)
|
|
if (!merged.CLAUDE_CONFIG_DIR) {
|
|
try {
|
|
const fallbackDir = join(tmpdir(), 'openpencil-claude-config')
|
|
mkdirSync(fallbackDir, { recursive: true })
|
|
// Only use fallback if we can't write to the default location
|
|
const defaultDir = join(homedir(), '.claude')
|
|
const testFile = join(defaultDir, '.write-test')
|
|
try {
|
|
writeFileSync(testFile, '', 'utf-8')
|
|
const { unlinkSync } = require('node:fs')
|
|
unlinkSync(testFile)
|
|
} catch {
|
|
// Default dir is not writable — use fallback
|
|
merged.CLAUDE_CONFIG_DIR = fallbackDir
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
/**
|
|
* Force Claude CLI debug output into a writable temp location.
|
|
* This avoids crashes in restricted environments where ~/.claude/debug is not writable.
|
|
*/
|
|
export function getClaudeAgentDebugFilePath(): string | undefined {
|
|
try {
|
|
const dir = join(tmpdir(), 'openpencil-claude-debug')
|
|
mkdirSync(dir, { recursive: true })
|
|
return join(dir, 'claude-agent.log')
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Custom spawnClaudeCodeProcess for Windows.
|
|
* On Windows, npm-installed CLIs are .cmd/.ps1 scripts that can't be spawned
|
|
* directly without a shell.
|
|
*
|
|
* - `.cmd` files: use `cmd.exe /c` (PowerShell can't run .cmd directly)
|
|
* - `.ps1` files: use `powershell.exe`
|
|
* - `.exe` files: spawned directly without shell
|
|
* - Others: use `cmd.exe /c` as safe default
|
|
*
|
|
* Also captures stderr to the debug file — when Claude Code crashes early,
|
|
* the debug file may be empty but stderr often contains the root cause.
|
|
*/
|
|
export function buildSpawnClaudeCodeProcess() {
|
|
if (process.platform !== 'win32') return undefined
|
|
return (options: { command: string; args: string[]; cwd?: string; env: Record<string, string | undefined>; signal: AbortSignal }) => {
|
|
const cmd = options.command
|
|
const isPowerShell = cmd.endsWith('.ps1')
|
|
|
|
let child
|
|
if (isPowerShell) {
|
|
// For .ps1 scripts, invoke via PowerShell
|
|
const psArgs = ['-ExecutionPolicy', 'Bypass', '-File', cmd, ...options.args]
|
|
child = spawn('powershell.exe', psArgs, {
|
|
cwd: options.cwd,
|
|
env: options.env as NodeJS.ProcessEnv,
|
|
signal: options.signal,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
})
|
|
} else if (cmd.endsWith('.exe')) {
|
|
// .exe files can be spawned directly without shell
|
|
child = spawn(cmd, options.args, {
|
|
cwd: options.cwd,
|
|
env: options.env as NodeJS.ProcessEnv,
|
|
signal: options.signal,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
})
|
|
} else {
|
|
// For .cmd or extensionless binaries, use shell
|
|
child = spawn(cmd, options.args, {
|
|
cwd: options.cwd,
|
|
env: options.env as NodeJS.ProcessEnv,
|
|
signal: options.signal,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
shell: true,
|
|
windowsHide: true,
|
|
})
|
|
}
|
|
|
|
// Capture stderr to debug file — helps diagnose crashes where the process
|
|
// exits before writing anything to the debug log
|
|
const stderrChunks: Buffer[] = []
|
|
child.stderr?.on('data', (chunk: Buffer) => { stderrChunks.push(chunk) })
|
|
child.on('exit', (code) => {
|
|
if (code !== 0 && stderrChunks.length > 0) {
|
|
const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim()
|
|
if (stderr) {
|
|
const debugPath = getClaudeAgentDebugFilePath()
|
|
if (debugPath) {
|
|
try {
|
|
appendFileSync(debugPath, `\n[stderr exit=${code}] ${stderr}\n`)
|
|
} catch { /* best effort */ }
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
return child
|
|
}
|
|
}
|