mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
V0.7.0 (#95)
* fix(agent): fix race condition causing 'no text output' on successful tool calls
The done event arrives via SSE before the executor's .then() callback
updates toolCallBlock status from 'running' to 'done'. Checking
status === 'done' at that moment finds nothing, showing the wrong message.
Fix: check if any tool calls exist (length > 0) instead of their status.
If the agent made tool calls and finished normally, the work was done.
* fix(ai): normalize built-in provider base urls
* fix(agent): preserve thinking steps in done handler, don't show empty fallback
The done handler called updateLastMessage(accumulated) which overwrote
the previously displayed thinking steps. Now:
- If tool calls + no text: show "Design generated successfully" with thinking prefix
- If thinking + no text + no tools: keep thinking steps, no fallback message
- If nothing at all: show "Agent completed with no text output"
* chore(agent): add diagnostic logging for agent event stream
* feat(agent): update submodule with tool_use SSE parsing support
* fix(agent): handle tool_use events in zigEventToSSE, suppress incomplete content_block_start
Two issues:
1. content_block_start with tool_name was forwarded as tool_call with
empty args {} (partial_json not yet accumulated). Client dispatched
generate_design with no prompt → error.
2. The complete tool_use event from Zig engine was not handled by
zigEventToSSE → mapped to 'unknown' type → client ignored it.
Fix: add tool_use branch to zigEventToSSE (maps to tool_call with full
args), suppress content_block_start tool calls (return empty string).
* feat(agent): make team mode mandatory when concurrency >= 2, force parallel designers
* feat(ai): add coding plan provider presets
* fix(ai): sync provider preset URL normalization from upstream
Includes canonicalize helpers, legacy URL migration, and region support
for Zhipu, GLM Coding, Kimi, Bailian, StepFun presets.
* test(ai): add preset URL chat/completions endpoint verification
Validates all 13 openai-compat provider presets produce correct
final URLs when combined with agent-native's /chat/completions suffix.
* feat(ai): extend RoleDefaults type with fill, stroke, effects
* feat(ai): add visual defaults to role definitions (fill, stroke, shadow)
* feat(ai): add hexLuminance and hasFill helpers for visual post-pass
* chore(agent): bump agent-native submodule to 2a57ee9
* feat(ai): add button foreground contrast post-pass fix
* feat(ai): add section background alternation post-pass fix
* feat(ai): add orphan container contrast post-pass fix
* feat(ai): add input sibling fill/stroke consistency post-pass fix
* feat(ai): enrich agent tool instructions with design quality brief
Also fix test mock for canvas-text-measure and TS cast in button
foreground contrast.
* feat(ai): infer semantic role from node name for models that don't set role
Weak models (e.g. minimax) often output nodes without the `role` property,
causing all visual defaults to be skipped. This adds name-based inference
that maps common node names (Button, Card, Search, Nav, etc.) to semantic
roles before applying defaults.
* feat(ai): intercept raw design JSON from agent and re-route through orchestrator
Some models (e.g. minimax-m2.5) ignore tool-calling instructions and output
design JSON directly as text instead of calling generate_design. This bypasses
the orchestrator pipeline (spatial decomposition, sub-agents, role resolution).
When the agent stream ends without any tool activity but the accumulated text
contains design JSON, extract the user's original prompt and re-generate
through generateDesign() for proper quality.
* feat(store): add panelWidth, panelHeight, isMaximized to AI store
* fix(ai): detect mobile intent and force 375x812 canvas in orchestrator re-route
When the agent intercept re-routes through generateDesign(), detect mobile
keywords (mobile, 手机, 移动端, app, ios, android, etc.) in the user prompt
and force canvas size to 375x812 instead of using the viewport size.
* fix(ai): clean up inline nodes before orchestrator re-route
When feedText() already inserted nodes during agent text streaming,
remove them before calling generateDesign() to avoid duplicate designs
on canvas.
* refactor(ai): remove feedText() from agent text streaming
Agent text output should never insert nodes inline — if the agent outputs
design JSON as text, the done handler re-routes through generateDesign().
Removes feedText() call and the now-unnecessary cleanup code.
* i18n: add keys for chat panel redesign, file menu, and unsaved dialog
* feat(ai): redesign quick actions to 2x2 card grid with descriptions
* feat(store): add recent files persistence utility
* feat(editor): add UnsavedChangesDialog component
* refactor(ai): simplify agent prompt and remove orchestrator re-route
Shorten AGENT_TOOL_INSTRUCTIONS to 4 clear rules that weak models can
follow: use tools, never output JSON, summarize after tool calls, add
style direction. Remove the complex interceptor that re-called
generateDesign() when the agent output raw JSON — the goal is to make
the agent reliably call the tool in the first place.
* feat(ai): move send/attach buttons into textarea, simplify bottom bar
* feat(editor): add FileMenu dropdown component with recent files
* fix(ai): strengthen generate_design result to prevent double calls
The tool result now explicitly tells the agent "Do NOT call
generate_design again — the task is done" to prevent weak models
from retrying after a successful generation.
* feat(ai): upgrade chat panel to freely resizable window with maximize
* fix(ai): retry sub-agent once on socket close or empty response
When a sub-agent stream fails (e.g. provider socket closed unexpectedly),
retry the subtask once before throwing. This handles transient connection
issues with providers like ark/volces that occasionally drop SSE streams.
* feat(editor): integrate file menu dropdown and recent files into top bar
* fix(ai): move send/attach buttons back to bottom bar with cleaner layout
* feat(editor): replace window.confirm with UnsavedChangesDialog
* refactor(editor): move file menu to left toolbar dropdown, clean up top bar
* fix(editor): add chevron indicator to file menu button
* feat(electron): add Save As, Open Recent to native File menu with recent files sync
* style(editor): redesign UnsavedChangesDialog with icon, glow ring, and stacked layout
* style(editor): redesign unsaved dialog with dark glass morphism and animated glow border
* fix(ai): keep SSE ping alive during builtin provider streaming
streamViaBuiltin() cleared the ping timer on the first SSE event,
leaving no keep-alive for the rest of the stream. If the provider
paused for >10s (thinking, slow generation), Bun's idle timeout
killed the connection. The ping timer is already cleared in the
finally block — removing the premature clearInterval.
* fix(editor): add light/dark theme support to UnsavedChangesDialog
* fix(ai): remove conflicting design knowledge from agent system prompt
The agent prompt combined AGENT_TOOL_INSTRUCTIONS ("FORBIDDEN: do not
output JSON") with pen-ai-skills design knowledge ("you MUST output
JSON"). These contradictory instructions caused weak models to ignore
tool calling. Agent mode now only gets tool instructions — design
knowledge is loaded by the orchestrator sub-agents.
* style(editor): refine unsaved dialog with staggered entry, grain texture, and breathing glow
* fix(ai): abort builtin engine after 60s of no SSE events
When a provider returns 200 OK but never sends SSE data, the Zig
readChunk blocks forever. Add a 60s timer that calls abortEngine()
to unblock the Zig thread, allowing the stream to end gracefully
and the orchestrator to retry or fail fast.
* fix(ai): skip orchestrator planning API for builtin providers
Builtin providers (minimax, etc.) consistently return empty responses
for the long planning system prompt. Skip the planning API call and
use the heuristic fallback plan directly — same result without the
60s timeout wait and wasted API call.
* feat(ai): add plan_layout + batch_insert tools for builtin provider design
For builtin providers (API key direct), the agent now uses plan_layout
and batch_insert tools instead of generate_design. This avoids the
orchestrator's multi-step API calls that unreliable providers can't
handle.
Flow:
1. Agent calls plan_layout(prompt) → creates root frame, returns sections
2. Agent generates PenNode JSON itself
3. Agent calls batch_insert(parentId, nodes) → inserts with role defaults
No extra API calls — the agent's single LLM connection handles everything.
CLI providers still use generate_design → orchestrator pipeline.
* fix(ai): remove design knowledge from builtin agent prompt to prevent JSON output
* fix(ai): prevent duplicate plan_layout calls with layoutCreated guard
* fix(ai): builtin generate_design creates layout frame instead of orchestrator
For builtin providers, generate_design now delegates to plan_layout
(creates root frame) and returns the layout info to the agent. The
agent then generates PenNode JSON and calls batch_insert — no
orchestrator, no extra API calls. CLI providers still use the full
orchestrator pipeline.
* fix(ai): minimize generate_design tool result to reduce token usage
* fix(ai): limit builtin agent to 5 turns to prevent token overflow
* fix(ai): keep long-running sse streams alive
* fix(ai): stop duplicate builtin design runs
* fix(ai): restore typecheck after rebase
* fix(ai): align builtin single-agent tooling
* fix(ai): guard builtin layout loops
* feat(ai): builtin agent uses full generate_design pipeline with streaming
- Remove normalizeKnownMalformedOpenAICompatURL — URL version detection
now handled in agent-native Zig layer
- Builtin agent uses same generate_design orchestrator pipeline as CLI
(StreamingDesignRenderer, breathing glow, skills)
- Fix streamViaBuiltin event parsing — was checking evt.type instead of
evt.stream_event.type, silently dropping all text/thinking events
- Add max_output_tokens/max_context_tokens passthrough in agent endpoint
- Fix tool completion status — add .then() handler to update tool block
to 'done' after execution (was stuck on 'running' forever)
- plan_layout reuses existing root frame via getActivePageChildren()
- batch_insert: breathing glow with random agent identity, streaming
insertion (3-node batches), max 9 nodes per call
- Mobile section element hints prevent Header/Main Content duplication
- Default background #FFFFFF, suppress thinking text in progress steps
- maxTurns unified to 20 for all providers
* fix(agent): lower default max_output_tokens to 16384
Update agent-native submodule — 200K default caused 400 errors on
providers with lower token limits (Volcengine, MiniMax, etc.).
* fix(ai): restore generate_design tool for builtin agent
Remote merge introduced getBuiltinLeadToolDefs() which filtered out
generate_design, reverting builtin agents to plan_layout+batch_insert.
Use getDesignToolDefs() for all agents to ensure the full orchestrator
pipeline is used.
* fix(ai): prevent duplicate content in mobile fallback plan and restore builtin planning
- Use single subtask for mobile fallback plan instead of Header/Main Content
split that caused both sub-agents to generate overlapping UI elements
- Remove login-screen-specific element hints that misled food/general app generation
- Always attempt AI planning for builtin providers (with 30s/60s fast timeout)
instead of unconditionally skipping — callOrchestrator already has internal
fallback for unparseable responses
- Respect user abort during planning — check abortSignal after streaming ends
to prevent fallback plan from continuing canvas mutations
* fix(ai): clean up root frames when sub-agent execution fails
Remove root frames created in Phase 2 before re-throwing, so the
canvas doesn't strand empty frames when orchestration fails after
canvas setup.
* fix(ai): only remove empty root frames on sub-agent failure
Check whether root frames have children before removing them, so
partial output from earlier successful subtasks is preserved.
* fix(ai): detect scaffold-only frames in failure cleanup
Snapshot descendant count after Phase 2 setup and compare on failure.
Only remove root frames whose descendant count hasn't grown beyond
scaffold nodes (status bar, dashboard columns), preserving frames
that received real sub-agent content.
* fix(ai): restore default frame instead of deleting it on failure
When the root frame replaced the default empty frame (DEFAULT_FRAME_ID)
and sub-agents fail, restore it to its empty state rather than removing
it, so the canvas always retains a usable frame.
* fix(ai): fully replace default frame on failure instead of shallow merge
Remove and re-add the default frame so stale layout, gap, fill, and
scaffold children from the failed generation run are fully cleared.
* style(editor): upgrade provider form with shadcn Select, always-visible API format toggle
* fix(editor): hide API format toggle for Anthropic and OpenAI presets
* feat(editor): auto-switch baseURL on API format change, make baseURL always editable
Add dual-format URLs for providers that support both OpenAI and Anthropic:
- OpenRouter: /api/v1 (OpenAI) / /api (Anthropic)
- DeepSeek: /v1 (OpenAI) / /anthropic (Anthropic)
- MiniMax: /v1 (OpenAI) / /anthropic (Anthropic)
* style(editor): redesign provider form with compact layout, field icons, and segmented controls
* fix(editor): lock baseURL for Anthropic and OpenAI presets
* fix(editor): only show API format toggle for providers that support both formats
* refactor(editor): use presetConfig.altType to determine API format toggle visibility
* feat(editor): add Anthropic format support for Zhipu (open.bigmodel.cn/api/anthropic)
* feat(editor): add Anthropic format support for GLM Coding Plan
* feat(editor): add Anthropic format support for Kimi (api.moonshot.cn/anthropic)
* feat(editor): add Anthropic format support for Bailian/DashScope
* feat(editor): add Anthropic format support for DouBao and Ark Coding Plan
* feat(editor): add Anthropic format support for ModelScope
* feat(editor): add Bailian Coding Plan preset with dedicated endpoint and Anthropic support
* feat(editor): add altRegions for region-aware Anthropic URL switching
Providers with regions now correctly switch altBaseURL by region:
- MiniMax: minimaxi.com / minimax.io
- Zhipu: bigmodel.cn / z.ai
- GLM Coding: bigmodel.cn / z.ai
- Kimi: moonshot.cn / moonshot.ai
- Bailian: aliyuncs.com / dashscope-intl
- Bailian Coding: coding.dashscope / coding-intl.dashscope
* fix(ai): remove hardcoded blue fill from button role defaults
Button role was injecting #2563EB fill on any frame inferred as a
button, including container frames named "Button Group" or similar.
This caused mysterious blue backgrounds on non-blue themed designs.
- Remove fill from all three button role branches
- Add word-boundary matching for button name inference
- Add container-suffix exclusion to prevent "Button Group" etc.
from being inferred as button role
* feat(types): add codegen types and wire DTO types to pen-types
Migrate canonical codegen type definitions (Framework, PlannedChunk,
CodePlanFromAI, ExecutableChunk, CodeExecutionPlan, ChunkContract,
ChunkResult, CodeGenProgress, ContractValidationResult) from
pen-codegen to pen-types to establish pen-types as the authoritative
source before pen-codegen package removal.
Add new wire DTO types for MCP/CLI responses:
- NodeSnapshot: depth-limited node snapshot for wire transfer
- ExecutableChunkPayload: hydrated chunk payload with truncated nodes
- ResolvedDepContract: dependency contract with null support
Add comprehensive type tests covering FRAMEWORKS constant, NodeSnapshot
variants, and ResolvedDepContract nullability.
* feat(core): add sanitizeName and nodeTreeToSummary utilities
Migrate utility functions from pen-codegen to pen-core for broader reuse:
- sanitizeName: converts strings to PascalCase
- nodeTreeToSummary: recursively renders node trees for AI prompts
Add comprehensive tests for both utilities.
* feat(mcp): migrate validateContract from pen-codegen
Move the validateContract function into pen-mcp to support the chunked
codegen pipeline. The types (ChunkResult, ContractValidationResult) are
already re-exported from @zseven-w/pen-types (Task 1 completed).
- Create packages/pen-mcp/src/utils/validate-contract.ts
- Add re-export to packages/pen-mcp/src/index.ts
- Add 4 test cases validating PascalCase, code presence, and SFC detection
* feat(mcp): implement read_nodes handler with depth-limited subtree fetching
Adds handleReadNodes() in pen-mcp that supports depth=-1 (full tree),
depth=0 (node only), and depth=N (N levels of children), reusing the
existing readNodeWithDepth utility. Includes 7 Vitest tests.
* feat(editor): implement App-side codegen plan state store
Adds in-memory plan store with createPlan, getPlan, submitChunkResult,
assemblePlan, and cleanPlan. Validates duplicate chunkIds, empty nodeIds,
unknown/circular dependencies, and missing nodes. Hydrates ExecutableChunkPayload
with NodeSnapshot[] and resolved dep contracts. TTL-based expiry (30m).
* fix(codegen): export PlanState interface and narrow statusOverride type
- Export PlanState interface for public API (getPlan returns PlanState | undefined)
- Narrow submitChunkResult statusOverride parameter from ChunkStatus to 'failed' | 'skipped'
(only these two values have defined semantics in the validation logic)
* feat(mcp): add HTTP endpoints for read_nodes and incremental codegen pipeline
Adds five Nitro route handlers under /api/mcp/:
- POST read-nodes: returns node snapshots with depth-limited children
- POST codegen/plan: validates and stores a CodePlanFromAI, returns execution plan
- POST codegen/submit: submits a ChunkResult and returns next ready chunk
- GET codegen/assemble/[id]: assembles final output from completed chunks
- DELETE codegen/plan/[id]: cleans up plan state early
All routes support live canvas (getSyncDocument) and offline file (openDocument)
modes. Adds @zseven-w/pen-mcp as a workspace dependency of apps/web.
* refactor(mcp,web): dedup readNodeWithDepth and validateContract imports
Move readNodeWithDepth export from inline copy in read-nodes.post.ts to
pen-mcp for DRY, and use pen-mcp's validateContract in codegen-plan-store
instead of maintaining duplicate implementation.
- Export readNodeWithDepth from pen-mcp/utils/node-operations
- Remove inline readNodeWithDepth from apps/web/server/api/mcp/read-nodes.post.ts
- Add type cast (as unknown as NodeSnapshot) for pen-mcp readNodeWithDepth result
- Replace validateContractFn with import from pen-mcp in codegen-plan-store.ts
All 36 tests pass (4 validate-contract + 7 read-nodes + 11 plan-store + 14 security)
* feat(mcp): add codegen HTTP client handlers
Implement four thin HTTP client wrappers for codegen pipeline:
- codegen_plan: POST /api/mcp/codegen/plan — initialize plan
- codegen_submit_chunk: POST /api/mcp/codegen/submit — submit chunk result
- codegen_assemble: GET /api/mcp/codegen/assemble/:planId — assemble code
- codegen_clean: DELETE /api/mcp/codegen/plan/:planId — cleanup state
All handlers use await getSyncUrl() to discover running App, throw on missing sync
URL (except codegen_clean which gracefully returns ok). Uses encodeURIComponent for
URL path parameters. No business logic, purely HTTP clients for App endpoint integration.
* feat(mcp): register codegen tools in server and replace export_nodes
Add codegen-routes.ts with definitions and dispatcher for all 5 new tools
(read_nodes, codegen_plan, codegen_submit_chunk, codegen_assemble,
codegen_clean). Replace EXPORT_* wiring in server.ts with CODEGEN_* and
update pen-mcp public API in index.ts to export the new handlers instead
of handleExportNodes.
* feat(cli): add read-nodes and codegen pipeline commands
Add op read-nodes to query document nodes via the App HTTP API, and
op codegen:plan/submit/assemble/clean commands to drive the incremental
codegen pipeline. All five commands are stateless HTTP clients against
the /api/mcp/* endpoints. Also update help text to document the new
commands.
* refactor(codegen): migrate all pen-codegen consumer imports (Task 12)
Replace all @zseven-w/pen-codegen imports in consumer files with their
canonical new homes: types → pen-types, sanitizeName/nodeTreeToSummary →
pen-core, validateContract → pen-mcp, generator functions → pen-sdk
(which retains one pen-codegen re-export until Task 13 deletes the package).
- code-panel.tsx: Framework, CodeGenProgress, ChunkStatus, FRAMEWORKS → pen-types
- code-generation-pipeline.ts: types → pen-types, sanitizeName → pen-core, validateContract → pen-mcp
- codegen-prompts.ts: types → pen-types, nodeTreeToSummary → pen-core
- code-generation-pipeline.test.ts: CodePlanFromAI, PlannedChunk → pen-types
- design-engine.ts: remove pen-codegen import + delete generateCode() method
- design-engine.test.ts: delete generateCode test
- pen-sdk/index.ts: codegen re-export split into types (pen-types) + functions (pen-codegen)
- apps/web/src/services/codegen/*.ts: delete all 9 generator proxy files
- apps/cli: migrate export.ts to pen-sdk; add pen-sdk dependency, drop pen-codegen
* fix(cli,sdk): fix Task 12 spec violations — delete export.ts, remove pen-codegen from pen-sdk
- Delete apps/cli/src/commands/export.ts (replaced by op read-nodes + op codegen:*)
- Remove `case 'export':` dispatcher and help text from apps/cli/src/index.ts
- Remove @zseven-w/pen-sdk dep from apps/cli/package.json (only used by deleted export.ts)
- Remove generator function re-exports from pen-sdk/src/index.ts (codegen section deleted)
- Remove @zseven-w/pen-codegen dep from pen-sdk/package.json
pen-sdk now has zero imports from @zseven-w/pen-codegen, unblocking Task 13.
* chore(codegen): remove pen-codegen package entirely
All consumers have been migrated to pen-types (types) and pen-core
(utility functions) in Tasks 1-12. Delete packages/pen-codegen/ and
remove the workspace dependency from apps/web and pen-engine.
* chore(mcp): remove pen-codegen from CI, scripts, desktop, and delete old export tool files
Cleans up all remaining references to the deleted pen-codegen package from
esbuild alias flags (mcp:compile, cli:compile, desktop dev.ts), publish scripts,
and the GitHub Actions publish workflow. Deletes the superseded export-routes.ts
and export-nodes.ts MCP files that were replaced by codegen-routes.ts in Task 10.
* fix(ai): inline validateContract in code-generation-pipeline to avoid pen-mcp browser import
Importing validateContract from @zseven-w/pen-mcp pulled in document-manager
transitively, which uses node:fs/promises and broke Vite's browser bundle:
"Module node:fs/promises has been externalized for browser compatibility".
Inline the 11-line pure function instead — the original plan listed inlining
as an acceptable option for this client-side consumer.
* fix(panels): force builtin provider in code panel for builtin: models
modelGroups reports 'anthropic' or 'openai' as the provider for builtin
providers (based on bp.type), but streamChat needs 'builtin' to route
the request through streamViaBuiltin on the server. Without this, models
like minimax were sent to streamViaAgentSDK (Claude Code) which rejected
them with "There's an issue with the selected model".
Mirror the same override that ai-chat-handlers.ts already applies, so the
code panel uses the same provider routing as the chat panel.
* fix(mcp): fall back to IPv6 for live sync url discovery
Vite 6+ resolves `localhost` to `::1` only on macOS, leaving IPv4
127.0.0.1 unreachable. The MCP document-manager previously only probed
IPv4 hosts, so every batch_get / open_document / get_variables call
failed with "fetch failed" even though the dev server was running. Add
[::1] to SYNC_BASE_URLS and race all probes in parallel via Promise.any
so the first reachable host wins immediately instead of serially waiting
for IPv4 timeouts.
* fix(ai): harden post-streaming pipeline against layout and phone mockup bugs
Weaker sub-agents (MiniMax M2, GLM, Kimi) were producing two classes of
structural failures that the post-streaming passes did not handle:
1. Missing layout on container frames — children collapsed to (0,0)
because computeLayoutPositions skips frames with no explicit layout.
2. Fake "Phone Mockup" wrappers — the sub-agent pasted phone bezel
properties (cornerRadius 32, width 260, black fill, horizontal layout)
onto its own root frame and stuffed an entire duplicated section tree
inside, compressing everything into a 40px-wide column.
The root cause for #2 was that `jsonl-format.md` (loaded by every
generation sub-agent) and a line in `orchestrator-sub-agent.ts` both
injected the phone-mockup spec unconditionally. On mobile flows where
the whole design IS already a phone screen, this prompt fragment has no
legitimate use and confuses the model.
Fixes:
- Add normalizeTreeLayout in pen-core: writes explicit `layout` for
frames missing it (prefers inferLayout, falls back to `vertical`),
strips stale x/y from auto-layout children. Skips the fallback when
any non-overlay child carries explicit x/y — preserves intentional
absolute positioning (phone mockup internals, hero overlays).
- Add unwrapFakePhoneMockups in pen-core: detects fake phone bezels by
name or visual signature (cornerRadius 28-40 + width 240-320) and
either promotes children to the parent (unwrap) or strips the fake
visuals if the fake wrapper is the root itself (sanitize).
- Remove the phone mockup line from jsonl-format.md; gate the in-code
version behind a subtask-label keyword guard.
- Add an explicit NO-PHONE-MOCKUP-WRAPPER instruction for mobile
sub-agents so M2 doesn't self-wrap.
- Fix stale-reference bug in applyPostStreamingTreeHeuristics: after
resolveTreePostPass calls updateNode, the original rootNode reference
is detached; refetch via getNodeById before running normalizeTreeLayout.
- Fix the same bug inside resolveTreePostPass itself: every internal
updateNode call was followed by direct mutations on a `root` parameter
that Zustand had already replaced. Track `currentRoot` and refresh it
after each updateNode.
- Force a canvas resync after the mutation passes via forcePageResync:
in-place mutations on store-owned nodes do not publish; without this
the canvas stays stale on sub-agent retry paths that skip the implicit
updateNode flush. Use forcePageResync (not a hand-rolled shallow doc
spread) because canvas-document-sync subscribes to active page
children identity, not doc identity.
All correctness guarantees are covered by unit tests in pen-core
(normalize-tree-layout.test.ts 13 cases, unwrap-fake-phone-mockup.test.ts
16 cases).
* fix(ai): require cover ratio for icon fallback fuzzy matching
The icon path resolver matched "heart" inside "heartRate-waveform" via
findSubstringFallback and replaced the waveform geometry with the lucide
heart icon, leaving the real waveform invisible. The root cause was that
both prefix and substring fallbacks accepted any matched key of >= 4
characters, regardless of how little of the name it actually covered.
- findPrefixFallback now requires the matched key to cover at least 50%
of the normalized name. "arrowdowncircle" -> "arrowdown" (60%) still
works; "heartratewaveform" -> "heart" (29%) is rejected.
- findSubstringFallback is now suffix-match only (not "anywhere"), with
the same 50% ratio guard. "badgecheck" -> "check" still resolves, but
"starheartbadge" -> "heart" no longer hijacks an unrelated compound
name.
Legitimate chart icons (chart, barchart, analytics, activity, trendingup)
still resolve via exact lookup and are unaffected by the stricter fallback.
* fix(ai): drop redundant section-level fills from sub-agent output
Sub-agents (MiniMax M2 especially) hedge by hardcoding a "safe dark" hex
(#0A0A0A, #111, etc.) on every section root they emit. That fill then
completely covers the page root's intended background color —
#1a1a2e in the health-tracker case — breaking theme switching and
creating visible seams between sections.
Two-layer fix:
1. Prompt guardrail in orchestrator-sub-agent.ts — tell the sub-agent NOT
to set `fill` on its section root, and show the actual inherited
background color so the model has no reason to hedge.
2. Post-pass cleanup stripRedundantSectionFills in pen-core — for each
direct child of the page root frame, drop the fill if:
- the child has no role or a structural role (section/row/column/
stack/container/hero/footer/cta-section/etc.), AND
- the fill matches the root fill exactly OR is one of the common
"safe dark" hexes sub-agents reach for.
Cards, buttons, chips, badges, inputs, phone mockups, status bars,
banners and other protected roles are never touched. Unknown roles
are also preserved (conservative default).
The pass is wired into both applyPostStreamingTreeHeuristics (streaming
path, runs on the outer parent frame via getParentOf so each sub-agent's
section root is visible at the strip scope) and sanitizeNodesForInsert/
Upsert (batch path). All mutations are covered by the existing
forcePageResync call so the canvas re-renders without a stale frame.
* fix(ai): narrow section-fill strip scope to the true page root
Follow-up on b4d35bb. The previous wiring ran
stripRedundantSectionFills in two places it should never have touched:
- applyPostStreamingTreeHeuristics called it on both `parentOfRoot` AND
`freshRoot`. When a sub-agent emits an inner section (e.g. bottomNav-root),
freshRoot is that inner section — NOT the page root — and its direct
children are components, not page sections. The strip pass then
misidentified those components as structural sections and could erase
their intended fills.
- sanitizeNodesForInsert/sanitizeNodesForUpsert called it on every cloned
node from batch/MCP APIs. Those nodes can be anything (a card, a button,
a component, a page), so the path cannot guarantee the node is a page
root. Stripping there risks wiping a card's own dark header fill, etc.
Fixes:
- In applyPostStreamingTreeHeuristics, pick exactly ONE target: use
parentOfRoot if it exists (the sub-agent's root was inserted as a
child of a page frame), otherwise use freshRoot (the sub-agent's root
IS the page frame via replaceEmptyFrame). Never both.
- Remove the strip call from sanitizeNodesForInsert/Upsert entirely and
explain in a comment why it would be unsafe there.
- Document the scope contract on the function itself: "callers MUST only
pass the true page root frame."
- Add a regression test `is strictly non-recursive: never touches
grandchildren even when caller mis-targets a card` to bound the blast
radius if a future caller ever mis-targets the function.
* fix(ai): restore section-fill strip coverage for non-streaming apply paths
Follow-up on 320ba10. Narrowing the scope of stripRedundantSectionFills
correctly took it out of sanitizeNodesForInsert/Upsert (which operate on
arbitrary cloned nodes that may be cards or components). But the batch
and non-streaming apply paths that DO land on the page root —
applyNodesToCanvas, upsertNodesToCanvas, upsertPreparedNodes,
extractAndApplyDesignModification — were left without any strip
coverage, silently regressing MCP batch_design page inserts and any
other non-streaming generation flow back to the original "section root
hardcoded #0A0A0A covers up #1a1a2e page background" bug.
Fix:
- Add a finalizePageRootAfterApply() helper that reads DEFAULT_FRAME_ID
from the store (the canonical page root), runs
stripRedundantSectionFills on it, and forcePageResync-s when any fill
was actually stripped.
- Call it at the end of every non-streaming apply path, including the
replaceEmptyFrame branches so a single-frame batch insert is covered.
- No-op when no page root exists yet (safe on first insert).
This preserves the card/component safety of 320ba10 while restoring
strip coverage for legitimate full-page applies.
* fix(ai): make section-fill cleanup page-aware for non-first pages
Follow-up on 36eb7c0. finalizePageRootAfterApply looked up the page
root via getNodeById(DEFAULT_FRAME_ID), which only works for Page 1 —
every subsequent page created via addPage() gets a fresh nanoid for its
root frame, so the lookup returned undefined and the cleanup was
silently a no-op on pages 2..N.
Rewrite the helper to read the active page's top-level children via
getActivePageChildren(doc, activePageId) and run
stripRedundantSectionFills on each top-level frame. Publishes once if
anything changed. Handles multi-root pages (comparison mockups, etc.)
by iterating. Still a no-op when the active page has no frame children
yet, which is the safe first-insert behavior.
* fix(ai): make full apply pipeline page-aware via primary-frame lookup
Follow-up on 8d0a481. Fixing section-fill cleanup for Page 2+ was not
enough — the whole non-streaming apply pipeline still assumed the
page root frame was always DEFAULT_FRAME_ID. addPage() gives every
page after the first a fresh nanoid for its initial frame, so on
Page 2+:
- applyNodesToCanvas / upsertNodesToCanvas / upsertPreparedNodes /
extractAndApplyDesignModification were looking up
getNodeById(DEFAULT_FRAME_ID) to decide the parent for inserts;
that lookup returned undefined, so parentId fell through to null
and new nodes got attached to the page top level as siblings of
the existing root frame instead of being parented inside it —
structurally wrong for multi-page documents.
- isCanvasOnlyEmptyFrame() also queried DEFAULT_FRAME_ID, so it
always returned false on Page 2+ and the replace-empty-frame fast
path never fired.
- replaceEmptyFrame() hardcoded DEFAULT_FRAME_ID as the update
target, so calling it on Page 2+ would silently mutate Page 1's
root frame instead of the page the user was actually editing.
- generationRootFrameId was initialized to DEFAULT_FRAME_ID and
reset back to it in resetGenerationRemapping(), baking the same
Page 1 assumption into the streaming path.
Introduce a small `getActivePagePrimaryFrameId()` helper that reads
getActivePageChildren() and returns the id of the first top-level
frame on whatever page is currently active. Replace every misuse of
DEFAULT_FRAME_ID as "the current page root" with this helper:
- isCanvasOnlyEmptyFrame() now inspects the active page's first
top-level frame directly (no id lookup at all).
- replaceEmptyFrame() looks up the target via
getActivePagePrimaryFrameId(), updates that frame, and returns the
target id so streaming can track it as the generation root.
- apply/upsert parentId lookups use the helper.
- resetGenerationRemapping() seeds generationRootFrameId with the
helper result, falling back to DEFAULT_FRAME_ID only for the
degenerate "active page has no frame yet" case.
- insertStreamingNode's replace-empty-frame branch threads
replaceEmptyFrame's returned id into generationRootFrameId.
Remaining DEFAULT_FRAME_ID references are all legitimate: the import,
init fallbacks, and explanatory comments tracking remapping history.
* fix(ai): stop reading streaming generationRootFrameId from non-streaming paths
Follow-up on d284fdc. The non-streaming apply paths
(applyNodesToCanvas, upsertNodesToCanvas, animateNodesToCanvas) each
ended with
const rootId = getGenerationRootFrameId();
if (rootId) scanAndFillImages(rootId).catch(() => {});
generationRootFrameId is module-level state owned by the STREAMING
path. In a non-streaming apply it is either the module init value
(DEFAULT_FRAME_ID) or leftover state from a previous streaming
generation — neither is guaranteed to exist on the currently-active
page. On Page 2+ specifically, this pointed at nothing on the active
page and scanAndFillImages would walk a stale/nonexistent subtree.
Likewise, adjustRootFrameHeightToContent() fell back to
generationRootFrameId when called without an explicit frameId — and
that is exactly how all three non-streaming apply paths call it.
Switch both call sites to getActivePagePrimaryFrameId() so the
non-streaming post-processing always targets the frame on the page
the user is actually editing:
- applyNodesToCanvas (both branches)
- upsertNodesToCanvas
- animateNodesToCanvas
- adjustRootFrameHeightToContent (fallback chain)
expandRootFrameHeight is left unchanged — it is only called from the
streaming path, where generationRootFrameId is actively maintained by
insertStreamingNode / replaceEmptyFrame.
* fix(ai): stop icon resolver and role inference from hijacking data-viz paths
The M2.7 health-tracker retry exposed two related regressions where the
post-processing passes were overreaching on descriptive path and frame
names:
1. applyIconPathResolution was running on every `path` node and trying
to match its name against ICON_PATH_MAP. Descriptive geometry names
share substrings with icon keys and got hijacked:
- "Heart Rate Chart" → lucide:circle (isIconLikeName fallback)
- "Chart Fill" → lucide:bar-chart-2 (prefix "chart" 55%)
- "Steps Progress" → lucide:circle (fallback)
- "Calories Progress" → lucide:circle (fallback)
- "Distance Progress" → lucide:circle (fallback)
The resolver also injected a fake `d` and left stroke.color="none",
so the progress arcs disappeared entirely and the rings visual
collapsed into a single opaque black disc (three concentric dark
tracks showing through).
Fix: the `path` type is for custom geometry; `icon_font` is the
canonical icon type. Only run icon resolution on a path whose name
carries an explicit icon/logo/symbol/glyph marker. Uses camelCase-
aware word splitting so "SearchIcon" / "MenuIcon" / "search-icon" /
"BrandLogo" still qualify, while "Heart Rate Chart" and friends do
not.
2. role-resolver name pattern /\bcard\b/i fired on "Card Header" and
applied the card role defaults (white fill, shadow, cornerRadius).
The "Card Header" node then rendered as a solid white strip across
the top of the activity rings card.
Fix: add a ROLE_PART_WORDS guard (header/body/footer/title/content/
image/action/etc.) alongside the existing CONTAINER_SUFFIXES guard.
A "Card Header" is a PIECE of a card, not a card — it must not
inherit role=card defaults. Also tighten stat-card, pricing-card,
feature-card, and card to honour the same skip-containers flag.
Verified with an inline script covering 41 cases (20 icon guard, 21
role inference), including camelCase, kebab, snake, and space variants
for legitimate icons plus all the failing descriptive names from the
retry dump.
* fix(ai): make role part-word guard position-sensitive
Follow-up on 5e2e6f9. The ROLE_PART_WORDS guard I added rejected any
name containing a part word anywhere, which misfired on "Icon Button":
- button pattern matches
- skipContainers=true
- ROLE_PART_WORDS.test("icon button") is true (icon is a part word)
- continue → fall through to /\bicon\b/ → returns 'icon' ❌
The intent of the guard is to catch "Card Header" / "Button Label" —
structural pieces named "<role> <part>". But "Icon Button" means "a
button OF THE ICON VARIETY", not "an icon inside a button". Word order
matters: part words are only piece-markers when they appear AFTER the
role word.
Switch the loop from pattern.test to pattern.exec so we know where the
role keyword sits, then only apply ROLE_PART_WORDS against the
substring AFTER the match. "Card Header" still skips (header after
card). "Icon Button" correctly returns 'button' (no part word after
button). Symmetric cases also work out: "Button Icon" falls through
card/button, hits the icon pattern and returns 'icon', which is the
right answer for "an icon frame inside a button context".
Inline coverage extended to 30 role cases including Icon/Primary/
Submit/Text/Image/Media modifier variants on both button and card.
* fix(ai): scope role part-word guard to the FIRST word after role keyword
Follow-up on d45f1a5. The previous pass switched to a suffix scan so
"Icon Button" (part word before role) kept its button role, but the
suffix scan still used a substring regex — it fired on any part word
anywhere in the tail. That misclassified realistic prepositional
variants:
"Card with Icon" → after="with icon", 'icon' matched → skipped
→ fell through to /\bicon\b/ → role='icon' ❌
"Button with Image" → similar → role='icon' ❌
"Card with Header" → skipped → role=null ❌
These names describe the role AS A WHOLE ("a card that has an icon",
"a button with an image"), not a piece of it, and should keep their
role.
Switch the guard to examine only the FIRST word token of the suffix:
"Card Header" → first="header" → part → skip ✓
"Card with Icon" → first="with" → keep ✓
"Button with Label"→ first="with" → keep ✓
"Card - Header" → first="header" → part → skip ✓ (punct stripped)
"Icon Button" → suffix empty → keep ✓
Store ROLE_PART_WORDS as a Set (with a small firstWordToken helper that
uses /\w+/ to skip leading whitespace and punctuation) instead of a
regex, so the semantics are unambiguous: "is the first word of the
suffix one of these exact tokens".
Inline coverage extended to 38 role cases, adding 6 prepositional
variants (with icon / with image / with header / with label) for both
button and card, plus two punctuation cases (Card - Header, Card: Body).
* fix(web): resolve React dynamically in test setup instead of hardcoded path
apps/web/src/__tests__/setup-react.ts loaded the native CJS React
via a hardcoded absolute path pointing at a previous developer's home
directory (/Users/kayshen/Workspace/ZSeven-W/openpencil/node_modules/
.bun/react@19.2.4/node_modules/react). That path exists nowhere
except on the original machine, so every apps/web vitest run outside
that machine failed immediately with "Cannot find module" before any
test could load — silently disabling the entire web test suite for
everybody else on the team.
Swap the hardcoded path for a plain `require('react')`, which resolves
through node's own module lookup and finds whatever copy of React is
actually installed. Everything else in the setup file (the
ReactSharedInternals proxy bridge that lets hook-using code share a
single dispatcher with react-dom) is unchanged — we just stopped
insisting on a machine-specific path to find React.
With this fix the apps/web test suite actually runs again. Verified
role-resolver.test.ts and icon-resolver.test.ts both execute without
the ResolveMessage error.
* test(ai): committed regression suite for role inference + icon marker fixes
Codex flagged that the recent role-resolver / icon-resolver heuristic
fixes (commits 5e2e6f9, d45f1a5, f842853) shipped without committed
regression coverage — validation happened only in a throwaway inline
script under /tmp/test-icon-role-fixes.mjs that would never run again.
If a future refactor reverted those fixes, nothing in the repo would
catch it.
Lift every case from the inline script into committed vitest tests:
role-resolver.test.ts — 35 new cases in the "name-based role
inference" describe block:
* 14 cases for "Card <part>" structural pieces (Card Header / Body /
Footer / Title / Content / Image / Media / Label / Action /
Actions / Meta / Caption / Description / Wrapper) — none may
resolve to role=card or inherit the white default fill+shadow.
* 3 punctuation cases ("Card - Header", "Card: Body", "Card /
Footer") — first-word scan must still skip punctuation to find
the part word.
* 9 modifier-before-role cases (Icon Button, Primary Button,
Submit Button, Text Button, Image Card, Icon Card, Media Card,
User Card, Product Card) — must still become button / card
because the modifier sits BEFORE the role word, not after.
* 7 prepositional variants ("Card with Icon", "Button with Image",
etc.) — first-word-after-match guard must not trip on part words
separated by "with".
* 2 fall-through cases ("Card Icon", "Button Icon") — card /
button skip via part word, the subsequent /\bicon\b/ pattern
takes over and role=icon.
icon-resolver.test.ts (new file) — 20 cases:
* 12 data-viz geometry names that must NOT be resolved
("Heart Rate Chart", "Heart Rate Waveform", "Steps/Calories/
Distance Progress", "Chart Fill", "Bar Chart", "Line Chart",
"Sparkline", "Activity Curve", "Custom Illustration",
"Decorative Shape"). For each, verifies the original d and
undefined iconId are preserved.
* 5 legitimate icon names in every separator convention
("Search Icon", "SearchIcon", "search-icon", "search_icon",
"Chart Icon") — all still resolve.
* 1 unknown-name-with-marker case ("Brand Logo") — still
recognized as an icon and overwritten (the placeholder + async
queue path).
* 1 bare geometry name ("Bar Chart") — no marker, left alone.
* 1 non-path node type — no-op.
Both files run under the now-fixed apps/web vitest env (see the
prior commit that unhardcoded the React resolution path). Total
suite: apps/web role-resolver 73 tests, icon-resolver 20 tests,
both green.
* fix(ai): make firstWordToken skip numeric indices and 1-char tokens
"Card 1 Content" / "Card 2 Header" / "Button 3 Label" — sub-agents
routinely stick a numeric index between the role word and the part
word. The /\w+/ token scan I was using grabbed the numeric "1" as the
first suffix token, decided it was not a ROLE_PART_WORD, and happily
returned role=card on the wrapper. The card default fill (#FFFFFF)
and shadow then covered the title text inside, which is why the
Upcoming workout cards in the latest retry showed no "Morning Run"
or "Yoga Session" labels — just the white card background overlay.
Switch the regex to /[a-z]{2,}/i so the scan skips any non-alpha
token (digits, punctuation) and any single-letter fragment, landing
on the first real word. "Card 1 Content" now exposes "content" as
the first token → part word → skipped, role not inferred. "Icon
Button" still keeps its button role (empty suffix after the match).
Added 7 numeric-index regression cases to role-resolver.test.ts.
Total file is now 80 committed cases, all green.
* fix(ai): normalize sub-agent stroke/fill schema violations
Dumping the latest M2.7 retry showed that the activity rings and
heart-rate chart line were all invisible, even though the AI had
generated reasonable geometry. The root cause was three overlapping
schema violations that the renderer silently dropped:
1. `stroke` as an array — the AI wraps it in [] as if it were `fill`.
`stroke.thickness` is then undefined, canvas gives up, and the
stroke is never drawn. Every activity ring had this.
2. Fill-shaped stroke — the inner value is { type: 'solid', color }
instead of { thickness, fill }. `strokeWidth` is written as a
top-level node field in CSS/SVG style rather than as
`stroke.thickness`. This is what the heart-rate chart line had.
3. CSS keyword colors in fill — `fill: [{type: 'solid', color: 'none'}]`
or `'transparent'`. Neither is valid PenFill; CanvasKit ignores them
or falls back to an unspecified default. The 8-digit transparent
hex `#00000000` is fine and is explicitly kept.
Add a new pen-core pass `normalizeStrokeFillSchema` that recursively
repairs all three in place:
- Unwrap array-wrapped strokes.
- Migrate fill-shaped stroke objects to proper PenStroke, pulling any
top-level `strokeWidth` into `stroke.thickness` (default 2 if missing),
moving the color into `stroke.fill[0]`, and deleting the stray
strokeWidth field.
- Drop `{color: "none"}` and `{color: "transparent"}` fill entries from
both `fill` and `stroke.fill`. If the resulting stroke has no fill
left, remove the whole stroke.
Covered by 13 pen-core unit tests including two end-to-end repros of
the M2.7 activity-ring and heart-rate-chart-line failures, plus
defensive tests for valid hex fills, 8-digit transparent hex,
object-shaped (non-array) strokes, and recursion into children.
Wired in at the start of applyPostStreamingTreeHeuristics (streaming
path) and sanitizeNodesForInsert/sanitizeNodesForUpsert (batch path),
so every apply route sees a clean schema before role resolution and
layout passes touch anything.
* fix(ai): preserve explicit no-fill intent when normalizing CSS keyword fills
Follow-up on 62b24bd. When every entry in a node's fill array was an
illegal CSS keyword ("none" / "transparent"), normalizeNodeFill was
calling `delete rec.fill` to remove the field. That was wrong:
rec.fill = [{type:'solid', color:'none'}]
↓ normalize ↓
rec.fill = undefined (field deleted)
↓ canvas render ↓
DEFAULT_FILL gray (canvas-object-factory fallback)
But the AI's intent was the opposite — "no fill, transparent shape" —
which is critical for things like activity rings (hollow interior with
only a stroke) and chart line backgrounds. Deleting the field silently
flipped those from transparent to opaque gray.
Replace the delete path with an explicit #00000000 transparent hex so
the renderer sees the intent and honours it. Only applies when EVERY
original entry was illegal; mixed arrays (one illegal, one legal) drop
the illegal ones and keep the real colors untouched.
stroke handling is unchanged — a stroke whose fill array is empty
really is meaningless (nothing to draw) and is still removed entirely.
Updated the M2.7 heart-rate chart line end-to-end regression test to
assert the transparent hex instead of the old "undefined or empty"
contract, plus a new mixed-array test covering the partial-cleanup
path. pen-core suite: 189 committed tests, all green.
Also cleaned up a handful of stale TypeScript casts in the test file
(PenNode['stroke'] / Partial<PenNode> annotations that no longer
compile since PenNode is a tagged union where only some variants
carry stroke). Test helpers now accept an untyped Record<string,
unknown> bag — appropriate for a normalizer test suite whose inputs
are intentionally schema violations.
* fix(ai): split fill normalization by shape vs foreground node type
Follow-up on 239f635. The transparent-hex replacement for illegal CSS
keyword fills (none / transparent) was applied uniformly to every node
type, but text.fill and icon_font.fill are FOREGROUND colors, not
shape backgrounds:
text.fill = [{color: "none"}] → AI meant "default text color"
↓ previous normalizer ↓
text.fill = [{color: "#00000000"}] → invisible text
↓ canvas render ↓
text is transparent — content lost
Split the behavior by node type:
FOREGROUND types (text, icon_font)
fill is the foreground color. An illegal "none" almost certainly
means "I forgot to pick a color" — delete the field so downstream
layers (role defaults, button contrast heuristic, style inheritance)
can populate something visible. Setting it transparent here would
freeze the content invisible.
SHAPE types (frame, rectangle, ellipse, path, group, ...)
fill is a background. "no fill" is a valid hollow-shape intent
(activity rings, chart line backdrops). Keep the explicit
#00000000 replacement so canvas-object-factory does not fall back
to the default gray.
Added 6 TDD cases to normalize-stroke-fill-schema.test.ts:
- text with sole "none" fill → fill deleted
- text with sole "transparent" fill → fill deleted
- icon_font with "none" → fill deleted
- text with legitimate hex → unchanged
- text with mixed [none, #00FF00] → drops illegal, keeps #00FF00
- sanity guard across frame/ellipse/path → still gets #00000000
pen-core suite: 195 committed tests, all green.
* fix(ai): hasFill must return false for transparent fills so contrast can paint
Follow-up on ef92774. Splitting the stroke/fill schema normalizer by
node type resolved the text/icon_font case, but left a third broken
scenario Codex caught immediately: a path node used as an icon inside
a button.
The chain was:
1. AI emits stroke-style line icon:
type=path, fill=[{color:"none"}], stroke={thickness:2} (no stroke color)
2. normalizeStrokeFillSchema replaces "none" with the explicit
transparent hex:
fill=[{color:"#00000000"}]
This is correct for activity rings / chart lines — deleting the
field would let the renderer fall back to the default gray.
3. fixButtonForegroundContrast walks the button children:
if (hasFill(child)) skip ← hasFill sees the transparent entry
else if stroke exists but has no fill → paint stroke
else if no stroke → paint fill
The naive hasFill counted the transparent entry as "has fill" and
took the skip branch, so the button icon never got a visible
color and rendered invisible on canvas.
The right fix is in hasFill itself — the function's semantic contract
is "does this node have a VISIBLE fill?", and a fully-transparent fill
(either CSS keyword "transparent"/"none", or any 8-digit hex with 00
alpha) draws nothing. Widen the invisibility check so every downstream
heuristic that relies on hasFill gets the same truthful answer without
each call site having to guard itself.
Added 5 unit tests for hasFill (#00000000, generic 00-alpha hex,
"transparent" keyword, "none" keyword, plus a sanity case showing
partially-transparent #FF000080 still counts as visible) and 2
end-to-end tests for resolveTreePostPass's button contrast pass
against path icons whose fill is #00000000:
- stroke-style icon (stroke without color) → stroke color assigned
- fill-style icon (no stroke) → fill color assigned
apps/web role-resolver: 87 committed tests, all green.
* fix(ai): split hasFill into two predicates so transparent fills are respected
Follow-up on b7639da. Widening hasFill to treat transparent fills as
"no fill" fixed the button foreground contrast case but broke the
opposite group of callers — heuristics that rely on hasFill for
overwrite PROTECTION:
- fixOrphanContainerContrast: wrapped "if hasFill(node) return" around
the white-fill-and-shadow assignment. With the new hasFill, any
card whose author chose fill=#00000000 would lose its transparency
and get painted white with drop shadows.
- fixSectionAlternation: filtered sections by \!hasFill to decide which
ones get the alternating background. Explicit transparent sections
would wrongly enter that pool and get repainted.
- fixInputSiblingConsistency: filtered "inputs with fill" — a
transparent input would be silently excluded from sibling
consistency alignment.
These two use-cases have opposing needs:
overwrite-protection callers
"Has the AI declared ANY fill entry, visible or not? If so, don't
touch it — transparent is a deliberate choice too."
contrast-paint callers
"Will this draw a visible color? If not, I need to paint one in."
Split the predicate in two:
hasFill(node)
Restored to the original semantics — any non-empty `fill` array
counts. Used by fixOrphanContainerContrast, fixSectionAlternation,
fixInputSiblingConsistency, and the `fixButtonForegroundContrast`
parent guard is UPGRADED to hasVisibleFill below (a transparent
button has no bg to compute contrast against anyway).
hasVisibleFill(node) [new]
Rejects fully-transparent solid fills (#00000000, any #RRGGBB00,
"transparent", "none"). Used by fixButtonForegroundContrast to
decide "does this child need a color painted in?" and on the
button parent itself to skip transparent buttons.
Rewired fixButtonForegroundContrast to hasVisibleFill at all check
sites (parent + three child branches). All other callers keep the
original hasFill and are unchanged.
Regression coverage:
- hasFill: added explicit "returns TRUE for transparent hex / CSS
keyword" tests so a future "helpful" refactor can't re-widen it.
- hasVisibleFill: 8 new tests covering every transparent form and a
partially-transparent sanity case (#FF000080 → true).
- fixOrphanContainerContrast: new case asserting a transparent card
keeps its fill and gains no shadow.
- fixSectionAlternation: new case asserting a transparent hero
section survives alternation unchanged.
apps/web role-resolver suite is now 94 committed tests, all green.
* fix(ai): hasVisibleFill respects the PenFill.opacity field
Follow-up on e37fb2e. hasVisibleFill only looked at the solid fill's
color string and missed the PenFill.opacity field entirely. A fill
like {type:'solid', color:'#FFFFFF', opacity:0} — or a linear gradient
with {opacity:0} — still reported as visible, so the button foreground
contrast pass would incorrectly skip painting a readable color onto a
child whose fill is 100% transparent via the opacity channel.
Extract a shared isFillInvisible helper and run the opacity check
BEFORE the type-specific color check, so the rule applies uniformly
across every PenFill variant (solid, linear_gradient, radial_gradient,
image). Negative opacity is treated defensively as zero.
hasFill stays untouched — opacity=0 is still a deliberate author
declaration, and the overwrite-protection callers
(fixOrphanContainerContrast, fixSectionAlternation,
fixInputSiblingConsistency) must keep respecting it.
Tests added to role-resolver.test.ts:
hasVisibleFill:
- solid with opacity: 0 → false
- linear_gradient with opacity: 0 → false
- solid with opacity: -0.5 → false (negative treated as zero)
- solid with opacity: 0.5 → true (non-zero partial transparency)
- solid with no opacity field → true (default opaque)
hasFill:
- opacity-0 fill → true (deliberate author choice, protect it)
resolveTreePostPass — transparent fill overwrite protection:
- fixOrphanContainerContrast does NOT overwrite a card whose fill
is {color:'#FFFFFF', opacity:0}
apps/web role-resolver: 101 committed tests, all green.
* feat(editor): add global PNG/JPEG/WEBP/PDF canvas export
Replaces the post-Fabric.js export stubs with a working CanvasKit-based
pipeline that can export both individual layers and the entire document
to PNG / JPEG / WEBP / PDF, no external dependencies.
Why:
- Per-layer Export button in the property panel was a no-op stub.
- Global export dialog was a no-op stub.
- Save As (Cmd+Shift+S) was silently overwriting in-place because it
shared a single handler with Cmd+S.
How:
- New global-export utility renders any page through MakeSWCanvasSurface
+ canvas.toDataURL — most reliable cross-build path. PDF is built
inline as a minimal raster PDF (one JPEG per design page via
/DCTDecode), no library required.
- Per-layer export reuses the same SkiaEngine pipeline against the
selected node's subtree.
- Export dialog rewritten with PNG/JPEG/WEBP/PDF + scale options and a
multi-page hint when format=PDF on multi-page docs.
- File menu entry "Export image..." in both web FileMenu and Electron
native menu. Electron entry uses registerAccelerator: false so the
hint shows without OS-level interception (the keystroke is handled
by the renderer's window keydown listener, which avoids HMR/IPC
fragility).
- exportDialogOpen lifted into canvas-store so menus, shortcuts, and
the dialog itself agree on a single source of truth.
- Cmd+Shift+S now explicitly takes the save-as branch (file picker)
instead of falling through to in-place save.
- New i18n keys (fileMenu.exportImage, export.pdfMultiPage) added to
all 15 locales.
* fix(ai): set ELECTRON_RUN_AS_NODE environment variable for CopilotClient in connect-agent
Updated the CopilotClient initialization in connect-agent.ts to include the ELECTRON_RUN_AS_NODE environment variable. This change ensures compatibility when running the SDK in Electron, allowing it to handle the .js entry correctly without rejecting it as a stray argument.
* fix(editor): make Cmd+Shift+E export shortcut bullet-proof
Why: Cmd+Shift+E was still not opening the export dialog for the user
even after registerAccelerator:false on the Electron menu entry. The
single shared bubble-phase window listener can be silenced by any
upstream stopPropagation, by an Electron menu accelerator that's still
registered (if main process wasn't restarted), or by a layout/IME state
that mutates e.key.
How:
- Pull Cmd+Shift+E out of the shared window keydown handler and give it
its own dedicated listener.
- Register on `document` in capture phase so it fires before every other
JS keydown handler in the page.
- Match on `e.code === 'KeyE'` (layout/IME-independent) with a fallback
to `e.key.toLowerCase() === 'e'`.
- Call setExportDialogOpen(true) instead of toggle, so even if the
Electron IPC path also fires the dialog still ends up open instead
of cancelling itself out.
- stopImmediatePropagation() to prevent the bubble-phase handler from
also reacting.
* feat(store): add typed documentEvents emitter for save signal
* fix(editor): rebind global export shortcut to Cmd+Shift+P
Cmd+Shift+E was being silently swallowed at the OS level (likely a
macOS Chinese IME or third-party tool) before any JS keydown handler
could fire — confirmed by the user reporting that even a capture-phase
document listener produced zero log output.
P (mnemonic for Print/PDF/Picture) is unused everywhere else in the
codebase and is not intercepted by common IMEs. Updated the renderer's
capture-phase listener (KeyP), the Electron native menu accelerator
hint, and the web file-menu shortcut display.
* refactor(store): use Partial<> in documentEvents to drop cast
* test(store): cover documentEvents emitter behavior
* feat(store): add consolidated save() and saveAs() actions
* refactor(store): make save() fallback to saveAs() explicit
* test(store): cover save()/saveAs() paths and saved-event emission
* refactor(editor): top-bar save callbacks delegate to store.save()/saveAs()
* chore(agent): init agent-native submodule and zig in CI
The packages/agent-native submodule was not being checked out in CI,
causing `bun install --frozen-lockfile` to fail on the nested
packages/agent-native/napi workspace. Enable submodule checkout in all
relevant workflows and install Zig 0.14.0 so the postinstall hook can
build the NAPI addon from source. The Dockerfile also pulls the whole
submodule into the builder stage and installs Zig there.
* refactor(hooks): use-edit-shortcuts Cmd+S delegates to store.save()
* refactor(hooks): electron menu save actions delegate to store.save()
* refactor(shared): save-dialog handleSave delegates to store.saveAs()
* fix(electron): disable reload and devtools in packaged builds
End users should not be able to reload the window or open DevTools from
a shipped app. Hide the View menu items, disable DevTools at the
webPreferences level, and swallow Chromium's built-in keyboard chords
(Cmd/Ctrl+R, Cmd/Ctrl+Shift+R, F5, F12, Cmd/Ctrl+Shift+I/J/C,
Cmd+Opt+I) via before-input-event. Dev mode is unaffected.
* chore(electron): exclude dev.ts from desktop tsconfig
dev.ts is a Bun-only orchestrator that uses ESM import.meta.dirname and
imports from packages/pen-ai-skills/. It's run directly via `bun run`
and never emitted to out/desktop/, so the CJS module + rootDir
constraints of the electron main/preload bundle don't apply. Excluding
it makes `tsc --noEmit -p apps/desktop/tsconfig.json` pass clean.
* chore(panels): drop unused getBuiltinLeadToolDefs import
It was imported alongside getDesignToolDefs but never referenced —
TS6133 unused-import warning.
* chore: add .prettierignore for submodule and generated files
oxfmt picks up .prettierignore alongside .gitignore. Skip the
packages/agent-native git submodule (owned by a separate repo) and the
auto-generated apps/web/src/routeTree.gen.ts so they don't trip
`bun run format:check` in CI. Submodule files showed up only after CI
started initializing submodules.
* style: apply oxfmt across the repo
Pure formatting pass — no semantic changes. Files had drifted from
oxfmt's settings (printWidth 100, single quotes, semis) over time and
`bun run format:check` was failing in CI. Generated by `bun run format`.
* chore: ignore .omx local directory
* feat(pen-core): scaffold merge module skeleton
* feat(pen-core): define merge module type contracts
* feat(pen-core): add merge-helpers indexing and equality utilities
* fix(ai): honor newRoot in plan_layout instead of reusing existing frame
handlePlanLayout had a guard that rejected a second plan_layout call
unless `newRoot: true` was passed, but the create-vs-reuse branch below
the guard always picked up the first frame on the page — so even with
`newRoot: true` the call returned the same rootFrameId and never added a
new frame. Skip the existingFrame lookup when newRoot is set.
The accompanying test was failing in CI for a related reason: the
beforeEach used createEmptyDocument(), which already ships a default
root-frame, so every "creates a root frame on the first plan_layout
call" assertion was actually walking the reuse branch. Reset the active
page's children in beforeEach so the tests exercise the code paths
their names describe.
* fix(sdk): mock pen-engine via package path in design-canvas test
The DesignCanvas test was pinning a vi.mock to an absolute filesystem
path rooted at the original author's machine
(/Users/kayshen/Workspace/...). On any other checkout — including CI —
the path didn't resolve, the mock silently became a no-op, the real
attachCanvas ran, and CanvasKit tried to fetch /canvaskit/canvaskit.wasm
under jsdom and aborted. Mock the package sub-path export
'@zseven-w/pen-engine/browser' that DesignCanvas actually imports from.
* test(pen-core): cover merge-helpers indexing and equality
* feat(pen-core): implement diffDocuments two-way diff
* test(pen-core): cover diffDocuments add/remove/modify/move
* style(pen-core): apply oxfmt to merge module files
The new merge module files (merge-helpers, merge-helpers.test, node-diff)
landed without going through `bun run format`, breaking format:check in
CI. Pure oxfmt output, no semantic changes.
* feat(pen-core): implement merge node classification and 7-grid
* feat(pen-core): implement merge tree rebuild from decisions
Document NodeDecision invariants (Task 8) and implement rebuildDocument
(Task 9): groups decisions by (pageId, parentId), walks ours' page
scaffold, and recursively assembles the merged tree sorted by (index, id).
* chore(hooks): auto-fix format drift via bun run format
Replace the staged-file `oxfmt --check` invocation with the same scripts
CI uses (`bun run format:check` / `bun run format`). On a check failure
the hook now runs the formatter, re-stages the originally staged files,
and continues — so new files added without going through the formatter
no longer slip past local commits and surface as CI red. lint stays
fail-loud and is unchanged.
* feat(pen-core): implement merge for variables, themes, scalar fields, pages-order
* chore: bump workspace version to 0.6.1
Auto-generated by the .githooks/pre-commit version-sync step from the
v0.6.1 branch name.
* test(pen-core): cover mergeDocuments 7-grid + reparent + doc-fields
* feat(pen-core): re-export merge module from package root
* feat: add Git integration and update dependencies
- Introduced Git API types and methods in the desktop app for enhanced version control functionality.
- Updated package versions in bun.lock and package.json, including isomorphic-git and sshpk.
- Adjusted Electron API to include Git-related methods.
- Modified desktop app's main and preload scripts to support Git operations.
- Added GitButton to the web app's top bar for user interaction with Git features.
- Updated TypeScript configuration to accommodate new Git-related paths.
* feat(ai): pure diagnostics layer with WCAG-aware detectors and severity contracts
Extract design pre-validation logic from apps/web into a reusable pure-
function diagnostics layer in @zseven-w/pen-ai-skills, then layer the
quality fixes that came out of debugging real LLM-generated dark theme
designs on top.
New module: packages/pen-ai-skills/src/diagnostics/
types.ts — Issue type with category / severity / property /
suggested fix shape
detectors.ts — 4 pure detector functions:
detectInvisibleContainers
detectEmptyPaths
detectTextExplicitHeights
detectSiblingInconsistencies
plus detectAllIssues orchestrator with
nodeId+property dedup
index.ts — barrel re-export
apps/web/src/services/ai/design-pre-validation.ts is now a thin wrapper
that calls detectAllIssues() against the live document store and applies
warning-severity issues only. The pre-validation pipeline is the only
side-effecting layer; the detectors are completely pure so MCP debug
tools and tests can reuse them safely.
Detector quality fixes baked into the initial implementation:
detectInvisibleContainers
- Uses WCAG relative-luminance contrast ratio (default threshold
1.10) instead of max RGB channel diff. Channel diff treats
#FAFAFA-vs-#F1F1F1 (light, invisible) the same as #111111-vs-
#1A1A1A (dark, distinguishable to a dark-adapted eye); WCAG ratio
is luminance-based and gets both regimes right.
- Dark-on-dark issues (parent luminance < 0.1) are downgraded to
'info' severity so the suggested #E2E8F0 light-gray border is
never silently auto-applied to a dark theme card. Pre-validation
skips info severity; debug tools still surface the issue.
detectSiblingInconsistencies
- Two-pass design with property-specific grouping:
* STRICT pass groups by ${type}:${role || '__none__'} and
checks role-dependent properties (height, fontSize). Same-role
siblings get the tightest consistency check; chrome elements
like a tab-bar (role=bottom-tab-bar) never compare against
sections (no role) because they're in different groups.
* LOOSE pass groups by ${type} only and checks role-independent
design-system tokens (cornerRadius). Catches outliers in
singleton-role compositions (web landing pages where every
section has a unique role and the strict pass would skip
every group as <3 members).
- LOOSE pass issues emit at 'info' severity (detect-only, not
auto-fixable) because cross-role comparison can match a
structurally distinct sibling like a rounded chrome element.
- Divider/spacer roles are skipped entirely from both passes.
Pre-validation
- applyFixes() now skips 'info' severity and protected status-bar
removals.
- runPreValidationFixes() returns the count of fixes ACTUALLY
applied, not the count detected.
Test coverage: 26 diagnostics unit tests (in pen-ai-skills) + 7
end-to-end pre-validation behavior tests (in apps/web).
* feat(mcp): add debug tools — validation_report, logs_tail, screenshot — behind feature flag
External LLMs (Claude Code, Codex, Gemini CLI) need an autonomous debug
loop for pen-ai-skills design generation: read canvas state, run
detectors, capture screenshots, tail logs — all without asking the
user to take screenshots and describe issues by hand. This batch adds
all three debug tools, isolated from the main MCP surface by the
OPENPENCIL_DEBUG_TOOLS=1 env flag so they don't pollute normal
generation flows.
debug_validation_report
Calls detectAllIssues() (from the new diagnostics layer) on either
the live canvas or a .pen file, returns the issue list grouped by
category. Pure read — no side effects, no auto-fixes. Lets the
debug loop see exactly what the pre-validation pipeline would
flag.
debug_logs_tail
Tails the Nitro server log at ~/.openpencil/logs/server-YYYY-MM-DD.log
with API keys and Authorization headers redacted. Supports grep
filter and sinceMs cutoff. Runs from the MCP process so it works
even when the web app is only accessed via the headless dev
server.
debug_screenshot
Returns an MCP image content block (PNG) of either the full
document or a specific node bbox, captured from the live renderer.
The image flows back through the SSE RPC channel built in the
next batch (Phase 2 SkiaEngine readback), but the tool wiring
lives here so the route surface is in one place.
Supporting infrastructure:
- Shared log-utils with sensitive-value redaction (Authorization,
Bearer tokens, x-api-key headers, ?key= query params). Migrated
apps/web/server/api/ai/chat.ts off its inline copy to use the
shared module — single source of truth for redaction patterns.
- batch_get gains a `resolve_refs` option that recursively walks
children and resolves $variable refs via resolveNodeForCanvas, so
debug callers can see the concrete values Skia actually receives
instead of raw $color-1 strings.
- debug-routes module is registered conditionally by server.ts only
when process.env.OPENPENCIL_DEBUG_TOOLS === '1'. Without the flag,
the debug tools simply do not appear in the tool list — zero
surface area for normal users.
- handleToolCall return type widened from Promise<string> to
Promise<string | ToolContent[]> so debug_screenshot can return an
image content block instead of a stringified payload.
Test coverage: 4 new test files (log-utils, node-operations resolveRefs,
debug_validation_report, debug_logs_tail).
* feat(canvas): SkiaEngine readback + SSE RPC for live debug screenshots
The MCP debug_screenshot tool needs an actual image, not the empty
buffer the design-screenshot.ts stub used to return after the Fabric →
Skia migration. This batch builds the full readback path: extend
SkiaEngine with a render-settle algorithm and a region capture API,
then wire a request-response RPC channel from the MCP server back
through the Nitro API to the renderer in the active browser tab.
SkiaEngine.captureRegion(bounds)
Synchronous draw of a node-bbox region into a PNG byte buffer via
surface.makeImageSnapshot(). Bounds are validated up front (numeric
x/y/w/h required, throw with a clear message otherwise) so callers
can't accidentally feed in declared sizing strings like
"fill_container".
SkiaEngine.waitForSettled(timeoutMs)
Resolves when two consecutive sync render passes both leave dirty=
false AND every pending font/image load has resolved. Uses a sync
loop driven by an explicit `dirty=false; this.render()` per pass
rather than RAF, because background tabs throttle RAF and the old
implementation hung the 5s timeout when the tab wasn't focused.
Concurrent-capture safety: a `_captureRefcount` integer (not boolean)
suppresses the agent-overlay self-loop in render() during capture so
the dirty flag can actually settle. Refcount lets parallel captures
share the suppression without one of them prematurely re-enabling
the loop.
Pending-load gating relies on the new pendingCount/flushPending APIs
on SkiaImageLoader and SkiaFontManager — without these the capture
could fire mid-load and produce a half-rendered PNG.
apps/web/src/services/ai/design-screenshot.ts
Replaces the post-Fabric stub with a real implementation that
delegates to SkiaEngine.captureRegion. design-validation.ts now
routes screenshot capture through the same path.
SSE request-response RPC (Nitro ↔ renderer):
server/utils/mcp-screenshot-rpc.ts
Pending-request map keyed by request id; resolves on POST to
/api/mcp/screenshot-response.
server/api/mcp/screenshot.post.ts
POST endpoint the MCP debug_screenshot tool calls. Allocates a
request id, sends a `screenshot:request` SSE event to the active
client, awaits the response on the pending map, returns the PNG.
server/api/mcp/screenshot-response.post.ts
The renderer's reply endpoint — resolves the corresponding pending
promise.
server/utils/mcp-sync-state.ts + active-ping endpoint
Track lastActiveClientId via per-client focus/visibility ping so
the MCP server can target the actually-active browser tab even
when several clients are connected. selection.post.ts also
forwards sourceClientId so the active tracker stays warm.
src/hooks/use-mcp-sync.ts
Renderer-side: handles the `screenshot:request` SSE event by
looking up the node bbox via engine.spatialIndex.get(nodeId) for
computed pixel coordinates (not declared sizing), calling
captureRegion, and posting the PNG back. Adds focus/visibility
listeners that fire active-pings.
Bug fixes baked into the initial implementation (caught by Codex
review while iterating):
- Background-tab RAF suspension would deadlock waitForSettled —
fixed by sync render loop.
- Agent overlay markDirty self-loop prevented settle during capture
— fixed by _captureRefcount suppression.
- handleScreenshotRequest read declared sizing strings as bounds —
fixed to use spatialIndex computed pixel values.
- captureRegion silently returned full frame on bad bounds — fixed
to throw fail-loud, with validation hoisted before refcount/try
block so the test path actually exercises the throw.
Test coverage: 4 new test files (font-manager + image-loader pending
APIs, skia-engine capture, mcp-screenshot-rpc, mcp-sync-state active
client tracking).
* fix(core,ai): layout engine and prompt fixes from real-design debugging
Three independent layout problems uncovered while debugging an LLM-
generated fitness app, all rooted in either the engine treating
ambiguous structures the wrong way or the LLM not having clear rules
to follow. Fixed at the engine and prompt level so future generations
don't reproduce them.
normalize-tree all-frame heuristic
Bug: AI emits a layout-less parent containing structured nested-
frame children (a ring + value-wrap composition, a hero + floating
card, a badge stack on top of an avatar). Children have no x/y
because the AI assumed (0,0) would just work. The previous
normalize-tree rule would silently rewrite the parent to layout=
'vertical', stacking the frames into a list and clipping anything
past the parent's bounds.
Fix: when EVERY non-overlay child of a layout-less parent is a
frame (>= 2 such children), treat it as an overlay container and
leave layout undefined so the renderer respects each frame's own
positioning. Mixed-type stacks (frame + rect, frame + text) still
get the vertical fallback because those are typically content
stacks where verticalization is the right call.
Two new tests lock in: all-frame children stay as overlays, and
mixed-type stacks still verticalize.
layout.md ring/circle anti-pattern
Bug: LLM emits `ellipse + sibling text` to represent an Apple-
Activity-Ring style indicator. Ellipse can't have children in the
PenNode schema, so the text ends up stacking above or below the
ring instead of in the center.
Fix: documented the canonical pattern in the layout skill —
`frame(width=80, height=80, cornerRadius=40, stroke={...}, fill=[],
layout='horizontal', alignItems='center', justifyContent='center')`
with the text as a child. Also explicitly forbids the donut-hole
trick (two ellipses, smaller one painted with parent bg color) and
the layout='none' + nested absolute-positioned children attempt,
both of which render unreliably in OpenPencil's flex-layout model.
layout.md horizontal row width math
Bug: LLM emits 3 activity rings at 100×100 with 24px gap inside a
card with 24px padding on a 375px mobile page. Total 348px > 279px
inner card width → the third ring is silently clipped on the right
edge. The layout engine doesn't shrink fixed-px items; it clips.
Fix: added the explicit formula
total = N × item_width + (N − 1) × gap
inner = parent_width − padding_left − padding_right
plus worked examples for 2/3/4-item mobile rows so the LLM has
the numbers ready, plus an explicit anti-pattern referencing the
rings overflow.
layout.md no-fixed-position spacers
Bug: same fitness app ended in a 62px empty "Bottom Spacer" frame
after an inline bottom-tab-bar — the model was reserving space as
if the tab-bar were `position: fixed`. OpenPencil has no
fixed/sticky positioning; the bar is already part of the page
flow.
Fix: documented the anti-pattern with the exact bug shape so the
LLM stops emitting these dead frames.
skills/phases/generation/layout.md budget bumped 1500 → 1700 to fit
the additions.
* fix(ai): theme-aware role defaults so dark pages stop getting white navbars
Root cause of the 'glaring white navbar on a dark fitness app' bug:
the navbar / card / input / divider role defaults in
role-definitions/index.ts hardcoded #FFFFFF (CARD_FILL), #F8FAFC
(INPUT_FILL), #E2E8F0 (INPUT_STROKE / divider). When an LLM generated
a dark theme but omitted the fill on a navbar (because it expected
the dark page bg to show through), applyDefaults() filled in #FFFFFF
and stamped a bright white bar across the top of the design.
Theme detection
resolveTreeRoles() now propagates a `theme: 'dark' | 'light'` value
through RoleContext. Detection reads the page root fill (luminance
< 0.3 = dark) and falls back to 'light' for backward compatibility
with all existing light-theme designs.
detectThemeFromNode is exported with an explicit warning in its
docstring: callers MUST pass the actual page root, NOT whatever
sub-tree the resolver is currently walking. A card or navbar with
no fill of its own would mis-detect to 'light'.
Theme-aware role defaults
navbar / card / stat-card / pricing-card / feature-card /
testimonial / input / form-input / search-bar / table-header /
divider all use new helpers (cardFill, inputFill, inputStroke,
navbarFill, navbarBottomBorder, dividerFill) that return the right
color for the active theme. Dark palette:
card / input fill = #1A1A1A
navbar fill = #111111 (matches page; differentiated by border)
input stroke = #2A2A2A
navbar bottom border = #1F1F1F
divider = #2A2A2A
applyDefaults overwrite-protection still holds — any LLM-supplied
fill is left alone, even when theme-aware defaults would pick
something else.
Live page-root lookup at sanitize time
sanitizeNodesForInsert / sanitizeNodesForUpsert receives an
arbitrary PenNode (a card, a navbar, a sub-section) — NOT the page
root. Calling resolveTreeRoles(node, …) without a theme would
read the sub-tree root's missing fill and fall back to light,
silently re-introducing the white-navbar bug for direct MCP call
paths.
Fix: a new detectActiveDocumentTheme() helper looks up the LIVE
active-page primary frame via getActivePagePrimaryFrameId()
(NOT the cached generationRootFrameId module variable, which is
stale or default for non-streaming applies — same precedent
already exists in upsertNodesToCanvas at line ~464). Both
sanitize functions compute the document theme once per batch and
pass it explicitly through resolveTreeRoles.
Test coverage: 8 new role-resolver tests (light/dark/protection/
fallback/sub-tree-without-fill/explicit-override-beats-auto-detect)
+ 3 new design-canvas-ops-theme end-to-end tests
(upsertNodesToCanvas → store-readback for dark, light, and
overwrite-protection paths). The end-to-end tests deliberately bypass
resetGenerationRemapping() to exercise the direct-MCP call path
where the bug lived.
* feat(editor): improve asset loading and editing workflows (#91)
Co-authored-by: Di <di.yan@mail.polimi.it>
* fix(ai): prevent broad skill matches from bloating prompts
Generation prompts were pulling unrelated form and codegen skills because keyword matching used substring checks and codegen skills were always eligible. Use word-boundary matching for ASCII keywords, narrow form-ui triggers, and gate codegen skills behind the explicit isCodeGen flag so normal design generation keeps focused context.
Constraint: CJK keywords still need substring matching because word boundaries do not apply
Rejected: Keep short keywords like input/email/mobile | they match unrelated prompts and inflate context
Rejected: Load codegen rules for all generation | design JSON generation should not carry code output instructions
Confidence: high
Scope-risk: moderate
Tested: Targeted pen-ai-skills resolver tests before reword
Not-tested: Full skill registry integration against all prompts
* fix(ai): keep mobile planning on model-driven paths longer
MiniMax/basic-tier planning was falling back too quickly and mobile app homepages could pull landing-page planning guidance. Add compact planner retries, style-guide shortlisting, near-miss JSON repair, and sub-agent prompt compaction so focused app screens get a model plan before heuristic fallback.
Constraint: Basic providers can spend long periods thinking before first JSON output
Rejected: Disable fallback entirely | still needed when providers return no usable plan
Rejected: Keep homepage landing-page predesign for mobile apps | it pollutes single-screen app plans
Confidence: high
Scope-risk: moderate
Tested: Targeted planner/sub-agent tests before reword
Not-tested: Live MiniMax end-to-end run after reword
* fix(ai): preserve dark generated UI through sanitize passes
Generated dark mobile screens were being damaged by role defaults and post-pass rewrites: cards could inherit white fills, page-chrome roles could be inferred inside cards, placeholder icons stayed generic, and activity rings could become filled discs. Add theme-aware input-first sanitization, anti-pattern rewrites, icon repair, and ring-fill guards with regression coverage.
Constraint: Streaming and MCP insert paths sanitize arbitrary subtrees before the live page root may be readable
Rejected: Trust LLM output without structural rewrites | repeated provider outputs produced broken rings and chart layouts
Rejected: Paint orphan rounded containers unconditionally | stroked rings are decorative geometry, not cards
Confidence: high
Scope-risk: broad
Tested: Targeted role/icon/sanitizer tests before reword
Not-tested: Visual regression suite across all design archetypes
* fix(renderer): render stroked generated shapes without phantom fills
AI-generated rings and imported stroke data need the core/renderer pipeline to preserve stroke intent. Normalize SVG-style stroke attributes including dash offset, keep composition primitives out of vertical fallback, map unsupported baseline alignment to bottom alignment, and render stroke-only shapes with transparent fallback fills instead of opaque interiors.
Constraint: CanvasKit still requires a paint object even for transparent fill fallback
Rejected: Treat all layout-less rectangles as overlay primitives | list/card stacks rely on vertical fallback
Confidence: high
Scope-risk: moderate
Tested: Targeted core and renderer tests before reword
Not-tested: Full renderer visual snapshot suite
* fix(agent): keep long design runs diagnosable and alive
Long generate_design tool calls can outlive visible SSE activity, so keepalive pings now refresh agent session activity before stale cleanup can drop the pending tool callback. The agent request log also mirrors the effective upstream prompt/tool shape, while the native HTTP client only prints successful request bodies behind an explicit opt-in.
Constraint: External tool execution may block model events for minutes while the UI still needs the session
Rejected: Increase stale-session TTL only | pings already represent an active stream and are a more precise heartbeat
Rejected: Always log successful provider request bodies | leaks prompt and conversation payloads
Confidence: medium
Scope-risk: moderate
Tested: Targeted SSE keepalive test before reword
Not-tested: Full agent-native Zig suite and live provider retry
* test(web): backfill git-store coverage for retry/close/status/require paths
* feat(git): implement directory selection and author identity probe
- Added IPC handler for opening a directory dialog to select Git repository folders.
- Introduced `getSystemAuthor` function to retrieve user name and email from system Git config.
- Updated Electron API to include `openDirectory` method for directory selection.
- Enhanced Git IPC handlers to support author identity retrieval.
- Implemented autosave logic in the Git engine to prevent unnecessary commits when content has not changed.
* feat(editor): keep branch deletion inside the git panel flow
Phase 5 needs the renderer to warn before deleting unmerged branches and to
retry with force after confirmation. The existing IPC surface could only do a
single best-effort delete, so the UI could not implement the approved design
honestly. Also replaces the four hardcoded loadLog({ ref: 'main' }) refreshes
with a currentLogRef() helper so the history list follows the actual current
branch after commit/promote/switch/merge.
Constraint: Phase 5 must not introduce new dependencies
Rejected: Hide delete for unmerged branches | violates the approved UX
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Do not remove branch-unmerged without replacing the confirm-then-force path in the UI
Tested: desktop git iso/engine tests, web git-store tests
Not-tested: manual folder-mode delete flow in Electron
* feat(editor): render the phase 5 branch picker shell
Phase 5 starts by replacing the disabled branch placeholder with a real picker
container and explicit list-mode entry points for create and merge. This keeps
the shell reviewable before the mutating flows are wired in.
Constraint: Conflict state must stay non-interactive until merge resolution ships
Rejected: Keep the placeholder until all branch actions are done | hides shell-level regressions until too late
Confidence: medium
Scope-risk: narrow
Reversibility: clean
Directive: Keep the delete affordance as a real sibling button; do not nest interactive controls inside the branch-row select button
Tested: branch picker shell tests, ready/panel smoke tests
Not-tested: Electron visual polish
* feat(editor): keep branch creation and switching inside the git dropdown
The panel already knew how to create and switch branches in the store, but the
header still rendered a disabled placeholder. Task 3 turns the picker from a
shell into a working create/switch flow: the list mode dispatches switchBranch
directly and closes on save-required so the existing panel save alert takes
over; the create view runs local empty/duplicate validation and then delegates
to the store (which already refreshes branches internally).
Constraint: Phase 5 must preserve the existing save-required gate
Rejected: Auto-switch immediately after create | the approved design only guarantees creation from current HEAD
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep branch action errors local to the picker unless the store must change global state
Tested: branch picker component tests (old + 4 new), ready/panel smoke tests
Not-tested: Electron manual switching with an externally modified repo
* feat(editor): wire delete-confirm and merge flows in the git panel
Phase 5 Task 4 replaces the remaining picker stubs. Delete goes through a
confirm dialog that upgrades to a force-delete retry only when the store
returns branch-unmerged (other errors surface as inline messages without
offering force). Merge uses the existing mergeBranch store action; the
conflict transition is observed via state.kind === 'conflict' and handled
by the picker's single conflict early-return. Also hoists a narrowed
activeRepo alias so nested handlers no longer need a non-null assertion.
Constraint: Force delete must only appear after branch-unmerged
Rejected: Show force delete for any delete error | retrying force on engine-crash cannot help
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Do not add a second conflict-disabled path; the top-level early return is the single source of truth
Tested: branch picker component tests (4 new: delete happy-then-force, delete non-unmerged error, merge happy, merge save-required)
Not-tested: Electron manual merge conflict handoff
* feat(i18n): localize phase 5 branch picker across 15 locales
Adds the 16 git.branch.* keys the picker needs (list/create/merge/delete
flows + noCommits fallback) and removes the orphaned git.header.branchComingSoon
placeholder tooltip key, now that Task 2 replaced the header placeholder with
the real picker.
Constraint: All 15 locales must ship together — no English-only fallback
Rejected: Ship English only and let other locales fall back | inconsistent UX across regions
Confidence: medium
Scope-risk: narrow
Reversibility: clean
Directive: Do not reintroduce branchComingSoon unless the picker stub returns
Tested: targeted vitest suite (iso/engine/store/picker/ready/panel), typecheck
Not-tested: Electron manual walkthrough (deferred to pre-release QA)
* chore: apply oxfmt drift across workspace
Phase 5 surfaced ~90 files with pre-existing oxfmt drift that the pre-commit
hook kept re-fixing. Landing the formatter sweep as a single chore commit so
future phases start from a consistent baseline.
* feat(desktop): expose origin remote get/set IPC for the clone wizard
Add a renderer-visible remote metadata/config contract: remoteGet reads
.git/config only and remoteSet upserts or removes the single 'origin'
remote via writeRemoteOrigin in git-iso. The wizard in Phase 6a uses the
returned GitRemoteInfo to update renderer state in a single round-trip.
* feat(store): add clone wizard state machine and remote metadata actions
Phase 6a's renderer surface: extend RepoMeta with cached origin metadata,
expose enterCloneWizard / cancelCloneWizard / refreshRemote / setRemoteUrl,
and route recoverable clone errors back into wizard-clone with an inline
error field so the form can keep its state on retry. setRemoteUrl mutates
state immediately from the IPC return value to avoid stale UI after save.
* feat(editor): ship the Phase 6a clone wizard form and empty-state entry
Replace the wizard-clone placeholder with GitPanelCloneForm: URL field,
destination picker via electronAPI.openDirectory, auth-mode inference,
anonymous-clone support for HTTPS, and an inline error block for the
recoverable failure codes the store leaves in wizard state. Re-enable
the clone card on the empty-state and translate every new key into the
15 locales.
* fix(store): keep clone wizard mounted across the clone round-trip
Previously cloneRepo unconditionally transitioned to `initializing`,
which unmounted GitPanelCloneForm and wiped the user's URL/dest/token
inputs. On a recoverable failure (8 codes in CLONE_INLINE_ERROR_CODES)
the user saw the inline error banner over an empty form and had to
re-type everything — defeating the whole point of inline retry.
Fix: when cloneRepo is launched from inside the wizard, stay in
`wizard-clone` and flip a new `busy` flag instead of teleporting
through `initializing`. The form now reads `busy` from the store
(local useState removed, so the stale-component `setBusy(false)` in
`finally` disappears too). CLI-driven clones still go through
`initializing` because no form is mounted.
Also reclaims headroom on git-store.ts (at the 800-LoC cap) by
extracting a `dropSaveRequired` helper for the duplicated
`saveRequiredFor` destructure in `cancelSaveRequired` and
`retrySaveRequired`.
Tests:
- New regression test asserts URL/dest/username/token inputs still
hold the typed values after a recoverable auth-failed failure.
- New store test asserts cloneRepo from the wizard never transitions
through `initializing` mid-IPC.
- New parametrized `it.each(CLONE_INLINE_ERROR_CODES)` test validates
all 8 inline error codes render the bundled en locale string (not
the GitError message, not the t() defaultValue fallback) — a typo
in any locale key now fails loudly instead of shipping silently.
- Replaces `await Promise.resolve()` patterns in the clone-form tests
with `waitFor`.
* fix(desktop): stop swallowing real I/O errors in writeRemoteOrigin
The `git.deleteRemote(...)` call was wrapped in a bare try/catch with
the comment "idempotent absent". That was wrong on two counts:
1. isomorphic-git implements `deleteRemote` as
`GitConfigManager.deleteSection('remote', 'origin')`, which is a
filter over parsed config entries. It does NOT throw when the
section is absent — the call is naturally idempotent.
2. The only way `deleteRemote` can actually throw is via
`GitConfigManager.save` (EACCES, ENOSPC, read-only fs). Those are
real I/O failures; swallowing them would show the user "origin
removed" in the UI while `.git/config` still held the old URL on
disk.
Removing the inner try/catch lets real errors propagate through the
outer catch and surface as a GitError to the renderer.
The existing "removes origin and is idempotent" test already covers
the double-remove case; I added a comment explaining why the absence
of a try/catch is load-bearing here so a future refactor doesn't
re-add one.
* feat(store): ship Phase 6b pull/push cascades and head-move helper
Extracts syncAfterHeadMove into git-store-helpers so pull, switchBranch,
and mergeBranch all refresh status + branches, reload the tracked .op
file, and refresh the log through a single path. Adds full 4-way pull
result handling (fast-forward / merge / conflict / conflict-non-op) and
threads status().unresolvedFiles into a new conflict state field so the
renderer can recover from non-.op pull conflicts before Phase 7.
* feat(editor): wire real pull/push controls and shared auth retry form
Replaces the disabled Phase 5 pull/push placeholders in the git panel
header with a dedicated GitPanelRemoteControls subtree that owns the
remote-action state machine. Pull auth-required / auth-failed and push
auth-failed both open the new shared GitPanelAuthForm (token + SSH
modes with a "remember this credential" toggle). Push-rejected surfaces
an inline "pull first" recovery strip so the user can one-click retry.
The conflict banner grows a non-.op unresolved-files list with
continue / abort so the Phase 6b pull conflict-non-op path has a
recovery surface before Phase 7 lands manual resolution.
* feat(i18n): localize Phase 6b remote controls and auth form across 15 locales
Adds the git.pull.*, git.push.*, git.auth.*, and git.conflict.nonOp.*
keys to every locale so the shared auth form, the push-rejected
recovery strip, and the non-.op unresolved-files banner all render in
the user's language.
* fix(store): delegate pull conflict-non-op to refreshStatus
The manual gitClient.status + buildConflictState block in pull's
conflict-non-op branch bypassed refreshStatus's Step 1 repo-meta update,
leaving currentBranch / ahead / behind / workingDirty stale after a
non-op merge landed. refreshStatus already promotes ready to conflict
with the correct unresolvedFiles via its mergeInProgress branch — trust
it instead of duplicating the state build on the pull side.
Also drops the helperCurrentLogRef alias in favor of inlining
currentLogRef(get()) at the two call sites, and collapses the
ready/conflict nested branches inside refreshStatus since both call
buildConflictState with identical arguments. git-store.ts drops from
787 to 771 lines.
* refactor(editor): consolidate remote auth classification in one helper
The stated goal of classifyRemoteAuthError was to keep pull/push auth
classification in a single place, but the component kept its own inline
isPullAuthCode / isPushAuthCode predicates and the helper was exported
dead. Wire the helper into git-panel-remote-controls for both pull and
push, collapse the duplicate PULL_AUTH_ERROR_CODES / PUSH_AUTH_ERROR_CODES
into a single REMOTE_AUTH_ERROR_CODES (the promised Phase 6c divergence
never materialized), and add a unit test that pins the helper contract
across all three auth codes plus non-auth / non-GitError values.
Also hides the remote controls entirely while the repo is in conflict
state — pull would fail deterministically against an in-flight merge and
push would try to push a half-merged tree. The conflict banner already
owns the recovery UI, so the controls returning null is the cleanest
handoff.
Drops the dead GitError branch in extractMessage (isGitError already
covers the instanceof path) and the now-unused GitError import.
* feat(editor): add phase 6c remote settings and ssh keys overflow views
Expand the git panel header overflow popover into a local state machine
that swaps between the menu view, a remote-settings subview that edits
the origin URL with an inline clear-confirm step and clear-saved-auth
affordance, and an ssh-keys subview that manages the key lifecycle
(generate, import via electron openFile, delete, copy public key) with
provider links for github/gitlab and iso-engine SSH gating guidance.
* feat(i18n): sweep phase 6c remote/ssh keys across 15 locales
Add the new git.remote.*, git.ssh.*, and git.header.overflow* keys for
the phase 6c subviews, and retire the four dead "coming soon"
placeholders (git.placeholder.cloneWizard, git.empty.cloneComingSoon,
git.header.pullComingSoon, git.header.pushComingSoon) that the shipped
phase 6a/6b UI replaced.
* fix(editor): harden phase 6c ssh keys view polish items
- Track the copy-flash timer in a ref and clear it on unmount so a
late setTimeout can't call setCopiedKeyId on a stale component.
- Feature-gate navigator.clipboard.writeText (undefined in non-secure
contexts) and surface a new localized git.ssh.copyUnsupported hint
across all 15 locales when the Clipboard API isn't available.
- Add aria-live="polite" to the "Copied" status span so assistive tech
announces it after it inserts post-mount.
- Mirror the remote-settings iso error mapping: both generateSshKey
and importSshKey catch blocks now translate ssh-not-supported-iso
into git.ssh.isoUnsupported instead of the raw engine message.
* chore(funding): add FUNDING.yml to support GitHub sponsorship
* fix(editor): resolve ssh auth form dead-end for first-time pull attempts
A user who opens a repo with an SSH remote, never visits the overflow →
SSH Keys subview, then tries to pull lands in a dead-end: the pull fails,
the auth form opens on the token tab (wrong), and switching to SSH shows
a "no keys" hint because the store never fetched the keys on disk. Even
if they do manage to reach the SSH picker, the useState lazy initializer
captured an empty list at mount, so sshKeyId stays '' and submit fails
validation with "select a key" while the <select> visibly has an option.
Three-part fix:
1. GitPanelAuthForm now fires refreshSshKeys() from a mount effect so a
first-time pull sees keys on disk without requiring a detour through
the overflow menu.
2. A second effect re-seeds sshKeyId to hostKeys[0].id once keys arrive
async after mount, guarded by sshKeyId === '' so it doesn't fight an
explicit user selection.
3. preseedAuthMode in GitPanelRemoteControls now falls back to inferring
the tab from the remote URL scheme (via isSshRemoteUrl) when there is
no stored credential for the host, so SSH remotes land on the SSH tab
instead of token.
* chore(git): ignore .worktrees directory
Allow parallel worktrees for isolated development without polluting the
tracked tree.
* feat(git): add worktree-merge helpers and real-git spike tests for folder-mode conflicts
Adds worktree-merge.ts (~307 lines) with system-git wrappers needed for
Phase 7a folder-mode merge orchestration:
- sysMergeNoCommit: git merge --no-commit --no-ff
- sysListUnresolved: git ls-files -u (all conflict types)
- readMergeHead: filesystem check for .git/MERGE_HEAD
- sysShowStageBlob: git show :1/:2/:3: for index stage reads
- sysRestoreOurs: git checkout --ours -- <file>
- sysStageFile: git add -- <file>
- sysFinalizeMerge: git commit with MERGE_HEAD auto-parents
- sysAbortMerge: git merge --abort (idempotent)
- sysReadHead: git rev-parse HEAD
Adds 10 real-git spike tests in git-sys-real.test.ts that prove each
helper works against a real repo, covering tracked .op conflicts, mixed
.op + README conflicts, finalize, and abort scenarios.
* feat(git): implement folder-mode divergent merge with on-disk state tracking (Phase 7a)
Replaces the Phase 2c 'pull-non-fast-forward' throw for folder-mode divergent
merges with a full merge workflow using system-git:
- engineBranchMergeFolderMode: delegates to worktree-merge.ts helpers;
classifies conflicts as 'conflict' (.op involved), 'conflict-non-op'
(only non-.op files), or 'merge' (clean); reads base/ours/theirs blobs
from git index stages for pen-core semantic merge; restores .op to
readable JSON after sysRestoreOurs
- engineStatus: now checks on-disk MERGE_HEAD in addition to
session.inflightMerge; reports mergeInProgress=true and populates
unresolvedFiles from both sources (survives session close/reopen)
- engineApplyMerge: handles folder-mode by checking non-.op unresolved
files before applying, staging the .op file, and using sysFinalizeMerge;
throws merge-still-conflicted with file paths when non-.op files remain
- engineAbortMerge: widened to run git merge --abort in folder mode when
MERGE_HEAD is present, in addition to clearing session state
Updates the stale Phase 2c test to assert the new behavior (folder-mode
divergent pull enters merge workflow instead of throwing).
Adds 6 new Phase 7a engine tests covering: conflict detection, conflict-non-op
classification, on-disk MERGE_HEAD status detection, applyMerge with folder
conflicts, applyMerge throwing for unresolved non-.op files, and abortMerge
clearing both session and on-disk state.
* chore(git): document git ≥ 2.35 floor for ls-files --format=%(path)
Add an inline comment on sysListUnresolved noting that the --format
option requires git ≥ 2.35 (Feb 2022) so future readers are not
surprised by the compatibility requirement.
* test(git): close Phase 7a spike gate — add missing scenarios 3 and 4
Issue 1 (spike gate violation): the plan required 4 spike scenarios
but only 2 were covered. Add the remaining two:
Scenario 3 — rename conflict: verifies that when the feature branch
renames the tracked .op file, sysListUnresolved reports the original
path and stage 3 blob is absent. The engine-level assertion confirms
engineBranchMerge returns { result: 'conflict-non-op' } in this case
(stage 3 missing → semantic merge impossible → user resolves in terminal).
Scenario 4 — dirty working tree gate: documents that sysMergeNoCommit
throws engine-crash or enters conflict state when the tracked file has
uncommitted changes that the merge would overwrite. Content is never
silently lost. The renderer-side workingDirty gate remains the right
place to enforce cleanliness.
Issue 2 — noop path: adds the missing test for engineApplyMerge's
folder-mode { noop: true } branch (merge already committed externally).
Issue 3 — Option A contract: adds a test verifying that engineApplyMerge
throws merge-still-conflicted with a "call status() first" message when
called without a prior status() call after panel reopen (MERGE_HEAD on
disk but no in-memory inflightMerge).
* docs(git): record file-size debt for git-engine.ts (I3)
Phase 7a grew git-engine.ts to ~1982 lines (~2.5× the 800-line limit).
engineBranchMergeFolderMode stayed here rather than moving to
worktree-merge.ts because it needs session state (repoSession,
setInflightMerge) and ref-resolution helpers; worktree-merge.ts is
intentionally a pure shell-wrapper boundary with no session coupling.
* fix(git): collapse dead-code branch in engineBranchMergeFolderMode (I4)
Both arms of the if (!baseBlob || !oursBlob || !theirsBlob) check
returned { result: 'conflict-non-op' }; the inner guard on
nonOpConflicts was unreachable. Collapse to a single return with a
unified comment that names the rename-conflict case (stage :3: null)
explicitly. Also removes the now-unused nonOpConflicts declaration.
* test(git): make sysMergeNoCommit tests self-sufficient for git identity (I6)
setupDivergentRepo now runs git config user.name/email immediately after
git init so the real-git spike tests pass on machines without a global
git config — some git versions read identity during merge --no-commit
bookkeeping. Also adds a JSDoc note to sysMergeNoCommit documenting
this identity dependency for future maintainers.
* feat(store): add finalizeError and conflict-state reconciliation (Phase 7b)
- add finalizeError: string | null to conflict GitState variant
- exitTrackedFilePicker action: returns to ready when rebinding, closes
repo session and returns to no-file when no trackedFilePath yet
- applyMerge catches merge-still-conflicted and surfaces it inline via
finalizeError instead of transitioning to the generic error card
- resolveConflict clears finalizeError when user resolves a conflict
- buildConflictState helper accepts optional finalizeError param
- 10 new store tests covering all new paths (65 total)
* feat(panels): add shared git-panel log loader hook
- create use-git-panel-log-loader.ts: reads state.repo.currentBranch
instead of hardcoded 'main', fires on state.kind and currentBranch changes
- replace hardcoded loadLog({ ref: 'main' }) effects in git-panel-ready.tsx
and git-panel-conflict.tsx with useGitPanelLogLoader
- add polling skeleton to git-panel-conflict.tsx: fires refreshStatus()
every 3s while unresolvedFiles.length > 0, with in-flight guard and
error-stops-polling semantics
- 5 new log loader tests
* feat(panels): add inline history diff block for expanded rows
- create git-panel-history-diff.tsx: loads computeDiff(parentHash, hash)
on expand, renders loading/error/initial-commit/summary+patches states
- replace diffComingSoon placeholder in git-panel-history-list.tsx with
GitPanelHistoryDiff for both milestone and autosave expanded rows
- add git.history.diff.* i18n keys (English only; Phase 7c sweep for others)
- add git.conflict.banner.* and git.picker.back* i18n keys (Phase 7b)
- update history-list test: add computeDiff mock, update diffComingSoon
assertion to initial-commit check
- 7 new history-diff tests
* feat(panels): upgrade git conflict banner with progress and finalize UI
- replace abort-only shell with full status header: title, progress
counter (resolved X / Y), non-.op file list, dynamic primary button,
inline finalizeError display
- primary button logic: "Apply merge" when .op conflicts unresolved,
"Continue" when all .op resolved and non-.op files still pending
- abort button always visible (unified label git.conflict.abort)
- inline finalizeError strip for merge-still-conflicted errors
- 10 banner tests covering all new paths
- update git-panel-conflict tests for new button key names and add
finalizeError/computeDiff fields to mock state
* feat(panels): add back affordance to tracked-file picker
- add exitTrackedFilePicker to picker component: renders "Back" label when
trackedFilePath is set (rebinding from ready) and "Cancel" label when
null (first post-open/clone screen)
- button always calls exitTrackedFilePicker which owns the navigation logic
in the store (back → ready, cancel → close repo + no-file)
- 4 new picker tests covering back label, cancel label, and call contract
* fix(i18n): add Phase 7b i18n keys to all 14 non-English locales and fix test type errors
Adds git.conflict.banner.*, git.picker.back/backClose, and git.history.diff.*
keys to de/es/fr (localised) and hi/id/ja/ko/pt/ru/th/tr/vi/zh-tw/zh (English
placeholders, Phase 7c will localise). Fixes circular type reference in
git-panel-conflict-banner.test.tsx, removes unused React imports in two test
files, and types the computeDiff mock patches as unknown[] to satisfy tsc.
All 1353 tests pass; npx tsc --noEmit is clean.
* fix(panels): surface git conflict polling errors inline
Promotes pollError from a ref to React state so the first refresh
failure triggers a re-render. A small destructive-tone banner appears
between GitPanelConflictBanner and the history list, keyed by
git.conflict.banner.pollError (new i18n key). Polling does not retry
after the first error, matching the existing stop-on-first-error contract.
Adds a fake-timer test that asserts the error UI appears and refreshStatus
is not called a second time.
* fix(store): preserve in-memory conflict resolutions across refreshStatus
Polling refreshStatus every 3s was calling buildConflictState which
re-hydrated the conflict bag from the wire format, erasing any
resolution choices the user had recorded via resolveConflict. The
same re-hydration also reset finalizeError to null, hiding the
"apply merge failed" banner after each poll cycle.
When the store is already in conflict state and the backend still
reports mergeInProgress, skip the re-hydration and only update
unresolvedFiles (which can change as the user resolves non-.op
files externally). The .op conflict bag itself does not mutate
during a merge session — only renderer-side resolution state
layered on top of it by resolveConflict needs to persist.
Adds three regression tests covering: resolution preservation,
finalizeError preservation, and unresolvedFiles update.
* chore(store): correct git-store.ts line debt comment to 831 lines
* chore(i18n): remove dead git.history.diffComingSoon key
The last consumer was removed in Phase 7b. Key was orphaned across
all 15 locale files without any TSX reference.
* perf(panels): narrow use-git-panel-log-loader selector to primitive fields
The previous selector subscribed to the entire state object, so every
polling refreshStatus cycle (every 3s) caused a re-render even when
neither state.kind nor currentBranch had changed. Subscribe to two
primitive selectors instead so Zustand's Object.is comparison skips
spurious re-renders.
* fix(panels): add cancelled guard to conflict polling effect
A post-unmount resolve from the in-flight refreshStatus() call could
fire setPollError on an already-unmounted component (React 18 strict
mode or fast panel toggle). The cancelled flag ensures the catch
handler is a no-op after cleanup returns.
* feat(renderer): add renderNodeThumbnail helper for node conflict cards
Exports a graceful null-returning async helper that renders a single
PenNode to a PNG data URL using CanvasKit + OffscreenCanvas, falling
back to null in test/SSR environments where CanvasKit is unavailable.
* feat(panels): add conflict-formatters helpers and inline JSON editor component
Pure helpers for safe JSON parsing, node validation, pretty-printing,
and truncation; plus a local-state textarea component used in conflict
cards for manual resolution — no store sync on keystroke.
* feat(panels): add node conflict card with async thumbnail rendering
Card shows our/their node thumbnails via renderNodeThumbnail, choose
buttons with resolution state tracking, and a toggleable JSON editor
for manual node overrides.
* feat(panels): add field conflict card with three-way value display
Shows base/ours/theirs JSON values in pre blocks with choose buttons
and a toggleable JSON editor for arbitrary value overrides.
* feat(panels): add conflict list with bulk actions and wire into conflict panel
Dispatcher routes node/field conflicts to correct cards; list interleaves
them with a progress summary and bulk all-ours / all-theirs actions that
loop over unresolved items without new IPC.
* feat(store): reload tracked file and refresh log after successful applyMerge
makeReloadAfterApply reads state after the ready transition to reload
the .op file from disk and refresh log/status; also calls refreshStatus
on merge-still-conflicted so the conflict state stays accurate.
* test(panels): add full coverage for Phase 7c conflict UI components and store
47 new tests: renderNodeThumbnail null-fallback contract, JSON editor
validation, node/field conflict cards, conflict list bulk actions, plus
3 new git-store tests for applyMerge reload cascade and 3 pre-existing
tests updated to mock refreshStatus status correctly.
* i18n: full 15-locale sweep for Phase 7 conflict UI and diff block
Adds git.conflict.list.*, git.conflict.item.*, git.conflict.card.*,
git.conflict.editor.* keys to all 15 locales; replaces Phase 7b
English placeholders for git.conflict.banner.*, git.history.diff.*,
and git.picker.back/backClose with native translations in all 14
non-English locales.
* fix(panels): order git conflict list by document tree position (Phase 7c)
Walk the current document depth-first to emit node conflicts in the same
sequence as the layer panel. Doc-field conflicts follow sorted alphabetically
by path; orphan node conflicts (deleted on theirs side) are appended last.
Adds orderConflicts() pure helper to conflict-formatters.ts. Replaces the
stale "nodes-first / future phase" comment and adds document-order,
orphan-handling, and field-interleaving test coverage.
* fix(renderer): resolve \$variables in renderNodeThumbnail
Apply resolveNodeForCanvas (via getDefaultTheme) after ref resolution and
before flattenToRenderNodes, mirroring the same step in renderer.ts. Without
this, nodes with fill: '\$color-primary' rendered the raw string instead of
the resolved RGB value in the git conflict ours/theirs thumbnail cards.
Adds two tests confirming the code path executes without throwing when the
document carries variable definitions and themed variables.
* chore(store): correct git-store.ts line debt to 848 lines
Phase 7b comment claimed 831 lines and expected Phase 7c to fix it.
Phase 7c extracted makeReloadAfterApply but applyMerge's reload orchestration
and noop handling added lines back, landing at 848. Update the debt comment
to reflect the current count and defer further extraction to Phase 8+.
* fix(panels): pass real document to renderNodeThumbnail (Phase 7c C1)
fakeDoc stub had no variables, themes, or children — every $variable
reference silently failed to resolve and ref nodes rendered as
placeholders. Replace with the live document from useDocumentStore so
resolveNodeForCanvas and resolveRefs have full context.
* fix(panels): handle multi-page documents in orderConflicts and renderNodeThumbnail (Phase 7c C2)
orderConflicts walked document.children which is empty for multi-page
docs — all node conflicts fell through to orphan ordering, defeating
the tree-order spec fix. Now walk each page's children separately with
pageId in scope so conflict matching is (pageId, nodeId) scoped.
renderNodeThumbnail also used document.children for ref resolution;
replace with getAllChildren which handles both single-page and
multi-page layouts. Adds multi-page ordering tests.
* fix(panels): hoist conflict list useMemo above early return (Phase 7c I3)
useMemo after an early return violates rules-of-hooks. Drop it entirely
— orderConflicts is an O(n) walk over a modest conflict list so
memoisation adds complexity without measurable benefit.
* test(renderer): verify resolveNodeForCanvas is called (Phase 7c I6)
Previous Gap-2 tests only asserted result === null, which cannot
distinguish a successful variable-resolution pass from a silent throw.
Add vi.spyOn tests that confirm resolveNodeForCanvas is invoked with
the node, document variables map, and the active theme derived from
getDefaultTheme.
* refactor(store): drop unused applyMerge result variable (Phase 7c I7)
noop: true and noop: false both produce the same outcome (transition
conflict → ready, reload tracked file, refresh log). Drop the let
result binding and void result lint suppressor — the await is all
that is needed.
* fix(git): mark reopened mid-merge state and render abort-only banner (I2)
Engine: `engineStatus` panel-reopen branch now sets `reopenedMidMerge: true` and
filters the tracked `.op` file out of `unresolvedFiles` so it no longer appears
as a misleading non-op file in the banner.
Types: `GitStatusInfo` and `GitState.conflict` gain `reopenedMidMerge: boolean`.
`buildConflictState` accepts the flag. `refreshStatus` enters conflict state even
when `unresolvedFiles` is empty and `conflicts` is null (the degraded reopen path).
Banner: `GitPanelConflictBanner` hides the primary Continue/Apply button and shows
a localized warning message when `reopenedMidMerge` is true — abort is the only
valid action in the degraded state.
i18n: `git.conflict.banner.reopenMessage` added to all 15 locales (14 placeholders).
Tests: engine test asserts `reopenedMidMerge=true` and tracked file absent from
`unresolvedFiles`; store test asserts ready→conflict promotion for the reopen path;
banner tests assert abort-only UI and message presence.
* fix(store): handle conflict-non-op result in mergeBranch (I3)
`mergeBranch` previously fell through to `syncAfterHeadMove` when the engine
returned `conflict-non-op`, which reloaded the document and history — wrong
because the merge is still in flight. Now mirrors the `pull` implementation:
call `refreshStatus()` directly so the store promotes ready → conflict with
the correct `unresolvedFiles` list.
Test: asserts that `loadOpFileFromPath` is NOT called and the store lands in
`conflict` state with the non-op files listed.
* chore(i18n): clean stale git placeholder copy and add audit test (Phase 8a)
Delete dead keys git.placeholder.trackedPicker and git.placeholder.readyState
(zero references in src outside locales). Rewrite git.conflict.description to
reflect the shipped Phase 7 conflict-resolver UX instead of advertising it as
coming. Add git-locale-audit.test.ts to catch future stale-placeholder drift.
* style(i18n): prefer startsWith over regex caret in git-locale-audit test
* chore(i18n): propagate git copy cleanup to 14 non-english locales (Phase 8b)
Remove dead git.placeholder.trackedPicker and git.placeholder.readyState keys
from all 14 non-English locale files, and retranslate git.conflict.description
from the new English prose introduced in Phase 8a.
* style(panels): refine git panel visuals and relocate trigger to title bar
Replace the heavy form-like commit composer with an integrated card,
introduce a timeline rail for the history list, add icons to the
overflow menu items, unify the remote-settings and SSH-keys inputs with
the new card style, and give the empty state a decorative icon tile
with hoverable cards. Move the git trigger from the top-bar right
section to the center next to the file name as a pill that shows the
current branch, and swap the popover's Radix arrow for a centered
custom arrow that points at the trigger after aligning the popover to
center.
* fix(ci): configure git identity and bump Zig to 0.15.2
- Add global user.name/user.email and init.defaultBranch=main so
system-git-gated tests in apps/desktop/git/__tests__ can run
merge/commit and clone bare remotes that only push main.
- Bump mlugg/setup-zig from 0.14.0 to 0.15.2 across all workflows;
agent-native uses unmanaged ArrayList APIs that need Zig 0.15+, so
ensure-agent-native.cjs was silently failing and agent_napi.node
was missing at test time.
* chore(agent): advance submodule to v0.1.0, fetch prebuilt by submodule SHA
The agent submodule's Zig source uses Zig 0.15+ ArrayList APIs and had
several latent issues that only surfaced once CI started exercising
the release path (libc dependency, missing PIC, Windows napi linking,
e2e message-store leaks). Those are all fixed upstream in 77f5300,
which is the commit tagged as v0.1.0 with prebuilt .node binaries for
darwin-arm64, darwin-x64, linux-x64, and windows-x64.
ensure-agent-native.cjs now:
1. Uses the bundled / previously-built binary if present.
2. Reads the submodule commit, walks ZSeven-W/agent tags until it
finds the one whose commit SHA matches, and downloads the asset
for the current host. Tag-matched lookups guarantee the prebuilt
matches the source you actually pinned — releases/latest would
silently drift when someone bumps the submodule without tagging.
3. Falls back to `zig build napi` if no matching release exists
(and the local machine has Zig installed).
The previous ci.yml git-identity step stays in place — it is still
needed for the desktop/git system-git-gated tests that shell out to
`git merge`.
* fix(figma): raise .fig import ceilings to unblock large design systems
Issue #94: the 150MB compressed / 300MB decompressed caps were tight
enough to reject legitimate Figma exports from real design systems.
Keep the zip-bomb defence but bump the ceilings an order of magnitude:
MAX_COMPRESSED_SIZE: 150MB → 1GB
MAX_UNZIPPED_SIZE: 300MB → 2GB
MAX_IMAGE_SIZE: 150MB → 512MB
Error messages now format the active limit from the constant so the
string never drifts from the value again. Stale 100/50MB comment in
the security test file updated to match.
* chore(agent): advance submodule to 7a30996 with glibc-pinned linux prebuilt
v0.1.0 was republished after pinning Zig to x86_64-linux-gnu.2.17 so
the linux-x64 NAPI addon embeds "libc.so.6" as its SONAME instead of
the host's absolute libc linker-script path ("/lib/x86_64-linux-gnu/
libc.so"), which Node's dlopen rejects with "invalid ELF header".
Dockerfile: bump the bundled Zig from 0.14.0 to 0.15.2 so the
source-build fallback still compiles when building images for an arch
we don't publish prebuilts for yet (linux-arm64). The URL path shape
changed between releases, hence the renamed directory segments.
* chore(release): bump version to 0.7.0
---------
Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Di <di.yan@mail.polimi.it>
This commit is contained in:
parent
3c697d817d
commit
e20957b740
1022 changed files with 126171 additions and 50219 deletions
|
|
@ -9,7 +9,5 @@
|
|||
"addOnOptions": {},
|
||||
"version": 1,
|
||||
"framework": "react-cra",
|
||||
"chosenAddOns": [
|
||||
"nitro"
|
||||
]
|
||||
}
|
||||
"chosenAddOns": ["nitro"]
|
||||
}
|
||||
|
|
|
|||
9
.editorconfig
Normal file
9
.editorconfig
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
|
@ -28,3 +28,35 @@ done
|
|||
if [ "$CHANGED" = "1" ]; then
|
||||
echo "[version-sync] branch $BRANCH → $VERSION"
|
||||
fi
|
||||
|
||||
# --- Auto-format via the same script CI uses, then re-stage staged files ---
|
||||
#
|
||||
# Run `bun run format:check` first; if it fails, run `bun run format` to
|
||||
# auto-fix and re-stage only the files that were already staged. New
|
||||
# files added without going through the formatter (the most common cause
|
||||
# of CI format-check failures) get fixed silently here. The format
|
||||
# scripts already respect .prettierignore so the agent-native submodule
|
||||
# and generated files are untouched.
|
||||
#
|
||||
# NOTE: re-adding files pulls in any unstaged hunks from a partially
|
||||
# staged file. Stage cleanly if that matters to you.
|
||||
if ! bun run format:check > /dev/null 2>&1; then
|
||||
echo "[pre-commit] format drift detected — running \`bun run format\`"
|
||||
if ! bun run format > /dev/null 2>&1; then
|
||||
echo "[pre-commit] \`bun run format\` failed"; exit 1;
|
||||
fi
|
||||
STAGED=$(git diff --cached --name-only --diff-filter=d \
|
||||
| grep -E '\.(ts|tsx|js|jsx|cjs|mjs|json|jsonc|md|yml|yaml)$' || true)
|
||||
if [ -n "$STAGED" ]; then
|
||||
echo "$STAGED" | xargs git add
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Lint staged TS/JS files (no auto-fix, fail loudly on errors) ---
|
||||
LINT_STAGED=$(git diff --cached --name-only --diff-filter=d \
|
||||
| grep -E '\.(ts|tsx|js|jsx|cjs|mjs)$' || true)
|
||||
if [ -n "$LINT_STAGED" ]; then
|
||||
echo "$LINT_STAGED" | xargs npx oxlint || {
|
||||
echo "[pre-commit] lint failed. Run: bun run lint:fix"; exit 1;
|
||||
}
|
||||
fi
|
||||
|
|
|
|||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
github: [ZSeven-W]
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -1,6 +1,6 @@
|
|||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
labels: ["bug"]
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -1,6 +1,6 @@
|
|||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
labels: ["enhancement"]
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
|
|
|||
6
.github/workflows/build-electron.yml
vendored
6
.github/workflows/build-electron.yml
vendored
|
|
@ -34,6 +34,8 @@ jobs:
|
|||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
|
|
@ -43,6 +45,10 @@ jobs:
|
|||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
|
|
|
|||
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
|
|
@ -14,17 +14,35 @@ jobs:
|
|||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
- name: Configure git for tests
|
||||
run: |
|
||||
git config --global user.name "CI"
|
||||
git config --global user.email "ci@example.com"
|
||||
git config --global init.defaultBranch main
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Generate skill registry
|
||||
run: bun run generate
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Format check
|
||||
run: bun run format:check
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
|
|
@ -39,11 +57,17 @@ jobs:
|
|||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
|
|
|
|||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
|
|
@ -52,6 +52,8 @@ jobs:
|
|||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
|
|
|
|||
18
.github/workflows/publish-cli.yml
vendored
18
.github/workflows/publish-cli.yml
vendored
|
|
@ -17,6 +17,8 @@ jobs:
|
|||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
|
|
@ -27,6 +29,10 @@ jobs:
|
|||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: 0.15.2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
|
|
@ -74,12 +80,6 @@ jobs:
|
|||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish pen-codegen
|
||||
run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-codegen@${{ steps.version.outputs.version }}" version
|
||||
working-directory: packages/pen-codegen
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish pen-figma
|
||||
run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-figma@${{ steps.version.outputs.version }}" version
|
||||
working-directory: packages/pen-figma
|
||||
|
|
@ -92,6 +92,12 @@ jobs:
|
|||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish pen-mcp
|
||||
run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-mcp@${{ steps.version.outputs.version }}" version
|
||||
working-directory: packages/pen-mcp
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish pen-sdk
|
||||
run: npm publish --access public 2>&1 || npm view "@zseven-w/pen-sdk@${{ steps.version.outputs.version }}" version
|
||||
working-directory: packages/pen-sdk
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -10,6 +10,7 @@ count.txt
|
|||
__unconfig*
|
||||
todos.json
|
||||
.openpencil-tmp/
|
||||
.superpowers/
|
||||
|
||||
docs/
|
||||
|
||||
|
|
@ -20,3 +21,5 @@ dist-ssr/
|
|||
electron-dist/
|
||||
dist-electron/
|
||||
.claude
|
||||
.omx
|
||||
.worktrees/
|
||||
|
|
|
|||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "packages/agent-native"]
|
||||
path = packages/agent-native
|
||||
url = https://github.com/ZSeven-W/agent.git
|
||||
7
.oxfmtrc.json
Normal file
7
.oxfmtrc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true
|
||||
}
|
||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# oxfmt picks this file up automatically (alongside .gitignore).
|
||||
|
||||
# Git submodule — owned by a separate repo, not formatted from here.
|
||||
packages/agent-native/
|
||||
|
||||
# Auto-generated by @tanstack/router-plugin.
|
||||
apps/web/src/routeTree.gen.ts
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -8,8 +8,5 @@
|
|||
"files.readonlyInclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"i18n-ally.localesPaths": [
|
||||
"src/i18n",
|
||||
"src/i18n/locales"
|
||||
]
|
||||
"i18n-ally.localesPaths": ["src/i18n", "src/i18n/locales"]
|
||||
}
|
||||
|
|
|
|||
147
AGENTS.md
Normal file
147
AGENTS.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
Detailed module docs are in `packages/AGENTS.md`, `apps/web/AGENTS.md`, `apps/desktop/AGENTS.md`, and `apps/cli/AGENTS.md` — loaded automatically when working in those directories.
|
||||
|
||||
## Commands
|
||||
|
||||
- **Dev server:** `bun --bun run dev` (runs on port 3000)
|
||||
- **Build:** `bun --bun run build`
|
||||
- **Preview production build:** `bun --bun run preview`
|
||||
- **Run all tests:** `bun --bun run test` (Vitest)
|
||||
- **Run a single test:** `bun --bun vitest run path/to/test.ts`
|
||||
- **Type check:** `npx tsc --noEmit`
|
||||
- **Install dependencies:** `bun install`
|
||||
- **Bump version:** `bun run bump <version>` (syncs all package.json files)
|
||||
- **Electron dev:** `bun run electron:dev` (starts Vite + Electron together)
|
||||
- **Electron compile:** `bun run electron:compile` (esbuild electron/ to out/desktop/)
|
||||
- **Electron build:** `bun run electron:build` (full web build + compile + electron-builder package)
|
||||
- **CLI compile:** `bun run cli:compile` (esbuild CLI to apps/cli/dist/)
|
||||
- **CLI dev:** `bun run cli:dev` (run CLI from source via Bun)
|
||||
- **Publish beta:** `bun run publish:beta [N]` (publish all npm packages with beta tag)
|
||||
|
||||
## Architecture
|
||||
|
||||
OpenPencil is an open-source vector design tool (alternative to Pencil.dev) with a Design-as-Code philosophy. Organized as a **Bun monorepo** with workspaces:
|
||||
|
||||
```text
|
||||
openpencil/
|
||||
├── apps/
|
||||
│ ├── web/ TanStack Start full-stack React app (Vite + Nitro)
|
||||
│ ├── desktop/ Electron desktop app (macOS, Windows, Linux)
|
||||
│ └── cli/ CLI tool — control the design tool from the terminal
|
||||
├── packages/
|
||||
│ ├── pen-types/ Type definitions for PenDocument model
|
||||
│ ├── pen-core/ Document tree ops, layout engine, variables, boolean ops, clone utilities
|
||||
│ ├── pen-codegen/ Multi-platform code generators
|
||||
│ ├── pen-figma/ Figma .fig file parser and converter
|
||||
│ ├── pen-renderer/ Standalone CanvasKit/Skia renderer
|
||||
│ ├── pen-sdk/ Umbrella SDK (re-exports all packages)
|
||||
│ ├── pen-ai-skills/ AI prompt skill engine (phase-driven prompt loading + design memory)
|
||||
│ └── agent/ Domain-agnostic AI agent SDK (Vercel AI SDK, multi-provider, agent teams)
|
||||
├── scripts/ Build and publish scripts
|
||||
└── .githooks/ Pre-commit version sync from branch name
|
||||
```
|
||||
|
||||
**Key technologies:** React 19, CanvasKit/Skia WASM (canvas engine), Paper.js (boolean path operations), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), Vercel AI SDK v6 (agent framework), i18next (15 locales), TypeScript (strict mode).
|
||||
|
||||
### Data Flow
|
||||
|
||||
```text
|
||||
React Components (Toolbar, LayerPanel, PropertyPanel)
|
||||
│ Zustand hooks
|
||||
▼
|
||||
┌─────────────────┐ ┌───────────────────┐
|
||||
│ canvas-store │ │ document-store │ ← single source of truth
|
||||
│ (UI state: │ │ (PenDocument) │
|
||||
│ tool/selection │ │ CRUD / tree ops │
|
||||
│ /viewport) │ │ │
|
||||
└────────┬────────┘ └────────┬──────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
CanvasKit/Skia canvas-sync-lock
|
||||
(GPU-accelerated (prevents circular sync)
|
||||
WASM renderer)
|
||||
```
|
||||
|
||||
- **document-store** is the single source of truth. CanvasKit only renders.
|
||||
- User edits on canvas → SkiaEngine events → update document-store
|
||||
- User edits in panels → update document-store → SkiaEngine `syncFromDocument()` re-renders
|
||||
- `canvas-sync-lock.ts` prevents circular updates when canvas events write to the store
|
||||
|
||||
### Multi-Page Architecture
|
||||
|
||||
```text
|
||||
PenDocument
|
||||
├── pages?: PenPage[] (id, name, children)
|
||||
└── children: PenNode[] (default/single-page fallback)
|
||||
```
|
||||
|
||||
### Design Variables Architecture
|
||||
|
||||
- **`$variable` references are preserved** in the document store (e.g. `$color-1` in fill color)
|
||||
- `resolveNodeForCanvas()` resolves `$refs` on-the-fly before CanvasKit rendering
|
||||
- Code generators output `var(--name)` for `$ref` values
|
||||
- Multiple theme axes supported (e.g. Theme-1 with Light/Dark, Theme-2 with Compact/Comfortable)
|
||||
|
||||
### MCP Layered Design Workflow
|
||||
|
||||
External LLMs (Codex, Codex, Gemini CLI, etc.) can generate designs via MCP:
|
||||
|
||||
- **Single-shot**: `batch_design` or `insert_node` — one call
|
||||
- **Layered**: `design_skeleton` → `design_content` × N → `design_refine` — phased generation with focused context
|
||||
- **Segmented prompts**: `get_design_prompt(section=...)` loads focused subsets (schema, layout, roles, icons, etc.)
|
||||
|
||||
### Path Aliases
|
||||
|
||||
`@/*` maps to `./src/*` (configured in `apps/web/tsconfig.json` and `apps/web/vite.config.ts`).
|
||||
|
||||
### Styling
|
||||
|
||||
Tailwind CSS v4 imported via `apps/web/src/styles.css`. UI primitives from shadcn/ui. Icons from `lucide-react`.
|
||||
|
||||
### CLI (`apps/cli/`)
|
||||
|
||||
The `op` command-line tool controls the desktop app or web server from the terminal. Arguments that accept JSON or DSL support three input methods: inline string, `@filepath` (read from file), or `-` (read from stdin).
|
||||
|
||||
- **App control:** `op start [--desktop|--web]`, `op stop`, `op status`
|
||||
- **Design:** `op design <dsl|@file|->` — batch design DSL operations
|
||||
- **Document:** `op open`, `op save`, `op get`, `op selection`
|
||||
- **Nodes:** `op insert`, `op update`, `op delete`, `op move`, `op copy`, `op replace`
|
||||
- **Export:** `op export <react|html|vue|svelte|flutter|swiftui|compose|rn|css>`
|
||||
- **Cross-platform:** macOS, Windows (NSIS/portable), Linux (AppImage/deb/snap/flatpak)
|
||||
|
||||
### CI / CD
|
||||
|
||||
- **`.github/workflows/ci.yml`** — Push/PR on `main` and `v*` branches: type check, tests, web build
|
||||
- **`.github/workflows/build-electron.yml`** — Tag push (`v*`) or manual: builds Electron for all platforms, creates draft GitHub Release
|
||||
- **`.github/workflows/publish-cli.yml`** — Tag push (`v*`) or manual: publishes all `@zseven-w/*` npm packages in topological order
|
||||
- **`.github/workflows/docker.yml`** — Docker image build and push
|
||||
|
||||
### Version Sync
|
||||
|
||||
- **Pre-commit hook** (`.githooks/pre-commit`): extracts version from branch name (e.g. `v0.5.0` → `0.5.0`) and syncs to all `package.json` files
|
||||
- **Manual bump:** `bun run bump <version>` to set a specific version across all workspaces
|
||||
- Requires `git config core.hooksPath .githooks` (one-time setup per clone)
|
||||
|
||||
## Code Style
|
||||
|
||||
- Single files must not exceed 800 lines. Split into smaller modules when they grow beyond this limit.
|
||||
- One component per file, each with a single responsibility.
|
||||
- `.ts` and `.tsx` files use kebab-case naming, e.g. `canvas-store.ts`, `use-keyboard-shortcuts.ts`.
|
||||
- UI components must use shadcn/ui design tokens (`bg-card`, `text-foreground`, `border-border`, etc.). No hardcoded Tailwind colors like `gray-*`, `blue-*`.
|
||||
- Toolbar button active state uses `isActive` conditional className (`bg-primary text-primary-foreground`), not Radix Toggle's `data-[state=on]:` selector (has twMerge conflicts).
|
||||
|
||||
## Git Commit Convention
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/) format: `<type>(<scope>): <subject>`
|
||||
|
||||
**Types:** `feat`, `fix`, `refactor`, `perf`, `style`, `docs`, `test`, `chore`
|
||||
|
||||
**Scopes:** `editor`, `canvas`, `panels`, `history`, `ai`, `codegen`, `store`, `types`, `variables`, `figma`, `mcp`, `electron`, `renderer`, `sdk`, `cli`, `agent`, `i18n`
|
||||
|
||||
**Rules:** Subject in English, lowercase start, no period, imperative mood. Body is optional; explain **why** not what. One commit per change.
|
||||
|
||||
## License
|
||||
|
||||
MIT License. See [LICENSE](./LICENSE) for details.
|
||||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -11,6 +11,8 @@ Detailed module docs are in `packages/CLAUDE.md`, `apps/web/CLAUDE.md`, `apps/de
|
|||
- **Run all tests:** `bun --bun run test` (Vitest)
|
||||
- **Run a single test:** `bun --bun vitest run path/to/test.ts`
|
||||
- **Type check:** `npx tsc --noEmit`
|
||||
- **Lint:** `bun run lint` (oxlint)
|
||||
- **Format:** `bun run format` (oxfmt)
|
||||
- **Install dependencies:** `bun install`
|
||||
- **Bump version:** `bun run bump <version>` (syncs all package.json files)
|
||||
- **Electron dev:** `bun run electron:dev` (starts Vite + Electron together)
|
||||
|
|
@ -18,6 +20,7 @@ Detailed module docs are in `packages/CLAUDE.md`, `apps/web/CLAUDE.md`, `apps/de
|
|||
- **Electron build:** `bun run electron:build` (full web build + compile + electron-builder package)
|
||||
- **CLI compile:** `bun run cli:compile` (esbuild CLI to apps/cli/dist/)
|
||||
- **CLI dev:** `bun run cli:dev` (run CLI from source via Bun)
|
||||
- **MCP dev:** `bun run mcp:dev` (run MCP server from source)
|
||||
- **Publish beta:** `bun run publish:beta [N]` (publish all npm packages with beta tag)
|
||||
|
||||
## Architecture
|
||||
|
|
@ -33,17 +36,20 @@ openpencil/
|
|||
├── packages/
|
||||
│ ├── pen-types/ Type definitions for PenDocument model
|
||||
│ ├── pen-core/ Document tree ops, layout engine, variables, boolean ops, clone utilities
|
||||
│ ├── pen-engine/ Headless design engine — framework-free document, selection, history, viewport
|
||||
│ ├── pen-react/ React UI SDK — DesignProvider, DesignCanvas, hooks, panels, toolbar
|
||||
│ ├── pen-codegen/ Multi-platform code generators
|
||||
│ ├── pen-figma/ Figma .fig file parser and converter
|
||||
│ ├── pen-renderer/ Standalone CanvasKit/Skia renderer
|
||||
│ ├── pen-mcp/ MCP server — tools, routes, document manager for external CLI integration
|
||||
│ ├── pen-sdk/ Umbrella SDK (re-exports all packages)
|
||||
│ ├── pen-ai-skills/ AI prompt skill engine (phase-driven prompt loading + design memory)
|
||||
│ └── agent/ Domain-agnostic AI agent SDK (Vercel AI SDK, multi-provider, agent teams)
|
||||
│ └── agent-native/ Native AI agent runtime (Zig NAPI, multi-provider, agent teams)
|
||||
├── scripts/ Build and publish scripts
|
||||
└── .githooks/ Pre-commit version sync from branch name
|
||||
```
|
||||
|
||||
**Key technologies:** React 19, CanvasKit/Skia WASM (canvas engine), Paper.js (boolean path operations), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), Vercel AI SDK v6 (agent framework), i18next (15 locales), TypeScript (strict mode).
|
||||
**Key technologies:** React 19, CanvasKit/Skia WASM (canvas engine), Paper.js (boolean path operations), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), Vercel AI SDK v6 (agent framework), i18next (15 locales), TypeScript (strict mode), oxlint/oxfmt (linting & formatting).
|
||||
|
||||
### Data Flow
|
||||
|
||||
|
|
|
|||
33
Dockerfile
33
Dockerfile
|
|
@ -1,19 +1,30 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
# ── Stage 1: Build web app ──
|
||||
FROM oven/bun:1 AS builder
|
||||
|
||||
# Install Zig. agent-native postinstall prefers downloading a prebuilt .node
|
||||
# matching the submodule commit from the ZSeven-W/agent release, but falls
|
||||
# back to `zig build napi` when no matching asset exists (e.g. building for
|
||||
# an arch we don't publish yet). Pin 0.15.2 because the Zig source uses the
|
||||
# unmanaged ArrayList / std.process.getEnvVarOwned shape introduced in 0.15.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl xz-utils ca-certificates \
|
||||
&& ARCH="$(uname -m)" \
|
||||
&& case "$ARCH" in \
|
||||
x86_64) ZIG_ARCH=x86_64 ;; \
|
||||
aarch64) ZIG_ARCH=aarch64 ;; \
|
||||
*) echo "Unsupported arch: $ARCH" && exit 1 ;; \
|
||||
esac \
|
||||
&& curl -fsSL "https://ziglang.org/download/0.15.2/zig-${ZIG_ARCH}-linux-0.15.2.tar.xz" \
|
||||
| tar -xJ -C /usr/local \
|
||||
&& ln -sf "/usr/local/zig-${ZIG_ARCH}-linux-0.15.2/zig" /usr/local/bin/zig \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
COPY packages/pen-types/package.json packages/pen-types/
|
||||
COPY packages/pen-core/package.json packages/pen-core/
|
||||
COPY packages/pen-codegen/package.json packages/pen-codegen/
|
||||
COPY packages/pen-figma/package.json packages/pen-figma/
|
||||
COPY packages/pen-renderer/package.json packages/pen-renderer/
|
||||
COPY packages/pen-sdk/package.json packages/pen-sdk/
|
||||
COPY packages/pen-ai-skills/package.json packages/pen-ai-skills/
|
||||
COPY packages/agent/package.json packages/agent/
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/desktop/package.json apps/desktop/
|
||||
COPY apps/cli/package.json apps/cli/
|
||||
COPY --parents packages/*/package.json apps/*/package.json ./
|
||||
# agent-native is a git submodule with a nested workspace package (napi/)
|
||||
# and Zig sources needed by the postinstall hook — copy it whole.
|
||||
COPY packages/agent-native ./packages/agent-native
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY . .
|
||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
|
|
|||
91
README.de.md
91
README.de.md
|
|
@ -124,15 +124,15 @@ bun run electron:dev
|
|||
|
||||
Mehrere Image-Varianten sind verfügbar — wählen Sie die passende für Ihre Anforderungen:
|
||||
|
||||
| Image | Größe | Enthält |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 MB | Nur Web-App |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | Alle CLI-Tools |
|
||||
| Image | Größe | Enthält |
|
||||
| ---------------------------- | ------- | -------------------- |
|
||||
| `openpencil:latest` | ~226 MB | Nur Web-App |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | Alle CLI-Tools |
|
||||
|
||||
**Ausführen (nur Web):**
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ docker build --target full -t openpencil-full .
|
|||
## KI-natives Design
|
||||
|
||||
**Vom Prompt zur UI**
|
||||
|
||||
- **Text-zu-Design** — eine Seite beschreiben und sie wird in Echtzeit mit Streaming-Animation auf der Canvas generiert
|
||||
- **Orchestrierer** — zerlegt komplexe Seiten in räumliche Teilaufgaben zur parallelen Generierung
|
||||
- **Design-Modifikation** — Elemente auswählen und Änderungen in natürlicher Sprache beschreiben
|
||||
|
|
@ -180,20 +181,21 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
**Multi-Agenten-Unterstützung**
|
||||
|
||||
| Agent | Einrichtung |
|
||||
| --- | --- |
|
||||
| Agent | Einrichtung |
|
||||
| ---------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| **Integriert (9+ Anbieter)** | Auswahl aus Anbieter-Presets mit Region-Switcher — Anthropic, OpenAI, Google, DeepSeek und mehr |
|
||||
| **Claude Code** | Keine Konfiguration — verwendet Claude Agent SDK mit lokalem OAuth |
|
||||
| **Codex CLI** | In den Agenteneinstellungen verbinden (`Cmd+,`) |
|
||||
| **OpenCode** | In den Agenteneinstellungen verbinden (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` dann in den Agenteneinstellungen verbinden (`Cmd+,`) |
|
||||
| **Gemini CLI** | In den Agenteneinstellungen verbinden (`Cmd+,`) |
|
||||
| **Claude Code** | Keine Konfiguration — verwendet Claude Agent SDK mit lokalem OAuth |
|
||||
| **Codex CLI** | In den Agenteneinstellungen verbinden (`Cmd+,`) |
|
||||
| **OpenCode** | In den Agenteneinstellungen verbinden (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` dann in den Agenteneinstellungen verbinden (`Cmd+,`) |
|
||||
| **Gemini CLI** | In den Agenteneinstellungen verbinden (`Cmd+,`) |
|
||||
|
||||
**Modell-Fähigkeitsprofile** — passt Prompts, Thinking-Modus und Timeouts automatisch pro Modellstufe an. Modelle der Vollstufe (Claude) erhalten vollständige Prompts; Standardstufe (GPT-4o, Gemini, DeepSeek) deaktiviert Thinking; Basisstufe (MiniMax, Qwen, Llama, Mistral) erhält vereinfachte verschachtelte JSON-Prompts für maximale Zuverlässigkeit.
|
||||
|
||||
**i18n** — Vollständige Interface-Lokalisierung in 15 Sprachen: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
|
||||
|
||||
**MCP-Server**
|
||||
|
||||
- Eingebauter MCP-Server — Ein-Klick-Installation in Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
|
||||
- Automatische Node.js-Erkennung — falls nicht installiert, automatischer Fallback auf HTTP-Transport und automatischer Start des MCP-HTTP-Servers
|
||||
- Design-Automatisierung vom Terminal aus: `.op`-Dateien über jeden MCP-kompatiblen Agenten lesen, erstellen und bearbeiten
|
||||
|
|
@ -202,6 +204,7 @@ docker build --target full -t openpencil-full .
|
|||
- Mehrseitige Unterstützung — Seiten erstellen, umbenennen, neu ordnen und duplizieren über MCP-Tools
|
||||
|
||||
**Codegenerierung**
|
||||
|
||||
- React + Tailwind CSS, HTML + CSS, CSS Variables
|
||||
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
|
||||
|
||||
|
|
@ -229,6 +232,7 @@ Unterstützt drei Eingabemethoden: Inline-String, `@filepath` (aus Datei lesen)
|
|||
## Funktionen
|
||||
|
||||
**Canvas und Zeichnen**
|
||||
|
||||
- Unendliche Canvas mit Pan, Zoom, intelligenten Ausrichtungshilfslinien und Einrasten
|
||||
- Rechteck, Ellipse, Linie, Polygon, Stift (Bezier), Frame, Text
|
||||
- Boolesche Operationen — Vereinigung, Subtraktion, Schnittmenge mit kontextbezogener Werkzeugleiste
|
||||
|
|
@ -237,15 +241,18 @@ Unterstützt drei Eingabemethoden: Inline-String, `@filepath` (aus Datei lesen)
|
|||
- Mehrseitige Dokumente mit Tab-Navigation
|
||||
|
||||
**Designsystem**
|
||||
|
||||
- Designvariablen — Farb-, Zahl- und Text-Tokens mit `$variable`-Referenzen
|
||||
- Multi-Theme-Unterstützung — mehrere Achsen, jeweils mit Varianten (Hell/Dunkel, Kompakt/Komfortabel)
|
||||
- Komponentensystem — wiederverwendbare Komponenten mit Instanzen und Überschreibungen
|
||||
- CSS-Synchronisierung — automatisch generierte benutzerdefinierte Eigenschaften, `var(--name)` in der Code-Ausgabe
|
||||
|
||||
**Figma-Import**
|
||||
|
||||
- `.fig`-Dateien importieren mit erhaltenem Layout, Füllungen, Konturen, Effekten, Text, Bildern und Vektoren
|
||||
|
||||
**Desktop-App**
|
||||
|
||||
- Natives macOS, Windows und Linux über Electron
|
||||
- `.op`-Dateizuordnung — Doppelklick zum Öffnen, Einzelinstanzsperre
|
||||
- Automatische Aktualisierung über GitHub Releases
|
||||
|
|
@ -253,17 +260,17 @@ Unterstützt drei Eingabemethoden: Inline-String, `@filepath` (aus Datei lesen)
|
|||
|
||||
## Technologie-Stack
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Canvas** | CanvasKit/Skia (WASM, GPU-beschleunigt) |
|
||||
| **State** | Zustand v5 |
|
||||
| **Server** | Nitro |
|
||||
| **Desktop** | Electron 35 |
|
||||
| **CLI** | `op` — Terminal-Steuerung, Batch-Design-DSL, Code-Export |
|
||||
| **KI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Laufzeit** | Bun · Vite 7 |
|
||||
| **Dateiformat** | `.op` — JSON-basiert, menschenlesbar, Git-freundlich |
|
||||
| | |
|
||||
| --------------- | -------------------------------------------------------------------------------- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Canvas** | CanvasKit/Skia (WASM, GPU-beschleunigt) |
|
||||
| **State** | Zustand v5 |
|
||||
| **Server** | Nitro |
|
||||
| **Desktop** | Electron 35 |
|
||||
| **CLI** | `op` — Terminal-Steuerung, Batch-Design-DSL, Code-Export |
|
||||
| **KI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Laufzeit** | Bun · Vite 7 |
|
||||
| **Dateiformat** | `.op` — JSON-basiert, menschenlesbar, Git-freundlich |
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
|
|
@ -304,21 +311,21 @@ openpencil/
|
|||
|
||||
## Tastaturkürzel
|
||||
|
||||
| Taste | Aktion | | Taste | Aktion |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | Auswählen | | `Cmd+S` | Speichern |
|
||||
| `R` | Rechteck | | `Cmd+Z` | Rückgängig |
|
||||
| `O` | Ellipse | | `Cmd+Shift+Z` | Wiederholen |
|
||||
| `L` | Linie | | `Cmd+C/X/V/D` | Kopieren/Ausschneiden/Einfügen/Duplizieren |
|
||||
| `T` | Text | | `Cmd+G` | Gruppieren |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Gruppierung aufheben |
|
||||
| `P` | Stiftwerkzeug | | `Cmd+Shift+E` | Exportieren |
|
||||
| `H` | Hand (Pan) | | `Cmd+Shift+C` | Code-Panel |
|
||||
| `Del` | Löschen | | `Cmd+Shift+V` | Variablen-Panel |
|
||||
| `[ / ]` | Reihenfolge ändern | | `Cmd+J` | KI-Chat |
|
||||
| Pfeiltasten | 1px verschieben | | `Cmd+,` | Agenteneinstellungen |
|
||||
| `Cmd+Alt+U` | Boolesche Vereinigung | | `Cmd+Alt+S` | Boolesche Subtraktion |
|
||||
| `Cmd+Alt+I` | Boolesche Schnittmenge | | | |
|
||||
| Taste | Aktion | | Taste | Aktion |
|
||||
| ----------- | ---------------------- | --- | ------------- | ------------------------------------------ |
|
||||
| `V` | Auswählen | | `Cmd+S` | Speichern |
|
||||
| `R` | Rechteck | | `Cmd+Z` | Rückgängig |
|
||||
| `O` | Ellipse | | `Cmd+Shift+Z` | Wiederholen |
|
||||
| `L` | Linie | | `Cmd+C/X/V/D` | Kopieren/Ausschneiden/Einfügen/Duplizieren |
|
||||
| `T` | Text | | `Cmd+G` | Gruppieren |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Gruppierung aufheben |
|
||||
| `P` | Stiftwerkzeug | | `Cmd+Shift+E` | Exportieren |
|
||||
| `H` | Hand (Pan) | | `Cmd+Shift+C` | Code-Panel |
|
||||
| `Del` | Löschen | | `Cmd+Shift+V` | Variablen-Panel |
|
||||
| `[ / ]` | Reihenfolge ändern | | `Cmd+J` | KI-Chat |
|
||||
| Pfeiltasten | 1px verschieben | | `Cmd+,` | Agenteneinstellungen |
|
||||
| `Cmd+Alt+U` | Boolesche Vereinigung | | `Cmd+Alt+S` | Boolesche Subtraktion |
|
||||
| `Cmd+Alt+I` | Boolesche Schnittmenge | | | |
|
||||
|
||||
## Skripte
|
||||
|
||||
|
|
|
|||
91
README.es.md
91
README.es.md
|
|
@ -124,15 +124,15 @@ bun run electron:dev
|
|||
|
||||
Hay varias variantes de imagen disponibles — elige la que se ajuste a tus necesidades:
|
||||
|
||||
| Imagen | Tamaño | Incluye |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 MB | Solo aplicación web |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | Todas las herramientas CLI |
|
||||
| Imagen | Tamaño | Incluye |
|
||||
| ---------------------------- | ------- | -------------------------- |
|
||||
| `openpencil:latest` | ~226 MB | Solo aplicación web |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | Todas las herramientas CLI |
|
||||
|
||||
**Ejecutar (solo web):**
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ docker build --target full -t openpencil-full .
|
|||
## Diseño Nativo de IA
|
||||
|
||||
**De Prompt a Interfaz**
|
||||
|
||||
- **Texto a diseño** — describe una página y se genera en el lienzo en tiempo real con animación de transmisión
|
||||
- **Orquestador** — descompone páginas complejas en subtareas espaciales para generación en paralelo
|
||||
- **Modificación de diseño** — selecciona elementos y describe los cambios en lenguaje natural
|
||||
|
|
@ -180,20 +181,21 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
**Soporte Multiagente**
|
||||
|
||||
| Agente | Configuración |
|
||||
| --- | --- |
|
||||
| Agente | Configuración |
|
||||
| ------------------------------ | ---------------------------------------------------------------------------------------------------------- |
|
||||
| **Integrado (9+ proveedores)** | Selecciona de preajustes de proveedores con selector de región — Anthropic, OpenAI, Google, DeepSeek y más |
|
||||
| **Claude Code** | Sin configuración — usa Claude Agent SDK con OAuth local |
|
||||
| **Codex CLI** | Conectar en Configuración de Agente (`Cmd+,`) |
|
||||
| **OpenCode** | Conectar en Configuración de Agente (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` y luego conectar en Configuración de Agente (`Cmd+,`) |
|
||||
| **Gemini CLI** | Conectar en Configuración de Agente (`Cmd+,`) |
|
||||
| **Claude Code** | Sin configuración — usa Claude Agent SDK con OAuth local |
|
||||
| **Codex CLI** | Conectar en Configuración de Agente (`Cmd+,`) |
|
||||
| **OpenCode** | Conectar en Configuración de Agente (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` y luego conectar en Configuración de Agente (`Cmd+,`) |
|
||||
| **Gemini CLI** | Conectar en Configuración de Agente (`Cmd+,`) |
|
||||
|
||||
**Perfiles de Capacidad de Modelos** — adapta automáticamente los prompts, el modo de pensamiento y los tiempos de espera según el nivel del modelo. Los modelos de nivel completo (Claude) reciben prompts completos; los de nivel estándar (GPT-4o, Gemini, DeepSeek) desactivan el pensamiento; los de nivel básico (MiniMax, Qwen, Llama, Mistral) reciben prompts simplificados de JSON anidado para máxima fiabilidad.
|
||||
|
||||
**i18n** — Localización completa de la interfaz en 15 idiomas: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
|
||||
|
||||
**Servidor MCP**
|
||||
|
||||
- Servidor MCP integrado — instalación con un clic en Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
|
||||
- Detección automática de Node.js — si no está instalado, recurre automáticamente al transporte HTTP e inicia el servidor MCP HTTP
|
||||
- Automatización de diseño desde la terminal: leer, crear y modificar archivos `.op` a través de cualquier agente compatible con MCP
|
||||
|
|
@ -202,6 +204,7 @@ docker build --target full -t openpencil-full .
|
|||
- Soporte multipágina — crear, renombrar, reordenar y duplicar páginas mediante herramientas MCP
|
||||
|
||||
**Generación de Código**
|
||||
|
||||
- React + Tailwind CSS, HTML + CSS, CSS Variables
|
||||
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
|
||||
|
||||
|
|
@ -229,6 +232,7 @@ Soporta tres métodos de entrada: cadena inline, `@filepath` (leer desde archivo
|
|||
## Características
|
||||
|
||||
**Lienzo y Dibujo**
|
||||
|
||||
- Lienzo infinito con panorámica, zoom, guías de alineación inteligentes y ajuste
|
||||
- Rectángulo, Elipse, Línea, Polígono, Pluma (Bezier), Frame, Texto
|
||||
- Operaciones booleanas — unión, resta, intersección con barra de herramientas contextual
|
||||
|
|
@ -237,15 +241,18 @@ Soporta tres métodos de entrada: cadena inline, `@filepath` (leer desde archivo
|
|||
- Documentos multipágina con navegación por pestañas
|
||||
|
||||
**Sistema de Diseño**
|
||||
|
||||
- Variables de diseño — tokens de color, número y texto con referencias `$variable`
|
||||
- Soporte multitema — múltiples ejes, cada uno con variantes (Claro/Oscuro, Compacto/Cómodo)
|
||||
- Sistema de componentes — componentes reutilizables con instancias y sobreescrituras
|
||||
- Sincronización CSS — propiedades personalizadas autogeneradas, `var(--name)` en la salida de código
|
||||
|
||||
**Importación de Figma**
|
||||
|
||||
- Importa archivos `.fig` conservando diseño, rellenos, trazos, efectos, texto, imágenes y vectores
|
||||
|
||||
**Aplicación de Escritorio**
|
||||
|
||||
- Compatible de forma nativa con macOS, Windows y Linux mediante Electron
|
||||
- Asociación de archivos `.op` — doble clic para abrir, bloqueo de instancia única
|
||||
- Actualización automática desde GitHub Releases
|
||||
|
|
@ -253,17 +260,17 @@ Soporta tres métodos de entrada: cadena inline, `@filepath` (leer desde archivo
|
|||
|
||||
## Stack Tecnológico
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Lienzo** | CanvasKit/Skia (WASM, acelerado por GPU) |
|
||||
| **Estado** | Zustand v5 |
|
||||
| **Servidor** | Nitro |
|
||||
| **Escritorio** | Electron 35 |
|
||||
| **CLI** | `op` — control desde terminal, DSL de diseño por lotes, exportación de código |
|
||||
| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Runtime** | Bun · Vite 7 |
|
||||
| **Formato de archivo** | `.op` — basado en JSON, legible por humanos, compatible con Git |
|
||||
| | |
|
||||
| ---------------------- | -------------------------------------------------------------------------------- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Lienzo** | CanvasKit/Skia (WASM, acelerado por GPU) |
|
||||
| **Estado** | Zustand v5 |
|
||||
| **Servidor** | Nitro |
|
||||
| **Escritorio** | Electron 35 |
|
||||
| **CLI** | `op` — control desde terminal, DSL de diseño por lotes, exportación de código |
|
||||
| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Runtime** | Bun · Vite 7 |
|
||||
| **Formato de archivo** | `.op` — basado en JSON, legible por humanos, compatible con Git |
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
|
|
@ -304,21 +311,21 @@ openpencil/
|
|||
|
||||
## Atajos de Teclado
|
||||
|
||||
| Tecla | Acción | | Tecla | Acción |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | Seleccionar | | `Cmd+S` | Guardar |
|
||||
| `R` | Rectángulo | | `Cmd+Z` | Deshacer |
|
||||
| `O` | Elipse | | `Cmd+Shift+Z` | Rehacer |
|
||||
| `L` | Línea | | `Cmd+C/X/V/D` | Copiar/Cortar/Pegar/Duplicar |
|
||||
| `T` | Texto | | `Cmd+G` | Agrupar |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Desagrupar |
|
||||
| `P` | Herramienta pluma | | `Cmd+Shift+E` | Exportar |
|
||||
| `H` | Mano (panorámica) | | `Cmd+Shift+C` | Panel de código |
|
||||
| `Del` | Eliminar | | `Cmd+Shift+V` | Panel de variables |
|
||||
| `[ / ]` | Reordenar | | `Cmd+J` | Chat de IA |
|
||||
| Flechas | Mover 1px | | `Cmd+,` | Configuración de agente |
|
||||
| `Cmd+Alt+U` | Unión booleana | | `Cmd+Alt+S` | Resta booleana |
|
||||
| `Cmd+Alt+I` | Intersección booleana | | | |
|
||||
| Tecla | Acción | | Tecla | Acción |
|
||||
| ----------- | --------------------- | --- | ------------- | ---------------------------- |
|
||||
| `V` | Seleccionar | | `Cmd+S` | Guardar |
|
||||
| `R` | Rectángulo | | `Cmd+Z` | Deshacer |
|
||||
| `O` | Elipse | | `Cmd+Shift+Z` | Rehacer |
|
||||
| `L` | Línea | | `Cmd+C/X/V/D` | Copiar/Cortar/Pegar/Duplicar |
|
||||
| `T` | Texto | | `Cmd+G` | Agrupar |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Desagrupar |
|
||||
| `P` | Herramienta pluma | | `Cmd+Shift+E` | Exportar |
|
||||
| `H` | Mano (panorámica) | | `Cmd+Shift+C` | Panel de código |
|
||||
| `Del` | Eliminar | | `Cmd+Shift+V` | Panel de variables |
|
||||
| `[ / ]` | Reordenar | | `Cmd+J` | Chat de IA |
|
||||
| Flechas | Mover 1px | | `Cmd+,` | Configuración de agente |
|
||||
| `Cmd+Alt+U` | Unión booleana | | `Cmd+Alt+S` | Resta booleana |
|
||||
| `Cmd+Alt+I` | Intersección booleana | | | |
|
||||
|
||||
## Scripts
|
||||
|
||||
|
|
|
|||
91
README.fr.md
91
README.fr.md
|
|
@ -124,15 +124,15 @@ bun run electron:dev
|
|||
|
||||
Plusieurs variantes d'images sont disponibles — choisissez celle qui correspond à vos besoins :
|
||||
|
||||
| Image | Taille | Contenu |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 Mo | Application web uniquement |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 Go | Tous les outils CLI |
|
||||
| Image | Taille | Contenu |
|
||||
| ---------------------------- | ------- | -------------------------- |
|
||||
| `openpencil:latest` | ~226 Mo | Application web uniquement |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 Go | Tous les outils CLI |
|
||||
|
||||
**Exécuter (web uniquement) :**
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ docker build --target full -t openpencil-full .
|
|||
## Design natif IA
|
||||
|
||||
**Du prompt à l'interface**
|
||||
|
||||
- **Texte vers design** — décrivez une page, elle est générée en temps réel sur le canevas avec une animation en streaming
|
||||
- **Orchestrateur** — décompose les pages complexes en sous-tâches spatiales pour une génération parallèle
|
||||
- **Modification de design** — sélectionnez des éléments, puis décrivez les modifications en langage naturel
|
||||
|
|
@ -180,20 +181,21 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
**Support multi-agents**
|
||||
|
||||
| Agent | Configuration |
|
||||
| --- | --- |
|
||||
| Agent | Configuration |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Intégré (9+ fournisseurs)** | Choisissez parmi les préréglages de fournisseurs avec sélecteur de région — Anthropic, OpenAI, Google, DeepSeek et plus |
|
||||
| **Claude Code** | Aucune configuration — utilise le Claude Agent SDK avec OAuth local |
|
||||
| **Codex CLI** | Connecter dans les Paramètres de l'agent (`Cmd+,`) |
|
||||
| **OpenCode** | Connecter dans les Paramètres de l'agent (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` puis connecter dans les Paramètres de l'agent (`Cmd+,`) |
|
||||
| **Gemini CLI** | Connecter dans les Paramètres de l'agent (`Cmd+,`) |
|
||||
| **Claude Code** | Aucune configuration — utilise le Claude Agent SDK avec OAuth local |
|
||||
| **Codex CLI** | Connecter dans les Paramètres de l'agent (`Cmd+,`) |
|
||||
| **OpenCode** | Connecter dans les Paramètres de l'agent (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` puis connecter dans les Paramètres de l'agent (`Cmd+,`) |
|
||||
| **Gemini CLI** | Connecter dans les Paramètres de l'agent (`Cmd+,`) |
|
||||
|
||||
**Profils de capacités des modèles** — adapte automatiquement les prompts, le mode de réflexion et les délais d'attente par niveau de modèle. Les modèles de niveau complet (Claude) reçoivent des prompts complets ; le niveau standard (GPT-4o, Gemini, DeepSeek) désactive la réflexion ; le niveau basique (MiniMax, Qwen, Llama, Mistral) reçoit des prompts JSON imbriqués simplifiés pour une fiabilité maximale.
|
||||
|
||||
**i18n** — Localisation complète de l'interface en 15 langues : English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
|
||||
|
||||
**Serveur MCP**
|
||||
|
||||
- Serveur MCP intégré — installation en un clic dans les CLI Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot
|
||||
- Détection automatique de Node.js — si non installé, bascule vers le transport HTTP et démarre automatiquement le serveur MCP HTTP
|
||||
- Automatisation du design depuis le terminal : lire, créer et modifier des fichiers `.op` via tout agent compatible MCP
|
||||
|
|
@ -202,6 +204,7 @@ docker build --target full -t openpencil-full .
|
|||
- Support multi-pages — créer, renommer, réordonner et dupliquer des pages via les outils MCP
|
||||
|
||||
**Génération de code**
|
||||
|
||||
- React + Tailwind CSS, HTML + CSS, CSS Variables
|
||||
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
|
||||
|
||||
|
|
@ -229,6 +232,7 @@ Supporte trois méthodes d'entrée : chaîne en ligne, `@filepath` (lecture depu
|
|||
## Fonctionnalités
|
||||
|
||||
**Canevas et dessin**
|
||||
|
||||
- Canevas infini avec panoramique, zoom, guides d'alignement intelligents et magnétisme
|
||||
- Rectangle, Ellipse, Ligne, Polygone, Plume (Bézier), Frame, Texte
|
||||
- Opérations booléennes — union, soustraction, intersection avec barre d'outils contextuelle
|
||||
|
|
@ -237,15 +241,18 @@ Supporte trois méthodes d'entrée : chaîne en ligne, `@filepath` (lecture depu
|
|||
- Documents multi-pages avec navigation par onglets
|
||||
|
||||
**Système de design**
|
||||
|
||||
- Variables de design — tokens de couleur, nombre et chaîne avec références `$variable`
|
||||
- Support multi-thèmes — plusieurs axes, chacun avec des variantes (Clair/Sombre, Compact/Confortable)
|
||||
- Système de composants — composants réutilisables avec instances et substitutions
|
||||
- Synchronisation CSS — propriétés personnalisées auto-générées, `var(--name)` dans la sortie de code
|
||||
|
||||
**Import Figma**
|
||||
|
||||
- Importer des fichiers `.fig` en préservant la mise en page, les remplissages, les contours, les effets, le texte, les images et les vecteurs
|
||||
|
||||
**Application de bureau**
|
||||
|
||||
- macOS, Windows et Linux natifs via Electron
|
||||
- Association de fichiers `.op` — double-cliquez pour ouvrir, verrouillage d'instance unique
|
||||
- Mise à jour automatique depuis GitHub Releases
|
||||
|
|
@ -253,17 +260,17 @@ Supporte trois méthodes d'entrée : chaîne en ligne, `@filepath` (lecture depu
|
|||
|
||||
## Stack technique
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Canevas** | CanvasKit/Skia (WASM, accélération GPU) |
|
||||
| **État** | Zustand v5 |
|
||||
| **Serveur** | Nitro |
|
||||
| **Bureau** | Electron 35 |
|
||||
| **CLI** | `op` — contrôle depuis le terminal, DSL de design par lots, export de code |
|
||||
| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Runtime** | Bun · Vite 7 |
|
||||
| **Format de fichier** | `.op` — basé sur JSON, lisible par l'humain, compatible Git |
|
||||
| | |
|
||||
| --------------------- | -------------------------------------------------------------------------------- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Canevas** | CanvasKit/Skia (WASM, accélération GPU) |
|
||||
| **État** | Zustand v5 |
|
||||
| **Serveur** | Nitro |
|
||||
| **Bureau** | Electron 35 |
|
||||
| **CLI** | `op` — contrôle depuis le terminal, DSL de design par lots, export de code |
|
||||
| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Runtime** | Bun · Vite 7 |
|
||||
| **Format de fichier** | `.op` — basé sur JSON, lisible par l'humain, compatible Git |
|
||||
|
||||
## Structure du projet
|
||||
|
||||
|
|
@ -304,21 +311,21 @@ openpencil/
|
|||
|
||||
## Raccourcis clavier
|
||||
|
||||
| Touche | Action | | Touche | Action |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | Sélectionner | | `Cmd+S` | Enregistrer |
|
||||
| `R` | Rectangle | | `Cmd+Z` | Annuler |
|
||||
| `O` | Ellipse | | `Cmd+Shift+Z` | Rétablir |
|
||||
| `L` | Ligne | | `Cmd+C/X/V/D` | Copier/Couper/Coller/Dupliquer |
|
||||
| `T` | Texte | | `Cmd+G` | Grouper |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Dégrouper |
|
||||
| `P` | Outil plume | | `Cmd+Shift+E` | Exporter |
|
||||
| `H` | Main (panoramique) | | `Cmd+Shift+C` | Panneau de code |
|
||||
| `Del` | Supprimer | | `Cmd+Shift+V` | Panneau des variables |
|
||||
| `[ / ]` | Réordonner | | `Cmd+J` | Chat IA |
|
||||
| Flèches | Déplacer de 1px | | `Cmd+,` | Paramètres de l'agent |
|
||||
| `Cmd+Alt+U` | Union booléenne | | `Cmd+Alt+S` | Soustraction booléenne |
|
||||
| `Cmd+Alt+I` | Intersection booléenne | | | |
|
||||
| Touche | Action | | Touche | Action |
|
||||
| ----------- | ---------------------- | --- | ------------- | ------------------------------ |
|
||||
| `V` | Sélectionner | | `Cmd+S` | Enregistrer |
|
||||
| `R` | Rectangle | | `Cmd+Z` | Annuler |
|
||||
| `O` | Ellipse | | `Cmd+Shift+Z` | Rétablir |
|
||||
| `L` | Ligne | | `Cmd+C/X/V/D` | Copier/Couper/Coller/Dupliquer |
|
||||
| `T` | Texte | | `Cmd+G` | Grouper |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Dégrouper |
|
||||
| `P` | Outil plume | | `Cmd+Shift+E` | Exporter |
|
||||
| `H` | Main (panoramique) | | `Cmd+Shift+C` | Panneau de code |
|
||||
| `Del` | Supprimer | | `Cmd+Shift+V` | Panneau des variables |
|
||||
| `[ / ]` | Réordonner | | `Cmd+J` | Chat IA |
|
||||
| Flèches | Déplacer de 1px | | `Cmd+,` | Paramètres de l'agent |
|
||||
| `Cmd+Alt+U` | Union booléenne | | `Cmd+Alt+S` | Soustraction booléenne |
|
||||
| `Cmd+Alt+I` | Intersection booléenne | | | |
|
||||
|
||||
## Scripts
|
||||
|
||||
|
|
|
|||
91
README.hi.md
91
README.hi.md
|
|
@ -124,15 +124,15 @@ bun run electron:dev
|
|||
|
||||
कई इमेज वेरिएंट उपलब्ध हैं — अपनी ज़रूरत के अनुसार चुनें:
|
||||
|
||||
| इमेज | आकार | शामिल |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 MB | केवल वेब ऐप |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | सभी CLI टूल |
|
||||
| इमेज | आकार | शामिल |
|
||||
| ---------------------------- | ------- | -------------------- |
|
||||
| `openpencil:latest` | ~226 MB | केवल वेब ऐप |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | सभी CLI टूल |
|
||||
|
||||
**चलाएँ (केवल वेब):**
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ docker build --target full -t openpencil-full .
|
|||
## AI-नेटिव डिज़ाइन
|
||||
|
||||
**प्रॉम्प्ट से UI तक**
|
||||
|
||||
- **टेक्स्ट-टू-डिज़ाइन** — एक पेज का विवरण दें, और स्ट्रीमिंग एनिमेशन के साथ रियल-टाइम में कैनवास पर जनरेट करें
|
||||
- **ऑर्केस्ट्रेटर** — जटिल पेजों को समानांतर जनरेशन के लिए स्थानिक सब-टास्क में विभाजित करता है
|
||||
- **डिज़ाइन संशोधन** — एलिमेंट चुनें, फिर प्राकृतिक भाषा में बदलाव का विवरण दें
|
||||
|
|
@ -180,20 +181,21 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
**मल्टी-एजेंट सपोर्ट**
|
||||
|
||||
| एजेंट | सेटअप |
|
||||
| --- | --- |
|
||||
| एजेंट | सेटअप |
|
||||
| ------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| **बिल्ट-इन (9+ प्रदाता)** | प्रदाता प्रीसेट से चुनें और क्षेत्र स्विच करें — Anthropic, OpenAI, Google, DeepSeek और अन्य |
|
||||
| **Claude Code** | कोई कॉन्फ़िग नहीं — लोकल OAuth के साथ Claude Agent SDK का उपयोग करता है |
|
||||
| **Codex CLI** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) |
|
||||
| **OpenCode** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` फिर एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) |
|
||||
| **Gemini CLI** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) |
|
||||
| **Claude Code** | कोई कॉन्फ़िग नहीं — लोकल OAuth के साथ Claude Agent SDK का उपयोग करता है |
|
||||
| **Codex CLI** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) |
|
||||
| **OpenCode** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` फिर एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) |
|
||||
| **Gemini CLI** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) |
|
||||
|
||||
**मॉडल क्षमता प्रोफ़ाइल** — प्रत्येक मॉडल टियर के अनुसार प्रॉम्प्ट, थिंकिंग मोड और टाइमआउट को स्वचालित रूप से अनुकूलित करता है। फुल-टियर मॉडल (Claude) को पूर्ण प्रॉम्प्ट मिलते हैं; स्टैंडर्ड-टियर (GPT-4o, Gemini, DeepSeek) में थिंकिंग अक्षम होती है; बेसिक-टियर (MiniMax, Qwen, Llama, Mistral) को अधिकतम विश्वसनीयता के लिए सरलीकृत नेस्टेड-JSON प्रॉम्प्ट मिलते हैं।
|
||||
|
||||
**i18n** — 15 भाषाओं में पूर्ण इंटरफ़ेस स्थानीयकरण: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia।
|
||||
|
||||
**MCP सर्वर**
|
||||
|
||||
- बिल्ट-इन MCP सर्वर — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs में वन-क्लिक इंस्टॉल
|
||||
- Node.js स्वचालित पहचान — यदि इंस्टॉल नहीं है तो HTTP ट्रांसपोर्ट पर स्वचालित फ़ॉलबैक और MCP HTTP सर्वर ऑटो-स्टार्ट
|
||||
- टर्मिनल से डिज़ाइन ऑटोमेशन: किसी भी MCP-संगत एजेंट के ज़रिए `.op` फ़ाइलें पढ़ें, बनाएँ और संपादित करें
|
||||
|
|
@ -202,6 +204,7 @@ docker build --target full -t openpencil-full .
|
|||
- मल्टी-पेज सपोर्ट — MCP टूल के ज़रिए पेज बनाएँ, नाम बदलें, क्रम बदलें और डुप्लिकेट करें
|
||||
|
||||
**कोड जनरेशन**
|
||||
|
||||
- React + Tailwind CSS, HTML + CSS, CSS Variables
|
||||
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
|
||||
|
||||
|
|
@ -229,6 +232,7 @@ cat design.dsl | op design - # stdin से पाइप करें
|
|||
## विशेषताएँ
|
||||
|
||||
**कैनवास और ड्रॉइंग**
|
||||
|
||||
- पैन, ज़ूम, स्मार्ट अलाइनमेंट गाइड और स्नैपिंग के साथ अनंत कैनवास
|
||||
- Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text
|
||||
- बूलियन ऑपरेशन — संयोजन, घटाना, प्रतिच्छेदन संदर्भ टूलबार के साथ
|
||||
|
|
@ -237,15 +241,18 @@ cat design.dsl | op design - # stdin से पाइप करें
|
|||
- टैब नेवीगेशन के साथ मल्टी-पेज दस्तावेज़
|
||||
|
||||
**डिज़ाइन सिस्टम**
|
||||
|
||||
- डिज़ाइन वेरिएबल — `$variable` रेफ़रेंस के साथ कलर, नंबर, स्ट्रिंग टोकन
|
||||
- मल्टी-थीम सपोर्ट — कई अक्ष, प्रत्येक में वेरिएंट (Light/Dark, Compact/Comfortable)
|
||||
- कम्पोनेंट सिस्टम — इंस्टेंस और ओवरराइड के साथ पुन: उपयोगी कम्पोनेंट
|
||||
- CSS सिंक — स्वतः-जनरेटेड कस्टम प्रॉपर्टीज़, कोड आउटपुट में `var(--name)`
|
||||
|
||||
**Figma इम्पोर्ट**
|
||||
|
||||
- लेआउट, फ़िल, स्ट्रोक, इफ़ेक्ट, टेक्स्ट, इमेज और वेक्टर को सुरक्षित रखते हुए `.fig` फ़ाइलें इम्पोर्ट करें
|
||||
|
||||
**डेस्कटॉप ऐप**
|
||||
|
||||
- Electron के ज़रिए नेटिव macOS, Windows और Linux सपोर्ट
|
||||
- `.op` फ़ाइल एसोसिएशन — डबल-क्लिक से खोलें, सिंगल-इंस्टेंस लॉक
|
||||
- GitHub Releases से ऑटो-अपडेट
|
||||
|
|
@ -253,17 +260,17 @@ cat design.dsl | op design - # stdin से पाइप करें
|
|||
|
||||
## टेक स्टैक
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **फ्रंटएंड** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **कैनवास** | CanvasKit/Skia (WASM, GPU-एक्सेलेरेटेड) |
|
||||
| **स्टेट** | Zustand v5 |
|
||||
| **सर्वर** | Nitro |
|
||||
| **डेस्कटॉप** | Electron 35 |
|
||||
| **CLI** | `op` — टर्मिनल नियंत्रण, बैच डिज़ाइन DSL, कोड एक्सपोर्ट |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **रनटाइम** | Bun · Vite 7 |
|
||||
| **फ़ाइल फ़ॉर्मेट** | `.op` — JSON-आधारित, मानव-पठनीय, Git-फ्रेंडली |
|
||||
| | |
|
||||
| ------------------ | -------------------------------------------------------------------------------- |
|
||||
| **फ्रंटएंड** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **कैनवास** | CanvasKit/Skia (WASM, GPU-एक्सेलेरेटेड) |
|
||||
| **स्टेट** | Zustand v5 |
|
||||
| **सर्वर** | Nitro |
|
||||
| **डेस्कटॉप** | Electron 35 |
|
||||
| **CLI** | `op` — टर्मिनल नियंत्रण, बैच डिज़ाइन DSL, कोड एक्सपोर्ट |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **रनटाइम** | Bun · Vite 7 |
|
||||
| **फ़ाइल फ़ॉर्मेट** | `.op` — JSON-आधारित, मानव-पठनीय, Git-फ्रेंडली |
|
||||
|
||||
## प्रोजेक्ट संरचना
|
||||
|
||||
|
|
@ -304,21 +311,21 @@ openpencil/
|
|||
|
||||
## कीबोर्ड शॉर्टकट
|
||||
|
||||
| कुंजी | क्रिया | | कुंजी | क्रिया |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | चुनें | | `Cmd+S` | सहेजें |
|
||||
| `R` | Rectangle | | `Cmd+Z` | पूर्ववत करें |
|
||||
| `O` | Ellipse | | `Cmd+Shift+Z` | फिर से करें |
|
||||
| `L` | Line | | `Cmd+C/X/V/D` | कॉपी/कट/पेस्ट/डुप्लिकेट |
|
||||
| `T` | Text | | `Cmd+G` | ग्रुप करें |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | अनग्रुप करें |
|
||||
| `P` | Pen tool | | `Cmd+Shift+E` | एक्सपोर्ट |
|
||||
| `H` | Hand (pan) | | `Cmd+Shift+C` | कोड पैनल |
|
||||
| `Del` | हटाएँ | | `Cmd+Shift+V` | वेरिएबल पैनल |
|
||||
| `[ / ]` | क्रम बदलें | | `Cmd+J` | AI चैट |
|
||||
| Arrows | 1px नज | | `Cmd+,` | एजेंट सेटिंग्स |
|
||||
| `Cmd+Alt+U` | बूलियन संयोजन | | `Cmd+Alt+S` | बूलियन घटाना |
|
||||
| `Cmd+Alt+I` | बूलियन प्रतिच्छेदन | | | |
|
||||
| कुंजी | क्रिया | | कुंजी | क्रिया |
|
||||
| ----------- | ------------------ | --- | ------------- | ----------------------- |
|
||||
| `V` | चुनें | | `Cmd+S` | सहेजें |
|
||||
| `R` | Rectangle | | `Cmd+Z` | पूर्ववत करें |
|
||||
| `O` | Ellipse | | `Cmd+Shift+Z` | फिर से करें |
|
||||
| `L` | Line | | `Cmd+C/X/V/D` | कॉपी/कट/पेस्ट/डुप्लिकेट |
|
||||
| `T` | Text | | `Cmd+G` | ग्रुप करें |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | अनग्रुप करें |
|
||||
| `P` | Pen tool | | `Cmd+Shift+E` | एक्सपोर्ट |
|
||||
| `H` | Hand (pan) | | `Cmd+Shift+C` | कोड पैनल |
|
||||
| `Del` | हटाएँ | | `Cmd+Shift+V` | वेरिएबल पैनल |
|
||||
| `[ / ]` | क्रम बदलें | | `Cmd+J` | AI चैट |
|
||||
| Arrows | 1px नज | | `Cmd+,` | एजेंट सेटिंग्स |
|
||||
| `Cmd+Alt+U` | बूलियन संयोजन | | `Cmd+Alt+S` | बूलियन घटाना |
|
||||
| `Cmd+Alt+I` | बूलियन प्रतिच्छेदन | | | |
|
||||
|
||||
## स्क्रिप्ट
|
||||
|
||||
|
|
|
|||
91
README.id.md
91
README.id.md
|
|
@ -124,15 +124,15 @@ bun run electron:dev
|
|||
|
||||
Tersedia beberapa varian image — pilih yang sesuai kebutuhan Anda:
|
||||
|
||||
| Image | Ukuran | Termasuk |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 MB | Hanya aplikasi web |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | Semua alat CLI |
|
||||
| Image | Ukuran | Termasuk |
|
||||
| ---------------------------- | ------- | -------------------- |
|
||||
| `openpencil:latest` | ~226 MB | Hanya aplikasi web |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | Semua alat CLI |
|
||||
|
||||
**Jalankan (hanya web):**
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ docker build --target full -t openpencil-full .
|
|||
## Desain Berbasis AI
|
||||
|
||||
**Dari Prompt ke UI**
|
||||
|
||||
- **Teks ke desain** — deskripsikan halaman, dan hasilkan di kanvas secara real-time dengan animasi streaming
|
||||
- **Orkestrator** — menguraikan halaman kompleks menjadi sub-tugas spasial untuk pembuatan secara paralel
|
||||
- **Modifikasi desain** — pilih elemen, lalu deskripsikan perubahan dalam bahasa alami
|
||||
|
|
@ -180,20 +181,21 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
**Dukungan Multi-Agen**
|
||||
|
||||
| Agen | Pengaturan |
|
||||
| --- | --- |
|
||||
| Agen | Pengaturan |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| **Bawaan (9+ penyedia)** | Pilih dari preset penyedia dengan pemilih wilayah — Anthropic, OpenAI, Google, DeepSeek dan lainnya |
|
||||
| **Claude Code** | Tanpa konfigurasi — menggunakan Claude Agent SDK dengan OAuth lokal |
|
||||
| **Codex CLI** | Hubungkan di Pengaturan Agen (`Cmd+,`) |
|
||||
| **OpenCode** | Hubungkan di Pengaturan Agen (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` lalu hubungkan di Pengaturan Agen (`Cmd+,`) |
|
||||
| **Gemini CLI** | Hubungkan di Pengaturan Agen (`Cmd+,`) |
|
||||
| **Claude Code** | Tanpa konfigurasi — menggunakan Claude Agent SDK dengan OAuth lokal |
|
||||
| **Codex CLI** | Hubungkan di Pengaturan Agen (`Cmd+,`) |
|
||||
| **OpenCode** | Hubungkan di Pengaturan Agen (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` lalu hubungkan di Pengaturan Agen (`Cmd+,`) |
|
||||
| **Gemini CLI** | Hubungkan di Pengaturan Agen (`Cmd+,`) |
|
||||
|
||||
**Profil Kemampuan Model** — secara otomatis menyesuaikan prompt, mode thinking, dan timeout per tingkatan model. Model tingkat penuh (Claude) mendapat prompt lengkap; tingkat standar (GPT-4o, Gemini, DeepSeek) menonaktifkan thinking; tingkat dasar (MiniMax, Qwen, Llama, Mistral) mendapat prompt JSON bertingkat yang disederhanakan untuk keandalan maksimum.
|
||||
|
||||
**i18n** — Lokalisasi antarmuka lengkap dalam 15 bahasa: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
|
||||
|
||||
**Server MCP**
|
||||
|
||||
- Server MCP bawaan — instal satu klik ke Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
|
||||
- Deteksi otomatis Node.js — jika tidak terinstal, otomatis beralih ke transport HTTP dan memulai server MCP HTTP
|
||||
- Otomasi desain dari terminal: baca, buat, dan modifikasi file `.op` melalui agen yang kompatibel dengan MCP
|
||||
|
|
@ -202,6 +204,7 @@ docker build --target full -t openpencil-full .
|
|||
- Dukungan multi-halaman — buat, ganti nama, urutkan ulang, dan duplikasi halaman melalui alat MCP
|
||||
|
||||
**Pembuatan Kode**
|
||||
|
||||
- React + Tailwind CSS, HTML + CSS, CSS Variables
|
||||
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
|
||||
|
||||
|
|
@ -229,6 +232,7 @@ Mendukung tiga metode input: string inline, `@filepath` (baca dari file), atau `
|
|||
## Fitur
|
||||
|
||||
**Kanvas & Menggambar**
|
||||
|
||||
- Kanvas tak terbatas dengan pan, zoom, panduan perataan cerdas, dan snapping
|
||||
- Persegi panjang, Elips, Garis, Poligon, Pen (Bezier), Frame, Teks
|
||||
- Operasi Boolean — gabungan, kurangi, irisan dengan toolbar kontekstual
|
||||
|
|
@ -237,15 +241,18 @@ Mendukung tiga metode input: string inline, `@filepath` (baca dari file), atau `
|
|||
- Dokumen multi-halaman dengan navigasi tab
|
||||
|
||||
**Sistem Desain**
|
||||
|
||||
- Variabel desain — token warna, angka, string dengan referensi `$variable`
|
||||
- Dukungan multi-tema — beberapa sumbu, masing-masing dengan varian (Terang/Gelap, Ringkas/Nyaman)
|
||||
- Sistem komponen — komponen yang dapat digunakan ulang dengan instans dan penggantian
|
||||
- Sinkronisasi CSS — properti kustom yang dibuat otomatis, `var(--name)` dalam keluaran kode
|
||||
|
||||
**Impor Figma**
|
||||
|
||||
- Impor file `.fig` dengan tata letak, fill, stroke, efek, teks, gambar, dan vektor tetap terjaga
|
||||
|
||||
**Aplikasi Desktop**
|
||||
|
||||
- macOS, Windows, dan Linux native melalui Electron
|
||||
- Asosiasi file `.op` — klik dua kali untuk membuka, kunci instans tunggal
|
||||
- Pembaruan otomatis dari GitHub Releases
|
||||
|
|
@ -253,17 +260,17 @@ Mendukung tiga metode input: string inline, `@filepath` (baca dari file), atau `
|
|||
|
||||
## Tumpukan Teknologi
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Kanvas** | CanvasKit/Skia (WASM, akselerasi GPU) |
|
||||
| **State** | Zustand v5 |
|
||||
| **Server** | Nitro |
|
||||
| **Desktop** | Electron 35 |
|
||||
| **CLI** | `op` — kontrol terminal, batch design DSL, ekspor kode |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Runtime** | Bun · Vite 7 |
|
||||
| **Format file** | `.op` — berbasis JSON, mudah dibaca manusia, ramah Git |
|
||||
| | |
|
||||
| --------------- | -------------------------------------------------------------------------------- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Kanvas** | CanvasKit/Skia (WASM, akselerasi GPU) |
|
||||
| **State** | Zustand v5 |
|
||||
| **Server** | Nitro |
|
||||
| **Desktop** | Electron 35 |
|
||||
| **CLI** | `op` — kontrol terminal, batch design DSL, ekspor kode |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Runtime** | Bun · Vite 7 |
|
||||
| **Format file** | `.op` — berbasis JSON, mudah dibaca manusia, ramah Git |
|
||||
|
||||
## Struktur Proyek
|
||||
|
||||
|
|
@ -304,21 +311,21 @@ openpencil/
|
|||
|
||||
## Pintasan Keyboard
|
||||
|
||||
| Tombol | Aksi | | Tombol | Aksi |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | Pilih | | `Cmd+S` | Simpan |
|
||||
| `R` | Persegi panjang | | `Cmd+Z` | Batalkan |
|
||||
| `O` | Elips | | `Cmd+Shift+Z` | Ulangi |
|
||||
| `L` | Garis | | `Cmd+C/X/V/D` | Salin/Potong/Tempel/Duplikat |
|
||||
| `T` | Teks | | `Cmd+G` | Grup |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Pisahkan grup |
|
||||
| `P` | Alat pen | | `Cmd+Shift+E` | Ekspor |
|
||||
| `H` | Hand (pan) | | `Cmd+Shift+C` | Panel kode |
|
||||
| `Del` | Hapus | | `Cmd+Shift+V` | Panel variabel |
|
||||
| `[ / ]` | Ubah urutan | | `Cmd+J` | Chat AI |
|
||||
| Panah | Geser 1px | | `Cmd+,` | Pengaturan agen |
|
||||
| `Cmd+Alt+U` | Union Boolean | | `Cmd+Alt+S` | Subtract Boolean |
|
||||
| `Cmd+Alt+I` | Intersect Boolean | | | |
|
||||
| Tombol | Aksi | | Tombol | Aksi |
|
||||
| ----------- | ----------------- | --- | ------------- | ---------------------------- |
|
||||
| `V` | Pilih | | `Cmd+S` | Simpan |
|
||||
| `R` | Persegi panjang | | `Cmd+Z` | Batalkan |
|
||||
| `O` | Elips | | `Cmd+Shift+Z` | Ulangi |
|
||||
| `L` | Garis | | `Cmd+C/X/V/D` | Salin/Potong/Tempel/Duplikat |
|
||||
| `T` | Teks | | `Cmd+G` | Grup |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Pisahkan grup |
|
||||
| `P` | Alat pen | | `Cmd+Shift+E` | Ekspor |
|
||||
| `H` | Hand (pan) | | `Cmd+Shift+C` | Panel kode |
|
||||
| `Del` | Hapus | | `Cmd+Shift+V` | Panel variabel |
|
||||
| `[ / ]` | Ubah urutan | | `Cmd+J` | Chat AI |
|
||||
| Panah | Geser 1px | | `Cmd+,` | Pengaturan agen |
|
||||
| `Cmd+Alt+U` | Union Boolean | | `Cmd+Alt+S` | Subtract Boolean |
|
||||
| `Cmd+Alt+I` | Intersect Boolean | | | |
|
||||
|
||||
## Skrip
|
||||
|
||||
|
|
|
|||
91
README.ja.md
91
README.ja.md
|
|
@ -124,15 +124,15 @@ bun run electron:dev
|
|||
|
||||
複数のイメージバリアントが利用可能です — ニーズに合ったものを選択してください:
|
||||
|
||||
| イメージ | サイズ | 含まれるもの |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 MB | Web アプリのみ |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | すべての CLI ツール |
|
||||
| イメージ | サイズ | 含まれるもの |
|
||||
| ---------------------------- | ------- | -------------------- |
|
||||
| `openpencil:latest` | ~226 MB | Web アプリのみ |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | すべての CLI ツール |
|
||||
|
||||
**実行(Web のみ):**
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ docker build --target full -t openpencil-full .
|
|||
## AI ネイティブデザイン
|
||||
|
||||
**プロンプトから UI へ**
|
||||
|
||||
- **テキストからデザインへ** — ページを説明すると、ストリーミングアニメーションでリアルタイムにキャンバス上に生成
|
||||
- **オーケストレーター** — 複雑なページを空間サブタスクに分解し、並列生成をサポート
|
||||
- **デザイン修正** — 要素を選択し、自然言語で変更内容を記述
|
||||
|
|
@ -180,20 +181,21 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
**マルチエージェントサポート**
|
||||
|
||||
| エージェント | 設定方法 |
|
||||
| --- | --- |
|
||||
| エージェント | 設定方法 |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **ビルトイン(9+ プロバイダー)** | プロバイダープリセットから選択し、リージョンを切り替え — Anthropic、OpenAI、Google、DeepSeek など |
|
||||
| **Claude Code** | 設定不要 — ローカル OAuth で Claude Agent SDK を使用 |
|
||||
| **Codex CLI** | エージェント設定で接続(`Cmd+,`) |
|
||||
| **OpenCode** | エージェント設定で接続(`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` 後、エージェント設定で接続(`Cmd+,`) |
|
||||
| **Gemini CLI** | エージェント設定で接続(`Cmd+,`) |
|
||||
| **Claude Code** | 設定不要 — ローカル OAuth で Claude Agent SDK を使用 |
|
||||
| **Codex CLI** | エージェント設定で接続(`Cmd+,`) |
|
||||
| **OpenCode** | エージェント設定で接続(`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` 後、エージェント設定で接続(`Cmd+,`) |
|
||||
| **Gemini CLI** | エージェント設定で接続(`Cmd+,`) |
|
||||
|
||||
**モデル能力プロファイル** — モデルの階層に応じてプロンプト、シンキングモード、タイムアウトを自動適応。フル階層モデル(Claude)には完全なプロンプト、標準階層(GPT-4o、Gemini、DeepSeek)ではシンキングを無効化、ベーシック階層(MiniMax、Qwen、Llama、Mistral)には最大限の信頼性のために簡略化されたネスト JSON プロンプトを使用。
|
||||
|
||||
**i18n** — 15言語での完全なインターフェースローカライゼーション:English、简体中文、繁體中文、日本語、한국어、Français、Español、Deutsch、Português、Русский、हिन्दी、Türkçe、ไทย、Tiếng Việt、Bahasa Indonesia。
|
||||
|
||||
**MCP サーバー**
|
||||
|
||||
- 内蔵 MCP サーバー — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI にワンクリックでインストール
|
||||
- Node.js を自動検出 — 未インストールの場合は HTTP トランスポートに自動フォールバックし、MCP HTTP サーバーを自動起動
|
||||
- ターミナルからのデザイン自動化:MCP 対応エージェントを通じて `.op` ファイルの読み取り、作成、編集が可能
|
||||
|
|
@ -202,6 +204,7 @@ docker build --target full -t openpencil-full .
|
|||
- マルチページサポート — MCP ツールを通じてページの作成、名前変更、並べ替え、複製が可能
|
||||
|
||||
**コード生成**
|
||||
|
||||
- React + Tailwind CSS、HTML + CSS、CSS Variables
|
||||
- Vue、Svelte、Flutter、SwiftUI、Jetpack Compose、React Native
|
||||
|
||||
|
|
@ -229,6 +232,7 @@ cat design.dsl | op design - # stdin からパイプ入力
|
|||
## 機能
|
||||
|
||||
**キャンバスと描画**
|
||||
|
||||
- パン、ズーム、スマートアライメントガイド、スナッピング対応の無限キャンバス
|
||||
- 矩形、楕円、直線、多角形、ペン(ベジェ)、Frame、テキスト
|
||||
- ブーリアン演算 — 合体、型抜き、交差(コンテキストツールバー付き)
|
||||
|
|
@ -237,15 +241,18 @@ cat design.dsl | op design - # stdin からパイプ入力
|
|||
- タブナビゲーション付きマルチページドキュメント
|
||||
|
||||
**デザインシステム**
|
||||
|
||||
- デザイン変数 — カラー・数値・文字列トークン、`$variable` 参照付き
|
||||
- マルチテーマサポート — 複数のテーマ軸、各軸に複数バリアント(Light/Dark、Compact/Comfortable)
|
||||
- コンポーネントシステム — インスタンスとオーバーライドを持つ再利用可能なコンポーネント
|
||||
- CSS 同期 — カスタムプロパティの自動生成、コード出力に `var(--name)` を使用
|
||||
|
||||
**Figma インポート**
|
||||
|
||||
- レイアウト、フィル、ストローク、エフェクト、テキスト、画像、ベクターを保持して `.fig` ファイルをインポート
|
||||
|
||||
**デスクトップアプリ**
|
||||
|
||||
- Electron によるネイティブ macOS・Windows・Linux 対応
|
||||
- `.op` ファイル関連付け — ダブルクリックで開く、シングルインスタンスロック
|
||||
- GitHub Releases からの自動アップデート
|
||||
|
|
@ -253,17 +260,17 @@ cat design.dsl | op design - # stdin からパイプ入力
|
|||
|
||||
## 技術スタック
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **フロントエンド** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **キャンバス** | CanvasKit/Skia(WASM、GPU アクセラレーション) |
|
||||
| **状態管理** | Zustand v5 |
|
||||
| **サーバー** | Nitro |
|
||||
| **デスクトップ** | Electron 35 |
|
||||
| **CLI** | `op` — ターミナル制御、バッチデザインDSL、コードエクスポート |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **ランタイム** | Bun · Vite 7 |
|
||||
| **ファイル形式** | `.op` — JSON ベース、人間が読みやすく、Git フレンドリー |
|
||||
| | |
|
||||
| ------------------ | -------------------------------------------------------------------------------- |
|
||||
| **フロントエンド** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **キャンバス** | CanvasKit/Skia(WASM、GPU アクセラレーション) |
|
||||
| **状態管理** | Zustand v5 |
|
||||
| **サーバー** | Nitro |
|
||||
| **デスクトップ** | Electron 35 |
|
||||
| **CLI** | `op` — ターミナル制御、バッチデザインDSL、コードエクスポート |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **ランタイム** | Bun · Vite 7 |
|
||||
| **ファイル形式** | `.op` — JSON ベース、人間が読みやすく、Git フレンドリー |
|
||||
|
||||
## プロジェクト構成
|
||||
|
||||
|
|
@ -304,21 +311,21 @@ openpencil/
|
|||
|
||||
## キーボードショートカット
|
||||
|
||||
| キー | 操作 | | キー | 操作 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | 選択 | | `Cmd+S` | 保存 |
|
||||
| `R` | 矩形 | | `Cmd+Z` | 元に戻す |
|
||||
| `O` | 楕円 | | `Cmd+Shift+Z` | やり直す |
|
||||
| `L` | 直線 | | `Cmd+C/X/V/D` | コピー/カット/ペースト/複製 |
|
||||
| `T` | テキスト | | `Cmd+G` | グループ化 |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | グループ解除 |
|
||||
| `P` | ペンツール | | `Cmd+Shift+E` | エクスポート |
|
||||
| `H` | ハンド(パン) | | `Cmd+Shift+C` | コードパネル |
|
||||
| `Del` | 削除 | | `Cmd+Shift+V` | 変数パネル |
|
||||
| `[ / ]` | 重ね順の変更 | | `Cmd+J` | AI チャット |
|
||||
| 矢印キー | 1px 微調整 | | `Cmd+,` | エージェント設定 |
|
||||
| `Cmd+Alt+U` | ブーリアン合体 | | `Cmd+Alt+S` | ブーリアン型抜き |
|
||||
| `Cmd+Alt+I` | ブーリアン交差 | | | |
|
||||
| キー | 操作 | | キー | 操作 |
|
||||
| ----------- | -------------- | --- | ------------- | --------------------------- |
|
||||
| `V` | 選択 | | `Cmd+S` | 保存 |
|
||||
| `R` | 矩形 | | `Cmd+Z` | 元に戻す |
|
||||
| `O` | 楕円 | | `Cmd+Shift+Z` | やり直す |
|
||||
| `L` | 直線 | | `Cmd+C/X/V/D` | コピー/カット/ペースト/複製 |
|
||||
| `T` | テキスト | | `Cmd+G` | グループ化 |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | グループ解除 |
|
||||
| `P` | ペンツール | | `Cmd+Shift+E` | エクスポート |
|
||||
| `H` | ハンド(パン) | | `Cmd+Shift+C` | コードパネル |
|
||||
| `Del` | 削除 | | `Cmd+Shift+V` | 変数パネル |
|
||||
| `[ / ]` | 重ね順の変更 | | `Cmd+J` | AI チャット |
|
||||
| 矢印キー | 1px 微調整 | | `Cmd+,` | エージェント設定 |
|
||||
| `Cmd+Alt+U` | ブーリアン合体 | | `Cmd+Alt+S` | ブーリアン型抜き |
|
||||
| `Cmd+Alt+I` | ブーリアン交差 | | | |
|
||||
|
||||
## スクリプト
|
||||
|
||||
|
|
|
|||
91
README.ko.md
91
README.ko.md
|
|
@ -124,15 +124,15 @@ bun run electron:dev
|
|||
|
||||
여러 이미지 변형을 사용할 수 있습니다 — 필요에 맞는 것을 선택하세요:
|
||||
|
||||
| 이미지 | 크기 | 포함 내용 |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 MB | 웹 앱만 |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | 모든 CLI 도구 |
|
||||
| 이미지 | 크기 | 포함 내용 |
|
||||
| ---------------------------- | ------- | -------------------- |
|
||||
| `openpencil:latest` | ~226 MB | 웹 앱만 |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | 모든 CLI 도구 |
|
||||
|
||||
**실행 (웹만):**
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ docker build --target full -t openpencil-full .
|
|||
## AI 네이티브 디자인
|
||||
|
||||
**프롬프트에서 UI로**
|
||||
|
||||
- **텍스트-투-디자인** — 페이지를 설명하면 스트리밍 애니메이션으로 실시간으로 캔버스에 생성
|
||||
- **오케스트레이터** — 복잡한 페이지를 공간적 서브태스크로 분해하여 병렬 생성
|
||||
- **디자인 수정** — 요소를 선택하고 자연어로 변경 사항을 설명
|
||||
|
|
@ -180,20 +181,21 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
**멀티 에이전트 지원**
|
||||
|
||||
| 에이전트 | 설정 방법 |
|
||||
| --- | --- |
|
||||
| 에이전트 | 설정 방법 |
|
||||
| -------------------- | ------------------------------------------------------------------------------- |
|
||||
| **내장 (9+ 제공자)** | 제공자 프리셋에서 선택하고 지역을 전환 — Anthropic, OpenAI, Google, DeepSeek 등 |
|
||||
| **Claude Code** | 설정 불필요 — 로컬 OAuth로 Claude Agent SDK 사용 |
|
||||
| **Codex CLI** | 에이전트 설정에서 연결 (`Cmd+,`) |
|
||||
| **OpenCode** | 에이전트 설정에서 연결 (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` 후 에이전트 설정에서 연결 (`Cmd+,`) |
|
||||
| **Gemini CLI** | 에이전트 설정에서 연결 (`Cmd+,`) |
|
||||
| **Claude Code** | 설정 불필요 — 로컬 OAuth로 Claude Agent SDK 사용 |
|
||||
| **Codex CLI** | 에이전트 설정에서 연결 (`Cmd+,`) |
|
||||
| **OpenCode** | 에이전트 설정에서 연결 (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` 후 에이전트 설정에서 연결 (`Cmd+,`) |
|
||||
| **Gemini CLI** | 에이전트 설정에서 연결 (`Cmd+,`) |
|
||||
|
||||
**모델 역량 프로파일** — 모델 티어에 따라 프롬프트, 사고 모드, 타임아웃을 자동 조정합니다. 풀 티어 모델(Claude)은 완전한 프롬프트를 받고, 스탠다드 티어(GPT-4o, Gemini, DeepSeek)는 사고 모드를 비활성화하며, 베이직 티어(MiniMax, Qwen, Llama, Mistral)는 최대 안정성을 위해 단순화된 중첩 JSON 프롬프트를 받습니다.
|
||||
|
||||
**i18n** — 15개 언어로 완전한 인터페이스 지역화: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
|
||||
|
||||
**MCP 서버**
|
||||
|
||||
- 내장 MCP 서버 — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI에 원클릭 설치
|
||||
- Node.js 자동 감지 — 설치되지 않은 경우 HTTP 전송 모드로 자동 대체하고 MCP HTTP 서버를 자동 시작
|
||||
- 터미널에서 디자인 자동화: MCP 호환 에이전트를 통해 `.op` 파일 읽기, 생성, 편집
|
||||
|
|
@ -202,6 +204,7 @@ docker build --target full -t openpencil-full .
|
|||
- 멀티 페이지 지원 — MCP 도구를 통해 페이지 생성, 이름 변경, 순서 변경, 복제
|
||||
|
||||
**코드 생성**
|
||||
|
||||
- React + Tailwind CSS, HTML + CSS, CSS Variables
|
||||
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
|
||||
|
||||
|
|
@ -229,6 +232,7 @@ cat design.dsl | op design - # stdin에서 파이프 입력
|
|||
## 기능
|
||||
|
||||
**캔버스 & 드로잉**
|
||||
|
||||
- 팬, 줌, 스마트 정렬 가이드, 스냅 지원의 무한 캔버스
|
||||
- Rectangle, Ellipse, Line, Polygon, Pen(Bezier), Frame, Text
|
||||
- 불리언 연산 — 합치기, 빼기, 교차 (컨텍스트 툴바)
|
||||
|
|
@ -237,15 +241,18 @@ cat design.dsl | op design - # stdin에서 파이프 입력
|
|||
- 탭 내비게이션이 있는 멀티 페이지 문서
|
||||
|
||||
**디자인 시스템**
|
||||
|
||||
- 디자인 변수 — 컬러, 숫자, 문자열 토큰, `$variable` 참조 지원
|
||||
- 멀티 테마 지원 — 여러 테마 축, 각 축에 변형(Light/Dark, Compact/Comfortable)
|
||||
- 컴포넌트 시스템 — 인스턴스와 오버라이드를 가진 재사용 가능한 컴포넌트
|
||||
- CSS 동기화 — 커스텀 프로퍼티 자동 생성, 코드 출력에 `var(--name)` 사용
|
||||
|
||||
**Figma 가져오기**
|
||||
|
||||
- 레이아웃, 채우기, 선, 효과, 텍스트, 이미지, 벡터를 유지하며 `.fig` 파일 가져오기
|
||||
|
||||
**데스크톱 앱**
|
||||
|
||||
- Electron을 통한 네이티브 macOS, Windows, Linux 지원
|
||||
- `.op` 파일 연결 — 더블 클릭으로 열기, 단일 인스턴스 잠금
|
||||
- GitHub Releases에서 자동 업데이트
|
||||
|
|
@ -253,17 +260,17 @@ cat design.dsl | op design - # stdin에서 파이프 입력
|
|||
|
||||
## 기술 스택
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **프론트엔드** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **캔버스** | CanvasKit/Skia (WASM, GPU 가속) |
|
||||
| **상태 관리** | Zustand v5 |
|
||||
| **서버** | Nitro |
|
||||
| **데스크톱** | Electron 35 |
|
||||
| **CLI** | `op` — 터미널 제어, 배치 디자인 DSL, 코드 내보내기 |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **런타임** | Bun · Vite 7 |
|
||||
| **파일 형식** | `.op` — JSON 기반, 사람이 읽을 수 있는, Git 친화적 |
|
||||
| | |
|
||||
| -------------- | -------------------------------------------------------------------------------- |
|
||||
| **프론트엔드** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **캔버스** | CanvasKit/Skia (WASM, GPU 가속) |
|
||||
| **상태 관리** | Zustand v5 |
|
||||
| **서버** | Nitro |
|
||||
| **데스크톱** | Electron 35 |
|
||||
| **CLI** | `op` — 터미널 제어, 배치 디자인 DSL, 코드 내보내기 |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **런타임** | Bun · Vite 7 |
|
||||
| **파일 형식** | `.op` — JSON 기반, 사람이 읽을 수 있는, Git 친화적 |
|
||||
|
||||
## 프로젝트 구조
|
||||
|
||||
|
|
@ -304,21 +311,21 @@ openpencil/
|
|||
|
||||
## 키보드 단축키
|
||||
|
||||
| 키 | 동작 | | 키 | 동작 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | 선택 | | `Cmd+S` | 저장 |
|
||||
| `R` | 사각형 | | `Cmd+Z` | 실행 취소 |
|
||||
| `O` | 타원 | | `Cmd+Shift+Z` | 다시 실행 |
|
||||
| `L` | 직선 | | `Cmd+C/X/V/D` | 복사/잘라내기/붙여넣기/복제 |
|
||||
| `T` | 텍스트 | | `Cmd+G` | 그룹화 |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | 그룹 해제 |
|
||||
| `P` | 펜 툴 | | `Cmd+Shift+E` | 내보내기 |
|
||||
| `H` | 핸드(팬) | | `Cmd+Shift+C` | 코드 패널 |
|
||||
| `Del` | 삭제 | | `Cmd+Shift+V` | 변수 패널 |
|
||||
| `[ / ]` | 순서 변경 | | `Cmd+J` | AI 채팅 |
|
||||
| 화살표 키 | 1px 이동 | | `Cmd+,` | 에이전트 설정 |
|
||||
| `Cmd+Alt+U` | 불리언 합치기 | | `Cmd+Alt+S` | 불리언 빼기 |
|
||||
| `Cmd+Alt+I` | 불리언 교차 | | | |
|
||||
| 키 | 동작 | | 키 | 동작 |
|
||||
| ----------- | ------------- | --- | ------------- | --------------------------- |
|
||||
| `V` | 선택 | | `Cmd+S` | 저장 |
|
||||
| `R` | 사각형 | | `Cmd+Z` | 실행 취소 |
|
||||
| `O` | 타원 | | `Cmd+Shift+Z` | 다시 실행 |
|
||||
| `L` | 직선 | | `Cmd+C/X/V/D` | 복사/잘라내기/붙여넣기/복제 |
|
||||
| `T` | 텍스트 | | `Cmd+G` | 그룹화 |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | 그룹 해제 |
|
||||
| `P` | 펜 툴 | | `Cmd+Shift+E` | 내보내기 |
|
||||
| `H` | 핸드(팬) | | `Cmd+Shift+C` | 코드 패널 |
|
||||
| `Del` | 삭제 | | `Cmd+Shift+V` | 변수 패널 |
|
||||
| `[ / ]` | 순서 변경 | | `Cmd+J` | AI 채팅 |
|
||||
| 화살표 키 | 1px 이동 | | `Cmd+,` | 에이전트 설정 |
|
||||
| `Cmd+Alt+U` | 불리언 합치기 | | `Cmd+Alt+S` | 불리언 빼기 |
|
||||
| `Cmd+Alt+I` | 불리언 교차 | | | |
|
||||
|
||||
## 스크립트
|
||||
|
||||
|
|
|
|||
149
README.md
149
README.md
|
|
@ -48,7 +48,7 @@ Describe any UI in natural language. Watch it appear on the infinite canvas in r
|
|||
|
||||
### 🤖 Concurrent Agent Teams
|
||||
|
||||
The orchestrator decomposes complex pages into spatial sub-tasks. Multiple AI agents work on different sections simultaneously — hero, features, footer — all streaming in parallel.
|
||||
The orchestrator decomposes complex pages into spatial sub-tasks. Multiple AI agents work on different sections simultaneously — hero, features, footer — all streaming in parallel with per-member canvas indicators.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -71,11 +71,20 @@ One-click install into Claude Code, Codex, Gemini, OpenCode, Kiro, or Copilot CL
|
|||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
### 🎨 Style Guides
|
||||
|
||||
Built-in style guide library with tag-based fuzzy matching. Apply visual styles (glassmorphism, brutalist, retro, etc.) to AI-generated designs. MCP tools for external agent access.
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
### 📦 Design-as-Code
|
||||
|
||||
`.op` files are JSON — human-readable, Git-friendly, diffable. Design variables generate CSS custom properties. Code export to React + Tailwind or HTML + CSS.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
### 🖥️ Runs Everywhere
|
||||
|
|
@ -83,8 +92,6 @@ One-click install into Claude Code, Codex, Gemini, OpenCode, Kiro, or Copilot CL
|
|||
Web app + native desktop on macOS, Windows, and Linux via Electron. Auto-updates from GitHub Releases. `.op` file association — double-click to open.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
### ⌨️ CLI — `op`
|
||||
|
|
@ -92,12 +99,32 @@ Web app + native desktop on macOS, Windows, and Linux via Electron. Auto-updates
|
|||
Control the design tool from your terminal. `op design`, `op insert`, `op export` — batch design DSL, node manipulation, code export. Pipe in from files or stdin. Works with desktop app or web server.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
### 🎯 Multi-Platform Code Export
|
||||
|
||||
Export to React + Tailwind, HTML + CSS, Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native — all from one `.op` file. Design variables become CSS custom properties.
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
### 🧩 Embeddable SDK
|
||||
|
||||
`pen-engine` (headless) + `pen-react` (React UI SDK) — embed the design engine in your own app. DesignProvider, DesignCanvas, hooks, panels, and toolbar components out of the box.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
### 🛡️ Design System Kit
|
||||
|
||||
Manage reusable UIKits with style switching and component composition. Import/export kits from `.pen` files. Built-in registry with MCP tools for external access.
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
@ -142,21 +169,21 @@ Or run as a desktop app:
|
|||
bun run electron:dev
|
||||
```
|
||||
|
||||
> **Prerequisites:** [Bun](https://bun.sh/) >= 1.0 and [Node.js](https://nodejs.org/) >= 18
|
||||
> **Prerequisites:** [Bun](https://bun.sh/) >= 1.0 and [Node.js](https://nodejs.org/) >= 18. Optional: [Zig](https://ziglang.org/) >= 0.14 for building `agent-native` from source (a prebuilt binary will be downloaded automatically if Zig is not installed).
|
||||
|
||||
### Docker
|
||||
|
||||
Multiple image variants are available — pick the one that fits your needs:
|
||||
|
||||
| Image | Size | Includes |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 MB | Web app only |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | All CLI tools |
|
||||
| Image | Size | Includes |
|
||||
| ---------------------------- | ------- | -------------------- |
|
||||
| `openpencil:latest` | ~226 MB | Web app only |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | All CLI tools |
|
||||
|
||||
**Run (web only):**
|
||||
|
||||
|
|
@ -197,35 +224,42 @@ docker build --target full -t openpencil-full .
|
|||
## AI-Native Design
|
||||
|
||||
**Prompt to UI**
|
||||
- **Text-to-design** — describe a page, get it generated on canvas in real-time with streaming animation
|
||||
|
||||
- **Text-to-design** — describe a page, get it generated on canvas in real-time with SSE streaming animation
|
||||
- **Orchestrator** — decomposes complex pages into spatial sub-tasks for parallel generation
|
||||
- **Agent Teams** — concurrent team members with delegate tool, per-member canvas indicators, and fallback strategies
|
||||
- **Design modification** — select elements, then describe changes in natural language
|
||||
- **Vision input** — attach screenshots or mockups for reference-based design
|
||||
- **Style Guides** — apply visual styles (glassmorphism, brutalist, retro, etc.) via tag-based fuzzy matching
|
||||
- **Anti-slop** — cross-generation diversity tracking to avoid repetitive AI output
|
||||
|
||||
**Multi-Agent Support**
|
||||
|
||||
| Agent | Setup |
|
||||
| --- | --- |
|
||||
| Agent | Setup |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **Built-in (9+ providers)** | Select from provider presets with region switcher — Anthropic, OpenAI, Google, DeepSeek, and more |
|
||||
| **Claude Code** | No config — uses Claude Agent SDK with local OAuth |
|
||||
| **Codex CLI** | Connect in Agent Settings (`Cmd+,`) |
|
||||
| **OpenCode** | Connect in Agent Settings (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` then connect in Agent Settings (`Cmd+,`) |
|
||||
| **Gemini CLI** | Connect in Agent Settings (`Cmd+,`) |
|
||||
| **Claude Code** | No config — uses Claude Agent SDK with local OAuth |
|
||||
| **Codex CLI** | Connect in Agent Settings (`Cmd+,`) |
|
||||
| **OpenCode** | Connect in Agent Settings (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` then connect in Agent Settings (`Cmd+,`) |
|
||||
| **Gemini CLI** | Connect in Agent Settings (`Cmd+,`) |
|
||||
|
||||
**Model Capability Profiles** — automatically adapts prompts, thinking mode, and timeouts per model tier. Full-tier models (Claude) get complete prompts; standard-tier (GPT-4o, Gemini, DeepSeek) disable thinking; basic-tier (MiniMax, Qwen, Llama, Mistral) get simplified nested-JSON prompts for maximum reliability.
|
||||
|
||||
**i18n** — Full interface localization in 15 languages: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
|
||||
|
||||
**MCP Server**
|
||||
- Built-in MCP server — one-click install into Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
|
||||
|
||||
- Built-in MCP server (`pen-mcp` package) — one-click install into Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
|
||||
- Auto-detects Node.js — if not installed, falls back to HTTP transport and auto-starts the MCP HTTP server
|
||||
- Design automation from terminal: read, create, and modify `.op` files via any MCP-compatible agent
|
||||
- **Layered design workflow** — `design_skeleton` → `design_content` → `design_refine` for higher-fidelity multi-section designs
|
||||
- **Segmented prompt retrieval** — load only the design knowledge you need (schema, layout, roles, icons, planning, etc.)
|
||||
- **Style guide tools** — `get_style_guide_tags` and `get_style_guide` for applying visual styles via MCP
|
||||
- Multi-page support — create, rename, reorder, and duplicate pages via MCP tools
|
||||
|
||||
**Code Generation**
|
||||
|
||||
- React + Tailwind CSS, HTML + CSS, CSS Variables
|
||||
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
|
||||
|
||||
|
|
@ -253,6 +287,7 @@ Supports three input methods: inline string, `@filepath` (read from file), or `-
|
|||
## Features
|
||||
|
||||
**Canvas & Drawing**
|
||||
|
||||
- Infinite canvas with pan, zoom, smart alignment guides, and snapping
|
||||
- Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text
|
||||
- Boolean operations — union, subtract, intersect with contextual toolbar
|
||||
|
|
@ -261,15 +296,18 @@ Supports three input methods: inline string, `@filepath` (read from file), or `-
|
|||
- Multi-page documents with tab navigation
|
||||
|
||||
**Design System**
|
||||
|
||||
- Design variables — color, number, string tokens with `$variable` references
|
||||
- Multi-theme support — multiple axes, each with variants (Light/Dark, Compact/Comfortable)
|
||||
- Component system — reusable components with instances and overrides
|
||||
- CSS sync — auto-generated custom properties, `var(--name)` in code output
|
||||
|
||||
**Figma Import**
|
||||
|
||||
- Import `.fig` files with layout, fills, strokes, effects, text, images, and vectors preserved
|
||||
|
||||
**Desktop App**
|
||||
|
||||
- Native macOS, Windows, and Linux via Electron
|
||||
- `.op` file association — double-click to open, single-instance lock
|
||||
- Auto-update from GitHub Releases
|
||||
|
|
@ -277,17 +315,19 @@ Supports three input methods: inline string, `@filepath` (read from file), or `-
|
|||
|
||||
## Tech Stack
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Canvas** | CanvasKit/Skia (WASM, GPU-accelerated) |
|
||||
| **State** | Zustand v5 |
|
||||
| **Server** | Nitro |
|
||||
| **Desktop** | Electron 35 |
|
||||
| **CLI** | `op` — terminal control, batch design DSL, code export |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Runtime** | Bun · Vite 7 |
|
||||
| **File format** | `.op` — JSON-based, human-readable, Git-friendly |
|
||||
| | |
|
||||
| --------------- | --------------------------------------------------------------------------------------- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Canvas** | CanvasKit/Skia (WASM, GPU-accelerated) |
|
||||
| **Engine** | pen-engine (headless) · pen-react (React UI SDK) |
|
||||
| **State** | Zustand v5 |
|
||||
| **Server** | Nitro |
|
||||
| **Desktop** | Electron 35 |
|
||||
| **CLI** | `op` — terminal control, batch design DSL, code export |
|
||||
| **AI** | agent-native (Zig NAPI) · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Runtime** | Bun · Vite 7 |
|
||||
| **Lint** | oxlint · oxfmt |
|
||||
| **File format** | `.op` — JSON-based, human-readable, Git-friendly |
|
||||
|
||||
## Project Structure
|
||||
|
||||
|
|
@ -301,7 +341,6 @@ openpencil/
|
|||
│ │ │ ├── services/ai/ AI chat, orchestrator, design generation, streaming
|
||||
│ │ │ ├── services/codegen/ Code generation service wrappers
|
||||
│ │ │ ├── stores/ Zustand — canvas, document, pages, history, AI
|
||||
│ │ │ ├── mcp/ MCP server tools for external CLI integration
|
||||
│ │ │ ├── hooks/ Keyboard shortcuts, file drop, Figma paste, MCP sync
|
||||
│ │ │ ├── i18n/ Internationalization — 15 locales
|
||||
│ │ │ └── uikit/ Reusable component kit system
|
||||
|
|
@ -320,32 +359,35 @@ openpencil/
|
|||
├── packages/
|
||||
│ ├── pen-types/ Type definitions for PenDocument model
|
||||
│ ├── pen-core/ Document tree ops, layout engine, variables
|
||||
│ ├── pen-engine/ Headless design engine — document, selection, history, viewport
|
||||
│ ├── pen-react/ React UI SDK — provider, canvas, hooks, panels, toolbar
|
||||
│ ├── pen-codegen/ Code generators (React, HTML, Vue, Flutter, ...)
|
||||
│ ├── pen-figma/ Figma .fig file parser and converter
|
||||
│ ├── pen-renderer/ Standalone CanvasKit/Skia renderer
|
||||
│ ├── pen-mcp/ MCP server — tools, routes, document manager
|
||||
│ ├── pen-sdk/ Umbrella SDK (re-exports all packages)
|
||||
│ ├── pen-ai-skills/ AI prompt skill engine (phase-driven prompt loading)
|
||||
│ └── agent/ AI agent SDK (Vercel AI SDK, multi-provider, agent teams)
|
||||
│ └── agent-native/ Native AI agent runtime (Zig NAPI, multi-provider, teams)
|
||||
└── .githooks/ Pre-commit version sync from branch name
|
||||
```
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action | | Key | Action |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | Select | | `Cmd+S` | Save |
|
||||
| `R` | Rectangle | | `Cmd+Z` | Undo |
|
||||
| `O` | Ellipse | | `Cmd+Shift+Z` | Redo |
|
||||
| `L` | Line | | `Cmd+C/X/V/D` | Copy/Cut/Paste/Duplicate |
|
||||
| `T` | Text | | `Cmd+G` | Group |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Ungroup |
|
||||
| `P` | Pen tool | | `Cmd+Shift+E` | Export |
|
||||
| `H` | Hand (pan) | | `Cmd+Shift+C` | Code panel |
|
||||
| `Del` | Delete | | `Cmd+Shift+V` | Variables panel |
|
||||
| `[ / ]` | Reorder | | `Cmd+J` | AI chat |
|
||||
| Arrows | Nudge 1px | | `Cmd+,` | Agent settings |
|
||||
| `Cmd+Alt+U` | Boolean union | | `Cmd+Alt+S` | Boolean subtract |
|
||||
| `Cmd+Alt+I` | Boolean intersect | | | |
|
||||
| Key | Action | | Key | Action |
|
||||
| ----------- | ----------------- | --- | ------------- | ------------------------ |
|
||||
| `V` | Select | | `Cmd+S` | Save |
|
||||
| `R` | Rectangle | | `Cmd+Z` | Undo |
|
||||
| `O` | Ellipse | | `Cmd+Shift+Z` | Redo |
|
||||
| `L` | Line | | `Cmd+C/X/V/D` | Copy/Cut/Paste/Duplicate |
|
||||
| `T` | Text | | `Cmd+G` | Group |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Ungroup |
|
||||
| `P` | Pen tool | | `Cmd+Shift+E` | Export |
|
||||
| `H` | Hand (pan) | | `Cmd+Shift+C` | Code panel |
|
||||
| `Del` | Delete | | `Cmd+Shift+V` | Variables panel |
|
||||
| `[ / ]` | Reorder | | `Cmd+J` | AI chat |
|
||||
| Arrows | Nudge 1px | | `Cmd+,` | Agent settings |
|
||||
| `Cmd+Alt+U` | Boolean union | | `Cmd+Alt+S` | Boolean subtract |
|
||||
| `Cmd+Alt+I` | Boolean intersect | | | |
|
||||
|
||||
## Scripts
|
||||
|
||||
|
|
@ -354,11 +396,14 @@ bun --bun run dev # Dev server (port 3000)
|
|||
bun --bun run build # Production build
|
||||
bun --bun run test # Run tests (Vitest)
|
||||
npx tsc --noEmit # Type check
|
||||
bun run lint # Lint (oxlint)
|
||||
bun run format # Format (oxfmt)
|
||||
bun run bump <version> # Sync version across all package.json
|
||||
bun run electron:dev # Electron dev
|
||||
bun run electron:build # Electron package
|
||||
bun run cli:dev # Run CLI from source
|
||||
bun run cli:compile # Compile CLI to dist
|
||||
bun run mcp:dev # Run MCP server from source
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
|
@ -386,6 +431,10 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details
|
|||
- [x] CLI tool (`op`) for terminal control
|
||||
- [x] Built-in AI agent SDK with multi-provider support
|
||||
- [x] i18n — 15 languages
|
||||
- [x] Headless design engine (`pen-engine`) + React UI SDK (`pen-react`)
|
||||
- [x] Style Guides with tag-based matching and MCP tools
|
||||
- [x] Concurrent Agent Teams with delegate tool and canvas indicators
|
||||
- [x] Native agent runtime (`agent-native` — Zig NAPI)
|
||||
- [ ] Collaborative editing
|
||||
- [ ] Plugin system
|
||||
|
||||
|
|
|
|||
91
README.pt.md
91
README.pt.md
|
|
@ -124,15 +124,15 @@ bun run electron:dev
|
|||
|
||||
Várias variantes de imagem estão disponíveis — escolha a que se adequa às suas necessidades:
|
||||
|
||||
| Imagem | Tamanho | Inclui |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 MB | Apenas aplicação web |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | Todas as ferramentas CLI |
|
||||
| Imagem | Tamanho | Inclui |
|
||||
| ---------------------------- | ------- | ------------------------ |
|
||||
| `openpencil:latest` | ~226 MB | Apenas aplicação web |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | Todas as ferramentas CLI |
|
||||
|
||||
**Executar (apenas web):**
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ docker build --target full -t openpencil-full .
|
|||
## Design Nativo com IA
|
||||
|
||||
**Do Prompt à UI**
|
||||
|
||||
- **Texto para design** — descreva uma página e ela será gerada no canvas em tempo real com animação de streaming
|
||||
- **Orquestrador** — decompõe páginas complexas em sub-tarefas espaciais para geração paralela
|
||||
- **Modificação de design** — selecione elementos e descreva as alterações em linguagem natural
|
||||
|
|
@ -180,20 +181,21 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
**Suporte Multi-Agente**
|
||||
|
||||
| Agente | Configuração |
|
||||
| --- | --- |
|
||||
| Agente | Configuração |
|
||||
| ----------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| **Integrado (9+ provedores)** | Selecione entre presets de provedores com seletor de região — Anthropic, OpenAI, Google, DeepSeek e mais |
|
||||
| **Claude Code** | Sem configuração — usa o Claude Agent SDK com OAuth local |
|
||||
| **Codex CLI** | Conectar nas Configurações do Agente (`Cmd+,`) |
|
||||
| **OpenCode** | Conectar nas Configurações do Agente (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` e depois conectar nas Configurações do Agente (`Cmd+,`) |
|
||||
| **Gemini CLI** | Conectar nas Configurações do Agente (`Cmd+,`) |
|
||||
| **Claude Code** | Sem configuração — usa o Claude Agent SDK com OAuth local |
|
||||
| **Codex CLI** | Conectar nas Configurações do Agente (`Cmd+,`) |
|
||||
| **OpenCode** | Conectar nas Configurações do Agente (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` e depois conectar nas Configurações do Agente (`Cmd+,`) |
|
||||
| **Gemini CLI** | Conectar nas Configurações do Agente (`Cmd+,`) |
|
||||
|
||||
**Perfis de Capacidade de Modelo** — adapta automaticamente prompts, modo de thinking e timeouts por nível de modelo. Modelos de nível completo (Claude) recebem prompts completos; nível padrão (GPT-4o, Gemini, DeepSeek) desativam thinking; nível básico (MiniMax, Qwen, Llama, Mistral) recebem prompts simplificados de JSON aninhado para máxima confiabilidade.
|
||||
|
||||
**i18n** — Localização completa da interface em 15 idiomas: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
|
||||
|
||||
**Servidor MCP**
|
||||
|
||||
- Servidor MCP integrado — instalação com um clique no Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
|
||||
- Detecção automática de Node.js — se não instalado, recurso automático para transporte HTTP e início automático do servidor MCP HTTP
|
||||
- Automação de design pelo terminal: leia, crie e modifique arquivos `.op` via qualquer agente compatível com MCP
|
||||
|
|
@ -202,6 +204,7 @@ docker build --target full -t openpencil-full .
|
|||
- Suporte a múltiplas páginas — crie, renomeie, reordene e duplique páginas via ferramentas MCP
|
||||
|
||||
**Geração de Código**
|
||||
|
||||
- React + Tailwind CSS, HTML + CSS, CSS Variables
|
||||
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
|
||||
|
||||
|
|
@ -229,6 +232,7 @@ Suporta três métodos de entrada: string inline, `@filepath` (ler de arquivo) o
|
|||
## Funcionalidades
|
||||
|
||||
**Canvas e Desenho**
|
||||
|
||||
- Canvas infinito com pan, zoom, guias de alinhamento inteligentes e snapping
|
||||
- Retângulo, Elipse, Linha, Polígono, Caneta (Bezier), Frame, Texto
|
||||
- Operações booleanas — união, subtração, interseção com barra de ferramentas contextual
|
||||
|
|
@ -237,15 +241,18 @@ Suporta três métodos de entrada: string inline, `@filepath` (ler de arquivo) o
|
|||
- Documentos com múltiplas páginas e navegação por abas
|
||||
|
||||
**Sistema de Design**
|
||||
|
||||
- Variáveis de design — tokens de cor, número e string com referências `$variable`
|
||||
- Suporte a múltiplos temas — vários eixos, cada um com variantes (Claro/Escuro, Compacto/Confortável)
|
||||
- Sistema de componentes — componentes reutilizáveis com instâncias e substituições
|
||||
- Sincronização CSS — propriedades personalizadas geradas automaticamente, `var(--name)` na saída de código
|
||||
|
||||
**Importação do Figma**
|
||||
|
||||
- Importe arquivos `.fig` preservando layout, preenchimentos, traços, efeitos, texto, imagens e vetores
|
||||
|
||||
**Aplicativo Desktop**
|
||||
|
||||
- macOS, Windows e Linux nativos via Electron
|
||||
- Associação de arquivos `.op` — clique duplo para abrir, bloqueio de instância única
|
||||
- Atualização automática a partir do GitHub Releases
|
||||
|
|
@ -253,17 +260,17 @@ Suporta três métodos de entrada: string inline, `@filepath` (ler de arquivo) o
|
|||
|
||||
## Stack Tecnológica
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Canvas** | CanvasKit/Skia (WASM, acelerado por GPU) |
|
||||
| **Estado** | Zustand v5 |
|
||||
| **Servidor** | Nitro |
|
||||
| **Desktop** | Electron 35 |
|
||||
| **CLI** | `op` — controle pelo terminal, DSL de design em lote, exportação de código |
|
||||
| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Runtime** | Bun · Vite 7 |
|
||||
| **Formato de arquivo** | `.op` — baseado em JSON, legível por humanos, compatível com Git |
|
||||
| | |
|
||||
| ---------------------- | -------------------------------------------------------------------------------- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Canvas** | CanvasKit/Skia (WASM, acelerado por GPU) |
|
||||
| **Estado** | Zustand v5 |
|
||||
| **Servidor** | Nitro |
|
||||
| **Desktop** | Electron 35 |
|
||||
| **CLI** | `op` — controle pelo terminal, DSL de design em lote, exportação de código |
|
||||
| **IA** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Runtime** | Bun · Vite 7 |
|
||||
| **Formato de arquivo** | `.op` — baseado em JSON, legível por humanos, compatível com Git |
|
||||
|
||||
## Estrutura do Projeto
|
||||
|
||||
|
|
@ -304,21 +311,21 @@ openpencil/
|
|||
|
||||
## Atalhos de Teclado
|
||||
|
||||
| Tecla | Ação | | Tecla | Ação |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | Selecionar | | `Cmd+S` | Salvar |
|
||||
| `R` | Retângulo | | `Cmd+Z` | Desfazer |
|
||||
| `O` | Elipse | | `Cmd+Shift+Z` | Refazer |
|
||||
| `L` | Linha | | `Cmd+C/X/V/D` | Copiar/Recortar/Colar/Duplicar |
|
||||
| `T` | Texto | | `Cmd+G` | Agrupar |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Desagrupar |
|
||||
| `P` | Ferramenta caneta | | `Cmd+Shift+E` | Exportar |
|
||||
| `H` | Mão (pan) | | `Cmd+Shift+C` | Painel de código |
|
||||
| `Del` | Excluir | | `Cmd+Shift+V` | Painel de variáveis |
|
||||
| `[ / ]` | Reordenar | | `Cmd+J` | Chat IA |
|
||||
| Setas | Mover 1px | | `Cmd+,` | Configurações do agente |
|
||||
| `Cmd+Alt+U` | União booleana | | `Cmd+Alt+S` | Subtração booleana |
|
||||
| `Cmd+Alt+I` | Interseção booleana | | | |
|
||||
| Tecla | Ação | | Tecla | Ação |
|
||||
| ----------- | ------------------- | --- | ------------- | ------------------------------ |
|
||||
| `V` | Selecionar | | `Cmd+S` | Salvar |
|
||||
| `R` | Retângulo | | `Cmd+Z` | Desfazer |
|
||||
| `O` | Elipse | | `Cmd+Shift+Z` | Refazer |
|
||||
| `L` | Linha | | `Cmd+C/X/V/D` | Copiar/Recortar/Colar/Duplicar |
|
||||
| `T` | Texto | | `Cmd+G` | Agrupar |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Desagrupar |
|
||||
| `P` | Ferramenta caneta | | `Cmd+Shift+E` | Exportar |
|
||||
| `H` | Mão (pan) | | `Cmd+Shift+C` | Painel de código |
|
||||
| `Del` | Excluir | | `Cmd+Shift+V` | Painel de variáveis |
|
||||
| `[ / ]` | Reordenar | | `Cmd+J` | Chat IA |
|
||||
| Setas | Mover 1px | | `Cmd+,` | Configurações do agente |
|
||||
| `Cmd+Alt+U` | União booleana | | `Cmd+Alt+S` | Subtração booleana |
|
||||
| `Cmd+Alt+I` | Interseção booleana | | | |
|
||||
|
||||
## Scripts
|
||||
|
||||
|
|
|
|||
91
README.ru.md
91
README.ru.md
|
|
@ -124,15 +124,15 @@ bun run electron:dev
|
|||
|
||||
Доступно несколько вариантов образов — выберите подходящий для ваших нужд:
|
||||
|
||||
| Образ | Размер | Содержит |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 МБ | Только веб-приложение |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 ГБ | Все CLI-инструменты |
|
||||
| Образ | Размер | Содержит |
|
||||
| ---------------------------- | ------- | --------------------- |
|
||||
| `openpencil:latest` | ~226 МБ | Только веб-приложение |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 ГБ | Все CLI-инструменты |
|
||||
|
||||
**Запуск (только веб):**
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ docker build --target full -t openpencil-full .
|
|||
## AI-нативный дизайн
|
||||
|
||||
**От запроса к UI**
|
||||
|
||||
- **Текст в дизайн** — опишите страницу и получите её на холсте в реальном времени со стриминговой анимацией
|
||||
- **Оркестратор** — разбивает сложные страницы на пространственные подзадачи для параллельной генерации
|
||||
- **Изменение дизайна** — выберите элементы и опишите изменения на естественном языке
|
||||
|
|
@ -180,20 +181,21 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
**Поддержка нескольких агентов**
|
||||
|
||||
| Агент | Настройка |
|
||||
| --- | --- |
|
||||
| Агент | Настройка |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| **Встроенный (9+ провайдеров)** | Выбор из предустановленных провайдеров с переключателем региона — Anthropic, OpenAI, Google, DeepSeek и другие |
|
||||
| **Claude Code** | Без настройки — использует Claude Agent SDK с локальным OAuth |
|
||||
| **Codex CLI** | Подключить в настройках агента (`Cmd+,`) |
|
||||
| **OpenCode** | Подключить в настройках агента (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login`, затем подключить в настройках агента (`Cmd+,`) |
|
||||
| **Gemini CLI** | Подключить в настройках агента (`Cmd+,`) |
|
||||
| **Claude Code** | Без настройки — использует Claude Agent SDK с локальным OAuth |
|
||||
| **Codex CLI** | Подключить в настройках агента (`Cmd+,`) |
|
||||
| **OpenCode** | Подключить в настройках агента (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login`, затем подключить в настройках агента (`Cmd+,`) |
|
||||
| **Gemini CLI** | Подключить в настройках агента (`Cmd+,`) |
|
||||
|
||||
**Профили возможностей моделей** — автоматически адаптирует промпты, режим thinking и таймауты для каждого уровня моделей. Модели полного уровня (Claude) получают полные промпты; стандартного уровня (GPT-4o, Gemini, DeepSeek) — с отключённым thinking; базового уровня (MiniMax, Qwen, Llama, Mistral) — упрощённые промпты с вложенным JSON для максимальной надёжности.
|
||||
|
||||
**i18n** — Полная локализация интерфейса на 15 языках: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
|
||||
|
||||
**MCP-сервер**
|
||||
|
||||
- Встроенный MCP-сервер — установка в один клик в Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
|
||||
- Автоопределение Node.js — если не установлен, автоматический переход на HTTP-транспорт и автозапуск MCP HTTP-сервера
|
||||
- Автоматизация дизайна из терминала: чтение, создание и изменение файлов `.op` через любой MCP-совместимый агент
|
||||
|
|
@ -202,6 +204,7 @@ docker build --target full -t openpencil-full .
|
|||
- Поддержка нескольких страниц — создание, переименование, переупорядочивание и дублирование страниц через инструменты MCP
|
||||
|
||||
**Генерация кода**
|
||||
|
||||
- React + Tailwind CSS, HTML + CSS, CSS Variables
|
||||
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
|
||||
|
||||
|
|
@ -229,6 +232,7 @@ cat design.dsl | op design - # Передача через stdin
|
|||
## Возможности
|
||||
|
||||
**Холст и рисование**
|
||||
|
||||
- Бесконечный холст с панорамированием, масштабированием, умными направляющими и привязкой
|
||||
- Прямоугольник, Эллипс, Линия, Многоугольник, Перо (Безье), Frame, Текст
|
||||
- Булевы операции — объединение, вычитание, пересечение с контекстной панелью инструментов
|
||||
|
|
@ -237,15 +241,18 @@ cat design.dsl | op design - # Передача через stdin
|
|||
- Многостраничные документы с навигацией по вкладкам
|
||||
|
||||
**Система дизайна**
|
||||
|
||||
- Переменные дизайна — цветовые, числовые и строковые токены со ссылками `$variable`
|
||||
- Поддержка нескольких тем — несколько осей, каждая с вариантами (Светлая/Тёмная, Компактная/Комфортная)
|
||||
- Система компонентов — переиспользуемые компоненты с экземплярами и переопределениями
|
||||
- CSS-синхронизация — автоматически генерируемые пользовательские свойства, `var(--name)` в выводе кода
|
||||
|
||||
**Импорт из Figma**
|
||||
|
||||
- Импорт файлов `.fig` с сохранением раскладки, заливок, обводок, эффектов, текста, изображений и векторов
|
||||
|
||||
**Десктопное приложение**
|
||||
|
||||
- Нативная поддержка macOS, Windows и Linux через Electron
|
||||
- Ассоциация файлов `.op` — двойной клик для открытия, блокировка единственного экземпляра
|
||||
- Автообновление из GitHub Releases
|
||||
|
|
@ -253,17 +260,17 @@ cat design.dsl | op design - # Передача через stdin
|
|||
|
||||
## Технологический стек
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Фронтенд** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Холст** | CanvasKit/Skia (WASM, GPU-ускорение) |
|
||||
| **Состояние** | Zustand v5 |
|
||||
| **Сервер** | Nitro |
|
||||
| **Десктоп** | Electron 35 |
|
||||
| **CLI** | `op` — управление из терминала, пакетный DSL дизайна, экспорт кода |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Среда выполнения** | Bun · Vite 7 |
|
||||
| **Формат файла** | `.op` — на основе JSON, удобочитаемый, дружественный к Git |
|
||||
| | |
|
||||
| -------------------- | -------------------------------------------------------------------------------- |
|
||||
| **Фронтенд** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Холст** | CanvasKit/Skia (WASM, GPU-ускорение) |
|
||||
| **Состояние** | Zustand v5 |
|
||||
| **Сервер** | Nitro |
|
||||
| **Десктоп** | Electron 35 |
|
||||
| **CLI** | `op` — управление из терминала, пакетный DSL дизайна, экспорт кода |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Среда выполнения** | Bun · Vite 7 |
|
||||
| **Формат файла** | `.op` — на основе JSON, удобочитаемый, дружественный к Git |
|
||||
|
||||
## Структура проекта
|
||||
|
||||
|
|
@ -304,21 +311,21 @@ openpencil/
|
|||
|
||||
## Горячие клавиши
|
||||
|
||||
| Клавиша | Действие | | Клавиша | Действие |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | Выбор | | `Cmd+S` | Сохранить |
|
||||
| `R` | Прямоугольник | | `Cmd+Z` | Отменить |
|
||||
| `O` | Эллипс | | `Cmd+Shift+Z` | Повторить |
|
||||
| `L` | Линия | | `Cmd+C/X/V/D` | Копировать/Вырезать/Вставить/Дублировать |
|
||||
| `T` | Текст | | `Cmd+G` | Сгруппировать |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Разгруппировать |
|
||||
| `P` | Инструмент пера | | `Cmd+Shift+E` | Экспорт |
|
||||
| `H` | Рука (панорама) | | `Cmd+Shift+C` | Панель кода |
|
||||
| `Del` | Удалить | | `Cmd+Shift+V` | Панель переменных |
|
||||
| `[ / ]` | Изменить порядок | | `Cmd+J` | AI-чат |
|
||||
| Стрелки | Сдвиг на 1px | | `Cmd+,` | Настройки агента |
|
||||
| `Cmd+Alt+U` | Булево объединение | | `Cmd+Alt+S` | Булево вычитание |
|
||||
| `Cmd+Alt+I` | Булево пересечение | | | |
|
||||
| Клавиша | Действие | | Клавиша | Действие |
|
||||
| ----------- | ------------------ | --- | ------------- | ---------------------------------------- |
|
||||
| `V` | Выбор | | `Cmd+S` | Сохранить |
|
||||
| `R` | Прямоугольник | | `Cmd+Z` | Отменить |
|
||||
| `O` | Эллипс | | `Cmd+Shift+Z` | Повторить |
|
||||
| `L` | Линия | | `Cmd+C/X/V/D` | Копировать/Вырезать/Вставить/Дублировать |
|
||||
| `T` | Текст | | `Cmd+G` | Сгруппировать |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Разгруппировать |
|
||||
| `P` | Инструмент пера | | `Cmd+Shift+E` | Экспорт |
|
||||
| `H` | Рука (панорама) | | `Cmd+Shift+C` | Панель кода |
|
||||
| `Del` | Удалить | | `Cmd+Shift+V` | Панель переменных |
|
||||
| `[ / ]` | Изменить порядок | | `Cmd+J` | AI-чат |
|
||||
| Стрелки | Сдвиг на 1px | | `Cmd+,` | Настройки агента |
|
||||
| `Cmd+Alt+U` | Булево объединение | | `Cmd+Alt+S` | Булево вычитание |
|
||||
| `Cmd+Alt+I` | Булево пересечение | | | |
|
||||
|
||||
## Скрипты
|
||||
|
||||
|
|
|
|||
91
README.th.md
91
README.th.md
|
|
@ -124,15 +124,15 @@ bun run electron:dev
|
|||
|
||||
มี image หลายรูปแบบให้เลือก — เลือกแบบที่เหมาะกับความต้องการของคุณ:
|
||||
|
||||
| Image | ขนาด | รวม |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 MB | เว็บแอปเท่านั้น |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | เครื่องมือ CLI ทั้งหมด |
|
||||
| Image | ขนาด | รวม |
|
||||
| ---------------------------- | ------- | ---------------------- |
|
||||
| `openpencil:latest` | ~226 MB | เว็บแอปเท่านั้น |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | เครื่องมือ CLI ทั้งหมด |
|
||||
|
||||
**รัน (เว็บเท่านั้น):**
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ docker build --target full -t openpencil-full .
|
|||
## การออกแบบที่ขับเคลื่อนด้วย AI
|
||||
|
||||
**จาก Prompt สู่ UI**
|
||||
|
||||
- **ข้อความเป็นดีไซน์** — อธิบายหน้า แล้วสร้างขึ้นบน Canvas แบบเรียลไทม์พร้อม animation แบบ streaming
|
||||
- **Orchestrator** — แบ่งหน้าที่ซับซ้อนออกเป็น sub-task เชิงพื้นที่เพื่อการสร้างแบบขนาน
|
||||
- **การแก้ไขดีไซน์** — เลือกองค์ประกอบ แล้วอธิบายการเปลี่ยนแปลงด้วยภาษาธรรมชาติ
|
||||
|
|
@ -180,20 +181,21 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
**รองรับหลาย Agent**
|
||||
|
||||
| Agent | วิธีตั้งค่า |
|
||||
| --- | --- |
|
||||
| Agent | วิธีตั้งค่า |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| **ในตัว (9+ ผู้ให้บริการ)** | เลือกจากพรีเซ็ตผู้ให้บริการพร้อมตัวสลับภูมิภาค — Anthropic, OpenAI, Google, DeepSeek และอื่น ๆ |
|
||||
| **Claude Code** | ไม่ต้องตั้งค่า — ใช้ Claude Agent SDK พร้อม local OAuth |
|
||||
| **Codex CLI** | เชื่อมต่อใน Agent Settings (`Cmd+,`) |
|
||||
| **OpenCode** | เชื่อมต่อใน Agent Settings (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` จากนั้นเชื่อมต่อใน Agent Settings (`Cmd+,`) |
|
||||
| **Gemini CLI** | เชื่อมต่อใน Agent Settings (`Cmd+,`) |
|
||||
| **Claude Code** | ไม่ต้องตั้งค่า — ใช้ Claude Agent SDK พร้อม local OAuth |
|
||||
| **Codex CLI** | เชื่อมต่อใน Agent Settings (`Cmd+,`) |
|
||||
| **OpenCode** | เชื่อมต่อใน Agent Settings (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` จากนั้นเชื่อมต่อใน Agent Settings (`Cmd+,`) |
|
||||
| **Gemini CLI** | เชื่อมต่อใน Agent Settings (`Cmd+,`) |
|
||||
|
||||
**โปรไฟล์ความสามารถของโมเดล** — ปรับ prompt, โหมด thinking และ timeout ตามระดับโมเดลโดยอัตโนมัติ โมเดลระดับเต็ม (Claude) ได้ prompt ครบถ้วน; โมเดลระดับมาตรฐาน (GPT-4o, Gemini, DeepSeek) ปิด thinking; โมเดลระดับพื้นฐาน (MiniMax, Qwen, Llama, Mistral) ได้ prompt แบบ nested-JSON ที่ย่อลงเพื่อความเสถียรสูงสุด
|
||||
|
||||
**i18n** — การแปลภาษาเต็มรูปแบบใน 15 ภาษา: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia
|
||||
|
||||
**MCP Server**
|
||||
|
||||
- MCP Server ในตัว — ติดตั้งได้ด้วยคลิกเดียวใน Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs
|
||||
- ตรวจจับ Node.js อัตโนมัติ — หากไม่ได้ติดตั้ง จะสำรองไปใช้ HTTP transport และเริ่ม MCP HTTP server โดยอัตโนมัติ
|
||||
- การทำ Design automation จาก terminal: อ่าน สร้าง และแก้ไขไฟล์ `.op` ผ่าน agent ที่รองรับ MCP
|
||||
|
|
@ -202,6 +204,7 @@ docker build --target full -t openpencil-full .
|
|||
- รองรับหลายหน้า — สร้าง เปลี่ยนชื่อ เรียงลำดับ และทำซ้ำหน้าผ่าน MCP tools
|
||||
|
||||
**การสร้างโค้ด**
|
||||
|
||||
- React + Tailwind CSS, HTML + CSS, CSS Variables
|
||||
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
|
||||
|
||||
|
|
@ -229,6 +232,7 @@ cat design.dsl | op design - # Pipe จาก stdin
|
|||
## ฟีเจอร์
|
||||
|
||||
**Canvas และการวาด**
|
||||
|
||||
- Canvas ไม่จำกัดขนาดพร้อม pan, zoom, smart alignment guides และ snapping
|
||||
- Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text
|
||||
- การดำเนินการบูลีน — รวม ลบ ตัดกัน พร้อมแถบเครื่องมือตามบริบท
|
||||
|
|
@ -237,15 +241,18 @@ cat design.dsl | op design - # Pipe จาก stdin
|
|||
- เอกสารหลายหน้าพร้อมการนำทางด้วย tab
|
||||
|
||||
**Design System**
|
||||
|
||||
- Design variables — color, number, string tokens พร้อมการอ้างอิง `$variable`
|
||||
- รองรับหลาย theme — หลาย axis แต่ละ axis มี variants (Light/Dark, Compact/Comfortable)
|
||||
- ระบบ Component — component ที่นำกลับมาใช้ใหม่ได้พร้อม instance และ override
|
||||
- CSS sync — สร้าง custom properties อัตโนมัติ, `var(--name)` ในผลลัพธ์โค้ด
|
||||
|
||||
**นำเข้าจาก Figma**
|
||||
|
||||
- นำเข้าไฟล์ `.fig` โดยคงไว้ซึ่ง layout, fills, strokes, effects, text, images และ vectors
|
||||
|
||||
**Desktop App**
|
||||
|
||||
- รองรับ macOS, Windows และ Linux แบบ native ผ่าน Electron
|
||||
- เชื่อมโยงไฟล์ `.op` — ดับเบิลคลิกเพื่อเปิด, single-instance lock
|
||||
- อัปเดตอัตโนมัติจาก GitHub Releases
|
||||
|
|
@ -253,17 +260,17 @@ cat design.dsl | op design - # Pipe จาก stdin
|
|||
|
||||
## Tech Stack
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Canvas** | CanvasKit/Skia (WASM, GPU-accelerated) |
|
||||
| **State** | Zustand v5 |
|
||||
| **Server** | Nitro |
|
||||
| **Desktop** | Electron 35 |
|
||||
| **CLI** | `op` — ควบคุมจาก terminal, batch design DSL, ส่งออกโค้ด |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Runtime** | Bun · Vite 7 |
|
||||
| **รูปแบบไฟล์** | `.op` — ใช้ JSON, อ่านได้โดยมนุษย์, Git-friendly |
|
||||
| | |
|
||||
| -------------- | -------------------------------------------------------------------------------- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Canvas** | CanvasKit/Skia (WASM, GPU-accelerated) |
|
||||
| **State** | Zustand v5 |
|
||||
| **Server** | Nitro |
|
||||
| **Desktop** | Electron 35 |
|
||||
| **CLI** | `op` — ควบคุมจาก terminal, batch design DSL, ส่งออกโค้ด |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Runtime** | Bun · Vite 7 |
|
||||
| **รูปแบบไฟล์** | `.op` — ใช้ JSON, อ่านได้โดยมนุษย์, Git-friendly |
|
||||
|
||||
## โครงสร้างโปรเจกต์
|
||||
|
||||
|
|
@ -304,21 +311,21 @@ openpencil/
|
|||
|
||||
## คีย์ลัด
|
||||
|
||||
| คีย์ | การทำงาน | | คีย์ | การทำงาน |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | เลือก | | `Cmd+S` | บันทึก |
|
||||
| `R` | Rectangle | | `Cmd+Z` | เลิกทำ |
|
||||
| `O` | Ellipse | | `Cmd+Shift+Z` | ทำซ้ำ |
|
||||
| `L` | Line | | `Cmd+C/X/V/D` | คัดลอก/ตัด/วาง/ทำซ้ำ |
|
||||
| `T` | Text | | `Cmd+G` | จัดกลุ่ม |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | ยกเลิกการจัดกลุ่ม |
|
||||
| `P` | Pen tool | | `Cmd+Shift+E` | ส่งออก |
|
||||
| `H` | Hand (pan) | | `Cmd+Shift+C` | Code panel |
|
||||
| `Del` | ลบ | | `Cmd+Shift+V` | Variables panel |
|
||||
| `[ / ]` | เรียงลำดับ | | `Cmd+J` | AI chat |
|
||||
| ลูกศร | เลื่อน 1px | | `Cmd+,` | Agent settings |
|
||||
| `Cmd+Alt+U` | รวมบูลีน | | `Cmd+Alt+S` | ลบบูลีน |
|
||||
| `Cmd+Alt+I` | ตัดกันบูลีน | | | |
|
||||
| คีย์ | การทำงาน | | คีย์ | การทำงาน |
|
||||
| ----------- | ----------- | --- | ------------- | -------------------- |
|
||||
| `V` | เลือก | | `Cmd+S` | บันทึก |
|
||||
| `R` | Rectangle | | `Cmd+Z` | เลิกทำ |
|
||||
| `O` | Ellipse | | `Cmd+Shift+Z` | ทำซ้ำ |
|
||||
| `L` | Line | | `Cmd+C/X/V/D` | คัดลอก/ตัด/วาง/ทำซ้ำ |
|
||||
| `T` | Text | | `Cmd+G` | จัดกลุ่ม |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | ยกเลิกการจัดกลุ่ม |
|
||||
| `P` | Pen tool | | `Cmd+Shift+E` | ส่งออก |
|
||||
| `H` | Hand (pan) | | `Cmd+Shift+C` | Code panel |
|
||||
| `Del` | ลบ | | `Cmd+Shift+V` | Variables panel |
|
||||
| `[ / ]` | เรียงลำดับ | | `Cmd+J` | AI chat |
|
||||
| ลูกศร | เลื่อน 1px | | `Cmd+,` | Agent settings |
|
||||
| `Cmd+Alt+U` | รวมบูลีน | | `Cmd+Alt+S` | ลบบูลีน |
|
||||
| `Cmd+Alt+I` | ตัดกันบูลีน | | | |
|
||||
|
||||
## Scripts
|
||||
|
||||
|
|
|
|||
91
README.tr.md
91
README.tr.md
|
|
@ -124,15 +124,15 @@ bun run electron:dev
|
|||
|
||||
Birden fazla görüntü varyantı mevcuttur — ihtiyaçlarınıza uygun olanı seçin:
|
||||
|
||||
| Görüntü | Boyut | İçerik |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 MB | Yalnızca web uygulaması |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | Tüm CLI araçları |
|
||||
| Görüntü | Boyut | İçerik |
|
||||
| ---------------------------- | ------- | ----------------------- |
|
||||
| `openpencil:latest` | ~226 MB | Yalnızca web uygulaması |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | Tüm CLI araçları |
|
||||
|
||||
**Çalıştır (yalnızca web):**
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ docker build --target full -t openpencil-full .
|
|||
## AI Destekli Tasarım
|
||||
|
||||
**Prompttan UI'ye**
|
||||
|
||||
- **Metinden tasarıma** — bir sayfayı tanımlayın, gerçek zamanlı akış animasyonuyla kanvasta oluşturulsun
|
||||
- **Orkestratör** — karmaşık sayfaları paralel üretim için uzamsal alt görevlere ayırır
|
||||
- **Tasarım değişikliği** — öğeleri seçin, ardından değişiklikleri doğal dille tanımlayın
|
||||
|
|
@ -180,20 +181,21 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
**Çok Ajanlı Destek**
|
||||
|
||||
| Ajan | Kurulum |
|
||||
| --- | --- |
|
||||
| Ajan | Kurulum |
|
||||
| --------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| **Yerleşik (9+ sağlayıcı)** | Sağlayıcı ön ayarlarından seçin ve bölge değiştirin — Anthropic, OpenAI, Google, DeepSeek ve daha fazlası |
|
||||
| **Claude Code** | Yapılandırma gerekmez — yerel OAuth ile Claude Agent SDK kullanır |
|
||||
| **Codex CLI** | Ajan Ayarlarından bağlanın (`Cmd+,`) |
|
||||
| **OpenCode** | Ajan Ayarlarından bağlanın (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` ardından Ajan Ayarlarından bağlanın (`Cmd+,`) |
|
||||
| **Gemini CLI** | Ajan Ayarlarından bağlanın (`Cmd+,`) |
|
||||
| **Claude Code** | Yapılandırma gerekmez — yerel OAuth ile Claude Agent SDK kullanır |
|
||||
| **Codex CLI** | Ajan Ayarlarından bağlanın (`Cmd+,`) |
|
||||
| **OpenCode** | Ajan Ayarlarından bağlanın (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` ardından Ajan Ayarlarından bağlanın (`Cmd+,`) |
|
||||
| **Gemini CLI** | Ajan Ayarlarından bağlanın (`Cmd+,`) |
|
||||
|
||||
**Model Yetenek Profilleri** — promptları, düşünme modunu ve zaman aşımlarını model katmanına göre otomatik olarak uyarlar. Tam katman modeller (Claude) eksiksiz promptlar alır; standart katman (GPT-4o, Gemini, DeepSeek) düşünme modunu devre dışı bırakır; temel katman (MiniMax, Qwen, Llama, Mistral) maksimum güvenilirlik için basitleştirilmiş iç içe JSON promptları alır.
|
||||
|
||||
**i18n** — 15 dilde tam arayüz yerelleştirmesi: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
|
||||
|
||||
**MCP Sunucusu**
|
||||
|
||||
- Yerleşik MCP sunucusu — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI'larına tek tıkla kurulum
|
||||
- Otomatik Node.js algılama — kurulu değilse otomatik olarak HTTP aktarımına geçer ve MCP HTTP sunucusunu otomatik başlatır
|
||||
- Terminalden tasarım otomasyonu: herhangi bir MCP uyumlu ajan aracılığıyla `.op` dosyalarını okuyun, oluşturun ve düzenleyin
|
||||
|
|
@ -202,6 +204,7 @@ docker build --target full -t openpencil-full .
|
|||
- Çok sayfa desteği — MCP araçları ile sayfaları oluşturun, yeniden adlandırın, sıralayın ve çoğaltın
|
||||
|
||||
**Kod Üretimi**
|
||||
|
||||
- React + Tailwind CSS, HTML + CSS, CSS Variables
|
||||
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
|
||||
|
||||
|
|
@ -229,6 +232,7 @@ cat design.dsl | op design - # stdin'den pipe ile besle
|
|||
## Özellikler
|
||||
|
||||
**Kanvas ve Çizim**
|
||||
|
||||
- Kaydırma, yakınlaştırma, akıllı hizalama kılavuzları ve yakalamayı destekleyen sonsuz kanvas
|
||||
- Dikdörtgen, Elips, Çizgi, Çokgen, Kalem (Bezier), Frame, Metin
|
||||
- Boolean işlemler — bağlamsal araç çubuğuyla birleştir, çıkar, kesiştir
|
||||
|
|
@ -237,15 +241,18 @@ cat design.dsl | op design - # stdin'den pipe ile besle
|
|||
- Sekme navigasyonlu çok sayfalı belgeler
|
||||
|
||||
**Tasarım Sistemi**
|
||||
|
||||
- Tasarım değişkenleri — `$variable` referanslı renk, sayı, metin tokenları
|
||||
- Çok tema desteği — birden fazla tema ekseni, her biri varyantlarıyla (Açık/Koyu, Kompakt/Rahat)
|
||||
- Bileşen sistemi — örnekler ve geçersiz kılmalarla yeniden kullanılabilir bileşenler
|
||||
- CSS senkronizasyonu — otomatik oluşturulan özel özellikler, kod çıktısında `var(--name)`
|
||||
|
||||
**Figma İçe Aktarma**
|
||||
|
||||
- Düzen, dolgu, kontur, efektler, metin, görseller ve vektörler korunarak `.fig` dosyalarını içe aktarın
|
||||
|
||||
**Masaüstü Uygulaması**
|
||||
|
||||
- Electron aracılığıyla yerel macOS, Windows ve Linux desteği
|
||||
- `.op` dosya ilişkilendirmesi — açmak için çift tıklayın, tekli örnek kilidi
|
||||
- GitHub Releases'ten otomatik güncelleme
|
||||
|
|
@ -253,17 +260,17 @@ cat design.dsl | op design - # stdin'den pipe ile besle
|
|||
|
||||
## Teknoloji Yığını
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Ön Uç** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Kanvas** | CanvasKit/Skia (WASM, GPU hızlandırmalı) |
|
||||
| **Durum Yönetimi** | Zustand v5 |
|
||||
| **Sunucu** | Nitro |
|
||||
| **Masaüstü** | Electron 35 |
|
||||
| **CLI** | `op` — terminal kontrolü, toplu tasarım DSL, kod dışa aktarımı |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Çalışma Ortamı** | Bun · Vite 7 |
|
||||
| **Dosya Formatı** | `.op` — JSON tabanlı, insan tarafından okunabilir, Git dostu |
|
||||
| | |
|
||||
| ------------------ | -------------------------------------------------------------------------------- |
|
||||
| **Ön Uç** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Kanvas** | CanvasKit/Skia (WASM, GPU hızlandırmalı) |
|
||||
| **Durum Yönetimi** | Zustand v5 |
|
||||
| **Sunucu** | Nitro |
|
||||
| **Masaüstü** | Electron 35 |
|
||||
| **CLI** | `op` — terminal kontrolü, toplu tasarım DSL, kod dışa aktarımı |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Çalışma Ortamı** | Bun · Vite 7 |
|
||||
| **Dosya Formatı** | `.op` — JSON tabanlı, insan tarafından okunabilir, Git dostu |
|
||||
|
||||
## Proje Yapısı
|
||||
|
||||
|
|
@ -304,21 +311,21 @@ openpencil/
|
|||
|
||||
## Klavye Kısayolları
|
||||
|
||||
| Tuş | İşlem | | Tuş | İşlem |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | Seç | | `Cmd+S` | Kaydet |
|
||||
| `R` | Dikdörtgen | | `Cmd+Z` | Geri Al |
|
||||
| `O` | Elips | | `Cmd+Shift+Z` | Yeniden Yap |
|
||||
| `L` | Çizgi | | `Cmd+C/X/V/D` | Kopyala/Kes/Yapıştır/Çoğalt |
|
||||
| `T` | Metin | | `Cmd+G` | Grupla |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Grubu Çöz |
|
||||
| `P` | Kalem aracı | | `Cmd+Shift+E` | Dışa Aktar |
|
||||
| `H` | El (kaydır) | | `Cmd+Shift+C` | Kod paneli |
|
||||
| `Del` | Sil | | `Cmd+Shift+V` | Değişkenler paneli |
|
||||
| `[ / ]` | Yeniden sırala | | `Cmd+J` | AI sohbet |
|
||||
| Oklar | 1px kaydır | | `Cmd+,` | Ajan ayarları |
|
||||
| `Cmd+Alt+U` | Boolean birleştir | | `Cmd+Alt+S` | Boolean çıkar |
|
||||
| `Cmd+Alt+I` | Boolean kesiştir | | | |
|
||||
| Tuş | İşlem | | Tuş | İşlem |
|
||||
| ----------- | ----------------- | --- | ------------- | --------------------------- |
|
||||
| `V` | Seç | | `Cmd+S` | Kaydet |
|
||||
| `R` | Dikdörtgen | | `Cmd+Z` | Geri Al |
|
||||
| `O` | Elips | | `Cmd+Shift+Z` | Yeniden Yap |
|
||||
| `L` | Çizgi | | `Cmd+C/X/V/D` | Kopyala/Kes/Yapıştır/Çoğalt |
|
||||
| `T` | Metin | | `Cmd+G` | Grupla |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Grubu Çöz |
|
||||
| `P` | Kalem aracı | | `Cmd+Shift+E` | Dışa Aktar |
|
||||
| `H` | El (kaydır) | | `Cmd+Shift+C` | Kod paneli |
|
||||
| `Del` | Sil | | `Cmd+Shift+V` | Değişkenler paneli |
|
||||
| `[ / ]` | Yeniden sırala | | `Cmd+J` | AI sohbet |
|
||||
| Oklar | 1px kaydır | | `Cmd+,` | Ajan ayarları |
|
||||
| `Cmd+Alt+U` | Boolean birleştir | | `Cmd+Alt+S` | Boolean çıkar |
|
||||
| `Cmd+Alt+I` | Boolean kesiştir | | | |
|
||||
|
||||
## Betikler
|
||||
|
||||
|
|
|
|||
91
README.vi.md
91
README.vi.md
|
|
@ -124,15 +124,15 @@ bun run electron:dev
|
|||
|
||||
Có nhiều biến thể image khác nhau — chọn loại phù hợp với nhu cầu của bạn:
|
||||
|
||||
| Image | Kích thước | Bao gồm |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 MB | Chỉ ứng dụng web |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | Tất cả công cụ CLI |
|
||||
| Image | Kích thước | Bao gồm |
|
||||
| ---------------------------- | ---------- | -------------------- |
|
||||
| `openpencil:latest` | ~226 MB | Chỉ ứng dụng web |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | Tất cả công cụ CLI |
|
||||
|
||||
**Chạy (chỉ web):**
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ docker build --target full -t openpencil-full .
|
|||
## Thiết kế thuần AI
|
||||
|
||||
**Từ Prompt đến Giao diện**
|
||||
|
||||
- **Văn bản thành thiết kế** — mô tả một trang, nhận kết quả được tạo ra trên canvas theo thời gian thực với hiệu ứng streaming
|
||||
- **Orchestrator** — phân rã các trang phức tạp thành các tác vụ con không gian để tạo song song
|
||||
- **Chỉnh sửa thiết kế** — chọn các phần tử, sau đó mô tả thay đổi bằng ngôn ngữ tự nhiên
|
||||
|
|
@ -180,20 +181,21 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
**Hỗ trợ Đa tác nhân**
|
||||
|
||||
| Tác nhân | Cài đặt |
|
||||
| --- | --- |
|
||||
| Tác nhân | Cài đặt |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| **Tích hợp sẵn (9+ nhà cung cấp)** | Chọn từ các preset nhà cung cấp với bộ chuyển đổi khu vực — Anthropic, OpenAI, Google, DeepSeek và nhiều hơn |
|
||||
| **Claude Code** | Không cần cấu hình — sử dụng Claude Agent SDK với OAuth cục bộ |
|
||||
| **Codex CLI** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) |
|
||||
| **OpenCode** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` rồi kết nối trong Cài đặt tác nhân (`Cmd+,`) |
|
||||
| **Gemini CLI** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) |
|
||||
| **Claude Code** | Không cần cấu hình — sử dụng Claude Agent SDK với OAuth cục bộ |
|
||||
| **Codex CLI** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) |
|
||||
| **OpenCode** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) |
|
||||
| **GitHub Copilot** | `copilot login` rồi kết nối trong Cài đặt tác nhân (`Cmd+,`) |
|
||||
| **Gemini CLI** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) |
|
||||
|
||||
**Hồ sơ Năng lực Mô hình** — tự động thích ứng prompt, chế độ thinking và thời gian chờ theo từng cấp mô hình. Mô hình cấp đầy đủ (Claude) nhận prompt hoàn chỉnh; cấp tiêu chuẩn (GPT-4o, Gemini, DeepSeek) tắt thinking; cấp cơ bản (MiniMax, Qwen, Llama, Mistral) nhận prompt JSON lồng nhau đơn giản hóa để đảm bảo độ tin cậy tối đa.
|
||||
|
||||
**i18n** — Bản địa hóa giao diện đầy đủ bằng 15 ngôn ngữ: English, 简体中文, 繁體中文, 日本語, 한국어, Français, Español, Deutsch, Português, Русский, हिन्दी, Türkçe, ไทย, Tiếng Việt, Bahasa Indonesia.
|
||||
|
||||
**Máy chủ MCP**
|
||||
|
||||
- Máy chủ MCP tích hợp sẵn — cài đặt một cú nhấp vào Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
|
||||
- Tự động phát hiện Node.js — nếu chưa cài đặt, tự động chuyển sang HTTP transport và khởi động MCP HTTP server
|
||||
- Tự động hóa thiết kế từ terminal: đọc, tạo và chỉnh sửa các tệp `.op` qua bất kỳ tác nhân tương thích MCP nào
|
||||
|
|
@ -202,6 +204,7 @@ docker build --target full -t openpencil-full .
|
|||
- Hỗ trợ nhiều trang — tạo, đổi tên, sắp xếp lại và nhân bản trang qua các công cụ MCP
|
||||
|
||||
**Tạo mã nguồn**
|
||||
|
||||
- React + Tailwind CSS, HTML + CSS, CSS Variables
|
||||
- Vue, Svelte, Flutter, SwiftUI, Jetpack Compose, React Native
|
||||
|
||||
|
|
@ -229,6 +232,7 @@ Hỗ trợ ba phương thức nhập liệu: chuỗi inline, `@filepath` (đọc
|
|||
## Tính năng
|
||||
|
||||
**Canvas và Vẽ**
|
||||
|
||||
- Canvas vô hạn với pan, zoom, hướng dẫn căn chỉnh thông minh và snapping
|
||||
- Hình chữ nhật, Hình ellipse, Đường thẳng, Đa giác, Bút (Bezier), Frame, Văn bản
|
||||
- Phép toán Boolean — hợp nhất, trừ, giao nhau với thanh công cụ ngữ cảnh
|
||||
|
|
@ -237,15 +241,18 @@ Hỗ trợ ba phương thức nhập liệu: chuỗi inline, `@filepath` (đọc
|
|||
- Tài liệu nhiều trang với điều hướng bằng tab
|
||||
|
||||
**Hệ thống Thiết kế**
|
||||
|
||||
- Biến thiết kế — token màu sắc, số, chuỗi với tham chiếu `$variable`
|
||||
- Hỗ trợ đa chủ đề — nhiều trục, mỗi trục có các biến thể (Sáng/Tối, Thu gọn/Thoải mái)
|
||||
- Hệ thống component — các component có thể tái sử dụng với instances và overrides
|
||||
- Đồng bộ CSS — thuộc tính tùy chỉnh tự động tạo, `var(--name)` trong đầu ra mã
|
||||
|
||||
**Nhập từ Figma**
|
||||
|
||||
- Nhập tệp `.fig` với layout, fills, strokes, effects, văn bản, hình ảnh và vector được bảo toàn
|
||||
|
||||
**Ứng dụng Desktop**
|
||||
|
||||
- macOS, Windows và Linux gốc qua Electron
|
||||
- Liên kết tệp `.op` — nhấp đúp để mở, khóa phiên bản đơn
|
||||
- Tự động cập nhật từ GitHub Releases
|
||||
|
|
@ -253,17 +260,17 @@ Hỗ trợ ba phương thức nhập liệu: chuỗi inline, `@filepath` (đọc
|
|||
|
||||
## Công nghệ
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Canvas** | CanvasKit/Skia (WASM, tăng tốc GPU) |
|
||||
| **Trạng thái** | Zustand v5 |
|
||||
| **Máy chủ** | Nitro |
|
||||
| **Desktop** | Electron 35 |
|
||||
| **CLI** | `op` — điều khiển từ terminal, batch design DSL, xuất mã |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Runtime** | Bun · Vite 7 |
|
||||
| **Định dạng tệp** | `.op` — dựa trên JSON, dễ đọc, thân thiện với Git |
|
||||
| | |
|
||||
| ----------------- | -------------------------------------------------------------------------------- |
|
||||
| **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **Canvas** | CanvasKit/Skia (WASM, tăng tốc GPU) |
|
||||
| **Trạng thái** | Zustand v5 |
|
||||
| **Máy chủ** | Nitro |
|
||||
| **Desktop** | Electron 35 |
|
||||
| **CLI** | `op` — điều khiển từ terminal, batch design DSL, xuất mã |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **Runtime** | Bun · Vite 7 |
|
||||
| **Định dạng tệp** | `.op` — dựa trên JSON, dễ đọc, thân thiện với Git |
|
||||
|
||||
## Cấu trúc dự án
|
||||
|
||||
|
|
@ -304,21 +311,21 @@ openpencil/
|
|||
|
||||
## Phím tắt
|
||||
|
||||
| Phím | Hành động | | Phím | Hành động |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | Chọn | | `Cmd+S` | Lưu |
|
||||
| `R` | Hình chữ nhật | | `Cmd+Z` | Hoàn tác |
|
||||
| `O` | Hình ellipse | | `Cmd+Shift+Z` | Làm lại |
|
||||
| `L` | Đường thẳng | | `Cmd+C/X/V/D` | Sao chép/Cắt/Dán/Nhân bản |
|
||||
| `T` | Văn bản | | `Cmd+G` | Nhóm |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Bỏ nhóm |
|
||||
| `P` | Công cụ bút | | `Cmd+Shift+E` | Xuất |
|
||||
| `H` | Tay (pan) | | `Cmd+Shift+C` | Bảng mã |
|
||||
| `Del` | Xóa | | `Cmd+Shift+V` | Bảng biến |
|
||||
| `[ / ]` | Sắp xếp lại | | `Cmd+J` | AI chat |
|
||||
| Mũi tên | Dịch chuyển 1px | | `Cmd+,` | Cài đặt tác nhân |
|
||||
| `Cmd+Alt+U` | Hợp nhất Boolean | | `Cmd+Alt+S` | Trừ Boolean |
|
||||
| `Cmd+Alt+I` | Giao nhau Boolean | | | |
|
||||
| Phím | Hành động | | Phím | Hành động |
|
||||
| ----------- | ----------------- | --- | ------------- | ------------------------- |
|
||||
| `V` | Chọn | | `Cmd+S` | Lưu |
|
||||
| `R` | Hình chữ nhật | | `Cmd+Z` | Hoàn tác |
|
||||
| `O` | Hình ellipse | | `Cmd+Shift+Z` | Làm lại |
|
||||
| `L` | Đường thẳng | | `Cmd+C/X/V/D` | Sao chép/Cắt/Dán/Nhân bản |
|
||||
| `T` | Văn bản | | `Cmd+G` | Nhóm |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | Bỏ nhóm |
|
||||
| `P` | Công cụ bút | | `Cmd+Shift+E` | Xuất |
|
||||
| `H` | Tay (pan) | | `Cmd+Shift+C` | Bảng mã |
|
||||
| `Del` | Xóa | | `Cmd+Shift+V` | Bảng biến |
|
||||
| `[ / ]` | Sắp xếp lại | | `Cmd+J` | AI chat |
|
||||
| Mũi tên | Dịch chuyển 1px | | `Cmd+,` | Cài đặt tác nhân |
|
||||
| `Cmd+Alt+U` | Hợp nhất Boolean | | `Cmd+Alt+S` | Trừ Boolean |
|
||||
| `Cmd+Alt+I` | Giao nhau Boolean | | | |
|
||||
|
||||
## Scripts
|
||||
|
||||
|
|
|
|||
|
|
@ -124,15 +124,15 @@ bun run electron:dev
|
|||
|
||||
提供多種映像檔變體 — 選擇適合您需求的版本:
|
||||
|
||||
| 映像檔 | 大小 | 包含 |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 MB | 僅 Web 應用程式 |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | 所有 CLI 工具 |
|
||||
| 映像檔 | 大小 | 包含 |
|
||||
| ---------------------------- | ------- | -------------------- |
|
||||
| `openpencil:latest` | ~226 MB | 僅 Web 應用程式 |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | 所有 CLI 工具 |
|
||||
|
||||
**執行(僅 Web):**
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ docker build --target full -t openpencil-full .
|
|||
## AI 原生設計
|
||||
|
||||
**提示詞生成 UI**
|
||||
|
||||
- **文字轉設計** — 描述一個頁面,即時以串流動畫在畫布上生成
|
||||
- **編排器** — 將複雜頁面分解為空間子任務,支援並行生成
|
||||
- **設計修改** — 選取元素後,以自然語言描述變更
|
||||
|
|
@ -180,20 +181,21 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
**多智能體支援**
|
||||
|
||||
| 智能體 | 設定方式 |
|
||||
| --- | --- |
|
||||
| 智能體 | 設定方式 |
|
||||
| --------------------- | --------------------------------------------------------------------- |
|
||||
| **內建(9+ 提供商)** | 從提供商預設中選擇並切換區域 — Anthropic、OpenAI、Google、DeepSeek 等 |
|
||||
| **Claude Code** | 無需設定 — 使用 Claude Agent SDK 本地 OAuth |
|
||||
| **Codex CLI** | 在 Agent 設定中連接(`Cmd+,`) |
|
||||
| **OpenCode** | 在 Agent 設定中連接(`Cmd+,`) |
|
||||
| **GitHub Copilot** | 執行 `copilot login` 後在 Agent 設定中連接(`Cmd+,`) |
|
||||
| **Gemini CLI** | 在 Agent 設定中連接(`Cmd+,`) |
|
||||
| **Claude Code** | 無需設定 — 使用 Claude Agent SDK 本地 OAuth |
|
||||
| **Codex CLI** | 在 Agent 設定中連接(`Cmd+,`) |
|
||||
| **OpenCode** | 在 Agent 設定中連接(`Cmd+,`) |
|
||||
| **GitHub Copilot** | 執行 `copilot login` 後在 Agent 設定中連接(`Cmd+,`) |
|
||||
| **Gemini CLI** | 在 Agent 設定中連接(`Cmd+,`) |
|
||||
|
||||
**模型能力設定檔** — 自動依據模型層級調整提示詞、思考模式和逾時設定。完整層級模型(Claude)獲得完整提示詞;標準層級(GPT-4o、Gemini、DeepSeek)停用思考模式;基礎層級(MiniMax、Qwen、Llama、Mistral)獲得精簡巢狀 JSON 提示詞,確保最大可靠性。
|
||||
|
||||
**國際化** — 完整介面本地化,支援 15 種語言:English、简体中文、繁體中文、日本語、한국어、Français、Español、Deutsch、Português、Русский、हिन्दी、Türkçe、ไทย、Tiếng Việt、Bahasa Indonesia。
|
||||
|
||||
**MCP 伺服器**
|
||||
|
||||
- 內建 MCP 伺服器 — 一鍵安裝至 Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
|
||||
- 自動偵測 Node.js — 若未安裝則自動回退到 HTTP 傳輸模式並啟動 MCP HTTP 伺服器
|
||||
- 從終端機進行設計自動化:透過任意 MCP 相容的智能體讀取、建立和修改 `.op` 檔案
|
||||
|
|
@ -202,6 +204,7 @@ docker build --target full -t openpencil-full .
|
|||
- 多頁面支援 — 透過 MCP 工具建立、重新命名、重新排序和複製頁面
|
||||
|
||||
**程式碼生成**
|
||||
|
||||
- React + Tailwind CSS、HTML + CSS、CSS Variables
|
||||
- Vue、Svelte、Flutter、SwiftUI、Jetpack Compose、React Native
|
||||
|
||||
|
|
@ -229,6 +232,7 @@ cat design.dsl | op design - # 從 stdin 管道輸入
|
|||
## 功能特色
|
||||
|
||||
**畫布與繪圖**
|
||||
|
||||
- 無限畫布,支援平移、縮放、智慧對齊參考線和吸附
|
||||
- 矩形、橢圓、直線、多邊形、鋼筆(貝茲曲線)、Frame、文字
|
||||
- 布林運算 — 聯合、減去、交集,搭配上下文工具列
|
||||
|
|
@ -237,15 +241,18 @@ cat design.dsl | op design - # 從 stdin 管道輸入
|
|||
- 多頁面文件,支援分頁導覽
|
||||
|
||||
**設計系統**
|
||||
|
||||
- 設計變數 — 顏色、數字、字串令牌,支援 `$variable` 參照
|
||||
- 多主題支援 — 多個主題軸,每個軸有多個變體(亮色/暗色、緊湊/舒適)
|
||||
- 元件系統 — 可重複使用元件,支援實體和覆寫
|
||||
- CSS 同步 — 自動生成自訂屬性,程式碼輸出中使用 `var(--name)`
|
||||
|
||||
**Figma 匯入**
|
||||
|
||||
- 匯入 `.fig` 檔案,保留版面配置、填色、筆觸、效果、文字、圖片和向量圖形
|
||||
|
||||
**桌面應用程式**
|
||||
|
||||
- 透過 Electron 支援原生 macOS、Windows 和 Linux
|
||||
- `.op` 檔案關聯 — 雙擊即可開啟,支援單一實體鎖定
|
||||
- 從 GitHub Releases 自動更新
|
||||
|
|
@ -253,17 +260,17 @@ cat design.dsl | op design - # 從 stdin 管道輸入
|
|||
|
||||
## 技術堆疊
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **前端** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **畫布** | CanvasKit/Skia(WASM、GPU 加速) |
|
||||
| **狀態管理** | Zustand v5 |
|
||||
| **伺服器** | Nitro |
|
||||
| **桌面端** | Electron 35 |
|
||||
| **CLI** | `op` — 終端機控制、批次設計 DSL、程式碼匯出 |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **執行環境** | Bun · Vite 7 |
|
||||
| **檔案格式** | `.op` — 基於 JSON,人類可讀,對 Git 友好 |
|
||||
| | |
|
||||
| ------------ | -------------------------------------------------------------------------------- |
|
||||
| **前端** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **畫布** | CanvasKit/Skia(WASM、GPU 加速) |
|
||||
| **狀態管理** | Zustand v5 |
|
||||
| **伺服器** | Nitro |
|
||||
| **桌面端** | Electron 35 |
|
||||
| **CLI** | `op` — 終端機控制、批次設計 DSL、程式碼匯出 |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **執行環境** | Bun · Vite 7 |
|
||||
| **檔案格式** | `.op` — 基於 JSON,人類可讀,對 Git 友好 |
|
||||
|
||||
## 專案結構
|
||||
|
||||
|
|
@ -304,21 +311,21 @@ openpencil/
|
|||
|
||||
## 鍵盤快捷鍵
|
||||
|
||||
| 按鍵 | 操作 | | 按鍵 | 操作 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | 選取 | | `Cmd+S` | 儲存 |
|
||||
| `R` | 矩形 | | `Cmd+Z` | 復原 |
|
||||
| `O` | 橢圓 | | `Cmd+Shift+Z` | 重做 |
|
||||
| `L` | 直線 | | `Cmd+C/X/V/D` | 複製/剪下/貼上/重複 |
|
||||
| `T` | 文字 | | `Cmd+G` | 群組 |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | 解散群組 |
|
||||
| `P` | 鋼筆工具 | | `Cmd+Shift+E` | 匯出 |
|
||||
| `H` | 手形(平移) | | `Cmd+Shift+C` | 程式碼面板 |
|
||||
| `Del` | 刪除 | | `Cmd+Shift+V` | 變數面板 |
|
||||
| `[ / ]` | 調整圖層順序 | | `Cmd+J` | AI 聊天 |
|
||||
| 方向鍵 | 微移 1px | | `Cmd+,` | 智能體設定 |
|
||||
| `Cmd+Alt+U` | 布林聯合 | | `Cmd+Alt+S` | 布林減去 |
|
||||
| `Cmd+Alt+I` | 布林交集 | | | |
|
||||
| 按鍵 | 操作 | | 按鍵 | 操作 |
|
||||
| ----------- | ------------ | --- | ------------- | ------------------- |
|
||||
| `V` | 選取 | | `Cmd+S` | 儲存 |
|
||||
| `R` | 矩形 | | `Cmd+Z` | 復原 |
|
||||
| `O` | 橢圓 | | `Cmd+Shift+Z` | 重做 |
|
||||
| `L` | 直線 | | `Cmd+C/X/V/D` | 複製/剪下/貼上/重複 |
|
||||
| `T` | 文字 | | `Cmd+G` | 群組 |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | 解散群組 |
|
||||
| `P` | 鋼筆工具 | | `Cmd+Shift+E` | 匯出 |
|
||||
| `H` | 手形(平移) | | `Cmd+Shift+C` | 程式碼面板 |
|
||||
| `Del` | 刪除 | | `Cmd+Shift+V` | 變數面板 |
|
||||
| `[ / ]` | 調整圖層順序 | | `Cmd+J` | AI 聊天 |
|
||||
| 方向鍵 | 微移 1px | | `Cmd+,` | 智能體設定 |
|
||||
| `Cmd+Alt+U` | 布林聯合 | | `Cmd+Alt+S` | 布林減去 |
|
||||
| `Cmd+Alt+I` | 布林交集 | | | |
|
||||
|
||||
## 指令碼命令
|
||||
|
||||
|
|
|
|||
91
README.zh.md
91
README.zh.md
|
|
@ -124,15 +124,15 @@ bun run electron:dev
|
|||
|
||||
提供多个镜像变体 — 按需选择:
|
||||
|
||||
| 镜像 | 大小 | 包含 |
|
||||
| --- | --- | --- |
|
||||
| `openpencil:latest` | ~226 MB | 仅 Web 应用 |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | 全部 CLI 工具 |
|
||||
| 镜像 | 大小 | 包含 |
|
||||
| ---------------------------- | ------- | -------------------- |
|
||||
| `openpencil:latest` | ~226 MB | 仅 Web 应用 |
|
||||
| `openpencil-claude:latest` | — | + Claude Code CLI |
|
||||
| `openpencil-codex:latest` | — | + Codex CLI |
|
||||
| `openpencil-opencode:latest` | — | + OpenCode CLI |
|
||||
| `openpencil-copilot:latest` | — | + GitHub Copilot CLI |
|
||||
| `openpencil-gemini:latest` | — | + Gemini CLI |
|
||||
| `openpencil-full:latest` | ~1 GB | 全部 CLI 工具 |
|
||||
|
||||
**运行(仅 Web):**
|
||||
|
||||
|
|
@ -173,6 +173,7 @@ docker build --target full -t openpencil-full .
|
|||
## AI 原生设计
|
||||
|
||||
**提示词生成 UI**
|
||||
|
||||
- **文字转设计** — 描述一个页面,实时以流式动画在画布上生成
|
||||
- **编排器** — 将复杂页面分解为空间子任务,支持并行生成
|
||||
- **设计修改** — 选中元素后,用自然语言描述更改
|
||||
|
|
@ -180,20 +181,21 @@ docker build --target full -t openpencil-full .
|
|||
|
||||
**多智能体支持**
|
||||
|
||||
| 智能体 | 配置方式 |
|
||||
| --- | --- |
|
||||
| 智能体 | 配置方式 |
|
||||
| --------------------- | --------------------------------------------------------------------- |
|
||||
| **内置(9+ 提供商)** | 从提供商预设中选择并切换区域 — Anthropic、OpenAI、Google、DeepSeek 等 |
|
||||
| **Claude Code** | 无需配置 — 使用 Claude Agent SDK 本地 OAuth |
|
||||
| **Codex CLI** | 在 Agent 设置中连接(`Cmd+,`) |
|
||||
| **OpenCode** | 在 Agent 设置中连接(`Cmd+,`) |
|
||||
| **GitHub Copilot** | 运行 `copilot login` 后在 Agent 设置中连接(`Cmd+,`) |
|
||||
| **Gemini CLI** | 在 Agent 设置中连接(`Cmd+,`) |
|
||||
| **Claude Code** | 无需配置 — 使用 Claude Agent SDK 本地 OAuth |
|
||||
| **Codex CLI** | 在 Agent 设置中连接(`Cmd+,`) |
|
||||
| **OpenCode** | 在 Agent 设置中连接(`Cmd+,`) |
|
||||
| **GitHub Copilot** | 运行 `copilot login` 后在 Agent 设置中连接(`Cmd+,`) |
|
||||
| **Gemini CLI** | 在 Agent 设置中连接(`Cmd+,`) |
|
||||
|
||||
**模型能力配置** — 自动根据模型层级适配提示词、思考模式和超时时间。完整层级模型(Claude)获得完整提示词;标准层级模型(GPT-4o、Gemini、DeepSeek)关闭思考模式;基础层级模型(MiniMax、Qwen、Llama、Mistral)使用简化的嵌套 JSON 提示词以确保最大可靠性。
|
||||
|
||||
**国际化** — 完整界面本地化,支持 15 种语言:English、简体中文、繁體中文、日本語、한국어、Français、Español、Deutsch、Português、Русский、हिन्दी、Türkçe、ไทย、Tiếng Việt、Bahasa Indonesia。
|
||||
|
||||
**MCP 服务器**
|
||||
|
||||
- 内置 MCP 服务器 — 一键安装到 Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI
|
||||
- 自动检测 Node.js — 若未安装则自动回退到 HTTP 传输模式并启动 MCP HTTP 服务器
|
||||
- 从终端进行设计自动化:通过任意 MCP 兼容的智能体读取、创建和修改 `.op` 文件
|
||||
|
|
@ -202,6 +204,7 @@ docker build --target full -t openpencil-full .
|
|||
- 多页面支持 — 通过 MCP 工具创建、重命名、重新排序和复制页面
|
||||
|
||||
**代码生成**
|
||||
|
||||
- React + Tailwind CSS、HTML + CSS、CSS Variables
|
||||
- Vue、Svelte、Flutter、SwiftUI、Jetpack Compose、React Native
|
||||
|
||||
|
|
@ -229,6 +232,7 @@ cat design.dsl | op design - # 从 stdin 管道输入
|
|||
## 功能特性
|
||||
|
||||
**画布与绘图**
|
||||
|
||||
- 无限画布,支持平移、缩放、智能对齐参考线和吸附
|
||||
- 矩形、椭圆、直线、多边形、钢笔(贝塞尔)、Frame、文本
|
||||
- 布尔运算 — 联合、减去、交集,配合上下文工具栏
|
||||
|
|
@ -237,15 +241,18 @@ cat design.dsl | op design - # 从 stdin 管道输入
|
|||
- 多页面文档,支持标签页导航
|
||||
|
||||
**设计系统**
|
||||
|
||||
- 设计变量 — 颜色、数字、字符串令牌,支持 `$variable` 引用
|
||||
- 多主题支持 — 多个主题轴,每个轴有多个变体(浅色/深色、紧凑/舒适)
|
||||
- 组件系统 — 可复用组件,支持实例和覆盖
|
||||
- CSS 同步 — 自动生成自定义属性,代码输出中使用 `var(--name)`
|
||||
|
||||
**Figma 导入**
|
||||
|
||||
- 导入 `.fig` 文件,保留布局、填充、描边、效果、文本、图片和矢量图形
|
||||
|
||||
**桌面应用**
|
||||
|
||||
- 通过 Electron 支持原生 macOS、Windows 和 Linux
|
||||
- `.op` 文件关联 — 双击即可打开,单实例锁定
|
||||
- 从 GitHub Releases 自动更新
|
||||
|
|
@ -253,17 +260,17 @@ cat design.dsl | op design - # 从 stdin 管道输入
|
|||
|
||||
## 技术栈
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **前端** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **画布** | CanvasKit/Skia(WASM, GPU 加速) |
|
||||
| **状态管理** | Zustand v5 |
|
||||
| **服务器** | Nitro |
|
||||
| **桌面端** | Electron 35 |
|
||||
| **CLI** | `op` — 终端控制、批量设计 DSL、代码导出 |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **运行时** | Bun · Vite 7 |
|
||||
| **文件格式** | `.op` — 基于 JSON,人类可读,对 Git 友好 |
|
||||
| | |
|
||||
| ------------ | -------------------------------------------------------------------------------- |
|
||||
| **前端** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui · i18next |
|
||||
| **画布** | CanvasKit/Skia(WASM, GPU 加速) |
|
||||
| **状态管理** | Zustand v5 |
|
||||
| **服务器** | Nitro |
|
||||
| **桌面端** | Electron 35 |
|
||||
| **CLI** | `op` — 终端控制、批量设计 DSL、代码导出 |
|
||||
| **AI** | Vercel AI SDK v6 · Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK |
|
||||
| **运行时** | Bun · Vite 7 |
|
||||
| **文件格式** | `.op` — 基于 JSON,人类可读,对 Git 友好 |
|
||||
|
||||
## 项目结构
|
||||
|
||||
|
|
@ -304,21 +311,21 @@ openpencil/
|
|||
|
||||
## 键盘快捷键
|
||||
|
||||
| 按键 | 操作 | | 按键 | 操作 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `V` | 选择 | | `Cmd+S` | 保存 |
|
||||
| `R` | 矩形 | | `Cmd+Z` | 撤销 |
|
||||
| `O` | 椭圆 | | `Cmd+Shift+Z` | 重做 |
|
||||
| `L` | 直线 | | `Cmd+C/X/V/D` | 复制/剪切/粘贴/重复 |
|
||||
| `T` | 文本 | | `Cmd+G` | 编组 |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | 取消编组 |
|
||||
| `P` | 钢笔工具 | | `Cmd+Shift+E` | 导出 |
|
||||
| `H` | 手形(平移) | | `Cmd+Shift+C` | 代码面板 |
|
||||
| `Del` | 删除 | | `Cmd+Shift+V` | 变量面板 |
|
||||
| `[ / ]` | 调整层级顺序 | | `Cmd+J` | AI 聊天 |
|
||||
| 方向键 | 微移 1px | | `Cmd+,` | 智能体设置 |
|
||||
| `Cmd+Alt+U` | 布尔联合 | | `Cmd+Alt+S` | 布尔减去 |
|
||||
| `Cmd+Alt+I` | 布尔交集 | | | |
|
||||
| 按键 | 操作 | | 按键 | 操作 |
|
||||
| ----------- | ------------ | --- | ------------- | ------------------- |
|
||||
| `V` | 选择 | | `Cmd+S` | 保存 |
|
||||
| `R` | 矩形 | | `Cmd+Z` | 撤销 |
|
||||
| `O` | 椭圆 | | `Cmd+Shift+Z` | 重做 |
|
||||
| `L` | 直线 | | `Cmd+C/X/V/D` | 复制/剪切/粘贴/重复 |
|
||||
| `T` | 文本 | | `Cmd+G` | 编组 |
|
||||
| `F` | Frame | | `Cmd+Shift+G` | 取消编组 |
|
||||
| `P` | 钢笔工具 | | `Cmd+Shift+E` | 导出 |
|
||||
| `H` | 手形(平移) | | `Cmd+Shift+C` | 代码面板 |
|
||||
| `Del` | 删除 | | `Cmd+Shift+V` | 变量面板 |
|
||||
| `[ / ]` | 调整层级顺序 | | `Cmd+J` | AI 聊天 |
|
||||
| 方向键 | 微移 1px | | `Cmd+,` | 智能体设置 |
|
||||
| `Cmd+Alt+U` | 布尔联合 | | `Cmd+Alt+S` | 布尔减去 |
|
||||
| `Cmd+Alt+I` | 布尔交集 | | | |
|
||||
|
||||
## 脚本命令
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil
|
|||
|
||||
Das CLI erkennt und startet die OpenPencil-Desktop-App automatisch auf allen Plattformen:
|
||||
|
||||
| Plattform | Erkannte Installationspfade |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS pro Benutzer (`%LOCALAPPDATA%`), systemweit (`%PROGRAMFILES%`), portabel |
|
||||
| Plattform | Erkannte Installationspfade |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS pro Benutzer (`%LOCALAPPDATA%`), systemweit (`%PROGRAMFILES%`), portabel |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
|
||||
## Verwendung
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil
|
|||
|
||||
El CLI detecta y lanza automaticamente la aplicacion de escritorio OpenPencil en todas las plataformas:
|
||||
|
||||
| Plataforma | Rutas de instalacion detectadas |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS por usuario (`%LOCALAPPDATA%`), por maquina (`%PROGRAMFILES%`), portable |
|
||||
| Plataforma | Rutas de instalacion detectadas |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS por usuario (`%LOCALAPPDATA%`), por maquina (`%PROGRAMFILES%`), portable |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
|
||||
## Uso
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil
|
|||
|
||||
Le CLI detecte et lance automatiquement l'application de bureau OpenPencil sur toutes les plateformes :
|
||||
|
||||
| Plateforme | Chemins d'installation detectes |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS par utilisateur (`%LOCALAPPDATA%`), par machine (`%PROGRAMFILES%`), portable |
|
||||
| Plateforme | Chemins d'installation detectes |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS par utilisateur (`%LOCALAPPDATA%`), par machine (`%PROGRAMFILES%`), portable |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
|
||||
## Utilisation
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil
|
|||
|
||||
CLI सभी प्लेटफ़ॉर्म पर OpenPencil डेस्कटॉप ऐप को स्वचालित रूप से पहचानता और लॉन्च करता है:
|
||||
|
||||
| प्लेटफ़ॉर्म | पहचाने गए इंस्टॉलेशन पथ |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS प्रति-उपयोगकर्ता (`%LOCALAPPDATA%`), प्रति-मशीन (`%PROGRAMFILES%`), पोर्टेबल |
|
||||
| प्लेटफ़ॉर्म | पहचाने गए इंस्टॉलेशन पथ |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS प्रति-उपयोगकर्ता (`%LOCALAPPDATA%`), प्रति-मशीन (`%PROGRAMFILES%`), पोर्टेबल |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
|
||||
## उपयोग
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil
|
|||
|
||||
CLI secara otomatis mendeteksi dan meluncurkan aplikasi desktop OpenPencil di semua platform:
|
||||
|
||||
| Platform | Jalur instalasi yang terdeteksi |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS per-pengguna (`%LOCALAPPDATA%`), per-mesin (`%PROGRAMFILES%`), portabel |
|
||||
| Platform | Jalur instalasi yang terdeteksi |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS per-pengguna (`%LOCALAPPDATA%`), per-mesin (`%PROGRAMFILES%`), portabel |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
|
||||
## Penggunaan
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ CLI はすべてのプラットフォームで OpenPencil デスクトップア
|
|||
| プラットフォーム | 検出されるインストールパス |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS ユーザー単位 (`%LOCALAPPDATA%`)、マシン単位 (`%PROGRAMFILES%`)、ポータブル |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
| **Windows** | NSIS ユーザー単位 (`%LOCALAPPDATA%`)、マシン単位 (`%PROGRAMFILES%`)、ポータブル |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
|
||||
## 使い方
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil
|
|||
|
||||
CLI는 모든 플랫폼에서 OpenPencil 데스크톱 앱을 자동으로 감지하고 실행합니다:
|
||||
|
||||
| 플랫폼 | 감지되는 설치 경로 |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS 사용자별 (`%LOCALAPPDATA%`), 시스템 전체 (`%PROGRAMFILES%`), 포터블 |
|
||||
| 플랫폼 | 감지되는 설치 경로 |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS 사용자별 (`%LOCALAPPDATA%`), 시스템 전체 (`%PROGRAMFILES%`), 포터블 |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
|
||||
## 사용법
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil
|
|||
|
||||
The CLI automatically detects and launches the OpenPencil desktop app on all platforms:
|
||||
|
||||
| Platform | Installation paths detected |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS per-user (`%LOCALAPPDATA%`), per-machine (`%PROGRAMFILES%`), portable |
|
||||
| Platform | Installation paths detected |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS per-user (`%LOCALAPPDATA%`), per-machine (`%PROGRAMFILES%`), portable |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
|
||||
## Usage
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil
|
|||
|
||||
A CLI detecta e inicia automaticamente o aplicativo desktop OpenPencil em todas as plataformas:
|
||||
|
||||
| Plataforma | Caminhos de instalacao detectados |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS por usuario (`%LOCALAPPDATA%`), por maquina (`%PROGRAMFILES%`), portatil |
|
||||
| Plataforma | Caminhos de instalacao detectados |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS por usuario (`%LOCALAPPDATA%`), por maquina (`%PROGRAMFILES%`), portatil |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
|
||||
## Uso
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil
|
|||
|
||||
CLI автоматически обнаруживает и запускает настольное приложение OpenPencil на всех платформах:
|
||||
|
||||
| Платформа | Обнаруживаемые пути установки |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS для пользователя (`%LOCALAPPDATA%`), для машины (`%PROGRAMFILES%`), портативная версия |
|
||||
| Платформа | Обнаруживаемые пути установки |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS для пользователя (`%LOCALAPPDATA%`), для машины (`%PROGRAMFILES%`), портативная версия |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
|
||||
## Использование
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil
|
|||
|
||||
CLI จะตรวจจับและเปิดแอปเดสก์ท็อป OpenPencil โดยอัตโนมัติบนทุกแพลตฟอร์ม:
|
||||
|
||||
| แพลตฟอร์ม | เส้นทางการติดตั้งที่ตรวจพบ |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS ต่อผู้ใช้ (`%LOCALAPPDATA%`), ต่อเครื่อง (`%PROGRAMFILES%`), แบบพกพา |
|
||||
| แพลตฟอร์ม | เส้นทางการติดตั้งที่ตรวจพบ |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS ต่อผู้ใช้ (`%LOCALAPPDATA%`), ต่อเครื่อง (`%PROGRAMFILES%`), แบบพกพา |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
|
||||
## การใช้งาน
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil
|
|||
|
||||
CLI, tum platformlarda OpenPencil masaustu uygulamasini otomatik olarak algilar ve baslatir:
|
||||
|
||||
| Platform | Algilanan kurulum yollari |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | Kullanici basina NSIS (`%LOCALAPPDATA%`), makine basina (`%PROGRAMFILES%`), tasinabilir |
|
||||
| Platform | Algilanan kurulum yollari |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | Kullanici basina NSIS (`%LOCALAPPDATA%`), makine basina (`%PROGRAMFILES%`), tasinabilir |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
|
||||
## Kullanim
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil
|
|||
|
||||
CLI tự động phát hiện và khởi chạy ứng dụng desktop OpenPencil trên tất cả các nền tảng:
|
||||
|
||||
| Nền tảng | Đường dẫn cài đặt được phát hiện |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS theo người dùng (`%LOCALAPPDATA%`), theo máy (`%PROGRAMFILES%`), di động |
|
||||
| Nền tảng | Đường dẫn cài đặt được phát hiện |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS theo người dùng (`%LOCALAPPDATA%`), theo máy (`%PROGRAMFILES%`), di động |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
|
||||
## Sử dụng
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil
|
|||
|
||||
CLI 會自動偵測並啟動所有平台上的 OpenPencil 桌面應用程式:
|
||||
|
||||
| 平台 | 偵測的安裝路徑 |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`、`~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS 使用者安裝(`%LOCALAPPDATA%`)、全域安裝(`%PROGRAMFILES%`)、可攜版 |
|
||||
| 平台 | 偵測的安裝路徑 |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`、`~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS 使用者安裝(`%LOCALAPPDATA%`)、全域安裝(`%PROGRAMFILES%`)、可攜版 |
|
||||
| **Linux** | `/usr/bin`、`/usr/local/bin`、`~/.local/bin`、AppImage(`~/Applications`、`~/Downloads`)、Snap、Flatpak |
|
||||
|
||||
## 使用方式
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ npm install -g @zseven-w/openpencil
|
|||
|
||||
CLI 会自动检测并启动各平台上的 OpenPencil 桌面应用:
|
||||
|
||||
| 平台 | 检测的安装路径 |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS 用户级 (`%LOCALAPPDATA%`)、系统级 (`%PROGRAMFILES%`)、便携版 |
|
||||
| 平台 | 检测的安装路径 |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **macOS** | `/Applications/OpenPencil.app`, `~/Applications/OpenPencil.app` |
|
||||
| **Windows** | NSIS 用户级 (`%LOCALAPPDATA%`)、系统级 (`%PROGRAMFILES%`)、便携版 |
|
||||
| **Linux** | `/usr/bin`, `/usr/local/bin`, `~/.local/bin`, AppImage (`~/Applications`, `~/Downloads`), Snap, Flatpak |
|
||||
|
||||
## 用法
|
||||
|
|
|
|||
|
|
@ -1,20 +1,24 @@
|
|||
{
|
||||
"name": "@zseven-w/openpencil",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"description": "CLI for OpenPencil — control the design tool from your terminal",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "ZSeven-W",
|
||||
"email": "xkayshen@gmail.com"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"op": "dist/openpencil-cli.cjs"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"compile": "cd ../web && esbuild ../cli/src/index.ts --bundle --platform=node --target=node20 --outfile=../cli/dist/openpencil-cli.cjs --format=cjs --sourcemap --alias:@=src --alias:@zseven-w/pen-types=../../packages/pen-types/src --alias:@zseven-w/pen-core=../../packages/pen-core/src --alias:@zseven-w/pen-codegen=../../packages/pen-codegen/src --alias:@zseven-w/pen-figma=../../packages/pen-figma/src --alias:@zseven-w/pen-renderer=../../packages/pen-renderer/src --alias:@zseven-w/pen-sdk=../../packages/pen-sdk/src --define:import.meta.env={} --external:canvas --external:paper"
|
||||
"compile": "cd ../.. && bun run cli:compile"
|
||||
},
|
||||
"dependencies": {
|
||||
"@zseven-w/pen-figma": "workspace:*",
|
||||
"@zseven-w/pen-mcp": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,32 @@
|
|||
import { getAppInfo } from '../connection'
|
||||
import { startDesktop, startWeb, stopApp } from '../launcher'
|
||||
import { output, outputError } from '../output'
|
||||
import { getAppInfo } from '../connection';
|
||||
import { startDesktop, startWeb, stopApp } from '../launcher';
|
||||
import { output, outputError } from '../output';
|
||||
|
||||
export async function cmdStart(flags: {
|
||||
desktop?: boolean
|
||||
web?: boolean
|
||||
}): Promise<void> {
|
||||
export async function cmdStart(flags: { desktop?: boolean; web?: boolean }): Promise<void> {
|
||||
try {
|
||||
let result: { port: number; pid: number }
|
||||
let result: { port: number; pid: number };
|
||||
if (flags.web) {
|
||||
result = await startWeb()
|
||||
result = await startWeb();
|
||||
} else {
|
||||
result = await startDesktop()
|
||||
result = await startDesktop();
|
||||
}
|
||||
output({ ok: true, ...result, url: `http://127.0.0.1:${result.port}` })
|
||||
output({ ok: true, ...result, url: `http://127.0.0.1:${result.port}` });
|
||||
} catch (err) {
|
||||
outputError((err as Error).message)
|
||||
outputError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdStop(): Promise<void> {
|
||||
const stopped = await stopApp()
|
||||
const stopped = await stopApp();
|
||||
if (stopped) {
|
||||
output({ ok: true, message: 'OpenPencil stopped' })
|
||||
output({ ok: true, message: 'OpenPencil stopped' });
|
||||
} else {
|
||||
output({ ok: true, message: 'No running instance found' })
|
||||
output({ ok: true, message: 'No running instance found' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdStatus(): Promise<void> {
|
||||
const info = await getAppInfo()
|
||||
const info = await getAppInfo();
|
||||
if (info) {
|
||||
output({
|
||||
running: true,
|
||||
|
|
@ -37,8 +34,8 @@ export async function cmdStatus(): Promise<void> {
|
|||
pid: info.pid,
|
||||
url: info.url,
|
||||
uptime: Math.floor((Date.now() - info.timestamp) / 1000),
|
||||
})
|
||||
});
|
||||
} else {
|
||||
output({ running: false })
|
||||
output({ running: false });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
120
apps/cli/src/commands/codegen.ts
Normal file
120
apps/cli/src/commands/codegen.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// apps/cli/src/commands/codegen.ts
|
||||
|
||||
import { resolveDocPath, getSyncUrl } from '@zseven-w/pen-mcp';
|
||||
import { outputError, resolveArg, parseJsonArg } from '../output';
|
||||
|
||||
interface GlobalFlags {
|
||||
file?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
async function requireSyncUrl(): Promise<string | null> {
|
||||
const syncUrl = await getSyncUrl();
|
||||
if (!syncUrl) {
|
||||
outputError('No running OpenPencil instance found. Start the app first with: op start');
|
||||
}
|
||||
return syncUrl;
|
||||
}
|
||||
|
||||
export async function cmdCodegenPlan(args: string[], flags: GlobalFlags): Promise<void> {
|
||||
const raw = await resolveArg(args[0]);
|
||||
if (!raw) {
|
||||
outputError('Usage: op codegen:plan <plan-json|@file|->');
|
||||
}
|
||||
const plan = await parseJsonArg(raw);
|
||||
|
||||
const syncUrl = await requireSyncUrl();
|
||||
if (!syncUrl) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${syncUrl}/api/mcp/codegen/plan`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
plan,
|
||||
filePath: flags.file ? resolveDocPath(flags.file) : undefined,
|
||||
pageId: flags.page,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
outputError(await res.text());
|
||||
}
|
||||
process.stdout.write(JSON.stringify(await res.json(), null, 2));
|
||||
} catch (err) {
|
||||
outputError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdCodegenSubmit(args: string[], _flags: GlobalFlags): Promise<void> {
|
||||
const planId = args[0];
|
||||
const raw = await resolveArg(args[1]);
|
||||
if (!planId || !raw) {
|
||||
outputError('Usage: op codegen:submit <planId> <chunk-result|@file|->');
|
||||
}
|
||||
const result = await parseJsonArg(raw);
|
||||
|
||||
const syncUrl = await requireSyncUrl();
|
||||
if (!syncUrl) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${syncUrl}/api/mcp/codegen/submit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ planId, result }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
outputError(await res.text());
|
||||
}
|
||||
process.stdout.write(JSON.stringify(await res.json(), null, 2));
|
||||
} catch (err) {
|
||||
outputError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdCodegenAssemble(
|
||||
args: string[],
|
||||
flags: GlobalFlags & { framework?: string },
|
||||
): Promise<void> {
|
||||
const planId = args[0];
|
||||
if (!planId) {
|
||||
outputError('Usage: op codegen:assemble <planId> [--framework react]');
|
||||
}
|
||||
const framework = flags.framework || 'react';
|
||||
|
||||
const syncUrl = await requireSyncUrl();
|
||||
if (!syncUrl) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${syncUrl}/api/mcp/codegen/assemble/${encodeURIComponent(planId)}?framework=${encodeURIComponent(framework)}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
outputError(await res.text());
|
||||
}
|
||||
process.stdout.write(JSON.stringify(await res.json(), null, 2));
|
||||
} catch (err) {
|
||||
outputError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdCodegenClean(args: string[], _flags: GlobalFlags): Promise<void> {
|
||||
const planId = args[0];
|
||||
if (!planId) {
|
||||
outputError('Usage: op codegen:clean <planId>');
|
||||
}
|
||||
|
||||
const syncUrl = await requireSyncUrl();
|
||||
if (!syncUrl) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${syncUrl}/api/mcp/codegen/plan/${encodeURIComponent(planId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
outputError(await res.text());
|
||||
}
|
||||
process.stdout.write(JSON.stringify(await res.json(), null, 2));
|
||||
} catch (err) {
|
||||
outputError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +1,67 @@
|
|||
import { handleBatchDesign } from '@/mcp/tools/batch-design'
|
||||
import { handleDesignSkeleton } from '@/mcp/tools/design-skeleton'
|
||||
import { handleDesignContent } from '@/mcp/tools/design-content'
|
||||
import { handleDesignRefine } from '@/mcp/tools/design-refine'
|
||||
import { output, outputError, parseJsonArg, resolveArg } from '../output'
|
||||
import { handleBatchDesign } from '@zseven-w/pen-mcp';
|
||||
import { handleDesignSkeleton } from '@zseven-w/pen-mcp';
|
||||
import { handleDesignContent } from '@zseven-w/pen-mcp';
|
||||
import { handleDesignRefine } from '@zseven-w/pen-mcp';
|
||||
import { output, outputError, parseJsonArg, resolveArg } from '../output';
|
||||
|
||||
interface GlobalFlags {
|
||||
file?: string
|
||||
page?: string
|
||||
file?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
export async function cmdDesign(
|
||||
args: string[],
|
||||
flags: GlobalFlags & { postProcess?: boolean; canvasWidth?: string },
|
||||
): Promise<void> {
|
||||
const operations = await resolveArg(args[0])
|
||||
const operations = await resolveArg(args[0]);
|
||||
const result = await handleBatchDesign({
|
||||
filePath: flags.file,
|
||||
operations,
|
||||
postProcess: flags.postProcess !== false,
|
||||
canvasWidth: flags.canvasWidth ? parseInt(flags.canvasWidth, 10) : undefined,
|
||||
pageId: flags.page,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdDesignSkeleton(
|
||||
args: string[],
|
||||
flags: GlobalFlags,
|
||||
): Promise<void> {
|
||||
const json = (await parseJsonArg(args[0])) as Record<string, unknown>
|
||||
export async function cmdDesignSkeleton(args: string[], flags: GlobalFlags): Promise<void> {
|
||||
const json = (await parseJsonArg(args[0])) as Record<string, unknown>;
|
||||
const result = await handleDesignSkeleton({
|
||||
filePath: flags.file,
|
||||
rootFrame: json.rootFrame as any,
|
||||
sections: json.sections as any,
|
||||
pageId: flags.page,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdDesignContent(
|
||||
args: string[],
|
||||
flags: GlobalFlags & { canvasWidth?: string },
|
||||
): Promise<void> {
|
||||
const sectionId = args[0]
|
||||
if (!sectionId) outputError('Usage: openpencil design:content <section-id> <json>')
|
||||
const json = (await parseJsonArg(args[1])) as Record<string, unknown>
|
||||
const sectionId = args[0];
|
||||
if (!sectionId) outputError('Usage: openpencil design:content <section-id> <json>');
|
||||
const json = (await parseJsonArg(args[1])) as Record<string, unknown>;
|
||||
const result = await handleDesignContent({
|
||||
filePath: flags.file,
|
||||
sectionId,
|
||||
children: json.children as any,
|
||||
canvasWidth: flags.canvasWidth ? parseInt(flags.canvasWidth, 10) : undefined,
|
||||
pageId: flags.page,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdDesignRefine(
|
||||
args: string[],
|
||||
_args: string[],
|
||||
flags: GlobalFlags & { rootId?: string; canvasWidth?: string },
|
||||
): Promise<void> {
|
||||
if (!flags.rootId) outputError('Usage: openpencil design:refine --root-id <id>')
|
||||
if (!flags.rootId) outputError('Usage: openpencil design:refine --root-id <id>');
|
||||
const result = await handleDesignRefine({
|
||||
filePath: flags.file,
|
||||
rootId: flags.rootId!,
|
||||
canvasWidth: flags.canvasWidth ? parseInt(flags.canvasWidth, 10) : undefined,
|
||||
pageId: flags.page,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,44 @@
|
|||
import { handleOpenDocument } from '@/mcp/tools/open-document'
|
||||
import { handleBatchGet } from '@/mcp/tools/batch-get'
|
||||
import { handleGetSelection } from '@/mcp/tools/get-selection'
|
||||
import { openDocument, saveDocument, resolveDocPath } from '@/mcp/document-manager'
|
||||
import { output, outputError } from '../output'
|
||||
import { handleOpenDocument } from '@zseven-w/pen-mcp';
|
||||
import { handleBatchGet } from '@zseven-w/pen-mcp';
|
||||
import { handleGetSelection } from '@zseven-w/pen-mcp';
|
||||
import { openDocument, saveDocument, resolveDocPath } from '@zseven-w/pen-mcp';
|
||||
import { output, outputError } from '../output';
|
||||
|
||||
interface GlobalFlags {
|
||||
file?: string
|
||||
page?: string
|
||||
file?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
export async function cmdOpen(args: string[], flags: GlobalFlags): Promise<void> {
|
||||
const result = await handleOpenDocument({ filePath: flags.file ?? args[0] })
|
||||
output(result)
|
||||
const result = await handleOpenDocument({ filePath: flags.file ?? args[0] });
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdSave(args: string[], flags: GlobalFlags): Promise<void> {
|
||||
const target = args[0]
|
||||
if (!target) outputError('Usage: openpencil save <file.op>')
|
||||
const doc = await openDocument(resolveDocPath(flags.file))
|
||||
await saveDocument(target, doc)
|
||||
output({ ok: true, filePath: target })
|
||||
const target = args[0];
|
||||
if (!target) outputError('Usage: openpencil save <file.op>');
|
||||
const doc = await openDocument(resolveDocPath(flags.file));
|
||||
await saveDocument(target, doc);
|
||||
output({ ok: true, filePath: target });
|
||||
}
|
||||
|
||||
export async function cmdGet(args: string[], flags: GlobalFlags & {
|
||||
type?: string
|
||||
name?: string
|
||||
id?: string
|
||||
depth?: string
|
||||
parent?: string
|
||||
}): Promise<void> {
|
||||
const patterns: { type?: string; name?: string }[] = []
|
||||
export async function cmdGet(
|
||||
_args: string[],
|
||||
flags: GlobalFlags & {
|
||||
type?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
depth?: string;
|
||||
parent?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const patterns: { type?: string; name?: string }[] = [];
|
||||
if (flags.type || flags.name) {
|
||||
patterns.push({ type: flags.type, name: flags.name })
|
||||
patterns.push({ type: flags.type, name: flags.name });
|
||||
}
|
||||
|
||||
const nodeIds: string[] = []
|
||||
if (flags.id) nodeIds.push(flags.id)
|
||||
const nodeIds: string[] = [];
|
||||
if (flags.id) nodeIds.push(flags.id);
|
||||
|
||||
const result = await handleBatchGet({
|
||||
filePath: flags.file,
|
||||
|
|
@ -44,14 +47,14 @@ export async function cmdGet(args: string[], flags: GlobalFlags & {
|
|||
parentId: flags.parent,
|
||||
readDepth: flags.depth ? parseInt(flags.depth, 10) : undefined,
|
||||
pageId: flags.page,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdSelection(flags: GlobalFlags & { depth?: string }): Promise<void> {
|
||||
const result = await handleGetSelection({
|
||||
filePath: flags.file,
|
||||
readDepth: flags.depth ? parseInt(flags.depth, 10) : undefined,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
import { openDocument, resolveDocPath } from '@/mcp/document-manager'
|
||||
import {
|
||||
generateReactFromDocument,
|
||||
generateHTMLFromDocument,
|
||||
generateVueFromDocument,
|
||||
generateSvelteFromDocument,
|
||||
generateFlutterFromDocument,
|
||||
generateSwiftUIFromDocument,
|
||||
generateComposeFromDocument,
|
||||
generateReactNativeFromDocument,
|
||||
generateCSSVariables,
|
||||
} from '@zseven-w/pen-codegen'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { output, outputError } from '../output'
|
||||
|
||||
type GeneratorResult = string | { html: string; css: string }
|
||||
|
||||
const GENERATORS: Record<string, (doc: any) => GeneratorResult> = {
|
||||
react: generateReactFromDocument,
|
||||
html: generateHTMLFromDocument,
|
||||
vue: generateVueFromDocument,
|
||||
svelte: generateSvelteFromDocument,
|
||||
flutter: generateFlutterFromDocument,
|
||||
swiftui: generateSwiftUIFromDocument,
|
||||
compose: generateComposeFromDocument,
|
||||
rn: generateReactNativeFromDocument,
|
||||
'react-native': generateReactNativeFromDocument,
|
||||
css: (doc: any) => generateCSSVariables(doc.variables ?? {}),
|
||||
}
|
||||
|
||||
function resultToString(result: GeneratorResult): string {
|
||||
if (typeof result === 'string') return result
|
||||
// HTML generator returns { html, css }
|
||||
const parts: string[] = []
|
||||
if (result.css) parts.push(`<style>\n${result.css}\n</style>`)
|
||||
parts.push(result.html)
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
export async function cmdExport(
|
||||
args: string[],
|
||||
flags: { file?: string; out?: string },
|
||||
): Promise<void> {
|
||||
const format = args[0]
|
||||
if (!format) {
|
||||
outputError(
|
||||
`Usage: op export <format> [--out file]\nFormats: ${Object.keys(GENERATORS).join(', ')}`,
|
||||
)
|
||||
}
|
||||
const generator = GENERATORS[format]
|
||||
if (!generator) {
|
||||
outputError(`Unknown format: "${format}". Available: ${Object.keys(GENERATORS).join(', ')}`)
|
||||
}
|
||||
|
||||
const filePath = resolveDocPath(flags.file)
|
||||
const doc = await openDocument(filePath)
|
||||
const result = generator(doc)
|
||||
const code = resultToString(result)
|
||||
|
||||
if (flags.out) {
|
||||
await writeFile(flags.out, code, 'utf-8')
|
||||
output({ ok: true, format, file: flags.out, length: code.length })
|
||||
} else {
|
||||
process.stdout.write(code)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +1,50 @@
|
|||
import { handleImportSvg } from '@/mcp/tools/import-svg'
|
||||
import { openDocument, saveDocument, resolveDocPath } from '@/mcp/document-manager'
|
||||
import { parseFigFile, figmaAllPagesToPenDocument } from '@zseven-w/pen-figma'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { output, outputError } from '../output'
|
||||
import { handleImportSvg } from '@zseven-w/pen-mcp';
|
||||
import { saveDocument } from '@zseven-w/pen-mcp';
|
||||
import { parseFigFile, figmaAllPagesToPenDocument } from '@zseven-w/pen-figma';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename } from 'node:path';
|
||||
import { output, outputError } from '../output';
|
||||
|
||||
interface GlobalFlags {
|
||||
file?: string
|
||||
page?: string
|
||||
file?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
export async function cmdImportSvg(
|
||||
args: string[],
|
||||
flags: GlobalFlags & { parent?: string },
|
||||
): Promise<void> {
|
||||
const svgPath = args[0]
|
||||
if (!svgPath) outputError('Usage: op import:svg <file.svg>')
|
||||
const svgPath = args[0];
|
||||
if (!svgPath) outputError('Usage: op import:svg <file.svg>');
|
||||
const result = await handleImportSvg({
|
||||
filePath: flags.file,
|
||||
svgPath,
|
||||
parent: flags.parent ?? null,
|
||||
pageId: flags.page,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdImportFigma(
|
||||
args: string[],
|
||||
flags: GlobalFlags & { out?: string },
|
||||
): Promise<void> {
|
||||
const figPath = args[0]
|
||||
if (!figPath) outputError('Usage: op import:figma <file.fig> [--out output.op]')
|
||||
const figPath = args[0];
|
||||
if (!figPath) outputError('Usage: op import:figma <file.fig> [--out output.op]');
|
||||
|
||||
const buf = await readFile(figPath)
|
||||
const figFile = parseFigFile(new Uint8Array(buf))
|
||||
const doc = figmaAllPagesToPenDocument(figFile)
|
||||
const buf = await readFile(figPath);
|
||||
const figFile = parseFigFile(buf.buffer as ArrayBuffer);
|
||||
const { document: doc, warnings } = figmaAllPagesToPenDocument(figFile, basename(figPath));
|
||||
|
||||
const outPath = flags.out ?? figPath.replace(/\.fig$/, '.op')
|
||||
await saveDocument(outPath, doc)
|
||||
const outPath = flags.out ?? figPath.replace(/\.fig$/, '.op');
|
||||
await saveDocument(outPath, doc);
|
||||
output({
|
||||
ok: true,
|
||||
filePath: outPath,
|
||||
pageCount: doc.pages?.length ?? 1,
|
||||
nodeCount: doc.pages
|
||||
? doc.pages.reduce((s, p) => s + p.children.length, 0)
|
||||
? doc.pages.reduce((s: number, p: { children: unknown[] }) => s + p.children.length, 0)
|
||||
: doc.children.length,
|
||||
})
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { handleSnapshotLayout } from '@/mcp/tools/snapshot-layout'
|
||||
import { handleFindEmptySpace } from '@/mcp/tools/find-empty-space'
|
||||
import { output, outputError } from '../output'
|
||||
import { handleSnapshotLayout } from '@zseven-w/pen-mcp';
|
||||
import { handleFindEmptySpace } from '@zseven-w/pen-mcp';
|
||||
import { output } from '../output';
|
||||
|
||||
interface GlobalFlags {
|
||||
file?: string
|
||||
page?: string
|
||||
file?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
export async function cmdLayout(
|
||||
|
|
@ -15,8 +15,8 @@ export async function cmdLayout(
|
|||
parentId: flags.parent,
|
||||
maxDepth: flags.depth ? parseInt(flags.depth, 10) : undefined,
|
||||
pageId: flags.page,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdFindSpace(
|
||||
|
|
@ -25,9 +25,9 @@ export async function cmdFindSpace(
|
|||
const result = await handleFindEmptySpace({
|
||||
filePath: flags.file,
|
||||
direction: (flags.direction as 'right' | 'bottom' | 'left' | 'top') ?? 'right',
|
||||
width: flags.width ? parseInt(flags.width, 10) : undefined,
|
||||
height: flags.height ? parseInt(flags.height, 10) : undefined,
|
||||
width: flags.width ? parseInt(flags.width, 10) : 400,
|
||||
height: flags.height ? parseInt(flags.height, 10) : 300,
|
||||
pageId: flags.page,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,104 +5,101 @@ import {
|
|||
handleMoveNode,
|
||||
handleCopyNode,
|
||||
handleReplaceNode,
|
||||
} from '@/mcp/tools/node-crud'
|
||||
import { output, outputError, parseJsonArg } from '../output'
|
||||
} from '@zseven-w/pen-mcp';
|
||||
import { output, outputError, parseJsonArg } from '../output';
|
||||
|
||||
interface GlobalFlags {
|
||||
file?: string
|
||||
page?: string
|
||||
file?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
export async function cmdInsert(
|
||||
args: string[],
|
||||
flags: GlobalFlags & { parent?: string; index?: string; postProcess?: boolean },
|
||||
): Promise<void> {
|
||||
const data = (await parseJsonArg(args[0])) as Record<string, unknown>
|
||||
const data = (await parseJsonArg(args[0])) as Record<string, unknown>;
|
||||
const result = await handleInsertNode({
|
||||
filePath: flags.file,
|
||||
parent: flags.parent ?? null,
|
||||
data,
|
||||
postProcess: flags.postProcess,
|
||||
pageId: flags.page,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdUpdate(
|
||||
args: string[],
|
||||
flags: GlobalFlags & { postProcess?: boolean },
|
||||
): Promise<void> {
|
||||
const nodeId = args[0]
|
||||
if (!nodeId) outputError('Usage: openpencil update <node-id> <json>')
|
||||
const data = (await parseJsonArg(args[1])) as Record<string, unknown>
|
||||
const nodeId = args[0];
|
||||
if (!nodeId) outputError('Usage: openpencil update <node-id> <json>');
|
||||
const data = (await parseJsonArg(args[1])) as Record<string, unknown>;
|
||||
const result = await handleUpdateNode({
|
||||
filePath: flags.file,
|
||||
nodeId,
|
||||
data,
|
||||
postProcess: flags.postProcess,
|
||||
pageId: flags.page,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdDelete(
|
||||
args: string[],
|
||||
flags: GlobalFlags,
|
||||
): Promise<void> {
|
||||
const nodeId = args[0]
|
||||
if (!nodeId) outputError('Usage: openpencil delete <node-id>')
|
||||
export async function cmdDelete(args: string[], flags: GlobalFlags): Promise<void> {
|
||||
const nodeId = args[0];
|
||||
if (!nodeId) outputError('Usage: openpencil delete <node-id>');
|
||||
const result = await handleDeleteNode({
|
||||
filePath: flags.file,
|
||||
nodeId,
|
||||
pageId: flags.page,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdMove(
|
||||
args: string[],
|
||||
flags: GlobalFlags & { parent?: string; index?: string },
|
||||
): Promise<void> {
|
||||
const nodeId = args[0]
|
||||
if (!nodeId) outputError('Usage: openpencil move <node-id> --parent <parent-id>')
|
||||
const nodeId = args[0];
|
||||
if (!nodeId) outputError('Usage: openpencil move <node-id> --parent <parent-id>');
|
||||
const result = await handleMoveNode({
|
||||
filePath: flags.file,
|
||||
nodeId,
|
||||
parent: flags.parent ?? null,
|
||||
index: flags.index ? parseInt(flags.index, 10) : undefined,
|
||||
pageId: flags.page,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdCopy(
|
||||
args: string[],
|
||||
flags: GlobalFlags & { parent?: string },
|
||||
): Promise<void> {
|
||||
const sourceId = args[0]
|
||||
if (!sourceId) outputError('Usage: openpencil copy <source-id> [--parent <parent-id>]')
|
||||
const sourceId = args[0];
|
||||
if (!sourceId) outputError('Usage: openpencil copy <source-id> [--parent <parent-id>]');
|
||||
const result = await handleCopyNode({
|
||||
filePath: flags.file,
|
||||
sourceId,
|
||||
parent: flags.parent ?? null,
|
||||
pageId: flags.page,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdReplace(
|
||||
args: string[],
|
||||
flags: GlobalFlags & { postProcess?: boolean },
|
||||
): Promise<void> {
|
||||
const nodeId = args[0]
|
||||
if (!nodeId) outputError('Usage: openpencil replace <node-id> <json>')
|
||||
const data = (await parseJsonArg(args[1])) as Record<string, unknown>
|
||||
const nodeId = args[0];
|
||||
if (!nodeId) outputError('Usage: openpencil replace <node-id> <json>');
|
||||
const data = (await parseJsonArg(args[1])) as Record<string, unknown>;
|
||||
const result = await handleReplaceNode({
|
||||
filePath: flags.file,
|
||||
nodeId,
|
||||
data,
|
||||
postProcess: flags.postProcess,
|
||||
pageId: flags.page,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,21 +4,21 @@ import {
|
|||
handleRenamePage,
|
||||
handleReorderPage,
|
||||
handleDuplicatePage,
|
||||
} from '@/mcp/tools/pages'
|
||||
import { handleOpenDocument } from '@/mcp/tools/open-document'
|
||||
import { output, outputError } from '../output'
|
||||
} from '@zseven-w/pen-mcp';
|
||||
import { handleOpenDocument } from '@zseven-w/pen-mcp';
|
||||
import { output, outputError } from '../output';
|
||||
|
||||
interface GlobalFlags {
|
||||
file?: string
|
||||
file?: string;
|
||||
}
|
||||
|
||||
export async function cmdPageList(flags: GlobalFlags): Promise<void> {
|
||||
const result = await handleOpenDocument({ filePath: flags.file })
|
||||
const result = await handleOpenDocument({ filePath: flags.file });
|
||||
output({
|
||||
pages: result.document.pages ?? [
|
||||
{ id: 'default', name: 'Page 1', childCount: result.document.childCount },
|
||||
],
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdPageAdd(
|
||||
|
|
@ -28,60 +28,48 @@ export async function cmdPageAdd(
|
|||
const result = await handleAddPage({
|
||||
filePath: flags.file,
|
||||
name: flags.name ?? args[0],
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdPageRemove(
|
||||
args: string[],
|
||||
flags: GlobalFlags,
|
||||
): Promise<void> {
|
||||
const pageId = args[0]
|
||||
if (!pageId) outputError('Usage: op page remove <page-id>')
|
||||
export async function cmdPageRemove(args: string[], flags: GlobalFlags): Promise<void> {
|
||||
const pageId = args[0];
|
||||
if (!pageId) outputError('Usage: op page remove <page-id>');
|
||||
const result = await handleRemovePage({
|
||||
filePath: flags.file,
|
||||
pageId,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdPageRename(
|
||||
args: string[],
|
||||
flags: GlobalFlags,
|
||||
): Promise<void> {
|
||||
const [pageId, name] = args
|
||||
if (!pageId || !name) outputError('Usage: op page rename <page-id> <name>')
|
||||
export async function cmdPageRename(args: string[], flags: GlobalFlags): Promise<void> {
|
||||
const [pageId, name] = args;
|
||||
if (!pageId || !name) outputError('Usage: op page rename <page-id> <name>');
|
||||
const result = await handleRenamePage({
|
||||
filePath: flags.file,
|
||||
pageId,
|
||||
name,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdPageReorder(
|
||||
args: string[],
|
||||
flags: GlobalFlags,
|
||||
): Promise<void> {
|
||||
const [pageId, indexStr] = args
|
||||
if (!pageId || !indexStr) outputError('Usage: op page reorder <page-id> <index>')
|
||||
export async function cmdPageReorder(args: string[], flags: GlobalFlags): Promise<void> {
|
||||
const [pageId, indexStr] = args;
|
||||
if (!pageId || !indexStr) outputError('Usage: op page reorder <page-id> <index>');
|
||||
const result = await handleReorderPage({
|
||||
filePath: flags.file,
|
||||
pageId,
|
||||
index: parseInt(indexStr, 10),
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdPageDuplicate(
|
||||
args: string[],
|
||||
flags: GlobalFlags,
|
||||
): Promise<void> {
|
||||
const pageId = args[0]
|
||||
if (!pageId) outputError('Usage: op page duplicate <page-id>')
|
||||
export async function cmdPageDuplicate(args: string[], flags: GlobalFlags): Promise<void> {
|
||||
const pageId = args[0];
|
||||
if (!pageId) outputError('Usage: op page duplicate <page-id>');
|
||||
const result = await handleDuplicatePage({
|
||||
filePath: flags.file,
|
||||
pageId,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
|
|
|||
46
apps/cli/src/commands/read-nodes.ts
Normal file
46
apps/cli/src/commands/read-nodes.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// apps/cli/src/commands/read-nodes.ts
|
||||
|
||||
import { resolveDocPath, getSyncUrl } from '@zseven-w/pen-mcp';
|
||||
import { output, outputError } from '../output';
|
||||
|
||||
interface Flags {
|
||||
file?: string;
|
||||
page?: string;
|
||||
depth?: string;
|
||||
vars?: boolean;
|
||||
}
|
||||
|
||||
export async function cmdReadNodes(args: string[], flags: Flags): Promise<void> {
|
||||
const nodeIds = args[0]
|
||||
? args[0]
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
const depth = flags.depth !== undefined ? parseInt(flags.depth, 10) : undefined;
|
||||
|
||||
const syncUrl = await getSyncUrl();
|
||||
if (!syncUrl) {
|
||||
outputError('No running OpenPencil instance found. Start the app first with: op start');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${syncUrl}/api/mcp/read-nodes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
nodeIds,
|
||||
depth,
|
||||
pageId: flags.page,
|
||||
filePath: flags.file ? resolveDocPath(flags.file) : undefined,
|
||||
includeVariables: flags.vars,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
outputError(await res.text());
|
||||
}
|
||||
output(await res.json());
|
||||
} catch (err) {
|
||||
outputError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +1,75 @@
|
|||
import { handleGetVariables, handleSetVariables, handleSetThemes } from '@/mcp/tools/variables'
|
||||
import { handleGetVariables, handleSetVariables, handleSetThemes } from '@zseven-w/pen-mcp';
|
||||
import {
|
||||
handleSaveThemePreset,
|
||||
handleLoadThemePreset,
|
||||
handleListThemePresets,
|
||||
} from '@/mcp/tools/theme-presets'
|
||||
import { output, outputError, parseJsonArg } from '../output'
|
||||
} from '@zseven-w/pen-mcp';
|
||||
import { output, outputError, parseJsonArg } from '../output';
|
||||
|
||||
interface GlobalFlags {
|
||||
file?: string
|
||||
file?: string;
|
||||
}
|
||||
|
||||
export async function cmdVars(flags: GlobalFlags): Promise<void> {
|
||||
const result = await handleGetVariables({ filePath: flags.file })
|
||||
output(result)
|
||||
const result = await handleGetVariables({ filePath: flags.file });
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdVarsSet(
|
||||
args: string[],
|
||||
flags: GlobalFlags & { replace?: boolean },
|
||||
): Promise<void> {
|
||||
const data = (await parseJsonArg(args[0])) as Record<string, unknown>
|
||||
const data = (await parseJsonArg(args[0])) as Record<string, unknown>;
|
||||
const result = await handleSetVariables({
|
||||
filePath: flags.file,
|
||||
variables: data as any,
|
||||
replace: flags.replace,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdThemes(flags: GlobalFlags): Promise<void> {
|
||||
const result = await handleGetVariables({ filePath: flags.file })
|
||||
output({ themes: result.themes })
|
||||
const result = await handleGetVariables({ filePath: flags.file });
|
||||
output({ themes: result.themes });
|
||||
}
|
||||
|
||||
export async function cmdThemesSet(
|
||||
args: string[],
|
||||
flags: GlobalFlags & { replace?: boolean },
|
||||
): Promise<void> {
|
||||
const data = (await parseJsonArg(args[0])) as Record<string, unknown>
|
||||
const data = (await parseJsonArg(args[0])) as Record<string, unknown>;
|
||||
const result = await handleSetThemes({
|
||||
filePath: flags.file,
|
||||
themes: data as any,
|
||||
replace: flags.replace,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdThemeSave(
|
||||
args: string[],
|
||||
flags: GlobalFlags,
|
||||
): Promise<void> {
|
||||
const presetPath = args[0]
|
||||
if (!presetPath) outputError('Usage: op theme:save <file.optheme>')
|
||||
export async function cmdThemeSave(args: string[], flags: GlobalFlags): Promise<void> {
|
||||
const presetPath = args[0];
|
||||
if (!presetPath) outputError('Usage: op theme:save <file.optheme>');
|
||||
const result = await handleSaveThemePreset({
|
||||
filePath: flags.file,
|
||||
presetPath,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdThemeLoad(
|
||||
args: string[],
|
||||
flags: GlobalFlags,
|
||||
): Promise<void> {
|
||||
const presetPath = args[0]
|
||||
if (!presetPath) outputError('Usage: op theme:load <file.optheme>')
|
||||
export async function cmdThemeLoad(args: string[], flags: GlobalFlags): Promise<void> {
|
||||
const presetPath = args[0];
|
||||
if (!presetPath) outputError('Usage: op theme:load <file.optheme>');
|
||||
const result = await handleLoadThemePreset({
|
||||
filePath: flags.file,
|
||||
presetPath,
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
||||
export async function cmdThemeList(args: string[]): Promise<void> {
|
||||
if (!args[0]) outputError('Usage: op theme:list <directory>')
|
||||
if (!args[0]) outputError('Usage: op theme:list <directory>');
|
||||
const result = await handleListThemePresets({
|
||||
directory: args[0],
|
||||
})
|
||||
output(result)
|
||||
});
|
||||
output(result);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,89 +1,87 @@
|
|||
/** Port file discovery and app health check. */
|
||||
|
||||
import { readFile, unlink } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { readFile, unlink } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const PORT_FILE_DIR = '.openpencil'
|
||||
const PORT_FILE_NAME = '.port'
|
||||
const PORT_FILE_PATH = join(homedir(), PORT_FILE_DIR, PORT_FILE_NAME)
|
||||
const APP_BASE_URLS = ['http://127.0.0.1', 'http://localhost']
|
||||
const PORT_FILE_DIR = '.openpencil';
|
||||
const PORT_FILE_NAME = '.port';
|
||||
const PORT_FILE_PATH = join(homedir(), PORT_FILE_DIR, PORT_FILE_NAME);
|
||||
const APP_BASE_URLS = ['http://127.0.0.1', 'http://localhost'];
|
||||
|
||||
async function getReachableAppUrl(port: number): Promise<string | null> {
|
||||
for (const baseUrl of APP_BASE_URLS) {
|
||||
const url = `${baseUrl}:${port}/api/mcp/server`
|
||||
const url = `${baseUrl}:${port}/api/mcp/server`;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
})
|
||||
if (res.ok) return `${baseUrl}:${port}`
|
||||
});
|
||||
if (res.ok) return `${baseUrl}:${port}`;
|
||||
} catch {
|
||||
// App may still be starting, or the port file may be stale.
|
||||
}
|
||||
if (attempt < 4) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
||||
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppInfo {
|
||||
port: number
|
||||
pid: number
|
||||
timestamp: number
|
||||
url: string
|
||||
port: number;
|
||||
pid: number;
|
||||
timestamp: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Read port file and return app info, or null if no running instance. */
|
||||
export async function getAppInfo(): Promise<AppInfo | null> {
|
||||
try {
|
||||
const raw = await readFile(PORT_FILE_PATH, 'utf-8')
|
||||
const raw = await readFile(PORT_FILE_PATH, 'utf-8');
|
||||
const { port, pid, timestamp } = JSON.parse(raw) as {
|
||||
port: number
|
||||
pid: number
|
||||
timestamp: number
|
||||
}
|
||||
const url = await getReachableAppUrl(port)
|
||||
port: number;
|
||||
pid: number;
|
||||
timestamp: number;
|
||||
};
|
||||
const url = await getReachableAppUrl(port);
|
||||
if (url) {
|
||||
return { port, pid, timestamp, url }
|
||||
return { port, pid, timestamp, url };
|
||||
}
|
||||
if (!isPidAlive(pid)) {
|
||||
try {
|
||||
await unlink(PORT_FILE_PATH)
|
||||
await unlink(PORT_FILE_PATH);
|
||||
} catch {
|
||||
// Ignore stale port file cleanup failures.
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get app URL or throw if not running. */
|
||||
export async function requireApp(): Promise<string> {
|
||||
const info = await getAppInfo()
|
||||
const info = await getAppInfo();
|
||||
if (!info) {
|
||||
throw new Error(
|
||||
'No running OpenPencil instance found. Run `openpencil start` first.',
|
||||
)
|
||||
throw new Error('No running OpenPencil instance found. Run `openpencil start` first.');
|
||||
}
|
||||
return info.url
|
||||
return info.url;
|
||||
}
|
||||
|
||||
/** Quick check if app is running. */
|
||||
export async function isAppRunning(): Promise<boolean> {
|
||||
return (await getAppInfo()) !== null
|
||||
return (await getAppInfo()) !== null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,34 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import pkg from '../package.json'
|
||||
import { setPretty, output, outputError } from './output'
|
||||
import pkg from '../package.json';
|
||||
import { setPretty, output, outputError } from './output';
|
||||
|
||||
// --- Arg parsing ---
|
||||
|
||||
interface ParsedArgs {
|
||||
command: string
|
||||
positionals: string[]
|
||||
flags: Record<string, string | boolean>
|
||||
command: string;
|
||||
positionals: string[];
|
||||
flags: Record<string, string | boolean>;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const args = argv.slice(2)
|
||||
const positionals: string[] = []
|
||||
const flags: Record<string, string | boolean> = {}
|
||||
const args = argv.slice(2);
|
||||
const positionals: string[] = [];
|
||||
const flags: Record<string, string | boolean> = {};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
const arg = args[i];
|
||||
if (arg.startsWith('--')) {
|
||||
const key = arg.slice(2)
|
||||
const next = args[i + 1]
|
||||
const key = arg.slice(2);
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith('--')) {
|
||||
flags[key] = next
|
||||
i++
|
||||
flags[key] = next;
|
||||
i++;
|
||||
} else {
|
||||
flags[key] = true
|
||||
flags[key] = true;
|
||||
}
|
||||
} else {
|
||||
positionals.push(arg)
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ function parseArgs(argv: string[]): ParsedArgs {
|
|||
command: positionals[0] ?? '',
|
||||
positionals: positionals.slice(1),
|
||||
flags,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// --- Help ---
|
||||
|
|
@ -70,9 +70,15 @@ Design:
|
|||
op design:content <section-id> <json|@file|->
|
||||
op design:refine --root-id <id>
|
||||
|
||||
Export:
|
||||
op export <format> [--out file]
|
||||
Formats: react, html, vue, svelte, flutter, swiftui, compose, rn, css
|
||||
Read Nodes:
|
||||
op read-nodes [ids] [--depth N] [--vars] [--page P] [--file F]
|
||||
ids: comma-separated node IDs (omit to read all)
|
||||
|
||||
Codegen Pipeline:
|
||||
op codegen:plan <plan-json|@file|->
|
||||
op codegen:submit <planId> <chunk-result|@file|->
|
||||
op codegen:assemble <planId> [--framework react]
|
||||
op codegen:clean <planId>
|
||||
|
||||
Variables & Themes:
|
||||
op vars Get variables
|
||||
|
|
@ -110,64 +116,63 @@ Global Flags:
|
|||
--pretty Human-readable JSON output
|
||||
--help Show this help
|
||||
--version Show version
|
||||
`
|
||||
`;
|
||||
|
||||
// --- Main ---
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { command, positionals, flags } = parseArgs(process.argv)
|
||||
const { command, positionals, flags } = parseArgs(process.argv);
|
||||
|
||||
if (flags.pretty) setPretty(true)
|
||||
if (flags.pretty) setPretty(true);
|
||||
if (flags.help || command === 'help') {
|
||||
process.stdout.write(HELP)
|
||||
return
|
||||
process.stdout.write(HELP);
|
||||
return;
|
||||
}
|
||||
if (flags.version || command === 'version') {
|
||||
output({ version: pkg.version })
|
||||
return
|
||||
output({ version: pkg.version });
|
||||
return;
|
||||
}
|
||||
if (!command) {
|
||||
process.stdout.write(HELP)
|
||||
return
|
||||
process.stdout.write(HELP);
|
||||
return;
|
||||
}
|
||||
|
||||
const globalFlags = {
|
||||
file: flags.file as string | undefined,
|
||||
page: flags.page as string | undefined,
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
switch (command) {
|
||||
// --- App ---
|
||||
case 'start': {
|
||||
const { cmdStart } = await import('./commands/app')
|
||||
await cmdStart({ desktop: !!flags.desktop, web: !!flags.web })
|
||||
break
|
||||
const { cmdStart } = await import('./commands/app');
|
||||
await cmdStart({ desktop: !!flags.desktop, web: !!flags.web });
|
||||
break;
|
||||
}
|
||||
case 'stop': {
|
||||
const { cmdStop } = await import('./commands/app')
|
||||
await cmdStop()
|
||||
break
|
||||
const { cmdStop } = await import('./commands/app');
|
||||
await cmdStop();
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
const { cmdStatus } = await import('./commands/app')
|
||||
await cmdStatus()
|
||||
break
|
||||
const { cmdStatus } = await import('./commands/app');
|
||||
await cmdStatus();
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Document ---
|
||||
case 'open': {
|
||||
const { cmdOpen } = await import('./commands/document')
|
||||
await cmdOpen(positionals, globalFlags)
|
||||
break
|
||||
const { cmdOpen } = await import('./commands/document');
|
||||
await cmdOpen(positionals, globalFlags);
|
||||
break;
|
||||
}
|
||||
case 'save': {
|
||||
const { cmdSave } = await import('./commands/document')
|
||||
await cmdSave(positionals, globalFlags)
|
||||
break
|
||||
const { cmdSave } = await import('./commands/document');
|
||||
await cmdSave(positionals, globalFlags);
|
||||
break;
|
||||
}
|
||||
case 'get': {
|
||||
const { cmdGet } = await import('./commands/document')
|
||||
const { cmdGet } = await import('./commands/document');
|
||||
await cmdGet(positionals, {
|
||||
...globalFlags,
|
||||
type: flags.type as string | undefined,
|
||||
|
|
@ -175,230 +180,259 @@ async function main(): Promise<void> {
|
|||
id: flags.id as string | undefined,
|
||||
depth: flags.depth as string | undefined,
|
||||
parent: flags.parent as string | undefined,
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'selection': {
|
||||
const { cmdSelection } = await import('./commands/document')
|
||||
await cmdSelection({ ...globalFlags, depth: flags.depth as string | undefined })
|
||||
break
|
||||
const { cmdSelection } = await import('./commands/document');
|
||||
await cmdSelection({ ...globalFlags, depth: flags.depth as string | undefined });
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Nodes ---
|
||||
case 'insert': {
|
||||
const { cmdInsert } = await import('./commands/nodes')
|
||||
const { cmdInsert } = await import('./commands/nodes');
|
||||
await cmdInsert(positionals, {
|
||||
...globalFlags,
|
||||
parent: flags.parent as string | undefined,
|
||||
index: flags.index as string | undefined,
|
||||
postProcess: !!flags['post-process'],
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
const { cmdUpdate } = await import('./commands/nodes')
|
||||
const { cmdUpdate } = await import('./commands/nodes');
|
||||
await cmdUpdate(positionals, {
|
||||
...globalFlags,
|
||||
postProcess: !!flags['post-process'],
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
const { cmdDelete } = await import('./commands/nodes')
|
||||
await cmdDelete(positionals, globalFlags)
|
||||
break
|
||||
const { cmdDelete } = await import('./commands/nodes');
|
||||
await cmdDelete(positionals, globalFlags);
|
||||
break;
|
||||
}
|
||||
case 'move': {
|
||||
const { cmdMove } = await import('./commands/nodes')
|
||||
const { cmdMove } = await import('./commands/nodes');
|
||||
await cmdMove(positionals, {
|
||||
...globalFlags,
|
||||
parent: flags.parent as string | undefined,
|
||||
index: flags.index as string | undefined,
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'copy': {
|
||||
const { cmdCopy } = await import('./commands/nodes')
|
||||
const { cmdCopy } = await import('./commands/nodes');
|
||||
await cmdCopy(positionals, {
|
||||
...globalFlags,
|
||||
parent: flags.parent as string | undefined,
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'replace': {
|
||||
const { cmdReplace } = await import('./commands/nodes')
|
||||
const { cmdReplace } = await import('./commands/nodes');
|
||||
await cmdReplace(positionals, {
|
||||
...globalFlags,
|
||||
postProcess: !!flags['post-process'],
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Design ---
|
||||
case 'design': {
|
||||
const { cmdDesign } = await import('./commands/design')
|
||||
const { cmdDesign } = await import('./commands/design');
|
||||
await cmdDesign(positionals, {
|
||||
...globalFlags,
|
||||
postProcess: flags['post-process'] !== false ? true : undefined,
|
||||
canvasWidth: flags['canvas-width'] as string | undefined,
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'design:skeleton': {
|
||||
const { cmdDesignSkeleton } = await import('./commands/design')
|
||||
await cmdDesignSkeleton(positionals, globalFlags)
|
||||
break
|
||||
const { cmdDesignSkeleton } = await import('./commands/design');
|
||||
await cmdDesignSkeleton(positionals, globalFlags);
|
||||
break;
|
||||
}
|
||||
case 'design:content': {
|
||||
const { cmdDesignContent } = await import('./commands/design')
|
||||
const { cmdDesignContent } = await import('./commands/design');
|
||||
await cmdDesignContent(positionals, {
|
||||
...globalFlags,
|
||||
canvasWidth: flags['canvas-width'] as string | undefined,
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'design:refine': {
|
||||
const { cmdDesignRefine } = await import('./commands/design')
|
||||
const { cmdDesignRefine } = await import('./commands/design');
|
||||
await cmdDesignRefine(positionals, {
|
||||
...globalFlags,
|
||||
rootId: flags['root-id'] as string | undefined,
|
||||
canvasWidth: flags['canvas-width'] as string | undefined,
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Export ---
|
||||
case 'export': {
|
||||
const { cmdExport } = await import('./commands/export')
|
||||
await cmdExport(positionals, {
|
||||
// --- Read Nodes ---
|
||||
case 'read-nodes': {
|
||||
const { cmdReadNodes } = await import('./commands/read-nodes');
|
||||
await cmdReadNodes(positionals, {
|
||||
file: globalFlags.file,
|
||||
out: flags.out as string | undefined,
|
||||
})
|
||||
break
|
||||
page: globalFlags.page,
|
||||
depth: flags.depth as string | undefined,
|
||||
vars: !!flags.vars,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Codegen Pipeline ---
|
||||
case 'codegen:plan': {
|
||||
const { cmdCodegenPlan } = await import('./commands/codegen');
|
||||
await cmdCodegenPlan(positionals, globalFlags);
|
||||
break;
|
||||
}
|
||||
case 'codegen:submit': {
|
||||
const { cmdCodegenSubmit } = await import('./commands/codegen');
|
||||
await cmdCodegenSubmit(positionals, globalFlags);
|
||||
break;
|
||||
}
|
||||
case 'codegen:assemble': {
|
||||
const { cmdCodegenAssemble } = await import('./commands/codegen');
|
||||
await cmdCodegenAssemble(positionals, {
|
||||
...globalFlags,
|
||||
framework: flags.framework as string | undefined,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'codegen:clean': {
|
||||
const { cmdCodegenClean } = await import('./commands/codegen');
|
||||
await cmdCodegenClean(positionals, globalFlags);
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Variables & Themes ---
|
||||
case 'vars': {
|
||||
const { cmdVars } = await import('./commands/variables')
|
||||
await cmdVars(globalFlags)
|
||||
break
|
||||
const { cmdVars } = await import('./commands/variables');
|
||||
await cmdVars(globalFlags);
|
||||
break;
|
||||
}
|
||||
case 'vars:set': {
|
||||
const { cmdVarsSet } = await import('./commands/variables')
|
||||
await cmdVarsSet(positionals, { ...globalFlags, replace: !!flags.replace })
|
||||
break
|
||||
const { cmdVarsSet } = await import('./commands/variables');
|
||||
await cmdVarsSet(positionals, { ...globalFlags, replace: !!flags.replace });
|
||||
break;
|
||||
}
|
||||
case 'themes': {
|
||||
const { cmdThemes } = await import('./commands/variables')
|
||||
await cmdThemes(globalFlags)
|
||||
break
|
||||
const { cmdThemes } = await import('./commands/variables');
|
||||
await cmdThemes(globalFlags);
|
||||
break;
|
||||
}
|
||||
case 'themes:set': {
|
||||
const { cmdThemesSet } = await import('./commands/variables')
|
||||
await cmdThemesSet(positionals, { ...globalFlags, replace: !!flags.replace })
|
||||
break
|
||||
const { cmdThemesSet } = await import('./commands/variables');
|
||||
await cmdThemesSet(positionals, { ...globalFlags, replace: !!flags.replace });
|
||||
break;
|
||||
}
|
||||
case 'theme:save': {
|
||||
const { cmdThemeSave } = await import('./commands/variables')
|
||||
await cmdThemeSave(positionals, globalFlags)
|
||||
break
|
||||
const { cmdThemeSave } = await import('./commands/variables');
|
||||
await cmdThemeSave(positionals, globalFlags);
|
||||
break;
|
||||
}
|
||||
case 'theme:load': {
|
||||
const { cmdThemeLoad } = await import('./commands/variables')
|
||||
await cmdThemeLoad(positionals, globalFlags)
|
||||
break
|
||||
const { cmdThemeLoad } = await import('./commands/variables');
|
||||
await cmdThemeLoad(positionals, globalFlags);
|
||||
break;
|
||||
}
|
||||
case 'theme:list': {
|
||||
const { cmdThemeList } = await import('./commands/variables')
|
||||
await cmdThemeList(positionals)
|
||||
break
|
||||
const { cmdThemeList } = await import('./commands/variables');
|
||||
await cmdThemeList(positionals);
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Pages ---
|
||||
case 'page': {
|
||||
const subCmd = positionals[0]
|
||||
const subArgs = positionals.slice(1)
|
||||
const subCmd = positionals[0];
|
||||
const subArgs = positionals.slice(1);
|
||||
switch (subCmd) {
|
||||
case 'list': {
|
||||
const { cmdPageList } = await import('./commands/pages')
|
||||
await cmdPageList(globalFlags)
|
||||
break
|
||||
const { cmdPageList } = await import('./commands/pages');
|
||||
await cmdPageList(globalFlags);
|
||||
break;
|
||||
}
|
||||
case 'add': {
|
||||
const { cmdPageAdd } = await import('./commands/pages')
|
||||
await cmdPageAdd(subArgs, { ...globalFlags, name: flags.name as string | undefined })
|
||||
break
|
||||
const { cmdPageAdd } = await import('./commands/pages');
|
||||
await cmdPageAdd(subArgs, { ...globalFlags, name: flags.name as string | undefined });
|
||||
break;
|
||||
}
|
||||
case 'remove': {
|
||||
const { cmdPageRemove } = await import('./commands/pages')
|
||||
await cmdPageRemove(subArgs, globalFlags)
|
||||
break
|
||||
const { cmdPageRemove } = await import('./commands/pages');
|
||||
await cmdPageRemove(subArgs, globalFlags);
|
||||
break;
|
||||
}
|
||||
case 'rename': {
|
||||
const { cmdPageRename } = await import('./commands/pages')
|
||||
await cmdPageRename(subArgs, globalFlags)
|
||||
break
|
||||
const { cmdPageRename } = await import('./commands/pages');
|
||||
await cmdPageRename(subArgs, globalFlags);
|
||||
break;
|
||||
}
|
||||
case 'reorder': {
|
||||
const { cmdPageReorder } = await import('./commands/pages')
|
||||
await cmdPageReorder(subArgs, globalFlags)
|
||||
break
|
||||
const { cmdPageReorder } = await import('./commands/pages');
|
||||
await cmdPageReorder(subArgs, globalFlags);
|
||||
break;
|
||||
}
|
||||
case 'duplicate': {
|
||||
const { cmdPageDuplicate } = await import('./commands/pages')
|
||||
await cmdPageDuplicate(subArgs, globalFlags)
|
||||
break
|
||||
const { cmdPageDuplicate } = await import('./commands/pages');
|
||||
await cmdPageDuplicate(subArgs, globalFlags);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
outputError(`Unknown page subcommand: "${subCmd}". Use: list, add, remove, rename, reorder, duplicate`)
|
||||
outputError(
|
||||
`Unknown page subcommand: "${subCmd}". Use: list, add, remove, rename, reorder, duplicate`,
|
||||
);
|
||||
}
|
||||
break
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Import ---
|
||||
case 'import:svg': {
|
||||
const { cmdImportSvg } = await import('./commands/import')
|
||||
const { cmdImportSvg } = await import('./commands/import');
|
||||
await cmdImportSvg(positionals, {
|
||||
...globalFlags,
|
||||
parent: flags.parent as string | undefined,
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'import:figma': {
|
||||
const { cmdImportFigma } = await import('./commands/import')
|
||||
const { cmdImportFigma } = await import('./commands/import');
|
||||
await cmdImportFigma(positionals, {
|
||||
...globalFlags,
|
||||
out: flags.out as string | undefined,
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Layout ---
|
||||
case 'layout': {
|
||||
const { cmdLayout } = await import('./commands/layout')
|
||||
const { cmdLayout } = await import('./commands/layout');
|
||||
await cmdLayout({
|
||||
...globalFlags,
|
||||
parent: flags.parent as string | undefined,
|
||||
depth: flags.depth as string | undefined,
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'find-space': {
|
||||
const { cmdFindSpace } = await import('./commands/layout')
|
||||
const { cmdFindSpace } = await import('./commands/layout');
|
||||
await cmdFindSpace({
|
||||
...globalFlags,
|
||||
direction: flags.direction as string | undefined,
|
||||
width: flags.width as string | undefined,
|
||||
height: flags.height as string | undefined,
|
||||
})
|
||||
break
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
outputError(`Unknown command: "${command}". Run "op --help" for usage.`)
|
||||
outputError(`Unknown command: "${command}". Run "op --help" for usage.`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
outputError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
outputError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,97 +1,101 @@
|
|||
/** Start/stop OpenPencil app from the CLI. */
|
||||
|
||||
import { spawn, fork, execSync } from 'node:child_process'
|
||||
import { createServer } from 'node:net'
|
||||
import { writeFile, unlink, mkdir } from 'node:fs/promises'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { getAppInfo } from './connection'
|
||||
import { spawn, fork, execSync } from 'node:child_process';
|
||||
import { createServer } from 'node:net';
|
||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { getAppInfo } from './connection';
|
||||
|
||||
const IS_WIN = process.platform === 'win32'
|
||||
const PORT_FILE_DIR = join(homedir(), '.openpencil')
|
||||
const PORT_FILE_PATH = join(PORT_FILE_DIR, '.port')
|
||||
const IS_WIN = process.platform === 'win32';
|
||||
const PORT_FILE_DIR = join(homedir(), '.openpencil');
|
||||
const PORT_FILE_PATH = join(PORT_FILE_DIR, '.port');
|
||||
|
||||
function getFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
const server = createServer();
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address()
|
||||
const addr = server.address();
|
||||
if (addr && typeof addr === 'object') {
|
||||
const { port } = addr
|
||||
server.close(() => resolve(port))
|
||||
const { port } = addr;
|
||||
server.close(() => resolve(port));
|
||||
} else {
|
||||
reject(new Error('Failed to get free port'))
|
||||
reject(new Error('Failed to get free port'));
|
||||
}
|
||||
})
|
||||
server.on('error', reject)
|
||||
})
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPortFile(timeoutMs = 15_000): Promise<{ port: number; pid: number }> {
|
||||
const start = Date.now()
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const info = await getAppInfo()
|
||||
if (info) return { port: info.port, pid: info.pid }
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
const info = await getAppInfo();
|
||||
if (info) return { port: info.port, pid: info.pid };
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
throw new Error('Timeout waiting for OpenPencil to start')
|
||||
throw new Error('Timeout waiting for OpenPencil to start');
|
||||
}
|
||||
|
||||
/** Find the installed desktop app binary. */
|
||||
function findDesktopBinary(): string | null {
|
||||
const candidates: string[] = []
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
candidates.push('/Applications/OpenPencil.app/Contents/MacOS/OpenPencil')
|
||||
candidates.push('/Applications/OpenPencil.app/Contents/MacOS/OpenPencil');
|
||||
candidates.push(
|
||||
join(homedir(), 'Applications', 'OpenPencil.app', 'Contents', 'MacOS', 'OpenPencil'),
|
||||
)
|
||||
);
|
||||
} else if (process.platform === 'win32') {
|
||||
const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local')
|
||||
const programFiles = process.env.PROGRAMFILES ?? 'C:\\Program Files'
|
||||
const programFilesX86 = process.env['PROGRAMFILES(X86)'] ?? 'C:\\Program Files (x86)'
|
||||
const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local');
|
||||
const programFiles = process.env.PROGRAMFILES ?? 'C:\\Program Files';
|
||||
const programFilesX86 = process.env['PROGRAMFILES(X86)'] ?? 'C:\\Program Files (x86)';
|
||||
// NSIS per-user install (default)
|
||||
candidates.push(join(localAppData, 'Programs', 'openpencil', 'OpenPencil.exe'))
|
||||
candidates.push(join(localAppData, 'Programs', 'openpencil', 'OpenPencil.exe'));
|
||||
// NSIS per-machine install
|
||||
candidates.push(join(programFiles, 'OpenPencil', 'OpenPencil.exe'))
|
||||
candidates.push(join(programFilesX86, 'OpenPencil', 'OpenPencil.exe'))
|
||||
candidates.push(join(programFiles, 'OpenPencil', 'OpenPencil.exe'));
|
||||
candidates.push(join(programFilesX86, 'OpenPencil', 'OpenPencil.exe'));
|
||||
// Portable — same directory as CLI
|
||||
candidates.push(join(__dirname, '..', 'OpenPencil.exe'))
|
||||
candidates.push(join(__dirname, '..', 'OpenPencil.exe'));
|
||||
} else {
|
||||
// Linux — AppImage, deb, snap, flatpak, manual
|
||||
candidates.push('/usr/bin/openpencil')
|
||||
candidates.push('/usr/local/bin/openpencil')
|
||||
candidates.push(join(homedir(), '.local', 'bin', 'openpencil'))
|
||||
candidates.push('/usr/bin/openpencil');
|
||||
candidates.push('/usr/local/bin/openpencil');
|
||||
candidates.push(join(homedir(), '.local', 'bin', 'openpencil'));
|
||||
// AppImage in common download locations
|
||||
const appImageDirs = [
|
||||
join(homedir(), 'Applications'),
|
||||
join(homedir(), 'Downloads'),
|
||||
join(homedir(), '.local', 'share', 'applications'),
|
||||
]
|
||||
];
|
||||
for (const dir of appImageDirs) {
|
||||
// Match OpenPencil*.AppImage (version may vary)
|
||||
try {
|
||||
if (existsSync(dir)) {
|
||||
const files = require('node:fs').readdirSync(dir) as string[]
|
||||
const files = require('node:fs').readdirSync(dir) as string[];
|
||||
const appImage = files.find(
|
||||
(f: string) => f.startsWith('OpenPencil') && f.endsWith('.AppImage'),
|
||||
)
|
||||
if (appImage) candidates.push(join(dir, appImage))
|
||||
);
|
||||
if (appImage) candidates.push(join(dir, appImage));
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
// Snap
|
||||
candidates.push('/snap/bin/openpencil')
|
||||
candidates.push('/snap/bin/openpencil');
|
||||
// Flatpak
|
||||
candidates.push('/var/lib/flatpak/exports/bin/dev.openpencil.app')
|
||||
candidates.push(join(homedir(), '.local', 'share', 'flatpak', 'exports', 'bin', 'dev.openpencil.app'))
|
||||
candidates.push('/var/lib/flatpak/exports/bin/dev.openpencil.app');
|
||||
candidates.push(
|
||||
join(homedir(), '.local', 'share', 'flatpak', 'exports', 'bin', 'dev.openpencil.app'),
|
||||
);
|
||||
}
|
||||
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return path
|
||||
if (existsSync(path)) return path;
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Find the Nitro server entry relative to CLI's location. */
|
||||
|
|
@ -102,46 +106,46 @@ function findServerEntry(): string | null {
|
|||
join(__dirname, '..', '..', '..', 'out', 'web', 'server', 'index.mjs'),
|
||||
join(__dirname, '..', '..', 'out', 'web', 'server', 'index.mjs'),
|
||||
join(__dirname, '..', 'server', 'index.mjs'), // when bundled in Electron resources
|
||||
]
|
||||
];
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return path
|
||||
if (existsSync(path)) return path;
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function startDesktop(): Promise<{ port: number; pid: number }> {
|
||||
const info = await getAppInfo()
|
||||
if (info) return { port: info.port, pid: info.pid }
|
||||
const info = await getAppInfo();
|
||||
if (info) return { port: info.port, pid: info.pid };
|
||||
|
||||
const binary = findDesktopBinary()
|
||||
const binary = findDesktopBinary();
|
||||
if (!binary) {
|
||||
throw new Error(
|
||||
'OpenPencil desktop app not found. Install it or use `op start --web` for the web server.',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const child = spawn(binary, [], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
...(IS_WIN ? { windowsHide: true, shell: false } : {}),
|
||||
})
|
||||
child.unref()
|
||||
});
|
||||
child.unref();
|
||||
|
||||
return waitForPortFile()
|
||||
return waitForPortFile();
|
||||
}
|
||||
|
||||
export async function startWeb(): Promise<{ port: number; pid: number }> {
|
||||
const info = await getAppInfo()
|
||||
if (info) return { port: info.port, pid: info.pid }
|
||||
const info = await getAppInfo();
|
||||
if (info) return { port: info.port, pid: info.pid };
|
||||
|
||||
const entry = findServerEntry()
|
||||
const entry = findServerEntry();
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
'Nitro server not found. Run `bun run build` first, or use `op start --desktop`.',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const port = await getFreePort()
|
||||
const port = await getFreePort();
|
||||
|
||||
const child = fork(entry, [], {
|
||||
detached: true,
|
||||
|
|
@ -154,45 +158,47 @@ export async function startWeb(): Promise<{ port: number; pid: number }> {
|
|||
NITRO_HOST: '127.0.0.1',
|
||||
NITRO_PORT: String(port),
|
||||
},
|
||||
})
|
||||
child.unref()
|
||||
});
|
||||
child.unref();
|
||||
|
||||
// Write port file (the Nitro plugin also writes it, but write early for faster discovery)
|
||||
await mkdir(PORT_FILE_DIR, { recursive: true })
|
||||
await mkdir(PORT_FILE_DIR, { recursive: true });
|
||||
await writeFile(
|
||||
PORT_FILE_PATH,
|
||||
JSON.stringify({ port, pid: child.pid, timestamp: Date.now() }),
|
||||
'utf-8',
|
||||
)
|
||||
);
|
||||
|
||||
return { port, pid: child.pid! }
|
||||
return { port, pid: child.pid! };
|
||||
}
|
||||
|
||||
export async function stopApp(): Promise<boolean> {
|
||||
const info = await getAppInfo()
|
||||
if (!info) return false
|
||||
const info = await getAppInfo();
|
||||
if (!info) return false;
|
||||
|
||||
try {
|
||||
if (IS_WIN) {
|
||||
// Windows: SIGTERM is not supported, use taskkill for graceful shutdown
|
||||
execSync(`taskkill /PID ${info.pid}`, { stdio: 'ignore' })
|
||||
execSync(`taskkill /PID ${info.pid}`, { stdio: 'ignore' });
|
||||
} else {
|
||||
process.kill(info.pid, 'SIGTERM')
|
||||
process.kill(info.pid, 'SIGTERM');
|
||||
}
|
||||
} catch {
|
||||
// already dead — try force kill on Windows
|
||||
if (IS_WIN) {
|
||||
try {
|
||||
execSync(`taskkill /F /PID ${info.pid}`, { stdio: 'ignore' })
|
||||
} catch { /* ignore */ }
|
||||
execSync(`taskkill /F /PID ${info.pid}`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(PORT_FILE_PATH)
|
||||
await unlink(PORT_FILE_PATH);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,33 @@
|
|||
/** Output formatting and process exit helpers for the CLI. */
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
let prettyMode = false
|
||||
let prettyMode = false;
|
||||
|
||||
export function setPretty(v: boolean): void {
|
||||
prettyMode = v
|
||||
prettyMode = v;
|
||||
}
|
||||
|
||||
export function output(data: unknown): void {
|
||||
const json = prettyMode
|
||||
? JSON.stringify(data, null, 2)
|
||||
: JSON.stringify(data)
|
||||
process.stdout.write(json + '\n')
|
||||
const json = prettyMode ? JSON.stringify(data, null, 2) : JSON.stringify(data);
|
||||
process.stdout.write(json + '\n');
|
||||
}
|
||||
|
||||
export function outputSuccess(data: Record<string, unknown> = {}): void {
|
||||
output({ ok: true, ...data })
|
||||
output({ ok: true, ...data });
|
||||
}
|
||||
|
||||
export function outputError(message: string, code = 1): never {
|
||||
process.stderr.write(
|
||||
JSON.stringify({ error: message }) + '\n',
|
||||
)
|
||||
process.exit(code)
|
||||
process.stderr.write(JSON.stringify({ error: message }) + '\n');
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
/** Read all of stdin when not a TTY (piped input). */
|
||||
export async function readStdin(): Promise<string> {
|
||||
if (process.stdin.isTTY) return ''
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of process.stdin) chunks.push(chunk as Buffer)
|
||||
return Buffer.concat(chunks).toString('utf-8')
|
||||
if (process.stdin.isTTY) return '';
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -43,31 +39,31 @@ export async function readStdin(): Promise<string> {
|
|||
*/
|
||||
export async function resolveArg(arg: string | undefined): Promise<string> {
|
||||
if (arg === '-') {
|
||||
const stdin = await readStdin()
|
||||
if (!stdin.trim()) outputError('No data received from stdin.')
|
||||
return stdin.trim()
|
||||
const stdin = await readStdin();
|
||||
if (!stdin.trim()) outputError('No data received from stdin.');
|
||||
return stdin.trim();
|
||||
}
|
||||
if (arg && arg.startsWith('@')) {
|
||||
const filePath = arg.slice(1)
|
||||
const filePath = arg.slice(1);
|
||||
try {
|
||||
return (await readFile(filePath, 'utf-8')).trim()
|
||||
return (await readFile(filePath, 'utf-8')).trim();
|
||||
} catch {
|
||||
outputError(`Cannot read file: ${filePath}`)
|
||||
outputError(`Cannot read file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
if (arg) return arg
|
||||
if (arg) return arg;
|
||||
// No explicit arg — try stdin if piped
|
||||
const stdin = await readStdin()
|
||||
if (stdin.trim()) return stdin.trim()
|
||||
outputError('No data provided. Pass as argument, @filepath, or pipe via stdin.')
|
||||
const stdin = await readStdin();
|
||||
if (stdin.trim()) return stdin.trim();
|
||||
outputError('No data provided. Pass as argument, @filepath, or pipe via stdin.');
|
||||
}
|
||||
|
||||
/** Parse JSON from a CLI positional arg, @filepath, or stdin. */
|
||||
export async function parseJsonArg(arg: string | undefined): Promise<unknown> {
|
||||
const raw = await resolveArg(arg)
|
||||
const raw = await resolveArg(arg);
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
outputError(`Invalid JSON: ${raw.slice(0, 200)}...`)
|
||||
outputError(`Invalid JSON: ${raw.slice(0, 200)}...`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"paths": {
|
||||
"@/*": ["../../apps/web/src/*"]
|
||||
}
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": []
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ BUILD_TARGET=electron bun run build
|
|||
## File Association
|
||||
|
||||
`.op` files are registered as OpenPencil documents via `fileAssociations` in `electron-builder.yml`:
|
||||
|
||||
- macOS: `open-file` app event handles double-click/drag
|
||||
- Windows/Linux: `requestSingleInstanceLock` + `second-instance` event forwards CLI args to existing window
|
||||
|
||||
|
|
|
|||
58
apps/desktop/__tests__/dev-utils.test.ts
Normal file
58
apps/desktop/__tests__/dev-utils.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
getDevServerConflictMessage,
|
||||
getElectronBinaryPath,
|
||||
getElectronSpawnEnv,
|
||||
} from '../dev-utils';
|
||||
|
||||
describe('getElectronBinaryPath', () => {
|
||||
it('uses the packaged electron.exe inside dist on Windows', () => {
|
||||
expect(getElectronBinaryPath('C:/repo', 'win32')).toBe(
|
||||
'C:/repo/node_modules/electron/dist/electron.exe',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the .bin shim on non-Windows platforms', () => {
|
||||
expect(getElectronBinaryPath('/repo', 'darwin')).toBe('/repo/node_modules/.bin/electron');
|
||||
});
|
||||
|
||||
it('removes ELECTRON_RUN_AS_NODE when launching Electron', () => {
|
||||
const env = getElectronSpawnEnv({
|
||||
PATH: 'x',
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
FOO: 'bar',
|
||||
});
|
||||
|
||||
expect(env).toEqual({
|
||||
PATH: 'x',
|
||||
FOO: 'bar',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects when port 3000 is occupied by a non-Vite server', () => {
|
||||
expect(
|
||||
getDevServerConflictMessage(
|
||||
{
|
||||
baseReachable: true,
|
||||
viteClientReachable: false,
|
||||
viteClientStatus: 404,
|
||||
},
|
||||
3000,
|
||||
),
|
||||
).toContain('Port 3000 is responding');
|
||||
});
|
||||
|
||||
it('does not report a conflict when the Vite client is reachable', () => {
|
||||
expect(
|
||||
getDevServerConflictMessage(
|
||||
{
|
||||
baseReachable: true,
|
||||
viteClientReachable: true,
|
||||
viteClientStatus: 200,
|
||||
},
|
||||
3000,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
34
apps/desktop/__tests__/unsaved-changes-dialog.test.ts
Normal file
34
apps/desktop/__tests__/unsaved-changes-dialog.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
buildUnsavedChangesDialogOptions,
|
||||
mapUnsavedChangesResponse,
|
||||
} from '../unsaved-changes-dialog';
|
||||
|
||||
describe('unsaved changes dialog helpers', () => {
|
||||
it('builds a three-button yes/no/cancel dialog', () => {
|
||||
expect(
|
||||
buildUnsavedChangesDialogOptions({
|
||||
yesLabel: '是',
|
||||
noLabel: '否',
|
||||
cancelLabel: '取消',
|
||||
message: '关闭前是否要保存更改?',
|
||||
detail: '如果不保存,您的更改将会丢失。',
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'question',
|
||||
buttons: ['是', '否', '取消'],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
message: '关闭前是否要保存更改?',
|
||||
detail: '如果不保存,您的更改将会丢失。',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps button responses to save/discard/cancel actions', () => {
|
||||
expect(mapUnsavedChangesResponse(0)).toBe('save');
|
||||
expect(mapUnsavedChangesResponse(1)).toBe('discard');
|
||||
expect(mapUnsavedChangesResponse(2)).toBe('cancel');
|
||||
expect(mapUnsavedChangesResponse(99)).toBe('cancel');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,32 @@
|
|||
import { app, BrowserWindow, Menu } from 'electron'
|
||||
import { app, BrowserWindow, Menu } from 'electron';
|
||||
|
||||
function sendMenuAction(action: string): void {
|
||||
const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
|
||||
win?.webContents.send('menu:action', action)
|
||||
const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0];
|
||||
win?.webContents.send('menu:action', action);
|
||||
}
|
||||
|
||||
/** Recent files submenu — rebuilt each time the menu opens. */
|
||||
function buildRecentFilesSubmenu(): Electron.MenuItemConstructorOptions[] {
|
||||
const recent = app.isReady() ? ((global as any).__recentFiles ?? []) : [];
|
||||
if (recent.length === 0) {
|
||||
return [{ label: 'No Recent Files', enabled: false }];
|
||||
}
|
||||
const items: Electron.MenuItemConstructorOptions[] = recent.map(
|
||||
(entry: { fileName: string; filePath: string }) => ({
|
||||
label: entry.fileName,
|
||||
click: () => sendMenuAction(`open-recent:${entry.filePath}`),
|
||||
}),
|
||||
);
|
||||
items.push({ type: 'separator' });
|
||||
items.push({
|
||||
label: 'Clear Recent Files',
|
||||
click: () => sendMenuAction('clear-recent-files'),
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
export function buildAppMenu(): void {
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
// macOS app menu
|
||||
|
|
@ -43,21 +63,44 @@ export function buildAppMenu(): void {
|
|||
accelerator: 'CmdOrCtrl+O',
|
||||
click: () => sendMenuAction('open'),
|
||||
},
|
||||
{
|
||||
label: 'Open Recent',
|
||||
submenu: buildRecentFilesSubmenu(),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Save',
|
||||
accelerator: 'CmdOrCtrl+S',
|
||||
click: () => sendMenuAction('save'),
|
||||
},
|
||||
{
|
||||
label: 'Save As\u2026',
|
||||
accelerator: 'CmdOrCtrl+Shift+S',
|
||||
click: () => sendMenuAction('save-as'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Export Image\u2026',
|
||||
// Use Cmd+Shift+P (P = Print/PDF/Picture). Cmd+Shift+E was being
|
||||
// swallowed at the OS level by Chinese IMEs / system tools on
|
||||
// macOS before reaching the renderer.
|
||||
//
|
||||
// `registerAccelerator: false` keeps the hint visible in the menu
|
||||
// but tells Electron NOT to register it with the OS — the
|
||||
// keystroke is handled by the renderer's capture-phase document
|
||||
// keydown listener in editor-layout.tsx, which avoids HMR/IPC
|
||||
// fragility.
|
||||
accelerator: 'CmdOrCtrl+Shift+P',
|
||||
registerAccelerator: false,
|
||||
click: () => sendMenuAction('export-image'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Import Figma\u2026',
|
||||
accelerator: 'CmdOrCtrl+Shift+F',
|
||||
click: () => sendMenuAction('import-figma'),
|
||||
},
|
||||
...(!isMac
|
||||
? [{ type: 'separator' as const }, { role: 'quit' as const }]
|
||||
: []),
|
||||
...(!isMac ? [{ type: 'separator' as const }, { role: 'quit' as const }] : []),
|
||||
],
|
||||
},
|
||||
|
||||
|
|
@ -88,10 +131,15 @@ export function buildAppMenu(): void {
|
|||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'reload' },
|
||||
{ role: 'forceReload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
// Reload / Force Reload / DevTools are dev-only — hide in packaged builds.
|
||||
...(app.isPackaged
|
||||
? []
|
||||
: [
|
||||
{ role: 'reload' as const },
|
||||
{ role: 'forceReload' as const },
|
||||
{ role: 'toggleDevTools' as const },
|
||||
{ type: 'separator' as const },
|
||||
]),
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
|
|
@ -107,14 +155,11 @@ export function buildAppMenu(): void {
|
|||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
...(isMac
|
||||
? [
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'front' as const },
|
||||
]
|
||||
? [{ type: 'separator' as const }, { role: 'front' as const }]
|
||||
: [{ role: 'close' as const }]),
|
||||
],
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { app, BrowserWindow } from 'electron'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import type { NsisUpdater } from 'electron-updater'
|
||||
import { GitHubProvider } from 'electron-updater/out/providers/GitHubProvider'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { GITHUB_OWNER, GITHUB_REPO } from './constants'
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import type { NsisUpdater } from 'electron-updater';
|
||||
import { GitHubProvider } from 'electron-updater/out/providers/GitHubProvider';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { GITHUB_OWNER, GITHUB_REPO } from './constants';
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
|
|
@ -20,64 +20,64 @@ export type UpdaterStatus =
|
|||
| 'downloading'
|
||||
| 'downloaded'
|
||||
| 'not-available'
|
||||
| 'error'
|
||||
| 'error';
|
||||
|
||||
export interface UpdaterState {
|
||||
status: UpdaterStatus
|
||||
currentVersion: string
|
||||
latestVersion?: string
|
||||
downloadProgress?: number
|
||||
releaseDate?: string
|
||||
error?: string
|
||||
status: UpdaterStatus;
|
||||
currentVersion: string;
|
||||
latestVersion?: string;
|
||||
downloadProgress?: number;
|
||||
releaseDate?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isDev = !app.isPackaged
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
let updaterState: UpdaterState = {
|
||||
status: isDev ? 'disabled' : 'idle',
|
||||
currentVersion: app.getVersion(),
|
||||
}
|
||||
};
|
||||
|
||||
let autoUpdateEnabled = true
|
||||
let updateCheckTimer: ReturnType<typeof setInterval> | null = null
|
||||
let lastUpdateCheckAt = 0
|
||||
let autoUpdateEnabled = true;
|
||||
let updateCheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let lastUpdateCheckAt = 0;
|
||||
|
||||
const MacGitHubUpdateProvider = class {
|
||||
constructor(options: unknown, updater: unknown, runtimeOptions: unknown) {
|
||||
const provider = new (GitHubProvider as any)(options, updater, runtimeOptions) as any
|
||||
const provider = new (GitHubProvider as any)(options, updater, runtimeOptions) as any;
|
||||
if (process.platform === 'darwin') {
|
||||
provider.getDefaultChannelName = () =>
|
||||
process.arch === 'arm64' ? 'latest-mac-arm64' : 'latest-mac'
|
||||
provider.getCustomChannelName = (channel: string) => channel
|
||||
process.arch === 'arm64' ? 'latest-mac-arm64' : 'latest-mac';
|
||||
provider.getCustomChannelName = (channel: string) => channel;
|
||||
}
|
||||
return provider
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getUpdaterState(): UpdaterState {
|
||||
return updaterState
|
||||
return updaterState;
|
||||
}
|
||||
|
||||
export function getAutoUpdateEnabled(): boolean {
|
||||
return autoUpdateEnabled
|
||||
return autoUpdateEnabled;
|
||||
}
|
||||
|
||||
export function setAutoUpdateEnabled(enabled: boolean): void {
|
||||
autoUpdateEnabled = enabled
|
||||
autoUpdateEnabled = enabled;
|
||||
}
|
||||
|
||||
export function broadcastUpdaterState(): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('updater:state', updaterState)
|
||||
win.webContents.send('updater:state', updaterState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -87,48 +87,51 @@ export function setUpdaterState(next: Partial<UpdaterState>): void {
|
|||
...updaterState,
|
||||
...next,
|
||||
currentVersion: app.getVersion(),
|
||||
}
|
||||
broadcastUpdaterState()
|
||||
};
|
||||
broadcastUpdaterState();
|
||||
}
|
||||
|
||||
export async function checkForAppUpdates(force = false): Promise<void> {
|
||||
if (isDev) return
|
||||
if (isDev) return;
|
||||
|
||||
const now = Date.now()
|
||||
const now = Date.now();
|
||||
if (!force && now - lastUpdateCheckAt < 60 * 1000) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
lastUpdateCheckAt = now
|
||||
lastUpdateCheckAt = now;
|
||||
|
||||
try {
|
||||
await autoUpdater.checkForUpdates()
|
||||
await autoUpdater.checkForUpdates();
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err)
|
||||
setUpdaterState({ status: 'error', error })
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
setUpdaterState({ status: 'error', error });
|
||||
}
|
||||
}
|
||||
|
||||
export function clearUpdateTimer(): void {
|
||||
if (updateCheckTimer) {
|
||||
clearInterval(updateCheckTimer)
|
||||
updateCheckTimer = null
|
||||
clearInterval(updateCheckTimer);
|
||||
updateCheckTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function startUpdateTimer(): void {
|
||||
if (updateCheckTimer) return
|
||||
updateCheckTimer = setInterval(() => {
|
||||
void checkForAppUpdates(false)
|
||||
}, 60 * 60 * 1000)
|
||||
updateCheckTimer.unref()
|
||||
if (updateCheckTimer) return;
|
||||
updateCheckTimer = setInterval(
|
||||
() => {
|
||||
void checkForAppUpdates(false);
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
updateCheckTimer.unref();
|
||||
}
|
||||
|
||||
export function quitAndInstall(): boolean {
|
||||
if (!isDev && updaterState.status === 'downloaded') {
|
||||
autoUpdater.quitAndInstall()
|
||||
return true
|
||||
autoUpdater.quitAndInstall();
|
||||
return true;
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -136,7 +139,7 @@ export function quitAndInstall(): boolean {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setupAutoUpdater(): void {
|
||||
if (isDev) return
|
||||
if (isDev) return;
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
// macOS needs a custom provider to select arm64 vs x64 channel
|
||||
|
|
@ -146,7 +149,7 @@ export function setupAutoUpdater(): void {
|
|||
owner: GITHUB_OWNER,
|
||||
repo: GITHUB_REPO,
|
||||
releaseType: 'release',
|
||||
} as any)
|
||||
} as any);
|
||||
} else {
|
||||
// Windows/Linux: use standard GitHub provider (reads from electron-builder.yml publish config)
|
||||
autoUpdater.setFeedURL({
|
||||
|
|
@ -154,12 +157,12 @@ export function setupAutoUpdater(): void {
|
|||
owner: GITHUB_OWNER,
|
||||
repo: GITHUB_REPO,
|
||||
releaseType: 'release',
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
autoUpdater.autoDownload = true
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
autoUpdater.allowPrerelease = true
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
autoUpdater.allowPrerelease = true;
|
||||
|
||||
// Windows: custom signature verification for self-signed certificate.
|
||||
// The default verifier requires the cert to be in the Windows trusted root
|
||||
|
|
@ -167,38 +170,44 @@ export function setupAutoUpdater(): void {
|
|||
// the publisher name from the Authenticode signature — it just skips the
|
||||
// trust chain check. This is NOT disabling verification.
|
||||
if (process.platform === 'win32') {
|
||||
const nsisUpdater = autoUpdater as NsisUpdater
|
||||
const nsisUpdater = autoUpdater as NsisUpdater;
|
||||
nsisUpdater.verifyUpdateCodeSignature = async (
|
||||
publisherNames: string[],
|
||||
tempUpdateFile: string,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('powershell.exe', [
|
||||
'-NoProfile', '-NonInteractive', '-Command',
|
||||
`(Get-AuthenticodeSignature '${tempUpdateFile.replace(/'/g, "''")}').SignerCertificate.Subject`,
|
||||
], { timeout: 30_000 })
|
||||
const { stdout } = await execFileAsync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
`(Get-AuthenticodeSignature '${tempUpdateFile.replace(/'/g, "''")}').SignerCertificate.Subject`,
|
||||
],
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
const subject = stdout.trim()
|
||||
const subject = stdout.trim();
|
||||
if (!subject) {
|
||||
return 'The update file is not signed.'
|
||||
return 'The update file is not signed.';
|
||||
}
|
||||
|
||||
for (const name of publisherNames) {
|
||||
if (subject.includes(name)) {
|
||||
return null // Publisher name matches — verification passed
|
||||
return null; // Publisher name matches — verification passed
|
||||
}
|
||||
}
|
||||
|
||||
return `Publisher mismatch. Expected: ${publisherNames.join(', ')}. Got: ${subject}`
|
||||
return `Publisher mismatch. Expected: ${publisherNames.join(', ')}. Got: ${subject}`;
|
||||
} catch (err) {
|
||||
return `Signature verification failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
return `Signature verification failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
setUpdaterState({ status: 'checking', error: undefined, downloadProgress: undefined })
|
||||
})
|
||||
setUpdaterState({ status: 'checking', error: undefined, downloadProgress: undefined });
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
setUpdaterState({
|
||||
|
|
@ -206,16 +215,16 @@ export function setupAutoUpdater(): void {
|
|||
latestVersion: info.version,
|
||||
releaseDate: info.releaseDate,
|
||||
error: undefined,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
setUpdaterState({
|
||||
status: 'downloading',
|
||||
downloadProgress: Math.round(progress.percent),
|
||||
error: undefined,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
setUpdaterState({
|
||||
|
|
@ -224,8 +233,8 @@ export function setupAutoUpdater(): void {
|
|||
releaseDate: info.releaseDate,
|
||||
downloadProgress: 100,
|
||||
error: undefined,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
setUpdaterState({
|
||||
|
|
@ -233,22 +242,22 @@ export function setupAutoUpdater(): void {
|
|||
latestVersion: info.version,
|
||||
downloadProgress: undefined,
|
||||
error: undefined,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
setUpdaterState({
|
||||
status: 'error',
|
||||
error: err?.message ?? String(err),
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
if (autoUpdateEnabled) {
|
||||
// Delay first check until app startup work is done.
|
||||
setTimeout(() => {
|
||||
void checkForAppUpdates(true)
|
||||
}, 5000)
|
||||
void checkForAppUpdates(true);
|
||||
}, 5000);
|
||||
|
||||
startUpdateTimer()
|
||||
startUpdateTimer();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,30 +3,30 @@
|
|||
*/
|
||||
|
||||
// GitHub publish target — used by auto-updater feed URL
|
||||
export const GITHUB_OWNER = 'ZSeven-W'
|
||||
export const GITHUB_REPO = 'openpencil'
|
||||
export const GITHUB_OWNER = 'ZSeven-W';
|
||||
export const GITHUB_REPO = 'openpencil';
|
||||
|
||||
// Port file for MCP sync discovery
|
||||
export const PORT_FILE_DIR_NAME = '.openpencil'
|
||||
export const PORT_FILE_NAME = '.port'
|
||||
export const PORT_FILE_DIR_NAME = '.openpencil';
|
||||
export const PORT_FILE_NAME = '.port';
|
||||
|
||||
// Dev server
|
||||
export const VITE_DEV_PORT = 3000
|
||||
export const VITE_DEV_PORT = 3000;
|
||||
|
||||
// Window defaults
|
||||
export const WINDOW_WIDTH = 1440
|
||||
export const WINDOW_HEIGHT = 900
|
||||
export const WINDOW_MIN_WIDTH = 1024
|
||||
export const WINDOW_MIN_HEIGHT = 600
|
||||
export const TITLEBAR_OVERLAY_HEIGHT = 36
|
||||
export const MACOS_TRAFFIC_LIGHT_POSITION = { x: 16, y: 11 }
|
||||
export const WINDOW_WIDTH = 1440;
|
||||
export const WINDOW_HEIGHT = 900;
|
||||
export const WINDOW_MIN_WIDTH = 1024;
|
||||
export const WINDOW_MIN_HEIGHT = 600;
|
||||
export const TITLEBAR_OVERLAY_HEIGHT = 36;
|
||||
export const MACOS_TRAFFIC_LIGHT_POSITION = { x: 16, y: 11 };
|
||||
|
||||
// CSS padding for window controls (px)
|
||||
export const MACOS_TRAFFIC_LIGHT_PAD = 74
|
||||
export const WIN_CONTROLS_PAD = 140
|
||||
export const LINUX_CONTROLS_PAD = 140
|
||||
export const MACOS_TRAFFIC_LIGHT_PAD = 74;
|
||||
export const WIN_CONTROLS_PAD = 140;
|
||||
export const LINUX_CONTROLS_PAD = 140;
|
||||
|
||||
// Nitro server
|
||||
export const NITRO_HOST = '127.0.0.1'
|
||||
export const NITRO_FALLBACK_TIMEOUT_WIN = 6000
|
||||
export const NITRO_FALLBACK_TIMEOUT_DEFAULT = 3000
|
||||
export const NITRO_HOST = '127.0.0.1';
|
||||
export const NITRO_FALLBACK_TIMEOUT_WIN = 6000;
|
||||
export const NITRO_FALLBACK_TIMEOUT_DEFAULT = 3000;
|
||||
|
|
|
|||
45
apps/desktop/dev-utils.ts
Normal file
45
apps/desktop/dev-utils.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { join } from 'node:path';
|
||||
|
||||
export function getElectronBinaryPath(
|
||||
root: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string {
|
||||
if (platform === 'win32') {
|
||||
return toForwardSlashes(join(root, 'node_modules', 'electron', 'dist', 'electron.exe'));
|
||||
}
|
||||
|
||||
return toForwardSlashes(join(root, 'node_modules', '.bin', 'electron'));
|
||||
}
|
||||
|
||||
function toForwardSlashes(path: string): string {
|
||||
return path.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
export function getElectronSpawnEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const next = { ...env };
|
||||
delete next.ELECTRON_RUN_AS_NODE;
|
||||
return next;
|
||||
}
|
||||
|
||||
interface DevServerProbeResult {
|
||||
baseReachable: boolean;
|
||||
viteClientReachable: boolean;
|
||||
viteClientStatus: number | null;
|
||||
}
|
||||
|
||||
export function getDevServerConflictMessage(
|
||||
probe: DevServerProbeResult,
|
||||
port: number,
|
||||
): string | null {
|
||||
if (probe.viteClientReachable) return null;
|
||||
if (probe.baseReachable && probe.viteClientStatus === 404) {
|
||||
return [
|
||||
`Port ${port} is responding, but it is not serving the Vite dev client.`,
|
||||
'A stale production server is likely still running on that port',
|
||||
'(for example `bun run ./out/web/server/index.mjs`).',
|
||||
'Stop it and retry the Electron dev launcher.',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -7,34 +7,101 @@
|
|||
* 4. Launch Electron pointing at the dev server
|
||||
*/
|
||||
|
||||
import { spawn, execSync, type ChildProcess } from 'node:child_process'
|
||||
import { build } from 'esbuild'
|
||||
import { join } from 'node:path'
|
||||
import { compileSkills } from '../../packages/pen-ai-skills/vite-plugin-skills'
|
||||
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
||||
import { build } from 'esbuild';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { compileSkills } from '../../packages/pen-ai-skills/vite-plugin-skills';
|
||||
import {
|
||||
getDevServerConflictMessage,
|
||||
getElectronBinaryPath,
|
||||
getElectronSpawnEnv,
|
||||
} from './dev-utils';
|
||||
|
||||
const DESKTOP_DIR = import.meta.dirname
|
||||
const ROOT = join(DESKTOP_DIR, '..', '..')
|
||||
const VITE_DEV_PORT = 3000
|
||||
const DESKTOP_DIR = import.meta.dirname;
|
||||
const ROOT = join(DESKTOP_DIR, '..', '..');
|
||||
const WEB_DIR = join(ROOT, 'apps', 'web');
|
||||
const VITE_DEV_PORT = 3000;
|
||||
const GENERATED_SKILL_REGISTRY = join(
|
||||
ROOT,
|
||||
'packages',
|
||||
'pen-ai-skills',
|
||||
'src',
|
||||
'_generated',
|
||||
'skill-registry.ts',
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function waitForServer(
|
||||
url: string,
|
||||
async function waitForViteServer(
|
||||
baseUrl: string,
|
||||
vite: ChildProcess,
|
||||
port: number,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<void> {
|
||||
const start = Date.now()
|
||||
const start = Date.now();
|
||||
let viteExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
||||
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
viteExit = { code, signal };
|
||||
};
|
||||
|
||||
vite.once('exit', handleExit);
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
let baseReachable = false;
|
||||
let viteClientReachable = false;
|
||||
let viteClientStatus: number | null = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (res.ok || res.status < 500) return
|
||||
const res = await fetch(baseUrl, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
});
|
||||
baseReachable = res.ok || res.status < 500;
|
||||
} catch {
|
||||
// server not ready yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/@vite/client`, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
});
|
||||
viteClientStatus = res.status;
|
||||
viteClientReachable = res.ok;
|
||||
if (viteClientReachable) {
|
||||
vite.off('exit', handleExit);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Vite client not ready yet.
|
||||
}
|
||||
|
||||
const conflict = getDevServerConflictMessage(
|
||||
{
|
||||
baseReachable,
|
||||
viteClientReachable,
|
||||
viteClientStatus,
|
||||
},
|
||||
port,
|
||||
);
|
||||
if (conflict) {
|
||||
vite.off('exit', handleExit);
|
||||
throw new Error(conflict);
|
||||
}
|
||||
|
||||
if (viteExit) {
|
||||
vite.off('exit', handleExit);
|
||||
const detail = viteExit.signal
|
||||
? `signal ${viteExit.signal}`
|
||||
: `exit code ${viteExit.code ?? 'unknown'}`;
|
||||
throw new Error(`Vite dev server exited before becoming ready (${detail}).`);
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Timeout waiting for ${url}`)
|
||||
|
||||
vite.off('exit', handleExit);
|
||||
throw new Error(`Timeout waiting for Vite dev server on ${baseUrl}`);
|
||||
}
|
||||
|
||||
async function compileElectron(): Promise<void> {
|
||||
|
|
@ -47,7 +114,7 @@ async function compileElectron(): Promise<void> {
|
|||
outdir: join(ROOT, 'out', 'desktop'),
|
||||
outExtension: { '.js': '.cjs' },
|
||||
format: 'cjs' as const,
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
build({
|
||||
|
|
@ -58,9 +125,9 @@ async function compileElectron(): Promise<void> {
|
|||
...common,
|
||||
entryPoints: [join(DESKTOP_DIR, 'preload.ts')],
|
||||
}),
|
||||
])
|
||||
]);
|
||||
|
||||
console.log('[electron-dev] Electron files compiled')
|
||||
console.log('[electron-dev] Electron files compiled');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -69,73 +136,100 @@ async function compileElectron(): Promise<void> {
|
|||
|
||||
async function main(): Promise<void> {
|
||||
// 1. Start Vite dev server
|
||||
console.log('[electron-dev] Starting Vite dev server...')
|
||||
const vite = spawn('bun', ['--bun', 'run', 'dev'], {
|
||||
cwd: ROOT,
|
||||
console.log('[electron-dev] Starting Vite dev server...');
|
||||
// Launch Vite directly on Windows. Spawning through `bun run dev` can tear
|
||||
// down the inner `vite.exe` process after startup, leaving Electron with a
|
||||
// ready log but no live dev server to connect to.
|
||||
const vite = spawn('bun', ['--bun', 'vite', 'dev', '--port', String(VITE_DEV_PORT)], {
|
||||
cwd: WEB_DIR,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env },
|
||||
})
|
||||
});
|
||||
|
||||
const stopVite = () => {
|
||||
if (process.platform === 'win32' && vite.pid) {
|
||||
try {
|
||||
execSync(`taskkill /pid ${vite.pid} /T /F`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
vite.kill();
|
||||
};
|
||||
|
||||
// Ensure cleanup on exit
|
||||
const cleanup = () => {
|
||||
if (process.platform === 'win32' && vite.pid) {
|
||||
try {
|
||||
execSync(`taskkill /pid ${vite.pid} /T /F`, { stdio: 'ignore' })
|
||||
} catch { /* ignore */ }
|
||||
} else {
|
||||
vite.kill()
|
||||
}
|
||||
process.exit()
|
||||
}
|
||||
process.on('SIGINT', cleanup)
|
||||
process.on('SIGTERM', cleanup)
|
||||
stopVite();
|
||||
process.exit();
|
||||
};
|
||||
process.on('SIGINT', cleanup);
|
||||
process.on('SIGTERM', cleanup);
|
||||
|
||||
// 2. Wait for Vite to be ready
|
||||
console.log(`[electron-dev] Waiting for Vite on port ${VITE_DEV_PORT}...`)
|
||||
await waitForServer(`http://localhost:${VITE_DEV_PORT}`)
|
||||
console.log('[electron-dev] Vite is ready')
|
||||
console.log(`[electron-dev] Waiting for Vite on port ${VITE_DEV_PORT}...`);
|
||||
try {
|
||||
await waitForViteServer(`http://localhost:${VITE_DEV_PORT}`, vite, VITE_DEV_PORT);
|
||||
} catch (error) {
|
||||
stopVite();
|
||||
throw error;
|
||||
}
|
||||
console.log('[electron-dev] Vite is ready');
|
||||
|
||||
// 3. Compile MCP server + Electron files
|
||||
compileSkills(join(ROOT, 'packages', 'pen-ai-skills'))
|
||||
console.log('[electron-dev] Compiling MCP server...')
|
||||
try {
|
||||
compileSkills(join(ROOT, 'packages', 'pen-ai-skills'));
|
||||
} catch (err) {
|
||||
if (!existsSync(GENERATED_SKILL_REGISTRY)) {
|
||||
throw err;
|
||||
}
|
||||
console.warn('[electron-dev] Skill registry refresh failed, using existing generated registry');
|
||||
console.warn(err);
|
||||
}
|
||||
console.log('[electron-dev] Compiling MCP server...');
|
||||
await build({
|
||||
platform: 'node',
|
||||
bundle: true,
|
||||
sourcemap: true,
|
||||
target: 'node20',
|
||||
format: 'cjs',
|
||||
entryPoints: [join(ROOT, 'apps', 'web', 'src', 'mcp', 'server.ts')],
|
||||
entryPoints: [join(ROOT, 'packages', 'pen-mcp', 'src', 'server.ts')],
|
||||
outfile: join(ROOT, 'out', 'mcp-server.cjs'),
|
||||
alias: { '@': join(ROOT, 'apps', 'web', 'src') },
|
||||
alias: {
|
||||
'@zseven-w/pen-types': join(ROOT, 'packages', 'pen-types', 'src'),
|
||||
'@zseven-w/pen-core': join(ROOT, 'packages', 'pen-core', 'src'),
|
||||
'@zseven-w/pen-figma': join(ROOT, 'packages', 'pen-figma', 'src'),
|
||||
'@zseven-w/pen-renderer': join(ROOT, 'packages', 'pen-renderer', 'src'),
|
||||
'@zseven-w/pen-sdk': join(ROOT, 'packages', 'pen-sdk', 'src'),
|
||||
'@zseven-w/pen-ai-skills': join(ROOT, 'packages', 'pen-ai-skills', 'src'),
|
||||
'@zseven-w/pen-mcp': join(ROOT, 'packages', 'pen-mcp', 'src'),
|
||||
'@zseven-w/pen-engine': join(ROOT, 'packages', 'pen-engine', 'src'),
|
||||
'@zseven-w/pen-react': join(ROOT, 'packages', 'pen-react', 'src'),
|
||||
},
|
||||
define: { 'import.meta.env': '{}' },
|
||||
external: ['canvas', 'paper'],
|
||||
})
|
||||
console.log('[electron-dev] MCP server compiled')
|
||||
});
|
||||
console.log('[electron-dev] MCP server compiled');
|
||||
|
||||
await compileElectron()
|
||||
await compileElectron();
|
||||
|
||||
// 4. Launch Electron
|
||||
console.log('[electron-dev] Starting Electron...')
|
||||
const electronBin = join(ROOT, 'node_modules', '.bin', 'electron')
|
||||
const electron = spawn(electronBin, [join(ROOT, 'out', 'desktop', 'main.cjs')], {
|
||||
console.log('[electron-dev] Starting Electron...');
|
||||
const electronBin = getElectronBinaryPath(ROOT);
|
||||
const electron = spawn(electronBin, [ROOT], {
|
||||
cwd: ROOT,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env },
|
||||
}) as ChildProcess
|
||||
env: getElectronSpawnEnv(process.env),
|
||||
}) as ChildProcess;
|
||||
|
||||
electron.on('exit', () => {
|
||||
if (process.platform === 'win32' && vite.pid) {
|
||||
try {
|
||||
execSync(`taskkill /pid ${vite.pid} /T /F`, { stdio: 'ignore' })
|
||||
} catch { /* ignore */ }
|
||||
} else {
|
||||
vite.kill()
|
||||
}
|
||||
process.exit()
|
||||
})
|
||||
stopVite();
|
||||
process.exit();
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ directories:
|
|||
files:
|
||||
- package.json
|
||||
- out/desktop/**/*
|
||||
- "!**/node_modules/**"
|
||||
- "!**/.git/**"
|
||||
- '!**/node_modules/**'
|
||||
- '!**/.git/**'
|
||||
|
||||
extraResources:
|
||||
- from: out/web/server
|
||||
|
|
@ -25,7 +25,7 @@ extraResources:
|
|||
mac:
|
||||
category: public.app-category.graphics-design
|
||||
icon: apps/desktop/build/icon.icns
|
||||
artifactName: "${productName}-${version}-${arch}-mac.${ext}"
|
||||
artifactName: '${productName}-${version}-${arch}-mac.${ext}'
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
|
|
@ -34,17 +34,17 @@ mac:
|
|||
notarize: true
|
||||
|
||||
dmg:
|
||||
title: "${productName} ${version}"
|
||||
title: '${productName} ${version}'
|
||||
|
||||
win:
|
||||
icon: apps/desktop/build/icon.ico
|
||||
artifactName: "${productName}-${version}-${arch}-win.${ext}"
|
||||
artifactName: '${productName}-${version}-${arch}-win.${ext}'
|
||||
target:
|
||||
- nsis
|
||||
- portable
|
||||
|
||||
nsis:
|
||||
artifactName: "${productName}-${version}-${arch}-win-setup.${ext}"
|
||||
artifactName: '${productName}-${version}-${arch}-win-setup.${ext}'
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
|
|
@ -54,7 +54,7 @@ nsis:
|
|||
linux:
|
||||
icon: apps/desktop/build/icon.png
|
||||
category: Graphics
|
||||
artifactName: "${productName}-${version}-${arch}-linux.${ext}"
|
||||
artifactName: '${productName}-${version}-${arch}-linux.${ext}'
|
||||
desktop:
|
||||
entry: |
|
||||
[Desktop Entry]
|
||||
|
|
|
|||
127
apps/desktop/git/__tests__/auth-store.test.ts
Normal file
127
apps/desktop/git/__tests__/auth-store.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// apps/desktop/git/__tests__/auth-store.test.ts
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
createAuthStore,
|
||||
createInMemoryBackend,
|
||||
createUnavailableBackend,
|
||||
type AuthStore,
|
||||
} from '../auth-store';
|
||||
import { mkTempDir } from './test-helpers';
|
||||
|
||||
describe('auth-store (in-memory backend)', () => {
|
||||
let temp: { dir: string; dispose: () => Promise<void> };
|
||||
let store: AuthStore;
|
||||
let filePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
temp = await mkTempDir();
|
||||
filePath = join(temp.dir, 'git-auth.bin');
|
||||
store = createAuthStore({ filePath, backend: createInMemoryBackend() });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await temp.dispose();
|
||||
});
|
||||
|
||||
it('round-trips a token credential through set + get', async () => {
|
||||
await store.set('github.com', {
|
||||
kind: 'token',
|
||||
username: 'kay',
|
||||
token: 'ghp_abc123',
|
||||
});
|
||||
const got = await store.get('github.com');
|
||||
expect(got).toEqual({ kind: 'token', username: 'kay', token: 'ghp_abc123' });
|
||||
});
|
||||
|
||||
it('round-trips an SSH credential', async () => {
|
||||
await store.set('git@github.com', { kind: 'ssh', keyId: 'key-1' });
|
||||
const got = await store.get('git@github.com');
|
||||
expect(got).toEqual({ kind: 'ssh', keyId: 'key-1' });
|
||||
});
|
||||
|
||||
it('returns null for an unknown host', async () => {
|
||||
const got = await store.get('not-stored.example.com');
|
||||
expect(got).toBeNull();
|
||||
});
|
||||
|
||||
it('list returns all stored hosts', async () => {
|
||||
await store.set('github.com', { kind: 'token', username: 'a', token: 't1' });
|
||||
await store.set('gitlab.com', { kind: 'token', username: 'b', token: 't2' });
|
||||
const hosts = (await store.list()).sort();
|
||||
expect(hosts).toEqual(['github.com', 'gitlab.com']);
|
||||
});
|
||||
|
||||
it('clear removes one host without affecting others', async () => {
|
||||
await store.set('github.com', { kind: 'token', username: 'a', token: 't1' });
|
||||
await store.set('gitlab.com', { kind: 'token', username: 'b', token: 't2' });
|
||||
await store.clear('github.com');
|
||||
expect(await store.get('github.com')).toBeNull();
|
||||
expect(await store.get('gitlab.com')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('persists across new store instances backed by the same file', async () => {
|
||||
await store.set('github.com', { kind: 'token', username: 'kay', token: 't' });
|
||||
// New instance, same file + same backend type.
|
||||
const store2 = createAuthStore({ filePath, backend: createInMemoryBackend() });
|
||||
const got = await store2.get('github.com');
|
||||
expect(got?.kind).toBe('token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth-store (unavailable backend → plaintext fallback)', () => {
|
||||
let temp: { dir: string; dispose: () => Promise<void> };
|
||||
|
||||
beforeEach(async () => {
|
||||
temp = await mkTempDir();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await temp.dispose();
|
||||
});
|
||||
|
||||
it('writes a plaintext file with the marker header when safeStorage is unavailable from the start', async () => {
|
||||
const filePath = join(temp.dir, 'git-auth.bin');
|
||||
const store = createAuthStore({ filePath, backend: createUnavailableBackend() });
|
||||
await store.set('github.com', { kind: 'token', username: 'kay', token: 't' });
|
||||
|
||||
const bytes = await fsp.readFile(filePath, 'utf-8');
|
||||
expect(bytes.startsWith('__OPENPENCIL_AUTH_PLAINTEXT_V1__')).toBe(true);
|
||||
const body = bytes.slice('__OPENPENCIL_AUTH_PLAINTEXT_V1__'.length);
|
||||
const obj = JSON.parse(body);
|
||||
expect(obj['github.com'].kind).toBe('token');
|
||||
});
|
||||
|
||||
it('locks the store when an existing encrypted file is read with no decryption key — refuses writes to avoid data loss', async () => {
|
||||
const filePath = join(temp.dir, 'git-auth.bin');
|
||||
// Step 1: write an encrypted file via the in-memory backend.
|
||||
const enc = createAuthStore({ filePath, backend: createInMemoryBackend() });
|
||||
await enc.set('github.com', { kind: 'token', username: 'kay', token: 'precious-pat' });
|
||||
await enc.set('gitlab.com', { kind: 'token', username: 'kay', token: 'also-precious' });
|
||||
|
||||
// Step 2: open a fresh store pointing at the same file but with an
|
||||
// unavailable backend. The encrypted bytes are NOT plaintext-marked, so
|
||||
// the store should detect them and lock.
|
||||
const locked = createAuthStore({ filePath, backend: createUnavailableBackend() });
|
||||
|
||||
// Reads return empty (locked) but do not throw.
|
||||
expect(await locked.get('github.com')).toBeNull();
|
||||
expect(await locked.list()).toEqual([]);
|
||||
|
||||
// Writes throw the lock error.
|
||||
await expect(
|
||||
locked.set('newhost.com', { kind: 'token', username: 'a', token: 'b' }),
|
||||
).rejects.toThrow(/locked/);
|
||||
await expect(locked.clear('github.com')).rejects.toThrow(/locked/);
|
||||
|
||||
// Critical: the original encrypted file is unchanged.
|
||||
const bytesAfter = await fsp.readFile(filePath, 'utf-8');
|
||||
expect(bytesAfter.startsWith('__OPENPENCIL_AUTH_PLAINTEXT_V1__')).toBe(false);
|
||||
// And a new instance with the in-memory backend can still read both
|
||||
// original credentials.
|
||||
const recovered = createAuthStore({ filePath, backend: createInMemoryBackend() });
|
||||
expect((await recovered.get('github.com'))?.kind).toBe('token');
|
||||
expect((await recovered.get('gitlab.com'))?.kind).toBe('token');
|
||||
});
|
||||
});
|
||||
2487
apps/desktop/git/__tests__/git-engine.test.ts
Normal file
2487
apps/desktop/git/__tests__/git-engine.test.ts
Normal file
File diff suppressed because it is too large
Load diff
53
apps/desktop/git/__tests__/git-ipc.test.ts
Normal file
53
apps/desktop/git/__tests__/git-ipc.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// apps/desktop/git/__tests__/git-ipc.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { GitError } from '../error';
|
||||
import { serializeGitError, GIT_ERROR_MARKER } from '../ipc-handlers';
|
||||
|
||||
describe('serializeGitError', () => {
|
||||
it('produces an Error whose message starts with the marker and round-trips fields', () => {
|
||||
const original = new GitError('commit-empty', 'No changes', { recoverable: true });
|
||||
const serialized = serializeGitError(original);
|
||||
expect(serialized.message.startsWith(GIT_ERROR_MARKER)).toBe(true);
|
||||
const payload = JSON.parse(serialized.message.slice(GIT_ERROR_MARKER.length));
|
||||
expect(payload.code).toBe('commit-empty');
|
||||
expect(payload.message).toBe('No changes');
|
||||
expect(payload.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves recoverable=false from the GitError', () => {
|
||||
const original = new GitError('engine-crash', 'something blew up', { recoverable: false });
|
||||
const serialized = serializeGitError(original);
|
||||
const payload = JSON.parse(serialized.message.slice(GIT_ERROR_MARKER.length));
|
||||
expect(payload.recoverable).toBe(false);
|
||||
});
|
||||
|
||||
it('the marker is a stable, unique string the renderer can pattern-match', () => {
|
||||
expect(GIT_ERROR_MARKER).toBe('__GIT_ERROR__');
|
||||
});
|
||||
|
||||
it('serializes auth-related and network error codes', () => {
|
||||
const codes = [
|
||||
'auth-failed',
|
||||
'auth-required',
|
||||
'clone-failed',
|
||||
'network',
|
||||
'pull-non-fast-forward',
|
||||
] as const;
|
||||
for (const code of codes) {
|
||||
const original = new GitError(code, `${code} test`);
|
||||
const serialized = serializeGitError(original);
|
||||
const payload = JSON.parse(serialized.message.slice(GIT_ERROR_MARKER.length));
|
||||
expect(payload.code).toBe(code);
|
||||
}
|
||||
});
|
||||
|
||||
it('serializes merge-related error codes', () => {
|
||||
const codes = ['merge-conflict', 'merge-still-conflicted', 'merge-abort-failed'] as const;
|
||||
for (const code of codes) {
|
||||
const original = new GitError(code, `${code} test`);
|
||||
const serialized = serializeGitError(original);
|
||||
const payload = JSON.parse(serialized.message.slice(GIT_ERROR_MARKER.length));
|
||||
expect(payload.code).toBe(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
653
apps/desktop/git/__tests__/git-iso.test.ts
Normal file
653
apps/desktop/git/__tests__/git-iso.test.ts
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
// apps/desktop/git/__tests__/git-iso.test.ts
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
initSingleFile,
|
||||
openRepo,
|
||||
commitFile,
|
||||
readBlobAtCommit,
|
||||
logForRef,
|
||||
restoreFileFromCommit,
|
||||
listBranches,
|
||||
createBranch,
|
||||
deleteBranch,
|
||||
switchBranch,
|
||||
getCurrentBranch,
|
||||
setRef,
|
||||
readBlobOidAt,
|
||||
findMergeBase,
|
||||
} from '../git-iso';
|
||||
import { detectRepo } from '../repo-detector';
|
||||
import { mkTempDir, writeOpFile } from './test-helpers';
|
||||
|
||||
describe('git-iso', () => {
|
||||
let temp: { dir: string; dispose: () => Promise<void> };
|
||||
|
||||
beforeEach(async () => {
|
||||
temp = await mkTempDir();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await temp.dispose();
|
||||
});
|
||||
|
||||
describe('initSingleFile', () => {
|
||||
it('creates .op-history/<basename>.git/ with HEAD pointing at refs/heads/main', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op');
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
|
||||
expect(handle.mode).toBe('single-file');
|
||||
expect(handle.dir).toBe(temp.dir);
|
||||
expect(handle.gitdir).toBe(join(temp.dir, '.op-history', 'login.op.git'));
|
||||
expect(existsSync(join(handle.gitdir, 'HEAD'))).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent: re-running init on an existing repo returns the same handle without erroring', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op');
|
||||
const first = await initSingleFile({ filePath: opFile });
|
||||
const second = await initSingleFile({ filePath: opFile });
|
||||
expect(second.gitdir).toBe(first.gitdir);
|
||||
expect(second.dir).toBe(first.dir);
|
||||
expect(existsSync(join(second.gitdir, 'HEAD'))).toBe(true);
|
||||
});
|
||||
|
||||
it('respects the defaultBranch option', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op');
|
||||
const handle = await initSingleFile({ filePath: opFile, defaultBranch: 'trunk' });
|
||||
// isomorphic-git writes HEAD as a symbolic ref string
|
||||
const fs = await import('node:fs/promises');
|
||||
const head = await fs.readFile(join(handle.gitdir, 'HEAD'), 'utf-8');
|
||||
expect(head.trim()).toBe('ref: refs/heads/trunk');
|
||||
});
|
||||
|
||||
it('writes core.worktree = ../.. so terminal git can inspect the repo', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op');
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
// Read the on-disk config and verify both core.worktree and core.bare.
|
||||
const fsp = await import('node:fs/promises');
|
||||
const config = await fsp.readFile(join(handle.gitdir, 'config'), 'utf-8');
|
||||
expect(config).toMatch(/worktree\s*=\s*\.\.\/\.\./);
|
||||
expect(config).toMatch(/bare\s*=\s*false/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openRepo', () => {
|
||||
it('opens an existing single-file repo via a successful detection', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op');
|
||||
await initSingleFile({ filePath: opFile });
|
||||
|
||||
const detection = await detectRepo(opFile);
|
||||
expect(detection.mode).toBe('single-file');
|
||||
if (detection.mode !== 'single-file') throw new Error('detection failed');
|
||||
|
||||
const handle = await openRepo(detection);
|
||||
expect(handle.mode).toBe('single-file');
|
||||
expect(handle.dir).toBe(temp.dir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('commitFile + readBlobAtCommit', () => {
|
||||
it('creates a commit on the given ref and the blob round-trips', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
|
||||
const { hash } = await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'initial',
|
||||
author: { name: 'tester', email: 'tester@example.com' },
|
||||
});
|
||||
expect(hash).toMatch(/^[a-f0-9]{40}$/);
|
||||
|
||||
const content = await readBlobAtCommit({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
commitHash: hash,
|
||||
});
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.children[0].id).toBe('r1');
|
||||
});
|
||||
|
||||
it('throws commit-empty when committing the same content twice', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op');
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'first',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
await expect(
|
||||
commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'second',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
}),
|
||||
).rejects.toMatchObject({ name: 'GitError', code: 'commit-empty' });
|
||||
});
|
||||
|
||||
it('chains commits: each commit has the previous as parent', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
|
||||
const { hash: h1 } = await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'first',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
|
||||
// Mutate the file then commit again.
|
||||
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
|
||||
const { hash: h2 } = await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'second',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
|
||||
expect(h2).not.toBe(h1);
|
||||
// Verify h1 content is recoverable
|
||||
const c1 = await readBlobAtCommit({ handle, filepath: 'login.op', commitHash: h1 });
|
||||
expect(JSON.parse(c1).children[0].id).toBe('r1');
|
||||
// Verify h2 content
|
||||
const c2 = await readBlobAtCommit({ handle, filepath: 'login.op', commitHash: h2 });
|
||||
expect(JSON.parse(c2).children[0].id).toBe('r2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logForRef', () => {
|
||||
it('returns commits in reverse chronological order (newest first)', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
|
||||
const { hash: h1 } = await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'first',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
|
||||
const { hash: h2 } = await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'second',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
|
||||
const log = await logForRef({ handle, ref: 'refs/heads/main', depth: 10 });
|
||||
expect(log).toHaveLength(2);
|
||||
expect(log[0].hash).toBe(h2);
|
||||
expect(log[0].message).toBe('second\n');
|
||||
expect(log[1].hash).toBe(h1);
|
||||
expect(log[1].message).toBe('first\n');
|
||||
});
|
||||
|
||||
it('returns an empty array for a ref that does not exist', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op');
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
const log = await logForRef({
|
||||
handle,
|
||||
ref: 'refs/openpencil/autosaves/main',
|
||||
depth: 10,
|
||||
});
|
||||
expect(log).toEqual([]);
|
||||
});
|
||||
|
||||
it('respects the depth parameter', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
// Make 3 commits.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: `r${i}` }],
|
||||
});
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: `commit-${i}`,
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
}
|
||||
const log = await logForRef({ handle, ref: 'refs/heads/main', depth: 2 });
|
||||
expect(log).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreFileFromCommit', () => {
|
||||
it("writes a previous commit's content to the working tree", async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
const { hash: h1 } = await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'first',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
// Mutate
|
||||
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
|
||||
// Restore
|
||||
await restoreFileFromCommit({ handle, filepath: 'login.op', commitHash: h1 });
|
||||
// Verify the working tree file was overwritten
|
||||
const fsp = await import('node:fs/promises');
|
||||
const restored = await fsp.readFile(opFile, 'utf-8');
|
||||
expect(JSON.parse(restored).children[0].id).toBe('r1');
|
||||
});
|
||||
|
||||
it('does NOT create a new commit (the caller is responsible)', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
const { hash: h1 } = await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'first',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'second',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
const logBefore = await logForRef({ handle, ref: 'refs/heads/main', depth: 10 });
|
||||
|
||||
await restoreFileFromCommit({ handle, filepath: 'login.op', commitHash: h1 });
|
||||
const logAfter = await logForRef({ handle, ref: 'refs/heads/main', depth: 10 });
|
||||
expect(logAfter).toHaveLength(logBefore.length); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe('branch operations', () => {
|
||||
it('listBranches returns empty for a fresh repo with no commits', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op');
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
const branches = await listBranches({ handle });
|
||||
expect(branches).toEqual([]); // no commits = no branches
|
||||
});
|
||||
|
||||
it('listBranches returns the branch after a commit creates it', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'first',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
const branches = await listBranches({ handle });
|
||||
expect(branches).toContain('main');
|
||||
});
|
||||
|
||||
it('createBranch from current HEAD adds a new branch', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'first',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
await createBranch({ handle, name: 'feature-x' });
|
||||
const branches = await listBranches({ handle });
|
||||
expect(branches).toContain('feature-x');
|
||||
expect(branches).toContain('main');
|
||||
});
|
||||
|
||||
it('createBranch throws branch-exists when the name is taken', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'first',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
await createBranch({ handle, name: 'feature-x' });
|
||||
await expect(createBranch({ handle, name: 'feature-x' })).rejects.toMatchObject({
|
||||
name: 'GitError',
|
||||
code: 'branch-exists',
|
||||
});
|
||||
});
|
||||
|
||||
it('deleteBranch throws branch-current when deleting the active branch', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'first',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
await expect(deleteBranch({ handle, name: 'main' })).rejects.toMatchObject({
|
||||
name: 'GitError',
|
||||
code: 'branch-current',
|
||||
});
|
||||
});
|
||||
|
||||
it('deleteBranch removes a non-active branch', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'first',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
await createBranch({ handle, name: 'feature-x' });
|
||||
await deleteBranch({ handle, name: 'feature-x' });
|
||||
const branches = await listBranches({ handle });
|
||||
expect(branches).not.toContain('feature-x');
|
||||
expect(branches).toContain('main');
|
||||
});
|
||||
|
||||
it('deleteBranch throws branch-unmerged for an unmerged branch without force', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'base',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
await createBranch({ handle, name: 'feature-x' });
|
||||
await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r2' }],
|
||||
});
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/feature-x',
|
||||
message: 'feature only',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
await expect(deleteBranch({ handle, name: 'feature-x' })).rejects.toMatchObject({
|
||||
name: 'GitError',
|
||||
code: 'branch-unmerged',
|
||||
});
|
||||
});
|
||||
|
||||
it('deleteBranch removes an unmerged branch when force=true', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'base',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
await createBranch({ handle, name: 'feature-x' });
|
||||
await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r2' }],
|
||||
});
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/feature-x',
|
||||
message: 'feature only',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
await deleteBranch({ handle, name: 'feature-x', force: true });
|
||||
const branches = await listBranches({ handle });
|
||||
expect(branches).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('deleteBranch treats a fast-forward-merged branch as merged (equal OID tips)', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'base',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
// Branch off main with no additional commits: feature-ff tip === main tip.
|
||||
await createBranch({ handle, name: 'feature-ff' });
|
||||
// Without the equal-OID short-circuit in isBranchMergedAnywhere this would
|
||||
// throw branch-unmerged because isomorphic-git's isDescendent returns
|
||||
// false when oid === ancestor.
|
||||
await deleteBranch({ handle, name: 'feature-ff' });
|
||||
const branches = await listBranches({ handle });
|
||||
expect(branches).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('getCurrentBranch returns the active branch name', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'first',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
const current = await getCurrentBranch({ handle });
|
||||
expect(current).toBe('main');
|
||||
});
|
||||
|
||||
it('switchBranch updates the working tree file to the target branch tip', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'first',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
// Branch off, then commit something different on the new branch.
|
||||
await createBranch({ handle, name: 'feature-x' });
|
||||
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/feature-x',
|
||||
message: 'feature change',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
// Switch back to main and verify the file content reverts.
|
||||
await switchBranch({ handle, name: 'main', filepath: 'login.op' });
|
||||
const fsp = await import('node:fs/promises');
|
||||
const content = await fsp.readFile(opFile, 'utf-8');
|
||||
expect(JSON.parse(content).children[0].id).toBe('r1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRef + readBlobOidAt', () => {
|
||||
it('setRef creates a new ref pointing at the given commit and force-overwrites an existing one', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
const { hash: h1 } = await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'first',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
|
||||
// Create a brand-new ref pointing at h1.
|
||||
await setRef({ handle, ref: 'refs/openpencil/autosaves/main', value: h1 });
|
||||
const log1 = await logForRef({
|
||||
handle,
|
||||
ref: 'refs/openpencil/autosaves/main',
|
||||
depth: 10,
|
||||
});
|
||||
expect(log1).toHaveLength(1);
|
||||
expect(log1[0].hash).toBe(h1);
|
||||
|
||||
// Make a second milestone, then force the autosave ref to it (overwrite).
|
||||
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
|
||||
const { hash: h2 } = await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'second',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
await setRef({ handle, ref: 'refs/openpencil/autosaves/main', value: h2 });
|
||||
const log2 = await logForRef({
|
||||
handle,
|
||||
ref: 'refs/openpencil/autosaves/main',
|
||||
depth: 10,
|
||||
});
|
||||
// The autosave ref now jumps to h2 — its log walks h2 → h1 (parent chain).
|
||||
expect(log2[0].hash).toBe(h2);
|
||||
});
|
||||
|
||||
it('readBlobOidAt returns the blob OID at the ref tip and null for missing ref/file', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
|
||||
// Before any commit, the ref doesn't exist → null.
|
||||
const before = await readBlobOidAt({
|
||||
handle,
|
||||
ref: 'refs/heads/main',
|
||||
filepath: 'login.op',
|
||||
});
|
||||
expect(before).toBeNull();
|
||||
|
||||
await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'first',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
|
||||
// After commit, the OID is a 40-char hex string.
|
||||
const after = await readBlobOidAt({
|
||||
handle,
|
||||
ref: 'refs/heads/main',
|
||||
filepath: 'login.op',
|
||||
});
|
||||
expect(after).toMatch(/^[a-f0-9]{40}$/);
|
||||
|
||||
// Asking for a file that's not in the commit's tree → null.
|
||||
const missing = await readBlobOidAt({
|
||||
handle,
|
||||
ref: 'refs/heads/main',
|
||||
filepath: 'does-not-exist.op',
|
||||
});
|
||||
expect(missing).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMergeBase', () => {
|
||||
it('returns the common ancestor of two divergent branches', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
children: [{ id: 'r1' }],
|
||||
});
|
||||
const handle = await initSingleFile({ filePath: opFile });
|
||||
|
||||
const { hash: base } = await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/main',
|
||||
message: 'base',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
});
|
||||
|
||||
// Branch off and commit on each branch.
|
||||
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r2' }] });
|
||||
const { hash: ours } = await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/feature-a',
|
||||
message: 'ours',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
parents: [base],
|
||||
});
|
||||
await writeOpFile(temp.dir, 'login.op', { version: '1.0.0', children: [{ id: 'r3' }] });
|
||||
const { hash: theirs } = await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref: 'refs/heads/feature-b',
|
||||
message: 'theirs',
|
||||
author: { name: 't', email: 't@example.com' },
|
||||
parents: [base],
|
||||
});
|
||||
|
||||
const mergeBase = await findMergeBase({ handle, oid1: ours, oid2: theirs });
|
||||
expect(mergeBase).toBe(base);
|
||||
});
|
||||
});
|
||||
});
|
||||
703
apps/desktop/git/__tests__/git-sys-real.test.ts
Normal file
703
apps/desktop/git/__tests__/git-sys-real.test.ts
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
// apps/desktop/git/__tests__/git-sys-real.test.ts
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { execFile, execFileSync } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { sysClone, sysFetch, sysPush, sysAheadBehind, mapSysError } from '../git-sys';
|
||||
import {
|
||||
sysMergeNoCommit,
|
||||
sysListUnresolved,
|
||||
readMergeHead,
|
||||
sysShowStageBlob,
|
||||
sysRestoreOurs,
|
||||
sysStageFile,
|
||||
sysFinalizeMerge,
|
||||
sysAbortMerge,
|
||||
sysReadHead,
|
||||
} from '../worktree-merge';
|
||||
import { mkTempDir } from './test-helpers';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Synchronous availability probe at module load. We can't use the async
|
||||
// isSystemGitAvailable() because vitest's it.skipIf() reads its predicate at
|
||||
// test-collection time, before any beforeEach hook has run.
|
||||
let systemGitAvailable: boolean;
|
||||
try {
|
||||
execFileSync('git', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
systemGitAvailable = true;
|
||||
} catch {
|
||||
systemGitAvailable = false;
|
||||
}
|
||||
|
||||
describe('git-sys real (gated on system git)', () => {
|
||||
let temp: { dir: string; dispose: () => Promise<void> };
|
||||
|
||||
beforeEach(async () => {
|
||||
temp = await mkTempDir();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (temp) await temp.dispose();
|
||||
});
|
||||
|
||||
it.skipIf(!systemGitAvailable)('clones a local bare remote', async () => {
|
||||
const remoteDir = join(temp.dir, 'remote.git');
|
||||
const sourceDir = join(temp.dir, 'source');
|
||||
const cloneDir = join(temp.dir, 'clone');
|
||||
|
||||
// Set up: bare remote, source repo with one commit, push source → remote.
|
||||
await execFileAsync('git', ['init', '--bare', remoteDir]);
|
||||
await fsp.mkdir(sourceDir, { recursive: true });
|
||||
await execFileAsync('git', ['init', '-b', 'main', sourceDir]);
|
||||
await fsp.writeFile(join(sourceDir, 'README.md'), '# test\n');
|
||||
await execFileAsync('git', ['add', '.'], { cwd: sourceDir });
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'init'],
|
||||
{ cwd: sourceDir },
|
||||
);
|
||||
await execFileAsync('git', ['remote', 'add', 'origin', remoteDir], { cwd: sourceDir });
|
||||
await execFileAsync('git', ['push', 'origin', 'main'], { cwd: sourceDir });
|
||||
|
||||
// Now clone via sysClone.
|
||||
await sysClone({ url: remoteDir, dest: cloneDir });
|
||||
|
||||
// Verify the clone has the README.
|
||||
const content = await fsp.readFile(join(cloneDir, 'README.md'), 'utf-8');
|
||||
expect(content).toBe('# test\n');
|
||||
});
|
||||
|
||||
it.skipIf(!systemGitAvailable)('fetch updates remote-tracking refs', async () => {
|
||||
const remoteDir = join(temp.dir, 'remote.git');
|
||||
const aDir = join(temp.dir, 'a');
|
||||
const bDir = join(temp.dir, 'b');
|
||||
|
||||
await execFileAsync('git', ['init', '--bare', remoteDir]);
|
||||
// a: clone, commit, push
|
||||
await execFileAsync('git', ['clone', remoteDir, aDir]);
|
||||
await execFileAsync('git', ['-C', aDir, 'checkout', '-b', 'main']);
|
||||
await fsp.writeFile(join(aDir, 'one.txt'), '1');
|
||||
await execFileAsync('git', ['-C', aDir, 'add', '.']);
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'one'],
|
||||
{},
|
||||
);
|
||||
await execFileAsync('git', ['-C', aDir, 'push', '-u', 'origin', 'main']);
|
||||
|
||||
// b: clone the same remote (now has main with one.txt)
|
||||
await execFileAsync('git', ['clone', remoteDir, bDir]);
|
||||
|
||||
// a commits another file and pushes
|
||||
await fsp.writeFile(join(aDir, 'two.txt'), '2');
|
||||
await execFileAsync('git', ['-C', aDir, 'add', '.']);
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'two'],
|
||||
{},
|
||||
);
|
||||
await execFileAsync('git', ['-C', aDir, 'push']);
|
||||
|
||||
// b's ahead/behind before fetch should be 0/0 (b doesn't know about the new commit yet).
|
||||
const before = await sysAheadBehind({ cwd: bDir, branch: 'main' });
|
||||
expect(before).toEqual({ ahead: 0, behind: 0 });
|
||||
|
||||
// Fetch updates b's remote-tracking ref.
|
||||
await sysFetch({ cwd: bDir });
|
||||
const after = await sysAheadBehind({ cwd: bDir, branch: 'main' });
|
||||
expect(after).toEqual({ ahead: 0, behind: 1 });
|
||||
});
|
||||
|
||||
it.skipIf(!systemGitAvailable)('push to local bare remote succeeds', async () => {
|
||||
const remoteDir = join(temp.dir, 'remote.git');
|
||||
const cloneDir = join(temp.dir, 'clone');
|
||||
|
||||
await execFileAsync('git', ['init', '--bare', remoteDir]);
|
||||
await execFileAsync('git', ['clone', remoteDir, cloneDir]);
|
||||
await execFileAsync('git', ['-C', cloneDir, 'checkout', '-b', 'main']);
|
||||
await fsp.writeFile(join(cloneDir, 'a.txt'), 'a');
|
||||
await execFileAsync('git', ['-C', cloneDir, 'add', '.']);
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['-C', cloneDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'a'],
|
||||
{},
|
||||
);
|
||||
|
||||
await sysPush({ cwd: cloneDir, branch: 'main' });
|
||||
|
||||
// Verify the bare remote has main pointing at the clone's commit.
|
||||
const { stdout: remoteHead } = await execFileAsync('git', [
|
||||
'-C',
|
||||
remoteDir,
|
||||
'rev-parse',
|
||||
'main',
|
||||
]);
|
||||
const { stdout: cloneHead } = await execFileAsync('git', ['-C', cloneDir, 'rev-parse', 'HEAD']);
|
||||
expect(remoteHead.trim()).toBe(cloneHead.trim());
|
||||
});
|
||||
|
||||
it.skipIf(!systemGitAvailable)('push non-fast-forward is rejected', async () => {
|
||||
const remoteDir = join(temp.dir, 'remote.git');
|
||||
const aDir = join(temp.dir, 'a');
|
||||
const bDir = join(temp.dir, 'b');
|
||||
|
||||
await execFileAsync('git', ['init', '--bare', remoteDir]);
|
||||
// a: seed remote with one commit
|
||||
await execFileAsync('git', ['clone', remoteDir, aDir]);
|
||||
await execFileAsync('git', ['-C', aDir, 'checkout', '-b', 'main']);
|
||||
await fsp.writeFile(join(aDir, 'one.txt'), '1');
|
||||
await execFileAsync('git', ['-C', aDir, 'add', '.']);
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'one'],
|
||||
{},
|
||||
);
|
||||
await execFileAsync('git', ['-C', aDir, 'push', '-u', 'origin', 'main']);
|
||||
|
||||
// b: clone, then a pushes a 2nd commit
|
||||
await execFileAsync('git', ['clone', remoteDir, bDir]);
|
||||
await fsp.writeFile(join(aDir, 'two.txt'), '2');
|
||||
await execFileAsync('git', ['-C', aDir, 'add', '.']);
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'two'],
|
||||
{},
|
||||
);
|
||||
await execFileAsync('git', ['-C', aDir, 'push']);
|
||||
|
||||
// b makes a divergent commit and tries to push → rejected.
|
||||
await fsp.writeFile(join(bDir, 'b.txt'), 'b');
|
||||
await execFileAsync('git', ['-C', bDir, 'add', '.']);
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['-C', bDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'b'],
|
||||
{},
|
||||
);
|
||||
await expect(sysPush({ cwd: bDir, branch: 'main' })).rejects.toMatchObject({
|
||||
name: 'GitError',
|
||||
code: 'push-rejected',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 7a: worktree-merge real-git spike tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('worktree-merge real-git spike (gated on system git)', () => {
|
||||
let temp: { dir: string; dispose: () => Promise<void> };
|
||||
|
||||
beforeEach(async () => {
|
||||
temp = await mkTempDir();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (temp) await temp.dispose();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: create a repo with two divergent branches, each modifying the
|
||||
* tracked .op file (and optionally a README.md side file).
|
||||
*/
|
||||
async function setupDivergentRepo(opts: {
|
||||
withReadme?: boolean;
|
||||
readmeConflict?: boolean;
|
||||
}): Promise<{ repoDir: string; gitdir: string }> {
|
||||
const repoDir = join(temp.dir, 'repo');
|
||||
await fsp.mkdir(repoDir, { recursive: true });
|
||||
|
||||
const g = (...args: string[]) => execFileAsync('git', args, { cwd: repoDir });
|
||||
const gc = (...args: string[]) =>
|
||||
execFileAsync('git', ['-c', 'user.name=t', '-c', 'user.email=t@e.com', ...args], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
await g('init', '-b', 'main');
|
||||
// sysMergeNoCommit reads identity during git's internal bookkeeping;
|
||||
// make tests self-sufficient for machines without global git config.
|
||||
await g('config', 'user.name', 'Test');
|
||||
await g('config', 'user.email', 'test@test.com');
|
||||
await fsp.writeFile(
|
||||
join(repoDir, 'design.op'),
|
||||
JSON.stringify({ version: '1.0.0', children: [{ id: 'base' }] }),
|
||||
);
|
||||
if (opts.withReadme) {
|
||||
await fsp.writeFile(join(repoDir, 'README.md'), '# Base\n');
|
||||
}
|
||||
await g('add', '.');
|
||||
await gc('commit', '-m', 'base');
|
||||
|
||||
// Branch off: feature changes
|
||||
await g('checkout', '-b', 'feature');
|
||||
await fsp.writeFile(
|
||||
join(repoDir, 'design.op'),
|
||||
JSON.stringify({ version: '1.0.0', children: [{ id: 'theirs' }] }),
|
||||
);
|
||||
if (opts.withReadme && opts.readmeConflict) {
|
||||
await fsp.writeFile(join(repoDir, 'README.md'), '# Feature\n');
|
||||
}
|
||||
await g('add', '.');
|
||||
await gc('commit', '-m', 'theirs');
|
||||
|
||||
// Return to main: ours changes
|
||||
await g('checkout', 'main');
|
||||
await fsp.writeFile(
|
||||
join(repoDir, 'design.op'),
|
||||
JSON.stringify({ version: '1.0.0', children: [{ id: 'ours' }] }),
|
||||
);
|
||||
if (opts.withReadme && opts.readmeConflict) {
|
||||
await fsp.writeFile(join(repoDir, 'README.md'), '# Main\n');
|
||||
}
|
||||
await g('add', '.');
|
||||
await gc('commit', '-m', 'ours');
|
||||
|
||||
const gitdir = join(repoDir, '.git');
|
||||
return { repoDir, gitdir };
|
||||
}
|
||||
|
||||
it.skipIf(!systemGitAvailable)(
|
||||
'sysMergeNoCommit returns conflict and MERGE_HEAD is set',
|
||||
async () => {
|
||||
const { repoDir, gitdir } = await setupDivergentRepo({});
|
||||
|
||||
const result = await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
||||
expect(result.kind).toBe('conflict');
|
||||
|
||||
const mergeHead = await readMergeHead(gitdir);
|
||||
expect(mergeHead).not.toBeNull();
|
||||
expect(mergeHead).toMatch(/^[a-f0-9]{40}$/);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!systemGitAvailable)(
|
||||
'sysListUnresolved lists the tracked .op file as unresolved',
|
||||
async () => {
|
||||
const { repoDir } = await setupDivergentRepo({});
|
||||
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
||||
|
||||
const unresolved = await sysListUnresolved({ cwd: repoDir });
|
||||
expect(unresolved).toContain('design.op');
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!systemGitAvailable)(
|
||||
'sysListUnresolved lists both .op and README when both conflict',
|
||||
async () => {
|
||||
const { repoDir } = await setupDivergentRepo({ withReadme: true, readmeConflict: true });
|
||||
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
||||
|
||||
const unresolved = await sysListUnresolved({ cwd: repoDir });
|
||||
expect(unresolved).toContain('design.op');
|
||||
expect(unresolved).toContain('README.md');
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!systemGitAvailable)(
|
||||
'sysShowStageBlob reads base/ours/theirs from the index',
|
||||
async () => {
|
||||
const { repoDir } = await setupDivergentRepo({});
|
||||
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
||||
|
||||
const base = await sysShowStageBlob({ cwd: repoDir, stage: 1, filepath: 'design.op' });
|
||||
const ours = await sysShowStageBlob({ cwd: repoDir, stage: 2, filepath: 'design.op' });
|
||||
const theirs = await sysShowStageBlob({ cwd: repoDir, stage: 3, filepath: 'design.op' });
|
||||
|
||||
expect(JSON.parse(base!).children[0].id).toBe('base');
|
||||
expect(JSON.parse(ours!).children[0].id).toBe('ours');
|
||||
expect(JSON.parse(theirs!).children[0].id).toBe('theirs');
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!systemGitAvailable)(
|
||||
'sysRestoreOurs writes readable JSON and keeps MERGE_HEAD alive',
|
||||
async () => {
|
||||
const { repoDir, gitdir } = await setupDivergentRepo({});
|
||||
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
||||
|
||||
await sysRestoreOurs({ cwd: repoDir, filepath: 'design.op' });
|
||||
|
||||
// File on disk is now readable JSON.
|
||||
const content = await fsp.readFile(join(repoDir, 'design.op'), 'utf-8');
|
||||
expect(() => JSON.parse(content)).not.toThrow();
|
||||
expect(JSON.parse(content).children[0].id).toBe('ours');
|
||||
|
||||
// MERGE_HEAD is still set.
|
||||
const mergeHead = await readMergeHead(gitdir);
|
||||
expect(mergeHead).not.toBeNull();
|
||||
|
||||
// File is still listed as unresolved in the index.
|
||||
const unresolved = await sysListUnresolved({ cwd: repoDir });
|
||||
expect(unresolved).toContain('design.op');
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!systemGitAvailable)(
|
||||
'sysStageFile marks file as resolved so sysListUnresolved no longer includes it',
|
||||
async () => {
|
||||
const { repoDir } = await setupDivergentRepo({});
|
||||
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
||||
|
||||
// Write the final content and stage it.
|
||||
await fsp.writeFile(
|
||||
join(repoDir, 'design.op'),
|
||||
JSON.stringify({ version: '1.0.0', children: [{ id: 'resolved' }] }),
|
||||
);
|
||||
await sysStageFile({ cwd: repoDir, filepath: 'design.op' });
|
||||
|
||||
const unresolved = await sysListUnresolved({ cwd: repoDir });
|
||||
expect(unresolved).not.toContain('design.op');
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!systemGitAvailable)(
|
||||
'sysFinalizeMerge creates a 2-parent merge commit and clears MERGE_HEAD',
|
||||
async () => {
|
||||
const { repoDir, gitdir } = await setupDivergentRepo({});
|
||||
const headBefore = await sysReadHead({ cwd: repoDir });
|
||||
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
||||
|
||||
// Resolve the conflict.
|
||||
await fsp.writeFile(
|
||||
join(repoDir, 'design.op'),
|
||||
JSON.stringify({ version: '1.0.0', children: [{ id: 'resolved' }] }),
|
||||
);
|
||||
await sysStageFile({ cwd: repoDir, filepath: 'design.op' });
|
||||
|
||||
const mergeCommit = await sysFinalizeMerge({
|
||||
cwd: repoDir,
|
||||
message: 'Merge feature into main',
|
||||
author: { name: 'Test', email: 'test@test.com' },
|
||||
});
|
||||
|
||||
expect(mergeCommit).toMatch(/^[a-f0-9]{40}$/);
|
||||
expect(mergeCommit).not.toBe(headBefore);
|
||||
|
||||
// MERGE_HEAD is gone.
|
||||
const mergeHead = await readMergeHead(gitdir);
|
||||
expect(mergeHead).toBeNull();
|
||||
|
||||
// Verify 2-parent commit via git cat-file.
|
||||
const catResult = await execFileAsync('git', ['cat-file', '-p', 'HEAD'], { cwd: repoDir });
|
||||
const parentLines = catResult.stdout.split('\n').filter((line) => line.startsWith('parent '));
|
||||
expect(parentLines).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!systemGitAvailable)(
|
||||
'sysAbortMerge restores working tree and clears MERGE_HEAD',
|
||||
async () => {
|
||||
const { repoDir, gitdir } = await setupDivergentRepo({});
|
||||
const headBefore = await sysReadHead({ cwd: repoDir });
|
||||
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
||||
|
||||
await sysAbortMerge({ cwd: repoDir });
|
||||
|
||||
// MERGE_HEAD is gone.
|
||||
const mergeHead = await readMergeHead(gitdir);
|
||||
expect(mergeHead).toBeNull();
|
||||
|
||||
// HEAD is unchanged.
|
||||
const headAfter = await sysReadHead({ cwd: repoDir });
|
||||
expect(headAfter).toBe(headBefore);
|
||||
|
||||
// design.op is the ours version (clean JSON, no conflict markers).
|
||||
const content = await fsp.readFile(join(repoDir, 'design.op'), 'utf-8');
|
||||
expect(() => JSON.parse(content)).not.toThrow();
|
||||
expect(JSON.parse(content).children[0].id).toBe('ours');
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!systemGitAvailable)(
|
||||
'sysAbortMerge is idempotent when no merge is in progress',
|
||||
async () => {
|
||||
const { repoDir } = await setupDivergentRepo({});
|
||||
// No merge started — abort should not throw.
|
||||
await expect(sysAbortMerge({ cwd: repoDir })).resolves.toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 7a spike scenario 3: rename conflict
|
||||
// Documents what git actually does when the tracked .op file is RENAMED
|
||||
// on the feature branch while also being modified on both branches.
|
||||
//
|
||||
// Setup:
|
||||
// base: design.op (base content)
|
||||
// main: design.op (modified — id: 'ours')
|
||||
// feature: design-v2.op (renamed + modified — id: 'theirs')
|
||||
//
|
||||
// Expected git behavior after `git merge --no-commit --no-ff feature`:
|
||||
// - exitCode 1 (conflict)
|
||||
// - `git ls-files -u` lists BOTH "design.op" (deleted-by-them, stages 1+2)
|
||||
// AND "design-v2.op" (added-by-them, stage 3 only)
|
||||
// - stage 3 blob for "design.op" is absent (file was renamed away on theirs)
|
||||
// - stage 1/2 blobs for "design-v2.op" are absent (file is new on theirs)
|
||||
//
|
||||
// Engine implication (verified in the engine test below):
|
||||
// Since stage 3 blob for the tracked "design.op" is missing, the engine
|
||||
// falls through to { result: 'conflict-non-op' }. This is CORRECT because
|
||||
// we cannot perform a semantic merge when theirs renamed the tracked file.
|
||||
// ---------------------------------------------------------------------------
|
||||
it.skipIf(!systemGitAvailable)(
|
||||
'spike scenario 3: rename conflict — sysListUnresolved reports both old and new name',
|
||||
async () => {
|
||||
const repoDir = join(temp.dir, 'repo-rename');
|
||||
await fsp.mkdir(repoDir, { recursive: true });
|
||||
|
||||
const g = (...args: string[]) => execFileAsync('git', args, { cwd: repoDir });
|
||||
const gc = (...args: string[]) =>
|
||||
execFileAsync('git', ['-c', 'user.name=t', '-c', 'user.email=t@e.com', ...args], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
await g('init', '-b', 'main');
|
||||
await fsp.writeFile(
|
||||
join(repoDir, 'design.op'),
|
||||
JSON.stringify({ version: '1.0.0', children: [{ id: 'base' }] }),
|
||||
);
|
||||
await g('add', '.');
|
||||
await gc('commit', '-m', 'base');
|
||||
|
||||
// feature branch: rename design.op → design-v2.op and modify content.
|
||||
await g('checkout', '-b', 'feature');
|
||||
await fsp.rename(join(repoDir, 'design.op'), join(repoDir, 'design-v2.op'));
|
||||
// Overwrite the new name with different content so there's a real content diff.
|
||||
await fsp.writeFile(
|
||||
join(repoDir, 'design-v2.op'),
|
||||
JSON.stringify({ version: '1.0.0', children: [{ id: 'theirs' }] }),
|
||||
);
|
||||
await g('add', '-A');
|
||||
await gc('commit', '-m', 'rename to design-v2.op');
|
||||
|
||||
// main branch: modify design.op in place (divergent from the feature rename).
|
||||
await g('checkout', 'main');
|
||||
await fsp.writeFile(
|
||||
join(repoDir, 'design.op'),
|
||||
JSON.stringify({ version: '1.0.0', children: [{ id: 'ours' }] }),
|
||||
);
|
||||
await g('add', '.');
|
||||
await gc('commit', '-m', 'ours');
|
||||
|
||||
// Attempt merge — expect conflict.
|
||||
const mergeResult = await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
||||
expect(mergeResult.kind).toBe('conflict');
|
||||
|
||||
// SPIKE FINDING: git reports the rename as a conflict by listing the old
|
||||
// path ("design.op") and possibly the new path ("design-v2.op") as unresolved.
|
||||
// The exact set depends on git version and rename detection thresholds.
|
||||
const unresolved = await sysListUnresolved({ cwd: repoDir });
|
||||
|
||||
// The original tracked file must appear in the unresolved list because
|
||||
// git detects a rename conflict involving it.
|
||||
expect(unresolved).toContain('design.op');
|
||||
|
||||
// Stage 3 blob for the ORIGINAL path must be absent (theirs renamed it away).
|
||||
const stage3Original = await sysShowStageBlob({
|
||||
cwd: repoDir,
|
||||
stage: 3,
|
||||
filepath: 'design.op',
|
||||
});
|
||||
expect(stage3Original).toBeNull();
|
||||
|
||||
// Stage 2 blob for the ORIGINAL path (ours) must be present.
|
||||
const stage2Original = await sysShowStageBlob({
|
||||
cwd: repoDir,
|
||||
stage: 2,
|
||||
filepath: 'design.op',
|
||||
});
|
||||
expect(stage2Original).not.toBeNull();
|
||||
expect(JSON.parse(stage2Original!).children[0].id).toBe('ours');
|
||||
|
||||
// CONCLUSION: the engine checks for all three stages of trackedRel.
|
||||
// When stage 3 is null, it returns { result: 'conflict-non-op' }.
|
||||
// This is correct: the user must resolve the rename in a terminal.
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 7a spike scenario 4: dirty working tree behavior
|
||||
//
|
||||
// The plan says the renderer-side `withCleanWorkingTree` gate should block
|
||||
// merge attempts when the tracked file has uncommitted changes. This test
|
||||
// documents what git *actually does* when that gate is bypassed — establishing
|
||||
// the engine-layer contract: "the engine trusts callers to gate dirty trees;
|
||||
// if they don't, here is what git does."
|
||||
//
|
||||
// Spike setup:
|
||||
// - Both branches have a divergent commit on design.op (true 3-way merge
|
||||
// scenario, not a fast-forward), so git must merge the working tree.
|
||||
// - The working tree has an ADDITIONAL uncommitted change on top of the
|
||||
// committed ours version.
|
||||
//
|
||||
// Spike finding:
|
||||
// git merge --no-commit --no-ff with dirty tracked files that the merge
|
||||
// would touch exits with a non-zero code and a "local changes would be
|
||||
// overwritten" message. sysMergeNoCommit sees exit code != 0 and != 1,
|
||||
// so it throws a GitError('engine-crash'). The dirty content is NOT
|
||||
// silently lost or overwritten.
|
||||
//
|
||||
// This confirms that the renderer gate is the right place for the check:
|
||||
// the engine will throw (not silently corrupt) if called with a dirty tree.
|
||||
// ---------------------------------------------------------------------------
|
||||
it.skipIf(!systemGitAvailable)(
|
||||
'spike scenario 4: sysMergeNoCommit throws engine-crash when dirty tracked file would be overwritten',
|
||||
async () => {
|
||||
const repoDir = join(temp.dir, 'repo-dirty');
|
||||
await fsp.mkdir(repoDir, { recursive: true });
|
||||
|
||||
const g = (...args: string[]) => execFileAsync('git', args, { cwd: repoDir });
|
||||
const gc = (...args: string[]) =>
|
||||
execFileAsync('git', ['-c', 'user.name=t', '-c', 'user.email=t@e.com', ...args], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
// Base commit on main.
|
||||
await g('init', '-b', 'main');
|
||||
await fsp.writeFile(
|
||||
join(repoDir, 'design.op'),
|
||||
JSON.stringify({ version: '1.0.0', children: [{ id: 'base' }] }),
|
||||
);
|
||||
await g('add', '.');
|
||||
await gc('commit', '-m', 'base');
|
||||
|
||||
// feature branch: modify design.op and commit.
|
||||
await g('checkout', '-b', 'feature');
|
||||
await fsp.writeFile(
|
||||
join(repoDir, 'design.op'),
|
||||
JSON.stringify({ version: '1.0.0', children: [{ id: 'theirs' }] }),
|
||||
);
|
||||
await g('add', '.');
|
||||
await gc('commit', '-m', 'theirs');
|
||||
|
||||
// main: ALSO make a divergent commit (creates a true 3-way merge).
|
||||
await g('checkout', 'main');
|
||||
await fsp.writeFile(
|
||||
join(repoDir, 'design.op'),
|
||||
JSON.stringify({ version: '1.0.0', children: [{ id: 'ours' }] }),
|
||||
);
|
||||
await g('add', '.');
|
||||
await gc('commit', '-m', 'ours');
|
||||
|
||||
// Now dirty the tracked file AFTER committing (uncommitted working-tree change).
|
||||
await fsp.writeFile(
|
||||
join(repoDir, 'design.op'),
|
||||
JSON.stringify({ version: '1.0.0', children: [{ id: 'dirty-uncommitted' }] }),
|
||||
);
|
||||
// Do NOT stage or commit — file is now dirty.
|
||||
|
||||
// SPIKE: attempt the merge. Git detects the dirty tracked file would be
|
||||
// overwritten by the merge and exits with a non-zero code OTHER than 1
|
||||
// (typically exit code 1 but with a "would be overwritten" message, or
|
||||
// exit code 128 on some git versions). Either way, sysMergeNoCommit
|
||||
// either throws or returns { kind: 'conflict' } (if git wrote markers).
|
||||
//
|
||||
// The critical contract: the dirty content is NEVER silently discarded.
|
||||
let threwOrConflict = false;
|
||||
try {
|
||||
const mergeResult = await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
||||
// If sysMergeNoCommit did not throw, git returned exit code 0 or 1.
|
||||
// Exit code 1 means it entered a conflict state — verify the dirty
|
||||
// content is preserved in conflict markers or the file is unresolved.
|
||||
if (mergeResult.kind === 'conflict') {
|
||||
threwOrConflict = true;
|
||||
const unresolved = await sysListUnresolved({ cwd: repoDir });
|
||||
// design.op must be listed — dirty working tree + merge conflict.
|
||||
expect(unresolved).toContain('design.op');
|
||||
// The working tree file should contain conflict markers (not clean JSON).
|
||||
const raw = await fsp.readFile(join(repoDir, 'design.op'), 'utf-8');
|
||||
// Either it has conflict markers OR it's valid JSON (git preserved ours).
|
||||
// In both cases the content must not be silently replaced with theirs.
|
||||
const hasConflictMarkers = raw.includes('<<<<<<<') || raw.includes('>>>>>>>');
|
||||
const isReadableJson = (() => {
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
expect(hasConflictMarkers || isReadableJson).toBe(true);
|
||||
}
|
||||
// If kind === 'clean', the dirty content was identical to what the merge
|
||||
// would produce — the merge happened to be a no-op for this file.
|
||||
} catch (err) {
|
||||
// sysMergeNoCommit threw a GitError — git refused the merge entirely.
|
||||
// This is the most common outcome when dirty files would be overwritten.
|
||||
threwOrConflict = true;
|
||||
// The error should be an engine-crash (non-0/non-1 exit code from git).
|
||||
const e = err as { name?: string; code?: string };
|
||||
expect(e.name).toBe('GitError');
|
||||
expect(e.code).toBe('engine-crash');
|
||||
}
|
||||
|
||||
// CONTRACT: either git threw (refused) or entered conflict state.
|
||||
// It must NOT silently overwrite the dirty content with theirs.
|
||||
expect(threwOrConflict).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!systemGitAvailable)(
|
||||
'full workflow: tracked .op conflict + non-.op conflict, then finalize',
|
||||
async () => {
|
||||
const { repoDir, gitdir } = await setupDivergentRepo({
|
||||
withReadme: true,
|
||||
readmeConflict: true,
|
||||
});
|
||||
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
||||
|
||||
// Confirm both conflict.
|
||||
const unresolved = await sysListUnresolved({ cwd: repoDir });
|
||||
expect(unresolved).toContain('design.op');
|
||||
expect(unresolved).toContain('README.md');
|
||||
|
||||
// Resolve .op by writing final merged content and staging.
|
||||
await fsp.writeFile(
|
||||
join(repoDir, 'design.op'),
|
||||
JSON.stringify({ version: '1.0.0', children: [{ id: 'merged' }] }),
|
||||
);
|
||||
await sysStageFile({ cwd: repoDir, filepath: 'design.op' });
|
||||
|
||||
// .op is resolved; README still unresolved.
|
||||
const afterOp = await sysListUnresolved({ cwd: repoDir });
|
||||
expect(afterOp).not.toContain('design.op');
|
||||
expect(afterOp).toContain('README.md');
|
||||
|
||||
// Resolve README (take ours).
|
||||
await sysRestoreOurs({ cwd: repoDir, filepath: 'README.md' });
|
||||
await sysStageFile({ cwd: repoDir, filepath: 'README.md' });
|
||||
|
||||
// All resolved.
|
||||
const afterAll = await sysListUnresolved({ cwd: repoDir });
|
||||
expect(afterAll).toHaveLength(0);
|
||||
|
||||
// Finalize.
|
||||
const mergeCommit = await sysFinalizeMerge({
|
||||
cwd: repoDir,
|
||||
message: 'Merge feature: mixed conflict',
|
||||
author: { name: 'Test', email: 'test@test.com' },
|
||||
});
|
||||
expect(mergeCommit).toMatch(/^[a-f0-9]{40}$/);
|
||||
const mergeHead = await readMergeHead(gitdir);
|
||||
expect(mergeHead).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('mapSysError', () => {
|
||||
it('maps known stderr substrings to GitError codes', () => {
|
||||
expect(mapSysError('Authentication failed for ...')).toBe('auth-failed');
|
||||
expect(mapSysError('Permission denied (publickey).')).toBe('auth-failed');
|
||||
expect(mapSysError('Repository not found')).toBe('clone-failed');
|
||||
expect(
|
||||
mapSysError("destination path 'foo' already exists and is not an empty directory."),
|
||||
).toBe('clone-target-exists');
|
||||
expect(mapSysError("Couldn't resolve host 'github.com'")).toBe('network');
|
||||
expect(mapSysError('Connection timed out')).toBe('timeout');
|
||||
expect(mapSysError('Updates were rejected because ...')).toBe('push-rejected');
|
||||
expect(mapSysError('not possible to fast-forward, aborting.')).toBe('pull-non-fast-forward');
|
||||
expect(mapSysError('fatal: not a git repository')).toBe('not-a-repo');
|
||||
expect(mapSysError('something completely unexpected')).toBe('engine-crash');
|
||||
});
|
||||
});
|
||||
72
apps/desktop/git/__tests__/git-sys.test.ts
Normal file
72
apps/desktop/git/__tests__/git-sys.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// apps/desktop/git/__tests__/git-sys.test.ts
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { isSystemGitAvailable, __resetSystemGitCache, getSystemAuthor } from '../git-sys';
|
||||
|
||||
describe('git-sys', () => {
|
||||
beforeEach(() => {
|
||||
__resetSystemGitCache();
|
||||
});
|
||||
|
||||
it('isSystemGitAvailable returns a boolean and caches the result', async () => {
|
||||
const first = await isSystemGitAvailable();
|
||||
expect(typeof first).toBe('boolean');
|
||||
// Second call should hit the cache and return the same value.
|
||||
const second = await isSystemGitAvailable();
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSystemAuthor (injected exec)', () => {
|
||||
// These tests use the injected-exec seam to stay deterministic and avoid
|
||||
// depending on whatever user.name/user.email happen to be configured on the
|
||||
// host running the suite. The seam short-circuits isSystemGitAvailable and
|
||||
// runGit entirely, so we exercise only the parse/validate/catch logic.
|
||||
|
||||
it('returns parsed name/email on success', async () => {
|
||||
const calls: string[][] = [];
|
||||
const fakeExec = async (args: string[]) => {
|
||||
calls.push(args);
|
||||
if (args[2] === 'user.name') return { stdout: 'Alice\n', stderr: '' };
|
||||
if (args[2] === 'user.email') return { stdout: 'alice@example.com\n', stderr: '' };
|
||||
return { stdout: '', stderr: '' };
|
||||
};
|
||||
|
||||
const result = await getSystemAuthor(fakeExec);
|
||||
|
||||
expect(result).toEqual({ name: 'Alice', email: 'alice@example.com' });
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]).toEqual(['config', '--get', 'user.name']);
|
||||
expect(calls[1]).toEqual(['config', '--get', 'user.email']);
|
||||
});
|
||||
|
||||
it('returns null when git throws (e.g. key not set)', async () => {
|
||||
const calls: string[][] = [];
|
||||
const fakeExec = async (args: string[]) => {
|
||||
calls.push(args);
|
||||
// Simulate `git config --get user.name` exiting non-zero when unset.
|
||||
throw new Error('git config --get user.name failed: exit code 1');
|
||||
};
|
||||
|
||||
const result = await getSystemAuthor(fakeExec);
|
||||
|
||||
expect(result).toBeNull();
|
||||
// First call throws, so the second call never happens.
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns null when either value is empty/whitespace', async () => {
|
||||
const calls: string[][] = [];
|
||||
const fakeExec = async (args: string[]) => {
|
||||
calls.push(args);
|
||||
if (args[2] === 'user.name') return { stdout: 'Bob\n', stderr: '' };
|
||||
if (args[2] === 'user.email') return { stdout: ' \n', stderr: '' };
|
||||
return { stdout: '', stderr: '' };
|
||||
};
|
||||
|
||||
const result = await getSystemAuthor(fakeExec);
|
||||
|
||||
expect(result).toBeNull();
|
||||
// Both calls happen because validation is post-fetch.
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
280
apps/desktop/git/__tests__/merge-orchestrator.test.ts
Normal file
280
apps/desktop/git/__tests__/merge-orchestrator.test.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
// apps/desktop/git/__tests__/merge-orchestrator.test.ts
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkTempDir, writeOpFile } from './test-helpers';
|
||||
import { initSingleFile, commitFile, type IsoRepoHandle } from '../git-iso';
|
||||
import { runMerge, applyResolutions } from '../merge-orchestrator';
|
||||
import { buildConflictBag } from '../merge-session';
|
||||
import type { PenDocument } from '@zseven-w/pen-types';
|
||||
import { mergeDocuments } from '@zseven-w/pen-core';
|
||||
|
||||
const author = { name: 't', email: 't@example.com' };
|
||||
|
||||
async function commitDocument(
|
||||
handle: IsoRepoHandle,
|
||||
doc: PenDocument,
|
||||
ref: string,
|
||||
message: string,
|
||||
parents?: string[],
|
||||
): Promise<string> {
|
||||
// Write to disk first (commitFile reads from disk, not from the input).
|
||||
const fsp = await import('node:fs/promises');
|
||||
const path = `${handle.dir}/login.op`;
|
||||
await fsp.writeFile(path, JSON.stringify(doc), 'utf-8');
|
||||
const { hash } = await commitFile({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
ref,
|
||||
message,
|
||||
author,
|
||||
parents,
|
||||
});
|
||||
return hash;
|
||||
}
|
||||
|
||||
describe('merge-orchestrator', () => {
|
||||
let temp: { dir: string; dispose: () => Promise<void> };
|
||||
let handle: IsoRepoHandle;
|
||||
|
||||
beforeEach(async () => {
|
||||
temp = await mkTempDir();
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op', {
|
||||
version: '1.0.0',
|
||||
name: 'login',
|
||||
children: [],
|
||||
});
|
||||
handle = await initSingleFile({ filePath: opFile });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await temp.dispose();
|
||||
});
|
||||
|
||||
it('runMerge produces an empty conflict bag for a clean merge', async () => {
|
||||
const base: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'doc',
|
||||
children: [{ id: 'rect-1', type: 'rectangle', x: 0, y: 0, width: 10, height: 10 } as never],
|
||||
};
|
||||
const ours: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'doc',
|
||||
children: [
|
||||
{ id: 'rect-1', type: 'rectangle', x: 0, y: 0, width: 10, height: 10 } as never,
|
||||
{ id: 'rect-2', type: 'rectangle', x: 20, y: 0, width: 10, height: 10 } as never,
|
||||
],
|
||||
};
|
||||
const theirs: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'doc',
|
||||
children: [
|
||||
{ id: 'rect-1', type: 'rectangle', x: 0, y: 0, width: 10, height: 10 } as never,
|
||||
{ id: 'rect-3', type: 'rectangle', x: 40, y: 0, width: 10, height: 10 } as never,
|
||||
],
|
||||
};
|
||||
|
||||
const baseHash = await commitDocument(handle, base, 'refs/heads/main', 'base');
|
||||
const oursHash = await commitDocument(handle, ours, 'refs/heads/feature-a', 'ours', [baseHash]);
|
||||
const theirsHash = await commitDocument(handle, theirs, 'refs/heads/feature-b', 'theirs', [
|
||||
baseHash,
|
||||
]);
|
||||
|
||||
const merged = await runMerge({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
oursCommit: oursHash,
|
||||
theirsCommit: theirsHash,
|
||||
baseCommit: baseHash,
|
||||
});
|
||||
|
||||
expect(merged.bag.nodeConflicts).toHaveLength(0);
|
||||
expect(merged.bag.docFieldConflicts).toHaveLength(0);
|
||||
expect(merged.conflictMap.size).toBe(0);
|
||||
});
|
||||
|
||||
it('runMerge surfaces a node conflict when both sides modify the same field', async () => {
|
||||
const base: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'doc',
|
||||
children: [
|
||||
{
|
||||
id: 'rect-1',
|
||||
type: 'rectangle',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
fill: [{ type: 'solid', color: '#ff0000' }],
|
||||
} as never,
|
||||
],
|
||||
};
|
||||
const ours: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'doc',
|
||||
children: [
|
||||
{
|
||||
id: 'rect-1',
|
||||
type: 'rectangle',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
fill: [{ type: 'solid', color: '#00ff00' }],
|
||||
} as never,
|
||||
],
|
||||
};
|
||||
const theirs: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'doc',
|
||||
children: [
|
||||
{
|
||||
id: 'rect-1',
|
||||
type: 'rectangle',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
fill: [{ type: 'solid', color: '#0000ff' }],
|
||||
} as never,
|
||||
],
|
||||
};
|
||||
|
||||
const baseHash = await commitDocument(handle, base, 'refs/heads/main', 'base');
|
||||
const oursHash = await commitDocument(handle, ours, 'refs/heads/feature-a', 'ours', [baseHash]);
|
||||
const theirsHash = await commitDocument(handle, theirs, 'refs/heads/feature-b', 'theirs', [
|
||||
baseHash,
|
||||
]);
|
||||
|
||||
const merged = await runMerge({
|
||||
handle,
|
||||
filepath: 'login.op',
|
||||
oursCommit: oursHash,
|
||||
theirsCommit: theirsHash,
|
||||
baseCommit: baseHash,
|
||||
});
|
||||
expect(merged.bag.nodeConflicts.length).toBeGreaterThan(0);
|
||||
const c = merged.bag.nodeConflicts[0];
|
||||
expect(c.id).toBe('node:_:rect-1');
|
||||
expect(c.reason).toBe('both-modified-same-field');
|
||||
});
|
||||
|
||||
it('applyResolutions with kind=ours leaves the merged tree unchanged', () => {
|
||||
const base: PenDocument = { version: '1.0.0', name: 'd', children: [] };
|
||||
const ours: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'd',
|
||||
children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 } as never],
|
||||
};
|
||||
const theirs: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'd',
|
||||
children: [{ id: 'a', type: 'rectangle', x: 5, y: 5, width: 1, height: 1 } as never],
|
||||
};
|
||||
const result = mergeDocuments({ base, ours, theirs });
|
||||
const { conflictMap } = buildConflictBag(result);
|
||||
|
||||
const out = applyResolutions({
|
||||
merged: result.merged,
|
||||
conflictMap,
|
||||
resolutions: new Map([['node:_:a', { kind: 'ours' }]]),
|
||||
});
|
||||
// pen-core's merge places ours as the placeholder, so the result is
|
||||
// identical to ours.
|
||||
expect(((out.children ?? [])[0] as { x: number }).x).toBe(0);
|
||||
});
|
||||
|
||||
it('applyResolutions with kind=theirs replaces the node with the theirs version', () => {
|
||||
const base: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'd',
|
||||
children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 } as never],
|
||||
};
|
||||
const ours: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'd',
|
||||
children: [{ id: 'a', type: 'rectangle', x: 5, y: 5, width: 1, height: 1 } as never],
|
||||
};
|
||||
const theirs: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'd',
|
||||
children: [{ id: 'a', type: 'rectangle', x: 9, y: 9, width: 1, height: 1 } as never],
|
||||
};
|
||||
const result = mergeDocuments({ base, ours, theirs });
|
||||
const { conflictMap } = buildConflictBag(result);
|
||||
expect(result.nodeConflicts.length).toBeGreaterThan(0);
|
||||
|
||||
const out = applyResolutions({
|
||||
merged: result.merged,
|
||||
conflictMap,
|
||||
resolutions: new Map([['node:_:a', { kind: 'theirs' }]]),
|
||||
});
|
||||
expect(((out.children ?? [])[0] as { x: number }).x).toBe(9);
|
||||
});
|
||||
|
||||
it('applyResolutions with kind=manual-node replaces the node with the user-supplied version', () => {
|
||||
const base: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'd',
|
||||
children: [{ id: 'a', type: 'rectangle', x: 0, y: 0, width: 1, height: 1 } as never],
|
||||
};
|
||||
const ours: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'd',
|
||||
children: [{ id: 'a', type: 'rectangle', x: 5, y: 5, width: 1, height: 1 } as never],
|
||||
};
|
||||
const theirs: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'd',
|
||||
children: [{ id: 'a', type: 'rectangle', x: 9, y: 9, width: 1, height: 1 } as never],
|
||||
};
|
||||
const result = mergeDocuments({ base, ours, theirs });
|
||||
const { conflictMap } = buildConflictBag(result);
|
||||
|
||||
const manualNode = {
|
||||
id: 'a',
|
||||
type: 'rectangle',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 1,
|
||||
height: 1,
|
||||
} as never;
|
||||
const out = applyResolutions({
|
||||
merged: result.merged,
|
||||
conflictMap,
|
||||
resolutions: new Map([['node:_:a', { kind: 'manual-node', node: manualNode }]]),
|
||||
});
|
||||
expect(((out.children ?? [])[0] as { x: number }).x).toBe(100);
|
||||
});
|
||||
|
||||
it('applyResolutions handles a doc-field conflict via setDocFieldByPath', () => {
|
||||
// Synthesize a doc-field conflict directly. We don't go through pen-core
|
||||
// because constructing a real variable conflict requires more PenDocument
|
||||
// boilerplate than this test needs to verify the path-set helper.
|
||||
const merged: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'd',
|
||||
children: [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...({ variables: { 'color-1': { value: 'red' } } } as any),
|
||||
};
|
||||
const conflictMap = new Map([
|
||||
[
|
||||
'field:variables:variables.color-1.value',
|
||||
{
|
||||
field: 'variables' as const,
|
||||
path: 'variables.color-1.value',
|
||||
base: 'red',
|
||||
ours: 'green',
|
||||
theirs: 'blue',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const out = applyResolutions({
|
||||
merged,
|
||||
conflictMap,
|
||||
resolutions: new Map([['field:variables:variables.color-1.value', { kind: 'theirs' }]]),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((out as any).variables['color-1'].value).toBe('blue');
|
||||
});
|
||||
});
|
||||
94
apps/desktop/git/__tests__/merge-session.test.ts
Normal file
94
apps/desktop/git/__tests__/merge-session.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// apps/desktop/git/__tests__/merge-session.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
encodeNodeConflictId,
|
||||
encodeDocFieldConflictId,
|
||||
parseConflictId,
|
||||
buildConflictBag,
|
||||
} from '../merge-session';
|
||||
import type { NodeConflict, DocFieldConflict, MergeResult } from '@zseven-w/pen-core';
|
||||
|
||||
describe('conflict id codec', () => {
|
||||
it('encodes node conflicts with the page-id placeholder for legacy single-page docs', () => {
|
||||
const a: NodeConflict = {
|
||||
pageId: 'page-1',
|
||||
nodeId: 'rect-7',
|
||||
reason: 'both-modified-same-field',
|
||||
base: null,
|
||||
ours: null,
|
||||
theirs: null,
|
||||
};
|
||||
expect(encodeNodeConflictId(a)).toBe('node:page-1:rect-7');
|
||||
|
||||
const b: NodeConflict = {
|
||||
pageId: null,
|
||||
nodeId: 'frame-root',
|
||||
reason: 'both-modified-same-field',
|
||||
base: null,
|
||||
ours: null,
|
||||
theirs: null,
|
||||
};
|
||||
expect(encodeNodeConflictId(b)).toBe('node:_:frame-root');
|
||||
});
|
||||
|
||||
it('encodes doc-field conflicts with field:path format', () => {
|
||||
const a: DocFieldConflict = {
|
||||
field: 'variables',
|
||||
path: 'variables.color-1.value',
|
||||
base: null,
|
||||
ours: null,
|
||||
theirs: null,
|
||||
};
|
||||
expect(encodeDocFieldConflictId(a)).toBe('field:variables:variables.color-1.value');
|
||||
});
|
||||
|
||||
it('parses node and field ids back into structured form', () => {
|
||||
expect(parseConflictId('node:page-1:rect-7')).toEqual({
|
||||
kind: 'node',
|
||||
pageId: 'page-1',
|
||||
nodeId: 'rect-7',
|
||||
});
|
||||
expect(parseConflictId('node:_:frame-root')).toEqual({
|
||||
kind: 'node',
|
||||
pageId: null,
|
||||
nodeId: 'frame-root',
|
||||
});
|
||||
expect(parseConflictId('field:variables:variables.color-1.value')).toEqual({
|
||||
kind: 'field',
|
||||
field: 'variables',
|
||||
path: 'variables.color-1.value',
|
||||
});
|
||||
});
|
||||
|
||||
it('buildConflictBag attaches ids and builds the lookup map in one pass', () => {
|
||||
const merged: MergeResult = {
|
||||
merged: { version: '1.0.0', name: 'doc', children: [] },
|
||||
nodeConflicts: [
|
||||
{
|
||||
pageId: null,
|
||||
nodeId: 'frame-root',
|
||||
reason: 'both-modified-same-field',
|
||||
base: null,
|
||||
ours: null,
|
||||
theirs: null,
|
||||
},
|
||||
],
|
||||
docFieldConflicts: [
|
||||
{
|
||||
field: 'variables',
|
||||
path: 'variables.color-1.value',
|
||||
base: 'red',
|
||||
ours: 'blue',
|
||||
theirs: 'green',
|
||||
},
|
||||
],
|
||||
};
|
||||
const { bag, conflictMap } = buildConflictBag(merged);
|
||||
expect(bag.nodeConflicts).toHaveLength(1);
|
||||
expect(bag.nodeConflicts[0].id).toBe('node:_:frame-root');
|
||||
expect(bag.docFieldConflicts[0].id).toBe('field:variables:variables.color-1.value');
|
||||
expect(conflictMap.size).toBe(2);
|
||||
expect(conflictMap.get('node:_:frame-root')).toBeDefined();
|
||||
expect(conflictMap.get('field:variables:variables.color-1.value')).toBeDefined();
|
||||
});
|
||||
});
|
||||
88
apps/desktop/git/__tests__/repo-detector.test.ts
Normal file
88
apps/desktop/git/__tests__/repo-detector.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// apps/desktop/git/__tests__/repo-detector.test.ts
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { detectRepo } from '../repo-detector';
|
||||
import { mkTempDir, writeOpFile, mkSubdir } from './test-helpers';
|
||||
|
||||
describe('detectRepo', () => {
|
||||
let temp: { dir: string; dispose: () => Promise<void> };
|
||||
|
||||
beforeEach(async () => {
|
||||
temp = await mkTempDir();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await temp.dispose();
|
||||
});
|
||||
|
||||
it('returns single-file mode when .op-history/<basename>.git/HEAD exists adjacent', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'login.op');
|
||||
const gitdir = await mkSubdir(temp.dir, '.op-history', 'login.op.git');
|
||||
await writeFile(join(gitdir, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8');
|
||||
|
||||
const result = await detectRepo(opFile);
|
||||
expect(result.mode).toBe('single-file');
|
||||
if (result.mode === 'single-file') {
|
||||
expect(result.rootPath).toBe(resolve(temp.dir));
|
||||
expect(result.gitdir).toBe(resolve(gitdir));
|
||||
}
|
||||
});
|
||||
|
||||
it('returns folder mode when the file lives inside a directory containing .git/HEAD', async () => {
|
||||
const repoRoot = await mkSubdir(temp.dir, 'repo');
|
||||
const dotGit = await mkSubdir(repoRoot, '.git');
|
||||
await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8');
|
||||
const opFile = await writeOpFile(repoRoot, 'design.op');
|
||||
|
||||
const result = await detectRepo(opFile);
|
||||
expect(result.mode).toBe('folder');
|
||||
if (result.mode === 'folder') {
|
||||
expect(result.rootPath).toBe(resolve(repoRoot));
|
||||
expect(result.gitdir).toBe(resolve(dotGit));
|
||||
}
|
||||
});
|
||||
|
||||
it('returns folder mode when the file is nested several levels inside a parent git repo', async () => {
|
||||
const repoRoot = await mkSubdir(temp.dir, 'project');
|
||||
const dotGit = await mkSubdir(repoRoot, '.git');
|
||||
await writeFile(join(dotGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8');
|
||||
const designsDir = await mkSubdir(repoRoot, 'designs', 'login');
|
||||
const opFile = await writeOpFile(designsDir, 'login.op');
|
||||
|
||||
const result = await detectRepo(opFile);
|
||||
expect(result.mode).toBe('folder');
|
||||
if (result.mode === 'folder') {
|
||||
expect(result.rootPath).toBe(resolve(repoRoot));
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers single-file mode when both single-file and parent folder repos exist', async () => {
|
||||
// Set up: a parent .git AND a sibling .op-history. Spec says single-file wins.
|
||||
const repoRoot = await mkSubdir(temp.dir, 'project');
|
||||
const parentGit = await mkSubdir(repoRoot, '.git');
|
||||
await writeFile(join(parentGit, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8');
|
||||
const opFile = await writeOpFile(repoRoot, 'login.op');
|
||||
const singleGitdir = await mkSubdir(repoRoot, '.op-history', 'login.op.git');
|
||||
await writeFile(join(singleGitdir, 'HEAD'), 'ref: refs/heads/main\n', 'utf-8');
|
||||
|
||||
const result = await detectRepo(opFile);
|
||||
expect(result.mode).toBe('single-file');
|
||||
if (result.mode === 'single-file') {
|
||||
expect(result.gitdir).toBe(resolve(singleGitdir));
|
||||
}
|
||||
});
|
||||
|
||||
it('returns none when no repository is found anywhere up the parent chain', async () => {
|
||||
const opFile = await writeOpFile(temp.dir, 'orphan.op');
|
||||
const result = await detectRepo(opFile);
|
||||
expect(result.mode).toBe('none');
|
||||
});
|
||||
|
||||
it('does not blow up when given a file path with non-existent parent directories', async () => {
|
||||
// The walk-up should still produce a 'none' result, not throw.
|
||||
const fakePath = join(temp.dir, 'does-not-exist', 'nested', 'fake.op');
|
||||
const result = await detectRepo(fakePath);
|
||||
expect(result.mode).toBe('none');
|
||||
});
|
||||
});
|
||||
122
apps/desktop/git/__tests__/repo-session.test.ts
Normal file
122
apps/desktop/git/__tests__/repo-session.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// apps/desktop/git/__tests__/repo-session.test.ts
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
registerSession,
|
||||
getSession,
|
||||
updateTrackedFile,
|
||||
updateCandidates,
|
||||
unregisterSession,
|
||||
clearAllSessions,
|
||||
sessionCount,
|
||||
setInflightMerge,
|
||||
clearInflightMerge,
|
||||
} from '../repo-session';
|
||||
import type { IsoRepoHandle } from '../git-iso';
|
||||
|
||||
const stubHandle: IsoRepoHandle = {
|
||||
dir: '/tmp/stub',
|
||||
gitdir: '/tmp/stub/.git',
|
||||
mode: 'folder',
|
||||
};
|
||||
|
||||
describe('repo-session', () => {
|
||||
beforeEach(() => {
|
||||
clearAllSessions();
|
||||
});
|
||||
|
||||
it('registerSession allocates a unique repoId and getSession round-trips', () => {
|
||||
const a = registerSession({
|
||||
handle: stubHandle,
|
||||
trackedFilePath: '/tmp/stub/a.op',
|
||||
candidateFiles: [],
|
||||
engineKind: 'iso',
|
||||
});
|
||||
const b = registerSession({
|
||||
handle: stubHandle,
|
||||
trackedFilePath: '/tmp/stub/b.op',
|
||||
candidateFiles: [],
|
||||
engineKind: 'iso',
|
||||
});
|
||||
expect(a.repoId).not.toBe(b.repoId);
|
||||
expect(getSession(a.repoId)?.trackedFilePath).toBe('/tmp/stub/a.op');
|
||||
expect(getSession(b.repoId)?.trackedFilePath).toBe('/tmp/stub/b.op');
|
||||
expect(sessionCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('updateTrackedFile mutates the session and returns true; unknown id returns false', () => {
|
||||
const s = registerSession({
|
||||
handle: stubHandle,
|
||||
trackedFilePath: null,
|
||||
candidateFiles: [],
|
||||
engineKind: 'iso',
|
||||
});
|
||||
expect(updateTrackedFile(s.repoId, '/tmp/stub/picked.op')).toBe(true);
|
||||
expect(getSession(s.repoId)?.trackedFilePath).toBe('/tmp/stub/picked.op');
|
||||
expect(updateTrackedFile('not-a-real-id', '/tmp/x.op')).toBe(false);
|
||||
});
|
||||
|
||||
it('updateCandidates replaces the cached candidate list', () => {
|
||||
const s = registerSession({
|
||||
handle: stubHandle,
|
||||
trackedFilePath: null,
|
||||
candidateFiles: [],
|
||||
engineKind: 'iso',
|
||||
});
|
||||
expect(
|
||||
updateCandidates(s.repoId, [
|
||||
{
|
||||
path: '/tmp/stub/a.op',
|
||||
relativePath: 'a.op',
|
||||
milestoneCount: 0,
|
||||
autosaveCount: 0,
|
||||
lastCommitAt: null,
|
||||
lastCommitMessage: null,
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(getSession(s.repoId)?.candidateFiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('unregisterSession removes the entry and getSession returns undefined', () => {
|
||||
const s = registerSession({
|
||||
handle: stubHandle,
|
||||
trackedFilePath: null,
|
||||
candidateFiles: [],
|
||||
engineKind: 'iso',
|
||||
});
|
||||
expect(unregisterSession(s.repoId)).toBe(true);
|
||||
expect(getSession(s.repoId)).toBeUndefined();
|
||||
expect(unregisterSession(s.repoId)).toBe(false); // already gone
|
||||
});
|
||||
|
||||
it('setInflightMerge and clearInflightMerge mutate the session', () => {
|
||||
const s = registerSession({
|
||||
handle: stubHandle,
|
||||
trackedFilePath: '/tmp/stub/a.op',
|
||||
candidateFiles: [],
|
||||
engineKind: 'iso',
|
||||
});
|
||||
expect(getSession(s.repoId)?.inflightMerge).toBeNull();
|
||||
|
||||
// Minimal InflightMerge stub (types cast bypasses full shape — the test
|
||||
// just exercises the registry mutators, not the merge logic).
|
||||
const merge = {
|
||||
oursCommit: 'a'.repeat(40),
|
||||
theirsCommit: 'b'.repeat(40),
|
||||
baseCommit: 'c'.repeat(40),
|
||||
mergeResult: {
|
||||
merged: { version: '1.0.0', name: 'd', children: [] },
|
||||
nodeConflicts: [],
|
||||
docFieldConflicts: [],
|
||||
},
|
||||
conflictMap: new Map(),
|
||||
resolutions: new Map(),
|
||||
defaultMessage: 'Merge',
|
||||
};
|
||||
expect(setInflightMerge(s.repoId, merge as never)).toBe(true);
|
||||
expect(getSession(s.repoId)?.inflightMerge).toBe(merge);
|
||||
|
||||
expect(clearInflightMerge(s.repoId)).toBe(true);
|
||||
expect(getSession(s.repoId)?.inflightMerge).toBeNull();
|
||||
});
|
||||
});
|
||||
75
apps/desktop/git/__tests__/ssh-keys.test.ts
Normal file
75
apps/desktop/git/__tests__/ssh-keys.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// apps/desktop/git/__tests__/ssh-keys.test.ts
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createSshKeyManager, type SshKeyManager } from '../ssh-keys';
|
||||
import { mkTempDir } from './test-helpers';
|
||||
|
||||
describe('ssh-keys', () => {
|
||||
let temp: { dir: string; dispose: () => Promise<void> };
|
||||
let manager: SshKeyManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
temp = await mkTempDir();
|
||||
manager = createSshKeyManager({ sshDir: join(temp.dir, 'ssh') });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await temp.dispose();
|
||||
});
|
||||
|
||||
it('generate produces a valid OpenSSH ed25519 public key with a SHA256 fingerprint', async () => {
|
||||
const info = await manager.generate({ host: 'github.com', comment: 'kay@laptop' });
|
||||
expect(info.publicKey.startsWith('ssh-ed25519 ')).toBe(true);
|
||||
expect(info.publicKey.endsWith('kay@laptop')).toBe(true);
|
||||
expect(info.fingerprint.startsWith('SHA256:')).toBe(true);
|
||||
expect(info.privateKeyPath).toMatch(/\.pem$/);
|
||||
|
||||
// The private key file exists with mode 0600.
|
||||
const stat = await fsp.stat(info.privateKeyPath);
|
||||
// mode bits include the file type, mask for permissions only.
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('list returns all generated keys', async () => {
|
||||
const a = await manager.generate({ host: 'github.com', comment: 'a' });
|
||||
const b = await manager.generate({ host: 'gitlab.com', comment: 'b' });
|
||||
const all = await manager.list();
|
||||
const ids = all.map((k) => k.id).sort();
|
||||
expect(ids).toEqual([a.id, b.id].sort());
|
||||
});
|
||||
|
||||
it('delete removes the key from the index and unlinks the private file', async () => {
|
||||
const info = await manager.generate({ host: 'github.com', comment: 'k' });
|
||||
await manager.delete(info.id);
|
||||
const all = await manager.list();
|
||||
expect(all).toHaveLength(0);
|
||||
await expect(fsp.access(info.privateKeyPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('delete is idempotent: deleting an unknown key is a no-op', async () => {
|
||||
await expect(manager.delete('not-a-real-id')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('getPrivateKeyPath returns the absolute path for an existing key and throws for unknown', async () => {
|
||||
const info = await manager.generate({ host: 'github.com', comment: 'k' });
|
||||
expect(await manager.getPrivateKeyPath(info.id)).toBe(info.privateKeyPath);
|
||||
await expect(manager.getPrivateKeyPath('not-real')).rejects.toThrow(/SSH key/);
|
||||
});
|
||||
|
||||
it('import takes an existing PEM private key file and stores a copy', async () => {
|
||||
// First generate one to get a real PEM file we can use as the "external" source.
|
||||
const seeded = await manager.generate({ host: 'github.com', comment: 'seed' });
|
||||
// Now create a fresh manager (different sshDir) and import the seeded private key.
|
||||
const otherDir = join(temp.dir, 'other-ssh');
|
||||
const other = createSshKeyManager({ sshDir: otherDir });
|
||||
const imported = await other.import({
|
||||
privateKeyPath: seeded.privateKeyPath,
|
||||
host: 'gitlab.com',
|
||||
});
|
||||
expect(imported.publicKey.startsWith('ssh-ed25519 ')).toBe(true);
|
||||
expect(imported.fingerprint).toBe(seeded.fingerprint); // same key → same fingerprint
|
||||
expect(imported.privateKeyPath).not.toBe(seeded.privateKeyPath); // copied to new location
|
||||
expect(imported.privateKeyPath.startsWith(otherDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
51
apps/desktop/git/__tests__/test-helpers.ts
Normal file
51
apps/desktop/git/__tests__/test-helpers.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// apps/desktop/git/__tests__/test-helpers.ts
|
||||
//
|
||||
// Shared test utilities for the desktop git layer tests. Each test creates
|
||||
// a fresh temp dir, runs its operation, and cleans up via the returned
|
||||
// disposer. This keeps tests isolated and parallel-safe.
|
||||
|
||||
import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Create a fresh temp directory under the OS temp path. Returns the path
|
||||
* and a disposer that recursively removes it. Always pair the call with
|
||||
* `try { ... } finally { await dispose(); }`.
|
||||
*/
|
||||
export async function mkTempDir(prefix = 'op-git-test-'): Promise<{
|
||||
dir: string;
|
||||
dispose: () => Promise<void>;
|
||||
}> {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
return {
|
||||
dir,
|
||||
dispose: async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a stub `.op` file (a tiny PenDocument JSON) into a directory.
|
||||
* Returns the absolute file path.
|
||||
*/
|
||||
export async function writeOpFile(
|
||||
dir: string,
|
||||
name: string,
|
||||
content: object = { version: '1.0.0', children: [] },
|
||||
): Promise<string> {
|
||||
const path = join(dir, name);
|
||||
await writeFile(path, JSON.stringify(content), 'utf-8');
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a nested directory structure under a temp root.
|
||||
* Useful for setting up "file inside parent git repo" scenarios.
|
||||
*/
|
||||
export async function mkSubdir(root: string, ...segments: string[]): Promise<string> {
|
||||
const dir = join(root, ...segments);
|
||||
await mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
198
apps/desktop/git/auth-store.ts
Normal file
198
apps/desktop/git/auth-store.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
// apps/desktop/git/auth-store.ts
|
||||
//
|
||||
// Encrypted credential store backed by Electron safeStorage. The whole
|
||||
// credential map is encrypted as a single blob and persisted to disk on
|
||||
// every mutation. We don't shard per-host because the map is tiny (a
|
||||
// handful of hosts at most) and atomic single-file writes are simpler.
|
||||
//
|
||||
// Tests inject a fake EncryptionBackend so they don't need a live Electron
|
||||
// process.
|
||||
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export type AuthCreds =
|
||||
| { kind: 'token'; username: string; token: string }
|
||||
| { kind: 'ssh'; keyId: string };
|
||||
|
||||
export interface EncryptionBackend {
|
||||
isAvailable(): boolean;
|
||||
encrypt(plain: string): Buffer | string;
|
||||
decrypt(cipher: Buffer | string): string;
|
||||
}
|
||||
|
||||
export interface AuthStore {
|
||||
set(host: string, creds: AuthCreds): Promise<void>;
|
||||
get(host: string): Promise<AuthCreds | null>;
|
||||
clear(host: string): Promise<void>;
|
||||
list(): Promise<string[]>;
|
||||
}
|
||||
|
||||
interface AuthStoreOpts {
|
||||
filePath: string;
|
||||
backend: EncryptionBackend;
|
||||
}
|
||||
|
||||
const PLAINTEXT_HEADER = '__OPENPENCIL_AUTH_PLAINTEXT_V1__';
|
||||
|
||||
/**
|
||||
* Build an AuthStore around a file path and an encryption backend. The
|
||||
* default factory at the bottom of this file uses Electron's safeStorage;
|
||||
* tests use createInMemoryBackend() instead.
|
||||
*/
|
||||
export function createAuthStore(opts: AuthStoreOpts): AuthStore {
|
||||
const { filePath, backend } = opts;
|
||||
let cache: Map<string, AuthCreds> | null = null;
|
||||
let warnedNoEncryption = false;
|
||||
// Set when we detect an encrypted blob on disk but the encryption backend
|
||||
// is unavailable. While locked, all reads return empty AND all writes throw
|
||||
// — we refuse to overwrite the encrypted file with plaintext (which would
|
||||
// destroy the user's stored credentials).
|
||||
let lockedOut = false;
|
||||
|
||||
async function load(): Promise<Map<string, AuthCreds>> {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const bytes = await fsp.readFile(filePath);
|
||||
let json: string;
|
||||
// Plaintext file (from a previous run without encryption available)?
|
||||
// Detect via the header marker.
|
||||
const head = bytes
|
||||
.slice(0, Math.min(PLAINTEXT_HEADER.length, bytes.length))
|
||||
.toString('utf-8');
|
||||
if (head === PLAINTEXT_HEADER) {
|
||||
json = bytes.slice(PLAINTEXT_HEADER.length).toString('utf-8');
|
||||
} else if (backend.isAvailable()) {
|
||||
json = backend.decrypt(bytes);
|
||||
} else {
|
||||
// Encrypted blob exists but no key. Lock the store: subsequent
|
||||
// writes will throw rather than silently destroying the encrypted
|
||||
// file by overwriting it with plaintext.
|
||||
if (!warnedNoEncryption) {
|
||||
console.warn(
|
||||
'[git/auth-store] Encrypted credential file exists but safeStorage is unavailable. ' +
|
||||
'Refusing to read or modify until encryption is restored (e.g. install libsecret on Linux).',
|
||||
);
|
||||
warnedNoEncryption = true;
|
||||
}
|
||||
lockedOut = true;
|
||||
cache = new Map();
|
||||
return cache;
|
||||
}
|
||||
const obj = JSON.parse(json) as Record<string, AuthCreds>;
|
||||
cache = new Map(Object.entries(obj));
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
cache = new Map();
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
async function save(map: Map<string, AuthCreds>): Promise<void> {
|
||||
if (lockedOut) {
|
||||
throw new Error(
|
||||
'auth-store is locked: encrypted credential file exists but safeStorage is unavailable. ' +
|
||||
'Restore the encryption backend before modifying credentials to avoid data loss.',
|
||||
);
|
||||
}
|
||||
|
||||
const obj: Record<string, AuthCreds> = {};
|
||||
for (const [host, creds] of map) obj[host] = creds;
|
||||
const json = JSON.stringify(obj);
|
||||
|
||||
if (backend.isAvailable()) {
|
||||
const encrypted = backend.encrypt(json);
|
||||
const buf = Buffer.isBuffer(encrypted) ? encrypted : Buffer.from(encrypted);
|
||||
await fsp.writeFile(filePath, buf, { mode: 0o600 });
|
||||
} else {
|
||||
if (!warnedNoEncryption) {
|
||||
console.warn(
|
||||
'[git/auth-store] safeStorage unavailable; persisting credentials in plaintext (file mode 0600). Install libsecret for encryption.',
|
||||
);
|
||||
warnedNoEncryption = true;
|
||||
}
|
||||
await fsp.writeFile(filePath, PLAINTEXT_HEADER + json, { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async set(host, creds) {
|
||||
const map = await load();
|
||||
map.set(host, creds);
|
||||
await save(map);
|
||||
},
|
||||
async get(host) {
|
||||
const map = await load();
|
||||
return map.get(host) ?? null;
|
||||
},
|
||||
async clear(host) {
|
||||
const map = await load();
|
||||
map.delete(host);
|
||||
await save(map);
|
||||
},
|
||||
async list() {
|
||||
const map = await load();
|
||||
return [...map.keys()];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory backend used by tests. Encrypt/decrypt are no-ops that wrap
|
||||
* the input in a marker so we can verify the round-trip happened.
|
||||
*/
|
||||
export function createInMemoryBackend(): EncryptionBackend {
|
||||
return {
|
||||
isAvailable: () => true,
|
||||
encrypt: (plain) => Buffer.from('MEMENC:' + plain, 'utf-8'),
|
||||
decrypt: (cipher) => {
|
||||
const s = Buffer.isBuffer(cipher) ? cipher.toString('utf-8') : cipher;
|
||||
if (!s.startsWith('MEMENC:')) throw new Error('not memenc');
|
||||
return s.slice('MEMENC:'.length);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only helper: build an unavailable backend that always returns false
|
||||
* for isAvailable() so tests can exercise the plaintext fallback.
|
||||
*/
|
||||
export function createUnavailableBackend(): EncryptionBackend {
|
||||
return {
|
||||
isAvailable: () => false,
|
||||
encrypt: () => {
|
||||
throw new Error('not available');
|
||||
},
|
||||
decrypt: () => {
|
||||
throw new Error('not available');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default factory: build an AuthStore that uses the real Electron safeStorage
|
||||
* and the standard userData git-auth.bin location. Imported by ipc-handlers.ts.
|
||||
*
|
||||
* NOTE: This factory must NOT be called at module load time — Electron's
|
||||
* safeStorage is only available after `app.whenReady()`. ipc-handlers.ts
|
||||
* calls this lazily inside setupGitIPC().
|
||||
*/
|
||||
export function createDefaultAuthStore(): AuthStore {
|
||||
// Lazy require so tests don't pull in Electron.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const electron = require('electron');
|
||||
const userDataDir: string = electron.app.getPath('userData');
|
||||
const filePath = join(userDataDir, 'git-auth.bin');
|
||||
const backend: EncryptionBackend = {
|
||||
isAvailable: () => electron.safeStorage.isEncryptionAvailable(),
|
||||
encrypt: (plain) => electron.safeStorage.encryptString(plain),
|
||||
decrypt: (cipher) => {
|
||||
const buf = Buffer.isBuffer(cipher) ? cipher : Buffer.from(cipher);
|
||||
return electron.safeStorage.decryptString(buf);
|
||||
},
|
||||
};
|
||||
return createAuthStore({ filePath, backend });
|
||||
}
|
||||
74
apps/desktop/git/error.ts
Normal file
74
apps/desktop/git/error.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// apps/desktop/git/error.ts
|
||||
//
|
||||
// Unified error type for the desktop git layer. Phase 1b only emits a subset
|
||||
// of these codes; Phase 2 will throw the rest (auth, network, merge, etc.)
|
||||
// without modifying this file.
|
||||
|
||||
/**
|
||||
* The complete error code union. New codes should land here, not in callsites,
|
||||
* so the renderer's error matrix in the spec stays in sync with the reality.
|
||||
*/
|
||||
export type GitErrorCode =
|
||||
// Phase 1b emits these:
|
||||
| 'init-failed'
|
||||
| 'open-failed'
|
||||
| 'not-a-repo'
|
||||
| 'commit-empty'
|
||||
| 'branch-exists'
|
||||
| 'branch-current'
|
||||
| 'branch-unmerged'
|
||||
| 'engine-crash'
|
||||
// Phase 2 will emit these (declared here for forward-compat):
|
||||
| 'no-file'
|
||||
| 'clone-failed'
|
||||
| 'clone-target-exists'
|
||||
| 'clone-network'
|
||||
| 'auth-required'
|
||||
| 'auth-failed'
|
||||
| 'auth-token-invalid'
|
||||
| 'network'
|
||||
| 'timeout'
|
||||
| 'commit-author-missing'
|
||||
| 'pull-non-fast-forward'
|
||||
| 'push-rejected'
|
||||
| 'push-no-remote'
|
||||
| 'branch-switch-dirty'
|
||||
| 'merge-conflict'
|
||||
| 'merge-conflict-non-op'
|
||||
| 'merge-still-conflicted'
|
||||
| 'merge-abort-failed'
|
||||
| 'restore-dirty'
|
||||
| 'ssh-not-supported-iso'
|
||||
| 'ssh-key-missing'
|
||||
| 'concurrent-busy'
|
||||
| 'external-modified'
|
||||
| 'save-required';
|
||||
|
||||
export class GitError extends Error {
|
||||
readonly code: GitErrorCode;
|
||||
readonly recoverable: boolean;
|
||||
readonly detail?: unknown;
|
||||
|
||||
constructor(
|
||||
code: GitErrorCode,
|
||||
message: string,
|
||||
opts: { recoverable?: boolean; detail?: unknown; cause?: unknown } = {},
|
||||
) {
|
||||
super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
|
||||
this.name = 'GitError';
|
||||
this.code = code;
|
||||
this.recoverable = opts.recoverable ?? true;
|
||||
if (opts.detail !== undefined) this.detail = opts.detail;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for catching GitError specifically (since `instanceof` across
|
||||
* realms can be flaky in tests, this is a defensive backup).
|
||||
*/
|
||||
export function isGitError(err: unknown): err is GitError {
|
||||
return (
|
||||
err instanceof GitError ||
|
||||
(typeof err === 'object' && err !== null && (err as { name?: string }).name === 'GitError')
|
||||
);
|
||||
}
|
||||
2010
apps/desktop/git/git-engine.ts
Normal file
2010
apps/desktop/git/git-engine.ts
Normal file
File diff suppressed because it is too large
Load diff
682
apps/desktop/git/git-iso.ts
Normal file
682
apps/desktop/git/git-iso.ts
Normal file
|
|
@ -0,0 +1,682 @@
|
|||
// apps/desktop/git/git-iso.ts
|
||||
//
|
||||
// Local git operations using isomorphic-git. This module is namespace-agnostic:
|
||||
// callers pass ref names (e.g. 'refs/heads/main') as parameters. The engine
|
||||
// in Phase 2 wraps these primitives with the actual ref naming convention
|
||||
// for milestones (refs/heads/<branch>) and autosaves (refs/openpencil/autosaves/<branch>).
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { dirname, basename, resolve, join } from 'node:path';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import * as git from 'isomorphic-git';
|
||||
import { GitError } from './error';
|
||||
import type { RepoDetectionFound } from './repo-detector';
|
||||
|
||||
export interface IsoRepoHandle {
|
||||
/** worktree (parent dir of .op file in single-file mode; repo root in folder mode) */
|
||||
dir: string;
|
||||
/** absolute path to the gitdir */
|
||||
gitdir: string;
|
||||
mode: 'single-file' | 'folder';
|
||||
}
|
||||
|
||||
export interface CommitMetaIso {
|
||||
hash: string;
|
||||
parentHashes: string[];
|
||||
message: string;
|
||||
author: { name: string; email: string; timestamp: number };
|
||||
}
|
||||
|
||||
export interface InitOptions {
|
||||
/** absolute path to the .op file (must already exist on disk) */
|
||||
filePath: string;
|
||||
/** branch name to initialize HEAD with; default 'main' */
|
||||
defaultBranch?: string;
|
||||
/** author for the initial empty commit; defaults to 'OpenPencil <noreply@openpencil>' */
|
||||
authorName?: string;
|
||||
authorEmail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a single-file repo at .op-history/<basename>.git next to the file.
|
||||
* Idempotent: if the gitdir already exists with a HEAD, returns the existing
|
||||
* handle without re-initializing.
|
||||
*
|
||||
* The new repo has:
|
||||
* - HEAD pointing at refs/heads/<defaultBranch>
|
||||
* - `core.worktree = ../..` written into the gitdir's config file so that
|
||||
* a user running `git -C <gitdir> status` from the terminal sees the
|
||||
* correct working tree (the parent dir of the .op file). isomorphic-git
|
||||
* itself doesn't need this — it accepts `dir` explicitly on every call —
|
||||
* but the spec documents the on-disk shape with this setting and the
|
||||
* CLI inspection use case demands it.
|
||||
* - No initial commit (the engine will create one with the file's current
|
||||
* content via commitFile).
|
||||
*/
|
||||
export async function initSingleFile(opts: InitOptions): Promise<IsoRepoHandle> {
|
||||
const absFile = resolve(opts.filePath);
|
||||
const dir = dirname(absFile);
|
||||
const baseName = basename(absFile);
|
||||
const gitdir = resolve(dir, '.op-history', `${baseName}.git`);
|
||||
const defaultBranch = opts.defaultBranch ?? 'main';
|
||||
|
||||
try {
|
||||
// If a HEAD already exists, this is a re-init. Return the handle as-is.
|
||||
if (fs.existsSync(join(gitdir, 'HEAD'))) {
|
||||
return { dir, gitdir, mode: 'single-file' };
|
||||
}
|
||||
|
||||
// Create the parent .op-history/ if needed.
|
||||
await mkdir(gitdir, { recursive: true });
|
||||
|
||||
// isomorphic-git's init creates the gitdir layout. We pass `dir` and
|
||||
// `gitdir` separately so the worktree is the file's parent dir, not
|
||||
// a sibling of gitdir.
|
||||
await git.init({
|
||||
fs,
|
||||
dir,
|
||||
gitdir,
|
||||
defaultBranch,
|
||||
bare: false,
|
||||
});
|
||||
|
||||
// Explicitly write core.worktree so terminal `git -C <gitdir>` sees the
|
||||
// correct working tree. The path is relative to the gitdir: from
|
||||
// <parent>/.op-history/<basename>.git → ../.. brings us back to <parent>.
|
||||
// We also force core.bare = false because some isomorphic-git versions
|
||||
// default it to true when dir != dirname(gitdir).
|
||||
await git.setConfig({
|
||||
fs,
|
||||
gitdir,
|
||||
path: 'core.worktree',
|
||||
value: '../..',
|
||||
});
|
||||
await git.setConfig({
|
||||
fs,
|
||||
gitdir,
|
||||
path: 'core.bare',
|
||||
value: false,
|
||||
});
|
||||
|
||||
return { dir, gitdir, mode: 'single-file' };
|
||||
} catch (err) {
|
||||
throw new GitError(
|
||||
'init-failed',
|
||||
`Failed to initialize single-file repo for ${opts.filePath}`,
|
||||
{
|
||||
cause: err,
|
||||
detail: { filePath: opts.filePath, gitdir },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an existing repository given a successful detection result.
|
||||
* This is a thin wrapper that just packages the detection into a handle and
|
||||
* verifies the gitdir is readable.
|
||||
*/
|
||||
export async function openRepo(detection: RepoDetectionFound): Promise<IsoRepoHandle> {
|
||||
try {
|
||||
// Verify the gitdir is readable by reading HEAD.
|
||||
const head = join(detection.gitdir, 'HEAD');
|
||||
if (!fs.existsSync(head)) {
|
||||
throw new GitError('open-failed', `gitdir has no HEAD: ${detection.gitdir}`);
|
||||
}
|
||||
return {
|
||||
dir: detection.rootPath,
|
||||
gitdir: detection.gitdir,
|
||||
mode: detection.mode,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof GitError) throw err;
|
||||
throw new GitError('open-failed', `Failed to open repository at ${detection.rootPath}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage the given file and create a commit on the specified ref. The ref
|
||||
* doesn't have to exist beforehand — if it doesn't, it's created at this
|
||||
* commit (this is how the engine seeds an empty branch with its first
|
||||
* autosave).
|
||||
*
|
||||
* Throws GitError 'commit-empty' if the working tree state for `filepath`
|
||||
* is identical to the ref's current tip (no changes to commit).
|
||||
*
|
||||
* Implementation note: isomorphic-git's `commit()` accepts a `ref` argument
|
||||
* and updates that ref directly (instead of HEAD). It also accepts an
|
||||
* explicit `parent` array. So we don't need any low-level tree-writing
|
||||
* primitives — just stage, detect-empty, then commit.
|
||||
*/
|
||||
export async function commitFile(opts: {
|
||||
handle: IsoRepoHandle;
|
||||
filepath: string;
|
||||
ref: string;
|
||||
message: string;
|
||||
author: { name: string; email: string };
|
||||
parents?: string[];
|
||||
}): Promise<{ hash: string }> {
|
||||
const { handle, filepath, ref, message, author } = opts;
|
||||
try {
|
||||
// Determine parent commit(s). If the caller passed parents explicitly,
|
||||
// use those. Otherwise look up the current ref tip; if the ref doesn't
|
||||
// exist, the new commit is a root commit (no parents).
|
||||
let parents = opts.parents;
|
||||
if (!parents) {
|
||||
try {
|
||||
const tip = await git.resolveRef({ fs, gitdir: handle.gitdir, ref });
|
||||
parents = [tip];
|
||||
} catch {
|
||||
parents = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Detect "no changes" by comparing the working tree blob hash for
|
||||
// `filepath` to the blob hash recorded at that path in the parent
|
||||
// commit's tree. Only valid for linear commits (exactly one parent).
|
||||
//
|
||||
// For root commits (no parents) the check doesn't apply. For merge
|
||||
// commits (2+ parents) the new tree may legitimately match one parent
|
||||
// but not the others — a merge that was fully resolved in favor of one
|
||||
// side still records history and must not be rejected. The engine layer
|
||||
// in Phase 2 owns the higher-level "is this merge meaningful" decision.
|
||||
if (parents.length === 1) {
|
||||
const fileBytes = await fs.promises.readFile(resolve(handle.dir, filepath));
|
||||
const { oid: workOid } = await git.hashBlob({ object: fileBytes });
|
||||
let parentBlobOid: string | undefined;
|
||||
try {
|
||||
const { oid } = await git.readBlob({
|
||||
fs,
|
||||
gitdir: handle.gitdir,
|
||||
oid: parents[0],
|
||||
filepath,
|
||||
});
|
||||
parentBlobOid = oid;
|
||||
} catch {
|
||||
// file didn't exist in parent → not empty, fall through to commit
|
||||
}
|
||||
if (parentBlobOid && parentBlobOid === workOid) {
|
||||
throw new GitError('commit-empty', `No changes to commit for ${filepath} on ${ref}`, {
|
||||
recoverable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stage the file. isomorphic-git's add reads the working tree file
|
||||
// and writes a blob into objects/, plus updates the index.
|
||||
await git.add({ fs, dir: handle.dir, gitdir: handle.gitdir, filepath });
|
||||
|
||||
// Create the commit. Passing `ref` makes isomorphic-git update that
|
||||
// ref instead of HEAD. Passing `parent` overrides the default
|
||||
// (which would be HEAD's tip). Together this lets us write to e.g.
|
||||
// refs/openpencil/autosaves/main without touching HEAD or refs/heads/main.
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
const hash = await git.commit({
|
||||
fs,
|
||||
dir: handle.dir,
|
||||
gitdir: handle.gitdir,
|
||||
message,
|
||||
ref,
|
||||
parent: parents,
|
||||
author: {
|
||||
name: author.name,
|
||||
email: author.email,
|
||||
timestamp: ts,
|
||||
timezoneOffset: 0,
|
||||
},
|
||||
committer: {
|
||||
name: author.name,
|
||||
email: author.email,
|
||||
timestamp: ts,
|
||||
timezoneOffset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return { hash };
|
||||
} catch (err) {
|
||||
if (err instanceof GitError) throw err;
|
||||
throw new GitError('engine-crash', `commitFile failed for ${filepath} on ${ref}`, {
|
||||
cause: err,
|
||||
detail: { filepath, ref },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the contents of a file at a specific commit. Returns the blob as
|
||||
* a UTF-8 string. Used by `restoreFileFromCommit` and by the engine's
|
||||
* "promote autosave" flow which needs to read the autosave's tree blob.
|
||||
*/
|
||||
export async function readBlobAtCommit(opts: {
|
||||
handle: IsoRepoHandle;
|
||||
filepath: string;
|
||||
commitHash: string;
|
||||
}): Promise<string> {
|
||||
const { handle, filepath, commitHash } = opts;
|
||||
try {
|
||||
const { blob } = await git.readBlob({
|
||||
fs,
|
||||
gitdir: handle.gitdir,
|
||||
oid: commitHash,
|
||||
filepath,
|
||||
});
|
||||
return new TextDecoder('utf-8').decode(blob);
|
||||
} catch (err) {
|
||||
throw new GitError('engine-crash', `readBlobAtCommit failed for ${filepath} at ${commitHash}`, {
|
||||
cause: err,
|
||||
detail: { filepath, commitHash },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk commits from a ref tip in reverse chronological order. Returns up to
|
||||
* `depth` commits, oldest-last. If the ref doesn't exist, returns an empty
|
||||
* array (rather than throwing) — this is convenient for the engine which
|
||||
* may query autosave refs that haven't been created yet.
|
||||
*/
|
||||
export async function logForRef(opts: {
|
||||
handle: IsoRepoHandle;
|
||||
ref: string;
|
||||
depth: number;
|
||||
}): Promise<CommitMetaIso[]> {
|
||||
const { handle, ref, depth } = opts;
|
||||
try {
|
||||
// Check existence first; missing ref → empty log.
|
||||
try {
|
||||
await git.resolveRef({ fs, gitdir: handle.gitdir, ref });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const commits = await git.log({
|
||||
fs,
|
||||
gitdir: handle.gitdir,
|
||||
ref,
|
||||
depth,
|
||||
});
|
||||
|
||||
return commits.map((c) => ({
|
||||
hash: c.oid,
|
||||
parentHashes: c.commit.parent,
|
||||
message: c.commit.message,
|
||||
author: {
|
||||
name: c.commit.author.name,
|
||||
email: c.commit.author.email,
|
||||
timestamp: c.commit.author.timestamp,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
throw new GitError('engine-crash', `logForRef failed for ${ref}`, {
|
||||
cause: err,
|
||||
detail: { ref },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file's contents at a commit and write them to the working tree.
|
||||
* Does NOT create a new commit — that's the caller's responsibility (the
|
||||
* engine's restore flow runs commitFile afterward to record the restore).
|
||||
*
|
||||
* The file is written via Node fs.writeFile, NOT via git checkout, because
|
||||
* checkout would also update the index and we want the working tree to be
|
||||
* dirty after a restore so the user sees pending changes.
|
||||
*/
|
||||
export async function restoreFileFromCommit(opts: {
|
||||
handle: IsoRepoHandle;
|
||||
filepath: string;
|
||||
commitHash: string;
|
||||
}): Promise<void> {
|
||||
const { handle, filepath, commitHash } = opts;
|
||||
try {
|
||||
const content = await readBlobAtCommit({ handle, filepath, commitHash });
|
||||
const absPath = resolve(handle.dir, filepath);
|
||||
await fs.promises.writeFile(absPath, content, 'utf-8');
|
||||
} catch (err) {
|
||||
if (err instanceof GitError) throw err;
|
||||
throw new GitError(
|
||||
'engine-crash',
|
||||
`restoreFileFromCommit failed for ${filepath} at ${commitHash}`,
|
||||
{
|
||||
cause: err,
|
||||
detail: { filepath, commitHash },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List branches under a ref prefix. The prefix is RELATIVE to refs/, so
|
||||
* 'heads' lists refs/heads/*, 'openpencil/autosaves' lists
|
||||
* refs/openpencil/autosaves/*. Returns just the branch names (last segment),
|
||||
* not the full ref paths.
|
||||
*/
|
||||
export async function listBranches(opts: {
|
||||
handle: IsoRepoHandle;
|
||||
prefix?: string;
|
||||
}): Promise<string[]> {
|
||||
const { handle } = opts;
|
||||
const prefix = opts.prefix ?? 'heads';
|
||||
try {
|
||||
if (prefix === 'heads') {
|
||||
// Use isomorphic-git's high-level API for the common case.
|
||||
return await git.listBranches({ fs, gitdir: handle.gitdir });
|
||||
}
|
||||
// Custom prefix: walk the gitdir's refs/<prefix>/ directory directly.
|
||||
const refsRoot = join(handle.gitdir, 'refs', prefix);
|
||||
try {
|
||||
const entries = await fs.promises.readdir(refsRoot, { withFileTypes: true });
|
||||
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof GitError) throw err;
|
||||
throw new GitError('engine-crash', `listBranches failed for prefix ${prefix}`, {
|
||||
cause: err,
|
||||
detail: { prefix },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new branch under refs/heads/. If `fromCommit` is omitted, the
|
||||
* new branch points at the current HEAD. Throws GitError 'branch-exists'
|
||||
* if the name is already taken.
|
||||
*/
|
||||
export async function createBranch(opts: {
|
||||
handle: IsoRepoHandle;
|
||||
name: string;
|
||||
fromCommit?: string;
|
||||
}): Promise<void> {
|
||||
const { handle, name, fromCommit } = opts;
|
||||
try {
|
||||
// Check existence first.
|
||||
const existing = await git.listBranches({ fs, gitdir: handle.gitdir });
|
||||
if (existing.includes(name)) {
|
||||
throw new GitError('branch-exists', `Branch ${name} already exists`);
|
||||
}
|
||||
|
||||
if (fromCommit) {
|
||||
// Write the ref directly to the specified commit.
|
||||
await git.writeRef({
|
||||
fs,
|
||||
gitdir: handle.gitdir,
|
||||
ref: `refs/heads/${name}`,
|
||||
value: fromCommit,
|
||||
force: false,
|
||||
});
|
||||
} else {
|
||||
// Branch from current HEAD via the high-level API.
|
||||
await git.branch({ fs, gitdir: handle.gitdir, ref: name, checkout: false });
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof GitError) throw err;
|
||||
throw new GitError('engine-crash', `createBranch failed for ${name}`, {
|
||||
cause: err,
|
||||
detail: { name, fromCommit },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the branch `name` is reachable from the tip of any OTHER
|
||||
* branch in refs/heads. "Reachable" means either:
|
||||
* - another branch's tip OID equals `name`'s tip (fast-forward case), OR
|
||||
* - another branch descends from `name`'s tip (merged-via-merge-commit).
|
||||
*
|
||||
* The equal-tip short-circuit is load-bearing: isomorphic-git's
|
||||
* `git.isDescendent` explicitly returns false when `oid === ancestor`
|
||||
* (see node_modules/isomorphic-git/index.cjs:12094), so without this
|
||||
* shortcut a branch that was just fast-forward merged (or a branch that was
|
||||
* just created from HEAD and never advanced) would be incorrectly flagged
|
||||
* as unmerged, blocking a legitimate delete.
|
||||
*/
|
||||
async function isBranchMergedAnywhere(opts: {
|
||||
handle: IsoRepoHandle;
|
||||
name: string;
|
||||
}): Promise<boolean> {
|
||||
const { handle, name } = opts;
|
||||
const targetOid = await git.resolveRef({ fs, gitdir: handle.gitdir, ref: name });
|
||||
const branches = await git.listBranches({ fs, gitdir: handle.gitdir });
|
||||
|
||||
for (const candidate of branches) {
|
||||
if (candidate === name) continue;
|
||||
const candidateOid = await git.resolveRef({ fs, gitdir: handle.gitdir, ref: candidate });
|
||||
if (candidateOid === targetOid) return true; // equal tips = merged (fast-forward case)
|
||||
const merged = await git.isDescendent({
|
||||
fs,
|
||||
dir: handle.dir,
|
||||
gitdir: handle.gitdir,
|
||||
oid: candidateOid,
|
||||
ancestor: targetOid,
|
||||
depth: -1,
|
||||
});
|
||||
if (merged) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a branch under refs/heads/. Throws GitError 'branch-current' if
|
||||
* the branch is the active HEAD. Throws GitError 'branch-unmerged' if the
|
||||
* branch's tip is not reachable from any other branch, unless `force` is
|
||||
* set — in which case the branch is deleted unconditionally.
|
||||
*
|
||||
* Note: `isomorphic-git`'s `deleteBranch` has no `force` option of its own,
|
||||
* so we implement the mergedness check ourselves above the low-level call.
|
||||
*/
|
||||
export async function deleteBranch(opts: {
|
||||
handle: IsoRepoHandle;
|
||||
name: string;
|
||||
force?: boolean;
|
||||
}): Promise<void> {
|
||||
const { handle, name, force = false } = opts;
|
||||
try {
|
||||
const current = await getCurrentBranch({ handle });
|
||||
if (current === name) {
|
||||
throw new GitError('branch-current', `Cannot delete the current branch ${name}`);
|
||||
}
|
||||
if (!force) {
|
||||
const merged = await isBranchMergedAnywhere({ handle, name });
|
||||
if (!merged) {
|
||||
throw new GitError('branch-unmerged', `Branch ${name} has unmerged commits`, {
|
||||
detail: { name },
|
||||
});
|
||||
}
|
||||
}
|
||||
await git.deleteBranch({ fs, gitdir: handle.gitdir, ref: name });
|
||||
} catch (err) {
|
||||
if (err instanceof GitError) throw err;
|
||||
throw new GitError('engine-crash', `deleteBranch failed for ${name}`, {
|
||||
cause: err,
|
||||
detail: { name, force },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch HEAD to a different branch and update the working tree's tracked
|
||||
* file to that branch's tip. Uses filepaths-scoped checkout so other files
|
||||
* in the worktree are untouched.
|
||||
*/
|
||||
export async function switchBranch(opts: {
|
||||
handle: IsoRepoHandle;
|
||||
name: string;
|
||||
filepath: string;
|
||||
}): Promise<void> {
|
||||
const { handle, name, filepath } = opts;
|
||||
try {
|
||||
await git.checkout({
|
||||
fs,
|
||||
dir: handle.dir,
|
||||
gitdir: handle.gitdir,
|
||||
ref: name,
|
||||
filepaths: [filepath],
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new GitError('engine-crash', `switchBranch failed for ${name}`, {
|
||||
cause: err,
|
||||
detail: { name, filepath },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current branch name (without the 'refs/heads/' prefix), or
|
||||
* null if HEAD is detached.
|
||||
*/
|
||||
export async function getCurrentBranch(opts: { handle: IsoRepoHandle }): Promise<string | null> {
|
||||
const { handle } = opts;
|
||||
try {
|
||||
const branch = await git.currentBranch({ fs, gitdir: handle.gitdir, fullname: false });
|
||||
return branch ?? null;
|
||||
} catch (err) {
|
||||
throw new GitError('engine-crash', `getCurrentBranch failed`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a ref to point at the given commit hash. Creates the ref if it does
|
||||
* not exist. Used by the engine's milestone commit flow to jump the
|
||||
* `refs/openpencil/autosaves/<branch>` ref to a freshly written milestone,
|
||||
* abandoning the intermediate autosave chain.
|
||||
*/
|
||||
export async function setRef(opts: {
|
||||
handle: IsoRepoHandle;
|
||||
ref: string;
|
||||
value: string;
|
||||
}): Promise<void> {
|
||||
const { handle, ref, value } = opts;
|
||||
try {
|
||||
await git.writeRef({
|
||||
fs,
|
||||
gitdir: handle.gitdir,
|
||||
ref,
|
||||
value,
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new GitError('engine-crash', `setRef failed for ${ref}`, {
|
||||
cause: err,
|
||||
detail: { ref, value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the blob OID for `filepath` at the tip of `ref`. Returns null if
|
||||
* the ref doesn't exist OR the file isn't present in that commit's tree.
|
||||
* Never throws — used by workingDirty detection where missing refs/files are
|
||||
* a normal case (fresh repo with no commits).
|
||||
*/
|
||||
export async function readBlobOidAt(opts: {
|
||||
handle: IsoRepoHandle;
|
||||
ref: string;
|
||||
filepath: string;
|
||||
}): Promise<string | null> {
|
||||
const { handle, ref, filepath } = opts;
|
||||
let tip: string;
|
||||
try {
|
||||
tip = await git.resolveRef({ fs, gitdir: handle.gitdir, ref });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const { oid } = await git.readBlob({
|
||||
fs,
|
||||
gitdir: handle.gitdir,
|
||||
oid: tip,
|
||||
filepath,
|
||||
});
|
||||
return oid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 6a: write the single 'origin' remote in `.git/config`.
|
||||
*
|
||||
* - non-empty `url` → `git.addRemote({ remote: 'origin', url, force: true })`
|
||||
* The `force: true` flag makes this an upsert (add OR update). isomorphic-git
|
||||
* itself rejects an existing remote without `force`.
|
||||
* - `null` url → `git.deleteRemote({ remote: 'origin' })`. Naturally
|
||||
* idempotent: isomorphic-git's `deleteRemote` is implemented as
|
||||
* `GitConfigManager.deleteSection('remote', 'origin')`, which is a
|
||||
* filter over parsed config entries and never throws when the section
|
||||
* is absent. We therefore do NOT wrap it in a try/catch — the only
|
||||
* failure modes are real I/O errors (EACCES, ENOSPC, etc.) from
|
||||
* `GitConfigManager.save`, and those must propagate so the UI can
|
||||
* surface them instead of silently claiming "origin removed" while
|
||||
* `.git/config` still holds the old url.
|
||||
*
|
||||
* This is a LOCAL config mutation only — never opens a network socket. Lives
|
||||
* in git-iso.ts (not git-sys.ts) per the Phase 6a plan: there is no transport
|
||||
* decision to make here, so the dispatch helper in git-engine.ts isn't needed.
|
||||
*/
|
||||
export async function writeRemoteOrigin(opts: {
|
||||
handle: IsoRepoHandle;
|
||||
url: string | null;
|
||||
}): Promise<void> {
|
||||
const { handle, url } = opts;
|
||||
try {
|
||||
if (url === null) {
|
||||
await git.deleteRemote({ fs, gitdir: handle.gitdir, remote: 'origin' });
|
||||
return;
|
||||
}
|
||||
await git.addRemote({
|
||||
fs,
|
||||
gitdir: handle.gitdir,
|
||||
remote: 'origin',
|
||||
url,
|
||||
force: true, // upsert
|
||||
});
|
||||
} catch (err) {
|
||||
throw new GitError('engine-crash', `writeRemoteOrigin failed`, {
|
||||
cause: err,
|
||||
detail: { url },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the merge base (common ancestor) of two commits. Used by the merge
|
||||
* orchestrator to load the `base` document for 3-way merge.
|
||||
*
|
||||
* Throws GitError 'engine-crash' if no merge base exists (unrelated
|
||||
* histories) — Phase 2c does not support merging unrelated histories.
|
||||
*/
|
||||
export async function findMergeBase(opts: {
|
||||
handle: IsoRepoHandle;
|
||||
oid1: string;
|
||||
oid2: string;
|
||||
}): Promise<string> {
|
||||
const { handle, oid1, oid2 } = opts;
|
||||
try {
|
||||
const oids = await git.findMergeBase({
|
||||
fs,
|
||||
gitdir: handle.gitdir,
|
||||
oids: [oid1, oid2],
|
||||
});
|
||||
if (!oids || oids.length === 0) {
|
||||
throw new GitError(
|
||||
'engine-crash',
|
||||
`No merge base for ${oid1} and ${oid2} (unrelated histories)`,
|
||||
);
|
||||
}
|
||||
return oids[0];
|
||||
} catch (err) {
|
||||
if (err instanceof GitError) throw err;
|
||||
throw new GitError('engine-crash', `findMergeBase failed`, {
|
||||
cause: err,
|
||||
detail: { oid1, oid2 },
|
||||
});
|
||||
}
|
||||
}
|
||||
294
apps/desktop/git/git-sys.ts
Normal file
294
apps/desktop/git/git-sys.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
// apps/desktop/git/git-sys.ts
|
||||
//
|
||||
// System git wrapper. Phase 2b makes this real: clone/fetch/push/pull-FF
|
||||
// run via execFile, returning typed results or throwing GitError on
|
||||
// recognized failure modes.
|
||||
//
|
||||
// All ops accept an optional env map so SSH callers can set
|
||||
// GIT_SSH_COMMAND='ssh -i <keyPath> -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new'
|
||||
// without affecting the parent process env.
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { GitError, type GitErrorCode } from './error';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
let cached: boolean | undefined;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
|
||||
export async function isSystemGitAvailable(): Promise<boolean> {
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
await execFileAsync('git', ['--version'], { timeout: 5000 });
|
||||
cached = true;
|
||||
} catch {
|
||||
cached = false;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function __resetSystemGitCache(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
|
||||
interface RunOpts {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface RunResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `git <args>` with the given env and cwd. On failure, maps stderr to a
|
||||
* GitError code and throws. Used by all the higher-level fns below.
|
||||
*/
|
||||
async function runGit(args: string[], opts: RunOpts = {}): Promise<RunResult> {
|
||||
const env = { ...process.env, ...opts.env };
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('git', args, {
|
||||
cwd: opts.cwd,
|
||||
env,
|
||||
timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
return { stdout, stderr };
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string };
|
||||
const stderr = e.stderr ?? '';
|
||||
const code = mapSysError(stderr, e);
|
||||
throw new GitError(code, `git ${args.join(' ')} failed: ${stderr.trim() || e.message}`, {
|
||||
cause: err,
|
||||
detail: { args, stderr },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map stderr text from a failed `git` invocation to a GitError code.
|
||||
* Patterns are intentionally simple substring checks — git's error messages
|
||||
* are not localized and have been stable for many years.
|
||||
*/
|
||||
export function mapSysError(stderr: string, err?: { message?: string }): GitErrorCode {
|
||||
const s = stderr.toLowerCase();
|
||||
const msg = (err?.message ?? '').toLowerCase();
|
||||
|
||||
// Auth failures
|
||||
if (
|
||||
s.includes('authentication failed') ||
|
||||
s.includes('could not read username') ||
|
||||
s.includes('permission denied (publickey)') ||
|
||||
s.includes('access denied')
|
||||
) {
|
||||
return 'auth-failed';
|
||||
}
|
||||
|
||||
// Repository missing
|
||||
if (s.includes('repository not found') || s.includes('does not exist')) {
|
||||
return 'clone-failed';
|
||||
}
|
||||
|
||||
// Clone target already exists
|
||||
if (s.includes('already exists and is not an empty directory')) {
|
||||
return 'clone-target-exists';
|
||||
}
|
||||
|
||||
// Network failures
|
||||
if (
|
||||
s.includes("couldn't resolve host") ||
|
||||
s.includes('could not resolve hostname') ||
|
||||
s.includes('connection refused') ||
|
||||
s.includes('no route to host')
|
||||
) {
|
||||
return 'network';
|
||||
}
|
||||
|
||||
// Timeouts (both git's own and execFile's)
|
||||
if (
|
||||
s.includes('connection timed out') ||
|
||||
s.includes('operation timed out') ||
|
||||
msg.includes('etimedout')
|
||||
) {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
// Push rejected (non-FF)
|
||||
if (s.includes('updates were rejected') || s.includes('non-fast-forward')) {
|
||||
return 'push-rejected';
|
||||
}
|
||||
|
||||
// Pull non-FF (we run --ff-only so any divergence yields this)
|
||||
if (s.includes('not possible to fast-forward')) {
|
||||
return 'pull-non-fast-forward';
|
||||
}
|
||||
|
||||
// Not a repo
|
||||
if (s.includes('not a git repository') || s.includes('does not appear to be a git repository')) {
|
||||
return 'not-a-repo';
|
||||
}
|
||||
|
||||
return 'engine-crash';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public ops
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function sysClone(opts: {
|
||||
url: string;
|
||||
dest: string;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
await runGit(['clone', opts.url, opts.dest], {
|
||||
env: opts.env,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sysFetch(opts: {
|
||||
cwd: string;
|
||||
remote?: string;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const remote = opts.remote ?? 'origin';
|
||||
await runGit(['fetch', remote], {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sysPullFastForward(opts: {
|
||||
cwd: string;
|
||||
remote?: string;
|
||||
branch: string;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ result: 'fast-forward' | 'up-to-date' }> {
|
||||
const remote = opts.remote ?? 'origin';
|
||||
// --ff-only refuses to merge; if the remote diverged, throws pull-non-fast-forward.
|
||||
const { stdout } = await runGit(['pull', '--ff-only', remote, opts.branch], {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
if (stdout.includes('Already up to date')) return { result: 'up-to-date' };
|
||||
return { result: 'fast-forward' };
|
||||
}
|
||||
|
||||
export async function sysPush(opts: {
|
||||
cwd: string;
|
||||
remote?: string;
|
||||
branch: string;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const remote = opts.remote ?? 'origin';
|
||||
await runGit(['push', remote, opts.branch], {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute ahead/behind counts for the current branch vs `<remote>/<branch>`.
|
||||
* Returns 0/0 if no remote-tracking ref exists for the branch.
|
||||
*/
|
||||
export async function sysAheadBehind(opts: {
|
||||
cwd: string;
|
||||
branch: string;
|
||||
remote?: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<{ ahead: number; behind: number }> {
|
||||
const remote = opts.remote ?? 'origin';
|
||||
try {
|
||||
const { stdout } = await runGit(
|
||||
['rev-list', '--left-right', '--count', `${opts.branch}...${remote}/${opts.branch}`],
|
||||
{ cwd: opts.cwd, env: opts.env },
|
||||
);
|
||||
// Output format: "<ahead>\t<behind>\n"
|
||||
const [aheadStr, behindStr] = stdout.trim().split(/\s+/);
|
||||
return {
|
||||
ahead: parseInt(aheadStr, 10) || 0,
|
||||
behind: parseInt(behindStr, 10) || 0,
|
||||
};
|
||||
} catch (err) {
|
||||
// No remote tracking ref → not an error, just zeros.
|
||||
if (err instanceof GitError && err.code === 'engine-crash') {
|
||||
return { ahead: 0, behind: 0 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executor shape used by getSystemAuthor's test seam. Mirrors runGit's return
|
||||
* type so tests can inject a fake without pulling in node:child_process.
|
||||
*/
|
||||
type RunGitExec = (args: string[]) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
/**
|
||||
* Read `user.name` / `user.email` from the system git config. Used by Phase 4a
|
||||
* as step 2 of the author identity lookup chain (prefs → sysGit → form).
|
||||
*
|
||||
* The optional `injectedExec` parameter is a TEST SEAM: production callers pass
|
||||
* no args and get the real `runGit`-backed path, while tests inject a fake to
|
||||
* deterministically exercise both the success and null branches without
|
||||
* depending on the host machine having a configured global git identity.
|
||||
*
|
||||
* Returns null on:
|
||||
* - system git not available (when not using injected exec)
|
||||
* - either config value missing / empty / whitespace-only
|
||||
* - git exec throwing (e.g. key not set, which git reports as exit 1)
|
||||
* The catch block intentionally swallows errors: "no identity" is a normal
|
||||
* state, not an operational failure, and the caller treats null as "fall
|
||||
* through to the next step in the chain".
|
||||
*/
|
||||
export async function getSystemAuthor(
|
||||
injectedExec?: RunGitExec,
|
||||
): Promise<{ name: string; email: string } | null> {
|
||||
if (!injectedExec && !(await isSystemGitAvailable())) return null;
|
||||
|
||||
const exec: RunGitExec = injectedExec ?? ((args) => runGit(args, { timeoutMs: 5000 }));
|
||||
|
||||
try {
|
||||
const nameResult = await exec(['config', '--get', 'user.name']);
|
||||
const emailResult = await exec(['config', '--get', 'user.email']);
|
||||
const name = nameResult.stdout.trim();
|
||||
const email = emailResult.stdout.trim();
|
||||
if (!name || !email) return null;
|
||||
return { name, email };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the GIT_SSH_COMMAND env value for an SSH key file. Used by the
|
||||
* engine before invoking sysClone/Fetch/Pull/Push with auth.kind === 'ssh'.
|
||||
*/
|
||||
export function buildSshCommand(privateKeyPath: string): string {
|
||||
// -i: identity file
|
||||
// -o IdentitiesOnly=yes: don't try ssh-agent identities (avoid prompting)
|
||||
// -o StrictHostKeyChecking=accept-new: trust on first use, verify thereafter
|
||||
// -o BatchMode=yes: never prompt; fail fast on missing creds
|
||||
return [
|
||||
'ssh',
|
||||
'-i',
|
||||
JSON.stringify(privateKeyPath),
|
||||
'-o',
|
||||
'IdentitiesOnly=yes',
|
||||
'-o',
|
||||
'StrictHostKeyChecking=accept-new',
|
||||
'-o',
|
||||
'BatchMode=yes',
|
||||
].join(' ');
|
||||
}
|
||||
354
apps/desktop/git/ipc-handlers.ts
Normal file
354
apps/desktop/git/ipc-handlers.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
// apps/desktop/git/ipc-handlers.ts
|
||||
//
|
||||
// Thin shim: each handler is a one-line forward to a git-engine function.
|
||||
// Tests call gitIpcHandlers directly. setupGitIPC wires them onto ipcMain.
|
||||
//
|
||||
// Error serialization: Electron's structured-clone drops custom Error
|
||||
// subclasses (only `message` survives reliably). We tag GitError instances
|
||||
// by stuffing { __gitError, code, message, recoverable } into the message
|
||||
// field as JSON, prefixed with a marker the renderer detects on receive.
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import { GitError, isGitError } from './error';
|
||||
import {
|
||||
engineDetect,
|
||||
engineInit,
|
||||
engineOpen,
|
||||
engineBindTrackedFile,
|
||||
engineListCandidates,
|
||||
engineClose,
|
||||
engineStatus,
|
||||
engineLog,
|
||||
engineCommit,
|
||||
engineRestore,
|
||||
enginePromote,
|
||||
engineBranchList,
|
||||
engineBranchCreate,
|
||||
engineBranchSwitch,
|
||||
engineBranchDelete,
|
||||
engineClone,
|
||||
engineFetch,
|
||||
enginePull,
|
||||
enginePush,
|
||||
engineDiff,
|
||||
engineBranchMerge,
|
||||
engineResolveConflict,
|
||||
engineApplyMerge,
|
||||
engineAbortMerge,
|
||||
engineRemoteGet,
|
||||
engineRemoteSet,
|
||||
setSshKeyManager,
|
||||
setAuthStore,
|
||||
} from './git-engine';
|
||||
import type { ConflictResolution } from './merge-session';
|
||||
import { createDefaultAuthStore, type AuthCreds, type AuthStore } from './auth-store';
|
||||
import { createDefaultSshKeyManager, type SshKeyInfo, type SshKeyManager } from './ssh-keys';
|
||||
import { getSystemAuthor as sysGetSystemAuthor } from './git-sys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level singletons assigned by setupGitIPC at boot. We require Electron
|
||||
// to be ready (app.whenReady()) before instantiating because both auth-store
|
||||
// and ssh-keys lazy-import electron's `app` and `safeStorage`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let authStore: AuthStore | null = null;
|
||||
let sshKeyManager: SshKeyManager | null = null;
|
||||
|
||||
function requireAuthStore(): AuthStore {
|
||||
if (!authStore) throw new Error('auth store not initialized; call setupGitIPC() first');
|
||||
return authStore;
|
||||
}
|
||||
|
||||
function requireSshKeyManager(): SshKeyManager {
|
||||
if (!sshKeyManager) throw new Error('ssh key manager not initialized; call setupGitIPC() first');
|
||||
return sshKeyManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the on-disk private key path before returning SSH key info to the
|
||||
* renderer. The path is backend-only — the renderer never needs it because
|
||||
* SSH transport is invoked via git-sys with GIT_SSH_COMMAND, never via the
|
||||
* renderer-side filesystem.
|
||||
*/
|
||||
type PublicSshKeyInfo = Omit<SshKeyInfo, 'privateKeyPath'>;
|
||||
|
||||
function stripPrivatePath(info: SshKeyInfo): PublicSshKeyInfo {
|
||||
const { privateKeyPath: _omit, ...rest } = info;
|
||||
void _omit;
|
||||
return rest;
|
||||
}
|
||||
|
||||
const GIT_ERROR_MARKER = '__GIT_ERROR__';
|
||||
|
||||
/**
|
||||
* Serialize a GitError into an Error whose message is JSON-encoded with the
|
||||
* GIT_ERROR_MARKER prefix. The renderer's git-client (Phase 3) detects the
|
||||
* marker and rehydrates a GitError on its side.
|
||||
*/
|
||||
export function serializeGitError(err: GitError): Error {
|
||||
const payload = {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
recoverable: err.recoverable,
|
||||
};
|
||||
return new Error(`${GIT_ERROR_MARKER}${JSON.stringify(payload)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a handler so any thrown GitError becomes a serialized Error suitable
|
||||
* for crossing the IPC boundary. Other errors propagate as-is (their
|
||||
* `message` survives clone but the stack/type does not).
|
||||
*/
|
||||
async function runHandler<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (isGitError(err)) {
|
||||
throw serializeGitError(err as GitError);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Direct handler functions — exported so tests can call them without ipcMain.
|
||||
// Each is a one-line forward to engineX(). Argument shapes mirror the IPC
|
||||
// contract section of the spec.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const gitIpcHandlers = {
|
||||
detect: (filePath: string) => engineDetect(filePath),
|
||||
init: (filePath: string) => engineInit(filePath),
|
||||
open: (repoPath: string, currentFilePath?: string) => engineOpen(repoPath, currentFilePath),
|
||||
bindTrackedFile: (repoId: string, filePath: string) => engineBindTrackedFile(repoId, filePath),
|
||||
listCandidates: (repoId: string) => engineListCandidates(repoId),
|
||||
close: (repoId: string) => {
|
||||
engineClose(repoId);
|
||||
return Promise.resolve();
|
||||
},
|
||||
status: (repoId: string) => engineStatus(repoId),
|
||||
log: (repoId: string, opts: { ref: 'main' | 'autosaves' | string; limit: number }) =>
|
||||
engineLog(repoId, opts),
|
||||
commit: (
|
||||
repoId: string,
|
||||
opts: {
|
||||
kind: 'milestone' | 'autosave';
|
||||
message: string;
|
||||
author: { name: string; email: string };
|
||||
},
|
||||
) => engineCommit(repoId, opts),
|
||||
restore: (repoId: string, commitHash: string) => engineRestore(repoId, commitHash),
|
||||
promote: (
|
||||
repoId: string,
|
||||
autosaveHash: string,
|
||||
message: string,
|
||||
author: { name: string; email: string },
|
||||
) => enginePromote(repoId, autosaveHash, message, author),
|
||||
branchList: (repoId: string) => engineBranchList(repoId),
|
||||
branchCreate: (repoId: string, opts: { name: string; fromCommit?: string }) =>
|
||||
engineBranchCreate(repoId, opts),
|
||||
branchSwitch: (repoId: string, name: string) => engineBranchSwitch(repoId, name),
|
||||
branchDelete: (repoId: string, name: string, opts?: { force?: boolean }) =>
|
||||
engineBranchDelete(repoId, name, opts),
|
||||
|
||||
// Phase 2b: remote ops
|
||||
clone: (opts: { url: string; dest: string; auth?: AuthCreds }) => engineClone(opts),
|
||||
fetch: (repoId: string, auth?: AuthCreds) => engineFetch(repoId, auth),
|
||||
pull: (repoId: string, auth?: AuthCreds) => enginePull(repoId, auth),
|
||||
push: (repoId: string, auth?: AuthCreds) => enginePush(repoId, auth),
|
||||
|
||||
// Phase 6a: remote metadata + config (no network)
|
||||
remoteGet: (repoId: string) => engineRemoteGet(repoId),
|
||||
remoteSet: (repoId: string, url: string | null) => engineRemoteSet(repoId, url),
|
||||
|
||||
// Phase 2b: auth
|
||||
authStore: (host: string, creds: AuthCreds) => requireAuthStore().set(host, creds),
|
||||
authGet: (host: string) => requireAuthStore().get(host),
|
||||
authClear: (host: string) => requireAuthStore().clear(host),
|
||||
|
||||
// Phase 2b: ssh keys (privateKeyPath stripped before crossing IPC)
|
||||
sshListKeys: async (): Promise<PublicSshKeyInfo[]> => {
|
||||
const all = await requireSshKeyManager().list();
|
||||
return all.map(stripPrivatePath);
|
||||
},
|
||||
sshGenerateKey: async (opts: { host: string; comment: string }): Promise<PublicSshKeyInfo> => {
|
||||
const info = await requireSshKeyManager().generate(opts);
|
||||
return stripPrivatePath(info);
|
||||
},
|
||||
sshImportKey: async (opts: {
|
||||
privateKeyPath: string;
|
||||
host: string;
|
||||
}): Promise<PublicSshKeyInfo> => {
|
||||
const info = await requireSshKeyManager().import(opts);
|
||||
return stripPrivatePath(info);
|
||||
},
|
||||
sshDeleteKey: (keyId: string) => requireSshKeyManager().delete(keyId),
|
||||
|
||||
// Phase 2c: merge orchestration
|
||||
diff: (repoId: string, fromCommit: string, toCommit: string) =>
|
||||
engineDiff(repoId, fromCommit, toCommit),
|
||||
branchMerge: (repoId: string, fromBranch: string) => engineBranchMerge(repoId, fromBranch),
|
||||
resolveConflict: (repoId: string, conflictId: string, choice: ConflictResolution) =>
|
||||
engineResolveConflict(repoId, conflictId, choice),
|
||||
applyMerge: (repoId: string) => engineApplyMerge(repoId),
|
||||
abortMerge: (repoId: string) => engineAbortMerge(repoId),
|
||||
|
||||
// Phase 4a: author identity probe (system git config)
|
||||
getSystemAuthor: () => sysGetSystemAuthor(),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ipcMain registration. Each channel is registered exactly once. Calling
|
||||
// setupGitIPC twice would throw on the second call (ipcMain.handle rejects
|
||||
// duplicate channel names) — main.ts must ensure single invocation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setupGitIPC(): void {
|
||||
// Lazy-instantiate the auth/ssh singletons. These pull in Electron, so they
|
||||
// must run AFTER app.whenReady() (which is when main.ts calls setupGitIPC).
|
||||
authStore = createDefaultAuthStore();
|
||||
sshKeyManager = createDefaultSshKeyManager();
|
||||
// Inject both into the engine so engineClone/Fetch/Pull/Push can:
|
||||
// - look up stored host credentials when no explicit auth was passed
|
||||
// - resolve SSH keyIds to private key file paths
|
||||
setAuthStore(authStore);
|
||||
setSshKeyManager(sshKeyManager);
|
||||
|
||||
ipcMain.handle('git:detect', (_e, filePath: string) =>
|
||||
runHandler(() => gitIpcHandlers.detect(filePath)),
|
||||
);
|
||||
ipcMain.handle('git:init', (_e, filePath: string) =>
|
||||
runHandler(() => gitIpcHandlers.init(filePath)),
|
||||
);
|
||||
ipcMain.handle('git:open', (_e, repoPath: string, currentFilePath?: string) =>
|
||||
runHandler(() => gitIpcHandlers.open(repoPath, currentFilePath)),
|
||||
);
|
||||
ipcMain.handle('git:bindTrackedFile', (_e, repoId: string, filePath: string) =>
|
||||
runHandler(() => gitIpcHandlers.bindTrackedFile(repoId, filePath)),
|
||||
);
|
||||
ipcMain.handle('git:listCandidates', (_e, repoId: string) =>
|
||||
runHandler(() => gitIpcHandlers.listCandidates(repoId)),
|
||||
);
|
||||
ipcMain.handle('git:close', (_e, repoId: string) =>
|
||||
runHandler(() => gitIpcHandlers.close(repoId)),
|
||||
);
|
||||
ipcMain.handle('git:status', (_e, repoId: string) =>
|
||||
runHandler(() => gitIpcHandlers.status(repoId)),
|
||||
);
|
||||
ipcMain.handle(
|
||||
'git:log',
|
||||
(_e, repoId: string, opts: { ref: 'main' | 'autosaves' | string; limit: number }) =>
|
||||
runHandler(() => gitIpcHandlers.log(repoId, opts)),
|
||||
);
|
||||
ipcMain.handle(
|
||||
'git:commit',
|
||||
(
|
||||
_e,
|
||||
repoId: string,
|
||||
opts: {
|
||||
kind: 'milestone' | 'autosave';
|
||||
message: string;
|
||||
author: { name: string; email: string };
|
||||
},
|
||||
) => runHandler(() => gitIpcHandlers.commit(repoId, opts)),
|
||||
);
|
||||
ipcMain.handle('git:restore', (_e, repoId: string, commitHash: string) =>
|
||||
runHandler(() => gitIpcHandlers.restore(repoId, commitHash)),
|
||||
);
|
||||
ipcMain.handle(
|
||||
'git:promote',
|
||||
(
|
||||
_e,
|
||||
repoId: string,
|
||||
autosaveHash: string,
|
||||
message: string,
|
||||
author: { name: string; email: string },
|
||||
) => runHandler(() => gitIpcHandlers.promote(repoId, autosaveHash, message, author)),
|
||||
);
|
||||
ipcMain.handle('git:branchList', (_e, repoId: string) =>
|
||||
runHandler(() => gitIpcHandlers.branchList(repoId)),
|
||||
);
|
||||
ipcMain.handle(
|
||||
'git:branchCreate',
|
||||
(_e, repoId: string, opts: { name: string; fromCommit?: string }) =>
|
||||
runHandler(() => gitIpcHandlers.branchCreate(repoId, opts)),
|
||||
);
|
||||
ipcMain.handle('git:branchSwitch', (_e, repoId: string, name: string) =>
|
||||
runHandler(() => gitIpcHandlers.branchSwitch(repoId, name)),
|
||||
);
|
||||
ipcMain.handle(
|
||||
'git:branchDelete',
|
||||
(_e, repoId: string, name: string, opts?: { force?: boolean }) =>
|
||||
runHandler(() => gitIpcHandlers.branchDelete(repoId, name, opts)),
|
||||
);
|
||||
|
||||
// ---- Phase 2b: remote ops ------------------------------------------------
|
||||
ipcMain.handle('git:clone', (_e, opts: { url: string; dest: string; auth?: AuthCreds }) =>
|
||||
runHandler(() => gitIpcHandlers.clone(opts)),
|
||||
);
|
||||
ipcMain.handle('git:fetch', (_e, repoId: string, auth?: AuthCreds) =>
|
||||
runHandler(() => gitIpcHandlers.fetch(repoId, auth)),
|
||||
);
|
||||
ipcMain.handle('git:pull', (_e, repoId: string, auth?: AuthCreds) =>
|
||||
runHandler(() => gitIpcHandlers.pull(repoId, auth)),
|
||||
);
|
||||
ipcMain.handle('git:push', (_e, repoId: string, auth?: AuthCreds) =>
|
||||
runHandler(() => gitIpcHandlers.push(repoId, auth)),
|
||||
);
|
||||
|
||||
// ---- Phase 6a: remote metadata + config ---------------------------------
|
||||
ipcMain.handle('git:remoteGet', (_e, repoId: string) =>
|
||||
runHandler(() => gitIpcHandlers.remoteGet(repoId)),
|
||||
);
|
||||
ipcMain.handle('git:remoteSet', (_e, repoId: string, url: string | null) =>
|
||||
runHandler(() => gitIpcHandlers.remoteSet(repoId, url)),
|
||||
);
|
||||
|
||||
// ---- Phase 2b: auth ------------------------------------------------------
|
||||
ipcMain.handle('git:authStore', (_e, host: string, creds: AuthCreds) =>
|
||||
runHandler(() => gitIpcHandlers.authStore(host, creds)),
|
||||
);
|
||||
ipcMain.handle('git:authGet', (_e, host: string) =>
|
||||
runHandler(() => gitIpcHandlers.authGet(host)),
|
||||
);
|
||||
ipcMain.handle('git:authClear', (_e, host: string) =>
|
||||
runHandler(() => gitIpcHandlers.authClear(host)),
|
||||
);
|
||||
|
||||
// ---- Phase 2b: ssh keys --------------------------------------------------
|
||||
ipcMain.handle('git:sshListKeys', () => runHandler(() => gitIpcHandlers.sshListKeys()));
|
||||
ipcMain.handle('git:sshGenerateKey', (_e, opts: { host: string; comment: string }) =>
|
||||
runHandler(() => gitIpcHandlers.sshGenerateKey(opts)),
|
||||
);
|
||||
ipcMain.handle('git:sshImportKey', (_e, opts: { privateKeyPath: string; host: string }) =>
|
||||
runHandler(() => gitIpcHandlers.sshImportKey(opts)),
|
||||
);
|
||||
ipcMain.handle('git:sshDeleteKey', (_e, keyId: string) =>
|
||||
runHandler(() => gitIpcHandlers.sshDeleteKey(keyId)),
|
||||
);
|
||||
|
||||
// ---- Phase 2c: merge orchestration --------------------------------------
|
||||
ipcMain.handle('git:diff', (_e, repoId: string, fromCommit: string, toCommit: string) =>
|
||||
runHandler(() => gitIpcHandlers.diff(repoId, fromCommit, toCommit)),
|
||||
);
|
||||
ipcMain.handle('git:branchMerge', (_e, repoId: string, fromBranch: string) =>
|
||||
runHandler(() => gitIpcHandlers.branchMerge(repoId, fromBranch)),
|
||||
);
|
||||
ipcMain.handle(
|
||||
'git:resolveConflict',
|
||||
(_e, repoId: string, conflictId: string, choice: ConflictResolution) =>
|
||||
runHandler(() => gitIpcHandlers.resolveConflict(repoId, conflictId, choice)),
|
||||
);
|
||||
ipcMain.handle('git:applyMerge', (_e, repoId: string) =>
|
||||
runHandler(() => gitIpcHandlers.applyMerge(repoId)),
|
||||
);
|
||||
ipcMain.handle('git:abortMerge', (_e, repoId: string) =>
|
||||
runHandler(() => gitIpcHandlers.abortMerge(repoId)),
|
||||
);
|
||||
|
||||
// Phase 4a: author identity probe
|
||||
ipcMain.handle('git:getSystemAuthor', () => runHandler(() => gitIpcHandlers.getSystemAuthor()));
|
||||
}
|
||||
|
||||
/** Exposed for the renderer-side rehydrator (Phase 3). Tests use it too. */
|
||||
export { GIT_ERROR_MARKER };
|
||||
273
apps/desktop/git/merge-orchestrator.ts
Normal file
273
apps/desktop/git/merge-orchestrator.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
// apps/desktop/git/merge-orchestrator.ts
|
||||
//
|
||||
// Single-file mode merge orchestration. Loads three PenDocument blobs from
|
||||
// git, calls pen-core's mergeDocuments, and produces the wire-format
|
||||
// ConflictBag with stable ids. Also applies user resolutions to a merged
|
||||
// document during applyMerge.
|
||||
|
||||
import {
|
||||
mergeDocuments,
|
||||
type MergeResult,
|
||||
type NodeConflict,
|
||||
type DocFieldConflict,
|
||||
} from '@zseven-w/pen-core';
|
||||
import type { PenDocument, PenNode } from '@zseven-w/pen-types';
|
||||
|
||||
import { GitError } from './error';
|
||||
import { readBlobAtCommit, type IsoRepoHandle } from './git-iso';
|
||||
import {
|
||||
buildConflictBag,
|
||||
parseConflictId,
|
||||
type ConflictBag,
|
||||
type ConflictResolution,
|
||||
} from './merge-session';
|
||||
|
||||
/**
|
||||
* Load the tracked file's content at three commits, JSON.parse, and run the
|
||||
* pen-core merge. Returns BOTH the raw MergeResult (so the caller can stash
|
||||
* it for later applyResolutions) AND the wire-format ConflictBag (so the
|
||||
* caller can return it across IPC immediately).
|
||||
*/
|
||||
export async function runMerge(opts: {
|
||||
handle: IsoRepoHandle;
|
||||
filepath: string;
|
||||
oursCommit: string;
|
||||
theirsCommit: string;
|
||||
baseCommit: string;
|
||||
}): Promise<{
|
||||
result: MergeResult;
|
||||
bag: ConflictBag;
|
||||
conflictMap: Map<string, NodeConflict | DocFieldConflict>;
|
||||
}> {
|
||||
const { handle, filepath, oursCommit, theirsCommit, baseCommit } = opts;
|
||||
|
||||
const [oursStr, theirsStr, baseStr] = await Promise.all([
|
||||
readBlobAtCommit({ handle, filepath, commitHash: oursCommit }),
|
||||
readBlobAtCommit({ handle, filepath, commitHash: theirsCommit }),
|
||||
readBlobAtCommit({ handle, filepath, commitHash: baseCommit }),
|
||||
]);
|
||||
|
||||
let ours: PenDocument;
|
||||
let theirs: PenDocument;
|
||||
let base: PenDocument;
|
||||
try {
|
||||
ours = JSON.parse(oursStr) as PenDocument;
|
||||
theirs = JSON.parse(theirsStr) as PenDocument;
|
||||
base = JSON.parse(baseStr) as PenDocument;
|
||||
} catch (err) {
|
||||
throw new GitError('engine-crash', `Failed to parse PenDocument blobs for merge`, {
|
||||
cause: err,
|
||||
detail: { filepath, oursCommit, theirsCommit, baseCommit },
|
||||
});
|
||||
}
|
||||
|
||||
const result = mergeDocuments({ base, ours, theirs });
|
||||
const { bag, conflictMap } = buildConflictBag(result);
|
||||
return { result, bag, conflictMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the user's conflict resolutions to a merged document. Returns a new
|
||||
* PenDocument with the chosen versions substituted in. Does NOT mutate the
|
||||
* input merged document.
|
||||
*
|
||||
* Resolution semantics:
|
||||
* - 'ours' → leave the merged tree unchanged at that node/field (pen-core's
|
||||
* mergeDocuments already places ours as the placeholder for unresolved
|
||||
* conflicts, so 'ours' is a no-op).
|
||||
* - 'theirs' → replace the conflicted node/field with the theirs version.
|
||||
* - 'manual-node' → replace the conflicted node with the user's edited version.
|
||||
* - 'manual-field' → set the doc-field value to the user's choice.
|
||||
*
|
||||
* If a conflict has no resolution in the map, we default to 'ours' (the
|
||||
* pen-core placeholder). The applyMerge engine fn enforces "all conflicts
|
||||
* must be resolved" before calling this — but defaulting here makes the
|
||||
* function safe to call in tests with partial resolution maps.
|
||||
*/
|
||||
export function applyResolutions(opts: {
|
||||
merged: PenDocument;
|
||||
conflictMap: Map<string, NodeConflict | DocFieldConflict>;
|
||||
resolutions: Map<string, ConflictResolution>;
|
||||
}): PenDocument {
|
||||
const { conflictMap, resolutions } = opts;
|
||||
// We build a new document by deep-cloning via JSON round-trip. The merge
|
||||
// result is already a fresh object from pen-core, but we don't want to
|
||||
// mutate it in case the caller still holds a reference for diagnostics.
|
||||
let doc = JSON.parse(JSON.stringify(opts.merged)) as PenDocument;
|
||||
|
||||
for (const [id, resolution] of resolutions) {
|
||||
const parsed = parseConflictId(id);
|
||||
const conflict = conflictMap.get(id);
|
||||
if (!conflict) {
|
||||
throw new GitError('engine-crash', `resolveConflict: unknown id ${id}`);
|
||||
}
|
||||
|
||||
if (parsed.kind === 'node') {
|
||||
const nodeConflict = conflict as NodeConflict;
|
||||
const target = pickNodeForResolution(resolution, nodeConflict);
|
||||
if (target === null) {
|
||||
// 'ours' (with ours being null = deleted) — drop the node from the tree.
|
||||
doc = removeNodeById(doc, parsed.pageId, parsed.nodeId);
|
||||
} else {
|
||||
doc = replaceNodeById(doc, parsed.pageId, parsed.nodeId, target);
|
||||
}
|
||||
} else {
|
||||
// doc-field conflict
|
||||
const fieldConflict = conflict as DocFieldConflict;
|
||||
const value = pickFieldForResolution(resolution, fieldConflict);
|
||||
doc = setDocFieldByPath(doc, parsed.field, parsed.path, value);
|
||||
}
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pickNodeForResolution(
|
||||
resolution: ConflictResolution,
|
||||
conflict: NodeConflict,
|
||||
): PenNode | null {
|
||||
if (resolution.kind === 'ours') return conflict.ours;
|
||||
if (resolution.kind === 'theirs') return conflict.theirs;
|
||||
if (resolution.kind === 'manual-node') return resolution.node;
|
||||
// manual-field is a programming error for node conflicts; treat as ours.
|
||||
return conflict.ours;
|
||||
}
|
||||
|
||||
function pickFieldForResolution(
|
||||
resolution: ConflictResolution,
|
||||
conflict: DocFieldConflict,
|
||||
): unknown {
|
||||
if (resolution.kind === 'ours') return conflict.ours;
|
||||
if (resolution.kind === 'theirs') return conflict.theirs;
|
||||
if (resolution.kind === 'manual-field') return resolution.value;
|
||||
// manual-node is a programming error for doc-field conflicts; treat as ours.
|
||||
return conflict.ours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a node in the document tree by id. Walks both the legacy
|
||||
* single-page `children` and the multi-page `pages` shape.
|
||||
*
|
||||
* NOTE: we do NOT use pen-core's `updateNodeInTree` here because its
|
||||
* semantics are shallow-merge (`{...oldNode, ...updates}`), which would
|
||||
* leave stale fields from the old node if the replacement changes type or
|
||||
* omits properties. Conflict resolution requires wholesale replacement —
|
||||
* the user's chosen node (from theirs or manual edit) must fully supplant
|
||||
* the old one with no residual fields leaking through.
|
||||
*/
|
||||
function replaceNodeById(
|
||||
doc: PenDocument,
|
||||
pageId: string | null,
|
||||
nodeId: string,
|
||||
replacement: PenNode,
|
||||
): PenDocument {
|
||||
if (doc.pages && pageId !== null) {
|
||||
return {
|
||||
...doc,
|
||||
pages: doc.pages.map((page) =>
|
||||
page.id === pageId
|
||||
? { ...page, children: replaceNodeInArray(page.children, nodeId, replacement) }
|
||||
: page,
|
||||
),
|
||||
};
|
||||
}
|
||||
// Legacy single-page or null pageId.
|
||||
return {
|
||||
...doc,
|
||||
children: replaceNodeInArray(doc.children ?? [], nodeId, replacement),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive tree walker that swaps a node by id with a wholesale replacement.
|
||||
* Returns a new array — does not mutate the input.
|
||||
*/
|
||||
function replaceNodeInArray(nodes: PenNode[], id: string, replacement: PenNode): PenNode[] {
|
||||
return nodes.map((n) => {
|
||||
if (n.id === id) return replacement;
|
||||
if ('children' in n && n.children) {
|
||||
return {
|
||||
...n,
|
||||
children: replaceNodeInArray(n.children, id, replacement),
|
||||
} as PenNode;
|
||||
}
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a node from the document tree by id. Returns a new document.
|
||||
*/
|
||||
function removeNodeById(doc: PenDocument, pageId: string | null, nodeId: string): PenDocument {
|
||||
if (doc.pages && pageId !== null) {
|
||||
return {
|
||||
...doc,
|
||||
pages: doc.pages.map((page) =>
|
||||
page.id === pageId
|
||||
? { ...page, children: removeNodeFromArray(page.children, nodeId) }
|
||||
: page,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...doc,
|
||||
children: removeNodeFromArray(doc.children ?? [], nodeId),
|
||||
};
|
||||
}
|
||||
|
||||
function removeNodeFromArray(nodes: PenNode[], id: string): PenNode[] {
|
||||
const out: PenNode[] = [];
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) continue;
|
||||
if ('children' in n && n.children) {
|
||||
out.push({ ...n, children: removeNodeFromArray(n.children, id) } as PenNode);
|
||||
} else {
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a doc-field value by its dotted path. Used for variables/themes/etc.
|
||||
* The path format matches DocFieldConflict.path (e.g.
|
||||
* 'variables.color-1.value' or 'pages').
|
||||
*/
|
||||
function setDocFieldByPath(
|
||||
doc: PenDocument,
|
||||
field: string,
|
||||
path: string,
|
||||
value: unknown,
|
||||
): PenDocument {
|
||||
// Split the path into segments. The first segment matches `field`; the
|
||||
// remaining segments navigate into nested object properties.
|
||||
const segments = path.split('.');
|
||||
if (segments.length === 0 || segments[0] !== field) {
|
||||
// Top-level field, no nesting (e.g. field === 'name', path === 'name').
|
||||
return { ...doc, [field]: value } as unknown as PenDocument;
|
||||
}
|
||||
|
||||
if (segments.length === 1) {
|
||||
return { ...doc, [field]: value } as unknown as PenDocument;
|
||||
}
|
||||
|
||||
// Clone the field value and navigate to the parent of the leaf.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const docAny = doc as any;
|
||||
const fieldValue = docAny[field];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const cloned: any = JSON.parse(JSON.stringify(fieldValue ?? {}));
|
||||
let cursor = cloned;
|
||||
for (let i = 1; i < segments.length - 1; i++) {
|
||||
const seg = segments[i];
|
||||
if (cursor[seg] == null) cursor[seg] = {};
|
||||
cursor = cursor[seg];
|
||||
}
|
||||
cursor[segments[segments.length - 1]] = value;
|
||||
|
||||
return { ...doc, [field]: cloned } as unknown as PenDocument;
|
||||
}
|
||||
132
apps/desktop/git/merge-session.ts
Normal file
132
apps/desktop/git/merge-session.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// apps/desktop/git/merge-session.ts
|
||||
//
|
||||
// In-flight merge state for a single repo. Stored on RepoSession.inflightMerge
|
||||
// during the conflict resolution loop. The conflict id codec lives here too
|
||||
// because it's the only string format both the engine and the tests share.
|
||||
|
||||
import type { MergeResult, NodeConflict, DocFieldConflict } from '@zseven-w/pen-core';
|
||||
import type { PenNode } from '@zseven-w/pen-types';
|
||||
|
||||
/**
|
||||
* The user's choice for resolving a single conflict. Mirrors the spec's
|
||||
* ConflictResolution union.
|
||||
*/
|
||||
export type ConflictResolution =
|
||||
| { kind: 'ours' }
|
||||
| { kind: 'theirs' }
|
||||
| { kind: 'manual-node'; node: PenNode } // for node conflicts
|
||||
| { kind: 'manual-field'; value: unknown }; // for doc-field conflicts
|
||||
|
||||
/**
|
||||
* Wire-format conflict bag returned across IPC. The renderer wraps this in
|
||||
* Maps for resolution tracking. Each conflict has a stable id that the
|
||||
* renderer passes back via resolveConflict().
|
||||
*/
|
||||
export interface ConflictBag {
|
||||
nodeConflicts: Array<NodeConflict & { id: string }>;
|
||||
docFieldConflicts: Array<DocFieldConflict & { id: string }>;
|
||||
}
|
||||
|
||||
export interface InflightMerge {
|
||||
/** The current HEAD commit at the time branchMerge was invoked. */
|
||||
oursCommit: string;
|
||||
/** The branch tip we're merging in. */
|
||||
theirsCommit: string;
|
||||
/** Common ancestor commit. */
|
||||
baseCommit: string;
|
||||
|
||||
/** Raw output from pen-core's mergeDocuments. */
|
||||
mergeResult: MergeResult;
|
||||
|
||||
/** O(1) lookup of conflict by id. Built once at branchMerge time. */
|
||||
conflictMap: Map<string, NodeConflict | DocFieldConflict>;
|
||||
|
||||
/** Accumulated user choices. Empty until resolveConflict is called. */
|
||||
resolutions: Map<string, ConflictResolution>;
|
||||
|
||||
/** Default commit message for applyMerge. The renderer can override later. */
|
||||
defaultMessage: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conflict id codec
|
||||
//
|
||||
// Encoding rules (matches spec line 836-841 verbatim):
|
||||
// Node conflict: `node:${pageId ?? '_'}:${nodeId}`
|
||||
// Doc-field conflict: `field:${field}:${path}`
|
||||
//
|
||||
// Stable, deterministic, both engine and renderer agree.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function encodeNodeConflictId(conflict: NodeConflict): string {
|
||||
return `node:${conflict.pageId ?? '_'}:${conflict.nodeId}`;
|
||||
}
|
||||
|
||||
export function encodeDocFieldConflictId(conflict: DocFieldConflict): string {
|
||||
return `field:${conflict.field}:${conflict.path}`;
|
||||
}
|
||||
|
||||
export type ParsedConflictId =
|
||||
| { kind: 'node'; pageId: string | null; nodeId: string }
|
||||
| { kind: 'field'; field: string; path: string };
|
||||
|
||||
/**
|
||||
* Parse a conflict id back into its components. Used by resolveConflict to
|
||||
* locate the conflict in session state. Throws if the id is malformed —
|
||||
* callers should treat that as a programming error (the renderer always
|
||||
* passes back ids the engine just emitted).
|
||||
*/
|
||||
export function parseConflictId(id: string): ParsedConflictId {
|
||||
if (id.startsWith('node:')) {
|
||||
const rest = id.slice('node:'.length);
|
||||
const colonIdx = rest.indexOf(':');
|
||||
if (colonIdx === -1) {
|
||||
throw new Error(`Malformed node conflict id: ${id}`);
|
||||
}
|
||||
const rawPage = rest.slice(0, colonIdx);
|
||||
const nodeId = rest.slice(colonIdx + 1);
|
||||
return {
|
||||
kind: 'node',
|
||||
pageId: rawPage === '_' ? null : rawPage,
|
||||
nodeId,
|
||||
};
|
||||
}
|
||||
if (id.startsWith('field:')) {
|
||||
const rest = id.slice('field:'.length);
|
||||
const colonIdx = rest.indexOf(':');
|
||||
if (colonIdx === -1) {
|
||||
throw new Error(`Malformed field conflict id: ${id}`);
|
||||
}
|
||||
return {
|
||||
kind: 'field',
|
||||
field: rest.slice(0, colonIdx),
|
||||
path: rest.slice(colonIdx + 1),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unknown conflict id prefix: ${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a wire-format ConflictBag from a MergeResult by attaching ids. Used
|
||||
* by branchMerge before stashing the InflightMerge in session state.
|
||||
*
|
||||
* Returns BOTH the bag AND the conflict map (id → conflict) so the caller
|
||||
* can hydrate the InflightMerge in one pass without re-walking the result.
|
||||
*/
|
||||
export function buildConflictBag(result: MergeResult): {
|
||||
bag: ConflictBag;
|
||||
conflictMap: Map<string, NodeConflict | DocFieldConflict>;
|
||||
} {
|
||||
const conflictMap = new Map<string, NodeConflict | DocFieldConflict>();
|
||||
const nodeConflicts = result.nodeConflicts.map((c) => {
|
||||
const id = encodeNodeConflictId(c);
|
||||
conflictMap.set(id, c);
|
||||
return { ...c, id };
|
||||
});
|
||||
const docFieldConflicts = result.docFieldConflicts.map((c) => {
|
||||
const id = encodeDocFieldConflictId(c);
|
||||
conflictMap.set(id, c);
|
||||
return { ...c, id };
|
||||
});
|
||||
return { bag: { nodeConflicts, docFieldConflicts }, conflictMap };
|
||||
}
|
||||
89
apps/desktop/git/repo-detector.ts
Normal file
89
apps/desktop/git/repo-detector.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// apps/desktop/git/repo-detector.ts
|
||||
//
|
||||
// Discover whether a .op file lives inside a git repository, and if so,
|
||||
// in which mode (single-file or folder). Returns a discriminated union
|
||||
// the engine can match against without follow-up filesystem checks.
|
||||
|
||||
import { dirname, basename, resolve } from 'node:path';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* Result of a successful detection. The shape is the same for both modes
|
||||
* so the engine can pass it directly to `openRepo` regardless of mode.
|
||||
*/
|
||||
export interface RepoDetectionFound {
|
||||
mode: 'single-file' | 'folder';
|
||||
/** worktree root (parent of the .op file in single-file mode; repo root in folder mode) */
|
||||
rootPath: string;
|
||||
/** absolute path to the gitdir */
|
||||
gitdir: string;
|
||||
}
|
||||
|
||||
export type RepoDetection = RepoDetectionFound | { mode: 'none' };
|
||||
|
||||
/**
|
||||
* Walk up from the given .op file looking for a tracked repository.
|
||||
*
|
||||
* Order of checks (single-file wins per spec):
|
||||
* 1. <dirname(filePath)>/.op-history/<basename(filePath)>.git/HEAD exists
|
||||
* → single-file mode
|
||||
* 2. Walk up parent dirs looking for any /.git/HEAD → folder mode
|
||||
* 3. Otherwise → none
|
||||
*
|
||||
* The function never throws on missing files; only on filesystem errors that
|
||||
* indicate something deeper is wrong (permissions, broken symlinks). Those
|
||||
* propagate as standard Node errors and are NOT wrapped in GitError — the
|
||||
* engine layer is responsible for translation.
|
||||
*/
|
||||
export async function detectRepo(filePath: string): Promise<RepoDetection> {
|
||||
const absFile = resolve(filePath);
|
||||
const parentDir = dirname(absFile);
|
||||
const baseName = basename(absFile);
|
||||
|
||||
// 1. Single-file mode check.
|
||||
const singleGitdir = resolve(parentDir, '.op-history', `${baseName}.git`);
|
||||
if (await pathExists(resolve(singleGitdir, 'HEAD'))) {
|
||||
return {
|
||||
mode: 'single-file',
|
||||
rootPath: parentDir,
|
||||
gitdir: singleGitdir,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Walk up parents looking for a .git directory.
|
||||
let current = parentDir;
|
||||
while (true) {
|
||||
const candidate = resolve(current, '.git');
|
||||
if (await pathExists(resolve(candidate, 'HEAD'))) {
|
||||
return {
|
||||
mode: 'folder',
|
||||
rootPath: current,
|
||||
gitdir: candidate,
|
||||
};
|
||||
}
|
||||
const parent = dirname(current);
|
||||
if (parent === current) {
|
||||
// Reached filesystem root.
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
// 3. No repo found.
|
||||
return { mode: 'none' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `path` exists (file or directory). Returns false on ENOENT.
|
||||
* Re-throws other errors (permission denied, etc.) so we don't silently
|
||||
* misbehave.
|
||||
*/
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
145
apps/desktop/git/repo-session.ts
Normal file
145
apps/desktop/git/repo-session.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// apps/desktop/git/repo-session.ts
|
||||
//
|
||||
// In-memory registry of open repositories. Each entry is allocated by
|
||||
// engineDetect/Init/Open and disposed by engineClose. The renderer treats
|
||||
// repoId as an opaque string and passes it back on every subsequent IPC
|
||||
// call.
|
||||
//
|
||||
// This map is module-level (singleton). It does NOT survive a main-process
|
||||
// restart, which matches the spec's session-scoped contract for repoId.
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { IsoRepoHandle } from './git-iso';
|
||||
import type { InflightMerge } from './merge-session';
|
||||
|
||||
export interface CandidateFileInfo {
|
||||
/** absolute path */
|
||||
path: string;
|
||||
/** path relative to RepoSession.handle.dir, used for UI display */
|
||||
relativePath: string;
|
||||
/** # of commits on refs/heads/<currentBranch> that include this file */
|
||||
milestoneCount: number;
|
||||
/** # of commits on refs/openpencil/autosaves/<currentBranch> for this file */
|
||||
autosaveCount: number;
|
||||
/** timestamp (seconds) of the most recent touching commit, null if never */
|
||||
lastCommitAt: number | null;
|
||||
lastCommitMessage: string | null;
|
||||
}
|
||||
|
||||
export interface RepoSession {
|
||||
repoId: string;
|
||||
handle: IsoRepoHandle;
|
||||
/** Which .op file the panel is currently tracking. May be null right after
|
||||
* an `open`/`clone` call where no auto-binding happened — the renderer
|
||||
* must call bindTrackedFile to set it before commit/restore work. */
|
||||
trackedFilePath: string | null;
|
||||
/** Cached candidate list, populated by engineDetect/Init/Open and refreshed
|
||||
* on demand by engineListCandidates. */
|
||||
candidateFiles: CandidateFileInfo[];
|
||||
/** Engine kind reported to the renderer. In Phase 2a always 'iso'. */
|
||||
engineKind: 'iso' | 'sys';
|
||||
/** In-flight merge state. Set by engineBranchMerge when conflicts are
|
||||
* present, cleared by engineApplyMerge / engineAbortMerge. Null when no
|
||||
* merge is in progress. */
|
||||
inflightMerge: InflightMerge | null;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, RepoSession>();
|
||||
|
||||
/**
|
||||
* Allocate a fresh session for a newly-opened repo. Returns the new repoId.
|
||||
* The caller fills in trackedFilePath / candidateFiles before returning to
|
||||
* the IPC layer.
|
||||
*/
|
||||
export function registerSession(args: {
|
||||
handle: IsoRepoHandle;
|
||||
trackedFilePath: string | null;
|
||||
candidateFiles: CandidateFileInfo[];
|
||||
engineKind: 'iso' | 'sys';
|
||||
}): RepoSession {
|
||||
const repoId = randomUUID();
|
||||
const session: RepoSession = {
|
||||
repoId,
|
||||
handle: args.handle,
|
||||
trackedFilePath: args.trackedFilePath,
|
||||
candidateFiles: args.candidateFiles,
|
||||
engineKind: args.engineKind,
|
||||
inflightMerge: null,
|
||||
};
|
||||
sessions.set(repoId, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an existing session by repoId. Returns undefined if the id is
|
||||
* unknown — the engine layer translates this to a GitError('no-file').
|
||||
*/
|
||||
export function getSession(repoId: string): RepoSession | undefined {
|
||||
return sessions.get(repoId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate the trackedFilePath of an existing session. Used by
|
||||
* engineBindTrackedFile.
|
||||
*/
|
||||
export function updateTrackedFile(repoId: string, trackedFilePath: string): boolean {
|
||||
const session = sessions.get(repoId);
|
||||
if (!session) return false;
|
||||
session.trackedFilePath = trackedFilePath;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate the candidateFiles cache of an existing session. Used by
|
||||
* engineListCandidates after re-walking the worktree.
|
||||
*/
|
||||
export function updateCandidates(repoId: string, candidateFiles: CandidateFileInfo[]): boolean {
|
||||
const session = sessions.get(repoId);
|
||||
if (!session) return false;
|
||||
session.candidateFiles = candidateFiles;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stash an in-flight merge on the session. Used by engineBranchMerge after
|
||||
* detecting .op-level conflicts.
|
||||
*/
|
||||
export function setInflightMerge(repoId: string, merge: InflightMerge): boolean {
|
||||
const session = sessions.get(repoId);
|
||||
if (!session) return false;
|
||||
session.inflightMerge = merge;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the in-flight merge. Used by engineApplyMerge (after a successful
|
||||
* commit) and engineAbortMerge (after the user discards).
|
||||
*/
|
||||
export function clearInflightMerge(repoId: string): boolean {
|
||||
const session = sessions.get(repoId);
|
||||
if (!session) return false;
|
||||
session.inflightMerge = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a session from the registry. Called by engineClose. The handle's
|
||||
* gitdir/file descriptors are managed by isomorphic-git internally — there's
|
||||
* nothing to flush here.
|
||||
*/
|
||||
export function unregisterSession(repoId: string): boolean {
|
||||
return sessions.delete(repoId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe the entire registry. Used in tests' afterEach to keep sessions from
|
||||
* leaking between cases. Production code should never call this.
|
||||
*/
|
||||
export function clearAllSessions(): void {
|
||||
sessions.clear();
|
||||
}
|
||||
|
||||
/** Test/debug helper: count active sessions. Not part of the IPC surface. */
|
||||
export function sessionCount(): number {
|
||||
return sessions.size;
|
||||
}
|
||||
183
apps/desktop/git/ssh-keys.ts
Normal file
183
apps/desktop/git/ssh-keys.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
// apps/desktop/git/ssh-keys.ts
|
||||
//
|
||||
// SSH key management. We generate ed25519 keypairs via node:crypto and
|
||||
// format the public key as OpenSSH using sshpk. Private keys are stored
|
||||
// as PEM PKCS#8 with file mode 0600. The metadata index lives at
|
||||
// <sshDir>/index.json and is rewritten atomically on every mutation.
|
||||
//
|
||||
// This module is factory-based so tests can inject a temp directory.
|
||||
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { generateKeyPair, randomUUID } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { join, basename } from 'node:path';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const sshpk = require('sshpk') as typeof import('sshpk');
|
||||
|
||||
const generateKeyPairAsync = promisify(generateKeyPair);
|
||||
|
||||
export interface SshKeyInfo {
|
||||
id: string;
|
||||
host: string;
|
||||
publicKey: string; // OpenSSH single-line format
|
||||
fingerprint: string; // SHA256 fingerprint with `SHA256:` prefix
|
||||
comment: string;
|
||||
/** Absolute path to the PEM private key file. Not exposed via IPC — used
|
||||
* internally by git-sys when constructing GIT_SSH_COMMAND. */
|
||||
privateKeyPath: string;
|
||||
}
|
||||
|
||||
export interface SshKeyManager {
|
||||
generate(opts: { host: string; comment: string }): Promise<SshKeyInfo>;
|
||||
import(opts: { privateKeyPath: string; host: string }): Promise<SshKeyInfo>;
|
||||
list(): Promise<SshKeyInfo[]>;
|
||||
delete(keyId: string): Promise<void>;
|
||||
/** Resolve a keyId to its private key path. Throws if missing. Used by
|
||||
* git-sys when invoking SSH transport. Not part of the IPC surface. */
|
||||
getPrivateKeyPath(keyId: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface SshKeyManagerOpts {
|
||||
/** Directory where private keys + index.json live. Created on first use. */
|
||||
sshDir: string;
|
||||
}
|
||||
|
||||
const INDEX_FILE = 'index.json';
|
||||
|
||||
export function createSshKeyManager(opts: SshKeyManagerOpts): SshKeyManager {
|
||||
const { sshDir } = opts;
|
||||
|
||||
async function ensureDir(): Promise<void> {
|
||||
await fsp.mkdir(sshDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
async function loadIndex(): Promise<SshKeyInfo[]> {
|
||||
try {
|
||||
const bytes = await fsp.readFile(join(sshDir, INDEX_FILE), 'utf-8');
|
||||
return JSON.parse(bytes) as SshKeyInfo[];
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveIndex(keys: SshKeyInfo[]): Promise<void> {
|
||||
await ensureDir();
|
||||
const tmp = join(sshDir, `${INDEX_FILE}.tmp`);
|
||||
const dest = join(sshDir, INDEX_FILE);
|
||||
await fsp.writeFile(tmp, JSON.stringify(keys, null, 2), { mode: 0o600 });
|
||||
await fsp.rename(tmp, dest);
|
||||
}
|
||||
|
||||
function computeFingerprint(opensshPublicKey: string): string {
|
||||
const key = sshpk.parseKey(opensshPublicKey, 'ssh');
|
||||
return key.fingerprint('sha256').toString();
|
||||
}
|
||||
|
||||
function formatPublicKeyOpenSsh(pemPublic: string, comment: string): string {
|
||||
const key = sshpk.parseKey(pemPublic, 'pem');
|
||||
const ssh = key.toString('ssh');
|
||||
// sshpk's ssh format already includes "ssh-ed25519 ..."; append comment.
|
||||
return `${ssh.trim()} ${comment}`.trim();
|
||||
}
|
||||
|
||||
return {
|
||||
async generate({ host, comment }) {
|
||||
await ensureDir();
|
||||
const { publicKey, privateKey } = await generateKeyPairAsync('ed25519', {
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
const id = randomUUID();
|
||||
const privateKeyPath = join(sshDir, `${id}.pem`);
|
||||
await fsp.writeFile(privateKeyPath, privateKey, { mode: 0o600 });
|
||||
|
||||
const sshPublic = formatPublicKeyOpenSsh(publicKey, comment);
|
||||
const fingerprint = computeFingerprint(sshPublic);
|
||||
|
||||
const info: SshKeyInfo = {
|
||||
id,
|
||||
host,
|
||||
publicKey: sshPublic,
|
||||
fingerprint,
|
||||
comment,
|
||||
privateKeyPath,
|
||||
};
|
||||
const all = await loadIndex();
|
||||
all.push(info);
|
||||
await saveIndex(all);
|
||||
return info;
|
||||
},
|
||||
|
||||
async import({ privateKeyPath, host }) {
|
||||
// Read the existing private key, derive the public key via sshpk.
|
||||
const pemBytes = await fsp.readFile(privateKeyPath);
|
||||
const privateKeyObj = sshpk.parsePrivateKey(pemBytes, 'auto');
|
||||
const publicKeyObj = privateKeyObj.toPublic();
|
||||
const sshPublic = publicKeyObj.toString('ssh').trim();
|
||||
const fingerprint = publicKeyObj.fingerprint('sha256').toString();
|
||||
const comment = `imported-${basename(privateKeyPath)}`;
|
||||
|
||||
// Copy the file into our sshDir so the user's original location can
|
||||
// move freely without breaking us.
|
||||
const id = randomUUID();
|
||||
const destPath = join(sshDir, `${id}.pem`);
|
||||
await ensureDir();
|
||||
// Re-export as PKCS#8 PEM via sshpk so we always store a uniform format.
|
||||
const pkcs8Pem = privateKeyObj.toString('pkcs8');
|
||||
await fsp.writeFile(destPath, pkcs8Pem, { mode: 0o600 });
|
||||
|
||||
const info: SshKeyInfo = {
|
||||
id,
|
||||
host,
|
||||
publicKey: `${sshPublic} ${comment}`,
|
||||
fingerprint,
|
||||
comment,
|
||||
privateKeyPath: destPath,
|
||||
};
|
||||
const all = await loadIndex();
|
||||
all.push(info);
|
||||
await saveIndex(all);
|
||||
return info;
|
||||
},
|
||||
|
||||
async list() {
|
||||
return loadIndex();
|
||||
},
|
||||
|
||||
async delete(keyId) {
|
||||
const all = await loadIndex();
|
||||
const idx = all.findIndex((k) => k.id === keyId);
|
||||
if (idx === -1) return; // already gone — idempotent
|
||||
const info = all[idx];
|
||||
try {
|
||||
await fsp.unlink(info.privateKeyPath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
all.splice(idx, 1);
|
||||
await saveIndex(all);
|
||||
},
|
||||
|
||||
async getPrivateKeyPath(keyId) {
|
||||
const all = await loadIndex();
|
||||
const found = all.find((k) => k.id === keyId);
|
||||
if (!found) {
|
||||
throw new Error(`SSH key ${keyId} not found`);
|
||||
}
|
||||
return found.privateKeyPath;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default factory: builds a manager pointed at <userData>/ssh/. Lazy-imports
|
||||
* Electron so tests don't pull it in.
|
||||
*/
|
||||
export function createDefaultSshKeyManager(): SshKeyManager {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const electron = require('electron');
|
||||
const userDataDir: string = electron.app.getPath('userData');
|
||||
return createSshKeyManager({ sshDir: join(userDataDir, 'ssh') });
|
||||
}
|
||||
307
apps/desktop/git/worktree-merge.ts
Normal file
307
apps/desktop/git/worktree-merge.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
// apps/desktop/git/worktree-merge.ts
|
||||
//
|
||||
// System-git helpers for folder-mode merge operations. These are the only
|
||||
// functions in the git layer that shell out to the system git binary for
|
||||
// merge state management — everything else uses isomorphic-git.
|
||||
//
|
||||
// DESIGN NOTE (Phase 7a spike):
|
||||
// We use system git's merge machinery because isomorphic-git has no
|
||||
// equivalent of --no-commit --no-ff merges and cannot write the three-stage
|
||||
// index entries needed for conflict detection. The exact command sequence
|
||||
// was chosen after verifying each shape against a live repo:
|
||||
//
|
||||
// 1. `git merge --no-commit --no-ff <ref>` — enters merge state; exits 1
|
||||
// on conflicts, exits 0 on clean merge (but still --no-commit so we
|
||||
// can write the tracked file before committing).
|
||||
// 2. `git ls-files -u` — lists all unresolved paths (all conflict types,
|
||||
// not just "both modified"), along with stage numbers 1/2/3.
|
||||
// 3. `git show :1:<path>`, `:2:<path>`, `:3:<path>` — reads base/ours/
|
||||
// theirs blobs from the index without touching the working tree.
|
||||
// 4. `git checkout --ours -- <file>` — writes the ours version to disk so
|
||||
// the tracked .op file is readable JSON; file stays "unresolved" in the
|
||||
// index so MERGE_HEAD and other unresolved files survive.
|
||||
// 5. `git add <file>` — marks a file resolved in the index.
|
||||
// 6. `git commit -m <message>` — when MERGE_HEAD is present, git
|
||||
// automatically creates a 2-parent merge commit.
|
||||
// 7. `git merge --abort` — atomically restores the working tree and index.
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { GitError } from './error';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface RunOpts {
|
||||
cwd: string;
|
||||
env?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface RunResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `git <args>`. Unlike the private runGit in git-sys.ts, this version
|
||||
* tolerates non-zero exits and returns the exit code so callers can
|
||||
* distinguish "conflict" from "error" — `git merge` exits 1 on conflicts
|
||||
* but that is not an error from the caller's perspective.
|
||||
*/
|
||||
async function runGitTolerant(args: string[], opts: RunOpts): Promise<RunResult> {
|
||||
const env = { ...process.env, ...opts.env };
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('git', args, {
|
||||
cwd: opts.cwd,
|
||||
env,
|
||||
timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
return { stdout, stderr, exitCode: 0 };
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string; code?: number };
|
||||
// exitCode is the numeric exit code from the child process; undefined if it
|
||||
// was killed by a signal (which we map to -1).
|
||||
const exitCode = typeof e.code === 'number' ? e.code : -1;
|
||||
return {
|
||||
stdout: e.stdout ?? '',
|
||||
stderr: e.stderr ?? '',
|
||||
exitCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attempt to merge `ref` into the current branch without auto-committing.
|
||||
* Uses `--no-ff` to always produce a merge commit even for fast-forwards.
|
||||
*
|
||||
* Returns:
|
||||
* - { kind: 'clean' } — merge succeeded with no conflicts; index is staged
|
||||
* but not committed (MERGE_HEAD is set).
|
||||
* - { kind: 'conflict' } — one or more conflicts; MERGE_HEAD is set, unresolved
|
||||
* files remain in the index at conflict stages.
|
||||
* - throws GitError — on engine-level failures (not available, unknown ref, etc.)
|
||||
*
|
||||
* NOTE: some git versions read user identity during merge bookkeeping even with
|
||||
* --no-commit. Callers must ensure the repo has user.name/user.email configured
|
||||
* (or inject them via opts.env) — machines without a global git config will fail.
|
||||
*/
|
||||
export async function sysMergeNoCommit(opts: {
|
||||
cwd: string;
|
||||
ref: string;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<{ kind: 'clean' | 'conflict' }> {
|
||||
const result = await runGitTolerant(['merge', '--no-commit', '--no-ff', opts.ref], {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
});
|
||||
|
||||
if (result.exitCode === 0) return { kind: 'clean' };
|
||||
|
||||
// Exit code 1 from `git merge` means conflicts. Any other code is an error.
|
||||
if (result.exitCode === 1) return { kind: 'conflict' };
|
||||
|
||||
throw new GitError(
|
||||
'engine-crash',
|
||||
`git merge --no-commit failed: ${result.stderr.trim() || result.stdout.trim()}`,
|
||||
{ detail: { ref: opts.ref, exitCode: result.exitCode } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all unresolved file paths in the current merge state.
|
||||
* Uses `git ls-files -u` which reports ALL conflict types (both-modified,
|
||||
* deleted-by-them, etc.), not just `--diff-filter=U` which only reports
|
||||
* "both modified". Returns deduplicated, sorted paths.
|
||||
*
|
||||
* MINIMUM GIT VERSION: `--format=%(path)` requires git ≥ 2.35 (Feb 2022).
|
||||
* No version check or fallback is provided here — callers must ensure the
|
||||
* system git is new enough. Document this floor in deployment requirements.
|
||||
*/
|
||||
export async function sysListUnresolved(opts: { cwd: string }): Promise<string[]> {
|
||||
const result = await runGitTolerant(['ls-files', '-u', '--format=%(path)'], {
|
||||
cwd: opts.cwd,
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new GitError('engine-crash', `git ls-files -u failed: ${result.stderr.trim()}`, {
|
||||
detail: { exitCode: result.exitCode },
|
||||
});
|
||||
}
|
||||
|
||||
const paths = result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Deduplicate: each unresolved path appears 2-3 times (one per stage).
|
||||
return [...new Set(paths)].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a merge is in progress by checking for MERGE_HEAD in the
|
||||
* gitdir. Does NOT run git — pure filesystem check. Returns the theirs
|
||||
* commit hash if in progress, null otherwise.
|
||||
*/
|
||||
export async function readMergeHead(gitdir: string): Promise<string | null> {
|
||||
const mergeHeadPath = join(gitdir, 'MERGE_HEAD');
|
||||
try {
|
||||
const content = await fsp.readFile(mergeHeadPath, 'utf-8');
|
||||
const hash = content.trim();
|
||||
if (hash.length === 40) return hash;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the content of a tracked file from the index at a specific stage:
|
||||
* stage 1 = base (merge-base ancestor)
|
||||
* stage 2 = ours (HEAD)
|
||||
* stage 3 = theirs (MERGE_HEAD)
|
||||
*
|
||||
* Returns null if the file is not present at that stage (e.g. deleted-by-them
|
||||
* conflict has no stage 3, only stages 1 and 2).
|
||||
*/
|
||||
export async function sysShowStageBlob(opts: {
|
||||
cwd: string;
|
||||
stage: 1 | 2 | 3;
|
||||
filepath: string;
|
||||
}): Promise<string | null> {
|
||||
const stageRef = `:${opts.stage}:${opts.filepath}`;
|
||||
const result = await runGitTolerant(['show', stageRef], { cwd: opts.cwd });
|
||||
|
||||
if (result.exitCode === 0) return result.stdout;
|
||||
|
||||
// Non-zero exit means the file doesn't exist at this stage — that is not
|
||||
// an error, it's a normal state (e.g. deleted-by-them has no :3:).
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the working-tree content of a tracked file to the "ours" version
|
||||
* (stage 2) so the renderer can read readable JSON instead of conflict
|
||||
* markers. The file stays "unresolved" in the index — MERGE_HEAD survives.
|
||||
*
|
||||
* The exact behaviour was verified in the Phase 7a spike:
|
||||
* `git checkout --ours -- <file>` writes stage 2 to disk and leaves the
|
||||
* index at conflict stages (1/2/3). `git diff --name-only --diff-filter=U`
|
||||
* still reports the file as unresolved after this call.
|
||||
*/
|
||||
export async function sysRestoreOurs(opts: { cwd: string; filepath: string }): Promise<void> {
|
||||
const result = await runGitTolerant(['checkout', '--ours', '--', opts.filepath], {
|
||||
cwd: opts.cwd,
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new GitError(
|
||||
'engine-crash',
|
||||
`git checkout --ours failed for ${opts.filepath}: ${result.stderr.trim()}`,
|
||||
{ detail: { filepath: opts.filepath, exitCode: result.exitCode } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a file, marking it as resolved in the index. Used after the tracked
|
||||
* .op file has been written with the final merged document so git accepts the
|
||||
* merge commit.
|
||||
*/
|
||||
export async function sysStageFile(opts: { cwd: string; filepath: string }): Promise<void> {
|
||||
const result = await runGitTolerant(['add', '--', opts.filepath], { cwd: opts.cwd });
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new GitError(
|
||||
'engine-crash',
|
||||
`git add failed for ${opts.filepath}: ${result.stderr.trim()}`,
|
||||
{ detail: { filepath: opts.filepath, exitCode: result.exitCode } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize the merge by creating the merge commit. MERGE_HEAD must be set.
|
||||
* When MERGE_HEAD is present, git automatically records both parents.
|
||||
*
|
||||
* Returns the new merge commit hash.
|
||||
*/
|
||||
export async function sysFinalizeMerge(opts: {
|
||||
cwd: string;
|
||||
message: string;
|
||||
author: { name: string; email: string };
|
||||
env?: Record<string, string>;
|
||||
}): Promise<string> {
|
||||
const env: Record<string, string> = {
|
||||
...opts.env,
|
||||
GIT_AUTHOR_NAME: opts.author.name,
|
||||
GIT_AUTHOR_EMAIL: opts.author.email,
|
||||
GIT_COMMITTER_NAME: opts.author.name,
|
||||
GIT_COMMITTER_EMAIL: opts.author.email,
|
||||
};
|
||||
|
||||
const result = await runGitTolerant(['commit', '-m', opts.message], {
|
||||
cwd: opts.cwd,
|
||||
env,
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new GitError(
|
||||
'engine-crash',
|
||||
`git commit (merge finalize) failed: ${result.stderr.trim()}`,
|
||||
{ detail: { exitCode: result.exitCode } },
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the new commit hash from `git rev-parse HEAD`.
|
||||
const headResult = await runGitTolerant(['rev-parse', 'HEAD'], { cwd: opts.cwd });
|
||||
if (headResult.exitCode !== 0 || !headResult.stdout.trim()) {
|
||||
throw new GitError('engine-crash', 'Failed to read HEAD after merge commit');
|
||||
}
|
||||
return headResult.stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-progress merge. Restores the working tree and index to pre-merge
|
||||
* state. Idempotent: safe to call even if no merge is in progress (git merge
|
||||
* --abort exits 0 with a warning in that case on modern git versions).
|
||||
*/
|
||||
export async function sysAbortMerge(opts: { cwd: string }): Promise<void> {
|
||||
const result = await runGitTolerant(['merge', '--abort'], { cwd: opts.cwd });
|
||||
|
||||
// Exit code 0 = success. Exit code 128 with "MERGE_HEAD missing" means there
|
||||
// was no merge in progress — treat that as idempotent success.
|
||||
if (result.exitCode === 0) return;
|
||||
|
||||
const msg = (result.stderr + result.stdout).toLowerCase();
|
||||
if (msg.includes('merge_head') || msg.includes('no merge in progress')) {
|
||||
return; // Nothing to abort — already clean.
|
||||
}
|
||||
|
||||
throw new GitError('merge-abort-failed', `git merge --abort failed: ${result.stderr.trim()}`, {
|
||||
detail: { exitCode: result.exitCode },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current HEAD commit hash. Throws if HEAD cannot be resolved
|
||||
* (e.g. repo has no commits).
|
||||
*/
|
||||
export async function sysReadHead(opts: { cwd: string }): Promise<string> {
|
||||
const result = await runGitTolerant(['rev-parse', 'HEAD'], { cwd: opts.cwd });
|
||||
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
||||
throw new GitError('engine-crash', `git rev-parse HEAD failed: ${result.stderr.trim()}`, {
|
||||
detail: { exitCode: result.exitCode },
|
||||
});
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
import {
|
||||
ipcMain,
|
||||
dialog,
|
||||
type BrowserWindow,
|
||||
} from 'electron'
|
||||
import { resolve, extname, sep } from 'node:path'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { app } from 'electron'
|
||||
import { ipcMain, dialog, type BrowserWindow } from 'electron';
|
||||
import { resolve, extname, sep } from 'node:path';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { app } from 'electron';
|
||||
|
||||
import {
|
||||
getUpdaterState,
|
||||
|
|
@ -16,148 +12,226 @@ import {
|
|||
setUpdaterState,
|
||||
clearUpdateTimer,
|
||||
startUpdateTimer,
|
||||
} from './auto-updater'
|
||||
import { getLogDir } from './logger'
|
||||
} from './auto-updater';
|
||||
import { getLogDir } from './logger';
|
||||
import {
|
||||
buildUnsavedChangesDialogOptions,
|
||||
mapUnsavedChangesResponse,
|
||||
} from './unsaved-changes-dialog';
|
||||
|
||||
interface IpcDeps {
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
getPendingFilePath: () => string | null
|
||||
clearPendingFilePath: () => void
|
||||
prefsCache: Record<string, string>
|
||||
schedulePrefsWrite: () => void
|
||||
writeAppSettings: (patch: { autoUpdate?: boolean }) => Promise<void>
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getPendingFilePath: () => string | null;
|
||||
clearPendingFilePath: () => void;
|
||||
prefsCache: Record<string, string>;
|
||||
schedulePrefsWrite: () => void;
|
||||
writeAppSettings: (patch: { autoUpdate?: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
export function setupIPC(deps: IpcDeps): void {
|
||||
const { getMainWindow, getPendingFilePath, clearPendingFilePath, prefsCache, schedulePrefsWrite, writeAppSettings } = deps
|
||||
const {
|
||||
getMainWindow,
|
||||
getPendingFilePath,
|
||||
clearPendingFilePath,
|
||||
prefsCache,
|
||||
schedulePrefsWrite,
|
||||
writeAppSettings,
|
||||
} = deps;
|
||||
|
||||
ipcMain.handle('dialog:openFile', async () => {
|
||||
const mainWindow = getMainWindow()
|
||||
if (!mainWindow) return null
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return null;
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Open .op file',
|
||||
filters: [{ name: 'OpenPencil Files', extensions: ['op', 'pen'] }],
|
||||
properties: ['openFile'],
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) return null
|
||||
const filePath = result.filePaths[0]
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
return { filePath, content }
|
||||
})
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
const filePath = result.filePaths[0];
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
return { filePath, content };
|
||||
});
|
||||
|
||||
ipcMain.handle('dialog:openDirectory', async () => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return null;
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Open Git repository folder',
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'dialog:saveFile',
|
||||
async (_event, payload: { content: string; defaultPath?: string }) => {
|
||||
const mainWindow = getMainWindow()
|
||||
if (!mainWindow) return null
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return null;
|
||||
const result = await dialog.showSaveDialog(mainWindow, {
|
||||
title: 'Save .op file',
|
||||
defaultPath: payload.defaultPath,
|
||||
filters: [{ name: 'OpenPencil Files', extensions: ['op'] }],
|
||||
})
|
||||
if (result.canceled || !result.filePath) return null
|
||||
await writeFile(result.filePath, payload.content, 'utf-8')
|
||||
return result.filePath
|
||||
});
|
||||
if (result.canceled || !result.filePath) return null;
|
||||
await writeFile(result.filePath, payload.content, 'utf-8');
|
||||
return result.filePath;
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'dialog:saveToPath',
|
||||
async (_event, payload: { filePath: string; content: string }) => {
|
||||
const resolved = resolve(payload.filePath)
|
||||
const resolved = resolve(payload.filePath);
|
||||
if (resolved.includes('\0')) {
|
||||
throw new Error('Invalid file path')
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
const ext = extname(resolved).toLowerCase()
|
||||
const ext = extname(resolved).toLowerCase();
|
||||
if (ext !== '.op' && ext !== '.pen') {
|
||||
throw new Error('Only .op and .pen file extensions are allowed')
|
||||
throw new Error('Only .op and .pen file extensions are allowed');
|
||||
}
|
||||
const allowedRoots = [app.getPath('home'), app.getPath('temp')]
|
||||
const allowedRoots = [app.getPath('home'), app.getPath('temp')];
|
||||
const inAllowedDir = allowedRoots.some(
|
||||
(root) => resolved === root || resolved.startsWith(root + sep),
|
||||
)
|
||||
);
|
||||
if (!inAllowedDir) {
|
||||
throw new Error('File path must be within the user home or temp directory')
|
||||
throw new Error('File path must be within the user home or temp directory');
|
||||
}
|
||||
await writeFile(resolved, payload.content, 'utf-8')
|
||||
return resolved
|
||||
await writeFile(resolved, payload.content, 'utf-8');
|
||||
return resolved;
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
ipcMain.handle('file:getPending', () => {
|
||||
const filePath = getPendingFilePath()
|
||||
const filePath = getPendingFilePath();
|
||||
if (filePath) {
|
||||
clearPendingFilePath()
|
||||
return filePath
|
||||
clearPendingFilePath();
|
||||
return filePath;
|
||||
}
|
||||
return null
|
||||
})
|
||||
return null;
|
||||
});
|
||||
|
||||
ipcMain.handle('file:read', async (_event, filePath: string) => {
|
||||
const resolved = resolve(filePath)
|
||||
const ext = extname(resolved).toLowerCase()
|
||||
if (ext !== '.op' && ext !== '.pen') return null
|
||||
const resolved = resolve(filePath);
|
||||
const ext = extname(resolved).toLowerCase();
|
||||
if (ext !== '.op' && ext !== '.pen') return null;
|
||||
try {
|
||||
const content = await readFile(resolved, 'utf-8')
|
||||
return { filePath: resolved, content }
|
||||
const content = await readFile(resolved, 'utf-8');
|
||||
return { filePath: resolved, content };
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
ipcMain.handle('dialog:openImageFile', async () => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return null;
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Open image file',
|
||||
filters: [
|
||||
{
|
||||
name: 'Image Files',
|
||||
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'avif'],
|
||||
},
|
||||
],
|
||||
properties: ['openFile'],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
|
||||
const filePath = result.filePaths[0];
|
||||
return {
|
||||
filePath,
|
||||
name: filePath.split(/[\\/]/).pop() ?? 'image',
|
||||
content:
|
||||
extname(filePath).toLowerCase() === '.svg' ? await readFile(filePath, 'utf-8') : null,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
'dialog:confirmUnsavedChanges',
|
||||
async (
|
||||
_event,
|
||||
payload: {
|
||||
message: string;
|
||||
detail?: string;
|
||||
yesLabel: string;
|
||||
noLabel: string;
|
||||
cancelLabel: string;
|
||||
},
|
||||
) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) return 'cancel';
|
||||
const { response } = await dialog.showMessageBox(
|
||||
mainWindow,
|
||||
buildUnsavedChangesDialogOptions(payload),
|
||||
);
|
||||
return mapUnsavedChangesResponse(response);
|
||||
},
|
||||
);
|
||||
|
||||
// Theme sync for Windows/Linux title bar overlay
|
||||
ipcMain.handle(
|
||||
'theme:set',
|
||||
(_event, theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => {
|
||||
const mainWindow = getMainWindow()
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux'
|
||||
if (!isWinOrLinux) return
|
||||
const isLinux = process.platform === 'linux'
|
||||
const fallbackBg = theme === 'dark' ? '#111' : '#fff'
|
||||
const fallbackFg = theme === 'dark' ? '#d4d4d8' : '#3f3f46'
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux';
|
||||
if (!isWinOrLinux) return;
|
||||
const isLinux = process.platform === 'linux';
|
||||
const fallbackBg = theme === 'dark' ? '#111' : '#fff';
|
||||
const fallbackFg = theme === 'dark' ? '#d4d4d8' : '#3f3f46';
|
||||
mainWindow.setTitleBarOverlay({
|
||||
color: isLinux ? (colors?.bg || fallbackBg) : 'rgba(0,0,0,0)',
|
||||
color: isLinux ? colors?.bg || fallbackBg : 'rgba(0,0,0,0)',
|
||||
symbolColor: colors?.fg || fallbackFg,
|
||||
})
|
||||
});
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
// Renderer preferences
|
||||
ipcMain.handle('prefs:getAll', () => ({ ...prefsCache }))
|
||||
ipcMain.handle('prefs:getAll', () => ({ ...prefsCache }));
|
||||
|
||||
ipcMain.handle('prefs:set', (_event, key: string, value: string) => {
|
||||
prefsCache[key] = value
|
||||
schedulePrefsWrite()
|
||||
})
|
||||
prefsCache[key] = value;
|
||||
schedulePrefsWrite();
|
||||
});
|
||||
|
||||
ipcMain.handle('prefs:remove', (_event, key: string) => {
|
||||
delete prefsCache[key]
|
||||
schedulePrefsWrite()
|
||||
})
|
||||
delete prefsCache[key];
|
||||
schedulePrefsWrite();
|
||||
});
|
||||
|
||||
ipcMain.handle('log:getDir', () => getLogDir())
|
||||
// Recent files sync from renderer → main (for native menu)
|
||||
ipcMain.on(
|
||||
'recent-files:sync',
|
||||
(_event, files: Array<{ fileName: string; filePath: string }>) => {
|
||||
(global as any).__recentFiles = files;
|
||||
// Rebuild menu so "Open Recent" reflects current state
|
||||
import('./app-menu').then(({ buildAppMenu }) => buildAppMenu());
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle('log:getDir', () => getLogDir());
|
||||
|
||||
// Updater IPC
|
||||
ipcMain.handle('updater:getState', () => getUpdaterState())
|
||||
ipcMain.handle('updater:getState', () => getUpdaterState());
|
||||
ipcMain.handle('updater:checkForUpdates', async () => {
|
||||
await checkForAppUpdates(true)
|
||||
return getUpdaterState()
|
||||
})
|
||||
ipcMain.handle('updater:quitAndInstall', () => quitAndInstall())
|
||||
ipcMain.handle('updater:getAutoCheck', () => getAutoUpdateEnabled())
|
||||
await checkForAppUpdates(true);
|
||||
return getUpdaterState();
|
||||
});
|
||||
ipcMain.handle('updater:quitAndInstall', () => quitAndInstall());
|
||||
ipcMain.handle('updater:getAutoCheck', () => getAutoUpdateEnabled());
|
||||
|
||||
ipcMain.handle('updater:setAutoCheck', async (_event, enabled: boolean) => {
|
||||
setAutoUpdateEnabled(enabled)
|
||||
await writeAppSettings({ autoUpdate: enabled })
|
||||
setAutoUpdateEnabled(enabled);
|
||||
await writeAppSettings({ autoUpdate: enabled });
|
||||
|
||||
if (enabled) {
|
||||
startUpdateTimer()
|
||||
setUpdaterState({ status: 'idle' })
|
||||
startUpdateTimer();
|
||||
setUpdaterState({ status: 'idle' });
|
||||
} else {
|
||||
clearUpdateTimer()
|
||||
setUpdaterState({ status: 'disabled' })
|
||||
clearUpdateTimer();
|
||||
setUpdaterState({ status: 'disabled' });
|
||||
}
|
||||
return enabled
|
||||
})
|
||||
return enabled;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,34 +12,34 @@
|
|||
* log.warn('message')
|
||||
*/
|
||||
|
||||
import { appendFile, readdir, unlink, mkdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { appendFile, readdir, unlink, mkdir, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
let logDir = ''
|
||||
let logFilePath = ''
|
||||
let initialized = false
|
||||
let logDir = '';
|
||||
let logFilePath = '';
|
||||
let initialized = false;
|
||||
|
||||
const MAX_LOG_DAYS = 7
|
||||
const MAX_LOG_DAYS = 7;
|
||||
|
||||
function timestamp(): string {
|
||||
return new Date().toISOString()
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function todayStamp(): string {
|
||||
return new Date().toISOString().slice(0, 10) // YYYY-MM-DD
|
||||
return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
}
|
||||
|
||||
async function writeLine(level: string, msg: string): Promise<void> {
|
||||
if (!initialized) return
|
||||
const line = `${timestamp()} [${level}] ${msg}\n`
|
||||
if (!initialized) return;
|
||||
const line = `${timestamp()} [${level}] ${msg}\n`;
|
||||
// Also forward to console for dev mode
|
||||
if (level === 'ERROR') {
|
||||
process.stderr.write(line)
|
||||
process.stderr.write(line);
|
||||
} else {
|
||||
process.stdout.write(line)
|
||||
process.stdout.write(line);
|
||||
}
|
||||
try {
|
||||
await appendFile(logFilePath, line, 'utf-8')
|
||||
await appendFile(logFilePath, line, 'utf-8');
|
||||
} catch {
|
||||
// Disk full or permission error — silently drop
|
||||
}
|
||||
|
|
@ -47,15 +47,15 @@ async function writeLine(level: string, msg: string): Promise<void> {
|
|||
|
||||
async function cleanOldLogs(): Promise<void> {
|
||||
try {
|
||||
const files = await readdir(logDir)
|
||||
const cutoff = Date.now() - MAX_LOG_DAYS * 24 * 60 * 60 * 1000
|
||||
const files = await readdir(logDir);
|
||||
const cutoff = Date.now() - MAX_LOG_DAYS * 24 * 60 * 60 * 1000;
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.log')) continue
|
||||
const filePath = join(logDir, file)
|
||||
if (!file.endsWith('.log')) continue;
|
||||
const filePath = join(logDir, file);
|
||||
try {
|
||||
const s = await stat(filePath)
|
||||
const s = await stat(filePath);
|
||||
if (s.mtimeMs < cutoff) {
|
||||
await unlink(filePath)
|
||||
await unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
// ignore individual file errors
|
||||
|
|
@ -70,26 +70,32 @@ async function cleanOldLogs(): Promise<void> {
|
|||
* Initialize the logger. Must be called after `app.getPath('userData')` is available.
|
||||
*/
|
||||
export async function initLogger(userDataPath: string): Promise<void> {
|
||||
logDir = join(userDataPath, 'logs')
|
||||
logFilePath = join(logDir, `main-${todayStamp()}.log`)
|
||||
logDir = join(userDataPath, 'logs');
|
||||
logFilePath = join(logDir, `main-${todayStamp()}.log`);
|
||||
try {
|
||||
await mkdir(logDir, { recursive: true })
|
||||
await mkdir(logDir, { recursive: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
initialized = true
|
||||
await writeLine('INFO', '--- OpenPencil started ---')
|
||||
initialized = true;
|
||||
await writeLine('INFO', '--- OpenPencil started ---');
|
||||
// Clean old logs in background
|
||||
cleanOldLogs()
|
||||
cleanOldLogs();
|
||||
}
|
||||
|
||||
/** Get the log directory path (for displaying to users). */
|
||||
export function getLogDir(): string {
|
||||
return logDir
|
||||
return logDir;
|
||||
}
|
||||
|
||||
export const log = {
|
||||
info: (msg: string) => { writeLine('INFO', msg) },
|
||||
warn: (msg: string) => { writeLine('WARN', msg) },
|
||||
error: (msg: string) => { writeLine('ERROR', msg) },
|
||||
}
|
||||
info: (msg: string) => {
|
||||
writeLine('INFO', msg);
|
||||
},
|
||||
warn: (msg: string) => {
|
||||
writeLine('WARN', msg);
|
||||
},
|
||||
error: (msg: string) => {
|
||||
writeLine('ERROR', msg);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue