From 718bb93a0cbc57d9b953f883fae27fa1ed9bdf32 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sat, 30 May 2026 16:12:50 -0400 Subject: [PATCH 01/19] chore: fix docs-only lint-staged commits --- .lintstagedrc.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 69abf9b..df251e3 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,3 +1,4 @@ { - "*.{js,jsx,ts,tsx,mjs,cjs,json,jsonc,md,yml,yaml,css,astro}": ["oxfmt --check", "oxlint"] + "*.{js,jsx,ts,tsx,mjs,cjs}": ["oxfmt --check", "oxlint"], + "*.{json,jsonc,md,yml,yaml,css,astro}": "oxfmt --check" } From 12930ba3ab61ec5b64b084a1849fdde81db0072b Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Sat, 30 May 2026 16:13:05 -0400 Subject: [PATCH 02/19] docs: add Caplets Cloud product design --- ...-30-caplets-cloud-hosted-product-design.md | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 docs/specs/2026-05-30-caplets-cloud-hosted-product-design.md diff --git a/docs/specs/2026-05-30-caplets-cloud-hosted-product-design.md b/docs/specs/2026-05-30-caplets-cloud-hosted-product-design.md new file mode 100644 index 0000000..79a6e35 --- /dev/null +++ b/docs/specs/2026-05-30-caplets-cloud-hosted-product-design.md @@ -0,0 +1,333 @@ +# Caplets Cloud Hosted Product Design + +## Status + +Approved product and architecture design from brainstorming and grilling session. Implementation plan is intentionally separate. + +## Goal + +Define the hosted Caplets product that lets users authenticate, configure tools once, expose a remote OAuth-protected MCP endpoint, and run Caplets remotely without operating their own server. + +The product should drive conversion through two concrete promises: + +- Connect once across agent clients. +- Show agents a smaller, staged tool surface instead of a giant flat tool wall. + +Caplets Cloud should serve solo developers first, while using workspace-shaped data boundaries so team workspaces can be added later. + +## Non-goals + +- Do not replace local Caplets or remove local mode. +- Do not make Basic Auth part of the hosted product surface. +- Do not make hosted UI design decisions outside the Caplets product design system. +- Do not require users to understand Mutagen or manually configure sync sessions. +- Do not expose project-bound Caplets to remote-only clients that cannot use them. +- Do not include task sequencing, milestones, or checklists in this spec. Those belong in a separate implementation plan. + +## Product Positioning + +Caplets Cloud is not generic "managed MCP hosting." It is a hosted capability layer for coding agents. + +The core promise is: + +```text +Connect once. Show agents less. +``` + +The hosted product should make setup speed and tool-surface reduction visible: + +- One hosted MCP URL per workspace. +- OAuth-based remote MCP connection flows for supported clients. +- Hosted connector setup for common developer tools. +- Session-aware tool lists that hide unusable capabilities. +- Per-workspace Tool Surface Report showing the reduction from flat tools to Caplet cards. + +The Tool Surface Report should be modeled on the existing deterministic benchmark but calculated from the user's actual workspace when possible: + +- Direct downstream tools hidden behind Caplets. +- Visible Caplet count. +- Initial payload or approximate context-surface estimate. +- Duplicate tool-name collisions avoided. +- Discovery trace such as `search_tools -> get_tool -> call_tool`. + +Claims must remain precise. Caplets Cloud can claim reduced initial MCP tool-surface payload and fewer initially visible candidate tools. It must not claim a universal provider bill reduction without provider-specific evidence. + +## Workspace Boundary + +`workspace` is the primary hosted tenant boundary. + +All hosted state is scoped to a workspace: + +- Hosted Caplet definitions. +- Hosted connector configuration. +- Provider credentials and secret vault entries. +- MCP OAuth client grants and sessions. +- Local presence registrations. +- Runtime leases. +- Tool-surface reports. +- Sync/apply receipts. +- Audit events. +- Usage and billing records. + +Solo v1 gives a user one personal workspace. Team support later adds members, roles, audit trails, shared billing, and policy controls to the same workspace object. + +## Hosted Authentication + +Hosted MCP access is OAuth-only. + +Basic Auth remains appropriate for local and self-hosted `caplets serve --transport http`, but hosted onboarding and hosted client configuration must not use Basic Auth. + +Hosted MCP authentication requirements: + +- Workspace MCP endpoints require OAuth. +- MCP clients authorize through an MCP-compatible OAuth flow where supported. +- Dynamic client registration should be supported where required by MCP-compatible clients. +- Access tokens are scoped to workspace, client/session, and allowed capability surface. +- Token revocation and session invalidation are first-class. +- Hosted web account login is separate from MCP client authorization, though both map to the same workspace. + +Provider auth is also workspace-scoped but separate from MCP client auth. For example, GitHub or Linear credentials live in the hosted secret vault and are used by hosted connectors. Local/project auth remains local when local overlays execute locally. + +## Execution Classes + +Caplets Cloud supports multiple execution classes behind one logical workspace MCP endpoint. + +### Hosted Connectors + +Hosted connectors run fully in cloud and require no local project presence. Examples include: + +- GitHub +- Linear +- Sourcegraph +- OSV +- npm +- PyPI +- Documentation and search connectors + +The connector catalog should avoid an empty first-run experience. Each connector should have connect, test, inspect, and status states. + +### Hosted Remote MCP And API Caplets + +Hosted remote Caplets call external services from cloud: + +- Streamable HTTP MCP servers. +- Legacy HTTP/SSE MCP servers where supported. +- OpenAPI endpoints. +- GraphQL endpoints. +- Explicit HTTP action Caplets. + +Hosted secrets and provider auth are used only server-side and must not be exposed through generated tool descriptions, logs, or errors. + +### Hosted Stdio And CLI Caplets + +Hosted stdio MCP servers and CLI Caplets run in managed sandbox runtimes. This is an MVP differentiator because many useful MCP servers remain stdio-only. + +The runtime must not assume one workspace container can be vertically resized while running. Treat stdio and CLI backends as schedulable workloads: + +- A workspace may use one or more ephemeral sandboxes. +- Each sandbox runs a Caplets supervisor plus assigned backend processes. +- Idle child processes stop first. +- Empty sandboxes sleep or stop after a short idle window. +- Heavy backends can be restarted in larger sandboxes when needed. + +### Project-Bound Remote Stdio And CLI Caplets + +Project-bound remote Caplets run in hosted sandboxes but sync and apply against a local project through an active local Caplets runtime. + +This is for tools that need project files but benefit from remote execution or remote-client availability. + +### Local Overlays + +Existing local overlay behavior remains central: + +- In remote mode, the effective surface is remote first, then user-global local, then project-local. +- User-global and project-local Caplets run locally. +- Local Caplets shadow hosted Caplets by ID. +- Project-local Caplets have the highest priority. + +This behavior already exists for native integrations and CLI remote mode and should be treated as a core product feature. + +## Session-Aware Capability Availability + +Hosted MCP tool lists are session-aware. + +Remote-only sessions see only hosted-capable Caplets. Project-bound Caplets are hidden unless a compatible local presence is online for the workspace. Agent-facing tool lists should not include capabilities that will fail immediately because the current session cannot execute them. + +Availability rules: + +- `executionTarget: "hosted"` is visible to remote-only sessions. +- `executionTarget: "project-bound"` is visible only when compatible local presence exists. +- `executionTarget: "auto"` is visible when hosted execution or project-bound execution is currently available. +- Local developer sessions continue to see the merged remote/local surface. +- Tool-list changes should emit MCP tool-list changed notifications when the transport and client support them. +- Clients that do not refresh dynamically should see the correct surface on the next session. + +`caplets doctor` and hosted diagnostics can show hidden Caplets with reasons. The agent-facing surface should hide them. + +## Local Presence + +A local Caplets runtime in `CAPLETS_MODE=remote` registers local presence for the current project root. + +Launching and configuring local Caplets in remote mode is treated as user consent for that local runtime to assist the hosted workspace. No extra per-client approval prompt is required. + +Presence is workspace-wide while online, but tightly scoped: + +- Same hosted workspace only. +- One current project root per local runtime. +- Project root fingerprint. +- Declared allowed Caplet IDs. +- Sync/apply policy. +- Heartbeat and expiry. +- Audit trail. + +Any authenticated remote MCP session in the same workspace may use compatible project-bound Caplets while that presence is online. Users revoke presence by stopping the local runtime, disabling remote mode, changing project policy, or revoking workspace credentials. + +## Project Sync + +Project-bound remote stdio and CLI execution uses managed project sync. + +Mutagen is the MVP sync provider. Caplets bundles and manages it automatically in the local CLI/native integration. Users should not have to install, configure, or operate Mutagen directly. + +The user-facing concept is project-bound remote execution. + +Sync rules: + +- The authoritative filesystem is the bound apply target, not the remote sandbox. +- The remote sandbox mirror is a disposable execution copy. +- Before a mutating remote call, Caplets syncs the mirror from the authoritative target. +- After execution, clean changes sync/apply back to the authoritative target. +- If the mirror becomes suspicious or stale, Caplets can discard and rebuild it from the authoritative target. +- Mutating remote calls are serialized per bound project target in v1. + +Sync scope is controlled by project rules: + +- Use `.capletsignore` when present. +- Use `.gitignore` and git exclude rules when inside a git repo. +- If not inside a git repo, use `.capletsignore` only. +- Do not invent broad hidden ignore defaults. +- Always exclude Caplets internal sync metadata. +- Secret scanning and size/file-count limits are policy checks, not silent ignore rules. + +`caplets doctor` should show the effective sync scope, including ignored files, policy blockers, and Mutagen status. + +## Implicit Apply And Conflict Handling + +Remote side-effecting tools should feel like normal tools. Clean changes apply implicitly to the bound target. + +Flow: + +1. Caplets syncs the remote mirror to the authoritative target. +2. The remote stdio or CLI backend runs in the sandbox. +3. Caplets captures the resulting filesystem changes. +4. Caplets applies clean changes back to the authoritative target. +5. The tool result includes an apply receipt. +6. Conflicts or policy violations return structured MCP results. + +Apply receipts should include: + +- Files created, modified, deleted, and skipped. +- Whether apply was clean. +- Sync version or target fingerprint. +- Runtime and Caplet identifiers. +- Policy warnings. +- Rollback metadata when available. + +Conflict results should be recoverable by agents. They should include enough structured data for an agent to inspect the conflict, generate a resolution, and retry without human intervention when safe. + +Human intervention is reserved for: + +- Secret or policy blocks. +- Unsafe paths. +- Oversized or unsupported changes. +- Repeated unresolved conflicts. +- Explicit workspace policy requiring review. + +## Hosted UI Requirements + +Hosted UI work must use the `impeccable` workflow for UX shaping, polish, and review. + +The hosted app is product UI, not a marketing surface. It should be dense, predictable, inspectable, and calm. + +Design requirements: + +- Preserve the existing Caplets design system: warm technical surfaces, charred ink, rare ember, ash borders, compact Inter typography, and monospace only for machine-facing content. +- Avoid generic SaaS hero-metric patterns. +- Make the Tool Surface Report feel like a benchmark or inspection artifact, not a celebratory stats grid. +- Use familiar developer-tool affordances: side navigation, tabs, tables, status rows, command snippets, copy buttons, scoped filters, and inline diagnostics. +- Show explicit states for default, loading, empty, connected, degraded, hidden, blocked, conflict, revoked, expired, and unauthorized. +- Use accessible status treatment with no color-only state, visible focus, keyboard navigation, contrast checks, reduced-motion alternatives, and readable dense tables. +- Use progressive disclosure in the UI: show capability, source, status, and next action first; reveal schemas, raw config, OAuth details, sync logs, and patch details only when requested. + +Primary hosted workflows: + +- Workspace MCP endpoint and OAuth client setup. +- Connector catalog. +- Tool Surface Report. +- Runtime status. +- Local presence and hidden Caplets diagnostics. +- Sync/apply receipts. +- Conflict review and recovery. +- Audit trail. + +## `caplets doctor` + +`caplets doctor` is the local diagnostic and repair surface. It should show project binding and sync implications only when remote mode is active. + +Doctor should report: + +- Remote mode configuration. +- Hosted reachability. +- OAuth/token/session status where safe. +- Current project root and project fingerprint. +- Whether local presence is registered. +- Mutagen availability, version, and sync health. +- `.gitignore` and `.capletsignore` effects. +- Secret-scan or quota blockers. +- Hidden project-bound Caplets and why they are hidden. +- Recent apply conflicts or failed syncs. + +Doctor should help users understand what the agent cannot see, what the hosted service can execute, and what local project state is currently exposed to Caplets Cloud. + +## Security And Trust + +Hosted Caplets handles sensitive boundaries: + +- OAuth-authenticated MCP clients. +- Hosted provider credentials. +- Local project presence. +- Remote sandbox execution. +- Filesystem sync and implicit apply. + +Required controls: + +- Hosted MCP uses OAuth, not Basic Auth. +- Hosted provider secrets are encrypted at rest and redacted everywhere. +- Local presence is scoped to one project root and one workspace. +- Project-bound Caplets are hidden unless executable in the current session. +- Remote stdio/CLI execution has process, time, disk, memory, and network limits. +- Mutating operations are audited. +- Secret scanning can block sync/apply. +- Sync metadata and internal control files are never exposed as normal project files. +- Apply operations validate paths and reject traversal or absolute-path writes outside the target. +- Policy blocks are explicit and agent-readable. + +## Open Risks + +- Mutagen packaging and licensing must be validated. Newer official builds include SSPL-licensed code by default; the MVP must confirm a viable licensing and distribution path. +- Cloud sandbox substrate must be validated for process lifecycle, filesystem semantics, networking, cold start, and cost. Cloudflare sandbox containers are a strong candidate because the repo already uses Cloudflare via Alchemy, but the implementation plan should compare viable substrates. +- MCP OAuth compatibility varies by client. Hosted Caplets should support standard flows and document client-specific limitations. +- Tool-list changed notifications may not be honored by all clients, so availability changes may require a new session in some clients. +- Sync conflict recovery needs careful structured result design so agents can resolve conflicts without overexposing project data. +- Implicit apply is a powerful capability. Workspace policy, doctor output, audit trails, and rollback metadata must make it trustworthy. + +## Success Criteria + +- A solo developer can create a workspace, authorize an MCP client through OAuth, and connect to one hosted MCP URL. +- The hosted app shows a Tool Surface Report for the workspace. +- Remote-only sessions see only hosted-capable Caplets. +- Local remote-mode sessions see the merged hosted plus local overlay surface. +- Project-bound Caplets become available to workspace sessions while compatible local presence is online. +- Clean remote stdio/CLI changes apply implicitly to the bound project target. +- Conflicts return structured recoverable MCP results. +- `caplets doctor` explains remote mode, local presence, sync state, hidden Caplets, and policy blockers. +- Hosted UI follows the Caplets product design system and uses `impeccable` for UI shaping and review. From 3edd9f61f7e94cff6f131fec4b2039321adab5aa Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 1 Jun 2026 08:05:00 -0400 Subject: [PATCH 03/19] feat: hosted cloud product initialization --- .gitignore | 1 + ..._apps-cloud-ui-src-routes-workspace-tsx.md | 108 +++ ..._apps-cloud-ui-src-routes-workspace-tsx.md | 106 +++ .lintstagedrc.json | 3 +- alchemy.run.ts | 55 +- apps/cloud-ui/src/lib/cloud-api.ts | 78 ++ apps/cloud-ui/src/lib/mock-workspace.ts | 123 +++ apps/landing/package.json | 3 + caplets/ast-grep/CAPLET.md | 22 +- caplets/context7/CAPLET.md | 24 +- caplets/playwright/CAPLET.md | 27 +- ...-30-caplets-cloud-hosted-product-design.md | 333 ------- infra/alchemy-domains.ts | 44 + .../alchemy-fetch-compat.test.ts | 2 +- {scripts => infra}/alchemy-fetch-compat.ts | 0 infra/alchemy-runner.test.ts | 86 ++ {scripts => infra}/alchemy-runner.ts | 0 output/playwright/cloud-ui-critique.png | Bin 0 -> 127872 bytes package.json | 19 +- packages/core/package.json | 4 + packages/core/rolldown.config.ts | 1 + packages/core/src/caplet-files.ts | 31 + packages/core/src/cli.ts | 17 + packages/core/src/cli/commands.ts | 2 + packages/core/src/cli/doctor.ts | 27 + packages/core/src/cli/setup-caplet.ts | 110 +++ packages/core/src/cli/setup.ts | 10 + packages/core/src/cloud-runtime.ts | 17 + packages/core/src/cloud/apply.ts | 110 +++ packages/core/src/cloud/client.ts | 95 ++ packages/core/src/cloud/mutagen.ts | 49 ++ packages/core/src/cloud/presence.ts | 84 ++ packages/core/src/cloud/project-root.ts | 35 + packages/core/src/cloud/runtime-adapter.ts | 171 ++++ packages/core/src/cloud/runtime-http.ts | 52 ++ packages/core/src/cloud/sync.ts | 100 +++ packages/core/src/config.ts | 50 ++ packages/core/src/index.ts | 13 + packages/core/src/native/options.ts | 61 ++ packages/core/src/native/service.ts | 51 +- packages/core/src/server/options.ts | 9 +- packages/core/src/setup/hash.ts | 50 ++ packages/core/src/setup/local-store.ts | 105 +++ packages/core/src/setup/runner.ts | 187 ++++ packages/core/src/setup/types.ts | 48 + packages/core/src/tools.ts | 20 + packages/core/test/cloud-apply.test.ts | 97 ++ packages/core/test/cloud-client.test.ts | 92 ++ packages/core/test/cloud-mutagen.test.ts | 47 + packages/core/test/cloud-presence.test.ts | 99 +++ packages/core/test/cloud-project-root.test.ts | 34 + packages/core/test/cloud-sync.test.ts | 64 ++ packages/core/test/config.test.ts | 125 ++- packages/core/test/doctor-cli.test.ts | 33 + packages/core/test/native-remote.test.ts | 118 +++ packages/core/test/setup-runner.test.ts | 179 ++++ pnpm-lock.yaml | 826 +++++++++--------- schemas/caplet.schema.json | 111 +++ schemas/caplets-config.schema.json | 660 ++++++++++++++ scripts/alchemy-runner.test.ts | 14 - scripts/mutagen-probe.test.ts | 23 + scripts/mutagen-probe.ts | 19 + tsconfig.json | 3 + vitest.config.ts | 4 +- 64 files changed, 4290 insertions(+), 801 deletions(-) create mode 100644 .impeccable/critique/2026-05-31T12-08-49Z__apps-cloud-ui-src-routes-workspace-tsx.md create mode 100644 .impeccable/critique/2026-05-31T12-57-16Z__apps-cloud-ui-src-routes-workspace-tsx.md create mode 100644 apps/cloud-ui/src/lib/cloud-api.ts create mode 100644 apps/cloud-ui/src/lib/mock-workspace.ts delete mode 100644 docs/specs/2026-05-30-caplets-cloud-hosted-product-design.md create mode 100644 infra/alchemy-domains.ts rename {scripts => infra}/alchemy-fetch-compat.test.ts (93%) rename {scripts => infra}/alchemy-fetch-compat.ts (100%) create mode 100644 infra/alchemy-runner.test.ts rename {scripts => infra}/alchemy-runner.ts (100%) create mode 100644 output/playwright/cloud-ui-critique.png create mode 100644 packages/core/src/cli/doctor.ts create mode 100644 packages/core/src/cli/setup-caplet.ts create mode 100644 packages/core/src/cloud-runtime.ts create mode 100644 packages/core/src/cloud/apply.ts create mode 100644 packages/core/src/cloud/client.ts create mode 100644 packages/core/src/cloud/mutagen.ts create mode 100644 packages/core/src/cloud/presence.ts create mode 100644 packages/core/src/cloud/project-root.ts create mode 100644 packages/core/src/cloud/runtime-adapter.ts create mode 100644 packages/core/src/cloud/runtime-http.ts create mode 100644 packages/core/src/cloud/sync.ts create mode 100644 packages/core/src/setup/hash.ts create mode 100644 packages/core/src/setup/local-store.ts create mode 100644 packages/core/src/setup/runner.ts create mode 100644 packages/core/src/setup/types.ts create mode 100644 packages/core/test/cloud-apply.test.ts create mode 100644 packages/core/test/cloud-client.test.ts create mode 100644 packages/core/test/cloud-mutagen.test.ts create mode 100644 packages/core/test/cloud-presence.test.ts create mode 100644 packages/core/test/cloud-project-root.test.ts create mode 100644 packages/core/test/cloud-sync.test.ts create mode 100644 packages/core/test/doctor-cli.test.ts create mode 100644 packages/core/test/setup-runner.test.ts delete mode 100644 scripts/alchemy-runner.test.ts create mode 100644 scripts/mutagen-probe.test.ts create mode 100644 scripts/mutagen-probe.ts diff --git a/.gitignore b/.gitignore index 7c7430c..d7aba94 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ benchmark-results/ # Turbo .turbo/ +.astro/ # Pi .pi-lens/ diff --git a/.impeccable/critique/2026-05-31T12-08-49Z__apps-cloud-ui-src-routes-workspace-tsx.md b/.impeccable/critique/2026-05-31T12-08-49Z__apps-cloud-ui-src-routes-workspace-tsx.md new file mode 100644 index 0000000..ede93b7 --- /dev/null +++ b/.impeccable/critique/2026-05-31T12-08-49Z__apps-cloud-ui-src-routes-workspace-tsx.md @@ -0,0 +1,108 @@ +--- +target: cloud ui +total_score: 22 +p0_count: 1 +p1_count: 2 +timestamp: 2026-05-31T12-08-49Z +slug: apps-cloud-ui-src-routes-workspace-tsx +--- + +# Caplets Cloud UI Critique + +## Design Health Score + +| # | Heuristic | Score | Key Issue | +| --------- | ------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Visibility of System Status | 2 | The live app shows an error state, but successful workspace health is split across status, presence, receipts, and audit without a clear current state summary. | +| 2 | Match System / Real World | 3 | The product language is credible for MCP and Caplets users, but terms such as local presence, sandbox leases, and implicit apply arrive before enough operational framing. | +| 3 | User Control and Freedom | 2 | Primary actions are mostly disabled or copy-only, and there is no visible way to retry, reconnect, authorize, revoke, or inspect details from the error/current states. | +| 4 | Consistency and Standards | 3 | Components are visually consistent and restrained, though every panel uses the same eyebrow/title/card rhythm. | +| 5 | Error Prevention | 2 | The UI explains OAuth-only and local presence concepts, but does not prevent dead-end states such as disabled Add connector and Run caplets doctor buttons. | +| 6 | Recognition Rather Than Recall | 2 | Users must infer how Endpoint, Connectors, Runtime, Presence, Receipts, and Audit relate to the setup path. | +| 7 | Flexibility and Efficiency | 2 | The dashboard lacks compact drill-downs, filters, copyable commands beyond the endpoint, or direct remediation actions for power users. | +| 8 | Aesthetic and Minimalist Design | 3 | The palette and density fit Caplets, but the grid background and repeated panel pattern flatten the page into a static spec sheet. | +| 9 | Error Recovery | 1 | The live error exposes a browser implementation message and gives no retry, fallback, or useful next step. | +| 10 | Help and Documentation | 2 | Copy is precise, but contextual help is too sparse for high-risk setup concepts like project-bound execution and apply receipts. | +| **Total** | | **22/40** | **Needs focused product polish** | + +## Anti-Patterns Verdict + +**LLM assessment**: This does not look like generic AI SaaS. It avoids hero metrics, gradient text, glass, huge rounded cards, and neon developer theater. The slop risk is subtler: repeated panel headers, disabled buttons, and a dashboard made from similarly weighted boxes make the workspace feel mocked rather than operated. + +**Deterministic scan**: CLI detector over `apps/cloud-ui/src` returned `[]`. Browser overlay on the live error state reported one `hero-eyebrow-chip` finding for `Workspace unavailable` above `Could not load workspace`. That is a fair warning because the error card inherits the same label treatment used elsewhere, making failure feel like another marketing/info panel instead of a recovery state. + +**Visual overlays**: Injection succeeded in the browser session after the live app loaded, and the console reported the eyebrow-chip issue on the error page. The overlay could only evaluate the error state because the app failed before the workspace UI rendered. + +## Overall Impression + +The intended direction is right: quiet, technical, dense, and specific to Caplets Cloud. The problem is that the running UI currently fails before users can reach it, and the designed workspace state does not yet behave like a control plane. It communicates the product thesis, but it does not yet help a developer complete setup, diagnose availability, or recover from failure. + +## What's Working + +- The visual register is aligned with Caplets: warm light surfaces, restrained ember, compact type, thin borders, and no theatrical dark-mode or generic SaaS polish. +- The copy has real product proof: `3 visible Caplets`, `106 hidden downstream tools`, OAuth-only hosted MCP, local presence, and sync/apply receipts are concrete. +- The information model includes the right control-plane objects: endpoint, connector catalog, tool surface report, runtime status, local presence, receipts, and audit trail. + +## Priority Issues + +### [P0] Live workspace route fails before showing the dashboard + +**Why it matters**: A user sees `Failed to execute fetch on Window: Illegal invocation` instead of the workspace. That destroys trust and prevents evaluation of every other design decision. + +**Fix**: Bind or wrap the default fetch implementation in `CloudApiClient` instead of storing the unbound browser `fetch`, then render a product-safe error only for real API failures. Add retry and a fallback diagnostic line that says which workspace/API URL failed. + +**Suggested command**: `$impeccable harden cloud ui` + +### [P1] Error recovery is raw, non-actionable, and visually under-prioritized + +**Why it matters**: Cloud setup failures are expected: auth, workspace creation, API reachability, local presence, connector auth. The current error state exposes implementation text and gives no path forward. + +**Fix**: Replace raw exception copy with a recovery panel: failed target, retry button, sign in/authorize action when relevant, workspace slug, API base URL, and a compact details disclosure for developer diagnostics. + +**Suggested command**: `$impeccable harden cloud ui` + +### [P1] The successful workspace state reads like a static product proof, not an operational workflow + +**Why it matters**: The page proves the thesis but does not guide the user through the next action. Disabled Add connector and Run caplets doctor controls signal unfinished product rather than unavailable state. + +**Fix**: Promote a setup/status rail above the panels: Connect MCP client, Add or authorize connector, Start local presence, Verify tool surface. Disabled actions need reasons and adjacent enabled alternatives, such as copy CLI command or open OAuth flow. + +**Suggested command**: `$impeccable onboard cloud ui` + +### [P2] Panel hierarchy is too even + +**Why it matters**: Endpoint, connectors, surface report, runtime, presence, receipts, and audit all compete at similar weight. Users cannot quickly tell what needs attention now. + +**Fix**: Make the endpoint/current health area the primary operational header, use a compact two-column health summary, and demote receipts/audit into tabs or collapsible inspection sections. Keep connectors as the main editable surface. + +**Suggested command**: `$impeccable layout cloud ui` + +### [P2] State cues rely too much on color and terse labels + +**Why it matters**: `Ready`, `Needs OAuth`, `Local required`, `presence required`, and colored dots are meaningful only after the user already understands the system. + +**Fix**: Pair every status with an icon/shape and a one-line action or reason. Example: `Needs OAuth: authorize Sourcegraph to expose this Caplet`. Use warning styling for actual action-required states, not just a yellow dot. + +**Suggested command**: `$impeccable clarify cloud ui` + +## Persona Red Flags + +**Morgan, agent power user**: They want to connect the endpoint and verify what agents will see. They can copy the endpoint, but cannot inspect the exact capability cards, simulate a client session, filter hidden tools, or see why a project-bound Caplet is unavailable. + +**Riley, first-time cloud user**: They land on an implementation error in the live app. Even in the intended state, `local presence`, `project-bound remote execution`, and `implicit apply pending` appear without enough action framing. The next step is unclear. + +**Sam, security-conscious team lead**: OAuth-only is visible, and audit exists, but there is no obvious token/session revocation, connector credential boundary, local presence revocation, or policy blocker detail. Trust signals are present but not inspectable enough. + +## Minor Observations + +- The global grid background is tasteful but constant. On long product surfaces it adds visual texture without improving scanability. +- The disabled buttons need `aria-describedby` reasons or inline explanatory text. +- The sidebar nav has no active section state, so it works as a table of contents rather than workspace navigation. +- The audit table is readable, but on mobile it becomes horizontal scroll. A stacked event list would be more usable below 640px. +- `Tool Surface Report` has one duplicated concept: `106 hidden downstream tools` appears in both `dt` and `dd`. + +## Questions to Consider + +- What is the one setup state Caplets Cloud should make impossible to miss? +- Should the first screen optimize for connecting an MCP client, authorizing connectors, or proving tool-surface reduction? +- Which trust details need to be inspectable before a developer lets project-bound execution touch a local repo? diff --git a/.impeccable/critique/2026-05-31T12-57-16Z__apps-cloud-ui-src-routes-workspace-tsx.md b/.impeccable/critique/2026-05-31T12-57-16Z__apps-cloud-ui-src-routes-workspace-tsx.md new file mode 100644 index 0000000..117f870 --- /dev/null +++ b/.impeccable/critique/2026-05-31T12-57-16Z__apps-cloud-ui-src-routes-workspace-tsx.md @@ -0,0 +1,106 @@ +--- +target: cloud UI +total_score: 22 +p0_count: 1 +p1_count: 2 +timestamp: 2026-05-31T12-57-16Z +slug: apps-cloud-ui-src-routes-workspace-tsx +--- + +# Design Health Score + +| # | Heuristic | Score | Key Issue | +| --------- | ------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | Visibility of System Status | 1 | The intended UI has status panels, but the live page is replaced by a Vite import error. | +| 2 | Match System / Real World | 3 | Developer-tool concepts mostly map well, though `local presence`, `sync/apply receipts`, and `tool surface` need more user-facing framing. | +| 3 | User Control and Freedom | 2 | Copy, retry, and anchor navigation exist, but there is no clear active route, current step control, or way back from blocked setup states. | +| 4 | Consistency and Standards | 3 | The component vocabulary is restrained and consistent, but the repeated panel-header pattern flattens sections. | +| 5 | Error Prevention | 1 | Missing modules block rendering, and status copy can imply health even while required setup is incomplete. | +| 6 | Recognition Rather Than Recall | 3 | The setup path, metrics, and command callouts reduce recall, but several commands are detached from their exact next action. | +| 7 | Flexibility and Efficiency | 2 | Sidebar anchors and copy buttons help, but there are no power-user shortcuts, filters, or compact inspection affordances. | +| 8 | Aesthetic and Minimalist Design | 3 | Quiet, usable product styling, with some panel sameness and cool-token drift from the stated design system. | +| 9 | Error Recovery | 2 | Source includes a recovery panel, retry, and diagnostics, but the actual browser error bypasses it entirely. | +| 10 | Help and Documentation | 2 | Inline instructions exist, but the UI lacks contextual help for local presence, connector authorization, and audit interpretation. | +| **Total** | | **22/40** | **Promising, currently blocked** | + +# Anti-Patterns Verdict + +**LLM assessment**: This does not read as instant AI slop. It has a credible developer-tool shell, restrained borders, compact typography, and useful non-color status shapes. The weak spots are product-specific: too many equal panels, repeated eyebrow-plus-title headers, and a first screen that describes the system more than it drives the next task. + +**Deterministic scan**: `detect.mjs --json apps/cloud-ui/src/routes/workspace.tsx apps/cloud-ui/src/components apps/cloud-ui/src/styles/global.css` returned `[]`. No bundled detector findings. + +**Visual overlays**: No reliable user-visible overlay is available. The Vite page fails before the app renders because `src/routes/workspace.tsx` cannot resolve `../lib/cloud-api`; browser evidence is the Vite import-analysis overlay, not the intended UI. + +# Overall Impression + +The design direction is right for Caplets Cloud: calm, technical, inspectable. The app shell wants to be a focused cloud workspace dashboard, not a marketing page, and the source mostly honors that. The biggest opportunity is to make the first screen operational: one current blocker, one primary next action, then inspectable detail. + +# What's Working + +1. The visual system is product-appropriate. The sidebar, flat panels, tight radii, visible focus style, and reduced-motion handling support a serious developer tool. +2. The source includes real state thinking: loading, error, empty, ready, connector status, local presence, sync receipts, runtime rows, and audit rows. +3. Copy is mostly concrete. Labels like `Copy endpoint`, `Retry workspace load`, and `Open OAuth metadata` describe actions rather than vague confirmations. + +# Priority Issues + +**[P0] The cloud UI does not render** + +**Why it matters**: A user never reaches the workspace dashboard. Vite shows `[plugin:vite:import-analysis] Failed to resolve import "../lib/cloud-api" from "src/routes/workspace.tsx"` at `apps/cloud-ui/src/routes/workspace.tsx:16`. + +**Fix**: Restore or create `apps/cloud-ui/src/lib/cloud-api.ts` and `apps/cloud-ui/src/lib/mock-workspace.ts`, then make `pnpm --filter @caplets/cloud-ui typecheck` and the Vite route pass before any visual polish. + +**Suggested command**: `$impeccable harden cloud UI` + +**[P1] The first screen lacks a single next step** + +**Why it matters**: The UI presents endpoint copy, a four-step setup rail, connectors, runtime, local presence, receipts, and audit as peers. A first-time workspace owner has to infer which blocker matters now. + +**Fix**: Promote a state-driven "Next action" area directly under the header. When Sourcegraph needs OAuth, make that the primary action. When local presence is missing, make the doctor command the primary action. Move completed proof into compact secondary detail. + +**Suggested command**: `$impeccable layout cloud UI` + +**[P1] Sidebar health can contradict workspace blockers** + +**Why it matters**: The sidebar says `OAuth active` and `tool surface verified` while the setup rail can still show `Authorize connector` and `Start local presence` as needing action. That undermines trust in the status model. + +**Fix**: Replace the static sidebar health block with a summarized blocker count and the current highest-severity state. Add active section styling to the sidebar anchors so navigation communicates location. + +**Suggested command**: `$impeccable clarify cloud UI` + +**[P2] Panel hierarchy is too even** + +**Why it matters**: Connector catalog, tool surface, runtime, presence, receipts, and audit all use the same bordered panel language. The user cannot tell which panels are operational, which are proof, and which are historical. + +**Fix**: Create three section weights: primary task, live status, and audit/history. Give the task panel stronger action placement, make live status denser, and let audit/history become quieter table/detail content. + +**Suggested command**: `$impeccable distill cloud UI` + +**[P2] Design tokens drift from the documented warmth** + +**Why it matters**: `DESIGN.md` describes ember, parchment, charred ink, and warm technical surfaces. The CSS tokens use cool hue values for parchment, linen, paper, ash, and focus. The result risks reading as generic cool admin UI rather than Caplets' owned warm map language. + +**Fix**: Bring the CSS OKLCH hues back toward the documented palette, keep ember rare, and reserve cool tones only where they carry specific state meaning. + +**Suggested command**: `$impeccable colorize cloud UI` + +# Persona Red Flags + +**Alex, agent power user**: Alex cannot use the UI at all until the missing imports are fixed. Once rendered, the equal panel grid and missing active nav state slow scanning. The audit table has no filter or search, so repeated operational checks become manual. + +**Jordan, first-time connector setup user**: Jordan sees a slogan, endpoint, setup path, connector catalog, local presence, and audit trail, but no single "do this now" control. `Use connector setup to authorize Sourcegraph` is instructional text, not an actionable button. + +**Sam, security-conscious workspace admin**: Sam needs a trustworthy status summary. Static `OAuth active` sidebar copy conflicts with visible warning states, and the audit table is readable but lacks severity, actor, filtering, or export affordances. + +# Minor Observations + +- The loading state is a centered panel, not a skeleton, so it does not prepare the user for the workspace layout. +- `Copy endpoint` succeeds through a screen-reader-only live region, but visual users get no visible confirmation. +- `runtimeReasons[row.label]` can render empty explanatory text if a new runtime row label appears. +- The `ConnectorList` status lookup is repeated several times per row; deriving it once would reduce fragility. +- The mobile table conversion is thoughtful, but the audit rows need stronger labels or grouping when they become cards. + +# Questions To Consider + +1. What is the one action a workspace owner should take after opening this page when two blockers exist? +2. Should Caplets Cloud sell the concept on this screen, or assume the user is already here to configure and verify? +3. Which state should the sidebar summarize: authentication, connector readiness, local presence, or overall workspace health? diff --git a/.lintstagedrc.json b/.lintstagedrc.json index df251e3..69abf9b 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,4 +1,3 @@ { - "*.{js,jsx,ts,tsx,mjs,cjs}": ["oxfmt --check", "oxlint"], - "*.{json,jsonc,md,yml,yaml,css,astro}": "oxfmt --check" + "*.{js,jsx,ts,tsx,mjs,cjs,json,jsonc,md,yml,yaml,css,astro}": ["oxfmt --check", "oxlint"] } diff --git a/alchemy.run.ts b/alchemy.run.ts index ed65db3..0cd38b2 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -1,20 +1,24 @@ import alchemy from "alchemy"; -import { Astro } from "alchemy/cloudflare"; +import { Astro, D1Database, R2Bucket, Vite, Worker } from "alchemy/cloudflare"; import { GitHubComment } from "alchemy/github"; import { CloudflareStateStore } from "alchemy/state"; -const globalBaseDomain = "caplets.dev"; +import { buildAlchemyDomains } from "./infra/alchemy-domains.ts"; const app = await alchemy("caplets", { stateStore: (scope) => new CloudflareStateStore(scope), password: process.env.ALCHEMY_PASSWORD!, }); -const baseDomain = - app.stage === "prod" ? globalBaseDomain : `${app.stage}.preview.${globalBaseDomain}`; - -const landingPageDomain = baseDomain; -const landingPageUrl = `https://${landingPageDomain}`; +const { + appDomain, + cloudApiDomains, + cloudApiUrl, + cloudUiEnv, + landingPageDomain, + landingPageUrl, + appUrl, +} = buildAlchemyDomains(app.stage, { local: app.local }); export const landingPage = await Astro("landing-page", { cwd: "apps/landing", dev: { @@ -23,8 +27,43 @@ export const landingPage = await Astro("landing-page", { domains: [landingPageDomain, `www.${landingPageDomain}`], }); +export const cloudState = await D1Database("cloud-state", { + name: `caplets-${app.stage}-cloud-state`, +}); + +export const cloudArtifacts = await R2Bucket("cloud-artifacts", { + name: `caplets-${app.stage}-cloud-artifacts`, +}); + +export const cloudApi = await Worker("cloud-api", { + cwd: "apps/cloud", + entrypoint: "src/index.ts", + dev: { + port: 8787, + }, + bindings: { + CLOUD_STATE: cloudState, + CLOUD_ARTIFACTS: cloudArtifacts, + }, + domains: cloudApiDomains, +}); + +export const cloudUi = await Vite("cloud-ui", { + cwd: "apps/cloud-ui", + build: { + env: cloudUiEnv, + }, + dev: { + command: "pnpm run dev" + (process.env.SSH_CONNECTION ? " --host 0.0.0.0" : ""), + env: cloudUiEnv, + }, + domains: [appDomain], +}); + console.log({ "Landing Page URL": landingPageUrl, + "Caplets Cloud UI URL": appUrl, + "Caplets Cloud API URL": cloudApiUrl, }); const [repositoryOwnerFromSlug, repositoryNameFromSlug] = @@ -48,6 +87,8 @@ if (pullRequestNumber) { Your changes have been deployed to a preview environment: **🌐 Landing Page:** ${landingPageUrl} +**☁️ Caplets Cloud UI:** https://${appDomain} +**🔌 Caplets Cloud API Domain:** ${cloudApiUrl} Built from commit ${process.env.GITHUB_SHA?.slice(0, 7) ?? "unknown"} diff --git a/apps/cloud-ui/src/lib/cloud-api.ts b/apps/cloud-ui/src/lib/cloud-api.ts new file mode 100644 index 0000000..866542d --- /dev/null +++ b/apps/cloud-ui/src/lib/cloud-api.ts @@ -0,0 +1,78 @@ +export interface WorkspaceSummary { + workspaceId: string; + slug: string; + name: string; + createdAt: string; +} + +export interface WorkspaceResponse { + workspace: WorkspaceSummary; +} + +export class CloudApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "CloudApiError"; + } +} + +export class CloudApiClient { + private readonly accessToken: string | undefined; + private readonly baseUrl: URL; + private readonly fetchImpl: typeof fetch; + + constructor({ + accessToken, + baseUrl, + fetchImpl, + }: { + accessToken?: string; + baseUrl: string | URL; + fetchImpl?: typeof fetch; + }) { + this.accessToken = accessToken; + this.baseUrl = new URL(baseUrl); + this.fetchImpl = fetchImpl ?? globalThis.fetch.bind(globalThis); + } + + async getWorkspace(slug: string, init: { signal?: AbortSignal } = {}): Promise { + const response = await this.fetchJson( + `api/workspaces/${encodeURIComponent(slug)}`, + init, + ); + return response.workspace; + } + + workspaceMcpEndpoint(slug: string): string { + return new URL(`ws/${encodeURIComponent(slug)}/mcp`, withTrailingSlash(this.baseUrl)).href; + } + + private async fetchJson(path: string, init: { signal?: AbortSignal } = {}): Promise { + const url = new URL(path, withTrailingSlash(this.baseUrl)); + const headers: Record = { accept: "application/json" }; + if (this.accessToken) headers.Authorization = `Bearer ${this.accessToken}`; + + const response = await this.fetchImpl(url, { + headers, + signal: init.signal, + }); + + if (!response.ok) { + throw new CloudApiError( + `Caplets Cloud API request failed: HTTP ${response.status}`, + response.status, + ); + } + + return (await response.json()) as T; + } +} + +function withTrailingSlash(url: URL): URL { + const next = new URL(url); + if (!next.pathname.endsWith("/")) next.pathname = `${next.pathname}/`; + return next; +} diff --git a/apps/cloud-ui/src/lib/mock-workspace.ts b/apps/cloud-ui/src/lib/mock-workspace.ts new file mode 100644 index 0000000..6251dde --- /dev/null +++ b/apps/cloud-ui/src/lib/mock-workspace.ts @@ -0,0 +1,123 @@ +export type ConnectorStatus = "Ready" | "Needs OAuth" | "Local required"; + +export interface Connector { + id: string; + name: string; + kind: string; + status: ConnectorStatus; + detail: string; +} + +export interface RuntimeRow { + label: string; + value: string; + state: "ok" | "warn"; +} + +export interface ReceiptStep { + step: number; + title: string; + detail: string; +} + +export interface AuditRow { + time: string; + event: string; + subject: string; + result: string; +} + +export interface WorkspaceMock { + endpoint: string; + visibleCaplets: number; + hiddenTools: number; + payloadReduction: string; + authMode: string; + workspaceName: string; + connectors: Connector[]; + runtimeRows: RuntimeRow[]; + receiptSteps: ReceiptStep[]; + auditRows: AuditRow[]; +} + +export const productCopyProofs = [ + "capability cards instead of flat downstream tool lists", + "106 hidden downstream tools", + "3 visible Caplets", +] as const; + +export const workspaceMock: WorkspaceMock = { + endpoint: "https://cloud.caplets.dev/ws/personal/mcp", + visibleCaplets: 3, + hiddenTools: 106, + payloadReduction: "87.9% smaller initial payload", + authMode: "OAuth", + workspaceName: "personal workspace", + connectors: [ + { + id: "github", + name: "GitHub", + kind: "Hosted MCP", + status: "Ready", + detail: "OAuth state and provider tokens stay server-side for the workspace.", + }, + { + id: "sourcegraph", + name: "Sourcegraph", + kind: "Hosted API", + status: "Needs OAuth", + detail: "Authorize once, then expose a capability card to every connected agent.", + }, + { + id: "repo-tools", + name: "Repo tools", + kind: "Project-bound CLI", + status: "Local required", + detail: + "Runs through project-bound remote stdio/CLI execution when local presence is active.", + }, + ], + runtimeRows: [ + { label: "Hosted connectors", value: "ready", state: "ok" }, + { label: "Sandbox leases", value: "idle", state: "ok" }, + { label: "Local-assisted sessions", value: "presence required", state: "warn" }, + { label: "Policy blockers", value: "0 blocking", state: "ok" }, + ], + receiptSteps: [ + { + step: 1, + title: "Sync lease opened", + detail: "Project files copied into the managed runtime for stdio/CLI work.", + }, + { + step: 2, + title: "Remote command finished", + detail: "project-bound remote stdio/CLI execution returned a patch receipt.", + }, + { + step: 3, + title: "Implicit apply pending", + detail: "Local runtime checks conflicts before writing back to the project root.", + }, + ], + auditRows: [ + { + time: "10:48", + event: "OAuth client authorized", + subject: "workspace/personal", + result: "requires workspace grant", + }, + { + time: "10:51", + event: "Tool surface report generated", + subject: "github", + result: "87.9% smaller initial payload", + }, + { + time: "10:54", + event: "Project-bound apply receipt recorded", + subject: "repo-tools", + result: "conflict-aware", + }, + ], +}; diff --git a/apps/landing/package.json b/apps/landing/package.json index f57b542..8da32e5 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -19,6 +19,9 @@ "tailwindcss": "^4.3.0", "typescript": "^6.0.3" }, + "devDependencies": { + "vite": "^7.3.3" + }, "engines": { "node": ">=22.12.0" } diff --git a/caplets/ast-grep/CAPLET.md b/caplets/ast-grep/CAPLET.md index 3b5ff0f..8110f23 100644 --- a/caplets/ast-grep/CAPLET.md +++ b/caplets/ast-grep/CAPLET.md @@ -6,9 +6,21 @@ tags: - mcp - code - search +setup: + commands: + - label: Install ast-grep MCP + command: npm + args: ["install", "-g", "ast-grep-mcp"] + timeoutMs: 120000 + maxOutputBytes: 200000 + verify: + - label: Check ast-grep MCP + command: ast-grep-mcp + args: ["--help"] + timeoutMs: 10000 + maxOutputBytes: 20000 mcpServer: - command: npx - args: [-y, ast-grep-mcp] + command: ast-grep-mcp --- # ast-grep MCP @@ -17,6 +29,12 @@ Use this Caplet to expose ast-grep's structural search, scan, rule testing, rewr The manifest uses the full `ast-grep-mcp` MCP server. +## Setup + +This Caplet installs `ast-grep-mcp` globally with npm, then verifies the installed binary with +`ast-grep-mcp --help`. Setup is explicit because hosted and local stdio runtimes need a stable +binary instead of running package-manager downloads during each MCP startup. + ## Safety Read-only search, scan, and normal test actions set `readOnlyHint: true`. Apply-all rewrite, snapshot-update, and scaffolding actions set `destructiveHint: true` because they can modify files. diff --git a/caplets/context7/CAPLET.md b/caplets/context7/CAPLET.md index 6668d04..341b19d 100644 --- a/caplets/context7/CAPLET.md +++ b/caplets/context7/CAPLET.md @@ -7,11 +7,21 @@ tags: - libraries - frameworks - api-reference +setup: + commands: + - label: Install Context7 MCP + command: npm + args: ["install", "-g", "@upstash/context7-mcp"] + timeoutMs: 120000 + maxOutputBytes: 200000 + verify: + - label: Check Context7 MCP + command: context7-mcp + args: ["--help"] + timeoutMs: 10000 + maxOutputBytes: 20000 mcpServer: - command: npx - args: - - -y - - "@upstash/context7-mcp" + command: context7-mcp --- # Context7 Documentation @@ -31,3 +41,9 @@ documentation before writing code or giving technical instructions. - Prefer primary documentation over snippets when implementation risk is high. - Record the library or package name clearly before searching. - Do not use this as a substitute for project-local types and tests. + +## Setup + +This Caplet installs `@upstash/context7-mcp` globally with npm, then verifies the installed +`context7-mcp` binary with `--help`. Setup is explicit so hosted and local stdio runtimes start a +known binary without running `npx` package downloads on every agent session. diff --git a/caplets/playwright/CAPLET.md b/caplets/playwright/CAPLET.md index 058838e..d692073 100644 --- a/caplets/playwright/CAPLET.md +++ b/caplets/playwright/CAPLET.md @@ -7,11 +7,27 @@ tags: - testing - mcp - frontend +setup: + commands: + - label: Install Playwright MCP + command: npm + args: ["install", "-g", "@playwright/mcp@0.0.75"] + timeoutMs: 120000 + maxOutputBytes: 200000 + - label: Install Chromium browser + command: npx + args: ["playwright", "install", "chromium"] + timeoutMs: 180000 + maxOutputBytes: 200000 + verify: + - label: Check Playwright MCP + command: playwright-mcp + args: ["--help"] + timeoutMs: 10000 + maxOutputBytes: 20000 mcpServer: - command: npx + command: playwright-mcp args: - - -y - - "@playwright/mcp@0.0.75" - --headless --- @@ -29,7 +45,10 @@ visual inspection, or end-to-end testing workflows. ## Setup -This Caplet starts Playwright MCP with `npx -y @playwright/mcp@0.0.75 --headless`. +This Caplet installs `@playwright/mcp@0.0.75` globally with npm, installs the Chromium browser +runtime with `npx playwright install chromium`, then verifies `playwright-mcp --help`. Setup is +explicit because browser automation needs both a stable MCP binary and a browser runtime before the +hosted or local stdio server starts. Remove `--headless`, or set `PLAYWRIGHT_MCP_HEADLESS=false` in a custom MCP environment, to use a visible browser. For advanced settings, create a diff --git a/docs/specs/2026-05-30-caplets-cloud-hosted-product-design.md b/docs/specs/2026-05-30-caplets-cloud-hosted-product-design.md deleted file mode 100644 index 79a6e35..0000000 --- a/docs/specs/2026-05-30-caplets-cloud-hosted-product-design.md +++ /dev/null @@ -1,333 +0,0 @@ -# Caplets Cloud Hosted Product Design - -## Status - -Approved product and architecture design from brainstorming and grilling session. Implementation plan is intentionally separate. - -## Goal - -Define the hosted Caplets product that lets users authenticate, configure tools once, expose a remote OAuth-protected MCP endpoint, and run Caplets remotely without operating their own server. - -The product should drive conversion through two concrete promises: - -- Connect once across agent clients. -- Show agents a smaller, staged tool surface instead of a giant flat tool wall. - -Caplets Cloud should serve solo developers first, while using workspace-shaped data boundaries so team workspaces can be added later. - -## Non-goals - -- Do not replace local Caplets or remove local mode. -- Do not make Basic Auth part of the hosted product surface. -- Do not make hosted UI design decisions outside the Caplets product design system. -- Do not require users to understand Mutagen or manually configure sync sessions. -- Do not expose project-bound Caplets to remote-only clients that cannot use them. -- Do not include task sequencing, milestones, or checklists in this spec. Those belong in a separate implementation plan. - -## Product Positioning - -Caplets Cloud is not generic "managed MCP hosting." It is a hosted capability layer for coding agents. - -The core promise is: - -```text -Connect once. Show agents less. -``` - -The hosted product should make setup speed and tool-surface reduction visible: - -- One hosted MCP URL per workspace. -- OAuth-based remote MCP connection flows for supported clients. -- Hosted connector setup for common developer tools. -- Session-aware tool lists that hide unusable capabilities. -- Per-workspace Tool Surface Report showing the reduction from flat tools to Caplet cards. - -The Tool Surface Report should be modeled on the existing deterministic benchmark but calculated from the user's actual workspace when possible: - -- Direct downstream tools hidden behind Caplets. -- Visible Caplet count. -- Initial payload or approximate context-surface estimate. -- Duplicate tool-name collisions avoided. -- Discovery trace such as `search_tools -> get_tool -> call_tool`. - -Claims must remain precise. Caplets Cloud can claim reduced initial MCP tool-surface payload and fewer initially visible candidate tools. It must not claim a universal provider bill reduction without provider-specific evidence. - -## Workspace Boundary - -`workspace` is the primary hosted tenant boundary. - -All hosted state is scoped to a workspace: - -- Hosted Caplet definitions. -- Hosted connector configuration. -- Provider credentials and secret vault entries. -- MCP OAuth client grants and sessions. -- Local presence registrations. -- Runtime leases. -- Tool-surface reports. -- Sync/apply receipts. -- Audit events. -- Usage and billing records. - -Solo v1 gives a user one personal workspace. Team support later adds members, roles, audit trails, shared billing, and policy controls to the same workspace object. - -## Hosted Authentication - -Hosted MCP access is OAuth-only. - -Basic Auth remains appropriate for local and self-hosted `caplets serve --transport http`, but hosted onboarding and hosted client configuration must not use Basic Auth. - -Hosted MCP authentication requirements: - -- Workspace MCP endpoints require OAuth. -- MCP clients authorize through an MCP-compatible OAuth flow where supported. -- Dynamic client registration should be supported where required by MCP-compatible clients. -- Access tokens are scoped to workspace, client/session, and allowed capability surface. -- Token revocation and session invalidation are first-class. -- Hosted web account login is separate from MCP client authorization, though both map to the same workspace. - -Provider auth is also workspace-scoped but separate from MCP client auth. For example, GitHub or Linear credentials live in the hosted secret vault and are used by hosted connectors. Local/project auth remains local when local overlays execute locally. - -## Execution Classes - -Caplets Cloud supports multiple execution classes behind one logical workspace MCP endpoint. - -### Hosted Connectors - -Hosted connectors run fully in cloud and require no local project presence. Examples include: - -- GitHub -- Linear -- Sourcegraph -- OSV -- npm -- PyPI -- Documentation and search connectors - -The connector catalog should avoid an empty first-run experience. Each connector should have connect, test, inspect, and status states. - -### Hosted Remote MCP And API Caplets - -Hosted remote Caplets call external services from cloud: - -- Streamable HTTP MCP servers. -- Legacy HTTP/SSE MCP servers where supported. -- OpenAPI endpoints. -- GraphQL endpoints. -- Explicit HTTP action Caplets. - -Hosted secrets and provider auth are used only server-side and must not be exposed through generated tool descriptions, logs, or errors. - -### Hosted Stdio And CLI Caplets - -Hosted stdio MCP servers and CLI Caplets run in managed sandbox runtimes. This is an MVP differentiator because many useful MCP servers remain stdio-only. - -The runtime must not assume one workspace container can be vertically resized while running. Treat stdio and CLI backends as schedulable workloads: - -- A workspace may use one or more ephemeral sandboxes. -- Each sandbox runs a Caplets supervisor plus assigned backend processes. -- Idle child processes stop first. -- Empty sandboxes sleep or stop after a short idle window. -- Heavy backends can be restarted in larger sandboxes when needed. - -### Project-Bound Remote Stdio And CLI Caplets - -Project-bound remote Caplets run in hosted sandboxes but sync and apply against a local project through an active local Caplets runtime. - -This is for tools that need project files but benefit from remote execution or remote-client availability. - -### Local Overlays - -Existing local overlay behavior remains central: - -- In remote mode, the effective surface is remote first, then user-global local, then project-local. -- User-global and project-local Caplets run locally. -- Local Caplets shadow hosted Caplets by ID. -- Project-local Caplets have the highest priority. - -This behavior already exists for native integrations and CLI remote mode and should be treated as a core product feature. - -## Session-Aware Capability Availability - -Hosted MCP tool lists are session-aware. - -Remote-only sessions see only hosted-capable Caplets. Project-bound Caplets are hidden unless a compatible local presence is online for the workspace. Agent-facing tool lists should not include capabilities that will fail immediately because the current session cannot execute them. - -Availability rules: - -- `executionTarget: "hosted"` is visible to remote-only sessions. -- `executionTarget: "project-bound"` is visible only when compatible local presence exists. -- `executionTarget: "auto"` is visible when hosted execution or project-bound execution is currently available. -- Local developer sessions continue to see the merged remote/local surface. -- Tool-list changes should emit MCP tool-list changed notifications when the transport and client support them. -- Clients that do not refresh dynamically should see the correct surface on the next session. - -`caplets doctor` and hosted diagnostics can show hidden Caplets with reasons. The agent-facing surface should hide them. - -## Local Presence - -A local Caplets runtime in `CAPLETS_MODE=remote` registers local presence for the current project root. - -Launching and configuring local Caplets in remote mode is treated as user consent for that local runtime to assist the hosted workspace. No extra per-client approval prompt is required. - -Presence is workspace-wide while online, but tightly scoped: - -- Same hosted workspace only. -- One current project root per local runtime. -- Project root fingerprint. -- Declared allowed Caplet IDs. -- Sync/apply policy. -- Heartbeat and expiry. -- Audit trail. - -Any authenticated remote MCP session in the same workspace may use compatible project-bound Caplets while that presence is online. Users revoke presence by stopping the local runtime, disabling remote mode, changing project policy, or revoking workspace credentials. - -## Project Sync - -Project-bound remote stdio and CLI execution uses managed project sync. - -Mutagen is the MVP sync provider. Caplets bundles and manages it automatically in the local CLI/native integration. Users should not have to install, configure, or operate Mutagen directly. - -The user-facing concept is project-bound remote execution. - -Sync rules: - -- The authoritative filesystem is the bound apply target, not the remote sandbox. -- The remote sandbox mirror is a disposable execution copy. -- Before a mutating remote call, Caplets syncs the mirror from the authoritative target. -- After execution, clean changes sync/apply back to the authoritative target. -- If the mirror becomes suspicious or stale, Caplets can discard and rebuild it from the authoritative target. -- Mutating remote calls are serialized per bound project target in v1. - -Sync scope is controlled by project rules: - -- Use `.capletsignore` when present. -- Use `.gitignore` and git exclude rules when inside a git repo. -- If not inside a git repo, use `.capletsignore` only. -- Do not invent broad hidden ignore defaults. -- Always exclude Caplets internal sync metadata. -- Secret scanning and size/file-count limits are policy checks, not silent ignore rules. - -`caplets doctor` should show the effective sync scope, including ignored files, policy blockers, and Mutagen status. - -## Implicit Apply And Conflict Handling - -Remote side-effecting tools should feel like normal tools. Clean changes apply implicitly to the bound target. - -Flow: - -1. Caplets syncs the remote mirror to the authoritative target. -2. The remote stdio or CLI backend runs in the sandbox. -3. Caplets captures the resulting filesystem changes. -4. Caplets applies clean changes back to the authoritative target. -5. The tool result includes an apply receipt. -6. Conflicts or policy violations return structured MCP results. - -Apply receipts should include: - -- Files created, modified, deleted, and skipped. -- Whether apply was clean. -- Sync version or target fingerprint. -- Runtime and Caplet identifiers. -- Policy warnings. -- Rollback metadata when available. - -Conflict results should be recoverable by agents. They should include enough structured data for an agent to inspect the conflict, generate a resolution, and retry without human intervention when safe. - -Human intervention is reserved for: - -- Secret or policy blocks. -- Unsafe paths. -- Oversized or unsupported changes. -- Repeated unresolved conflicts. -- Explicit workspace policy requiring review. - -## Hosted UI Requirements - -Hosted UI work must use the `impeccable` workflow for UX shaping, polish, and review. - -The hosted app is product UI, not a marketing surface. It should be dense, predictable, inspectable, and calm. - -Design requirements: - -- Preserve the existing Caplets design system: warm technical surfaces, charred ink, rare ember, ash borders, compact Inter typography, and monospace only for machine-facing content. -- Avoid generic SaaS hero-metric patterns. -- Make the Tool Surface Report feel like a benchmark or inspection artifact, not a celebratory stats grid. -- Use familiar developer-tool affordances: side navigation, tabs, tables, status rows, command snippets, copy buttons, scoped filters, and inline diagnostics. -- Show explicit states for default, loading, empty, connected, degraded, hidden, blocked, conflict, revoked, expired, and unauthorized. -- Use accessible status treatment with no color-only state, visible focus, keyboard navigation, contrast checks, reduced-motion alternatives, and readable dense tables. -- Use progressive disclosure in the UI: show capability, source, status, and next action first; reveal schemas, raw config, OAuth details, sync logs, and patch details only when requested. - -Primary hosted workflows: - -- Workspace MCP endpoint and OAuth client setup. -- Connector catalog. -- Tool Surface Report. -- Runtime status. -- Local presence and hidden Caplets diagnostics. -- Sync/apply receipts. -- Conflict review and recovery. -- Audit trail. - -## `caplets doctor` - -`caplets doctor` is the local diagnostic and repair surface. It should show project binding and sync implications only when remote mode is active. - -Doctor should report: - -- Remote mode configuration. -- Hosted reachability. -- OAuth/token/session status where safe. -- Current project root and project fingerprint. -- Whether local presence is registered. -- Mutagen availability, version, and sync health. -- `.gitignore` and `.capletsignore` effects. -- Secret-scan or quota blockers. -- Hidden project-bound Caplets and why they are hidden. -- Recent apply conflicts or failed syncs. - -Doctor should help users understand what the agent cannot see, what the hosted service can execute, and what local project state is currently exposed to Caplets Cloud. - -## Security And Trust - -Hosted Caplets handles sensitive boundaries: - -- OAuth-authenticated MCP clients. -- Hosted provider credentials. -- Local project presence. -- Remote sandbox execution. -- Filesystem sync and implicit apply. - -Required controls: - -- Hosted MCP uses OAuth, not Basic Auth. -- Hosted provider secrets are encrypted at rest and redacted everywhere. -- Local presence is scoped to one project root and one workspace. -- Project-bound Caplets are hidden unless executable in the current session. -- Remote stdio/CLI execution has process, time, disk, memory, and network limits. -- Mutating operations are audited. -- Secret scanning can block sync/apply. -- Sync metadata and internal control files are never exposed as normal project files. -- Apply operations validate paths and reject traversal or absolute-path writes outside the target. -- Policy blocks are explicit and agent-readable. - -## Open Risks - -- Mutagen packaging and licensing must be validated. Newer official builds include SSPL-licensed code by default; the MVP must confirm a viable licensing and distribution path. -- Cloud sandbox substrate must be validated for process lifecycle, filesystem semantics, networking, cold start, and cost. Cloudflare sandbox containers are a strong candidate because the repo already uses Cloudflare via Alchemy, but the implementation plan should compare viable substrates. -- MCP OAuth compatibility varies by client. Hosted Caplets should support standard flows and document client-specific limitations. -- Tool-list changed notifications may not be honored by all clients, so availability changes may require a new session in some clients. -- Sync conflict recovery needs careful structured result design so agents can resolve conflicts without overexposing project data. -- Implicit apply is a powerful capability. Workspace policy, doctor output, audit trails, and rollback metadata must make it trustworthy. - -## Success Criteria - -- A solo developer can create a workspace, authorize an MCP client through OAuth, and connect to one hosted MCP URL. -- The hosted app shows a Tool Surface Report for the workspace. -- Remote-only sessions see only hosted-capable Caplets. -- Local remote-mode sessions see the merged hosted plus local overlay surface. -- Project-bound Caplets become available to workspace sessions while compatible local presence is online. -- Clean remote stdio/CLI changes apply implicitly to the bound project target. -- Conflicts return structured recoverable MCP results. -- `caplets doctor` explains remote mode, local presence, sync state, hidden Caplets, and policy blockers. -- Hosted UI follows the Caplets product design system and uses `impeccable` for UI shaping and review. diff --git a/infra/alchemy-domains.ts b/infra/alchemy-domains.ts new file mode 100644 index 0000000..c858473 --- /dev/null +++ b/infra/alchemy-domains.ts @@ -0,0 +1,44 @@ +const globalBaseDomain = "caplets.dev"; + +export interface AlchemyDomains { + appDomain: string; + baseDomain: string; + cloudApiDomains: string[]; + cloudApiUrl: string; + cloudDomain: string; + cloudUiEnv: { + VITE_CAPLETS_CLOUD_API_URL: string; + VITE_CAPLETS_WORKSPACE_SLUG: string; + }; + landingPageDomain: string; + landingPageUrl: string; + appUrl: string; +} + +export function buildAlchemyDomains( + stage: string, + { local = false }: { local?: boolean } = {}, +): AlchemyDomains { + const baseDomain = stage === "prod" ? globalBaseDomain : `${stage}.preview.${globalBaseDomain}`; + const landingPageDomain = baseDomain; + const landingPageUrl = `https://${landingPageDomain}`; + const cloudDomain = `cloud.${baseDomain}`; + const cloudApiUrl = local ? "http://localhost:8787" : `https://${cloudDomain}`; + const appDomain = `app.${baseDomain}`; + const appUrl = `https://${appDomain}`; + + return { + appDomain, + baseDomain, + cloudApiDomains: local ? [] : [cloudDomain], + cloudApiUrl, + cloudDomain, + cloudUiEnv: { + VITE_CAPLETS_CLOUD_API_URL: cloudApiUrl, + VITE_CAPLETS_WORKSPACE_SLUG: "personal", + }, + landingPageDomain, + landingPageUrl, + appUrl, + }; +} diff --git a/scripts/alchemy-fetch-compat.test.ts b/infra/alchemy-fetch-compat.test.ts similarity index 93% rename from scripts/alchemy-fetch-compat.test.ts rename to infra/alchemy-fetch-compat.test.ts index 446dd42..55b94f1 100644 --- a/scripts/alchemy-fetch-compat.test.ts +++ b/infra/alchemy-fetch-compat.test.ts @@ -7,7 +7,7 @@ test("Alchemy fetch compatibility shim removes userland undici dispatcher before return new Response("ok"); }; - await import("./alchemy-fetch-compat"); + await import("./alchemy-fetch-compat.js"); const response = await globalThis.fetch("https://example.test", { dispatcher: { dispatch() {} }, diff --git a/scripts/alchemy-fetch-compat.ts b/infra/alchemy-fetch-compat.ts similarity index 100% rename from scripts/alchemy-fetch-compat.ts rename to infra/alchemy-fetch-compat.ts diff --git a/infra/alchemy-runner.test.ts b/infra/alchemy-runner.test.ts new file mode 100644 index 0000000..b1ec9e7 --- /dev/null +++ b/infra/alchemy-runner.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { buildAlchemyDomains } from "./alchemy-domains.js"; +import { buildNodeOptions } from "./alchemy-runner.js"; + +const shimPath = fileURLToPath(new URL("./alchemy-fetch-compat.ts", import.meta.url)); +const alchemyRunPath = fileURLToPath(new URL("../alchemy.run.ts", import.meta.url)); + +test("Alchemy runner injects fetch shim through NODE_OPTIONS for child processes", () => { + expect(buildNodeOptions(undefined)).toBe(`--import=${shimPath}`); +}); + +test("Alchemy runner preserves existing NODE_OPTIONS after the fetch shim", () => { + expect(buildNodeOptions("--trace-warnings")).toBe(`--import=${shimPath} --trace-warnings`); +}); + +test("Alchemy runner keeps fetch compatibility shim for cloud deployments", () => { + expect(buildNodeOptions()).toContain("alchemy-fetch-compat"); +}); + +test("Cloud UI deployment injects matching Cloud API origin into Vite", () => { + const source = readFileSync(alchemyRunPath, "utf8"); + + expect(source).toContain('from "./infra/alchemy-domains.ts"'); + expect(source).toContain("buildAlchemyDomains(app.stage, { local: app.local })"); + expect(source).toMatch(/build:\s*{\s*env:\s*cloudUiEnv,\s*}/s); + expect(source).toMatch(/dev:\s*{[^}]*env:\s*cloudUiEnv,/s); +}); + +test.each([ + { + appDomain: "app.caplets.dev", + cloudApiUrl: "https://cloud.caplets.dev", + cloudApiDomains: ["cloud.caplets.dev"], + cloudDomain: "cloud.caplets.dev", + landingPageDomain: "caplets.dev", + stage: "prod", + }, + { + appDomain: "app.branch.preview.caplets.dev", + cloudApiUrl: "https://cloud.branch.preview.caplets.dev", + cloudApiDomains: ["cloud.branch.preview.caplets.dev"], + cloudDomain: "cloud.branch.preview.caplets.dev", + landingPageDomain: "branch.preview.caplets.dev", + stage: "branch", + }, + { + appDomain: "app.dev.preview.caplets.dev", + cloudApiUrl: "https://cloud.dev.preview.caplets.dev", + cloudApiDomains: ["cloud.dev.preview.caplets.dev"], + cloudDomain: "cloud.dev.preview.caplets.dev", + landingPageDomain: "dev.preview.caplets.dev", + stage: "dev", + }, +])( + "derives matching Cloud UI and API domains for $stage", + ({ appDomain, cloudApiDomains, cloudApiUrl, cloudDomain, landingPageDomain, stage }) => { + expect(buildAlchemyDomains(stage)).toMatchObject({ + appDomain, + cloudApiDomains, + cloudApiUrl, + cloudDomain, + cloudUiEnv: { + VITE_CAPLETS_CLOUD_API_URL: cloudApiUrl, + VITE_CAPLETS_WORKSPACE_SLUG: "personal", + }, + landingPageDomain, + landingPageUrl: `https://${landingPageDomain}`, + }); + }, +); + +test("derives local Cloud API origin for alchemy dev", () => { + expect(buildAlchemyDomains("ianpascoe", { local: true })).toMatchObject({ + appDomain: "app.ianpascoe.preview.caplets.dev", + cloudApiDomains: [], + cloudApiUrl: "http://localhost:8787", + cloudDomain: "cloud.ianpascoe.preview.caplets.dev", + cloudUiEnv: { + VITE_CAPLETS_CLOUD_API_URL: "http://localhost:8787", + VITE_CAPLETS_WORKSPACE_SLUG: "personal", + }, + }); +}); diff --git a/scripts/alchemy-runner.ts b/infra/alchemy-runner.ts similarity index 100% rename from scripts/alchemy-runner.ts rename to infra/alchemy-runner.ts diff --git a/output/playwright/cloud-ui-critique.png b/output/playwright/cloud-ui-critique.png new file mode 100644 index 0000000000000000000000000000000000000000..a398c92dff13bf6325b08d003992d91fb9006874 GIT binary patch literal 127872 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

@@ -47,7 +47,7 @@ In the deterministic benchmark, 106 flat tools became 3 top-level capabilities w ## Quick Start -Caplets requires Node.js 22 or newer. +Caplets requires Node.js 24 or newer. ```sh npm install -g caplets @@ -157,6 +157,10 @@ The Claude Code and Codex commands install from this GitHub repository through e plugin marketplace flow; users do not need to clone the repository manually. Plugin MCP configs run `caplets serve` directly, so install the Caplets CLI globally first. +## Core Alchemy + +Core Alchemy deploys the public landing page from `apps/landing`. It does not deploy the private Cloud Worker or Cloud dashboard; those belong to the nested Cloud repository. + ### Remote Caplets service OpenCode and Pi can use native `caplets_` tools backed by a remote Caplets HTTP service. Codex, Claude Code, and any MCP client can connect to the same remote MCP endpoint directly. diff --git a/docs/plans/2026-05-21-mcp-resources-prompts.md b/docs/plans/2026-05-21-mcp-resources-prompts.md index 5462346..397e1aa 100644 --- a/docs/plans/2026-05-21-mcp-resources-prompts.md +++ b/docs/plans/2026-05-21-mcp-resources-prompts.md @@ -1,5 +1,7 @@ # MCP Resources and Prompts Implementation Plan +> Superseded for remote/server environment naming: current client integrations use `CAPLETS_REMOTE_*`, while the process hosting `caplets serve --transport http` uses `CAPLETS_SERVER_*`. This plan remains historical implementation context. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add MCP resource, resource-template, prompt, and completion support to MCP-backed Caplets while keeping non-MCP backends tool-only. diff --git a/docs/plans/2026-05-29-cli-integration-setup.md b/docs/plans/2026-05-29-cli-integration-setup.md index 39df2d3..4eac40e 100644 --- a/docs/plans/2026-05-29-cli-integration-setup.md +++ b/docs/plans/2026-05-29-cli-integration-setup.md @@ -1,5 +1,7 @@ # CLI Integration Setup Implementation Plan +> Superseded for remote/server environment naming: current client integrations use `CAPLETS_REMOTE_*`, while the process hosting `caplets serve --transport http` uses `CAPLETS_SERVER_*`. This plan remains historical implementation context. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a quick `caplets setup` CLI command that actually installs or configures supported agent integrations, with `--dry-run` available for preview. From 7dd004ef77bde51e47aee8870da0fc9dfe6aa47a Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 4 Jun 2026 08:57:18 -0400 Subject: [PATCH 13/19] chore: update dependencies --- apps/landing/package.json | 4 +- package.json | 16 +- packages/benchmarks/package.json | 4 +- packages/cli/package.json | 4 +- packages/core/package.json | 6 +- packages/opencode/package.json | 4 +- packages/pi/package.json | 4 +- pnpm-lock.yaml | 1475 ++++++++++++++++++------------ 8 files changed, 894 insertions(+), 623 deletions(-) diff --git a/apps/landing/package.json b/apps/landing/package.json index 8da32e5..7bc51e7 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -15,12 +15,12 @@ "@astrojs/check": "^0.9.9", "@hugeicons/core-free-icons": "4.2.0", "@tailwindcss/vite": "^4.3.0", - "astro": "^6.4.2", + "astro": "^6.4.3", "tailwindcss": "^4.3.0", "typescript": "^6.0.3" }, "devDependencies": { - "vite": "^7.3.3" + "vite": "^8.0.16" }, "engines": { "node": ">=22.12.0" diff --git a/package.json b/package.json index 018cc0d..bbd6e11 100644 --- a/package.json +++ b/package.json @@ -33,20 +33,20 @@ }, "devDependencies": { "@changesets/cli": "^2.31.0", - "@cloudflare/workers-types": "^4.20260530.1", + "@cloudflare/workers-types": "^4.20260603.1", "@types/node": "^25.9.1", - "@typescript/native-preview": "7.0.0-dev.20260527.2", - "alchemy": "0.93.9", + "@typescript/native-preview": "7.0.0-dev.20260603.1", + "alchemy": "0.93.10", "husky": "^9.1.7", - "lint-staged": "^17.0.6", - "oxfmt": "^0.52.0", - "oxlint": "^1.67.0", + "lint-staged": "^17.0.7", + "oxfmt": "^0.53.0", + "oxlint": "^1.68.0", "prettier-plugin-astro": "^0.14.1", "rolldown": "^1.0.3", - "tsx": "^4.22.3", + "tsx": "^4.22.4", "turbo": "^2.9.16", "typescript": "^6.0.3", - "vitest": "^4.1.7" + "vitest": "^4.1.8" }, "engines": { "node": ">=24" diff --git a/packages/benchmarks/package.json b/packages/benchmarks/package.json index 91a13dc..e16919a 100644 --- a/packages/benchmarks/package.json +++ b/packages/benchmarks/package.json @@ -17,9 +17,9 @@ }, "devDependencies": { "@types/node": "^25.9.1", - "@typescript/native-preview": "7.0.0-dev.20260527.2", + "@typescript/native-preview": "7.0.0-dev.20260603.1", "caplets": "workspace:*", "typescript": "^6.0.3", - "vitest": "^4.1.7" + "vitest": "^4.1.8" } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 8979eb3..7bd4449 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,10 +46,10 @@ }, "devDependencies": { "@types/node": "^25.9.1", - "@typescript/native-preview": "7.0.0-dev.20260527.2", + "@typescript/native-preview": "7.0.0-dev.20260603.1", "rolldown": "^1.0.3", "typescript": "^6.0.3", - "vitest": "^4.1.7" + "vitest": "^4.1.8" }, "engines": { "node": ">=22" diff --git a/packages/core/package.json b/packages/core/package.json index 9208797..02283f4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -73,7 +73,7 @@ "@hono/node-server": "^2.0.4", "@modelcontextprotocol/sdk": "^1.29.0", "commander": "^15.0.0", - "graphql": "^16.14.0", + "graphql": "^16.14.1", "hono": "^4.12.23", "vfile": "^6.0.3", "vfile-matter": "^5.0.1", @@ -82,10 +82,10 @@ }, "devDependencies": { "@types/node": "^25.9.1", - "@typescript/native-preview": "7.0.0-dev.20260527.2", + "@typescript/native-preview": "7.0.0-dev.20260603.1", "rolldown": "^1.0.3", "typescript": "^6.0.3", - "vitest": "^4.1.7" + "vitest": "^4.1.8" }, "engines": { "node": ">=22" diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 2a80f31..b9aa547 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -38,10 +38,10 @@ }, "devDependencies": { "@types/node": "^25.9.1", - "@typescript/native-preview": "7.0.0-dev.20260527.2", + "@typescript/native-preview": "7.0.0-dev.20260603.1", "rolldown": "^1.0.3", "typescript": "^6.0.3", - "vitest": "^4.1.7" + "vitest": "^4.1.8" }, "peerDependencies": { "@opencode-ai/plugin": ">=1" diff --git a/packages/pi/package.json b/packages/pi/package.json index 4f37e83..afdf059 100644 --- a/packages/pi/package.json +++ b/packages/pi/package.json @@ -38,10 +38,10 @@ }, "devDependencies": { "@types/node": "^25.9.1", - "@typescript/native-preview": "7.0.0-dev.20260527.2", + "@typescript/native-preview": "7.0.0-dev.20260603.1", "rolldown": "^1.0.3", "typescript": "^6.0.3", - "vitest": "^4.1.7" + "vitest": "^4.1.8" }, "peerDependencies": { "@earendil-works/pi-coding-agent": "*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c396031..67178d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,29 +12,29 @@ importers: specifier: ^2.31.0 version: 2.31.0(@types/node@25.9.1) '@cloudflare/workers-types': - specifier: ^4.20260530.1 - version: 4.20260531.1 + specifier: ^4.20260603.1 + version: 4.20260603.1 '@types/node': specifier: ^25.9.1 version: 25.9.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260527.2 - version: 7.0.0-dev.20260527.2 + specifier: 7.0.0-dev.20260603.1 + version: 7.0.0-dev.20260603.1 alchemy: - specifier: 0.93.9 - version: 0.93.9(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260526.1) + specifier: 0.93.10 + version: 0.93.10(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260601.1) husky: specifier: ^9.1.7 version: 9.1.7 lint-staged: - specifier: ^17.0.6 + specifier: ^17.0.7 version: 17.0.7 oxfmt: - specifier: ^0.52.0 - version: 0.52.0 + specifier: ^0.53.0 + version: 0.53.0 oxlint: - specifier: ^1.67.0 - version: 1.67.0 + specifier: ^1.68.0 + version: 1.68.0 prettier-plugin-astro: specifier: ^0.14.1 version: 0.14.1 @@ -42,7 +42,7 @@ importers: specifier: ^1.0.3 version: 1.0.3 tsx: - specifier: ^4.22.3 + specifier: ^4.22.4 version: 4.22.4 turbo: specifier: ^2.9.16 @@ -51,8 +51,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: ^4.1.8 + version: 4.1.8(@types/node@25.9.1)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) apps/landing: dependencies: @@ -64,10 +64,10 @@ importers: version: 4.2.0 '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.3.0(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) astro: - specifier: ^6.4.2 - version: 6.4.2(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0) + specifier: ^6.4.3 + version: 6.4.3(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.61.0)(tsx@4.22.4)(yaml@2.9.0) tailwindcss: specifier: ^4.3.0 version: 4.3.0 @@ -76,8 +76,8 @@ importers: version: 6.0.3 devDependencies: vite: - specifier: ^7.3.3 - version: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + specifier: ^8.0.16 + version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) packages/benchmarks: dependencies: @@ -92,8 +92,8 @@ importers: specifier: ^25.9.1 version: 25.9.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260527.2 - version: 7.0.0-dev.20260527.2 + specifier: 7.0.0-dev.20260603.1 + version: 7.0.0-dev.20260603.1 caplets: specifier: workspace:* version: link:../cli @@ -101,8 +101,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: ^4.1.8 + version: 4.1.8(@types/node@25.9.1)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/cli: dependencies: @@ -117,8 +117,8 @@ importers: specifier: ^25.9.1 version: 25.9.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260527.2 - version: 7.0.0-dev.20260527.2 + specifier: 7.0.0-dev.20260603.1 + version: 7.0.0-dev.20260603.1 rolldown: specifier: ^1.0.3 version: 1.0.3 @@ -126,8 +126,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: ^4.1.8 + version: 4.1.8(@types/node@25.9.1)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/core: dependencies: @@ -147,8 +147,8 @@ importers: specifier: ^15.0.0 version: 15.0.0 graphql: - specifier: ^16.14.0 - version: 16.14.0 + specifier: ^16.14.1 + version: 16.14.1 hono: specifier: ^4.12.23 version: 4.12.23 @@ -169,8 +169,8 @@ importers: specifier: ^25.9.1 version: 25.9.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260527.2 - version: 7.0.0-dev.20260527.2 + specifier: 7.0.0-dev.20260603.1 + version: 7.0.0-dev.20260603.1 rolldown: specifier: ^1.0.3 version: 1.0.3 @@ -178,8 +178,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: ^4.1.8 + version: 4.1.8(@types/node@25.9.1)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/opencode: dependencies: @@ -194,8 +194,8 @@ importers: specifier: ^25.9.1 version: 25.9.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260527.2 - version: 7.0.0-dev.20260527.2 + specifier: 7.0.0-dev.20260603.1 + version: 7.0.0-dev.20260603.1 rolldown: specifier: ^1.0.3 version: 1.0.3 @@ -203,8 +203,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: ^4.1.8 + version: 4.1.8(@types/node@25.9.1)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/pi: dependencies: @@ -222,8 +222,8 @@ importers: specifier: ^25.9.1 version: 25.9.1 '@typescript/native-preview': - specifier: 7.0.0-dev.20260527.2 - version: 7.0.0-dev.20260527.2 + specifier: 7.0.0-dev.20260603.1 + version: 7.0.0-dev.20260603.1 rolldown: specifier: ^1.0.3 version: 1.0.3 @@ -231,8 +231,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 vitest: - specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: ^4.1.8 + version: 4.1.8(@types/node@25.9.1)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -323,52 +323,88 @@ packages: resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-cognito-identity@3.1057.0': - resolution: {integrity: sha512-5MliYkp2u0+2arTp5fZIaxl+xmm90LEKv/VeSxhfNQW4t0fvWJrNO429/jchWQenNoDRrOGE59VfbuZUfwFujg==} + '@aws-sdk/client-cognito-identity@3.1060.0': + resolution: {integrity: sha512-JS3YVzOnwLYD+OzAYVXVrP/IzaQrW8c9c/pI2eehsltvqSrl9pyNu5RzcgAxy79zHwv5f21j6FBWcgQ7Bj9eHA==} engines: {node: '>=20.0.0'} '@aws-sdk/core@3.974.15': resolution: {integrity: sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-cognito-identity@3.972.38': - resolution: {integrity: sha512-OHkK6xOx/IHkSbQdDWxnVCLU+j28EFl8wyWgBILQDFAPY8n240C/O4gjmFx+zFU12lL8njgJQ5GWAIWq88CnSQ==} + '@aws-sdk/core@3.974.17': + resolution: {integrity: sha512-r8o4h2K7j6P9ngno+8ei0aK0U/4JwDb7A2fMMxGVoSqDN8AFlIzSDeZHME9LcVLR2codyhtr1WAAg+/nmkeeMA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-cognito-identity@3.972.40': + resolution: {integrity: sha512-viZSv9qWrcfWvpnhy8FPQ5y0ee4TiCOX/xM8LSQXR+xJn0P4DV7B+XwXfOZ+Ik3+yQ/aO69qVnngEpXExAwizA==} engines: {node: '>=20.0.0'} '@aws-sdk/credential-provider-env@3.972.41': resolution: {integrity: sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.43': + resolution: {integrity: sha512-g0XVQKzaA/4cq1vz1IvCQwYM+1Pkv01J9yHDpCTXekVuGZRDEz0wqBQ1AuYTq7FM6uik4uBGH8Tb5d9YvgeA7g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.43': resolution: {integrity: sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.45': + resolution: {integrity: sha512-w9PuOoKCt6+xoESvY+zlV0u3PKQ0mVL259PcsVR6a3S/uYJJHnIi4r1NxdJHEcNldUVRIciltWnFMGBR4YEm3g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.46': resolution: {integrity: sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.48': + resolution: {integrity: sha512-+6BQ6Lrnc+EyAGElLRW6j+Sa+RirPHnIJsobvYO6nnyK+oGKmz1ne/ieclbLWyjyDKEU3/JVJWcWY3VLFPvGtQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.45': resolution: {integrity: sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.47': + resolution: {integrity: sha512-Iy2ebWVgrZBH05464uJiQYu6HSSiROnwVZptthEFXx2gWjo1ORCxEAFZB5Cr2MdfrSnZ+0QUPkZ1ZpCqpkUrLQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.47': resolution: {integrity: sha512-HrId+C0DWA5qDIyLG64/kjUB2RNtPypxmABnIctK+TA1P1kHlOYoE/Wf5T5tKOMKgb08P7k/zNyhvfJ3lh5Oag==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.50': + resolution: {integrity: sha512-b05Aelq5cqAvCCDQjCYacl0XmR8QhBNSqLbsdISkQmlQBa5oPS66zYPteWcSp5LswbpoIe552EUGjluKiadBig==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.41': resolution: {integrity: sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.43': + resolution: {integrity: sha512-GPokLNyvTfCmuaHk+v3GKVs4ZT3cMu5kgS2a+NPkOMt96cq6fSIK0g+mZHpGS6Cd4QGrPKesANEaLUKgOskTzg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.45': resolution: {integrity: sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.47': + resolution: {integrity: sha512-0AzvLrzlvJs0DzbeWGvNj+bX3Uzd7VNS6vDqCOdZzBlCGKGd78uxctJSW9iK/Rt/nxiJqpTvrYQlVJ4guVM2Dw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.45': resolution: {integrity: sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-providers@3.1057.0': - resolution: {integrity: sha512-rbrEHtz11g0kxsSkYr3fx2HABNNblp4AhB2MgPvJHgYOWfJ2eBviU7Mvoaef0PW8QH6lbZDfJcnM7eKvtvz3sw==} + '@aws-sdk/credential-provider-web-identity@3.972.47': + resolution: {integrity: sha512-eksfbUErOejUAGWBAcNqaP7IX21oUOEo73d9R56k9Ua4d57qS90NEYkWJsuSGzTXMFulCu17qXJI/qGmM7hvoA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-providers@3.1060.0': + resolution: {integrity: sha512-fcazJa+YQTJo+Cxc8K7IOTHpEzc2vUBKnchjNpqr9rLcTKGXajl3U0SByi4YDo89AC4WP2d6LqQaTNysuWMNhA==} engines: {node: '>=20.0.0'} '@aws-sdk/eventstream-handler-node@3.972.18': @@ -387,10 +423,18 @@ packages: resolution: {integrity: sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.15': + resolution: {integrity: sha512-Fpri1/PXKMKveORZ7E00VLTlWS5DkfZkW70PUE+bOnpWpAeHAQLoiDHhkzN3kNWbbSsGg64+IZYiq/EZgME3Mg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.30': resolution: {integrity: sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.31': + resolution: {integrity: sha512-Kn2up9SlG1KC6wRtwf0d7waTGF6rvp9DxYqB54x6UCKdQ6kyaXCqHL4WGb5vUJga5kS8FxnjhY0LqM28aMvnNQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1048.0': resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} engines: {node: '>=20.0.0'} @@ -399,6 +443,14 @@ packages: resolution: {integrity: sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1060.0': + resolution: {integrity: sha512-6NZaMKkFhpaNiwLpHi1sZaYjidL/lCJE6ME6NxwA8gv9vQna+Kr0j4OFwVoz6tANRWM3WbGz6jiPsGX/Vkjwow==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.10': + resolution: {integrity: sha512-992QrTO7G9qCvKD0fx1rMlqcL14plUcRAbwmqqYVsuF3GrqcvlAL9qxR+baMafarEZ+l7DUQ5lCMmt5mbMhF7g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.9': resolution: {integrity: sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==} engines: {node: '>=20.0.0'} @@ -411,6 +463,10 @@ packages: resolution: {integrity: sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.27': + resolution: {integrity: sha512-hpsCXCOI436kxWpjtRuIHVvuPP81MOw8f18jzfZeg+UOiiOvlqWcmWChzEhJEu16cOC6+ku4ncBN+7rdt+DZ9g==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} @@ -531,8 +587,8 @@ packages: cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-64@1.20260526.1': - resolution: {integrity: sha512-/pR3GH3gfv0PUp7DjI8v0aAIDOqFwibq4bg5xT7TZgcVdBV/cJQWckdXCMqiRtHiawLwogUX00EIOINkYJ1Zqg==} + '@cloudflare/workerd-darwin-64@1.20260601.1': + resolution: {integrity: sha512-iXZBVuRbvuVqQ/63wul01hHCv/3R8G5S8zbkjfoHvyPZFynmlKTV59Hk+H8whyGwFAZuB71UJGLr+G5mJKfjWA==} engines: {node: '>=16'} cpu: [x64] os: [darwin] @@ -543,8 +599,8 @@ packages: cpu: [arm64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260526.1': - resolution: {integrity: sha512-rcyu0iANYfaiezKh3Mcao1O4IIgVfQldxduiL5TZT1sP0NIeRY4YReSTrzPxNnXxSYaIqaqRHMcHbUM/ic4knA==} + '@cloudflare/workerd-darwin-arm64@1.20260601.1': + resolution: {integrity: sha512-veGpZQGBw07Twt+Y4z3oyo+/obKHt0iWUwvDV5GOiDAYjC/zW+YGstgVzg4SHq+k1sLH3ElqL2TXx20I5WBv3Q==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] @@ -555,8 +611,8 @@ packages: cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-64@1.20260526.1': - resolution: {integrity: sha512-5EZAEnlLwa9oGJRo8Nd3iY5Wcd9ROGNNG90xNIGp8MEjj8v2jTn42NC47fCZKFdnLj3+S+vWEhu1x0GVJnALjA==} + '@cloudflare/workerd-linux-64@1.20260601.1': + resolution: {integrity: sha512-n/9hDz7fPGpYF0J684+Xr5zgjcS2jdmY2Of5m6e+eQ/M9+RfR+UaU8Ee/tkA1dDC0LYQB13hfPafZG66Ff1CsA==} engines: {node: '>=16'} cpu: [x64] os: [linux] @@ -567,8 +623,8 @@ packages: cpu: [arm64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260526.1': - resolution: {integrity: sha512-X/YBQXeXFeCN7QTStoWrATEBc9WKl7PIqkw/dQkjyJ72gh3rkLe0+Xkzp3wO7gtxTDQMa7NPGy1W4+sdMf8q1g==} + '@cloudflare/workerd-linux-arm64@1.20260601.1': + resolution: {integrity: sha512-VHRZZbexATS+n+1j3x/CZaYbIJEye0J3iIHgG0Wp+l+NrZCKQ8qi8Lq1uTV0dLJQ67FuZtJtWdQ95mm9F7Fc+A==} engines: {node: '>=16'} cpu: [arm64] os: [linux] @@ -579,14 +635,14 @@ packages: cpu: [x64] os: [win32] - '@cloudflare/workerd-windows-64@1.20260526.1': - resolution: {integrity: sha512-R+tqpFFdcfZIljx8fIW9rj9fRTtDgfoA2yonsfAGa6e8snrmr+38mdFHtkRC0D3UyZpn/hOtmXiUBfdX2gMR7Q==} + '@cloudflare/workerd-windows-64@1.20260601.1': + resolution: {integrity: sha512-ye0C7MFLkeH16iTo8Tcjv2KiFmp23+sZGvUzSQa4xhP0QMe6EoJ+H/4SqqvnZ5nfN54slqKvx2VnXceENWe2CQ==} engines: {node: '>=16'} cpu: [x64] os: [win32] - '@cloudflare/workers-types@4.20260531.1': - resolution: {integrity: sha512-7DybhbX12n+mVgJEDvm9W/jjqpaUIczg+RWj1Hua9nGEG+pNJnT+yZj1JKENrbdyuGWx3OFEgUCNFcGJN86Dvg==} + '@cloudflare/workers-types@4.20260603.1': + resolution: {integrity: sha512-TLeVHoBbcYv35S5TdRWUoj3IJ56BhHtrsuci+O7ithU8yz7ttNdCk6rAl1QUSGNVEWSIp54bWOuV/xmX1zu79g==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -1703,246 +1759,246 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} - '@oxfmt/binding-android-arm-eabi@0.52.0': - resolution: {integrity: sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ==} + '@oxfmt/binding-android-arm-eabi@0.53.0': + resolution: {integrity: sha512-XfVM8AmIovBTKXCt14Op5wbfcoM8418nttd+nhMgM3RAVaJg1MtJc73FyWfUt0oxLyBGVwfniNVUsbV/b3VmPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.52.0': - resolution: {integrity: sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ==} + '@oxfmt/binding-android-arm64@0.53.0': + resolution: {integrity: sha512-btHDfXckwdf9zgyAVznfZkf+GVyB0I1m1hlvaOMRx2xoyz3hphfPX97s89J3wfCN8QBETLtk4lQUaeOkrMuQOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.52.0': - resolution: {integrity: sha512-f9+bLvOYxy7NttCLFTvQ7afmqDOWY4wIP9xdvfj5trQ1qj6f2UFAGwZESlfsMjvJNTyRpXfIlOanCI9FOvoeQA==} + '@oxfmt/binding-darwin-arm64@0.53.0': + resolution: {integrity: sha512-k2RjMcSTkHjoOlsVGbL35JVzXL+oQco3GHPl/5kjebVF4oHNfE24In8F5isqBh9LBJucycWHKDXdGrCchdWcHQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.52.0': - resolution: {integrity: sha512-YSTB9sJ5nnQd/Q0ddHkgof0ZCHPAnWZT1IW2SJ8omz7CP7KluJhO1fNHrpqdxCtpztJwSs4hY1uAee35wKxxaw==} + '@oxfmt/binding-darwin-x64@0.53.0': + resolution: {integrity: sha512-65jIBE2H1l5SSs16fmv6/7b6sAx/WpvnsgDhVWK9qSjNFDUro7MPQ6q5UhpY7kl46yltfR046iAnxy/Bzqbiew==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.52.0': - resolution: {integrity: sha512-NIrRNTTPCs4UbmVs0bxLSCDlLCtIRMJIXklNKaXa5Oj2/K1UIMBvgE8+uPVo01Io3N9HF0+GAX+aAHjUgZS7vA==} + '@oxfmt/binding-freebsd-x64@0.53.0': + resolution: {integrity: sha512-oYe1gkz7U49PCYrS9147d2fJZj8mDI4Di6AvlsU5fu9p+Tq8S7qqOMSZjUiVTLX8bXuSA9Lk/tIxuegVjkNYRA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.52.0': - resolution: {integrity: sha512-JXUCde8mn3GpgQouz2PXUokgy/uT1QrRJBL2s983VWcSQp62wTFYiNXgTKdeo1Jgbr0IgUnKKvzIk/YBlj/nVQ==} + '@oxfmt/binding-linux-arm-gnueabihf@0.53.0': + resolution: {integrity: sha512-ailB2vLzGi629tymdAb2VYJyEHref7oqGxP+tRBrtRBxQrb6NV55JMT7xtGZ8uTeG2+Y9zojqW4LhJYxQnz9Pg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.52.0': - resolution: {integrity: sha512-psbUXaRZ+V8DaXz10Qf7LSHtdtdKAmC8fxXgeU608jjzrmWK4quamZMOpl6sf+dikoFHA85uE93Q0BqxrCdQrQ==} + '@oxfmt/binding-linux-arm-musleabihf@0.53.0': + resolution: {integrity: sha512-abh4mWBvOvD966sobqF7r103y2yYx7Rb4WGHLOS4+5igGqLbbPxS9aK5+45D6iUY7dWMsk3Muz9a8gUtufvqJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.52.0': - resolution: {integrity: sha512-Jw7MgWUU9lcLCcy82updISP3EthTlfvAwR6gWNxPzqly7+fLvOi2gHQE9xXQjpqaVLm/8P+gOzlv9ODuoVlaaw==} + '@oxfmt/binding-linux-arm64-gnu@0.53.0': + resolution: {integrity: sha512-z73PvuhJ8qA+cDbaiqbtopHglA91U4+y5wn2sTJJrnpB957d5P33FEuyP3DQIFd7ofljmDmfVT4G0CVGHZaJWg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.52.0': - resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} + '@oxfmt/binding-linux-arm64-musl@0.53.0': + resolution: {integrity: sha512-I6bhOTroqc3ThrwZ89l2k3ivKuELhdPLbAcJhRNyjWvlgwb0vjRgEnVL1XLx5Jud04/ypNRZBykAWrSk6l/D+g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.52.0': - resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} + '@oxfmt/binding-linux-ppc64-gnu@0.53.0': + resolution: {integrity: sha512-w0p3JzB/PkkQjXALMJMqP9YfP3yq4w6zGsu5kezQmUnxRkN3b/Theg2l/nDgBsOcczxS3gL6Gam5XNAVrO6QJQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.52.0': - resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} + '@oxfmt/binding-linux-riscv64-gnu@0.53.0': + resolution: {integrity: sha512-mzBhF6k1Yq1K/dqDmVe/AAafnlJfEpx7yfUiksyeWXJk5iSzZqBSxcsa02zIytYgQFRZ7h6WPZfwHg/DoOE1Kw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.52.0': - resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} + '@oxfmt/binding-linux-riscv64-musl@0.53.0': + resolution: {integrity: sha512-AlFCpnRQhogQFzZXWbO6xB6/Udy745L+eQNmDPGg7G/OeWsYmJc4jZYfUN5pQg0reOPWSED2mOQqKZOJM1U8cA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.52.0': - resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} + '@oxfmt/binding-linux-s390x-gnu@0.53.0': + resolution: {integrity: sha512-XD4ulY4f1DWbuuZXAqxhVn+gdPmrhnmojWtFN78ctVoupmS845fGhsUrk1HZXKQI+iymbaiz9vAjPsghHNQ7Ag==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.52.0': - resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} + '@oxfmt/binding-linux-x64-gnu@0.53.0': + resolution: {integrity: sha512-xg8KWX0QnxmYWRe60CgHYWXI0ZOtBbqTsXvWiWrcl2XUHJ3fht2QerOk2iWvylzX3zNT2GpvBRxGoR4d3sxPRQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.52.0': - resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} + '@oxfmt/binding-linux-x64-musl@0.53.0': + resolution: {integrity: sha512-MWExpYBGvl+pIvVB/gj/CcWlN2al8AizT7rUbtaYaWNoQkhWARM6W3qpgoCr72CYSN9PborzPmM5MIRe2BrNdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.52.0': - resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} + '@oxfmt/binding-openharmony-arm64@0.53.0': + resolution: {integrity: sha512-u4sajgO4nxgmJIgc/y2AqPhkdbOkQH8WugXpA1+pW0ESQhvGZ1oGq61Q4xMbJHJU1hFgtO18QNrcFYDPYH0gwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.52.0': - resolution: {integrity: sha512-k2sz6gWQdMfh5HPpIS+Bw/0UEV/kaK2xuqJRrWL233sEHx9WLlsmvlPFM4HUNThkYbSN0U0vPW7LVKZWDS8hPQ==} + '@oxfmt/binding-win32-arm64-msvc@0.53.0': + resolution: {integrity: sha512-Yq9sOZoIOJ5xPjO0qOyHJS4CiPuTkB2en9auxZz7Ar2p5RaC7BzLyVVmAA7zz9/L9YnjjY1DwNxN+ivKXimN/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.52.0': - resolution: {integrity: sha512-rhke69GTcArodLHpjMTfNnvjTEBryDeZcUCKK/VjXDMtfTULl6QRh0ymX5/hbCUv2WjYm9h/QbW++q2vE15gWQ==} + '@oxfmt/binding-win32-ia32-msvc@0.53.0': + resolution: {integrity: sha512-es1fVNZEkBqEcQtBpn19SYFgZF7FawlkCjkT/iImfEAus4gun8fBwB1E9hpV5LcR9B0DBNvRIXhW8BQk3JaE+Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.52.0': - resolution: {integrity: sha512-q5xL7oeXkZdEtNZWBdvehJcmt+GRu9l2bK40yJs1jJXlqq+r0Hygb1rTjq+FM2o/2xyt4cufH6KRplHp3Jjsvw==} + '@oxfmt/binding-win32-x64-msvc@0.53.0': + resolution: {integrity: sha512-QFmJs2bEu9AO4O6qsmEaZNGi6dFq8N+rT8EHAAnZIq/B9SeJDUbc4DzVxQ48MfDsL7D3sCZzo37zuTuspcURgg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.67.0': - resolution: {integrity: sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw==} + '@oxlint/binding-android-arm-eabi@1.68.0': + resolution: {integrity: sha512-wEdsIspexXLLMCPAEOcCuFLMt6aE3AzTuA/nQKLPRnoJ+EQTturmGheDkhHuuVHx0GbutjQ3JKmEn+Gz6Ag28Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.67.0': - resolution: {integrity: sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ==} + '@oxlint/binding-android-arm64@1.68.0': + resolution: {integrity: sha512-6aZRNNXQTsYtgaus8HTb9nuCcsrQTlKXGnktwvwW0n/SooRWNxNb3925grDkC63aEYZuCIyOVLV16IdYIoC2aQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.67.0': - resolution: {integrity: sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg==} + '@oxlint/binding-darwin-arm64@1.68.0': + resolution: {integrity: sha512-lVTbsE3kO4bLpZELgjRZuAJc8kP98wb83yMXWH8gaPaFZ+cM2IDeZto4ByoUAYj0Mxv2rvw+A1ssZequSepVSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.67.0': - resolution: {integrity: sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg==} + '@oxlint/binding-darwin-x64@1.68.0': + resolution: {integrity: sha512-nCmw2XrmQskjBUh/sfP5yKs93V68LijQgjd1cuuZ/q4SCARngLYs60/qqyzuMsg8QQ9KArDI98hxs/RDGE4KRQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.67.0': - resolution: {integrity: sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg==} + '@oxlint/binding-freebsd-x64@1.68.0': + resolution: {integrity: sha512-TI4ovQJliYE9V6e06cEv+qEI9uj7Ao65fmif4er4HD+aouyYyh0P31q2jh3KtqsOHHcQqv2PZ61TjJFLpBDGWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.67.0': - resolution: {integrity: sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g==} + '@oxlint/binding-linux-arm-gnueabihf@1.68.0': + resolution: {integrity: sha512-LcNnEi9g71Cmry5ZpLbKT+oVv+/zYG3hYVAbBBB5X85nOQZSk8l92CnDkxJMcxUg0NCnMCOFZuaVDlMyv4tYJw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.67.0': - resolution: {integrity: sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw==} + '@oxlint/binding-linux-arm-musleabihf@1.68.0': + resolution: {integrity: sha512-OovHahL3FX4UaK+hgSf11llUx2vszqjSdQQ61Ck9InOEI/ptZoC4XSQJurITqItVvd53JSlmkLMeaNjM1PoQew==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.67.0': - resolution: {integrity: sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg==} + '@oxlint/binding-linux-arm64-gnu@1.68.0': + resolution: {integrity: sha512-YbzTglnHLzzi9zv5or8Ztz5fykAoZE8W9iM42/bOrF4HBSB6rJTqdLQWuoP76EHQw9DuKl76K1QmFlG29sPJXQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.67.0': - resolution: {integrity: sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==} + '@oxlint/binding-linux-arm64-musl@1.68.0': + resolution: {integrity: sha512-qVKtCZNic+OoNnOr/hCQAu22HSQzflI7Fsq/Blzkw02SnLuv163k3kfmrVpZjSBlUHgsRKj6WgQiw30d3SX02Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.67.0': - resolution: {integrity: sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==} + '@oxlint/binding-linux-ppc64-gnu@1.68.0': + resolution: {integrity: sha512-zExyZ8ZOUuAyQ0y9jpTcyjKUz62YY9JhKPyVxzvjTpXzZ3ujdqiVwfPWDdnA1SsIOrxdtxHn7KErDHLWskFjXg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.67.0': - resolution: {integrity: sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==} + '@oxlint/binding-linux-riscv64-gnu@1.68.0': + resolution: {integrity: sha512-6C4MPuwewyDavA7sxM14wzgRi5GGL68HPIxRCdVyS75U4MDbpFVYzKO9WNR6KLKTMPq2pcz3THwo1sK2uiqngw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.67.0': - resolution: {integrity: sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==} + '@oxlint/binding-linux-riscv64-musl@1.68.0': + resolution: {integrity: sha512-bnZooVeHAcvA+dH0EDLgx+7HY/DRi6e0hFszg3P+OBatuUjV6EvfIyNIzWOusmqAVh4L6r21GGTZtiKE4iqM4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.67.0': - resolution: {integrity: sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==} + '@oxlint/binding-linux-s390x-gnu@1.68.0': + resolution: {integrity: sha512-dIqnZnJSmHCMOUpUcWQOiV14o3DDPVx1DSsMaSzvdhNjC1tB1iEPZbdiMSCIEYbkgbsYznHXWqFdKL8WUB3F8g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.67.0': - resolution: {integrity: sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==} + '@oxlint/binding-linux-x64-gnu@1.68.0': + resolution: {integrity: sha512-zc9lEnfV/HreDTY6gdMlZe+irkwHSxQ4/B1pS9GyK7RVaA5LxhoZY/w6/o2vIwLLEYiXQ5ujGxOM1ZazeFAAIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.67.0': - resolution: {integrity: sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==} + '@oxlint/binding-linux-x64-musl@1.68.0': + resolution: {integrity: sha512-Dl5QEX0TCo/40Cdh1o1JdPS//+YiWqjC+Hrrya5OQmStZZr4svAFtdlqcpCrU9yq2Mo3vRVyO9B3h0dzD8s36Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.67.0': - resolution: {integrity: sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==} + '@oxlint/binding-openharmony-arm64@1.68.0': + resolution: {integrity: sha512-/qy6dOvi4S3/LeXq0l5BT5pRKPYA7oj3uKwJOAZOr5HRLL+HK6jdBynvWuXIA2wwfE01RzNYmbBdM7vwYx00sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.67.0': - resolution: {integrity: sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA==} + '@oxlint/binding-win32-arm64-msvc@1.68.0': + resolution: {integrity: sha512-fHNtVqPHSYE7UFDSLVFUjxQjnSVXxseNJmRW+XuP4pXXDwePdPda43NL7/BBCFTxHjycOc44JNDaOPtFDNui9A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.67.0': - resolution: {integrity: sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg==} + '@oxlint/binding-win32-ia32-msvc@1.68.0': + resolution: {integrity: sha512-NnKXr4Wgo4nps3erhrE0f8shBvBPZMHg72nDsvX0JyrRvsNiP3f1JNvbCKh+A6VFvpF7ZoJxu904P3cKMhvZnA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.67.0': - resolution: {integrity: sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ==} + '@oxlint/binding-win32-x64-msvc@1.68.0': + resolution: {integrity: sha512-zg5pA+84AlU6XHJ3ruiRxziO71QTrz8nLsk6u01JGS5+tL9/bnlakFiklFrcy4R1/V7ktWtaNitN3JZWmKnf6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2097,173 +2153,173 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.60.4': - resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + '@rollup/rollup-android-arm-eabi@4.61.0': + resolution: {integrity: sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.60.4': - resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + '@rollup/rollup-android-arm64@4.61.0': + resolution: {integrity: sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.4': - resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + '@rollup/rollup-darwin-arm64@4.61.0': + resolution: {integrity: sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.4': - resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + '@rollup/rollup-darwin-x64@4.61.0': + resolution: {integrity: sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.60.4': - resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + '@rollup/rollup-freebsd-arm64@4.61.0': + resolution: {integrity: sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.4': - resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + '@rollup/rollup-freebsd-x64@4.61.0': + resolution: {integrity: sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.4': - resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + '@rollup/rollup-linux-arm-gnueabihf@4.61.0': + resolution: {integrity: sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.60.4': - resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + '@rollup/rollup-linux-arm-musleabihf@4.61.0': + resolution: {integrity: sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.60.4': - resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + '@rollup/rollup-linux-arm64-gnu@4.61.0': + resolution: {integrity: sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.60.4': - resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + '@rollup/rollup-linux-arm64-musl@4.61.0': + resolution: {integrity: sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.60.4': - resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + '@rollup/rollup-linux-loong64-gnu@4.61.0': + resolution: {integrity: sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.60.4': - resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + '@rollup/rollup-linux-loong64-musl@4.61.0': + resolution: {integrity: sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.60.4': - resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + '@rollup/rollup-linux-ppc64-gnu@4.61.0': + resolution: {integrity: sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.60.4': - resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + '@rollup/rollup-linux-ppc64-musl@4.61.0': + resolution: {integrity: sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.60.4': - resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + '@rollup/rollup-linux-riscv64-gnu@4.61.0': + resolution: {integrity: sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.60.4': - resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + '@rollup/rollup-linux-riscv64-musl@4.61.0': + resolution: {integrity: sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.60.4': - resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + '@rollup/rollup-linux-s390x-gnu@4.61.0': + resolution: {integrity: sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.60.4': - resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + '@rollup/rollup-linux-x64-gnu@4.61.0': + resolution: {integrity: sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.60.4': - resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + '@rollup/rollup-linux-x64-musl@4.61.0': + resolution: {integrity: sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.60.4': - resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + '@rollup/rollup-openbsd-x64@4.61.0': + resolution: {integrity: sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.60.4': - resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + '@rollup/rollup-openharmony-arm64@4.61.0': + resolution: {integrity: sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.4': - resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + '@rollup/rollup-win32-arm64-msvc@4.61.0': + resolution: {integrity: sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.4': - resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + '@rollup/rollup-win32-ia32-msvc@4.61.0': + resolution: {integrity: sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.4': - resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + '@rollup/rollup-win32-x64-gnu@4.61.0': + resolution: {integrity: sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.4': - resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + '@rollup/rollup-win32-x64-msvc@4.61.0': + resolution: {integrity: sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==} cpu: [x64] os: [win32] '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@shikijs/core@4.1.0': - resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==} + '@shikijs/core@4.2.0': + resolution: {integrity: sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ==} engines: {node: '>=20'} - '@shikijs/engine-javascript@4.1.0': - resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==} + '@shikijs/engine-javascript@4.2.0': + resolution: {integrity: sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og==} engines: {node: '>=20'} - '@shikijs/engine-oniguruma@4.1.0': - resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==} + '@shikijs/engine-oniguruma@4.2.0': + resolution: {integrity: sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g==} engines: {node: '>=20'} - '@shikijs/langs@4.1.0': - resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==} + '@shikijs/langs@4.2.0': + resolution: {integrity: sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ==} engines: {node: '>=20'} - '@shikijs/primitive@4.1.0': - resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==} + '@shikijs/primitive@4.2.0': + resolution: {integrity: sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA==} engines: {node: '>=20'} - '@shikijs/themes@4.1.0': - resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==} + '@shikijs/themes@4.2.0': + resolution: {integrity: sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w==} engines: {node: '>=20'} - '@shikijs/types@4.1.0': - resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==} + '@shikijs/types@4.2.0': + resolution: {integrity: sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw==} engines: {node: '>=20'} '@shikijs/vscode-textmate@10.0.2': @@ -2284,20 +2340,32 @@ packages: resolution: {integrity: sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==} engines: {node: '>=18.0.0'} + '@smithy/core@3.24.6': + resolution: {integrity: sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.3.6': resolution: {integrity: sha512-tHhdiWZfG1ZIh2YcRfPJmY2gHcBmqbAzqm3ER4TIDFYsSEqTD5tICT7cgQ/kI8LRakxp12myOYyK68XPn7MnHw==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.3.7': + resolution: {integrity: sha512-xj8gq/bjFABAh6qWPSDCYcY3kzQIm4b561C+YnHH4zGq8rOgzQ3Shk+JGlpUxSd41UGiO6FkLdUCtNX1FAeHgg==} + engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.4.5': resolution: {integrity: sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.4.6': + resolution: {integrity: sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==} + engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/node-config-provider@4.4.5': - resolution: {integrity: sha512-c2G9QJ4xVZLwAkAf+WQESSSCkKbtt33ytje1klGvTcBn6cKuqV28E+62wbRPHwuTikkB3LQ7CBnNrayCoJur5A==} + '@smithy/node-config-provider@4.4.6': + resolution: {integrity: sha512-M+gG6eQ0y073mSmNB+erRXJvwpsqsN72ol2w6vcd8FEKeG7pqYK0JvzfVqONkPj2ElBB2pg+cU13I850b//Wag==} engines: {node: '>=18.0.0'} '@smithy/node-http-handler@4.7.3': @@ -2308,14 +2376,26 @@ packages: resolution: {integrity: sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.7.6': + resolution: {integrity: sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ==} + engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.4.5': resolution: {integrity: sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.4.6': + resolution: {integrity: sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.14.2': resolution: {integrity: sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==} engines: {node: '>=18.0.0'} + '@smithy/types@4.14.3': + resolution: {integrity: sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==} + engines: {node: '>=18.0.0'} + '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} @@ -2466,9 +2546,6 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -2499,61 +2576,61 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260527.2': - resolution: {integrity: sha512-3LqSu4DlxkEfeC/Z/29QMCJn5jjkDtXI7LYuxfmjdmAatS6umDKqm8J17fnP/7fyrZUMBTIYRwSDpChGV3G1ew==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-BvaaQAHWaHA0nl26DsWTsXsKkCHqUTm7f5FMuNDyCU83Hvo7zHx0vpSTrzL+1KEWeWLUVVGA7U224dk+3yIosQ==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260527.2': - resolution: {integrity: sha512-H4+sxE9qaBbLF83wMdWE0FsgfK0Pom+/O+/oxqyGzhVkDJlNt3vfpgQZMit48/Gm44AacGfBggJ9Dhbi3aeSFw==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-a2JJezvJAqWXgTAD1tKdWs7iA/4s3EakLcCYx4rg3ptEbBF6ADWv7A9ySHxT/+CQWYCD0DYIb9du1JUWL85fRw==} engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260527.2': - resolution: {integrity: sha512-BGUDMjC2Z3TTdZRkGGwhBLelkP5UYgO2rbep8aF4dS3fu7T5lFPPrnfS6EgqJgie+cF5Fsev7xEq8wWyBDM+lg==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-MQ0JcRucLdcR6MT14wlrGNz9HaxJrFF8Axmo0IN6e5gSou2UrKKUvvAH1i8zU5Gm7jl8CWCLzzX/qGCi1TsinA==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260527.2': - resolution: {integrity: sha512-6I9Cv9ozwfS9zB9vRQDPIYseLX3artEO9jl3yVgLj4ishwlSF4cWAbIsjl5IztPaEgHv8coej/6tX1D0uaBzXg==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-GLsTfJQiGfTN+r1ezlxMcTd5MNYRB/tADD6Y1j1jfLjZsFYSVkNfCnDQA/jwUG6GddBBF+0Um7tBUP16emej9w==} engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260527.2': - resolution: {integrity: sha512-vpazOu+ozlxBo8U57YJMzsOPuxAV8H7fu36KJ8ea8At/D8pdGmOAy5TuB+9OBQV9JDe0OXJMy2kmbhOpmkTAmA==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-u6gvCiVGSDagdR2+GI5VJLPxJbGevATJgjZ2QFLKBLWI3re7liTGlPAuaINNDq/r0m9rUPj2rEn0zwqtcLK0nQ==} engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260527.2': - resolution: {integrity: sha512-DBFnFE3V6AITkPO1K1VxXf3yEZKjU2FwtXlNwRqhzDu0rrL2SsJHOSrBDX+OacTxQFzZMxFcpiuhV8jHZALPEg==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-slxm6HBYs+jk0GpIBC2o03uY5qHgW7wIH+OTjK8JHbd2sUCgYV7P7bfZHUVZYi/cJZcVrnDW6MxXx734TuFn+w==} engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260527.2': - resolution: {integrity: sha512-1tBlErMvQgcMqqYwsx4tytupcjCJcOUXD3vBn1Wb/kAvus1FzWQAFE0fcKBvLfcqLQfTiiEwKKEtbLjGmakqqg==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-G7SDZJn2Z9+c1qsAFzI/JL0OsRjJ18diQ39ycWKmJikilZZeL7iS7j933bemWY4ODaLTfxhMRKPMHf9292gpDA==} engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260527.2': - resolution: {integrity: sha512-piqkDwikVeizCFqA1lcwI5F4wOAtBdxuliWe77ApBNRyBPPvfCJB+u/HYi9/8t5nd0sWvFs6/qt/AzJ1CCoykQ==} + '@typescript/native-preview@7.0.0-dev.20260603.1': + resolution: {integrity: sha512-CO519Ccw5rji4JIG0DGVMR5owraCeQhm94jM53eRhMdlzz0nAJcAZ63Y6m1u3dUwqGssqlYxh4CcwTFPxTpMYw==} engines: {node: '>=16.20.0'} hasBin: true '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} - '@vitest/expect@4.1.7': - resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} - '@vitest/mocker@4.1.7': - resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2563,20 +2640,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.7': - resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} - '@vitest/runner@4.1.7': - resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} - '@vitest/snapshot@4.1.7': - resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} - '@vitest/spy@4.1.7': - resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} - '@vitest/utils@4.1.7': - resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} '@volar/kit@2.4.28': resolution: {integrity: sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==} @@ -2631,8 +2708,8 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - alchemy@0.93.9: - resolution: {integrity: sha512-suneExNJ0sziKP7xBLesOBFx/E6esousqKiIroKIML+4IQ6S8H0nhrt9i9j0fEZA0Z+BFE+H8qNG2mGzqIDtXg==} + alchemy@0.93.10: + resolution: {integrity: sha512-Ypl31Jpd8YpGnZfsnir2K344uFJLXhqKIM1zEDMiEucmSyckcZQ1Q3lpkz8ZDmOajDvPUOJ38VqtvlNC/1x6OQ==} hasBin: true peerDependencies: '@astrojs/cloudflare': ^12.6.4 @@ -2737,8 +2814,8 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - astro@6.4.2: - resolution: {integrity: sha512-8H89CH2dKL5SCU99OCqdU9BGjmPkSJqaPurywj5XMo7eMFGUFD3vsNhdEKnEh4mK4LgGje3/QDTTSIIGst0G0Q==} + astro@6.4.3: + resolution: {integrity: sha512-heArIk8zLcxuoj1WgBH2zGdAD8zKSU1mEcBvS6hYMEHRPlbtvB+4Y8ri9Z27hzeryvGaFgrH32zjghEfV2y07g==} engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true @@ -3482,8 +3559,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.14.0: - resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} + graphql@16.14.1: + resolution: {integrity: sha512-cQOsSMS/IrDz82PVyRDvf/Q1F/bRbBVjJlh+xYOkI1qw2bWRvWGiWc+m2O0d6l4Bt1fyY+8kzJ8JFWGJqNeDBg==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} h3@1.15.11: @@ -3700,8 +3777,8 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true json-bigint@1.0.0: @@ -4042,8 +4119,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - miniflare@4.20260526.0: - resolution: {integrity: sha512-JYQ7jPZZWoaaj9jWHb8Ucp6Cu2SbDVqIsAJhumqdzzLkkfq0pYkDeino/sZfW1ixJWPjv/C44zjm9gVJC2izCA==} + miniflare@4.20260601.0: + resolution: {integrity: sha512-56TFiulSEQu43cYxdXgCiA3U3i+Ls0NoXwJXd6DmpNsx8yl/1Il2T3DQ4CMXjR6yfE7CSvC5MuXaqcSAMREjgw==} engines: {node: '>=22.0.0'} hasBin: true @@ -4189,8 +4266,8 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} - oxfmt@0.52.0: - resolution: {integrity: sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug==} + oxfmt@0.53.0: + resolution: {integrity: sha512-9cB5glS3Ip6NMuZ+6NYTao9FCWkDhRtPYCtR3QBu/NxHoFbgzzTvi41N4jxz/GqGfuLKspui1qb/LlSu2IbMcw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -4202,8 +4279,8 @@ packages: vite-plus: optional: true - oxlint@1.67.0: - resolution: {integrity: sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==} + oxlint@1.68.0: + resolution: {integrity: sha512-dXcbq+xsmLrMy6T8d0euf3IYUfLmjHIE11pOxiUSi5LHkFZaYPv568R6sEjcavVpUxoaQe66UBuK4HEi74NxpA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -4374,8 +4451,8 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} - property-information@7.1.0: - resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} protobufjs@7.6.2: resolution: {integrity: sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==} @@ -4518,31 +4595,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.60.4: - resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + rollup@4.61.0: + resolution: {integrity: sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rosie-skills-darwin-arm64@0.6.4: - resolution: {integrity: sha512-rn1s5hqFKcxeiDEWWoFa1hdGPshR8TkwHLzy/cBavb9XJNAaUxbe3oQ78W9sQkRHAgRyzJYyk9tw68Qrdnizgg==} - cpu: [arm64] - os: [darwin] - - rosie-skills-freebsd-x64@0.6.4: - resolution: {integrity: sha512-SxCRduPBMtfjkQ+q56Yw9OLA3PyaqoALzt7kER7IDKuUVfM2O/1w8sa5xhTDiCvWkZJixnH5d5Ya6KT+/Mwcng==} - cpu: [x64] - os: [freebsd] - - rosie-skills-linux-x64@0.6.4: - resolution: {integrity: sha512-D9Y9mfu7goB0s0X59uU3hcFeUTef3VbpCIDwFMzyvJrAq3XhRACWBDMHQsHlyWdHxTXPX/ILyW65RXyrJlgqng==} - cpu: [x64] - os: [linux] - - rosie-skills@0.6.4: - resolution: {integrity: sha512-ojfhSiQRdZ2QyWbmKAHOSAUbaLYrTc5zIH7mS1jKoP8KCFSQddwVhMyFqldckTeybTfW3zNcsZzyOTzGTN1SBA==} - engines: {node: '>=18'} - hasBin: true - router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -4604,8 +4661,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shiki@4.1.0: - resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==} + shiki@4.2.0: + resolution: {integrity: sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ==} engines: {node: '>=20'} side-channel-list@1.0.1: @@ -4754,8 +4811,8 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyclip@0.1.13: - resolution: {integrity: sha512-8OqlXQ35euK9+e7L68u8UwcODxkHoIkjbGsgXuARKNyQ5G6xt8nw1YPeMbxMLgCPFkToU+UEK5j05t2t8edKpQ==} + tinyclip@0.1.14: + resolution: {integrity: sha512-F1oWdz8tjT17qe1d5JgDK6z03WGOhYYAN0lK3/D/fzNiy93xswLLEw7pk+3g05onhAy6Bsc6PLNUGhdgVjemMQ==} engines: {node: ^16.14.0 || >= 17.3.0} tinyexec@1.2.4: @@ -4984,8 +5041,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@7.3.3: - resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + vite@7.3.5: + resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -5024,6 +5081,49 @@ packages: yaml: optional: true + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitefu@1.1.3: resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} peerDependencies: @@ -5032,20 +5132,20 @@ packages: vite: optional: true - vitest@4.1.7: - resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.7 - '@vitest/browser-preview': 4.1.7 - '@vitest/browser-webdriverio': 4.1.7 - '@vitest/coverage-istanbul': 4.1.7 - '@vitest/coverage-v8': 4.1.7 - '@vitest/ui': 4.1.7 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -5155,6 +5255,9 @@ packages: vscode-languageserver-types@3.17.5: resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + vscode-languageserver-types@3.18.0: + resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} + vscode-languageserver@9.0.1: resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} hasBin: true @@ -5191,17 +5294,17 @@ packages: engines: {node: '>=16'} hasBin: true - workerd@1.20260526.1: - resolution: {integrity: sha512-IHzymht98p10JH1zzwdCpbViAqw97HrwKl7+KfZeASFMsYSrIsAULWdPn0LRC5FTUzBpamLNyKCCKxbgXHgRHQ==} + workerd@1.20260601.1: + resolution: {integrity: sha512-Bg4+HF3B8TW0urAv8chiz25HSQ/aJxMBjgheUzu/nB1NQa+CaKGrUPv+Z3bf0np/WxLHYW1kcseVEtzZVPbX4g==} engines: {node: '>=16'} hasBin: true - wrangler@4.95.0: - resolution: {integrity: sha512-vgXzFVSCdUbeCadgVXvu8fK5tzNm8T9W+7lriyGWZMx0B1+CAdr4d8JTlZszHfgjypRAHmAxb49etZGIRD9pgg==} + wrangler@4.97.0: + resolution: {integrity: sha512-jzW/aNvjerV+4TmwbvwGY6lpcuBk7EFUTonMDNfci45wSmMTj2/OJN+83cc/CeepKdb+6ZjGJw9NRjmcQoxqRg==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20260526.1 + '@cloudflare/workers-types': ^4.20260601.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -5341,7 +5444,7 @@ snapshots: '@apidevtools/json-schema-ref-parser@14.0.1': dependencies: '@types/json-schema': 7.0.15 - js-yaml: 4.1.1 + js-yaml: 4.2.0 '@apidevtools/openapi-schemas@2.1.0': {} @@ -5376,10 +5479,10 @@ snapshots: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - js-yaml: 4.1.1 + js-yaml: 4.2.0 picomatch: 4.0.4 retext-smartypants: 6.2.0 - shiki: 4.1.0 + shiki: 4.2.0 smol-toml: 1.6.1 unified: 11.0.5 @@ -5450,7 +5553,7 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.9 + '@aws-sdk/types': 3.973.10 tslib: 2.8.1 '@aws-crypto/sha256-browser@5.2.0': @@ -5458,7 +5561,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.9 + '@aws-sdk/types': 3.973.10 '@aws-sdk/util-locate-window': 3.965.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -5466,7 +5569,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.9 + '@aws-sdk/types': 3.973.10 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -5475,7 +5578,7 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.9 + '@aws-sdk/types': 3.973.10 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -5496,17 +5599,17 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/client-cognito-identity@3.1057.0': + '@aws-sdk/client-cognito-identity@3.1060.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.15 - '@aws-sdk/credential-provider-node': 3.972.47 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.5 - '@smithy/fetch-http-handler': 5.4.5 - '@smithy/node-http-handler': 4.7.5 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.974.17 + '@aws-sdk/credential-provider-node': 3.972.50 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.6 + '@smithy/types': 4.14.3 tslib: 2.8.1 '@aws-sdk/core@3.974.15': @@ -5520,12 +5623,23 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-cognito-identity@3.972.38': + '@aws-sdk/core@3.974.17': dependencies: - '@aws-sdk/nested-clients': 3.997.13 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.5 - '@smithy/types': 4.14.2 + '@aws-sdk/types': 3.973.10 + '@aws-sdk/xml-builder': 3.972.27 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.6 + '@smithy/signature-v4': 5.4.6 + '@smithy/types': 4.14.3 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-cognito-identity@3.972.40': + dependencies: + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 tslib: 2.8.1 '@aws-sdk/credential-provider-env@3.972.41': @@ -5536,6 +5650,14 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.43': dependencies: '@aws-sdk/core': 3.974.15 @@ -5546,6 +5668,16 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.45': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.46': dependencies: '@aws-sdk/core': 3.974.15 @@ -5562,6 +5694,22 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.48': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/credential-provider-env': 3.972.43 + '@aws-sdk/credential-provider-http': 3.972.45 + '@aws-sdk/credential-provider-login': 3.972.47 + '@aws-sdk/credential-provider-process': 3.972.43 + '@aws-sdk/credential-provider-sso': 3.972.47 + '@aws-sdk/credential-provider-web-identity': 3.972.47 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/credential-provider-imds': 4.3.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.45': dependencies: '@aws-sdk/core': 3.974.15 @@ -5571,6 +5719,15 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.47': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.47': dependencies: '@aws-sdk/credential-provider-env': 3.972.41 @@ -5585,6 +5742,20 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.50': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.43 + '@aws-sdk/credential-provider-http': 3.972.45 + '@aws-sdk/credential-provider-ini': 3.972.48 + '@aws-sdk/credential-provider-process': 3.972.43 + '@aws-sdk/credential-provider-sso': 3.972.47 + '@aws-sdk/credential-provider-web-identity': 3.972.47 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/credential-provider-imds': 4.3.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.41': dependencies: '@aws-sdk/core': 3.974.15 @@ -5593,6 +5764,14 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.45': dependencies: '@aws-sdk/core': 3.974.15 @@ -5603,6 +5782,16 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.47': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/token-providers': 3.1060.0 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.45': dependencies: '@aws-sdk/core': 3.974.15 @@ -5612,24 +5801,33 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/credential-providers@3.1057.0': + '@aws-sdk/credential-provider-web-identity@3.972.47': dependencies: - '@aws-sdk/client-cognito-identity': 3.1057.0 - '@aws-sdk/core': 3.974.15 - '@aws-sdk/credential-provider-cognito-identity': 3.972.38 - '@aws-sdk/credential-provider-env': 3.972.41 - '@aws-sdk/credential-provider-http': 3.972.43 - '@aws-sdk/credential-provider-ini': 3.972.46 - '@aws-sdk/credential-provider-login': 3.972.45 - '@aws-sdk/credential-provider-node': 3.972.47 - '@aws-sdk/credential-provider-process': 3.972.41 - '@aws-sdk/credential-provider-sso': 3.972.45 - '@aws-sdk/credential-provider-web-identity': 3.972.45 - '@aws-sdk/nested-clients': 3.997.13 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.5 - '@smithy/credential-provider-imds': 4.3.6 - '@smithy/types': 4.14.2 + '@aws-sdk/core': 3.974.17 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-providers@3.1060.0': + dependencies: + '@aws-sdk/client-cognito-identity': 3.1060.0 + '@aws-sdk/core': 3.974.17 + '@aws-sdk/credential-provider-cognito-identity': 3.972.40 + '@aws-sdk/credential-provider-env': 3.972.43 + '@aws-sdk/credential-provider-http': 3.972.45 + '@aws-sdk/credential-provider-ini': 3.972.48 + '@aws-sdk/credential-provider-login': 3.972.47 + '@aws-sdk/credential-provider-node': 3.972.50 + '@aws-sdk/credential-provider-process': 3.972.43 + '@aws-sdk/credential-provider-sso': 3.972.47 + '@aws-sdk/credential-provider-web-identity': 3.972.47 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/credential-provider-imds': 4.3.7 + '@smithy/types': 4.14.3 tslib: 2.8.1 '@aws-sdk/eventstream-handler-node@3.972.18': @@ -5669,6 +5867,19 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.15': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.17 + '@aws-sdk/signature-v4-multi-region': 3.996.31 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.30': dependencies: '@aws-sdk/types': 3.973.9 @@ -5676,6 +5887,13 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.31': + dependencies: + '@aws-sdk/types': 3.973.10 + '@smithy/signature-v4': 5.4.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1048.0': dependencies: '@aws-sdk/core': 3.974.15 @@ -5694,6 +5912,20 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@aws-sdk/token-providers@3.1060.0': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.10': + dependencies: + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@aws-sdk/types@3.973.9': dependencies: '@smithy/types': 4.14.2 @@ -5709,6 +5941,12 @@ snapshots: fast-xml-parser: 5.7.3 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.27': + dependencies: + '@smithy/types': 4.14.3 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': {} '@babel/helper-string-parser@7.29.7': {} @@ -5838,7 +6076,7 @@ snapshots: '@changesets/parse@0.4.3': dependencies: '@changesets/types': 6.1.0 - js-yaml: 4.1.1 + js-yaml: 4.2.0 '@changesets/pre@2.0.2': dependencies: @@ -5887,49 +6125,49 @@ snapshots: '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260526.1)': + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260601.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20260526.1 + workerd: 1.20260601.1 - '@cloudflare/unenv-preset@2.7.7(unenv@2.0.0-rc.21)(workerd@1.20260526.1)': + '@cloudflare/unenv-preset@2.7.7(unenv@2.0.0-rc.21)(workerd@1.20260601.1)': dependencies: unenv: 2.0.0-rc.21 optionalDependencies: - workerd: 1.20260526.1 + workerd: 1.20260601.1 '@cloudflare/workerd-darwin-64@1.20260424.1': optional: true - '@cloudflare/workerd-darwin-64@1.20260526.1': + '@cloudflare/workerd-darwin-64@1.20260601.1': optional: true '@cloudflare/workerd-darwin-arm64@1.20260424.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260526.1': + '@cloudflare/workerd-darwin-arm64@1.20260601.1': optional: true '@cloudflare/workerd-linux-64@1.20260424.1': optional: true - '@cloudflare/workerd-linux-64@1.20260526.1': + '@cloudflare/workerd-linux-64@1.20260601.1': optional: true '@cloudflare/workerd-linux-arm64@1.20260424.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260526.1': + '@cloudflare/workerd-linux-arm64@1.20260601.1': optional: true '@cloudflare/workerd-windows-64@1.20260424.1': optional: true - '@cloudflare/workerd-windows-64@1.20260526.1': + '@cloudflare/workerd-windows-64@1.20260601.1': optional: true - '@cloudflare/workers-types@4.20260531.1': {} + '@cloudflare/workers-types@4.20260603.1': {} '@cspotcode/source-map-support@0.8.1': dependencies: @@ -6735,118 +6973,118 @@ snapshots: '@oxc-project/types@0.133.0': {} - '@oxfmt/binding-android-arm-eabi@0.52.0': + '@oxfmt/binding-android-arm-eabi@0.53.0': optional: true - '@oxfmt/binding-android-arm64@0.52.0': + '@oxfmt/binding-android-arm64@0.53.0': optional: true - '@oxfmt/binding-darwin-arm64@0.52.0': + '@oxfmt/binding-darwin-arm64@0.53.0': optional: true - '@oxfmt/binding-darwin-x64@0.52.0': + '@oxfmt/binding-darwin-x64@0.53.0': optional: true - '@oxfmt/binding-freebsd-x64@0.52.0': + '@oxfmt/binding-freebsd-x64@0.53.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.52.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.53.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.52.0': + '@oxfmt/binding-linux-arm-musleabihf@0.53.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.52.0': + '@oxfmt/binding-linux-arm64-gnu@0.53.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.52.0': + '@oxfmt/binding-linux-arm64-musl@0.53.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.52.0': + '@oxfmt/binding-linux-ppc64-gnu@0.53.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.52.0': + '@oxfmt/binding-linux-riscv64-gnu@0.53.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.52.0': + '@oxfmt/binding-linux-riscv64-musl@0.53.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.52.0': + '@oxfmt/binding-linux-s390x-gnu@0.53.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.52.0': + '@oxfmt/binding-linux-x64-gnu@0.53.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.52.0': + '@oxfmt/binding-linux-x64-musl@0.53.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.52.0': + '@oxfmt/binding-openharmony-arm64@0.53.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.52.0': + '@oxfmt/binding-win32-arm64-msvc@0.53.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.52.0': + '@oxfmt/binding-win32-ia32-msvc@0.53.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.52.0': + '@oxfmt/binding-win32-x64-msvc@0.53.0': optional: true - '@oxlint/binding-android-arm-eabi@1.67.0': + '@oxlint/binding-android-arm-eabi@1.68.0': optional: true - '@oxlint/binding-android-arm64@1.67.0': + '@oxlint/binding-android-arm64@1.68.0': optional: true - '@oxlint/binding-darwin-arm64@1.67.0': + '@oxlint/binding-darwin-arm64@1.68.0': optional: true - '@oxlint/binding-darwin-x64@1.67.0': + '@oxlint/binding-darwin-x64@1.68.0': optional: true - '@oxlint/binding-freebsd-x64@1.67.0': + '@oxlint/binding-freebsd-x64@1.68.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.67.0': + '@oxlint/binding-linux-arm-gnueabihf@1.68.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.67.0': + '@oxlint/binding-linux-arm-musleabihf@1.68.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.67.0': + '@oxlint/binding-linux-arm64-gnu@1.68.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.67.0': + '@oxlint/binding-linux-arm64-musl@1.68.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.67.0': + '@oxlint/binding-linux-ppc64-gnu@1.68.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.67.0': + '@oxlint/binding-linux-riscv64-gnu@1.68.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.67.0': + '@oxlint/binding-linux-riscv64-musl@1.68.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.67.0': + '@oxlint/binding-linux-s390x-gnu@1.68.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.67.0': + '@oxlint/binding-linux-x64-gnu@1.68.0': optional: true - '@oxlint/binding-linux-x64-musl@1.67.0': + '@oxlint/binding-linux-x64-musl@1.68.0': optional: true - '@oxlint/binding-openharmony-arm64@1.67.0': + '@oxlint/binding-openharmony-arm64@1.68.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.67.0': + '@oxlint/binding-win32-arm64-msvc@1.68.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.67.0': + '@oxlint/binding-win32-ia32-msvc@1.68.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.67.0': + '@oxlint/binding-win32-x64-msvc@1.68.0': optional: true '@pkgjs/parseargs@0.11.0': @@ -6937,125 +7175,125 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/pluginutils@5.4.0(rollup@4.60.4)': + '@rollup/pluginutils@5.4.0(rollup@4.61.0)': dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 picomatch: 4.0.4 optionalDependencies: - rollup: 4.60.4 + rollup: 4.61.0 - '@rollup/rollup-android-arm-eabi@4.60.4': + '@rollup/rollup-android-arm-eabi@4.61.0': optional: true - '@rollup/rollup-android-arm64@4.60.4': + '@rollup/rollup-android-arm64@4.61.0': optional: true - '@rollup/rollup-darwin-arm64@4.60.4': + '@rollup/rollup-darwin-arm64@4.61.0': optional: true - '@rollup/rollup-darwin-x64@4.60.4': + '@rollup/rollup-darwin-x64@4.61.0': optional: true - '@rollup/rollup-freebsd-arm64@4.60.4': + '@rollup/rollup-freebsd-arm64@4.61.0': optional: true - '@rollup/rollup-freebsd-x64@4.60.4': + '@rollup/rollup-freebsd-x64@4.61.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + '@rollup/rollup-linux-arm-gnueabihf@4.61.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.4': + '@rollup/rollup-linux-arm-musleabihf@4.61.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.4': + '@rollup/rollup-linux-arm64-gnu@4.61.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.4': + '@rollup/rollup-linux-arm64-musl@4.61.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.4': + '@rollup/rollup-linux-loong64-gnu@4.61.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.4': + '@rollup/rollup-linux-loong64-musl@4.61.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.4': + '@rollup/rollup-linux-ppc64-gnu@4.61.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.4': + '@rollup/rollup-linux-ppc64-musl@4.61.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.4': + '@rollup/rollup-linux-riscv64-gnu@4.61.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.4': + '@rollup/rollup-linux-riscv64-musl@4.61.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.4': + '@rollup/rollup-linux-s390x-gnu@4.61.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.4': + '@rollup/rollup-linux-x64-gnu@4.61.0': optional: true - '@rollup/rollup-linux-x64-musl@4.60.4': + '@rollup/rollup-linux-x64-musl@4.61.0': optional: true - '@rollup/rollup-openbsd-x64@4.60.4': + '@rollup/rollup-openbsd-x64@4.61.0': optional: true - '@rollup/rollup-openharmony-arm64@4.60.4': + '@rollup/rollup-openharmony-arm64@4.61.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.4': + '@rollup/rollup-win32-arm64-msvc@4.61.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.4': + '@rollup/rollup-win32-ia32-msvc@4.61.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.4': + '@rollup/rollup-win32-x64-gnu@4.61.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.4': + '@rollup/rollup-win32-x64-msvc@4.61.0': optional: true '@sec-ant/readable-stream@0.4.1': {} - '@shikijs/core@4.1.0': + '@shikijs/core@4.2.0': dependencies: - '@shikijs/primitive': 4.1.0 - '@shikijs/types': 4.1.0 + '@shikijs/primitive': 4.2.0 + '@shikijs/types': 4.2.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@4.1.0': + '@shikijs/engine-javascript@4.2.0': dependencies: - '@shikijs/types': 4.1.0 + '@shikijs/types': 4.2.0 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 - '@shikijs/engine-oniguruma@4.1.0': + '@shikijs/engine-oniguruma@4.2.0': dependencies: - '@shikijs/types': 4.1.0 + '@shikijs/types': 4.2.0 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@4.1.0': + '@shikijs/langs@4.2.0': dependencies: - '@shikijs/types': 4.1.0 + '@shikijs/types': 4.2.0 - '@shikijs/primitive@4.1.0': + '@shikijs/primitive@4.2.0': dependencies: - '@shikijs/types': 4.1.0 + '@shikijs/types': 4.2.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - '@shikijs/themes@4.1.0': + '@shikijs/themes@4.2.0': dependencies: - '@shikijs/types': 4.1.0 + '@shikijs/types': 4.2.0 - '@shikijs/types@4.1.0': + '@shikijs/types@4.2.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -7074,25 +7312,43 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@smithy/core@3.24.6': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.3.6': dependencies: '@smithy/core': 3.24.5 '@smithy/types': 4.14.2 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.3.7': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@smithy/fetch-http-handler@5.4.5': dependencies: '@smithy/core': 3.24.5 '@smithy/types': 4.14.2 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.4.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@smithy/is-array-buffer@2.2.0': dependencies: tslib: 2.8.1 - '@smithy/node-config-provider@4.4.5': + '@smithy/node-config-provider@4.4.6': dependencies: - '@smithy/core': 3.24.5 + '@smithy/core': 3.24.6 tslib: 2.8.1 '@smithy/node-http-handler@4.7.3': @@ -7107,16 +7363,32 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 + '@smithy/node-http-handler@4.7.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@smithy/signature-v4@5.4.5': dependencies: '@smithy/core': 3.24.5 '@smithy/types': 4.14.2 tslib: 2.8.1 + '@smithy/signature-v4@5.4.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + '@smithy/types@4.14.2': dependencies: tslib: 2.8.1 + '@smithy/types@4.14.3': + dependencies: + tslib: 2.8.1 + '@smithy/util-buffer-from@2.2.0': dependencies: '@smithy/is-array-buffer': 2.2.0 @@ -7192,12 +7464,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - '@tailwindcss/vite@4.3.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.0 '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) '@turbo/darwin-64@2.9.16': optional: true @@ -7233,8 +7505,6 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/estree@1.0.8': {} - '@types/estree@1.0.9': {} '@types/hast@3.0.4': @@ -7263,77 +7533,77 @@ snapshots: '@types/unist@3.0.3': {} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260527.2': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260603.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260527.2': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260603.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260527.2': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260603.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260527.2': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260603.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260527.2': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260603.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260527.2': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260603.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260527.2': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260603.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260527.2': + '@typescript/native-preview@7.0.0-dev.20260603.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260527.2 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260527.2 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260527.2 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260527.2 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260527.2 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260527.2 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260527.2 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260603.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260603.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260603.1 '@ungap/structured-clone@1.3.1': {} - '@vitest/expect@4.1.7': + '@vitest/expect@4.1.8': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.7 - '@vitest/utils': 4.1.7 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.7 + '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - '@vitest/pretty-format@4.1.7': + '@vitest/pretty-format@4.1.8': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.7': + '@vitest/runner@4.1.8': dependencies: - '@vitest/utils': 4.1.7 + '@vitest/utils': 4.1.8 pathe: 2.0.3 - '@vitest/snapshot@4.1.7': + '@vitest/snapshot@4.1.8': dependencies: - '@vitest/pretty-format': 4.1.7 - '@vitest/utils': 4.1.7 + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.7': {} + '@vitest/spy@4.1.8': {} - '@vitest/utils@4.1.7': + '@vitest/utils@4.1.8': dependencies: - '@vitest/pretty-format': 4.1.7 + '@vitest/pretty-format': 4.1.8 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -7382,7 +7652,7 @@ snapshots: emmet: 2.4.11 jsonc-parser: 2.3.1 vscode-languageserver-textdocument: 1.0.12 - vscode-languageserver-types: 3.17.5 + vscode-languageserver-types: 3.18.0 vscode-uri: 3.1.0 '@vscode/l10n@0.0.18': {} @@ -7409,17 +7679,17 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@0.93.9(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260526.1): + alchemy@0.93.10(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260601.1): dependencies: - '@aws-sdk/credential-providers': 3.1057.0 - '@cloudflare/unenv-preset': 2.7.7(unenv@2.0.0-rc.21)(workerd@1.20260526.1) - '@cloudflare/workers-types': 4.20260531.1 + '@aws-sdk/credential-providers': 3.1060.0 + '@cloudflare/unenv-preset': 2.7.7(unenv@2.0.0-rc.21)(workerd@1.20260601.1) + '@cloudflare/workers-types': 4.20260603.1 '@iarna/toml': 2.2.5 '@octokit/rest': 21.1.1 - '@smithy/node-config-provider': 4.4.5 - '@smithy/types': 4.14.2 + '@smithy/node-config-provider': 4.4.6 + '@smithy/types': 4.14.3 aws4fetch: 1.0.20 - drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260531.1) + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260603.1) env-paths: 3.0.0 esbuild: 0.25.12 execa: 9.6.1 @@ -7438,11 +7708,11 @@ snapshots: proper-lockfile: 4.1.2 signal-exit: 4.1.0 unenv: 2.0.0-rc.21 - wrangler: 4.95.0(@cloudflare/workers-types@4.20260531.1) + wrangler: 4.97.0(@cloudflare/workers-types@4.20260603.1) ws: 8.21.0 yaml: 2.9.0 optionalDependencies: - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@aws-sdk/client-rds-data' - '@electric-sql/pglite' @@ -7510,7 +7780,7 @@ snapshots: assertion-error@2.0.1: {} - astro@6.4.2(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0): + astro@6.4.3(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.61.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: '@astrojs/compiler': 4.0.0 '@astrojs/internal-helpers': 0.10.0 @@ -7519,7 +7789,7 @@ snapshots: '@capsizecss/unpack': 4.0.0 '@clack/prompts': 1.5.0 '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.4.0(rollup@4.60.4) + '@rollup/pluginutils': 5.4.0(rollup@4.61.0) aria-query: 5.3.2 axobject-query: 4.1.0 ci-info: 4.4.0 @@ -7537,7 +7807,7 @@ snapshots: github-slugger: 2.0.0 html-escaper: 3.0.3 http-cache-semantics: 4.2.0 - js-yaml: 4.1.1 + js-yaml: 4.2.0 jsonc-parser: 3.3.1 magic-string: 0.30.21 magicast: 0.5.3 @@ -7551,10 +7821,10 @@ snapshots: picomatch: 4.0.4 rehype: 13.0.2 semver: 7.8.1 - shiki: 4.1.0 + shiki: 4.2.0 smol-toml: 1.6.1 svgo: 4.0.1 - tinyclip: 0.1.13 + tinyclip: 0.1.14 tinyexec: 1.2.4 tinyglobby: 0.2.17 ultrahtml: 1.6.0 @@ -7562,8 +7832,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.5(aws4fetch@1.0.20) vfile: 6.0.3 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) - vitefu: 1.1.3(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + vite: 7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + vitefu: 1.1.3(vite@7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.4.3 @@ -7855,9 +8125,9 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 - drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260531.1): + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260603.1): optionalDependencies: - '@cloudflare/workers-types': 4.20260531.1 + '@cloudflare/workers-types': 4.20260603.1 dset@3.1.4: {} @@ -8361,7 +8631,7 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.14.0: {} + graphql@16.14.1: {} h3@1.15.11: dependencies: @@ -8398,7 +8668,7 @@ snapshots: '@types/unist': 3.0.3 devlop: 1.1.0 hastscript: 9.0.1 - property-information: 7.1.0 + property-information: 7.2.0 vfile: 6.0.3 vfile-location: 5.0.3 web-namespaces: 2.0.1 @@ -8436,7 +8706,7 @@ snapshots: hast-util-whitespace: 3.0.0 html-void-elements: 3.0.0 mdast-util-to-hast: 13.2.1 - property-information: 7.1.0 + property-information: 7.2.0 space-separated-tokens: 2.0.2 stringify-entities: 4.0.4 zwitch: 2.0.4 @@ -8446,7 +8716,7 @@ snapshots: '@types/hast': 3.0.4 comma-separated-tokens: 2.0.3 devlop: 1.1.0 - property-information: 7.1.0 + property-information: 7.2.0 space-separated-tokens: 2.0.2 web-namespaces: 2.0.1 zwitch: 2.0.4 @@ -8467,7 +8737,7 @@ snapshots: '@types/hast': 3.0.4 comma-separated-tokens: 2.0.3 hast-util-parse-selector: 4.0.0 - property-information: 7.1.0 + property-information: 7.2.0 space-separated-tokens: 2.0.2 highlight.js@10.7.3: {} @@ -8597,7 +8867,7 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.1: + js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -9106,12 +9376,12 @@ snapshots: - bufferutil - utf-8-validate - miniflare@4.20260526.0: + miniflare@4.20260601.0: dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.34.5 undici: 7.24.8 - workerd: 1.20260526.1 + workerd: 1.20260601.1 ws: 8.20.1 youch: 4.1.0-beta.10 transitivePeerDependencies: @@ -9162,7 +9432,7 @@ snapshots: neverthrow@8.2.0: optionalDependencies: - '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.61.0 nlcst-to-string@4.0.0: dependencies: @@ -9246,51 +9516,51 @@ snapshots: outdent@0.5.0: {} - oxfmt@0.52.0: + oxfmt@0.53.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.52.0 - '@oxfmt/binding-android-arm64': 0.52.0 - '@oxfmt/binding-darwin-arm64': 0.52.0 - '@oxfmt/binding-darwin-x64': 0.52.0 - '@oxfmt/binding-freebsd-x64': 0.52.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.52.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.52.0 - '@oxfmt/binding-linux-arm64-gnu': 0.52.0 - '@oxfmt/binding-linux-arm64-musl': 0.52.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.52.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.52.0 - '@oxfmt/binding-linux-riscv64-musl': 0.52.0 - '@oxfmt/binding-linux-s390x-gnu': 0.52.0 - '@oxfmt/binding-linux-x64-gnu': 0.52.0 - '@oxfmt/binding-linux-x64-musl': 0.52.0 - '@oxfmt/binding-openharmony-arm64': 0.52.0 - '@oxfmt/binding-win32-arm64-msvc': 0.52.0 - '@oxfmt/binding-win32-ia32-msvc': 0.52.0 - '@oxfmt/binding-win32-x64-msvc': 0.52.0 - - oxlint@1.67.0: + '@oxfmt/binding-android-arm-eabi': 0.53.0 + '@oxfmt/binding-android-arm64': 0.53.0 + '@oxfmt/binding-darwin-arm64': 0.53.0 + '@oxfmt/binding-darwin-x64': 0.53.0 + '@oxfmt/binding-freebsd-x64': 0.53.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.53.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.53.0 + '@oxfmt/binding-linux-arm64-gnu': 0.53.0 + '@oxfmt/binding-linux-arm64-musl': 0.53.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.53.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.53.0 + '@oxfmt/binding-linux-riscv64-musl': 0.53.0 + '@oxfmt/binding-linux-s390x-gnu': 0.53.0 + '@oxfmt/binding-linux-x64-gnu': 0.53.0 + '@oxfmt/binding-linux-x64-musl': 0.53.0 + '@oxfmt/binding-openharmony-arm64': 0.53.0 + '@oxfmt/binding-win32-arm64-msvc': 0.53.0 + '@oxfmt/binding-win32-ia32-msvc': 0.53.0 + '@oxfmt/binding-win32-x64-msvc': 0.53.0 + + oxlint@1.68.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.67.0 - '@oxlint/binding-android-arm64': 1.67.0 - '@oxlint/binding-darwin-arm64': 1.67.0 - '@oxlint/binding-darwin-x64': 1.67.0 - '@oxlint/binding-freebsd-x64': 1.67.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.67.0 - '@oxlint/binding-linux-arm-musleabihf': 1.67.0 - '@oxlint/binding-linux-arm64-gnu': 1.67.0 - '@oxlint/binding-linux-arm64-musl': 1.67.0 - '@oxlint/binding-linux-ppc64-gnu': 1.67.0 - '@oxlint/binding-linux-riscv64-gnu': 1.67.0 - '@oxlint/binding-linux-riscv64-musl': 1.67.0 - '@oxlint/binding-linux-s390x-gnu': 1.67.0 - '@oxlint/binding-linux-x64-gnu': 1.67.0 - '@oxlint/binding-linux-x64-musl': 1.67.0 - '@oxlint/binding-openharmony-arm64': 1.67.0 - '@oxlint/binding-win32-arm64-msvc': 1.67.0 - '@oxlint/binding-win32-ia32-msvc': 1.67.0 - '@oxlint/binding-win32-x64-msvc': 1.67.0 + '@oxlint/binding-android-arm-eabi': 1.68.0 + '@oxlint/binding-android-arm64': 1.68.0 + '@oxlint/binding-darwin-arm64': 1.68.0 + '@oxlint/binding-darwin-x64': 1.68.0 + '@oxlint/binding-freebsd-x64': 1.68.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.68.0 + '@oxlint/binding-linux-arm-musleabihf': 1.68.0 + '@oxlint/binding-linux-arm64-gnu': 1.68.0 + '@oxlint/binding-linux-arm64-musl': 1.68.0 + '@oxlint/binding-linux-ppc64-gnu': 1.68.0 + '@oxlint/binding-linux-riscv64-gnu': 1.68.0 + '@oxlint/binding-linux-riscv64-musl': 1.68.0 + '@oxlint/binding-linux-s390x-gnu': 1.68.0 + '@oxlint/binding-linux-x64-gnu': 1.68.0 + '@oxlint/binding-linux-x64-musl': 1.68.0 + '@oxlint/binding-openharmony-arm64': 1.68.0 + '@oxlint/binding-win32-arm64-msvc': 1.68.0 + '@oxlint/binding-win32-ia32-msvc': 1.68.0 + '@oxlint/binding-win32-x64-msvc': 1.68.0 p-filter@2.1.0: dependencies: @@ -9423,7 +9693,7 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 - property-information@7.1.0: {} + property-information@7.2.0: {} protobufjs@7.6.2: dependencies: @@ -9634,52 +9904,37 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.3 '@rolldown/binding-win32-x64-msvc': 1.0.3 - rollup@4.60.4: + rollup@4.61.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.4 - '@rollup/rollup-android-arm64': 4.60.4 - '@rollup/rollup-darwin-arm64': 4.60.4 - '@rollup/rollup-darwin-x64': 4.60.4 - '@rollup/rollup-freebsd-arm64': 4.60.4 - '@rollup/rollup-freebsd-x64': 4.60.4 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 - '@rollup/rollup-linux-arm-musleabihf': 4.60.4 - '@rollup/rollup-linux-arm64-gnu': 4.60.4 - '@rollup/rollup-linux-arm64-musl': 4.60.4 - '@rollup/rollup-linux-loong64-gnu': 4.60.4 - '@rollup/rollup-linux-loong64-musl': 4.60.4 - '@rollup/rollup-linux-ppc64-gnu': 4.60.4 - '@rollup/rollup-linux-ppc64-musl': 4.60.4 - '@rollup/rollup-linux-riscv64-gnu': 4.60.4 - '@rollup/rollup-linux-riscv64-musl': 4.60.4 - '@rollup/rollup-linux-s390x-gnu': 4.60.4 - '@rollup/rollup-linux-x64-gnu': 4.60.4 - '@rollup/rollup-linux-x64-musl': 4.60.4 - '@rollup/rollup-openbsd-x64': 4.60.4 - '@rollup/rollup-openharmony-arm64': 4.60.4 - '@rollup/rollup-win32-arm64-msvc': 4.60.4 - '@rollup/rollup-win32-ia32-msvc': 4.60.4 - '@rollup/rollup-win32-x64-gnu': 4.60.4 - '@rollup/rollup-win32-x64-msvc': 4.60.4 + '@rollup/rollup-android-arm-eabi': 4.61.0 + '@rollup/rollup-android-arm64': 4.61.0 + '@rollup/rollup-darwin-arm64': 4.61.0 + '@rollup/rollup-darwin-x64': 4.61.0 + '@rollup/rollup-freebsd-arm64': 4.61.0 + '@rollup/rollup-freebsd-x64': 4.61.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.0 + '@rollup/rollup-linux-arm-musleabihf': 4.61.0 + '@rollup/rollup-linux-arm64-gnu': 4.61.0 + '@rollup/rollup-linux-arm64-musl': 4.61.0 + '@rollup/rollup-linux-loong64-gnu': 4.61.0 + '@rollup/rollup-linux-loong64-musl': 4.61.0 + '@rollup/rollup-linux-ppc64-gnu': 4.61.0 + '@rollup/rollup-linux-ppc64-musl': 4.61.0 + '@rollup/rollup-linux-riscv64-gnu': 4.61.0 + '@rollup/rollup-linux-riscv64-musl': 4.61.0 + '@rollup/rollup-linux-s390x-gnu': 4.61.0 + '@rollup/rollup-linux-x64-gnu': 4.61.0 + '@rollup/rollup-linux-x64-musl': 4.61.0 + '@rollup/rollup-openbsd-x64': 4.61.0 + '@rollup/rollup-openharmony-arm64': 4.61.0 + '@rollup/rollup-win32-arm64-msvc': 4.61.0 + '@rollup/rollup-win32-ia32-msvc': 4.61.0 + '@rollup/rollup-win32-x64-gnu': 4.61.0 + '@rollup/rollup-win32-x64-msvc': 4.61.0 fsevents: 2.3.3 - rosie-skills-darwin-arm64@0.6.4: - optional: true - - rosie-skills-freebsd-x64@0.6.4: - optional: true - - rosie-skills-linux-x64@0.6.4: - optional: true - - rosie-skills@0.6.4: - optionalDependencies: - rosie-skills-darwin-arm64: 0.6.4 - rosie-skills-freebsd-x64: 0.6.4 - rosie-skills-linux-x64: 0.6.4 - router@2.2.0: dependencies: debug: 4.4.3 @@ -9778,14 +10033,14 @@ snapshots: shebang-regex@3.0.0: {} - shiki@4.1.0: + shiki@4.2.0: dependencies: - '@shikijs/core': 4.1.0 - '@shikijs/engine-javascript': 4.1.0 - '@shikijs/engine-oniguruma': 4.1.0 - '@shikijs/langs': 4.1.0 - '@shikijs/themes': 4.1.0 - '@shikijs/types': 4.1.0 + '@shikijs/core': 4.2.0 + '@shikijs/engine-javascript': 4.2.0 + '@shikijs/engine-oniguruma': 4.2.0 + '@shikijs/langs': 4.2.0 + '@shikijs/themes': 4.2.0 + '@shikijs/types': 4.2.0 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -9934,7 +10189,7 @@ snapshots: tinybench@2.9.0: {} - tinyclip@0.1.13: {} + tinyclip@0.1.14: {} tinyexec@1.2.4: {} @@ -10123,13 +10378,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0): + vite@7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.15 - rollup: 4.60.4 + rollup: 4.61.0 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.1 @@ -10139,19 +10394,34 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitefu@1.1.3(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): + vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.1 + esbuild: 0.28.0 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.22.4 + yaml: 2.9.0 + + vitefu@1.1.3(vite@7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): optionalDependencies: - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) - vitest@4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@types/node@25.9.1)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.7 - '@vitest/runner': 4.1.7 - '@vitest/snapshot': 4.1.7 - '@vitest/spy': 4.1.7 - '@vitest/utils': 4.1.7 + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -10163,7 +10433,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.1 @@ -10237,14 +10507,14 @@ snapshots: dependencies: '@vscode/l10n': 0.0.18 vscode-languageserver-textdocument: 1.0.12 - vscode-languageserver-types: 3.17.5 + vscode-languageserver-types: 3.18.0 vscode-uri: 3.1.0 vscode-json-languageservice@4.1.8: dependencies: jsonc-parser: 3.3.1 vscode-languageserver-textdocument: 1.0.12 - vscode-languageserver-types: 3.17.5 + vscode-languageserver-types: 3.18.0 vscode-nls: 5.2.0 vscode-uri: 3.1.0 @@ -10259,6 +10529,8 @@ snapshots: vscode-languageserver-types@3.17.5: {} + vscode-languageserver-types@3.18.0: {} + vscode-languageserver@9.0.1: dependencies: vscode-languageserver-protocol: 3.17.5 @@ -10290,27 +10562,26 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260424.1 '@cloudflare/workerd-windows-64': 1.20260424.1 - workerd@1.20260526.1: + workerd@1.20260601.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260526.1 - '@cloudflare/workerd-darwin-arm64': 1.20260526.1 - '@cloudflare/workerd-linux-64': 1.20260526.1 - '@cloudflare/workerd-linux-arm64': 1.20260526.1 - '@cloudflare/workerd-windows-64': 1.20260526.1 + '@cloudflare/workerd-darwin-64': 1.20260601.1 + '@cloudflare/workerd-darwin-arm64': 1.20260601.1 + '@cloudflare/workerd-linux-64': 1.20260601.1 + '@cloudflare/workerd-linux-arm64': 1.20260601.1 + '@cloudflare/workerd-windows-64': 1.20260601.1 - wrangler@4.95.0(@cloudflare/workers-types@4.20260531.1): + wrangler@4.97.0(@cloudflare/workers-types@4.20260603.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260526.1) + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260601.1) blake3-wasm: 2.1.5 esbuild: 0.27.3 - miniflare: 4.20260526.0 + miniflare: 4.20260601.0 path-to-regexp: 6.3.0 - rosie-skills: 0.6.4 unenv: 2.0.0-rc.24 - workerd: 1.20260526.1 + workerd: 1.20260601.1 optionalDependencies: - '@cloudflare/workers-types': 4.20260531.1 + '@cloudflare/workers-types': 4.20260603.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil @@ -10368,7 +10639,7 @@ snapshots: vscode-json-languageservice: 4.1.8 vscode-languageserver: 9.0.1 vscode-languageserver-textdocument: 1.0.12 - vscode-languageserver-types: 3.17.5 + vscode-languageserver-types: 3.18.0 vscode-uri: 3.1.0 yaml: 2.7.1 From 379bdec95326b49874c8be06e3f07893236e3f6f Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 4 Jun 2026 09:00:56 -0400 Subject: [PATCH 14/19] chore: revert vite version --- apps/landing/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/landing/package.json b/apps/landing/package.json index 7bc51e7..6842c48 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -20,7 +20,7 @@ "typescript": "^6.0.3" }, "devDependencies": { - "vite": "^8.0.16" + "vite": "^7.3.5" }, "engines": { "node": ">=22.12.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67178d7..5d586d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,7 +64,7 @@ importers: version: 4.2.0 '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.0(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.3.0(vite@7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) astro: specifier: ^6.4.3 version: 6.4.3(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.61.0)(tsx@4.22.4)(yaml@2.9.0) @@ -76,8 +76,8 @@ importers: version: 6.0.3 devDependencies: vite: - specifier: ^8.0.16 - version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + specifier: ^7.3.5 + version: 7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) packages/benchmarks: dependencies: @@ -7464,12 +7464,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - '@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.0(vite@7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.0 '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) '@turbo/darwin-64@2.9.16': optional: true From 15ffa4823c799648c0f4f59c7e62532b2fad09aa Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 4 Jun 2026 13:32:44 -0400 Subject: [PATCH 15/19] docs: plan remote attach integration --- ...04-remote-attach-and-agent-integrations.md | 1738 +++++++++++++++++ 1 file changed, 1738 insertions(+) create mode 100644 docs/plans/2026-06-04-remote-attach-and-agent-integrations.md diff --git a/docs/plans/2026-06-04-remote-attach-and-agent-integrations.md b/docs/plans/2026-06-04-remote-attach-and-agent-integrations.md new file mode 100644 index 0000000..85ceaa2 --- /dev/null +++ b/docs/plans/2026-06-04-remote-attach-and-agent-integrations.md @@ -0,0 +1,1738 @@ +# Remote Attach And Agent Integrations Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `caplets serve` local-only, make `caplets attach` the remote-backed MCP server for self-hosted and Cloud upstreams, keep OpenCode/Pi on the shared resolver, and remove Codex/Claude native plugin artifacts. + +**Architecture:** Consolidate remote selection in Core around `CAPLETS_MODE` and `CAPLETS_REMOTE_*`, then reuse that resolver from the attach command and native integrations. `serve` keeps the existing local `CapletsEngine` path, while `attach` creates a remote-plus-local-overlay MCP surface and starts Project Binding automatically for Cloud selections. + +**Tech Stack:** TypeScript, Commander, Vitest, MCP SDK stdio and Streamable HTTP transports, Hono HTTP server, existing Cloud Auth store/client, existing Project Binding session manager, OpenCode and Pi native integration packages, pnpm. + +--- + +## Source Spec + +- Root design spec: `/Users/ianpascoe/src/caplets-mono/docs/superpowers/specs/2026-06-04-remote-attach-and-agent-integration-design.md` +- Core plan location follows `core/AGENTS.md`: `/Users/ianpascoe/src/caplets-mono/core/docs/plans/` + +## File Structure + +- Modify `packages/core/src/remote/options.ts`: expand mode parsing to `auto | local | remote | cloud`, add Cloud URL detection, and keep self-hosted auth resolution separate from Cloud Auth. +- Add `packages/core/src/remote/selection.ts`: resolve a full upstream selection for commands and integrations, including self-hosted remote options, Cloud credentials, refresh, selected workspace, and Project Binding metadata. +- Modify `packages/core/src/project-binding/attach.ts`: consume the shared selection helper for one-shot Project Binding probes and long-running sessions; stop treating saved Cloud Auth as an implicit remote when no remote URL or Cloud mode is selected. +- Add `packages/core/src/attach/options.ts`: resolve `caplets attach` server options, including `--transport stdio|http`, HTTP bind/auth options, and the remote selection. +- Add `packages/core/src/attach/server.ts`: start the remote-backed MCP server for stdio and HTTP transports and own the attach server lifecycle. +- Add `packages/core/src/serve/native-session.ts`: register MCP tools from a `NativeCapletsService` so `attach` can expose the remote-plus-local-overlay surface without inventing a second merge path. +- Modify `packages/core/src/serve/http.ts`: extract the session creation seam so HTTP serving can use either local `CapletsMcpSession` or native-service-backed sessions. +- Modify `packages/core/src/serve/index.ts`: export reusable serve helpers used by attach. +- Modify `packages/core/src/cli.ts`: change `caplets attach` from binding-only default to MCP server default; keep `--once` as the Project Binding smoke path. +- Modify `packages/core/src/native/options.ts`: use the shared resolver and expose `local | remote | cloud` semantics to OpenCode/Pi. +- Modify `packages/core/src/native/service.ts`: start Cloud Project Binding from saved Cloud Auth in Cloud mode and preserve local overlay precedence. +- Modify `packages/core/src/native/remote.ts`: adjust auth failure guidance for self-hosted vs Cloud modes. +- Modify `packages/core/src/native.ts`: export new resolver and option types. +- Modify `packages/core/test/remote-options.test.ts`: cover `CAPLETS_MODE=cloud`, auto Cloud detection, invalid mode/URL combinations, and self-hosted auth. +- Add `packages/core/test/remote-selection.test.ts`: cover saved Cloud Auth loading, refresh, workspace matching, token precedence, and no-implicit-cloud behavior. +- Modify `packages/core/test/attach-cli.test.ts`: cover attach server option parsing, `--once` Project Binding smoke behavior, local-mode rejection, and Cloud mode errors. +- Add `packages/core/test/attach-server.test.ts`: cover stdio/http attach server delegation using a fake native service and fake serve transports. +- Modify `packages/core/test/cloud-auth-refresh-attach.test.ts`: assert refresh occurs only through explicit Cloud selection. +- Modify `packages/core/test/native-options.test.ts`: cover Cloud mode and auto Cloud detection for native integrations. +- Modify `packages/core/test/native-remote.test.ts`: cover Cloud Project Binding startup and fallback semantics. +- Modify `packages/core/test/agent-plugins.test.ts`: invert plugin assertions so Codex/Claude plugin artifacts are absent and manual MCP docs are present. +- Delete `plugins/caplets/.codex-plugin/plugin.json`. +- Delete `plugins/caplets/.claude-plugin/plugin.json`. +- Delete `plugins/caplets/mcp.json`. +- Delete `plugins/caplets/skills/caplets/SKILL.md`. +- Delete `plugins/caplets/assets/icon.png` only if no package README or landing asset still references it; otherwise move the asset to a non-plugin docs asset path in the same task. +- Delete `plugins/caplets/` after required children are removed. +- Delete `.agents/plugins/marketplace.json`. +- Delete `.claude-plugin/marketplace.json`. +- Delete `scripts/sync-plugin-versions.ts`. +- Modify `package.json`: remove `scripts/sync-plugin-versions.ts` from `version-packages`. +- Modify `README.md`: replace Codex/Claude plugin install docs with manual MCP config for `serve` and `attach`. +- Modify `packages/cli/README.md` if it contains plugin install guidance. +- Modify `docs/native-integrations.md`: document OpenCode/Pi `CAPLETS_MODE` and `CAPLETS_REMOTE_*` behavior. +- Modify `packages/opencode/README.md`: document local, self-hosted, and Cloud env flows. +- Modify `packages/pi/README.md`: document local, self-hosted, and Cloud env/settings flows. +- Add a Changesets entry for `@caplets/core`, `caplets`, `@caplets/opencode`, and `@caplets/pi`. + +--- + +## Task 1: Shared Remote Mode Semantics + +**Files:** + +- Modify: `packages/core/src/remote/options.ts` +- Modify: `packages/core/test/remote-options.test.ts` + +- [ ] **Step 1: Add failing resolver tests** + +Append these cases to `packages/core/test/remote-options.test.ts`: + +```ts +it("supports explicit cloud mode with a Caplets Cloud URL", () => { + expect( + resolveRemoteMode( + {}, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + }, + ), + ).toEqual({ mode: "cloud" }); +}); + +it("detects cloud mode in auto from CAPLETS_REMOTE_URL", () => { + expect(resolveRemoteMode({}, { CAPLETS_REMOTE_URL: "https://cloud.caplets.dev" })).toEqual({ + mode: "cloud", + }); +}); + +it("keeps non-Cloud CAPLETS_REMOTE_URL in self-hosted remote mode", () => { + expect( + resolveRemoteMode({}, { CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets" }), + ).toEqual({ + mode: "remote", + }); +}); + +it("rejects explicit cloud mode with a non-Cloud URL", () => { + expect(() => + resolveRemoteMode( + {}, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets", + }, + ), + ).toThrow(/CAPLETS_MODE=cloud requires CAPLETS_REMOTE_URL to point at Caplets Cloud/u); +}); + +it("rejects explicit cloud mode without CAPLETS_REMOTE_URL", () => { + expect(() => resolveRemoteMode({}, { CAPLETS_MODE: "cloud" })).toThrow( + /CAPLETS_MODE=cloud requires CAPLETS_REMOTE_URL/u, + ); +}); + +it("parses cloud as a valid CAPLETS_MODE value", () => { + expect(() => resolveRemoteMode({}, { CAPLETS_MODE: "sidecar" })).toThrow( + /Expected CAPLETS_MODE to be auto, local, remote, or cloud/u, + ); +}); +``` + +- [ ] **Step 2: Run resolver tests to verify red** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/remote-options.test.ts +``` + +Expected: FAIL because `cloud` is not accepted and auto always maps any remote URL to `remote`. + +- [ ] **Step 3: Implement mode expansion** + +In `packages/core/src/remote/options.ts`, change the mode type and parser: + +```ts +export type CapletsRemoteMode = "local" | "remote" | "cloud"; + +export function resolveRemoteMode( + input: CapletsRemoteModeInput = {}, + env: CapletsRemoteEnv = process.env, +): { mode: CapletsRemoteMode } { + const mode = parseCapletsMode(input.mode ?? env.CAPLETS_MODE ?? "auto"); + if (mode === "local") return { mode: "local" }; + + const rawUrl = + nonEmpty(input.remoteUrl, "remoteUrl") ?? + nonEmpty(env.CAPLETS_REMOTE_URL, "CAPLETS_REMOTE_URL"); + + if (mode === "remote") { + if (rawUrl === undefined) { + throw new CapletsError( + "REQUEST_INVALID", + "CAPLETS_MODE=remote requires CAPLETS_REMOTE_URL or remoteUrl.", + ); + } + return { mode: "remote" }; + } + + if (mode === "cloud") { + if (rawUrl === undefined) { + throw new CapletsError("REQUEST_INVALID", "CAPLETS_MODE=cloud requires CAPLETS_REMOTE_URL."); + } + if (!isCapletsCloudUrl(rawUrl)) { + throw new CapletsError( + "REQUEST_INVALID", + "CAPLETS_MODE=cloud requires CAPLETS_REMOTE_URL to point at Caplets Cloud.", + ); + } + return { mode: "cloud" }; + } + + if (rawUrl === undefined) return { mode: "local" }; + return isCapletsCloudUrl(rawUrl) ? { mode: "cloud" } : { mode: "remote" }; +} + +export function isCapletsCloudUrl(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + const host = url.hostname.toLowerCase(); + return host === "cloud.caplets.dev" || host.endsWith(".preview.caplets.dev"); +} + +function parseCapletsMode(value: string): "auto" | CapletsRemoteMode { + if (value === "auto" || value === "local" || value === "remote" || value === "cloud") { + return value; + } + throw new CapletsError( + "REQUEST_INVALID", + `Expected CAPLETS_MODE to be auto, local, remote, or cloud, got ${value}`, + ); +} +``` + +- [ ] **Step 4: Run resolver tests to verify green** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/remote-options.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/remote/options.ts packages/core/test/remote-options.test.ts +git commit -m "feat(core): resolve cloud remote mode" +``` + +--- + +## Task 2: Full Upstream Selection With Cloud Auth + +**Files:** + +- Add: `packages/core/src/remote/selection.ts` +- Modify: `packages/core/src/project-binding/attach.ts` +- Add: `packages/core/test/remote-selection.test.ts` +- Modify: `packages/core/test/cloud-auth-refresh-attach.test.ts` + +- [ ] **Step 1: Add failing selection tests** + +Create `packages/core/test/remote-selection.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { CloudAuthStore } from "../src/cloud-auth/store"; +import { resolveRemoteSelection } from "../src/remote/selection"; +import { hostedCredentials, tempCloudAuthPath } from "./fixtures/cloud-auth"; + +describe("resolveRemoteSelection", () => { + it("rejects attach selection in local mode", async () => { + await expect(resolveRemoteSelection({}, { CAPLETS_MODE: "local" })).rejects.toThrow( + /caplets attach requires a remote upstream; use caplets serve for local-only MCP/u, + ); + }); + + it("rejects auto mode without a remote URL for attach", async () => { + await expect(resolveRemoteSelection({}, {})).rejects.toThrow(/CAPLETS_REMOTE_URL/u); + }); + + it("resolves self-hosted remote auth from CAPLETS_REMOTE variables", async () => { + await expect( + resolveRemoteSelection( + {}, + { + CAPLETS_MODE: "remote", + CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets", + CAPLETS_REMOTE_TOKEN: "remote-token", + }, + ), + ).resolves.toMatchObject({ + kind: "self_hosted_remote", + remote: { + baseUrl: new URL("https://caplets.example.com/caplets"), + auth: { type: "bearer", token: "remote-token" }, + }, + }); + }); + + it("uses saved Cloud Auth in cloud mode and ignores self-hosted token vars", async () => { + const path = tempCloudAuthPath(); + await new CloudAuthStore({ path }).save(hostedCredentials({ accessToken: "cloud-access" })); + + const resolved = await resolveRemoteSelection( + {}, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + CAPLETS_REMOTE_TOKEN: "self-hosted-token", + CAPLETS_CLOUD_AUTH_PATH: path, + }, + ); + + expect(resolved).toMatchObject({ + kind: "hosted_cloud", + selectedWorkspace: "personal", + remote: { + baseUrl: new URL("https://cloud.caplets.dev"), + auth: { type: "bearer", token: "cloud-access" }, + }, + }); + }); + + it("refreshes expired Cloud credentials before returning the upstream", async () => { + const path = tempCloudAuthPath(); + await new CloudAuthStore({ path }).save( + hostedCredentials({ + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: "2026-06-03T00:00:00.000Z", + }), + ); + + const resolved = await resolveRemoteSelection( + { + fetch: async (url, init) => { + expect(String(url)).toBe("https://cloud.caplets.dev/api/cloud-client/refresh"); + expect(JSON.parse(String(init?.body))).toEqual({ refreshToken: "old-refresh" }); + return Response.json({ + status: "authenticated", + cloudUrl: "https://cloud.caplets.dev", + workspaceId: "workspace_personal", + workspaceSlug: "personal", + accessToken: "new-access", + refreshToken: "new-refresh", + expiresAt: "2999-01-01T00:00:00.000Z", + scope: ["project_binding:read", "project_binding:write"], + tokenType: "Bearer", + credentialFamilyId: "family_123", + }); + }, + }, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + CAPLETS_CLOUD_AUTH_PATH: path, + }, + ); + + expect(resolved.remote.auth).toEqual({ type: "bearer", token: "new-access" }); + }); + + it("requires Cloud Auth when cloud mode is selected", async () => { + await expect( + resolveRemoteSelection( + {}, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + }, + ), + ).rejects.toMatchObject({ + projectBindingCode: "cloud_auth_required", + }); + }); +}); +``` + +- [ ] **Step 2: Run selection tests to verify red** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/remote-selection.test.ts +``` + +Expected: FAIL because `remote/selection.ts` does not exist. + +- [ ] **Step 3: Implement `resolveRemoteSelection`** + +Create `packages/core/src/remote/selection.ts` with these exports and behavior: + +```ts +import { CloudAuthClient } from "../cloud-auth/client"; +import { CloudAuthStore, type CloudAuthCredentials } from "../cloud-auth/store"; +import { projectBindingError } from "../project-binding/errors"; +import { resolveCapletsRemote, resolveRemoteMode, type ResolvedCapletsRemote } from "./options"; + +export type RemoteSelectionInput = { + remoteUrl?: string; + user?: string; + password?: string; + token?: string; + workspace?: string; + fetch?: typeof fetch; + requireUpstream?: boolean; +}; + +export type ResolvedRemoteSelection = + | { + kind: "self_hosted_remote"; + remote: ResolvedCapletsRemote; + } + | { + kind: "hosted_cloud"; + remote: ResolvedCapletsRemote; + selectedWorkspace: string; + credentials: CloudAuthCredentials; + cloudPresence: { + url: URL; + accessToken: string; + workspaceId: string; + }; + }; + +export async function resolveRemoteSelection( + input: RemoteSelectionInput = {}, + env: Record = process.env, +): Promise { + const mode = resolveRemoteMode({ mode: env.CAPLETS_MODE, remoteUrl: input.remoteUrl }, env); + if (mode.mode === "local") { + throw new Error( + "caplets attach requires a remote upstream; use caplets serve for local-only MCP.", + ); + } + + if (mode.mode === "remote") { + return { + kind: "self_hosted_remote", + remote: resolveCapletsRemote( + { + url: input.remoteUrl, + user: input.user, + password: input.password, + token: input.token, + workspace: input.workspace, + fetch: input.fetch, + }, + env, + ), + }; + } + + const store = new CloudAuthStore({ env }); + let credentials = await store.load(); + if (!credentials?.accessToken) throw projectBindingError("cloud_auth_required"); + + if (credentialsNeedRefresh(credentials)) { + if (!credentials.refreshToken) throw projectBindingError("cloud_auth_required"); + const refreshed = await new CloudAuthClient({ + cloudUrl: credentials.cloudUrl, + ...(input.fetch ? { fetch: input.fetch } : {}), + }).refresh({ refreshToken: credentials.refreshToken }); + credentials = { + ...credentials, + ...refreshed, + refreshToken: refreshed.refreshToken ?? credentials.refreshToken, + createdAt: credentials.createdAt, + lastRefreshAt: new Date().toISOString(), + }; + await store.save(credentials); + } + + const selectedWorkspace = credentials.workspaceSlug ?? credentials.workspaceId; + if ( + input.workspace && + input.workspace !== credentials.workspaceId && + input.workspace !== credentials.workspaceSlug + ) { + throw projectBindingError( + "workspace_switch_required", + `Requested workspace ${input.workspace} differs from saved Selected Workspace ${selectedWorkspace}.`, + ); + } + + const remoteUrl = input.remoteUrl ?? env.CAPLETS_REMOTE_URL ?? credentials.cloudUrl; + const remote = resolveCapletsRemote( + { + url: remoteUrl, + token: credentials.accessToken, + workspace: selectedWorkspace, + ...(input.fetch ? { fetch: input.fetch } : {}), + }, + {}, + ); + + return { + kind: "hosted_cloud", + remote, + selectedWorkspace, + credentials, + cloudPresence: { + url: new URL(remoteUrl), + accessToken: credentials.accessToken, + workspaceId: credentials.workspaceId, + }, + }; +} + +function credentialsNeedRefresh(credentials: { expiresAt: string }): boolean { + const expiresAt = Date.parse(credentials.expiresAt); + return Number.isFinite(expiresAt) && expiresAt <= Date.now() + 60_000; +} +``` + +During implementation, replace the plain `Error` local-mode failure with `CapletsError("REQUEST_INVALID", ...)` so JSON CLI handling remains structured. + +- [ ] **Step 4: Update Project Binding attach to use selection** + +In `packages/core/src/project-binding/attach.ts`: + +- Replace direct `resolveCapletsRemote(...)` calls with `await resolveRemoteSelection(...)`. +- Remove `hasExplicitRemote(...)`; saved Cloud Auth must not implicitly select Cloud without `CAPLETS_MODE=cloud` or `CAPLETS_MODE=auto` plus a Cloud `CAPLETS_REMOTE_URL`. +- Keep `authMode` values as `"self_hosted_remote"` and `"hosted_cloud"` from `selection.kind`. +- Keep `selectedWorkspace` only for Cloud selections. + +The resolved return should be shaped like: + +```ts +const selection = await resolveRemoteSelection(remoteInput, env); +return { + projectRoot: raw.projectRoot ?? process.cwd(), + json: raw.json === true, + verbose: raw.verbose === true, + once: raw.once === true, + remote: selection.remote, + authMode: selection.kind, + ...(selection.kind === "hosted_cloud" + ? { selectedWorkspace: selection.selectedWorkspace } + : remoteInput.workspace + ? { selectedWorkspace: remoteInput.workspace } + : {}), +}; +``` + +- [ ] **Step 5: Update Cloud refresh attach tests** + +In `packages/core/test/cloud-auth-refresh-attach.test.ts`, update every attach call that expects Cloud Auth to pass: + +```ts +{ + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + CAPLETS_CLOUD_AUTH_PATH: path, +} +``` + +Add one regression test: + +```ts +it("does not implicitly use saved Cloud Auth without cloud mode or a Cloud remote URL", async () => { + const path = tempCloudAuthPath(); + await new CloudAuthStore({ path }).save(hostedCredentials()); + + await expect( + attachProjectOnce({ projectRoot: "/repo" }, { CAPLETS_CLOUD_AUTH_PATH: path }), + ).rejects.toThrow(/CAPLETS_REMOTE_URL/u); +}); +``` + +- [ ] **Step 6: Run selection and attach tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/remote-selection.test.ts test/cloud-auth-refresh-attach.test.ts test/attach-cli.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add packages/core/src/remote/selection.ts packages/core/src/project-binding/attach.ts packages/core/test/remote-selection.test.ts packages/core/test/cloud-auth-refresh-attach.test.ts packages/core/test/attach-cli.test.ts +git commit -m "feat(core): select remote upstreams for attach" +``` + +--- + +## Task 3: Native-Service-Backed MCP Sessions + +**Files:** + +- Add: `packages/core/src/serve/native-session.ts` +- Modify: `packages/core/src/serve/index.ts` +- Add: `packages/core/test/attach-server.test.ts` + +- [ ] **Step 1: Add failing session tests** + +Create `packages/core/test/attach-server.test.ts` with a fake MCP server: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { NativeCapletsMcpSession } from "../src/serve/native-session"; + +describe("NativeCapletsMcpSession", () => { + it("registers tools from a native Caplets service", async () => { + const registered = new Map(); + const server = { + registerTool: vi.fn((name: string, definition: unknown, callback: unknown) => { + registered.set(name, { definition, callback }); + return { remove: vi.fn(), update: vi.fn() }; + }), + connect: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + }; + const service = { + listTools: () => [ + { + caplet: "remote-alpha", + toolName: "caplets_remote_alpha", + title: "Remote Alpha", + description: "Remote alpha tool", + promptGuidance: [], + inputSchema: { + type: "object", + properties: { operation: { type: "string", enum: ["inspect"] } }, + }, + operationNames: ["inspect"], + }, + ], + execute: vi.fn(async () => ({ ok: true })), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => undefined), + close: vi.fn(async () => undefined), + }; + + const session = new NativeCapletsMcpSession(service, { server }); + + expect([...registered.keys()]).toEqual(["remote-alpha"]); + const tool = registered.get("remote-alpha") as { + callback: (request: unknown) => Promise; + }; + await expect(tool.callback({ operation: "inspect" })).resolves.toEqual({ ok: true }); + expect(service.execute).toHaveBeenCalledWith("remote-alpha", { operation: "inspect" }); + await session.close(); + expect(service.close).toHaveBeenCalledOnce(); + }); + + it("updates registered tools when the native service changes", () => { + let listener: ((tools: unknown[]) => void) | undefined; + const removed = vi.fn(); + const updates: unknown[] = []; + const server = { + registerTool: vi.fn((_name: string, _definition: unknown, _callback: unknown) => ({ + remove: removed, + update: (definition: unknown) => updates.push(definition), + })), + connect: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + }; + const service = { + listTools: () => [ + { caplet: "alpha", title: "Alpha", description: "Alpha", promptGuidance: [] }, + ], + execute: vi.fn(async () => ({})), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn((nextListener: (tools: unknown[]) => void) => { + listener = nextListener; + return () => undefined; + }), + close: vi.fn(async () => undefined), + }; + + new NativeCapletsMcpSession(service as never, { server }); + listener?.([{ caplet: "beta", title: "Beta", description: "Beta", promptGuidance: [] }]); + + expect(removed).toHaveBeenCalledOnce(); + expect(server.registerTool).toHaveBeenCalledWith( + "beta", + expect.objectContaining({ title: "Beta" }), + expect.any(Function), + ); + }); +}); +``` + +- [ ] **Step 2: Run session tests to verify red** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/attach-server.test.ts +``` + +Expected: FAIL because `serve/native-session.ts` does not exist. + +- [ ] **Step 3: Implement native MCP session** + +Create `packages/core/src/serve/native-session.ts`: + +```ts +import { McpServer, type RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport"; +import { version as packageJsonVersion } from "../../package.json"; +import type { NativeCapletsService, NativeCapletTool } from "../native/service"; + +export type NativeToolServer = Pick; + +export type NativeCapletsMcpSessionOptions = { + server?: NativeToolServer; +}; + +export class NativeCapletsMcpSession { + readonly server: NativeToolServer; + private readonly tools = new Map(); + private readonly unsubscribe: () => void; + private closed = false; + + constructor( + private readonly service: NativeCapletsService, + options: NativeCapletsMcpSessionOptions = {}, + ) { + this.server = + options.server ?? + new McpServer({ + name: "caplets", + version: packageJsonVersion, + }); + this.unsubscribe = service.onToolsChanged((tools) => this.reconcileTools(tools)); + this.reconcileTools(service.listTools()); + } + + async connect(transport: Transport): Promise { + await this.server.connect(transport); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.unsubscribe(); + this.tools.clear(); + await this.server.close(); + await this.service.close(); + } + + private reconcileTools(next: NativeCapletTool[]): void { + const enabled = new Map(next.map((tool) => [tool.caplet, tool])); + for (const [id, registered] of this.tools) { + const tool = enabled.get(id); + if (!tool) { + registered.remove(); + this.tools.delete(id); + continue; + } + registered.update(this.definition(tool)); + } + for (const tool of enabled.values()) { + if (!this.tools.has(tool.caplet)) { + this.tools.set( + tool.caplet, + this.server.registerTool(tool.caplet, this.definition(tool), async (request) => + this.service.execute(tool.caplet, request), + ), + ); + } + } + } + + private definition(tool: NativeCapletTool) { + return { + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + }; + } +} +``` + +During implementation, verify the installed MCP SDK overload. If `registerTool` requires Zod `paramsSchema` instead of raw JSON Schema, convert missing or raw schemas through the existing generated Caplets schema for remote tools and document the lossless raw schema follow-up in the commit body. + +- [ ] **Step 4: Export the session** + +In `packages/core/src/serve/index.ts`, export: + +```ts +export { NativeCapletsMcpSession } from "./native-session"; +export type { NativeCapletsMcpSessionOptions, NativeToolServer } from "./native-session"; +``` + +- [ ] **Step 5: Run session tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/attach-server.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/src/serve/native-session.ts packages/core/src/serve/index.ts packages/core/test/attach-server.test.ts +git commit -m "feat(core): expose native caplets over mcp" +``` + +--- + +## Task 4: `caplets attach` MCP Server + +**Files:** + +- Add: `packages/core/src/attach/options.ts` +- Add: `packages/core/src/attach/server.ts` +- Modify: `packages/core/src/cli.ts` +- Modify: `packages/core/test/attach-cli.test.ts` +- Modify: `packages/core/test/attach-server.test.ts` + +- [ ] **Step 1: Add failing CLI contract tests** + +In `packages/core/test/attach-cli.test.ts`, update help expectations: + +```ts +expect(out.join("")).toContain("Start a remote-backed Caplets MCP server."); +expect(out.join("")).toContain("--transport "); +expect(out.join("")).toContain("--once"); +``` + +Add tests: + +```ts +it("runs attach as a stdio MCP server by default", async () => { + const served: unknown[] = []; + await runCli(["attach"], { + env: { + CAPLETS_MODE: "remote", + CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets", + }, + attachServe: async (options) => { + served.push(options); + }, + } as never); + + expect(served).toHaveLength(1); + expect(served[0]).toMatchObject({ + transport: "stdio", + selection: { kind: "self_hosted_remote" }, + }); +}); + +it("rejects attach server in local mode", async () => { + await expect( + runCli(["attach"], { + env: { CAPLETS_MODE: "local" }, + attachServe: async () => undefined, + } as never), + ).rejects.toThrow(/use caplets serve for local-only MCP/u); +}); + +it("keeps attach --once as the finite Project Binding smoke path", async () => { + const out: string[] = []; + await runCli(["attach", "--once", "--remote-url", "https://caplets.example.com/caplets"], { + fetch: async () => Response.json({ error: "websocket_upgrade_required" }, { status: 426 }), + writeOut: (value) => out.push(value), + }); + expect(out.join("")).toContain("Project Binding available at"); +}); +``` + +The `attachServe` seam is intentionally test-only and mirrors the existing `serve` seam. + +- [ ] **Step 2: Run attach CLI tests to verify red** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/attach-cli.test.ts +``` + +Expected: FAIL because `attach` does not accept `--transport` and defaults to Project Binding session behavior. + +- [ ] **Step 3: Implement attach option resolution** + +Create `packages/core/src/attach/options.ts`: + +```ts +import { resolveServeOptions, type RawServeOptions, type ServeOptions } from "../serve/options"; +import { + resolveRemoteSelection, + type RemoteSelectionInput, + type ResolvedRemoteSelection, +} from "../remote/selection"; + +export type RawAttachServeOptions = RemoteSelectionInput & + RawServeOptions & { + projectRoot?: string; + }; + +export type AttachServeOptions = ServeOptions & { + projectRoot: string; + selection: ResolvedRemoteSelection; +}; + +export async function resolveAttachServeOptions( + raw: RawAttachServeOptions = {}, + env: Record = process.env, +): Promise { + const selection = await resolveRemoteSelection(raw, env); + const serve = resolveServeOptions(raw, env); + return { + ...serve, + projectRoot: raw.projectRoot ?? process.cwd(), + selection, + }; +} +``` + +- [ ] **Step 4: Implement attach server lifecycle** + +Create `packages/core/src/attach/server.ts`: + +```ts +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio"; +import { createNativeCapletsService } from "../native/service"; +import { NativeCapletsMcpSession } from "../serve/native-session"; +import { serveHttpWithSessionFactory } from "../serve/http"; +import type { AttachServeOptions } from "./options"; + +export type AttachServeIo = { + writeErr?: (value: string) => void; +}; + +export async function attachResolvedCaplets( + options: AttachServeOptions, + io: AttachServeIo = {}, +): Promise { + const service = createNativeCapletsService({ + mode: options.selection.kind === "hosted_cloud" ? "cloud" : "remote", + server: { + url: options.selection.remote.baseUrl.toString(), + fetch: options.selection.remote.fetch, + }, + remote: { + fetch: options.selection.remote.fetch, + cloud: + options.selection.kind === "hosted_cloud" + ? { + url: options.selection.cloudPresence.url.toString(), + accessToken: options.selection.cloudPresence.accessToken, + workspaceId: options.selection.cloudPresence.workspaceId, + projectRoot: options.projectRoot, + } + : undefined, + }, + ...(io.writeErr ? { writeErr: io.writeErr } : {}), + }); + await service.reload(); + + if (options.transport === "stdio") { + const session = new NativeCapletsMcpSession(service); + await session.connect(new StdioServerTransport()); + return; + } + + await serveHttpWithSessionFactory( + options, + () => new NativeCapletsMcpSession(service), + io.writeErr, + ); +} +``` + +During implementation, copy credentials and request headers from `selection.remote.requestInit` into the native remote client options. The snippet above shows the lifecycle shape; the final code must preserve Bearer and Basic Auth headers. + +- [ ] **Step 5: Extract HTTP session factory** + +In `packages/core/src/serve/http.ts`, extract the existing `createHttpSession(...)` path behind a function that can accept either: + +```ts +type HttpMcpSessionFactory = () => { + connect(transport: StreamableHTTPTransport): Promise; + close(): Promise; +}; +``` + +Export: + +```ts +export async function serveHttpWithSessionFactory( + options: HttpServeOptions, + createSession: HttpMcpSessionFactory, + writeErr?: (value: string) => void, +): Promise; +``` + +Keep `serveHttp(...)` behavior unchanged by calling the new helper with a factory that creates the existing local `CapletsMcpSession`. + +- [ ] **Step 6: Wire CLI default attach behavior** + +In `packages/core/src/cli.ts`: + +- Add `attachServe?: (options: AttachServeOptions) => Promise` to `CliIO`. +- Add attach options `--transport`, `--host`, `--port`, `--path`, `--user`, `--password`, `--token`, `--allow-unauthenticated-http`, and `--trust-proxy`. +- Keep `--once` on the existing `attachProjectOnce(...)` path. +- For non-`--once`, call `resolveAttachServeOptions(...)`, then `io.attachServe ?? attachResolvedCaplets`. +- Change description to `Start a remote-backed Caplets MCP server.` + +- [ ] **Step 7: Run attach tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/attach-cli.test.ts test/attach-server.test.ts +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add packages/core/src/attach/options.ts packages/core/src/attach/server.ts packages/core/src/serve/http.ts packages/core/src/cli.ts packages/core/test/attach-cli.test.ts packages/core/test/attach-server.test.ts +git commit -m "feat(core): serve remote mcp with attach" +``` + +--- + +## Task 5: Native Integration Cloud Mode + +**Files:** + +- Modify: `packages/core/src/native/options.ts` +- Modify: `packages/core/src/native/service.ts` +- Modify: `packages/core/src/native/remote.ts` +- Modify: `packages/core/src/native.ts` +- Modify: `packages/core/test/native-options.test.ts` +- Modify: `packages/core/test/native-remote.test.ts` + +- [ ] **Step 1: Add failing native option tests** + +Append to `packages/core/test/native-options.test.ts`: + +```ts +it("uses cloud mode in auto when CAPLETS_REMOTE_URL points at Caplets Cloud", () => { + expect( + resolveNativeCapletsServiceOptions( + {}, + { + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + }, + ), + ).toMatchObject({ + mode: "cloud", + remote: { + url: new URL("https://cloud.caplets.dev/mcp"), + }, + }); +}); + +it("uses cloud mode when CAPLETS_MODE=cloud is explicit", () => { + expect( + resolveNativeCapletsServiceOptions( + {}, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + }, + ), + ).toMatchObject({ mode: "cloud" }); +}); + +it("rejects CAPLETS_MODE=cloud with a self-hosted remote URL", () => { + expect(() => + resolveNativeCapletsServiceOptions( + {}, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets", + }, + ), + ).toThrow(/Caplets Cloud/u); +}); +``` + +- [ ] **Step 2: Add failing native Cloud service tests** + +Append to `packages/core/test/native-remote.test.ts`: + +```ts +it("starts Cloud Project Binding when native service runs in cloud mode", async () => { + const path = tempCloudAuthPath(); + await new CloudAuthStore({ path }).save(hostedCredentials({ accessToken: "cloud-access" })); + const factory = vi.fn(() => client([{ name: "remote", description: "Remote" }]).api); + + const service = createNativeCapletsService({ + mode: "cloud", + server: { url: "https://cloud.caplets.dev" }, + remoteClientFactory: factory, + projectConfigPath: tempProjectConfigWithTool("local"), + } as never); + + await service.reload(); + expect(service.listTools().map((tool) => tool.caplet)).toContain("remote"); + await service.close(); +}); +``` + +Use existing test helpers in this file for local overlay config, or add a small helper that writes `.caplets/config.json` into a temp directory and returns the path. + +- [ ] **Step 3: Run native tests to verify red** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/native-options.test.ts test/native-remote.test.ts +``` + +Expected: FAIL because native mode only supports `local | remote`. + +- [ ] **Step 4: Update native option types** + +In `packages/core/src/native/options.ts`: + +- Change `type CapletsMode = "auto" | "local" | "remote";` to include `"cloud"`. +- Let `ResolvedNativeCapletsServiceOptions` use `mode: "remote" | "cloud"` for remote-backed services. +- Call `resolveRemoteMode(...)` from Task 1. +- For Cloud mode, return the MCP URL and request headers needed for Cloud Auth resolution but do not require `CAPLETS_CLOUD_TOKEN` env vars. + +The final union should be: + +```ts +export type ResolvedNativeCapletsServiceOptions = + | { mode: "local" } + | { + mode: "remote" | "cloud"; + remote: { + url: URL; + auth: NativeRemoteAuthOptions; + pollIntervalMs: number; + requestInit: RequestInit; + cloud?: ResolvedNativeCloudPresenceOptions; + fetch?: typeof fetch; + }; + }; +``` + +- [ ] **Step 5: Load saved Cloud Auth for native Cloud mode** + +In `packages/core/src/native/service.ts`, update `createNativeCapletsService(...)`: + +- Treat `resolved.mode === "cloud"` the same as remote-backed for composition. +- Before creating the SDK remote client, use the shared selection helper or a native equivalent to load saved Cloud Auth credentials. +- Populate `resolved.remote.requestInit` with `Authorization: Bearer `. +- Populate `resolved.remote.cloud` with Cloud Project Binding presence options. +- Preserve existing behavior where explicit self-hosted remote mode fails hard, while auto Cloud fallback can warn and return local only when the remote setup cannot initialize. + +Keep local overlay precedence unchanged: + +```ts +const localIds = new Set(localTools.map((tool) => tool.caplet)); +return [...remoteTools.filter((tool) => !localIds.has(tool.caplet)), ...localTools]; +``` + +- [ ] **Step 6: Update remote auth error copy** + +In `packages/core/src/native/remote.ts`, make `remoteAuthError(...)` accept an auth kind: + +```ts +function remoteAuthError(kind: "self_hosted_remote" | "hosted_cloud"): CapletsError { + return new CapletsError( + "AUTH_FAILED", + kind === "hosted_cloud" + ? "Caplets Cloud authentication failed; run caplets cloud auth login." + : "Remote Caplets authentication failed; check CAPLETS_REMOTE_TOKEN or CAPLETS_REMOTE_USER and CAPLETS_REMOTE_PASSWORD.", + ); +} +``` + +Thread the kind from native service creation into `RemoteNativeCapletsService`. + +- [ ] **Step 7: Run native tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/native-options.test.ts test/native-remote.test.ts +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add packages/core/src/native/options.ts packages/core/src/native/service.ts packages/core/src/native/remote.ts packages/core/src/native.ts packages/core/test/native-options.test.ts packages/core/test/native-remote.test.ts +git commit -m "feat(core): drive native integrations from cloud mode" +``` + +--- + +## Task 6: OpenCode And Pi Docs/Config Validation + +**Files:** + +- Modify: `packages/opencode/src/index.ts` +- Modify: `packages/opencode/test/opencode.test.ts` +- Modify: `packages/opencode/README.md` +- Modify: `packages/pi/src/index.ts` +- Modify: `packages/pi/test/pi.test.ts` +- Modify: `packages/pi/README.md` +- Modify: `docs/native-integrations.md` + +- [ ] **Step 1: Add integration config tests** + +In `packages/opencode/test/opencode.test.ts`, add: + +```ts +it("passes cloud mode config into the native service", async () => { + vi.resetModules(); + const nativeMocks = { + createNativeCapletsService: vi.fn(() => ({ + listTools: () => [], + execute: vi.fn(async () => ({})), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => {}), + close: vi.fn(async () => {}), + })), + registerNativeCapletsProcessCleanup: vi.fn(), + }; + vi.doMock("@caplets/core/native", () => nativeMocks); + const plugin = (await import("../src/index")).default; + + await plugin( + {} as never, + { mode: "cloud", server: { url: "https://cloud.caplets.dev" } } as never, + ); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({ + mode: "cloud", + server: { url: "https://cloud.caplets.dev" }, + }); +}); +``` + +In `packages/pi/test/pi.test.ts`, add a settings extraction case: + +```ts +it("loads cloud mode from Pi settings", async () => { + fsMocks.readFile.mockImplementation(async (path: string) => + path.includes(".pi/agent/settings.json") + ? JSON.stringify({ caplets: { mode: "cloud", server: { url: "https://cloud.caplets.dev" } } }) + : Promise.reject(Object.assign(new Error("missing"), { code: "ENOENT" })), + ); + + await capletsPiExtension(mockPiApi()); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "cloud", + server: { url: "https://cloud.caplets.dev" }, + }), + ); +}); +``` + +- [ ] **Step 2: Run package tests to verify red or confirm no code change needed** + +Run: + +```bash +pnpm --filter @caplets/opencode test +pnpm --filter @caplets/pi test +``` + +Expected: PASS if existing config plumbing already accepts `"cloud"` after Task 5 type changes; otherwise FAIL on narrow type validation and fix in Step 3. + +- [ ] **Step 3: Confirm config types accept cloud mode** + +In `packages/opencode/src/index.ts` and `packages/pi/src/index.ts`, keep the current config shape but ensure the imported `NativeCapletsServiceOptions` type includes `mode: "cloud"` after Task 5: + +```ts +export type CapletsOpenCodeConfig = Pick; +type PiNativeCapletsOptions = Pick; +``` + +No runtime special case should be added for Cloud. + +- [ ] **Step 4: Update native integration docs** + +In `docs/native-integrations.md`, add this contract: + +```md +## Remote Selection + +OpenCode and Pi use the same resolver as `caplets attach`. + +- `CAPLETS_MODE=local` exposes local/user/project Caplets only. +- `CAPLETS_MODE=remote` requires `CAPLETS_REMOTE_URL` and connects to a self-hosted Caplets service. +- `CAPLETS_MODE=cloud` requires `CAPLETS_REMOTE_URL` pointing at Caplets Cloud and uses saved `caplets cloud auth login` credentials. +- `CAPLETS_MODE=auto` treats Cloud URLs as Cloud, non-Cloud remote URLs as self-hosted, and no remote URL as local. + +Cloud mode starts Project Binding automatically for the current project and overlays local/project Caplets over the remote workspace. +``` + +In both integration READMEs, include the three copyable env examples: + +```bash +CAPLETS_MODE=local opencode +CAPLETS_MODE=remote CAPLETS_REMOTE_URL=https://caplets.example.com/caplets opencode +CAPLETS_MODE=cloud CAPLETS_REMOTE_URL=https://cloud.caplets.dev opencode +``` + +Use `pi` in the Pi README examples. + +- [ ] **Step 5: Run package tests** + +Run: + +```bash +pnpm --filter @caplets/opencode test +pnpm --filter @caplets/pi test +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/opencode/src/index.ts packages/opencode/test/opencode.test.ts packages/opencode/README.md packages/pi/src/index.ts packages/pi/test/pi.test.ts packages/pi/README.md docs/native-integrations.md +git commit -m "docs(core): document native cloud mode" +``` + +--- + +## Task 7: Remove Codex And Claude Plugin Artifacts + +**Files:** + +- Delete: `plugins/caplets/.codex-plugin/plugin.json` +- Delete: `plugins/caplets/.claude-plugin/plugin.json` +- Delete: `plugins/caplets/mcp.json` +- Delete: `plugins/caplets/skills/caplets/SKILL.md` +- Delete: `plugins/caplets/assets/icon.png` or move it to a non-plugin asset path if still referenced +- Delete: `.agents/plugins/marketplace.json` +- Delete: `.claude-plugin/marketplace.json` +- Delete: `scripts/sync-plugin-versions.ts` +- Modify: `package.json` +- Modify: `packages/core/test/agent-plugins.test.ts` + +- [ ] **Step 1: Invert plugin artifact tests** + +Replace `packages/core/test/agent-plugins.test.ts` with contract tests that assert absence: + +```ts +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); + +describe("Codex and Claude manual MCP setup", () => { + it("does not ship native Codex or Claude plugin artifacts", () => { + for (const removedPath of [ + "plugins/caplets", + ".agents/plugins/marketplace.json", + ".claude-plugin/marketplace.json", + "scripts/sync-plugin-versions.ts", + ]) { + expect(existsSync(path.join(repoRoot, removedPath)), removedPath).toBe(false); + } + }); + + it("documents manual MCP config for Codex and Claude users", async () => { + const readme = await readFile(path.join(repoRoot, "README.md"), "utf8"); + expect(readme).toContain('"command": "caplets"'); + expect(readme).toContain('"args": ["serve"]'); + expect(readme).toContain('"args": ["attach"]'); + expect(readme).not.toMatch(/plugin marketplace add|plugin install caplets@caplets/u); + }); + + it("does not keep version-package plugin sync wiring", async () => { + const packageJson = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8")) as { + scripts: Record; + }; + expect(packageJson.scripts["version-packages"] ?? "").not.toContain("sync-plugin-versions"); + }); +}); +``` + +- [ ] **Step 2: Run plugin tests to verify red** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/agent-plugins.test.ts +``` + +Expected: FAIL because plugin artifacts still exist and README still references plugin install flows. + +- [ ] **Step 3: Delete plugin artifacts** + +Delete these paths: + +```bash +git rm -r plugins/caplets .agents/plugins/marketplace.json .claude-plugin/marketplace.json scripts/sync-plugin-versions.ts +``` + +If `git rm -r plugins/caplets` fails because the icon is referenced by `README.md`, move the image first: + +```bash +mkdir -p docs/assets +git mv plugins/caplets/assets/icon.png docs/assets/caplets-icon.png +git rm -r plugins/caplets .agents/plugins/marketplace.json .claude-plugin/marketplace.json scripts/sync-plugin-versions.ts +``` + +Then update the README image path from `plugins/caplets/assets/icon.png` to `docs/assets/caplets-icon.png`. + +- [ ] **Step 4: Remove version sync wiring** + +In `package.json`, remove `scripts/sync-plugin-versions.ts` from the `version-packages` script. The final script must still run Changesets and formatting for remaining generated files: + +```json +"version-packages": "changeset version && oxlint --fix --quiet && oxfmt --write ." +``` + +If the current script has additional non-plugin commands, preserve them and remove only the plugin sync command. + +- [ ] **Step 5: Run plugin tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/agent-plugins.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add package.json packages/core/test/agent-plugins.test.ts README.md docs/assets/caplets-icon.png +git add -u plugins .agents .claude-plugin scripts +git commit -m "refactor(core): remove codex and claude plugins" +``` + +--- + +## Task 8: Manual MCP Documentation + +**Files:** + +- Modify: `README.md` +- Modify: `packages/cli/README.md` +- Modify: `packages/core/test/agent-plugins.test.ts` + +- [ ] **Step 1: Add docs contract assertions** + +Extend `packages/core/test/agent-plugins.test.ts`: + +```ts +it("documents serve for local MCP and attach for remote MCP", async () => { + const readme = await readFile(path.join(repoRoot, "README.md"), "utf8"); + expect(readme).toContain("caplets serve"); + expect(readme).toContain("caplets attach"); + expect(readme).toContain("CAPLETS_MODE=cloud"); + expect(readme).toContain("CAPLETS_REMOTE_URL=https://cloud.caplets.dev"); + expect(readme).toContain("CAPLETS_MODE=remote"); +}); +``` + +- [ ] **Step 2: Update top-level Core README integration table** + +In `README.md`, replace the existing agent integration table rows for Claude and Codex with: + +```md +| Codex | Add `caplets serve` or `caplets attach` manually in MCP config | Local or remote/Cloud progressive-disclosure gateway | +| Claude Code | Add `caplets serve` or `caplets attach` manually in MCP config | Local or remote/Cloud progressive-disclosure gateway | +``` + +Add the manual MCP examples exactly: + +```json +{ + "mcpServers": { + "caplets": { + "command": "caplets", + "args": ["serve"] + } + } +} +``` + +```json +{ + "mcpServers": { + "caplets": { + "command": "caplets", + "args": ["attach"] + } + } +} +``` + +Add Cloud env setup: + +```bash +caplets cloud auth login +export CAPLETS_MODE=cloud +export CAPLETS_REMOTE_URL=https://cloud.caplets.dev +``` + +Add self-hosted env setup: + +```bash +export CAPLETS_MODE=remote +export CAPLETS_REMOTE_URL=https://caplets.example.com/caplets +export CAPLETS_REMOTE_TOKEN=... +``` + +- [ ] **Step 3: Update CLI README** + +If `packages/cli/README.md` exists and references plugin installs, replace those paragraphs with the same manual MCP examples from Step 2. If it does not contain plugin guidance, add a short `Manual MCP setup` section with the two JSON examples. + +- [ ] **Step 4: Run docs contract tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/agent-plugins.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add README.md packages/cli/README.md packages/core/test/agent-plugins.test.ts +git commit -m "docs(core): prefer manual mcp setup" +``` + +--- + +## Task 9: CLI Error Handling And Regression Matrix + +**Files:** + +- Modify: `packages/core/test/attach-cli.test.ts` +- Modify: `packages/core/test/remote-options.test.ts` +- Modify: `packages/core/test/native-options.test.ts` +- Modify: `packages/core/src/cli.ts` +- Modify: `packages/core/src/remote/selection.ts` + +- [ ] **Step 1: Add explicit error tests** + +Add these cases across the listed test files: + +```ts +it("prints JSON error for attach --once when cloud auth is missing", async () => { + const out: string[] = []; + let exitCode = 0; + await runCli(["attach", "--once", "--json"], { + env: { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + }, + writeOut: (value) => out.push(value), + setExitCode: (code) => { + exitCode = code; + }, + }); + + expect(exitCode).toBe(1); + expect(JSON.parse(out.join(""))).toMatchObject({ + error: { + code: "cloud_auth_required", + recoveryCommand: "caplets cloud auth login", + }, + }); +}); + +it("references CAPLETS_REMOTE_TOKEN or Basic Auth vars for self-hosted auth failures", async () => { + await expect( + resolveCapletsRemote( + { user: "caplets" }, + { CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets" }, + ), + ).toThrow(/CAPLETS_REMOTE_PASSWORD/u); +}); +``` + +- [ ] **Step 2: Run focused regression tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/attach-cli.test.ts test/remote-options.test.ts test/native-options.test.ts +``` + +Expected: FAIL for any missing structured error handling. + +- [ ] **Step 3: Normalize errors** + +In `packages/core/src/remote/selection.ts`, throw `CapletsError` or `ProjectBindingError` for every user-facing failure: + +- local mode for attach: `REQUEST_INVALID` +- missing remote URL for attach: `REQUEST_INVALID` +- missing Cloud Auth: existing `projectBindingError("cloud_auth_required")` +- workspace mismatch: existing `projectBindingError("workspace_switch_required")` +- Cloud URL mismatch: `REQUEST_INVALID` + +In `packages/core/src/cli.ts`, ensure the `--once --json` handler serializes these errors consistently with the existing Project Binding JSON branch. + +- [ ] **Step 4: Run regression tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/attach-cli.test.ts test/remote-options.test.ts test/native-options.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/cli.ts packages/core/src/remote/selection.ts packages/core/test/attach-cli.test.ts packages/core/test/remote-options.test.ts packages/core/test/native-options.test.ts +git commit -m "fix(core): clarify attach remote errors" +``` + +--- + +## Task 10: Changeset And Full Verification + +**Files:** + +- Add: `.changeset/.md` +- Modify: any generated schema or docs files changed by package scripts + +- [ ] **Step 1: Add Changesets entry** + +Create a changeset: + +```bash +pnpm changeset +``` + +Use this content: + +```md +--- +"@caplets/core": minor +"caplets": minor +"@caplets/opencode": minor +"@caplets/pi": minor +--- + +Make `caplets attach` the remote-backed MCP server command, add Cloud-aware `CAPLETS_MODE` resolution, keep OpenCode and Pi on the shared resolver, and remove Codex/Claude plugin artifacts in favor of manual MCP configuration. +``` + +- [ ] **Step 2: Run formatting** + +Run: + +```bash +pnpm format:check +``` + +Expected: PASS. If it fails only on changed files, run `pnpm format` and re-run `pnpm format:check`. + +- [ ] **Step 3: Run Core focused tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/remote-options.test.ts test/remote-selection.test.ts test/attach-cli.test.ts test/attach-server.test.ts test/cloud-auth-refresh-attach.test.ts test/native-options.test.ts test/native-remote.test.ts test/agent-plugins.test.ts +``` + +Expected: PASS. + +- [ ] **Step 4: Run integration package tests** + +Run: + +```bash +pnpm --filter @caplets/opencode test +pnpm --filter @caplets/pi test +``` + +Expected: PASS. + +- [ ] **Step 5: Run Core verification** + +Run: + +```bash +pnpm core:verify +``` + +Expected: PASS. + +- [ ] **Step 6: Run root coordination verification** + +Run: + +```bash +pnpm verify +``` + +Expected: PASS. + +- [ ] **Step 7: Manual smoke commands** + +Build first: + +```bash +pnpm core:build +``` + +Smoke local serve help: + +```bash +node core/packages/cli/dist/index.js serve --help +``` + +Expected output includes `Serve configured Caplets as an MCP server.` and does not mention Cloud Auth. + +Smoke attach help: + +```bash +node core/packages/cli/dist/index.js attach --help +``` + +Expected output includes `Start a remote-backed Caplets MCP server.`, `--transport `, `--once`, and `--remote-url `. + +Smoke local-mode attach rejection: + +```bash +CAPLETS_MODE=local node core/packages/cli/dist/index.js attach --transport stdio +``` + +Expected: exits non-zero with `use caplets serve for local-only MCP`. + +- [ ] **Step 8: Final commit** + +```bash +git add . +git commit -m "chore(core): verify remote attach integration" +``` + +If every previous task already committed all files and the working tree is clean, skip the final commit and record the verification commands in the final response. + +--- + +## Self-Review Checklist + +- [ ] `caplets serve` remains local-only for stdio and HTTP and does not consult Cloud Auth. +- [ ] `caplets attach` starts an MCP server by default and supports stdio and HTTP transports. +- [ ] `caplets attach --once` remains a finite Project Binding smoke path. +- [ ] `CAPLETS_MODE=local` rejects attach and points users to `caplets serve`. +- [ ] `CAPLETS_MODE=remote` requires `CAPLETS_REMOTE_URL` and uses self-hosted token or Basic Auth. +- [ ] `CAPLETS_MODE=cloud` requires a Cloud URL and saved `caplets cloud auth login` credentials. +- [ ] `CAPLETS_MODE=auto` detects Cloud from `CAPLETS_REMOTE_URL`, detects self-hosted remotes from non-Cloud URLs, and falls back to local only for native integrations with no remote URL. +- [ ] Cloud mode ignores self-hosted `CAPLETS_REMOTE_TOKEN` and uses saved Cloud Auth. +- [ ] Cloud mode refreshes expired saved credentials before attach/native remote use. +- [ ] Cloud mode starts Project Binding automatically and preserves local/project overlay precedence. +- [ ] OpenCode and Pi remain installed native integrations and require no Cloud-specific plugin manifest. +- [ ] Codex and Claude plugin marketplace metadata, bundled MCP config, and shared plugin skill are removed. +- [ ] Codex and Claude docs show manual MCP config for both `serve` and `attach`. +- [ ] Focused tests, package tests, `pnpm core:verify`, and root `pnpm verify` pass. From 138073cee5d83333a417a190e150914aa8c9045e Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 4 Jun 2026 13:54:53 -0400 Subject: [PATCH 16/19] feat(core): implement remote attach integrations --- .agents/plugins/marketplace.json | 20 -- .../remote-attach-agent-integrations.md | 8 + .claude-plugin/marketplace.json | 21 -- README.md | 68 +++++-- .../icon.png => docs/assets/caplets-icon.png | Bin docs/native-integrations.md | 11 ++ package.json | 2 +- packages/core/src/attach/options.ts | 29 +++ packages/core/src/attach/server.ts | 77 ++++++++ packages/core/src/cli.ts | 69 ++++--- packages/core/src/native/options.ts | 29 ++- packages/core/src/native/remote.ts | 13 +- packages/core/src/native/service.ts | 184 +++++++++++++++--- packages/core/src/project-binding/attach.ts | 109 ++--------- packages/core/src/remote/options.ts | 39 +++- packages/core/src/remote/selection.ts | 134 +++++++++++++ packages/core/src/serve/http.ts | 45 ++++- packages/core/src/serve/index.ts | 2 + packages/core/src/serve/native-session.ts | 114 +++++++++++ packages/core/test/agent-plugins.test.ts | 179 +++-------------- packages/core/test/attach-cli.test.ts | 174 +++++++---------- packages/core/test/attach-server.test.ts | 82 ++++++++ .../test/cloud-auth-refresh-attach.test.ts | 21 +- packages/core/test/native-options.test.ts | 40 ++++ packages/core/test/native-remote.test.ts | 36 +++- packages/core/test/remote-options.test.ts | 57 ++++++ packages/core/test/remote-selection.test.ts | 112 +++++++++++ packages/opencode/README.md | 17 +- packages/opencode/test/opencode.test.ts | 26 +++ packages/pi/README.md | 17 +- packages/pi/src/index.ts | 5 +- packages/pi/test/pi.test.ts | 22 +++ plugins/caplets/.claude-plugin/plugin.json | 14 -- plugins/caplets/.codex-plugin/plugin.json | 28 --- plugins/caplets/mcp.json | 8 - plugins/caplets/skills/caplets/SKILL.md | 48 ----- scripts/sync-plugin-versions.ts | 26 --- 37 files changed, 1271 insertions(+), 615 deletions(-) delete mode 100644 .agents/plugins/marketplace.json create mode 100644 .changeset/remote-attach-agent-integrations.md delete mode 100644 .claude-plugin/marketplace.json rename plugins/caplets/assets/icon.png => docs/assets/caplets-icon.png (100%) create mode 100644 packages/core/src/attach/options.ts create mode 100644 packages/core/src/attach/server.ts create mode 100644 packages/core/src/remote/selection.ts create mode 100644 packages/core/src/serve/native-session.ts create mode 100644 packages/core/test/attach-server.test.ts create mode 100644 packages/core/test/remote-selection.test.ts delete mode 100644 plugins/caplets/.claude-plugin/plugin.json delete mode 100644 plugins/caplets/.codex-plugin/plugin.json delete mode 100644 plugins/caplets/mcp.json delete mode 100644 plugins/caplets/skills/caplets/SKILL.md delete mode 100644 scripts/sync-plugin-versions.ts diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json deleted file mode 100644 index c0599d5..0000000 --- a/.agents/plugins/marketplace.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "caplets", - "interface": { - "displayName": "Caplets" - }, - "plugins": [ - { - "name": "caplets", - "source": { - "source": "local", - "path": "./plugins/caplets" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "Developer Tools" - } - ] -} diff --git a/.changeset/remote-attach-agent-integrations.md b/.changeset/remote-attach-agent-integrations.md new file mode 100644 index 0000000..65b5674 --- /dev/null +++ b/.changeset/remote-attach-agent-integrations.md @@ -0,0 +1,8 @@ +--- +"@caplets/core": minor +"caplets": minor +"@caplets/opencode": minor +"@caplets/pi": minor +--- + +Make `caplets attach` the remote-backed MCP server command, add Cloud-aware `CAPLETS_MODE` resolution, keep OpenCode and Pi on the shared resolver, and remove Codex/Claude plugin artifacts in favor of manual MCP configuration. diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json deleted file mode 100644 index b90d2ff..0000000 --- a/.claude-plugin/marketplace.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "caplets", - "owner": { - "name": "Spirit-Led Software LLC", - "url": "https://github.com/spiritledsoftware" - }, - "plugins": [ - { - "name": "caplets", - "source": "./plugins/caplets", - "description": "Expose configured Caplets as progressive-disclosure tools in Claude Code.", - "category": "Developer Tools", - "author": { - "name": "Spirit-Led Software LLC", - "url": "https://github.com/spiritledsoftware", - "email": "ianpascoe@spiritledsoftware.com" - }, - "homepage": "https://github.com/spiritledsoftware/caplets" - } - ] -} diff --git a/README.md b/README.md index 1211f0f..19e5d92 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Caplets logo + Caplets logo

Caplets

@@ -134,28 +134,60 @@ Completions include command names, options, common enum values, configured Caple Backends that require OAuth or token auth may need `caplets auth login ` before live downstream completions can return richer results. Completion never starts interactive login flows. -## Agent Plugins +## Agent Integrations Use Caplets as a normal MCP server everywhere, or install a native agent integration when your coding agent supports one. -| Agent | Install | What It Provides | -| -------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| Any MCP client | Add `caplets serve` as a stdio MCP server | Universal progressive-disclosure gateway | -| Claude Code | `claude plugin marketplace add spiritledsoftware/caplets && claude plugin install caplets@caplets` | Claude Code plugin metadata, MCP config, and shared skill guidance | -| Codex | `codex plugin marketplace add spiritledsoftware/caplets`, then install `caplets` from Codex plugins | Codex plugin metadata, MCP config, and shared skill guidance | -| OpenCode | Install [`@caplets/opencode`](packages/opencode/README.md) | Native `caplets_` tools and prompt guidance hooks | -| Pi | Install [`@caplets/pi`](packages/pi/README.md) | Native `caplets_` tools with Pi prompt snippets/guidelines | +| Agent | Install | What It Provides | +| -------------- | -------------------------------------------------------------- | -------------------------------------------------------------- | +| Any MCP client | Add `caplets serve` or `caplets attach` manually in MCP config | Universal progressive-disclosure gateway | +| Claude Code | Add `caplets serve` or `caplets attach` manually in MCP config | Local or remote/Cloud progressive-disclosure gateway | +| Codex | Add `caplets serve` or `caplets attach` manually in MCP config | Local or remote/Cloud progressive-disclosure gateway | +| OpenCode | Install [`@caplets/opencode`](packages/opencode/README.md) | Native `caplets_` tools and prompt guidance hooks | +| Pi | Install [`@caplets/pi`](packages/pi/README.md) | Native `caplets_` tools with Pi prompt snippets/guidelines | -Codex and Claude Code plugins are plugin-native but MCP-backed. The installable plugin -lives under `plugins/caplets/`, with agent-specific manifests in `.codex-plugin/` and -`.claude-plugin/`, a shared `skills/` directory, and shared `mcp.json` config. The -repo-level `.agents/plugins/marketplace.json` and `.claude-plugin/marketplace.json` -files only advertise that installable plugin root. +Manual local MCP config: -The Claude Code and Codex commands install from this GitHub repository through each agent's -plugin marketplace flow; users do not need to clone the repository manually. Plugin MCP -configs run `caplets serve` directly, so install the Caplets CLI globally first. +```json +{ + "mcpServers": { + "caplets": { + "command": "caplets", + "args": ["serve"] + } + } +} +``` + +Manual remote or Cloud MCP config: + +```json +{ + "mcpServers": { + "caplets": { + "command": "caplets", + "args": ["attach"] + } + } +} +``` + +For Caplets Cloud, authenticate once and set the remote selection environment for the agent: + +```sh +caplets cloud auth login +export CAPLETS_MODE=cloud +export CAPLETS_REMOTE_URL=https://cloud.caplets.dev +``` + +For a self-hosted remote: + +```sh +export CAPLETS_MODE=remote +export CAPLETS_REMOTE_URL=https://caplets.example.com/caplets +export CAPLETS_REMOTE_TOKEN=... +``` ## Core Alchemy @@ -179,7 +211,7 @@ Cloud Auth stores one Selected Workspace locally. `caplets attach --workspace = process.env, +): Promise { + const selection = await resolveRemoteSelection(raw, env); + const serve = resolveServeOptions(raw, env); + return { + ...serve, + projectRoot: raw.projectRoot ?? process.cwd(), + selection, + }; +} diff --git a/packages/core/src/attach/server.ts b/packages/core/src/attach/server.ts new file mode 100644 index 0000000..c1faeca --- /dev/null +++ b/packages/core/src/attach/server.ts @@ -0,0 +1,77 @@ +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio"; +import type { CapletsRemoteAuth } from "../remote/options"; +import { createSdkRemoteCapletsClient } from "../native/remote"; +import { createNativeCapletsService } from "../native/service"; +import type { NativeRemoteAuthOptions } from "../native/options"; +import { serveHttpWithSessionFactory } from "../serve/http"; +import { NativeCapletsMcpSession } from "../serve/native-session"; +import type { AttachServeOptions } from "./options"; + +export type AttachServeIo = { + writeErr?: (value: string) => void; +}; + +export async function attachResolvedCaplets( + options: AttachServeOptions, + io: AttachServeIo = {}, +): Promise { + if (options.transport === "stdio") { + const service = createAttachNativeService(options, io); + const session = new NativeCapletsMcpSession(service); + await service.reload(); + await session.connect(new StdioServerTransport()); + return; + } + + await serveHttpWithSessionFactory( + options, + async () => { + const service = createAttachNativeService(options, io); + await service.reload(); + return new NativeCapletsMcpSession(service); + }, + io.writeErr, + ); +} + +function createAttachNativeService(options: AttachServeOptions, io: AttachServeIo) { + return createNativeCapletsService({ + mode: options.selection.kind === "hosted_cloud" ? "cloud" : "remote", + server: { + url: options.selection.remote.baseUrl.toString(), + ...(options.selection.remote.fetch ? { fetch: options.selection.remote.fetch } : {}), + }, + remote: { + ...(options.selection.remote.fetch ? { fetch: options.selection.remote.fetch } : {}), + ...(options.selection.kind === "hosted_cloud" + ? { + cloud: { + url: options.selection.cloudPresence.url.toString(), + accessToken: options.selection.cloudPresence.accessToken, + workspaceId: options.selection.cloudPresence.workspaceId, + projectRoot: options.projectRoot, + }, + } + : {}), + }, + remoteClientFactory: (resolved) => + createSdkRemoteCapletsClient({ + ...resolved, + requestInit: options.selection.remote.requestInit, + auth: nativeAuthFromRemoteAuth(options.selection.remote.auth), + url: options.selection.remote.mcpUrl, + ...(options.selection.remote.fetch ? { fetch: options.selection.remote.fetch } : {}), + }), + ...(io.writeErr ? { writeErr: io.writeErr } : {}), + }); +} + +function nativeAuthFromRemoteAuth(auth: CapletsRemoteAuth): NativeRemoteAuthOptions { + if (auth.type === "basic") { + return { enabled: true, user: auth.user, password: auth.password }; + } + if (auth.type === "none") { + return { enabled: false, user: auth.user }; + } + return { enabled: false, user: "caplets" }; +} diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 27a1750..442f7a4 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -62,7 +62,9 @@ import { } from "./config"; import { CapletsEngine } from "./engine"; import { CapletsError } from "./errors"; -import { attachProjectOnce, attachProjectSession } from "./project-binding/attach"; +import { resolveAttachServeOptions, type AttachServeOptions } from "./attach/options"; +import { attachResolvedCaplets } from "./attach/server"; +import { attachProjectOnce } from "./project-binding/attach"; import { ProjectBindingError } from "./project-binding/errors"; import type { ProjectBindingWebSocketFactory } from "./project-binding/transport"; import { RemoteControlClient } from "./remote-control/client"; @@ -102,6 +104,7 @@ type CliIO = { version?: string; setExitCode?: (code: number) => void; serve?: (options: ServeOptions) => Promise; + attachServe?: (options: AttachServeOptions) => Promise; daemon?: ServeDaemonOperationOptions; runSetupCommand?: SetupCommandRunner; }; @@ -453,12 +456,21 @@ export function createProgram(io: CliIO = {}): Command { program .command(cliCommands.attach) - .description("Attach the current project to a remote Caplets runtime.") + .description("Start a remote-backed Caplets MCP server.") + .option("--transport ", "server transport: stdio or http") + .option("--host ", "HTTP bind host") + .option("--port ", "HTTP bind port") + .option("--path ", "HTTP service base path") .option("--remote-url ", "remote Caplets service base URL") .option("--user ", "remote Basic Auth username") .option("--password ", "remote Basic Auth password") .option("--token ", "remote bearer token") .option("--workspace ", "hosted Cloud workspace ID or slug") + .option( + "--allow-unauthenticated-http", + "allow unauthenticated HTTP serving on non-loopback hosts", + ) + .option("--trust-proxy", "trust X-Forwarded-* headers from a reverse proxy") .option("--json", "print JSON status events") .option("--verbose", "print detailed attach diagnostics") .option("--once", "validate Project Binding once and exit") @@ -466,10 +478,16 @@ export function createProgram(io: CliIO = {}): Command { .action( async (options: { remoteUrl?: string; + transport?: string; + host?: string; + port?: string; + path?: string; user?: string; password?: string; token?: string; workspace?: string; + allowUnauthenticatedHttp?: boolean; + trustProxy?: boolean; json?: boolean; verbose?: boolean; once?: boolean; @@ -477,22 +495,17 @@ export function createProgram(io: CliIO = {}): Command { }) => { try { const attachOptions = { ...options, ...(io.fetch ? { fetch: io.fetch } : {}) }; - const sessionOptions = options.json - ? { - signal: io.signal, - webSocketFactory: io.projectBindingWebSocketFactory, - onEvent: (event: import("./project-binding/attach").AttachSessionEvent) => - writeOut(`${JSON.stringify(event, null, 2)}\n`), - } - : { - signal: io.signal, - webSocketFactory: io.projectBindingWebSocketFactory, - }; - const result = options.once - ? await attachProjectOnce(attachOptions, env) - : await attachProjectSession(attachOptions, env, sessionOptions); + if (!options.once) { + const resolved = await resolveAttachServeOptions(attachOptions, env); + await ( + io.attachServe ?? + ((serveOptions) => attachResolvedCaplets(serveOptions, { writeErr })) + )(resolved); + return; + } + const result = await attachProjectOnce(attachOptions, env); if (options.json) { - if (options.once) writeOut(`${JSON.stringify(result, null, 2)}\n`); + writeOut(`${JSON.stringify(result, null, 2)}\n`); return; } writeOut(`Project Binding available at ${result.webSocketUrl}.\n`); @@ -533,13 +546,25 @@ export function createProgram(io: CliIO = {}): Command { setExitCode(1); return; } + if (options.json && error instanceof CapletsError) { + writeOut( + `${JSON.stringify( + { + ok: false, + error: { + code: error.code, + message: error.message, + }, + }, + null, + 2, + )}\n`, + ); + setExitCode(1); + return; + } throw error; } - if (!options.once && options.verbose) { - writeErr( - "Long-running Project Binding attach will keep using the WebSocket transport.\n", - ); - } }, ); diff --git a/packages/core/src/native/options.ts b/packages/core/src/native/options.ts index 9523fde..f96b2e6 100644 --- a/packages/core/src/native/options.ts +++ b/packages/core/src/native/options.ts @@ -7,7 +7,7 @@ import { type CapletsRemoteEnv, } from "../remote/options"; -type CapletsMode = "auto" | "local" | "remote"; +type CapletsMode = "auto" | "local" | "remote" | "cloud"; export type NativeCapletsMode = CapletsMode; @@ -40,7 +40,7 @@ export type NativeRemoteAuthOptions = export type ResolvedNativeCapletsServiceOptions = | { mode: "local" } | { - mode: "remote"; + mode: "remote" | "cloud"; remote: { url: URL; auth: NativeRemoteAuthOptions; @@ -70,19 +70,32 @@ export function resolveNativeCapletsServiceOptions( } const serverFetch = input.remote?.fetch ?? input.server?.fetch; - const server = resolveCapletsRemote( - { ...input.server, ...(serverFetch ? { fetch: serverFetch } : {}) }, - env, - ); + const serverInput = { + ...input.server, + ...(serverFetch ? { fetch: serverFetch } : {}), + }; + const server = + mode.mode === "cloud" + ? resolveCapletsRemote( + { + url: input.server?.url ?? env.CAPLETS_REMOTE_URL ?? "", + ...(serverFetch ? { fetch: serverFetch } : {}), + }, + {}, + ) + : resolveCapletsRemote(serverInput, env); const cloud = resolveNativeCloudPresence(input.remote?.cloud, env); return { - mode: "remote", + mode: mode.mode, remote: { url: mcpUrlForBase(server.baseUrl), auth: nativeAuthFromRemoteAuth(server.auth), pollIntervalMs: parsePollInterval(input.remote?.pollIntervalMs), - requestInit: server.requestInit, + requestInit: + mode.mode === "cloud" && cloud + ? { headers: { Authorization: `Bearer ${cloud.accessToken}` } } + : server.requestInit, ...(cloud ? { cloud } : {}), ...(server.fetch ? { fetch: server.fetch } : {}), }, diff --git a/packages/core/src/native/remote.ts b/packages/core/src/native/remote.ts index fa791a7..4dd5397 100644 --- a/packages/core/src/native/remote.ts +++ b/packages/core/src/native/remote.ts @@ -27,13 +27,14 @@ export type RemoteCapletsClient = { }; export type RemoteCapletsClientOptions = ResolvedNativeCapletsServiceOptions & { - mode: "remote"; + mode: "remote" | "cloud"; }; export type RemoteNativeCapletsServiceOptions = { client: RemoteCapletsClient; clientFactory?: () => RemoteCapletsClient; pollIntervalMs: number; + authKind?: "self_hosted_remote" | "hosted_cloud"; writeErr?: (value: string) => void; }; @@ -118,7 +119,7 @@ export class RemoteNativeCapletsService implements NativeCapletsService { return await this.client.callTool(capletId, request); } catch (error) { if (isAuthFailure(error)) { - throw remoteAuthError(); + throw remoteAuthError(this.options.authKind ?? "self_hosted_remote"); } if (isSessionFailure(error)) { if (!(await this.resetClient()) || this.closed) { @@ -128,7 +129,7 @@ export class RemoteNativeCapletsService implements NativeCapletsService { return await this.client.callTool(capletId, request); } catch (retryError) { if (isAuthFailure(retryError)) { - throw remoteAuthError(); + throw remoteAuthError(this.options.authKind ?? "self_hosted_remote"); } throw retryError; } @@ -273,10 +274,12 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function remoteAuthError(): CapletsError { +function remoteAuthError(kind: "self_hosted_remote" | "hosted_cloud"): CapletsError { return new CapletsError( "AUTH_FAILED", - "Remote Caplets authentication failed; check CAPLETS_REMOTE_USER and CAPLETS_REMOTE_PASSWORD.", + kind === "hosted_cloud" + ? "Caplets Cloud authentication failed; run caplets cloud auth login." + : "Remote Caplets authentication failed; check CAPLETS_REMOTE_TOKEN or CAPLETS_REMOTE_USER and CAPLETS_REMOTE_PASSWORD.", ); } diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index 07eabec..797aa14 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -1,5 +1,9 @@ import type { NativeCapletsServiceResolutionInput } from "./options"; -import { resolveNativeCapletsServiceOptions } from "./options"; +import { + resolveNativeCapletsServiceOptions, + type NativeRemoteAuthOptions, + type ResolvedNativeCloudPresenceOptions, +} from "./options"; import { CapletsCloudClient } from "../cloud/client"; import { ProjectBindingSessionManager } from "../cloud/presence"; import { projectSyncFiles } from "../cloud/sync"; @@ -23,6 +27,8 @@ import { type LocalOverlayConfigWithSources, } from "../config"; import { generatedToolInputJsonSchemaForCaplet } from "../generated-tool-input-schema"; +import type { CapletsRemoteAuth } from "../remote/options"; +import { resolveRemoteSelection } from "../remote/selection"; const REMOTE_PROJECT_BINDING_FALLBACK_WARNING = "Remote project binding unavailable; using local Caplets only. Run caplets doctor for details.\n"; @@ -35,12 +41,7 @@ export type NativeCapletsServiceOptions = NativeCapletsServiceResolutionInput & watchDebounceMs?: number; watch?: boolean; writeErr?: (value: string) => void; - remoteClientFactory?: ( - options: Extract< - ReturnType, - { mode: "remote" } - >["remote"], - ) => RemoteCapletsClient; + remoteClientFactory?: (options: ResolvedNativeRemoteOptions) => RemoteCapletsClient; localServiceFactory?: (options: LocalNativeCapletsServiceOptions) => NativeCapletsService; }; @@ -69,23 +70,9 @@ export function createNativeCapletsService( ): NativeCapletsService { const resolved = resolveNativeCapletsServiceOptions(options); if (resolved.mode === "remote") { - const localOptions = { - ...options, - mode: "local", - configLoader: createLocalOverlayConfigLoader(options), - } satisfies LocalNativeCapletsServiceOptions; - const local = (options.localServiceFactory ?? createDefaultNativeCapletsService)(localOptions); + const local = createLocalOverlayService(options); try { - const client = (options.remoteClientFactory ?? createSdkRemoteCapletsClient)(resolved.remote); - const remote = new RemoteNativeCapletsService({ - client, - clientFactory: () => - (options.remoteClientFactory ?? createSdkRemoteCapletsClient)(resolved.remote), - pollIntervalMs: resolved.remote.pollIntervalMs, - ...(options.writeErr ? { writeErr: options.writeErr } : {}), - }); - const presence = createProjectBindingSessionManager(resolved.remote.cloud, local, options); - return new CompositeNativeCapletsService(remote, local, options, presence); + return createCompositeRemoteService(resolved.remote, local, options, "self_hosted_remote"); } catch (error) { if (options.mode !== "remote") { warnRemoteProjectBindingFallback(options); @@ -100,6 +87,9 @@ export function createNativeCapletsService( throw error; } } + if (resolved.mode === "cloud") { + return new CloudNativeCapletsService(options, resolved.remote); + } return new DefaultNativeCapletsService(options); } @@ -160,6 +150,149 @@ function createDefaultNativeCapletsService( return new DefaultNativeCapletsService(options); } +type ResolvedNativeRemoteOptions = Extract< + Exclude, { mode: "local" }>, + { remote: unknown } +>["remote"]; + +function createLocalOverlayService(options: NativeCapletsServiceOptions): NativeCapletsService { + const localOptions = { + ...options, + mode: "local", + configLoader: createLocalOverlayConfigLoader(options), + } satisfies LocalNativeCapletsServiceOptions; + return (options.localServiceFactory ?? createDefaultNativeCapletsService)(localOptions); +} + +function createCompositeRemoteService( + remoteOptions: ResolvedNativeRemoteOptions, + local: NativeCapletsService, + options: NativeCapletsServiceOptions, + authKind: "self_hosted_remote" | "hosted_cloud", +): NativeCapletsService { + const client = (options.remoteClientFactory ?? createSdkRemoteCapletsClient)(remoteOptions); + const remote = new RemoteNativeCapletsService({ + client, + clientFactory: () => + (options.remoteClientFactory ?? createSdkRemoteCapletsClient)(remoteOptions), + pollIntervalMs: remoteOptions.pollIntervalMs, + authKind, + ...(options.writeErr ? { writeErr: options.writeErr } : {}), + }); + const presence = createProjectBindingSessionManager(remoteOptions.cloud, local, options); + return new CompositeNativeCapletsService(remote, local, options, presence); +} + +class CloudNativeCapletsService implements NativeCapletsService { + private readonly local: NativeCapletsService; + private readonly listeners = new Set(); + private delegate: NativeCapletsService | undefined; + private unsubscribeDelegate: (() => void) | undefined; + private closed = false; + + constructor( + private readonly options: NativeCapletsServiceOptions, + private readonly baseRemote: ResolvedNativeRemoteOptions, + ) { + this.local = createLocalOverlayService(options); + } + + listTools(): NativeCapletTool[] { + return this.delegate?.listTools() ?? this.local.listTools(); + } + + async execute(capletId: string, request: unknown): Promise { + return await (this.delegate ?? this.local).execute(capletId, request); + } + + async reload(): Promise { + if (this.closed) return false; + if (!this.delegate) { + try { + const cloudFetch = this.options.remote?.fetch ?? this.options.server?.fetch; + const remoteUrl = + this.options.server?.url ?? this.baseRemote.url.toString().replace(/\/mcp$/u, ""); + const selection = await resolveRemoteSelection( + { + mode: "cloud", + remoteUrl, + ...(cloudFetch ? { fetch: cloudFetch } : {}), + }, + { + ...process.env, + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: remoteUrl, + }, + ); + if (selection.kind !== "hosted_cloud") { + throw new CapletsError("REQUEST_INVALID", "CAPLETS_MODE=cloud requires Caplets Cloud."); + } + const cloudPresence = { + url: selection.cloudPresence.url, + accessToken: selection.cloudPresence.accessToken, + workspaceId: selection.cloudPresence.workspaceId, + ...(this.options.remote?.cloud?.projectRoot + ? { projectRoot: this.options.remote.cloud.projectRoot } + : {}), + heartbeatIntervalMs: + this.options.remote?.cloud?.heartbeatIntervalMs ?? + this.baseRemote.cloud?.heartbeatIntervalMs ?? + 30_000, + } satisfies ResolvedNativeCloudPresenceOptions; + const remoteOptions = { + ...this.baseRemote, + url: selection.remote.mcpUrl, + auth: nativeAuthFromRemoteAuth(selection.remote.auth), + requestInit: selection.remote.requestInit, + ...(selection.remote.fetch ? { fetch: selection.remote.fetch } : {}), + cloud: cloudPresence, + } satisfies ResolvedNativeRemoteOptions; + this.delegate = createCompositeRemoteService( + remoteOptions, + this.local, + this.options, + "hosted_cloud", + ); + this.unsubscribeDelegate = this.delegate.onToolsChanged((tools) => this.emit(tools)); + } catch (error) { + if (this.options.mode === "cloud") throw error; + warnRemoteProjectBindingFallback(this.options); + return await this.local.reload(); + } + } + return await this.delegate.reload(); + } + + onToolsChanged(listener: NativeCapletsToolsChangedListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.unsubscribeDelegate?.(); + this.listeners.clear(); + await (this.delegate ? this.delegate.close() : this.local.close()); + } + + private emit(tools: NativeCapletTool[]): void { + for (const listener of this.listeners) { + listener(tools); + } + } +} + +function nativeAuthFromRemoteAuth(auth: CapletsRemoteAuth): NativeRemoteAuthOptions { + if (auth.type === "basic") { + return { enabled: true, user: auth.user, password: auth.password }; + } + if (auth.type === "none") { + return { enabled: false, user: auth.user }; + } + return { enabled: false, user: "caplets" }; +} + class CompositeNativeCapletsService implements NativeCapletsService { private readonly listeners = new Set(); private readonly unsubscribers: Array<() => void>; @@ -275,10 +408,7 @@ class CompositeNativeCapletsService implements NativeCapletsService { } function createProjectBindingSessionManager( - cloud: Extract< - ReturnType, - { mode: "remote" } - >["remote"]["cloud"], + cloud: ResolvedNativeRemoteOptions["cloud"], local: NativeCapletsService, options: NativeCapletsServiceOptions, ): ProjectBindingSessionManager | undefined { diff --git a/packages/core/src/project-binding/attach.ts b/packages/core/src/project-binding/attach.ts index 5f391de..b15d3f8 100644 --- a/packages/core/src/project-binding/attach.ts +++ b/packages/core/src/project-binding/attach.ts @@ -1,9 +1,8 @@ import { existsSync } from "node:fs"; import { CapletsError } from "../errors"; -import { CloudAuthClient } from "../cloud-auth/client"; -import { CloudAuthStore } from "../cloud-auth/store"; -import { resolveCapletsRemote, type ResolvedCapletsRemote } from "../remote/options"; -import { projectBindingError, ProjectBindingError } from "./errors"; +import type { ResolvedCapletsRemote } from "../remote/options"; +import { resolveRemoteSelection } from "../remote/selection"; +import { ProjectBindingError } from "./errors"; import { bootstrapProjectBindingGitignore } from "./gitignore"; import { runProjectBindingSession, type ProjectBindingSessionEvent } from "./session"; import { buildProjectSyncManifest } from "./sync-filter"; @@ -33,26 +32,38 @@ export type ResolvedAttachOptions = { selectedWorkspace?: string | undefined; }; -export function resolveAttachOptions( +export async function resolveAttachOptions( raw: RawAttachOptions = {}, env: Record = process.env, -): ResolvedAttachOptions { +): Promise { + return await resolveAttachOptionsForRun(raw, env); +} + +export async function resolveAttachOptionsForRun( + raw: RawAttachOptions = {}, + env: Record = process.env, +): Promise { const remoteInput = { - ...(raw.remoteUrl !== undefined ? { url: raw.remoteUrl } : {}), + ...(raw.remoteUrl !== undefined ? { remoteUrl: raw.remoteUrl } : {}), ...(raw.user !== undefined ? { user: raw.user } : {}), ...(raw.password !== undefined ? { password: raw.password } : {}), ...(raw.token !== undefined ? { token: raw.token } : {}), ...(raw.workspace !== undefined ? { workspace: raw.workspace } : {}), ...(raw.fetch !== undefined ? { fetch: raw.fetch } : {}), }; + const selection = await resolveRemoteSelection(remoteInput, env); return { projectRoot: raw.projectRoot ?? process.cwd(), json: raw.json === true, verbose: raw.verbose === true, once: raw.once === true, - remote: resolveCapletsRemote(remoteInput, env), - authMode: "self_hosted_remote", - ...(remoteInput.workspace ? { selectedWorkspace: remoteInput.workspace } : {}), + remote: selection.remote, + authMode: selection.kind, + ...(selection.kind === "hosted_cloud" + ? { selectedWorkspace: selection.selectedWorkspace } + : raw.workspace + ? { selectedWorkspace: raw.workspace } + : {}), }; } @@ -106,62 +117,6 @@ export async function attachProjectSession( }); } -export async function resolveAttachOptionsForRun( - raw: RawAttachOptions = {}, - env: Record = process.env, -): Promise { - if (hasExplicitRemote(raw, env)) return resolveAttachOptions(raw, env); - - const store = new CloudAuthStore({ env }); - let credentials = await store.load(); - if (!credentials?.accessToken) throw projectBindingError("cloud_auth_required"); - if (credentialsNeedRefresh(credentials)) { - if (!credentials.refreshToken) throw projectBindingError("cloud_auth_required"); - const refreshed = await new CloudAuthClient({ - cloudUrl: credentials.cloudUrl, - ...(raw.fetch !== undefined ? { fetch: raw.fetch } : {}), - }).refresh({ refreshToken: credentials.refreshToken }); - credentials = { - ...credentials, - ...refreshed, - refreshToken: refreshed.refreshToken ?? credentials.refreshToken, - createdAt: credentials.createdAt, - lastRefreshAt: new Date().toISOString(), - }; - await store.save(credentials); - } - const selected = credentials.workspaceSlug ?? credentials.workspaceId; - if ( - raw.workspace && - raw.workspace !== credentials.workspaceId && - raw.workspace !== credentials.workspaceSlug - ) { - throw projectBindingError( - "workspace_switch_required", - `Requested workspace ${raw.workspace} differs from saved Selected Workspace ${selected}.`, - ); - } - - const remote = resolveCapletsRemote( - { - url: credentials.cloudUrl, - token: credentials.accessToken, - workspace: selected, - ...(raw.fetch !== undefined ? { fetch: raw.fetch } : {}), - }, - {}, - ); - return { - projectRoot: raw.projectRoot ?? process.cwd(), - json: raw.json === true, - verbose: raw.verbose === true, - once: raw.once === true, - remote, - authMode: "hosted_cloud", - selectedWorkspace: selected, - }; -} - function projectBindingProbeUrl(remote: ResolvedCapletsRemote): URL { const url = new URL(remote.projectBindingWebSocketUrl); if (url.protocol === "wss:") url.protocol = "https:"; @@ -177,22 +132,6 @@ async function isWebSocketUpgradeRequired(response: Response): Promise return body?.error === "websocket_upgrade_required"; } -function hasExplicitRemote( - raw: RawAttachOptions, - env: Record, -): boolean { - return Boolean( - raw.remoteUrl ?? - raw.user ?? - raw.password ?? - raw.token ?? - env.CAPLETS_REMOTE_URL ?? - env.CAPLETS_REMOTE_TOKEN ?? - env.CAPLETS_REMOTE_USER ?? - env.CAPLETS_REMOTE_PASSWORD, - ); -} - function preflightProjectSync(projectRoot: string, tier: ProjectSyncTier): void { if (!existsSync(projectRoot)) return; const manifest = buildProjectSyncManifest({ projectRoot }); @@ -210,9 +149,3 @@ function hostedTier(env: Record): ProjectSyncTier { const value = env.CAPLETS_CLOUD_TIER?.toLowerCase(); return value === "plus" || value === "pro" || value === "enterprise" ? value : "free"; } - -function credentialsNeedRefresh(credentials: { expiresAt: string }): boolean { - const expiresAt = Date.parse(credentials.expiresAt); - if (!Number.isFinite(expiresAt)) return false; - return expiresAt <= Date.now() + 60_000; -} diff --git a/packages/core/src/remote/options.ts b/packages/core/src/remote/options.ts index c179e1e..6eb8f1b 100644 --- a/packages/core/src/remote/options.ts +++ b/packages/core/src/remote/options.ts @@ -20,6 +20,8 @@ export type CapletsRemoteModeInput = { remoteUrl?: string; }; +export type CapletsRemoteMode = "local" | "remote" | "cloud"; + export type CapletsRemoteInput = { url?: string; user?: string; @@ -51,7 +53,7 @@ const DEFAULT_REMOTE_USER = "caplets"; export function resolveRemoteMode( input: CapletsRemoteModeInput = {}, env: CapletsRemoteEnv = process.env, -): { mode: "local" } | { mode: "remote" } { +): { mode: CapletsRemoteMode } { const mode = parseCapletsMode(input.mode ?? env.CAPLETS_MODE ?? "auto"); if (mode === "local") return { mode: "local" }; @@ -68,7 +70,21 @@ export function resolveRemoteMode( return { mode: "remote" }; } - return rawUrl === undefined ? { mode: "local" } : { mode: "remote" }; + if (mode === "cloud") { + if (rawUrl === undefined) { + throw new CapletsError("REQUEST_INVALID", "CAPLETS_MODE=cloud requires CAPLETS_REMOTE_URL."); + } + if (!isCapletsCloudUrl(rawUrl)) { + throw new CapletsError( + "REQUEST_INVALID", + "CAPLETS_MODE=cloud requires CAPLETS_REMOTE_URL to point at Caplets Cloud.", + ); + } + return { mode: "cloud" }; + } + + if (rawUrl === undefined) return { mode: "local" }; + return isCapletsCloudUrl(rawUrl) ? { mode: "cloud" } : { mode: "remote" }; } export function resolveCapletsRemote( @@ -142,11 +158,24 @@ export function projectBindingWebSocketUrlForBase(baseUrl: URL): URL { return url; } -function parseCapletsMode(value: string): "auto" | "local" | "remote" { - if (value === "auto" || value === "local" || value === "remote") return value; +export function isCapletsCloudUrl(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + const host = url.hostname.toLowerCase(); + return host === "cloud.caplets.dev" || host.endsWith(".preview.caplets.dev"); +} + +function parseCapletsMode(value: string): "auto" | CapletsRemoteMode { + if (value === "auto" || value === "local" || value === "remote" || value === "cloud") { + return value; + } throw new CapletsError( "REQUEST_INVALID", - `Expected CAPLETS_MODE to be auto, local, or remote, got ${value}`, + `Expected CAPLETS_MODE to be auto, local, remote, or cloud, got ${value}`, ); } diff --git a/packages/core/src/remote/selection.ts b/packages/core/src/remote/selection.ts new file mode 100644 index 0000000..835ce1e --- /dev/null +++ b/packages/core/src/remote/selection.ts @@ -0,0 +1,134 @@ +import { CloudAuthClient } from "../cloud-auth/client"; +import { CloudAuthStore, type CloudAuthCredentials } from "../cloud-auth/store"; +import { CapletsError } from "../errors"; +import { projectBindingError } from "../project-binding/errors"; +import { resolveCapletsRemote, resolveRemoteMode, type ResolvedCapletsRemote } from "./options"; + +export type RemoteSelectionInput = { + mode?: string; + remoteUrl?: string; + user?: string; + password?: string; + token?: string; + workspace?: string; + fetch?: typeof fetch; +}; + +export type ResolvedRemoteSelection = + | { + kind: "self_hosted_remote"; + remote: ResolvedCapletsRemote; + } + | { + kind: "hosted_cloud"; + remote: ResolvedCapletsRemote; + selectedWorkspace: string; + credentials: CloudAuthCredentials; + cloudPresence: { + url: URL; + accessToken: string; + workspaceId: string; + }; + }; + +export async function resolveRemoteSelection( + input: RemoteSelectionInput = {}, + env: Record = process.env, +): Promise { + const modeValue = input.mode ?? env.CAPLETS_MODE; + const mode = resolveRemoteMode( + { + ...(modeValue !== undefined ? { mode: modeValue } : {}), + ...(input.remoteUrl !== undefined ? { remoteUrl: input.remoteUrl } : {}), + }, + env, + ); + + if (mode.mode === "local") { + throw new CapletsError( + "REQUEST_INVALID", + "caplets attach requires a remote upstream; set CAPLETS_REMOTE_URL or use caplets serve for local-only MCP.", + ); + } + + if (mode.mode === "remote") { + return { + kind: "self_hosted_remote", + remote: resolveCapletsRemote( + { + ...(input.remoteUrl !== undefined ? { url: input.remoteUrl } : {}), + ...(input.user !== undefined ? { user: input.user } : {}), + ...(input.password !== undefined ? { password: input.password } : {}), + ...(input.token !== undefined ? { token: input.token } : {}), + ...(input.workspace !== undefined ? { workspace: input.workspace } : {}), + ...(input.fetch !== undefined ? { fetch: input.fetch } : {}), + }, + env, + ), + }; + } + + const store = new CloudAuthStore({ env }); + let credentials = await store.load(); + if (!credentials?.accessToken) { + throw projectBindingError("cloud_auth_required"); + } + + if (credentialsNeedRefresh(credentials)) { + if (!credentials.refreshToken) { + throw projectBindingError("cloud_auth_required"); + } + const refreshed = await new CloudAuthClient({ + cloudUrl: credentials.cloudUrl, + ...(input.fetch !== undefined ? { fetch: input.fetch } : {}), + }).refresh({ refreshToken: credentials.refreshToken }); + credentials = { + ...credentials, + ...refreshed, + refreshToken: refreshed.refreshToken ?? credentials.refreshToken, + createdAt: credentials.createdAt, + lastRefreshAt: new Date().toISOString(), + }; + await store.save(credentials); + } + + const selectedWorkspace = credentials.workspaceSlug ?? credentials.workspaceId; + if ( + input.workspace && + input.workspace !== credentials.workspaceId && + input.workspace !== credentials.workspaceSlug + ) { + throw projectBindingError( + "workspace_switch_required", + `Requested workspace ${input.workspace} differs from saved Selected Workspace ${selectedWorkspace}.`, + ); + } + + const remoteUrl = input.remoteUrl ?? env.CAPLETS_REMOTE_URL ?? credentials.cloudUrl; + const remote = resolveCapletsRemote( + { + url: remoteUrl, + token: credentials.accessToken, + workspace: selectedWorkspace, + ...(input.fetch !== undefined ? { fetch: input.fetch } : {}), + }, + {}, + ); + + return { + kind: "hosted_cloud", + remote, + selectedWorkspace, + credentials, + cloudPresence: { + url: new URL(remoteUrl), + accessToken: credentials.accessToken, + workspaceId: credentials.workspaceId, + }, + }; +} + +function credentialsNeedRefresh(credentials: { expiresAt: string }): boolean { + const expiresAt = Date.parse(credentials.expiresAt); + return Number.isFinite(expiresAt) && expiresAt <= Date.now() + 60_000; +} diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index 283f84e..c6b9326 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -20,10 +20,18 @@ type HttpServeIo = { writeErr?: (value: string) => void; control?: Omit; authFlowStore?: RemoteAuthFlowStore; + sessionFactory?: HttpMcpSessionFactory; }; +type HttpMcpSession = { + connect(transport: StreamableHTTPTransport): Promise; + close(): Promise; +}; + +export type HttpMcpSessionFactory = () => HttpMcpSession | Promise; + type HttpSession = { - server: CapletsMcpSession; + server: HttpMcpSession; transport: StreamableHTTPTransport; }; @@ -101,7 +109,7 @@ export function createHttpServeApp( const nextSessionId = randomUUID(); const session = await createHttpSession( - engine, + io.sessionFactory ?? (() => new CapletsMcpSession(engine)), nextSessionId, options, async (closedSessionId) => { @@ -280,6 +288,35 @@ export async function serveHttp( installHttpSignalHandlers(server, app, engine, writeErr); } +export async function serveHttpWithSessionFactory( + options: HttpServeOptions, + createSession: HttpMcpSessionFactory, + writeErr: (value: string) => void = (value) => process.stderr.write(value), +): Promise { + const engine = new CapletsEngine({}); + const app = createHttpServeApp(options, engine, { + writeErr, + sessionFactory: createSession, + control: { + projectCapletsRoot: resolveProjectCapletsRoot(), + }, + }); + const paths = servicePaths(options.path); + const origin = `http://${formatHost(options.host)}:${options.port}`; + const baseUrl = `${origin}${paths.base === "/" ? "" : paths.base}`; + const server = serve({ fetch: app.fetch, hostname: options.host, port: options.port }, () => { + writeErr(`Caplets HTTP service listening on ${baseUrl}\n`); + writeErr(`MCP endpoint: ${origin}${paths.mcp}\n`); + writeErr(`Control endpoint: ${origin}${paths.control}\n`); + writeErr(`Health check: ${origin}${paths.health}\n`); + writeErr( + `Basic Auth: ${options.auth.enabled ? `enabled (user: ${options.auth.user})` : "disabled"}\n`, + ); + }); + + installHttpSignalHandlers(server, app, engine, writeErr); +} + function projectCapletsRootForEngineOptions(engineOptions: CapletsEngineOptions): string { return engineOptions.projectConfigPath ? resolveProjectCapletsRootForConfigPath(engineOptions.projectConfigPath) @@ -309,7 +346,7 @@ export function servicePaths(base: string): { } async function createHttpSession( - engine: CapletsEngine, + createServer: HttpMcpSessionFactory, sessionId: string, options: HttpServeOptions, onClose: (sessionId: string) => Promise, @@ -319,7 +356,7 @@ async function createHttpSession( onsessionclosed: onClose, ...(options.loopback ? dnsRebindingOptions(options) : {}), }); - const server = new CapletsMcpSession(engine); + const server = await createServer(); await server.connect(transport); return { server, transport }; } diff --git a/packages/core/src/serve/index.ts b/packages/core/src/serve/index.ts index 7a04281..790e492 100644 --- a/packages/core/src/serve/index.ts +++ b/packages/core/src/serve/index.ts @@ -6,6 +6,8 @@ import { serveStdio } from "./stdio"; export { serveHttp } from "./http"; export { resolveDaemonServeOptions, resolveServeOptions } from "./options"; export type { HttpServeOptions, RawServeOptions, ServeOptions, StdioServeOptions } from "./options"; +export { NativeCapletsMcpSession } from "./native-session"; +export type { NativeCapletsMcpSessionOptions, NativeToolServer } from "./native-session"; export { serveStdio } from "./stdio"; export { buildDaemonPlatformDescriptor, diff --git a/packages/core/src/serve/native-session.ts b/packages/core/src/serve/native-session.ts new file mode 100644 index 0000000..4ac3763 --- /dev/null +++ b/packages/core/src/serve/native-session.ts @@ -0,0 +1,114 @@ +import { McpServer, type RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport"; +import { z } from "zod"; +import { version as packageJsonVersion } from "../../package.json"; +import type { NativeCapletTool, NativeCapletsService } from "../native/service"; + +export type NativeToolServer = Pick; + +export type NativeCapletsMcpSessionOptions = { + server?: NativeToolServer; +}; + +export class NativeCapletsMcpSession { + readonly server: NativeToolServer; + private readonly tools = new Map(); + private readonly unsubscribe: () => void; + private closed = false; + + constructor( + private readonly service: NativeCapletsService, + options: NativeCapletsMcpSessionOptions = {}, + ) { + this.server = + options.server ?? + new McpServer({ + name: "caplets", + version: packageJsonVersion, + }); + this.unsubscribe = service.onToolsChanged((tools) => this.reconcileTools(tools)); + this.reconcileTools(service.listTools()); + } + + async connect(transport: Transport): Promise { + await this.server.connect(transport); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.unsubscribe(); + this.tools.clear(); + await this.server.close(); + await this.service.close(); + } + + private reconcileTools(next: NativeCapletTool[]): void { + const enabled = new Map(next.map((tool) => [tool.caplet, tool])); + for (const [id, registered] of this.tools) { + const tool = enabled.get(id); + if (!tool) { + registered.remove(); + this.tools.delete(id); + continue; + } + registered.update(this.definition(tool)); + } + for (const tool of enabled.values()) { + if (!this.tools.has(tool.caplet)) { + this.tools.set( + tool.caplet, + this.server.registerTool( + tool.caplet, + this.definition(tool), + async (request: unknown) => (await this.service.execute(tool.caplet, request)) as never, + ), + ); + } + } + } + + private definition(tool: NativeCapletTool) { + return { + title: tool.title, + description: tool.description, + inputSchema: isRecord(tool.inputSchema) ? jsonSchemaToZodShape(tool.inputSchema) : undefined, + }; + } +} + +function jsonSchemaToZodShape(schema: Record): Record { + const properties = isRecord(schema.properties) ? schema.properties : {}; + const shape: Record = {}; + for (const [key, value] of Object.entries(properties)) { + shape[key] = jsonSchemaPropertyToZod(value); + } + return shape; +} + +function jsonSchemaPropertyToZod(value: unknown): z.ZodTypeAny { + if (!isRecord(value)) return z.unknown().optional(); + if (Array.isArray(value.enum) && value.enum.every((item) => typeof item === "string")) { + const values = value.enum as string[]; + if (values.length > 0) return z.enum(values as [string, ...string[]]).optional(); + } + switch (value.type) { + case "string": + return z.string().optional(); + case "number": + case "integer": + return z.number().optional(); + case "boolean": + return z.boolean().optional(); + case "array": + return z.array(z.unknown()).optional(); + case "object": + return z.record(z.string(), z.unknown()).optional(); + default: + return z.unknown().optional(); + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/core/test/agent-plugins.test.ts b/packages/core/test/agent-plugins.test.ts index ac252c1..44adce0 100644 --- a/packages/core/test/agent-plugins.test.ts +++ b/packages/core/test/agent-plugins.test.ts @@ -1,4 +1,4 @@ -import { existsSync, lstatSync } from "node:fs"; +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -6,162 +6,39 @@ import { describe, expect, it } from "vitest"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); -async function readJson(filePath: string): Promise { - return JSON.parse(await readFile(filePath, "utf8")) as T; -} - -describe("root agent plugin artifacts", () => { - it("declares agent plugin components using agent-specific files", async () => { - for (const pluginManifest of [ - "plugins/caplets/.codex-plugin/plugin.json", - "plugins/caplets/.claude-plugin/plugin.json", +describe("Codex and Claude manual MCP setup", () => { + it("does not ship native Codex or Claude plugin artifacts", () => { + for (const removedPath of [ + "plugins/caplets", + ".agents/plugins/marketplace.json", + ".claude-plugin/marketplace.json", + "scripts/sync-plugin-versions.ts", ]) { - const manifest = await readJson>(path.join(repoRoot, pluginManifest)); - - expect(manifest.skills).toBe("./skills/"); - expect(manifest.mcpServers).toBe("./mcp.json"); - expect(manifest.hooks).toBeUndefined(); + expect(existsSync(path.join(repoRoot, removedPath)), removedPath).toBe(false); } }); - it("keeps plugin manifest versions aligned with the CLI package", async () => { - const [rootPackage, cliPackage, codexManifest, claudeManifest] = await Promise.all([ - readJson<{ scripts: Record }>(path.join(repoRoot, "package.json")), - readJson<{ version: string }>(path.join(repoRoot, "packages/cli/package.json")), - readJson<{ version: string }>( - path.join(repoRoot, "plugins/caplets/.codex-plugin/plugin.json"), - ), - readJson<{ version: string }>( - path.join(repoRoot, "plugins/caplets/.claude-plugin/plugin.json"), - ), - ]); - - expect(codexManifest.version).toBe(cliPackage.version); - expect(claudeManifest.version).toBe(cliPackage.version); - expect(rootPackage.scripts["version-packages"]).toContain("scripts/sync-plugin-versions.ts"); - expect(rootPackage.scripts["version-packages"]).toContain("oxfmt"); - }); - - it("declares a Claude Code marketplace entry using Claude's schema", async () => { - const marketplace = await readJson<{ - name: string; - owner: { name: string }; - plugins: Array<{ - name: string; - source: string; - }>; - }>(path.join(repoRoot, ".claude-plugin/marketplace.json")); - - expect(marketplace.name).toBe("caplets"); - expect(marketplace.owner.name).toBe("Spirit-Led Software LLC"); - expect(marketplace.plugins).toEqual([ - expect.objectContaining({ - name: "caplets", - source: "./plugins/caplets", - }), - ]); - }); - - it("declares a Codex marketplace entry using Codex's schema", async () => { - const marketplace = await readJson<{ - name: string; - plugins: Array<{ - name: string; - source: { source: string; path: string }; - }>; - }>(path.join(repoRoot, ".agents/plugins/marketplace.json")); - - expect(marketplace.name).toBe("caplets"); - expect(marketplace.plugins).toEqual([ - expect.objectContaining({ - name: "caplets", - source: { source: "local", path: "./plugins/caplets" }, - }), - ]); - }); - - it("runs the globally installed Caplets CLI in both MCP configs", async () => { - const mcp = await readJson<{ mcpServers: { caplets: { command: string; args: string[] } } }>( - path.join(repoRoot, "plugins/caplets/mcp.json"), - ); - - expect(mcp.mcpServers.caplets).toEqual({ - command: "caplets", - args: ["serve"], - }); - expect(JSON.stringify(mcp)).not.toContain("caplets@"); - }); - - it("documents remote Caplets service configuration for MCP-backed plugins", async () => { + it("documents manual MCP config for Codex and Claude users", async () => { const readme = await readFile(path.join(repoRoot, "README.md"), "utf8"); - - expect(readme).toContain("CAPLETS_SERVER_URL"); - expect(readme).toContain("caplets serve --transport http"); - expect(readme).toContain("https://caplets.example.com/caplets"); - expect(readme).toContain("/control"); - expect(readme).toMatch(/HTTPS\/TLS|TLS|HTTPS/); - }); - - it("uses a strong shared plugin skill for automatic selection", async () => { - const skill = await readFile( - path.join(repoRoot, "plugins/caplets/skills/caplets/SKILL.md"), - "utf8", - ); - - expect(skill).toContain("name: caplets"); - expect(skill).toContain("when_to_use:"); - expect(skill).toContain("external tools"); - expect(skill).toContain("MCP servers"); - expect(skill).toContain("OpenAPI"); - expect(skill).toContain("GraphQL"); - expect(skill).toContain("HTTP endpoints"); - expect(skill).toContain("call_tool"); - expect(skill).toContain("Skip this skill for normal local code edits"); + expect(readme).toContain('"command": "caplets"'); + expect(readme).toContain('"args": ["serve"]'); + expect(readme).toContain('"args": ["attach"]'); + expect(readme).not.toMatch(/plugin marketplace add|plugin install caplets@caplets/u); }); - it("keeps the Codex marketplace source self-contained for plugin installs", () => { - const pluginRoot = path.join(repoRoot, "plugins/caplets"); - const skillDir = path.join(pluginRoot, "skills"); - - expect(lstatSync(pluginRoot).isDirectory()).toBe(true); - expect(lstatSync(skillDir).isDirectory()).toBe(true); - expect(lstatSync(skillDir).isSymbolicLink()).toBe(false); - expect(existsSync(path.join(pluginRoot, ".codex-plugin/plugin.json"))).toBe(true); - expect(existsSync(path.join(pluginRoot, "skills/caplets/SKILL.md"))).toBe(true); - expect(existsSync(path.join(pluginRoot, "mcp.json"))).toBe(true); - }); - - it("keeps plugin metadata and components in documented locations", () => { - for (const forbiddenPath of [ - "packages/codex", - "packages/claude-code", - "packages/agent-plugin-shared", - ".codex-plugin", - ".codex-plugins", - ".mcp.json", - "hooks", - ".claude-plugin/plugin.json", - ".claude-plugin/.mcp.json", - ".codex-plugin/hooks.json", - ".claude-plugin/hooks.json", - ".codex-plugin/hooks", - ".claude-plugin/hooks", - ".claude-plugin/skills", - ]) { - expect(existsSync(path.join(repoRoot, forbiddenPath)), forbiddenPath).toBe(false); - } - - for (const requiredPath of [ - "plugins/caplets/.codex-plugin/plugin.json", - "plugins/caplets/.claude-plugin/plugin.json", - "plugins/caplets/mcp.json", - "plugins/caplets/skills/caplets/SKILL.md", - "plugins/caplets/assets/icon.png", - ".claude-plugin/marketplace.json", - ".agents/plugins/marketplace.json", - "scripts/sync-plugin-versions.ts", - ]) { - expect(existsSync(path.join(repoRoot, requiredPath)), requiredPath).toBe(true); - } + it("documents serve for local MCP and attach for remote MCP", async () => { + const readme = await readFile(path.join(repoRoot, "README.md"), "utf8"); + expect(readme).toContain("caplets serve"); + expect(readme).toContain("caplets attach"); + expect(readme).toContain("CAPLETS_MODE=cloud"); + expect(readme).toContain("CAPLETS_REMOTE_URL=https://cloud.caplets.dev"); + expect(readme).toContain("CAPLETS_MODE=remote"); + }); + + it("does not keep version-package plugin sync wiring", async () => { + const packageJson = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8")) as { + scripts: Record; + }; + expect(packageJson.scripts["version-packages"] ?? "").not.toContain("sync-plugin-versions"); }); }); diff --git a/packages/core/test/attach-cli.test.ts b/packages/core/test/attach-cli.test.ts index 410df54..291562f 100644 --- a/packages/core/test/attach-cli.test.ts +++ b/packages/core/test/attach-cli.test.ts @@ -5,7 +5,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { attachProjectOnce, resolveAttachOptions } from "../src/project-binding/attach"; import { runCli } from "../src/cli"; import { CloudAuthStore } from "../src/cloud-auth/store"; -import type { ProjectBindingWebSocket } from "../src/project-binding/transport"; import { hostedCredentials, tempCloudAuthPath } from "./fixtures/cloud-auth"; const tempDirs: string[] = []; @@ -22,14 +21,43 @@ describe("caplets attach CLI", () => { await runCli(["attach", "--help"], { writeOut: (value) => out.push(value) }); - expect(out.join("")).toContain("Attach the current project to a remote Caplets runtime."); + expect(out.join("")).toContain("Start a remote-backed Caplets MCP server."); + expect(out.join("")).toContain("--transport "); expect(out.join("")).toContain("--remote-url "); expect(out.join("")).toContain("--workspace "); expect(out.join("")).toContain("--once"); }); - it("resolves attach options from flags, env, and the caller cwd", () => { - const resolved = resolveAttachOptions( + it("runs attach as a stdio MCP server by default", async () => { + const served: unknown[] = []; + await runCli(["attach"], { + env: { + CAPLETS_MODE: "remote", + CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets", + }, + attachServe: async (options: unknown) => { + served.push(options); + }, + } as never); + + expect(served).toHaveLength(1); + expect(served[0]).toMatchObject({ + transport: "stdio", + selection: { kind: "self_hosted_remote" }, + }); + }); + + it("rejects attach server in local mode", async () => { + await expect( + runCli(["attach"], { + env: { CAPLETS_MODE: "local" }, + attachServe: async () => undefined, + } as never), + ).rejects.toThrow(/use caplets serve for local-only MCP/u); + }); + + it("resolves attach options from flags, env, and the caller cwd", async () => { + const resolved = await resolveAttachOptions( { remoteUrl: "https://caplets.example.com/caplets", token: "token", @@ -104,6 +132,15 @@ describe("caplets attach CLI", () => { ); }); + it("keeps attach --once as the finite Project Binding smoke path", async () => { + const out: string[] = []; + await runCli(["attach", "--once", "--remote-url", "https://caplets.example.com/caplets"], { + fetch: async () => Response.json({ error: "websocket_upgrade_required" }, { status: 426 }), + writeOut: (value) => out.push(value), + }); + expect(out.join("")).toContain("Project Binding available at"); + }); + it("prints structured JSON for CLI WebSocket failures", async () => { const out: string[] = []; let exitCode = 0; @@ -134,6 +171,29 @@ describe("caplets attach CLI", () => { }); }); + it("prints JSON error for attach --once when cloud auth is missing", async () => { + const out: string[] = []; + let exitCode = 0; + await runCli(["attach", "--once", "--json"], { + env: { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + }, + writeOut: (value) => out.push(value), + setExitCode: (code) => { + exitCode = code; + }, + }); + + expect(exitCode).toBe(1); + expect(JSON.parse(out.join(""))).toMatchObject({ + error: { + code: "cloud_auth_required", + recoveryCommand: "caplets cloud auth login", + }, + }); + }); + it("rejects attach --workspace when it differs from the saved Selected Workspace", async () => { const path = tempCloudAuthPath(); const out: string[] = []; @@ -141,7 +201,11 @@ describe("caplets attach CLI", () => { await new CloudAuthStore({ path }).save(hostedCredentials({ workspaceSlug: "personal" })); await runCli(["attach", "--workspace", "team", "--once", "--json", "--project-root", "/repo"], { - env: { CAPLETS_CLOUD_AUTH_PATH: path }, + env: { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + CAPLETS_CLOUD_AUTH_PATH: path, + }, writeOut: (value) => out.push(value), setExitCode: (code) => { exitCode = code; @@ -163,41 +227,17 @@ describe("caplets attach CLI", () => { await new CloudAuthStore({ path }).save(hostedCredentials()); await runCli(["attach", "--once", "--json", "--project-root", "/repo"], { - env: { CAPLETS_CLOUD_AUTH_PATH: path }, + env: { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + CAPLETS_CLOUD_AUTH_PATH: path, + }, fetch: async () => Response.json({ error: "websocket_upgrade_required" }, { status: 426 }), writeOut: (value) => out.push(value), }); expect(out.join("")).not.toMatch(/approve|approval|confirm/i); }); - - it("runs long-running attach through a Binding Session and ends cleanly on abort", async () => { - const path = tempCloudAuthPath(); - const out: string[] = []; - const controller = new AbortController(); - await new CloudAuthStore({ path }).save(hostedCredentials()); - const session = fakeProjectBindingSession({ onReady: () => controller.abort() }); - - await runCli(["attach", "--json", "--project-root", "/repo"], { - env: { CAPLETS_CLOUD_AUTH_PATH: path }, - fetch: session.fetch, - writeOut: (value) => out.push(value), - signal: controller.signal, - projectBindingWebSocketFactory: session.webSocketFactory, - }); - - const events = out.map((line) => JSON.parse(line)); - expect(events).toContainEqual(expect.objectContaining({ type: "state", state: "attaching" })); - expect(events).toContainEqual( - expect.objectContaining({ - type: "ready", - bindingId: "binding_1", - sessionId: "binding_session_1", - }), - ); - expect(events.at(-1)).toMatchObject({ type: "ended" }); - expect(JSON.stringify(events)).not.toContain("cap_access_secret"); - }); }); function tempProjectRoot(): string { @@ -205,69 +245,3 @@ function tempProjectRoot(): string { tempDirs.push(root); return root; } - -function fakeProjectBindingSession(options: { onReady?: () => void } = {}) { - return { - fetch: async (url: Parameters[0], _init?: RequestInit) => { - if (String(url).endsWith("/control/project-bindings/sessions")) { - return Response.json( - { - binding: { bindingId: "binding_1", state: "attaching", syncState: "pending" }, - sessionId: "binding_session_1", - }, - { status: 201 }, - ); - } - return Response.json({ ok: true, binding: { bindingId: "binding_1" } }); - }, - webSocketFactory: () => - new FakeProjectBindingSocket( - [ - { - type: "ready", - bindingId: "binding_1", - sessionId: "binding_session_1", - syncState: "idle", - }, - ], - options, - ), - }; -} - -class FakeProjectBindingSocket implements ProjectBindingWebSocket { - readonly readyState = 1; - private readonly listeners = new Map void)[]>(); - - constructor( - private readonly messages: unknown[], - private readonly options: { onReady?: () => void }, - ) { - setTimeout(() => { - for (const message of this.messages) { - this.dispatch("message", { data: JSON.stringify(message) }); - if (isReadyMessage(message)) this.options.onReady?.(); - } - }, 0); - } - - send(): void {} - close(): void {} - - addEventListener( - type: "open" | "message" | "close" | "error", - listener: (event: { data?: unknown }) => void, - ): void { - this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); - } - - private dispatch(type: string, event: { data?: unknown }): void { - for (const listener of this.listeners.get(type) ?? []) listener(event); - } -} - -function isReadyMessage(message: unknown): boolean { - return ( - typeof message === "object" && message !== null && "type" in message && message.type === "ready" - ); -} diff --git a/packages/core/test/attach-server.test.ts b/packages/core/test/attach-server.test.ts new file mode 100644 index 0000000..70aa997 --- /dev/null +++ b/packages/core/test/attach-server.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import { NativeCapletsMcpSession } from "../src/serve/native-session"; + +describe("NativeCapletsMcpSession", () => { + it("registers tools from a native Caplets service", async () => { + const registered = new Map(); + const server = { + registerTool: vi.fn((name: string, definition: unknown, callback: unknown) => { + registered.set(name, { definition, callback }); + return { remove: vi.fn(), update: vi.fn() }; + }), + connect: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + }; + const service = { + listTools: () => [ + { + caplet: "remote-alpha", + toolName: "caplets_remote_alpha", + title: "Remote Alpha", + description: "Remote alpha tool", + promptGuidance: [], + inputSchema: { + type: "object", + properties: { operation: { type: "string", enum: ["inspect"] } }, + }, + operationNames: ["inspect"], + }, + ], + execute: vi.fn(async () => ({ ok: true })), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => undefined), + close: vi.fn(async () => undefined), + }; + + const session = new NativeCapletsMcpSession(service, { server: server as never }); + + expect([...registered.keys()]).toEqual(["remote-alpha"]); + const tool = registered.get("remote-alpha") as { + callback: (request: unknown) => Promise; + }; + await expect(tool.callback({ operation: "inspect" })).resolves.toEqual({ ok: true }); + expect(service.execute).toHaveBeenCalledWith("remote-alpha", { operation: "inspect" }); + await session.close(); + expect(service.close).toHaveBeenCalledOnce(); + }); + + it("updates registered tools when the native service changes", () => { + let listener: ((tools: unknown[]) => void) | undefined; + const removed = vi.fn(); + const server = { + registerTool: vi.fn((_name: string, _definition: unknown, _callback: unknown) => ({ + remove: removed, + update: vi.fn(), + })), + connect: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + }; + const service = { + listTools: () => [ + { caplet: "alpha", title: "Alpha", description: "Alpha", promptGuidance: [] }, + ], + execute: vi.fn(async () => ({})), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn((nextListener: (tools: unknown[]) => void) => { + listener = nextListener; + return () => undefined; + }), + close: vi.fn(async () => undefined), + }; + + new NativeCapletsMcpSession(service as never, { server: server as never }); + listener?.([{ caplet: "beta", title: "Beta", description: "Beta", promptGuidance: [] }]); + + expect(removed).toHaveBeenCalledOnce(); + expect(server.registerTool).toHaveBeenCalledWith( + "beta", + expect.objectContaining({ title: "Beta" }), + expect.any(Function), + ); + }); +}); diff --git a/packages/core/test/cloud-auth-refresh-attach.test.ts b/packages/core/test/cloud-auth-refresh-attach.test.ts index d378326..a32f411 100644 --- a/packages/core/test/cloud-auth-refresh-attach.test.ts +++ b/packages/core/test/cloud-auth-refresh-attach.test.ts @@ -40,7 +40,11 @@ describe("hosted Cloud Auth refresh before attach", () => { return Response.json({ error: "websocket_upgrade_required" }, { status: 426 }); }, }, - { CAPLETS_CLOUD_AUTH_PATH: path }, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + CAPLETS_CLOUD_AUTH_PATH: path, + }, ), ).resolves.toMatchObject({ ok: true }); @@ -71,10 +75,23 @@ describe("hosted Cloud Auth refresh before attach", () => { { status: 401 }, ), }, - { CAPLETS_CLOUD_AUTH_PATH: path }, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + CAPLETS_CLOUD_AUTH_PATH: path, + }, ), ).rejects.toMatchObject({ code: "AUTH_FAILED" }); }); + + it("does not implicitly use saved Cloud Auth without cloud mode or a Cloud remote URL", async () => { + const path = tempCloudAuthPath(); + await new CloudAuthStore({ path }).save(hostedCredentials()); + + await expect( + attachProjectOnce({ projectRoot: "/repo" }, { CAPLETS_CLOUD_AUTH_PATH: path }), + ).rejects.toThrow(/CAPLETS_REMOTE_URL/u); + }); }); function headerValue(headers: RequestInit["headers"] | undefined, name: string): string { diff --git a/packages/core/test/native-options.test.ts b/packages/core/test/native-options.test.ts index c655c5d..01a9b1c 100644 --- a/packages/core/test/native-options.test.ts +++ b/packages/core/test/native-options.test.ts @@ -24,6 +24,46 @@ describe("resolveNativeCapletsServiceOptions", () => { }); }); + it("uses cloud mode in auto when CAPLETS_REMOTE_URL points at Caplets Cloud", () => { + expect( + resolveNativeCapletsServiceOptions( + {}, + { + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + }, + ), + ).toMatchObject({ + mode: "cloud", + remote: { + url: new URL("https://cloud.caplets.dev/mcp"), + }, + }); + }); + + it("uses cloud mode when CAPLETS_MODE=cloud is explicit", () => { + expect( + resolveNativeCapletsServiceOptions( + {}, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + }, + ), + ).toMatchObject({ mode: "cloud" }); + }); + + it("rejects CAPLETS_MODE=cloud with a self-hosted remote URL", () => { + expect(() => + resolveNativeCapletsServiceOptions( + {}, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets", + }, + ), + ).toThrow(/Caplets Cloud/u); + }); + it("lets explicit local mode ignore server env vars", () => { expect( resolveNativeCapletsServiceOptions( diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index e06ad7c..8f3f610 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -4,11 +4,13 @@ import { dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { CapletsError } from "../src/errors"; +import { CloudAuthStore } from "../src/cloud-auth/store"; import { RemoteNativeCapletsService, type RemoteCapletsClient } from "../src/native/remote"; import { createNativeCapletsService, resetNativeProjectBindingFallbackWarningForTests, } from "../src/native/service"; +import { hostedCredentials, tempCloudAuthPath } from "./fixtures/cloud-auth"; function client( tools: Array<{ name: string; title?: string | undefined; description?: string | undefined }> = [ @@ -243,7 +245,7 @@ describe("RemoteNativeCapletsService", () => { await expect(service.execute("alpha", {})).rejects.toMatchObject({ code: "AUTH_FAILED", - message: expect.stringContaining("CAPLETS_REMOTE_USER"), + message: expect.stringContaining("CAPLETS_REMOTE_TOKEN"), } satisfies Partial); await service.close(); @@ -283,6 +285,7 @@ describe("createNativeCapletsService remote mode", () => { afterEach(() => { resetNativeProjectBindingFallbackWarningForTests(); + vi.unstubAllEnvs(); for (const dir of dirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } @@ -699,6 +702,37 @@ describe("createNativeCapletsService remote mode", () => { await service.close(); }); + it("starts Cloud Project Binding when native service runs in cloud mode", async () => { + const path = tempCloudAuthPath(); + vi.stubEnv("CAPLETS_CLOUD_AUTH_PATH", path); + await new CloudAuthStore({ path }).save(hostedCredentials({ accessToken: "cloud-access" })); + const factory = vi.fn(() => client([{ name: "remote", description: "Remote" }]).api); + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + local: { name: "Local", description: "Local Caplet.", command: process.execPath }, + }, + }); + dirs.push(dir); + + const service = createNativeCapletsService({ + mode: "cloud", + server: { url: "https://cloud.caplets.dev" }, + remoteClientFactory: factory, + configPath, + projectConfigPath, + }); + + await service.reload(); + expect(service.listTools().map((tool) => tool.caplet)).toContain("remote"); + expect(factory).toHaveBeenCalledWith( + expect.objectContaining({ + url: new URL("https://cloud.caplets.dev/mcp"), + requestInit: { headers: { Authorization: "Bearer cloud-access" } }, + }), + ); + await service.close(); + }); + it("picks up valid local overlay additions when existing warnings are unchanged", async () => { const fixture = client([{ name: "remote", title: "Remote" }]); const writeErr = vi.fn(); diff --git a/packages/core/test/remote-options.test.ts b/packages/core/test/remote-options.test.ts index a21d82b..c557aba 100644 --- a/packages/core/test/remote-options.test.ts +++ b/packages/core/test/remote-options.test.ts @@ -25,6 +25,54 @@ describe("resolveRemoteMode", () => { expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError, ); }); + + it("supports explicit cloud mode with a Caplets Cloud URL", () => { + expect( + resolveRemoteMode( + {}, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + }, + ), + ).toEqual({ mode: "cloud" }); + }); + + it("detects cloud mode in auto from CAPLETS_REMOTE_URL", () => { + expect(resolveRemoteMode({}, { CAPLETS_REMOTE_URL: "https://cloud.caplets.dev" })).toEqual({ + mode: "cloud", + }); + }); + + it("keeps non-Cloud CAPLETS_REMOTE_URL in self-hosted remote mode", () => { + expect( + resolveRemoteMode({}, { CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets" }), + ).toEqual({ mode: "remote" }); + }); + + it("rejects explicit cloud mode with a non-Cloud URL", () => { + expect(() => + resolveRemoteMode( + {}, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets", + }, + ), + ).toThrow(/CAPLETS_MODE=cloud requires CAPLETS_REMOTE_URL to point at Caplets Cloud/u); + }); + + it("rejects explicit cloud mode without CAPLETS_REMOTE_URL", () => { + expect(() => resolveRemoteMode({}, { CAPLETS_MODE: "cloud" })).toThrow( + /CAPLETS_MODE=cloud requires CAPLETS_REMOTE_URL/u, + ); + }); + + it("parses cloud as a valid CAPLETS_MODE value", () => { + expect(() => resolveRemoteMode({}, { CAPLETS_MODE: "sidecar" })).toThrow( + /Expected CAPLETS_MODE to be auto, local, remote, or cloud/u, + ); + }); }); describe("resolveCapletsRemote", () => { @@ -67,4 +115,13 @@ describe("resolveCapletsRemote", () => { "Bearer input-token", ); }); + + it("references CAPLETS_REMOTE_TOKEN or Basic Auth vars for self-hosted auth failures", () => { + expect(() => + resolveCapletsRemote( + { user: "caplets" }, + { CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets" }, + ), + ).toThrow(/CAPLETS_REMOTE_PASSWORD/u); + }); }); diff --git a/packages/core/test/remote-selection.test.ts b/packages/core/test/remote-selection.test.ts new file mode 100644 index 0000000..d54e0cb --- /dev/null +++ b/packages/core/test/remote-selection.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { CloudAuthStore } from "../src/cloud-auth/store"; +import { resolveRemoteSelection } from "../src/remote/selection"; +import { hostedCredentials, tempCloudAuthPath } from "./fixtures/cloud-auth"; + +describe("resolveRemoteSelection", () => { + it("rejects attach selection in local mode", async () => { + await expect(resolveRemoteSelection({}, { CAPLETS_MODE: "local" })).rejects.toThrow( + /caplets attach requires a remote upstream; set CAPLETS_REMOTE_URL or use caplets serve/u, + ); + }); + + it("rejects auto mode without a remote URL for attach", async () => { + await expect(resolveRemoteSelection({}, {})).rejects.toThrow(/CAPLETS_REMOTE_URL/u); + }); + + it("resolves self-hosted remote auth from CAPLETS_REMOTE variables", async () => { + await expect( + resolveRemoteSelection( + {}, + { + CAPLETS_MODE: "remote", + CAPLETS_REMOTE_URL: "https://caplets.example.com/caplets", + CAPLETS_REMOTE_TOKEN: "remote-token", + }, + ), + ).resolves.toMatchObject({ + kind: "self_hosted_remote", + remote: { + baseUrl: new URL("https://caplets.example.com/caplets"), + auth: { type: "bearer", token: "remote-token" }, + }, + }); + }); + + it("uses saved Cloud Auth in cloud mode and ignores self-hosted token vars", async () => { + const path = tempCloudAuthPath(); + await new CloudAuthStore({ path }).save(hostedCredentials({ accessToken: "cloud-access" })); + + const resolved = await resolveRemoteSelection( + {}, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + CAPLETS_REMOTE_TOKEN: "self-hosted-token", + CAPLETS_CLOUD_AUTH_PATH: path, + }, + ); + + expect(resolved).toMatchObject({ + kind: "hosted_cloud", + selectedWorkspace: "personal", + remote: { + baseUrl: new URL("https://cloud.caplets.dev"), + auth: { type: "bearer", token: "cloud-access" }, + }, + }); + }); + + it("refreshes expired Cloud credentials before returning the upstream", async () => { + const path = tempCloudAuthPath(); + await new CloudAuthStore({ path }).save( + hostedCredentials({ + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: "2026-06-03T00:00:00.000Z", + }), + ); + + const resolved = await resolveRemoteSelection( + { + fetch: async (url, init) => { + expect(String(url)).toBe("https://cloud.caplets.dev/api/cloud-client/refresh"); + expect(JSON.parse(String(init?.body))).toEqual({ refreshToken: "old-refresh" }); + return Response.json({ + status: "authenticated", + cloudUrl: "https://cloud.caplets.dev", + workspaceId: "workspace_personal", + workspaceSlug: "personal", + accessToken: "new-access", + refreshToken: "new-refresh", + expiresAt: "2999-01-01T00:00:00.000Z", + scope: ["project_binding:read", "project_binding:write"], + tokenType: "Bearer", + credentialFamilyId: "family_123", + }); + }, + }, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + CAPLETS_CLOUD_AUTH_PATH: path, + }, + ); + + expect(resolved.remote.auth).toEqual({ type: "bearer", token: "new-access" }); + }); + + it("requires Cloud Auth when cloud mode is selected", async () => { + await expect( + resolveRemoteSelection( + {}, + { + CAPLETS_MODE: "cloud", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev", + }, + ), + ).rejects.toMatchObject({ + projectBindingCode: "cloud_auth_required", + }); + }); +}); diff --git a/packages/opencode/README.md b/packages/opencode/README.md index af04172..5a37e1f 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -18,21 +18,22 @@ rebuilt from current Caplets state for the tools registered when the plugin load current plugin API snapshots `Hooks.tool` at plugin load, so adding, removing, or renaming native tools still requires restarting OpenCode; newly added tools are not advertised until restart. -## Remote Caplets service +## Remote Selection -By default the plugin reads local Caplets config. To use a remote `caplets serve --transport http` service, set environment variables: +By default the plugin reads local Caplets config. Use `CAPLETS_MODE` and `CAPLETS_REMOTE_*` to select local, self-hosted remote, or Caplets Cloud behavior: ```sh -CAPLETS_MODE=remote CAPLETS_SERVER_URL=http://127.0.0.1:5387/caplets opencode +CAPLETS_MODE=local opencode +CAPLETS_MODE=remote CAPLETS_REMOTE_URL=https://caplets.example.com/caplets opencode +CAPLETS_MODE=cloud CAPLETS_REMOTE_URL=https://cloud.caplets.dev opencode ``` -For authenticated remote services, keep the password in the environment: +Run `caplets cloud auth login` before Cloud mode. For authenticated self-hosted remotes, keep credentials in the environment: ```sh CAPLETS_MODE=remote \ -CAPLETS_SERVER_URL=https://caplets.example.com/caplets \ -CAPLETS_SERVER_USER=caplets \ -CAPLETS_SERVER_PASSWORD=... \ +CAPLETS_REMOTE_URL=https://caplets.example.com/caplets \ +CAPLETS_REMOTE_TOKEN=... \ opencode ``` @@ -58,4 +59,4 @@ export default { }; ``` -Plugin config overrides environment variables. The explicit config shape is `{ mode, server: { url, user }, remote: { pollIntervalMs } }`. Prefer `CAPLETS_SERVER_PASSWORD` for the Basic Auth password unless your OpenCode setup provides secure secret storage. +Plugin config overrides environment variables. The explicit config shape is `{ mode, server: { url, user }, remote: { pollIntervalMs } }`. Prefer `CAPLETS_REMOTE_TOKEN` or `CAPLETS_REMOTE_PASSWORD` for self-hosted credentials unless your OpenCode setup provides secure secret storage. diff --git a/packages/opencode/test/opencode.test.ts b/packages/opencode/test/opencode.test.ts index 574f727..660d8be 100644 --- a/packages/opencode/test/opencode.test.ts +++ b/packages/opencode/test/opencode.test.ts @@ -214,6 +214,32 @@ describe("@caplets/opencode", () => { }); }); + it("passes cloud mode config into the native service", async () => { + vi.resetModules(); + const nativeMocks = { + createNativeCapletsService: vi.fn(() => ({ + listTools: () => [], + execute: vi.fn(async () => ({})), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => {}), + close: vi.fn(async () => {}), + })), + registerNativeCapletsProcessCleanup: vi.fn(), + }; + vi.doMock("@caplets/core/native", () => nativeMocks); + const plugin = (await import("../src/index")).default; + + await plugin( + {} as never, + { mode: "cloud", server: { url: "https://cloud.caplets.dev" } } as never, + ); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({ + mode: "cloud", + server: { url: "https://cloud.caplets.dev" }, + }); + }); + it("awaits initial native service reload before creating hooks", async () => { vi.resetModules(); const tools = [ diff --git a/packages/pi/README.md b/packages/pi/README.md index 9030f54..30dc9da 100644 --- a/packages/pi/README.md +++ b/packages/pi/README.md @@ -35,19 +35,18 @@ running without `getActiveTools()` / `setActiveTools()`, stale tools may remain Pi reloads extensions or restarts, but calls to removed Caplets return Caplets' normal structured "server not found" error. -## Remote Caplets service +## Remote Selection -By default the extension uses the local Caplets native service. To connect Pi to a remote -`caplets serve --transport http` service, prefer environment variables for connection details, -especially the password: +By default the extension uses the local Caplets native service. Use `CAPLETS_MODE` and `CAPLETS_REMOTE_*` to select local, self-hosted remote, or Caplets Cloud behavior: ```sh -export CAPLETS_MODE="remote" -export CAPLETS_SERVER_URL="https://caplets.example.com/caplets" -export CAPLETS_SERVER_USER="caplets" -export CAPLETS_SERVER_PASSWORD="..." # or load from your shell/secret manager +CAPLETS_MODE=local pi +CAPLETS_MODE=remote CAPLETS_REMOTE_URL=https://caplets.example.com/caplets pi +CAPLETS_MODE=cloud CAPLETS_REMOTE_URL=https://cloud.caplets.dev pi ``` +Run `caplets cloud auth login` before Cloud mode. For authenticated self-hosted remotes, prefer `CAPLETS_REMOTE_TOKEN`, or `CAPLETS_REMOTE_USER` plus `CAPLETS_REMOTE_PASSWORD`, from your shell or secret manager. + Pi currently calls extension factories with the Pi API only, so this extension reads its remote settings from the top-level `caplets` key in `~/.pi/agent/settings.json` when no programmatic options are supplied: @@ -97,5 +96,5 @@ export default createCapletsPiExtension({ ``` The explicit config shape is `{ mode, server: { url, user }, remote: { pollIntervalMs } }`. -Prefer environment variables for `CAPLETS_SERVER_PASSWORD` rather than storing passwords in +Prefer environment variables for `CAPLETS_REMOTE_TOKEN` or `CAPLETS_REMOTE_PASSWORD` rather than storing passwords in settings files or source code. diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts index 472d240..3b7aff4 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -267,7 +267,9 @@ function parsePiNativeOptions( const result: PiCapletsSettings = {}; const mode = (value as Record).mode; if (mode !== undefined) { - if (mode !== "auto" && mode !== "local" && mode !== "remote") return undefined; + if (mode !== "auto" && mode !== "local" && mode !== "remote" && mode !== "cloud") { + return undefined; + } result.mode = mode; } const statusWidget = (value as Record).statusWidget; @@ -358,6 +360,7 @@ function shouldShowStatusWidget( } return ( options.mode === "remote" || + options.mode === "cloud" || !!options.server?.url || process.env.CAPLETS_SERVER_URL !== undefined ); diff --git a/packages/pi/test/pi.test.ts b/packages/pi/test/pi.test.ts index c772386..544e69d 100644 --- a/packages/pi/test/pi.test.ts +++ b/packages/pi/test/pi.test.ts @@ -980,6 +980,28 @@ describe("@caplets/pi", () => { }); }); + it("loads cloud mode from Pi settings", async () => { + const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + fsMocks.readFile.mockImplementation(async (path: string) => + path.includes(".pi/agent/settings.json") + ? JSON.stringify({ + caplets: { mode: "cloud", server: { url: "https://cloud.caplets.dev" } }, + }) + : Promise.reject(Object.assign(new Error("missing"), { code: "ENOENT" })), + ); + const { api } = mockPiApi(); + + await capletsPiExtension(api as unknown as PiExtensionApi); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "cloud", + server: { url: "https://cloud.caplets.dev" }, + }), + ); + }); + it("ignores package entry args and uses empty settings without top-level caplets config", async () => { const service = mockService([]); nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); diff --git a/plugins/caplets/.claude-plugin/plugin.json b/plugins/caplets/.claude-plugin/plugin.json deleted file mode 100644 index 79da695..0000000 --- a/plugins/caplets/.claude-plugin/plugin.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "caplets", - "version": "0.17.9", - "description": "Expose configured Caplets as progressive-disclosure tools in Claude Code.", - "author": { - "name": "Spirit-Led Software LLC" - }, - "homepage": "https://github.com/spiritledsoftware/caplets#readme", - "repository": "https://github.com/spiritledsoftware/caplets", - "license": "MIT", - "keywords": ["caplets", "mcp", "claude-code", "tools"], - "skills": "./skills/", - "mcpServers": "./mcp.json" -} diff --git a/plugins/caplets/.codex-plugin/plugin.json b/plugins/caplets/.codex-plugin/plugin.json deleted file mode 100644 index 05b1298..0000000 --- a/plugins/caplets/.codex-plugin/plugin.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "caplets", - "version": "0.17.9", - "description": "Expose configured Caplets as progressive-disclosure tools in Codex.", - "author": { - "name": "Spirit-Led Software LLC" - }, - "homepage": "https://github.com/spiritledsoftware/caplets#readme", - "repository": "https://github.com/spiritledsoftware/caplets", - "license": "MIT", - "keywords": ["caplets", "mcp", "codex", "tools"], - "skills": "./skills/", - "mcpServers": "./mcp.json", - "interface": { - "displayName": "Caplets", - "shortDescription": "Progressive disclosure for Caplets tools.", - "longDescription": "Use Caplets to discover and call configured MCP, OpenAPI, GraphQL, HTTP, and CLI tools without flattening every downstream tool into context.", - "developerName": "Spirit-Led Software LLC", - "category": "Developer Tools", - "capabilities": ["MCP", "Tools"], - "composerIcon": "./assets/icon.png", - "websiteURL": "https://github.com/spiritledsoftware/caplets", - "defaultPrompt": [ - "Use Caplets to discover configured capability domains via progressive discovery before calling downstream tools." - ], - "brandColor": "#E0582F" - } -} diff --git a/plugins/caplets/mcp.json b/plugins/caplets/mcp.json deleted file mode 100644 index 399fe48..0000000 --- a/plugins/caplets/mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "caplets": { - "command": "caplets", - "args": ["serve"] - } - } -} diff --git a/plugins/caplets/skills/caplets/SKILL.md b/plugins/caplets/skills/caplets/SKILL.md deleted file mode 100644 index 95315db..0000000 --- a/plugins/caplets/skills/caplets/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: caplets -description: Use Caplets to discover and call configured external tools through progressive disclosure before using downstream MCP, API, or CLI capabilities. -when_to_use: Trigger when the user asks to use an integration, inspect available tools, call a configured service, query external information, access an MCP/OpenAPI/GraphQL/HTTP backend, run curated repository commands, or use a capability that may not appear as a direct top-level tool. Do not use for ordinary local code edits that only need built-in file, shell, or search tools. ---- - -# Caplets - -Use Caplets before searching for or calling downstream tools exposed through configured Caplets backends, including MCP servers, OpenAPI services, GraphQL endpoints, HTTP endpoints, external information services, project systems, source-control systems, or curated CLI commands. - -Caplets exposes progressive discovery operations instead of flattening every downstream tool into the agent context. Start with the configured capability domain, inspect only what you need, then call the specific downstream tool. - -## Trigger Heuristics - -- Use this skill when the user mentions Caplets, configured tools, MCP, OpenAPI, GraphQL, HTTP tools, external information, project systems, source-control systems, or other installed integration domains. -- Use this skill when the task needs a capability that may exist behind Caplets but is not directly available as a top-level tool. -- Use this skill before broad tool discovery so you can search Caplets capability domains first. -- Skip this skill for normal local code edits, file reads, shell commands, or repository searches that do not need an external or configured Caplets backend. - -## Workflow - -1. Read the caplet card with `get_caplet` when you need to understand what a configured Caplet provides. -2. Check backend availability with `check_backend` or the equivalent operation before relying on a backend. -3. Discover tools with `list_tools` or `search_tools`. -4. Inspect a downstream tool schema with `get_tool` before calling it. -5. Call downstream tools with `call_tool`, putting downstream inputs inside the top-level `arguments` object. - -## Guidance - -- Prefer `search_tools` when you know the capability you need. -- Prefer `list_tools` when exploring a small or unfamiliar Caplet. -- Keep downstream arguments nested under `arguments`; do not put downstream fields at the top level. -- Request only the output fields needed when the Caplet supports field selection. -- Treat Caplet backends as live integrations: handle unavailable services, auth failures, validation errors, and partial responses explicitly. -- Avoid loading broad tool lists unless the user task requires exploration. -- When Caplets is configured as a remote MCP HTTP service, treat connection/auth failures as remote-service issues and ask the user to verify `CAPLETS_SERVER_URL`, Basic Auth credentials, and that `caplets serve --transport http` is running. - -## Example - -```json -{ - "operation": "call_tool", - "tool": "example_tool", - "arguments": { - "query": "what the user needs" - } -} -``` diff --git a/scripts/sync-plugin-versions.ts b/scripts/sync-plugin-versions.ts deleted file mode 100644 index 9a282ac..0000000 --- a/scripts/sync-plugin-versions.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { readFile, writeFile } from "node:fs/promises"; - -async function readJson(filePath: string) { - try { - return JSON.parse(await readFile(filePath, "utf8")); - } catch (error) { - throw new Error(`Could not parse JSON at ${filePath}`, { cause: error }); - } -} - -const cliPackage = await readJson("packages/cli/package.json"); - -for (const manifestPath of [ - "plugins/caplets/.codex-plugin/plugin.json", - "plugins/caplets/.claude-plugin/plugin.json", -]) { - const manifest = await readJson(manifestPath); - - if (typeof manifest.version !== "string") { - throw new Error(`Could not find version field in ${manifestPath}`); - } - - manifest.version = cliPackage.version; - - await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); -} From 1933f47ab274c0ed3f4998aa2ea290d9ebadc5f1 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 4 Jun 2026 15:12:56 -0400 Subject: [PATCH 17/19] fix(core): address project binding review comments --- packages/core/src/cloud/client.ts | 13 ++- packages/core/src/project-binding/session.ts | 24 ++++-- .../core/src/project-binding/sync-filter.ts | 65 +++++++++++---- .../core/src/project-binding/transport.ts | 12 ++- packages/core/test/cloud-client.test.ts | 10 ++- packages/core/test/cloud-sync.test.ts | 2 + .../core/test/project-binding-session.test.ts | 80 ++++++++++++++++++- .../test/project-binding-sync-filter.test.ts | 36 +++++++++ 8 files changed, 211 insertions(+), 31 deletions(-) diff --git a/packages/core/src/cloud/client.ts b/packages/core/src/cloud/client.ts index 97caaaa..897ec01 100644 --- a/packages/core/src/cloud/client.ts +++ b/packages/core/src/cloud/client.ts @@ -85,8 +85,17 @@ export class CapletsCloudClient { } async updatePresenceCaplets(presenceId: string, allowedCapletIds: string[]): Promise { - void presenceId; - void allowedCapletIds; + const response = await this.fetchImpl( + this.endpoint(`api/project-bindings/${encodeURIComponent(presenceId)}`), + { + method: "PATCH", + headers: this.headers({ json: true }), + body: JSON.stringify({ allowedCapletIds }), + }, + ); + if (!response.ok) { + throw new Error(`Caplets Cloud Project Binding update failed: HTTP ${response.status}`); + } } private headers(options: { json?: boolean } = {}): Headers { diff --git a/packages/core/src/project-binding/session.ts b/packages/core/src/project-binding/session.ts index 8380a3c..4a374d1 100644 --- a/packages/core/src/project-binding/session.ts +++ b/packages/core/src/project-binding/session.ts @@ -1,3 +1,4 @@ +import { Buffer } from "node:buffer"; import { fingerprintProjectRoot } from "../cloud/project-root"; import { CapletsError } from "../errors"; import type { ResolvedCapletsRemote } from "../remote/options"; @@ -104,7 +105,8 @@ export async function runProjectBindingSession(input: RunProjectBindingSessionIn let ended = false; let heartbeatTimer: ReturnType | undefined; const publicWebSocketUrl = input.remote.projectBindingWebSocketUrl.toString(); - const socketUrl = authenticatedSocketUrl(input.remote, bindingId, sessionId, projectFingerprint); + const socketUrl = bindingSocketUrl(input.remote, bindingId, sessionId, projectFingerprint); + const socketProtocols = bindingSocketProtocols(input.remote); const emitReady = (requestId?: string | undefined) => { input.onEvent?.({ @@ -138,7 +140,7 @@ export async function runProjectBindingSession(input: RunProjectBindingSessionIn }; const connect = async (attempt: number): Promise => { - const socket = webSocketFactory(socketUrl); + const socket = webSocketFactory(socketUrl, socketProtocols); await waitForOpen(socket, input.signal); if (input.signal?.aborted) { closeSocket(socket, 1000, "aborted"); @@ -294,7 +296,7 @@ function controlProjectBindingUrl(remote: ResolvedCapletsRemote, suffix: string) return url; } -function authenticatedSocketUrl( +function bindingSocketUrl( remote: ResolvedCapletsRemote, bindingId: string, sessionId: string, @@ -304,10 +306,17 @@ function authenticatedSocketUrl( url.searchParams.set("bindingId", bindingId); url.searchParams.set("sessionId", sessionId); url.searchParams.set("projectFingerprint", projectFingerprint); - if (remote.auth.type === "bearer") url.searchParams.set("accessToken", remote.auth.token); return url.toString(); } +function bindingSocketProtocols(remote: ResolvedCapletsRemote): string[] | undefined { + if (remote.auth.type !== "bearer") return undefined; + return [ + "caplets.project-binding.v1", + `caplets.bearer.${Buffer.from(remote.auth.token).toString("base64url")}`, + ]; +} + function parseSocketMessage(data: unknown): ProjectBindingSocketServerMessage | undefined { const text = typeof data === "string" @@ -326,7 +335,7 @@ async function waitForOpen( socket: ProjectBindingWebSocket, signal: AbortSignal | undefined, ): Promise { - if (socket.readyState === PROJECT_BINDING_SOCKET_OPEN || socket.addEventListener === undefined) { + if (socket.readyState === PROJECT_BINDING_SOCKET_OPEN) { return; } await Promise.race([ @@ -367,11 +376,12 @@ function listen( } const key = `on${type}` as const; const existing = socket[key]; - socket[key] = (event) => { + const wrapper = (event: ProjectBindingSocketEvent) => { existing?.(event); listener(event); - if (options?.once && socket[key] === listener) socket[key] = null; + if (options?.once && socket[key] === wrapper) socket[key] = existing ?? null; }; + socket[key] = wrapper; } function closeSocket(socket: ProjectBindingWebSocket, code: number, reason: string): void { diff --git a/packages/core/src/project-binding/sync-filter.ts b/packages/core/src/project-binding/sync-filter.ts index 62dbb09..7881ae1 100644 --- a/packages/core/src/project-binding/sync-filter.ts +++ b/packages/core/src/project-binding/sync-filter.ts @@ -21,6 +21,11 @@ export type ProjectSyncManifest = { exclusionSummary: ProjectSyncExclusionSummary[]; }; +type IgnoreRule = { + pattern: string; + negated: boolean; +}; + const HARD_DENYLIST = [ ".git/", ".hg/", @@ -115,21 +120,31 @@ function walk(root: string, visit: (absolutePath: string, directory: boolean) => } } -function loadIgnoreFile(root: string, name: string): string[] { +function loadIgnoreFile(root: string, name: string): IgnoreRule[] { const path = join(root, name); if (!existsSync(path)) return []; return readFileSync(path, "utf8") .split(/\r?\n/u) .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith("#") && !line.startsWith("!")); + .filter((line) => line.length > 0 && !line.startsWith("#")) + .map((line) => { + const negated = line.startsWith("!"); + return { pattern: negated ? line.slice(1) : line, negated }; + }) + .filter((rule) => rule.pattern.length > 0); } function matchingIgnorePattern( - patterns: string[], + rules: IgnoreRule[], relativePath: string, directory: boolean, ): string | undefined { - return patterns.find((pattern) => matchesPattern(relativePath, pattern, directory)); + let matchedPattern: string | undefined; + for (const rule of rules) { + if (!matchesPattern(relativePath, rule.pattern, directory)) continue; + matchedPattern = rule.negated ? undefined : rule.pattern; + } + return matchedPattern; } function hardDenylistPattern(relativePath: string, directory: boolean): string | undefined { @@ -137,19 +152,39 @@ function hardDenylistPattern(relativePath: string, directory: boolean): string | } function matchesPattern(relativePath: string, pattern: string, directory: boolean): boolean { - const normalized = pattern.replace(/\\/gu, "/").replace(/^\//u, ""); - if (normalized.endsWith("/")) { - const prefix = normalized.slice(0, -1); + const normalized = pattern.replace(/\\/gu, "/"); + const anchored = normalized.startsWith("/"); + const body = anchored ? normalized.replace(/^\/+/u, "") : normalized; + if (!body) return false; + if (body.endsWith("/")) { + const prefix = body.slice(0, -1); + if (!prefix) return false; return directory - ? relativePath === prefix || relativePath.startsWith(`${prefix}/`) - : relativePath.startsWith(`${prefix}/`); + ? matchesPathOrDescendant(relativePath, prefix, anchored) + : matchesDescendant(relativePath, prefix, anchored); + } + if (body.startsWith("*.")) { + return anchored + ? !relativePath.includes("/") && relativePath.endsWith(body.slice(1)) + : relativePath.endsWith(body.slice(1)); } - if (normalized.startsWith("*.")) return relativePath.endsWith(normalized.slice(1)); - return ( - relativePath === normalized || - relativePath.startsWith(`${normalized}/`) || - relativePath.split("/").includes(normalized) - ); + return matchesPathOrDescendant(relativePath, body, anchored || body.includes("/")); +} + +function matchesDescendant(relativePath: string, pattern: string, anchored: boolean): boolean { + if (relativePath.startsWith(`${pattern}/`)) return true; + if (anchored) return false; + return relativePath.includes(`/${pattern}/`); +} + +function matchesPathOrDescendant( + relativePath: string, + pattern: string, + anchored: boolean, +): boolean { + if (relativePath === pattern || relativePath.startsWith(`${pattern}/`)) return true; + if (anchored) return false; + return relativePath.split("/").includes(pattern); } function safeTemplate(relativePath: string): boolean { diff --git a/packages/core/src/project-binding/transport.ts b/packages/core/src/project-binding/transport.ts index e858797..feb2eae 100644 --- a/packages/core/src/project-binding/transport.ts +++ b/packages/core/src/project-binding/transport.ts @@ -23,14 +23,20 @@ export type ProjectBindingWebSocket = { onerror?: ((event: ProjectBindingSocketEvent) => void) | null; }; -export type ProjectBindingWebSocketFactory = (url: string) => ProjectBindingWebSocket; +export type ProjectBindingWebSocketFactory = ( + url: string, + protocols?: string | string[] | undefined, +) => ProjectBindingWebSocket; export const PROJECT_BINDING_SOCKET_OPEN = 1; -export function defaultProjectBindingWebSocketFactory(url: string): ProjectBindingWebSocket { +export function defaultProjectBindingWebSocketFactory( + url: string, + protocols?: string | string[] | undefined, +): ProjectBindingWebSocket { const WebSocketCtor = globalThis.WebSocket; if (!WebSocketCtor) { throw new Error("WebSocket is not available in this runtime."); } - return new WebSocketCtor(url) as ProjectBindingWebSocket; + return new WebSocketCtor(url, protocols) as ProjectBindingWebSocket; } diff --git a/packages/core/test/cloud-client.test.ts b/packages/core/test/cloud-client.test.ts index a99c567..753cb1b 100644 --- a/packages/core/test/cloud-client.test.ts +++ b/packages/core/test/cloud-client.test.ts @@ -81,7 +81,7 @@ describe("CapletsCloudClient", () => { ); }); - it("keeps visible local Caplet updates local for compatibility", async () => { + it("updates visible local Caplet allowlist on the Project Binding", async () => { const fetch = vi.fn(async () => Response.json({ ok: true })); const client = new CapletsCloudClient({ baseUrl: new URL("https://cloud.caplets.dev"), @@ -91,6 +91,12 @@ describe("CapletsCloudClient", () => { await expect(client.updatePresenceCaplets("presence_1", ["repo-cli"])).resolves.toBeUndefined(); - expect(fetch).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledWith( + new URL("https://cloud.caplets.dev/api/project-bindings/presence_1"), + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ allowedCapletIds: ["repo-cli"] }), + }), + ); }); }); diff --git a/packages/core/test/cloud-sync.test.ts b/packages/core/test/cloud-sync.test.ts index d3511ff..3ec14d7 100644 --- a/packages/core/test/cloud-sync.test.ts +++ b/packages/core/test/cloud-sync.test.ts @@ -63,12 +63,14 @@ describe("ProjectSyncCoordinator", () => { ".capletsignore", ".env.example", ".gitignore", + "important.env", "src/app.ts", ]); expect(projectSyncFiles(dir)).toEqual([ { path: ".capletsignore", content: "secrets\n" }, { path: ".env.example", content: "SAFE=\n" }, { path: ".gitignore", content: "dist\n*.env\n!important.env\n" }, + { path: "important.env", content: "ok" }, { path: "src/app.ts", content: "app" }, ]); } finally { diff --git a/packages/core/test/project-binding-session.test.ts b/packages/core/test/project-binding-session.test.ts index e398353..1d8dc01 100644 --- a/packages/core/test/project-binding-session.test.ts +++ b/packages/core/test/project-binding-session.test.ts @@ -1,13 +1,19 @@ +import { Buffer } from "node:buffer"; import { describe, expect, it } from "vitest"; import { resolveCapletsRemote } from "../src/remote/options"; import { runProjectBindingSession } from "../src/project-binding/session"; -import type { ProjectBindingWebSocket } from "../src/project-binding/transport"; +import type { + ProjectBindingSocketEvent, + ProjectBindingWebSocket, +} from "../src/project-binding/transport"; describe("runProjectBindingSession", () => { it("creates a session, opens WebSocket, sends heartbeats, and ends remotely on abort", async () => { const controller = new AbortController(); const requests: { method: string; url: string; body?: unknown }[] = []; const events: unknown[] = []; + let socketUrl = ""; + let socketProtocols: string | string[] | undefined; const socket = new FakeProjectBindingSocket([ { type: "state", state: "syncing", syncState: "syncing" }, { type: "ready", bindingId: "binding_1", sessionId: "binding_session_1", syncState: "idle" }, @@ -34,7 +40,11 @@ describe("runProjectBindingSession", () => { } return Response.json({ ok: true, binding: { bindingId: "binding_1" } }); }, - webSocketFactory: () => socket, + webSocketFactory: (url, protocols) => { + socketUrl = url; + socketProtocols = protocols; + return socket; + }, signal: controller.signal, heartbeatIntervalMs: 1, onEvent: (event) => { @@ -44,6 +54,13 @@ describe("runProjectBindingSession", () => { }); expect(result).toMatchObject({ bindingId: "binding_1", sessionId: "binding_session_1" }); + expect(socketUrl).toContain("bindingId=binding_1"); + expect(socketUrl).toContain("sessionId=binding_session_1"); + expect(socketUrl).not.toContain("accessToken="); + expect(socketProtocols).toEqual([ + "caplets.project-binding.v1", + `caplets.bearer.${Buffer.from("cap_access_secret").toString("base64url")}`, + ]); expect(socket.sent.map((item) => item.type)).toContain("heartbeat"); expect(requests.some((request) => request.url.endsWith("/heartbeat"))).toBe(true); expect( @@ -91,6 +108,33 @@ describe("runProjectBindingSession", () => { expect(events).toContainEqual(expect.objectContaining({ type: "reconnecting", attempt: 1 })); }); + + it("cleans up once listeners in the on-event WebSocket fallback path", async () => { + const controller = new AbortController(); + const socket = new FallbackProjectBindingSocket(); + + await runProjectBindingSession({ + projectRoot: "/repo", + remote: resolveCapletsRemote({ url: "https://cloud.caplets.dev", token: "token" }), + fetch: async (url) => { + if (String(url).endsWith("/sessions")) { + return Response.json( + { binding: { bindingId: "binding_1" }, sessionId: "binding_session_1" }, + { status: 201 }, + ); + } + return Response.json({ ok: true }); + }, + webSocketFactory: () => socket, + signal: controller.signal, + heartbeatIntervalMs: 1, + onEvent: (event) => { + if (event.type === "ready") controller.abort(); + }, + }); + + expect(socket.onopen).toBeNull(); + }); }); class FakeProjectBindingSocket implements ProjectBindingWebSocket { @@ -133,3 +177,35 @@ class FakeProjectBindingSocket implements ProjectBindingWebSocket { for (const listener of this.listeners.get(type) ?? []) listener(event); } } + +class FallbackProjectBindingSocket implements ProjectBindingWebSocket { + readyState = 0; + readonly sent: { type: string }[] = []; + onopen: ((event: ProjectBindingSocketEvent) => void) | null = null; + onmessage: ((event: ProjectBindingSocketEvent) => void) | null = null; + onclose: ((event: ProjectBindingSocketEvent) => void) | null = null; + onerror: ((event: ProjectBindingSocketEvent) => void) | null = null; + + constructor() { + setTimeout(() => { + this.readyState = 1; + this.onopen?.({}); + setTimeout(() => { + this.onmessage?.({ + data: JSON.stringify({ + type: "ready", + bindingId: "binding_1", + sessionId: "binding_session_1", + syncState: "idle", + }), + }); + }, 0); + }, 0); + } + + send(data: string): void { + this.sent.push(JSON.parse(data) as { type: string }); + } + + close(): void {} +} diff --git a/packages/core/test/project-binding-sync-filter.test.ts b/packages/core/test/project-binding-sync-filter.test.ts index 1708d7c..e07cc5e 100644 --- a/packages/core/test/project-binding-sync-filter.test.ts +++ b/packages/core/test/project-binding-sync-filter.test.ts @@ -40,4 +40,40 @@ describe("Project Binding sync filter", () => { expect(JSON.stringify(manifest.exclusionSummary)).not.toContain(".git/config"); expect(JSON.stringify(manifest.exclusionSummary)).not.toContain("SECRET=1"); }); + + it("honors gitignore negation rules in order", () => { + const root = mkdtempSync(join(tmpdir(), "caplets-sync-filter-negation-")); + writeFileSync(join(root, ".gitignore"), "*.log\n!deploy.log\n"); + writeFileSync(join(root, "debug.log"), "debug"); + writeFileSync(join(root, "deploy.log"), "deploy"); + + const manifest = buildProjectSyncManifest({ projectRoot: root }); + + expect(manifest.files.map((file) => file.relativePath).sort()).toEqual([ + ".gitignore", + "deploy.log", + ]); + expect(manifest.exclusionSummary).toEqual([ + expect.objectContaining({ source: "gitignore", pattern: "*.log", count: 1 }), + ]); + }); + + it("keeps leading slash ignore patterns anchored to the project root", () => { + const root = mkdtempSync(join(tmpdir(), "caplets-sync-filter-anchor-")); + mkdirSync(join(root, "artifact"), { recursive: true }); + mkdirSync(join(root, "src", "artifact"), { recursive: true }); + writeFileSync(join(root, ".gitignore"), "/artifact\n"); + writeFileSync(join(root, "artifact", "top.js"), "top"); + writeFileSync(join(root, "src", "artifact", "nested.js"), "nested"); + + const manifest = buildProjectSyncManifest({ projectRoot: root }); + + expect(manifest.files.map((file) => file.relativePath).sort()).toEqual([ + ".gitignore", + "src/artifact/nested.js", + ]); + expect(manifest.exclusionSummary).toEqual([ + expect.objectContaining({ source: "gitignore", pattern: "/artifact", count: 1 }), + ]); + }); }); From 65c897d2af1b0f54f8461c989d4f0ef4fd73bb7d Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 4 Jun 2026 15:49:56 -0400 Subject: [PATCH 18/19] fix(core): continue cloud login during workspace selection --- packages/core/src/cli.ts | 10 ++-- .../core/test/cloud-auth-login-cli.test.ts | 55 +++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 442f7a4..9aa0528 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -221,7 +221,9 @@ async function waitForCloudLogin( const started = Date.now(); while (Date.now() - started <= timeoutMs) { const result = await client.pollLogin(loginId); - if (result.status !== "pending") return result; + if (result.status !== "pending" && result.status !== "workspace_selection_required") { + return result; + } await sleep(intervalMs); } return { status: "expired" as const, message: "Cloud Auth login timed out." }; @@ -602,11 +604,7 @@ export function createProgram(io: CliIO = {}): Command { const completed = await waitForCloudLogin(client, started.loginId, env); if (completed.status !== "completed") { - const message = - completed.status === "workspace_selection_required" - ? "Workspace selection is required in the browser." - : `Cloud Auth login ${completed.status}.`; - throw new CapletsError("AUTH_FAILED", message); + throw new CapletsError("AUTH_FAILED", `Cloud Auth login ${completed.status}.`); } const exchanged = await client.exchangeToken({ loginId: started.loginId, diff --git a/packages/core/test/cloud-auth-login-cli.test.ts b/packages/core/test/cloud-auth-login-cli.test.ts index 96e5742..7cb8668 100644 --- a/packages/core/test/cloud-auth-login-cli.test.ts +++ b/packages/core/test/cloud-auth-login-cli.test.ts @@ -67,4 +67,59 @@ describe("caplets cloud auth login", () => { assertNoSecrets(out.join("")); expect(readFileSync(path, "utf8")).toContain("cap_refresh_secret"); }); + + it("continues polling while browser workspace selection is required", async () => { + const path = tempCloudAuthPath(); + const requests: string[] = []; + const responses = [ + Response.json({ + loginId: "login_123", + loginUrl: "https://cloud.caplets.dev/cli-login/login_123", + userCode: "ABCD-EFGH", + expiresAt: "2026-06-03T12:10:00.000Z", + }), + Response.json({ + status: "workspace_selection_required", + workspaces: [ + { workspaceId: "workspace_personal", slug: "personal" }, + { workspaceId: "workspace_team", slug: "team" }, + ], + expiresAt: "2026-06-03T12:10:00.000Z", + }), + Response.json({ + status: "completed", + selectedWorkspace: { workspaceId: "workspace_team", slug: "team" }, + oneTimeCode: "one_time_code_secret", + }), + Response.json({ + status: "authenticated", + cloudUrl: "https://cloud.caplets.dev", + workspaceId: "workspace_team", + workspaceSlug: "team", + accessToken: "cap_access_secret", + refreshToken: "cap_refresh_secret", + expiresAt: "2099-06-03T13:00:00.000Z", + scope: ["project_binding:read", "project_binding:write"], + tokenType: "Bearer", + credentialFamilyId: "family_123", + }), + ]; + + await runCli( + ["cloud", "auth", "login", "--cloud-url", "https://cloud.caplets.dev", "--no-open", "--json"], + { + env: { CAPLETS_CLOUD_AUTH_PATH: path, CAPLETS_CLOUD_AUTH_POLL_INTERVAL_MS: "0" }, + fetch: async (input) => { + requests.push(String(input)); + return responses.shift() ?? Response.json({}, { status: 500 }); + }, + writeOut: () => undefined, + }, + ); + + expect( + requests.filter((url) => url.endsWith("/api/cloud-client/login/login_123")), + ).toHaveLength(2); + expect(readFileSync(path, "utf8")).toContain("workspace_team"); + }); }); From 0023541eed013d47c30e4d03e3bb550569a7cf24 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 4 Jun 2026 16:15:09 -0400 Subject: [PATCH 19/19] fix(core): include binding visibility on registration --- packages/core/src/cloud/client.ts | 2 ++ packages/core/test/cloud-client.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/core/src/cloud/client.ts b/packages/core/src/cloud/client.ts index 897ec01..871538b 100644 --- a/packages/core/src/cloud/client.ts +++ b/packages/core/src/cloud/client.ts @@ -39,6 +39,8 @@ export class CapletsCloudClient { projectFingerprint: input.projectFingerprint, state: "ready", syncState: "idle", + allowedCapletIds: input.allowedCapletIds, + fallbackConsent: input.fallbackConsent ?? "deny", projectFiles: input.projectFiles ?? [], }), }); diff --git a/packages/core/test/cloud-client.test.ts b/packages/core/test/cloud-client.test.ts index 753cb1b..4919501 100644 --- a/packages/core/test/cloud-client.test.ts +++ b/packages/core/test/cloud-client.test.ts @@ -39,6 +39,8 @@ describe("CapletsCloudClient", () => { projectFingerprint: "sha256:abc", state: "ready", syncState: "idle", + allowedCapletIds: ["repo-cli"], + fallbackConsent: "deny", projectFiles: [{ path: "src/app.ts", content: "app" }], }); });

eis3HwH*`}CeuxjcYCYf+K&CK@OMF?f4}~w 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 H(Eq;ytc$Qy literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 0917b59..24d116e 100644 --- a/package.json +++ b/package.json @@ -3,18 +3,19 @@ "private": true, "type": "module", "scripts": { - "alchemy:dev": "tsx ./scripts/alchemy-runner.ts dev", - "alchemy:deploy": "tsx ./scripts/alchemy-runner.ts deploy", - "alchemy:destroy": "tsx ./scripts/alchemy-runner.ts destroy", - "clean": "turbo clean", - "build": "turbo build", - "build:watch": "turbo build:watch", + "alchemy:deploy": "tsx ./infra/alchemy-runner.ts deploy", + "alchemy:destroy": "tsx ./infra/alchemy-runner.ts destroy", + "alchemy:dev": "tsx ./infra/alchemy-runner.ts dev", "benchmark": "pnpm --filter @caplets/benchmarks benchmark", "benchmark:check": "pnpm --filter @caplets/benchmarks benchmark:check", "benchmark:live": "pnpm --filter @caplets/benchmarks benchmark:live", "benchmark:live:opencode": "pnpm --filter @caplets/benchmarks benchmark:live:opencode", "benchmark:live:pi": "pnpm --filter @caplets/benchmarks benchmark:live:pi", + "build": "turbo build", + "build:watch": "turbo build:watch", "changeset": "changeset", + "clean": "turbo clean", + "clean-install": "rm -rf **/node_modules pnpm-lock.yaml && pnpm install", "dev": "tsx ./scripts/dev.ts", "format": "oxfmt .", "format:check": "oxfmt --check .", @@ -25,19 +26,19 @@ "release": "turbo build && changeset publish", "schema:check": "tsx ./scripts/generate-config-schema.ts --check", "schema:generate": "tsx ./scripts/generate-config-schema.ts", - "typecheck": "tsgo --noEmit && turbo typecheck", "test": "vitest run", + "typecheck": "tsgo --noEmit && turbo typecheck", "verify": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm schema:check && pnpm test && pnpm benchmark:check && pnpm build", "version-packages": "changeset version && tsx scripts/sync-plugin-versions.ts && oxfmt plugins/caplets/.codex-plugin/plugin.json plugins/caplets/.claude-plugin/plugin.json" }, "devDependencies": { "@changesets/cli": "^2.31.0", - "@cloudflare/workers-types": "^4.20260529.1", + "@cloudflare/workers-types": "^4.20260530.1", "@types/node": "^25.9.1", "@typescript/native-preview": "7.0.0-dev.20260527.2", "alchemy": "0.93.9", "husky": "^9.1.7", - "lint-staged": "^17.0.5", + "lint-staged": "^17.0.6", "oxfmt": "^0.52.0", "oxlint": "^1.67.0", "prettier-plugin-astro": "^0.14.1", diff --git a/packages/core/package.json b/packages/core/package.json index 380184e..7977251 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -38,6 +38,10 @@ "./native": { "types": "./dist/native.d.ts", "default": "./dist/native.js" + }, + "./cloud-runtime": { + "types": "./dist/cloud-runtime.d.ts", + "default": "./dist/cloud-runtime.js" } }, "publishConfig": { diff --git a/packages/core/rolldown.config.ts b/packages/core/rolldown.config.ts index b3d04df..9fad9d0 100644 --- a/packages/core/rolldown.config.ts +++ b/packages/core/rolldown.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "rolldown"; export default defineConfig({ input: { index: "src/index.ts", + "cloud-runtime": "src/cloud-runtime.ts", "generated-tool-input-schema": "src/generated-tool-input-schema.ts", native: "src/native.ts", }, diff --git a/packages/core/src/caplet-files.ts b/packages/core/src/caplet-files.ts index 51209af..19338ef 100644 --- a/packages/core/src/caplet-files.ts +++ b/packages/core/src/caplet-files.ts @@ -61,6 +61,30 @@ const capletRemoteAuthSchema = z ]) .describe("Authentication settings for a remote MCP server."); +const capletSetupCommandSchema = z + .object({ + label: z.string().min(1).describe("Human-readable setup or verification step label."), + command: z.string().min(1).describe("Executable command to spawn without a shell."), + args: z.array(z.string()).optional().describe("Arguments passed to the command."), + env: z.record(z.string(), z.string()).optional().describe("Additional environment variables."), + cwd: z.string().min(1).optional().describe("Working directory for this command."), + timeoutMs: z.number().int().positive().optional(), + maxOutputBytes: z.number().int().positive().optional(), + }) + .strict(); + +const capletSetupSchema = z + .object({ + commands: z.array(capletSetupCommandSchema).optional(), + verify: z.array(capletSetupCommandSchema).optional(), + }) + .strict() + .refine( + (setup) => (setup.commands?.length ?? 0) > 0 || (setup.verify?.length ?? 0) > 0, + "setup must define at least one command or verify step", + ) + .describe("Optional explicit setup and verification metadata for this Caplet."); + const capletEndpointAuthSchema = z .discriminatedUnion("type", [ z.object({ type: z.literal("none") }).strict(), @@ -555,6 +579,7 @@ export const capletFileSchema = z .array(z.string().trim().min(1).max(80)) .optional() .describe("Optional tags for grouping or searching Caplets."), + setup: capletSetupSchema.optional(), mcpServer: capletMcpServerSchema .describe("MCP server backend configuration for this Caplet.") .optional(), @@ -962,6 +987,7 @@ function capletToServerConfig( name: frontmatter.name, description: frontmatter.description, ...(frontmatter.tags ? { tags: frontmatter.tags } : {}), + ...(frontmatter.setup ? { setup: frontmatter.setup } : {}), body, }; } @@ -975,6 +1001,7 @@ function capletToServerConfig( name: frontmatter.name, description: frontmatter.description, ...(frontmatter.tags ? { tags: frontmatter.tags } : {}), + ...(frontmatter.setup ? { setup: frontmatter.setup } : {}), body, }; } @@ -986,6 +1013,7 @@ function capletToServerConfig( name: frontmatter.name, description: frontmatter.description, ...(frontmatter.tags ? { tags: frontmatter.tags } : {}), + ...(frontmatter.setup ? { setup: frontmatter.setup } : {}), body, }; } @@ -999,6 +1027,7 @@ function capletToServerConfig( name: frontmatter.name, description: frontmatter.description, ...(frontmatter.tags ? { tags: frontmatter.tags } : {}), + ...(frontmatter.setup ? { setup: frontmatter.setup } : {}), body, }; } @@ -1012,6 +1041,7 @@ function capletToServerConfig( name: frontmatter.name, description: frontmatter.description, ...(frontmatter.tags ? { tags: frontmatter.tags } : {}), + ...(frontmatter.setup ? { setup: frontmatter.setup } : {}), body, }; } @@ -1021,6 +1051,7 @@ function capletToServerConfig( name: frontmatter.name, description: frontmatter.description, ...(frontmatter.tags ? { tags: frontmatter.tags } : {}), + ...(frontmatter.setup ? { setup: frontmatter.setup } : {}), body, }; } diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index eba41cf..6368f5e 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -20,6 +20,7 @@ import { } from "./cli/auth"; import { cliCommands } from "./cli/commands"; import { initConfig } from "./cli/init"; +import { formatDoctorReport } from "./cli/doctor"; import { completeCliWords, completionScript, @@ -271,6 +272,8 @@ export function createProgram(io: CliIO = {}): Command { .option("--server-url ", "remote Caplets service base URL") .option("--output ", "config path to write for generic MCP setup") .option("--dry-run", "print actions without running commands or writing files") + .option("--yes", "approve Caplet setup commands for the exact current content hash") + .option("--target ", "Caplet setup target: local, remote, or cloud", parseSetupTarget) .option("--format ", "output format: plain or json", parseSetupFormat) .action( async ( @@ -280,6 +283,8 @@ export function createProgram(io: CliIO = {}): Command { serverUrl?: string; output?: string; dryRun?: boolean; + yes?: boolean; + target?: "local" | "remote" | "cloud"; format?: SetupFormat; }, ) => { @@ -293,6 +298,13 @@ export function createProgram(io: CliIO = {}): Command { }, ); + program + .command(cliCommands.doctor) + .description("Diagnose Caplets local, remote, and project-sync configuration.") + .action(() => { + writeOut(formatDoctorReport({ env })); + }); + program .command(cliCommands.list) .description("List configured Caplets.") @@ -1368,6 +1380,11 @@ function parseSetupFormat(value: string): SetupFormat { throw new CapletsError("REQUEST_INVALID", "setup format must be plain or json"); } +function parseSetupTarget(value: string): "local" | "remote" | "cloud" { + if (value === "local" || value === "remote" || value === "cloud") return value; + throw new CapletsError("REQUEST_INVALID", "setup target must be local, remote, or cloud"); +} + function parseQualifiedTarget( capletOrTarget: string, toolArgument?: string | undefined, diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts index 5fe5535..ba0d60b 100644 --- a/packages/core/src/cli/commands.ts +++ b/packages/core/src/cli/commands.ts @@ -7,6 +7,7 @@ export const cliCommands = { serve: "serve", init: "init", setup: "setup", + doctor: "doctor", list: "list", install: "install", add: "add", @@ -32,6 +33,7 @@ export const topLevelCommandNames = [ cliCommands.serve, cliCommands.init, cliCommands.setup, + cliCommands.doctor, cliCommands.list, cliCommands.install, cliCommands.add, diff --git a/packages/core/src/cli/doctor.ts b/packages/core/src/cli/doctor.ts new file mode 100644 index 0000000..b173de1 --- /dev/null +++ b/packages/core/src/cli/doctor.ts @@ -0,0 +1,27 @@ +import { findProjectRoot, fingerprintProjectRoot } from "../cloud/project-root"; +import { mutagenDoctorLine, type MutagenStatus } from "../cloud/mutagen"; +import { resolveCapletsMode } from "../server/options"; + +export type DoctorOptions = { + env?: NodeJS.ProcessEnv | Record; + cwd?: string; + mutagenStatus?: MutagenStatus; +}; + +export function formatDoctorReport(options: DoctorOptions = {}): string { + const env = options.env ?? process.env; + const mode = resolveCapletsMode({}, env).mode; + const lines = [`Mode: ${mode}`]; + if (mode === "remote") { + const server = env.CAPLETS_SERVER_URL?.trim() ?? ""; + const root = findProjectRoot(options.cwd ?? process.cwd()); + lines.push(`Server: ${server}`); + lines.push(`Project root: ${root}`); + lines.push(`Project fingerprint: ${fingerprintProjectRoot(root)}`); + lines.push("Project sync: configured when local presence is active"); + lines.push( + mutagenDoctorLine(options.mutagenStatus ?? { available: false, reason: "not checked" }), + ); + } + return `${lines.join("\n")}\n`; +} diff --git a/packages/core/src/cli/setup-caplet.ts b/packages/core/src/cli/setup-caplet.ts new file mode 100644 index 0000000..b3c1915 --- /dev/null +++ b/packages/core/src/cli/setup-caplet.ts @@ -0,0 +1,110 @@ +import type { CapletConfig } from "../config"; +import { loadConfig, resolveConfigPath, resolveProjectConfigPath } from "../config"; +import { CapletsError } from "../errors"; +import { capletSetupContentHash } from "../setup/hash"; +import { LocalSetupStore } from "../setup/local-store"; +import { runCapletSetup, type SetupSpawn } from "../setup/runner"; +import type { SetupActor, SetupTargetKind } from "../setup/types"; + +export type CapletSetupCliOptions = { + yes?: boolean; + target?: SetupTargetKind; + remote?: boolean; + configPath?: string | undefined; + projectConfigPath?: string | undefined; + baseDir?: string | undefined; + spawn?: SetupSpawn; +}; + +export async function runCapletSetupCli( + capletId: string, + options: CapletSetupCliOptions = {}, +): Promise { + const targetKind = resolveSetupTarget(options); + if (targetKind === "cloud") { + throw new CapletsError( + "REQUEST_INVALID", + "Cloud setup runs through the Caplets Cloud API, not the local CLI runner", + ); + } + + const configPath = options.configPath ?? resolveConfigPath(); + const projectConfigPath = options.projectConfigPath ?? resolveProjectConfigPath(); + const config = loadConfig(configPath, projectConfigPath); + const caplet = Object.values({ + ...config.mcpServers, + ...config.openapiEndpoints, + ...config.graphqlEndpoints, + ...config.httpApis, + ...config.cliTools, + ...config.capletSets, + }).find((entry) => entry.server === capletId); + if (!caplet) throw new CapletsError("CONFIG_INVALID", `Unknown Caplet ID: ${capletId}`); + if (!caplet.setup || (!caplet.setup.commands?.length && !caplet.setup.verify?.length)) { + return `No setup metadata is defined for ${caplet.name} (${caplet.server}).\n`; + } + + const contentHash = capletSetupContentHash(caplet as CapletConfig); + const store = new LocalSetupStore(options.baseDir ? { baseDir: options.baseDir } : {}); + const existingApproval = await store.getApproval(caplet.server, contentHash, targetKind); + const actor: SetupActor = options.yes ? "cli-yes" : "cli-interactive"; + if (!existingApproval && !options.yes) { + return [ + `Setup approval required for ${caplet.name} (${caplet.server}).`, + `Content hash: ${contentHash}`, + `Target: ${targetKind}`, + "", + "Commands:", + ...formatCommands(caplet.setup.commands ?? []), + "Verify:", + ...formatCommands(caplet.setup.verify ?? []), + "", + `Run caplets setup ${caplet.server} --yes to approve and execute these exact steps.`, + "", + ].join("\n"); + } + + if (options.yes && !existingApproval) { + await store.approve({ + capletId: caplet.server, + contentHash, + targetKind, + approvedAt: new Date().toISOString(), + actor, + }); + } + + const attempts = await runCapletSetup({ + capletId: caplet.server, + contentHash, + targetKind, + setup: caplet.setup, + actor, + approved: true, + store, + ...(options.spawn ? { spawn: options.spawn } : {}), + }); + const failed = attempts.find((attempt) => attempt.status === "failed"); + if (failed) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `Setup failed for ${caplet.server}: ${failed.commandLabel}`, + { attempts }, + ); + } + return `Completed setup for ${caplet.name} (${caplet.server}).\n`; +} + +function resolveSetupTarget(options: CapletSetupCliOptions): SetupTargetKind { + if (options.target) return options.target; + return options.remote ? "remote" : "local"; +} + +function formatCommands( + commands: Array<{ label: string; command: string; args?: string[] | undefined }>, +): string[] { + if (commands.length === 0) return [" none"]; + return commands.map( + (command) => ` - ${command.label}: ${[command.command, ...(command.args ?? [])].join(" ")}`, + ); +} diff --git a/packages/core/src/cli/setup.ts b/packages/core/src/cli/setup.ts index 22e1e3d..ef4c4a3 100644 --- a/packages/core/src/cli/setup.ts +++ b/packages/core/src/cli/setup.ts @@ -3,6 +3,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { promisify } from "node:util"; import { CapletsError } from "../errors"; +import { runCapletSetupCli } from "./setup-caplet"; const execFileAsync = promisify(execFile); @@ -32,6 +33,8 @@ export type SetupOptions = { env?: NodeJS.ProcessEnv | Record; format?: SetupFormat; runCommand?: SetupCommandRunner; + yes?: boolean; + target?: "local" | "remote" | "cloud"; }; type SetupAction = @@ -84,6 +87,13 @@ export function formatSetupMenu(): string { } export async function runSetup(integration: string, options: SetupOptions = {}): Promise { + if (!setupIntegrationIds.includes(integration as SetupIntegrationId)) { + return await runCapletSetupCli(integration, { + ...(options.yes === undefined ? {} : { yes: options.yes }), + ...(options.target === undefined ? {} : { target: options.target }), + ...(options.remote === undefined ? {} : { remote: options.remote }), + }); + } const result = await executeSetup(integration, options); if (options.format === "json") return `${JSON.stringify(result, null, 2)}\n`; return formatSetupResult(result); diff --git a/packages/core/src/cloud-runtime.ts b/packages/core/src/cloud-runtime.ts new file mode 100644 index 0000000..33a3509 --- /dev/null +++ b/packages/core/src/cloud-runtime.ts @@ -0,0 +1,17 @@ +export { + createCloudRuntimeAdapter, + type CloudRuntimeAdapter, + type CloudRuntimeAdapterOptions, +} from "./cloud/runtime-adapter"; +export { createRuntimeHttpApp, type RuntimeHttpOptions } from "./cloud/runtime-http"; +export { capletSetupContentHash, stableJson } from "./setup/hash"; +export { LocalSetupStore, type LocalSetupStoreOptions } from "./setup/local-store"; +export { runCapletSetup, spawnCommand, type SetupSpawn, type SpawnResult } from "./setup/runner"; +export type { + SetupActor, + SetupApproval, + SetupAttempt, + SetupAttemptStatus, + SetupPlan, + SetupTargetKind, +} from "./setup/types"; diff --git a/packages/core/src/cloud/apply.ts b/packages/core/src/cloud/apply.ts new file mode 100644 index 0000000..578398f --- /dev/null +++ b/packages/core/src/cloud/apply.ts @@ -0,0 +1,110 @@ +import { createHash } from "node:crypto"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + realpathSync, + writeFileSync, +} from "node:fs"; +import { dirname, relative, resolve } from "node:path"; + +export type ApplyReceiptInput = { + projectFingerprint: string; + filesChanged: string[]; + skipped: string[]; + policyWarnings: string[]; +}; + +export type ApplyReceipt = ApplyReceiptInput & { + status: "applied"; +}; + +export type ApplyConflict = { + path: string; + kind: "content" | "delete_modify" | "binary"; + message?: string; +}; + +export type RemoteFileChange = { + path: string; + baseSha256?: string; + content: string; +}; + +export function createApplyReceipt(input: ApplyReceiptInput): ApplyReceipt { + return { status: "applied", ...input }; +} + +export function classifyApplyResult(input: { + conflicts: ApplyConflict[]; +}): + | { status: "applied"; recoverable: false } + | { status: "apply_conflict"; recoverable: true; conflicts: ApplyConflict[] } { + if (input.conflicts.length === 0) return { status: "applied", recoverable: false }; + return { status: "apply_conflict", recoverable: true, conflicts: input.conflicts }; +} + +export function applyRemoteFileChanges( + projectRoot: string, + changes: RemoteFileChange[], +): ApplyReceipt | { status: "apply_conflict"; recoverable: true; conflicts: ApplyConflict[] } { + const root = resolve(projectRoot); + const conflicts: ApplyConflict[] = []; + const writable: Array<{ path: string; absolutePath: string; content: string }> = []; + + for (const change of changes) { + const absolutePath = resolve(root, change.path); + if (relative(root, absolutePath).startsWith("..")) { + conflicts.push({ path: change.path, kind: "content", message: "Path escapes project root." }); + continue; + } + if (pathHasSymlink(root, absolutePath)) { + conflicts.push({ path: change.path, kind: "content", message: "Path traverses a symlink." }); + continue; + } + const current = existsSync(absolutePath) ? readFileSync(absolutePath, "utf8") : ""; + if (change.baseSha256 && sha256(current) !== change.baseSha256) { + conflicts.push({ + path: change.path, + kind: "content", + message: "Local file changed since the remote sandbox base.", + }); + continue; + } + writable.push({ path: change.path, absolutePath, content: change.content }); + } + + if (conflicts.length > 0) { + return { status: "apply_conflict", recoverable: true, conflicts }; + } + + for (const change of writable) { + mkdirSync(dirname(change.absolutePath), { recursive: true }); + writeFileSync(change.absolutePath, change.content, "utf8"); + } + + return createApplyReceipt({ + projectFingerprint: sha256(root), + filesChanged: writable.map((change) => change.path), + skipped: [], + policyWarnings: [], + }); +} + +export function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function pathHasSymlink(root: string, target: string): boolean { + let current = root; + for (const part of relative(root, target).split(/[\\/]+/u)) { + if (!part) continue; + current = resolve(current, part); + if (!existsSync(current)) continue; + if (lstatSync(current).isSymbolicLink()) return true; + const real = realpathSync(current); + if (relative(root, real).startsWith("..")) return true; + } + return false; +} diff --git a/packages/core/src/cloud/client.ts b/packages/core/src/cloud/client.ts new file mode 100644 index 0000000..e4d4424 --- /dev/null +++ b/packages/core/src/cloud/client.ts @@ -0,0 +1,95 @@ +export type CapletsCloudClientOptions = { + baseUrl: URL; + accessToken: string; + fetch?: typeof fetch; +}; + +export type RegisterPresenceInput = { + workspaceId: string; + projectRoot: string; + projectFingerprint: string; + allowedCapletIds: string[]; + fallbackConsent?: "allow" | "deny" | undefined; +}; + +export type RegisterPresenceResult = { + presenceId: string; + expiresAt: string; +}; + +export type HeartbeatPresenceResult = RegisterPresenceResult; + +export class CapletsCloudClient { + private readonly fetchImpl: typeof fetch; + + constructor(private readonly options: CapletsCloudClientOptions) { + this.fetchImpl = options.fetch ?? fetch; + } + + async registerPresence(input: RegisterPresenceInput): Promise { + const response = await this.fetchImpl(this.endpoint("api/presence"), { + method: "POST", + headers: this.headers({ json: true }), + body: JSON.stringify(input), + }); + if (!response.ok) { + throw new Error(`Caplets Cloud presence registration failed: HTTP ${response.status}`); + } + return (await response.json()) as RegisterPresenceResult; + } + + async stopPresence(presenceId: string): Promise { + const response = await this.fetchImpl( + this.endpoint(`api/presence/${encodeURIComponent(presenceId)}`), + { + method: "DELETE", + headers: this.headers(), + }, + ); + if (!response.ok && response.status !== 404) { + throw new Error(`Caplets Cloud presence stop failed: HTTP ${response.status}`); + } + } + + async heartbeatPresence(presenceId: string): Promise { + const response = await this.fetchImpl( + this.endpoint(`api/presence/${encodeURIComponent(presenceId)}/heartbeat`), + { + method: "POST", + headers: this.headers(), + }, + ); + if (!response.ok) { + throw new Error(`Caplets Cloud presence heartbeat failed: HTTP ${response.status}`); + } + return (await response.json()) as HeartbeatPresenceResult; + } + + async updatePresenceCaplets(presenceId: string, allowedCapletIds: string[]): Promise { + const response = await this.fetchImpl( + this.endpoint(`api/presence/${encodeURIComponent(presenceId)}/caplets`), + { + method: "PATCH", + headers: this.headers({ json: true }), + body: JSON.stringify({ allowedCapletIds }), + }, + ); + if (!response.ok) { + throw new Error(`Caplets Cloud presence update failed: HTTP ${response.status}`); + } + } + + private headers(options: { json?: boolean } = {}): Headers { + const headers = new Headers(); + headers.set("authorization", `Bearer ${this.options.accessToken}`); + if (options.json) headers.set("content-type", "application/json"); + return headers; + } + + private endpoint(path: string): URL { + const url = new URL(this.options.baseUrl.href); + const basePath = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/u, ""); + url.pathname = `${basePath}/${path.replace(/^\/+/u, "")}`; + return url; + } +} diff --git a/packages/core/src/cloud/mutagen.ts b/packages/core/src/cloud/mutagen.ts new file mode 100644 index 0000000..0ac242f --- /dev/null +++ b/packages/core/src/cloud/mutagen.ts @@ -0,0 +1,49 @@ +export type MutagenLicenseProfile = "mit" | "sspl" | "unknown"; + +export type MutagenBuildInfo = { + version: string; + licenseProfile: MutagenLicenseProfile; +}; + +export type MutagenStatus = + | { available: true; path: string; version: string; licenseProfile: "mit" } + | { available: false; path?: string; reason: string }; + +export function parseMutagenVersionOutput(output: string): MutagenBuildInfo { + const version = output.match(/Mutagen version\s+([^\s]+)/u)?.[1] ?? "unknown"; + const normalized = output.toLocaleLowerCase(); + const licenseProfile = normalized.includes("license profile: mit") + ? "mit" + : normalized.includes("license profile: sspl") + ? "sspl" + : "unknown"; + return { version, licenseProfile }; +} + +export function mutagenBuildIsAllowed(info: MutagenBuildInfo): boolean { + return info.licenseProfile === "mit"; +} + +export async function checkMutagenBinary( + path: string, + run: (path: string, args: string[]) => Promise, +): Promise { + let output: string; + try { + output = await run(path, ["version"]); + } catch (error) { + return { available: false, path, reason: error instanceof Error ? error.message : "failed" }; + } + const info = parseMutagenVersionOutput(output); + if (!mutagenBuildIsAllowed(info)) { + return { available: false, path, reason: `unsupported license profile ${info.licenseProfile}` }; + } + return { available: true, path, version: info.version, licenseProfile: "mit" }; +} + +export function mutagenDoctorLine(status: MutagenStatus): string { + if (!status.available) { + return `Mutagen: unavailable (${status.reason})`; + } + return `Mutagen: available ${status.version} (${status.path})`; +} diff --git a/packages/core/src/cloud/presence.ts b/packages/core/src/cloud/presence.ts new file mode 100644 index 0000000..4581051 --- /dev/null +++ b/packages/core/src/cloud/presence.ts @@ -0,0 +1,84 @@ +import type { CapletsCloudClient, RegisterPresenceInput } from "./client"; + +type PresenceClient = Pick & { + heartbeatPresence?: (presenceId: string) => Promise; + stopPresence?: (presenceId: string) => Promise; + updatePresenceCaplets?: (presenceId: string, allowedCapletIds: string[]) => Promise; +}; + +export type LocalPresenceManagerOptions = RegisterPresenceInput & { + client: PresenceClient; + heartbeatIntervalMs?: number; + setInterval?: typeof setInterval; + clearInterval?: typeof clearInterval; + onError?: (error: unknown) => void; +}; + +export class LocalPresenceManager { + private presenceId: string | undefined; + private heartbeatTimer: ReturnType | undefined; + private startPromise: Promise | undefined; + + constructor(private readonly options: LocalPresenceManagerOptions) {} + + async start(): Promise { + if (this.startPromise) { + return await this.startPromise; + } + this.startPromise = this.register(); + return await this.startPromise; + } + + private async register(): Promise { + const result = await this.options.client.registerPresence({ + workspaceId: this.options.workspaceId, + projectRoot: this.options.projectRoot, + projectFingerprint: this.options.projectFingerprint, + allowedCapletIds: this.options.allowedCapletIds, + fallbackConsent: this.options.fallbackConsent ?? "deny", + }); + this.presenceId = result.presenceId; + this.startHeartbeat(); + } + + async close(): Promise { + await this.startPromise?.catch(() => undefined); + const presenceId = this.presenceId; + this.presenceId = undefined; + this.stopHeartbeat(); + if (presenceId && this.options.client.stopPresence) { + await this.options.client.stopPresence(presenceId); + } + } + + async updateAllowedCapletIds(allowedCapletIds: string[]): Promise { + await this.startPromise?.catch(() => undefined); + const presenceId = this.presenceId; + if (!presenceId || !this.options.client.updatePresenceCaplets) { + return; + } + await this.options.client.updatePresenceCaplets(presenceId, allowedCapletIds); + } + + private startHeartbeat(): void { + if (!this.options.client.heartbeatPresence || this.options.heartbeatIntervalMs === undefined) { + return; + } + const setIntervalImpl = this.options.setInterval ?? setInterval; + this.heartbeatTimer = setIntervalImpl(() => { + const presenceId = this.presenceId; + if (!presenceId || !this.options.client.heartbeatPresence) return; + void this.options.client.heartbeatPresence(presenceId).catch((error) => { + this.options.onError?.(error); + }); + }, this.options.heartbeatIntervalMs); + } + + private stopHeartbeat(): void { + const timer = this.heartbeatTimer; + this.heartbeatTimer = undefined; + if (timer) { + (this.options.clearInterval ?? clearInterval)(timer); + } + } +} diff --git a/packages/core/src/cloud/project-root.ts b/packages/core/src/cloud/project-root.ts new file mode 100644 index 0000000..4a51678 --- /dev/null +++ b/packages/core/src/cloud/project-root.ts @@ -0,0 +1,35 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +const ROOT_MARKERS = [".caplets", ".git", "package.json", "pnpm-workspace.yaml"] as const; + +export function findProjectRoot(start = process.cwd()): string { + let current = resolve(start); + while (true) { + if (ROOT_MARKERS.some((marker) => existsSync(join(current, marker)))) { + return current; + } + const parent = dirname(current); + if (parent === current) return resolve(start); + current = parent; + } +} + +export function fingerprintProjectRoot(root: string): string { + const resolved = resolve(root); + const hash = createHash("sha256"); + hash.update(resolved); + for (const marker of ROOT_MARKERS) { + const path = join(resolved, marker); + if (!existsSync(path)) continue; + hash.update(marker); + try { + const stat = statSync(path); + hash.update(stat.isDirectory() ? "directory" : readFileSync(path)); + } catch { + hash.update("unreadable"); + } + } + return `sha256:${hash.digest("hex")}`; +} diff --git a/packages/core/src/cloud/runtime-adapter.ts b/packages/core/src/cloud/runtime-adapter.ts new file mode 100644 index 0000000..1e1c80a --- /dev/null +++ b/packages/core/src/cloud/runtime-adapter.ts @@ -0,0 +1,171 @@ +import type { CapletConfig } from "../config"; +import { CapletsEngine } from "../engine"; +import { CapletsError } from "../errors"; +import { capletSetupContentHash } from "../setup/hash"; +import { LocalSetupStore } from "../setup/local-store"; +import { runCapletSetup } from "../setup/runner"; +import type { SetupActor, SetupAttempt, SetupPlan } from "../setup/types"; + +export type CloudRuntimeAdapterOptions = { + configPath?: string; + projectConfigPath?: string; + authDir?: string; + runtimeId: string; + sandboxId?: string; + executionKind: "cloud" | "local-fallback"; + setupStore?: LocalSetupStore; +}; + +export type CloudRuntimeAdapter = { + listTools(): Promise; + callTool(name: string, args: unknown): Promise; + checkBackend(capletId: string): Promise; + setupPlan(capletId: string): Promise; + runSetup( + capletId: string, + input: { approved: boolean; actor: SetupActor }, + ): Promise; + close(): Promise; +}; + +export function createCloudRuntimeAdapter( + options: CloudRuntimeAdapterOptions, +): CloudRuntimeAdapter { + return new DefaultCloudRuntimeAdapter(options); +} + +class DefaultCloudRuntimeAdapter implements CloudRuntimeAdapter { + private readonly engine: CapletsEngine; + private readonly setupStore: LocalSetupStore; + + constructor(private readonly options: CloudRuntimeAdapterOptions) { + this.engine = new CapletsEngine({ + ...(options.configPath === undefined ? {} : { configPath: options.configPath }), + ...(options.projectConfigPath === undefined + ? {} + : { projectConfigPath: options.projectConfigPath }), + ...(options.authDir === undefined ? {} : { authDir: options.authDir }), + watch: false, + }); + this.setupStore = options.setupStore ?? new LocalSetupStore(); + } + + async listTools(): Promise { + return { + tools: this.enabledCaplets().map((caplet) => ({ + name: caplet.server, + title: caplet.name, + description: caplet.description, + inputSchema: { type: "object", additionalProperties: true }, + _meta: { caplets: { execution: this.executionMetadata() } }, + })), + }; + } + + async callTool(name: string, args: unknown): Promise { + const request = + isRecord(args) && typeof args.operation === "string" + ? args + : { operation: "call_tool", tool: name, arguments: isRecord(args) ? args : {} }; + const result = await this.engine.execute(name, request); + return annotateExecution(result, this.executionMetadata()); + } + + async checkBackend(capletId: string): Promise { + return annotateExecution( + await this.engine.execute(capletId, { operation: "check_backend" }), + this.executionMetadata(), + ); + } + + async setupPlan(capletId: string): Promise { + const caplet = this.requireCaplet(capletId); + const contentHash = capletSetupContentHash(caplet); + const approved = Boolean(await this.setupStore.getApproval(capletId, contentHash, "cloud")); + return { + capletId, + name: caplet.name, + contentHash, + targetKind: "cloud", + setup: caplet.setup ?? {}, + approved, + commands: caplet.setup?.commands ?? [], + verify: caplet.setup?.verify ?? [], + }; + } + + async runSetup( + capletId: string, + input: { approved: boolean; actor: SetupActor }, + ): Promise { + const plan = await this.setupPlan(capletId); + if (input.approved && !plan.approved) { + await this.setupStore.approve({ + capletId, + contentHash: plan.contentHash, + targetKind: "cloud", + actor: input.actor, + approvedAt: new Date().toISOString(), + }); + } + return await runCapletSetup({ + capletId, + contentHash: plan.contentHash, + targetKind: "cloud", + setup: plan.setup, + actor: input.actor, + approved: input.approved || plan.approved, + store: this.setupStore, + }); + } + + async close(): Promise { + await this.engine.close(); + } + + private enabledCaplets(): CapletConfig[] { + return Object.values({ + ...this.engine.currentConfig().mcpServers, + ...this.engine.currentConfig().openapiEndpoints, + ...this.engine.currentConfig().graphqlEndpoints, + ...this.engine.currentConfig().httpApis, + ...this.engine.currentConfig().cliTools, + ...this.engine.currentConfig().capletSets, + }).filter((caplet) => !caplet.disabled); + } + + private requireCaplet(capletId: string): CapletConfig { + const caplet = this.enabledCaplets().find((entry) => entry.server === capletId); + if (!caplet) throw new CapletsError("CONFIG_INVALID", `Unknown Caplet ID: ${capletId}`); + return caplet; + } + + private executionMetadata() { + return { + kind: this.options.executionKind, + runtimeId: this.options.runtimeId, + ...(this.options.sandboxId ? { sandboxId: this.options.sandboxId } : {}), + ...(this.options.executionKind === "local-fallback" ? { fallback: true } : {}), + }; + } +} + +function annotateExecution(result: unknown, execution: Record): unknown { + if (!isRecord(result)) return result; + const meta = isRecord(result._meta) ? result._meta : {}; + const caplets = isRecord(meta.caplets) ? meta.caplets : {}; + return { + ...result, + _meta: { + ...meta, + caplets: { + ...caplets, + execution, + }, + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/core/src/cloud/runtime-http.ts b/packages/core/src/cloud/runtime-http.ts new file mode 100644 index 0000000..bf041b4 --- /dev/null +++ b/packages/core/src/cloud/runtime-http.ts @@ -0,0 +1,52 @@ +import { Hono } from "hono"; +import { createCloudRuntimeAdapter, type CloudRuntimeAdapterOptions } from "./runtime-adapter"; + +export type RuntimeHttpOptions = CloudRuntimeAdapterOptions & { + token: string; +}; + +export function createRuntimeHttpApp(options: RuntimeHttpOptions): Hono { + const app = new Hono(); + const adapter = createCloudRuntimeAdapter(options); + + app.use("/runtime/*", async (c, next) => { + const authorization = c.req.header("authorization") ?? ""; + if (authorization !== `Bearer ${options.token}`) { + return c.json({ error: "unauthorized" }, 401); + } + await next(); + }); + + app.get("/healthz", (c) => c.json({ status: "ok", runtimeId: options.runtimeId })); + + app.post("/runtime/tools/list", async (c) => c.json(await adapter.listTools())); + + app.post("/runtime/tools/call", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as { name?: string; arguments?: unknown }; + if (!body.name) return c.json({ error: "tool_name_required" }, 400); + return c.json(await adapter.callTool(body.name, body.arguments ?? {})); + }); + + app.post("/runtime/caplets/:id/check", async (c) => { + return c.json(await adapter.checkBackend(c.req.param("id"))); + }); + + app.get("/runtime/caplets/:id/setup", async (c) => { + return c.json(await adapter.setupPlan(c.req.param("id"))); + }); + + app.post("/runtime/caplets/:id/setup/run", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as { + approved?: boolean; + actor?: "cli-interactive" | "cli-yes" | "ui" | "automation"; + }; + return c.json( + await adapter.runSetup(c.req.param("id"), { + approved: body.approved === true, + actor: body.actor ?? "automation", + }), + ); + }); + + return app; +} diff --git a/packages/core/src/cloud/sync.ts b/packages/core/src/cloud/sync.ts new file mode 100644 index 0000000..6c47e60 --- /dev/null +++ b/packages/core/src/cloud/sync.ts @@ -0,0 +1,100 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +export class ProjectSyncCoordinator { + private readonly queues = new Map>(); + + async runMutating(projectId: string, task: () => Promise): Promise { + const previous = this.queues.get(projectId) ?? Promise.resolve(); + let release!: () => void; + const next = new Promise((resolve) => { + release = resolve; + }); + const queued = previous.catch(() => undefined).then(() => next); + this.queues.set(projectId, queued); + + await previous.catch(() => undefined); + try { + return await task(); + } finally { + release(); + if (this.queues.get(projectId) === queued) { + this.queues.delete(projectId); + } + } + } +} + +export function projectSyncManifest(projectRoot: string): string[] { + const ignoreRules = readIgnoreRules(projectRoot); + const files: string[] = []; + walk(projectRoot, projectRoot, ignoreRules, files); + return files.sort(); +} + +function walk(root: string, current: string, ignoreRules: string[], files: string[]): void { + for (const entry of readdirSync(current)) { + const absolute = join(current, entry); + const relativePath = relative(root, absolute).replace(/\\/gu, "/"); + if ( + relativePath === ".git" || + relativePath === ".caplets-sync" || + ignored(relativePath, ignoreRules) + ) { + continue; + } + const stat = statSync(absolute); + if (stat.isDirectory()) { + walk(root, absolute, ignoreRules, files); + } else if (stat.isFile()) { + files.push(relativePath); + } + } +} + +function readIgnoreRules(projectRoot: string): string[] { + return [".gitignore", join(".git", "info", "exclude"), ".capletsignore"].flatMap((file) => { + const path = join(projectRoot, file); + if (!existsSync(path)) return []; + return readFileSync(path, "utf8") + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); + }); +} + +function ignored(path: string, rules: string[]): boolean { + let ignoredPath = false; + for (const rule of rules) { + const negated = rule.startsWith("!"); + const pattern = (negated ? rule.slice(1) : rule).replace(/^\/+/u, ""); + if (!pattern) continue; + if (matchesIgnorePattern(path, pattern)) { + ignoredPath = !negated; + } + } + return ignoredPath; +} + +function matchesIgnorePattern(path: string, pattern: string): boolean { + const directoryPattern = pattern.endsWith("/"); + const normalized = pattern.replace(/\/$/u, ""); + const candidates = normalized.includes("/") + ? [path] + : path.split("/").map((_, index, parts) => parts.slice(index).join("/")); + return candidates.some((candidate) => { + if (globMatch(candidate, normalized)) return true; + return directoryPattern && candidate.startsWith(`${normalized}/`); + }); +} + +function globMatch(value: string, pattern: string): boolean { + const regex = new RegExp( + `^${pattern + .split("*") + .map((part) => part.replace(/[.+?^${}()|[\]\\]/gu, "\\$&")) + .join("[^/]*")}(?:/.*)?$`, + "u", + ); + return regex.test(value); +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index b165569..9d7ed66 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -66,6 +66,21 @@ export type RemoteAuthConfig = redirectUri?: string | undefined; }; +export type CapletSetupCommandConfig = { + label: string; + command: string; + args?: string[] | undefined; + env?: Record | undefined; + cwd?: string | undefined; + timeoutMs?: number | undefined; + maxOutputBytes?: number | undefined; +}; + +export type CapletSetupConfig = { + commands?: CapletSetupCommandConfig[] | undefined; + verify?: CapletSetupCommandConfig[] | undefined; +}; + export type CapletServerConfig = { server: string; backend: "mcp"; @@ -84,6 +99,7 @@ export type CapletServerConfig = { callTimeoutMs: number; toolCacheTtlMs: number; disabled: boolean; + setup?: CapletSetupConfig | undefined; }; export type OpenApiAuthConfig = @@ -106,6 +122,7 @@ export type OpenApiEndpointConfig = { requestTimeoutMs: number; operationCacheTtlMs: number; disabled: boolean; + setup?: CapletSetupConfig | undefined; }; export type GraphQlOperationConfig = { @@ -132,6 +149,7 @@ export type GraphQlEndpointConfig = { operationCacheTtlMs: number; selectionDepth: number; disabled: boolean; + setup?: CapletSetupConfig | undefined; }; export type HttpActionConfig = { @@ -158,6 +176,7 @@ export type HttpApiConfig = { requestTimeoutMs: number; maxResponseBytes: number; disabled: boolean; + setup?: CapletSetupConfig | undefined; }; export type CliToolOutputConfig = { @@ -198,6 +217,7 @@ export type CliToolsConfig = { timeoutMs: number; maxOutputBytes: number; disabled: boolean; + setup?: CapletSetupConfig | undefined; }; export type CapletSetConfig = { @@ -213,6 +233,7 @@ export type CapletSetConfig = { maxSearchLimit: number; toolCacheTtlMs: number; disabled: boolean; + setup?: CapletSetupConfig | undefined; }; export type CapletConfig = @@ -360,6 +381,29 @@ const openApiAuthSchema = z ]) .describe("Authentication settings for an OpenAPI endpoint."); +const setupCommandSchema = z + .object({ + label: z.string().min(1).describe("Human-readable setup or verification step label."), + command: z.string().min(1).describe("Executable command to spawn without a shell."), + args: z.array(z.string()).optional().describe("Arguments passed to the command."), + env: z.record(z.string(), z.string()).optional().describe("Additional environment variables."), + cwd: z.string().min(1).optional().describe("Working directory for this command."), + timeoutMs: z.number().int().positive().optional(), + maxOutputBytes: z.number().int().positive().optional(), + }) + .strict(); + +const setupSchema = z + .object({ + commands: z.array(setupCommandSchema).optional(), + verify: z.array(setupCommandSchema).optional(), + }) + .strict() + .refine( + (setup) => (setup.commands?.length ?? 0) > 0 || (setup.verify?.length ?? 0) > 0, + "setup must define at least one command or verify step", + ); + const publicServerSchema = z .object({ name: z.string().trim().min(1).max(80).describe("Human-readable server display name."), @@ -385,6 +429,7 @@ const publicServerSchema = z url: z.string().url().optional().describe("Remote MCP server URL for http or sse transport."), auth: remoteAuthSchema.optional(), tags: z.array(z.string().trim().min(1).max(80)).optional(), + setup: setupSchema.optional(), startupTimeoutMs: z .number() .int() @@ -432,6 +477,7 @@ const publicOpenApiEndpointSchema = z 'Explicit OpenAPI request auth config. Use {"type":"none"} for public APIs.', ), tags: z.array(z.string().trim().min(1).max(80)).optional(), + setup: setupSchema.optional(), requestTimeoutMs: z .number() .int() @@ -501,6 +547,7 @@ const publicGraphQlEndpointSchema = z 'Explicit GraphQL request auth config. Use {"type":"none"} for public APIs.', ), tags: z.array(z.string().trim().min(1).max(80)).optional(), + setup: setupSchema.optional(), requestTimeoutMs: z .number() .int() @@ -614,6 +661,7 @@ const publicHttpApiSchema = z ) .describe("Configured HTTP actions keyed by stable tool name."), tags: z.array(z.string().trim().min(1).max(80)).optional(), + setup: setupSchema.optional(), requestTimeoutMs: z .number() .int() @@ -706,6 +754,7 @@ const publicCliToolsSchema = z .optional() .describe("Default environment variables for CLI actions."), tags: z.array(z.string().trim().min(1).max(80)).optional(), + setup: setupSchema.optional(), timeoutMs: z .number() .int() @@ -759,6 +808,7 @@ const publicCapletSetSchema = z .default(30_000) .describe("Milliseconds child Caplet metadata stays fresh. Set 0 to refresh every time."), tags: z.array(z.string().trim().min(1).max(80)).optional(), + setup: setupSchema.optional(), disabled: z.boolean().default(false).describe("When true, omit this Caplet set."), }) .strict() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bc092e8..26d7272 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,19 @@ export { runCli, createProgram } from "./cli"; export { parseConfig, loadConfig } from "./config"; export { capabilityDescription, ServerRegistry } from "./registry"; export { generatedToolInputSchema, handleServerTool } from "./tools"; +export type { CapletExecutionMetadata, CapletResultMetadata } from "./tools"; +export type { CapletSetupCommandConfig, CapletSetupConfig } from "./config"; +export { capletSetupContentHash, stableJson } from "./setup/hash"; +export { LocalSetupStore } from "./setup/local-store"; +export { runCapletSetup } from "./setup/runner"; +export type { + SetupActor, + SetupApproval, + SetupAttempt, + SetupAttemptStatus, + SetupPlan, + SetupTargetKind, +} from "./setup/types"; export { hasRenderableStructuredContent, markdownCallToolResultContent, diff --git a/packages/core/src/native/options.ts b/packages/core/src/native/options.ts index 9d90c81..dbca9ba 100644 --- a/packages/core/src/native/options.ts +++ b/packages/core/src/native/options.ts @@ -13,6 +13,15 @@ export type NativeCapletsMode = CapletsMode; export type NativeRemoteCapletsOptions = { pollIntervalMs?: number; fetch?: typeof fetch; + cloud?: NativeCloudPresenceInput; +}; + +export type NativeCloudPresenceInput = { + url?: string; + accessToken?: string; + workspaceId?: string; + projectRoot?: string; + heartbeatIntervalMs?: number; }; export type NativeCapletsServiceResolutionInput = { @@ -36,11 +45,13 @@ export type ResolvedNativeCapletsServiceOptions = auth: NativeRemoteAuthOptions; pollIntervalMs: number; requestInit: RequestInit; + cloud?: ResolvedNativeCloudPresenceOptions; fetch?: typeof fetch; }; }; const DEFAULT_POLL_INTERVAL_MS = 30_000; +const DEFAULT_PRESENCE_HEARTBEAT_INTERVAL_MS = 30_000; export function resolveNativeCapletsServiceOptions( input: NativeCapletsServiceResolutionInput = {}, @@ -63,6 +74,7 @@ export function resolveNativeCapletsServiceOptions( env, ); + const cloud = resolveNativeCloudPresence(input.remote?.cloud, env); return { mode: "remote", remote: { @@ -70,6 +82,7 @@ export function resolveNativeCapletsServiceOptions( auth: server.auth, pollIntervalMs: parsePollInterval(input.remote?.pollIntervalMs), requestInit: server.requestInit, + ...(cloud ? { cloud } : {}), ...(server.fetch ? { fetch: server.fetch } : {}), }, }; @@ -84,3 +97,51 @@ function parsePollInterval(value: number | undefined): number { } return value; } + +export type ResolvedNativeCloudPresenceOptions = { + url: URL; + accessToken: string; + workspaceId: string; + projectRoot?: string; + heartbeatIntervalMs: number; +}; + +function resolveNativeCloudPresence( + input: NativeCloudPresenceInput | undefined, + env: NativeCapletsEnv, +): ResolvedNativeCloudPresenceOptions | undefined { + const url = input?.url ?? env.CAPLETS_CLOUD_URL; + const accessToken = input?.accessToken ?? env.CAPLETS_CLOUD_TOKEN; + const workspaceId = input?.workspaceId ?? env.CAPLETS_CLOUD_WORKSPACE_ID; + if (!url && !accessToken && !workspaceId && !input?.projectRoot) { + return undefined; + } + if (!url || !accessToken || !workspaceId) { + throw new CapletsError( + "REQUEST_INVALID", + "Cloud presence requires CAPLETS_CLOUD_URL, CAPLETS_CLOUD_TOKEN, and CAPLETS_CLOUD_WORKSPACE_ID.", + ); + } + return { + url: new URL(url), + accessToken, + workspaceId, + ...((input?.projectRoot ?? env.CAPLETS_PROJECT_ROOT) + ? { projectRoot: input?.projectRoot ?? env.CAPLETS_PROJECT_ROOT } + : {}), + heartbeatIntervalMs: parsePresenceHeartbeatInterval(input?.heartbeatIntervalMs), + }; +} + +function parsePresenceHeartbeatInterval(value: number | undefined): number { + if (value === undefined) { + return DEFAULT_PRESENCE_HEARTBEAT_INTERVAL_MS; + } + if (!Number.isInteger(value) || value < 1_000) { + throw new CapletsError( + "REQUEST_INVALID", + "cloud.heartbeatIntervalMs must be an integer >= 1000.", + ); + } + return value; +} diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index a9fc481..8795651 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -1,5 +1,8 @@ import type { NativeCapletsServiceResolutionInput } from "./options"; import { resolveNativeCapletsServiceOptions } from "./options"; +import { CapletsCloudClient } from "../cloud/client"; +import { LocalPresenceManager } from "../cloud/presence"; +import { findProjectRoot, fingerprintProjectRoot } from "../cloud/project-root"; import { createSdkRemoteCapletsClient, RemoteNativeCapletsService, @@ -76,7 +79,8 @@ export function createNativeCapletsService( pollIntervalMs: resolved.remote.pollIntervalMs, ...(options.writeErr ? { writeErr: options.writeErr } : {}), }); - return new CompositeNativeCapletsService(remote, local, options); + const presence = createLocalPresenceManager(resolved.remote.cloud, local, options); + return new CompositeNativeCapletsService(remote, local, options, presence); } catch (error) { void local.close().catch((closeError) => { writeErr( @@ -154,12 +158,19 @@ class CompositeNativeCapletsService implements NativeCapletsService { private readonly remote: NativeCapletsService, private readonly local: NativeCapletsService, private readonly options: NativeCapletsServiceOptions, + private readonly presence?: LocalPresenceManager, ) { this.unsubscribers = [ this.remote.onToolsChanged(() => this.updateMergedTools()), this.local.onToolsChanged(() => this.updateMergedTools()), ]; this.tools = this.mergeTools(); + void this.presence?.start().catch((error) => { + writeErr( + options, + `Could not register Caplets Cloud local presence: ${errorMessage(error)}\n`, + ); + }); } listTools(): NativeCapletTool[] { @@ -184,6 +195,11 @@ class CompositeNativeCapletsService implements NativeCapletsService { if (remoteReloaded === undefined || localReloaded === undefined) { return false; } + if (localReloaded) { + await this.presence?.updateAllowedCapletIds( + this.local.listTools().map((tool) => tool.caplet), + ); + } this.updateMergedTools(); return remoteReloaded || localReloaded; } @@ -202,7 +218,7 @@ class CompositeNativeCapletsService implements NativeCapletsService { unsubscribe(); } this.listeners.clear(); - await Promise.all([this.remote.close(), this.local.close()]); + await Promise.all([this.remote.close(), this.local.close(), this.presence?.close()]); } private updateMergedTools(): void { @@ -245,6 +261,37 @@ class CompositeNativeCapletsService implements NativeCapletsService { } } +function createLocalPresenceManager( + cloud: Extract< + ReturnType, + { mode: "remote" } + >["remote"]["cloud"], + local: NativeCapletsService, + options: NativeCapletsServiceOptions, +): LocalPresenceManager | undefined { + if (!cloud) { + return undefined; + } + const projectRoot = cloud.projectRoot ?? findProjectRoot(); + const cloudFetch = options.remote?.fetch ?? options.server?.fetch; + const clientOptions = { + baseUrl: cloud.url, + accessToken: cloud.accessToken, + ...(cloudFetch ? { fetch: cloudFetch } : {}), + }; + return new LocalPresenceManager({ + client: new CapletsCloudClient(clientOptions), + workspaceId: cloud.workspaceId, + projectRoot, + projectFingerprint: fingerprintProjectRoot(projectRoot), + allowedCapletIds: local.listTools().map((tool) => tool.caplet), + heartbeatIntervalMs: cloud.heartbeatIntervalMs, + onError: (error) => { + writeErr(options, `Caplets Cloud local presence heartbeat failed: ${errorMessage(error)}\n`); + }, + }); +} + function createLocalOverlayConfigLoader(options: NativeCapletsServiceOptions) { let hasLoaded = false; let previousWarnings = new Set(); diff --git a/packages/core/src/server/options.ts b/packages/core/src/server/options.ts index 0c1d563..bd45e45 100644 --- a/packages/core/src/server/options.ts +++ b/packages/core/src/server/options.ts @@ -6,7 +6,14 @@ export type CapletsMode = "auto" | "local" | "remote"; export type CapletsServerEnv = Partial< Record< - "CAPLETS_MODE" | "CAPLETS_SERVER_URL" | "CAPLETS_SERVER_USER" | "CAPLETS_SERVER_PASSWORD", + | "CAPLETS_MODE" + | "CAPLETS_SERVER_URL" + | "CAPLETS_SERVER_USER" + | "CAPLETS_SERVER_PASSWORD" + | "CAPLETS_CLOUD_URL" + | "CAPLETS_CLOUD_TOKEN" + | "CAPLETS_CLOUD_WORKSPACE_ID" + | "CAPLETS_PROJECT_ROOT", string > >; diff --git a/packages/core/src/setup/hash.ts b/packages/core/src/setup/hash.ts new file mode 100644 index 0000000..7f47d42 --- /dev/null +++ b/packages/core/src/setup/hash.ts @@ -0,0 +1,50 @@ +import { createHash } from "node:crypto"; +import type { CapletConfig } from "../config"; + +export function capletSetupContentHash(caplet: CapletConfig): string { + return createHash("sha256") + .update(stableJson(stableCapletForHash(caplet))) + .digest("hex"); +} + +export function stableJson(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function stableCapletForHash(caplet: CapletConfig): Record { + return { + server: caplet.server, + name: caplet.name, + description: caplet.description, + backend: caplet.backend, + tags: caplet.tags, + body: caplet.body, + setup: caplet.setup, + backendConfig: Object.fromEntries( + Object.entries(caplet).filter( + ([key]) => + ![ + "server", + "name", + "description", + "backend", + "tags", + "body", + "setup", + "disabled", + ].includes(key), + ), + ), + }; +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, sortJson(entry)]), + ); +} diff --git a/packages/core/src/setup/local-store.ts b/packages/core/src/setup/local-store.ts new file mode 100644 index 0000000..1d77c60 --- /dev/null +++ b/packages/core/src/setup/local-store.ts @@ -0,0 +1,105 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { defaultCacheBaseDir } from "../config/paths"; +import type { SetupApproval, SetupAttempt, SetupTargetKind } from "./types"; + +export type LocalSetupStoreOptions = { + baseDir?: string; + now?: () => Date; + maxAttempts?: number; + retentionDays?: number; +}; + +export class LocalSetupStore { + private readonly root: string; + private readonly now: () => Date; + private readonly maxAttempts: number; + private readonly retentionDays: number; + + constructor(options: LocalSetupStoreOptions = {}) { + this.root = options.baseDir ?? join(defaultCacheBaseDir(), "caplets", "setup"); + this.now = options.now ?? (() => new Date()); + this.maxAttempts = options.maxAttempts ?? 3; + this.retentionDays = options.retentionDays ?? 7; + } + + async getApproval( + capletId: string, + contentHash: string, + targetKind: SetupTargetKind, + ): Promise { + return this.approvals().find( + (approval) => + approval.capletId === capletId && + approval.contentHash === contentHash && + approval.targetKind === targetKind, + ); + } + + async approve(approval: SetupApproval): Promise { + const approvals = this.approvals().filter( + (existing) => + existing.capletId !== approval.capletId || + existing.contentHash !== approval.contentHash || + existing.targetKind !== approval.targetKind, + ); + approvals.push(approval); + mkdirSync(this.root, { recursive: true }); + writeFileSync(this.approvalsPath(), `${JSON.stringify(approvals, null, 2)}\n`, { + mode: 0o600, + }); + return approval; + } + + async recordAttempt(attempt: SetupAttempt): Promise { + const attempts = this.prunedAttempts([...this.attempts(attempt.capletId), attempt]); + mkdirSync(join(this.root, "attempts"), { recursive: true }); + writeFileSync( + this.attemptsPath(attempt.capletId), + attempts.map((entry) => JSON.stringify(entry)).join("\n") + "\n", + { mode: 0o600 }, + ); + } + + async listAttempts(capletId: string): Promise { + return this.attempts(capletId); + } + + retention(): { maxAttempts: number; days: number } { + return { maxAttempts: this.maxAttempts, days: this.retentionDays }; + } + + private approvals(): SetupApproval[] { + const path = this.approvalsPath(); + if (!existsSync(path)) return []; + return JSON.parse(readFileSync(path, "utf8")) as SetupApproval[]; + } + + private attempts(capletId: string): SetupAttempt[] { + const path = this.attemptsPath(capletId); + if (!existsSync(path)) return []; + return readFileSync(path, "utf8") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as SetupAttempt); + } + + private prunedAttempts(attempts: SetupAttempt[]): SetupAttempt[] { + const cutoffMs = this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000; + return attempts + .filter((attempt) => new Date(attempt.finishedAt).getTime() >= cutoffMs) + .slice(-this.maxAttempts); + } + + private approvalsPath(): string { + return join(this.root, "approvals.json"); + } + + private attemptsPath(capletId: string): string { + return join(this.root, "attempts", `${safeFileName(capletId)}.jsonl`); + } +} + +function safeFileName(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]/gu, "_"); +} diff --git a/packages/core/src/setup/runner.ts b/packages/core/src/setup/runner.ts new file mode 100644 index 0000000..68b7da8 --- /dev/null +++ b/packages/core/src/setup/runner.ts @@ -0,0 +1,187 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { isAbsolute, resolve } from "node:path"; +import type { CapletSetupCommandConfig, CapletSetupConfig } from "../config"; +import { CapletsError } from "../errors"; +import type { LocalSetupStore } from "./local-store"; +import type { SetupActor, SetupAttempt, SetupTargetKind } from "./types"; + +export type SpawnResult = { + exitCode?: number | undefined; + signal?: string | undefined; + stdout: string; + stderr: string; + durationMs: number; +}; + +export type SetupSpawn = ( + command: string, + args: string[], + options: { + cwd?: string | undefined; + env: NodeJS.ProcessEnv; + timeoutMs: number; + maxOutputBytes: number; + }, +) => Promise; + +export type RunCapletSetupOptions = { + capletId: string; + contentHash: string; + targetKind: SetupTargetKind; + setup: CapletSetupConfig; + actor: SetupActor; + approved: boolean; + store: Pick; + spawn?: SetupSpawn; + now?: () => Date; +}; + +const DEFAULT_TIMEOUT_MS = 120_000; +const DEFAULT_MAX_OUTPUT_BYTES = 200_000; + +export async function runCapletSetup(options: RunCapletSetupOptions): Promise { + if (!options.approved) { + throw new CapletsError("REQUEST_INVALID", "Setup approval is required before commands run"); + } + + const attempts: SetupAttempt[] = []; + const commands = options.setup.commands ?? []; + const verify = options.setup.verify ?? []; + for (const phase of ["commands", "verify"] as const) { + for (const command of phase === "commands" ? commands : verify) { + const attempt = await runSetupCommand(options, phase, command); + attempts.push(attempt); + await options.store.recordAttempt(attempt); + if (attempt.status !== "succeeded") { + return attempts; + } + } + } + return attempts; +} + +async function runSetupCommand( + options: RunCapletSetupOptions, + phase: "commands" | "verify", + command: CapletSetupCommandConfig, +): Promise { + const now = options.now ?? (() => new Date()); + const startedAt = now(); + const argv = [command.command, ...(command.args ?? [])]; + const env = { + ...process.env, + ...command.env, + }; + const timeoutMs = command.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxOutputBytes = command.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const spawnImpl = options.spawn ?? spawnCommand; + const result = await spawnImpl(command.command, command.args ?? [], { + cwd: resolveCwd(command.cwd), + env, + timeoutMs, + maxOutputBytes, + }); + const finishedAt = now(); + const redacted = redactsSecrets(command.env); + const stdout = redactOutput(result.stdout, command.env); + const stderr = redactOutput(result.stderr, command.env); + return { + attemptId: randomUUID(), + capletId: options.capletId, + contentHash: options.contentHash, + targetKind: options.targetKind, + actor: options.actor, + status: result.exitCode === 0 && !result.signal ? "succeeded" : "failed", + phase, + commandLabel: command.label, + argv, + ...(result.exitCode === undefined ? {} : { exitCode: result.exitCode }), + ...(result.signal === undefined ? {} : { signal: result.signal }), + durationMs: result.durationMs, + startedAt: startedAt.toISOString(), + finishedAt: finishedAt.toISOString(), + stdout: capBytes(stdout, maxOutputBytes), + stderr: capBytes(stderr, maxOutputBytes), + redacted, + retention: options.store.retention(), + }; +} + +export async function spawnCommand( + command: string, + args: string[], + options: { + cwd?: string | undefined; + env: NodeJS.ProcessEnv; + timeoutMs: number; + maxOutputBytes: number; + }, +): Promise { + const startedAt = Date.now(); + return await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + const chunks = { stdout: "", stderr: "" }; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + }, options.timeoutMs); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk) => { + chunks.stdout = capBytes(chunks.stdout + chunk, options.maxOutputBytes); + }); + child.stderr?.on("data", (chunk) => { + chunks.stderr = capBytes(chunks.stderr + chunk, options.maxOutputBytes); + }); + child.on("close", (exitCode, signal) => { + clearTimeout(timer); + resolvePromise({ + exitCode: exitCode ?? undefined, + signal: signal ?? undefined, + stdout: chunks.stdout, + stderr: chunks.stderr, + durationMs: Date.now() - startedAt, + }); + }); + }); +} + +function resolveCwd(cwd: string | undefined): string | undefined { + if (!cwd) return undefined; + if (!isAbsolute(cwd)) { + throw new CapletsError("CONFIG_INVALID", "Setup command cwd must be absolute"); + } + return resolve(cwd); +} + +function redactsSecrets(env: Record | undefined): boolean { + return Object.entries(env ?? {}).some(([key, value]) => isSecretKey(key) && value.length > 0); +} + +function redactOutput(output: string, env: Record | undefined): string { + let redacted = output; + for (const [key, value] of Object.entries(env ?? {})) { + if (!isSecretKey(key) || !value) continue; + redacted = redacted.split(value).join("[REDACTED]"); + } + return redacted; +} + +function isSecretKey(key: string): boolean { + return /TOKEN|SECRET|PASSWORD|KEY|AUTH/iu.test(key); +} + +function capBytes(value: string, maxBytes: number): string { + const bytes = Buffer.byteLength(value); + if (bytes <= maxBytes) return value; + return Buffer.from(value).subarray(0, maxBytes).toString("utf8"); +} diff --git a/packages/core/src/setup/types.ts b/packages/core/src/setup/types.ts new file mode 100644 index 0000000..2920c20 --- /dev/null +++ b/packages/core/src/setup/types.ts @@ -0,0 +1,48 @@ +import type { CapletSetupCommandConfig, CapletSetupConfig } from "../config"; + +export type SetupTargetKind = "local" | "remote" | "cloud"; +export type SetupActor = "cli-interactive" | "cli-yes" | "ui" | "automation"; +export type SetupAttemptStatus = "running" | "succeeded" | "failed"; + +export type SetupApproval = { + capletId: string; + contentHash: string; + targetKind: SetupTargetKind; + approvedAt: string; + actor: SetupActor; +}; + +export type SetupAttempt = { + attemptId: string; + capletId: string; + contentHash: string; + targetKind: SetupTargetKind; + actor: SetupActor; + status: SetupAttemptStatus; + phase: "commands" | "verify"; + commandLabel: string; + argv: string[]; + exitCode?: number | undefined; + signal?: string | undefined; + durationMs: number; + startedAt: string; + finishedAt: string; + stdout: string; + stderr: string; + redacted: boolean; + retention: { + maxAttempts: number; + days: number; + }; +}; + +export type SetupPlan = { + capletId: string; + name: string; + contentHash: string; + targetKind: SetupTargetKind; + setup: CapletSetupConfig; + approved: boolean; + commands: CapletSetupCommandConfig[]; + verify: CapletSetupCommandConfig[]; +}; diff --git a/packages/core/src/tools.ts b/packages/core/src/tools.ts index 5509769..e058980 100644 --- a/packages/core/src/tools.ts +++ b/packages/core/src/tools.ts @@ -430,6 +430,23 @@ export type CapletArtifact = { pathResolution: "absolute" | "relative-to-mcp-server"; }; +export type CapletExecutionMetadata = { + kind: "local" | "remote" | "cloud" | "local-fallback"; + runtimeId?: string | undefined; + sandboxId?: string | undefined; + presenceId?: string | undefined; + fallback?: boolean | undefined; + fallbackReason?: "hosted_runtime_limit_reached" | "hosted_runtime_degraded" | undefined; + project?: + | { + bound: boolean; + fingerprint?: string | undefined; + syncReceiptId?: string | undefined; + applyReceiptId?: string | undefined; + } + | undefined; +}; + export type CapletResultMetadata = { id: string; name: string; @@ -441,6 +458,7 @@ export type CapletResultMetadata = { status: "ok" | "error"; elapsedMs?: number; artifacts?: CapletArtifact[]; + execution?: CapletExecutionMetadata | undefined; }; export function metadataFor( @@ -448,6 +466,7 @@ export function metadataFor( operation: RequiredOperationRequest["operation"], target?: string | { tool?: string; uri?: string; prompt?: string }, startedAt?: number, + execution?: CapletExecutionMetadata, ): CapletResultMetadata { const targetFields = typeof target === "string" ? { tool: target } : (target ?? {}); return { @@ -458,6 +477,7 @@ export function metadataFor( ...targetFields, status: "ok", ...(startedAt === undefined ? {} : { elapsedMs: Date.now() - startedAt }), + ...(execution === undefined ? {} : { execution }), }; } diff --git a/packages/core/test/cloud-apply.test.ts b/packages/core/test/cloud-apply.test.ts new file mode 100644 index 0000000..e0499d2 --- /dev/null +++ b/packages/core/test/cloud-apply.test.ts @@ -0,0 +1,97 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + applyRemoteFileChanges, + classifyApplyResult, + createApplyReceipt, + sha256, +} from "../src/cloud/apply"; + +describe("cloud apply receipts", () => { + it("creates a clean apply receipt", () => { + expect( + createApplyReceipt({ + projectFingerprint: "sha256:abc", + filesChanged: ["src/app.ts"], + skipped: [], + policyWarnings: [], + }), + ).toEqual({ + status: "applied", + projectFingerprint: "sha256:abc", + filesChanged: ["src/app.ts"], + skipped: [], + policyWarnings: [], + }); + }); + + it("classifies conflicts as recoverable", () => { + expect(classifyApplyResult({ conflicts: [{ path: "src/app.ts", kind: "content" }] })).toEqual({ + status: "apply_conflict", + recoverable: true, + conflicts: [{ path: "src/app.ts", kind: "content" }], + }); + }); + + it("implicitly applies clean remote sandbox file changes", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-apply-")); + try { + mkdirSync(join(dir, "src")); + writeFileSync(join(dir, "src/app.ts"), "old", "utf8"); + + const result = applyRemoteFileChanges(dir, [ + { path: "src/app.ts", baseSha256: sha256("old"), content: "new" }, + ]); + + expect(result).toMatchObject({ status: "applied", filesChanged: ["src/app.ts"] }); + expect(readFileSync(join(dir, "src/app.ts"), "utf8")).toBe("new"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns conflicts without writing when local files diverged", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-apply-conflict-")); + try { + mkdirSync(join(dir, "src")); + writeFileSync(join(dir, "src/app.ts"), "local edit", "utf8"); + + const result = applyRemoteFileChanges(dir, [ + { path: "src/app.ts", baseSha256: sha256("old"), content: "remote edit" }, + ]); + + expect(result).toMatchObject({ + status: "apply_conflict", + recoverable: true, + conflicts: [expect.objectContaining({ path: "src/app.ts", kind: "content" })], + }); + expect(readFileSync(join(dir, "src/app.ts"), "utf8")).toBe("local edit"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not follow project symlinks while applying changes", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-apply-symlink-")); + const outside = mkdtempSync(join(tmpdir(), "caplets-outside-")); + try { + writeFileSync(join(outside, "target.txt"), "outside", "utf8"); + symlinkSync(join(outside, "target.txt"), join(dir, "link.txt")); + + const result = applyRemoteFileChanges(dir, [ + { path: "link.txt", baseSha256: sha256("outside"), content: "remote edit" }, + ]); + + expect(result).toMatchObject({ + status: "apply_conflict", + conflicts: [expect.objectContaining({ path: "link.txt" })], + }); + expect(readFileSync(join(outside, "target.txt"), "utf8")).toBe("outside"); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/test/cloud-client.test.ts b/packages/core/test/cloud-client.test.ts new file mode 100644 index 0000000..2864b43 --- /dev/null +++ b/packages/core/test/cloud-client.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { CapletsCloudClient } from "../src/cloud/client"; + +describe("CapletsCloudClient", () => { + it("registers local presence with bearer auth", async () => { + const fetch = vi.fn(async () => + Response.json({ presenceId: "presence_1", expiresAt: "2026-05-30T00:05:00.000Z" }), + ); + const client = new CapletsCloudClient({ + baseUrl: new URL("https://cloud.caplets.dev"), + accessToken: "token", + fetch, + }); + + await expect( + client.registerPresence({ + workspaceId: "ws_1", + projectRoot: "/repo", + projectFingerprint: "sha256:abc", + allowedCapletIds: ["repo-cli"], + }), + ).resolves.toEqual({ presenceId: "presence_1", expiresAt: "2026-05-30T00:05:00.000Z" }); + + expect(fetch).toHaveBeenCalledWith( + new URL("https://cloud.caplets.dev/api/presence"), + expect.objectContaining({ + method: "POST", + headers: expect.any(Headers), + }), + ); + const [, init] = fetch.mock.calls[0] as unknown as [URL, RequestInit]; + const headers = init.headers; + expect(headers).toBeInstanceOf(Headers); + expect((headers as Headers).get("authorization")).toBe("Bearer token"); + }); + + it("stops local presence and ignores missing records", async () => { + const fetch = vi.fn(async () => new Response(null, { status: 404 })); + const client = new CapletsCloudClient({ + baseUrl: new URL("https://cloud.caplets.dev/ws/ian"), + accessToken: "token", + fetch, + }); + + await expect(client.stopPresence("presence_1")).resolves.toBeUndefined(); + + expect(fetch).toHaveBeenCalledWith( + new URL("https://cloud.caplets.dev/ws/ian/api/presence/presence_1"), + expect.objectContaining({ method: "DELETE" }), + ); + }); + + it("heartbeats local presence", async () => { + const fetch = vi.fn(async () => + Response.json({ presenceId: "presence_1", expiresAt: "2026-05-30T00:10:00.000Z" }), + ); + const client = new CapletsCloudClient({ + baseUrl: new URL("https://cloud.caplets.dev"), + accessToken: "token", + fetch, + }); + + await expect(client.heartbeatPresence("presence_1")).resolves.toEqual({ + presenceId: "presence_1", + expiresAt: "2026-05-30T00:10:00.000Z", + }); + + expect(fetch).toHaveBeenCalledWith( + new URL("https://cloud.caplets.dev/api/presence/presence_1/heartbeat"), + expect.objectContaining({ method: "POST" }), + ); + }); + + it("updates visible local Caplets for an active presence", async () => { + const fetch = vi.fn(async () => Response.json({ ok: true })); + const client = new CapletsCloudClient({ + baseUrl: new URL("https://cloud.caplets.dev"), + accessToken: "token", + fetch, + }); + + await expect(client.updatePresenceCaplets("presence_1", ["repo-cli"])).resolves.toBeUndefined(); + + expect(fetch).toHaveBeenCalledWith( + new URL("https://cloud.caplets.dev/api/presence/presence_1/caplets"), + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ allowedCapletIds: ["repo-cli"] }), + }), + ); + }); +}); diff --git a/packages/core/test/cloud-mutagen.test.ts b/packages/core/test/cloud-mutagen.test.ts new file mode 100644 index 0000000..7012299 --- /dev/null +++ b/packages/core/test/cloud-mutagen.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import { + checkMutagenBinary, + mutagenDoctorLine, + parseMutagenVersionOutput, +} from "../src/cloud/mutagen"; + +describe("managed Mutagen adapter", () => { + it("reports available MIT-only Mutagen", async () => { + const run = vi.fn(async () => "Mutagen version 0.18.1\nLicense profile: mit\n"); + + await expect(checkMutagenBinary("/bin/mutagen", run)).resolves.toEqual({ + available: true, + path: "/bin/mutagen", + version: "0.18.1", + licenseProfile: "mit", + }); + }); + + it("rejects unsupported license profiles", async () => { + const run = vi.fn(async () => "Mutagen version 0.18.1\nLicense profile: sspl\n"); + + await expect(checkMutagenBinary("/bin/mutagen", run)).resolves.toEqual({ + available: false, + path: "/bin/mutagen", + reason: "unsupported license profile sspl", + }); + }); + + it("formats doctor output", () => { + expect( + mutagenDoctorLine({ + available: true, + path: "/bin/mutagen", + version: "0.18.1", + licenseProfile: "mit", + }), + ).toBe("Mutagen: available 0.18.1 (/bin/mutagen)"); + }); + + it("parses unknown version output conservatively", () => { + expect(parseMutagenVersionOutput("mutagen dev build")).toEqual({ + version: "unknown", + licenseProfile: "unknown", + }); + }); +}); diff --git a/packages/core/test/cloud-presence.test.ts b/packages/core/test/cloud-presence.test.ts new file mode 100644 index 0000000..e2f2ae6 --- /dev/null +++ b/packages/core/test/cloud-presence.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; +import { LocalPresenceManager } from "../src/cloud/presence"; + +describe("LocalPresenceManager", () => { + it("registers and stops local presence", async () => { + const client = { + registerPresence: vi.fn(async () => ({ + presenceId: "presence_1", + expiresAt: "2026-05-30T00:05:00.000Z", + })), + stopPresence: vi.fn(async () => undefined), + }; + const manager = new LocalPresenceManager({ + client, + workspaceId: "ws_1", + projectRoot: "/repo", + projectFingerprint: "sha256:abc", + allowedCapletIds: ["repo-cli"], + }); + + await manager.start(); + await manager.close(); + + expect(client.registerPresence).toHaveBeenCalledOnce(); + expect(client.stopPresence).toHaveBeenCalledWith("presence_1"); + }); + + it("does not stop before registration succeeds", async () => { + const client = { + registerPresence: vi.fn(async () => { + throw new Error("offline"); + }), + stopPresence: vi.fn(async () => undefined), + }; + const manager = new LocalPresenceManager({ + client, + workspaceId: "ws_1", + projectRoot: "/repo", + projectFingerprint: "sha256:abc", + allowedCapletIds: ["repo-cli"], + }); + + await expect(manager.start()).rejects.toThrow("offline"); + await manager.close(); + + expect(client.stopPresence).not.toHaveBeenCalled(); + }); + + it("heartbeats active local presence until closed", async () => { + vi.useFakeTimers(); + const client = { + registerPresence: vi.fn(async () => ({ + presenceId: "presence_1", + expiresAt: "2026-05-30T00:05:00.000Z", + })), + heartbeatPresence: vi.fn(async () => undefined), + stopPresence: vi.fn(async () => undefined), + }; + const manager = new LocalPresenceManager({ + client, + workspaceId: "ws_1", + projectRoot: "/repo", + projectFingerprint: "sha256:abc", + allowedCapletIds: ["repo-cli"], + heartbeatIntervalMs: 1_000, + }); + + await manager.start(); + await vi.advanceTimersByTimeAsync(1_000); + await manager.close(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(client.heartbeatPresence).toHaveBeenCalledTimes(1); + expect(client.heartbeatPresence).toHaveBeenCalledWith("presence_1"); + vi.useRealTimers(); + }); + + it("updates active local presence with the current local Caplet set", async () => { + const client = { + registerPresence: vi.fn(async () => ({ + presenceId: "presence_1", + expiresAt: "2026-05-30T00:05:00.000Z", + })), + updatePresenceCaplets: vi.fn(async () => undefined), + }; + const manager = new LocalPresenceManager({ + client, + workspaceId: "ws_1", + projectRoot: "/repo", + projectFingerprint: "sha256:abc", + allowedCapletIds: ["repo-cli"], + }); + + await manager.start(); + await manager.updateAllowedCapletIds(["repo-cli", "eslint"]); + + expect(client.updatePresenceCaplets).toHaveBeenCalledWith("presence_1", ["repo-cli", "eslint"]); + }); +}); diff --git a/packages/core/test/cloud-project-root.test.ts b/packages/core/test/cloud-project-root.test.ts new file mode 100644 index 0000000..907c9a6 --- /dev/null +++ b/packages/core/test/cloud-project-root.test.ts @@ -0,0 +1,34 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { fingerprintProjectRoot, findProjectRoot } from "../src/cloud/project-root"; + +describe("project root detection", () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + it("finds the nearest .caplets or git root", () => { + const root = mkdtempSync(join(tmpdir(), "caplets-project-root-")); + dirs.push(root); + mkdirSync(join(root, ".caplets")); + mkdirSync(join(root, "src", "nested"), { recursive: true }); + + expect(findProjectRoot(join(root, "src", "nested"))).toBe(root); + }); + + it("creates a stable fingerprint from root path and marker files", () => { + const root = mkdtempSync(join(tmpdir(), "caplets-project-fingerprint-")); + dirs.push(root); + writeFileSync(join(root, "package.json"), '{"name":"demo"}'); + + const first = fingerprintProjectRoot(root); + const second = fingerprintProjectRoot(root); + + expect(first).toMatch(/^sha256:/u); + expect(second).toBe(first); + }); +}); diff --git a/packages/core/test/cloud-sync.test.ts b/packages/core/test/cloud-sync.test.ts new file mode 100644 index 0000000..01eca7b --- /dev/null +++ b/packages/core/test/cloud-sync.test.ts @@ -0,0 +1,64 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { ProjectSyncCoordinator, projectSyncManifest } from "../src/cloud/sync"; + +describe("ProjectSyncCoordinator", () => { + it("serializes mutating calls per project target", async () => { + const events: string[] = []; + const coordinator = new ProjectSyncCoordinator(); + + await Promise.all([ + coordinator.runMutating("project_1", async () => { + events.push("first:start"); + await Promise.resolve(); + events.push("first:end"); + }), + coordinator.runMutating("project_1", async () => { + events.push("second:start"); + events.push("second:end"); + }), + ]); + + expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]); + }); + + it("allows independent project targets to run independently", async () => { + const coordinator = new ProjectSyncCoordinator(); + const task = vi.fn(async () => undefined); + + await Promise.all([coordinator.runMutating("a", task), coordinator.runMutating("b", task)]); + + expect(task).toHaveBeenCalledTimes(2); + }); + + it("builds sync scope from gitignore and capletsignore only", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-sync-")); + try { + mkdirSync(join(dir, "src")); + mkdirSync(join(dir, "dist")); + mkdirSync(join(dir, "secrets")); + mkdirSync(join(dir, ".git", "info"), { recursive: true }); + writeFileSync(join(dir, ".gitignore"), "dist\n*.env\n!important.env\n", "utf8"); + writeFileSync(join(dir, ".git", "info", "exclude"), "tmp\n", "utf8"); + writeFileSync(join(dir, ".capletsignore"), "secrets\n", "utf8"); + writeFileSync(join(dir, "src/app.ts"), "app", "utf8"); + writeFileSync(join(dir, "dist/app.js"), "build", "utf8"); + writeFileSync(join(dir, "secrets/token"), "secret", "utf8"); + writeFileSync(join(dir, ".env"), "secret", "utf8"); + writeFileSync(join(dir, "important.env"), "ok", "utf8"); + mkdirSync(join(dir, "tmp")); + writeFileSync(join(dir, "tmp/cache"), "cache", "utf8"); + + expect(projectSyncManifest(dir)).toEqual([ + ".capletsignore", + ".gitignore", + "important.env", + "src/app.ts", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index d60ecd0..aa03677 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -89,6 +89,89 @@ describe("config", () => { rmSync(dir, { recursive: true, force: true }); }); + it("loads top-level setup metadata from CAPLET.md", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-setup-files-")); + const root = join(dir, ".caplets"); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "ast-grep.md"), + [ + "---", + "name: ast-grep", + "description: Structural search through ast-grep MCP.", + "setup:", + " commands:", + " - label: Install ast-grep MCP", + " command: npm", + " args: [install, -g, ast-grep-mcp]", + " timeoutMs: 120000", + " maxOutputBytes: 200000", + " verify:", + " - label: Check ast-grep MCP", + " command: ast-grep-mcp", + " args: [--version]", + " timeoutMs: 10000", + " maxOutputBytes: 20000", + "mcpServer:", + " command: ast-grep-mcp", + "---", + "", + "# ast-grep", + "", + ].join("\n"), + ); + + const config = loadCapletFiles(root); + + expect(config?.mcpServers?.["ast-grep"]).toMatchObject({ + setup: { + commands: [ + { + label: "Install ast-grep MCP", + command: "npm", + args: ["install", "-g", "ast-grep-mcp"], + timeoutMs: 120000, + maxOutputBytes: 200000, + }, + ], + verify: [ + { + label: "Check ast-grep MCP", + command: "ast-grep-mcp", + args: ["--version"], + timeoutMs: 10000, + maxOutputBytes: 20000, + }, + ], + }, + }); + rmSync(dir, { recursive: true, force: true }); + }); + + it("rejects setup commands that look like agent tools", () => { + const root = mkdtempSync(join(tmpdir(), "caplets-setup-invalid-")); + writeFileSync( + join(root, "bad.md"), + [ + "---", + "name: Bad", + "description: Invalid setup metadata.", + "setup:", + " commands:", + " - label: Bad", + " command: npm", + " inputSchema: {}", + "mcpServer:", + " command: bad", + "---", + "", + ].join("\n"), + ); + + expect(() => loadCapletFiles(root)).toThrow(CapletsError); + rmSync(root, { recursive: true, force: true }); + }); + it("rejects OpenAPI executable backend maps from project config", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-project-openapi-")); const projectConfigPath = join(dir, ".caplets", "config.json"); @@ -688,8 +771,16 @@ describe("config", () => { expect(config.mcpServers.context7).toMatchObject({ server: "context7", name: "Context7 Documentation", - command: "npx", - args: ["-y", "@upstash/context7-mcp"], + command: "context7-mcp", + setup: { + commands: [ + { + label: "Install Context7 MCP", + command: "npm", + args: ["install", "-g", "@upstash/context7-mcp"], + }, + ], + }, }); expect(config.mcpServers.github).toMatchObject({ server: "github", @@ -708,8 +799,16 @@ describe("config", () => { expect(config.mcpServers["ast-grep"]).toMatchObject({ server: "ast-grep", name: "ast-grep", - command: "npx", - args: ["-y", "ast-grep-mcp"], + command: "ast-grep-mcp", + setup: { + verify: [ + { + label: "Check ast-grep MCP", + command: "ast-grep-mcp", + args: ["--help"], + }, + ], + }, }); expect(config.httpApis.osv).toMatchObject({ server: "osv", @@ -763,8 +862,22 @@ describe("config", () => { expect(config.mcpServers.playwright).toMatchObject({ server: "playwright", name: "Playwright", - command: "npx", - args: ["-y", "@playwright/mcp@0.0.75", "--headless"], + command: "playwright-mcp", + args: ["--headless"], + setup: { + commands: [ + { + label: "Install Playwright MCP", + command: "npm", + args: ["install", "-g", "@playwright/mcp@0.0.75"], + }, + { + label: "Install Chromium browser", + command: "npx", + args: ["playwright", "install", "chromium"], + }, + ], + }, }); expect(config.mcpServers.lsp).toMatchObject({ server: "lsp", diff --git a/packages/core/test/doctor-cli.test.ts b/packages/core/test/doctor-cli.test.ts new file mode 100644 index 0000000..0532e9a --- /dev/null +++ b/packages/core/test/doctor-cli.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { runCli } from "../src/cli"; + +describe("caplets doctor", () => { + it("shows local mode without remote sync details", async () => { + const out: string[] = []; + + await runCli(["doctor"], { + env: {}, + writeOut: (value) => out.push(value), + }); + + expect(out.join("")).toContain("Mode: local"); + expect(out.join("")).not.toContain("Mutagen"); + }); + + it("shows remote mode diagnostics", async () => { + const out: string[] = []; + + await runCli(["doctor"], { + env: { + CAPLETS_MODE: "remote", + CAPLETS_SERVER_URL: "https://cloud.caplets.dev/ws/ian", + }, + writeOut: (value) => out.push(value), + }); + + expect(out.join("")).toContain("Mode: remote"); + expect(out.join("")).toContain("Server: https://cloud.caplets.dev/ws/ian"); + expect(out.join("")).toContain("Project sync"); + expect(out.join("")).toContain("Mutagen:"); + }); +}); diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index feb26c6..1df25ae 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -703,6 +703,124 @@ describe("createNativeCapletsService remote mode", () => { vi.useRealTimers(); }); + it("registers and tears down local presence in cloud remote mode", async () => { + const fixture = client(); + const fetch = vi.fn( + async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(input.toString()); + if (url.pathname.endsWith("/api/presence") && init?.method === "POST") { + return Response.json({ + presenceId: "presence_1", + expiresAt: "2026-05-30T00:05:00.000Z", + }); + } + if (url.pathname.endsWith("/api/presence/presence_1") && init?.method === "DELETE") { + return Response.json({ ok: true }); + } + if (url.pathname.endsWith("/api/presence/presence_1/caplets") && init?.method === "PATCH") { + return Response.json({ ok: true }); + } + return new Response("not found", { status: 404 }); + }, + ); + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + local: { name: "Local", description: "Local Caplet.", command: process.execPath }, + }, + }); + dirs.push(dir); + + const service = createNativeCapletsService({ + mode: "remote", + server: { url: "http://127.0.0.1:5387", fetch }, + remote: { + cloud: { + url: "https://cloud.caplets.dev", + accessToken: "token", + workspaceId: "ws_1", + projectRoot: dirname(projectConfigPath), + heartbeatIntervalMs: 60_000, + }, + }, + remoteClientFactory: vi.fn(() => fixture.api), + configPath, + projectConfigPath, + }); + + await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.anything())); + await service.close(); + + const presenceBodies = fetch.mock.calls + .map(([, init]) => init?.body) + .filter((body): body is string => typeof body === "string") + .map((body) => JSON.parse(body) as { allowedCapletIds?: string[] }); + expect(presenceBodies[0]?.allowedCapletIds).toEqual(["local"]); + expect(fetch).toHaveBeenCalledWith( + new URL("https://cloud.caplets.dev/api/presence/presence_1"), + expect.objectContaining({ method: "DELETE" }), + ); + }); + + it("updates local presence after local overlay reload changes the Caplet set", async () => { + const fixture = client(); + const fetch = vi.fn( + async (input: Parameters[0], init?: RequestInit) => { + const url = new URL(input.toString()); + if (url.pathname.endsWith("/api/presence") && init?.method === "POST") { + return Response.json({ + presenceId: "presence_1", + expiresAt: "2026-05-30T00:05:00.000Z", + }); + } + if (url.pathname.endsWith("/api/presence/presence_1/caplets") && init?.method === "PATCH") { + return Response.json({ ok: true }); + } + if (url.pathname.endsWith("/api/presence/presence_1") && init?.method === "DELETE") { + return Response.json({ ok: true }); + } + return new Response("not found", { status: 404 }); + }, + ); + const { dir, configPath, projectConfigPath } = tempConfig({}); + dirs.push(dir); + const service = createNativeCapletsService({ + mode: "remote", + server: { url: "http://127.0.0.1:5387", fetch }, + remote: { + cloud: { + url: "https://cloud.caplets.dev", + accessToken: "token", + workspaceId: "ws_1", + projectRoot: dirname(projectConfigPath), + }, + }, + remoteClientFactory: vi.fn(() => fixture.api), + configPath, + projectConfigPath, + }); + await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.anything())); + + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + local: { name: "Local", description: "Local Caplet.", command: process.execPath }, + }, + }), + "utf8", + ); + await service.reload(); + + expect(fetch).toHaveBeenCalledWith( + new URL("https://cloud.caplets.dev/api/presence/presence_1/caplets"), + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ allowedCapletIds: ["local"] }), + }), + ); + await service.close(); + }); + it("fails fast for invalid remote config", () => { expect(() => createNativeCapletsService({ mode: "remote", server: { url: "http://example.com" } }), diff --git a/packages/core/test/setup-runner.test.ts b/packages/core/test/setup-runner.test.ts new file mode 100644 index 0000000..c46f531 --- /dev/null +++ b/packages/core/test/setup-runner.test.ts @@ -0,0 +1,179 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { CapletConfig } from "../src/config"; +import { capletSetupContentHash } from "../src/setup/hash"; +import { LocalSetupStore } from "../src/setup/local-store"; +import { runCapletSetup, type SetupSpawn } from "../src/setup/runner"; +import type { SetupAttempt } from "../src/setup/types"; + +describe("setup runner", () => { + it("changes content hash when setup metadata changes", () => { + const first = caplet("npm", ["install", "-g", "first"]); + const second = caplet("npm", ["install", "-g", "second"]); + expect(capletSetupContentHash(first)).not.toBe(capletSetupContentHash(second)); + }); + + it("requires approval before commands run", async () => { + const store = memoryStore(); + await expect( + runCapletSetup({ + capletId: "ast-grep", + contentHash: "hash", + targetKind: "local", + actor: "cli-interactive", + approved: false, + setup: { commands: [{ label: "Install", command: "npm" }] }, + store, + spawn: successfulSpawn(), + }), + ).rejects.toMatchObject({ code: "REQUEST_INVALID" }); + expect(store.attempts).toEqual([]); + }); + + it("records successful setup and verify attempts without executing real package managers", async () => { + const store = memoryStore(); + const attempts = await runCapletSetup({ + capletId: "ast-grep", + contentHash: "hash", + targetKind: "local", + actor: "cli-yes", + approved: true, + setup: { + commands: [{ label: "Install", command: "npm", args: ["install"] }], + verify: [{ label: "Verify", command: "ast-grep-mcp", args: ["--help"] }], + }, + store, + spawn: successfulSpawn(), + }); + expect(attempts).toHaveLength(2); + expect(attempts.map((attempt) => attempt.status)).toEqual(["succeeded", "succeeded"]); + expect(attempts[0]?.actor).toBe("cli-yes"); + expect(store.attempts).toHaveLength(2); + }); + + it("leaves status failed when verify fails", async () => { + const store = memoryStore(); + const attempts = await runCapletSetup({ + capletId: "ast-grep", + contentHash: "hash", + targetKind: "local", + actor: "cli-yes", + approved: true, + setup: { + commands: [{ label: "Install", command: "npm" }], + verify: [{ label: "Verify", command: "ast-grep-mcp" }], + }, + store, + spawn: async (command) => ({ + exitCode: command === "ast-grep-mcp" ? 1 : 0, + stdout: "", + stderr: "missing", + durationMs: 1, + }), + }); + expect(attempts.at(-1)).toMatchObject({ phase: "verify", status: "failed" }); + }); + + it("caps output and redacts secret-looking env values", async () => { + const store = memoryStore(); + const attempts = await runCapletSetup({ + capletId: "secret", + contentHash: "hash", + targetKind: "local", + actor: "cli-yes", + approved: true, + setup: { + commands: [ + { + label: "Install", + command: "echo", + env: { API_TOKEN: "super-secret-value" }, + maxOutputBytes: 12, + }, + ], + }, + store, + spawn: async () => ({ + exitCode: 0, + stdout: "super-secret-value with trailing data", + stderr: "", + durationMs: 1, + }), + }); + expect(attempts[0]?.stdout).toBe("[REDACTED] w"); + expect(attempts[0]?.redacted).toBe(true); + }); + + it("keeps local attempts to the free retention window", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-setup-store-")); + try { + const store = new LocalSetupStore({ baseDir: dir, maxAttempts: 3, retentionDays: 7 }); + for (let index = 0; index < 5; index += 1) { + await store.recordAttempt({ + ...attempt(index), + capletId: "ast-grep", + }); + } + const attempts = await store.listAttempts("ast-grep"); + expect(attempts).toHaveLength(3); + expect(attempts.map((entry) => entry.commandLabel)).toEqual(["2", "3", "4"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +function caplet(command: string, args: string[]): CapletConfig { + return { + server: "ast-grep", + backend: "mcp", + name: "ast-grep", + description: "Structural search", + transport: "stdio", + command: "ast-grep-mcp", + startupTimeoutMs: 10, + callTimeoutMs: 10, + toolCacheTtlMs: 10, + disabled: false, + setup: { commands: [{ label: "Install", command, args }] }, + }; +} + +function successfulSpawn(): SetupSpawn { + return async () => ({ exitCode: 0, stdout: "ok", stderr: "", durationMs: 1 }); +} + +function memoryStore() { + const attempts: SetupAttempt[] = []; + return { + attempts, + retention: () => ({ maxAttempts: 3, days: 7 }), + recordAttempt: async (attempt: SetupAttempt) => { + attempts.push(attempt); + }, + }; +} + +function attempt(index: number): SetupAttempt { + return { + attemptId: `attempt-${index}`, + capletId: "caplet", + contentHash: "hash", + targetKind: "local", + actor: "cli-yes", + status: "succeeded", + phase: "commands", + commandLabel: String(index), + argv: ["true"], + exitCode: 0, + durationMs: 1, + startedAt: new Date(Date.now() + index).toISOString(), + finishedAt: new Date(Date.now() + index).toISOString(), + stdout: "", + stderr: "", + redacted: false, + retention: { maxAttempts: 3, days: 7 }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77edf9f..d6b535f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^2.31.0 version: 2.31.0(@types/node@25.9.1) '@cloudflare/workers-types': - specifier: ^4.20260529.1 - version: 4.20260529.1 + specifier: ^4.20260530.1 + version: 4.20260530.1 '@types/node': specifier: ^25.9.1 version: 25.9.1 @@ -22,13 +22,13 @@ importers: version: 7.0.0-dev.20260527.2 alchemy: specifier: 0.93.9 - version: 0.93.9(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0))(workerd@1.20260526.1) + version: 0.93.9(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))(workerd@1.20260526.1) husky: specifier: ^9.1.7 version: 9.1.7 lint-staged: - specifier: ^17.0.5 - version: 17.0.5 + specifier: ^17.0.6 + version: 17.0.6 oxfmt: specifier: ^0.52.0 version: 0.52.0 @@ -52,7 +52,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) apps/landing: dependencies: @@ -74,6 +74,10 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + devDependencies: + vite: + specifier: ^7.3.3 + version: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) packages/benchmarks: dependencies: @@ -98,7 +102,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) packages/cli: dependencies: @@ -123,7 +127,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) packages/core: dependencies: @@ -175,7 +179,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) packages/opencode: dependencies: @@ -184,7 +188,7 @@ importers: version: link:../core '@opencode-ai/plugin': specifier: '>=1' - version: 1.15.10 + version: 1.15.12 devDependencies: '@types/node': specifier: ^25.9.1 @@ -200,7 +204,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) packages/pi: dependencies: @@ -209,10 +213,10 @@ importers: version: link:../core '@earendil-works/pi-coding-agent': specifier: '*' - version: 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + version: 0.78.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) '@earendil-works/pi-tui': specifier: '*' - version: 0.75.5 + version: 0.78.0 devDependencies: '@types/node': specifier: ^25.9.1 @@ -228,7 +232,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) packages: @@ -319,12 +323,8 @@ packages: resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-cognito-identity@3.1056.0': - resolution: {integrity: sha512-Fywg6+B39uGiYZRYFEsOXbIeHQ8wvtMqlt6FUwWev8N2H+V0pVdgCKn32pSOzud1i17wnm5gpB2VXZEoyVHc2A==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/core@3.974.13': - resolution: {integrity: sha512-+Y5/4tHki0uYgyx8eun146DegRVQBpdKGK5RbV0FTKJPpaKTchvqVxrrRFK6Wk0JksO4iAZKw3eqxGEIwtO98w==} + '@aws-sdk/client-cognito-identity@3.1057.0': + resolution: {integrity: sha512-5MliYkp2u0+2arTp5fZIaxl+xmm90LEKv/VeSxhfNQW4t0fvWJrNO429/jchWQenNoDRrOGE59VfbuZUfwFujg==} engines: {node: '>=20.0.0'} '@aws-sdk/core@3.974.15': @@ -335,98 +335,58 @@ packages: resolution: {integrity: sha512-OHkK6xOx/IHkSbQdDWxnVCLU+j28EFl8wyWgBILQDFAPY8n240C/O4gjmFx+zFU12lL8njgJQ5GWAIWq88CnSQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.39': - resolution: {integrity: sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.41': resolution: {integrity: sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.41': - resolution: {integrity: sha512-IA3CQTjtJkb6u1H4mE4936c8OPBMa9Jggtwe8U2Mqw/vvb/tZ5Ebd0mcZcX0uKWQhOyYo/+qNIwkV5Xh+FeJJA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.43': resolution: {integrity: sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.43': - resolution: {integrity: sha512-4mzII+3mZEVXXE1xzrLQrCJL7/r62A63bA6SVzZoNL5rqCJghpf+xgGltVrIBBs0n+mOZBKrQl2tRREtvZ5l6A==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-ini@3.972.45': - resolution: {integrity: sha512-sJe5ZWibO4s7RWjFQ8Zol76KxoJcIYyEZH1/wxQSBMSIAAxzaJ8cS/ITAaIHWUQvDKQdt18+cJAHKWB7n1Jmrg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-login@3.972.43': - resolution: {integrity: sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA==} + '@aws-sdk/credential-provider-ini@3.972.46': + resolution: {integrity: sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA==} engines: {node: '>=20.0.0'} '@aws-sdk/credential-provider-login@3.972.45': resolution: {integrity: sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.44': - resolution: {integrity: sha512-sDaBIT0yrNNIPfvlsiTCmANm07zKju+ipWODjEXgZlsjMeIJR3LVp7RDyAOzUoAsTbDfYKDWp+i5WrFiQP6rmQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-node@3.972.46': - resolution: {integrity: sha512-cS4w0jzDRb1jOlkiJS3y80OxddHzkky/MN9k3NYs5jganNKVLjF0lpvjlwS118oGMr3cdAfOlVdo8gLurTSE7w==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/credential-provider-process@3.972.39': - resolution: {integrity: sha512-2k/amBifLd75eXNwgvPw/2lKYSQ3NhvHQgkVKVjfUq13/eJ3JRtHmznuFenn74OK3sSfp4SMy1YB2w+UVXoKqA==} + '@aws-sdk/credential-provider-node@3.972.47': + resolution: {integrity: sha512-HrId+C0DWA5qDIyLG64/kjUB2RNtPypxmABnIctK+TA1P1kHlOYoE/Wf5T5tKOMKgb08P7k/zNyhvfJ3lh5Oag==} engines: {node: '>=20.0.0'} '@aws-sdk/credential-provider-process@3.972.41': resolution: {integrity: sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.43': - resolution: {integrity: sha512-LPc3+Y4vhH1T4x6CMqwCM6hk5+SRf/Lwmgm8INm95wxTtIRHcMwQUVkDzWu4Iw/RSncxYM2BC01OrYbxOPZvyg==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.45': resolution: {integrity: sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.43': - resolution: {integrity: sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.45': resolution: {integrity: sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-providers@3.1056.0': - resolution: {integrity: sha512-Qp7ndCG+dZldiaURze6BM/dLkHQJxwi6WNRR1sR9lhX9jS9QG5ZIOiY3jm6T668vgGqHuNQS7r/P9pimxnHyyg==} + '@aws-sdk/credential-providers@3.1057.0': + resolution: {integrity: sha512-rbrEHtz11g0kxsSkYr3fx2HABNNblp4AhB2MgPvJHgYOWfJ2eBviU7Mvoaef0PW8QH6lbZDfJcnM7eKvtvz3sw==} engines: {node: '>=20.0.0'} - '@aws-sdk/eventstream-handler-node@3.972.17': - resolution: {integrity: sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ==} + '@aws-sdk/eventstream-handler-node@3.972.18': + resolution: {integrity: sha512-QPQhwY/fstR8fMZFWrsJRNoTP6D1RjRPHGRX7u9/VkF3opCsvD0oXPz6qzkX94SchzvuS5vyFZbJbPcMEs2Jeg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-eventstream@3.972.13': - resolution: {integrity: sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A==} + '@aws-sdk/middleware-eventstream@3.972.14': + resolution: {integrity: sha512-DoZ4djVj/74XQ6M/IwxuKh543tTvLCL7u1Dx+VDHMgW9yGNrFSJJ1l0LrUQRaekic5CB12wUiiOoHL0VI6H0gg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-websocket@3.972.21': - resolution: {integrity: sha512-yr+5+C7v9R55sAJ89A55Wrm7wIKPVn5cm6J3Hztnd5s/iwEUKxyJqCnIxJu4fVXgG9XBQD1Jc4rsWC1ozahJjA==} + '@aws-sdk/middleware-websocket@3.972.23': + resolution: {integrity: sha512-F0d4A9pJFiwljyKgSwU1Z5n+CXSv8bp+V5SthbS2rftB8wBN9z1K2Yyv3xbeK0AM2T0g4q6Ptf0shFF+oQZyiA==} engines: {node: '>= 14.0.0'} - '@aws-sdk/nested-clients@3.997.11': - resolution: {integrity: sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.13': resolution: {integrity: sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.28': - resolution: {integrity: sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q==} - engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.30': resolution: {integrity: sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw==} engines: {node: '>=20.0.0'} @@ -435,10 +395,6 @@ packages: resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1052.0': - resolution: {integrity: sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1056.0': resolution: {integrity: sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA==} engines: {node: '>=20.0.0'} @@ -451,10 +407,6 @@ packages: resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.25': - resolution: {integrity: sha512-GH+Kjz4nPKWKHnsiQpnhP1MJdTGIcK4rAka6tzakgjjUkVgNsmPeEbbRAf09SzS1hjGu6duGHCBsxYke0BhHjQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.26': resolution: {integrity: sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==} engines: {node: '>=20.0.0'} @@ -543,12 +495,12 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@clack/core@1.3.1': - resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==} + '@clack/core@1.4.0': + resolution: {integrity: sha512-7Wctjq6f7c1CPz8sPpkwUnz8yRgVANkpNupb81q432FjcJg4l+Sw7XANdNSdWfAKq0IHI0JTcUeK5dxs/HrGPw==} engines: {node: '>= 20.12.0'} - '@clack/prompts@1.4.0': - resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} + '@clack/prompts@1.5.0': + resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} engines: {node: '>= 20.12.0'} '@cloudflare/kv-asset-handler@0.5.0': @@ -633,29 +585,29 @@ packages: cpu: [x64] os: [win32] - '@cloudflare/workers-types@4.20260529.1': - resolution: {integrity: sha512-33n3nsaWELSgn4DLKj1X9dwZc3kVDnO+jF/hLH9fdaXG9mQzKDeUkQaVRWLJXvrPXPa9RaIuSAFO4Zh9YOqOog==} + '@cloudflare/workers-types@4.20260530.1': + resolution: {integrity: sha512-H0BcFCJqqDwoDUY88mv3qJuA3DUdnMmtUdsHRrEZY+SRhXOK8TyFqFb+iS7A/84+qzpTFmFzWo6JN9tVALO2nw==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@earendil-works/pi-agent-core@0.75.5': - resolution: {integrity: sha512-LHygOgsW2pgXKb3IkXkOAeZPovHr9VF+EixgXVsDNuB4jmhEOXgshy/zksZ7slkUAx10OQ9W1Ed/2jsnhd1NqA==} + '@earendil-works/pi-agent-core@0.78.0': + resolution: {integrity: sha512-xhWd59Qzd8yO88gYQw2S4dEQstJJEiUtxRP01//YzVJ61jCtUASMfcyAmYhgGYR4Onp7GmwEAbBBGOiV6Iwk9g==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@0.75.5': - resolution: {integrity: sha512-zf1F5kXk1pqZeFShXOqq9ibUk8QdtRoLCDPAjO+hj44e3EUs9/GFO2qnhTC5+JA2uwVCx+WCNe1PiCjlBYWm5w==} + '@earendil-works/pi-ai@0.78.0': + resolution: {integrity: sha512-q0hUrvT6ngT6cgBX0oIbzfQfmzztgdkZobP8OTL+sCOOBlnG6+1YRt8g7zO9CC/4NdeYEqa7uGqWdQhH0fjCLA==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-coding-agent@0.75.5': - resolution: {integrity: sha512-O3CCQDYy28D4uwtP6zZkdEwzHN6X22v49Sb0+SZTC7x37V/YfmogrWPiaFoWeoc2hmdKhSATI7ZAK5bQbJG5NA==} + '@earendil-works/pi-coding-agent@0.78.0': + resolution: {integrity: sha512-gXt6pD3BoSG0yLwfLqb6844vz6qAO87PvNrv+YSDYKP3QliTjcwIld9v4ihmDcmBjO13QwKswubq/lYCvn4bkg==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-tui@0.75.5': - resolution: {integrity: sha512-LkXUM1/49pvzzeI39Y5wjBMlgafcCf67HCLhB9Z7yuXHy4XgT+VqxWcZVW5hBdhQsHZd0znjJotfGH1BzxMfiA==} + '@earendil-works/pi-tui@0.78.0': + resolution: {integrity: sha512-3a705FnsVVUhAyceShNB3kS2rpxcxLcx+hqB0u6MMMpHwQGbW+m++MqA6r7eOzq/8FLx5e3vDh38h/SVTk2qzw==} engines: {node: '>=22.19.0'} '@emmetio/abbreviation@2.3.3': @@ -1538,72 +1490,72 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@mariozechner/clipboard-darwin-arm64@0.3.6': - resolution: {integrity: sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==} + '@mariozechner/clipboard-darwin-arm64@0.3.9': + resolution: {integrity: sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@mariozechner/clipboard-darwin-universal@0.3.6': - resolution: {integrity: sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==} + '@mariozechner/clipboard-darwin-universal@0.3.9': + resolution: {integrity: sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==} engines: {node: '>= 10'} os: [darwin] - '@mariozechner/clipboard-darwin-x64@0.3.6': - resolution: {integrity: sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==} + '@mariozechner/clipboard-darwin-x64@0.3.9': + resolution: {integrity: sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@mariozechner/clipboard-linux-arm64-gnu@0.3.6': - resolution: {integrity: sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==} + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + resolution: {integrity: sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@mariozechner/clipboard-linux-arm64-musl@0.3.6': - resolution: {integrity: sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==} + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@mariozechner/clipboard-linux-riscv64-gnu@0.3.6': - resolution: {integrity: sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==} + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@mariozechner/clipboard-linux-x64-gnu@0.3.6': - resolution: {integrity: sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==} + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@mariozechner/clipboard-linux-x64-musl@0.3.6': - resolution: {integrity: sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==} + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@mariozechner/clipboard-win32-arm64-msvc@0.3.6': - resolution: {integrity: sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==} + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@mariozechner/clipboard-win32-x64-msvc@0.3.6': - resolution: {integrity: sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==} + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + resolution: {integrity: sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@mariozechner/clipboard@0.3.6': - resolution: {integrity: sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==} + '@mariozechner/clipboard@0.3.9': + resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} engines: {node: '>= 10'} '@mistralai/mistralai@2.2.1': @@ -1655,9 +1607,6 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@nodable/entities@2.1.0': - resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} - '@nodable/entities@2.1.1': resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} @@ -1731,12 +1680,12 @@ packages: '@octokit/types@14.1.0': resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} - '@opencode-ai/plugin@1.15.10': - resolution: {integrity: sha512-V2p7CvpBtKWB+FID7Dl1y0Ci02zUT40A9b2RD9R9BOiuD8ZcKhHWov+irN0xVJA0Eg6OhEBfA0lPKRn1FNKPlw==} + '@opencode-ai/plugin@1.15.12': + resolution: {integrity: sha512-BBteGXEwJt+1ehHqQ+yKXmoWltrW+2xO++B1Fm/dnMGYWT9luEKA5RlUuVYA2qDF6uwlE7kmHZvQZAM79zWHEA==} peerDependencies: - '@opentui/core': '>=0.2.15' - '@opentui/keymap': '>=0.2.15' - '@opentui/solid': '>=0.2.15' + '@opentui/core': '>=0.2.16' + '@opentui/keymap': '>=0.2.16' + '@opentui/solid': '>=0.2.16' peerDependenciesMeta: '@opentui/core': optional: true @@ -1745,12 +1694,15 @@ packages: '@opentui/solid': optional: true - '@opencode-ai/sdk@1.15.10': - resolution: {integrity: sha512-CUhpmMGGOqzvPnNNjjWmEIodAfP6Qnuki2ChIUKWYF7UImZ4zUcMZnzO5BtUxu/Ni1P8qzWxDioXs+7aIZQEhA==} + '@opencode-ai/sdk@1.15.12': + resolution: {integrity: sha512-lOaBNX93dkakZe6C42ttX1bkSx3K2c6+Yv+w8Qv02v5rPlu1vCXbmdfYDh9/bw+oq+NKPSaBm9d6kPA19hA5Lg==} '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@oxc-project/types@0.132.0': + resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} + '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} @@ -2041,36 +1993,73 @@ packages: '@protobufjs/utf8@1.1.1': resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@rolldown/binding-android-arm64@1.0.2': + resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@rolldown/binding-darwin-arm64@1.0.2': + resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.3': resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.2': + resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.3': resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.2': + resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.3': resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.0.3': resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2078,6 +2067,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2085,6 +2081,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2092,6 +2095,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.2': + resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2099,6 +2109,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2106,6 +2123,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2113,23 +2137,46 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-openharmony-arm64@1.0.2': + resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.2': + resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.3': resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.3': resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.3': resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2139,8 +2186,8 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} engines: {node: '>=14.0.0'} peerDependencies: rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 @@ -2331,24 +2378,12 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@smithy/core@3.24.4': - resolution: {integrity: sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==} - engines: {node: '>=18.0.0'} - '@smithy/core@3.24.5': resolution: {integrity: sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.3.4': - resolution: {integrity: sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==} - engines: {node: '>=18.0.0'} - - '@smithy/credential-provider-imds@4.3.5': - resolution: {integrity: sha512-yiF8xHpdkaTfzLVqFzsP6WvNghEK+qZzLYWFD13L2SsFhbXwBGlxdocKF95qjr7s5lE5NRage+EJFK4mAsx88Q==} - engines: {node: '>=18.0.0'} - - '@smithy/fetch-http-handler@5.4.4': - resolution: {integrity: sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==} + '@smithy/credential-provider-imds@4.3.6': + resolution: {integrity: sha512-tHhdiWZfG1ZIh2YcRfPJmY2gHcBmqbAzqm3ER4TIDFYsSEqTD5tICT7cgQ/kI8LRakxp12myOYyK68XPn7MnHw==} engines: {node: '>=18.0.0'} '@smithy/fetch-http-handler@5.4.5': @@ -2367,18 +2402,10 @@ packages: resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.7.4': - resolution: {integrity: sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==} - engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.7.5': resolution: {integrity: sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.4.4': - resolution: {integrity: sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==} - engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.4.5': resolution: {integrity: sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==} engines: {node: '>=18.0.0'} @@ -3896,8 +3923,8 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - lint-staged@17.0.5: - resolution: {integrity: sha512-d12yC+/e8RhBjZtaxZn71FyrgU/P5e+uAPifhCLwdosQZP/zamSdKRWDC30ocVIbzDKiFG1McHc/LUgB92GIPw==} + lint-staged@17.0.6: + resolution: {integrity: sha512-xTowloQX5tfs9TC6SUHnKuBSx/TUx+9w39zRTbVrB70DxUJZh3OZWnOa0LbejxVX9adYuioGJoP4dpQ04QHehg==} engines: {node: '>=22.22.1'} hasBin: true @@ -3929,10 +3956,6 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.5.0: - resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} - engines: {node: 20 || >=22} - lru-cache@11.5.1: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} @@ -4588,6 +4611,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown@1.0.2: + resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4833,6 +4861,10 @@ packages: resolution: {integrity: sha512-8OqlXQ35euK9+e7L68u8UwcODxkHoIkjbGsgXuARKNyQ5G6xt8nw1YPeMbxMLgCPFkToU+UEK5j05t2t8edKpQ==} engines: {node: ^16.14.0 || >= 17.3.0} + tinyexec@1.2.2: + resolution: {integrity: sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==} + engines: {node: '>=18'} + tinyexec@1.2.3: resolution: {integrity: sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==} engines: {node: '>=18'} @@ -5099,6 +5131,49 @@ packages: yaml: optional: true + vite@8.0.14: + resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitefu@1.1.3: resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} peerDependencies: @@ -5558,25 +5633,25 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.13 - '@aws-sdk/credential-provider-node': 3.972.44 - '@aws-sdk/eventstream-handler-node': 3.972.17 - '@aws-sdk/middleware-eventstream': 3.972.13 - '@aws-sdk/middleware-websocket': 3.972.21 + '@aws-sdk/core': 3.974.15 + '@aws-sdk/credential-provider-node': 3.972.47 + '@aws-sdk/eventstream-handler-node': 3.972.18 + '@aws-sdk/middleware-eventstream': 3.972.14 + '@aws-sdk/middleware-websocket': 3.972.23 '@aws-sdk/token-providers': 3.1048.0 '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 - '@smithy/fetch-http-handler': 5.4.4 + '@smithy/core': 3.24.5 + '@smithy/fetch-http-handler': 5.4.5 '@smithy/node-http-handler': 4.7.3 '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/client-cognito-identity@3.1056.0': + '@aws-sdk/client-cognito-identity@3.1057.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 '@aws-sdk/core': 3.974.15 - '@aws-sdk/credential-provider-node': 3.972.46 + '@aws-sdk/credential-provider-node': 3.972.47 '@aws-sdk/types': 3.973.9 '@smithy/core': 3.24.5 '@smithy/fetch-http-handler': 5.4.5 @@ -5584,17 +5659,6 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/core@3.974.13': - dependencies: - '@aws-sdk/types': 3.973.9 - '@aws-sdk/xml-builder': 3.972.25 - '@aws/lambda-invoke-store': 0.2.4 - '@smithy/core': 3.24.4 - '@smithy/signature-v4': 5.4.4 - '@smithy/types': 4.14.2 - bowser: 2.14.1 - tslib: 2.8.1 - '@aws-sdk/core@3.974.15': dependencies: '@aws-sdk/types': 3.973.9 @@ -5614,14 +5678,6 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.39': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.41': dependencies: '@aws-sdk/core': 3.974.15 @@ -5630,16 +5686,6 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.41': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 - '@smithy/fetch-http-handler': 5.4.4 - '@smithy/node-http-handler': 4.7.4 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.43': dependencies: '@aws-sdk/core': 3.974.15 @@ -5650,23 +5696,7 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.43': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/credential-provider-env': 3.972.39 - '@aws-sdk/credential-provider-http': 3.972.41 - '@aws-sdk/credential-provider-login': 3.972.43 - '@aws-sdk/credential-provider-process': 3.972.39 - '@aws-sdk/credential-provider-sso': 3.972.43 - '@aws-sdk/credential-provider-web-identity': 3.972.43 - '@aws-sdk/nested-clients': 3.997.11 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 - '@smithy/credential-provider-imds': 4.3.4 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-ini@3.972.45': + '@aws-sdk/credential-provider-ini@3.972.46': dependencies: '@aws-sdk/core': 3.974.15 '@aws-sdk/credential-provider-env': 3.972.41 @@ -5678,16 +5708,7 @@ snapshots: '@aws-sdk/nested-clients': 3.997.13 '@aws-sdk/types': 3.973.9 '@smithy/core': 3.24.5 - '@smithy/credential-provider-imds': 4.3.5 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-login@3.972.43': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/nested-clients': 3.997.11 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 + '@smithy/credential-provider-imds': 4.3.6 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -5700,39 +5721,17 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.44': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.39 - '@aws-sdk/credential-provider-http': 3.972.41 - '@aws-sdk/credential-provider-ini': 3.972.43 - '@aws-sdk/credential-provider-process': 3.972.39 - '@aws-sdk/credential-provider-sso': 3.972.43 - '@aws-sdk/credential-provider-web-identity': 3.972.43 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 - '@smithy/credential-provider-imds': 4.3.4 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-node@3.972.46': + '@aws-sdk/credential-provider-node@3.972.47': dependencies: '@aws-sdk/credential-provider-env': 3.972.41 '@aws-sdk/credential-provider-http': 3.972.43 - '@aws-sdk/credential-provider-ini': 3.972.45 + '@aws-sdk/credential-provider-ini': 3.972.46 '@aws-sdk/credential-provider-process': 3.972.41 '@aws-sdk/credential-provider-sso': 3.972.45 '@aws-sdk/credential-provider-web-identity': 3.972.45 '@aws-sdk/types': 3.973.9 '@smithy/core': 3.24.5 - '@smithy/credential-provider-imds': 4.3.5 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-process@3.972.39': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 + '@smithy/credential-provider-imds': 4.3.6 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -5744,16 +5743,6 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.43': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/nested-clients': 3.997.11 - '@aws-sdk/token-providers': 3.1052.0 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.45': dependencies: '@aws-sdk/core': 3.974.15 @@ -5764,15 +5753,6 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.43': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/nested-clients': 3.997.11 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.45': dependencies: '@aws-sdk/core': 3.974.15 @@ -5782,60 +5762,47 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/credential-providers@3.1056.0': + '@aws-sdk/credential-providers@3.1057.0': dependencies: - '@aws-sdk/client-cognito-identity': 3.1056.0 + '@aws-sdk/client-cognito-identity': 3.1057.0 '@aws-sdk/core': 3.974.15 '@aws-sdk/credential-provider-cognito-identity': 3.972.38 '@aws-sdk/credential-provider-env': 3.972.41 '@aws-sdk/credential-provider-http': 3.972.43 - '@aws-sdk/credential-provider-ini': 3.972.45 + '@aws-sdk/credential-provider-ini': 3.972.46 '@aws-sdk/credential-provider-login': 3.972.45 - '@aws-sdk/credential-provider-node': 3.972.46 + '@aws-sdk/credential-provider-node': 3.972.47 '@aws-sdk/credential-provider-process': 3.972.41 '@aws-sdk/credential-provider-sso': 3.972.45 '@aws-sdk/credential-provider-web-identity': 3.972.45 '@aws-sdk/nested-clients': 3.997.13 '@aws-sdk/types': 3.973.9 '@smithy/core': 3.24.5 - '@smithy/credential-provider-imds': 4.3.5 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/eventstream-handler-node@3.972.17': - dependencies: - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 + '@smithy/credential-provider-imds': 4.3.6 '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/middleware-eventstream@3.972.13': + '@aws-sdk/eventstream-handler-node@3.972.18': dependencies: '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 + '@smithy/core': 3.24.5 '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/middleware-websocket@3.972.21': + '@aws-sdk/middleware-eventstream@3.972.14': dependencies: - '@aws-sdk/core': 3.974.13 '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 - '@smithy/fetch-http-handler': 5.4.4 - '@smithy/signature-v4': 5.4.4 + '@smithy/core': 3.24.5 '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.11': + '@aws-sdk/middleware-websocket@3.972.23': dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.13 - '@aws-sdk/signature-v4-multi-region': 3.996.28 + '@aws-sdk/core': 3.974.15 '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 - '@smithy/fetch-http-handler': 5.4.4 - '@smithy/node-http-handler': 4.7.4 + '@smithy/core': 3.24.5 + '@smithy/fetch-http-handler': 5.4.5 + '@smithy/signature-v4': 5.4.5 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -5852,14 +5819,6 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.28': - dependencies: - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 - '@smithy/signature-v4': 5.4.4 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.30': dependencies: '@aws-sdk/types': 3.973.9 @@ -5869,19 +5828,10 @@ snapshots: '@aws-sdk/token-providers@3.1048.0': dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/nested-clients': 3.997.11 - '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@aws-sdk/token-providers@3.1052.0': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/core': 3.974.15 + '@aws-sdk/nested-clients': 3.997.13 '@aws-sdk/types': 3.973.9 - '@smithy/core': 3.24.4 + '@smithy/core': 3.24.5 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -5903,13 +5853,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.25': - dependencies: - '@nodable/entities': 2.1.0 - '@smithy/types': 4.14.2 - fast-xml-parser: 5.7.3 - tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.26': dependencies: '@smithy/types': 4.14.2 @@ -6080,14 +6023,14 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 - '@clack/core@1.3.1': + '@clack/core@1.4.0': dependencies: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clack/prompts@1.4.0': + '@clack/prompts@1.5.0': dependencies: - '@clack/core': 1.3.1 + '@clack/core': 1.4.0 fast-string-width: 3.0.2 fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 @@ -6136,15 +6079,15 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260526.1': optional: true - '@cloudflare/workers-types@4.20260529.1': {} + '@cloudflare/workers-types@4.20260530.1': {} '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@earendil-works/pi-agent-core@0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-agent-core@0.78.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-ai': 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -6156,7 +6099,7 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.78.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 @@ -6176,11 +6119,11 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-coding-agent@0.78.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: - '@earendil-works/pi-agent-core': 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-ai': 0.75.5(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - '@earendil-works/pi-tui': 0.75.5 + '@earendil-works/pi-agent-core': 0.78.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.78.0 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 @@ -6196,7 +6139,7 @@ snapshots: undici: 8.3.0 yaml: 2.9.0 optionalDependencies: - '@mariozechner/clipboard': 0.3.6 + '@mariozechner/clipboard': 0.3.9 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil @@ -6205,7 +6148,7 @@ snapshots: - ws - zod - '@earendil-works/pi-tui@0.75.5': + '@earendil-works/pi-tui@0.78.0': dependencies: get-east-asian-width: 1.6.0 marked: 15.0.12 @@ -6746,48 +6689,48 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@mariozechner/clipboard-darwin-arm64@0.3.6': + '@mariozechner/clipboard-darwin-arm64@0.3.9': optional: true - '@mariozechner/clipboard-darwin-universal@0.3.6': + '@mariozechner/clipboard-darwin-universal@0.3.9': optional: true - '@mariozechner/clipboard-darwin-x64@0.3.6': + '@mariozechner/clipboard-darwin-x64@0.3.9': optional: true - '@mariozechner/clipboard-linux-arm64-gnu@0.3.6': + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': optional: true - '@mariozechner/clipboard-linux-arm64-musl@0.3.6': + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': optional: true - '@mariozechner/clipboard-linux-riscv64-gnu@0.3.6': + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': optional: true - '@mariozechner/clipboard-linux-x64-gnu@0.3.6': + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': optional: true - '@mariozechner/clipboard-linux-x64-musl@0.3.6': + '@mariozechner/clipboard-linux-x64-musl@0.3.9': optional: true - '@mariozechner/clipboard-win32-arm64-msvc@0.3.6': + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': optional: true - '@mariozechner/clipboard-win32-x64-msvc@0.3.6': + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': optional: true - '@mariozechner/clipboard@0.3.6': + '@mariozechner/clipboard@0.3.9': optionalDependencies: - '@mariozechner/clipboard-darwin-arm64': 0.3.6 - '@mariozechner/clipboard-darwin-universal': 0.3.6 - '@mariozechner/clipboard-darwin-x64': 0.3.6 - '@mariozechner/clipboard-linux-arm64-gnu': 0.3.6 - '@mariozechner/clipboard-linux-arm64-musl': 0.3.6 - '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.6 - '@mariozechner/clipboard-linux-x64-gnu': 0.3.6 - '@mariozechner/clipboard-linux-x64-musl': 0.3.6 - '@mariozechner/clipboard-win32-arm64-msvc': 0.3.6 - '@mariozechner/clipboard-win32-x64-msvc': 0.3.6 + '@mariozechner/clipboard-darwin-arm64': 0.3.9 + '@mariozechner/clipboard-darwin-universal': 0.3.9 + '@mariozechner/clipboard-darwin-x64': 0.3.9 + '@mariozechner/clipboard-linux-arm64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-arm64-musl': 0.3.9 + '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-musl': 0.3.9 + '@mariozechner/clipboard-win32-arm64-msvc': 0.3.9 + '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 optional: true '@mistralai/mistralai@2.2.1': @@ -6846,8 +6789,6 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@nodable/entities@2.1.0': {} - '@nodable/entities@2.1.1': {} '@nodelib/fs.scandir@2.1.5': @@ -6930,18 +6871,20 @@ snapshots: dependencies: '@octokit/openapi-types': 25.1.0 - '@opencode-ai/plugin@1.15.10': + '@opencode-ai/plugin@1.15.12': dependencies: - '@opencode-ai/sdk': 1.15.10 + '@opencode-ai/sdk': 1.15.12 effect: 4.0.0-beta.66 zod: 4.1.8 - '@opencode-ai/sdk@1.15.10': + '@opencode-ai/sdk@1.15.12': dependencies: cross-spawn: 7.0.6 '@oslojs/encoding@1.1.0': {} + '@oxc-project/types@0.132.0': {} + '@oxc-project/types@0.133.0': {} '@oxfmt/binding-android-arm-eabi@0.52.0': @@ -7095,42 +7038,85 @@ snapshots: '@protobufjs/utf8@1.1.1': {} + '@rolldown/binding-android-arm64@1.0.2': + optional: true + '@rolldown/binding-android-arm64@1.0.3': optional: true + '@rolldown/binding-darwin-arm64@1.0.2': + optional: true + '@rolldown/binding-darwin-arm64@1.0.3': optional: true + '@rolldown/binding-darwin-x64@1.0.2': + optional: true + '@rolldown/binding-darwin-x64@1.0.3': optional: true + '@rolldown/binding-freebsd-x64@1.0.2': + optional: true + '@rolldown/binding-freebsd-x64@1.0.3': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.2': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.3': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.2': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.3': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.3': optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.2': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.3': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.2': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.3': optional: true + '@rolldown/binding-linux-x64-musl@1.0.2': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.3': optional: true + '@rolldown/binding-openharmony-arm64@1.0.2': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.3': optional: true + '@rolldown/binding-wasm32-wasi@1.0.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + '@rolldown/binding-wasm32-wasi@1.0.3': dependencies: '@emnapi/core': 1.10.0 @@ -7138,15 +7124,21 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.2': + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.3': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.2': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.3': optional: true '@rolldown/pluginutils@1.0.1': {} - '@rollup/pluginutils@5.3.0(rollup@4.60.4)': + '@rollup/pluginutils@5.4.0(rollup@4.60.4)': dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 @@ -7277,36 +7269,18 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@smithy/core@3.24.4': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - '@smithy/core@3.24.5': dependencies: '@aws-crypto/crc32': 5.2.0 '@smithy/types': 4.14.2 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.3.4': - dependencies: - '@smithy/core': 3.24.4 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@smithy/credential-provider-imds@4.3.5': + '@smithy/credential-provider-imds@4.3.6': dependencies: '@smithy/core': 3.24.5 '@smithy/types': 4.14.2 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.4.4': - dependencies: - '@smithy/core': 3.24.4 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - '@smithy/fetch-http-handler@5.4.5': dependencies: '@smithy/core': 3.24.5 @@ -7324,13 +7298,7 @@ snapshots: '@smithy/node-http-handler@4.7.3': dependencies: - '@smithy/core': 3.24.4 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - - '@smithy/node-http-handler@4.7.4': - dependencies: - '@smithy/core': 3.24.4 + '@smithy/core': 3.24.5 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -7340,12 +7308,6 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@smithy/signature-v4@5.4.4': - dependencies: - '@smithy/core': 3.24.4 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - '@smithy/signature-v4@5.4.5': dependencies: '@smithy/core': 3.24.5 @@ -7544,13 +7506,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0))': + '@vitest/mocker@4.1.7(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) '@vitest/pretty-format@4.1.7': dependencies: @@ -7648,17 +7610,17 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@0.93.9(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0))(workerd@1.20260526.1): + alchemy@0.93.9(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))(workerd@1.20260526.1): dependencies: - '@aws-sdk/credential-providers': 3.1056.0 + '@aws-sdk/credential-providers': 3.1057.0 '@cloudflare/unenv-preset': 2.7.7(unenv@2.0.0-rc.21)(workerd@1.20260526.1) - '@cloudflare/workers-types': 4.20260529.1 + '@cloudflare/workers-types': 4.20260530.1 '@iarna/toml': 2.2.5 '@octokit/rest': 21.1.1 '@smithy/node-config-provider': 4.4.5 '@smithy/types': 4.14.2 aws4fetch: 1.0.20 - drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260529.1) + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260530.1) env-paths: 3.0.0 esbuild: 0.25.12 execa: 9.6.1 @@ -7677,11 +7639,11 @@ snapshots: proper-lockfile: 4.1.2 signal-exit: 4.1.0 unenv: 2.0.0-rc.21 - wrangler: 4.95.0(@cloudflare/workers-types@4.20260529.1) + wrangler: 4.95.0(@cloudflare/workers-types@4.20260530.1) ws: 8.21.0 yaml: 2.9.0 optionalDependencies: - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - '@aws-sdk/client-rds-data' - '@electric-sql/pglite' @@ -7756,9 +7718,9 @@ snapshots: '@astrojs/markdown-remark': 7.2.0 '@astrojs/telemetry': 3.3.2 '@capsizecss/unpack': 4.0.0 - '@clack/prompts': 1.4.0 + '@clack/prompts': 1.5.0 '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.60.4) + '@rollup/pluginutils': 5.4.0(rollup@4.60.4) aria-query: 5.3.2 axobject-query: 4.1.0 ci-info: 4.4.0 @@ -8094,9 +8056,9 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 - drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260529.1): + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260530.1): optionalDependencies: - '@cloudflare/workers-types': 4.20260529.1 + '@cloudflare/workers-types': 4.20260530.1 dset@3.1.4: {} @@ -8721,7 +8683,7 @@ snapshots: hosted-git-info@9.0.3: dependencies: - lru-cache: 11.5.0 + lru-cache: 11.5.1 html-escaper@3.0.3: {} @@ -8942,12 +8904,12 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - lint-staged@17.0.5: + lint-staged@17.0.6: dependencies: listr2: 10.2.1 picomatch: 4.0.4 string-argv: 0.3.2 - tinyexec: 1.2.3 + tinyexec: 1.2.2 optionalDependencies: yaml: 2.9.0 @@ -8981,8 +8943,6 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.5.0: {} - lru-cache@11.5.1: {} magic-string@0.30.21: @@ -9611,7 +9571,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.5.0 + lru-cache: 11.5.1 minipass: 7.1.3 path-to-regexp@6.3.0: {} @@ -9854,6 +9814,27 @@ snapshots: rfdc@1.4.1: {} + rolldown@1.0.2: + dependencies: + '@oxc-project/types': 0.132.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.2 + '@rolldown/binding-darwin-arm64': 1.0.2 + '@rolldown/binding-darwin-x64': 1.0.2 + '@rolldown/binding-freebsd-x64': 1.0.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 + '@rolldown/binding-linux-arm64-gnu': 1.0.2 + '@rolldown/binding-linux-arm64-musl': 1.0.2 + '@rolldown/binding-linux-ppc64-gnu': 1.0.2 + '@rolldown/binding-linux-s390x-gnu': 1.0.2 + '@rolldown/binding-linux-x64-gnu': 1.0.2 + '@rolldown/binding-linux-x64-musl': 1.0.2 + '@rolldown/binding-openharmony-arm64': 1.0.2 + '@rolldown/binding-wasm32-wasi': 1.0.2 + '@rolldown/binding-win32-arm64-msvc': 1.0.2 + '@rolldown/binding-win32-x64-msvc': 1.0.2 + rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -10177,6 +10158,8 @@ snapshots: tinyclip@0.1.13: {} + tinyexec@1.2.2: {} + tinyexec@1.2.3: {} tinyglobby@0.2.16: @@ -10380,14 +10363,29 @@ snapshots: tsx: 4.22.3 yaml: 2.9.0 + vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.2 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.9.1 + esbuild: 0.28.0 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.22.3 + yaml: 2.9.0 + vitefu@1.1.3(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)): optionalDependencies: vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) - vitest@4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)): + vitest@4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)) + '@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.7 '@vitest/runner': 4.1.7 '@vitest/snapshot': 4.1.7 @@ -10404,7 +10402,7 @@ snapshots: tinyexec: 1.2.3 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.1 @@ -10539,7 +10537,7 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260526.1 '@cloudflare/workerd-windows-64': 1.20260526.1 - wrangler@4.95.0(@cloudflare/workers-types@4.20260529.1): + wrangler@4.95.0(@cloudflare/workers-types@4.20260530.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260526.1) @@ -10551,7 +10549,7 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260526.1 optionalDependencies: - '@cloudflare/workers-types': 4.20260529.1 + '@cloudflare/workers-types': 4.20260530.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil diff --git a/schemas/caplet.schema.json b/schemas/caplet.schema.json index 6d75922..067bec9 100644 --- a/schemas/caplet.schema.json +++ b/schemas/caplet.schema.json @@ -29,6 +29,117 @@ "maxLength": 80 } }, + "setup": { + "type": "object", + "properties": { + "commands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable setup or verification step label." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this command.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["label", "command"], + "additionalProperties": false + } + }, + "verify": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable setup or verification step label." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this command.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["label", "command"], + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "description": "Optional explicit setup and verification metadata for this Caplet." + }, "mcpServer": { "type": "object", "properties": { diff --git a/schemas/caplets-config.schema.json b/schemas/caplets-config.schema.json index 645b252..66b8ab5 100644 --- a/schemas/caplets-config.schema.json +++ b/schemas/caplets-config.schema.json @@ -301,6 +301,116 @@ "maxLength": 80 } }, + "setup": { + "type": "object", + "properties": { + "commands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable setup or verification step label." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this command.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["label", "command"], + "additionalProperties": false + } + }, + "verify": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable setup or verification step label." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this command.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["label", "command"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, "startupTimeoutMs": { "default": 10000, "description": "Timeout in milliseconds for starting or checking a downstream server.", @@ -544,6 +654,116 @@ "maxLength": 80 } }, + "setup": { + "type": "object", + "properties": { + "commands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable setup or verification step label." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this command.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["label", "command"], + "additionalProperties": false + } + }, + "verify": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable setup or verification step label." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this command.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["label", "command"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, "requestTimeoutMs": { "default": 60000, "description": "Timeout in milliseconds for OpenAPI HTTP requests.", @@ -819,6 +1039,116 @@ "maxLength": 80 } }, + "setup": { + "type": "object", + "properties": { + "commands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable setup or verification step label." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this command.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["label", "command"], + "additionalProperties": false + } + }, + "verify": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable setup or verification step label." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this command.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["label", "command"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, "requestTimeoutMs": { "default": 60000, "description": "Timeout in milliseconds for GraphQL HTTP requests.", @@ -1144,6 +1474,116 @@ "maxLength": 80 } }, + "setup": { + "type": "object", + "properties": { + "commands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable setup or verification step label." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this command.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["label", "command"], + "additionalProperties": false + } + }, + "verify": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable setup or verification step label." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this command.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["label", "command"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, "requestTimeoutMs": { "default": 60000, "description": "Timeout in milliseconds for HTTP action requests.", @@ -1318,6 +1758,116 @@ "maxLength": 80 } }, + "setup": { + "type": "object", + "properties": { + "commands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable setup or verification step label." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this command.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["label", "command"], + "additionalProperties": false + } + }, + "verify": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable setup or verification step label." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this command.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["label", "command"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, "timeoutMs": { "default": 60000, "description": "Default timeout in milliseconds for CLI actions.", @@ -1402,6 +1952,116 @@ "maxLength": 80 } }, + "setup": { + "type": "object", + "properties": { + "commands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable setup or verification step label." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this command.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["label", "command"], + "additionalProperties": false + } + }, + "verify": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable setup or verification step label." + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this command.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["label", "command"], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, "disabled": { "default": false, "description": "When true, omit this Caplet set.", diff --git a/scripts/alchemy-runner.test.ts b/scripts/alchemy-runner.test.ts deleted file mode 100644 index f7227b6..0000000 --- a/scripts/alchemy-runner.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { expect, test } from "vitest"; -import { fileURLToPath } from "node:url"; - -import { buildNodeOptions } from "./alchemy-runner"; - -const shimPath = fileURLToPath(new URL("./alchemy-fetch-compat.ts", import.meta.url)); - -test("Alchemy runner injects fetch shim through NODE_OPTIONS for child processes", () => { - expect(buildNodeOptions(undefined)).toBe(`--import=${shimPath}`); -}); - -test("Alchemy runner preserves existing NODE_OPTIONS after the fetch shim", () => { - expect(buildNodeOptions("--trace-warnings")).toBe(`--import=${shimPath} --trace-warnings`); -}); diff --git a/scripts/mutagen-probe.test.ts b/scripts/mutagen-probe.test.ts new file mode 100644 index 0000000..ffec24a --- /dev/null +++ b/scripts/mutagen-probe.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { mutagenBuildIsAllowed, parseMutagenVersionOutput } from "./mutagen-probe"; + +describe("mutagen probe", () => { + it("parses version output with license metadata", () => { + const output = ["Mutagen version 0.18.1", "Build type: release", "License profile: mit"].join( + "\n", + ); + + expect(parseMutagenVersionOutput(output)).toEqual({ + version: "0.18.1", + licenseProfile: "mit", + }); + }); + + it("rejects SSPL builds for bundled hosted product use", () => { + expect(mutagenBuildIsAllowed({ version: "0.18.1", licenseProfile: "sspl" })).toBe(false); + }); + + it("accepts MIT-only builds", () => { + expect(mutagenBuildIsAllowed({ version: "0.18.1", licenseProfile: "mit" })).toBe(true); + }); +}); diff --git a/scripts/mutagen-probe.ts b/scripts/mutagen-probe.ts new file mode 100644 index 0000000..012dd51 --- /dev/null +++ b/scripts/mutagen-probe.ts @@ -0,0 +1,19 @@ +export type MutagenBuildInfo = { + version: string; + licenseProfile: "mit" | "sspl" | "unknown"; +}; + +export function parseMutagenVersionOutput(output: string): MutagenBuildInfo { + const version = output.match(/Mutagen version\s+([^\s]+)/u)?.[1] ?? "unknown"; + const normalized = output.toLowerCase(); + const licenseProfile = normalized.includes("license profile: mit") + ? "mit" + : normalized.includes("license profile: sspl") + ? "sspl" + : "unknown"; + return { version, licenseProfile }; +} + +export function mutagenBuildIsAllowed(info: MutagenBuildInfo): boolean { + return info.licenseProfile === "mit"; +} diff --git a/tsconfig.json b/tsconfig.json index 0dbf657..1748f7a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "./packages/core/tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true + }, "exclude": ["node_modules", ".turbo", "dist", "apps", "packages", "tools"] } diff --git a/vitest.config.ts b/vitest.config.ts index 7ae22ff..7021603 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,8 +5,8 @@ export default defineConfig({ projects: [ { test: { - name: "scripts", - include: ["scripts/**/*.test.ts"], + name: "root", + include: ["infra/**/*.test.ts", "scripts/**/*.test.ts"], }, }, "apps/*", From a8333b8eddeaeb0737dc0aabb29909a30d7a47ac Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 1 Jun 2026 09:11:14 -0400 Subject: [PATCH 04/19] fix: verify failures --- mise.toml | 1 + package.json | 6 +- pnpm-lock.yaml | 199 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 3 deletions(-) diff --git a/mise.toml b/mise.toml index 6ea5a7e..bbf696b 100644 --- a/mise.toml +++ b/mise.toml @@ -1,2 +1,3 @@ [tools] node = "24" +pnpm = "11.5.0" diff --git a/package.json b/package.json index 24d116e..018cc0d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@caplets/monorepo", + "name": "@caplets/core-mono", "private": true, "type": "module", "scripts": { @@ -49,7 +49,7 @@ "vitest": "^4.1.7" }, "engines": { - "node": ">=22" + "node": ">=24" }, - "packageManager": "pnpm@11.4.0" + "packageManager": "pnpm@11.5.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6b535f..3f27dfa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,202 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + '@pnpm/exe': + specifier: ^11.5.0 + version: 11.5.0 + pnpm: + specifier: ^11.5.0 + version: 11.5.0 + +packages: + + '@pnpm/exe@11.5.0': + resolution: {integrity: sha512-4hzOXq1HHrNPjwI8k1rt7Ot/Yrdx1JX3pn/L/M95ii1gid1Q6ZK6dVg4+gbSgUdPsYmYDZ4/Yfc0A7vd5C0ndg==} + hasBin: true + + '@pnpm/linux-arm64@11.5.0': + resolution: {integrity: sha512-NV9HdzzCB0epuI9LqZZeTaqjH3OweNQSQCS76GzEkFxJHS9e5Gvu7tgex91gxVL7bCZ+R4yr/3d3yexBFtr2ug==} + cpu: [arm64] + os: [linux] + + '@pnpm/linux-x64@11.5.0': + resolution: {integrity: sha512-vH83rRx4iPk/bwm9pBVCn+5hXbcQI66I/4zk6Vc09SusJgTqOdbN4U6VhMcGIqSEdr901ksYGCyIbMv7f6Guew==} + cpu: [x64] + os: [linux] + + '@pnpm/linuxstatic-arm64@11.5.0': + resolution: {integrity: sha512-2nOnMW1rSwGv22q2yZz1HlGT3ly/Ij8wUlX0NB4n+Krx7nETRHA3MgWsbkVejxHknDcTulRVudAghuX9rgrXcw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/linuxstatic-x64@11.5.0': + resolution: {integrity: sha512-ONOC1Mg0JusHtjzkRlre9di1QO+GAjy4HP7jMjDx21yGhrSheNdUweTXbekMH1EflRd19kTU6d8M3zewJFPtVg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/macos-arm64@11.5.0': + resolution: {integrity: sha512-od0ALdTxs4A7s5vAH5q2l2phzCJb98+PVOW1rq7BGpWGeYxQ+EwvL+vq0KaO6iLsn/eVVoncCkgZ/k6QNYuTgw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/win-arm64@11.5.0': + resolution: {integrity: sha512-9HqbI80FjVVqFx4+EPxYYNfeP9Sx69W6kYqUDvOJn9G7RJ/2NNNQ898cVHTMpXlW1/PrMEcijmdpa/NjZIrWiQ==} + cpu: [arm64] + os: [win32] + + '@pnpm/win-x64@11.5.0': + resolution: {integrity: sha512-Q89CQqFGAsWmfvHZs5Kbbar45q3GBYtfAdPUCiVMVNJoLi3dsBS2LCvUq8ak3AufkFDaJBpvhaFcDP2M1NXr3A==} + cpu: [x64] + os: [win32] + + '@reflink/reflink-darwin-arm64@0.1.19': + resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@reflink/reflink-darwin-x64@0.1.19': + resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@reflink/reflink-linux-arm64-gnu@0.1.19': + resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@reflink/reflink-linux-arm64-musl@0.1.19': + resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@reflink/reflink-linux-x64-gnu@0.1.19': + resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@reflink/reflink-linux-x64-musl@0.1.19': + resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@reflink/reflink-win32-arm64-msvc@0.1.19': + resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@reflink/reflink-win32-x64-msvc@0.1.19': + resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@reflink/reflink@0.1.19': + resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==} + engines: {node: '>= 10'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + pnpm@11.5.0: + resolution: {integrity: sha512-2/zE+Bz0hZev1Lw5H/3xLBHxqfuDo5W/prCi2cwv2P/rr9scy9UpYyFT95OQTCYVt/Cf4aNFRz/Rw1hFFyqOsQ==} + engines: {node: '>=22.13'} + hasBin: true + +snapshots: + + '@pnpm/exe@11.5.0': + dependencies: + '@reflink/reflink': 0.1.19 + detect-libc: 2.1.2 + optionalDependencies: + '@pnpm/linux-arm64': 11.5.0 + '@pnpm/linux-x64': 11.5.0 + '@pnpm/linuxstatic-arm64': 11.5.0 + '@pnpm/linuxstatic-x64': 11.5.0 + '@pnpm/macos-arm64': 11.5.0 + '@pnpm/win-arm64': 11.5.0 + '@pnpm/win-x64': 11.5.0 + + '@pnpm/linux-arm64@11.5.0': + optional: true + + '@pnpm/linux-x64@11.5.0': + optional: true + + '@pnpm/linuxstatic-arm64@11.5.0': + optional: true + + '@pnpm/linuxstatic-x64@11.5.0': + optional: true + + '@pnpm/macos-arm64@11.5.0': + optional: true + + '@pnpm/win-arm64@11.5.0': + optional: true + + '@pnpm/win-x64@11.5.0': + optional: true + + '@reflink/reflink-darwin-arm64@0.1.19': + optional: true + + '@reflink/reflink-darwin-x64@0.1.19': + optional: true + + '@reflink/reflink-linux-arm64-gnu@0.1.19': + optional: true + + '@reflink/reflink-linux-arm64-musl@0.1.19': + optional: true + + '@reflink/reflink-linux-x64-gnu@0.1.19': + optional: true + + '@reflink/reflink-linux-x64-musl@0.1.19': + optional: true + + '@reflink/reflink-win32-arm64-msvc@0.1.19': + optional: true + + '@reflink/reflink-win32-x64-msvc@0.1.19': + optional: true + + '@reflink/reflink@0.1.19': + optionalDependencies: + '@reflink/reflink-darwin-arm64': 0.1.19 + '@reflink/reflink-darwin-x64': 0.1.19 + '@reflink/reflink-linux-arm64-gnu': 0.1.19 + '@reflink/reflink-linux-arm64-musl': 0.1.19 + '@reflink/reflink-linux-x64-gnu': 0.1.19 + '@reflink/reflink-linux-x64-musl': 0.1.19 + '@reflink/reflink-win32-arm64-msvc': 0.1.19 + '@reflink/reflink-win32-x64-msvc': 0.1.19 + + detect-libc@2.1.2: {} + + pnpm@11.5.0: {} + +--- lockfileVersion: '9.0' settings: From 15d4792048fef43d161258f1648348e2d25d51be Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Mon, 1 Jun 2026 10:24:16 -0400 Subject: [PATCH 05/19] fix: repo setup --- .lintstagedrc.json | 3 ++- .oxfmtrc.json | 2 +- .oxlintrc.json | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 69abf9b..02429c9 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,3 +1,4 @@ { - "*.{js,jsx,ts,tsx,mjs,cjs,json,jsonc,md,yml,yaml,css,astro}": ["oxfmt --check", "oxlint"] + "*.{js,jsx,ts,tsx,mjs,cjs,json,jsonc,md,yml,yaml,css,astro}": ["oxfmt --check", "oxlint"], + "*.{ts,tsx}": ["tsc --noEmit"] } diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 2f61360..17a8580 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": [".brv/"], + "ignorePatterns": ["**/.brv/**"], "plugins": ["prettier-plugin-astro"], "overrides": [ { diff --git a/.oxlintrc.json b/.oxlintrc.json index 627f137..5c839c4 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "ignorePatterns": [".brv/"], + "ignorePatterns": ["**/.brv/**"], "plugins": ["typescript", "unicorn", "oxc"], "categories": { "correctness": "error" From e7e821496c0c33d098224b8d7fb719068947bc2a Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Tue, 2 Jun 2026 06:16:44 -0400 Subject: [PATCH 06/19] fix: tests on mac --- .env.example | 6 - .lintstagedrc.json | 3 +- apps/cloud-ui/src/lib/cloud-api.ts | 78 ---- apps/cloud-ui/src/lib/mock-workspace.ts | 123 ----- packages/core/src/cli/add.ts | 21 +- packages/core/src/cli/install.ts | 11 + packages/core/src/cloud/apply.ts | 7 +- packages/core/src/config/paths.ts | 9 +- packages/core/test/config.test.ts | 106 +++-- packages/core/test/engine.test.ts | 5 + packages/core/test/native.test.ts | 5 + pnpm-lock.yaml | 578 +++--------------------- 12 files changed, 182 insertions(+), 770 deletions(-) delete mode 100644 .env.example delete mode 100644 apps/cloud-ui/src/lib/cloud-api.ts delete mode 100644 apps/cloud-ui/src/lib/mock-workspace.ts diff --git a/.env.example b/.env.example deleted file mode 100644 index 0246cf9..0000000 --- a/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -ALCHEMY_PASSWORD= -ALCHEMY_STATE_TOKEN= - -CLOUDFLARE_API_TOKEN= -CLOUDFLARE_ACCOUNT_ID= -CLOUDFLARE_EMAIL= diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 02429c9..69abf9b 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,4 +1,3 @@ { - "*.{js,jsx,ts,tsx,mjs,cjs,json,jsonc,md,yml,yaml,css,astro}": ["oxfmt --check", "oxlint"], - "*.{ts,tsx}": ["tsc --noEmit"] + "*.{js,jsx,ts,tsx,mjs,cjs,json,jsonc,md,yml,yaml,css,astro}": ["oxfmt --check", "oxlint"] } diff --git a/apps/cloud-ui/src/lib/cloud-api.ts b/apps/cloud-ui/src/lib/cloud-api.ts deleted file mode 100644 index 866542d..0000000 --- a/apps/cloud-ui/src/lib/cloud-api.ts +++ /dev/null @@ -1,78 +0,0 @@ -export interface WorkspaceSummary { - workspaceId: string; - slug: string; - name: string; - createdAt: string; -} - -export interface WorkspaceResponse { - workspace: WorkspaceSummary; -} - -export class CloudApiError extends Error { - constructor( - message: string, - readonly status: number, - ) { - super(message); - this.name = "CloudApiError"; - } -} - -export class CloudApiClient { - private readonly accessToken: string | undefined; - private readonly baseUrl: URL; - private readonly fetchImpl: typeof fetch; - - constructor({ - accessToken, - baseUrl, - fetchImpl, - }: { - accessToken?: string; - baseUrl: string | URL; - fetchImpl?: typeof fetch; - }) { - this.accessToken = accessToken; - this.baseUrl = new URL(baseUrl); - this.fetchImpl = fetchImpl ?? globalThis.fetch.bind(globalThis); - } - - async getWorkspace(slug: string, init: { signal?: AbortSignal } = {}): Promise { - const response = await this.fetchJson( - `api/workspaces/${encodeURIComponent(slug)}`, - init, - ); - return response.workspace; - } - - workspaceMcpEndpoint(slug: string): string { - return new URL(`ws/${encodeURIComponent(slug)}/mcp`, withTrailingSlash(this.baseUrl)).href; - } - - private async fetchJson(path: string, init: { signal?: AbortSignal } = {}): Promise { - const url = new URL(path, withTrailingSlash(this.baseUrl)); - const headers: Record = { accept: "application/json" }; - if (this.accessToken) headers.Authorization = `Bearer ${this.accessToken}`; - - const response = await this.fetchImpl(url, { - headers, - signal: init.signal, - }); - - if (!response.ok) { - throw new CloudApiError( - `Caplets Cloud API request failed: HTTP ${response.status}`, - response.status, - ); - } - - return (await response.json()) as T; - } -} - -function withTrailingSlash(url: URL): URL { - const next = new URL(url); - if (!next.pathname.endsWith("/")) next.pathname = `${next.pathname}/`; - return next; -} diff --git a/apps/cloud-ui/src/lib/mock-workspace.ts b/apps/cloud-ui/src/lib/mock-workspace.ts deleted file mode 100644 index 6251dde..0000000 --- a/apps/cloud-ui/src/lib/mock-workspace.ts +++ /dev/null @@ -1,123 +0,0 @@ -export type ConnectorStatus = "Ready" | "Needs OAuth" | "Local required"; - -export interface Connector { - id: string; - name: string; - kind: string; - status: ConnectorStatus; - detail: string; -} - -export interface RuntimeRow { - label: string; - value: string; - state: "ok" | "warn"; -} - -export interface ReceiptStep { - step: number; - title: string; - detail: string; -} - -export interface AuditRow { - time: string; - event: string; - subject: string; - result: string; -} - -export interface WorkspaceMock { - endpoint: string; - visibleCaplets: number; - hiddenTools: number; - payloadReduction: string; - authMode: string; - workspaceName: string; - connectors: Connector[]; - runtimeRows: RuntimeRow[]; - receiptSteps: ReceiptStep[]; - auditRows: AuditRow[]; -} - -export const productCopyProofs = [ - "capability cards instead of flat downstream tool lists", - "106 hidden downstream tools", - "3 visible Caplets", -] as const; - -export const workspaceMock: WorkspaceMock = { - endpoint: "https://cloud.caplets.dev/ws/personal/mcp", - visibleCaplets: 3, - hiddenTools: 106, - payloadReduction: "87.9% smaller initial payload", - authMode: "OAuth", - workspaceName: "personal workspace", - connectors: [ - { - id: "github", - name: "GitHub", - kind: "Hosted MCP", - status: "Ready", - detail: "OAuth state and provider tokens stay server-side for the workspace.", - }, - { - id: "sourcegraph", - name: "Sourcegraph", - kind: "Hosted API", - status: "Needs OAuth", - detail: "Authorize once, then expose a capability card to every connected agent.", - }, - { - id: "repo-tools", - name: "Repo tools", - kind: "Project-bound CLI", - status: "Local required", - detail: - "Runs through project-bound remote stdio/CLI execution when local presence is active.", - }, - ], - runtimeRows: [ - { label: "Hosted connectors", value: "ready", state: "ok" }, - { label: "Sandbox leases", value: "idle", state: "ok" }, - { label: "Local-assisted sessions", value: "presence required", state: "warn" }, - { label: "Policy blockers", value: "0 blocking", state: "ok" }, - ], - receiptSteps: [ - { - step: 1, - title: "Sync lease opened", - detail: "Project files copied into the managed runtime for stdio/CLI work.", - }, - { - step: 2, - title: "Remote command finished", - detail: "project-bound remote stdio/CLI execution returned a patch receipt.", - }, - { - step: 3, - title: "Implicit apply pending", - detail: "Local runtime checks conflicts before writing back to the project root.", - }, - ], - auditRows: [ - { - time: "10:48", - event: "OAuth client authorized", - subject: "workspace/personal", - result: "requires workspace grant", - }, - { - time: "10:51", - event: "Tool surface report generated", - subject: "github", - result: "87.9% smaller initial payload", - }, - { - time: "10:54", - event: "Project-bound apply receipt recorded", - subject: "repo-tools", - result: "conflict-aware", - }, - ], -}; diff --git a/packages/core/src/cli/add.ts b/packages/core/src/cli/add.ts index fe371bb..1dc8868 100644 --- a/packages/core/src/cli/add.ts +++ b/packages/core/src/cli/add.ts @@ -5,6 +5,7 @@ import { mkdirSync, mkdtempSync, openSync, + realpathSync, rmSync, writeFileSync, writeSync, @@ -373,14 +374,19 @@ function renderLocalPaths(fields: YamlField[], outputDir: string): YamlField[] { } function localPathRelativeToOutput(path: string, outputDir: string): string { - const absolutePath = resolve(path); - const rendered = relative(outputDir, resolve(path)); + const absolutePath = displayPath(resolve(path)); + const rendered = relative(outputDir, absolutePath); if (rendered.startsWith("../..") || rendered.startsWith("..\\..")) { return absolutePath; } return rendered === "" ? "." : rendered; } +function displayPath(path: string): string { + if (process.platform !== "darwin") return path; + return path.replace(/^\/private\/(var|tmp)(?=\/|$)/u, "/$1"); +} + function rejectUnsafeDestinationParents(path: string): void { const parent = dirname(resolve(path)); const root = parse(parent).root; @@ -394,6 +400,7 @@ function rejectUnsafeDestinationParents(path: string): void { return; } if (stats.isSymbolicLink()) { + if (isDarwinSystemAliasSymlink(current)) continue; throw new CapletsError( "CONFIG_EXISTS", `Output parent path ${current} is a symlink; remove it before writing`, @@ -408,6 +415,16 @@ function rejectUnsafeDestinationParents(path: string): void { } } +function isDarwinSystemAliasSymlink(path: string): boolean { + if (process.platform !== "darwin") return false; + if (path !== "/var" && path !== "/tmp") return false; + try { + return realpathSync(path) === `/private${path}`; + } catch { + return false; + } +} + function lstatIfExists(path: string): ReturnType | undefined { try { return lstatSync(path); diff --git a/packages/core/src/cli/install.ts b/packages/core/src/cli/install.ts index 847e4c8..a319cdd 100644 --- a/packages/core/src/cli/install.ts +++ b/packages/core/src/cli/install.ts @@ -197,6 +197,7 @@ function rejectUnsafeInstallParents(path: string): void { return; } if (stats.isSymbolicLink()) { + if (isDarwinSystemAliasSymlink(current)) continue; throw new CapletsError( "CONFIG_EXISTS", `Install destination parent ${current} is a symlink; remove it before installing`, @@ -211,6 +212,16 @@ function rejectUnsafeInstallParents(path: string): void { } } +function isDarwinSystemAliasSymlink(path: string): boolean { + if (process.platform !== "darwin") return false; + if (path !== "/var" && path !== "/tmp") return false; + try { + return realpathSync(path) === `/private${path}`; + } catch { + return false; + } +} + function rejectUnsafeInstallDestination(plan: InstallPlan, force: boolean): void { const stats = lstatIfExists(plan.destination); if (!stats) { diff --git a/packages/core/src/cloud/apply.ts b/packages/core/src/cloud/apply.ts index 578398f..5463073 100644 --- a/packages/core/src/cloud/apply.ts +++ b/packages/core/src/cloud/apply.ts @@ -50,6 +50,7 @@ export function applyRemoteFileChanges( changes: RemoteFileChange[], ): ApplyReceipt | { status: "apply_conflict"; recoverable: true; conflicts: ApplyConflict[] } { const root = resolve(projectRoot); + const realRoot = realpathSync(root); const conflicts: ApplyConflict[] = []; const writable: Array<{ path: string; absolutePath: string; content: string }> = []; @@ -59,7 +60,7 @@ export function applyRemoteFileChanges( conflicts.push({ path: change.path, kind: "content", message: "Path escapes project root." }); continue; } - if (pathHasSymlink(root, absolutePath)) { + if (pathHasSymlink(root, realRoot, absolutePath)) { conflicts.push({ path: change.path, kind: "content", message: "Path traverses a symlink." }); continue; } @@ -96,7 +97,7 @@ export function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } -function pathHasSymlink(root: string, target: string): boolean { +function pathHasSymlink(root: string, realRoot: string, target: string): boolean { let current = root; for (const part of relative(root, target).split(/[\\/]+/u)) { if (!part) continue; @@ -104,7 +105,7 @@ function pathHasSymlink(root: string, target: string): boolean { if (!existsSync(current)) continue; if (lstatSync(current).isSymbolicLink()) return true; const real = realpathSync(current); - if (relative(root, real).startsWith("..")) return true; + if (relative(realRoot, real).startsWith("..")) return true; } return false; } diff --git a/packages/core/src/config/paths.ts b/packages/core/src/config/paths.ts index 3c33c49..b004ed6 100644 --- a/packages/core/src/config/paths.ts +++ b/packages/core/src/config/paths.ts @@ -95,7 +95,7 @@ export function resolveConfigPath(path?: string): string { } export function resolveProjectConfigPath(cwd = process.cwd()): string { - return join(cwd, PROJECT_CONFIG_FILE); + return join(displayPath(cwd), PROJECT_CONFIG_FILE); } export function resolveCapletsRoot(configPath = resolveConfigPath()): string { @@ -103,5 +103,10 @@ export function resolveCapletsRoot(configPath = resolveConfigPath()): string { } export function resolveProjectCapletsRoot(cwd = process.cwd()): string { - return join(cwd, ".caplets"); + return join(displayPath(cwd), ".caplets"); +} + +function displayPath(path: string): string { + if (process.platform !== "darwin") return path; + return path.replace(/^\/private\/(var|tmp)(?=\/|$)/u, "/$1"); } diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index aa03677..d13a25c 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -1595,52 +1595,55 @@ describe("config", () => { } }); - it("skips all entries for duplicate IDs after precedence in best-effort mode", () => { - const root = mkdtempSync(join(tmpdir(), "caplets-files-")); - try { - writeFileSync( - join(root, "tools.md"), - [ - "---", - "name: Tools Lower", - "description: Lowercase extension Caplet.", - "mcpServer:", - " command: lower-tools", - "---", - "# Tools Lower", - ].join("\n"), - ); - writeFileSync( - join(root, "tools.MD"), - [ - "---", - "name: Tools Upper", - "description: Uppercase extension Caplet.", - "mcpServer:", - " command: upper-tools", - "---", - "# Tools Upper", - ].join("\n"), - ); - - const result = loadCapletFilesWithPathsBestEffort(root); - - expect(result).toEqual({ - config: {}, - paths: {}, - warnings: [ - expect.objectContaining({ - path: join(root, "tools.MD"), - message: expect.stringContaining("Duplicate Caplet ID tools"), - }), - ], - }); - expect(result?.warnings[0]?.message).toContain(join(root, "tools.md")); - expect(result?.warnings[0]?.message).toContain(join(root, "tools.MD")); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); + it.runIf(isCaseSensitiveTempFs())( + "skips all entries for duplicate IDs after precedence in best-effort mode", + () => { + const root = mkdtempSync(join(tmpdir(), "caplets-files-")); + try { + writeFileSync( + join(root, "tools.md"), + [ + "---", + "name: Tools Lower", + "description: Lowercase extension Caplet.", + "mcpServer:", + " command: lower-tools", + "---", + "# Tools Lower", + ].join("\n"), + ); + writeFileSync( + join(root, "tools.MD"), + [ + "---", + "name: Tools Upper", + "description: Uppercase extension Caplet.", + "mcpServer:", + " command: upper-tools", + "---", + "# Tools Upper", + ].join("\n"), + ); + + const result = loadCapletFilesWithPathsBestEffort(root); + + expect(result).toEqual({ + config: {}, + paths: {}, + warnings: [ + expect.objectContaining({ + path: join(root, "tools.MD"), + message: expect.stringContaining("Duplicate Caplet ID tools"), + }), + ], + }); + expect(result?.warnings[0]?.message).toContain(join(root, "tools.md")); + expect(result?.warnings[0]?.message).toContain(join(root, "tools.MD")); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); it("rejects invalid OIDC URL fields in Caplet files", () => { const root = mkdtempSync(join(tmpdir(), "caplets-files-")); @@ -2065,6 +2068,17 @@ describe("config", () => { }); }); +function isCaseSensitiveTempFs(): boolean { + const root = mkdtempSync(join(tmpdir(), "caplets-case-check-")); + try { + writeFileSync(join(root, "case.md"), "lower"); + writeFileSync(join(root, "case.MD"), "upper"); + return readdirSync(root).length === 2; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + function markdownLinkTargets(markdown: string): string[] { return [...markdown.matchAll(/\[[^\]]+\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)].flatMap((match) => match[1] ? [match[1]] : [], diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index 90792e1..53fb22f 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -269,6 +269,7 @@ describe("CapletsEngine", () => { return true; }); + await watcherReady(); writeFileSync(projectFile, "after"); await eventually(() => expect(reloads).toBeGreaterThan(0)); @@ -313,3 +314,7 @@ async function eventually(assertion: () => void): Promise { throw lastError; } } + +async function watcherReady(): Promise { + await new Promise((resolve) => setTimeout(resolve, 20)); +} diff --git a/packages/core/test/native.test.ts b/packages/core/test/native.test.ts index ef04063..39d4a00 100644 --- a/packages/core/test/native.test.ts +++ b/packages/core/test/native.test.ts @@ -267,6 +267,7 @@ describe("native Caplets service", () => { }); try { + await watcherReady(); writeFileSync( configPath, JSON.stringify({ @@ -345,3 +346,7 @@ describe("native Caplets service", () => { return { dir, configPath, projectConfigPath }; } }); + +async function watcherReady(): Promise { + await new Promise((resolve) => setTimeout(resolve, 20)); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f27dfa..c396031 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,202 +1,3 @@ ---- -lockfileVersion: '9.0' - -importers: - - .: - configDependencies: {} - packageManagerDependencies: - '@pnpm/exe': - specifier: ^11.5.0 - version: 11.5.0 - pnpm: - specifier: ^11.5.0 - version: 11.5.0 - -packages: - - '@pnpm/exe@11.5.0': - resolution: {integrity: sha512-4hzOXq1HHrNPjwI8k1rt7Ot/Yrdx1JX3pn/L/M95ii1gid1Q6ZK6dVg4+gbSgUdPsYmYDZ4/Yfc0A7vd5C0ndg==} - hasBin: true - - '@pnpm/linux-arm64@11.5.0': - resolution: {integrity: sha512-NV9HdzzCB0epuI9LqZZeTaqjH3OweNQSQCS76GzEkFxJHS9e5Gvu7tgex91gxVL7bCZ+R4yr/3d3yexBFtr2ug==} - cpu: [arm64] - os: [linux] - - '@pnpm/linux-x64@11.5.0': - resolution: {integrity: sha512-vH83rRx4iPk/bwm9pBVCn+5hXbcQI66I/4zk6Vc09SusJgTqOdbN4U6VhMcGIqSEdr901ksYGCyIbMv7f6Guew==} - cpu: [x64] - os: [linux] - - '@pnpm/linuxstatic-arm64@11.5.0': - resolution: {integrity: sha512-2nOnMW1rSwGv22q2yZz1HlGT3ly/Ij8wUlX0NB4n+Krx7nETRHA3MgWsbkVejxHknDcTulRVudAghuX9rgrXcw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@pnpm/linuxstatic-x64@11.5.0': - resolution: {integrity: sha512-ONOC1Mg0JusHtjzkRlre9di1QO+GAjy4HP7jMjDx21yGhrSheNdUweTXbekMH1EflRd19kTU6d8M3zewJFPtVg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@pnpm/macos-arm64@11.5.0': - resolution: {integrity: sha512-od0ALdTxs4A7s5vAH5q2l2phzCJb98+PVOW1rq7BGpWGeYxQ+EwvL+vq0KaO6iLsn/eVVoncCkgZ/k6QNYuTgw==} - cpu: [arm64] - os: [darwin] - - '@pnpm/win-arm64@11.5.0': - resolution: {integrity: sha512-9HqbI80FjVVqFx4+EPxYYNfeP9Sx69W6kYqUDvOJn9G7RJ/2NNNQ898cVHTMpXlW1/PrMEcijmdpa/NjZIrWiQ==} - cpu: [arm64] - os: [win32] - - '@pnpm/win-x64@11.5.0': - resolution: {integrity: sha512-Q89CQqFGAsWmfvHZs5Kbbar45q3GBYtfAdPUCiVMVNJoLi3dsBS2LCvUq8ak3AufkFDaJBpvhaFcDP2M1NXr3A==} - cpu: [x64] - os: [win32] - - '@reflink/reflink-darwin-arm64@0.1.19': - resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@reflink/reflink-darwin-x64@0.1.19': - resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@reflink/reflink-linux-arm64-gnu@0.1.19': - resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@reflink/reflink-linux-arm64-musl@0.1.19': - resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@reflink/reflink-linux-x64-gnu@0.1.19': - resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@reflink/reflink-linux-x64-musl@0.1.19': - resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@reflink/reflink-win32-arm64-msvc@0.1.19': - resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@reflink/reflink-win32-x64-msvc@0.1.19': - resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@reflink/reflink@0.1.19': - resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==} - engines: {node: '>= 10'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - pnpm@11.5.0: - resolution: {integrity: sha512-2/zE+Bz0hZev1Lw5H/3xLBHxqfuDo5W/prCi2cwv2P/rr9scy9UpYyFT95OQTCYVt/Cf4aNFRz/Rw1hFFyqOsQ==} - engines: {node: '>=22.13'} - hasBin: true - -snapshots: - - '@pnpm/exe@11.5.0': - dependencies: - '@reflink/reflink': 0.1.19 - detect-libc: 2.1.2 - optionalDependencies: - '@pnpm/linux-arm64': 11.5.0 - '@pnpm/linux-x64': 11.5.0 - '@pnpm/linuxstatic-arm64': 11.5.0 - '@pnpm/linuxstatic-x64': 11.5.0 - '@pnpm/macos-arm64': 11.5.0 - '@pnpm/win-arm64': 11.5.0 - '@pnpm/win-x64': 11.5.0 - - '@pnpm/linux-arm64@11.5.0': - optional: true - - '@pnpm/linux-x64@11.5.0': - optional: true - - '@pnpm/linuxstatic-arm64@11.5.0': - optional: true - - '@pnpm/linuxstatic-x64@11.5.0': - optional: true - - '@pnpm/macos-arm64@11.5.0': - optional: true - - '@pnpm/win-arm64@11.5.0': - optional: true - - '@pnpm/win-x64@11.5.0': - optional: true - - '@reflink/reflink-darwin-arm64@0.1.19': - optional: true - - '@reflink/reflink-darwin-x64@0.1.19': - optional: true - - '@reflink/reflink-linux-arm64-gnu@0.1.19': - optional: true - - '@reflink/reflink-linux-arm64-musl@0.1.19': - optional: true - - '@reflink/reflink-linux-x64-gnu@0.1.19': - optional: true - - '@reflink/reflink-linux-x64-musl@0.1.19': - optional: true - - '@reflink/reflink-win32-arm64-msvc@0.1.19': - optional: true - - '@reflink/reflink-win32-x64-msvc@0.1.19': - optional: true - - '@reflink/reflink@0.1.19': - optionalDependencies: - '@reflink/reflink-darwin-arm64': 0.1.19 - '@reflink/reflink-darwin-x64': 0.1.19 - '@reflink/reflink-linux-arm64-gnu': 0.1.19 - '@reflink/reflink-linux-arm64-musl': 0.1.19 - '@reflink/reflink-linux-x64-gnu': 0.1.19 - '@reflink/reflink-linux-x64-musl': 0.1.19 - '@reflink/reflink-win32-arm64-msvc': 0.1.19 - '@reflink/reflink-win32-x64-msvc': 0.1.19 - - detect-libc@2.1.2: {} - - pnpm@11.5.0: {} - ---- lockfileVersion: '9.0' settings: @@ -212,7 +13,7 @@ importers: version: 2.31.0(@types/node@25.9.1) '@cloudflare/workers-types': specifier: ^4.20260530.1 - version: 4.20260530.1 + version: 4.20260531.1 '@types/node': specifier: ^25.9.1 version: 25.9.1 @@ -221,13 +22,13 @@ importers: version: 7.0.0-dev.20260527.2 alchemy: specifier: 0.93.9 - version: 0.93.9(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))(workerd@1.20260526.1) + version: 0.93.9(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260526.1) husky: specifier: ^9.1.7 version: 9.1.7 lint-staged: specifier: ^17.0.6 - version: 17.0.6 + version: 17.0.7 oxfmt: specifier: ^0.52.0 version: 0.52.0 @@ -242,7 +43,7 @@ importers: version: 1.0.3 tsx: specifier: ^4.22.3 - version: 4.22.3 + version: 4.22.4 turbo: specifier: ^2.9.16 version: 2.9.16 @@ -251,7 +52,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) apps/landing: dependencies: @@ -263,10 +64,10 @@ importers: version: 4.2.0 '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.3.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) astro: specifier: ^6.4.2 - version: 6.4.2(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.3)(yaml@2.9.0) + version: 6.4.2(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0) tailwindcss: specifier: ^4.3.0 version: 4.3.0 @@ -276,7 +77,7 @@ importers: devDependencies: vite: specifier: ^7.3.3 - version: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) + version: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) packages/benchmarks: dependencies: @@ -301,7 +102,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) packages/cli: dependencies: @@ -326,7 +127,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) packages/core: dependencies: @@ -378,7 +179,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) packages/opencode: dependencies: @@ -387,7 +188,7 @@ importers: version: link:../core '@opencode-ai/plugin': specifier: '>=1' - version: 1.15.12 + version: 1.15.13 devDependencies: '@types/node': specifier: ^25.9.1 @@ -403,7 +204,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) packages/pi: dependencies: @@ -431,7 +232,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + version: 4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -784,8 +585,8 @@ packages: cpu: [x64] os: [win32] - '@cloudflare/workers-types@4.20260530.1': - resolution: {integrity: sha512-H0BcFCJqqDwoDUY88mv3qJuA3DUdnMmtUdsHRrEZY+SRhXOK8TyFqFb+iS7A/84+qzpTFmFzWo6JN9tVALO2nw==} + '@cloudflare/workers-types@4.20260531.1': + resolution: {integrity: sha512-7DybhbX12n+mVgJEDvm9W/jjqpaUIczg+RWj1Hua9nGEG+pNJnT+yZj1JKENrbdyuGWx3OFEgUCNFcGJN86Dvg==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -1879,8 +1680,8 @@ packages: '@octokit/types@14.1.0': resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} - '@opencode-ai/plugin@1.15.12': - resolution: {integrity: sha512-BBteGXEwJt+1ehHqQ+yKXmoWltrW+2xO++B1Fm/dnMGYWT9luEKA5RlUuVYA2qDF6uwlE7kmHZvQZAM79zWHEA==} + '@opencode-ai/plugin@1.15.13': + resolution: {integrity: sha512-NFwZGhmxIPijtfz9swPJXDmhOpq4UWP8WjEE7GEMr7FwtJrK/hv6v36nFimed5+OKk+pQCrTJn/vhRW7Io72IA==} peerDependencies: '@opentui/core': '>=0.2.16' '@opentui/keymap': '>=0.2.16' @@ -1893,15 +1694,12 @@ packages: '@opentui/solid': optional: true - '@opencode-ai/sdk@1.15.12': - resolution: {integrity: sha512-lOaBNX93dkakZe6C42ttX1bkSx3K2c6+Yv+w8Qv02v5rPlu1vCXbmdfYDh9/bw+oq+NKPSaBm9d6kPA19hA5Lg==} + '@opencode-ai/sdk@1.15.13': + resolution: {integrity: sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==} '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} - '@oxc-project/types@0.132.0': - resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} - '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} @@ -2192,73 +1990,36 @@ packages: '@protobufjs/utf8@1.1.1': resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} - '@rolldown/binding-android-arm64@1.0.2': - resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.2': - resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - '@rolldown/binding-darwin-arm64@1.0.3': resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.2': - resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - '@rolldown/binding-darwin-x64@1.0.3': resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.2': - resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - '@rolldown/binding-freebsd-x64@1.0.3': resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.2': - resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - '@rolldown/binding-linux-arm-gnueabihf@1.0.3': resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.2': - resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@rolldown/binding-linux-arm64-gnu@1.0.3': resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2266,13 +2027,6 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.2': - resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2280,13 +2034,6 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.2': - resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2294,13 +2041,6 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.2': - resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2308,13 +2048,6 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.2': - resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2322,13 +2055,6 @@ packages: os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.2': - resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2336,46 +2062,23 @@ packages: os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.2': - resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.2': - resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - '@rolldown/binding-wasm32-wasi@1.0.3': resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.2': - resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - '@rolldown/binding-win32-arm64-msvc@1.0.3': resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.2': - resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.3': resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4122,8 +3825,8 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - lint-staged@17.0.6: - resolution: {integrity: sha512-xTowloQX5tfs9TC6SUHnKuBSx/TUx+9w39zRTbVrB70DxUJZh3OZWnOa0LbejxVX9adYuioGJoP4dpQ04QHehg==} + lint-staged@17.0.7: + resolution: {integrity: sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==} engines: {node: '>=22.22.1'} hasBin: true @@ -4674,8 +4377,8 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - protobufjs@7.6.1: - resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==} + protobufjs@7.6.2: + resolution: {integrity: sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==} engines: {node: '>=12.0.0'} proxy-addr@2.0.7: @@ -4810,11 +4513,6 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rolldown@1.0.2: - resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5060,16 +4758,12 @@ packages: resolution: {integrity: sha512-8OqlXQ35euK9+e7L68u8UwcODxkHoIkjbGsgXuARKNyQ5G6xt8nw1YPeMbxMLgCPFkToU+UEK5j05t2t8edKpQ==} engines: {node: ^16.14.0 || >= 17.3.0} - tinyexec@1.2.2: - resolution: {integrity: sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==} + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} - tinyexec@1.2.3: - resolution: {integrity: sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==} - engines: {node: '>=18'} - - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} tinypool@2.1.0: @@ -5104,8 +4798,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.22.3: - resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==} + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} engines: {node: '>=18.0.0'} hasBin: true @@ -5330,49 +5024,6 @@ packages: yaml: optional: true - vite@8.0.14: - resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.18 - esbuild: ^0.27.0 || ^0.28.0 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vitefu@1.1.3: resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} peerDependencies: @@ -5742,7 +5393,7 @@ snapshots: '@volar/language-server': 2.4.28 '@volar/language-service': 2.4.28 muggle-string: 0.4.1 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 volar-service-css: 0.0.70(@volar/language-service@2.4.28) volar-service-emmet: 0.0.70(@volar/language-service@2.4.28) volar-service-html: 0.0.70(@volar/language-service@2.4.28) @@ -6278,7 +5929,7 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260526.1': optional: true - '@cloudflare/workers-types@4.20260530.1': {} + '@cloudflare/workers-types@4.20260531.1': {} '@cspotcode/source-map-support@0.8.1': dependencies: @@ -6707,7 +6358,7 @@ snapshots: dependencies: google-auth-library: 10.6.2 p-retry: 4.6.2 - protobufjs: 7.6.1 + protobufjs: 7.6.2 ws: 8.21.0 optionalDependencies: '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) @@ -7070,20 +6721,18 @@ snapshots: dependencies: '@octokit/openapi-types': 25.1.0 - '@opencode-ai/plugin@1.15.12': + '@opencode-ai/plugin@1.15.13': dependencies: - '@opencode-ai/sdk': 1.15.12 + '@opencode-ai/sdk': 1.15.13 effect: 4.0.0-beta.66 zod: 4.1.8 - '@opencode-ai/sdk@1.15.12': + '@opencode-ai/sdk@1.15.13': dependencies: cross-spawn: 7.0.6 '@oslojs/encoding@1.1.0': {} - '@oxc-project/types@0.132.0': {} - '@oxc-project/types@0.133.0': {} '@oxfmt/binding-android-arm-eabi@0.52.0': @@ -7237,85 +6886,42 @@ snapshots: '@protobufjs/utf8@1.1.1': {} - '@rolldown/binding-android-arm64@1.0.2': - optional: true - '@rolldown/binding-android-arm64@1.0.3': optional: true - '@rolldown/binding-darwin-arm64@1.0.2': - optional: true - '@rolldown/binding-darwin-arm64@1.0.3': optional: true - '@rolldown/binding-darwin-x64@1.0.2': - optional: true - '@rolldown/binding-darwin-x64@1.0.3': optional: true - '@rolldown/binding-freebsd-x64@1.0.2': - optional: true - '@rolldown/binding-freebsd-x64@1.0.3': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.2': - optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.3': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.2': - optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.3': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.2': - optional: true - '@rolldown/binding-linux-arm64-musl@1.0.3': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.2': - optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.3': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.2': - optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.3': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.2': - optional: true - '@rolldown/binding-linux-x64-gnu@1.0.3': optional: true - '@rolldown/binding-linux-x64-musl@1.0.2': - optional: true - '@rolldown/binding-linux-x64-musl@1.0.3': optional: true - '@rolldown/binding-openharmony-arm64@1.0.2': - optional: true - '@rolldown/binding-openharmony-arm64@1.0.3': optional: true - '@rolldown/binding-wasm32-wasi@1.0.2': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - optional: true - '@rolldown/binding-wasm32-wasi@1.0.3': dependencies: '@emnapi/core': 1.10.0 @@ -7323,15 +6929,9 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.2': - optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.3': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.2': - optional: true - '@rolldown/binding-win32-x64-msvc@1.0.3': optional: true @@ -7592,12 +7192,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - '@tailwindcss/vite@4.3.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.0(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.0 '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) '@turbo/darwin-64@2.9.16': optional: true @@ -7705,13 +7305,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))': + '@vitest/mocker@4.1.7(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.7': dependencies: @@ -7809,17 +7409,17 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@0.93.9(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))(workerd@1.20260526.1): + alchemy@0.93.9(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260526.1): dependencies: '@aws-sdk/credential-providers': 3.1057.0 '@cloudflare/unenv-preset': 2.7.7(unenv@2.0.0-rc.21)(workerd@1.20260526.1) - '@cloudflare/workers-types': 4.20260530.1 + '@cloudflare/workers-types': 4.20260531.1 '@iarna/toml': 2.2.5 '@octokit/rest': 21.1.1 '@smithy/node-config-provider': 4.4.5 '@smithy/types': 4.14.2 aws4fetch: 1.0.20 - drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260530.1) + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260531.1) env-paths: 3.0.0 esbuild: 0.25.12 execa: 9.6.1 @@ -7838,11 +7438,11 @@ snapshots: proper-lockfile: 4.1.2 signal-exit: 4.1.0 unenv: 2.0.0-rc.21 - wrangler: 4.95.0(@cloudflare/workers-types@4.20260530.1) + wrangler: 4.95.0(@cloudflare/workers-types@4.20260531.1) ws: 8.21.0 yaml: 2.9.0 optionalDependencies: - vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@aws-sdk/client-rds-data' - '@electric-sql/pglite' @@ -7910,7 +7510,7 @@ snapshots: assertion-error@2.0.1: {} - astro@6.4.2(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.3)(yaml@2.9.0): + astro@6.4.2(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(tsx@4.22.4)(yaml@2.9.0): dependencies: '@astrojs/compiler': 4.0.0 '@astrojs/internal-helpers': 0.10.0 @@ -7955,15 +7555,15 @@ snapshots: smol-toml: 1.6.1 svgo: 4.0.1 tinyclip: 0.1.13 - tinyexec: 1.2.3 - tinyglobby: 0.2.16 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 ultrahtml: 1.6.0 unifont: 0.7.4 unist-util-visit: 5.1.0 unstorage: 1.17.5(aws4fetch@1.0.20) vfile: 6.0.3 - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) - vitefu: 1.1.3(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + vitefu: 1.1.3(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.4.3 @@ -8255,9 +7855,9 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 - drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260530.1): + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260531.1): optionalDependencies: - '@cloudflare/workers-types': 4.20260530.1 + '@cloudflare/workers-types': 4.20260531.1 dset@3.1.4: {} @@ -9103,12 +8703,12 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - lint-staged@17.0.6: + lint-staged@17.0.7: dependencies: listr2: 10.2.1 picomatch: 4.0.4 string-argv: 0.3.2 - tinyexec: 1.2.2 + tinyexec: 1.2.4 optionalDependencies: yaml: 2.9.0 @@ -9825,7 +9425,7 @@ snapshots: property-information@7.1.0: {} - protobufjs@7.6.1: + protobufjs@7.6.2: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -10013,27 +9613,6 @@ snapshots: rfdc@1.4.1: {} - rolldown@1.0.2: - dependencies: - '@oxc-project/types': 0.132.0 - '@rolldown/pluginutils': 1.0.1 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.2 - '@rolldown/binding-darwin-arm64': 1.0.2 - '@rolldown/binding-darwin-x64': 1.0.2 - '@rolldown/binding-freebsd-x64': 1.0.2 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 - '@rolldown/binding-linux-arm64-gnu': 1.0.2 - '@rolldown/binding-linux-arm64-musl': 1.0.2 - '@rolldown/binding-linux-ppc64-gnu': 1.0.2 - '@rolldown/binding-linux-s390x-gnu': 1.0.2 - '@rolldown/binding-linux-x64-gnu': 1.0.2 - '@rolldown/binding-linux-x64-musl': 1.0.2 - '@rolldown/binding-openharmony-arm64': 1.0.2 - '@rolldown/binding-wasm32-wasi': 1.0.2 - '@rolldown/binding-win32-arm64-msvc': 1.0.2 - '@rolldown/binding-win32-x64-msvc': 1.0.2 - rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -10357,11 +9936,9 @@ snapshots: tinyclip@0.1.13: {} - tinyexec@1.2.2: {} + tinyexec@1.2.4: {} - tinyexec@1.2.3: {} - - tinyglobby@0.2.16: + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 @@ -10386,7 +9963,7 @@ snapshots: tslib@2.8.1: {} - tsx@4.22.3: + tsx@4.22.4: dependencies: esbuild: 0.28.0 optionalDependencies: @@ -10546,45 +10123,30 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0): + vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.15 rollup: 4.60.4 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.1 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 - tsx: 4.22.3 - yaml: 2.9.0 - - vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.15 - rolldown: 1.0.2 - tinyglobby: 0.2.16 - optionalDependencies: - '@types/node': 25.9.1 - esbuild: 0.28.0 - fsevents: 2.3.3 - jiti: 2.7.0 - tsx: 4.22.3 + tsx: 4.22.4 yaml: 2.9.0 - vitefu@1.1.3(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)): + vitefu@1.1.3(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): optionalDependencies: - vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) - vitest@4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)): + vitest@4.1.7(@types/node@25.9.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) + '@vitest/mocker': 4.1.7(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.7 '@vitest/runner': 4.1.7 '@vitest/snapshot': 4.1.7 @@ -10598,10 +10160,10 @@ snapshots: picomatch: 4.0.4 std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.2.3 - tinyglobby: 0.2.16 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.1 @@ -10736,7 +10298,7 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260526.1 '@cloudflare/workerd-windows-64': 1.20260526.1 - wrangler@4.95.0(@cloudflare/workers-types@4.20260530.1): + wrangler@4.95.0(@cloudflare/workers-types@4.20260531.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260526.1) @@ -10748,7 +10310,7 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260526.1 optionalDependencies: - '@cloudflare/workers-types': 4.20260530.1 + '@cloudflare/workers-types': 4.20260531.1 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil From df269f1a7a957e7b52f5a8ae4d3f35799a95990f Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Tue, 2 Jun 2026 06:23:01 -0400 Subject: [PATCH 07/19] fix: tests --- packages/core/test/engine.test.ts | 2 ++ packages/core/test/native.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/test/engine.test.ts b/packages/core/test/engine.test.ts index 53fb22f..8f592c1 100644 --- a/packages/core/test/engine.test.ts +++ b/packages/core/test/engine.test.ts @@ -208,6 +208,7 @@ describe("CapletsEngine", () => { return true; }); + await watcherReady(); writeConfig(configPath, { mcpServers: { beta: { @@ -243,6 +244,7 @@ describe("CapletsEngine", () => { return true; }); + await watcherReady(); writeFileSync(nestedFile, "after"); await eventually(() => expect(reloads).toBeGreaterThan(0)); diff --git a/packages/core/test/native.test.ts b/packages/core/test/native.test.ts index 39d4a00..4e301ef 100644 --- a/packages/core/test/native.test.ts +++ b/packages/core/test/native.test.ts @@ -311,6 +311,7 @@ describe("native Caplets service", () => { }); try { + await watcherReady(); writeFileSync( configPath, JSON.stringify({ @@ -324,7 +325,7 @@ describe("native Caplets service", () => { }), ); - await expect.poll(() => events).toEqual([["beta"]]); + await expect.poll(() => events.at(-1)).toEqual(["beta"]); } finally { await service.close(); } @@ -348,5 +349,5 @@ describe("native Caplets service", () => { }); async function watcherReady(): Promise { - await new Promise((resolve) => setTimeout(resolve, 20)); + await new Promise((resolve) => setTimeout(resolve, 100)); } From 7ade58323917b636640d47d1694f2f06ebb98e36 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 4 Jun 2026 06:48:58 -0400 Subject: [PATCH 08/19] feat: provenance, project bindings, etc --- .caplets/.gitignore | 2 + README.md | 16 + docs/native-integrations.md | 15 + docs/project-binding.md | 55 + packages/core/package.json | 18 +- packages/core/rolldown.config.ts | 36 +- packages/core/src/caplet-files-bundle.ts | 1158 +++++++++++++++++ packages/core/src/caplet-files.ts | 1053 +-------------- packages/core/src/caplet-source/bundle.ts | 33 + packages/core/src/caplet-source/filesystem.ts | 65 + packages/core/src/caplet-source/index.ts | 9 + packages/core/src/caplet-source/parse.ts | 181 +++ packages/core/src/caplet-source/types.ts | 33 + packages/core/src/cli.ts | 506 ++++++- packages/core/src/cli/add.ts | 3 +- packages/core/src/cli/commands.ts | 6 + packages/core/src/cli/doctor.ts | 160 ++- packages/core/src/cli/setup-caplet.ts | 14 +- packages/core/src/cli/setup.ts | 24 +- packages/core/src/cloud-auth/client.ts | 181 +++ packages/core/src/cloud-auth/errors.ts | 74 ++ packages/core/src/cloud-auth/open-url.ts | 24 + packages/core/src/cloud-auth/store.ts | 133 ++ packages/core/src/cloud-auth/types.ts | 86 ++ packages/core/src/cloud-runtime.ts | 17 - packages/core/src/cloud/client.ts | 58 +- packages/core/src/cloud/mutagen.ts | 49 - packages/core/src/cloud/presence.ts | 10 +- packages/core/src/cloud/runtime-adapter.ts | 15 +- packages/core/src/cloud/sync.ts | 85 +- packages/core/src/config-runtime.ts | 664 ++++++++++ packages/core/src/config.ts | 64 + packages/core/src/config/paths.ts | 3 +- packages/core/src/index.ts | 134 ++ packages/core/src/native/options.ts | 35 +- packages/core/src/native/remote.ts | 2 +- packages/core/src/native/service.ts | 38 +- packages/core/src/project-binding/attach.ts | 218 ++++ packages/core/src/project-binding/errors.ts | 104 ++ .../core/src/project-binding/gitignore.ts | 31 + packages/core/src/project-binding/mutagen.ts | 453 +++++++ packages/core/src/project-binding/routes.ts | 36 + packages/core/src/project-binding/session.ts | 383 ++++++ .../core/src/project-binding/sync-filter.ts | 171 +++ .../core/src/project-binding/sync-size.ts | 62 + .../core/src/project-binding/transport.ts | 36 + packages/core/src/project-binding/types.ts | 65 + .../core/src/project-binding/workspaces.ts | 305 +++++ packages/core/src/remote-control/client.ts | 2 +- packages/core/src/remote/options.ts | 166 +++ packages/core/src/runtime-plan/features.ts | 139 ++ packages/core/src/runtime-plan/index.ts | 4 + packages/core/src/runtime-plan/planner.ts | 113 ++ packages/core/src/runtime-plan/resources.ts | 88 ++ packages/core/src/runtime-plan/types.ts | 158 +++ packages/core/src/serve/daemon/config.ts | 94 ++ packages/core/src/serve/daemon/index.ts | 181 +++ packages/core/src/serve/daemon/paths.ts | 66 + .../core/src/serve/daemon/platform-darwin.ts | 38 + .../core/src/serve/daemon/platform-linux.ts | 48 + .../core/src/serve/daemon/platform-windows.ts | 21 + packages/core/src/serve/daemon/platform.ts | 40 + packages/core/src/serve/daemon/process.ts | 76 ++ packages/core/src/serve/daemon/types.ts | 106 ++ packages/core/src/serve/http.ts | 14 + packages/core/src/serve/index.ts | 21 +- packages/core/src/serve/options.ts | 10 + packages/core/src/setup/local-store.ts | 97 +- packages/core/src/setup/runner.ts | 50 +- packages/core/src/setup/types.ts | 14 +- packages/core/test/attach-cli.test.ts | 240 ++++ packages/core/test/caplet-files.test.ts | 62 + packages/core/test/caplet-source.test.ts | 186 +++ packages/core/test/cloud-auth-client.test.ts | 73 ++ .../core/test/cloud-auth-login-cli.test.ts | 70 + .../test/cloud-auth-refresh-attach.test.ts | 82 ++ packages/core/test/cloud-auth.test.ts | 170 +++ .../core/test/cloud-bundle-runtime.test.ts | 111 ++ packages/core/test/cloud-client.test.ts | 48 +- packages/core/test/cloud-mutagen.test.ts | 64 +- .../cloud-runtime-adapter-provenance.test.ts | 21 + packages/core/test/cloud-sync.test.ts | 23 +- packages/core/test/doctor-cli.test.ts | 53 +- packages/core/test/fixtures/cloud-auth.ts | 41 + .../fixtures/project-binding/project/build.js | 1 + .../project-binding/project/package.json | 6 + packages/core/test/native-options.test.ts | 16 +- packages/core/test/native-remote.test.ts | 119 +- packages/core/test/package-boundaries.test.ts | 18 + .../test/project-binding-gitignore.test.ts | 61 + .../test/project-binding-integration.test.ts | 123 ++ .../core/test/project-binding-mutagen.test.ts | 203 +++ .../core/test/project-binding-routes.test.ts | 37 + .../core/test/project-binding-session.test.ts | 135 ++ .../test/project-binding-sync-filter.test.ts | 43 + .../test/project-binding-sync-size.test.ts | 26 + .../test/project-binding-workspaces.test.ts | 221 ++++ .../core/test/remote-control-client.test.ts | 2 +- packages/core/test/remote-options.test.ts | 70 + packages/core/test/runtime-features.test.ts | 113 ++ .../core/test/runtime-plan-contract.test.ts | 128 ++ packages/core/test/runtime-plan.test.ts | 103 ++ packages/core/test/serve-daemon.test.ts | 375 ++++++ packages/core/test/serve-http.test.ts | 49 + packages/core/test/serve-options.test.ts | 17 +- packages/core/test/setup-runner.test.ts | 209 ++- schemas/caplet.schema.json | 252 ++++ schemas/caplets-config.schema.json | 240 ++++ scripts/dev.ts | 6 +- 109 files changed, 10894 insertions(+), 1392 deletions(-) create mode 100644 .caplets/.gitignore create mode 100644 docs/native-integrations.md create mode 100644 docs/project-binding.md create mode 100644 packages/core/src/caplet-files-bundle.ts create mode 100644 packages/core/src/caplet-source/bundle.ts create mode 100644 packages/core/src/caplet-source/filesystem.ts create mode 100644 packages/core/src/caplet-source/index.ts create mode 100644 packages/core/src/caplet-source/parse.ts create mode 100644 packages/core/src/caplet-source/types.ts create mode 100644 packages/core/src/cloud-auth/client.ts create mode 100644 packages/core/src/cloud-auth/errors.ts create mode 100644 packages/core/src/cloud-auth/open-url.ts create mode 100644 packages/core/src/cloud-auth/store.ts create mode 100644 packages/core/src/cloud-auth/types.ts delete mode 100644 packages/core/src/cloud-runtime.ts delete mode 100644 packages/core/src/cloud/mutagen.ts create mode 100644 packages/core/src/config-runtime.ts create mode 100644 packages/core/src/project-binding/attach.ts create mode 100644 packages/core/src/project-binding/errors.ts create mode 100644 packages/core/src/project-binding/gitignore.ts create mode 100644 packages/core/src/project-binding/mutagen.ts create mode 100644 packages/core/src/project-binding/routes.ts create mode 100644 packages/core/src/project-binding/session.ts create mode 100644 packages/core/src/project-binding/sync-filter.ts create mode 100644 packages/core/src/project-binding/sync-size.ts create mode 100644 packages/core/src/project-binding/transport.ts create mode 100644 packages/core/src/project-binding/types.ts create mode 100644 packages/core/src/project-binding/workspaces.ts create mode 100644 packages/core/src/remote/options.ts create mode 100644 packages/core/src/runtime-plan/features.ts create mode 100644 packages/core/src/runtime-plan/index.ts create mode 100644 packages/core/src/runtime-plan/planner.ts create mode 100644 packages/core/src/runtime-plan/resources.ts create mode 100644 packages/core/src/runtime-plan/types.ts create mode 100644 packages/core/src/serve/daemon/config.ts create mode 100644 packages/core/src/serve/daemon/index.ts create mode 100644 packages/core/src/serve/daemon/paths.ts create mode 100644 packages/core/src/serve/daemon/platform-darwin.ts create mode 100644 packages/core/src/serve/daemon/platform-linux.ts create mode 100644 packages/core/src/serve/daemon/platform-windows.ts create mode 100644 packages/core/src/serve/daemon/platform.ts create mode 100644 packages/core/src/serve/daemon/process.ts create mode 100644 packages/core/src/serve/daemon/types.ts create mode 100644 packages/core/test/attach-cli.test.ts create mode 100644 packages/core/test/caplet-files.test.ts create mode 100644 packages/core/test/caplet-source.test.ts create mode 100644 packages/core/test/cloud-auth-client.test.ts create mode 100644 packages/core/test/cloud-auth-login-cli.test.ts create mode 100644 packages/core/test/cloud-auth-refresh-attach.test.ts create mode 100644 packages/core/test/cloud-auth.test.ts create mode 100644 packages/core/test/cloud-bundle-runtime.test.ts create mode 100644 packages/core/test/cloud-runtime-adapter-provenance.test.ts create mode 100644 packages/core/test/fixtures/cloud-auth.ts create mode 100644 packages/core/test/fixtures/project-binding/project/build.js create mode 100644 packages/core/test/fixtures/project-binding/project/package.json create mode 100644 packages/core/test/project-binding-gitignore.test.ts create mode 100644 packages/core/test/project-binding-integration.test.ts create mode 100644 packages/core/test/project-binding-mutagen.test.ts create mode 100644 packages/core/test/project-binding-routes.test.ts create mode 100644 packages/core/test/project-binding-session.test.ts create mode 100644 packages/core/test/project-binding-sync-filter.test.ts create mode 100644 packages/core/test/project-binding-sync-size.test.ts create mode 100644 packages/core/test/project-binding-workspaces.test.ts create mode 100644 packages/core/test/remote-options.test.ts create mode 100644 packages/core/test/runtime-features.test.ts create mode 100644 packages/core/test/runtime-plan-contract.test.ts create mode 100644 packages/core/test/runtime-plan.test.ts create mode 100644 packages/core/test/serve-daemon.test.ts diff --git a/.caplets/.gitignore b/.caplets/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/.caplets/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/README.md b/README.md index 2ac9e53..2c58e17 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,22 @@ configs run `caplets serve` directly, so install the Caplets CLI globally first. OpenCode and Pi can use native `caplets_` tools backed by a remote Caplets HTTP service. Codex, Claude Code, and any MCP client can connect to the same remote MCP endpoint directly. +Hosted Caplets Cloud uses browser-mediated Cloud Auth: + +```sh +caplets cloud auth login --workspace personal +caplets cloud auth status +caplets cloud auth workspaces +caplets cloud auth switch team +caplets cloud auth logout +``` + +Cloud Auth stores one Selected Workspace locally. `caplets attach --workspace ` must match that saved workspace; switch explicitly before attaching another hosted workspace. Self-hosted remotes continue to use `CAPLETS_REMOTE_URL`, `CAPLETS_REMOTE_TOKEN`, or Basic Auth credentials. + +Access tokens are short-lived. The CLI refreshes expired hosted credentials before attach, persists the rotated refresh token returned by Cloud, and treats revoked refresh credentials as a fresh-login requirement. `caplets cloud auth logout` clears local credentials; Cloud logout revokes the refresh credential family so rotated refresh tokens stop working together. + +Use `caplets attach --once` for a finite Project Binding smoke test, or `caplets attach` for a foreground Binding Session with WebSocket control, heartbeats, one reconnect attempt, and remote cleanup on interrupt. + Start a local HTTP service. `--path` is the service base path; Caplets mounts MCP, control, and health endpoints underneath it: diff --git a/docs/native-integrations.md b/docs/native-integrations.md new file mode 100644 index 0000000..0f5fb6f --- /dev/null +++ b/docs/native-integrations.md @@ -0,0 +1,15 @@ +# Native Integrations And Project Binding + +Native integrations use the same Project Binding vocabulary as the CLI. + +Explicit remote mode is eager. If a user configures a remote service and the remote Project Binding path cannot start, the integration fails hard so the caller sees the configuration problem. + +Auto or configured hosted behavior is lazy. The native integration can start local Caplets immediately, then attach hosted Project Binding metadata when the remote side becomes available. When the lazy path fails, local Caplets remain available and the warning points to `caplets doctor`. + +Native metadata should expose: + +- auth mode: `hosted_cloud`, `self_hosted_remote`, or `unconfigured` +- Selected Workspace ID or slug when hosted Cloud Auth is active +- Binding Session state +- Project Binding endpoint +- last recovery command diff --git a/docs/project-binding.md b/docs/project-binding.md new file mode 100644 index 0000000..29883e3 --- /dev/null +++ b/docs/project-binding.md @@ -0,0 +1,55 @@ +# Project Binding + +Project Binding connects one local project root to a remote Caplets runtime so project-bound tools can run against the same files the user is editing. + +## Attach Modes + +`caplets attach --once` validates Cloud Auth or self-hosted remote credentials, bootstraps `.caplets/.gitignore`, checks the Project Binding endpoint, runs sync preflight when the project root exists, and exits. + +`caplets attach` opens a foreground Binding Session. It reports state events, keeps the Project Binding lease alive, and exits only when interrupted or when the session reaches a terminal state. + +Hosted Cloud uses `caplets cloud auth login` and a Selected Workspace. Self-hosted remotes use `CAPLETS_REMOTE_URL` plus `CAPLETS_REMOTE_TOKEN` or Basic Auth credentials. Passing `--workspace` must match the saved Selected Workspace; use `caplets cloud auth switch ` for an explicit switch. + +## Binding Session Loop + +Foreground attach creates a server-side Binding Session through the Project Binding control API, opens the control WebSocket, emits normalized JSON events, sends periodic heartbeats, and sends a remote session-end request when interrupted. A single reconnectable WebSocket close emits a `reconnecting` event before retrying the same Binding Session. + +`caplets attach --once` remains finite. It only probes the HTTP equivalent of the WebSocket endpoint and accepts the `websocket_upgrade_required` response as proof that the endpoint is reachable. + +Hosted attach refreshes expired local Cloud Auth credentials before creating the Binding Session. If the saved refresh credential has been revoked, attach fails closed and asks the user to log in again. + +## States + +Binding Session states are: + +- `not_attached` +- `attaching` +- `syncing` +- `ready` +- `degraded` +- `blocked` +- `offline` +- `cleaning_up` +- `ended` +- `expired` + +Terminal states include a reason with a stable code, message, optional request ID, and recovery command. + +## Sync Safety + +Sync filtering applies in this order: + +- Caplets hard denylist, including `.git`, `node_modules`, `.venv`, caches, build outputs, archives, private keys, and unsafe env files. +- `.gitignore`. +- `.capletsignore`. + +Safe env templates remain allowed: `.env.example`, `.env.sample`, and `.env.template`. + +Hosted defaults are Free 25 MiB per file and 250 MiB per project, Plus 100 MiB and 1 GiB, Pro 250 MiB and 5 GiB, and Enterprise policy-controlled. Self-hosted defaults to 250 MiB and 5 GiB. + +## Recovery + +- `cloud_auth_required`: `caplets cloud auth login` +- `workspace_switch_required`: `caplets cloud auth switch ` +- `sync_size_limit_exceeded`: add exclusions to `.capletsignore` or upgrade the workspace plan +- `endpoint_unavailable`: `caplets doctor` diff --git a/packages/core/package.json b/packages/core/package.json index 7977251..9208797 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,9 +39,21 @@ "types": "./dist/native.d.ts", "default": "./dist/native.js" }, - "./cloud-runtime": { - "types": "./dist/cloud-runtime.d.ts", - "default": "./dist/cloud-runtime.js" + "./caplet-source": { + "types": "./dist/caplet-source/index.d.ts", + "default": "./dist/caplet-source.js" + }, + "./caplet-source/filesystem": { + "types": "./dist/caplet-source/filesystem.d.ts", + "default": "./dist/caplet-source/filesystem.js" + }, + "./runtime-plan": { + "types": "./dist/runtime-plan/index.d.ts", + "default": "./dist/runtime-plan.js" + }, + "./config-runtime": { + "types": "./dist/config-runtime.d.ts", + "default": "./dist/config-runtime.js" } }, "publishConfig": { diff --git a/packages/core/rolldown.config.ts b/packages/core/rolldown.config.ts index 9fad9d0..9ed8b39 100644 --- a/packages/core/rolldown.config.ts +++ b/packages/core/rolldown.config.ts @@ -1,15 +1,29 @@ import { defineConfig } from "rolldown"; -export default defineConfig({ - input: { - index: "src/index.ts", - "cloud-runtime": "src/cloud-runtime.ts", - "generated-tool-input-schema": "src/generated-tool-input-schema.ts", - native: "src/native.ts", +export default defineConfig([ + { + input: { + index: "src/index.ts", + "caplet-source/filesystem": "src/caplet-source/filesystem.ts", + "config-runtime": "src/config-runtime.ts", + "generated-tool-input-schema": "src/generated-tool-input-schema.ts", + native: "src/native.ts", + }, + output: { + dir: "./dist", + format: "esm", + }, + platform: "node", }, - output: { - dir: "./dist", - format: "esm", + { + input: { + "caplet-source": "src/caplet-source/index.ts", + "runtime-plan": "src/runtime-plan/index.ts", + }, + output: { + dir: "./dist", + format: "esm", + }, + platform: "browser", }, - platform: "node", -}); +]); diff --git a/packages/core/src/caplet-files-bundle.ts b/packages/core/src/caplet-files-bundle.ts new file mode 100644 index 0000000..a07988a --- /dev/null +++ b/packages/core/src/caplet-files-bundle.ts @@ -0,0 +1,1158 @@ +import { parse as parseYaml } from "yaml"; +import { z } from "zod"; +import { + FORBIDDEN_HEADERS, + HEADER_NAME_PATTERN, + HTTP_BASE_URL_PATTERN, + SERVER_ID_PATTERN, + isAllowedHttpBaseUrl, + isAllowedRemoteUrl, + isUrl, + validateHttpActionHeaders, +} from "./config/validation"; +import { CapletsError, redactSecrets } from "./errors"; +import { nestedSchema, schemaPath } from "./schema-utils"; + +const MAX_CAPLET_FILE_BYTES = 128 * 1024; +const MAX_CAPLET_BODY_CHARS = 64 * 1024; + +const capletRemoteAuthSchema = z + .discriminatedUnion("type", [ + z.object({ type: z.literal("none") }).strict(), + z.object({ type: z.literal("bearer"), token: z.string().min(1) }).strict(), + z + .object({ type: z.literal("headers"), headers: z.record(z.string(), z.string().min(1)) }) + .strict(), + z + .object({ + type: z.literal("oauth2"), + authorizationUrl: z.string().min(1).optional(), + tokenUrl: z.string().min(1).optional(), + issuer: z.string().min(1).optional(), + resourceMetadataUrl: z.string().min(1).optional(), + authorizationServerMetadataUrl: z.string().min(1).optional(), + openidConfigurationUrl: z.string().min(1).optional(), + clientMetadataUrl: z.string().min(1).optional(), + clientId: z.string().min(1).optional(), + clientSecret: z.string().min(1).optional(), + scopes: z.array(z.string().min(1)).optional(), + redirectUri: z.string().min(1).optional(), + }) + .strict(), + z + .object({ + type: z.literal("oidc"), + authorizationUrl: z.string().min(1).optional(), + tokenUrl: z.string().min(1).optional(), + issuer: z.string().min(1).optional(), + resourceMetadataUrl: z.string().min(1).optional(), + authorizationServerMetadataUrl: z.string().min(1).optional(), + openidConfigurationUrl: z.string().min(1).optional(), + clientMetadataUrl: z.string().min(1).optional(), + clientId: z.string().min(1).optional(), + clientSecret: z.string().min(1).optional(), + scopes: z.array(z.string().min(1)).optional(), + redirectUri: z.string().min(1).optional(), + }) + .strict(), + ]) + .describe("Authentication settings for a remote MCP server."); + +const capletSetupCommandSchema = z + .object({ + label: z.string().min(1).describe("Human-readable setup or verification step label."), + command: z.string().min(1).describe("Executable command to spawn without a shell."), + args: z.array(z.string()).optional().describe("Arguments passed to the command."), + env: z.record(z.string(), z.string()).optional().describe("Additional environment variables."), + cwd: z.string().min(1).optional().describe("Working directory for this command."), + timeoutMs: z.number().int().positive().optional(), + maxOutputBytes: z.number().int().positive().optional(), + }) + .strict(); + +const capletSetupSchema = z + .object({ + commands: z.array(capletSetupCommandSchema).optional(), + verify: z.array(capletSetupCommandSchema).optional(), + }) + .strict() + .refine( + (setup) => (setup.commands?.length ?? 0) > 0 || (setup.verify?.length ?? 0) > 0, + "setup must define at least one command or verify step", + ) + .describe("Optional explicit setup and verification metadata for this Caplet."); + +const capletProjectBindingSchema = z + .object({ required: z.literal(true) }) + .strict() + .describe("Project Binding requirements for Caplets that need an attached project."); + +const capletRuntimeFeatureSchema = z.enum(["docker", "browser"]); + +const capletRuntimeRequirementsSchema = z + .object({ + features: z + .array(capletRuntimeFeatureSchema) + .refine( + (features) => new Set(features).size === features.length, + "runtime.features must not contain duplicate feature names", + ) + .optional(), + resources: z + .object({ + class: z.enum(["standard", "large", "heavy"]).optional(), + }) + .strict() + .optional(), + }) + .strict() + .describe("Runtime feature and resource requirements for hosted execution."); + +const capletEndpointAuthSchema = z + .discriminatedUnion("type", [ + z.object({ type: z.literal("none") }).strict(), + z.object({ type: z.literal("bearer"), token: z.string().min(1) }).strict(), + z + .object({ type: z.literal("headers"), headers: z.record(z.string(), z.string().min(1)) }) + .strict(), + z + .object({ + type: z.literal("oauth2"), + authorizationUrl: z.string().min(1).optional(), + tokenUrl: z.string().min(1).optional(), + issuer: z.string().min(1).optional(), + resourceMetadataUrl: z.string().min(1).optional(), + authorizationServerMetadataUrl: z.string().min(1).optional(), + openidConfigurationUrl: z.string().min(1).optional(), + clientMetadataUrl: z.string().min(1).optional(), + clientId: z.string().min(1).optional(), + clientSecret: z.string().min(1).optional(), + scopes: z.array(z.string().min(1)).optional(), + redirectUri: z.string().min(1).optional(), + }) + .strict(), + z + .object({ + type: z.literal("oidc"), + authorizationUrl: z.string().min(1).optional(), + tokenUrl: z.string().min(1).optional(), + issuer: z.string().min(1).optional(), + resourceMetadataUrl: z.string().min(1).optional(), + authorizationServerMetadataUrl: z.string().min(1).optional(), + openidConfigurationUrl: z.string().min(1).optional(), + clientMetadataUrl: z.string().min(1).optional(), + clientId: z.string().min(1).optional(), + clientSecret: z.string().min(1).optional(), + scopes: z.array(z.string().min(1)).optional(), + redirectUri: z.string().min(1).optional(), + }) + .strict(), + ]) + .describe("Authentication settings for an OpenAPI or GraphQL endpoint."); + +const capletMcpServerSchema = z + .object({ + transport: z + .enum(["stdio", "http", "sse"]) + .optional() + .describe("Downstream MCP transport. Defaults to stdio when command is present."), + command: z.string().min(1).optional().describe("Executable command for stdio servers."), + args: z.array(z.string()).optional().describe("Arguments passed to the stdio command."), + env: z + .record(z.string(), z.string()) + .optional() + .describe("Environment variables for stdio servers. Supports ${VAR} and $env:VAR."), + cwd: z.string().min(1).optional().describe("Working directory for stdio servers."), + url: z.string().min(1).optional().describe("Remote MCP server URL for http or sse transport."), + auth: capletRemoteAuthSchema.optional(), + startupTimeoutMs: z + .number() + .int() + .positive() + .optional() + .describe("Timeout in milliseconds for starting or checking a downstream server."), + callTimeoutMs: z + .number() + .int() + .positive() + .optional() + .describe("Timeout in milliseconds for downstream tool calls."), + toolCacheTtlMs: z + .number() + .int() + .nonnegative() + .optional() + .describe("Milliseconds downstream tool metadata stays fresh. Set 0 to refresh every time."), + disabled: z + .boolean() + .optional() + .describe("When true, omit this Caplet from discovery and do not start its MCP server."), + projectBinding: capletProjectBindingSchema.optional(), + runtime: capletRuntimeRequirementsSchema.optional(), + }) + .strict() + .superRefine((server, ctx) => { + const effectiveTransport = server.transport ?? (server.command ? "stdio" : undefined); + const hasStdio = Boolean(server.command); + const hasRemote = Boolean(server.url); + if (hasStdio === hasRemote) { + ctx.addIssue({ + code: "custom", + message: "mcpServer must define exactly one connection shape: command or url", + }); + } + + if (effectiveTransport === "stdio" && !server.command) { + ctx.addIssue({ + code: "custom", + path: ["command"], + message: "stdio servers require command", + }); + } + + if ((effectiveTransport === "http" || effectiveTransport === "sse") && !server.url) { + ctx.addIssue({ + code: "custom", + path: ["url"], + message: "remote servers require url", + }); + } + + if (server.url && !hasEnvReference(server.url) && !isAllowedRemoteUrl(server.url)) { + ctx.addIssue({ + code: "custom", + path: ["url"], + message: "remote url must use https except loopback development urls", + }); + } + + if (server.auth?.type === "oauth2" || server.auth?.type === "oidc") { + for (const field of [ + "authorizationUrl", + "tokenUrl", + "issuer", + "clientMetadataUrl", + "redirectUri", + ] as const) { + const value = server.auth[field]; + if (value && !hasEnvReference(value) && !isUrl(value)) { + ctx.addIssue({ + code: "custom", + path: ["auth", field], + message: `${field} must be a URL or environment reference`, + }); + } + } + } + + if (server.auth?.type === "headers") { + for (const headerName of Object.keys(server.auth.headers)) { + const normalized = headerName.toLowerCase(); + if (!HEADER_NAME_PATTERN.test(headerName) || FORBIDDEN_HEADERS.has(normalized)) { + ctx.addIssue({ + code: "custom", + path: ["auth", "headers", headerName], + message: `header ${headerName} is not allowed`, + }); + } + } + } + }); + +const capletOpenApiEndpointSchema = z + .object({ + specPath: z.string().min(1).optional().describe("Local OpenAPI specification path."), + specUrl: z.string().min(1).optional().describe("Remote OpenAPI specification URL."), + baseUrl: z.string().min(1).optional().describe("Override base URL for OpenAPI requests."), + auth: capletEndpointAuthSchema.describe( + 'Explicit OpenAPI request auth config. Use {"type":"none"} for public APIs.', + ), + requestTimeoutMs: z + .number() + .int() + .positive() + .optional() + .describe("Timeout in milliseconds for OpenAPI HTTP requests."), + operationCacheTtlMs: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Milliseconds OpenAPI operation metadata stays fresh. Set 0 to refresh every time.", + ), + disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."), + projectBinding: capletProjectBindingSchema.optional(), + runtime: capletRuntimeRequirementsSchema.optional(), + }) + .strict() + .superRefine((endpoint, ctx) => { + if (Boolean(endpoint.specPath) === Boolean(endpoint.specUrl)) { + ctx.addIssue({ + code: "custom", + message: "openapiEndpoint must define exactly one spec source: specPath or specUrl", + }); + } + if ( + endpoint.specUrl && + !hasEnvReference(endpoint.specUrl) && + !isAllowedRemoteUrl(endpoint.specUrl) + ) { + ctx.addIssue({ + code: "custom", + path: ["specUrl"], + message: "OpenAPI specUrl must use https except loopback development urls", + }); + } + if ( + endpoint.baseUrl && + !hasEnvReference(endpoint.baseUrl) && + !isAllowedRemoteUrl(endpoint.baseUrl) + ) { + ctx.addIssue({ + code: "custom", + path: ["baseUrl"], + message: "OpenAPI baseUrl must use https except loopback development urls", + }); + } + validateEndpointAuthHeaders(endpoint.auth, ctx); + }); + +const capletGraphQlOperationSchema = z + .object({ + document: z.string().min(1).optional().describe("Inline GraphQL operation document."), + documentPath: z.string().min(1).optional().describe("Path to a GraphQL operation document."), + operationName: z.string().min(1).optional().describe("Operation name to execute."), + description: z.string().min(1).optional().describe("Operation capability description."), + }) + .strict() + .superRefine((operation, ctx) => { + if (Boolean(operation.document) === Boolean(operation.documentPath)) { + ctx.addIssue({ + code: "custom", + message: + "GraphQL operation must define exactly one document source: document or documentPath", + }); + } + }); + +const capletGraphQlEndpointSchema = z + .object({ + endpointUrl: z.string().min(1).describe("GraphQL HTTP endpoint URL."), + schemaPath: z.string().min(1).optional().describe("Local GraphQL SDL or introspection path."), + schemaUrl: z.string().min(1).optional().describe("Remote GraphQL SDL or introspection URL."), + introspection: z + .literal(true) + .optional() + .describe("Load schema through endpoint introspection."), + operations: z + .record(z.string().regex(SERVER_ID_PATTERN), capletGraphQlOperationSchema) + .optional() + .describe("Configured GraphQL operations keyed by stable tool name."), + auth: capletEndpointAuthSchema.describe( + 'Explicit GraphQL request auth config. Use {"type":"none"} for public APIs.', + ), + requestTimeoutMs: z + .number() + .int() + .positive() + .optional() + .describe("Timeout in milliseconds for GraphQL HTTP requests."), + operationCacheTtlMs: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Milliseconds GraphQL operation metadata stays fresh. Set 0 to refresh every time.", + ), + selectionDepth: z + .number() + .int() + .positive() + .max(5) + .optional() + .describe("Maximum depth for auto-generated GraphQL selection sets."), + disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."), + projectBinding: capletProjectBindingSchema.optional(), + runtime: capletRuntimeRequirementsSchema.optional(), + }) + .strict() + .superRefine((endpoint, ctx) => { + const sourceCount = + Number(Boolean(endpoint.schemaPath)) + + Number(Boolean(endpoint.schemaUrl)) + + Number(endpoint.introspection === true); + if (sourceCount !== 1) { + ctx.addIssue({ + code: "custom", + message: + "graphqlEndpoint must define exactly one schema source: schemaPath, schemaUrl, or introspection", + }); + } + if ( + endpoint.endpointUrl && + !hasEnvReference(endpoint.endpointUrl) && + !isAllowedRemoteUrl(endpoint.endpointUrl) + ) { + ctx.addIssue({ + code: "custom", + path: ["endpointUrl"], + message: "GraphQL endpointUrl must use https except loopback development urls", + }); + } + if ( + endpoint.schemaUrl && + !hasEnvReference(endpoint.schemaUrl) && + !isAllowedRemoteUrl(endpoint.schemaUrl) + ) { + ctx.addIssue({ + code: "custom", + path: ["schemaUrl"], + message: "GraphQL schemaUrl must use https except loopback development urls", + }); + } + validateEndpointAuthHeaders(endpoint.auth, ctx); + }); + +const httpScalarMappingSchema = z.record( + z.string(), + z.union([z.string(), z.number(), z.boolean()]), +); + +const capletHttpActionSchema = z + .object({ + method: z + .enum(["GET", "POST", "PUT", "PATCH", "DELETE"]) + .describe("HTTP method used for this action."), + path: z + .string() + .min(1) + .regex(/^\//, "HTTP action path must start with /") + .describe("URL path appended to the HTTP API baseUrl.") + .refine((value) => !value.startsWith("//"), "HTTP action path must not start with //") + .refine((value) => !isUrl(value), "HTTP action path must be a URL path, not a URL"), + description: z.string().min(1).optional().describe("Action capability description."), + inputSchema: z + .record(z.string(), z.unknown()) + .optional() + .describe("JSON Schema for call_tool arguments."), + query: httpScalarMappingSchema.optional().describe("Query parameter mapping."), + headers: httpScalarMappingSchema.optional().describe("Request header mapping."), + jsonBody: z.unknown().optional().describe("JSON request body mapping."), + }) + .strict() + .superRefine((action, ctx) => { + if (action.method === "GET" && action.jsonBody !== undefined) { + ctx.addIssue({ + code: "custom", + path: ["jsonBody"], + message: "HTTP GET actions must not define jsonBody", + }); + } + }); + +const capletHttpApiSchema = z + .object({ + baseUrl: z + .string() + .min(1) + .regex( + HTTP_BASE_URL_PATTERN, + "HTTP API baseUrl must not include credentials, query, or fragment", + ) + .describe("Base URL for HTTP action requests."), + auth: capletEndpointAuthSchema.describe( + 'Explicit HTTP API request auth config. Use {"type":"none"} for public APIs.', + ), + actions: z + .record(z.string().regex(SERVER_ID_PATTERN), capletHttpActionSchema) + .refine( + (actions) => Object.keys(actions).length > 0, + "HTTP API must define at least one action", + ) + .describe("Configured HTTP actions keyed by stable tool name."), + requestTimeoutMs: z + .number() + .int() + .positive() + .optional() + .describe("Timeout in milliseconds for HTTP action requests."), + maxResponseBytes: z + .number() + .int() + .positive() + .optional() + .describe("Maximum HTTP action response body bytes to read."), + disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."), + projectBinding: capletProjectBindingSchema.optional(), + runtime: capletRuntimeRequirementsSchema.optional(), + }) + .strict() + .superRefine((api, ctx) => { + if (api.baseUrl && !hasEnvReference(api.baseUrl) && !isAllowedHttpBaseUrl(api.baseUrl)) { + ctx.addIssue({ + code: "custom", + path: ["baseUrl"], + message: + "HTTP API baseUrl must use https except loopback development urls and must not include credentials, query, or fragment", + }); + } + validateEndpointAuthHeaders(api.auth, ctx); + for (const [actionName, action] of Object.entries(api.actions)) { + if (action.headers) { + validateHttpActionHeaders(action.headers, ctx, ["actions", actionName, "headers"]); + } + } + }); + +const capletCliToolOutputSchema = z + .object({ + type: z.enum(["text", "json"]).optional(), + }) + .strict(); + +const capletCliToolAnnotationsSchema = z + .object({ + readOnlyHint: z.boolean().optional(), + destructiveHint: z.boolean().optional(), + idempotentHint: z.boolean().optional(), + openWorldHint: z.boolean().optional(), + }) + .strict(); + +const capletCliToolActionSchema = z + .object({ + description: z.string().min(1).optional().describe("Action capability description."), + inputSchema: z + .record(z.string(), z.unknown()) + .optional() + .describe("JSON Schema for call_tool arguments."), + outputSchema: z + .record(z.string(), z.unknown()) + .optional() + .describe("JSON Schema for structuredContent returned by this action."), + command: z.string().min(1).describe("Executable command to spawn without a shell."), + args: z.array(z.string()).optional().describe("Arguments passed to the command."), + env: z.record(z.string(), z.string()).optional().describe("Additional environment variables."), + cwd: z.string().min(1).optional().describe("Working directory for this action."), + timeoutMs: z.number().int().positive().optional(), + maxOutputBytes: z.number().int().positive().optional(), + output: capletCliToolOutputSchema.optional(), + annotations: capletCliToolAnnotationsSchema.optional(), + }) + .strict(); + +const capletCliToolsSchema = z + .object({ + actions: z + .record(z.string().regex(SERVER_ID_PATTERN), capletCliToolActionSchema) + .refine( + (actions) => Object.keys(actions).length > 0, + "CLI tools backend must define at least one action", + ) + .describe("Configured CLI actions keyed by stable tool name."), + cwd: z.string().min(1).optional().describe("Default working directory for CLI actions."), + env: z.record(z.string(), z.string()).optional().describe("Default environment variables."), + timeoutMs: z.number().int().positive().optional(), + maxOutputBytes: z.number().int().positive().optional(), + disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."), + projectBinding: capletProjectBindingSchema.optional(), + runtime: capletRuntimeRequirementsSchema.optional(), + }) + .strict(); + +const capletSetSchema = z + .object({ + configPath: z.string().min(1).optional().describe("Child Caplets config.json path."), + capletsRoot: z.string().min(1).optional().describe("Child Markdown Caplets root directory."), + defaultSearchLimit: z.number().int().positive().optional(), + maxSearchLimit: z.number().int().positive().max(50).optional(), + toolCacheTtlMs: z.number().int().nonnegative().optional(), + disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."), + projectBinding: capletProjectBindingSchema.optional(), + runtime: capletRuntimeRequirementsSchema.optional(), + }) + .strict() + .superRefine((set, ctx) => { + if (!set.configPath && !set.capletsRoot) { + ctx.addIssue({ + code: "custom", + message: "capletSet must define at least one source: configPath or capletsRoot", + }); + } + if ( + set.defaultSearchLimit !== undefined && + set.maxSearchLimit !== undefined && + set.defaultSearchLimit > set.maxSearchLimit + ) { + ctx.addIssue({ + code: "custom", + path: ["defaultSearchLimit"], + message: "defaultSearchLimit must be <= maxSearchLimit", + }); + } + }); + +export const capletFileSchema = z + .object({ + $schema: z + .string() + .url() + .optional() + .describe("Optional JSON Schema URL for editor validation."), + name: z.string().trim().min(1).max(80).describe("Human-readable Caplet display name."), + description: z + .string() + .describe("Compact capability description shown before the full Caplet card is disclosed.") + .refine( + (value) => value.trim().length >= 10, + "description must contain at least 10 non-whitespace characters", + ) + .refine((value) => value.length <= 1500, "description must be at most 1500 characters"), + tags: z + .array(z.string().trim().min(1).max(80)) + .optional() + .describe("Optional tags for grouping or searching Caplets."), + setup: capletSetupSchema.optional(), + projectBinding: capletProjectBindingSchema.optional(), + runtime: capletRuntimeRequirementsSchema.optional(), + mcpServer: capletMcpServerSchema + .describe("MCP server backend configuration for this Caplet.") + .optional(), + openapiEndpoint: capletOpenApiEndpointSchema + .describe("OpenAPI endpoint backend configuration for this Caplet.") + .optional(), + graphqlEndpoint: capletGraphQlEndpointSchema + .describe("GraphQL endpoint backend configuration for this Caplet.") + .optional(), + httpApi: capletHttpApiSchema + .describe("HTTP API backend configuration for this Caplet.") + .optional(), + cliTools: capletCliToolsSchema + .describe("CLI tools backend configuration for this Caplet.") + .optional(), + capletSet: capletSetSchema + .describe("Nested Caplet collection backend configuration for this Caplet.") + .optional(), + }) + .strict() + .superRefine((frontmatter, ctx) => { + const backendCount = + Number(Boolean(frontmatter.mcpServer)) + + Number(Boolean(frontmatter.openapiEndpoint)) + + Number(Boolean(frontmatter.graphqlEndpoint)) + + Number(Boolean(frontmatter.httpApi)) + + Number(Boolean(frontmatter.cliTools)) + + Number(Boolean(frontmatter.capletSet)); + if (backendCount !== 1) { + ctx.addIssue({ + code: "custom", + message: + "Caplet file must define exactly one backend: mcpServer, openapiEndpoint, graphqlEndpoint, httpApi, cliTools, or capletSet", + }); + } + }); + +type CapletFileFrontmatter = z.infer; + +export function capletJsonSchema(): unknown { + return patchCapletJsonSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json", + title: "Caplet file frontmatter", + description: "YAML frontmatter schema for a Markdown Caplet file.", + ...z.toJSONSchema(capletFileSchema, { io: "input" }), + }); +} + +export type CapletFileConfig = { + mcpServers?: Record; + openapiEndpoints?: Record; + graphqlEndpoints?: Record; + httpApis?: Record; + cliTools?: Record; + capletSets?: Record; +}; + +export type CapletFileLoadResult = { + config: CapletFileConfig; + paths: Record; +}; + +export type CapletFileMapInput = { + files: Array<{ path: string; content: string }>; +}; + +export type CapletFileWarning = { + path?: string | undefined; + message: string; +}; + +export type BestEffortCapletFileLoadResult = CapletFileLoadResult & { + warnings: CapletFileWarning[]; +}; + +export function loadCapletFilesFromMap( + input: CapletFileMapInput, +): CapletFileLoadResult | undefined { + const files = new Map(); + for (const file of input.files) { + const path = normalizeMapPath(file.path); + if (files.has(path)) { + throw new CapletsError("CONFIG_INVALID", `Duplicate Caplet file path ${path}`); + } + files.set(path, file.content); + } + + return buildCapletFileLoadResultFromEntries( + "in-memory bundle", + discoverCapletFileMapCandidates([...files.keys()]), + (path) => { + const content = files.get(path); + if (content === undefined) { + throw new CapletsError("CONFIG_INVALID", `Caplet file at ${path} was not found`); + } + return readCapletFileContent(path, content, mapDirname(path), normalizeBundleLocalPath); + }, + ); +} + +export function buildCapletFileLoadResultFromEntries( + root: string, + candidates: Array<{ id: string; path: string }>, + readConfig: (path: string) => unknown, + warnings?: CapletFileWarning[], +): BestEffortCapletFileLoadResult | undefined { + const servers: Record = {}; + const openapiEndpoints: Record = {}; + const graphqlEndpoints: Record = {}; + const httpApis: Record = {}; + const cliTools: Record = {}; + const capletSets: Record = {}; + const paths: Record = {}; + + function hasId(id: string): boolean { + return Boolean( + servers[id] || + openapiEndpoints[id] || + graphqlEndpoints[id] || + httpApis[id] || + cliTools[id] || + capletSets[id], + ); + } + + for (const candidate of candidates) { + if (hasId(candidate.id)) { + const message = `Duplicate Caplet ID ${candidate.id} under ${root}`; + if (!warnings) { + throw new CapletsError("CONFIG_INVALID", message); + } + warnings.push({ + path: candidate.path, + message: `${message}; skipping duplicate at ${candidate.path}`, + }); + continue; + } + + let config: unknown; + try { + config = readConfig(candidate.path); + } catch (error) { + if (!warnings) { + throw error; + } + warnings.push({ + path: candidate.path, + message: `Skipping invalid Caplet file at ${candidate.path}: ${errorMessage(error)}`, + }); + continue; + } + + paths[candidate.id] = candidate.path; + if (isPlainObject(config) && config.backend === "openapi") { + const { backend: _backend, ...endpoint } = config; + openapiEndpoints[candidate.id] = endpoint; + } else if (isPlainObject(config) && config.backend === "graphql") { + const { backend: _backend, ...endpoint } = config; + graphqlEndpoints[candidate.id] = endpoint; + } else if (isPlainObject(config) && config.backend === "http") { + const { backend: _backend, ...endpoint } = config; + httpApis[candidate.id] = endpoint; + } else if (isPlainObject(config) && config.backend === "cli") { + const { backend: _backend, ...endpoint } = config; + cliTools[candidate.id] = endpoint; + } else if (isPlainObject(config) && config.backend === "caplets") { + const { backend: _backend, ...endpoint } = config; + capletSets[candidate.id] = endpoint; + } else { + servers[candidate.id] = config; + } + } + + const hasServers = Object.keys(servers).length > 0; + const hasOpenApi = Object.keys(openapiEndpoints).length > 0; + const hasGraphQl = Object.keys(graphqlEndpoints).length > 0; + const hasHttpApis = Object.keys(httpApis).length > 0; + const hasCliTools = Object.keys(cliTools).length > 0; + const hasCapletSets = Object.keys(capletSets).length > 0; + const config = { + ...(hasServers ? { mcpServers: servers } : {}), + ...(hasOpenApi ? { openapiEndpoints } : {}), + ...(hasGraphQl ? { graphqlEndpoints } : {}), + ...(hasHttpApis ? { httpApis } : {}), + ...(hasCliTools ? { cliTools } : {}), + ...(hasCapletSets ? { capletSets } : {}), + }; + const hasConfig = Object.keys(config).length > 0; + if (!hasConfig && warnings?.length === 0) { + return undefined; + } + + return { config, paths, warnings: warnings ?? [] }; +} + +function discoverCapletFileMapCandidates(paths: string[]): Array<{ id: string; path: string }> { + const sorted = [...paths].sort((left, right) => left.localeCompare(right)); + const candidates: Array<{ id: string; path: string; isDirectoryCaplet: boolean }> = []; + for (const path of sorted) { + const segments = path.split("/"); + const fileName = segments.at(-1); + if (!fileName) { + continue; + } + if (fileName === "CAPLET.md" && segments.length > 1) { + candidates.push({ id: segments.at(-2) ?? "CAPLET", path, isDirectoryCaplet: true }); + continue; + } + if (segments.length === 1 && extname(fileName).toLowerCase() === ".md") { + candidates.push({ + id: basename(fileName, extname(fileName)), + path, + isDirectoryCaplet: false, + }); + } + } + + return candidates.map(({ id, path }) => { + validateCapletId(id, path); + return { id, path }; + }); +} + +export function readCapletFileContent( + path: string, + text: string, + baseDir: string, + normalizePath: (value: string | undefined, baseDir: string) => string | undefined, +): unknown { + if (byteLength(text) > MAX_CAPLET_FILE_BYTES) { + throw new CapletsError( + "CONFIG_INVALID", + `Caplet file at ${path} exceeds the ${MAX_CAPLET_FILE_BYTES} byte limit`, + ); + } + const { frontmatter, body } = parseFrontmatter(text, path); + if (body.length > MAX_CAPLET_BODY_CHARS) { + throw new CapletsError( + "CONFIG_INVALID", + `Caplet file at ${path} body exceeds the ${MAX_CAPLET_BODY_CHARS} character limit`, + ); + } + const parsed = capletFileSchema.safeParse(frontmatter); + if (!parsed.success) { + throw new CapletsError( + "CONFIG_INVALID", + `Caplet file at ${path} has invalid frontmatter`, + parsed.error.issues, + ); + } + + return capletToServerConfig(parsed.data, body, baseDir, normalizePath); +} + +function capletToServerConfig( + frontmatter: CapletFileFrontmatter, + body: string, + baseDir: string, + normalizePath: (value: string | undefined, baseDir: string) => string | undefined, +): unknown { + if (frontmatter.openapiEndpoint) { + return { + ...frontmatter.openapiEndpoint, + specPath: normalizePath(frontmatter.openapiEndpoint.specPath, baseDir), + backend: "openapi", + name: frontmatter.name, + description: frontmatter.description, + ...sharedCapletFields(frontmatter), + body, + }; + } + + if (frontmatter.graphqlEndpoint) { + return { + ...frontmatter.graphqlEndpoint, + schemaPath: normalizePath(frontmatter.graphqlEndpoint.schemaPath, baseDir), + operations: normalizeGraphQlOperations( + frontmatter.graphqlEndpoint.operations, + baseDir, + normalizePath, + ), + backend: "graphql", + name: frontmatter.name, + description: frontmatter.description, + ...sharedCapletFields(frontmatter), + body, + }; + } + + if (frontmatter.httpApi) { + return { + ...frontmatter.httpApi, + backend: "http", + name: frontmatter.name, + description: frontmatter.description, + ...sharedCapletFields(frontmatter), + body, + }; + } + + if (frontmatter.cliTools) { + return { + ...frontmatter.cliTools, + cwd: normalizePath(frontmatter.cliTools.cwd, baseDir), + actions: normalizeCliToolActions(frontmatter.cliTools.actions, baseDir, normalizePath), + backend: "cli", + name: frontmatter.name, + description: frontmatter.description, + ...sharedCapletFields(frontmatter), + body, + }; + } + + if (frontmatter.capletSet) { + return { + ...frontmatter.capletSet, + configPath: normalizePath(frontmatter.capletSet.configPath, baseDir), + capletsRoot: normalizePath(frontmatter.capletSet.capletsRoot, baseDir), + backend: "caplets", + name: frontmatter.name, + description: frontmatter.description, + ...sharedCapletFields(frontmatter), + body, + }; + } + + return { + ...frontmatter.mcpServer!, + name: frontmatter.name, + description: frontmatter.description, + ...sharedCapletFields(frontmatter), + body, + }; +} + +function sharedCapletFields(frontmatter: CapletFileFrontmatter): Record { + return { + ...(frontmatter.tags ? { tags: frontmatter.tags } : {}), + ...(frontmatter.setup ? { setup: frontmatter.setup } : {}), + ...(frontmatter.projectBinding ? { projectBinding: frontmatter.projectBinding } : {}), + ...(frontmatter.runtime ? { runtime: frontmatter.runtime } : {}), + }; +} + +function normalizeCliToolActions( + actions: z.infer["actions"], + baseDir: string, + normalizePath: (value: string | undefined, baseDir: string) => string | undefined, +): z.infer["actions"] { + return Object.fromEntries( + Object.entries(actions).map(([name, action]) => [ + name, + { + ...action, + cwd: normalizePath(action.cwd, baseDir) as string | undefined, + }, + ]), + ); +} + +function normalizeGraphQlOperations( + operations: z.infer["operations"], + baseDir: string, + normalizePath: (value: string | undefined, baseDir: string) => string | undefined, +): z.infer["operations"] { + if (!operations) { + return undefined; + } + return Object.fromEntries( + Object.entries(operations).map(([name, operation]) => [ + name, + { + ...operation, + documentPath: normalizePath(operation.documentPath, baseDir), + }, + ]), + ); +} + +export function normalizeBundleLocalPath( + value: string | undefined, + baseDir: string, +): string | undefined { + if (!value || isMapAbsolutePath(value) || hasEnvReference(value)) { + return value; + } + const parts = [...(baseDir ? baseDir.split("/") : []), ...value.split("/")]; + const normalized: string[] = []; + for (const part of parts) { + if (!part || part === ".") { + continue; + } + if (part === "..") { + normalized.pop(); + continue; + } + normalized.push(part); + } + return normalized.join("/"); +} + +export function normalizeMapPath(path: string): string { + const normalized = path.trim().replace(/\\/g, "/").replace(/^\.\//u, ""); + if (!normalized || normalized.startsWith("/") || normalized.includes("/../")) { + throw new CapletsError("CONFIG_INVALID", `Invalid Caplet file path ${path}`); + } + return normalized; +} + +export function mapDirname(path: string): string { + return path.split("/").slice(0, -1).join("/"); +} + +function basename(path: string, suffix = ""): string { + const name = path.split("/").filter(Boolean).at(-1) ?? path; + return suffix && name.endsWith(suffix) ? name.slice(0, -suffix.length) : name; +} + +function extname(path: string): string { + const name = basename(path); + const index = name.lastIndexOf("."); + return index > 0 ? name.slice(index) : ""; +} + +function isMapAbsolutePath(value: string): boolean { + return value.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(value); +} + +function byteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function validateEndpointAuthHeaders( + auth: z.infer | undefined, + ctx: z.RefinementCtx, +): void { + if (auth?.type !== "headers") { + return; + } + for (const headerName of Object.keys(auth.headers)) { + const normalized = headerName.toLowerCase(); + if (!HEADER_NAME_PATTERN.test(headerName) || FORBIDDEN_HEADERS.has(normalized)) { + ctx.addIssue({ + code: "custom", + path: ["auth", "headers", headerName], + message: `header ${headerName} is not allowed`, + }); + } + } +} + +function parseFrontmatter(text: string, path: string): { frontmatter: unknown; body: string } { + if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) { + throw new CapletsError( + "CONFIG_INVALID", + `Caplet file at ${path} must start with fenced YAML frontmatter`, + ); + } + + try { + const newline = text.startsWith("---\r\n") ? "\r\n" : "\n"; + const fence = `${newline}---`; + const fenceIndex = text.indexOf(fence, 3); + if (fenceIndex < 0) { + throw new Error("missing closing frontmatter fence"); + } + + const frontmatterText = text.slice(3 + newline.length, fenceIndex); + const afterFenceIndex = fenceIndex + fence.length; + const bodyStart = + text.slice(afterFenceIndex, afterFenceIndex + 2) === "\r\n" + ? afterFenceIndex + 2 + : text.slice(afterFenceIndex, afterFenceIndex + 1) === "\n" + ? afterFenceIndex + 1 + : afterFenceIndex; + const frontmatter = parseYaml(frontmatterText); + if (!isPlainObject(frontmatter) || Object.keys(frontmatter).length === 0) { + throw new Error("empty frontmatter"); + } + return { + frontmatter, + body: text.slice(bodyStart), + }; + } catch (error) { + throw new CapletsError( + "CONFIG_INVALID", + `Caplet file at ${path} has invalid YAML frontmatter`, + redactSecrets(error), + ); + } +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function validateCapletId(id: string, path: string): void { + if (!SERVER_ID_PATTERN.test(id)) { + throw new CapletsError( + "CONFIG_INVALID", + `Caplet file at ${path} derives invalid ID ${id}; ID must match ^[a-zA-Z0-9_-]{1,64}$`, + ); + } +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function hasEnvReference(value: string): boolean { + return /\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*/.test(value); +} + +function patchCapletJsonSchema(schema: T): T { + const httpApiProperties = schemaPath>(schema, [ + "properties", + "httpApi", + "properties", + ]); + const actions = nestedSchema>(httpApiProperties, "actions"); + if (actions) { + actions.minProperties = 1; + } + const baseUrl = nestedSchema>(httpApiProperties, "baseUrl"); + if (baseUrl) { + baseUrl.format = "uri"; + } + const cliToolsProperties = schemaPath>(schema, [ + "properties", + "cliTools", + "properties", + ]); + const cliActions = nestedSchema>(cliToolsProperties, "actions"); + if (cliActions) { + cliActions.minProperties = 1; + } + return schema; +} diff --git a/packages/core/src/caplet-files.ts b/packages/core/src/caplet-files.ts index 19338ef..1652c80 100644 --- a/packages/core/src/caplet-files.ts +++ b/packages/core/src/caplet-files.ts @@ -1,656 +1,28 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { basename, dirname, extname, isAbsolute, join } from "node:path"; -import { VFile } from "vfile"; -import { matter as parseMatter } from "vfile-matter"; -import { z } from "zod"; +import { CapletsError } from "./errors"; +export { capletFileSchema, capletJsonSchema, loadCapletFilesFromMap } from "./caplet-files-bundle"; +export type { + BestEffortCapletFileLoadResult, + CapletFileConfig, + CapletFileLoadResult, + CapletFileMapInput, + CapletFileWarning, +} from "./caplet-files-bundle"; import { - FORBIDDEN_HEADERS, - HEADER_NAME_PATTERN, - HTTP_BASE_URL_PATTERN, - SERVER_ID_PATTERN, - isAllowedHttpBaseUrl, - isAllowedRemoteUrl, - isUrl, - validateHttpActionHeaders, -} from "./config/validation"; -import { CapletsError, redactSecrets } from "./errors"; -import { nestedSchema, schemaPath } from "./schema-utils"; + buildCapletFileLoadResultFromEntries, + errorMessage, + readCapletFileContent, + validateCapletId, +} from "./caplet-files-bundle"; +import type { + BestEffortCapletFileLoadResult, + CapletFileConfig, + CapletFileLoadResult, + CapletFileWarning, +} from "./caplet-files-bundle"; const MAX_CAPLET_FILE_BYTES = 128 * 1024; -const MAX_CAPLET_BODY_CHARS = 64 * 1024; - -const capletRemoteAuthSchema = z - .discriminatedUnion("type", [ - z.object({ type: z.literal("none") }).strict(), - z.object({ type: z.literal("bearer"), token: z.string().min(1) }).strict(), - z - .object({ type: z.literal("headers"), headers: z.record(z.string(), z.string().min(1)) }) - .strict(), - z - .object({ - type: z.literal("oauth2"), - authorizationUrl: z.string().min(1).optional(), - tokenUrl: z.string().min(1).optional(), - issuer: z.string().min(1).optional(), - resourceMetadataUrl: z.string().min(1).optional(), - authorizationServerMetadataUrl: z.string().min(1).optional(), - openidConfigurationUrl: z.string().min(1).optional(), - clientMetadataUrl: z.string().min(1).optional(), - clientId: z.string().min(1).optional(), - clientSecret: z.string().min(1).optional(), - scopes: z.array(z.string().min(1)).optional(), - redirectUri: z.string().min(1).optional(), - }) - .strict(), - z - .object({ - type: z.literal("oidc"), - authorizationUrl: z.string().min(1).optional(), - tokenUrl: z.string().min(1).optional(), - issuer: z.string().min(1).optional(), - resourceMetadataUrl: z.string().min(1).optional(), - authorizationServerMetadataUrl: z.string().min(1).optional(), - openidConfigurationUrl: z.string().min(1).optional(), - clientMetadataUrl: z.string().min(1).optional(), - clientId: z.string().min(1).optional(), - clientSecret: z.string().min(1).optional(), - scopes: z.array(z.string().min(1)).optional(), - redirectUri: z.string().min(1).optional(), - }) - .strict(), - ]) - .describe("Authentication settings for a remote MCP server."); - -const capletSetupCommandSchema = z - .object({ - label: z.string().min(1).describe("Human-readable setup or verification step label."), - command: z.string().min(1).describe("Executable command to spawn without a shell."), - args: z.array(z.string()).optional().describe("Arguments passed to the command."), - env: z.record(z.string(), z.string()).optional().describe("Additional environment variables."), - cwd: z.string().min(1).optional().describe("Working directory for this command."), - timeoutMs: z.number().int().positive().optional(), - maxOutputBytes: z.number().int().positive().optional(), - }) - .strict(); - -const capletSetupSchema = z - .object({ - commands: z.array(capletSetupCommandSchema).optional(), - verify: z.array(capletSetupCommandSchema).optional(), - }) - .strict() - .refine( - (setup) => (setup.commands?.length ?? 0) > 0 || (setup.verify?.length ?? 0) > 0, - "setup must define at least one command or verify step", - ) - .describe("Optional explicit setup and verification metadata for this Caplet."); - -const capletEndpointAuthSchema = z - .discriminatedUnion("type", [ - z.object({ type: z.literal("none") }).strict(), - z.object({ type: z.literal("bearer"), token: z.string().min(1) }).strict(), - z - .object({ type: z.literal("headers"), headers: z.record(z.string(), z.string().min(1)) }) - .strict(), - z - .object({ - type: z.literal("oauth2"), - authorizationUrl: z.string().min(1).optional(), - tokenUrl: z.string().min(1).optional(), - issuer: z.string().min(1).optional(), - resourceMetadataUrl: z.string().min(1).optional(), - authorizationServerMetadataUrl: z.string().min(1).optional(), - openidConfigurationUrl: z.string().min(1).optional(), - clientMetadataUrl: z.string().min(1).optional(), - clientId: z.string().min(1).optional(), - clientSecret: z.string().min(1).optional(), - scopes: z.array(z.string().min(1)).optional(), - redirectUri: z.string().min(1).optional(), - }) - .strict(), - z - .object({ - type: z.literal("oidc"), - authorizationUrl: z.string().min(1).optional(), - tokenUrl: z.string().min(1).optional(), - issuer: z.string().min(1).optional(), - resourceMetadataUrl: z.string().min(1).optional(), - authorizationServerMetadataUrl: z.string().min(1).optional(), - openidConfigurationUrl: z.string().min(1).optional(), - clientMetadataUrl: z.string().min(1).optional(), - clientId: z.string().min(1).optional(), - clientSecret: z.string().min(1).optional(), - scopes: z.array(z.string().min(1)).optional(), - redirectUri: z.string().min(1).optional(), - }) - .strict(), - ]) - .describe("Authentication settings for an OpenAPI or GraphQL endpoint."); - -const capletMcpServerSchema = z - .object({ - transport: z - .enum(["stdio", "http", "sse"]) - .optional() - .describe("Downstream MCP transport. Defaults to stdio when command is present."), - command: z.string().min(1).optional().describe("Executable command for stdio servers."), - args: z.array(z.string()).optional().describe("Arguments passed to the stdio command."), - env: z - .record(z.string(), z.string()) - .optional() - .describe("Environment variables for stdio servers. Supports ${VAR} and $env:VAR."), - cwd: z.string().min(1).optional().describe("Working directory for stdio servers."), - url: z.string().min(1).optional().describe("Remote MCP server URL for http or sse transport."), - auth: capletRemoteAuthSchema.optional(), - startupTimeoutMs: z - .number() - .int() - .positive() - .optional() - .describe("Timeout in milliseconds for starting or checking a downstream server."), - callTimeoutMs: z - .number() - .int() - .positive() - .optional() - .describe("Timeout in milliseconds for downstream tool calls."), - toolCacheTtlMs: z - .number() - .int() - .nonnegative() - .optional() - .describe("Milliseconds downstream tool metadata stays fresh. Set 0 to refresh every time."), - disabled: z - .boolean() - .optional() - .describe("When true, omit this Caplet from discovery and do not start its MCP server."), - }) - .strict() - .superRefine((server, ctx) => { - const effectiveTransport = server.transport ?? (server.command ? "stdio" : undefined); - const hasStdio = Boolean(server.command); - const hasRemote = Boolean(server.url); - if (hasStdio === hasRemote) { - ctx.addIssue({ - code: "custom", - message: "mcpServer must define exactly one connection shape: command or url", - }); - } - - if (effectiveTransport === "stdio" && !server.command) { - ctx.addIssue({ - code: "custom", - path: ["command"], - message: "stdio servers require command", - }); - } - - if ((effectiveTransport === "http" || effectiveTransport === "sse") && !server.url) { - ctx.addIssue({ - code: "custom", - path: ["url"], - message: "remote servers require url", - }); - } - - if (server.url && !hasEnvReference(server.url) && !isAllowedRemoteUrl(server.url)) { - ctx.addIssue({ - code: "custom", - path: ["url"], - message: "remote url must use https except loopback development urls", - }); - } - - if (server.auth?.type === "oauth2" || server.auth?.type === "oidc") { - for (const field of [ - "authorizationUrl", - "tokenUrl", - "issuer", - "clientMetadataUrl", - "redirectUri", - ] as const) { - const value = server.auth[field]; - if (value && !hasEnvReference(value) && !isUrl(value)) { - ctx.addIssue({ - code: "custom", - path: ["auth", field], - message: `${field} must be a URL or environment reference`, - }); - } - } - } - - if (server.auth?.type === "headers") { - for (const headerName of Object.keys(server.auth.headers)) { - const normalized = headerName.toLowerCase(); - if (!HEADER_NAME_PATTERN.test(headerName) || FORBIDDEN_HEADERS.has(normalized)) { - ctx.addIssue({ - code: "custom", - path: ["auth", "headers", headerName], - message: `header ${headerName} is not allowed`, - }); - } - } - } - }); - -const capletOpenApiEndpointSchema = z - .object({ - specPath: z.string().min(1).optional().describe("Local OpenAPI specification path."), - specUrl: z.string().min(1).optional().describe("Remote OpenAPI specification URL."), - baseUrl: z.string().min(1).optional().describe("Override base URL for OpenAPI requests."), - auth: capletEndpointAuthSchema.describe( - 'Explicit OpenAPI request auth config. Use {"type":"none"} for public APIs.', - ), - requestTimeoutMs: z - .number() - .int() - .positive() - .optional() - .describe("Timeout in milliseconds for OpenAPI HTTP requests."), - operationCacheTtlMs: z - .number() - .int() - .nonnegative() - .optional() - .describe( - "Milliseconds OpenAPI operation metadata stays fresh. Set 0 to refresh every time.", - ), - disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."), - }) - .strict() - .superRefine((endpoint, ctx) => { - if (Boolean(endpoint.specPath) === Boolean(endpoint.specUrl)) { - ctx.addIssue({ - code: "custom", - message: "openapiEndpoint must define exactly one spec source: specPath or specUrl", - }); - } - if ( - endpoint.specUrl && - !hasEnvReference(endpoint.specUrl) && - !isAllowedRemoteUrl(endpoint.specUrl) - ) { - ctx.addIssue({ - code: "custom", - path: ["specUrl"], - message: "OpenAPI specUrl must use https except loopback development urls", - }); - } - if ( - endpoint.baseUrl && - !hasEnvReference(endpoint.baseUrl) && - !isAllowedRemoteUrl(endpoint.baseUrl) - ) { - ctx.addIssue({ - code: "custom", - path: ["baseUrl"], - message: "OpenAPI baseUrl must use https except loopback development urls", - }); - } - validateEndpointAuthHeaders(endpoint.auth, ctx); - }); - -const capletGraphQlOperationSchema = z - .object({ - document: z.string().min(1).optional().describe("Inline GraphQL operation document."), - documentPath: z.string().min(1).optional().describe("Path to a GraphQL operation document."), - operationName: z.string().min(1).optional().describe("Operation name to execute."), - description: z.string().min(1).optional().describe("Operation capability description."), - }) - .strict() - .superRefine((operation, ctx) => { - if (Boolean(operation.document) === Boolean(operation.documentPath)) { - ctx.addIssue({ - code: "custom", - message: - "GraphQL operation must define exactly one document source: document or documentPath", - }); - } - }); - -const capletGraphQlEndpointSchema = z - .object({ - endpointUrl: z.string().min(1).describe("GraphQL HTTP endpoint URL."), - schemaPath: z.string().min(1).optional().describe("Local GraphQL SDL or introspection path."), - schemaUrl: z.string().min(1).optional().describe("Remote GraphQL SDL or introspection URL."), - introspection: z - .literal(true) - .optional() - .describe("Load schema through endpoint introspection."), - operations: z - .record(z.string().regex(SERVER_ID_PATTERN), capletGraphQlOperationSchema) - .optional() - .describe("Configured GraphQL operations keyed by stable tool name."), - auth: capletEndpointAuthSchema.describe( - 'Explicit GraphQL request auth config. Use {"type":"none"} for public APIs.', - ), - requestTimeoutMs: z - .number() - .int() - .positive() - .optional() - .describe("Timeout in milliseconds for GraphQL HTTP requests."), - operationCacheTtlMs: z - .number() - .int() - .nonnegative() - .optional() - .describe( - "Milliseconds GraphQL operation metadata stays fresh. Set 0 to refresh every time.", - ), - selectionDepth: z - .number() - .int() - .positive() - .max(5) - .optional() - .describe("Maximum depth for auto-generated GraphQL selection sets."), - disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."), - }) - .strict() - .superRefine((endpoint, ctx) => { - const sourceCount = - Number(Boolean(endpoint.schemaPath)) + - Number(Boolean(endpoint.schemaUrl)) + - Number(endpoint.introspection === true); - if (sourceCount !== 1) { - ctx.addIssue({ - code: "custom", - message: - "graphqlEndpoint must define exactly one schema source: schemaPath, schemaUrl, or introspection", - }); - } - if ( - endpoint.endpointUrl && - !hasEnvReference(endpoint.endpointUrl) && - !isAllowedRemoteUrl(endpoint.endpointUrl) - ) { - ctx.addIssue({ - code: "custom", - path: ["endpointUrl"], - message: "GraphQL endpointUrl must use https except loopback development urls", - }); - } - if ( - endpoint.schemaUrl && - !hasEnvReference(endpoint.schemaUrl) && - !isAllowedRemoteUrl(endpoint.schemaUrl) - ) { - ctx.addIssue({ - code: "custom", - path: ["schemaUrl"], - message: "GraphQL schemaUrl must use https except loopback development urls", - }); - } - validateEndpointAuthHeaders(endpoint.auth, ctx); - }); - -const httpScalarMappingSchema = z.record( - z.string(), - z.union([z.string(), z.number(), z.boolean()]), -); - -const capletHttpActionSchema = z - .object({ - method: z - .enum(["GET", "POST", "PUT", "PATCH", "DELETE"]) - .describe("HTTP method used for this action."), - path: z - .string() - .min(1) - .regex(/^\//, "HTTP action path must start with /") - .describe("URL path appended to the HTTP API baseUrl.") - .refine((value) => !value.startsWith("//"), "HTTP action path must not start with //") - .refine((value) => !isUrl(value), "HTTP action path must be a URL path, not a URL"), - description: z.string().min(1).optional().describe("Action capability description."), - inputSchema: z - .record(z.string(), z.unknown()) - .optional() - .describe("JSON Schema for call_tool arguments."), - query: httpScalarMappingSchema.optional().describe("Query parameter mapping."), - headers: httpScalarMappingSchema.optional().describe("Request header mapping."), - jsonBody: z.unknown().optional().describe("JSON request body mapping."), - }) - .strict() - .superRefine((action, ctx) => { - if (action.method === "GET" && action.jsonBody !== undefined) { - ctx.addIssue({ - code: "custom", - path: ["jsonBody"], - message: "HTTP GET actions must not define jsonBody", - }); - } - }); - -const capletHttpApiSchema = z - .object({ - baseUrl: z - .string() - .min(1) - .regex( - HTTP_BASE_URL_PATTERN, - "HTTP API baseUrl must not include credentials, query, or fragment", - ) - .describe("Base URL for HTTP action requests."), - auth: capletEndpointAuthSchema.describe( - 'Explicit HTTP API request auth config. Use {"type":"none"} for public APIs.', - ), - actions: z - .record(z.string().regex(SERVER_ID_PATTERN), capletHttpActionSchema) - .refine( - (actions) => Object.keys(actions).length > 0, - "HTTP API must define at least one action", - ) - .describe("Configured HTTP actions keyed by stable tool name."), - requestTimeoutMs: z - .number() - .int() - .positive() - .optional() - .describe("Timeout in milliseconds for HTTP action requests."), - maxResponseBytes: z - .number() - .int() - .positive() - .optional() - .describe("Maximum HTTP action response body bytes to read."), - disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."), - }) - .strict() - .superRefine((api, ctx) => { - if (api.baseUrl && !hasEnvReference(api.baseUrl) && !isAllowedHttpBaseUrl(api.baseUrl)) { - ctx.addIssue({ - code: "custom", - path: ["baseUrl"], - message: - "HTTP API baseUrl must use https except loopback development urls and must not include credentials, query, or fragment", - }); - } - validateEndpointAuthHeaders(api.auth, ctx); - for (const [actionName, action] of Object.entries(api.actions)) { - if (action.headers) { - validateHttpActionHeaders(action.headers, ctx, ["actions", actionName, "headers"]); - } - } - }); - -const capletCliToolOutputSchema = z - .object({ - type: z.enum(["text", "json"]).optional(), - }) - .strict(); - -const capletCliToolAnnotationsSchema = z - .object({ - readOnlyHint: z.boolean().optional(), - destructiveHint: z.boolean().optional(), - idempotentHint: z.boolean().optional(), - openWorldHint: z.boolean().optional(), - }) - .strict(); - -const capletCliToolActionSchema = z - .object({ - description: z.string().min(1).optional().describe("Action capability description."), - inputSchema: z - .record(z.string(), z.unknown()) - .optional() - .describe("JSON Schema for call_tool arguments."), - outputSchema: z - .record(z.string(), z.unknown()) - .optional() - .describe("JSON Schema for structuredContent returned by this action."), - command: z.string().min(1).describe("Executable command to spawn without a shell."), - args: z.array(z.string()).optional().describe("Arguments passed to the command."), - env: z.record(z.string(), z.string()).optional().describe("Additional environment variables."), - cwd: z.string().min(1).optional().describe("Working directory for this action."), - timeoutMs: z.number().int().positive().optional(), - maxOutputBytes: z.number().int().positive().optional(), - output: capletCliToolOutputSchema.optional(), - annotations: capletCliToolAnnotationsSchema.optional(), - }) - .strict(); - -const capletCliToolsSchema = z - .object({ - actions: z - .record(z.string().regex(SERVER_ID_PATTERN), capletCliToolActionSchema) - .refine( - (actions) => Object.keys(actions).length > 0, - "CLI tools backend must define at least one action", - ) - .describe("Configured CLI actions keyed by stable tool name."), - cwd: z.string().min(1).optional().describe("Default working directory for CLI actions."), - env: z.record(z.string(), z.string()).optional().describe("Default environment variables."), - timeoutMs: z.number().int().positive().optional(), - maxOutputBytes: z.number().int().positive().optional(), - disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."), - }) - .strict(); - -const capletSetSchema = z - .object({ - configPath: z.string().min(1).optional().describe("Child Caplets config.json path."), - capletsRoot: z.string().min(1).optional().describe("Child Markdown Caplets root directory."), - defaultSearchLimit: z.number().int().positive().optional(), - maxSearchLimit: z.number().int().positive().max(50).optional(), - toolCacheTtlMs: z.number().int().nonnegative().optional(), - disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."), - }) - .strict() - .superRefine((set, ctx) => { - if (!set.configPath && !set.capletsRoot) { - ctx.addIssue({ - code: "custom", - message: "capletSet must define at least one source: configPath or capletsRoot", - }); - } - if ( - set.defaultSearchLimit !== undefined && - set.maxSearchLimit !== undefined && - set.defaultSearchLimit > set.maxSearchLimit - ) { - ctx.addIssue({ - code: "custom", - path: ["defaultSearchLimit"], - message: "defaultSearchLimit must be <= maxSearchLimit", - }); - } - }); - -export const capletFileSchema = z - .object({ - $schema: z - .string() - .url() - .optional() - .describe("Optional JSON Schema URL for editor validation."), - name: z.string().trim().min(1).max(80).describe("Human-readable Caplet display name."), - description: z - .string() - .describe("Compact capability description shown before the full Caplet card is disclosed.") - .refine( - (value) => value.trim().length >= 10, - "description must contain at least 10 non-whitespace characters", - ) - .refine((value) => value.length <= 1500, "description must be at most 1500 characters"), - tags: z - .array(z.string().trim().min(1).max(80)) - .optional() - .describe("Optional tags for grouping or searching Caplets."), - setup: capletSetupSchema.optional(), - mcpServer: capletMcpServerSchema - .describe("MCP server backend configuration for this Caplet.") - .optional(), - openapiEndpoint: capletOpenApiEndpointSchema - .describe("OpenAPI endpoint backend configuration for this Caplet.") - .optional(), - graphqlEndpoint: capletGraphQlEndpointSchema - .describe("GraphQL endpoint backend configuration for this Caplet.") - .optional(), - httpApi: capletHttpApiSchema - .describe("HTTP API backend configuration for this Caplet.") - .optional(), - cliTools: capletCliToolsSchema - .describe("CLI tools backend configuration for this Caplet.") - .optional(), - capletSet: capletSetSchema - .describe("Nested Caplet collection backend configuration for this Caplet.") - .optional(), - }) - .strict() - .superRefine((frontmatter, ctx) => { - const backendCount = - Number(Boolean(frontmatter.mcpServer)) + - Number(Boolean(frontmatter.openapiEndpoint)) + - Number(Boolean(frontmatter.graphqlEndpoint)) + - Number(Boolean(frontmatter.httpApi)) + - Number(Boolean(frontmatter.cliTools)) + - Number(Boolean(frontmatter.capletSet)); - if (backendCount !== 1) { - ctx.addIssue({ - code: "custom", - message: - "Caplet file must define exactly one backend: mcpServer, openapiEndpoint, graphqlEndpoint, httpApi, cliTools, or capletSet", - }); - } - }); - -type CapletFileFrontmatter = z.infer; - -export function capletJsonSchema(): unknown { - return patchCapletJsonSchema({ - $schema: "https://json-schema.org/draft/2020-12/schema", - $id: "https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json", - title: "Caplet file frontmatter", - description: "YAML frontmatter schema for a Markdown Caplet file.", - ...z.toJSONSchema(capletFileSchema, { io: "input" }), - }); -} - -export type CapletFileConfig = { - mcpServers?: Record; - openapiEndpoints?: Record; - graphqlEndpoints?: Record; - httpApis?: Record; - cliTools?: Record; - capletSets?: Record; -}; - -export type CapletFileLoadResult = { - config: CapletFileConfig; - paths: Record; -}; - -export type CapletFileWarning = { - path?: string | undefined; - message: string; -}; - -export type BestEffortCapletFileLoadResult = CapletFileLoadResult & { - warnings: CapletFileWarning[]; -}; export function loadCapletFiles(root: string): CapletFileConfig | undefined { return loadCapletFilesWithPaths(root)?.config; @@ -661,65 +33,9 @@ export function loadCapletFilesWithPaths(root: string): CapletFileLoadResult | u return undefined; } - const servers: Record = {}; - const openapiEndpoints: Record = {}; - const graphqlEndpoints: Record = {}; - const httpApis: Record = {}; - const cliTools: Record = {}; - const capletSets: Record = {}; - const paths: Record = {}; - for (const candidate of discoverCapletFiles(root)) { - if ( - servers[candidate.id] || - openapiEndpoints[candidate.id] || - graphqlEndpoints[candidate.id] || - httpApis[candidate.id] || - cliTools[candidate.id] || - capletSets[candidate.id] - ) { - throw new CapletsError("CONFIG_INVALID", `Duplicate Caplet ID ${candidate.id} under ${root}`); - } - paths[candidate.id] = candidate.path; - const config = readCapletFile(candidate.path); - if (isPlainObject(config) && config.backend === "openapi") { - const { backend: _backend, ...endpoint } = config; - openapiEndpoints[candidate.id] = endpoint; - } else if (isPlainObject(config) && config.backend === "graphql") { - const { backend: _backend, ...endpoint } = config; - graphqlEndpoints[candidate.id] = endpoint; - } else if (isPlainObject(config) && config.backend === "http") { - const { backend: _backend, ...endpoint } = config; - httpApis[candidate.id] = endpoint; - } else if (isPlainObject(config) && config.backend === "cli") { - const { backend: _backend, ...endpoint } = config; - cliTools[candidate.id] = endpoint; - } else if (isPlainObject(config) && config.backend === "caplets") { - const { backend: _backend, ...endpoint } = config; - capletSets[candidate.id] = endpoint; - } else { - servers[candidate.id] = config; - } - } - - const hasServers = Object.keys(servers).length > 0; - const hasOpenApi = Object.keys(openapiEndpoints).length > 0; - const hasGraphQl = Object.keys(graphqlEndpoints).length > 0; - const hasHttpApis = Object.keys(httpApis).length > 0; - const hasCliTools = Object.keys(cliTools).length > 0; - const hasCapletSets = Object.keys(capletSets).length > 0; - return hasServers || hasOpenApi || hasGraphQl || hasHttpApis || hasCliTools || hasCapletSets - ? { - config: { - ...(hasServers ? { mcpServers: servers } : {}), - ...(hasOpenApi ? { openapiEndpoints } : {}), - ...(hasGraphQl ? { graphqlEndpoints } : {}), - ...(hasHttpApis ? { httpApis } : {}), - ...(hasCliTools ? { cliTools } : {}), - ...(hasCapletSets ? { capletSets } : {}), - }, - paths, - } - : undefined; + return buildCapletFileLoadResultFromEntries(root, discoverCapletFiles(root), (path) => + readCapletFile(path), + ); } export function loadCapletFilesWithPathsBestEffort( @@ -730,101 +46,12 @@ export function loadCapletFilesWithPathsBestEffort( } const warnings: CapletFileWarning[] = []; - return buildCapletFileLoadResult(root, discoverCapletFilesBestEffort(root, warnings), warnings); -} - -function buildCapletFileLoadResult( - root: string, - candidates: Array<{ id: string; path: string }>, - warnings?: CapletFileWarning[], -): BestEffortCapletFileLoadResult | undefined { - const servers: Record = {}; - const openapiEndpoints: Record = {}; - const graphqlEndpoints: Record = {}; - const httpApis: Record = {}; - const cliTools: Record = {}; - const capletSets: Record = {}; - const paths: Record = {}; - - function hasId(id: string): boolean { - return Boolean( - servers[id] || - openapiEndpoints[id] || - graphqlEndpoints[id] || - httpApis[id] || - cliTools[id] || - capletSets[id], - ); - } - - for (const candidate of candidates) { - if (hasId(candidate.id)) { - const message = `Duplicate Caplet ID ${candidate.id} under ${root}`; - if (!warnings) { - throw new CapletsError("CONFIG_INVALID", message); - } - warnings.push({ - path: candidate.path, - message: `${message}; skipping duplicate at ${candidate.path}`, - }); - continue; - } - - let config: unknown; - try { - config = readCapletFile(candidate.path); - } catch (error) { - if (!warnings) { - throw error; - } - warnings.push({ - path: candidate.path, - message: `Skipping invalid Caplet file at ${candidate.path}: ${errorMessage(error)}`, - }); - continue; - } - - paths[candidate.id] = candidate.path; - if (isPlainObject(config) && config.backend === "openapi") { - const { backend: _backend, ...endpoint } = config; - openapiEndpoints[candidate.id] = endpoint; - } else if (isPlainObject(config) && config.backend === "graphql") { - const { backend: _backend, ...endpoint } = config; - graphqlEndpoints[candidate.id] = endpoint; - } else if (isPlainObject(config) && config.backend === "http") { - const { backend: _backend, ...endpoint } = config; - httpApis[candidate.id] = endpoint; - } else if (isPlainObject(config) && config.backend === "cli") { - const { backend: _backend, ...endpoint } = config; - cliTools[candidate.id] = endpoint; - } else if (isPlainObject(config) && config.backend === "caplets") { - const { backend: _backend, ...endpoint } = config; - capletSets[candidate.id] = endpoint; - } else { - servers[candidate.id] = config; - } - } - - const hasServers = Object.keys(servers).length > 0; - const hasOpenApi = Object.keys(openapiEndpoints).length > 0; - const hasGraphQl = Object.keys(graphqlEndpoints).length > 0; - const hasHttpApis = Object.keys(httpApis).length > 0; - const hasCliTools = Object.keys(cliTools).length > 0; - const hasCapletSets = Object.keys(capletSets).length > 0; - const config = { - ...(hasServers ? { mcpServers: servers } : {}), - ...(hasOpenApi ? { openapiEndpoints } : {}), - ...(hasGraphQl ? { graphqlEndpoints } : {}), - ...(hasHttpApis ? { httpApis } : {}), - ...(hasCliTools ? { cliTools } : {}), - ...(hasCapletSets ? { capletSets } : {}), - }; - const hasConfig = Object.keys(config).length > 0; - if (!hasConfig && warnings?.length === 0) { - return undefined; - } - - return { config, paths, warnings: warnings ?? [] }; + return buildCapletFileLoadResultFromEntries( + root, + discoverCapletFilesBestEffort(root, warnings), + (path) => readCapletFile(path), + warnings, + ); } export function discoverCapletFiles(root: string): Array<{ id: string; path: string }> { @@ -938,10 +165,6 @@ function discoverCapletFilesBestEffort( return Array.from(byId.values()).map(({ id, path }) => ({ id, path })); } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function readCapletFile(path: string): unknown { const stat = statSync(path); if (stat.size > MAX_CAPLET_FILE_BYTES) { @@ -951,144 +174,13 @@ function readCapletFile(path: string): unknown { ); } const text = readFileSync(path, "utf8"); - const { frontmatter, body } = parseFrontmatter(text, path); - if (body.length > MAX_CAPLET_BODY_CHARS) { - throw new CapletsError( - "CONFIG_INVALID", - `Caplet file at ${path} body exceeds the ${MAX_CAPLET_BODY_CHARS} character limit`, - ); - } - const parsed = capletFileSchema.safeParse(frontmatter); - if (!parsed.success) { - throw new CapletsError( - "CONFIG_INVALID", - `Caplet file at ${path} has invalid frontmatter`, - parsed.error.issues, - ); - } - - return capletToServerConfig(parsed.data, body, dirname(path)); + return readCapletFileContent(path, text, dirname(path), normalizeLocalPath); } export function validateCapletFile(path: string): void { readCapletFile(path); } -function capletToServerConfig( - frontmatter: CapletFileFrontmatter, - body: string, - baseDir: string, -): unknown { - if (frontmatter.openapiEndpoint) { - return { - ...frontmatter.openapiEndpoint, - specPath: normalizeLocalPath(frontmatter.openapiEndpoint.specPath, baseDir), - backend: "openapi", - name: frontmatter.name, - description: frontmatter.description, - ...(frontmatter.tags ? { tags: frontmatter.tags } : {}), - ...(frontmatter.setup ? { setup: frontmatter.setup } : {}), - body, - }; - } - - if (frontmatter.graphqlEndpoint) { - return { - ...frontmatter.graphqlEndpoint, - schemaPath: normalizeLocalPath(frontmatter.graphqlEndpoint.schemaPath, baseDir), - operations: normalizeGraphQlOperations(frontmatter.graphqlEndpoint.operations, baseDir), - backend: "graphql", - name: frontmatter.name, - description: frontmatter.description, - ...(frontmatter.tags ? { tags: frontmatter.tags } : {}), - ...(frontmatter.setup ? { setup: frontmatter.setup } : {}), - body, - }; - } - - if (frontmatter.httpApi) { - return { - ...frontmatter.httpApi, - backend: "http", - name: frontmatter.name, - description: frontmatter.description, - ...(frontmatter.tags ? { tags: frontmatter.tags } : {}), - ...(frontmatter.setup ? { setup: frontmatter.setup } : {}), - body, - }; - } - - if (frontmatter.cliTools) { - return { - ...frontmatter.cliTools, - cwd: normalizeLocalPath(frontmatter.cliTools.cwd, baseDir), - actions: normalizeCliToolActions(frontmatter.cliTools.actions, baseDir), - backend: "cli", - name: frontmatter.name, - description: frontmatter.description, - ...(frontmatter.tags ? { tags: frontmatter.tags } : {}), - ...(frontmatter.setup ? { setup: frontmatter.setup } : {}), - body, - }; - } - - if (frontmatter.capletSet) { - return { - ...frontmatter.capletSet, - configPath: normalizeLocalPath(frontmatter.capletSet.configPath, baseDir), - capletsRoot: normalizeLocalPath(frontmatter.capletSet.capletsRoot, baseDir), - backend: "caplets", - name: frontmatter.name, - description: frontmatter.description, - ...(frontmatter.tags ? { tags: frontmatter.tags } : {}), - ...(frontmatter.setup ? { setup: frontmatter.setup } : {}), - body, - }; - } - - return { - ...frontmatter.mcpServer!, - name: frontmatter.name, - description: frontmatter.description, - ...(frontmatter.tags ? { tags: frontmatter.tags } : {}), - ...(frontmatter.setup ? { setup: frontmatter.setup } : {}), - body, - }; -} - -function normalizeCliToolActions( - actions: z.infer["actions"], - baseDir: string, -): z.infer["actions"] { - return Object.fromEntries( - Object.entries(actions).map(([name, action]) => [ - name, - { - ...action, - cwd: normalizeLocalPath(action.cwd, baseDir) as string | undefined, - }, - ]), - ); -} - -function normalizeGraphQlOperations( - operations: z.infer["operations"], - baseDir: string, -): z.infer["operations"] { - if (!operations) { - return undefined; - } - return Object.fromEntries( - Object.entries(operations).map(([name, operation]) => [ - name, - { - ...operation, - documentPath: normalizeLocalPath(operation.documentPath, baseDir), - }, - ]), - ); -} - function normalizeLocalPath(value: string | undefined, baseDir: string): string | undefined { if (!value || isAbsolute(value) || hasEnvReference(value)) { return value; @@ -1096,91 +188,6 @@ function normalizeLocalPath(value: string | undefined, baseDir: string): string return join(baseDir, value); } -function validateEndpointAuthHeaders( - auth: z.infer | undefined, - ctx: z.RefinementCtx, -): void { - if (auth?.type !== "headers") { - return; - } - for (const headerName of Object.keys(auth.headers)) { - const normalized = headerName.toLowerCase(); - if (!HEADER_NAME_PATTERN.test(headerName) || FORBIDDEN_HEADERS.has(normalized)) { - ctx.addIssue({ - code: "custom", - path: ["auth", "headers", headerName], - message: `header ${headerName} is not allowed`, - }); - } - } -} - -function parseFrontmatter(text: string, path: string): { frontmatter: unknown; body: string } { - if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) { - throw new CapletsError( - "CONFIG_INVALID", - `Caplet file at ${path} must start with fenced YAML frontmatter`, - ); - } - - try { - const file = new VFile({ path, value: text }); - parseMatter(file, { strip: true }); - if (!isPlainObject(file.data.matter) || Object.keys(file.data.matter).length === 0) { - throw new Error("empty frontmatter"); - } - return { - frontmatter: file.data.matter, - body: String(file), - }; - } catch (error) { - throw new CapletsError( - "CONFIG_INVALID", - `Caplet file at ${path} has invalid YAML frontmatter`, - redactSecrets(error), - ); - } -} - -function isPlainObject(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function validateCapletId(id: string, path: string): void { - if (!SERVER_ID_PATTERN.test(id)) { - throw new CapletsError( - "CONFIG_INVALID", - `Caplet file at ${path} derives invalid ID ${id}; ID must match ^[a-zA-Z0-9_-]{1,64}$`, - ); - } -} - function hasEnvReference(value: string): boolean { return /\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*/.test(value); } - -function patchCapletJsonSchema(schema: T): T { - const httpApiProperties = schemaPath>(schema, [ - "properties", - "httpApi", - "properties", - ]); - const actions = nestedSchema>(httpApiProperties, "actions"); - if (actions) { - actions.minProperties = 1; - } - const baseUrl = nestedSchema>(httpApiProperties, "baseUrl"); - if (baseUrl) { - baseUrl.format = "uri"; - } - const cliToolsProperties = schemaPath>(schema, [ - "properties", - "cliTools", - "properties", - ]); - const cliActions = nestedSchema>(cliToolsProperties, "actions"); - if (cliActions) { - cliActions.minProperties = 1; - } - return schema; -} diff --git a/packages/core/src/caplet-source/bundle.ts b/packages/core/src/caplet-source/bundle.ts new file mode 100644 index 0000000..6336db9 --- /dev/null +++ b/packages/core/src/caplet-source/bundle.ts @@ -0,0 +1,33 @@ +import { CapletsError } from "../errors"; +import type { CapletSource, CapletSourceFile } from "./types"; +import { normalizeCapletSourcePath } from "./types"; + +export class BundleCapletSource implements CapletSource { + private readonly files: Map; + + constructor(files: CapletSourceFile[]) { + this.files = new Map(); + for (const file of files) { + const path = normalizeCapletSourcePath(file.path); + if (!path) { + throw new CapletsError("CONFIG_INVALID", `Invalid bundle file path ${file.path}`); + } + if (this.files.has(path)) { + throw new CapletsError("CONFIG_INVALID", `Duplicate bundle file path ${path}`); + } + this.files.set(path, { path, content: file.content }); + } + } + + async listFiles(): Promise { + return [...this.files.values()].sort((left, right) => left.path.localeCompare(right.path)); + } + + async readFile(path: string): Promise { + const normalized = normalizeCapletSourcePath(path); + if (!normalized) { + return undefined; + } + return this.files.get(normalized); + } +} diff --git a/packages/core/src/caplet-source/filesystem.ts b/packages/core/src/caplet-source/filesystem.ts new file mode 100644 index 0000000..a84e224 --- /dev/null +++ b/packages/core/src/caplet-source/filesystem.ts @@ -0,0 +1,65 @@ +import { Dirent, existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import type { CapletSource, CapletSourceFile } from "./types"; +import { normalizeCapletSourcePath } from "./types"; + +export class FilesystemCapletSource implements CapletSource { + private readonly root: string; + + constructor(root: string) { + this.root = resolve(root); + } + + async listFiles(): Promise { + if (!existsSync(this.root) || !statSync(this.root).isDirectory()) { + return []; + } + return walkFiles(this.root, this.root).sort((left, right) => + left.path.localeCompare(right.path), + ); + } + + async readFile(path: string): Promise { + const normalized = normalizeCapletSourcePath(path); + if (!normalized) { + return undefined; + } + const absolute = resolve(this.root, normalized); + if ( + !isWithinRoot(this.root, absolute) || + !existsSync(absolute) || + !statSync(absolute).isFile() + ) { + return undefined; + } + return { path: normalized, content: readFileSync(absolute, "utf8") }; + } +} + +function walkFiles(root: string, dir: string): CapletSourceFile[] { + const files: CapletSourceFile[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort(compareDirents)) { + const absolute = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...walkFiles(root, absolute)); + continue; + } + if (!entry.isFile()) { + continue; + } + const normalized = normalizeCapletSourcePath(relative(root, absolute)); + if (normalized) { + files.push({ path: normalized, content: readFileSync(absolute, "utf8") }); + } + } + return files; +} + +function compareDirents(left: Dirent, right: Dirent): number { + return left.name.localeCompare(right.name); +} + +function isWithinRoot(root: string, absolute: string): boolean { + const rel = relative(root, absolute); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} diff --git a/packages/core/src/caplet-source/index.ts b/packages/core/src/caplet-source/index.ts new file mode 100644 index 0000000..4c37301 --- /dev/null +++ b/packages/core/src/caplet-source/index.ts @@ -0,0 +1,9 @@ +export { BundleCapletSource } from "./bundle"; +export { parseCapletSource } from "./parse"; +export type { + CapletSourceParseMessage, + CapletSourceParseResult, + CapletSourceReference, + ParsedCapletSourceCaplet, +} from "./parse"; +export type { CapletSource, CapletSourceFile } from "./types"; diff --git a/packages/core/src/caplet-source/parse.ts b/packages/core/src/caplet-source/parse.ts new file mode 100644 index 0000000..37bca58 --- /dev/null +++ b/packages/core/src/caplet-source/parse.ts @@ -0,0 +1,181 @@ +import { loadCapletFilesFromMap } from "../caplet-files-bundle"; +import { parseConfig, type CapletConfig, type CapletsConfig } from "../config-runtime"; +import { planCapletRuntimeRoutes, type CapletRuntimePlan } from "../runtime-plan"; +import type { CapletSource } from "./types"; + +export type CapletSourceReference = { + path: string; + exists: boolean; +}; + +export type ParsedCapletSourceCaplet = { + id: string; + name: string; + description: string; + backend: CapletConfig["backend"]; + sourcePath: string; + setupRequired: boolean; + authRequired: boolean; + projectBindingRequired: boolean; + runtime: CapletRuntimePlan["runtime"] & { + route: CapletRuntimePlan["route"]; + setupTarget?: CapletRuntimePlan["setupTarget"] | undefined; + }; + localReferences: CapletSourceReference[]; + config: CapletConfig; +}; + +export type CapletSourceParseMessage = { + path?: string | undefined; + message: string; +}; + +export type CapletSourceParseResult = { + ok: boolean; + config?: CapletsConfig | undefined; + resolvedCaplets: ParsedCapletSourceCaplet[]; + warnings: CapletSourceParseMessage[]; + errors: CapletSourceParseMessage[]; +}; + +export async function parseCapletSource(source: CapletSource): Promise { + const files = await source.listFiles(); + let loaded: ReturnType; + try { + loaded = loadCapletFilesFromMap({ files }); + } catch (error) { + return { + ok: false, + resolvedCaplets: [], + warnings: [], + errors: [{ message: errorMessage(error) }], + }; + } + + if (!loaded) { + return { + ok: false, + resolvedCaplets: [], + warnings: [], + errors: [ + { + message: + "Caplet source must include at least one CAPLET.md or top-level Markdown Caplet file.", + }, + ], + }; + } + + let config: CapletsConfig; + try { + config = parseConfig({ version: 1, ...loaded.config }); + } catch (error) { + return { + ok: false, + resolvedCaplets: [], + warnings: [], + errors: [{ message: errorMessage(error) }], + }; + } + + const configCaplets = capletsFromConfig(config); + const plansById = new Map( + planCapletRuntimeRoutes(configCaplets, { deployment: "hosted" }).map((plan) => [plan.id, plan]), + ); + const caplets = configCaplets.map((caplet) => { + const plan = plansById.get(caplet.server); + return { + id: caplet.server, + name: caplet.name, + description: caplet.description, + backend: caplet.backend, + sourcePath: loaded.paths[caplet.server] ?? "CAPLET.md", + setupRequired: Boolean(caplet.setup), + authRequired: authRequired("auth" in caplet ? caplet.auth : undefined), + projectBindingRequired: plan?.projectBindingRequired ?? false, + runtime: { + ...(plan?.runtime ?? { + features: [], + featureProvenance: [], + resources: { class: "standard", cpu: 2, memoryMb: 4096, diskMb: 8192 }, + }), + route: plan?.route ?? "local_only", + ...(plan?.setupTarget === undefined ? {} : { setupTarget: plan.setupTarget }), + }, + localReferences: localReferencePaths(caplet).map((path) => ({ path, exists: false })), + config: caplet, + }; + }); + + for (const caplet of caplets) { + for (const reference of caplet.localReferences) { + reference.exists = Boolean(await source.readFile(reference.path)); + } + } + + const errors = caplets.flatMap((caplet) => + caplet.localReferences + .filter((reference) => !reference.exists) + .map((reference) => ({ + path: caplet.sourcePath, + message: `Referenced file ${reference.path} was not found.`, + })), + ); + + return { + ok: errors.length === 0, + config, + resolvedCaplets: errors.length === 0 ? caplets : [], + warnings: [], + errors, + }; +} + +function capletsFromConfig(config: CapletsConfig): CapletConfig[] { + return [ + ...Object.values(config.mcpServers), + ...Object.values(config.openapiEndpoints), + ...Object.values(config.graphqlEndpoints), + ...Object.values(config.httpApis), + ...Object.values(config.cliTools), + ...Object.values(config.capletSets), + ]; +} + +function localReferencePaths(caplet: CapletConfig): string[] { + if (caplet.backend === "openapi") { + return filterLocalReferences([caplet.specPath]); + } + if (caplet.backend === "graphql") { + return filterLocalReferences([ + caplet.schemaPath, + ...Object.values(caplet.operations ?? {}).map((operation) => operation.documentPath), + ]); + } + if (caplet.backend === "caplets") { + return filterLocalReferences([caplet.configPath]); + } + return []; +} + +function filterLocalReferences(values: Array): string[] { + return values.filter( + (value): value is string => + typeof value === "string" && + value.length > 0 && + !hasEnvReference(value) && + !/^[a-z][a-z0-9+.-]*:/iu.test(value), + ); +} + +function authRequired(auth: unknown): boolean { + return auth !== null && typeof auth === "object" && "type" in auth && auth.type !== "none"; +} + +function hasEnvReference(value: string): boolean { + return /\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*/u.test(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/src/caplet-source/types.ts b/packages/core/src/caplet-source/types.ts new file mode 100644 index 0000000..b1e539a --- /dev/null +++ b/packages/core/src/caplet-source/types.ts @@ -0,0 +1,33 @@ +export type CapletSourceFile = { + path: string; + content: string; +}; + +export type CapletSource = { + listFiles(): Promise; + readFile(path: string): Promise; +}; + +export function normalizeCapletSourcePath(path: string): string | undefined { + const normalized = path.trim().replace(/\\/g, "/").replace(/^\.\//u, ""); + if (!normalized || normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized)) { + return undefined; + } + + const stack: string[] = []; + for (const segment of normalized.split("/")) { + if (!segment || segment === ".") { + continue; + } + if (segment === "..") { + if (stack.length === 0) { + return undefined; + } + stack.pop(); + continue; + } + stack.push(segment); + } + + return stack.length > 0 ? stack.join("/") : undefined; +} diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 6368f5e..27a1750 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -20,7 +20,7 @@ import { } from "./cli/auth"; import { cliCommands } from "./cli/commands"; import { initConfig } from "./cli/init"; -import { formatDoctorReport } from "./cli/doctor"; +import { doctorJsonReport, formatDoctorReport } from "./cli/doctor"; import { completeCliWords, completionScript, @@ -28,6 +28,13 @@ import { trailingSpaceCompletionToken, type CompletionShell, } from "./cli/completion"; +import { CloudAuthClient } from "./cloud-auth/client"; +import { openBrowserUrl } from "./cloud-auth/open-url"; +import { + CloudAuthStore, + redactedCloudAuthStatus, + type CloudAuthCredentials, +} from "./cloud-auth/store"; import { formatCapletList, formatConfigPaths, @@ -55,10 +62,24 @@ import { } from "./config"; import { CapletsEngine } from "./engine"; import { CapletsError } from "./errors"; +import { attachProjectOnce, attachProjectSession } from "./project-binding/attach"; +import { ProjectBindingError } from "./project-binding/errors"; +import type { ProjectBindingWebSocketFactory } from "./project-binding/transport"; import { RemoteControlClient } from "./remote-control/client"; import type { RemoteCliCommand } from "./remote-control/types"; import { resolveCapletsMode, resolveCapletsServer } from "./server/options"; -import { resolveServeOptions, serveResolvedCaplets, type ServeOptions } from "./serve"; +import { + daemonStatus, + disableDaemon, + enableDaemon, + resolveServeOptions, + restartDaemon, + serveResolvedCaplets, + startDaemon, + stopDaemon, + type ServeDaemonOperationOptions, + type ServeOptions, +} from "./serve"; export { initConfig, starterConfig } from "./cli/init"; export { installCaplets, normalizeGitRepo } from "./cli/install"; @@ -75,10 +96,13 @@ type CliIO = { writeErr?: (value: string) => void; env?: NodeJS.ProcessEnv | Record; fetch?: typeof fetch; + signal?: AbortSignal; + projectBindingWebSocketFactory?: ProjectBindingWebSocketFactory; authDir?: string; version?: string; setExitCode?: (code: number) => void; serve?: (options: ServeOptions) => Promise; + daemon?: ServeDaemonOperationOptions; runSetupCommand?: SetupCommandRunner; }; @@ -109,6 +133,108 @@ function normalizeCompletionWords(words: string[]): string[] { return words.map((word) => (word === trailingSpaceCompletionToken ? "" : word)); } +type ServeDaemonCommandOptions = { + transport?: string; + host?: string; + port?: string; + path?: string; + user?: string; + password?: string; + allowUnauthenticatedHttp?: boolean; + trustProxy?: boolean; + json?: boolean; +}; + +function addServeDaemonCommand( + parent: Command, + name: string, + description: string, + action: (options: ServeDaemonCommandOptions) => Promise, +): void { + parent + .command(name) + .description(description) + .option("--transport ", "server transport: http") + .option("--host ", "HTTP bind host") + .option("--port ", "HTTP bind port") + .option("--path ", "HTTP service base path") + .option("--user ", "HTTP Basic Auth username") + .option("--password ", "HTTP Basic Auth password") + .option( + "--allow-unauthenticated-http", + "allow unauthenticated HTTP serving on non-loopback hosts", + ) + .option("--trust-proxy", "trust X-Forwarded-* headers from a reverse proxy") + .option("--json", "print JSON output") + .action(function (this: Command, options: ServeDaemonCommandOptions) { + return action({ ...this.parent?.opts(), ...options }); + }); +} + +function cloudAuthStore( + env: NodeJS.ProcessEnv | Record, +): CloudAuthStore { + return new CloudAuthStore({ env }); +} + +function cloudAuthStatus(credentials: CloudAuthCredentials | undefined): Record { + return redactedCloudAuthStatus(credentials); +} + +function isProjectBindingWebSocketUnavailable(error: unknown): boolean { + return ( + error instanceof CapletsError && + error.code === "SERVER_UNAVAILABLE" && + error.message.includes("Project Binding WebSocket unavailable") + ); +} + +function isProjectBindingCliError(error: unknown): error is ProjectBindingError { + return error instanceof ProjectBindingError; +} + +function serveRawOptions(options: ServeDaemonCommandOptions) { + return { + ...(options.transport !== undefined ? { transport: options.transport } : {}), + ...(options.host !== undefined ? { host: options.host } : {}), + ...(options.port !== undefined ? { port: options.port } : {}), + ...(options.path !== undefined ? { path: options.path } : {}), + ...(options.user !== undefined ? { user: options.user } : {}), + ...(options.password !== undefined ? { password: options.password } : {}), + ...(options.allowUnauthenticatedHttp !== undefined + ? { allowUnauthenticatedHttp: options.allowUnauthenticatedHttp } + : {}), + ...(options.trustProxy !== undefined ? { trustProxy: options.trustProxy } : {}), + }; +} + +async function waitForCloudLogin( + client: CloudAuthClient, + loginId: string, + env: NodeJS.ProcessEnv | Record, +) { + const timeoutMs = numberEnv(env.CAPLETS_CLOUD_AUTH_TIMEOUT_MS, 120_000); + const intervalMs = numberEnv(env.CAPLETS_CLOUD_AUTH_POLL_INTERVAL_MS, 1_500); + const started = Date.now(); + while (Date.now() - started <= timeoutMs) { + const result = await client.pollLogin(loginId); + if (result.status !== "pending") return result; + await sleep(intervalMs); + } + return { status: "expired" as const, message: "Cloud Auth login timed out." }; +} + +function numberEnv(value: string | undefined, fallback: number): number { + if (value === undefined) return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +async function sleep(ms: number): Promise { + if (ms <= 0) return; + await new Promise((resolve) => setTimeout(resolve, ms)); +} + export function createProgram(io: CliIO = {}): Command { const writeOut = io.writeOut ?? ((value: string) => process.stdout.write(value)); const writeErr = io.writeErr ?? ((value: string) => process.stderr.write(value)); @@ -197,7 +323,7 @@ export function createProgram(io: CliIO = {}): Command { if (suggestions.length > 0) writeOut(`${suggestions.join("\n")}\n`); }); - program + const serve = program .command(cliCommands.serve) .description("Serve configured Caplets as an MCP server.") .option("--transport ", "server transport: stdio or http") @@ -239,6 +365,371 @@ export function createProgram(io: CliIO = {}): Command { }, ); + const daemonOptions = (): ServeDaemonOperationOptions => ({ + env, + ...io.daemon, + }); + + addServeDaemonCommand( + serve, + "start", + "Start the default Caplets HTTP daemon.", + async (options) => { + const result = await startDaemon(serveRawOptions(options), daemonOptions()); + const serveConfig = result.status.config?.serve; + writeOut( + `Started Caplets HTTP daemon on ${serveConfig?.host ?? "127.0.0.1"}:${serveConfig?.port ?? 5387}.\n`, + ); + if (options.json) writeOut(`${JSON.stringify(result.status, null, 2)}\n`); + }, + ); + addServeDaemonCommand(serve, "stop", "Stop the default Caplets HTTP daemon.", async (options) => { + const result = await stopDaemon(daemonOptions()); + if (options.json) { + writeOut(`${JSON.stringify(result.status, null, 2)}\n`); + return; + } + writeOut("Stopped Caplets HTTP daemon.\n"); + }); + addServeDaemonCommand( + serve, + "status", + "Show the default Caplets HTTP daemon status.", + async (options) => { + const status = await daemonStatus(daemonOptions()); + if (options.json) { + writeOut(`${JSON.stringify(status, null, 2)}\n`); + return; + } + writeOut( + status.running + ? `Caplets HTTP daemon is running${status.pid ? ` (pid ${status.pid})` : ""}.\n` + : "Caplets HTTP daemon is stopped.\n", + ); + }, + ); + addServeDaemonCommand( + serve, + "restart", + "Restart the default Caplets HTTP daemon.", + async (options) => { + const result = await restartDaemon(serveRawOptions(options), daemonOptions()); + if (options.json) { + writeOut(`${JSON.stringify(result.status, null, 2)}\n`); + return; + } + const serveConfig = result.status.config?.serve; + writeOut( + `Restarted Caplets HTTP daemon on ${serveConfig?.host ?? "127.0.0.1"}:${serveConfig?.port ?? 5387}.\n`, + ); + }, + ); + addServeDaemonCommand( + serve, + "enable", + "Enable the default Caplets HTTP daemon at login.", + async (options) => { + const result = await enableDaemon(daemonOptions()); + if (options.json) { + writeOut(`${JSON.stringify(result, null, 2)}\n`); + return; + } + writeOut(`Enabled Caplets HTTP daemon at login (${result.descriptor.kind}).\n`); + }, + ); + addServeDaemonCommand( + serve, + "disable", + "Disable the default Caplets HTTP daemon at login.", + async (options) => { + const result = await disableDaemon(daemonOptions()); + if (options.json) { + writeOut(`${JSON.stringify(result, null, 2)}\n`); + return; + } + writeOut(`Disabled Caplets HTTP daemon at login (${result.descriptor.kind}).\n`); + }, + ); + + program + .command(cliCommands.attach) + .description("Attach the current project to a remote Caplets runtime.") + .option("--remote-url ", "remote Caplets service base URL") + .option("--user ", "remote Basic Auth username") + .option("--password ", "remote Basic Auth password") + .option("--token ", "remote bearer token") + .option("--workspace ", "hosted Cloud workspace ID or slug") + .option("--json", "print JSON status events") + .option("--verbose", "print detailed attach diagnostics") + .option("--once", "validate Project Binding once and exit") + .option("--project-root ", "test-only project root override") + .action( + async (options: { + remoteUrl?: string; + user?: string; + password?: string; + token?: string; + workspace?: string; + json?: boolean; + verbose?: boolean; + once?: boolean; + projectRoot?: string; + }) => { + try { + const attachOptions = { ...options, ...(io.fetch ? { fetch: io.fetch } : {}) }; + const sessionOptions = options.json + ? { + signal: io.signal, + webSocketFactory: io.projectBindingWebSocketFactory, + onEvent: (event: import("./project-binding/attach").AttachSessionEvent) => + writeOut(`${JSON.stringify(event, null, 2)}\n`), + } + : { + signal: io.signal, + webSocketFactory: io.projectBindingWebSocketFactory, + }; + const result = options.once + ? await attachProjectOnce(attachOptions, env) + : await attachProjectSession(attachOptions, env, sessionOptions); + if (options.json) { + if (options.once) writeOut(`${JSON.stringify(result, null, 2)}\n`); + return; + } + writeOut(`Project Binding available at ${result.webSocketUrl}.\n`); + } catch (error) { + if (options.json && isProjectBindingWebSocketUnavailable(error)) { + writeOut( + `${JSON.stringify( + { + ok: false, + error: { + code: "PROJECT_BINDING_WEBSOCKET_UNAVAILABLE", + message: error instanceof Error ? error.message : String(error), + }, + }, + null, + 2, + )}\n`, + ); + setExitCode(1); + return; + } + if (options.json && isProjectBindingCliError(error)) { + writeOut( + `${JSON.stringify( + { + ok: false, + error: { + code: error.projectBindingCode, + message: error.message, + recoveryCommand: error.recoveryCommand, + requestId: error.requestId, + }, + }, + null, + 2, + )}\n`, + ); + setExitCode(1); + return; + } + throw error; + } + if (!options.once && options.verbose) { + writeErr( + "Long-running Project Binding attach will keep using the WebSocket transport.\n", + ); + } + }, + ); + + const cloud = program.command(cliCommands.cloud).description("Manage hosted Caplets Cloud."); + const cloudAuth = cloud + .command("auth") + .description("Authenticate this Caplets client to hosted Caplets Cloud."); + cloudAuth + .command("login") + .description("Log in to hosted Caplets Cloud.") + .option("--cloud-url ", "hosted Caplets Cloud URL") + .option("--workspace ", "workspace ID or slug to select") + .option("--device-name ", "device label for this Cloud Auth credential") + .option("--no-open", "print the login URL without opening a browser") + .option("--json", "print JSON output") + .action( + async (options: { + cloudUrl?: string; + workspace?: string; + deviceName?: string; + open?: boolean; + json?: boolean; + }) => { + const cloudUrl = options.cloudUrl ?? env.CAPLETS_CLOUD_URL ?? "https://cloud.caplets.dev"; + const client = new CloudAuthClient({ cloudUrl, ...(io.fetch ? { fetch: io.fetch } : {}) }); + const started = await client.startLogin({ + requestedWorkspace: options.workspace, + deviceName: options.deviceName ?? env.CAPLETS_DEVICE_NAME ?? "Caplets CLI", + }); + if (options.open !== false) await openBrowserUrl(started.loginUrl); + if (!options.json) { + writeOut(`Open ${started.loginUrl}\n`); + writeOut(`Enter code ${started.userCode} if prompted.\n`); + } + + const completed = await waitForCloudLogin(client, started.loginId, env); + if (completed.status !== "completed") { + const message = + completed.status === "workspace_selection_required" + ? "Workspace selection is required in the browser." + : `Cloud Auth login ${completed.status}.`; + throw new CapletsError("AUTH_FAILED", message); + } + const exchanged = await client.exchangeToken({ + loginId: started.loginId, + oneTimeCode: completed.oneTimeCode, + }); + const now = new Date().toISOString(); + const credentials: CloudAuthCredentials = { + version: 2, + cloudUrl: exchanged.cloudUrl, + workspaceId: exchanged.workspaceId, + ...(exchanged.workspaceSlug ? { workspaceSlug: exchanged.workspaceSlug } : {}), + accessToken: exchanged.accessToken, + refreshToken: exchanged.refreshToken ?? "", + expiresAt: exchanged.expiresAt, + scope: exchanged.scope, + tokenType: exchanged.tokenType, + credentialFamilyId: exchanged.credentialFamilyId, + deviceName: exchanged.deviceName ?? options.deviceName ?? "Caplets CLI", + createdAt: now, + lastRefreshAt: now, + }; + await cloudAuthStore(env).save(credentials); + const status = cloudAuthStatus(credentials); + if (options.json) { + writeOut(`${JSON.stringify(status, null, 2)}\n`); + return; + } + writeOut( + `Authenticated to ${credentials.cloudUrl} as workspace ${credentials.workspaceSlug ?? credentials.workspaceId}.\n`, + ); + }, + ); + cloudAuth + .command("status") + .description("Show hosted Caplets Cloud authentication status.") + .option("--json", "print JSON output") + .action(async (options: { json?: boolean }) => { + const credentials = await cloudAuthStore(env).load(); + if (options.json) { + writeOut(`${JSON.stringify(cloudAuthStatus(credentials), null, 2)}\n`); + return; + } + writeOut( + credentials + ? `Authenticated to ${credentials.cloudUrl} as workspace ${credentials.workspaceSlug ?? credentials.workspaceId}.\n` + : "Not authenticated to hosted Caplets Cloud.\n", + ); + }); + cloudAuth + .command("logout") + .description("Log out of hosted Caplets Cloud.") + .action(async () => { + const store = cloudAuthStore(env); + const credentials = await store.load(); + if (credentials?.refreshToken) { + await new CloudAuthClient({ + cloudUrl: credentials.cloudUrl, + ...(io.fetch ? { fetch: io.fetch } : {}), + }) + .logout(credentials.refreshToken) + .catch(() => undefined); + } + await store.clear(); + writeOut("Logged out of hosted Caplets Cloud.\n"); + }); + cloudAuth + .command("workspaces") + .description("List hosted Caplets Cloud workspaces.") + .option("--json", "print JSON output") + .action(async (options: { json?: boolean }) => { + const credentials = await cloudAuthStore(env).load(); + const workspaces = credentials?.accessToken + ? ( + await new CloudAuthClient({ + cloudUrl: credentials.cloudUrl, + ...(io.fetch ? { fetch: io.fetch } : {}), + }) + .workspaces(credentials.accessToken) + .catch(() => ({ + workspaces: [ + { + workspaceId: credentials.workspaceId, + ...(credentials.workspaceSlug ? { slug: credentials.workspaceSlug } : {}), + }, + ], + })) + ).workspaces.map((workspace) => ({ + ...workspace, + selected: + workspace.workspaceId === credentials.workspaceId || + workspace.slug === credentials.workspaceSlug, + })) + : []; + if (options.json) { + writeOut(`${JSON.stringify({ workspaces }, null, 2)}\n`); + return; + } + if (workspaces.length === 0) { + writeOut("No hosted Caplets Cloud workspaces available. Run caplets cloud auth login.\n"); + return; + } + for (const workspace of workspaces) { + writeOut(`${workspace.selected ? "* " : " "}${workspace.slug ?? workspace.workspaceId}\n`); + } + }); + cloudAuth + .command("switch") + .description("Switch the hosted Caplets Cloud Selected Workspace.") + .argument("", "workspace ID or slug") + .option("--json", "print JSON output") + .action(async (workspace: string, options: { json?: boolean }) => { + const store = cloudAuthStore(env); + const credentials = await store.load(); + if (!credentials) { + throw new CapletsError("AUTH_REQUIRED", "Run caplets cloud auth login first."); + } + const client = new CloudAuthClient({ + cloudUrl: credentials.cloudUrl, + ...(io.fetch ? { fetch: io.fetch } : {}), + }); + const switched = await client.switchWorkspace({ + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + workspace, + deviceName: credentials.deviceName, + }); + const now = new Date().toISOString(); + const next: CloudAuthCredentials = { + ...credentials, + workspaceId: switched.workspaceId, + workspaceSlug: switched.workspaceSlug, + accessToken: switched.accessToken, + refreshToken: switched.refreshToken ?? credentials.refreshToken, + expiresAt: switched.expiresAt, + scope: switched.scope, + tokenType: switched.tokenType, + credentialFamilyId: switched.credentialFamilyId, + lastRefreshAt: now, + selectedWorkspaceSwitchedAt: now, + }; + await store.save(next); + if (options.json) { + writeOut(`${JSON.stringify(cloudAuthStatus(next), null, 2)}\n`); + return; + } + writeOut(`Selected workspace ${next.workspaceSlug ?? next.workspaceId}.\n`); + }); + program .command(cliCommands.init) .description("Create a starter Caplets config file.") @@ -301,8 +792,13 @@ export function createProgram(io: CliIO = {}): Command { program .command(cliCommands.doctor) .description("Diagnose Caplets local, remote, and project-sync configuration.") - .action(() => { - writeOut(formatDoctorReport({ env })); + .option("--json", "print JSON output") + .action(async (options: { json?: boolean }) => { + if (options.json) { + writeOut(`${JSON.stringify(await doctorJsonReport({ env }), null, 2)}\n`); + return; + } + writeOut(await formatDoctorReport({ env })); }); program diff --git a/packages/core/src/cli/add.ts b/packages/core/src/cli/add.ts index 1dc8868..5679809 100644 --- a/packages/core/src/cli/add.ts +++ b/packages/core/src/cli/add.ts @@ -383,8 +383,7 @@ function localPathRelativeToOutput(path: string, outputDir: string): string { } function displayPath(path: string): string { - if (process.platform !== "darwin") return path; - return path.replace(/^\/private\/(var|tmp)(?=\/|$)/u, "/$1"); + return path; } function rejectUnsafeDestinationParents(path: string): void { diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts index ba0d60b..17f96e6 100644 --- a/packages/core/src/cli/commands.ts +++ b/packages/core/src/cli/commands.ts @@ -5,6 +5,8 @@ export const cliCommands = { completion: "completion", completeHidden: "__complete", serve: "serve", + attach: "attach", + cloud: "cloud", init: "init", setup: "setup", doctor: "doctor", @@ -31,6 +33,8 @@ export const cliCommands = { export const topLevelCommandNames = [ cliCommands.serve, + cliCommands.attach, + cliCommands.cloud, cliCommands.init, cliCommands.setup, cliCommands.doctor, @@ -59,8 +63,10 @@ export const topLevelCommandNames = [ export const cliSubcommands = { [cliCommands.add]: ["cli", "mcp", "openapi", "graphql", "http"], [cliCommands.auth]: ["login", "logout", "list"], + [cliCommands.cloud]: ["auth"], [cliCommands.completion]: [...completionShells], [cliCommands.config]: ["path", "paths"], + [cliCommands.serve]: ["start", "stop", "status", "restart", "enable", "disable"], [cliCommands.setup]: ["codex", "claude-code", "opencode", "pi", "mcp-client"], } as const satisfies Record; diff --git a/packages/core/src/cli/doctor.ts b/packages/core/src/cli/doctor.ts index b173de1..83f9649 100644 --- a/packages/core/src/cli/doctor.ts +++ b/packages/core/src/cli/doctor.ts @@ -1,27 +1,153 @@ import { findProjectRoot, fingerprintProjectRoot } from "../cloud/project-root"; -import { mutagenDoctorLine, type MutagenStatus } from "../cloud/mutagen"; -import { resolveCapletsMode } from "../server/options"; +import { CloudAuthStore, redactedCloudAuthStatus } from "../cloud-auth/store"; +import { projectBindingWorkspacePaths } from "../project-binding/workspaces"; +import { resolveCapletsRemote } from "../remote/options"; +import { resolveCapletsServer } from "../server/options"; +import type { MutagenProjectSyncDoctorData } from "../project-binding/mutagen"; export type DoctorOptions = { env?: NodeJS.ProcessEnv | Record; cwd?: string; - mutagenStatus?: MutagenStatus; + syncStatus?: MutagenProjectSyncDoctorData; + cloudAuthStore?: CloudAuthStore; }; -export function formatDoctorReport(options: DoctorOptions = {}): string { +export type DoctorJsonReport = { + server: Record; + remote: Record; + projectBinding: Record; + sync: Record; + daemon: Record; + cloudAuth: Record; +}; + +export async function doctorJsonReport(options: DoctorOptions = {}): Promise { const env = options.env ?? process.env; - const mode = resolveCapletsMode({}, env).mode; - const lines = [`Mode: ${mode}`]; - if (mode === "remote") { - const server = env.CAPLETS_SERVER_URL?.trim() ?? ""; - const root = findProjectRoot(options.cwd ?? process.cwd()); - lines.push(`Server: ${server}`); - lines.push(`Project root: ${root}`); - lines.push(`Project fingerprint: ${fingerprintProjectRoot(root)}`); - lines.push("Project sync: configured when local presence is active"); - lines.push( - mutagenDoctorLine(options.mutagenStatus ?? { available: false, reason: "not checked" }), - ); - } + const root = findProjectRoot(options.cwd ?? process.cwd()); + const projectFingerprint = fingerprintProjectRoot(root); + const paths = projectBindingWorkspacePaths(projectFingerprint, { env }); + const server = resolveServerSection(env); + const remote = resolveRemoteSection(env); + const credentials = await (options.cloudAuthStore ?? new CloudAuthStore({ env })).load(); + + return { + server, + remote, + projectBinding: { + state: "not_attached", + projectRoot: root, + projectFingerprint, + workspacePath: paths.project, + authMode: credentials + ? "hosted_cloud" + : remote.configured + ? "self_hosted_remote" + : "unconfigured", + selectedWorkspace: + credentials?.workspaceSlug ?? credentials?.workspaceId ?? remote.workspace ?? null, + webSocketUrl: remote.webSocketUrl, + lease: null, + lastUpgradeError: null, + recoveryCommand: + credentials || remote.configured ? "caplets attach --once" : "caplets cloud auth login", + }, + sync: { + state: options.syncStatus?.state ?? "idle", + diagnosticCode: options.syncStatus?.diagnosticCode ?? null, + mutagenBinary: options.syncStatus?.mutagenBinary ?? "mutagen", + mutagenVersion: options.syncStatus?.mutagenVersion ?? null, + lastCommand: options.syncStatus?.lastCommand ?? null, + }, + daemon: { + configured: false, + running: false, + }, + cloudAuth: redactedCloudAuthStatus(credentials), + }; +} + +export async function formatDoctorReport(options: DoctorOptions = {}): Promise { + const report = await doctorJsonReport(options); + const lines = [ + "Server hosting", + ` Configured: ${yesNo(Boolean(report.server.configured))}`, + ...(report.server.configured ? [` Base URL: ${report.server.baseUrl}`] : []), + "", + "Remote client", + ` Configured: ${yesNo(Boolean(report.remote.configured))}`, + ...(report.remote.configured + ? [ + ` MCP URL: ${report.remote.mcpUrl}`, + ` Control URL: ${report.remote.controlUrl}`, + ` Health URL: ${report.remote.healthUrl}`, + ` WebSocket URL: ${report.remote.webSocketUrl}`, + ` Auth: ${report.remote.auth}`, + ] + : []), + "", + "Project Binding", + ` State: ${report.projectBinding.state}`, + ` Project root: ${report.projectBinding.projectRoot}`, + ` Project fingerprint: ${report.projectBinding.projectFingerprint}`, + ` Workspace path: ${report.projectBinding.workspacePath}`, + ` Auth mode: ${report.projectBinding.authMode}`, + ` Selected Workspace: ${report.projectBinding.selectedWorkspace ?? "none"}`, + ` Binding Session: ${report.projectBinding.state}`, + ` Recovery: ${report.projectBinding.recoveryCommand}`, + "", + "Project sync", + ` State: ${report.sync.state}`, + ` Mutagen: ${report.sync.mutagenVersion ?? report.sync.mutagenBinary}`, + ...(report.sync.diagnosticCode ? [` Diagnostic: ${report.sync.diagnosticCode}`] : []), + "", + "Daemon", + ` Running: ${yesNo(Boolean(report.daemon.running))}`, + "", + "Cloud Auth", + ` Authenticated: ${yesNo(Boolean(report.cloudAuth.authenticated))}`, + ...(report.cloudAuth.cloudUrl ? [` Cloud URL: ${report.cloudAuth.cloudUrl}`] : []), + ...(report.cloudAuth.workspaceSlug || report.cloudAuth.workspaceId + ? [` Selected Workspace: ${report.cloudAuth.workspaceSlug ?? report.cloudAuth.workspaceId}`] + : []), + ]; return `${lines.join("\n")}\n`; } + +function resolveServerSection(env: NodeJS.ProcessEnv | Record) { + try { + const server = resolveCapletsServer({}, env); + return { + configured: true, + baseUrl: server.baseUrl.href, + mcpUrl: server.mcpUrl.href, + controlUrl: server.controlUrl.href, + healthUrl: server.healthUrl.href, + auth: server.auth.enabled ? "basic" : "none", + }; + } catch { + return { configured: false }; + } +} + +function resolveRemoteSection(env: NodeJS.ProcessEnv | Record) { + try { + const remote = resolveCapletsRemote({}, env); + return { + configured: true, + baseUrl: remote.baseUrl.href, + mcpUrl: remote.mcpUrl.href, + controlUrl: remote.controlUrl.href, + healthUrl: remote.healthUrl.href, + webSocketUrl: remote.projectBindingWebSocketUrl.href, + auth: remote.auth.type, + tokenPresent: remote.auth.type === "bearer", + workspace: remote.workspace ?? null, + }; + } catch { + return { configured: false }; + } +} + +function yesNo(value: boolean): string { + return value ? "yes" : "no"; +} diff --git a/packages/core/src/cli/setup-caplet.ts b/packages/core/src/cli/setup-caplet.ts index b3c1915..e4c0c4e 100644 --- a/packages/core/src/cli/setup-caplet.ts +++ b/packages/core/src/cli/setup-caplet.ts @@ -21,7 +21,7 @@ export async function runCapletSetupCli( options: CapletSetupCliOptions = {}, ): Promise { const targetKind = resolveSetupTarget(options); - if (targetKind === "cloud") { + if (targetKind === "hosted_sandbox") { throw new CapletsError( "REQUEST_INVALID", "Cloud setup runs through the Caplets Cloud API, not the local CLI runner", @@ -45,8 +45,14 @@ export async function runCapletSetupCli( } const contentHash = capletSetupContentHash(caplet as CapletConfig); + const projectFingerprint = "default"; const store = new LocalSetupStore(options.baseDir ? { baseDir: options.baseDir } : {}); - const existingApproval = await store.getApproval(caplet.server, contentHash, targetKind); + const existingApproval = await store.getApproval( + projectFingerprint, + caplet.server, + contentHash, + targetKind, + ); const actor: SetupActor = options.yes ? "cli-yes" : "cli-interactive"; if (!existingApproval && !options.yes) { return [ @@ -66,6 +72,7 @@ export async function runCapletSetupCli( if (options.yes && !existingApproval) { await store.approve({ + projectFingerprint, capletId: caplet.server, contentHash, targetKind, @@ -75,6 +82,7 @@ export async function runCapletSetupCli( } const attempts = await runCapletSetup({ + projectFingerprint, capletId: caplet.server, contentHash, targetKind, @@ -97,7 +105,7 @@ export async function runCapletSetupCli( function resolveSetupTarget(options: CapletSetupCliOptions): SetupTargetKind { if (options.target) return options.target; - return options.remote ? "remote" : "local"; + return options.remote ? "remote_host" : "local_host"; } function formatCommands( diff --git a/packages/core/src/cli/setup.ts b/packages/core/src/cli/setup.ts index ef4c4a3..0d3e06c 100644 --- a/packages/core/src/cli/setup.ts +++ b/packages/core/src/cli/setup.ts @@ -4,6 +4,7 @@ import { dirname } from "node:path"; import { promisify } from "node:util"; import { CapletsError } from "../errors"; import { runCapletSetupCli } from "./setup-caplet"; +import { isSetupTargetKind, type SetupTargetKind } from "../setup/types"; const execFileAsync = promisify(execFile); @@ -17,6 +18,7 @@ export const setupIntegrationIds = [ export type SetupIntegrationId = (typeof setupIntegrationIds)[number]; export type SetupFormat = "plain" | "json"; +export type SetupTargetOption = SetupTargetKind | "local" | "remote" | "cloud" | "hosted_worker"; export type SetupCommandResult = { stdout: string; @@ -34,7 +36,7 @@ export type SetupOptions = { format?: SetupFormat; runCommand?: SetupCommandRunner; yes?: boolean; - target?: "local" | "remote" | "cloud"; + target?: SetupTargetOption; }; type SetupAction = @@ -52,6 +54,7 @@ type SetupResult = { integration: SetupIntegrationId; name: string; mode: "local" | "remote"; + targetKind: SetupTargetKind; dryRun: boolean; actions: SetupActionResult[]; nextSteps: string[]; @@ -90,7 +93,7 @@ export async function runSetup(integration: string, options: SetupOptions = {}): if (!setupIntegrationIds.includes(integration as SetupIntegrationId)) { return await runCapletSetupCli(integration, { ...(options.yes === undefined ? {} : { yes: options.yes }), - ...(options.target === undefined ? {} : { target: options.target }), + target: resolveSetupTargetKind(options), ...(options.remote === undefined ? {} : { remote: options.remote }), }); } @@ -148,6 +151,7 @@ async function executeSetup(integration: string, options: SetupOptions): Promise integration: id, name: definition.name, mode: options.remote ? "remote" : "local", + targetKind: resolveSetupTargetKind(options), dryRun: Boolean(options.dryRun), actions, nextSteps: definition.nextSteps, @@ -355,7 +359,7 @@ async function defaultSetupCommandRunner( function formatSetupResult(result: SetupResult): string { const lines = [ - `${result.dryRun ? "Dry run" : "Completed"} ${result.name} setup (${result.mode})`, + `${result.dryRun ? "Dry run" : "Completed"} ${result.name} setup (${result.mode}, ${result.targetKind})`, "", ]; for (const action of result.actions) { @@ -378,3 +382,17 @@ function nonEmpty(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } + +function resolveSetupTargetKind(options: SetupOptions): SetupTargetKind { + if (options.target !== undefined) { + if (isSetupTargetKind(options.target)) return options.target; + if (options.target === "local") return "local_host"; + if (options.target === "remote") return "remote_host"; + if (options.target === "cloud" || options.target === "hosted_worker") return "hosted_sandbox"; + throw new CapletsError( + "REQUEST_INVALID", + "setup target must be one of: local_host, remote_host, hosted_sandbox", + ); + } + return options.remote ? "remote_host" : "local_host"; +} diff --git a/packages/core/src/cloud-auth/client.ts b/packages/core/src/cloud-auth/client.ts new file mode 100644 index 0000000..ae6c9c1 --- /dev/null +++ b/packages/core/src/cloud-auth/client.ts @@ -0,0 +1,181 @@ +import { CapletsError } from "../errors"; +import { redactCloudAuthSecrets, type CloudAuthRecovery, type CloudAuthErrorCode } from "./errors"; +import type { + CloudAuthLoginPollResult, + CloudAuthLoginStart, + CloudAuthTokenResponse, + CloudAuthWorkspace, +} from "./types"; + +export type CloudAuthClientOptions = { + cloudUrl: string; + fetch?: typeof fetch; +}; + +export type StartLoginInput = { + requestedWorkspace?: string | undefined; + deviceName?: string | undefined; + scope?: string[] | undefined; +}; + +export type ExchangeTokenInput = { + loginId: string; + oneTimeCode: string; +}; + +export type RefreshTokenInput = { + refreshToken: string; +}; + +export type CloudAuthClientCredentials = Required< + Pick< + CloudAuthTokenResponse, + "workspaceId" | "accessToken" | "expiresAt" | "tokenType" | "credentialFamilyId" + > +> & + Pick & { + cloudUrl: string; + scope: string[]; + redacted: Record; + }; + +export class CloudAuthClient { + private readonly cloudUrl: URL; + private readonly fetchImpl: typeof fetch; + + constructor(options: CloudAuthClientOptions) { + this.cloudUrl = new URL(options.cloudUrl); + this.fetchImpl = options.fetch ?? fetch; + } + + async startLogin(input: StartLoginInput = {}): Promise { + return await this.requestJson("/api/cloud-client/login/start", { + method: "POST", + body: JSON.stringify({ + ...(input.requestedWorkspace ? { requestedWorkspace: input.requestedWorkspace } : {}), + ...(input.deviceName ? { deviceName: input.deviceName } : {}), + ...(input.scope ? { scope: input.scope } : {}), + }), + }); + } + + async pollLogin(loginId: string): Promise { + return await this.requestJson(`/api/cloud-client/login/${loginId}`); + } + + async exchangeToken(input: ExchangeTokenInput): Promise { + const response = await this.requestJson("/api/cloud-client/token", { + method: "POST", + body: JSON.stringify(input), + }); + return normalizeCredentials(response, this.cloudUrl.origin); + } + + async refresh(input: RefreshTokenInput): Promise { + const response = await this.requestJson("/api/cloud-client/refresh", { + method: "POST", + body: JSON.stringify(input), + }); + return normalizeCredentials(response, this.cloudUrl.origin); + } + + async logout(refreshToken: string): Promise { + await this.requestJson("/api/cloud-client/logout", { + method: "POST", + body: JSON.stringify({ refreshToken }), + }); + } + + async workspaces(accessToken: string): Promise<{ workspaces: CloudAuthWorkspace[] }> { + return await this.requestJson<{ workspaces: CloudAuthWorkspace[] }>( + "/api/cloud-client/workspaces", + { + headers: { Authorization: `Bearer ${accessToken}` }, + }, + ); + } + + async switchWorkspace(input: { + accessToken: string; + workspace: string; + refreshToken?: string | undefined; + deviceName?: string | undefined; + }): Promise { + const response = await this.requestJson("/api/cloud-client/switch", { + method: "POST", + headers: { Authorization: `Bearer ${input.accessToken}` }, + body: JSON.stringify({ + workspace: input.workspace, + ...(input.refreshToken ? { refreshToken: input.refreshToken } : {}), + ...(input.deviceName ? { deviceName: input.deviceName } : {}), + }), + }); + return normalizeCredentials(response, this.cloudUrl.origin); + } + + private async requestJson(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + if (init.body !== undefined && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + const response = await this.fetchImpl(new URL(path, this.cloudUrl), { ...init, headers }); + const requestId = response.headers.get("x-request-id") ?? undefined; + const body = (await response.json().catch(() => ({}))) as Record; + if (!response.ok) { + const code = typeof body.error === "string" ? body.error : "endpoint_unavailable"; + const message = + typeof body.message === "string" + ? body.message + : `Cloud Auth request failed (${response.status}).`; + const recovery: CloudAuthRecovery = { + code: code as CloudAuthErrorCode, + message, + recoveryCommand: + code === "workspace_switch_required" + ? "caplets cloud auth switch " + : "caplets cloud auth login", + requestId, + }; + throw new CapletsError("AUTH_FAILED", message, redactCloudAuthSecrets(recovery)); + } + return { ...body, ...(requestId && !body.requestId ? { requestId } : {}) } as T; + } +} + +function normalizeCredentials( + response: CloudAuthTokenResponse, + fallbackCloudUrl: string, +): CloudAuthClientCredentials { + const scope = Array.isArray(response.scope) + ? response.scope.map(String) + : typeof response.scope === "string" + ? response.scope.split(/\s+/u).filter(Boolean) + : ["project_binding:read", "project_binding:write"]; + const credentialFamilyId = response.credentialFamilyId ?? "cloud_client_credential_family"; + const tokenType = response.tokenType ?? "Bearer"; + const credentials: CloudAuthClientCredentials = { + cloudUrl: response.cloudUrl ?? fallbackCloudUrl, + workspaceId: response.workspaceId, + ...(response.workspaceSlug ? { workspaceSlug: response.workspaceSlug } : {}), + accessToken: response.accessToken, + ...(response.refreshToken ? { refreshToken: response.refreshToken } : {}), + expiresAt: response.expiresAt, + scope, + tokenType, + credentialFamilyId, + ...(response.deviceName ? { deviceName: response.deviceName } : {}), + ...(response.requestId ? { requestId: response.requestId } : {}), + redacted: { + cloudUrl: response.cloudUrl ?? fallbackCloudUrl, + workspaceId: response.workspaceId, + ...(response.workspaceSlug ? { workspaceSlug: response.workspaceSlug } : {}), + expiresAt: response.expiresAt, + scope, + tokenType, + credentialFamilyId, + ...(response.deviceName ? { deviceName: response.deviceName } : {}), + ...(response.requestId ? { requestId: response.requestId } : {}), + }, + }; + return credentials; +} diff --git a/packages/core/src/cloud-auth/errors.ts b/packages/core/src/cloud-auth/errors.ts new file mode 100644 index 0000000..0bb12f0 --- /dev/null +++ b/packages/core/src/cloud-auth/errors.ts @@ -0,0 +1,74 @@ +import { CapletsError } from "../errors"; + +export type CloudAuthErrorCode = + | "cloud_auth_required" + | "cloud_auth_expired" + | "cloud_auth_revoked" + | "workspace_selection_required" + | "workspace_switch_required" + | "workspace_forbidden" + | "endpoint_unavailable"; + +export type CloudAuthRecovery = { + code: CloudAuthErrorCode; + message: string; + recoveryCommand: string; + requestId?: string | undefined; +}; + +const SECRET_PATTERN = + /(cap_access_[a-z0-9._~+/=-]+|cap_refresh_[a-z0-9._~+/=-]+|one_time_code_[a-z0-9._~+/=-]+|Bearer\s+)[^\s"]*/giu; + +export function redactCloudAuthSecrets(value: unknown): unknown { + if (typeof value === "string") return value.replace(SECRET_PATTERN, "$1[REDACTED]"); + if (Array.isArray(value)) return value.map((item) => redactCloudAuthSecrets(item)); + if (value && typeof value === "object") { + const redacted: Record = {}; + for (const [key, nested] of Object.entries(value)) { + redacted[key] = /token|secret|code|authorization|credential/i.test(key) + ? "[REDACTED]" + : redactCloudAuthSecrets(nested); + } + return redacted; + } + return value; +} + +export function cloudAuthRecovery(code: CloudAuthErrorCode, detail?: string): CloudAuthRecovery { + const recoveryCommand = + code === "workspace_switch_required" + ? "caplets cloud auth switch " + : code === "workspace_selection_required" + ? "caplets cloud auth login --workspace " + : "caplets cloud auth login"; + return { + code, + message: detail ?? defaultMessage(code), + recoveryCommand, + }; +} + +export function cloudAuthError(code: CloudAuthErrorCode, detail?: string): CapletsError { + const recovery = cloudAuthRecovery(code, detail); + return new CapletsError("AUTH_REQUIRED", recovery.message, recovery); +} + +function defaultMessage(code: CloudAuthErrorCode): string { + switch (code) { + case "cloud_auth_expired": + return "Hosted Caplets Cloud credentials have expired."; + case "cloud_auth_revoked": + return "Hosted Caplets Cloud credentials were revoked."; + case "workspace_selection_required": + return "Select a hosted Caplets Cloud workspace before attaching this project."; + case "workspace_switch_required": + return "The requested workspace differs from the saved Selected Workspace."; + case "workspace_forbidden": + return "The saved Cloud Auth credential cannot access the requested workspace."; + case "endpoint_unavailable": + return "Hosted Caplets Cloud is unavailable."; + case "cloud_auth_required": + default: + return "Run caplets cloud auth login before using hosted Project Binding."; + } +} diff --git a/packages/core/src/cloud-auth/open-url.ts b/packages/core/src/cloud-auth/open-url.ts new file mode 100644 index 0000000..aa793f8 --- /dev/null +++ b/packages/core/src/cloud-auth/open-url.ts @@ -0,0 +1,24 @@ +import { spawn } from "node:child_process"; + +export type OpenUrlResult = { + opened: boolean; + command?: string | undefined; +}; + +export async function openBrowserUrl( + url: string, + options: { opener?: (url: string) => Promise | OpenUrlResult } = {}, +): Promise { + if (options.opener) return await options.opener(url); + const command = + process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"; + const args = process.platform === "win32" ? ["/c", "start", "", url] : [url]; + return await new Promise((resolve) => { + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.once("error", () => resolve({ opened: false, command })); + child.once("spawn", () => { + child.unref(); + resolve({ opened: true, command }); + }); + }); +} diff --git a/packages/core/src/cloud-auth/store.ts b/packages/core/src/cloud-auth/store.ts new file mode 100644 index 0000000..7e302de --- /dev/null +++ b/packages/core/src/cloud-auth/store.ts @@ -0,0 +1,133 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, posix, win32 } from "node:path"; +import { defaultConfigBaseDir } from "../config/paths"; +import type { RedactedCloudAuthStatus } from "./types"; + +type CloudAuthPathEnv = Partial< + Record<"CAPLETS_CLOUD_AUTH_PATH" | "XDG_CONFIG_HOME" | "APPDATA", string> +>; + +export type CloudAuthCredentials = { + version?: 1 | 2 | undefined; + cloudUrl: string; + workspaceId: string; + workspaceSlug?: string | undefined; + accessToken: string; + refreshToken: string; + expiresAt: string; + scope?: string[] | undefined; + tokenType?: string | undefined; + credentialFamilyId?: string | undefined; + deviceName?: string | undefined; + createdAt?: string | undefined; + lastRefreshAt?: string | undefined; + selectedWorkspaceSwitchedAt?: string | undefined; +}; + +export type CloudAuthStoreOptions = { + path?: string; + env?: CloudAuthPathEnv; + home?: string; + platform?: NodeJS.Platform; +}; + +export class CloudAuthStore { + readonly path: string; + + constructor(options: CloudAuthStoreOptions = {}) { + this.path = options.path ?? cloudAuthPath(options); + } + + async load(): Promise { + if (!existsSync(this.path)) return undefined; + return migrateCredentials(JSON.parse(readFileSync(this.path, "utf8"))); + } + + async save(credentials: CloudAuthCredentials): Promise { + mkdirSync(dirname(this.path), { recursive: true }); + writeFileSync(this.path, `${JSON.stringify(migrateCredentials(credentials), null, 2)}\n`, { + mode: 0o600, + }); + } + + async clear(): Promise { + rmSync(this.path, { force: true }); + } +} + +export function migrateCredentials(value: unknown): CloudAuthCredentials { + const record = isRecord(value) ? value : {}; + const now = new Date().toISOString(); + return { + version: 2, + cloudUrl: stringValue(record.cloudUrl) ?? "https://cloud.caplets.dev", + workspaceId: stringValue(record.workspaceId) ?? "", + ...(stringValue(record.workspaceSlug) + ? { workspaceSlug: stringValue(record.workspaceSlug) } + : {}), + accessToken: stringValue(record.accessToken) ?? "", + refreshToken: stringValue(record.refreshToken) ?? "", + expiresAt: stringValue(record.expiresAt) ?? now, + scope: arrayValue(record.scope) ?? ["project_binding:read", "project_binding:write"], + tokenType: stringValue(record.tokenType) ?? "Bearer", + credentialFamilyId: stringValue(record.credentialFamilyId) ?? "legacy_family", + deviceName: stringValue(record.deviceName) ?? "Caplets CLI", + createdAt: stringValue(record.createdAt) ?? now, + lastRefreshAt: stringValue(record.lastRefreshAt) ?? stringValue(record.createdAt) ?? now, + ...(stringValue(record.selectedWorkspaceSwitchedAt) + ? { selectedWorkspaceSwitchedAt: stringValue(record.selectedWorkspaceSwitchedAt) } + : {}), + }; +} + +export function redactedCloudAuthStatus( + credentials: CloudAuthCredentials | undefined, + now = new Date(), +): RedactedCloudAuthStatus { + if (!credentials) return { authenticated: false, status: "unauthenticated" }; + const expired = Number.isFinite(Date.parse(credentials.expiresAt)) + ? Date.parse(credentials.expiresAt) <= now.getTime() + : false; + const refreshable = expired && Boolean(credentials.refreshToken); + return { + authenticated: !expired, + status: refreshable ? "refreshable" : expired ? "expired" : "authenticated", + cloudUrl: credentials.cloudUrl, + workspaceId: credentials.workspaceId, + ...(credentials.workspaceSlug ? { workspaceSlug: credentials.workspaceSlug } : {}), + expiresAt: credentials.expiresAt, + scope: credentials.scope, + tokenType: credentials.tokenType, + credentialFamilyId: credentials.credentialFamilyId, + deviceName: credentials.deviceName, + createdAt: credentials.createdAt, + lastRefreshAt: credentials.lastRefreshAt, + selectedWorkspaceSwitchedAt: credentials.selectedWorkspaceSwitchedAt, + }; +} + +export function cloudAuthPath(options: CloudAuthStoreOptions = {}): string { + const env = options.env ?? process.env; + if (env.CAPLETS_CLOUD_AUTH_PATH?.trim()) return env.CAPLETS_CLOUD_AUTH_PATH.trim(); + const platform = options.platform ?? process.platform; + const home = options.home ?? homedir(); + if (platform === "win32") { + return win32.join(defaultConfigBaseDir(env, home, platform), "Caplets", "cloud-auth.json"); + } + return posix.join(defaultConfigBaseDir(env, home, platform), "caplets", "cloud-auth.json"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function arrayValue(value: unknown): string[] | undefined { + if (Array.isArray(value)) return value.map(String).filter(Boolean); + if (typeof value === "string") return value.split(/\s+/u).filter(Boolean); + return undefined; +} diff --git a/packages/core/src/cloud-auth/types.ts b/packages/core/src/cloud-auth/types.ts new file mode 100644 index 0000000..d0f3139 --- /dev/null +++ b/packages/core/src/cloud-auth/types.ts @@ -0,0 +1,86 @@ +export const CLOUD_AUTH_STATES = [ + "unauthenticated", + "login_pending", + "workspace_selection_required", + "authenticated", + "refreshable", + "switch_required", + "expired", + "revoked", +] as const; + +export type CloudAuthState = (typeof CLOUD_AUTH_STATES)[number]; + +export type CloudAuthScope = "project_binding:read" | "project_binding:write" | string; + +export type CloudAuthWorkspace = { + workspaceId: string; + slug?: string | undefined; + displayName?: string | undefined; + name?: string | undefined; + role?: string | undefined; + selected?: boolean | undefined; +}; + +export type CloudAuthLoginStart = { + loginId: string; + loginUrl: string; + userCode: string; + expiresAt: string; + requestId?: string | undefined; +}; + +export type CloudAuthLoginPollResult = + | { + status: "pending"; + expiresAt?: string | undefined; + requestId?: string | undefined; + } + | { + status: "workspace_selection_required"; + workspaces: CloudAuthWorkspace[]; + expiresAt?: string | undefined; + requestId?: string | undefined; + } + | { + status: "completed"; + selectedWorkspace?: Pick | undefined; + oneTimeCode: string; + requestId?: string | undefined; + } + | { + status: "expired" | "denied" | "consumed"; + message?: string | undefined; + requestId?: string | undefined; + }; + +export type CloudAuthTokenResponse = { + status?: "authenticated" | undefined; + cloudUrl?: string | undefined; + workspaceId: string; + workspaceSlug?: string | undefined; + accessToken: string; + refreshToken?: string | undefined; + expiresAt: string; + scope?: CloudAuthScope[] | string | undefined; + tokenType?: "Bearer" | string | undefined; + credentialFamilyId?: string | undefined; + deviceName?: string | undefined; + requestId?: string | undefined; +}; + +export type RedactedCloudAuthStatus = { + authenticated: boolean; + status: CloudAuthState; + cloudUrl?: string | undefined; + workspaceId?: string | undefined; + workspaceSlug?: string | undefined; + expiresAt?: string | undefined; + scope?: CloudAuthScope[] | undefined; + tokenType?: string | undefined; + credentialFamilyId?: string | undefined; + deviceName?: string | undefined; + createdAt?: string | undefined; + lastRefreshAt?: string | undefined; + selectedWorkspaceSwitchedAt?: string | undefined; +}; diff --git a/packages/core/src/cloud-runtime.ts b/packages/core/src/cloud-runtime.ts deleted file mode 100644 index 33a3509..0000000 --- a/packages/core/src/cloud-runtime.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - createCloudRuntimeAdapter, - type CloudRuntimeAdapter, - type CloudRuntimeAdapterOptions, -} from "./cloud/runtime-adapter"; -export { createRuntimeHttpApp, type RuntimeHttpOptions } from "./cloud/runtime-http"; -export { capletSetupContentHash, stableJson } from "./setup/hash"; -export { LocalSetupStore, type LocalSetupStoreOptions } from "./setup/local-store"; -export { runCapletSetup, spawnCommand, type SetupSpawn, type SpawnResult } from "./setup/runner"; -export type { - SetupActor, - SetupApproval, - SetupAttempt, - SetupAttemptStatus, - SetupPlan, - SetupTargetKind, -} from "./setup/types"; diff --git a/packages/core/src/cloud/client.ts b/packages/core/src/cloud/client.ts index e4d4424..97caaaa 100644 --- a/packages/core/src/cloud/client.ts +++ b/packages/core/src/cloud/client.ts @@ -1,3 +1,5 @@ +import type { ProjectSyncFile } from "./sync"; + export type CapletsCloudClientOptions = { baseUrl: URL; accessToken: string; @@ -9,6 +11,7 @@ export type RegisterPresenceInput = { projectRoot: string; projectFingerprint: string; allowedCapletIds: string[]; + projectFiles?: ProjectSyncFile[] | undefined; fallbackConsent?: "allow" | "deny" | undefined; }; @@ -27,56 +30,63 @@ export class CapletsCloudClient { } async registerPresence(input: RegisterPresenceInput): Promise { - const response = await this.fetchImpl(this.endpoint("api/presence"), { + const response = await this.fetchImpl(this.endpoint("api/project-bindings"), { method: "POST", headers: this.headers({ json: true }), - body: JSON.stringify(input), + body: JSON.stringify({ + workspaceId: input.workspaceId, + projectRoot: input.projectRoot, + projectFingerprint: input.projectFingerprint, + state: "ready", + syncState: "idle", + projectFiles: input.projectFiles ?? [], + }), }); if (!response.ok) { - throw new Error(`Caplets Cloud presence registration failed: HTTP ${response.status}`); + throw new Error(`Caplets Cloud Project Binding registration failed: HTTP ${response.status}`); } - return (await response.json()) as RegisterPresenceResult; + const body = (await response.json()) as { binding?: { bindingId?: string } }; + return { + presenceId: body.binding?.bindingId ?? `${input.workspaceId}:${input.projectFingerprint}`, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }; } async stopPresence(presenceId: string): Promise { const response = await this.fetchImpl( - this.endpoint(`api/presence/${encodeURIComponent(presenceId)}`), + this.endpoint(`api/project-bindings/${encodeURIComponent(presenceId)}`), { - method: "DELETE", - headers: this.headers(), + method: "PATCH", + headers: this.headers({ json: true }), + body: JSON.stringify({ state: "offline" }), }, ); if (!response.ok && response.status !== 404) { - throw new Error(`Caplets Cloud presence stop failed: HTTP ${response.status}`); + throw new Error(`Caplets Cloud Project Binding stop failed: HTTP ${response.status}`); } } async heartbeatPresence(presenceId: string): Promise { const response = await this.fetchImpl( - this.endpoint(`api/presence/${encodeURIComponent(presenceId)}/heartbeat`), + this.endpoint(`api/project-bindings/${encodeURIComponent(presenceId)}`), { - method: "POST", - headers: this.headers(), + method: "PATCH", + headers: this.headers({ json: true }), + body: JSON.stringify({ state: "ready", syncState: "idle" }), }, ); if (!response.ok) { - throw new Error(`Caplets Cloud presence heartbeat failed: HTTP ${response.status}`); + throw new Error(`Caplets Cloud Project Binding heartbeat failed: HTTP ${response.status}`); } - return (await response.json()) as HeartbeatPresenceResult; + return { + presenceId, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }; } async updatePresenceCaplets(presenceId: string, allowedCapletIds: string[]): Promise { - const response = await this.fetchImpl( - this.endpoint(`api/presence/${encodeURIComponent(presenceId)}/caplets`), - { - method: "PATCH", - headers: this.headers({ json: true }), - body: JSON.stringify({ allowedCapletIds }), - }, - ); - if (!response.ok) { - throw new Error(`Caplets Cloud presence update failed: HTTP ${response.status}`); - } + void presenceId; + void allowedCapletIds; } private headers(options: { json?: boolean } = {}): Headers { diff --git a/packages/core/src/cloud/mutagen.ts b/packages/core/src/cloud/mutagen.ts deleted file mode 100644 index 0ac242f..0000000 --- a/packages/core/src/cloud/mutagen.ts +++ /dev/null @@ -1,49 +0,0 @@ -export type MutagenLicenseProfile = "mit" | "sspl" | "unknown"; - -export type MutagenBuildInfo = { - version: string; - licenseProfile: MutagenLicenseProfile; -}; - -export type MutagenStatus = - | { available: true; path: string; version: string; licenseProfile: "mit" } - | { available: false; path?: string; reason: string }; - -export function parseMutagenVersionOutput(output: string): MutagenBuildInfo { - const version = output.match(/Mutagen version\s+([^\s]+)/u)?.[1] ?? "unknown"; - const normalized = output.toLocaleLowerCase(); - const licenseProfile = normalized.includes("license profile: mit") - ? "mit" - : normalized.includes("license profile: sspl") - ? "sspl" - : "unknown"; - return { version, licenseProfile }; -} - -export function mutagenBuildIsAllowed(info: MutagenBuildInfo): boolean { - return info.licenseProfile === "mit"; -} - -export async function checkMutagenBinary( - path: string, - run: (path: string, args: string[]) => Promise, -): Promise { - let output: string; - try { - output = await run(path, ["version"]); - } catch (error) { - return { available: false, path, reason: error instanceof Error ? error.message : "failed" }; - } - const info = parseMutagenVersionOutput(output); - if (!mutagenBuildIsAllowed(info)) { - return { available: false, path, reason: `unsupported license profile ${info.licenseProfile}` }; - } - return { available: true, path, version: info.version, licenseProfile: "mit" }; -} - -export function mutagenDoctorLine(status: MutagenStatus): string { - if (!status.available) { - return `Mutagen: unavailable (${status.reason})`; - } - return `Mutagen: available ${status.version} (${status.path})`; -} diff --git a/packages/core/src/cloud/presence.ts b/packages/core/src/cloud/presence.ts index 4581051..3dfa096 100644 --- a/packages/core/src/cloud/presence.ts +++ b/packages/core/src/cloud/presence.ts @@ -6,7 +6,7 @@ type PresenceClient = Pick & { updatePresenceCaplets?: (presenceId: string, allowedCapletIds: string[]) => Promise; }; -export type LocalPresenceManagerOptions = RegisterPresenceInput & { +export type ProjectBindingSessionManagerOptions = RegisterPresenceInput & { client: PresenceClient; heartbeatIntervalMs?: number; setInterval?: typeof setInterval; @@ -14,12 +14,12 @@ export type LocalPresenceManagerOptions = RegisterPresenceInput & { onError?: (error: unknown) => void; }; -export class LocalPresenceManager { +export class ProjectBindingSessionManager { private presenceId: string | undefined; private heartbeatTimer: ReturnType | undefined; private startPromise: Promise | undefined; - constructor(private readonly options: LocalPresenceManagerOptions) {} + constructor(private readonly options: ProjectBindingSessionManagerOptions) {} async start(): Promise { if (this.startPromise) { @@ -35,6 +35,7 @@ export class LocalPresenceManager { projectRoot: this.options.projectRoot, projectFingerprint: this.options.projectFingerprint, allowedCapletIds: this.options.allowedCapletIds, + projectFiles: this.options.projectFiles, fallbackConsent: this.options.fallbackConsent ?? "deny", }); this.presenceId = result.presenceId; @@ -82,3 +83,6 @@ export class LocalPresenceManager { } } } + +export type LocalPresenceManagerOptions = ProjectBindingSessionManagerOptions; +export const LocalPresenceManager = ProjectBindingSessionManager; diff --git a/packages/core/src/cloud/runtime-adapter.ts b/packages/core/src/cloud/runtime-adapter.ts index 1e1c80a..f98d959 100644 --- a/packages/core/src/cloud/runtime-adapter.ts +++ b/packages/core/src/cloud/runtime-adapter.ts @@ -81,12 +81,17 @@ class DefaultCloudRuntimeAdapter implements CloudRuntimeAdapter { async setupPlan(capletId: string): Promise { const caplet = this.requireCaplet(capletId); const contentHash = capletSetupContentHash(caplet); - const approved = Boolean(await this.setupStore.getApproval(capletId, contentHash, "cloud")); + const projectFingerprint = "hosted"; + const targetKind = "hosted_sandbox"; + const approved = Boolean( + await this.setupStore.getApproval(projectFingerprint, capletId, contentHash, targetKind), + ); return { + projectFingerprint, capletId, name: caplet.name, contentHash, - targetKind: "cloud", + targetKind, setup: caplet.setup ?? {}, approved, commands: caplet.setup?.commands ?? [], @@ -101,17 +106,19 @@ class DefaultCloudRuntimeAdapter implements CloudRuntimeAdapter { const plan = await this.setupPlan(capletId); if (input.approved && !plan.approved) { await this.setupStore.approve({ + projectFingerprint: plan.projectFingerprint, capletId, contentHash: plan.contentHash, - targetKind: "cloud", + targetKind: plan.targetKind, actor: input.actor, approvedAt: new Date().toISOString(), }); } return await runCapletSetup({ capletId, + projectFingerprint: plan.projectFingerprint, contentHash: plan.contentHash, - targetKind: "cloud", + targetKind: plan.targetKind, setup: plan.setup, actor: input.actor, approved: input.approved || plan.approved, diff --git a/packages/core/src/cloud/sync.ts b/packages/core/src/cloud/sync.ts index 6c47e60..129bf20 100644 --- a/packages/core/src/cloud/sync.ts +++ b/packages/core/src/cloud/sync.ts @@ -1,5 +1,11 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { join, relative } from "node:path"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { buildProjectSyncManifest } from "../project-binding/sync-filter"; + +export type ProjectSyncFile = { + path: string; + content: string; +}; export class ProjectSyncCoordinator { private readonly queues = new Map>(); @@ -26,75 +32,12 @@ export class ProjectSyncCoordinator { } export function projectSyncManifest(projectRoot: string): string[] { - const ignoreRules = readIgnoreRules(projectRoot); - const files: string[] = []; - walk(projectRoot, projectRoot, ignoreRules, files); - return files.sort(); -} - -function walk(root: string, current: string, ignoreRules: string[], files: string[]): void { - for (const entry of readdirSync(current)) { - const absolute = join(current, entry); - const relativePath = relative(root, absolute).replace(/\\/gu, "/"); - if ( - relativePath === ".git" || - relativePath === ".caplets-sync" || - ignored(relativePath, ignoreRules) - ) { - continue; - } - const stat = statSync(absolute); - if (stat.isDirectory()) { - walk(root, absolute, ignoreRules, files); - } else if (stat.isFile()) { - files.push(relativePath); - } - } -} - -function readIgnoreRules(projectRoot: string): string[] { - return [".gitignore", join(".git", "info", "exclude"), ".capletsignore"].flatMap((file) => { - const path = join(projectRoot, file); - if (!existsSync(path)) return []; - return readFileSync(path, "utf8") - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith("#")); - }); -} - -function ignored(path: string, rules: string[]): boolean { - let ignoredPath = false; - for (const rule of rules) { - const negated = rule.startsWith("!"); - const pattern = (negated ? rule.slice(1) : rule).replace(/^\/+/u, ""); - if (!pattern) continue; - if (matchesIgnorePattern(path, pattern)) { - ignoredPath = !negated; - } - } - return ignoredPath; -} - -function matchesIgnorePattern(path: string, pattern: string): boolean { - const directoryPattern = pattern.endsWith("/"); - const normalized = pattern.replace(/\/$/u, ""); - const candidates = normalized.includes("/") - ? [path] - : path.split("/").map((_, index, parts) => parts.slice(index).join("/")); - return candidates.some((candidate) => { - if (globMatch(candidate, normalized)) return true; - return directoryPattern && candidate.startsWith(`${normalized}/`); - }); + return buildProjectSyncManifest({ projectRoot }).files.map((file) => file.relativePath); } -function globMatch(value: string, pattern: string): boolean { - const regex = new RegExp( - `^${pattern - .split("*") - .map((part) => part.replace(/[.+?^${}()|[\]\\]/gu, "\\$&")) - .join("[^/]*")}(?:/.*)?$`, - "u", - ); - return regex.test(value); +export function projectSyncFiles(projectRoot: string): ProjectSyncFile[] { + return projectSyncManifest(projectRoot).map((path) => ({ + path, + content: readFileSync(join(projectRoot, path), "utf8"), + })); } diff --git a/packages/core/src/config-runtime.ts b/packages/core/src/config-runtime.ts new file mode 100644 index 0000000..48cc63c --- /dev/null +++ b/packages/core/src/config-runtime.ts @@ -0,0 +1,664 @@ +import { z } from "zod"; +import { + FORBIDDEN_HEADERS, + HEADER_NAME_PATTERN, + HTTP_BASE_URL_PATTERN, + SERVER_ID_PATTERN, + isAllowedHttpBaseUrl, + isAllowedRemoteUrl, + isUrl, + validateHttpActionHeaders, +} from "./config/validation"; +import { CapletsError } from "./errors"; + +export type RemoteAuthConfig = + | { type: "none" } + | { type: "bearer"; token: string } + | { type: "headers"; headers: Record } + | { + type: "oauth2" | "oidc"; + authorizationUrl?: string | undefined; + tokenUrl?: string | undefined; + issuer?: string | undefined; + resourceMetadataUrl?: string | undefined; + authorizationServerMetadataUrl?: string | undefined; + openidConfigurationUrl?: string | undefined; + clientMetadataUrl?: string | undefined; + clientId?: string | undefined; + clientSecret?: string | undefined; + scopes?: string[] | undefined; + redirectUri?: string | undefined; + }; + +export type CapletSetupCommandConfig = { + label: string; + command: string; + args?: string[] | undefined; + env?: Record | undefined; + cwd?: string | undefined; + timeoutMs?: number | undefined; + maxOutputBytes?: number | undefined; +}; + +export type CapletSetupConfig = { + commands?: CapletSetupCommandConfig[] | undefined; + verify?: CapletSetupCommandConfig[] | undefined; +}; + +export type ProjectBindingConfig = { required: true }; +export type RuntimeFeature = "docker" | "browser"; +export type RuntimeResourceClass = "standard" | "large" | "heavy"; +export type RuntimeRequirementsConfig = { + features?: RuntimeFeature[] | undefined; + resources?: { class?: RuntimeResourceClass | undefined } | undefined; +}; + +export type CapletServerConfig = CommonCapletConfig & { + backend: "mcp"; + transport: "stdio" | "http" | "sse"; + command?: string | undefined; + args?: string[] | undefined; + env?: Record | undefined; + cwd?: string | undefined; + url?: string | undefined; + auth?: RemoteAuthConfig | undefined; + startupTimeoutMs: number; + callTimeoutMs: number; + toolCacheTtlMs: number; +}; + +export type OpenApiAuthConfig = RemoteAuthConfig; + +export type OpenApiEndpointConfig = CommonCapletConfig & { + backend: "openapi"; + specPath?: string | undefined; + specUrl?: string | undefined; + baseUrl?: string | undefined; + auth: OpenApiAuthConfig; + requestTimeoutMs: number; + operationCacheTtlMs: number; +}; + +export type GraphQlOperationConfig = { + document?: string | undefined; + documentPath?: string | undefined; + operationName?: string | undefined; + description?: string | undefined; +}; + +export type GraphQlEndpointConfig = CommonCapletConfig & { + backend: "graphql"; + endpointUrl: string; + schemaPath?: string | undefined; + schemaUrl?: string | undefined; + introspection?: true | undefined; + operations?: Record | undefined; + auth: OpenApiAuthConfig; + requestTimeoutMs: number; + operationCacheTtlMs: number; + selectionDepth: number; +}; + +export type HttpActionConfig = { + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + path: string; + description?: string | undefined; + inputSchema?: Record | undefined; + outputSchema?: Record | undefined; + query?: Record | undefined; + headers?: Record | undefined; + jsonBody?: unknown; +}; + +export type HttpApiConfig = CommonCapletConfig & { + backend: "http"; + baseUrl: string; + auth: OpenApiAuthConfig; + actions: Record; + requestTimeoutMs: number; + maxResponseBytes: number; +}; + +export type CliToolActionConfig = { + description?: string | undefined; + inputSchema?: Record | undefined; + outputSchema?: Record | undefined; + command: string; + args?: string[] | undefined; + env?: Record | undefined; + cwd?: string | undefined; + timeoutMs?: number | undefined; + maxOutputBytes?: number | undefined; + output?: { type: "text" | "json" } | undefined; + annotations?: + | { + readOnlyHint?: boolean | undefined; + destructiveHint?: boolean | undefined; + idempotentHint?: boolean | undefined; + openWorldHint?: boolean | undefined; + } + | undefined; +}; + +export type CliToolsConfig = CommonCapletConfig & { + backend: "cli"; + actions: Record; + cwd?: string | undefined; + env?: Record | undefined; + timeoutMs: number; + maxOutputBytes: number; +}; + +export type CapletSetConfig = CommonCapletConfig & { + backend: "caplets"; + configPath?: string | undefined; + capletsRoot?: string | undefined; + defaultSearchLimit: number; + maxSearchLimit: number; + toolCacheTtlMs: number; +}; + +export type CapletConfig = + | CapletServerConfig + | OpenApiEndpointConfig + | GraphQlEndpointConfig + | HttpApiConfig + | CliToolsConfig + | CapletSetConfig; + +export type CapletsConfig = { + version: 1; + options: { + defaultSearchLimit: number; + maxSearchLimit: number; + completion: { + discoveryTimeoutMs: number; + overallTimeoutMs: number; + cacheTtlMs: number; + negativeCacheTtlMs: number; + }; + }; + mcpServers: Record; + openapiEndpoints: Record; + graphqlEndpoints: Record; + httpApis: Record; + cliTools: Record; + capletSets: Record; +}; + +type CommonCapletConfig = { + server: string; + name: string; + description: string; + tags?: string[] | undefined; + body?: string | undefined; + setup?: CapletSetupConfig | undefined; + projectBinding?: ProjectBindingConfig | undefined; + runtime?: RuntimeRequirementsConfig | undefined; + disabled: boolean; +}; + +const stringMapSchema = z.record(z.string(), z.string()); +const authSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("none") }).strict(), + z.object({ type: z.literal("bearer"), token: z.string().min(1) }).strict(), + z.object({ type: z.literal("headers"), headers: stringMapSchema }).strict(), + oauthLikeSchema("oauth2"), + oauthLikeSchema("oidc"), +]); +const setupCommandSchema = z + .object({ + label: z.string().min(1), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: stringMapSchema.optional(), + cwd: z.string().min(1).optional(), + timeoutMs: z.number().int().positive().optional(), + maxOutputBytes: z.number().int().positive().optional(), + }) + .strict(); +const setupSchema = z + .object({ + commands: z.array(setupCommandSchema).optional(), + verify: z.array(setupCommandSchema).optional(), + }) + .strict() + .refine( + (setup) => (setup.commands?.length ?? 0) > 0 || (setup.verify?.length ?? 0) > 0, + "setup must define at least one command or verify step", + ); +const projectBindingSchema = z.object({ required: z.literal(true) }).strict(); +const runtimeFeatureSchema = z.enum(["docker", "browser"]); +const runtimeFeaturesSchema = z + .array(runtimeFeatureSchema) + .refine((features) => new Set(features).size === features.length, { + message: "runtime.features must not contain duplicate feature names", + }); +const runtimeRequirementsSchema = z + .object({ + features: runtimeFeaturesSchema.optional(), + resources: z + .object({ + class: z.enum(["standard", "large", "heavy"]).optional(), + }) + .strict() + .optional(), + }) + .strict(); +const commonSchema = { + name: z.string().trim().min(1).max(80), + description: z + .string() + .refine( + (value) => value.trim().length >= 10, + "description must contain at least 10 non-whitespace characters", + ) + .refine((value) => value.length <= 1500, "description must be at most 1500 characters"), + tags: z.array(z.string().trim().min(1).max(80)).optional(), + body: z.string().optional(), + setup: setupSchema.optional(), + projectBinding: projectBindingSchema.optional(), + runtime: runtimeRequirementsSchema.optional(), + disabled: z.boolean().default(false), +}; +const mcpServerSchema = z + .object({ + ...commonSchema, + transport: z.enum(["stdio", "http", "sse"]).optional(), + command: z.string().min(1).optional(), + args: z.array(z.string()).optional(), + env: stringMapSchema.optional(), + cwd: z.string().min(1).optional(), + url: z.string().min(1).optional(), + auth: authSchema.optional(), + startupTimeoutMs: z.number().int().positive().default(10_000), + callTimeoutMs: z.number().int().positive().default(60_000), + toolCacheTtlMs: z.number().int().nonnegative().default(30_000), + }) + .strict(); +const openApiEndpointSchema = z + .object({ + ...commonSchema, + specPath: z.string().min(1).optional(), + specUrl: z.string().min(1).optional(), + baseUrl: z.string().min(1).optional(), + auth: authSchema, + requestTimeoutMs: z.number().int().positive().default(60_000), + operationCacheTtlMs: z.number().int().nonnegative().default(30_000), + }) + .strict(); +const graphQlOperationSchema = z + .object({ + document: z.string().min(1).optional(), + documentPath: z.string().min(1).optional(), + operationName: z.string().min(1).optional(), + description: z.string().min(1).optional(), + }) + .strict() + .refine((operation) => Boolean(operation.document) !== Boolean(operation.documentPath), { + message: "GraphQL operation must define exactly one document source", + }); +const graphQlEndpointSchema = z + .object({ + ...commonSchema, + endpointUrl: z.string().min(1), + schemaPath: z.string().min(1).optional(), + schemaUrl: z.string().min(1).optional(), + introspection: z.literal(true).optional(), + operations: z.record(z.string().regex(SERVER_ID_PATTERN), graphQlOperationSchema).optional(), + auth: authSchema, + requestTimeoutMs: z.number().int().positive().default(60_000), + operationCacheTtlMs: z.number().int().nonnegative().default(30_000), + selectionDepth: z.number().int().positive().max(5).default(2), + }) + .strict(); +const scalarMapSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])); +const httpActionSchema = z + .object({ + method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), + path: z + .string() + .min(1) + .regex(/^\//, "HTTP action path must start with /") + .refine((value) => !value.startsWith("//"), "HTTP action path must not start with //") + .refine((value) => !isUrl(value), "HTTP action path must be a URL path, not a URL"), + description: z.string().min(1).optional(), + inputSchema: z.record(z.string(), z.unknown()).optional(), + outputSchema: z.record(z.string(), z.unknown()).optional(), + query: scalarMapSchema.optional(), + headers: scalarMapSchema.optional(), + jsonBody: z.unknown().optional(), + }) + .strict() + .refine((action) => action.method !== "GET" || action.jsonBody === undefined, { + path: ["jsonBody"], + message: "HTTP GET actions must not define jsonBody", + }); +const httpApiSchema = z + .object({ + ...commonSchema, + baseUrl: z + .string() + .min(1) + .regex( + HTTP_BASE_URL_PATTERN, + "HTTP API baseUrl must not include credentials, query, or fragment", + ), + auth: authSchema, + actions: z + .record(z.string().regex(SERVER_ID_PATTERN), httpActionSchema) + .refine( + (actions) => Object.keys(actions).length > 0, + "HTTP API must define at least one action", + ), + requestTimeoutMs: z.number().int().positive().default(60_000), + maxResponseBytes: z.number().int().positive().default(200_000), + }) + .strict(); +const cliActionSchema = z + .object({ + description: z.string().min(1).optional(), + inputSchema: z.record(z.string(), z.unknown()).optional(), + outputSchema: z.record(z.string(), z.unknown()).optional(), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: stringMapSchema.optional(), + cwd: z.string().min(1).optional(), + timeoutMs: z.number().int().positive().optional(), + maxOutputBytes: z.number().int().positive().optional(), + output: z + .object({ type: z.enum(["text", "json"]).default("text") }) + .strict() + .optional(), + annotations: z + .object({ + readOnlyHint: z.boolean().optional(), + destructiveHint: z.boolean().optional(), + idempotentHint: z.boolean().optional(), + openWorldHint: z.boolean().optional(), + }) + .strict() + .optional(), + }) + .strict(); +const cliToolsSchema = z + .object({ + ...commonSchema, + actions: z + .record(z.string().regex(SERVER_ID_PATTERN), cliActionSchema) + .refine( + (actions) => Object.keys(actions).length > 0, + "CLI tools backend must define at least one action", + ), + cwd: z.string().min(1).optional(), + env: stringMapSchema.optional(), + timeoutMs: z.number().int().positive().default(60_000), + maxOutputBytes: z.number().int().positive().default(200_000), + }) + .strict(); +const capletSetSchema = z + .object({ + ...commonSchema, + configPath: z.string().min(1).optional(), + capletsRoot: z.string().min(1).optional(), + defaultSearchLimit: z.number().int().positive().default(20), + maxSearchLimit: z.number().int().positive().max(50).default(50), + toolCacheTtlMs: z.number().int().nonnegative().default(30_000), + }) + .strict(); + +const configSchema = z + .object({ + version: z.literal(1).default(1), + defaultSearchLimit: z.number().int().positive().default(20), + maxSearchLimit: z.number().int().positive().max(50).default(50), + completion: z + .object({ + discoveryTimeoutMs: z.number().int().positive().default(750), + overallTimeoutMs: z.number().int().positive().default(1500), + cacheTtlMs: z.number().int().nonnegative().default(300_000), + negativeCacheTtlMs: z.number().int().nonnegative().default(30_000), + }) + .strict() + .default({ + discoveryTimeoutMs: 750, + overallTimeoutMs: 1500, + cacheTtlMs: 300_000, + negativeCacheTtlMs: 30_000, + }), + mcpServers: z.record(z.string().regex(SERVER_ID_PATTERN), mcpServerSchema).default({}), + openapiEndpoints: z + .record(z.string().regex(SERVER_ID_PATTERN), openApiEndpointSchema) + .default({}), + graphqlEndpoints: z + .record(z.string().regex(SERVER_ID_PATTERN), graphQlEndpointSchema) + .default({}), + httpApis: z.record(z.string().regex(SERVER_ID_PATTERN), httpApiSchema).default({}), + cliTools: z.record(z.string().regex(SERVER_ID_PATTERN), cliToolsSchema).default({}), + capletSets: z.record(z.string().regex(SERVER_ID_PATTERN), capletSetSchema).default({}), + }) + .strict() + .superRefine((config, ctx) => { + if (config.defaultSearchLimit > config.maxSearchLimit) { + ctx.addIssue({ + code: "custom", + path: ["defaultSearchLimit"], + message: "defaultSearchLimit must be <= maxSearchLimit", + }); + } + validateBackends(config, ctx); + }); + +export function parseConfig(input: unknown): CapletsConfig { + const parsed = configSchema.safeParse(input); + if (!parsed.success) { + throw new CapletsError("CONFIG_INVALID", "Caplets config is invalid", parsed.error.issues); + } + const config = parsed.data; + return { + version: 1, + options: { + defaultSearchLimit: config.defaultSearchLimit, + maxSearchLimit: config.maxSearchLimit, + completion: config.completion, + }, + mcpServers: mapBackend(config.mcpServers, "mcp", (id, raw) => { + const server = raw as z.infer; + return { + ...server, + server: id, + transport: server.transport ?? (server.command ? "stdio" : "http"), + }; + }), + openapiEndpoints: mapBackend(config.openapiEndpoints, "openapi"), + graphqlEndpoints: mapBackend(config.graphqlEndpoints, "graphql"), + httpApis: mapBackend(config.httpApis, "http"), + cliTools: mapBackend(config.cliTools, "cli"), + capletSets: mapBackend(config.capletSets, "caplets"), + }; +} + +function oauthLikeSchema(type: "oauth2" | "oidc") { + return z + .object({ + type: z.literal(type), + authorizationUrl: z.string().min(1).optional(), + tokenUrl: z.string().min(1).optional(), + issuer: z.string().min(1).optional(), + resourceMetadataUrl: z.string().min(1).optional(), + authorizationServerMetadataUrl: z.string().min(1).optional(), + openidConfigurationUrl: z.string().min(1).optional(), + clientMetadataUrl: z.string().min(1).optional(), + clientId: z.string().min(1).optional(), + clientSecret: z.string().min(1).optional(), + scopes: z.array(z.string().min(1)).optional(), + redirectUri: z.string().min(1).optional(), + }) + .strict(); +} + +function validateBackends(config: z.infer, ctx: z.RefinementCtx): void { + for (const [server, raw] of Object.entries(config.mcpServers)) { + const effectiveTransport = raw.transport ?? (raw.command ? "stdio" : undefined); + const hasCommand = Boolean(raw.command); + const hasUrl = Boolean(raw.url); + if (hasCommand === hasUrl) { + ctx.addIssue({ + code: "custom", + path: ["mcpServers", server], + message: "MCP server must define exactly one connection shape: command or url", + }); + } + if (effectiveTransport === "stdio" && !raw.command) { + ctx.addIssue({ + code: "custom", + path: ["mcpServers", server, "command"], + message: "stdio servers require command", + }); + } + if ((effectiveTransport === "http" || effectiveTransport === "sse") && !raw.url) { + ctx.addIssue({ + code: "custom", + path: ["mcpServers", server, "url"], + message: "remote servers require url", + }); + } + if (raw.url && !hasEnvReference(raw.url) && !isAllowedRemoteUrl(raw.url)) { + ctx.addIssue({ + code: "custom", + path: ["mcpServers", server, "url"], + message: "remote url must use https except loopback development urls", + }); + } + validateAuthHeaders(raw.auth, ctx, ["mcpServers", server, "auth"]); + } + for (const [server, raw] of Object.entries(config.openapiEndpoints)) { + if (Boolean(raw.specPath) === Boolean(raw.specUrl)) { + ctx.addIssue({ + code: "custom", + path: ["openapiEndpoints", server], + message: "OpenAPI endpoint must define exactly one spec source: specPath or specUrl", + }); + } + if (raw.specUrl && !hasEnvReference(raw.specUrl) && !isAllowedRemoteUrl(raw.specUrl)) { + ctx.addIssue({ + code: "custom", + path: ["openapiEndpoints", server, "specUrl"], + message: "OpenAPI specUrl must use https except loopback development urls", + }); + } + if (raw.baseUrl && !hasEnvReference(raw.baseUrl) && !isAllowedRemoteUrl(raw.baseUrl)) { + ctx.addIssue({ + code: "custom", + path: ["openapiEndpoints", server, "baseUrl"], + message: "OpenAPI baseUrl must use https except loopback development urls", + }); + } + validateAuthHeaders(raw.auth, ctx, ["openapiEndpoints", server, "auth"]); + } + for (const [server, raw] of Object.entries(config.graphqlEndpoints)) { + const sourceCount = + Number(Boolean(raw.schemaPath)) + + Number(Boolean(raw.schemaUrl)) + + Number(raw.introspection === true); + if (sourceCount !== 1) { + ctx.addIssue({ + code: "custom", + path: ["graphqlEndpoints", server], + message: "GraphQL endpoint must define exactly one schema source", + }); + } + if ( + raw.endpointUrl && + !hasEnvReference(raw.endpointUrl) && + !isAllowedRemoteUrl(raw.endpointUrl) + ) { + ctx.addIssue({ + code: "custom", + path: ["graphqlEndpoints", server, "endpointUrl"], + message: "GraphQL endpointUrl must use https except loopback development urls", + }); + } + validateAuthHeaders(raw.auth, ctx, ["graphqlEndpoints", server, "auth"]); + } + for (const [server, raw] of Object.entries(config.httpApis)) { + if (raw.baseUrl && !hasEnvReference(raw.baseUrl) && !isAllowedHttpBaseUrl(raw.baseUrl)) { + ctx.addIssue({ + code: "custom", + path: ["httpApis", server, "baseUrl"], + message: + "HTTP API baseUrl must use https except loopback development urls and must not include credentials, query, or fragment", + }); + } + validateAuthHeaders(raw.auth, ctx, ["httpApis", server, "auth"]); + for (const [actionName, action] of Object.entries(raw.actions)) { + if (action.headers) + validateHttpActionHeaders(action.headers, ctx, [ + "httpApis", + server, + "actions", + actionName, + "headers", + ]); + } + } + for (const [server, raw] of Object.entries(config.capletSets)) { + if (!raw.configPath && !raw.capletsRoot) { + ctx.addIssue({ + code: "custom", + path: ["capletSets", server], + message: "Caplet set must define at least one source: configPath or capletsRoot", + }); + } + if (raw.defaultSearchLimit > raw.maxSearchLimit) { + ctx.addIssue({ + code: "custom", + path: ["capletSets", server, "defaultSearchLimit"], + message: "defaultSearchLimit must be <= maxSearchLimit", + }); + } + } +} + +function validateAuthHeaders( + auth: RemoteAuthConfig | undefined, + ctx: z.RefinementCtx, + path: Array, +): void { + if (auth?.type !== "headers") return; + for (const headerName of Object.keys(auth.headers)) { + const normalized = headerName.toLowerCase(); + if (!HEADER_NAME_PATTERN.test(headerName) || FORBIDDEN_HEADERS.has(normalized)) { + ctx.addIssue({ + code: "custom", + path: [...path, "headers", headerName], + message: `header ${headerName} is not allowed`, + }); + } + } +} + +function mapBackend( + records: Record, + backend: B, + prepare?: (id: string, raw: object) => object, +): Record> { + return Object.fromEntries( + Object.entries(records).map(([id, raw]) => [ + id, + stripUndefined({ + ...(prepare ? prepare(id, raw) : raw), + server: id, + backend, + }) as Extract, + ]), + ); +} + +function stripUndefined(value: Record): Record { + return Object.fromEntries(Object.entries(value).filter(([, nested]) => nested !== undefined)); +} + +function hasEnvReference(value: string): boolean { + return /\$\{?[A-Z_][A-Z0-9_]*\}?|\$env:[A-Z_][A-Z0-9_]*/u.test(value); +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 9d7ed66..8babef2 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -81,6 +81,14 @@ export type CapletSetupConfig = { verify?: CapletSetupCommandConfig[] | undefined; }; +export type ProjectBindingConfig = { required: true }; +export type RuntimeFeature = "docker" | "browser"; +export type RuntimeResourceClass = "standard" | "large" | "heavy"; +export type RuntimeRequirementsConfig = { + features?: RuntimeFeature[] | undefined; + resources?: { class?: RuntimeResourceClass | undefined } | undefined; +}; + export type CapletServerConfig = { server: string; backend: "mcp"; @@ -100,6 +108,8 @@ export type CapletServerConfig = { toolCacheTtlMs: number; disabled: boolean; setup?: CapletSetupConfig | undefined; + projectBinding?: ProjectBindingConfig | undefined; + runtime?: RuntimeRequirementsConfig | undefined; }; export type OpenApiAuthConfig = @@ -123,6 +133,8 @@ export type OpenApiEndpointConfig = { operationCacheTtlMs: number; disabled: boolean; setup?: CapletSetupConfig | undefined; + projectBinding?: ProjectBindingConfig | undefined; + runtime?: RuntimeRequirementsConfig | undefined; }; export type GraphQlOperationConfig = { @@ -150,6 +162,8 @@ export type GraphQlEndpointConfig = { selectionDepth: number; disabled: boolean; setup?: CapletSetupConfig | undefined; + projectBinding?: ProjectBindingConfig | undefined; + runtime?: RuntimeRequirementsConfig | undefined; }; export type HttpActionConfig = { @@ -177,6 +191,8 @@ export type HttpApiConfig = { maxResponseBytes: number; disabled: boolean; setup?: CapletSetupConfig | undefined; + projectBinding?: ProjectBindingConfig | undefined; + runtime?: RuntimeRequirementsConfig | undefined; }; export type CliToolOutputConfig = { @@ -218,6 +234,8 @@ export type CliToolsConfig = { maxOutputBytes: number; disabled: boolean; setup?: CapletSetupConfig | undefined; + projectBinding?: ProjectBindingConfig | undefined; + runtime?: RuntimeRequirementsConfig | undefined; }; export type CapletSetConfig = { @@ -234,6 +252,8 @@ export type CapletSetConfig = { toolCacheTtlMs: number; disabled: boolean; setup?: CapletSetupConfig | undefined; + projectBinding?: ProjectBindingConfig | undefined; + runtime?: RuntimeRequirementsConfig | undefined; }; export type CapletConfig = @@ -404,6 +424,38 @@ const setupSchema = z "setup must define at least one command or verify step", ); +const projectBindingSchema = z + .object({ + required: z.literal(true).describe("Requires Project Binding before this Caplet can run."), + }) + .strict() + .describe("Project Binding requirements for Caplets that need an attached project."); + +const runtimeFeatureSchema = z.enum(["docker", "browser"]); +const runtimeFeaturesSchema = z + .array(runtimeFeatureSchema) + .refine((features) => new Set(features).size === features.length, { + message: "runtime.features must not contain duplicate feature names", + }) + .describe("Runtime features required by this Caplet."); + +const runtimeRequirementsSchema = z + .object({ + features: runtimeFeaturesSchema.optional(), + resources: z + .object({ + class: z + .enum(["standard", "large", "heavy"]) + .optional() + .describe("Requested hosted sandbox resource class."), + }) + .strict() + .optional() + .describe("Hosted sandbox resource requirements."), + }) + .strict() + .describe("Runtime feature and resource requirements for hosted execution."); + const publicServerSchema = z .object({ name: z.string().trim().min(1).max(80).describe("Human-readable server display name."), @@ -430,6 +482,8 @@ const publicServerSchema = z auth: remoteAuthSchema.optional(), tags: z.array(z.string().trim().min(1).max(80)).optional(), setup: setupSchema.optional(), + projectBinding: projectBindingSchema.optional(), + runtime: runtimeRequirementsSchema.optional(), startupTimeoutMs: z .number() .int() @@ -478,6 +532,8 @@ const publicOpenApiEndpointSchema = z ), tags: z.array(z.string().trim().min(1).max(80)).optional(), setup: setupSchema.optional(), + projectBinding: projectBindingSchema.optional(), + runtime: runtimeRequirementsSchema.optional(), requestTimeoutMs: z .number() .int() @@ -548,6 +604,8 @@ const publicGraphQlEndpointSchema = z ), tags: z.array(z.string().trim().min(1).max(80)).optional(), setup: setupSchema.optional(), + projectBinding: projectBindingSchema.optional(), + runtime: runtimeRequirementsSchema.optional(), requestTimeoutMs: z .number() .int() @@ -662,6 +720,8 @@ const publicHttpApiSchema = z .describe("Configured HTTP actions keyed by stable tool name."), tags: z.array(z.string().trim().min(1).max(80)).optional(), setup: setupSchema.optional(), + projectBinding: projectBindingSchema.optional(), + runtime: runtimeRequirementsSchema.optional(), requestTimeoutMs: z .number() .int() @@ -755,6 +815,8 @@ const publicCliToolsSchema = z .describe("Default environment variables for CLI actions."), tags: z.array(z.string().trim().min(1).max(80)).optional(), setup: setupSchema.optional(), + projectBinding: projectBindingSchema.optional(), + runtime: runtimeRequirementsSchema.optional(), timeoutMs: z .number() .int() @@ -809,6 +871,8 @@ const publicCapletSetSchema = z .describe("Milliseconds child Caplet metadata stays fresh. Set 0 to refresh every time."), tags: z.array(z.string().trim().min(1).max(80)).optional(), setup: setupSchema.optional(), + projectBinding: projectBindingSchema.optional(), + runtime: runtimeRequirementsSchema.optional(), disabled: z.boolean().default(false).describe("When true, omit this Caplet set."), }) .strict() diff --git a/packages/core/src/config/paths.ts b/packages/core/src/config/paths.ts index b004ed6..6a15072 100644 --- a/packages/core/src/config/paths.ts +++ b/packages/core/src/config/paths.ts @@ -107,6 +107,5 @@ export function resolveProjectCapletsRoot(cwd = process.cwd()): string { } function displayPath(path: string): string { - if (process.platform !== "darwin") return path; - return path.replace(/^\/private\/(var|tmp)(?=\/|$)/u, "/$1"); + return path; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 26d7272..d528526 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,28 @@ export { CapletsRuntime } from "./runtime"; export { runCli, createProgram } from "./cli"; export { parseConfig, loadConfig } from "./config"; +export { BundleCapletSource, parseCapletSource } from "./caplet-source"; +export { FilesystemCapletSource } from "./caplet-source/filesystem"; +export { + classifyCapletRuntimeRoute, + planCapletRuntimeRoute, + planCapletRuntimeRoutes, +} from "./runtime-plan"; +export type { + CapletSource, + CapletSourceFile, + CapletSourceParseMessage, + CapletSourceParseResult, + CapletSourceReference, + ParsedCapletSourceCaplet, +} from "./caplet-source"; +export type { + CapletRuntimePlan, + RuntimePlanDeployment, + RuntimePlanOptions, + RuntimeRouteKind, + SetupTargetKind as RuntimePlanSetupTargetKind, +} from "./runtime-plan"; export { capabilityDescription, ServerRegistry } from "./registry"; export { generatedToolInputSchema, handleServerTool } from "./tools"; export type { CapletExecutionMetadata, CapletResultMetadata } from "./tools"; @@ -8,6 +30,24 @@ export type { CapletSetupCommandConfig, CapletSetupConfig } from "./config"; export { capletSetupContentHash, stableJson } from "./setup/hash"; export { LocalSetupStore } from "./setup/local-store"; export { runCapletSetup } from "./setup/runner"; +export { CloudAuthClient } from "./cloud-auth/client"; +export { openBrowserUrl } from "./cloud-auth/open-url"; +export { + CloudAuthStore, + cloudAuthPath, + migrateCredentials, + redactedCloudAuthStatus, +} from "./cloud-auth/store"; +export type { CloudAuthCredentials, CloudAuthStoreOptions } from "./cloud-auth/store"; +export type { + CloudAuthLoginPollResult, + CloudAuthLoginStart, + CloudAuthScope, + CloudAuthState, + CloudAuthTokenResponse, + CloudAuthWorkspace, + RedactedCloudAuthStatus, +} from "./cloud-auth/types"; export type { SetupActor, SetupApproval, @@ -25,3 +65,97 @@ export type { ResultMarkdownContext } from "./result-content"; export { serveCaplets, serveHttp, serveResolvedCaplets, serveStdio } from "./serve"; export type { HttpServeOptions, RawServeOptions, ServeOptions, StdioServeOptions } from "./serve"; +export { PROJECT_BINDING_STATES, PROJECT_BINDING_SYNC_STATES } from "./project-binding/types"; +export type { + BindingTerminalReason, + ProjectBindingLease, + ProjectBindingSetupReceipt, + ProjectBindingState, + ProjectBindingSyncState, + ProjectBindingWorkspaceMetadata, +} from "./project-binding/types"; +export { + PROJECT_BINDING_ERROR_CODES, + ProjectBindingError, + projectBindingError, + projectBindingRecovery, +} from "./project-binding/errors"; +export type { ProjectBindingErrorCode, ProjectBindingRecovery } from "./project-binding/errors"; +export { buildProjectSyncManifest } from "./project-binding/sync-filter"; +export type { + ProjectSyncExclusionSource, + ProjectSyncExclusionSummary, + ProjectSyncManifest, + ProjectSyncManifestFile, +} from "./project-binding/sync-filter"; +export { DEFAULT_SYNC_LIMITS, enforceProjectSyncSizeLimits } from "./project-binding/sync-size"; +export type { + ProjectSyncLimits, + ProjectSyncSizeResult, + ProjectSyncTier, +} from "./project-binding/sync-size"; +export { + PROJECT_BINDING_CONNECT_PATH, + PROJECT_BINDINGS_CONTROL_PATH, + projectBindingConnectPath, + projectBindingConnectUrl, + projectBindingStatusPath, + projectBindingStatusUrl, +} from "./project-binding/routes"; +export { + attachProjectOnce, + attachProjectSession, + resolveAttachOptions, +} from "./project-binding/attach"; +export type { + AttachSessionEvent, + RawAttachOptions, + ResolvedAttachOptions, +} from "./project-binding/attach"; +export { runProjectBindingSession } from "./project-binding/session"; +export type { + ProjectBindingSessionEvent, + ProjectBindingSocketClientMessage, + ProjectBindingSocketServerMessage, + RunProjectBindingSessionInput, +} from "./project-binding/session"; +export { defaultProjectBindingWebSocketFactory } from "./project-binding/transport"; +export type { + ProjectBindingWebSocket, + ProjectBindingWebSocketFactory, +} from "./project-binding/transport"; +export { + ProjectBindingWorkspaceStore, + projectBindingWorkspacePaths, + projectBindingWorkspaceRoot, +} from "./project-binding/workspaces"; +export type { + EnsureProjectBindingWorkspaceInput, + ProjectBindingCleanupResult, + ProjectBindingWorkspacePaths, + ProjectBindingWorkspaceRootOptions, + ProjectBindingWorkspaceStoreOptions, +} from "./project-binding/workspaces"; +export { + ManagedMutagenProjectSync, + mutagenProjectSyncDoctorData, + mutagenSyncName, + parseMutagenVersionOutput as parseManagedMutagenVersionOutput, + planMutagenSyncCreateCommand, + planMutagenSyncListCommand, + planMutagenSyncTerminateCommand, + planMutagenVersionCommand, +} from "./project-binding/mutagen"; +export type { + ManagedMutagenProjectSyncOptions, + ManagedSyncDiagnosticCode, + ManagedSyncState, + ManagedSyncStateSnapshot, + MutagenCommandPlan, + MutagenLastCommandStatus, + MutagenProcessResult, + MutagenProcessRunner, + MutagenProjectSyncBindingInput, + MutagenProjectSyncDoctorData, + StartMutagenProjectSyncInput, +} from "./project-binding/mutagen"; diff --git a/packages/core/src/native/options.ts b/packages/core/src/native/options.ts index dbca9ba..9523fde 100644 --- a/packages/core/src/native/options.ts +++ b/packages/core/src/native/options.ts @@ -1,12 +1,13 @@ import { CapletsError } from "../errors"; +import { mcpUrlForBase, type CapletsServerEnv, type CapletsServerInput } from "../server/options"; import { - mcpUrlForBase, - resolveCapletsMode, - resolveCapletsServer, - type CapletsMode, - type CapletsServerEnv, - type CapletsServerInput, -} from "../server/options"; + resolveCapletsRemote, + resolveRemoteMode, + type CapletsRemoteAuth, + type CapletsRemoteEnv, +} from "../remote/options"; + +type CapletsMode = "auto" | "local" | "remote"; export type NativeCapletsMode = CapletsMode; @@ -30,7 +31,7 @@ export type NativeCapletsServiceResolutionInput = { remote?: NativeRemoteCapletsOptions; }; -export type NativeCapletsEnv = CapletsServerEnv; +export type NativeCapletsEnv = CapletsServerEnv & CapletsRemoteEnv; export type NativeRemoteAuthOptions = | { enabled: false; user: string } @@ -57,10 +58,10 @@ export function resolveNativeCapletsServiceOptions( input: NativeCapletsServiceResolutionInput = {}, env: NativeCapletsEnv = process.env, ): ResolvedNativeCapletsServiceOptions { - const mode = resolveCapletsMode( + const mode = resolveRemoteMode( { ...(input.mode ? { mode: input.mode } : {}), - ...(input.server?.url ? { serverUrl: input.server.url } : {}), + ...(input.server?.url ? { remoteUrl: input.server.url } : {}), }, env, ); @@ -69,7 +70,7 @@ export function resolveNativeCapletsServiceOptions( } const serverFetch = input.remote?.fetch ?? input.server?.fetch; - const server = resolveCapletsServer( + const server = resolveCapletsRemote( { ...input.server, ...(serverFetch ? { fetch: serverFetch } : {}) }, env, ); @@ -79,7 +80,7 @@ export function resolveNativeCapletsServiceOptions( mode: "remote", remote: { url: mcpUrlForBase(server.baseUrl), - auth: server.auth, + auth: nativeAuthFromRemoteAuth(server.auth), pollIntervalMs: parsePollInterval(input.remote?.pollIntervalMs), requestInit: server.requestInit, ...(cloud ? { cloud } : {}), @@ -88,6 +89,16 @@ export function resolveNativeCapletsServiceOptions( }; } +function nativeAuthFromRemoteAuth(auth: CapletsRemoteAuth): NativeRemoteAuthOptions { + if (auth.type === "basic") { + return { enabled: true, user: auth.user, password: auth.password }; + } + if (auth.type === "none") { + return { enabled: false, user: auth.user }; + } + return { enabled: false, user: "caplets" }; +} + function parsePollInterval(value: number | undefined): number { if (value === undefined) { return DEFAULT_POLL_INTERVAL_MS; diff --git a/packages/core/src/native/remote.ts b/packages/core/src/native/remote.ts index a6c77d2..fa791a7 100644 --- a/packages/core/src/native/remote.ts +++ b/packages/core/src/native/remote.ts @@ -276,7 +276,7 @@ function errorMessage(error: unknown): string { function remoteAuthError(): CapletsError { return new CapletsError( "AUTH_FAILED", - "Remote Caplets authentication failed; check CAPLETS_SERVER_USER and CAPLETS_SERVER_PASSWORD.", + "Remote Caplets authentication failed; check CAPLETS_REMOTE_USER and CAPLETS_REMOTE_PASSWORD.", ); } diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index 8795651..07eabec 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -1,7 +1,8 @@ import type { NativeCapletsServiceResolutionInput } from "./options"; import { resolveNativeCapletsServiceOptions } from "./options"; import { CapletsCloudClient } from "../cloud/client"; -import { LocalPresenceManager } from "../cloud/presence"; +import { ProjectBindingSessionManager } from "../cloud/presence"; +import { projectSyncFiles } from "../cloud/sync"; import { findProjectRoot, fingerprintProjectRoot } from "../cloud/project-root"; import { createSdkRemoteCapletsClient, @@ -23,6 +24,10 @@ import { } from "../config"; import { generatedToolInputJsonSchemaForCaplet } from "../generated-tool-input-schema"; +const REMOTE_PROJECT_BINDING_FALLBACK_WARNING = + "Remote project binding unavailable; using local Caplets only. Run caplets doctor for details.\n"; +let hasWarnedRemoteProjectBindingFallback = false; + export type NativeCapletsServiceOptions = NativeCapletsServiceResolutionInput & { configPath?: string; projectConfigPath?: string; @@ -79,9 +84,13 @@ export function createNativeCapletsService( pollIntervalMs: resolved.remote.pollIntervalMs, ...(options.writeErr ? { writeErr: options.writeErr } : {}), }); - const presence = createLocalPresenceManager(resolved.remote.cloud, local, options); + const presence = createProjectBindingSessionManager(resolved.remote.cloud, local, options); return new CompositeNativeCapletsService(remote, local, options, presence); } catch (error) { + if (options.mode !== "remote") { + warnRemoteProjectBindingFallback(options); + return local; + } void local.close().catch((closeError) => { writeErr( options, @@ -94,6 +103,10 @@ export function createNativeCapletsService( return new DefaultNativeCapletsService(options); } +export function resetNativeProjectBindingFallbackWarningForTests(): void { + hasWarnedRemoteProjectBindingFallback = false; +} + type LocalNativeCapletsServiceOptions = NativeCapletsServiceOptions & { configLoader?: (configPath: string, projectConfigPath: string) => CapletsConfig; }; @@ -158,7 +171,7 @@ class CompositeNativeCapletsService implements NativeCapletsService { private readonly remote: NativeCapletsService, private readonly local: NativeCapletsService, private readonly options: NativeCapletsServiceOptions, - private readonly presence?: LocalPresenceManager, + private readonly presence?: ProjectBindingSessionManager, ) { this.unsubscribers = [ this.remote.onToolsChanged(() => this.updateMergedTools()), @@ -168,7 +181,7 @@ class CompositeNativeCapletsService implements NativeCapletsService { void this.presence?.start().catch((error) => { writeErr( options, - `Could not register Caplets Cloud local presence: ${errorMessage(error)}\n`, + `Could not register Caplets Cloud Project Binding: ${errorMessage(error)}\n`, ); }); } @@ -261,14 +274,14 @@ class CompositeNativeCapletsService implements NativeCapletsService { } } -function createLocalPresenceManager( +function createProjectBindingSessionManager( cloud: Extract< ReturnType, { mode: "remote" } >["remote"]["cloud"], local: NativeCapletsService, options: NativeCapletsServiceOptions, -): LocalPresenceManager | undefined { +): ProjectBindingSessionManager | undefined { if (!cloud) { return undefined; } @@ -279,15 +292,16 @@ function createLocalPresenceManager( accessToken: cloud.accessToken, ...(cloudFetch ? { fetch: cloudFetch } : {}), }; - return new LocalPresenceManager({ + return new ProjectBindingSessionManager({ client: new CapletsCloudClient(clientOptions), workspaceId: cloud.workspaceId, projectRoot, projectFingerprint: fingerprintProjectRoot(projectRoot), + projectFiles: projectSyncFiles(projectRoot), allowedCapletIds: local.listTools().map((tool) => tool.caplet), heartbeatIntervalMs: cloud.heartbeatIntervalMs, onError: (error) => { - writeErr(options, `Caplets Cloud local presence heartbeat failed: ${errorMessage(error)}\n`); + writeErr(options, `Caplets Cloud Project Binding heartbeat failed: ${errorMessage(error)}\n`); }, }); } @@ -339,6 +353,14 @@ function writeErr(options: NativeCapletsServiceOptions, message: string): void { options.writeErr?.(message); } +function warnRemoteProjectBindingFallback(options: NativeCapletsServiceOptions): void { + if (hasWarnedRemoteProjectBindingFallback) { + return; + } + hasWarnedRemoteProjectBindingFallback = true; + writeErr(options, REMOTE_PROJECT_BINDING_FALLBACK_WARNING); +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/packages/core/src/project-binding/attach.ts b/packages/core/src/project-binding/attach.ts new file mode 100644 index 0000000..5f391de --- /dev/null +++ b/packages/core/src/project-binding/attach.ts @@ -0,0 +1,218 @@ +import { existsSync } from "node:fs"; +import { CapletsError } from "../errors"; +import { CloudAuthClient } from "../cloud-auth/client"; +import { CloudAuthStore } from "../cloud-auth/store"; +import { resolveCapletsRemote, type ResolvedCapletsRemote } from "../remote/options"; +import { projectBindingError, ProjectBindingError } from "./errors"; +import { bootstrapProjectBindingGitignore } from "./gitignore"; +import { runProjectBindingSession, type ProjectBindingSessionEvent } from "./session"; +import { buildProjectSyncManifest } from "./sync-filter"; +import { enforceProjectSyncSizeLimits, type ProjectSyncTier } from "./sync-size"; +import type { ProjectBindingWebSocketFactory } from "./transport"; + +export type RawAttachOptions = { + remoteUrl?: string; + user?: string; + password?: string; + token?: string; + workspace?: string; + json?: boolean; + verbose?: boolean; + once?: boolean; + projectRoot?: string; + fetch?: typeof fetch; +}; + +export type ResolvedAttachOptions = { + projectRoot: string; + json: boolean; + verbose: boolean; + once: boolean; + remote: ResolvedCapletsRemote; + authMode: "self_hosted_remote" | "hosted_cloud"; + selectedWorkspace?: string | undefined; +}; + +export function resolveAttachOptions( + raw: RawAttachOptions = {}, + env: Record = process.env, +): ResolvedAttachOptions { + const remoteInput = { + ...(raw.remoteUrl !== undefined ? { url: raw.remoteUrl } : {}), + ...(raw.user !== undefined ? { user: raw.user } : {}), + ...(raw.password !== undefined ? { password: raw.password } : {}), + ...(raw.token !== undefined ? { token: raw.token } : {}), + ...(raw.workspace !== undefined ? { workspace: raw.workspace } : {}), + ...(raw.fetch !== undefined ? { fetch: raw.fetch } : {}), + }; + return { + projectRoot: raw.projectRoot ?? process.cwd(), + json: raw.json === true, + verbose: raw.verbose === true, + once: raw.once === true, + remote: resolveCapletsRemote(remoteInput, env), + authMode: "self_hosted_remote", + ...(remoteInput.workspace ? { selectedWorkspace: remoteInput.workspace } : {}), + }; +} + +export async function attachProjectOnce( + raw: RawAttachOptions = {}, + env: Record = process.env, +): Promise<{ ok: true; projectRoot: string; webSocketUrl: string }> { + const resolved = await resolveAttachOptionsForRun({ ...raw, once: true }, env); + bootstrapProjectBindingGitignore(resolved.projectRoot); + preflightProjectSync(resolved.projectRoot, hostedTier(env)); + const response = await (resolved.remote.fetch ?? fetch)(projectBindingProbeUrl(resolved.remote), { + ...resolved.remote.requestInit, + method: "GET", + }); + if (response.status !== 101 && !(await isWebSocketUpgradeRequired(response))) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `Project Binding WebSocket unavailable at ${resolved.remote.projectBindingWebSocketUrl}. Run caplets doctor for details.`, + ); + } + return { + ok: true, + projectRoot: resolved.projectRoot, + webSocketUrl: resolved.remote.projectBindingWebSocketUrl.toString(), + }; +} + +export type AttachSessionEvent = ProjectBindingSessionEvent; + +export async function attachProjectSession( + raw: RawAttachOptions = {}, + env: Record = process.env, + options: { + heartbeatIntervalMs?: number | undefined; + signal?: AbortSignal | undefined; + webSocketFactory?: ProjectBindingWebSocketFactory | undefined; + onEvent?: (event: AttachSessionEvent) => void; + } = {}, +) { + const resolved = await resolveAttachOptionsForRun(raw, env); + bootstrapProjectBindingGitignore(resolved.projectRoot); + preflightProjectSync(resolved.projectRoot, hostedTier(env)); + return await runProjectBindingSession({ + projectRoot: resolved.projectRoot, + remote: resolved.remote, + fetch: resolved.remote.fetch, + signal: options.signal, + heartbeatIntervalMs: options.heartbeatIntervalMs, + webSocketFactory: options.webSocketFactory, + onEvent: options.onEvent, + }); +} + +export async function resolveAttachOptionsForRun( + raw: RawAttachOptions = {}, + env: Record = process.env, +): Promise { + if (hasExplicitRemote(raw, env)) return resolveAttachOptions(raw, env); + + const store = new CloudAuthStore({ env }); + let credentials = await store.load(); + if (!credentials?.accessToken) throw projectBindingError("cloud_auth_required"); + if (credentialsNeedRefresh(credentials)) { + if (!credentials.refreshToken) throw projectBindingError("cloud_auth_required"); + const refreshed = await new CloudAuthClient({ + cloudUrl: credentials.cloudUrl, + ...(raw.fetch !== undefined ? { fetch: raw.fetch } : {}), + }).refresh({ refreshToken: credentials.refreshToken }); + credentials = { + ...credentials, + ...refreshed, + refreshToken: refreshed.refreshToken ?? credentials.refreshToken, + createdAt: credentials.createdAt, + lastRefreshAt: new Date().toISOString(), + }; + await store.save(credentials); + } + const selected = credentials.workspaceSlug ?? credentials.workspaceId; + if ( + raw.workspace && + raw.workspace !== credentials.workspaceId && + raw.workspace !== credentials.workspaceSlug + ) { + throw projectBindingError( + "workspace_switch_required", + `Requested workspace ${raw.workspace} differs from saved Selected Workspace ${selected}.`, + ); + } + + const remote = resolveCapletsRemote( + { + url: credentials.cloudUrl, + token: credentials.accessToken, + workspace: selected, + ...(raw.fetch !== undefined ? { fetch: raw.fetch } : {}), + }, + {}, + ); + return { + projectRoot: raw.projectRoot ?? process.cwd(), + json: raw.json === true, + verbose: raw.verbose === true, + once: raw.once === true, + remote, + authMode: "hosted_cloud", + selectedWorkspace: selected, + }; +} + +function projectBindingProbeUrl(remote: ResolvedCapletsRemote): URL { + const url = new URL(remote.projectBindingWebSocketUrl); + if (url.protocol === "wss:") url.protocol = "https:"; + if (url.protocol === "ws:") url.protocol = "http:"; + return url; +} + +async function isWebSocketUpgradeRequired(response: Response): Promise { + if (response.status !== 426) return false; + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) return false; + const body = (await response.json().catch(() => undefined)) as { error?: unknown } | undefined; + return body?.error === "websocket_upgrade_required"; +} + +function hasExplicitRemote( + raw: RawAttachOptions, + env: Record, +): boolean { + return Boolean( + raw.remoteUrl ?? + raw.user ?? + raw.password ?? + raw.token ?? + env.CAPLETS_REMOTE_URL ?? + env.CAPLETS_REMOTE_TOKEN ?? + env.CAPLETS_REMOTE_USER ?? + env.CAPLETS_REMOTE_PASSWORD, + ); +} + +function preflightProjectSync(projectRoot: string, tier: ProjectSyncTier): void { + if (!existsSync(projectRoot)) return; + const manifest = buildProjectSyncManifest({ projectRoot }); + const size = enforceProjectSyncSizeLimits({ tier, files: manifest.files }); + if (!size.ok) { + throw new ProjectBindingError({ + code: size.code, + message: "Project sync size exceeds the selected workspace policy.", + recoveryCommand: size.recoveryCommand, + }); + } +} + +function hostedTier(env: Record): ProjectSyncTier { + const value = env.CAPLETS_CLOUD_TIER?.toLowerCase(); + return value === "plus" || value === "pro" || value === "enterprise" ? value : "free"; +} + +function credentialsNeedRefresh(credentials: { expiresAt: string }): boolean { + const expiresAt = Date.parse(credentials.expiresAt); + if (!Number.isFinite(expiresAt)) return false; + return expiresAt <= Date.now() + 60_000; +} diff --git a/packages/core/src/project-binding/errors.ts b/packages/core/src/project-binding/errors.ts new file mode 100644 index 0000000..e47ee2d --- /dev/null +++ b/packages/core/src/project-binding/errors.ts @@ -0,0 +1,104 @@ +import { CapletsError } from "../errors"; + +export const PROJECT_BINDING_ERROR_CODES = [ + "cloud_auth_required", + "cloud_auth_expired", + "cloud_auth_revoked", + "workspace_selection_required", + "workspace_switch_required", + "workspace_forbidden", + "project_binding_forbidden", + "endpoint_unavailable", + "websocket_upgrade_required", + "sync_required", + "sync_failed", + "sync_size_limit_exceeded", + "lease_conflict", + "lease_expired", + "policy_denied", + "usage_limit_reached", + "billing_required", + "subscription_past_due", + "email_verification_required", + "remote_credentials_required", + "remote_auth_failed", +] as const; + +export type ProjectBindingErrorCode = (typeof PROJECT_BINDING_ERROR_CODES)[number]; + +export type ProjectBindingRecovery = { + code: ProjectBindingErrorCode; + message: string; + recoveryCommand?: string | undefined; + requestId?: string | undefined; +}; + +export class ProjectBindingError extends CapletsError { + readonly projectBindingCode: ProjectBindingErrorCode; + readonly recoveryCommand?: string | undefined; + readonly requestId?: string | undefined; + + constructor(input: ProjectBindingRecovery) { + super("SERVER_UNAVAILABLE", input.message, input); + this.name = "ProjectBindingError"; + this.projectBindingCode = input.code; + this.recoveryCommand = input.recoveryCommand; + this.requestId = input.requestId; + } +} + +export function projectBindingRecovery( + code: ProjectBindingErrorCode, + message = defaultProjectBindingMessage(code), +): ProjectBindingRecovery { + return { + code, + message, + recoveryCommand: recoveryCommandFor(code), + }; +} + +export function projectBindingError( + code: ProjectBindingErrorCode, + message?: string, +): ProjectBindingError { + return new ProjectBindingError(projectBindingRecovery(code, message)); +} + +function recoveryCommandFor(code: ProjectBindingErrorCode): string | undefined { + switch (code) { + case "cloud_auth_required": + case "cloud_auth_expired": + case "cloud_auth_revoked": + case "workspace_selection_required": + return "caplets cloud auth login"; + case "workspace_switch_required": + return "caplets cloud auth switch "; + case "sync_size_limit_exceeded": + return "Add exclusions to .capletsignore or upgrade the workspace plan."; + case "remote_credentials_required": + case "remote_auth_failed": + return "Set CAPLETS_REMOTE_URL and remote credentials."; + case "endpoint_unavailable": + case "websocket_upgrade_required": + return "caplets doctor"; + default: + return undefined; + } +} + +function defaultProjectBindingMessage(code: ProjectBindingErrorCode): string { + switch (code) { + case "sync_size_limit_exceeded": + return "Project sync size exceeds the selected workspace policy."; + case "workspace_switch_required": + return "The requested workspace differs from the saved Selected Workspace."; + case "cloud_auth_required": + return "Hosted Project Binding requires Cloud Auth."; + case "endpoint_unavailable": + case "websocket_upgrade_required": + return "Project Binding endpoint is unavailable."; + default: + return code.replace(/_/gu, " "); + } +} diff --git a/packages/core/src/project-binding/gitignore.ts b/packages/core/src/project-binding/gitignore.ts new file mode 100644 index 0000000..75239b9 --- /dev/null +++ b/packages/core/src/project-binding/gitignore.ts @@ -0,0 +1,31 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const CAPLETS_GITIGNORE_CONTENT = "*\n!.gitignore\n"; + +export type ProjectBindingGitignoreBootstrap = { + path: string; + changed: boolean; +}; + +export function bootstrapProjectBindingGitignore( + projectRoot: string, +): ProjectBindingGitignoreBootstrap { + const capletsDir = join(projectRoot, ".caplets"); + const gitignorePath = join(capletsDir, ".gitignore"); + if (!existsSync(projectRoot)) return { path: gitignorePath, changed: false }; + mkdirSync(capletsDir, { recursive: true }); + + if (!existsSync(gitignorePath)) { + writeFileSync(gitignorePath, CAPLETS_GITIGNORE_CONTENT, { mode: 0o600 }); + return { path: gitignorePath, changed: true }; + } + + const existing = readFileSync(gitignorePath, "utf8"); + if (existing.startsWith(CAPLETS_GITIGNORE_CONTENT)) { + return { path: gitignorePath, changed: false }; + } + + writeFileSync(gitignorePath, `${CAPLETS_GITIGNORE_CONTENT}${existing}`, { mode: 0o600 }); + return { path: gitignorePath, changed: true }; +} diff --git a/packages/core/src/project-binding/mutagen.ts b/packages/core/src/project-binding/mutagen.ts new file mode 100644 index 0000000..daacdf7 --- /dev/null +++ b/packages/core/src/project-binding/mutagen.ts @@ -0,0 +1,453 @@ +import { spawn } from "node:child_process"; + +export type ManagedSyncState = "idle" | "starting" | "syncing" | "ready" | "blocked" | "stopped"; + +export type ManagedSyncDiagnosticCode = + | "project_sync_binary_missing" + | "project_sync_auth_failed" + | "project_sync_conflict" + | "project_sync_process_exit" + | "project_sync_status_unavailable"; + +export type MutagenCommandPlan = { + command: string; + args: string[]; +}; + +export type MutagenProcessResult = { + stdout?: string; + stderr?: string; + exitCode?: number; +}; + +export type MutagenProcessRunner = ( + command: string, + args: string[], +) => Promise; + +export type MutagenLastCommandStatus = MutagenCommandPlan & { + stdout: string; + stderr: string; + exitCode?: number; +}; + +export type ManagedSyncStateSnapshot = { + state: ManagedSyncState; + publicMessage: string; + bindingId?: string; + diagnosticCode?: ManagedSyncDiagnosticCode; + mutagenBinary?: string; + mutagenVersion?: string; + lastCommand?: MutagenLastCommandStatus; +}; + +export type MutagenProjectSyncDoctorData = { + state: ManagedSyncState; + diagnosticCode?: ManagedSyncDiagnosticCode; + mutagenBinary?: string; + mutagenVersion?: string; + lastCommand?: MutagenLastCommandStatus; +}; + +export type StartMutagenProjectSyncInput = { + bindingId: string; + localProjectRoot: string; + serverProjectRoot: string; +}; + +export type MutagenProjectSyncBindingInput = { + bindingId: string; +}; + +export type ManagedMutagenProjectSyncOptions = { + mutagenBinary?: string; + runner?: MutagenProcessRunner; +}; + +type MutagenVersionInfo = { + version: string; +}; + +type ManagedSyncStateUpdate = { + state: ManagedSyncState; + publicMessage: string; + bindingId?: string | undefined; + diagnosticCode?: ManagedSyncDiagnosticCode | undefined; +}; + +const readyStatuses = new Set(["watching", "ready", "ok"]); +const syncingStatuses = new Set([ + "connecting", + "halted on root", + "reconciling", + "scanning", + "staging", + "syncing", + "transitioning", + "watching for changes", +]); + +export function planMutagenVersionCommand(mutagenBinary = "mutagen"): MutagenCommandPlan { + return { command: mutagenBinary, args: ["version"] }; +} + +export function planMutagenSyncCreateCommand( + input: StartMutagenProjectSyncInput, + mutagenBinary = "mutagen", +): MutagenCommandPlan { + return { + command: mutagenBinary, + args: [ + "sync", + "create", + input.localProjectRoot, + input.serverProjectRoot, + "--name", + mutagenSyncName(input.bindingId), + ], + }; +} + +export function planMutagenSyncListCommand(mutagenBinary = "mutagen"): MutagenCommandPlan { + return { command: mutagenBinary, args: ["sync", "list", "--template", "json"] }; +} + +export function planMutagenSyncTerminateCommand( + bindingId: string, + mutagenBinary = "mutagen", +): MutagenCommandPlan { + return { command: mutagenBinary, args: ["sync", "terminate", mutagenSyncName(bindingId)] }; +} + +export function mutagenSyncName(bindingId: string): string { + return `caplets-${bindingId}`; +} + +export class ManagedMutagenProjectSync { + readonly mutagenBinary: string; + + #runner: MutagenProcessRunner; + #snapshot: ManagedSyncStateSnapshot; + + constructor(options: ManagedMutagenProjectSyncOptions = {}) { + this.mutagenBinary = options.mutagenBinary ?? "mutagen"; + this.#runner = options.runner ?? defaultMutagenProcessRunner; + this.#snapshot = { + state: "idle", + publicMessage: "Project sync is idle.", + mutagenBinary: this.mutagenBinary, + }; + } + + async start(input: StartMutagenProjectSyncInput): Promise { + this.#setState({ + state: "starting", + bindingId: input.bindingId, + publicMessage: "Project sync is starting.", + }); + const versionResult = await this.#run(planMutagenVersionCommand(this.mutagenBinary)); + if (versionResult.blocked) { + return this.snapshot(); + } + this.#snapshot.mutagenVersion = parseMutagenVersionOutput( + versionResult.result.stdout ?? "", + ).version; + + const createResult = await this.#run(planMutagenSyncCreateCommand(input, this.mutagenBinary)); + if (createResult.blocked) { + return this.snapshot(); + } + this.#setState({ + state: "syncing", + bindingId: input.bindingId, + publicMessage: "Project sync is starting.", + }); + return this.snapshot(); + } + + async refresh(input: MutagenProjectSyncBindingInput): Promise { + const listResult = await this.#run( + planMutagenSyncListCommand(this.mutagenBinary), + input.bindingId, + ); + if (listResult.blocked) { + return this.snapshot(); + } + + let session: { name: string; status: string } | undefined; + try { + session = findMutagenSyncSession(listResult.result.stdout, mutagenSyncName(input.bindingId)); + } catch { + this.#block(input.bindingId, "project_sync_status_unavailable"); + return this.snapshot(); + } + if (!session) { + this.#block(input.bindingId, "project_sync_status_unavailable"); + return this.snapshot(); + } + + const normalizedStatus = session.status.toLocaleLowerCase(); + if (readyStatuses.has(normalizedStatus)) { + this.#setState({ + state: "ready", + bindingId: input.bindingId, + publicMessage: "Project sync is ready.", + }); + return this.snapshot(); + } + if (syncingStatuses.has(normalizedStatus)) { + this.#setState({ + state: "syncing", + bindingId: input.bindingId, + publicMessage: "Project sync is catching up.", + }); + return this.snapshot(); + } + + this.#block(input.bindingId, mapTextToDiagnosticCode(session.status)); + return this.snapshot(); + } + + async stop(input: MutagenProjectSyncBindingInput): Promise { + const stopResult = await this.#run( + planMutagenSyncTerminateCommand(input.bindingId, this.mutagenBinary), + input.bindingId, + ); + if (stopResult.blocked) { + return this.snapshot(); + } + this.#setState({ + state: "stopped", + bindingId: input.bindingId, + publicMessage: "Project sync has stopped.", + }); + return this.snapshot(); + } + + snapshot(): ManagedSyncStateSnapshot { + const snapshot: ManagedSyncStateSnapshot = { + ...this.#snapshot, + }; + if (this.#snapshot.lastCommand) { + snapshot.lastCommand = { + ...this.#snapshot.lastCommand, + args: [...this.#snapshot.lastCommand.args], + }; + } + return snapshot; + } + + async #run( + plan: MutagenCommandPlan, + bindingId = this.#snapshot.bindingId, + ): Promise<{ blocked: false; result: Required } | { blocked: true }> { + try { + const result = normalizeProcessResult(await this.#runner(plan.command, [...plan.args])); + this.#snapshot.lastCommand = { ...plan, ...result, args: [...plan.args] }; + if (result.exitCode !== 0) { + this.#block(bindingId, "project_sync_process_exit"); + return { blocked: true }; + } + return { blocked: false, result }; + } catch (error) { + const errorResult: MutagenProcessResult = { + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + }; + const exitCode = errorExitCode(error); + if (exitCode !== undefined) { + errorResult.exitCode = exitCode; + } + this.#snapshot.lastCommand = commandStatus(plan, errorResult); + this.#block(bindingId, mapErrorToDiagnosticCode(error)); + return { blocked: true }; + } + } + + #block(bindingId: string | undefined, diagnosticCode: ManagedSyncDiagnosticCode): void { + this.#setState({ + state: "blocked", + bindingId, + diagnosticCode, + publicMessage: "Project sync is blocked.", + }); + } + + #setState(next: ManagedSyncStateUpdate): void { + const snapshot: ManagedSyncStateSnapshot = { + ...this.#snapshot, + state: next.state, + publicMessage: next.publicMessage, + mutagenBinary: this.mutagenBinary, + }; + if (next.bindingId !== undefined) { + snapshot.bindingId = next.bindingId; + } + if (next.diagnosticCode !== undefined) { + snapshot.diagnosticCode = next.diagnosticCode; + } else { + delete snapshot.diagnosticCode; + } + this.#snapshot = snapshot; + } +} + +export function mutagenProjectSyncDoctorData( + snapshot: ManagedSyncStateSnapshot, +): MutagenProjectSyncDoctorData { + const doctorData: MutagenProjectSyncDoctorData = { + state: snapshot.state, + }; + if (snapshot.diagnosticCode !== undefined) { + doctorData.diagnosticCode = snapshot.diagnosticCode; + } + if (snapshot.mutagenBinary !== undefined) { + doctorData.mutagenBinary = snapshot.mutagenBinary; + } + if (snapshot.mutagenVersion !== undefined) { + doctorData.mutagenVersion = snapshot.mutagenVersion; + } + if (snapshot.lastCommand !== undefined) { + doctorData.lastCommand = snapshot.lastCommand; + } + return doctorData; +} + +export function parseMutagenVersionOutput(output: string): MutagenVersionInfo { + return { version: output.match(/Mutagen version\s+([^\s]+)/u)?.[1] ?? "unknown" }; +} + +async function defaultMutagenProcessRunner( + command: string, + args: string[], +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (exitCode) => { + resolve(exitCode === null ? { stdout, stderr } : { stdout, stderr, exitCode }); + }); + }); +} + +function normalizeProcessResult(result: MutagenProcessResult): Required { + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + exitCode: result.exitCode ?? 0, + }; +} + +function commandStatus( + plan: MutagenCommandPlan, + result: MutagenProcessResult, +): MutagenLastCommandStatus { + const status: MutagenLastCommandStatus = { + ...plan, + args: [...plan.args], + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; + if (result.exitCode !== undefined) { + status.exitCode = result.exitCode; + } + return status; +} + +function mapErrorToDiagnosticCode(error: unknown): ManagedSyncDiagnosticCode { + const text = errorText(error); + if (errorCode(error) === "ENOENT" || /\bnot found\b|enoent|no such file/u.test(text)) { + return "project_sync_binary_missing"; + } + return mapTextToDiagnosticCode(text, errorExitCode(error)); +} + +function mapTextToDiagnosticCode(text: string, exitCode?: number): ManagedSyncDiagnosticCode { + const normalized = text.toLocaleLowerCase(); + if (/auth|credential|permission denied|unauthorized|forbidden/u.test(normalized)) { + return "project_sync_auth_failed"; + } + if (/already exists|conflict|duplicate|in use/u.test(normalized)) { + return "project_sync_conflict"; + } + if (exitCode !== undefined || /exit|failed|terminated/u.test(normalized)) { + return "project_sync_process_exit"; + } + return "project_sync_status_unavailable"; +} + +function errorText(error: unknown): string { + return error instanceof Error + ? error.message.toLocaleLowerCase() + : String(error).toLocaleLowerCase(); +} + +function errorCode(error: unknown): unknown { + return typeof error === "object" && error !== null && "code" in error + ? (error as { code?: unknown }).code + : undefined; +} + +function errorExitCode(error: unknown): number | undefined { + return typeof error === "object" && error !== null && "exitCode" in error + ? (error as { exitCode?: number }).exitCode + : undefined; +} + +function findMutagenSyncSession( + stdout: string, + name: string, +): { name: string; status: string } | undefined { + const parsed = JSON.parse(stdout) as unknown; + for (const entry of collectCandidateSessions(parsed)) { + if (entry.name === name) { + return entry; + } + } + return undefined; +} + +function collectCandidateSessions(value: unknown): Array<{ name: string; status: string }> { + if (Array.isArray(value)) { + return value.flatMap(collectCandidateSessions); + } + if (!isRecord(value)) { + return []; + } + + const ownSession = sessionFromRecord(value); + const nested = ["synchronizations", "sessions", "syncs"].flatMap((key) => + collectCandidateSessions(value[key]), + ); + return ownSession ? [ownSession, ...nested] : nested; +} + +function sessionFromRecord( + value: Record, +): { name: string; status: string } | undefined { + const name = stringProperty(value, "name") ?? stringProperty(value, "Name"); + const status = + stringProperty(value, "status") ?? + stringProperty(value, "Status") ?? + stringProperty(value, "sessionStatus") ?? + stringProperty(value, "SessionStatus"); + return name && status ? { name, status } : undefined; +} + +function stringProperty(value: Record, key: string): string | undefined { + return typeof value[key] === "string" ? value[key] : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/core/src/project-binding/routes.ts b/packages/core/src/project-binding/routes.ts new file mode 100644 index 0000000..e0cbd83 --- /dev/null +++ b/packages/core/src/project-binding/routes.ts @@ -0,0 +1,36 @@ +import { PROJECT_BINDING_STATES, type ProjectBindingState } from "./types"; + +export { PROJECT_BINDING_STATES }; +export type { ProjectBindingState }; + +export const PROJECT_BINDINGS_CONTROL_PATH = "/control/project-bindings"; +export const PROJECT_BINDING_CONNECT_PATH = `${PROJECT_BINDINGS_CONTROL_PATH}/connect`; + +export function projectBindingConnectPath(): string { + return PROJECT_BINDING_CONNECT_PATH; +} + +export function projectBindingStatusPath(bindingId: string): string { + return `${PROJECT_BINDINGS_CONTROL_PATH}/${encodeURIComponent(bindingId)}/status`; +} + +export function projectBindingConnectUrl(baseUrl: string | URL): string { + return withBasePath(baseUrl, projectBindingConnectPath()).toString(); +} + +export function projectBindingStatusUrl(baseUrl: string | URL, bindingId: string): string { + return withBasePath(baseUrl, projectBindingStatusPath(bindingId)).toString(); +} + +function withBasePath(baseUrl: string | URL, path: string): URL { + const url = new URL(baseUrl); + url.pathname = `${trimTrailingSlash(url.pathname)}${path}`; + url.search = ""; + url.hash = ""; + return url; +} + +function trimTrailingSlash(pathname: string): string { + if (pathname === "/") return ""; + return pathname.replace(/\/+$/u, ""); +} diff --git a/packages/core/src/project-binding/session.ts b/packages/core/src/project-binding/session.ts new file mode 100644 index 0000000..8380a3c --- /dev/null +++ b/packages/core/src/project-binding/session.ts @@ -0,0 +1,383 @@ +import { fingerprintProjectRoot } from "../cloud/project-root"; +import { CapletsError } from "../errors"; +import type { ResolvedCapletsRemote } from "../remote/options"; +import type { BindingTerminalReason, ProjectBindingState, ProjectBindingSyncState } from "./types"; +import { + defaultProjectBindingWebSocketFactory, + PROJECT_BINDING_SOCKET_OPEN, + type ProjectBindingSocketEvent, + type ProjectBindingWebSocket, + type ProjectBindingWebSocketFactory, +} from "./transport"; + +export type ProjectBindingSessionEvent = + | { type: "state"; state: ProjectBindingState; message?: string | undefined; requestId?: string } + | { + type: "ready"; + bindingId: string; + sessionId: string; + projectRoot: string; + projectFingerprint: string; + webSocketUrl: string; + requestId?: string | undefined; + } + | { + type: "reconnecting"; + bindingId: string; + sessionId: string; + attempt: number; + reason: string; + requestId?: string | undefined; + } + | { type: "heartbeat"; bindingId: string; sessionId: string; state: ProjectBindingState } + | { type: "ended"; bindingId?: string; sessionId?: string; reason: BindingTerminalReason }; + +export type ProjectBindingSocketServerMessage = + | { + type: "state"; + state: ProjectBindingState; + syncState: ProjectBindingSyncState; + requestId?: string | undefined; + } + | { + type: "ready"; + bindingId: string; + sessionId: string; + syncState: ProjectBindingSyncState; + requestId?: string | undefined; + } + | { type: "blocked"; reason: BindingTerminalReason } + | { type: "ended"; reason: BindingTerminalReason }; + +export type ProjectBindingSocketClientMessage = + | { + type: "heartbeat"; + bindingId: string; + sessionId: string; + state: ProjectBindingState; + syncState: ProjectBindingSyncState; + } + | { type: "end"; bindingId: string; sessionId: string; reason: BindingTerminalReason }; + +export type RunProjectBindingSessionInput = { + projectRoot: string; + remote: ResolvedCapletsRemote; + fetch?: typeof fetch | undefined; + webSocketFactory?: ProjectBindingWebSocketFactory | undefined; + signal?: AbortSignal | undefined; + heartbeatIntervalMs?: number | undefined; + onEvent?: ((event: ProjectBindingSessionEvent) => void) | undefined; +}; + +export async function runProjectBindingSession(input: RunProjectBindingSessionInput): Promise<{ + ok: true; + bindingId: string; + sessionId: string; + projectRoot: string; + projectFingerprint: string; + webSocketUrl: string; + ended: true; +}> { + const fetchImpl = input.fetch ?? input.remote.fetch ?? fetch; + const webSocketFactory = input.webSocketFactory ?? defaultProjectBindingWebSocketFactory; + const heartbeatIntervalMs = input.heartbeatIntervalMs ?? 15_000; + const projectFingerprint = fingerprintProjectRoot(input.projectRoot); + const requestInit = input.remote.requestInit; + input.onEvent?.({ type: "state", state: "attaching" }); + + const created = await postJson<{ + binding: { + bindingId: string; + state?: ProjectBindingState; + syncState?: ProjectBindingSyncState; + }; + sessionId: string; + }>(fetchImpl, sessionUrl(input.remote), requestInit, { + projectRoot: input.projectRoot, + projectFingerprint, + workspaceId: input.remote.workspace ?? "default", + }); + const bindingId = created.binding.bindingId; + const sessionId = created.sessionId; + let state: ProjectBindingState = created.binding.state ?? "attaching"; + let syncState: ProjectBindingSyncState = created.binding.syncState ?? "pending"; + let ended = false; + let heartbeatTimer: ReturnType | undefined; + const publicWebSocketUrl = input.remote.projectBindingWebSocketUrl.toString(); + const socketUrl = authenticatedSocketUrl(input.remote, bindingId, sessionId, projectFingerprint); + + const emitReady = (requestId?: string | undefined) => { + input.onEvent?.({ + type: "ready", + bindingId, + sessionId, + projectRoot: input.projectRoot, + projectFingerprint, + webSocketUrl: publicWebSocketUrl, + ...(requestId ? { requestId } : {}), + }); + }; + + const heartbeat = async (socket?: ProjectBindingWebSocket | undefined) => { + const payload: ProjectBindingSocketClientMessage = { + type: "heartbeat", + bindingId, + sessionId, + state, + syncState, + }; + if (socket?.readyState === PROJECT_BINDING_SOCKET_OPEN) { + socket.send(JSON.stringify(payload)); + } + await postJson(fetchImpl, heartbeatUrl(input.remote, bindingId), requestInit, { + sessionId, + state, + syncState, + }).catch(() => undefined); + input.onEvent?.({ type: "heartbeat", bindingId, sessionId, state }); + }; + + const connect = async (attempt: number): Promise => { + const socket = webSocketFactory(socketUrl); + await waitForOpen(socket, input.signal); + if (input.signal?.aborted) { + closeSocket(socket, 1000, "aborted"); + return; + } + + const closePromise = new Promise<{ reconnect: boolean; reason: string }>((resolve) => { + listen(socket, "message", (event) => { + const message = parseSocketMessage(event.data); + if (!message) return; + if (message.type === "state") { + state = message.state; + syncState = message.syncState; + input.onEvent?.({ + type: "state", + state, + ...(message.requestId ? { requestId: message.requestId } : {}), + }); + return; + } + if (message.type === "ready") { + state = "ready"; + syncState = message.syncState; + emitReady(message.requestId); + return; + } + if (message.type === "blocked") { + state = "blocked"; + input.onEvent?.({ type: "ended", bindingId, sessionId, reason: message.reason }); + resolve({ reconnect: false, reason: message.reason.message }); + return; + } + input.onEvent?.({ type: "ended", bindingId, sessionId, reason: message.reason }); + resolve({ reconnect: false, reason: message.reason.message }); + }); + listen(socket, "close", (event) => { + resolve({ + reconnect: !input.signal?.aborted && attempt < 1, + reason: event.reason ?? `WebSocket closed${event.code ? ` (${event.code})` : ""}.`, + }); + }); + listen(socket, "error", () => { + resolve({ reconnect: !input.signal?.aborted && attempt < 1, reason: "WebSocket error." }); + }); + }); + + heartbeatTimer = setInterval(() => { + void heartbeat(socket); + }, heartbeatIntervalMs); + await heartbeat(socket); + + const closed = await Promise.race([closePromise, waitForAbort(input.signal)]); + if (heartbeatTimer) clearInterval(heartbeatTimer); + heartbeatTimer = undefined; + closeSocket(socket, 1000, "Binding Session closed."); + + if (closed === "abort") return; + if (closed.reconnect) { + input.onEvent?.({ + type: "reconnecting", + bindingId, + sessionId, + attempt: attempt + 1, + reason: closed.reason, + }); + await connect(attempt + 1); + } + }; + + try { + await connect(0); + } finally { + ended = true; + if (heartbeatTimer) clearInterval(heartbeatTimer); + const reason: BindingTerminalReason = input.signal?.aborted + ? { code: "interrupted", message: "Binding Session ended." } + : { code: "completed", message: "Binding Session completed." }; + await endRemoteSession(fetchImpl, input.remote, requestInit, bindingId, sessionId, reason); + input.onEvent?.({ type: "ended", bindingId, sessionId, reason }); + } + + return { + ok: true, + bindingId, + sessionId, + projectRoot: input.projectRoot, + projectFingerprint, + webSocketUrl: publicWebSocketUrl, + ended, + }; +} + +async function postJson( + fetchImpl: typeof fetch, + url: URL, + requestInit: RequestInit, + body: unknown, +): Promise { + const headers = new Headers(requestInit.headers); + headers.set("content-type", "application/json"); + const response = await fetchImpl(url, { + ...requestInit, + method: "POST", + headers, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new CapletsError( + "SERVER_UNAVAILABLE", + `Project Binding request failed (${response.status}).`, + ); + } + return (await response.json().catch(() => ({}))) as T; +} + +async function endRemoteSession( + fetchImpl: typeof fetch, + remote: ResolvedCapletsRemote, + requestInit: RequestInit, + bindingId: string, + sessionId: string, + reason: BindingTerminalReason, +): Promise { + const headers = new Headers(requestInit.headers); + headers.set("content-type", "application/json"); + await fetchImpl(endSessionUrl(remote, bindingId), { + ...requestInit, + method: "DELETE", + headers, + body: JSON.stringify({ sessionId, terminalReason: reason }), + }).catch(() => undefined); +} + +function sessionUrl(remote: ResolvedCapletsRemote): URL { + return controlProjectBindingUrl(remote, "sessions"); +} + +function heartbeatUrl(remote: ResolvedCapletsRemote, bindingId: string): URL { + return controlProjectBindingUrl(remote, `${encodeURIComponent(bindingId)}/heartbeat`); +} + +function endSessionUrl(remote: ResolvedCapletsRemote, bindingId: string): URL { + return controlProjectBindingUrl(remote, `${encodeURIComponent(bindingId)}/session`); +} + +function controlProjectBindingUrl(remote: ResolvedCapletsRemote, suffix: string): URL { + const url = new URL(remote.projectBindingWebSocketUrl); + if (url.protocol === "wss:") url.protocol = "https:"; + if (url.protocol === "ws:") url.protocol = "http:"; + url.pathname = url.pathname.replace(/\/connect$/u, `/${suffix}`); + url.search = ""; + url.hash = ""; + return url; +} + +function authenticatedSocketUrl( + remote: ResolvedCapletsRemote, + bindingId: string, + sessionId: string, + projectFingerprint: string, +): string { + const url = new URL(remote.projectBindingWebSocketUrl); + url.searchParams.set("bindingId", bindingId); + url.searchParams.set("sessionId", sessionId); + url.searchParams.set("projectFingerprint", projectFingerprint); + if (remote.auth.type === "bearer") url.searchParams.set("accessToken", remote.auth.token); + return url.toString(); +} + +function parseSocketMessage(data: unknown): ProjectBindingSocketServerMessage | undefined { + const text = + typeof data === "string" + ? data + : data instanceof ArrayBuffer + ? new TextDecoder().decode(data) + : undefined; + if (!text) return undefined; + const parsed = JSON.parse(text) as Partial; + return typeof parsed.type === "string" + ? (parsed as ProjectBindingSocketServerMessage) + : undefined; +} + +async function waitForOpen( + socket: ProjectBindingWebSocket, + signal: AbortSignal | undefined, +): Promise { + if (socket.readyState === PROJECT_BINDING_SOCKET_OPEN || socket.addEventListener === undefined) { + return; + } + await Promise.race([ + new Promise((resolve, reject) => { + listen(socket, "open", () => resolve(), { once: true }); + listen( + socket, + "error", + () => + reject( + new CapletsError("SERVER_UNAVAILABLE", "Project Binding WebSocket failed to open."), + ), + { + once: true, + }, + ); + }), + waitForAbort(signal).then(() => undefined), + ]); +} + +function waitForAbort(signal: AbortSignal | undefined): Promise<"abort"> { + if (signal?.aborted) return Promise.resolve("abort"); + return new Promise((resolve) => { + signal?.addEventListener("abort", () => resolve("abort"), { once: true }); + }); +} + +function listen( + socket: ProjectBindingWebSocket, + type: "open" | "message" | "close" | "error", + listener: (event: ProjectBindingSocketEvent) => void, + options?: { once?: boolean }, +): void { + if (socket.addEventListener) { + socket.addEventListener(type, listener, options); + return; + } + const key = `on${type}` as const; + const existing = socket[key]; + socket[key] = (event) => { + existing?.(event); + listener(event); + if (options?.once && socket[key] === listener) socket[key] = null; + }; +} + +function closeSocket(socket: ProjectBindingWebSocket, code: number, reason: string): void { + try { + socket.close(code, reason); + } catch { + // Best-effort cleanup; the REST lease end is the durable cleanup path. + } +} diff --git a/packages/core/src/project-binding/sync-filter.ts b/packages/core/src/project-binding/sync-filter.ts new file mode 100644 index 0000000..62dbb09 --- /dev/null +++ b/packages/core/src/project-binding/sync-filter.ts @@ -0,0 +1,171 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +export type ProjectSyncExclusionSource = "hard_denylist" | "gitignore" | "capletsignore"; + +export type ProjectSyncExclusionSummary = { + source: ProjectSyncExclusionSource; + pattern: string; + count: number; +}; + +export type ProjectSyncManifestFile = { + relativePath: string; + sizeBytes: number; +}; + +export type ProjectSyncManifest = { + projectRoot: string; + files: ProjectSyncManifestFile[]; + totalBytes: number; + exclusionSummary: ProjectSyncExclusionSummary[]; +}; + +const HARD_DENYLIST = [ + ".git/", + ".hg/", + ".svn/", + "node_modules/", + ".venv/", + "venv/", + "__pycache__/", + ".pytest_cache/", + ".mypy_cache/", + ".ruff_cache/", + ".next/", + ".nuxt/", + ".turbo/", + ".cache/", + "dist/", + "build/", + "coverage/", + ".DS_Store", + ".env", + ".env.local", + ".env.development", + ".env.production", + ".npmrc", + ".pypirc", + "id_rsa", + "id_ed25519", + "*.pem", + "*.key", + "*.p12", + "*.zip", + "*.tar", + "*.tar.gz", + "*.tgz", + "*.rar", + "*.7z", +]; + +const SAFE_TEMPLATE_ALLOWLIST = [/^\.env\.(example|sample|template)$/u]; + +export function buildProjectSyncManifest(input: { projectRoot: string }): ProjectSyncManifest { + const gitignore = [ + ...loadIgnoreFile(input.projectRoot, ".gitignore"), + ...loadIgnoreFile(input.projectRoot, join(".git", "info", "exclude")), + ]; + const capletsignore = loadIgnoreFile(input.projectRoot, ".capletsignore"); + const files: ProjectSyncManifestFile[] = []; + const excluded = new Map(); + + walk(input.projectRoot, (absolutePath, directory) => { + const relativePath = normalizeRelative(relative(input.projectRoot, absolutePath)); + if (!relativePath) return true; + const denial = hardDenylistPattern(relativePath, directory); + if (denial && !safeTemplate(relativePath)) { + addExcluded(excluded, "hard_denylist", denial); + return false; + } + const gitPattern = matchingIgnorePattern(gitignore, relativePath, directory); + if (gitPattern) { + addExcluded(excluded, "gitignore", gitPattern); + return false; + } + const capletsPattern = matchingIgnorePattern(capletsignore, relativePath, directory); + if (capletsPattern && !safeTemplate(relativePath)) { + addExcluded(excluded, "capletsignore", capletsPattern); + return false; + } + if (!directory) { + files.push({ relativePath, sizeBytes: statSync(absolutePath).size }); + } + return true; + }); + + files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + return { + projectRoot: input.projectRoot, + files, + totalBytes: files.reduce((total, file) => total + file.sizeBytes, 0), + exclusionSummary: [...excluded.values()].sort((a, b) => + `${a.source}:${a.pattern}`.localeCompare(`${b.source}:${b.pattern}`), + ), + }; +} + +function walk(root: string, visit: (absolutePath: string, directory: boolean) => boolean): void { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (entry.isSymbolicLink()) continue; + const absolutePath = join(root, entry.name); + const directory = entry.isDirectory(); + if (!visit(absolutePath, directory)) continue; + if (directory) walk(absolutePath, visit); + } +} + +function loadIgnoreFile(root: string, name: string): string[] { + const path = join(root, name); + if (!existsSync(path)) return []; + return readFileSync(path, "utf8") + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#") && !line.startsWith("!")); +} + +function matchingIgnorePattern( + patterns: string[], + relativePath: string, + directory: boolean, +): string | undefined { + return patterns.find((pattern) => matchesPattern(relativePath, pattern, directory)); +} + +function hardDenylistPattern(relativePath: string, directory: boolean): string | undefined { + return HARD_DENYLIST.find((pattern) => matchesPattern(relativePath, pattern, directory)); +} + +function matchesPattern(relativePath: string, pattern: string, directory: boolean): boolean { + const normalized = pattern.replace(/\\/gu, "/").replace(/^\//u, ""); + if (normalized.endsWith("/")) { + const prefix = normalized.slice(0, -1); + return directory + ? relativePath === prefix || relativePath.startsWith(`${prefix}/`) + : relativePath.startsWith(`${prefix}/`); + } + if (normalized.startsWith("*.")) return relativePath.endsWith(normalized.slice(1)); + return ( + relativePath === normalized || + relativePath.startsWith(`${normalized}/`) || + relativePath.split("/").includes(normalized) + ); +} + +function safeTemplate(relativePath: string): boolean { + return SAFE_TEMPLATE_ALLOWLIST.some((pattern) => pattern.test(relativePath)); +} + +function addExcluded( + excluded: Map, + source: ProjectSyncExclusionSource, + pattern: string, +): void { + const key = `${source}:${pattern}`; + const existing = excluded.get(key); + excluded.set(key, { source, pattern, count: (existing?.count ?? 0) + 1 }); +} + +function normalizeRelative(value: string): string { + return value.split(sep).join("/"); +} diff --git a/packages/core/src/project-binding/sync-size.ts b/packages/core/src/project-binding/sync-size.ts new file mode 100644 index 0000000..0ef08d7 --- /dev/null +++ b/packages/core/src/project-binding/sync-size.ts @@ -0,0 +1,62 @@ +import type { ProjectBindingErrorCode } from "./errors"; +import type { ProjectSyncManifestFile } from "./sync-filter"; + +export type ProjectSyncTier = "free" | "plus" | "pro" | "enterprise" | "self_hosted"; + +export type ProjectSyncLimits = { + maxSingleFileBytes: number; + maxProjectBytes: number; +}; + +export const DEFAULT_SYNC_LIMITS: Record = { + free: { maxSingleFileBytes: 25 * 1024 * 1024, maxProjectBytes: 250 * 1024 * 1024 }, + plus: { maxSingleFileBytes: 100 * 1024 * 1024, maxProjectBytes: 1024 * 1024 * 1024 }, + pro: { maxSingleFileBytes: 250 * 1024 * 1024, maxProjectBytes: 5 * 1024 * 1024 * 1024 }, + enterprise: { maxSingleFileBytes: 250 * 1024 * 1024, maxProjectBytes: 5 * 1024 * 1024 * 1024 }, + self_hosted: { maxSingleFileBytes: 250 * 1024 * 1024, maxProjectBytes: 5 * 1024 * 1024 * 1024 }, +}; + +export type ProjectSyncSizeResult = + | { + ok: true; + totalBytes: number; + maxSingleFileBytes: number; + maxProjectBytes: number; + } + | { + ok: false; + code: ProjectBindingErrorCode; + totalBytes: number; + maxSingleFileBytes: number; + maxProjectBytes: number; + largestFileBytes?: number | undefined; + recoveryCommand: string; + }; + +export function enforceProjectSyncSizeLimits(input: { + tier: ProjectSyncTier; + files: ProjectSyncManifestFile[]; + limits?: Partial | undefined; +}): ProjectSyncSizeResult { + const defaults = DEFAULT_SYNC_LIMITS[input.tier]; + const limits = { ...defaults, ...input.limits }; + const totalBytes = input.files.reduce((total, file) => total + file.sizeBytes, 0); + const largestFileBytes = Math.max(0, ...input.files.map((file) => file.sizeBytes)); + if (largestFileBytes > limits.maxSingleFileBytes || totalBytes > limits.maxProjectBytes) { + return { + ok: false, + code: "sync_size_limit_exceeded", + totalBytes, + maxSingleFileBytes: limits.maxSingleFileBytes, + maxProjectBytes: limits.maxProjectBytes, + largestFileBytes, + recoveryCommand: "Add exclusions to .capletsignore or upgrade the workspace plan.", + }; + } + return { + ok: true, + totalBytes, + maxSingleFileBytes: limits.maxSingleFileBytes, + maxProjectBytes: limits.maxProjectBytes, + }; +} diff --git a/packages/core/src/project-binding/transport.ts b/packages/core/src/project-binding/transport.ts new file mode 100644 index 0000000..e858797 --- /dev/null +++ b/packages/core/src/project-binding/transport.ts @@ -0,0 +1,36 @@ +export type ProjectBindingSocketEvent = { + data?: unknown; + code?: number | undefined; + reason?: string | undefined; +}; + +export type ProjectBindingWebSocket = { + readonly readyState?: number; + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener?: ( + type: "open" | "message" | "close" | "error", + listener: (event: ProjectBindingSocketEvent) => void, + options?: { once?: boolean }, + ) => void; + removeEventListener?: ( + type: "open" | "message" | "close" | "error", + listener: (event: ProjectBindingSocketEvent) => void, + ) => void; + onopen?: ((event: ProjectBindingSocketEvent) => void) | null; + onmessage?: ((event: ProjectBindingSocketEvent) => void) | null; + onclose?: ((event: ProjectBindingSocketEvent) => void) | null; + onerror?: ((event: ProjectBindingSocketEvent) => void) | null; +}; + +export type ProjectBindingWebSocketFactory = (url: string) => ProjectBindingWebSocket; + +export const PROJECT_BINDING_SOCKET_OPEN = 1; + +export function defaultProjectBindingWebSocketFactory(url: string): ProjectBindingWebSocket { + const WebSocketCtor = globalThis.WebSocket; + if (!WebSocketCtor) { + throw new Error("WebSocket is not available in this runtime."); + } + return new WebSocketCtor(url) as ProjectBindingWebSocket; +} diff --git a/packages/core/src/project-binding/types.ts b/packages/core/src/project-binding/types.ts new file mode 100644 index 0000000..77f176b --- /dev/null +++ b/packages/core/src/project-binding/types.ts @@ -0,0 +1,65 @@ +export type ProjectBindingState = + | "not_attached" + | "attaching" + | "syncing" + | "ready" + | "degraded" + | "blocked" + | "offline" + | "cleaning_up" + | "ended" + | "expired"; + +export const PROJECT_BINDING_STATES: readonly ProjectBindingState[] = [ + "not_attached", + "attaching", + "syncing", + "ready", + "degraded", + "blocked", + "offline", + "cleaning_up", + "ended", + "expired", +]; + +export const PROJECT_BINDING_SYNC_STATES = [ + "not_started", + "pending", + "syncing", + "idle", + "failed", +] as const; + +export type ProjectBindingSyncState = (typeof PROJECT_BINDING_SYNC_STATES)[number]; + +export type BindingTerminalReason = { + code: import("./errors").ProjectBindingErrorCode | "interrupted" | "completed"; + message: string; + recoveryCommand?: string | undefined; + requestId?: string | undefined; +}; + +export type ProjectBindingLease = { + bindingId: string; + projectFingerprint: string; + state: ProjectBindingState; + active: boolean; + updatedAt: string; + expiresAt?: string; + diagnosticCode?: string; +}; + +export type ProjectBindingWorkspaceMetadata = { + projectFingerprint: string; + projectRoot: string; + createdAt: string; + lastActiveAt: string; +}; + +export type ProjectBindingSetupReceipt = { + capletId: string; + status: "succeeded" | "failed" | "skipped"; + recordedAt?: string; + contentHash?: string; +}; diff --git a/packages/core/src/project-binding/workspaces.ts b/packages/core/src/project-binding/workspaces.ts new file mode 100644 index 0000000..2b56284 --- /dev/null +++ b/packages/core/src/project-binding/workspaces.ts @@ -0,0 +1,305 @@ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join, posix, win32 } from "node:path"; +import type { + ProjectBindingLease, + ProjectBindingSetupReceipt, + ProjectBindingWorkspaceMetadata, +} from "./types"; + +const DEFAULT_STALE_LEASE_TTL_MS = 2 * 60 * 1000; +const DEFAULT_INACTIVE_WORKSPACE_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const DEFAULT_SOFT_DISK_CAP_BYTES = 10 * 1024 * 1024 * 1024; + +type PathEnv = Partial>; + +export type ProjectBindingWorkspaceRootOptions = { + env?: PathEnv; + platform?: NodeJS.Platform; + homedir?: string; + root?: string; +}; + +export type ProjectBindingWorkspacePaths = { + projectFingerprint: string; + root: string; + project: string; + metadata: string; + leases: string; + setup: string; + setupReceipts: string; + lease(bindingId: string): string; +}; + +export type ProjectBindingWorkspaceStoreOptions = ProjectBindingWorkspaceRootOptions & { + now?: () => Date; + staleLeaseTtlMs?: number; + inactiveWorkspaceTtlMs?: number; + softDiskCapBytes?: number; + workspaceSizeBytes?: (paths: ProjectBindingWorkspacePaths) => number; +}; + +export type EnsureProjectBindingWorkspaceInput = { + projectFingerprint: string; + projectRoot: string; + lastActiveAt?: string; + createdAt?: string; +}; + +export type ProjectBindingCleanupResult = { + expiredLeases: string[]; + deletedWorkspaces: string[]; + retainedWorkspaces: string[]; +}; + +export function projectBindingWorkspaceRoot( + options: ProjectBindingWorkspaceRootOptions = {}, +): string { + if (options.root) return options.root; + + const platform = options.platform ?? process.platform; + const home = options.homedir ?? homedir(); + const env = options.env ?? process.env; + if (platform === "win32") { + const base = + env.LOCALAPPDATA && win32.isAbsolute(env.LOCALAPPDATA) + ? env.LOCALAPPDATA + : win32.join(home, "AppData", "Local"); + return win32.join(base, "Caplets", "State", "workspaces"); + } + + const base = + env.XDG_STATE_HOME && posix.isAbsolute(env.XDG_STATE_HOME) + ? env.XDG_STATE_HOME + : posix.join(home, ".local", "state"); + return posix.join(base, "caplets", "workspaces"); +} + +export function projectBindingWorkspacePaths( + projectFingerprint: string, + options: ProjectBindingWorkspaceRootOptions = {}, +): ProjectBindingWorkspacePaths { + assertPathSegment(projectFingerprint, "project fingerprint"); + const pathJoin = pathJoinFor(options.platform); + const root = pathJoin(projectBindingWorkspaceRoot(options), projectFingerprint); + const leases = pathJoin(root, "leases"); + const setup = pathJoin(root, "setup"); + return { + projectFingerprint, + root, + project: pathJoin(root, "project"), + metadata: pathJoin(root, "metadata.json"), + leases, + setup, + setupReceipts: pathJoin(setup, "receipts.json"), + lease(bindingId: string) { + assertPathSegment(bindingId, "binding ID"); + return pathJoin(leases, `${bindingId}.json`); + }, + }; +} + +export class ProjectBindingWorkspaceStore { + private readonly root: string; + private readonly now: () => Date; + private readonly staleLeaseTtlMs: number; + private readonly inactiveWorkspaceTtlMs: number; + private readonly softDiskCapBytes: number; + private readonly workspaceSizeBytes: (paths: ProjectBindingWorkspacePaths) => number; + + constructor(private readonly options: ProjectBindingWorkspaceStoreOptions = {}) { + this.root = projectBindingWorkspaceRoot(options); + this.now = options.now ?? (() => new Date()); + this.staleLeaseTtlMs = options.staleLeaseTtlMs ?? DEFAULT_STALE_LEASE_TTL_MS; + this.inactiveWorkspaceTtlMs = + options.inactiveWorkspaceTtlMs ?? DEFAULT_INACTIVE_WORKSPACE_TTL_MS; + this.softDiskCapBytes = options.softDiskCapBytes ?? DEFAULT_SOFT_DISK_CAP_BYTES; + this.workspaceSizeBytes = options.workspaceSizeBytes ?? ((paths) => directorySize(paths.root)); + } + + paths(projectFingerprint: string): ProjectBindingWorkspacePaths { + return projectBindingWorkspacePaths(projectFingerprint, { ...this.options, root: this.root }); + } + + async ensureWorkspace( + input: EnsureProjectBindingWorkspaceInput, + ): Promise { + const paths = this.paths(input.projectFingerprint); + const now = this.now().toISOString(); + const existing = this.readMetadata(input.projectFingerprint); + const metadata: ProjectBindingWorkspaceMetadata = { + projectFingerprint: input.projectFingerprint, + projectRoot: input.projectRoot, + createdAt: input.createdAt ?? existing?.createdAt ?? now, + lastActiveAt: input.lastActiveAt ?? now, + }; + + mkdirSync(paths.project, { recursive: true }); + mkdirSync(paths.leases, { recursive: true }); + mkdirSync(paths.setup, { recursive: true }); + writeJson(paths.metadata, metadata); + return paths; + } + + async writeLease(lease: ProjectBindingLease): Promise { + const paths = this.paths(lease.projectFingerprint); + mkdirSync(paths.leases, { recursive: true }); + writeJson(paths.lease(lease.bindingId), lease); + if (lease.active) { + const metadata = this.readMetadata(lease.projectFingerprint); + if (metadata) { + writeJson(paths.metadata, { ...metadata, lastActiveAt: lease.updatedAt }); + } + } + } + + async listLeases(projectFingerprint: string): Promise { + return this.leasesFor(this.paths(projectFingerprint)).map((entry) => entry.lease); + } + + async writeSetupReceipts( + projectFingerprint: string, + receipts: ProjectBindingSetupReceipt[], + ): Promise { + const paths = this.paths(projectFingerprint); + mkdirSync(paths.setup, { recursive: true }); + writeJson(paths.setupReceipts, receipts); + } + + async cleanup(): Promise { + const expiredLeases: string[] = []; + const deletedWorkspaces: string[] = []; + const retainedWorkspaces: string[] = []; + const candidates: WorkspaceCandidate[] = []; + + for (const paths of this.workspacePaths()) { + const leases = this.leasesFor(paths); + for (const entry of leases) { + if (!entry.lease.active && this.isStaleLease(entry.lease)) { + rmSync(entry.path, { force: true }); + expiredLeases.push(entry.path); + } + } + + const active = this.leasesFor(paths).some((entry) => entry.lease.active); + if (active) { + retainedWorkspaces.push(paths.root); + continue; + } + + const metadata = this.readMetadata(paths.projectFingerprint); + const lastActiveMs = metadata + ? Date.parse(metadata.lastActiveAt) + : workspaceMtime(paths.root); + const sizeBytes = this.workspaceSizeBytes(paths); + candidates.push({ paths, lastActiveMs, sizeBytes }); + } + + for (const candidate of candidates) { + if (this.isInactiveWorkspace(candidate.lastActiveMs)) { + rmSync(candidate.paths.root, { recursive: true, force: true }); + deletedWorkspaces.push(candidate.paths.root); + } + } + + const remaining = candidates + .filter((candidate) => !deletedWorkspaces.includes(candidate.paths.root)) + .sort((first, second) => first.lastActiveMs - second.lastActiveMs); + let totalBytes = remaining.reduce((sum, candidate) => sum + candidate.sizeBytes, 0); + for (const candidate of remaining) { + if (totalBytes <= this.softDiskCapBytes) { + retainedWorkspaces.push(candidate.paths.root); + continue; + } + rmSync(candidate.paths.root, { recursive: true, force: true }); + deletedWorkspaces.push(candidate.paths.root); + totalBytes -= candidate.sizeBytes; + } + + return { expiredLeases, deletedWorkspaces, retainedWorkspaces }; + } + + private readMetadata(projectFingerprint: string): ProjectBindingWorkspaceMetadata | undefined { + const path = this.paths(projectFingerprint).metadata; + if (!existsSync(path)) return undefined; + return JSON.parse(readFileSync(path, "utf8")) as ProjectBindingWorkspaceMetadata; + } + + private workspacePaths(): ProjectBindingWorkspacePaths[] { + if (!existsSync(this.root)) return []; + return readdirSync(this.root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => this.paths(entry.name)); + } + + private leasesFor( + paths: ProjectBindingWorkspacePaths, + ): { path: string; lease: ProjectBindingLease }[] { + if (!existsSync(paths.leases)) return []; + return readdirSync(paths.leases, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => { + const path = join(paths.leases, entry.name); + return { path, lease: JSON.parse(readFileSync(path, "utf8")) as ProjectBindingLease }; + }); + } + + private isStaleLease(lease: ProjectBindingLease): boolean { + const parsedExpiresAt = lease.expiresAt ? Date.parse(lease.expiresAt) : Number.NaN; + const staleAt = Number.isFinite(parsedExpiresAt) + ? parsedExpiresAt + : Date.parse(lease.updatedAt) + this.staleLeaseTtlMs; + return staleAt <= this.now().getTime(); + } + + private isInactiveWorkspace(lastActiveMs: number): boolean { + return lastActiveMs + this.inactiveWorkspaceTtlMs <= this.now().getTime(); + } +} + +type WorkspaceCandidate = { + paths: ProjectBindingWorkspacePaths; + lastActiveMs: number; + sizeBytes: number; +}; + +function writeJson(path: string, value: unknown): void { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); +} + +function directorySize(path: string): number { + if (!existsSync(path)) return 0; + const stat = statSync(path); + if (stat.isFile()) return stat.size; + if (!stat.isDirectory()) return 0; + return readdirSync(path).reduce((sum, entry) => sum + directorySize(join(path, entry)), 0); +} + +function workspaceMtime(path: string): number { + return existsSync(path) ? statSync(path).mtimeMs : 0; +} + +function pathJoinFor(platform = process.platform): typeof join { + return platform === "win32" ? win32.join : join; +} + +function assertPathSegment(value: string, label: string): void { + if ( + !value || + value.includes("/") || + value.includes("\\") || + value.includes("\0") || + value === "." || + value === ".." + ) { + throw new Error(`Invalid ${label}: ${value}`); + } +} diff --git a/packages/core/src/remote-control/client.ts b/packages/core/src/remote-control/client.ts index 1729663..7e3398e 100644 --- a/packages/core/src/remote-control/client.ts +++ b/packages/core/src/remote-control/client.ts @@ -41,7 +41,7 @@ export class RemoteControlClient { if (response.status === 401 || response.status === 403) { throw new CapletsError( "AUTH_FAILED", - "Caplets server authentication failed. Check CAPLETS_SERVER_USER and CAPLETS_SERVER_PASSWORD.", + "Caplets remote authentication failed. Check CAPLETS_REMOTE_USER and CAPLETS_REMOTE_PASSWORD.", ); } diff --git a/packages/core/src/remote/options.ts b/packages/core/src/remote/options.ts new file mode 100644 index 0000000..c179e1e --- /dev/null +++ b/packages/core/src/remote/options.ts @@ -0,0 +1,166 @@ +import { Buffer } from "node:buffer"; +import { CapletsError } from "../errors"; +import { appendBasePath, parseServerBaseUrl } from "../server/options"; + +export type CapletsRemoteEnv = Partial< + Record< + | "CAPLETS_MODE" + | "CAPLETS_REMOTE_URL" + | "CAPLETS_REMOTE_USER" + | "CAPLETS_REMOTE_PASSWORD" + | "CAPLETS_REMOTE_TOKEN" + | "CAPLETS_REMOTE_WORKSPACE" + | "CAPLETS_SERVER_URL", + string + > +>; + +export type CapletsRemoteModeInput = { + mode?: string; + remoteUrl?: string; +}; + +export type CapletsRemoteInput = { + url?: string; + user?: string; + password?: string; + token?: string; + workspace?: string; + fetch?: typeof fetch; +}; + +export type CapletsRemoteAuth = + | { type: "none"; user: string } + | { type: "basic"; user: string; password: string } + | { type: "bearer"; token: string }; + +export type ResolvedCapletsRemote = { + baseUrl: URL; + mcpUrl: URL; + controlUrl: URL; + healthUrl: URL; + projectBindingWebSocketUrl: URL; + auth: CapletsRemoteAuth; + requestInit: RequestInit; + workspace?: string | undefined; + fetch?: typeof fetch; +}; + +const DEFAULT_REMOTE_USER = "caplets"; + +export function resolveRemoteMode( + input: CapletsRemoteModeInput = {}, + env: CapletsRemoteEnv = process.env, +): { mode: "local" } | { mode: "remote" } { + const mode = parseCapletsMode(input.mode ?? env.CAPLETS_MODE ?? "auto"); + if (mode === "local") return { mode: "local" }; + + const rawUrl = + nonEmpty(input.remoteUrl, "remoteUrl") ?? + nonEmpty(env.CAPLETS_REMOTE_URL, "CAPLETS_REMOTE_URL"); + if (mode === "remote") { + if (rawUrl === undefined) { + throw new CapletsError( + "REQUEST_INVALID", + "CAPLETS_MODE=remote requires CAPLETS_REMOTE_URL or remoteUrl.", + ); + } + return { mode: "remote" }; + } + + return rawUrl === undefined ? { mode: "local" } : { mode: "remote" }; +} + +export function resolveCapletsRemote( + input: CapletsRemoteInput = {}, + env: CapletsRemoteEnv = process.env, +): ResolvedCapletsRemote { + const rawUrl = + nonEmpty(input.url, "url") ?? nonEmpty(env.CAPLETS_REMOTE_URL, "CAPLETS_REMOTE_URL"); + if (rawUrl === undefined) { + throw new CapletsError("REQUEST_INVALID", "CAPLETS_REMOTE_URL or url is required."); + } + + const baseUrl = parseServerBaseUrl(rawUrl); + const token = + nonEmpty(input.token, "token") ?? nonEmpty(env.CAPLETS_REMOTE_TOKEN, "CAPLETS_REMOTE_TOKEN"); + const userWasExplicit = input.user !== undefined || hasEnv(env.CAPLETS_REMOTE_USER); + const user = + nonEmpty(input.user, "user") ?? + nonEmpty(env.CAPLETS_REMOTE_USER, "CAPLETS_REMOTE_USER") ?? + DEFAULT_REMOTE_USER; + const password = + nonEmpty(input.password, "password") ?? + nonEmpty(env.CAPLETS_REMOTE_PASSWORD, "CAPLETS_REMOTE_PASSWORD"); + const workspace = + nonEmpty(input.workspace, "workspace") ?? + nonEmpty(env.CAPLETS_REMOTE_WORKSPACE, "CAPLETS_REMOTE_WORKSPACE"); + + if (token && password) { + throw new CapletsError( + "REQUEST_INVALID", + "Use either CAPLETS_REMOTE_TOKEN or CAPLETS_REMOTE_PASSWORD, not both.", + ); + } + + if (!token && userWasExplicit && password === undefined) { + throw new CapletsError( + "REQUEST_INVALID", + "Remote Caplets Basic Auth requires a password; set CAPLETS_REMOTE_PASSWORD or password.", + ); + } + + const auth: CapletsRemoteAuth = token + ? { type: "bearer", token } + : password === undefined + ? { type: "none", user } + : { type: "basic", user, password }; + const requestInit: RequestInit = + auth.type === "bearer" + ? { headers: { Authorization: `Bearer ${auth.token}` } } + : auth.type === "basic" + ? { headers: { Authorization: basicAuthHeader(auth.user, auth.password) } } + : {}; + + return { + baseUrl, + mcpUrl: appendBasePath(baseUrl, "mcp"), + controlUrl: appendBasePath(baseUrl, "control"), + healthUrl: appendBasePath(baseUrl, "healthz"), + projectBindingWebSocketUrl: projectBindingWebSocketUrlForBase(baseUrl), + auth, + requestInit, + ...(workspace ? { workspace } : {}), + ...(input.fetch ? { fetch: input.fetch } : {}), + }; +} + +export function projectBindingWebSocketUrlForBase(baseUrl: URL): URL { + const url = appendBasePath(baseUrl, "control/project-bindings/connect"); + if (url.protocol === "https:") url.protocol = "wss:"; + if (url.protocol === "http:") url.protocol = "ws:"; + return url; +} + +function parseCapletsMode(value: string): "auto" | "local" | "remote" { + if (value === "auto" || value === "local" || value === "remote") return value; + throw new CapletsError( + "REQUEST_INVALID", + `Expected CAPLETS_MODE to be auto, local, or remote, got ${value}`, + ); +} + +function basicAuthHeader(user: string, password: string): string { + return `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`; +} + +function nonEmpty(value: string | undefined, label: string): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + if (!trimmed) throw new CapletsError("REQUEST_INVALID", `${label} must not be empty`); + return trimmed; +} + +function hasEnv(value: string | undefined): boolean { + return value !== undefined && value.trim() !== ""; +} diff --git a/packages/core/src/runtime-plan/features.ts b/packages/core/src/runtime-plan/features.ts new file mode 100644 index 0000000..dc90dad --- /dev/null +++ b/packages/core/src/runtime-plan/features.ts @@ -0,0 +1,139 @@ +import type { CapletConfig, RuntimeFeature } from "../config-runtime"; +import type { RuntimeFeatureProvenance } from "./types"; + +export type RuntimeFeatureInference = { + features: RuntimeFeature[]; + provenance: RuntimeFeatureProvenance[]; +}; + +type CommandSource = RuntimeFeatureProvenance["source"]; + +type CommandRecord = { + source: CommandSource; + command: string; + args: string[]; +}; + +export function inferRuntimeFeatures( + caplet: CapletConfig | Record, +): RuntimeFeatureInference { + const provenance: RuntimeFeatureProvenance[] = []; + for (const feature of explicitFeatures(caplet)) { + provenance.push({ feature, source: "explicit", matched: "runtime.features" }); + } + for (const command of commandRecords(caplet)) { + const text = [command.command, ...command.args].join(" "); + const dockerMatch = matchDocker(command.command, command.args); + if (dockerMatch) { + provenance.push({ + feature: "docker", + source: command.source, + matched: dockerMatch, + command: text, + }); + } + const browserMatch = matchBrowser(command.command, command.args); + if (browserMatch) { + provenance.push({ + feature: "browser", + source: command.source, + matched: browserMatch, + command: text, + }); + } + } + return { + features: orderedFeatures([...new Set(provenance.map((entry) => entry.feature))]), + provenance, + }; +} + +function explicitFeatures(caplet: Record): RuntimeFeature[] { + const runtime = caplet.runtime; + if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) return []; + const features = (runtime as { features?: unknown }).features; + return Array.isArray(features) + ? features.filter( + (feature): feature is RuntimeFeature => feature === "docker" || feature === "browser", + ) + : []; +} + +function commandRecords(caplet: Record): CommandRecord[] { + return [ + ...setupCommands(caplet.setup, "setup.commands"), + ...setupCommands(caplet.setup, "setup.verify", true), + ...mcpCommands(caplet), + ...cliCommands(caplet), + ]; +} + +function setupCommands(setup: unknown, source: CommandSource, verify = false): CommandRecord[] { + if (!setup || typeof setup !== "object" || Array.isArray(setup)) return []; + const values = (setup as { commands?: unknown; verify?: unknown })[ + verify ? "verify" : "commands" + ]; + if (!Array.isArray(values)) return []; + return values.flatMap((value) => commandRecordFrom(value, source)); +} + +function mcpCommands(caplet: Record): CommandRecord[] { + if (caplet.backend !== "mcp" || typeof caplet.command !== "string") return []; + return [{ source: "mcp.command", command: caplet.command, args: stringArray(caplet.args) }]; +} + +function cliCommands(caplet: Record): CommandRecord[] { + if (caplet.backend !== "cli") return []; + const records: CommandRecord[] = []; + if (typeof caplet.command === "string") { + records.push({ + source: "cli.command", + command: caplet.command, + args: stringArray(caplet.args), + }); + } + const actions = caplet.actions; + if (actions && typeof actions === "object" && !Array.isArray(actions)) { + for (const action of Object.values(actions)) { + records.push(...commandRecordFrom(action, "cli.action")); + } + } + return records; +} + +function commandRecordFrom(value: unknown, source: CommandSource): CommandRecord[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const command = (value as { command?: unknown }).command; + if (typeof command !== "string") return []; + return [{ source, command, args: stringArray((value as { args?: unknown }).args) }]; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function matchDocker(command: string, args: string[]): string | undefined { + const text = [command, ...args].join(" ").toLowerCase(); + if (text.includes("docker-mcp")) return "docker-mcp"; + if (/\bdocker(?:-compose)?\b/u.test(text)) return command === "docker" ? command : text; + return text.includes("docker mcp") ? "docker mcp" : undefined; +} + +function matchBrowser(command: string, args: string[]): string | undefined { + const text = [command, ...args].join(" ").toLowerCase(); + if (text.includes("@playwright/mcp")) return "@playwright/mcp"; + if (text.includes("playwright install")) return "playwright install"; + if (text.includes("playwright")) return "playwright"; + if (text.includes("browser-use")) return "browser-use"; + if (text.includes("puppeteer")) return "puppeteer"; + if (text.includes("chromium")) return "chromium"; + return /\bchrome\b/u.test(text) ? "chrome" : undefined; +} + +function orderedFeatures(features: RuntimeFeature[]): RuntimeFeature[] { + return ["docker", "browser"].filter((feature): feature is RuntimeFeature => + features.includes(feature as RuntimeFeature), + ); +} diff --git a/packages/core/src/runtime-plan/index.ts b/packages/core/src/runtime-plan/index.ts new file mode 100644 index 0000000..f1af219 --- /dev/null +++ b/packages/core/src/runtime-plan/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./features"; +export * from "./resources"; +export * from "./planner"; diff --git a/packages/core/src/runtime-plan/planner.ts b/packages/core/src/runtime-plan/planner.ts new file mode 100644 index 0000000..5751548 --- /dev/null +++ b/packages/core/src/runtime-plan/planner.ts @@ -0,0 +1,113 @@ +import type { CapletConfig } from "../config-runtime"; +import type { + CapletRuntimePlan, + RuntimePlanOptions, + RuntimeRouteKind, + SetupTargetKind, +} from "./types"; +import { inferRuntimeFeatures } from "./features"; +import { resolveRuntimeResources } from "./resources"; + +export function planCapletRuntimeRoutes( + caplets: Array>, + options: RuntimePlanOptions = {}, +): CapletRuntimePlan[] { + return caplets.map((caplet) => planSingleCaplet(caplet, options)); +} + +export function planCapletRuntimeRoute( + caplet: CapletConfig | Record, + options: RuntimePlanOptions = {}, +): CapletRuntimePlan { + return planCapletRuntimeRoutes([caplet], options)[0] ?? planSingleCaplet(caplet, options); +} + +function planSingleCaplet( + caplet: CapletConfig | Record, + options: RuntimePlanOptions, +): CapletRuntimePlan { + const route = classifyCapletRuntimeRoute(caplet); + const setupRequired = Boolean(caplet.setup); + const projectBindingRequired = projectBindingRequiredFor(caplet); + const features = inferRuntimeFeatures(caplet); + const runtime = { + features: features.features, + featureProvenance: features.provenance, + resources: resolveRuntimeResources({ + backend: typeof caplet.backend === "string" ? caplet.backend : undefined, + features: features.features, + explicitClass: explicitResourceClass(caplet), + policy: options.resourcePolicy, + setupRequired, + }), + }; + return { + id: String(caplet.server ?? ""), + backend: typeof caplet.backend === "string" ? caplet.backend : "unknown", + route, + setupRequired, + authRequired: authRequired("auth" in caplet ? caplet.auth : undefined), + ...(route === "process" || route === "project_bound_process" + ? { setupTarget: setupTargetFor(options.deployment) } + : {}), + projectBindingRequired, + runtime, + caplet, + }; +} + +export function classifyCapletRuntimeRoute(caplet: Record): RuntimeRouteKind { + if (projectBindingRequiredFor(caplet)) { + return "project_bound_process"; + } + if (caplet.setup) { + return "process"; + } + if (caplet.backend === "cli") { + return "process"; + } + if (caplet.backend === "mcp") { + return caplet.transport === "stdio" || Boolean(caplet.command) ? "process" : "worker_safe"; + } + if (caplet.backend === "openapi" || caplet.backend === "graphql" || caplet.backend === "http") { + return "worker_safe"; + } + if (caplet.backend === "caplets") { + return "worker_safe"; + } + return "local_only"; +} + +function setupTargetFor(deployment: RuntimePlanOptions["deployment"]): SetupTargetKind { + if (deployment === "local") return "local_host"; + return deployment === "self_hosted" ? "remote_host" : "hosted_sandbox"; +} + +function authRequired(auth: unknown): boolean { + return auth !== null && typeof auth === "object" && "type" in auth && auth.type !== "none"; +} + +function projectBindingRequiredFor(caplet: Record): boolean { + const projectBinding = caplet.projectBinding; + return ( + Boolean(projectBinding) && + typeof projectBinding === "object" && + !Array.isArray(projectBinding) && + (projectBinding as { required?: unknown }).required === true + ); +} + +function explicitResourceClass(caplet: Record) { + const runtime = caplet.runtime; + if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) return undefined; + const resources = (runtime as { resources?: unknown }).resources; + if (!resources || typeof resources !== "object" || Array.isArray(resources)) return undefined; + const value = (resources as { class?: unknown }).class; + return value === "small" || + value === "medium" || + value === "standard" || + value === "large" || + value === "heavy" + ? value + : undefined; +} diff --git a/packages/core/src/runtime-plan/resources.ts b/packages/core/src/runtime-plan/resources.ts new file mode 100644 index 0000000..7a51fc8 --- /dev/null +++ b/packages/core/src/runtime-plan/resources.ts @@ -0,0 +1,88 @@ +import type { + HostedRuntimeResourceClass, + RuntimeResourcePolicy, + RuntimeResourceResolution, +} from "./types"; + +const defaults: Record = { + small: { class: "small", cpu: 1, memoryMb: 1024, diskMb: 4096 }, + medium: { class: "medium", cpu: 2, memoryMb: 4096, diskMb: 8192 }, + standard: { class: "standard", cpu: 2, memoryMb: 4096, diskMb: 8192 }, + large: { class: "large", cpu: 4, memoryMb: 8192, diskMb: 20480 }, + heavy: { class: "heavy", cpu: 8, memoryMb: 16384, diskMb: 40960 }, +}; + +const rank: Record = { + small: 0, + standard: 1, + medium: 1, + large: 2, + heavy: 3, +}; + +type ResourceInput = { + backend?: string | undefined; + features: string[]; + explicitClass?: HostedRuntimeResourceClass | undefined; + setupRequired?: boolean | undefined; + policy?: RuntimeResourcePolicy | undefined; +}; + +export function resolveRuntimeResources(input: ResourceInput): RuntimeResourceResolution; +export function resolveRuntimeResources( + caplet: Record, + features: string[], + policy?: RuntimeResourcePolicy | undefined, +): RuntimeResourceResolution; +export function resolveRuntimeResources( + inputOrCaplet: ResourceInput | Record, + features?: string[], + policy?: RuntimeResourcePolicy | undefined, +): RuntimeResourceResolution { + const caplet = inputOrCaplet as Record; + const input = + features === undefined + ? (inputOrCaplet as ResourceInput) + : { + backend: typeof caplet.backend === "string" ? caplet.backend : undefined, + features, + explicitClass: explicitResourceClass(caplet), + setupRequired: Boolean(caplet.setup), + policy, + }; + const requested = input.explicitClass ?? defaultResourceClass(input); + const capped = capClass(requested, input.policy?.maxClass); + const resolved = defaults[capped] ?? defaults.standard!; + return { + ...resolved, + ...(requested !== capped ? { cappedByPolicy: input.policy?.maxClass } : {}), + }; +} + +function defaultResourceClass(input: ResourceInput): HostedRuntimeResourceClass { + const hasDocker = input.features.includes("docker"); + const hasBrowser = input.features.includes("browser"); + if (hasDocker && hasBrowser) return "heavy"; + if (hasDocker || hasBrowser) return "large"; + if (input.backend === "cli" || input.backend === "mcp" || input.setupRequired) return "medium"; + return "small"; +} + +function explicitResourceClass( + caplet: Record, +): HostedRuntimeResourceClass | undefined { + const runtime = caplet.runtime; + if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) return undefined; + const resources = (runtime as { resources?: unknown }).resources; + if (!resources || typeof resources !== "object" || Array.isArray(resources)) return undefined; + const value = (resources as { class?: unknown }).class; + return typeof value === "string" ? (value as HostedRuntimeResourceClass) : undefined; +} + +function capClass( + requested: HostedRuntimeResourceClass, + maxClass: HostedRuntimeResourceClass | undefined, +) { + if (!maxClass) return requested; + return (rank[requested] ?? 0) > (rank[maxClass] ?? 0) ? maxClass : requested; +} diff --git a/packages/core/src/runtime-plan/types.ts b/packages/core/src/runtime-plan/types.ts new file mode 100644 index 0000000..5869b84 --- /dev/null +++ b/packages/core/src/runtime-plan/types.ts @@ -0,0 +1,158 @@ +import type { CapletConfig, RuntimeFeature, RuntimeResourceClass } from "../config-runtime"; + +export type HostedRuntimeResourceClass = RuntimeResourceClass | "small" | "medium"; + +export type { RuntimeFeature, RuntimeResourceClass }; + +export type RuntimeRouteKind = "worker_safe" | "process" | "project_bound_process" | "local_only"; +export type SetupTargetKind = "local_host" | "remote_host" | "hosted_sandbox"; +export type HostedSetupState = + | "not_required" + | "approval_required" + | "approved" + | "queued" + | "running" + | "verifying" + | "ready" + | "failed" + | "expired"; + +export type HostedBackendCheckState = + | "not_run" + | "queued" + | "running" + | "passed" + | "failed" + | "stale"; + +export type HostedSandboxState = + | "not_started" + | "preparing" + | "uploading_bundle" + | "running_setup" + | "starting_adapter" + | "ready" + | "busy" + | "degraded" + | "stopping" + | "stopped" + | "failed"; + +export const HIDDEN_REASON_CODES = [ + "setup_required", + "setup_running", + "setup_failed", + "verify_failed", + "backend_auth_required", + "backend_check_failed", + "project_binding_required", + "project_binding_syncing", + "project_binding_blocked", + "project_binding_stale", + "provider_unavailable", + "provider_capacity_exhausted", + "provider_queue_timeout", + "policy_denied", + "billing_required", + "subscription_past_due", + "usage_limit_reached", + "email_verification_required", + "docker_required", + "docker_denied", + "browser_required", + "browser_denied", + "resource_class_denied", + "local_only", + "invalid_bundle", + "unsupported_backend", +] as const; + +export type HiddenReasonCode = (typeof HIDDEN_REASON_CODES)[number]; + +export type RuntimePlanDeployment = "hosted" | "self_hosted" | "local"; + +export type RuntimePlanOptions = { + deployment?: RuntimePlanDeployment | undefined; + resourcePolicy?: RuntimeResourcePolicy | undefined; +}; + +export type RuntimeFeatureProvenanceSource = + | "explicit" + | "setup.commands" + | "setup.verify" + | "mcp.command" + | "cli.command" + | "cli.action"; + +export type RuntimeFeatureProvenance = { + feature: RuntimeFeature; + source: RuntimeFeatureProvenanceSource; + matched: string; + command?: string | undefined; +}; + +export type RuntimeResourcePolicy = { + maxClass?: HostedRuntimeResourceClass | undefined; +}; + +export type RuntimeResourceResolution = { + class: HostedRuntimeResourceClass; + cpu: number; + memoryMb: number; + diskMb: number; + cappedByPolicy?: HostedRuntimeResourceClass | undefined; +}; + +export type RuntimeRequirementsResolution = { + features: RuntimeFeature[]; + featureProvenance: RuntimeFeatureProvenance[]; + resources: RuntimeResourceResolution; +}; + +export type CapletRuntimePlan = { + id: string; + backend: CapletConfig["backend"] | string; + route: RuntimeRouteKind; + setupTarget?: SetupTargetKind | undefined; + setupRequired: boolean; + authRequired: boolean; + projectBindingRequired: boolean; + runtime: RuntimeRequirementsResolution; + caplet: CapletConfig | Record; +}; + +export type HostedRoutePlan = { + workspaceId: string; + capletId: string; + contentHash: string; + bundleRevision: string; + route: RuntimeRouteKind; + backend: string; + runtimeFeatures: string[]; + resourceClass: HostedRuntimeResourceClass; + setupState: HostedSetupState; + checkState: HostedBackendCheckState; + projectBindingRequired: boolean; + projectFingerprint?: string | undefined; + hiddenReasons: HiddenReasonCode[]; + primaryHiddenReason?: HiddenReasonCode | undefined; + policyDecision: "allowed" | "denied" | "not_evaluated"; + provenanceSource: "bundle_validation" | "install" | "runtime_refresh" | "call"; + updatedAt: string; +}; + +export type HostedCallProvenance = { + requestId: string; + workspaceId: string; + capletId: string; + contentHash: string; + route: RuntimeRouteKind; + backend: string; + provider?: "daytona" | undefined; + sandboxId?: string | undefined; + snapshotId?: string | undefined; + runtimeFeatures: string[]; + projectFingerprint?: string | undefined; + usageEventIds: string[]; + auditEventId?: string | undefined; +}; diff --git a/packages/core/src/serve/daemon/config.ts b/packages/core/src/serve/daemon/config.ts new file mode 100644 index 0000000..cba40c7 --- /dev/null +++ b/packages/core/src/serve/daemon/config.ts @@ -0,0 +1,94 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import type { + DaemonCommandPlan, + ServeDaemonConfig, + ServeDaemonPaths, + ServeDaemonState, + ServeDaemonStatus, +} from "./types"; + +export function readDaemonConfig(paths: ServeDaemonPaths): ServeDaemonConfig | undefined { + return readJson(paths.configFile); +} + +export function writeDaemonConfig( + paths: ServeDaemonPaths, + serve: ServeDaemonConfig["serve"], + command: DaemonCommandPlan, + now = new Date(), +): ServeDaemonConfig { + const config: ServeDaemonConfig = { + instance: "default", + serve, + command, + paths, + updatedAt: now.toISOString(), + }; + writeJson(paths.configFile, config); + return config; +} + +export function readDaemonState(paths: ServeDaemonPaths): ServeDaemonState | undefined { + return readJson(paths.stateFile); +} + +export function writeDaemonState( + paths: ServeDaemonPaths, + state: Omit & { updatedAt?: string }, + now = new Date(), +): ServeDaemonState { + const next: ServeDaemonState = { + instance: "default", + ...state, + updatedAt: state.updatedAt ?? now.toISOString(), + }; + writeJson(paths.stateFile, next); + return next; +} + +export function redactDaemonStatus(status: ServeDaemonStatus): ServeDaemonStatus { + return redactDaemonValue(status) as ServeDaemonStatus; +} + +function redactDaemonValue(value: unknown): unknown { + if (Array.isArray(value)) return redactDaemonArray(value); + if (!value || typeof value !== "object") return value; + const redacted: Record = {}; + for (const [key, nested] of Object.entries(value)) { + redacted[key] = /password|token|secret|authorization|credential/iu.test(key) + ? "[REDACTED]" + : redactDaemonValue(nested); + } + return redacted; +} + +function redactDaemonArray(value: unknown[]): unknown[] { + const redacted: unknown[] = []; + let redactNext = false; + for (const item of value) { + if (redactNext) { + redacted.push("[REDACTED]"); + redactNext = false; + continue; + } + redacted.push(redactDaemonValue(item)); + if ( + typeof item === "string" && + /--(?:password|token|secret|authorization|credential)$/iu.test(item) + ) { + redactNext = true; + } + } + return redacted; +} + +function readJson(path: string): T | undefined { + if (!existsSync(path)) return undefined; + return JSON.parse(readFileSync(path, "utf8")) as T; +} + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} diff --git a/packages/core/src/serve/daemon/index.ts b/packages/core/src/serve/daemon/index.ts new file mode 100644 index 0000000..6dcc459 --- /dev/null +++ b/packages/core/src/serve/daemon/index.ts @@ -0,0 +1,181 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { CapletsError } from "../../errors"; +import { resolveDaemonServeOptions, type RawServeOptions } from "../options"; +import { + readDaemonConfig, + readDaemonState, + redactDaemonStatus, + writeDaemonConfig, + writeDaemonState, +} from "./config"; +import { buildDaemonPlatformDescriptor } from "./platform"; +import { createNodeDaemonProcessRunner, daemonServeCommand } from "./process"; +import { resolveServeDaemonPaths } from "./paths"; +import type { + DaemonPlatformDescriptor, + ServeDaemonOperationOptions, + ServeDaemonOperationResult, + ServeDaemonStatus, +} from "./types"; + +export type ServeDaemonServiceResult = { + enabled: boolean; + descriptor: DaemonPlatformDescriptor; + status: ServeDaemonStatus; +}; + +export async function startDaemon( + raw: RawServeOptions = {}, + options: ServeDaemonOperationOptions = {}, +): Promise { + const paths = resolveServeDaemonPaths(options); + const processRunner = options.process ?? createNodeDaemonProcessRunner(); + const existing = await daemonStatus({ ...options, process: processRunner }); + if (existing.running) { + throw new CapletsError("REQUEST_INVALID", "Caplets HTTP daemon is already running."); + } + + const serve = resolveDaemonServeOptions(raw, options.env ?? process.env); + const command = daemonServeCommand(serve); + mkdirSync(paths.logDir, { recursive: true }); + const config = writeDaemonConfig(paths, serve, command); + const pid = await processRunner.start({ + args: command.args, + stdoutLog: paths.stdoutLog, + stderrLog: paths.stderrLog, + configFile: paths.configFile, + }); + writeFileSync(paths.pidFile, `${pid}\n`); + const now = new Date().toISOString(); + const state = writeDaemonState(paths, { + running: true, + pid, + startedAt: now, + enabled: existing.enabled, + updatedAt: now, + }); + + return { + status: redactDaemonStatus({ ...state, paths, config }), + }; +} + +export async function stopDaemon( + options: ServeDaemonOperationOptions = {}, +): Promise { + const paths = resolveServeDaemonPaths(options); + const processRunner = options.process ?? createNodeDaemonProcessRunner(); + const existing = await daemonStatus({ ...options, process: processRunner }); + if (existing.running && existing.pid !== undefined) { + await processRunner.stop(existing.pid); + } + rmSync(paths.pidFile, { force: true }); + const state = writeDaemonState(paths, { + running: false, + enabled: existing.enabled, + }); + return { + status: redactDaemonStatus({ + ...state, + paths, + ...configProperty(readDaemonConfig(paths)), + }), + }; +} + +export async function restartDaemon( + raw: RawServeOptions = {}, + options: ServeDaemonOperationOptions = {}, +): Promise { + await stopDaemon(options); + return startDaemon(raw, options); +} + +export async function daemonStatus( + options: ServeDaemonOperationOptions = {}, +): Promise { + const paths = resolveServeDaemonPaths(options); + const processRunner = options.process ?? createNodeDaemonProcessRunner(); + const config = readDaemonConfig(paths); + const storedState = readDaemonState(paths); + const pid = readPid(paths.pidFile) ?? storedState?.pid; + const running = pid === undefined ? false : await processRunner.isRunning(pid); + if (!running) { + rmSync(paths.pidFile, { force: true }); + } + const state = writeDaemonState(paths, { + running, + ...(running && pid !== undefined ? { pid } : {}), + ...(running && storedState?.startedAt ? { startedAt: storedState.startedAt } : {}), + enabled: storedState?.enabled ?? false, + }); + return redactDaemonStatus({ ...state, paths, ...(config ? { config } : {}) }); +} + +export async function enableDaemon( + options: ServeDaemonOperationOptions = {}, +): Promise { + return setDaemonEnabled(true, options); +} + +export async function disableDaemon( + options: ServeDaemonOperationOptions = {}, +): Promise { + return setDaemonEnabled(false, options); +} + +async function setDaemonEnabled( + enabled: boolean, + options: ServeDaemonOperationOptions, +): Promise { + const paths = resolveServeDaemonPaths(options); + const config = readDaemonConfig(paths); + const command = config?.command ?? daemonServeCommand(resolveDaemonServeOptions({}, options.env)); + const descriptor = buildDaemonPlatformDescriptor({ + ...(options.platform !== undefined ? { platform: options.platform } : {}), + ...(options.serviceAvailable !== undefined + ? { serviceAvailable: options.serviceAvailable } + : {}), + paths, + command, + }); + const current = await daemonStatus(options); + const state = writeDaemonState(paths, { + running: current.running, + ...(current.running && current.pid !== undefined ? { pid: current.pid } : {}), + ...(current.running && current.startedAt ? { startedAt: current.startedAt } : {}), + enabled, + }); + return { + enabled, + descriptor, + status: redactDaemonStatus({ ...state, paths, ...configProperty(config) }), + }; +} + +function configProperty(config: ReturnType): { + config?: NonNullable; +} { + return config ? { config } : {}; +} + +function readPid(path: string): number | undefined { + try { + const value = Number(readFileSync(path, "utf8").trim()); + return Number.isInteger(value) && value > 0 ? value : undefined; + } catch { + return undefined; + } +} + +export { buildDaemonPlatformDescriptor } from "./platform"; +export { resolveServeDaemonPaths } from "./paths"; +export type { + DaemonPlatformDescriptor, + DaemonProcessRunner, + ServeDaemonConfig, + ServeDaemonOperationOptions, + ServeDaemonPaths, + ServeDaemonState, + ServeDaemonStatus, +} from "./types"; diff --git a/packages/core/src/serve/daemon/paths.ts b/packages/core/src/serve/daemon/paths.ts new file mode 100644 index 0000000..a36ceda --- /dev/null +++ b/packages/core/src/serve/daemon/paths.ts @@ -0,0 +1,66 @@ +import { homedir } from "node:os"; +import { dirname, posix, win32 } from "node:path"; +import { defaultConfigBaseDir, defaultStateBaseDir } from "../../config/paths"; +import type { ServeDaemonOperationOptions, ServeDaemonPaths } from "./types"; + +export function resolveServeDaemonPaths( + options: Pick = {}, +): ServeDaemonPaths { + const platform = options.platform ?? process.platform; + const home = options.home ?? homedir(); + const env = options.env ?? process.env; + const path = platform === "win32" ? win32 : posix; + const configBase = defaultConfigBaseDir(env as NodeJS.ProcessEnv, home, platform); + const stateBase = defaultStateBaseDir(env as NodeJS.ProcessEnv, home, platform); + + if (platform === "win32") { + const stateDir = path.join(stateBase, "Caplets", "State", "serve", "default"); + const logDir = path.join(stateDir, "logs"); + return { + instance: "default", + stateDir, + logDir, + stateFile: path.join(stateDir, "state.json"), + pidFile: path.join(stateDir, "server.pid"), + stdoutLog: path.join(logDir, "stdout.log"), + stderrLog: path.join(logDir, "stderr.log"), + configFile: path.join(configBase, "Caplets", "serve", "default.json"), + }; + } + + const stateDir = path.join(stateBase, "caplets", "serve", "default"); + const logDir = path.join(stateDir, "logs"); + return { + instance: "default", + stateDir, + logDir, + stateFile: path.join(stateDir, "state.json"), + pidFile: path.join(stateDir, "server.pid"), + stdoutLog: path.join(logDir, "stdout.log"), + stderrLog: path.join(logDir, "stderr.log"), + configFile: path.join(configBase, "caplets", "serve", "default.json"), + }; +} + +export function daemonServiceDescriptorPath( + paths: ServeDaemonPaths, + platform: NodeJS.Platform, +): string { + const path = platform === "win32" ? win32 : posix; + if (platform === "darwin") { + return path.join( + dirname(dirname(paths.configFile)), + "launchd", + "dev.caplets.serve.default.plist", + ); + } + if (platform === "linux") { + return path.join( + dirname(dirname(paths.configFile)), + "systemd", + "user", + "caplets-serve-default.service", + ); + } + return paths.configFile; +} diff --git a/packages/core/src/serve/daemon/platform-darwin.ts b/packages/core/src/serve/daemon/platform-darwin.ts new file mode 100644 index 0000000..1df79f3 --- /dev/null +++ b/packages/core/src/serve/daemon/platform-darwin.ts @@ -0,0 +1,38 @@ +import { escapeXml } from "./platform"; +import type { DaemonCommandPlan, LaunchdUserAgentDescriptor, ServeDaemonPaths } from "./types"; + +export function buildLaunchdUserAgentDescriptor( + paths: ServeDaemonPaths, + command: DaemonCommandPlan, +): LaunchdUserAgentDescriptor { + const label = "dev.caplets.serve.default"; + return { + kind: "launchd-user-agent", + label, + path: `${paths.configFile}.plist`, + plist: [ + '', + '', + '', + "", + " Label", + ` ${label}`, + " ProgramArguments", + " ", + ` ${escapeXml(command.executable)}`, + ...command.args.map((arg) => ` ${escapeXml(arg)}`), + " ", + " RunAtLoad", + " ", + " KeepAlive", + " ", + " StandardOutPath", + ` ${escapeXml(paths.stdoutLog)}`, + " StandardErrorPath", + ` ${escapeXml(paths.stderrLog)}`, + "", + "", + "", + ].join("\n"), + }; +} diff --git a/packages/core/src/serve/daemon/platform-linux.ts b/packages/core/src/serve/daemon/platform-linux.ts new file mode 100644 index 0000000..4ea6d13 --- /dev/null +++ b/packages/core/src/serve/daemon/platform-linux.ts @@ -0,0 +1,48 @@ +import type { + DaemonCommandPlan, + ManualServiceDescriptor, + ServeDaemonPaths, + SystemdUserServiceDescriptor, +} from "./types"; + +export function buildLinuxServiceDescriptor( + paths: ServeDaemonPaths, + command: DaemonCommandPlan, + serviceAvailable = true, +): SystemdUserServiceDescriptor | ManualServiceDescriptor { + if (!serviceAvailable) { + return { + kind: "manual", + reason: "Linux systemd user service is not available; run the daemon command manually.", + command, + }; + } + + return { + kind: "systemd-user", + unitName: "caplets-serve-default.service", + path: `${paths.configFile}.service`, + unit: [ + "[Unit]", + "Description=Caplets HTTP daemon (default)", + "After=network.target", + "", + "[Service]", + "Type=simple", + `ExecStart=${shellJoin([command.executable, ...command.args])}`, + "Restart=on-failure", + `StandardOutput=append:${paths.stdoutLog}`, + `StandardError=append:${paths.stderrLog}`, + "", + "[Install]", + "WantedBy=default.target", + "", + ].join("\n"), + }; +} + +function shellJoin(args: string[]): string { + return args + .map((arg) => (/^[A-Za-z0-9_./:=@-]+$/u.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`)) + .join(" "); +} diff --git a/packages/core/src/serve/daemon/platform-windows.ts b/packages/core/src/serve/daemon/platform-windows.ts new file mode 100644 index 0000000..0cf5b22 --- /dev/null +++ b/packages/core/src/serve/daemon/platform-windows.ts @@ -0,0 +1,21 @@ +import type { DaemonCommandPlan, WindowsScheduledTaskDescriptor } from "./types"; + +export function buildWindowsScheduledTaskDescriptor( + command: DaemonCommandPlan, +): WindowsScheduledTaskDescriptor { + const taskName = "Caplets Serve Default"; + const taskRun = commandLine([command.executable, ...command.args]); + return { + kind: "windows-scheduled-task", + taskName, + commands: { + register: `schtasks /Create /TN "${taskName}" /SC ONLOGON /TR "${taskRun}" /F`, + unregister: `schtasks /Delete /TN "${taskName}" /F`, + query: `schtasks /Query /TN "${taskName}"`, + }, + }; +} + +function commandLine(args: string[]): string { + return args.map((arg) => (arg.includes(" ") ? `\\"${arg}\\"` : arg)).join(" "); +} diff --git a/packages/core/src/serve/daemon/platform.ts b/packages/core/src/serve/daemon/platform.ts new file mode 100644 index 0000000..11434bf --- /dev/null +++ b/packages/core/src/serve/daemon/platform.ts @@ -0,0 +1,40 @@ +import { buildLaunchdUserAgentDescriptor } from "./platform-darwin"; +import { buildLinuxServiceDescriptor } from "./platform-linux"; +import { buildWindowsScheduledTaskDescriptor } from "./platform-windows"; +import type { DaemonCommandPlan, DaemonPlatformDescriptor, ServeDaemonPaths } from "./types"; + +export type BuildDaemonPlatformDescriptorOptions = { + platform?: NodeJS.Platform; + serviceAvailable?: boolean; + paths: ServeDaemonPaths; + command: DaemonCommandPlan; +}; + +export function buildDaemonPlatformDescriptor( + options: BuildDaemonPlatformDescriptorOptions, +): DaemonPlatformDescriptor { + const platform = options.platform ?? process.platform; + if (platform === "darwin") { + return buildLaunchdUserAgentDescriptor(options.paths, options.command); + } + if (platform === "linux") { + return buildLinuxServiceDescriptor(options.paths, options.command, options.serviceAvailable); + } + if (platform === "win32") { + return buildWindowsScheduledTaskDescriptor(options.command); + } + return { + kind: "manual", + reason: `Automatic user service descriptors are not available on ${platform}.`, + command: options.command, + }; +} + +export function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/packages/core/src/serve/daemon/process.ts b/packages/core/src/serve/daemon/process.ts new file mode 100644 index 0000000..7458933 --- /dev/null +++ b/packages/core/src/serve/daemon/process.ts @@ -0,0 +1,76 @@ +import { spawn } from "node:child_process"; +import { closeSync, mkdirSync, openSync } from "node:fs"; +import { dirname } from "node:path"; +import type { HttpServeOptions } from "../options"; +import type { DaemonCommandPlan, DaemonProcessRunner, DaemonProcessStart } from "./types"; + +export function daemonServeCommand(options: HttpServeOptions): DaemonCommandPlan { + return { + executable: process.argv[1] ?? "caplets", + args: daemonServeArgs(options), + }; +} + +export function daemonServeArgs(options: HttpServeOptions): string[] { + const args = [ + "serve", + "--transport", + "http", + "--host", + options.host, + "--port", + String(options.port), + "--path", + options.path, + "--user", + options.auth.user, + ]; + if (options.auth.enabled) { + args.push("--password", options.auth.password); + } + if (options.warnUnauthenticatedNetwork) { + args.push("--allow-unauthenticated-http"); + } + if (options.trustProxy) { + args.push("--trust-proxy"); + } + return args; +} + +export function createNodeDaemonProcessRunner(): DaemonProcessRunner { + return { + async isRunning(pid: number): Promise { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }, + async start(command: DaemonProcessStart): Promise { + mkdirSync(dirname(command.stdoutLog), { recursive: true }); + mkdirSync(dirname(command.stderrLog), { recursive: true }); + const stdout = openSync(command.stdoutLog, "a"); + const stderr = openSync(command.stderrLog, "a"); + try { + const child = spawn(process.execPath, [process.argv[1] ?? "caplets", ...command.args], { + detached: true, + stdio: ["ignore", stdout, stderr], + env: process.env, + }); + child.unref(); + return child.pid ?? 0; + } finally { + closeSync(stdout); + closeSync(stderr); + } + }, + async stop(pid: number): Promise { + try { + process.kill(pid, "SIGTERM"); + } catch { + return; + } + }, + }; +} diff --git a/packages/core/src/serve/daemon/types.ts b/packages/core/src/serve/daemon/types.ts new file mode 100644 index 0000000..0e34eb6 --- /dev/null +++ b/packages/core/src/serve/daemon/types.ts @@ -0,0 +1,106 @@ +import type { HttpServeOptions, RawServeOptions } from "../options"; + +export type ServeDaemonInstance = "default"; + +export type ServeDaemonPaths = { + instance: ServeDaemonInstance; + stateDir: string; + logDir: string; + stateFile: string; + pidFile: string; + stdoutLog: string; + stderrLog: string; + configFile: string; +}; + +export type ServeDaemonConfig = { + instance: ServeDaemonInstance; + serve: HttpServeOptions; + command: DaemonCommandPlan; + paths: ServeDaemonPaths; + updatedAt: string; +}; + +export type ServeDaemonState = { + instance: ServeDaemonInstance; + running: boolean; + pid?: number; + startedAt?: string; + updatedAt: string; + enabled: boolean; +}; + +export type ServeDaemonStatus = ServeDaemonState & { + paths: ServeDaemonPaths; + config?: ServeDaemonConfig; +}; + +export type DaemonCommandPlan = { + executable: string; + args: string[]; +}; + +export type DaemonProcessStart = { + args: string[]; + stdoutLog: string; + stderrLog: string; + configFile: string; +}; + +export type DaemonProcessRunner = { + isRunning(pid: number): Promise; + start(command: DaemonProcessStart): Promise; + stop(pid: number): Promise; +}; + +export type ServeDaemonOperationOptions = { + env?: NodeJS.ProcessEnv | Record; + home?: string; + platform?: NodeJS.Platform; + process?: DaemonProcessRunner; + serviceAvailable?: boolean; +}; + +export type ServeDaemonStartOptions = ServeDaemonOperationOptions & { + raw?: RawServeOptions; +}; + +export type ServeDaemonOperationResult = { + status: ServeDaemonStatus; +}; + +export type LaunchdUserAgentDescriptor = { + kind: "launchd-user-agent"; + label: string; + path: string; + plist: string; +}; + +export type SystemdUserServiceDescriptor = { + kind: "systemd-user"; + unitName: string; + path: string; + unit: string; +}; + +export type ManualServiceDescriptor = { + kind: "manual"; + reason: string; + command: DaemonCommandPlan; +}; + +export type WindowsScheduledTaskDescriptor = { + kind: "windows-scheduled-task"; + taskName: string; + commands: { + register: string; + unregister: string; + query: string; + }; +}; + +export type DaemonPlatformDescriptor = + | LaunchdUserAgentDescriptor + | SystemdUserServiceDescriptor + | ManualServiceDescriptor + | WindowsScheduledTaskDescriptor; diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index cbc1bc9..283f84e 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -149,6 +149,20 @@ export function createHttpServeApp( ); }); + app.get(routePath(paths.control, "project-bindings/connect"), basicAuth(options.auth), (c) => + c.json({ error: "websocket_upgrade_required" }, 426), + ); + + app.get( + routePath(paths.control, "project-bindings/:bindingId/status"), + basicAuth(options.auth), + (c) => + c.json({ + bindingId: c.req.param("bindingId"), + state: "not_attached", + }), + ); + app.get(routePath(paths.control, "auth/callback/:flowId"), async (c) => { const flowId = c.req.param("flowId"); const result = await dispatchRemoteCliRequest( diff --git a/packages/core/src/serve/index.ts b/packages/core/src/serve/index.ts index 764479c..7a04281 100644 --- a/packages/core/src/serve/index.ts +++ b/packages/core/src/serve/index.ts @@ -4,9 +4,28 @@ import { resolveServeOptions, type RawServeOptions, type ServeOptions } from "./ import { serveStdio } from "./stdio"; export { serveHttp } from "./http"; -export { resolveServeOptions } from "./options"; +export { resolveDaemonServeOptions, resolveServeOptions } from "./options"; export type { HttpServeOptions, RawServeOptions, ServeOptions, StdioServeOptions } from "./options"; export { serveStdio } from "./stdio"; +export { + buildDaemonPlatformDescriptor, + daemonStatus, + disableDaemon, + enableDaemon, + resolveServeDaemonPaths, + restartDaemon, + startDaemon, + stopDaemon, +} from "./daemon"; +export type { + DaemonPlatformDescriptor, + DaemonProcessRunner, + ServeDaemonConfig, + ServeDaemonOperationOptions, + ServeDaemonPaths, + ServeDaemonState, + ServeDaemonStatus, +} from "./daemon"; export type ServeCapletsOptions = { raw: RawServeOptions; diff --git a/packages/core/src/serve/options.ts b/packages/core/src/serve/options.ts index b36bdc4..cc22759 100644 --- a/packages/core/src/serve/options.ts +++ b/packages/core/src/serve/options.ts @@ -112,6 +112,16 @@ export function resolveServeOptions( }; } +export function resolveDaemonServeOptions( + raw: RawServeOptions, + env: ServeEnv = process.env, +): HttpServeOptions { + if (raw.transport !== undefined && raw.transport !== "http") { + throw new CapletsError("REQUEST_INVALID", "Daemonized serve requires --transport http."); + } + return resolveServeOptions({ ...raw, transport: "http" }, env) as HttpServeOptions; +} + export function isLoopbackHost(host: string): boolean { const normalized = host.toLocaleLowerCase(); return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; diff --git a/packages/core/src/setup/local-store.ts b/packages/core/src/setup/local-store.ts index 1d77c60..3fb8c01 100644 --- a/packages/core/src/setup/local-store.ts +++ b/packages/core/src/setup/local-store.ts @@ -1,7 +1,19 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { defaultCacheBaseDir } from "../config/paths"; -import type { SetupApproval, SetupAttempt, SetupTargetKind } from "./types"; +import { CapletsError } from "../errors"; +import { + isSetupTargetKind, + type SetupApproval, + type SetupAttempt, + type SetupTargetKind, +} from "./types"; + +type SetupApprovalInput = Omit & { + projectFingerprint?: string | undefined; +}; + +const DEFAULT_PROJECT_FINGERPRINT = "default"; export type LocalSetupStoreOptions = { baseDir?: string; @@ -27,18 +39,37 @@ export class LocalSetupStore { capletId: string, contentHash: string, targetKind: SetupTargetKind, - ): Promise { + ): Promise; + async getApproval( + projectFingerprint: string, + capletId: string, + contentHash: string, + targetKind: SetupTargetKind, + ): Promise; + async getApproval( + ...args: [string, string, SetupTargetKind] | [string, string, string, SetupTargetKind] + ) { + const [projectFingerprint, capletId, contentHash, targetKind] = + args.length === 3 ? [DEFAULT_PROJECT_FINGERPRINT, args[0], args[1], args[2]] : args; + assertSetupTargetKind(targetKind); return this.approvals().find( (approval) => + approval.projectFingerprint === projectFingerprint && approval.capletId === capletId && approval.contentHash === contentHash && approval.targetKind === targetKind, ); } - async approve(approval: SetupApproval): Promise { + async approve(input: SetupApprovalInput): Promise { + const approval = { + ...input, + projectFingerprint: input.projectFingerprint ?? DEFAULT_PROJECT_FINGERPRINT, + }; + assertSetupTargetKind(approval.targetKind); const approvals = this.approvals().filter( (existing) => + existing.projectFingerprint !== approval.projectFingerprint || existing.capletId !== approval.capletId || existing.contentHash !== approval.contentHash || existing.targetKind !== approval.targetKind, @@ -52,17 +83,26 @@ export class LocalSetupStore { } async recordAttempt(attempt: SetupAttempt): Promise { - const attempts = this.prunedAttempts([...this.attempts(attempt.capletId), attempt]); - mkdirSync(join(this.root, "attempts"), { recursive: true }); + assertSetupTargetKind(attempt.targetKind); + const projectFingerprint = attempt.projectFingerprint ?? DEFAULT_PROJECT_FINGERPRINT; + const attempts = this.prunedAttempts([ + ...this.attempts(projectFingerprint, attempt.capletId), + { ...attempt, projectFingerprint }, + ]); + mkdirSync(this.attemptsDir(projectFingerprint), { recursive: true }); writeFileSync( - this.attemptsPath(attempt.capletId), + this.attemptsPath(projectFingerprint, attempt.capletId), attempts.map((entry) => JSON.stringify(entry)).join("\n") + "\n", { mode: 0o600 }, ); } - async listAttempts(capletId: string): Promise { - return this.attempts(capletId); + async listAttempts(capletId: string): Promise; + async listAttempts(projectFingerprint: string, capletId: string): Promise; + async listAttempts(...args: [string] | [string, string]): Promise { + const [projectFingerprint, capletId] = + args.length === 1 ? [DEFAULT_PROJECT_FINGERPRINT, args[0]] : args; + return this.attempts(projectFingerprint, capletId); } retention(): { maxAttempts: number; days: number } { @@ -72,16 +112,11 @@ export class LocalSetupStore { private approvals(): SetupApproval[] { const path = this.approvalsPath(); if (!existsSync(path)) return []; - return JSON.parse(readFileSync(path, "utf8")) as SetupApproval[]; - } - - private attempts(capletId: string): SetupAttempt[] { - const path = this.attemptsPath(capletId); - if (!existsSync(path)) return []; - return readFileSync(path, "utf8") - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as SetupAttempt); + const approvals = JSON.parse(readFileSync(path, "utf8")) as SetupApprovalInput[]; + return approvals.map((approval) => ({ + ...approval, + projectFingerprint: approval.projectFingerprint ?? DEFAULT_PROJECT_FINGERPRINT, + })); } private prunedAttempts(attempts: SetupAttempt[]): SetupAttempt[] { @@ -95,11 +130,33 @@ export class LocalSetupStore { return join(this.root, "approvals.json"); } - private attemptsPath(capletId: string): string { - return join(this.root, "attempts", `${safeFileName(capletId)}.jsonl`); + private attempts(projectFingerprint: string, capletId: string): SetupAttempt[] { + const path = this.attemptsPath(projectFingerprint, capletId); + if (!existsSync(path)) return []; + return readFileSync(path, "utf8") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as SetupAttempt); + } + + private attemptsDir(projectFingerprint: string): string { + return join(this.root, "projects", safeFileName(projectFingerprint), "attempts"); + } + + private attemptsPath(projectFingerprint: string, capletId: string): string { + return join(this.attemptsDir(projectFingerprint), `${safeFileName(capletId)}.jsonl`); } } function safeFileName(value: string): string { return value.replace(/[^a-zA-Z0-9._-]/gu, "_"); } + +function assertSetupTargetKind(value: string): asserts value is SetupTargetKind { + if (!isSetupTargetKind(value)) { + throw new CapletsError( + "REQUEST_INVALID", + "setup target must be one of: local_host, remote_host, hosted_sandbox", + ); + } +} diff --git a/packages/core/src/setup/runner.ts b/packages/core/src/setup/runner.ts index 68b7da8..e644645 100644 --- a/packages/core/src/setup/runner.ts +++ b/packages/core/src/setup/runner.ts @@ -2,9 +2,15 @@ import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { isAbsolute, resolve } from "node:path"; import type { CapletSetupCommandConfig, CapletSetupConfig } from "../config"; +import type { RuntimeFeature } from "../config-runtime"; import { CapletsError } from "../errors"; import type { LocalSetupStore } from "./local-store"; -import type { SetupActor, SetupAttempt, SetupTargetKind } from "./types"; +import { + isSetupTargetKind, + type SetupActor, + type SetupAttempt, + type SetupTargetKind, +} from "./types"; export type SpawnResult = { exitCode?: number | undefined; @@ -26,9 +32,14 @@ export type SetupSpawn = ( ) => Promise; export type RunCapletSetupOptions = { + projectFingerprint?: string; capletId: string; contentHash: string; + setupHash?: string | undefined; targetKind: SetupTargetKind; + runtimeFeatures?: RuntimeFeature[] | undefined; + projectBindingRequired?: boolean | undefined; + projectWorkspacePath?: string | undefined; setup: CapletSetupConfig; actor: SetupActor; approved: boolean; @@ -41,6 +52,7 @@ const DEFAULT_TIMEOUT_MS = 120_000; const DEFAULT_MAX_OUTPUT_BYTES = 200_000; export async function runCapletSetup(options: RunCapletSetupOptions): Promise { + assertSetupTargetKind(options.targetKind); if (!options.approved) { throw new CapletsError("REQUEST_INVALID", "Setup approval is required before commands run"); } @@ -75,9 +87,15 @@ async function runSetupCommand( }; const timeoutMs = command.timeoutMs ?? DEFAULT_TIMEOUT_MS; const maxOutputBytes = command.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const cwd = resolveCwd(command.cwd); + assertProjectWorkspaceSetupAllowed({ + cwd, + projectBindingRequired: options.projectBindingRequired === true, + projectWorkspacePath: options.projectWorkspacePath, + }); const spawnImpl = options.spawn ?? spawnCommand; const result = await spawnImpl(command.command, command.args ?? [], { - cwd: resolveCwd(command.cwd), + cwd, env, timeoutMs, maxOutputBytes, @@ -88,9 +106,12 @@ async function runSetupCommand( const stderr = redactOutput(result.stderr, command.env); return { attemptId: randomUUID(), + projectFingerprint: options.projectFingerprint ?? "default", capletId: options.capletId, contentHash: options.contentHash, + ...(options.setupHash === undefined ? {} : { setupHash: options.setupHash }), targetKind: options.targetKind, + ...(options.runtimeFeatures === undefined ? {} : { runtimeFeatures: options.runtimeFeatures }), actor: options.actor, status: result.exitCode === 0 && !result.signal ? "succeeded" : "failed", phase, @@ -108,6 +129,22 @@ async function runSetupCommand( }; } +function assertProjectWorkspaceSetupAllowed(input: { + cwd?: string | undefined; + projectBindingRequired: boolean; + projectWorkspacePath?: string | undefined; +}): void { + if (input.projectBindingRequired || !input.cwd || !input.projectWorkspacePath) return; + const workspacePath = resolve(input.projectWorkspacePath); + const cwd = resolve(input.cwd); + if (cwd === workspacePath || cwd.startsWith(`${workspacePath}/`)) { + throw new CapletsError( + "REQUEST_INVALID", + "Non-project setup cannot run inside project workspace without projectBinding.required", + ); + } +} + export async function spawnCommand( command: string, args: string[], @@ -185,3 +222,12 @@ function capBytes(value: string, maxBytes: number): string { if (bytes <= maxBytes) return value; return Buffer.from(value).subarray(0, maxBytes).toString("utf8"); } + +function assertSetupTargetKind(value: string): asserts value is SetupTargetKind { + if (!isSetupTargetKind(value)) { + throw new CapletsError( + "REQUEST_INVALID", + "setup target must be one of: local_host, remote_host, hosted_sandbox", + ); + } +} diff --git a/packages/core/src/setup/types.ts b/packages/core/src/setup/types.ts index 2920c20..f59233b 100644 --- a/packages/core/src/setup/types.ts +++ b/packages/core/src/setup/types.ts @@ -1,10 +1,14 @@ import type { CapletSetupCommandConfig, CapletSetupConfig } from "../config"; +import type { RuntimeFeature } from "../config-runtime"; -export type SetupTargetKind = "local" | "remote" | "cloud"; +export const setupTargetKinds = ["local_host", "remote_host", "hosted_sandbox"] as const; + +export type SetupTargetKind = (typeof setupTargetKinds)[number]; export type SetupActor = "cli-interactive" | "cli-yes" | "ui" | "automation"; export type SetupAttemptStatus = "running" | "succeeded" | "failed"; export type SetupApproval = { + projectFingerprint: string; capletId: string; contentHash: string; targetKind: SetupTargetKind; @@ -14,9 +18,12 @@ export type SetupApproval = { export type SetupAttempt = { attemptId: string; + projectFingerprint: string; capletId: string; contentHash: string; + setupHash?: string | undefined; targetKind: SetupTargetKind; + runtimeFeatures?: RuntimeFeature[] | undefined; actor: SetupActor; status: SetupAttemptStatus; phase: "commands" | "verify"; @@ -37,6 +44,7 @@ export type SetupAttempt = { }; export type SetupPlan = { + projectFingerprint: string; capletId: string; name: string; contentHash: string; @@ -46,3 +54,7 @@ export type SetupPlan = { commands: CapletSetupCommandConfig[]; verify: CapletSetupCommandConfig[]; }; + +export function isSetupTargetKind(value: string): value is SetupTargetKind { + return setupTargetKinds.includes(value as SetupTargetKind); +} diff --git a/packages/core/test/attach-cli.test.ts b/packages/core/test/attach-cli.test.ts new file mode 100644 index 0000000..ae9bbc6 --- /dev/null +++ b/packages/core/test/attach-cli.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import { attachProjectOnce, resolveAttachOptions } from "../src/project-binding/attach"; +import { runCli } from "../src/cli"; +import { CloudAuthStore } from "../src/cloud-auth/store"; +import type { ProjectBindingWebSocket } from "../src/project-binding/transport"; +import { hostedCredentials, tempCloudAuthPath } from "./fixtures/cloud-auth"; + +describe("caplets attach CLI", () => { + it("shows attach help", async () => { + const out: string[] = []; + + await runCli(["attach", "--help"], { writeOut: (value) => out.push(value) }); + + expect(out.join("")).toContain("Attach the current project to a remote Caplets runtime."); + expect(out.join("")).toContain("--remote-url "); + expect(out.join("")).toContain("--workspace "); + expect(out.join("")).toContain("--once"); + }); + + it("resolves attach options from flags, env, and the caller cwd", () => { + const resolved = resolveAttachOptions( + { + remoteUrl: "https://caplets.example.com/caplets", + token: "token", + workspace: "workspace", + once: true, + projectRoot: "/repo", + }, + { CAPLETS_REMOTE_URL: "https://env.example.com" }, + ); + + expect(resolved).toMatchObject({ + projectRoot: "/repo", + once: true, + remote: { + baseUrl: new URL("https://caplets.example.com/caplets"), + workspace: "workspace", + auth: { type: "bearer", token: "token" }, + }, + }); + }); + + it("reports WebSocket upgrade failures clearly in once mode", async () => { + await expect( + attachProjectOnce({ + projectRoot: "/repo", + remoteUrl: "https://caplets.example.com/caplets", + fetch: async () => new Response("upgrade blocked", { status: 426 }), + }), + ).rejects.toMatchObject({ + code: "SERVER_UNAVAILABLE", + message: expect.stringContaining("Project Binding WebSocket unavailable"), + }); + }); + + it("probes the HTTP equivalent of the Project Binding WebSocket URL", async () => { + let requestedUrl: string | undefined; + + await expect( + attachProjectOnce({ + projectRoot: "/repo", + remoteUrl: "http://127.0.0.1:8787/caplets", + fetch: async (url) => { + requestedUrl = String(url); + return Response.json({ error: "websocket_upgrade_required" }, { status: 426 }); + }, + }), + ).resolves.toMatchObject({ + ok: true, + webSocketUrl: "ws://127.0.0.1:8787/caplets/control/project-bindings/connect", + }); + expect(requestedUrl).toBe("http://127.0.0.1:8787/caplets/control/project-bindings/connect"); + }); + + it("runs once from the CLI and reports WebSocket availability", async () => { + const out: string[] = []; + + await runCli(["attach", "--remote-url", "https://caplets.example.com/caplets", "--once"], { + fetch: async () => Response.json({ error: "websocket_upgrade_required" }, { status: 426 }), + writeOut: (value) => out.push(value), + }); + + expect(out.join("")).toContain( + "Project Binding available at wss://caplets.example.com/caplets/control/project-bindings/connect.", + ); + }); + + it("prints structured JSON for CLI WebSocket failures", async () => { + const out: string[] = []; + let exitCode = 0; + + await runCli( + ["attach", "--remote-url", "https://caplets.example.com/caplets", "--once", "--json"], + { + fetch: async () => new Response("upgrade blocked", { status: 426 }), + writeOut: (value) => out.push(value), + setExitCode: (code) => { + exitCode = code; + }, + }, + ); + + expect(exitCode).toBe(1); + expect(JSON.parse(out.join(""))).toMatchObject({ + ok: false, + error: { code: "PROJECT_BINDING_WEBSOCKET_UNAVAILABLE" }, + }); + }); + + it("rejects attach --workspace when it differs from the saved Selected Workspace", async () => { + const path = tempCloudAuthPath(); + const out: string[] = []; + let exitCode = 0; + await new CloudAuthStore({ path }).save(hostedCredentials({ workspaceSlug: "personal" })); + + await runCli(["attach", "--workspace", "team", "--once", "--json", "--project-root", "/repo"], { + env: { CAPLETS_CLOUD_AUTH_PATH: path }, + writeOut: (value) => out.push(value), + setExitCode: (code) => { + exitCode = code; + }, + }); + + expect(exitCode).toBe(1); + expect(JSON.parse(out[0] ?? "{}")).toMatchObject({ + error: { + code: "workspace_switch_required", + recoveryCommand: "caplets cloud auth switch ", + }, + }); + }); + + it("does not print a first-time project sync approval prompt", async () => { + const path = tempCloudAuthPath(); + const out: string[] = []; + await new CloudAuthStore({ path }).save(hostedCredentials()); + + await runCli(["attach", "--once", "--json", "--project-root", "/repo"], { + env: { CAPLETS_CLOUD_AUTH_PATH: path }, + fetch: async () => Response.json({ error: "websocket_upgrade_required" }, { status: 426 }), + writeOut: (value) => out.push(value), + }); + + expect(out.join("")).not.toMatch(/approve|approval|confirm/i); + }); + + it("runs long-running attach through a Binding Session and ends cleanly on abort", async () => { + const path = tempCloudAuthPath(); + const out: string[] = []; + const controller = new AbortController(); + await new CloudAuthStore({ path }).save(hostedCredentials()); + const session = fakeProjectBindingSession({ onReady: () => controller.abort() }); + + await runCli(["attach", "--json", "--project-root", "/repo"], { + env: { CAPLETS_CLOUD_AUTH_PATH: path }, + fetch: session.fetch, + writeOut: (value) => out.push(value), + signal: controller.signal, + projectBindingWebSocketFactory: session.webSocketFactory, + }); + + const events = out.map((line) => JSON.parse(line)); + expect(events).toContainEqual(expect.objectContaining({ type: "state", state: "attaching" })); + expect(events).toContainEqual( + expect.objectContaining({ + type: "ready", + bindingId: "binding_1", + sessionId: "binding_session_1", + }), + ); + expect(events.at(-1)).toMatchObject({ type: "ended" }); + expect(JSON.stringify(events)).not.toContain("cap_access_secret"); + }); +}); + +function fakeProjectBindingSession(options: { onReady?: () => void } = {}) { + return { + fetch: async (url: Parameters[0], _init?: RequestInit) => { + if (String(url).endsWith("/control/project-bindings/sessions")) { + return Response.json( + { + binding: { bindingId: "binding_1", state: "attaching", syncState: "pending" }, + sessionId: "binding_session_1", + }, + { status: 201 }, + ); + } + return Response.json({ ok: true, binding: { bindingId: "binding_1" } }); + }, + webSocketFactory: () => + new FakeProjectBindingSocket( + [ + { + type: "ready", + bindingId: "binding_1", + sessionId: "binding_session_1", + syncState: "idle", + }, + ], + options, + ), + }; +} + +class FakeProjectBindingSocket implements ProjectBindingWebSocket { + readonly readyState = 1; + private readonly listeners = new Map void)[]>(); + + constructor( + private readonly messages: unknown[], + private readonly options: { onReady?: () => void }, + ) { + setTimeout(() => { + for (const message of this.messages) { + this.dispatch("message", { data: JSON.stringify(message) }); + if (isReadyMessage(message)) this.options.onReady?.(); + } + }, 0); + } + + send(): void {} + close(): void {} + + addEventListener( + type: "open" | "message" | "close" | "error", + listener: (event: { data?: unknown }) => void, + ): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + private dispatch(type: string, event: { data?: unknown }): void { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } +} + +function isReadyMessage(message: unknown): boolean { + return ( + typeof message === "object" && message !== null && "type" in message && message.type === "ready" + ); +} diff --git a/packages/core/test/caplet-files.test.ts b/packages/core/test/caplet-files.test.ts new file mode 100644 index 0000000..1b0fa24 --- /dev/null +++ b/packages/core/test/caplet-files.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { loadCapletFilesFromMap } from "../src/caplet-files"; + +describe("in-memory Caplet files", () => { + it("loads directory CAPLET.md files from an in-memory map", () => { + const result = loadCapletFilesFromMap({ + files: [ + { + path: "pypi/CAPLET.md", + content: `--- +name: PyPI +description: Query Python package metadata. +openapiEndpoint: + specPath: ./openapi.yaml + auth: + type: none +--- + +# PyPI +`, + }, + ], + }); + + expect(result?.paths).toEqual({ pypi: "pypi/CAPLET.md" }); + expect(result?.config.openapiEndpoints?.pypi).toEqual( + expect.objectContaining({ + name: "PyPI", + description: "Query Python package metadata.", + specPath: "pypi/openapi.yaml", + body: "\n# PyPI\n", + }), + ); + }); + + it("rejects duplicate in-memory caplet ids", () => { + expect(() => + loadCapletFilesFromMap({ + files: [ + { path: "search.md", content: caplet("Search A") }, + { path: "search/CAPLET.md", content: caplet("Search B") }, + ], + }), + ).toThrow(/Duplicate Caplet ID search/); + }); +}); + +function caplet(name: string): string { + return `--- +name: ${name} +description: Search project resources. +httpApi: + baseUrl: https://example.com + auth: + type: none + actions: + list: + method: GET + path: /list +--- +`; +} diff --git a/packages/core/test/caplet-source.test.ts b/packages/core/test/caplet-source.test.ts new file mode 100644 index 0000000..7da1713 --- /dev/null +++ b/packages/core/test/caplet-source.test.ts @@ -0,0 +1,186 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { BundleCapletSource } from "../src/caplet-source/bundle"; +import { FilesystemCapletSource } from "../src/caplet-source/filesystem"; +import { parseCapletSource } from "../src/caplet-source/parse"; + +const fixtureFiles = [ + { + path: "./weather/CAPLET.md", + content: `--- +name: Weather +description: Query weather forecast metadata. +openapiEndpoint: + specPath: ./openapi.yaml + auth: + type: bearer + token: \${WEATHER_TOKEN} +--- + +# Weather +`, + }, + { + path: "weather/openapi.yaml", + content: `openapi: 3.1.0 +info: + title: Weather + version: 1.0.0 +paths: {} +`, + }, + { + path: "tools/CAPLET.md", + content: `--- +name: Project Tools +description: Run project maintenance tools. +setup: + commands: + - label: Install tools + command: pnpm + args: [install] +cliTools: + projectBinding: + required: true + runtime: + features: [docker] + resources: + class: heavy + actions: + list_files: + description: List project files. + command: npx + args: [-y, "@playwright/mcp", ./scripts/list-files.js] +--- + +# Project Tools +`, + }, + { + path: "tools/scripts/list-files.js", + content: "console.log(JSON.stringify(process.argv.slice(2)));\n", + }, +]; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("CapletSource adapters", () => { + it("list and read normalized relative files for bundles", async () => { + const source = new BundleCapletSource(fixtureFiles); + + await expect(source.listFiles()).resolves.toEqual([ + expect.objectContaining({ path: "tools/CAPLET.md" }), + expect.objectContaining({ path: "tools/scripts/list-files.js" }), + expect.objectContaining({ path: "weather/CAPLET.md" }), + expect.objectContaining({ path: "weather/openapi.yaml" }), + ]); + await expect(source.readFile("./weather\\openapi.yaml")).resolves.toEqual({ + path: "weather/openapi.yaml", + content: fixtureFiles[1]!.content, + }); + await expect(source.readFile("../outside.yaml")).resolves.toBeUndefined(); + }); + + it("list and read normalized relative files for filesystems", async () => { + const root = writeFixtureTree(); + const source = new FilesystemCapletSource(root); + + await expect(source.listFiles()).resolves.toEqual([ + expect.objectContaining({ path: "tools/CAPLET.md" }), + expect.objectContaining({ path: "tools/scripts/list-files.js" }), + expect.objectContaining({ path: "weather/CAPLET.md" }), + expect.objectContaining({ path: "weather/openapi.yaml" }), + ]); + await expect(source.readFile("./tools/scripts/list-files.js")).resolves.toEqual({ + path: "tools/scripts/list-files.js", + content: fixtureFiles[3]!.content, + }); + await expect(source.readFile("/absolute.js")).resolves.toBeUndefined(); + }); + + it("parses equivalent bundle and filesystem multi-file Caplets identically", async () => { + const bundle = await parseCapletSource(new BundleCapletSource(fixtureFiles)); + const filesystem = await parseCapletSource(new FilesystemCapletSource(writeFixtureTree())); + + expect(summary(bundle)).toEqual(summary(filesystem)); + expect(summary(bundle)).toEqual([ + { + id: "weather", + backend: "openapi", + setupRequired: false, + authRequired: true, + projectBindingRequired: false, + runtime: { + route: "worker_safe", + setupTarget: undefined, + features: [], + resources: { class: "small", cpu: 1, memoryMb: 1024, diskMb: 4096 }, + }, + localReferences: [{ path: "weather/openapi.yaml", exists: true }], + }, + { + id: "tools", + backend: "cli", + setupRequired: true, + authRequired: false, + projectBindingRequired: true, + runtime: { + route: "project_bound_process", + setupTarget: "hosted_sandbox", + features: ["docker", "browser"], + resources: { class: "heavy", cpu: 8, memoryMb: 16384, diskMb: 40960 }, + }, + localReferences: [], + }, + ]); + }); + + it("reports missing local references through shared parser semantics", async () => { + const result = await parseCapletSource( + new BundleCapletSource(fixtureFiles.filter((file) => file.path !== "weather/openapi.yaml")), + ); + + expect(result.ok).toBe(false); + expect(result.errors.map((error) => error.message).join("\n")).toMatch( + /weather\/openapi\.yaml/, + ); + }); +}); + +function summary(result: Awaited>) { + expect(result.ok).toBe(true); + return result.resolvedCaplets.map((caplet) => ({ + id: caplet.id, + backend: caplet.backend, + setupRequired: caplet.setupRequired, + authRequired: caplet.authRequired, + projectBindingRequired: caplet.projectBindingRequired, + runtime: { + route: caplet.runtime.route, + setupTarget: caplet.runtime.setupTarget, + features: caplet.runtime.features, + resources: caplet.runtime.resources, + }, + localReferences: caplet.localReferences, + })); +} + +function writeFixtureTree(): string { + const root = mkdtempSync(join(tmpdir(), "caplets-source-")); + tempDirs.push(root); + for (const file of fixtureFiles) { + const normalized = file.path.replace(/^\.\//u, ""); + const path = join(root, normalized); + mkdirSync(path.split("/").slice(0, -1).join("/"), { recursive: true }); + writeFileSync(path, file.content, "utf8"); + } + return root; +} diff --git a/packages/core/test/cloud-auth-client.test.ts b/packages/core/test/cloud-auth-client.test.ts new file mode 100644 index 0000000..71461c6 --- /dev/null +++ b/packages/core/test/cloud-auth-client.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { CloudAuthClient } from "../src/cloud-auth/client"; + +describe("CloudAuthClient", () => { + it("starts a browser-mediated CLI login transaction", async () => { + const requests: Request[] = []; + const client = new CloudAuthClient({ + cloudUrl: "https://cloud.caplets.dev", + fetch: async (input, init) => { + const request = new Request(input, init); + requests.push(request); + return Response.json( + { + loginId: "login_123", + loginUrl: "https://cloud.caplets.dev/cli-login/login_123", + userCode: "ABCD-EFGH", + expiresAt: "2026-06-03T12:10:00.000Z", + requestId: "req_login_start", + }, + { status: 201, headers: { "x-request-id": "req_login_start" } }, + ); + }, + }); + + const result = await client.startLogin({ + requestedWorkspace: "team", + deviceName: "MacBook", + }); + + expect(requests[0]?.url).toBe("https://cloud.caplets.dev/api/cloud-client/login/start"); + await expect(requests[0]?.json()).resolves.toMatchObject({ + requestedWorkspace: "team", + deviceName: "MacBook", + }); + expect(result).toMatchObject({ + loginId: "login_123", + userCode: "ABCD-EFGH", + requestId: "req_login_start", + }); + }); + + it("exchanges a completed one-time login for redacted workspace-scoped credentials", async () => { + const client = new CloudAuthClient({ + cloudUrl: "https://cloud.caplets.dev", + fetch: async () => + Response.json({ + status: "authenticated", + cloudUrl: "https://cloud.caplets.dev", + workspaceId: "workspace_team", + workspaceSlug: "team", + accessToken: "cap_access_secret", + refreshToken: "cap_refresh_secret", + expiresAt: "2026-06-03T13:00:00.000Z", + scope: ["project_binding:read", "project_binding:write"], + tokenType: "Bearer", + credentialFamilyId: "family_123", + deviceName: "MacBook", + requestId: "req_token", + }), + }); + + const credentials = await client.exchangeToken({ + loginId: "login_123", + oneTimeCode: "one_time_code_secret", + }); + + expect(credentials.workspaceId).toBe("workspace_team"); + expect(credentials.scope).toEqual(["project_binding:read", "project_binding:write"]); + expect(JSON.stringify(credentials.redacted)).not.toContain("cap_access_secret"); + expect(JSON.stringify(credentials.redacted)).not.toContain("cap_refresh_secret"); + }); +}); diff --git a/packages/core/test/cloud-auth-login-cli.test.ts b/packages/core/test/cloud-auth-login-cli.test.ts new file mode 100644 index 0000000..96e5742 --- /dev/null +++ b/packages/core/test/cloud-auth-login-cli.test.ts @@ -0,0 +1,70 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { runCli } from "../src/cli"; +import { assertNoSecrets, tempCloudAuthPath } from "./fixtures/cloud-auth"; + +describe("caplets cloud auth login", () => { + it("polls completion, writes one workspace-scoped profile, and redacts JSON", async () => { + const path = tempCloudAuthPath(); + const responses = [ + Response.json({ + loginId: "login_123", + loginUrl: "https://cloud.caplets.dev/cli-login/login_123", + userCode: "ABCD-EFGH", + expiresAt: "2026-06-03T12:10:00.000Z", + requestId: "req_start", + }), + Response.json({ + status: "completed", + selectedWorkspace: { workspaceId: "workspace_team", slug: "team" }, + oneTimeCode: "one_time_code_secret", + requestId: "req_poll", + }), + Response.json({ + status: "authenticated", + cloudUrl: "https://cloud.caplets.dev", + workspaceId: "workspace_team", + workspaceSlug: "team", + accessToken: "cap_access_secret", + refreshToken: "cap_refresh_secret", + expiresAt: "2099-06-03T13:00:00.000Z", + scope: ["project_binding:read", "project_binding:write"], + tokenType: "Bearer", + credentialFamilyId: "family_123", + deviceName: "Test Device", + requestId: "req_token", + }), + ]; + const out: string[] = []; + + await runCli( + [ + "cloud", + "auth", + "login", + "--cloud-url", + "https://cloud.caplets.dev", + "--workspace", + "team", + "--no-open", + "--json", + ], + { + env: { CAPLETS_CLOUD_AUTH_PATH: path, CAPLETS_CLOUD_AUTH_POLL_INTERVAL_MS: "0" }, + fetch: async () => responses.shift() ?? Response.json({}, { status: 500 }), + writeOut: (value) => out.push(value), + }, + ); + + expect(JSON.parse(out.join(""))).toMatchObject({ + authenticated: true, + status: "authenticated", + cloudUrl: "https://cloud.caplets.dev", + workspaceId: "workspace_team", + workspaceSlug: "team", + }); + assertNoSecrets(out.join("")); + expect(readFileSync(path, "utf8")).toContain("cap_refresh_secret"); + }); +}); diff --git a/packages/core/test/cloud-auth-refresh-attach.test.ts b/packages/core/test/cloud-auth-refresh-attach.test.ts new file mode 100644 index 0000000..d378326 --- /dev/null +++ b/packages/core/test/cloud-auth-refresh-attach.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { CloudAuthStore } from "../src/cloud-auth/store"; +import { attachProjectOnce } from "../src/project-binding/attach"; +import { hostedCredentials, tempCloudAuthPath } from "./fixtures/cloud-auth"; + +describe("hosted Cloud Auth refresh before attach", () => { + it("refreshes expired hosted credentials, persists rotation, and attaches with the new access token", async () => { + const path = tempCloudAuthPath(); + const store = new CloudAuthStore({ path }); + await store.save( + hostedCredentials({ + accessToken: "old_access", + refreshToken: "old_refresh", + expiresAt: "2026-06-03T00:00:00.000Z", + }), + ); + const authorizationHeaders: string[] = []; + + await expect( + attachProjectOnce( + { + projectRoot: "/repo", + fetch: async (url, init) => { + if (String(url).endsWith("/api/cloud-client/refresh")) { + expect(JSON.parse(String(init?.body))).toEqual({ refreshToken: "old_refresh" }); + return Response.json({ + status: "authenticated", + cloudUrl: "https://cloud.caplets.dev", + workspaceId: "workspace_personal", + workspaceSlug: "personal", + accessToken: "new_access", + refreshToken: "new_refresh", + expiresAt: "2999-01-01T00:00:00.000Z", + scope: ["project_binding:read", "project_binding:write"], + tokenType: "Bearer", + credentialFamilyId: "family_123", + }); + } + authorizationHeaders.push(headerValue(init?.headers, "authorization")); + return Response.json({ error: "websocket_upgrade_required" }, { status: 426 }); + }, + }, + { CAPLETS_CLOUD_AUTH_PATH: path }, + ), + ).resolves.toMatchObject({ ok: true }); + + await expect(store.load()).resolves.toMatchObject({ + accessToken: "new_access", + refreshToken: "new_refresh", + expiresAt: "2999-01-01T00:00:00.000Z", + }); + expect(authorizationHeaders).toEqual(["Bearer new_access"]); + }); + + it("fails closed when the saved refresh token is revoked", async () => { + const path = tempCloudAuthPath(); + await new CloudAuthStore({ path }).save( + hostedCredentials({ + expiresAt: "2026-06-03T00:00:00.000Z", + refreshToken: "revoked_refresh", + }), + ); + + await expect( + attachProjectOnce( + { + projectRoot: "/repo", + fetch: async () => + Response.json( + { error: "invalid_refresh_token", message: "Refresh token was revoked." }, + { status: 401 }, + ), + }, + { CAPLETS_CLOUD_AUTH_PATH: path }, + ), + ).rejects.toMatchObject({ code: "AUTH_FAILED" }); + }); +}); + +function headerValue(headers: RequestInit["headers"] | undefined, name: string): string { + return new Headers(headers).get(name) ?? ""; +} diff --git a/packages/core/test/cloud-auth.test.ts b/packages/core/test/cloud-auth.test.ts new file mode 100644 index 0000000..5b29581 --- /dev/null +++ b/packages/core/test/cloud-auth.test.ts @@ -0,0 +1,170 @@ +import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + CloudAuthStore, + cloudAuthPath, + redactedCloudAuthStatus, + type CloudAuthCredentials, +} from "../src/cloud-auth/store"; +import { runCli } from "../src/cli"; + +const tempDirs: string[] = []; +const credentials: CloudAuthCredentials = { + version: 2, + cloudUrl: "https://cloud.caplets.dev", + workspaceId: "ws_123", + workspaceSlug: "team", + accessToken: "access", + refreshToken: "refresh", + expiresAt: "2099-06-02T12:00:00.000Z", + scope: ["project_binding:read", "project_binding:write"], + tokenType: "Bearer", + credentialFamilyId: "family_123", + deviceName: "Test Device", + createdAt: "2026-06-03T12:00:00.000Z", + lastRefreshAt: "2026-06-03T12:00:00.000Z", +}; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("caplets cloud auth CLI", () => { + it("shows Cloud Auth help", async () => { + const out: string[] = []; + + await runCli(["cloud", "auth", "--help"], { writeOut: (value) => out.push(value) }); + + expect(out.join("")).toContain("Authenticate this Caplets client to hosted Caplets Cloud."); + expect(out.join("")).toContain("login"); + expect(out.join("")).toContain("status"); + expect(out.join("")).toContain("logout"); + expect(out.join("")).toContain("workspaces"); + }); + + it("prints unauthenticated status as JSON when no credentials are stored", async () => { + const out: string[] = []; + + await runCli(["cloud", "auth", "status", "--json"], { + env: { CAPLETS_CLOUD_AUTH_PATH: tempAuthPath() }, + writeOut: (value) => out.push(value), + }); + + expect(JSON.parse(out.join(""))).toEqual({ + authenticated: false, + status: "unauthenticated", + }); + }); + + it("prints authenticated status and stored workspace as JSON", async () => { + const path = tempAuthPath(); + await new CloudAuthStore({ path }).save(credentials); + const out: string[] = []; + + await runCli(["cloud", "auth", "status", "--json"], { + env: { CAPLETS_CLOUD_AUTH_PATH: path }, + writeOut: (value) => out.push(value), + }); + + expect(JSON.parse(out.join(""))).toEqual({ + authenticated: true, + status: "authenticated", + cloudUrl: "https://cloud.caplets.dev", + workspaceId: "ws_123", + workspaceSlug: "team", + expiresAt: "2099-06-02T12:00:00.000Z", + scope: ["project_binding:read", "project_binding:write"], + tokenType: "Bearer", + credentialFamilyId: "family_123", + deviceName: "Test Device", + createdAt: "2026-06-03T12:00:00.000Z", + lastRefreshAt: "2026-06-03T12:00:00.000Z", + }); + }); + + it("lists the stored workspace as JSON", async () => { + const path = tempAuthPath(); + await new CloudAuthStore({ path }).save(credentials); + const out: string[] = []; + + await runCli(["cloud", "auth", "workspaces", "--json"], { + env: { CAPLETS_CLOUD_AUTH_PATH: path }, + writeOut: (value) => out.push(value), + }); + + expect(JSON.parse(out.join(""))).toEqual({ + workspaces: [{ workspaceId: "ws_123", slug: "team", selected: true }], + }); + }); + + it("logs out by deleting stored Cloud Auth credentials", async () => { + const path = tempAuthPath(); + await new CloudAuthStore({ path }).save(credentials); + + await runCli(["cloud", "auth", "logout"], { + env: { CAPLETS_CLOUD_AUTH_PATH: path }, + writeOut: () => undefined, + }); + + expect(existsSync(path)).toBe(false); + }); +}); + +describe("CloudAuthStore", () => { + it("stores refreshable Cloud Auth credentials with restrictive file permissions", async () => { + const path = tempAuthPath(); + const store = new CloudAuthStore({ path }); + + await store.save(credentials); + + expect(await store.load()).toEqual(credentials); + if (process.platform !== "win32") { + expect(statSync(path).mode & 0o777).toBe(0o600); + } + }); + + it("classifies expired credentials with a refresh token as refreshable without exposing secrets", () => { + const status = redactedCloudAuthStatus( + { + ...credentials, + accessToken: "secret_access", + refreshToken: "secret_refresh", + expiresAt: "2026-06-03T00:00:00.000Z", + }, + new Date("2026-06-04T00:00:00.000Z"), + ); + + expect(status).toMatchObject({ + authenticated: false, + status: "refreshable", + workspaceId: "ws_123", + }); + expect(JSON.stringify(status)).not.toContain("secret_access"); + expect(JSON.stringify(status)).not.toContain("secret_refresh"); + }); + + it("uses platform config directories for the default path", () => { + expect( + cloudAuthPath({ + env: { XDG_CONFIG_HOME: "/config" }, + home: "/home/alice", + platform: "linux", + }), + ).toBe("/config/caplets/cloud-auth.json"); + expect( + cloudAuthPath({ + env: { APPDATA: "C:\\Users\\Alice\\AppData\\Roaming" }, + home: "C:\\Users\\Alice", + platform: "win32", + }), + ).toBe("C:\\Users\\Alice\\AppData\\Roaming\\Caplets\\cloud-auth.json"); + }); +}); + +function tempAuthPath(): string { + const dir = mkdtempSync(join(tmpdir(), "caplets-cloud-auth-")); + tempDirs.push(dir); + return join(dir, "cloud-auth.json"); +} diff --git a/packages/core/test/cloud-bundle-runtime.test.ts b/packages/core/test/cloud-bundle-runtime.test.ts new file mode 100644 index 0000000..53c5694 --- /dev/null +++ b/packages/core/test/cloud-bundle-runtime.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { BundleCapletSource, parseCapletSource } from "../src/caplet-source"; +import { classifyCapletRuntimeRoute, planCapletRuntimeRoutes } from "../src/runtime-plan"; + +describe("neutral hosted Caplet bundle runtime", () => { + it("validates a bundle with a CAPLET.md and companion OpenAPI file", async () => { + const parsed = await parseCapletSource( + new BundleCapletSource([ + { + path: "CAPLET.md", + content: `--- +name: PyPI +description: Query Python package metadata. +openapiEndpoint: + specPath: ./openapi.yaml + auth: + type: none +--- + +# PyPI +`, + }, + { + path: "openapi.yaml", + content: `openapi: 3.1.0 +info: + title: PyPI + version: 1.0.0 +paths: {} +`, + }, + ]), + ); + + expect(parsed.ok).toBe(true); + expect(parsed.resolvedCaplets).toEqual([ + expect.objectContaining({ + id: "CAPLET", + backend: "openapi", + sourcePath: "CAPLET.md", + }), + ]); + expect( + planCapletRuntimeRoutes( + parsed.resolvedCaplets.map((caplet) => caplet.config), + { deployment: "hosted" }, + ), + ).toEqual([expect.objectContaining({ id: "CAPLET", route: "worker_safe" })]); + expect(parsed.errors).toEqual([]); + }); + + it("rejects missing local references", async () => { + const parsed = await parseCapletSource( + new BundleCapletSource([ + { + path: "CAPLET.md", + content: `--- +name: Missing Spec +description: Missing OpenAPI spec file. +openapiEndpoint: + specPath: ./missing.yaml + auth: + type: none +--- +`, + }, + ]), + ); + + expect(parsed.ok).toBe(false); + expect(parsed.errors.map((error) => error.message).join("\n")).toMatch(/missing\.yaml/); + }); + + it("classifies remote-safe backends for worker execution", () => { + expect( + classifyCapletRuntimeRoute({ + backend: "mcp", + transport: "http", + url: "https://example.com/mcp", + }), + ).toBe("worker_safe"); + expect(classifyCapletRuntimeRoute({ backend: "http" })).toBe("worker_safe"); + expect(classifyCapletRuntimeRoute({ backend: "graphql", introspection: true })).toBe( + "worker_safe", + ); + }); + + it("classifies process-backed and setup routes semantically", () => { + expect( + classifyCapletRuntimeRoute({ + backend: "mcp", + transport: "stdio", + command: "uvx", + }), + ).toBe("process"); + expect(classifyCapletRuntimeRoute({ backend: "cli", actions: {} })).toBe("process"); + expect( + classifyCapletRuntimeRoute({ + backend: "openapi", + setup: { commands: [{ label: "Install", command: "npm" }] }, + }), + ).toBe("process"); + expect( + classifyCapletRuntimeRoute({ + backend: "cli", + projectBinding: { required: true }, + actions: {}, + }), + ).toBe("project_bound_process"); + }); +}); diff --git a/packages/core/test/cloud-client.test.ts b/packages/core/test/cloud-client.test.ts index 2864b43..a99c567 100644 --- a/packages/core/test/cloud-client.test.ts +++ b/packages/core/test/cloud-client.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it, vi } from "vitest"; import { CapletsCloudClient } from "../src/cloud/client"; describe("CapletsCloudClient", () => { - it("registers local presence with bearer auth", async () => { + it("registers Project Binding with bearer auth and synced project files", async () => { const fetch = vi.fn(async () => - Response.json({ presenceId: "presence_1", expiresAt: "2026-05-30T00:05:00.000Z" }), + Response.json({ binding: { bindingId: "project_binding_1" } }, { status: 201 }), ); const client = new CapletsCloudClient({ baseUrl: new URL("https://cloud.caplets.dev"), @@ -18,11 +18,12 @@ describe("CapletsCloudClient", () => { projectRoot: "/repo", projectFingerprint: "sha256:abc", allowedCapletIds: ["repo-cli"], + projectFiles: [{ path: "src/app.ts", content: "app" }], }), - ).resolves.toEqual({ presenceId: "presence_1", expiresAt: "2026-05-30T00:05:00.000Z" }); + ).resolves.toMatchObject({ presenceId: "project_binding_1" }); expect(fetch).toHaveBeenCalledWith( - new URL("https://cloud.caplets.dev/api/presence"), + new URL("https://cloud.caplets.dev/api/project-bindings"), expect.objectContaining({ method: "POST", headers: expect.any(Headers), @@ -32,9 +33,17 @@ describe("CapletsCloudClient", () => { const headers = init.headers; expect(headers).toBeInstanceOf(Headers); expect((headers as Headers).get("authorization")).toBe("Bearer token"); + expect(JSON.parse(init.body as string)).toMatchObject({ + workspaceId: "ws_1", + projectRoot: "/repo", + projectFingerprint: "sha256:abc", + state: "ready", + syncState: "idle", + projectFiles: [{ path: "src/app.ts", content: "app" }], + }); }); - it("stops local presence and ignores missing records", async () => { + it("marks Project Binding offline and ignores missing records", async () => { const fetch = vi.fn(async () => new Response(null, { status: 404 })); const client = new CapletsCloudClient({ baseUrl: new URL("https://cloud.caplets.dev/ws/ian"), @@ -45,15 +54,13 @@ describe("CapletsCloudClient", () => { await expect(client.stopPresence("presence_1")).resolves.toBeUndefined(); expect(fetch).toHaveBeenCalledWith( - new URL("https://cloud.caplets.dev/ws/ian/api/presence/presence_1"), - expect.objectContaining({ method: "DELETE" }), + new URL("https://cloud.caplets.dev/ws/ian/api/project-bindings/presence_1"), + expect.objectContaining({ method: "PATCH", body: JSON.stringify({ state: "offline" }) }), ); }); - it("heartbeats local presence", async () => { - const fetch = vi.fn(async () => - Response.json({ presenceId: "presence_1", expiresAt: "2026-05-30T00:10:00.000Z" }), - ); + it("heartbeats Project Binding state", async () => { + const fetch = vi.fn(async () => Response.json({ binding: { bindingId: "presence_1" } })); const client = new CapletsCloudClient({ baseUrl: new URL("https://cloud.caplets.dev"), accessToken: "token", @@ -62,16 +69,19 @@ describe("CapletsCloudClient", () => { await expect(client.heartbeatPresence("presence_1")).resolves.toEqual({ presenceId: "presence_1", - expiresAt: "2026-05-30T00:10:00.000Z", + expiresAt: expect.any(String), }); expect(fetch).toHaveBeenCalledWith( - new URL("https://cloud.caplets.dev/api/presence/presence_1/heartbeat"), - expect.objectContaining({ method: "POST" }), + new URL("https://cloud.caplets.dev/api/project-bindings/presence_1"), + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ state: "ready", syncState: "idle" }), + }), ); }); - it("updates visible local Caplets for an active presence", async () => { + it("keeps visible local Caplet updates local for compatibility", async () => { const fetch = vi.fn(async () => Response.json({ ok: true })); const client = new CapletsCloudClient({ baseUrl: new URL("https://cloud.caplets.dev"), @@ -81,12 +91,6 @@ describe("CapletsCloudClient", () => { await expect(client.updatePresenceCaplets("presence_1", ["repo-cli"])).resolves.toBeUndefined(); - expect(fetch).toHaveBeenCalledWith( - new URL("https://cloud.caplets.dev/api/presence/presence_1/caplets"), - expect.objectContaining({ - method: "PATCH", - body: JSON.stringify({ allowedCapletIds: ["repo-cli"] }), - }), - ); + expect(fetch).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/test/cloud-mutagen.test.ts b/packages/core/test/cloud-mutagen.test.ts index 7012299..f3c0d11 100644 --- a/packages/core/test/cloud-mutagen.test.ts +++ b/packages/core/test/cloud-mutagen.test.ts @@ -1,47 +1,55 @@ import { describe, expect, it, vi } from "vitest"; import { - checkMutagenBinary, - mutagenDoctorLine, + ManagedMutagenProjectSync, + mutagenProjectSyncDoctorData, parseMutagenVersionOutput, -} from "../src/cloud/mutagen"; +} from "../src/project-binding/mutagen"; -describe("managed Mutagen adapter", () => { - it("reports available MIT-only Mutagen", async () => { - const run = vi.fn(async () => "Mutagen version 0.18.1\nLicense profile: mit\n"); +describe("managed Project Binding sync adapter", () => { + it("records available Mutagen version information after start", async () => { + const run = vi.fn(async (command: string, args: string[]) => { + if (args[0] === "version") { + return { stdout: "Mutagen version 0.18.1\nLicense profile: mit\n", exitCode: 0 }; + } + return { stdout: "", exitCode: 0 }; + }); + const sync = new ManagedMutagenProjectSync({ mutagenBinary: "/bin/mutagen", runner: run }); + + await sync.start({ + bindingId: "bind_1", + localProjectRoot: "/repo", + serverProjectRoot: "/state/workspaces/fingerprint/project", + }); - await expect(checkMutagenBinary("/bin/mutagen", run)).resolves.toEqual({ - available: true, - path: "/bin/mutagen", - version: "0.18.1", - licenseProfile: "mit", + expect(mutagenProjectSyncDoctorData(sync.snapshot())).toMatchObject({ + state: "syncing", + mutagenBinary: "/bin/mutagen", + mutagenVersion: "0.18.1", }); }); - it("rejects unsupported license profiles", async () => { - const run = vi.fn(async () => "Mutagen version 0.18.1\nLicense profile: sspl\n"); + it("blocks with a stable diagnostic when the binary is unavailable", async () => { + const sync = new ManagedMutagenProjectSync({ + runner: async () => { + throw new Error("not found"); + }, + }); - await expect(checkMutagenBinary("/bin/mutagen", run)).resolves.toEqual({ - available: false, - path: "/bin/mutagen", - reason: "unsupported license profile sspl", + await sync.start({ + bindingId: "bind_1", + localProjectRoot: "/repo", + serverProjectRoot: "/state/workspaces/fingerprint/project", }); - }); - it("formats doctor output", () => { - expect( - mutagenDoctorLine({ - available: true, - path: "/bin/mutagen", - version: "0.18.1", - licenseProfile: "mit", - }), - ).toBe("Mutagen: available 0.18.1 (/bin/mutagen)"); + expect(mutagenProjectSyncDoctorData(sync.snapshot())).toMatchObject({ + state: "blocked", + diagnosticCode: "project_sync_binary_missing", + }); }); it("parses unknown version output conservatively", () => { expect(parseMutagenVersionOutput("mutagen dev build")).toEqual({ version: "unknown", - licenseProfile: "unknown", }); }); }); diff --git a/packages/core/test/cloud-runtime-adapter-provenance.test.ts b/packages/core/test/cloud-runtime-adapter-provenance.test.ts new file mode 100644 index 0000000..3ef603b --- /dev/null +++ b/packages/core/test/cloud-runtime-adapter-provenance.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { createRuntimeHttpApp } from "../src/cloud/runtime-http"; + +describe("Cloud runtime adapter HTTP provenance boundary", () => { + it("rejects runtime adapter calls without the runtime bearer token", async () => { + const app = createRuntimeHttpApp({ + runtimeId: "runtime_1", + sandboxId: "sandbox_1", + executionKind: "cloud", + token: "runtime_secret", + }); + + const response = await app.request("http://adapter.local/runtime/tools/list", { + method: "POST", + headers: { authorization: "Bearer wrong" }, + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }); + }); +}); diff --git a/packages/core/test/cloud-sync.test.ts b/packages/core/test/cloud-sync.test.ts index 01eca7b..d3511ff 100644 --- a/packages/core/test/cloud-sync.test.ts +++ b/packages/core/test/cloud-sync.test.ts @@ -1,8 +1,8 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { ProjectSyncCoordinator, projectSyncManifest } from "../src/cloud/sync"; +import { ProjectSyncCoordinator, projectSyncFiles, projectSyncManifest } from "../src/cloud/sync"; describe("ProjectSyncCoordinator", () => { it("serializes mutating calls per project target", async () => { @@ -33,11 +33,13 @@ describe("ProjectSyncCoordinator", () => { expect(task).toHaveBeenCalledTimes(2); }); - it("builds sync scope from gitignore and capletsignore only", () => { + it("builds hosted upload scope from the Project Binding sync filter", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-sync-")); + const external = mkdtempSync(join(tmpdir(), "caplets-sync-external-")); try { mkdirSync(join(dir, "src")); mkdirSync(join(dir, "dist")); + mkdirSync(join(dir, "node_modules", "pkg"), { recursive: true }); mkdirSync(join(dir, "secrets")); mkdirSync(join(dir, ".git", "info"), { recursive: true }); writeFileSync(join(dir, ".gitignore"), "dist\n*.env\n!important.env\n", "utf8"); @@ -45,20 +47,33 @@ describe("ProjectSyncCoordinator", () => { writeFileSync(join(dir, ".capletsignore"), "secrets\n", "utf8"); writeFileSync(join(dir, "src/app.ts"), "app", "utf8"); writeFileSync(join(dir, "dist/app.js"), "build", "utf8"); + writeFileSync(join(dir, "node_modules", "pkg", "index.js"), "pkg", "utf8"); writeFileSync(join(dir, "secrets/token"), "secret", "utf8"); writeFileSync(join(dir, ".env"), "secret", "utf8"); + writeFileSync(join(dir, ".env.example"), "SAFE=\n", "utf8"); + writeFileSync(join(dir, "deploy.pem"), "secret", "utf8"); writeFileSync(join(dir, "important.env"), "ok", "utf8"); mkdirSync(join(dir, "tmp")); writeFileSync(join(dir, "tmp/cache"), "cache", "utf8"); + writeFileSync(join(external, "secret.txt"), "secret", "utf8"); + symlinkSync(join(external, "secret.txt"), join(dir, "linked-secret")); + symlinkSync(external, join(dir, "linked-dir")); expect(projectSyncManifest(dir)).toEqual([ ".capletsignore", + ".env.example", ".gitignore", - "important.env", "src/app.ts", ]); + expect(projectSyncFiles(dir)).toEqual([ + { path: ".capletsignore", content: "secrets\n" }, + { path: ".env.example", content: "SAFE=\n" }, + { path: ".gitignore", content: "dist\n*.env\n!important.env\n" }, + { path: "src/app.ts", content: "app" }, + ]); } finally { rmSync(dir, { recursive: true, force: true }); + rmSync(external, { recursive: true, force: true }); } }); }); diff --git a/packages/core/test/doctor-cli.test.ts b/packages/core/test/doctor-cli.test.ts index 0532e9a..9d9ee16 100644 --- a/packages/core/test/doctor-cli.test.ts +++ b/packages/core/test/doctor-cli.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { runCli } from "../src/cli"; describe("caplets doctor", () => { - it("shows local mode without remote sync details", async () => { + it("shows sectioned local diagnostics without stale presence wording", async () => { const out: string[] = []; await runCli(["doctor"], { @@ -10,24 +10,57 @@ describe("caplets doctor", () => { writeOut: (value) => out.push(value), }); - expect(out.join("")).toContain("Mode: local"); - expect(out.join("")).not.toContain("Mutagen"); + const report = out.join(""); + expect(report).toContain("Server hosting"); + expect(report).toContain("Remote client"); + expect(report).toContain("Project Binding"); + expect(report).toContain("Project sync"); + expect(report).toContain("Daemon"); + expect(report).toContain("Cloud Auth"); + expect(report).not.toContain("local presence"); }); - it("shows remote mode diagnostics", async () => { + it("shows remote client derived URLs and auth state", async () => { const out: string[] = []; await runCli(["doctor"], { env: { - CAPLETS_MODE: "remote", - CAPLETS_SERVER_URL: "https://cloud.caplets.dev/ws/ian", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev/ws/ian", + CAPLETS_REMOTE_TOKEN: "secret", + CAPLETS_REMOTE_WORKSPACE: "ws_1", }, writeOut: (value) => out.push(value), }); - expect(out.join("")).toContain("Mode: remote"); - expect(out.join("")).toContain("Server: https://cloud.caplets.dev/ws/ian"); - expect(out.join("")).toContain("Project sync"); - expect(out.join("")).toContain("Mutagen:"); + const report = out.join(""); + expect(report).toContain("MCP URL: https://cloud.caplets.dev/ws/ian/mcp"); + expect(report).toContain("Control URL: https://cloud.caplets.dev/ws/ian/control"); + expect(report).toContain("Health URL: https://cloud.caplets.dev/ws/ian/healthz"); + expect(report).toContain( + "WebSocket URL: wss://cloud.caplets.dev/ws/ian/control/project-bindings/connect", + ); + expect(report).toContain("Auth: bearer"); + expect(report).not.toContain("secret"); + }); + + it("emits JSON diagnostics with separate server, remote, binding, sync, daemon, and auth sections", async () => { + const out: string[] = []; + + await runCli(["doctor", "--json"], { + env: { + CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets", + CAPLETS_REMOTE_URL: "https://cloud.caplets.dev/ws/ian", + }, + writeOut: (value) => out.push(value), + }); + + expect(JSON.parse(out.join(""))).toMatchObject({ + server: { configured: true }, + remote: { configured: true }, + projectBinding: { state: "not_attached" }, + sync: { state: "idle" }, + daemon: { running: false }, + cloudAuth: { authenticated: false }, + }); }); }); diff --git a/packages/core/test/fixtures/cloud-auth.ts b/packages/core/test/fixtures/cloud-auth.ts new file mode 100644 index 0000000..4543792 --- /dev/null +++ b/packages/core/test/fixtures/cloud-auth.ts @@ -0,0 +1,41 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import type { CloudAuthCredentials } from "../../src/cloud-auth/store"; + +export const fixedNow = "2026-06-03T12:00:00.000Z"; +export const fixedLater = "2999-01-01T00:00:00.000Z"; + +export function tempCloudAuthPath(): string { + return join(mkdtempSync(join(tmpdir(), "caplets-cloud-auth-")), "cloud-auth.json"); +} + +export function hostedCredentials( + overrides: Partial = {}, +): CloudAuthCredentials { + return { + version: 2, + cloudUrl: "https://cloud.caplets.dev", + workspaceId: "workspace_personal", + workspaceSlug: "personal", + accessToken: "cap_access_secret", + refreshToken: "cap_refresh_secret", + expiresAt: fixedLater, + scope: ["project_binding:read", "project_binding:write"], + tokenType: "Bearer", + credentialFamilyId: "family_123", + deviceName: "Test Device", + createdAt: fixedNow, + lastRefreshAt: fixedNow, + ...overrides, + }; +} + +export function assertNoSecrets(output: string): void { + expect(output).not.toContain("cap_access_secret"); + expect(output).not.toContain("cap_refresh_secret"); + expect(output).not.toContain("Authorization"); + expect(output).not.toContain("one_time_code_secret"); +} diff --git a/packages/core/test/fixtures/project-binding/project/build.js b/packages/core/test/fixtures/project-binding/project/build.js new file mode 100644 index 0000000..fcf23bc --- /dev/null +++ b/packages/core/test/fixtures/project-binding/project/build.js @@ -0,0 +1 @@ +console.log(process.cwd()); diff --git a/packages/core/test/fixtures/project-binding/project/package.json b/packages/core/test/fixtures/project-binding/project/package.json new file mode 100644 index 0000000..9e37887 --- /dev/null +++ b/packages/core/test/fixtures/project-binding/project/package.json @@ -0,0 +1,6 @@ +{ + "name": "project-binding-fixture", + "scripts": { + "build": "node build.js" + } +} diff --git a/packages/core/test/native-options.test.ts b/packages/core/test/native-options.test.ts index 7a312ae..c655c5d 100644 --- a/packages/core/test/native-options.test.ts +++ b/packages/core/test/native-options.test.ts @@ -13,7 +13,7 @@ describe("resolveNativeCapletsServiceOptions", () => { it("uses remote mode in auto when a server URL is configured", () => { expect( - resolveNativeCapletsServiceOptions({}, { CAPLETS_SERVER_URL: "http://127.0.0.1:5387" }), + resolveNativeCapletsServiceOptions({}, { CAPLETS_REMOTE_URL: "http://127.0.0.1:5387" }), ).toMatchObject({ mode: "remote", remote: { @@ -28,11 +28,17 @@ describe("resolveNativeCapletsServiceOptions", () => { expect( resolveNativeCapletsServiceOptions( { mode: "local" }, - { CAPLETS_SERVER_URL: "http://127.0.0.1:5387" }, + { CAPLETS_REMOTE_URL: "http://127.0.0.1:5387" }, ), ).toEqual({ mode: "local" }); }); + it("does not treat server hosting env vars as native remote client settings", () => { + expect( + resolveNativeCapletsServiceOptions({}, { CAPLETS_SERVER_URL: "http://127.0.0.1:5387" }), + ).toEqual({ mode: "local" }); + }); + it("requires a URL in explicit remote mode", () => { expect(() => resolveNativeCapletsServiceOptions({ mode: "remote" }, {})).toThrow( expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError, @@ -76,9 +82,9 @@ describe("resolveNativeCapletsServiceOptions", () => { }, }, { - CAPLETS_SERVER_URL: "https://env.example.com", - CAPLETS_SERVER_USER: "env-user", - CAPLETS_SERVER_PASSWORD: ["env", "password"].join("-"), + CAPLETS_REMOTE_URL: "https://env.example.com", + CAPLETS_REMOTE_USER: "env-user", + CAPLETS_REMOTE_PASSWORD: ["env", "password"].join("-"), }, ), ).toMatchObject({ diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index 1df25ae..e06ad7c 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -5,7 +5,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { CapletsError } from "../src/errors"; import { RemoteNativeCapletsService, type RemoteCapletsClient } from "../src/native/remote"; -import { createNativeCapletsService } from "../src/native/service"; +import { + createNativeCapletsService, + resetNativeProjectBindingFallbackWarningForTests, +} from "../src/native/service"; function client( tools: Array<{ name: string; title?: string | undefined; description?: string | undefined }> = [ @@ -240,7 +243,7 @@ describe("RemoteNativeCapletsService", () => { await expect(service.execute("alpha", {})).rejects.toMatchObject({ code: "AUTH_FAILED", - message: expect.stringContaining("CAPLETS_SERVER_USER"), + message: expect.stringContaining("CAPLETS_REMOTE_USER"), } satisfies Partial); await service.close(); @@ -279,6 +282,7 @@ describe("createNativeCapletsService remote mode", () => { const dirs: string[] = []; afterEach(() => { + resetNativeProjectBindingFallbackWarningForTests(); for (const dir of dirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } @@ -377,6 +381,67 @@ describe("createNativeCapletsService remote mode", () => { ); }); + it("fails hard when explicit remote mode cannot create the remote Project Binding service", () => { + const localClose = vi.fn(async () => undefined); + const localService = { + listTools: vi.fn(() => []), + execute: vi.fn(async () => undefined), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => undefined), + close: localClose, + }; + + expect(() => + createNativeCapletsService({ + mode: "remote", + server: { url: "http://127.0.0.1:5387" }, + localServiceFactory: vi.fn(() => localService), + remoteClientFactory: vi.fn(() => { + throw new Error("Project Binding unavailable"); + }), + }), + ).toThrow("Project Binding unavailable"); + + expect(localClose).toHaveBeenCalledTimes(1); + }); + + it("falls back to local overlay once when configured remote Project Binding is unavailable", async () => { + const writeErr = vi.fn(); + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + local: { name: "Local", description: "Local Caplet.", command: process.execPath }, + }, + }); + dirs.push(dir); + + const service = createNativeCapletsService({ + server: { url: "http://127.0.0.1:5387" }, + remoteClientFactory: vi.fn(() => { + throw new Error("Project Binding unavailable"); + }), + configPath, + projectConfigPath, + writeErr, + }); + const secondService = createNativeCapletsService({ + server: { url: "http://127.0.0.1:5387" }, + remoteClientFactory: vi.fn(() => { + throw new Error("Project Binding unavailable"); + }), + configPath, + projectConfigPath, + writeErr, + }); + + expect(service.listTools().map((tool) => tool.caplet)).toEqual(["local"]); + expect(writeErr).toHaveBeenCalledTimes(1); + expect(writeErr).toHaveBeenCalledWith( + "Remote project binding unavailable; using local Caplets only. Run caplets doctor for details.\n", + ); + await service.close(); + await secondService.close(); + }); + it("lists local overlay Caplets after remote tools and shadows matching remote Caplets", async () => { const fixture = client([ { name: "shared", title: "Remote Shared" }, @@ -708,17 +773,15 @@ describe("createNativeCapletsService remote mode", () => { const fetch = vi.fn( async (input: Parameters[0], init?: RequestInit) => { const url = new URL(input.toString()); - if (url.pathname.endsWith("/api/presence") && init?.method === "POST") { + if (url.pathname.endsWith("/api/project-bindings") && init?.method === "POST") { return Response.json({ - presenceId: "presence_1", - expiresAt: "2026-05-30T00:05:00.000Z", + binding: { bindingId: "presence_1" }, }); } - if (url.pathname.endsWith("/api/presence/presence_1") && init?.method === "DELETE") { - return Response.json({ ok: true }); - } - if (url.pathname.endsWith("/api/presence/presence_1/caplets") && init?.method === "PATCH") { - return Response.json({ ok: true }); + if (url.pathname.endsWith("/api/project-bindings/presence_1") && init?.method === "PATCH") { + return Response.json({ + binding: { bindingId: "presence_1" }, + }); } return new Response("not found", { status: 404 }); }, @@ -750,14 +813,19 @@ describe("createNativeCapletsService remote mode", () => { await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.anything())); await service.close(); - const presenceBodies = fetch.mock.calls + const projectBindingBodies = fetch.mock.calls .map(([, init]) => init?.body) .filter((body): body is string => typeof body === "string") - .map((body) => JSON.parse(body) as { allowedCapletIds?: string[] }); - expect(presenceBodies[0]?.allowedCapletIds).toEqual(["local"]); + .map( + (body) => JSON.parse(body) as { projectFiles?: Array<{ path: string; content: string }> }, + ); + expect(projectBindingBodies[0]?.projectFiles).toEqual([{ path: "config.json", content: "{}" }]); expect(fetch).toHaveBeenCalledWith( - new URL("https://cloud.caplets.dev/api/presence/presence_1"), - expect.objectContaining({ method: "DELETE" }), + new URL("https://cloud.caplets.dev/api/project-bindings/presence_1"), + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ state: "offline" }), + }), ); }); @@ -766,17 +834,15 @@ describe("createNativeCapletsService remote mode", () => { const fetch = vi.fn( async (input: Parameters[0], init?: RequestInit) => { const url = new URL(input.toString()); - if (url.pathname.endsWith("/api/presence") && init?.method === "POST") { + if (url.pathname.endsWith("/api/project-bindings") && init?.method === "POST") { return Response.json({ - presenceId: "presence_1", - expiresAt: "2026-05-30T00:05:00.000Z", + binding: { bindingId: "presence_1" }, }); } - if (url.pathname.endsWith("/api/presence/presence_1/caplets") && init?.method === "PATCH") { - return Response.json({ ok: true }); - } - if (url.pathname.endsWith("/api/presence/presence_1") && init?.method === "DELETE") { - return Response.json({ ok: true }); + if (url.pathname.endsWith("/api/project-bindings/presence_1") && init?.method === "PATCH") { + return Response.json({ + binding: { bindingId: "presence_1" }, + }); } return new Response("not found", { status: 404 }); }, @@ -812,11 +878,8 @@ describe("createNativeCapletsService remote mode", () => { await service.reload(); expect(fetch).toHaveBeenCalledWith( - new URL("https://cloud.caplets.dev/api/presence/presence_1/caplets"), - expect.objectContaining({ - method: "PATCH", - body: JSON.stringify({ allowedCapletIds: ["local"] }), - }), + new URL("https://cloud.caplets.dev/api/project-bindings"), + expect.objectContaining({ method: "POST" }), ); await service.close(); }); diff --git a/packages/core/test/package-boundaries.test.ts b/packages/core/test/package-boundaries.test.ts index bd7c8b3..55ed399 100644 --- a/packages/core/test/package-boundaries.test.ts +++ b/packages/core/test/package-boundaries.test.ts @@ -89,6 +89,24 @@ describe("package boundaries", () => { expect(missingTypeDefinitions).toEqual([]); }); + + it("does not publish obsolete Cloud-specific runtime exports", () => { + expect(Object.keys(corePackage.exports)).not.toContain("./cloud-runtime"); + expect(Object.keys(corePackage.exports)).not.toContain("./cloud/bundle-runtime"); + }); + + it("keeps Worker-safe core exports on dedicated bundles", () => { + const dedicatedExports = ["./caplet-source", "./runtime-plan"] as const; + const rootDefault = (corePackage.exports["."] as { default: string }).default; + + for (const specifier of dedicatedExports) { + const target = corePackage.exports[specifier] as { default: string; types: string }; + + expect(target.default, specifier).not.toBe(rootDefault); + expect(target.default, specifier).not.toBe("./dist/index.js"); + expect(target.types, specifier).not.toBe("./dist/index.d.ts"); + } + }); }); function scanFiles(roots: string[]): string[] { diff --git a/packages/core/test/project-binding-gitignore.test.ts b/packages/core/test/project-binding-gitignore.test.ts new file mode 100644 index 0000000..b2da835 --- /dev/null +++ b/packages/core/test/project-binding-gitignore.test.ts @@ -0,0 +1,61 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { attachProjectOnce } from "../src/project-binding/attach"; +import { bootstrapProjectBindingGitignore } from "../src/project-binding/gitignore"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("Project Binding gitignore bootstrap", () => { + it("creates only .caplets/.gitignore with private-state ignore rules", () => { + const projectRoot = tempProjectRoot(); + + const result = bootstrapProjectBindingGitignore(projectRoot); + + expect(result).toEqual({ path: join(projectRoot, ".caplets", ".gitignore"), changed: true }); + expect(readFileSync(result.path, "utf8")).toBe("*\n!.gitignore\n"); + expect(existsSync(join(projectRoot, ".caplets", "config.json"))).toBe(false); + expect(existsSync(join(projectRoot, ".capletsignore"))).toBe(false); + }); + + it("is idempotent and preserves existing ignore entries", () => { + const projectRoot = tempProjectRoot(); + const gitignorePath = join(projectRoot, ".caplets", ".gitignore"); + bootstrapProjectBindingGitignore(projectRoot); + writeFileSync(gitignorePath, `${readFileSync(gitignorePath, "utf8")}custom\n`, "utf8"); + + const result = bootstrapProjectBindingGitignore(projectRoot); + + expect(result.changed).toBe(false); + expect(readFileSync(gitignorePath, "utf8")).toBe("*\n!.gitignore\ncustom\n"); + }); + + it("bootstraps .caplets/.gitignore during attach once", async () => { + const projectRoot = tempProjectRoot(); + + await attachProjectOnce({ + projectRoot, + remoteUrl: "http://127.0.0.1:8787/caplets", + fetch: async () => Response.json({ error: "websocket_upgrade_required" }, { status: 426 }), + }); + + expect(readFileSync(join(projectRoot, ".caplets", ".gitignore"), "utf8")).toBe( + "*\n!.gitignore\n", + ); + expect(existsSync(join(projectRoot, ".caplets", "config.json"))).toBe(false); + expect(existsSync(join(projectRoot, ".capletsignore"))).toBe(false); + }); +}); + +function tempProjectRoot(): string { + const root = mkdtempSync(join(tmpdir(), "caplets-project-binding-")); + tempDirs.push(root); + return root; +} diff --git a/packages/core/test/project-binding-integration.test.ts b/packages/core/test/project-binding-integration.test.ts new file mode 100644 index 0000000..59d2162 --- /dev/null +++ b/packages/core/test/project-binding-integration.test.ts @@ -0,0 +1,123 @@ +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ManagedMutagenProjectSync } from "../src/project-binding/mutagen"; +import { ProjectBindingWorkspaceStore } from "../src/project-binding/workspaces"; +import { createNativeCapletsService } from "../src/native/service"; +import type { RemoteCapletsClient } from "../src/native/remote"; + +const dirs: string[] = []; +const fixtureProjectRoot = resolve(import.meta.dirname, "fixtures/project-binding/project"); + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("Project Binding integration", () => { + it("syncs an attached project into the self-hosted server workspace", async () => { + const stateRoot = mkdtempSync(join(tmpdir(), "caplets-project-binding-state-")); + dirs.push(stateRoot); + const workspaces = new ProjectBindingWorkspaceStore({ root: stateRoot }); + const workspace = await workspaces.ensureWorkspace({ + projectFingerprint: "sha256-fixture", + projectRoot: fixtureProjectRoot, + }); + const sync = new ManagedMutagenProjectSync({ + runner: async (_command, args) => { + if (args[0] === "version") return { stdout: "Mutagen version 0.18.1\n", exitCode: 0 }; + if (args[0] === "sync" && args[1] === "create") { + cpSync(fixtureProjectRoot, workspace.project, { recursive: true }); + return { stdout: "", exitCode: 0 }; + } + if (args[0] === "sync" && args[1] === "list") { + return { + stdout: JSON.stringify([{ name: "caplets-bind_fixture", status: "ready" }]), + exitCode: 0, + }; + } + return { stdout: "", exitCode: 0 }; + }, + }); + + await sync.start({ + bindingId: "bind_fixture", + localProjectRoot: fixtureProjectRoot, + serverProjectRoot: workspace.project, + }); + await sync.refresh({ bindingId: "bind_fixture" }); + + expect(sync.snapshot()).toMatchObject({ state: "ready", bindingId: "bind_fixture" }); + expect(existsSync(join(workspace.project, "package.json"))).toBe(true); + expect(readFileSync(join(workspace.project, "build.js"), "utf8")).toContain("process.cwd()"); + }); + + it("preserves local overlay shadowing while remote-only Caplets execute remotely", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-project-binding-native-")); + dirs.push(dir); + const userDir = join(dir, "user"); + const projectDir = join(dir, "project", ".caplets"); + mkdirSync(userDir, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + const configPath = join(userDir, "config.json"); + const projectConfigPath = join(projectDir, "config.json"); + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + build: { name: "Local Build", description: "Local build.", command: process.execPath }, + }, + }), + "utf8", + ); + writeFileSync(projectConfigPath, JSON.stringify({}), "utf8"); + const remoteClient = remoteClientFixture([ + { name: "build", title: "Remote Build" }, + { name: "deploy", title: "Remote Deploy" }, + ]); + const service = createNativeCapletsService({ + mode: "remote", + server: { url: "http://127.0.0.1:5387" }, + remoteClientFactory: vi.fn(() => remoteClient), + configPath, + projectConfigPath, + }); + + await service.reload(); + expect(service.listTools().map((tool) => [tool.caplet, tool.title])).toEqual([ + ["deploy", "Remote Deploy"], + ["build", "Local Build"], + ]); + + await expect(service.execute("build", { operation: "inspect" })).resolves.toMatchObject({ + content: expect.any(Array), + }); + await expect(service.execute("deploy", { input: true })).resolves.toEqual({ + name: "deploy", + args: { input: true }, + }); + expect(remoteClient.callTool).toHaveBeenCalledTimes(1); + await service.close(); + }); +}); + +function remoteClientFixture( + tools: Array<{ name: string; title?: string | undefined; description?: string | undefined }>, +): RemoteCapletsClient { + return { + listTools: vi.fn(async () => tools), + callTool: vi.fn(async (name: string, args: unknown) => ({ name, args })), + onToolsChanged: vi.fn(() => () => undefined), + close: vi.fn(async () => undefined), + }; +} diff --git a/packages/core/test/project-binding-mutagen.test.ts b/packages/core/test/project-binding-mutagen.test.ts new file mode 100644 index 0000000..914b137 --- /dev/null +++ b/packages/core/test/project-binding-mutagen.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ManagedMutagenProjectSync, + mutagenProjectSyncDoctorData, + planMutagenSyncCreateCommand, + planMutagenSyncListCommand, + planMutagenSyncTerminateCommand, + planMutagenVersionCommand, + type ManagedSyncStateSnapshot, + type MutagenProcessRunner, +} from "../src/project-binding/mutagen"; + +const bindingId = "bind_123"; +const localProjectRoot = "/Users/alice/project"; +const serverProjectRoot = "/state/caplets/workspaces/fp/project"; + +describe("project binding Mutagen command planning", () => { + it("plans the version command", () => { + expect(planMutagenVersionCommand()).toEqual({ command: "mutagen", args: ["version"] }); + }); + + it("plans sync creation with the project roots and binding-scoped name", () => { + expect( + planMutagenSyncCreateCommand({ bindingId, localProjectRoot, serverProjectRoot }), + ).toEqual({ + command: "mutagen", + args: ["sync", "create", localProjectRoot, serverProjectRoot, "--name", "caplets-bind_123"], + }); + }); + + it("plans sync list as JSON for status inspection", () => { + expect(planMutagenSyncListCommand()).toEqual({ + command: "mutagen", + args: ["sync", "list", "--template", "json"], + }); + }); + + it("plans sync termination by binding-scoped name", () => { + expect(planMutagenSyncTerminateCommand(bindingId)).toEqual({ + command: "mutagen", + args: ["sync", "terminate", "caplets-bind_123"], + }); + }); +}); + +describe("managed project sync state transitions", () => { + it("starts project sync through an injectable process runner", async () => { + const runner = recordRunner([ + { stdout: "Mutagen version 0.18.1\nLicense profile: mit\n" }, + { stdout: "" }, + ]); + const sync = new ManagedMutagenProjectSync({ runner }); + + await sync.start({ bindingId, localProjectRoot, serverProjectRoot }); + + expect(runner.calls).toEqual([ + { command: "mutagen", args: ["version"] }, + { + command: "mutagen", + args: ["sync", "create", localProjectRoot, serverProjectRoot, "--name", "caplets-bind_123"], + }, + ]); + expect(sync.snapshot()).toEqual({ + state: "syncing", + bindingId, + publicMessage: "Project sync is starting.", + mutagenBinary: "mutagen", + mutagenVersion: "0.18.1", + lastCommand: { + args: ["sync", "create", localProjectRoot, serverProjectRoot, "--name", "caplets-bind_123"], + command: "mutagen", + exitCode: 0, + stderr: "", + stdout: "", + }, + } satisfies ManagedSyncStateSnapshot); + }); + + it("marks project sync ready when the named session is watching cleanly", async () => { + const sync = new ManagedMutagenProjectSync({ + runner: recordRunner([ + { + stdout: JSON.stringify({ + synchronizations: [{ name: "caplets-bind_123", status: "Watching" }], + }), + }, + ]), + }); + + await sync.refresh({ bindingId }); + + expect(sync.snapshot()).toMatchObject({ + state: "ready", + bindingId, + publicMessage: "Project sync is ready.", + }); + }); + + it("marks project sync syncing while the named session is staging or scanning", async () => { + const sync = new ManagedMutagenProjectSync({ + runner: recordRunner([ + { + stdout: JSON.stringify({ sessions: [{ name: "caplets-bind_123", status: "Scanning" }] }), + }, + ]), + }); + + await sync.refresh({ bindingId }); + + expect(sync.snapshot()).toMatchObject({ + state: "syncing", + publicMessage: "Project sync is catching up.", + }); + }); + + it("terminates project sync and transitions to stopped", async () => { + const runner = recordRunner([{ stdout: "" }]); + const sync = new ManagedMutagenProjectSync({ runner }); + + await sync.stop({ bindingId }); + + expect(runner.calls).toEqual([ + { command: "mutagen", args: ["sync", "terminate", "caplets-bind_123"] }, + ]); + expect(sync.snapshot()).toMatchObject({ + state: "stopped", + bindingId, + publicMessage: "Project sync has stopped.", + }); + }); + + it.each([ + { + name: "missing binary", + error: Object.assign(new Error("spawn mutagen ENOENT"), { code: "ENOENT" }), + diagnosticCode: "project_sync_binary_missing", + }, + { + name: "auth failure", + error: new Error("permission denied: unable to authenticate"), + diagnosticCode: "project_sync_auth_failed", + }, + { + name: "conflict", + error: new Error("synchronization session already exists with this name"), + diagnosticCode: "project_sync_conflict", + }, + { + name: "process exit", + error: Object.assign(new Error("mutagen exited with code 2"), { exitCode: 2 }), + diagnosticCode: "project_sync_process_exit", + }, + ])("maps $name to a blocked project sync diagnostic", async ({ error, diagnosticCode }) => { + const sync = new ManagedMutagenProjectSync({ + runner: recordRunner([error]), + }); + + await sync.start({ bindingId, localProjectRoot, serverProjectRoot }); + + expect(sync.snapshot()).toMatchObject({ + state: "blocked", + bindingId, + diagnosticCode, + publicMessage: "Project sync is blocked.", + }); + }); + + it("keeps Mutagen details in doctor-level data", async () => { + const runner = recordRunner([ + { stdout: "Mutagen version 0.18.1\nLicense profile: mit\n" }, + { stdout: "" }, + ]); + const sync = new ManagedMutagenProjectSync({ runner, mutagenBinary: "/usr/local/bin/mutagen" }); + + await sync.start({ bindingId, localProjectRoot, serverProjectRoot }); + + expect(mutagenProjectSyncDoctorData(sync.snapshot())).toMatchObject({ + state: "syncing", + mutagenBinary: "/usr/local/bin/mutagen", + mutagenVersion: "0.18.1", + lastCommand: { + command: "/usr/local/bin/mutagen", + exitCode: 0, + }, + }); + }); +}); + +function recordRunner( + results: Array> | Error>, +): MutagenProcessRunner & { calls: Array<{ command: string; args: string[] }> } { + const calls: Array<{ command: string; args: string[] }> = []; + const run = vi.fn(async (command, args) => { + calls.push({ command, args }); + const result = results.shift(); + if (result instanceof Error) { + throw result; + } + return result ?? { stdout: "" }; + }) as unknown as MutagenProcessRunner & { calls: Array<{ command: string; args: string[] }> }; + run.calls = calls; + return run; +} diff --git a/packages/core/test/project-binding-routes.test.ts b/packages/core/test/project-binding-routes.test.ts new file mode 100644 index 0000000..441265e --- /dev/null +++ b/packages/core/test/project-binding-routes.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { + PROJECT_BINDING_STATES, + projectBindingConnectUrl, + projectBindingStatusUrl, + type ProjectBindingState, +} from "../src/project-binding/routes"; + +describe("project binding routes", () => { + it("derives the connect URL from a hosted base URL", () => { + expect(projectBindingConnectUrl("https://example.com/caplets")).toBe( + "https://example.com/caplets/control/project-bindings/connect", + ); + }); + + it("derives a binding status URL from a hosted base URL", () => { + expect(projectBindingStatusUrl("https://example.com/caplets", "bind_123")).toBe( + "https://example.com/caplets/control/project-bindings/bind_123/status", + ); + }); + + it("exposes the exact project binding states", () => { + const states: ProjectBindingState[] = [ + "not_attached", + "attaching", + "syncing", + "ready", + "degraded", + "blocked", + "offline", + "cleaning_up", + "ended", + "expired", + ]; + expect(PROJECT_BINDING_STATES).toEqual(states); + }); +}); diff --git a/packages/core/test/project-binding-session.test.ts b/packages/core/test/project-binding-session.test.ts new file mode 100644 index 0000000..e398353 --- /dev/null +++ b/packages/core/test/project-binding-session.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { resolveCapletsRemote } from "../src/remote/options"; +import { runProjectBindingSession } from "../src/project-binding/session"; +import type { ProjectBindingWebSocket } from "../src/project-binding/transport"; + +describe("runProjectBindingSession", () => { + it("creates a session, opens WebSocket, sends heartbeats, and ends remotely on abort", async () => { + const controller = new AbortController(); + const requests: { method: string; url: string; body?: unknown }[] = []; + const events: unknown[] = []; + const socket = new FakeProjectBindingSocket([ + { type: "state", state: "syncing", syncState: "syncing" }, + { type: "ready", bindingId: "binding_1", sessionId: "binding_session_1", syncState: "idle" }, + ]); + + const result = await runProjectBindingSession({ + projectRoot: "/repo", + remote: resolveCapletsRemote({ + url: "https://cloud.caplets.dev", + token: "cap_access_secret", + workspace: "personal", + }), + fetch: async (url, init) => { + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + requests.push({ method: init?.method ?? "GET", url: String(url), body }); + if (String(url).endsWith("/control/project-bindings/sessions")) { + return Response.json( + { + binding: { bindingId: "binding_1", state: "attaching", syncState: "pending" }, + sessionId: "binding_session_1", + }, + { status: 201 }, + ); + } + return Response.json({ ok: true, binding: { bindingId: "binding_1" } }); + }, + webSocketFactory: () => socket, + signal: controller.signal, + heartbeatIntervalMs: 1, + onEvent: (event) => { + events.push(event); + if (event.type === "ready") controller.abort(); + }, + }); + + expect(result).toMatchObject({ bindingId: "binding_1", sessionId: "binding_session_1" }); + expect(socket.sent.map((item) => item.type)).toContain("heartbeat"); + expect(requests.some((request) => request.url.endsWith("/heartbeat"))).toBe(true); + expect( + requests.some((request) => request.method === "DELETE" && request.url.endsWith("/session")), + ).toBe(true); + expect(events).toContainEqual(expect.objectContaining({ type: "ready" })); + expect(events).toContainEqual(expect.objectContaining({ type: "ended" })); + }); + + it("emits a reconnecting event after one reconnectable socket close", async () => { + const controller = new AbortController(); + const events: unknown[] = []; + const sockets = [ + new FakeProjectBindingSocket([], { closeImmediately: true }), + new FakeProjectBindingSocket([ + { + type: "ready", + bindingId: "binding_1", + sessionId: "binding_session_1", + syncState: "idle", + }, + ]), + ]; + + await runProjectBindingSession({ + projectRoot: "/repo", + remote: resolveCapletsRemote({ url: "https://cloud.caplets.dev", token: "token" }), + fetch: async (url) => { + if (String(url).endsWith("/sessions")) { + return Response.json( + { binding: { bindingId: "binding_1" }, sessionId: "binding_session_1" }, + { status: 201 }, + ); + } + return Response.json({ ok: true }); + }, + webSocketFactory: () => sockets.shift() ?? new FakeProjectBindingSocket([]), + signal: controller.signal, + heartbeatIntervalMs: 1, + onEvent: (event) => { + events.push(event); + if (event.type === "ready") controller.abort(); + }, + }); + + expect(events).toContainEqual(expect.objectContaining({ type: "reconnecting", attempt: 1 })); + }); +}); + +class FakeProjectBindingSocket implements ProjectBindingWebSocket { + readonly readyState = 1; + readonly sent: { type: string }[] = []; + private readonly listeners = new Map< + string, + ((event: { data?: unknown; reason?: string }) => void)[] + >(); + + constructor( + private readonly messages: unknown[], + options: { closeImmediately?: boolean } = {}, + ) { + setTimeout(() => { + if (options.closeImmediately) { + this.dispatch("close", { reason: "network reset" }); + return; + } + for (const message of this.messages) { + this.dispatch("message", { data: JSON.stringify(message) }); + } + }, 0); + } + + send(data: string): void { + this.sent.push(JSON.parse(data) as { type: string }); + } + + close(): void {} + + addEventListener( + type: "open" | "message" | "close" | "error", + listener: (event: { data?: unknown; reason?: string }) => void, + ): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + private dispatch(type: string, event: { data?: unknown; reason?: string }): void { + for (const listener of this.listeners.get(type) ?? []) listener(event); + } +} diff --git a/packages/core/test/project-binding-sync-filter.test.ts b/packages/core/test/project-binding-sync-filter.test.ts new file mode 100644 index 0000000..1708d7c --- /dev/null +++ b/packages/core/test/project-binding-sync-filter.test.ts @@ -0,0 +1,43 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { buildProjectSyncManifest } from "../src/project-binding/sync-filter"; + +describe("Project Binding sync filter", () => { + it("honors hard denylist, .gitignore, and .capletsignore while allowing safe env templates", () => { + const root = mkdtempSync(join(tmpdir(), "caplets-sync-filter-")); + mkdirSync(join(root, ".git")); + mkdirSync(join(root, "node_modules"), { recursive: true }); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, ".git", "config"), "secret"); + writeFileSync(join(root, "node_modules", "pkg.js"), "ignored"); + writeFileSync(join(root, ".env"), "SECRET=1"); + writeFileSync(join(root, ".env.example"), "SECRET="); + writeFileSync(join(root, ".gitignore"), "dist\n"); + writeFileSync(join(root, ".capletsignore"), "tmp-local\n.env.example\n"); + writeFileSync(join(root, "dist"), "ignored by gitignore"); + writeFileSync(join(root, "tmp-local"), "ignored by capletsignore"); + writeFileSync(join(root, "src", "index.ts"), "console.log('ok');"); + + const manifest = buildProjectSyncManifest({ projectRoot: root }); + + expect(manifest.files.map((file) => file.relativePath).sort()).toEqual([ + ".capletsignore", + ".env.example", + ".gitignore", + "src/index.ts", + ]); + expect(manifest.exclusionSummary).toEqual( + expect.arrayContaining([ + expect.objectContaining({ source: "hard_denylist", pattern: ".git/" }), + expect.objectContaining({ source: "hard_denylist", pattern: "node_modules/" }), + expect.objectContaining({ source: "gitignore", pattern: "dist" }), + expect.objectContaining({ source: "capletsignore", pattern: "tmp-local" }), + ]), + ); + expect(JSON.stringify(manifest.exclusionSummary)).not.toContain(".git/config"); + expect(JSON.stringify(manifest.exclusionSummary)).not.toContain("SECRET=1"); + }); +}); diff --git a/packages/core/test/project-binding-sync-size.test.ts b/packages/core/test/project-binding-sync-size.test.ts new file mode 100644 index 0000000..287e9c0 --- /dev/null +++ b/packages/core/test/project-binding-sync-size.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_SYNC_LIMITS, + enforceProjectSyncSizeLimits, +} from "../src/project-binding/sync-size"; + +describe("Project Binding sync size limits", () => { + it("returns sync_size_limit_exceeded with safe totals for hosted Free", () => { + const result = enforceProjectSyncSizeLimits({ + tier: "free", + files: [ + { relativePath: "src/a.ts", sizeBytes: 10 * 1024 * 1024 }, + { relativePath: "data/big.bin", sizeBytes: 30 * 1024 * 1024 }, + ], + }); + + expect(result).toMatchObject({ + ok: false, + code: "sync_size_limit_exceeded", + maxSingleFileBytes: DEFAULT_SYNC_LIMITS.free.maxSingleFileBytes, + recoveryCommand: "Add exclusions to .capletsignore or upgrade the workspace plan.", + }); + expect(JSON.stringify(result)).not.toContain("data/big.bin"); + }); +}); diff --git a/packages/core/test/project-binding-workspaces.test.ts b/packages/core/test/project-binding-workspaces.test.ts new file mode 100644 index 0000000..fbebcf2 --- /dev/null +++ b/packages/core/test/project-binding-workspaces.test.ts @@ -0,0 +1,221 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, win32 } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + ProjectBindingWorkspaceStore, + projectBindingWorkspacePaths, + projectBindingWorkspaceRoot, +} from "../src/project-binding/workspaces"; + +const baseNow = new Date("2026-06-02T12:00:00.000Z"); +const fingerprint = "sha256_abc"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("project binding workspace layout", () => { + it("uses XDG_STATE_HOME for self-hosted workspace roots", () => { + const root = projectBindingWorkspaceRoot({ + env: { XDG_STATE_HOME: "/state" }, + platform: "linux", + homedir: "/home/alice", + }); + expect(root).toBe("/state/caplets/workspaces"); + }); + + it("falls back to ~/.local/state on Unix platforms", () => { + const root = projectBindingWorkspaceRoot({ + env: {}, + platform: "linux", + homedir: "/home/alice", + }); + expect(root).toBe("/home/alice/.local/state/caplets/workspaces"); + }); + + it("uses LOCALAPPDATA for Windows self-hosted workspace roots", () => { + const root = projectBindingWorkspaceRoot({ + env: { LOCALAPPDATA: "C:\\Users\\Alice\\AppData\\Local" }, + platform: "win32", + homedir: "C:\\Users\\Alice", + }); + expect(root).toBe( + win32.join("C:\\Users\\Alice\\AppData\\Local", "Caplets", "State", "workspaces"), + ); + }); + + it("derives per-fingerprint project, metadata, lease, and receipt paths", () => { + const paths = projectBindingWorkspacePaths(fingerprint, { root: "/state/caplets/workspaces" }); + expect(paths.root).toBe("/state/caplets/workspaces/sha256_abc"); + expect(paths.project).toBe("/state/caplets/workspaces/sha256_abc/project"); + expect(paths.metadata).toBe("/state/caplets/workspaces/sha256_abc/metadata.json"); + expect(paths.lease("bind_123")).toBe( + "/state/caplets/workspaces/sha256_abc/leases/bind_123.json", + ); + expect(paths.setupReceipts).toBe("/state/caplets/workspaces/sha256_abc/setup/receipts.json"); + }); + + it("derives Windows per-fingerprint project paths", () => { + const paths = projectBindingWorkspacePaths(fingerprint, { + env: { LOCALAPPDATA: "C:\\Users\\Alice\\AppData\\Local" }, + platform: "win32", + homedir: "C:\\Users\\Alice", + }); + expect(paths.project).toBe( + win32.join( + "C:\\Users\\Alice\\AppData\\Local", + "Caplets", + "State", + "workspaces", + fingerprint, + "project", + ), + ); + }); + + it("creates metadata, lease, and setup receipt directories", async () => { + const root = tempRoot(); + const store = new ProjectBindingWorkspaceStore({ root, now: () => baseNow }); + + const paths = await store.ensureWorkspace({ + projectFingerprint: fingerprint, + projectRoot: "/repo", + }); + await store.writeLease({ + bindingId: "bind_123", + projectFingerprint: fingerprint, + state: "ready", + active: true, + updatedAt: baseNow.toISOString(), + }); + await store.writeSetupReceipts(fingerprint, [{ capletId: "repo-cli", status: "succeeded" }]); + + expect(existsSync(paths.project)).toBe(true); + expect(JSON.parse(readFileSync(paths.metadata, "utf8"))).toMatchObject({ + projectFingerprint: fingerprint, + projectRoot: "/repo", + lastActiveAt: baseNow.toISOString(), + }); + expect(JSON.parse(readFileSync(paths.lease("bind_123"), "utf8"))).toMatchObject({ + bindingId: "bind_123", + active: true, + }); + expect(JSON.parse(readFileSync(paths.setupReceipts, "utf8"))).toEqual([ + { capletId: "repo-cli", status: "succeeded" }, + ]); + }); +}); + +describe("project binding workspace cleanup", () => { + it("keeps active leases and their workspaces even when old", async () => { + const root = tempRoot(); + const store = new ProjectBindingWorkspaceStore({ + root, + now: () => baseNow, + }); + await workspace(store, "active-old", 60); + await store.writeLease({ + bindingId: "bind_active", + projectFingerprint: "active-old", + state: "ready", + active: true, + updatedAt: daysAgo(60), + }); + + const result = await store.cleanup(); + + expect(result.deletedWorkspaces).toEqual([]); + expect(existsSync(join(root, "active-old"))).toBe(true); + expect(existsSync(join(root, "active-old", "leases", "bind_active.json"))).toBe(true); + }); + + it("expires inactive stale leases after two minutes", async () => { + const root = tempRoot(); + const store = new ProjectBindingWorkspaceStore({ + root, + now: () => baseNow, + }); + await workspace(store, "lease-stale", 1); + await store.writeLease({ + bindingId: "bind_stale", + projectFingerprint: "lease-stale", + state: "offline", + active: false, + updatedAt: new Date(baseNow.getTime() - 121_000).toISOString(), + }); + + const result = await store.cleanup(); + + expect(result.expiredLeases).toEqual([join(root, "lease-stale", "leases", "bind_stale.json")]); + expect(existsSync(join(root, "lease-stale", "leases", "bind_stale.json"))).toBe(false); + expect(existsSync(join(root, "lease-stale"))).toBe(true); + }); + + it("deletes inactive workspaces after thirty days", async () => { + const root = tempRoot(); + const store = new ProjectBindingWorkspaceStore({ + root, + now: () => baseNow, + }); + await workspace(store, "recent", 29); + await workspace(store, "old", 31); + + const result = await store.cleanup(); + + expect(result.deletedWorkspaces).toEqual([join(root, "old")]); + expect(existsSync(join(root, "recent"))).toBe(true); + expect(existsSync(join(root, "old"))).toBe(false); + }); + + it("applies the soft disk cap by deleting oldest inactive workspaces first", async () => { + const root = tempRoot(); + const sizes = new Map(); + const store = new ProjectBindingWorkspaceStore({ + root, + now: () => baseNow, + softDiskCapBytes: 100, + workspaceSizeBytes: (paths) => sizes.get(paths.projectFingerprint) ?? 0, + }); + await workspace(store, "oldest", 5); + await workspace(store, "middle", 3); + await workspace(store, "newest", 1); + sizes.set("oldest", 60); + sizes.set("middle", 50); + sizes.set("newest", 40); + + const result = await store.cleanup(); + + expect(result.deletedWorkspaces).toEqual([join(root, "oldest")]); + expect(existsSync(join(root, "oldest"))).toBe(false); + expect(existsSync(join(root, "middle"))).toBe(true); + expect(existsSync(join(root, "newest"))).toBe(true); + }); +}); + +function tempRoot(): string { + const dir = mkdtempSync(join(tmpdir(), "caplets-project-binding-")); + tempDirs.push(dir); + return dir; +} + +async function workspace( + store: ProjectBindingWorkspaceStore, + projectFingerprint: string, + inactiveDays: number, +) { + await store.ensureWorkspace({ + projectFingerprint, + projectRoot: `/repos/${projectFingerprint}`, + lastActiveAt: daysAgo(inactiveDays), + }); + writeFileSync(join(store.paths(projectFingerprint).project, "CAPLET.md"), "# Test\n"); +} + +function daysAgo(days: number): string { + return new Date(baseNow.getTime() - days * 24 * 60 * 60 * 1000).toISOString(); +} diff --git a/packages/core/test/remote-control-client.test.ts b/packages/core/test/remote-control-client.test.ts index 0c22c81..532701f 100644 --- a/packages/core/test/remote-control-client.test.ts +++ b/packages/core/test/remote-control-client.test.ts @@ -67,7 +67,7 @@ describe("RemoteControlClient", () => { await expect(client.request("list", {})).rejects.toMatchObject({ code: "AUTH_FAILED", - message: expect.stringContaining("CAPLETS_SERVER_USER"), + message: expect.stringContaining("CAPLETS_REMOTE_USER"), }); try { diff --git a/packages/core/test/remote-options.test.ts b/packages/core/test/remote-options.test.ts new file mode 100644 index 0000000..a21d82b --- /dev/null +++ b/packages/core/test/remote-options.test.ts @@ -0,0 +1,70 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, it } from "vitest"; +import type { CapletsError } from "../src/errors"; +import { resolveCapletsRemote, resolveRemoteMode } from "../src/remote/options"; + +describe("resolveRemoteMode", () => { + it("uses local mode by default without remote client settings", () => { + expect(resolveRemoteMode({}, {})).toEqual({ mode: "local" }); + }); + + it("uses remote mode in auto when CAPLETS_REMOTE_URL is configured", () => { + expect(resolveRemoteMode({}, { CAPLETS_REMOTE_URL: "https://example.com/caplets" })).toEqual({ + mode: "remote", + }); + }); + + it("does not treat CAPLETS_SERVER_URL as client remote configuration", () => { + expect(resolveRemoteMode({}, { CAPLETS_SERVER_URL: "https://example.com/caplets" })).toEqual({ + mode: "local", + }); + }); + + it("requires CAPLETS_REMOTE_URL in explicit remote mode", () => { + expect(() => resolveRemoteMode({ mode: "remote" }, {})).toThrow( + expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError, + ); + }); +}); + +describe("resolveCapletsRemote", () => { + it("derives remote service URLs and Basic Auth from CAPLETS_REMOTE variables", () => { + const password = "remote-password"; + const resolved = resolveCapletsRemote( + {}, + { + CAPLETS_REMOTE_URL: "https://example.com/caplets/", + CAPLETS_REMOTE_USER: "env-user", + CAPLETS_REMOTE_PASSWORD: password, + }, + ); + + expect(resolved).toMatchObject({ + baseUrl: new URL("https://example.com/caplets"), + mcpUrl: new URL("https://example.com/caplets/mcp"), + controlUrl: new URL("https://example.com/caplets/control"), + healthUrl: new URL("https://example.com/caplets/healthz"), + projectBindingWebSocketUrl: new URL( + "wss://example.com/caplets/control/project-bindings/connect", + ), + auth: { type: "basic", user: "env-user", password }, + }); + expect(resolved.workspace).toBeUndefined(); + expect(new Headers(resolved.requestInit.headers).get("authorization")).toBe( + `Basic ${Buffer.from(`env-user:${password}`).toString("base64")}`, + ); + }); + + it("supports bearer token and workspace settings", () => { + const resolved = resolveCapletsRemote( + { token: "input-token", workspace: "team" }, + { CAPLETS_REMOTE_URL: "https://example.com" }, + ); + + expect(resolved.auth).toEqual({ type: "bearer", token: "input-token" }); + expect(resolved.workspace).toBe("team"); + expect(new Headers(resolved.requestInit.headers).get("authorization")).toBe( + "Bearer input-token", + ); + }); +}); diff --git a/packages/core/test/runtime-features.test.ts b/packages/core/test/runtime-features.test.ts new file mode 100644 index 0000000..837feb7 --- /dev/null +++ b/packages/core/test/runtime-features.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import type { CapletConfig } from "../src/config-runtime"; +import { inferRuntimeFeatures, resolveRuntimeResources } from "../src/runtime-plan"; + +describe("runtime feature inference", () => { + it("does not infer features for ordinary filesystem MCP packages", () => { + expect( + inferRuntimeFeatures(mcp("npx", ["-y", "@modelcontextprotocol/server-filesystem"])), + ).toMatchObject({ features: [], provenance: [] }); + }); + + it("infers docker from executable and package command patterns", () => { + expect(inferRuntimeFeatures(mcp("docker", ["run", "mcp/server"]))).toMatchObject({ + features: ["docker"], + }); + expect(inferRuntimeFeatures(mcp("npx", ["-y", "docker-mcp"])).provenance).toEqual([ + expect.objectContaining({ + feature: "docker", + source: "mcp.command", + command: "npx -y docker-mcp", + matched: "docker-mcp", + }), + ]); + }); + + it("infers browser from Playwright, browser-use, and setup commands with provenance", () => { + expect(inferRuntimeFeatures(mcp("npx", ["-y", "@playwright/mcp"]))).toMatchObject({ + features: ["browser"], + }); + expect(inferRuntimeFeatures(mcp("uvx", ["browser-use"]))).toMatchObject({ + features: ["browser"], + }); + + const inferred = inferRuntimeFeatures({ + ...mcp("node", ["server.js"]), + setup: { verify: [{ label: "Browsers", command: "npx", args: ["playwright", "install"] }] }, + }); + expect(inferred.provenance).toEqual([ + expect.objectContaining({ + feature: "browser", + source: "setup.verify", + command: "npx playwright install", + matched: "playwright install", + }), + ]); + }); + + it("merges explicit features before inferred features in stable order", () => { + const inferred = inferRuntimeFeatures({ + ...mcp("npx", ["-y", "@playwright/mcp"]), + runtime: { features: ["docker"] }, + }); + + expect(inferred.features).toEqual(["docker", "browser"]); + expect(inferred.provenance).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + feature: "docker", + source: "explicit", + matched: "runtime.features", + }), + expect.objectContaining({ feature: "browser", source: "mcp.command" }), + ]), + ); + }); + + it("extracts CLI action commands and resolves resource defaults", () => { + const caplet = { + server: "repo", + backend: "cli", + name: "Repo", + description: "Run repository automation.", + disabled: false, + actions: { + inspect: { command: "uvx", args: ["browser-use"] }, + }, + timeoutMs: 1000, + maxOutputBytes: 1000, + } satisfies CapletConfig; + const inferred = inferRuntimeFeatures(caplet); + + expect(inferred).toMatchObject({ features: ["browser"] }); + expect(resolveRuntimeResources(caplet, inferred.features)).toEqual({ + class: "large", + cpu: 4, + memoryMb: 8192, + diskMb: 20480, + }); + expect( + resolveRuntimeResources( + { ...caplet, runtime: { resources: { class: "heavy" } } }, + inferred.features, + { maxClass: "large" }, + ), + ).toMatchObject({ class: "large" }); + }); +}); + +function mcp(command: string, args: string[] = []): CapletConfig { + return { + server: "mcp", + backend: "mcp", + name: "MCP", + description: "Run an MCP test server.", + disabled: false, + transport: "stdio", + command, + args, + startupTimeoutMs: 1000, + callTimeoutMs: 1000, + toolCacheTtlMs: 1000, + }; +} diff --git a/packages/core/test/runtime-plan-contract.test.ts b/packages/core/test/runtime-plan-contract.test.ts new file mode 100644 index 0000000..1a9a8e1 --- /dev/null +++ b/packages/core/test/runtime-plan-contract.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { + HIDDEN_REASON_CODES, + inferRuntimeFeatures, + planCapletRuntimeRoute, + resolveRuntimeResources, + type HiddenReasonCode, +} from "../src/runtime-plan"; + +const runtimeCapletFixtures = { + httpAction: { server: "http_action", backend: "http", expectedRoute: "worker_safe" }, + openapi: { server: "openapi_weather", backend: "openapi", expectedRoute: "worker_safe" }, + graphql: { server: "graphql_inventory", backend: "graphql", expectedRoute: "worker_safe" }, + remoteMcp: { + server: "remote_mcp", + backend: "mcp", + transport: "http", + expectedRoute: "worker_safe", + }, + cli: { server: "cli_process", backend: "cli", command: "node", expectedRoute: "process" }, + stdioMcp: { + server: "stdio_mcp", + backend: "mcp", + transport: "stdio", + command: "node", + expectedRoute: "process", + }, + setupBacked: { + server: "setup_backed", + backend: "cli", + setup: { commands: [{ label: "Install", command: "pnpm", args: ["install"] }] }, + expectedRoute: "process", + }, + docker: { + server: "docker_tool", + backend: "cli", + runtime: { features: ["docker"] }, + expectedRoute: "process", + }, + browser: { + server: "browser_tool", + backend: "cli", + runtime: { features: ["browser"] }, + expectedRoute: "process", + }, + projectBound: { + server: "project_bound", + backend: "cli", + projectBinding: { required: true }, + expectedRoute: "project_bound_process", + }, + localOnly: { server: "local_only", backend: "unknown", expectedRoute: "local_only" }, +} as const; + +describe("hosted runtime route-plan contract", () => { + it("classifies canonical fixture routes", () => { + for (const fixture of Object.values(runtimeCapletFixtures)) { + expect(planCapletRuntimeRoute(fixture).route).toBe(fixture.expectedRoute); + } + }); + + it("infers Docker and browser features from explicit and command provenance", () => { + expect(inferRuntimeFeatures({ runtime: { features: ["docker"] } }).features).toContain( + "docker", + ); + expect(inferRuntimeFeatures({ runtime: { features: ["browser"] } }).features).toContain( + "browser", + ); + expect( + inferRuntimeFeatures({ + setup: { commands: [{ label: "Build", command: "docker", args: ["build", "."] }] }, + }).features, + ).toContain("docker"); + expect( + inferRuntimeFeatures({ command: "playwright", backend: "mcp", transport: "stdio" }).features, + ).toContain("browser"); + }); + + it("resolves Hosted Sandbox resource classes conservatively", () => { + expect(resolveRuntimeResources({ features: [], backend: "http" }).class).toBe("small"); + expect(resolveRuntimeResources({ features: [], backend: "cli" }).class).toBe("medium"); + expect(resolveRuntimeResources({ features: ["docker"], backend: "cli" }).class).toBe("large"); + expect(resolveRuntimeResources({ features: ["browser"], backend: "cli" }).class).toBe("large"); + expect(resolveRuntimeResources({ features: ["docker", "browser"], backend: "cli" }).class).toBe( + "heavy", + ); + expect( + resolveRuntimeResources({ + features: ["docker"], + backend: "cli", + policy: { maxClass: "medium" }, + }), + ).toMatchObject({ class: "medium", cappedByPolicy: "medium" }); + }); + + it("exports every canonical hidden reason code as a stable literal union", () => { + const required: HiddenReasonCode[] = [ + "setup_required", + "setup_running", + "setup_failed", + "verify_failed", + "backend_auth_required", + "backend_check_failed", + "project_binding_required", + "project_binding_syncing", + "project_binding_blocked", + "project_binding_stale", + "provider_unavailable", + "provider_capacity_exhausted", + "provider_queue_timeout", + "policy_denied", + "billing_required", + "subscription_past_due", + "usage_limit_reached", + "email_verification_required", + "docker_required", + "docker_denied", + "browser_required", + "browser_denied", + "resource_class_denied", + "local_only", + "invalid_bundle", + "unsupported_backend", + ]; + + expect(HIDDEN_REASON_CODES).toEqual(required); + }); +}); diff --git a/packages/core/test/runtime-plan.test.ts b/packages/core/test/runtime-plan.test.ts new file mode 100644 index 0000000..1eb94a2 --- /dev/null +++ b/packages/core/test/runtime-plan.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import type { CapletConfig } from "../src/config"; +import { planCapletRuntimeRoutes } from "../src/runtime-plan/planner"; + +describe("runtime route planning", () => { + it("plans route and setup target rules for neutral Caplet configs", () => { + expect(routes([caplet("cli", { backend: "cli" })])).toEqual([ + { id: "cli", route: "process", setupTarget: "hosted_sandbox" }, + ]); + expect( + routes([caplet("stdio", { backend: "mcp", transport: "stdio", command: "uvx" })]), + ).toEqual([{ id: "stdio", route: "process", setupTarget: "hosted_sandbox" }]); + expect(routes([caplet("command", { backend: "mcp", command: "node" })])).toEqual([ + { id: "command", route: "process", setupTarget: "hosted_sandbox" }, + ]); + expect( + routes([ + caplet("remote", { backend: "mcp", transport: "sse", url: "https://example.com/sse" }), + ]), + ).toEqual([{ id: "remote", route: "worker_safe", setupTarget: undefined }]); + expect(routes([caplet("http-api", { backend: "http" })])).toEqual([ + { id: "http-api", route: "worker_safe", setupTarget: undefined }, + ]); + expect(routes([caplet("setup", { backend: "openapi", setup: setup() })], "hosted")).toEqual([ + { id: "setup", route: "process", setupTarget: "hosted_sandbox" }, + ]); + expect( + routes([caplet("setup", { backend: "openapi", setup: setup() })], "self_hosted"), + ).toEqual([{ id: "setup", route: "process", setupTarget: "remote_host" }]); + expect( + routes([caplet("project", { backend: "cli", projectBinding: { required: true } })]), + ).toEqual([{ id: "project", route: "project_bound_process", setupTarget: "hosted_sandbox" }]); + expect(routes([caplet("unknown", { backend: "native" })])).toEqual([ + { id: "unknown", route: "local_only", setupTarget: undefined }, + ]); + }); + + it("keeps Caplet sets worker-safe facades even when children require process routes", () => { + const plans = planCapletRuntimeRoutes([ + caplet("child", { backend: "cli" }), + caplet("set", { backend: "caplets", dependencies: ["child"] }), + caplet("remote-set", { backend: "caplets", dependencies: ["worker"] }), + caplet("worker", { backend: "openapi" }), + ]); + + expect(plans.find((plan) => plan.id === "set")).toEqual( + expect.objectContaining({ route: "worker_safe" }), + ); + expect(plans.find((plan) => plan.id === "remote-set")).toEqual( + expect.objectContaining({ route: "worker_safe" }), + ); + }); + + it("adds runtime feature provenance and resource defaults to plans", () => { + const [plan] = planCapletRuntimeRoutes([ + caplet("browser", { + backend: "cli", + runtime: { features: ["docker"] }, + actions: { + inspect: { command: "npx", args: ["-y", "@playwright/mcp"] }, + }, + }), + ]); + + expect(plan?.runtime).toMatchObject({ + features: ["docker", "browser"], + resources: { class: "heavy", cpu: 8, memoryMb: 16384, diskMb: 40960 }, + }); + expect(plan?.runtime.featureProvenance).toEqual( + expect.arrayContaining([ + expect.objectContaining({ feature: "docker", source: "explicit" }), + expect.objectContaining({ + feature: "browser", + source: "cli.action", + command: "npx -y @playwright/mcp", + matched: "@playwright/mcp", + }), + ]), + ); + }); +}); + +function routes(caplets: CapletConfig[], deployment: "hosted" | "self_hosted" = "hosted") { + return planCapletRuntimeRoutes(caplets, { deployment }).map((plan) => ({ + id: plan.id, + route: plan.route, + setupTarget: plan.setupTarget, + })); +} + +function caplet(id: string, overrides: Record): CapletConfig { + return { + server: id, + name: id, + description: `Test Caplet ${id}.`, + disabled: false, + ...overrides, + } as CapletConfig; +} + +function setup() { + return { commands: [{ label: "Install", command: "pnpm" }] }; +} diff --git a/packages/core/test/serve-daemon.test.ts b/packages/core/test/serve-daemon.test.ts new file mode 100644 index 0000000..de63c8f --- /dev/null +++ b/packages/core/test/serve-daemon.test.ts @@ -0,0 +1,375 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, posix, win32 } from "node:path"; +import { describe, expect, it } from "vitest"; +import { runCli } from "../src/cli"; +import type { CapletsError } from "../src/errors"; +import { + buildDaemonPlatformDescriptor, + daemonStatus, + disableDaemon, + enableDaemon, + resolveServeDaemonPaths, + restartDaemon, + startDaemon, + stopDaemon, + type DaemonProcessRunner, +} from "../src/serve"; + +describe("caplets serve daemon CLI", () => { + it("shows daemon subcommand help", async () => { + const out: string[] = []; + + await runCli(["serve", "start", "--help"], { writeOut: (value) => out.push(value) }); + await runCli(["serve", "status", "--help"], { writeOut: (value) => out.push(value) }); + + const text = out.join(""); + expect(text).toContain("Start the default Caplets HTTP daemon."); + expect(text).toContain("Show the default Caplets HTTP daemon status."); + expect(text).toContain("--transport "); + }); + + it("rejects stdio daemon start", async () => { + await expect( + runCli(["serve", "start", "--transport", "stdio"], { writeErr: () => {} }), + ).rejects.toThrow( + expect.objectContaining({ + code: "REQUEST_INVALID", + message: "Daemonized serve requires --transport http.", + }) as CapletsError, + ); + }); + + it("defaults daemon start to HTTP", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-cli-")); + const out: string[] = []; + try { + await runCli(["serve", "start"], { + env: { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }, + writeOut: (value) => out.push(value), + daemon: { + process: fakeProcessRunner({ running: false, pid: 1200 }), + }, + }); + + expect(out.join("")).toContain("Started Caplets HTTP daemon on 127.0.0.1:5387."); + const config = JSON.parse( + readFileSync(join(dir, "config", "caplets", "serve", "default.json"), "utf8"), + ) as { serve: { transport: string } }; + expect(config.serve.transport).toBe("http"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prints redacted JSON status", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-cli-")); + const out: string[] = []; + try { + await startDaemon( + { password: "super-secret-password" }, + { + env: { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }, + process: fakeProcessRunner({ running: false, pid: 1300 }), + }, + ); + + await runCli(["serve", "status", "--json"], { + env: { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }, + writeOut: (value) => out.push(value), + daemon: { + process: fakeProcessRunner({ running: true, pid: 1300 }), + }, + }); + + const status = JSON.parse(out.join("")) as { + config: { serve: { auth: { password: string } } }; + }; + expect(status.config.serve.auth.password).toBe("[REDACTED]"); + expect(out.join("")).not.toContain("super-secret-password"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("serve daemon paths", () => { + it("uses XDG state and config roots for macOS and Linux", () => { + const paths = resolveServeDaemonPaths({ + env: { XDG_CONFIG_HOME: "/config", XDG_STATE_HOME: "/state" }, + home: "/home/alice", + platform: "linux", + }); + + expect(paths.stateFile).toBe(posix.join("/state", "caplets", "serve", "default", "state.json")); + expect(paths.pidFile).toBe(posix.join("/state", "caplets", "serve", "default", "server.pid")); + expect(paths.stdoutLog).toBe( + posix.join("/state", "caplets", "serve", "default", "logs", "stdout.log"), + ); + expect(paths.stderrLog).toBe( + posix.join("/state", "caplets", "serve", "default", "logs", "stderr.log"), + ); + expect(paths.configFile).toBe(posix.join("/config", "caplets", "serve", "default.json")); + }); + + it("uses LOCALAPPDATA state and APPDATA config roots for Windows", () => { + const paths = resolveServeDaemonPaths({ + env: { + APPDATA: "C:\\Users\\Alice\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\Alice\\AppData\\Local", + }, + home: "C:\\Users\\Alice", + platform: "win32", + }); + + expect(paths.stateFile).toBe( + win32.join( + "C:\\Users\\Alice\\AppData\\Local", + "Caplets", + "State", + "serve", + "default", + "state.json", + ), + ); + expect(paths.pidFile).toBe( + win32.join( + "C:\\Users\\Alice\\AppData\\Local", + "Caplets", + "State", + "serve", + "default", + "server.pid", + ), + ); + expect(paths.stdoutLog).toBe( + win32.join( + "C:\\Users\\Alice\\AppData\\Local", + "Caplets", + "State", + "serve", + "default", + "logs", + "stdout.log", + ), + ); + expect(paths.configFile).toBe( + win32.join("C:\\Users\\Alice\\AppData\\Roaming", "Caplets", "serve", "default.json"), + ); + }); +}); + +describe("serve daemon lifecycle", () => { + it("starts, reports status, and stops the default instance", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-")); + const process = fakeProcessRunner({ running: false, pid: 1400 }); + try { + const options = { + env: { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }, + process, + }; + + const started = await startDaemon({ port: "5480", password: "secret-password" }, options); + expect(started.status.running).toBe(true); + expect(started.status.pid).toBe(1400); + expect(process.starts[0]?.args).toEqual([ + "serve", + "--transport", + "http", + "--host", + "127.0.0.1", + "--port", + "5480", + "--path", + "/", + "--user", + "caplets", + "--password", + "secret-password", + ]); + + const status = await daemonStatus({ + ...options, + process: fakeProcessRunner({ running: true, pid: 1400 }), + }); + expect(status.running).toBe(true); + expect(status.config?.serve.port).toBe(5480); + + const stopped = await stopDaemon({ + ...options, + process: fakeProcessRunner({ running: true, pid: 1400 }), + }); + expect(stopped.status.running).toBe(false); + expect(stopped.status.pid).toBeUndefined(); + + const afterStop = await daemonStatus({ + ...options, + process: fakeProcessRunner({ running: true, pid: 1400 }), + }); + expect(afterStop.running).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("fails start when already running and lets restart apply config changes", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-")); + try { + const env = { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }; + await startDaemon( + { port: "5480" }, + { env, process: fakeProcessRunner({ running: false, pid: 1400 }) }, + ); + + await expect( + startDaemon( + { port: "5481" }, + { env, process: fakeProcessRunner({ running: true, pid: 1400 }) }, + ), + ).rejects.toThrow("Caplets HTTP daemon is already running."); + + const process = fakeProcessRunner({ running: true, pid: 1400, nextPid: 1401 }); + const restarted = await restartDaemon({ port: "5481" }, { env, process }); + + expect(restarted.status.running).toBe(true); + expect(restarted.status.pid).toBe(1401); + expect(process.stops).toEqual([1400]); + expect(process.starts[0]?.args).toContain("5481"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("enables and disables the platform service without installing in tests", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-daemon-")); + try { + const env = { XDG_CONFIG_HOME: join(dir, "config"), XDG_STATE_HOME: join(dir, "state") }; + await startDaemon( + { port: "5482" }, + { env, process: fakeProcessRunner({ running: false, pid: 1400 }) }, + ); + + const enabled = await enableDaemon({ env, platform: "linux", serviceAvailable: true }); + expect(enabled.enabled).toBe(true); + expect(enabled.descriptor.kind).toBe("systemd-user"); + + const disabled = await disableDaemon({ env, platform: "linux", serviceAvailable: true }); + expect(disabled.enabled).toBe(false); + expect(disabled.descriptor.kind).toBe("systemd-user"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("serve daemon platform descriptors", () => { + it("describes a macOS launchd user agent", () => { + const descriptor = buildDaemonPlatformDescriptor({ + platform: "darwin", + paths: resolveServeDaemonPaths({ + env: { XDG_CONFIG_HOME: "/config", XDG_STATE_HOME: "/state" }, + home: "/Users/alice", + platform: "darwin", + }), + command: { executable: "/usr/local/bin/caplets", args: ["serve", "--transport", "http"] }, + }); + + expect(descriptor.kind).toBe("launchd-user-agent"); + if (descriptor.kind !== "launchd-user-agent") throw new Error("expected launchd descriptor"); + expect(descriptor.label).toBe("dev.caplets.serve.default"); + expect(descriptor.plist).toContain("Label"); + expect(descriptor.plist).toContain("dev.caplets.serve.default"); + expect(descriptor.plist).toContain("/usr/local/bin/caplets"); + }); + + it("describes a Linux systemd user service when available", () => { + const descriptor = buildDaemonPlatformDescriptor({ + platform: "linux", + serviceAvailable: true, + paths: resolveServeDaemonPaths({ + env: { XDG_CONFIG_HOME: "/config", XDG_STATE_HOME: "/state" }, + home: "/home/alice", + platform: "linux", + }), + command: { executable: "/usr/bin/caplets", args: ["serve", "--transport", "http"] }, + }); + + expect(descriptor.kind).toBe("systemd-user"); + if (descriptor.kind !== "systemd-user") throw new Error("expected systemd descriptor"); + expect(descriptor.unitName).toBe("caplets-serve-default.service"); + expect(descriptor.unit).toContain("[Service]"); + expect(descriptor.unit).toContain("ExecStart=/usr/bin/caplets serve --transport http"); + }); + + it("describes a Linux fallback when systemd user services are unavailable", () => { + const descriptor = buildDaemonPlatformDescriptor({ + platform: "linux", + serviceAvailable: false, + paths: resolveServeDaemonPaths({ + env: { XDG_CONFIG_HOME: "/config", XDG_STATE_HOME: "/state" }, + home: "/home/alice", + platform: "linux", + }), + command: { executable: "/usr/bin/caplets", args: ["serve", "--transport", "http"] }, + }); + + expect(descriptor.kind).toBe("manual"); + if (descriptor.kind !== "manual") throw new Error("expected manual descriptor"); + expect(descriptor.reason).toContain("systemd user service is not available"); + }); + + it("describes a Windows per-user Scheduled Task command plan", () => { + const descriptor = buildDaemonPlatformDescriptor({ + platform: "win32", + paths: resolveServeDaemonPaths({ + env: { + APPDATA: "C:\\Users\\Alice\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\Alice\\AppData\\Local", + }, + home: "C:\\Users\\Alice", + platform: "win32", + }), + command: { + executable: "C:\\Program Files\\nodejs\\caplets.cmd", + args: ["serve", "--transport", "http"], + }, + }); + + expect(descriptor.kind).toBe("windows-scheduled-task"); + if (descriptor.kind !== "windows-scheduled-task") + throw new Error("expected scheduled task descriptor"); + expect(descriptor.taskName).toBe("Caplets Serve Default"); + expect(descriptor.commands.register).toContain("schtasks"); + expect(descriptor.commands.register).toContain("/SC ONLOGON"); + expect(descriptor.commands.register).toContain("caplets.cmd"); + }); +}); + +function fakeProcessRunner(initial: { + running: boolean; + pid: number; + nextPid?: number; +}): DaemonProcessRunner & { + starts: Array<{ args: string[]; stdoutLog: string; stderrLog: string }>; + stops: number[]; +} { + let running = initial.running; + let pid = initial.pid; + const starts: Array<{ args: string[]; stdoutLog: string; stderrLog: string }> = []; + const stops: number[] = []; + return { + starts, + stops, + isRunning: async (candidate) => running && candidate === pid, + start: async (command) => { + pid = initial.nextPid ?? pid; + running = true; + starts.push(command); + return pid; + }, + stop: async (candidate) => { + stops.push(candidate); + running = false; + }, + }; +} diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts index b42d3dd..186f72a 100644 --- a/packages/core/test/serve-http.test.ts +++ b/packages/core/test/serve-http.test.ts @@ -120,6 +120,55 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("exposes authenticated Project Binding status under the control namespace", async () => { + const { engine } = testEngine(); + const testPassword = ["test", "password"].join("-"); + const app = createHttpServeApp( + httpOptions({ auth: { enabled: true, user: "caplets", password: testPassword } }), + engine, + { writeErr: () => {} }, + ); + + const missing = await app.request( + "http://127.0.0.1:5387/control/project-bindings/bind_123/status", + ); + expect(missing.status).toBe(401); + + const response = await app.request( + "http://127.0.0.1:5387/control/project-bindings/bind_123/status", + { + headers: { + authorization: `Basic ${Buffer.from(`caplets:${testPassword}`).toString("base64")}`, + }, + }, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + bindingId: "bind_123", + state: "not_attached", + }); + + await engine.close(); + }); + + it("exposes the Project Binding WebSocket upgrade route under a base path", async () => { + const { engine } = testEngine(); + const app = createHttpServeApp(httpOptions({ path: "/caplets" }), engine, { + writeErr: () => {}, + }); + + const response = await app.request( + "http://127.0.0.1:5387/caplets/control/project-bindings/connect", + ); + + expect(response.status).toBe(426); + await expect(response.json()).resolves.toMatchObject({ + error: "websocket_upgrade_required", + }); + + await engine.close(); + }); + it("mounts service routes under a base path", async () => { const { engine } = testEngine(); const app = createHttpServeApp(httpOptions({ path: "/caplets" }), engine, { diff --git a/packages/core/test/serve-options.test.ts b/packages/core/test/serve-options.test.ts index cbe6400..13d3e02 100644 --- a/packages/core/test/serve-options.test.ts +++ b/packages/core/test/serve-options.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveServeOptions } from "../src/serve/options"; +import { resolveDaemonServeOptions, resolveServeOptions } from "../src/serve/options"; describe("resolveServeOptions", () => { it("defaults serve to stdio", () => { @@ -176,6 +176,21 @@ describe("resolveServeOptions", () => { ); }); + it("defaults daemonized serve to HTTP", () => { + expect(resolveDaemonServeOptions({}, {})).toMatchObject({ + transport: "http", + host: "127.0.0.1", + port: 5387, + path: "/", + }); + }); + + it("rejects daemonized stdio serve", () => { + expect(() => resolveDaemonServeOptions({ transport: "stdio" }, {})).toThrow( + "Daemonized serve requires --transport http.", + ); + }); + it("rejects invalid port and path", () => { expect(() => resolveServeOptions({ transport: "http", port: "0" }, {})).toThrow( /valid TCP port/u, diff --git a/packages/core/test/setup-runner.test.ts b/packages/core/test/setup-runner.test.ts index c46f531..fb92bb6 100644 --- a/packages/core/test/setup-runner.test.ts +++ b/packages/core/test/setup-runner.test.ts @@ -3,12 +3,56 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import type { CapletConfig } from "../src/config"; +import { runSetup } from "../src/cli/setup"; import { capletSetupContentHash } from "../src/setup/hash"; import { LocalSetupStore } from "../src/setup/local-store"; import { runCapletSetup, type SetupSpawn } from "../src/setup/runner"; -import type { SetupAttempt } from "../src/setup/types"; +import type { SetupAttempt, SetupTargetKind } from "../src/setup/types"; describe("setup runner", () => { + it("accepts only local_host, remote_host, and hosted_sandbox setup targets", async () => { + const accepted: SetupTargetKind[] = ["local_host", "remote_host", "hosted_sandbox"]; + expect([...accepted].sort()).toEqual(["hosted_sandbox", "local_host", "remote_host"]); + + for (const targetKind of accepted) { + await expect( + runCapletSetup({ + projectFingerprint: "project", + capletId: "ast-grep", + contentHash: "hash", + targetKind, + actor: "cli-yes", + approved: true, + setup: { commands: [] }, + store: memoryStore(), + spawn: successfulSpawn(), + }), + ).resolves.toEqual([]); + } + }); + + it.each(["local", "remote_server", "hosted_container"])( + "rejects legacy stored setup target %s", + async (targetKind) => { + await expect( + runCapletSetup({ + projectFingerprint: "project", + capletId: "ast-grep", + contentHash: "hash", + targetKind: targetKind as SetupTargetKind, + actor: "cli-yes", + approved: true, + setup: { commands: [] }, + store: memoryStore(), + spawn: successfulSpawn(), + }), + ).rejects.toMatchObject({ + code: "REQUEST_INVALID", + message: "setup target must be one of: local_host, remote_host, hosted_sandbox", + }); + }, + ); + it("changes content hash when setup metadata changes", () => { const first = caplet("npm", ["install", "-g", "first"]); const second = caplet("npm", ["install", "-g", "second"]); @@ -19,9 +63,10 @@ describe("setup runner", () => { const store = memoryStore(); await expect( runCapletSetup({ + projectFingerprint: "project", capletId: "ast-grep", contentHash: "hash", - targetKind: "local", + targetKind: "local_host", actor: "cli-interactive", approved: false, setup: { commands: [{ label: "Install", command: "npm" }] }, @@ -35,9 +80,10 @@ describe("setup runner", () => { it("records successful setup and verify attempts without executing real package managers", async () => { const store = memoryStore(); const attempts = await runCapletSetup({ + projectFingerprint: "project", capletId: "ast-grep", contentHash: "hash", - targetKind: "local", + targetKind: "local_host", actor: "cli-yes", approved: true, setup: { @@ -50,15 +96,17 @@ describe("setup runner", () => { expect(attempts).toHaveLength(2); expect(attempts.map((attempt) => attempt.status)).toEqual(["succeeded", "succeeded"]); expect(attempts[0]?.actor).toBe("cli-yes"); + expect(attempts[0]?.projectFingerprint).toBe("project"); expect(store.attempts).toHaveLength(2); }); it("leaves status failed when verify fails", async () => { const store = memoryStore(); const attempts = await runCapletSetup({ + projectFingerprint: "project", capletId: "ast-grep", contentHash: "hash", - targetKind: "local", + targetKind: "local_host", actor: "cli-yes", approved: true, setup: { @@ -79,9 +127,10 @@ describe("setup runner", () => { it("caps output and redacts secret-looking env values", async () => { const store = memoryStore(); const attempts = await runCapletSetup({ + projectFingerprint: "project", capletId: "secret", contentHash: "hash", - targetKind: "local", + targetKind: "local_host", actor: "cli-yes", approved: true, setup: { @@ -106,6 +155,33 @@ describe("setup runner", () => { expect(attempts[0]?.redacted).toBe(true); }); + it("keys approvals by project fingerprint, caplet, content hash, and target", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-setup-store-")); + try { + const store = new LocalSetupStore({ baseDir: dir }); + await store.approve({ + projectFingerprint: "project-a", + capletId: "ast-grep", + contentHash: "hash", + targetKind: "remote_host", + actor: "cli-yes", + approvedAt: "2026-06-02T12:00:00.000Z", + }); + + await expect( + store.getApproval("project-a", "ast-grep", "hash", "remote_host"), + ).resolves.toMatchObject({ projectFingerprint: "project-a" }); + await expect( + store.getApproval("project-b", "ast-grep", "hash", "remote_host"), + ).resolves.toBeUndefined(); + await expect( + store.getApproval("project-a", "ast-grep", "hash", "local_host"), + ).resolves.toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("keeps local attempts to the free retention window", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-setup-store-")); try { @@ -116,13 +192,127 @@ describe("setup runner", () => { capletId: "ast-grep", }); } - const attempts = await store.listAttempts("ast-grep"); + const attempts = await store.listAttempts("project", "ast-grep"); expect(attempts).toHaveLength(3); expect(attempts.map((entry) => entry.commandLabel)).toEqual(["2", "3", "4"]); } finally { rmSync(dir, { recursive: true, force: true }); } }); + + it("keeps attempt retention scoped to a project fingerprint", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-setup-store-")); + try { + const store = new LocalSetupStore({ baseDir: dir, maxAttempts: 3, retentionDays: 7 }); + await store.recordAttempt({ + ...attempt(0), + projectFingerprint: "project-a", + commandLabel: "a", + }); + await store.recordAttempt({ + ...attempt(1), + projectFingerprint: "project-b", + commandLabel: "b", + }); + + await expect(store.listAttempts("project-a", "ast-grep")).resolves.toMatchObject([ + { projectFingerprint: "project-a", commandLabel: "a" }, + ]); + await expect(store.listAttempts("project-b", "ast-grep")).resolves.toMatchObject([ + { projectFingerprint: "project-b", commandLabel: "b" }, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("uses neutral setup target names in CLI setup copy", async () => { + const local = await runSetup("opencode", { + target: "local_host", + dryRun: true, + format: "json", + }); + expect(JSON.parse(local)).toMatchObject({ targetKind: "local_host" }); + + const remoteServer = await runSetup("opencode", { + remote: true, + target: "remote_host", + dryRun: true, + format: "json", + }); + expect(JSON.parse(remoteServer)).toMatchObject({ targetKind: "remote_host" }); + + const hostedContainer = await runSetup("opencode", { + target: "hosted_sandbox", + dryRun: true, + format: "json", + }); + expect(JSON.parse(hostedContainer)).toMatchObject({ targetKind: "hosted_sandbox" }); + }); + + it.each(["remote", "cloud", "hosted_worker"])( + "serializes legacy CLI setup alias %s to a semantic target", + async (target) => { + const result = await runSetup("opencode", { + target: target as "remote" | "cloud" | "hosted_worker", + dryRun: true, + format: "json", + }); + expect(JSON.parse(result).targetKind).toBe( + target === "remote" ? "remote_host" : "hosted_sandbox", + ); + }, + ); + + it("records setup hash and runtime features without requiring project output retention", async () => { + const store = memoryStore(); + const attempts = await runCapletSetup({ + projectFingerprint: "project", + capletId: "browser", + contentHash: "content", + setupHash: "setup", + targetKind: "hosted_sandbox", + runtimeFeatures: ["browser"], + actor: "cli-yes", + approved: true, + setup: { commands: [{ label: "Install", command: "npx", args: ["playwright", "install"] }] }, + store, + spawn: successfulSpawn(), + }); + + expect(attempts[0]).toMatchObject({ + setupHash: "setup", + runtimeFeatures: ["browser"], + targetKind: "hosted_sandbox", + }); + }); + + it("rejects non-project setup commands that run inside a synced project workspace", async () => { + const projectRoot = mkdtempSync(join(tmpdir(), "caplets-project-")); + try { + await expect( + runCapletSetup({ + capletId: "global-tool", + contentHash: "hash", + targetKind: "local_host", + actor: "cli-yes", + approved: true, + projectWorkspacePath: projectRoot, + projectBindingRequired: false, + setup: { + commands: [{ label: "Install", command: "npm", cwd: join(projectRoot, "tools") }], + }, + store: memoryStore(), + spawn: successfulSpawn(), + }), + ).rejects.toMatchObject({ + code: "REQUEST_INVALID", + message: expect.stringContaining("Non-project setup cannot run inside project workspace"), + }); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } + }); }); function caplet(command: string, args: string[]): CapletConfig { @@ -159,9 +349,12 @@ function memoryStore() { function attempt(index: number): SetupAttempt { return { attemptId: `attempt-${index}`, - capletId: "caplet", + projectFingerprint: "project", + capletId: "ast-grep", contentHash: "hash", - targetKind: "local", + setupHash: "hash", + targetKind: "local_host", + runtimeFeatures: [], actor: "cli-yes", status: "succeeded", phase: "commands", diff --git a/schemas/caplet.schema.json b/schemas/caplet.schema.json index 067bec9..a8f8b41 100644 --- a/schemas/caplet.schema.json +++ b/schemas/caplet.schema.json @@ -140,6 +140,42 @@ "additionalProperties": false, "description": "Optional explicit setup and verification metadata for this Caplet." }, + "projectBinding": { + "type": "object", + "properties": { + "required": { + "type": "boolean", + "const": true + } + }, + "required": ["required"], + "additionalProperties": false, + "description": "Project Binding requirements for Caplets that need an attached project." + }, + "runtime": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["docker", "browser"] + } + }, + "resources": { + "type": "object", + "properties": { + "class": { + "type": "string", + "enum": ["standard", "large", "heavy"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Runtime feature and resource requirements for hosted execution." + }, "mcpServer": { "type": "object", "properties": { @@ -369,6 +405,42 @@ "disabled": { "description": "When true, omit this Caplet from discovery and do not start its MCP server.", "type": "boolean" + }, + "projectBinding": { + "type": "object", + "properties": { + "required": { + "type": "boolean", + "const": true + } + }, + "required": ["required"], + "additionalProperties": false, + "description": "Project Binding requirements for Caplets that need an attached project." + }, + "runtime": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["docker", "browser"] + } + }, + "resources": { + "type": "object", + "properties": { + "class": { + "type": "string", + "enum": ["standard", "large", "heavy"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Runtime feature and resource requirements for hosted execution." } }, "additionalProperties": false, @@ -575,6 +647,42 @@ "disabled": { "description": "When true, omit this Caplet from discovery.", "type": "boolean" + }, + "projectBinding": { + "type": "object", + "properties": { + "required": { + "type": "boolean", + "const": true + } + }, + "required": ["required"], + "additionalProperties": false, + "description": "Project Binding requirements for Caplets that need an attached project." + }, + "runtime": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["docker", "browser"] + } + }, + "resources": { + "type": "object", + "properties": { + "class": { + "type": "string", + "enum": ["standard", "large", "heavy"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Runtime feature and resource requirements for hosted execution." } }, "required": ["auth"], @@ -827,6 +935,42 @@ "disabled": { "description": "When true, omit this Caplet from discovery.", "type": "boolean" + }, + "projectBinding": { + "type": "object", + "properties": { + "required": { + "type": "boolean", + "const": true + } + }, + "required": ["required"], + "additionalProperties": false, + "description": "Project Binding requirements for Caplets that need an attached project." + }, + "runtime": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["docker", "browser"] + } + }, + "resources": { + "type": "object", + "properties": { + "class": { + "type": "string", + "enum": ["standard", "large", "heavy"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Runtime feature and resource requirements for hosted execution." } }, "required": ["endpointUrl", "auth"], @@ -1109,6 +1253,42 @@ "disabled": { "description": "When true, omit this Caplet from discovery.", "type": "boolean" + }, + "projectBinding": { + "type": "object", + "properties": { + "required": { + "type": "boolean", + "const": true + } + }, + "required": ["required"], + "additionalProperties": false, + "description": "Project Binding requirements for Caplets that need an attached project." + }, + "runtime": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["docker", "browser"] + } + }, + "resources": { + "type": "object", + "properties": { + "class": { + "type": "string", + "enum": ["standard", "large", "heavy"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Runtime feature and resource requirements for hosted execution." } }, "required": ["baseUrl", "auth", "actions"], @@ -1248,6 +1428,42 @@ "disabled": { "description": "When true, omit this Caplet from discovery.", "type": "boolean" + }, + "projectBinding": { + "type": "object", + "properties": { + "required": { + "type": "boolean", + "const": true + } + }, + "required": ["required"], + "additionalProperties": false, + "description": "Project Binding requirements for Caplets that need an attached project." + }, + "runtime": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["docker", "browser"] + } + }, + "resources": { + "type": "object", + "properties": { + "class": { + "type": "string", + "enum": ["standard", "large", "heavy"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Runtime feature and resource requirements for hosted execution." } }, "required": ["actions"], @@ -1285,6 +1501,42 @@ "disabled": { "description": "When true, omit this Caplet from discovery.", "type": "boolean" + }, + "projectBinding": { + "type": "object", + "properties": { + "required": { + "type": "boolean", + "const": true + } + }, + "required": ["required"], + "additionalProperties": false, + "description": "Project Binding requirements for Caplets that need an attached project." + }, + "runtime": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["docker", "browser"] + } + }, + "resources": { + "type": "object", + "properties": { + "class": { + "type": "string", + "enum": ["standard", "large", "heavy"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Runtime feature and resource requirements for hosted execution." } }, "additionalProperties": false, diff --git a/schemas/caplets-config.schema.json b/schemas/caplets-config.schema.json index 66b8ab5..399c2aa 100644 --- a/schemas/caplets-config.schema.json +++ b/schemas/caplets-config.schema.json @@ -411,6 +411,46 @@ }, "additionalProperties": false }, + "projectBinding": { + "type": "object", + "properties": { + "required": { + "type": "boolean", + "const": true, + "description": "Requires Project Binding before this Caplet can run." + } + }, + "required": ["required"], + "additionalProperties": false, + "description": "Project Binding requirements for Caplets that need an attached project." + }, + "runtime": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["docker", "browser"] + }, + "description": "Runtime features required by this Caplet." + }, + "resources": { + "description": "Hosted sandbox resource requirements.", + "type": "object", + "properties": { + "class": { + "description": "Requested hosted sandbox resource class.", + "type": "string", + "enum": ["standard", "large", "heavy"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Runtime feature and resource requirements for hosted execution." + }, "startupTimeoutMs": { "default": 10000, "description": "Timeout in milliseconds for starting or checking a downstream server.", @@ -764,6 +804,46 @@ }, "additionalProperties": false }, + "projectBinding": { + "type": "object", + "properties": { + "required": { + "type": "boolean", + "const": true, + "description": "Requires Project Binding before this Caplet can run." + } + }, + "required": ["required"], + "additionalProperties": false, + "description": "Project Binding requirements for Caplets that need an attached project." + }, + "runtime": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["docker", "browser"] + }, + "description": "Runtime features required by this Caplet." + }, + "resources": { + "description": "Hosted sandbox resource requirements.", + "type": "object", + "properties": { + "class": { + "description": "Requested hosted sandbox resource class.", + "type": "string", + "enum": ["standard", "large", "heavy"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Runtime feature and resource requirements for hosted execution." + }, "requestTimeoutMs": { "default": 60000, "description": "Timeout in milliseconds for OpenAPI HTTP requests.", @@ -1149,6 +1229,46 @@ }, "additionalProperties": false }, + "projectBinding": { + "type": "object", + "properties": { + "required": { + "type": "boolean", + "const": true, + "description": "Requires Project Binding before this Caplet can run." + } + }, + "required": ["required"], + "additionalProperties": false, + "description": "Project Binding requirements for Caplets that need an attached project." + }, + "runtime": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["docker", "browser"] + }, + "description": "Runtime features required by this Caplet." + }, + "resources": { + "description": "Hosted sandbox resource requirements.", + "type": "object", + "properties": { + "class": { + "description": "Requested hosted sandbox resource class.", + "type": "string", + "enum": ["standard", "large", "heavy"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Runtime feature and resource requirements for hosted execution." + }, "requestTimeoutMs": { "default": 60000, "description": "Timeout in milliseconds for GraphQL HTTP requests.", @@ -1584,6 +1704,46 @@ }, "additionalProperties": false }, + "projectBinding": { + "type": "object", + "properties": { + "required": { + "type": "boolean", + "const": true, + "description": "Requires Project Binding before this Caplet can run." + } + }, + "required": ["required"], + "additionalProperties": false, + "description": "Project Binding requirements for Caplets that need an attached project." + }, + "runtime": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["docker", "browser"] + }, + "description": "Runtime features required by this Caplet." + }, + "resources": { + "description": "Hosted sandbox resource requirements.", + "type": "object", + "properties": { + "class": { + "description": "Requested hosted sandbox resource class.", + "type": "string", + "enum": ["standard", "large", "heavy"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Runtime feature and resource requirements for hosted execution." + }, "requestTimeoutMs": { "default": 60000, "description": "Timeout in milliseconds for HTTP action requests.", @@ -1868,6 +2028,46 @@ }, "additionalProperties": false }, + "projectBinding": { + "type": "object", + "properties": { + "required": { + "type": "boolean", + "const": true, + "description": "Requires Project Binding before this Caplet can run." + } + }, + "required": ["required"], + "additionalProperties": false, + "description": "Project Binding requirements for Caplets that need an attached project." + }, + "runtime": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["docker", "browser"] + }, + "description": "Runtime features required by this Caplet." + }, + "resources": { + "description": "Hosted sandbox resource requirements.", + "type": "object", + "properties": { + "class": { + "description": "Requested hosted sandbox resource class.", + "type": "string", + "enum": ["standard", "large", "heavy"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Runtime feature and resource requirements for hosted execution." + }, "timeoutMs": { "default": 60000, "description": "Default timeout in milliseconds for CLI actions.", @@ -2062,6 +2262,46 @@ }, "additionalProperties": false }, + "projectBinding": { + "type": "object", + "properties": { + "required": { + "type": "boolean", + "const": true, + "description": "Requires Project Binding before this Caplet can run." + } + }, + "required": ["required"], + "additionalProperties": false, + "description": "Project Binding requirements for Caplets that need an attached project." + }, + "runtime": { + "type": "object", + "properties": { + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["docker", "browser"] + }, + "description": "Runtime features required by this Caplet." + }, + "resources": { + "description": "Hosted sandbox resource requirements.", + "type": "object", + "properties": { + "class": { + "description": "Requested hosted sandbox resource class.", + "type": "string", + "enum": ["standard", "large", "heavy"] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Runtime feature and resource requirements for hosted execution." + }, "disabled": { "default": false, "description": "When true, omit this Caplet set.", diff --git a/scripts/dev.ts b/scripts/dev.ts index bd8d979..76816a0 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -3,6 +3,8 @@ import { watch } from "rolldown"; import cliConfig from "../packages/cli/rolldown.config"; import coreConfig from "../packages/core/rolldown.config"; +const coreNodeConfig = Array.isArray(coreConfig) ? coreConfig[0]! : coreConfig; + let child: ChildProcess | null = null; let starting = false; @@ -31,10 +33,10 @@ function startServer() { const watcher = watch([ { - ...coreConfig, + ...coreNodeConfig, input: "./packages/core/src/index.ts", output: { - ...coreConfig.output, + ...coreNodeConfig.output, dir: "./packages/core/dist", }, }, From 830ecaacbb49cb252d1395b4534a3d44e1c04a28 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 4 Jun 2026 06:59:21 -0400 Subject: [PATCH 09/19] fix: tests --- packages/core/test/attach-cli.test.ts | 61 +++++++++++++++++++++------ packages/core/test/cli.test.ts | 12 ++++-- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/packages/core/test/attach-cli.test.ts b/packages/core/test/attach-cli.test.ts index ae9bbc6..410df54 100644 --- a/packages/core/test/attach-cli.test.ts +++ b/packages/core/test/attach-cli.test.ts @@ -1,10 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { attachProjectOnce, resolveAttachOptions } from "../src/project-binding/attach"; import { runCli } from "../src/cli"; import { CloudAuthStore } from "../src/cloud-auth/store"; import type { ProjectBindingWebSocket } from "../src/project-binding/transport"; import { hostedCredentials, tempCloudAuthPath } from "./fixtures/cloud-auth"; +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + describe("caplets attach CLI", () => { it("shows attach help", async () => { const out: string[] = []; @@ -74,11 +85,19 @@ describe("caplets attach CLI", () => { it("runs once from the CLI and reports WebSocket availability", async () => { const out: string[] = []; + const cwd = process.cwd(); + const projectRoot = tempProjectRoot(); - await runCli(["attach", "--remote-url", "https://caplets.example.com/caplets", "--once"], { - fetch: async () => Response.json({ error: "websocket_upgrade_required" }, { status: 426 }), - writeOut: (value) => out.push(value), - }); + try { + process.chdir(projectRoot); + + await runCli(["attach", "--remote-url", "https://caplets.example.com/caplets", "--once"], { + fetch: async () => Response.json({ error: "websocket_upgrade_required" }, { status: 426 }), + writeOut: (value) => out.push(value), + }); + } finally { + process.chdir(cwd); + } expect(out.join("")).toContain( "Project Binding available at wss://caplets.example.com/caplets/control/project-bindings/connect.", @@ -88,17 +107,25 @@ describe("caplets attach CLI", () => { it("prints structured JSON for CLI WebSocket failures", async () => { const out: string[] = []; let exitCode = 0; + const cwd = process.cwd(); + const projectRoot = tempProjectRoot(); - await runCli( - ["attach", "--remote-url", "https://caplets.example.com/caplets", "--once", "--json"], - { - fetch: async () => new Response("upgrade blocked", { status: 426 }), - writeOut: (value) => out.push(value), - setExitCode: (code) => { - exitCode = code; + try { + process.chdir(projectRoot); + + await runCli( + ["attach", "--remote-url", "https://caplets.example.com/caplets", "--once", "--json"], + { + fetch: async () => new Response("upgrade blocked", { status: 426 }), + writeOut: (value) => out.push(value), + setExitCode: (code) => { + exitCode = code; + }, }, - }, - ); + ); + } finally { + process.chdir(cwd); + } expect(exitCode).toBe(1); expect(JSON.parse(out.join(""))).toMatchObject({ @@ -173,6 +200,12 @@ describe("caplets attach CLI", () => { }); }); +function tempProjectRoot(): string { + const root = mkdtempSync(join(tmpdir(), "caplets-attach-cli-")); + tempDirs.push(root); + return root; +} + function fakeProjectBindingSession(options: { onReady?: () => void } = {}) { return { fetch: async (url: Parameters[0], _init?: RequestInit) => { diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index ec64ac8..c8fda36 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -113,11 +113,12 @@ describe("cli init", () => { try { mkdirSync(projectRoot, { recursive: true }); process.chdir(projectRoot); + const expectedConfigPath = join(process.cwd(), ".caplets", "config.json"); await runCli(["init"], { writeOut: (value) => out.push(value) }); expect(existsSync(projectConfigPath)).toBe(true); - expect(out.join("")).toBe(`Created Caplets config at ${projectConfigPath}\n`); + expect(out.join("")).toBe(`Created Caplets config at ${expectedConfigPath}\n`); } finally { process.chdir(cwd); rmSync(dir, { recursive: true, force: true }); @@ -440,6 +441,7 @@ describe("cli init", () => { ); process.env.CAPLETS_CONFIG = configPath; process.chdir(projectRoot); + const expectedProjectCapletPath = join(process.cwd(), ".caplets", "github.md"); await runCli(["list", "--json"], { writeOut: (value) => out.push(value) }); @@ -447,7 +449,7 @@ describe("cli init", () => { expect.objectContaining({ server: "github", source: "project-file", - path: projectCapletPath, + path: expectedProjectCapletPath, shadows: [{ kind: "global-config", path: configPath }], }), ]); @@ -972,6 +974,7 @@ describe("cli init", () => { writeCliRepo(repo); mkdirSync(projectRoot, { recursive: true }); process.chdir(projectRoot); + const expectedOutput = join(process.cwd(), ".caplets", "repo-tools.md"); await runCli(["add", "cli", "repo-tools", "--repo", repo, "--include", "package"], { writeOut: (value) => out.push(value), @@ -979,7 +982,7 @@ describe("cli init", () => { const output = join(projectRoot, ".caplets", "repo-tools.md"); expect(readFileSync(output, "utf8")).toContain("package_test:"); - expect(out.join("")).toBe(`Wrote CLI Caplet to ${output}\n`); + expect(out.join("")).toBe(`Wrote CLI Caplet to ${expectedOutput}\n`); } finally { process.chdir(cwd); rmSync(dir, { recursive: true, force: true }); @@ -1543,12 +1546,13 @@ describe("cli init", () => { process.env.CAPLETS_CONFIG = configPath; mkdirSync(projectRoot, { recursive: true }); process.chdir(projectRoot); + const expectedDestination = join(process.cwd(), ".caplets", "github"); await runCli(["install", repo, "github"], { writeOut: (value) => out.push(value) }); expect(existsSync(join(projectRoot, ".caplets", "github", "CAPLET.md"))).toBe(true); expect(existsSync(join(projectRoot, ".caplets", "filesystem.md"))).toBe(false); - expect(out.join("")).toBe(`Installed github to ${join(projectRoot, ".caplets", "github")}\n`); + expect(out.join("")).toBe(`Installed github to ${expectedDestination}\n`); } finally { process.chdir(cwd); rmSync(dir, { recursive: true, force: true }); From 672eef47f1cfc12219b933479938dd4e8e489967 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 4 Jun 2026 07:09:30 -0400 Subject: [PATCH 10/19] ci: remove cloud from core deploy --- alchemy.run.ts | 49 ++------------------------------ infra/alchemy-domains.ts | 24 +--------------- infra/alchemy-runner.test.ts | 55 +++--------------------------------- 3 files changed, 7 insertions(+), 121 deletions(-) diff --git a/alchemy.run.ts b/alchemy.run.ts index 0cd38b2..efe5d07 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -1,5 +1,5 @@ import alchemy from "alchemy"; -import { Astro, D1Database, R2Bucket, Vite, Worker } from "alchemy/cloudflare"; +import { Astro } from "alchemy/cloudflare"; import { GitHubComment } from "alchemy/github"; import { CloudflareStateStore } from "alchemy/state"; @@ -10,15 +10,7 @@ const app = await alchemy("caplets", { password: process.env.ALCHEMY_PASSWORD!, }); -const { - appDomain, - cloudApiDomains, - cloudApiUrl, - cloudUiEnv, - landingPageDomain, - landingPageUrl, - appUrl, -} = buildAlchemyDomains(app.stage, { local: app.local }); +const { landingPageDomain, landingPageUrl } = buildAlchemyDomains(app.stage, { local: app.local }); export const landingPage = await Astro("landing-page", { cwd: "apps/landing", dev: { @@ -27,43 +19,8 @@ export const landingPage = await Astro("landing-page", { domains: [landingPageDomain, `www.${landingPageDomain}`], }); -export const cloudState = await D1Database("cloud-state", { - name: `caplets-${app.stage}-cloud-state`, -}); - -export const cloudArtifacts = await R2Bucket("cloud-artifacts", { - name: `caplets-${app.stage}-cloud-artifacts`, -}); - -export const cloudApi = await Worker("cloud-api", { - cwd: "apps/cloud", - entrypoint: "src/index.ts", - dev: { - port: 8787, - }, - bindings: { - CLOUD_STATE: cloudState, - CLOUD_ARTIFACTS: cloudArtifacts, - }, - domains: cloudApiDomains, -}); - -export const cloudUi = await Vite("cloud-ui", { - cwd: "apps/cloud-ui", - build: { - env: cloudUiEnv, - }, - dev: { - command: "pnpm run dev" + (process.env.SSH_CONNECTION ? " --host 0.0.0.0" : ""), - env: cloudUiEnv, - }, - domains: [appDomain], -}); - console.log({ "Landing Page URL": landingPageUrl, - "Caplets Cloud UI URL": appUrl, - "Caplets Cloud API URL": cloudApiUrl, }); const [repositoryOwnerFromSlug, repositoryNameFromSlug] = @@ -87,8 +44,6 @@ if (pullRequestNumber) { Your changes have been deployed to a preview environment: **🌐 Landing Page:** ${landingPageUrl} -**☁️ Caplets Cloud UI:** https://${appDomain} -**🔌 Caplets Cloud API Domain:** ${cloudApiUrl} Built from commit ${process.env.GITHUB_SHA?.slice(0, 7) ?? "unknown"} diff --git a/infra/alchemy-domains.ts b/infra/alchemy-domains.ts index c858473..bb4c388 100644 --- a/infra/alchemy-domains.ts +++ b/infra/alchemy-domains.ts @@ -1,18 +1,9 @@ const globalBaseDomain = "caplets.dev"; export interface AlchemyDomains { - appDomain: string; baseDomain: string; - cloudApiDomains: string[]; - cloudApiUrl: string; - cloudDomain: string; - cloudUiEnv: { - VITE_CAPLETS_CLOUD_API_URL: string; - VITE_CAPLETS_WORKSPACE_SLUG: string; - }; landingPageDomain: string; landingPageUrl: string; - appUrl: string; } export function buildAlchemyDomains( @@ -21,24 +12,11 @@ export function buildAlchemyDomains( ): AlchemyDomains { const baseDomain = stage === "prod" ? globalBaseDomain : `${stage}.preview.${globalBaseDomain}`; const landingPageDomain = baseDomain; - const landingPageUrl = `https://${landingPageDomain}`; - const cloudDomain = `cloud.${baseDomain}`; - const cloudApiUrl = local ? "http://localhost:8787" : `https://${cloudDomain}`; - const appDomain = `app.${baseDomain}`; - const appUrl = `https://${appDomain}`; + const landingPageUrl = local ? `http://localhost:4321` : `https://${landingPageDomain}`; return { - appDomain, baseDomain, - cloudApiDomains: local ? [] : [cloudDomain], - cloudApiUrl, - cloudDomain, - cloudUiEnv: { - VITE_CAPLETS_CLOUD_API_URL: cloudApiUrl, - VITE_CAPLETS_WORKSPACE_SLUG: "personal", - }, landingPageDomain, landingPageUrl, - appUrl, }; } diff --git a/infra/alchemy-runner.test.ts b/infra/alchemy-runner.test.ts index b1ec9e7..dc03509 100644 --- a/infra/alchemy-runner.test.ts +++ b/infra/alchemy-runner.test.ts @@ -1,12 +1,10 @@ import { expect, test } from "vitest"; -import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { buildAlchemyDomains } from "./alchemy-domains.js"; import { buildNodeOptions } from "./alchemy-runner.js"; const shimPath = fileURLToPath(new URL("./alchemy-fetch-compat.ts", import.meta.url)); -const alchemyRunPath = fileURLToPath(new URL("../alchemy.run.ts", import.meta.url)); test("Alchemy runner injects fetch shim through NODE_OPTIONS for child processes", () => { expect(buildNodeOptions(undefined)).toBe(`--import=${shimPath}`); @@ -20,67 +18,22 @@ test("Alchemy runner keeps fetch compatibility shim for cloud deployments", () = expect(buildNodeOptions()).toContain("alchemy-fetch-compat"); }); -test("Cloud UI deployment injects matching Cloud API origin into Vite", () => { - const source = readFileSync(alchemyRunPath, "utf8"); - - expect(source).toContain('from "./infra/alchemy-domains.ts"'); - expect(source).toContain("buildAlchemyDomains(app.stage, { local: app.local })"); - expect(source).toMatch(/build:\s*{\s*env:\s*cloudUiEnv,\s*}/s); - expect(source).toMatch(/dev:\s*{[^}]*env:\s*cloudUiEnv,/s); -}); - test.each([ { - appDomain: "app.caplets.dev", - cloudApiUrl: "https://cloud.caplets.dev", - cloudApiDomains: ["cloud.caplets.dev"], - cloudDomain: "cloud.caplets.dev", landingPageDomain: "caplets.dev", stage: "prod", }, { - appDomain: "app.branch.preview.caplets.dev", - cloudApiUrl: "https://cloud.branch.preview.caplets.dev", - cloudApiDomains: ["cloud.branch.preview.caplets.dev"], - cloudDomain: "cloud.branch.preview.caplets.dev", landingPageDomain: "branch.preview.caplets.dev", stage: "branch", }, { - appDomain: "app.dev.preview.caplets.dev", - cloudApiUrl: "https://cloud.dev.preview.caplets.dev", - cloudApiDomains: ["cloud.dev.preview.caplets.dev"], - cloudDomain: "cloud.dev.preview.caplets.dev", landingPageDomain: "dev.preview.caplets.dev", stage: "dev", }, -])( - "derives matching Cloud UI and API domains for $stage", - ({ appDomain, cloudApiDomains, cloudApiUrl, cloudDomain, landingPageDomain, stage }) => { - expect(buildAlchemyDomains(stage)).toMatchObject({ - appDomain, - cloudApiDomains, - cloudApiUrl, - cloudDomain, - cloudUiEnv: { - VITE_CAPLETS_CLOUD_API_URL: cloudApiUrl, - VITE_CAPLETS_WORKSPACE_SLUG: "personal", - }, - landingPageDomain, - landingPageUrl: `https://${landingPageDomain}`, - }); - }, -); - -test("derives local Cloud API origin for alchemy dev", () => { - expect(buildAlchemyDomains("ianpascoe", { local: true })).toMatchObject({ - appDomain: "app.ianpascoe.preview.caplets.dev", - cloudApiDomains: [], - cloudApiUrl: "http://localhost:8787", - cloudDomain: "cloud.ianpascoe.preview.caplets.dev", - cloudUiEnv: { - VITE_CAPLETS_CLOUD_API_URL: "http://localhost:8787", - VITE_CAPLETS_WORKSPACE_SLUG: "personal", - }, +])("derives matching domains for $stage", ({ landingPageDomain, stage }) => { + expect(buildAlchemyDomains(stage)).toMatchObject({ + landingPageDomain, + landingPageUrl: `https://${landingPageDomain}`, }); }); From d6ab7201885f1ff34f6d3153fa060eeaad5c61ed Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 4 Jun 2026 07:34:12 -0400 Subject: [PATCH 11/19] fix: CI tests --- packages/core/src/cloud/runtime-http.ts | 16 ++++++++++------ .../cloud-runtime-adapter-provenance.test.ts | 4 ++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/core/src/cloud/runtime-http.ts b/packages/core/src/cloud/runtime-http.ts index bf041b4..ff30940 100644 --- a/packages/core/src/cloud/runtime-http.ts +++ b/packages/core/src/cloud/runtime-http.ts @@ -7,7 +7,11 @@ export type RuntimeHttpOptions = CloudRuntimeAdapterOptions & { export function createRuntimeHttpApp(options: RuntimeHttpOptions): Hono { const app = new Hono(); - const adapter = createCloudRuntimeAdapter(options); + let adapter: ReturnType | undefined; + const runtimeAdapter = () => { + adapter ??= createCloudRuntimeAdapter(options); + return adapter; + }; app.use("/runtime/*", async (c, next) => { const authorization = c.req.header("authorization") ?? ""; @@ -19,20 +23,20 @@ export function createRuntimeHttpApp(options: RuntimeHttpOptions): Hono { app.get("/healthz", (c) => c.json({ status: "ok", runtimeId: options.runtimeId })); - app.post("/runtime/tools/list", async (c) => c.json(await adapter.listTools())); + app.post("/runtime/tools/list", async (c) => c.json(await runtimeAdapter().listTools())); app.post("/runtime/tools/call", async (c) => { const body = (await c.req.json().catch(() => ({}))) as { name?: string; arguments?: unknown }; if (!body.name) return c.json({ error: "tool_name_required" }, 400); - return c.json(await adapter.callTool(body.name, body.arguments ?? {})); + return c.json(await runtimeAdapter().callTool(body.name, body.arguments ?? {})); }); app.post("/runtime/caplets/:id/check", async (c) => { - return c.json(await adapter.checkBackend(c.req.param("id"))); + return c.json(await runtimeAdapter().checkBackend(c.req.param("id"))); }); app.get("/runtime/caplets/:id/setup", async (c) => { - return c.json(await adapter.setupPlan(c.req.param("id"))); + return c.json(await runtimeAdapter().setupPlan(c.req.param("id"))); }); app.post("/runtime/caplets/:id/setup/run", async (c) => { @@ -41,7 +45,7 @@ export function createRuntimeHttpApp(options: RuntimeHttpOptions): Hono { actor?: "cli-interactive" | "cli-yes" | "ui" | "automation"; }; return c.json( - await adapter.runSetup(c.req.param("id"), { + await runtimeAdapter().runSetup(c.req.param("id"), { approved: body.approved === true, actor: body.actor ?? "automation", }), diff --git a/packages/core/test/cloud-runtime-adapter-provenance.test.ts b/packages/core/test/cloud-runtime-adapter-provenance.test.ts index 3ef603b..2ac3688 100644 --- a/packages/core/test/cloud-runtime-adapter-provenance.test.ts +++ b/packages/core/test/cloud-runtime-adapter-provenance.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { createRuntimeHttpApp } from "../src/cloud/runtime-http"; describe("Cloud runtime adapter HTTP provenance boundary", () => { it("rejects runtime adapter calls without the runtime bearer token", async () => { const app = createRuntimeHttpApp({ + configPath: join(tmpdir(), "caplets-missing-config.json"), + projectConfigPath: join(tmpdir(), "caplets-missing-project-config.json"), runtimeId: "runtime_1", sandboxId: "sandbox_1", executionKind: "cloud", From 0203312f2819aab049d4c069bfe53a5e84c1d0ff Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 4 Jun 2026 08:42:00 -0400 Subject: [PATCH 12/19] ci(core): gate deploy workflows before release --- .github/workflows/deploy.yml | 3 +++ .github/workflows/pr-preview-deploy.yml | 4 ++++ .lintstagedrc.json | 3 ++- AGENTS.md | 3 ++- README.md | 8 ++++++-- docs/plans/2026-05-21-mcp-resources-prompts.md | 2 ++ docs/plans/2026-05-29-cli-integration-setup.md | 2 ++ 7 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0561586..9d82c4d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -44,6 +44,9 @@ jobs: run: pnpm install --frozen-lockfile - name: Run quality gates + run: pnpm verify + + - name: Deploy landing page run: pnpm run alchemy:deploy env: ALCHEMY_STAGE: prod diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml index d8729ea..dc833ce 100644 --- a/.github/workflows/pr-preview-deploy.yml +++ b/.github/workflows/pr-preview-deploy.yml @@ -48,6 +48,10 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Run quality gates + if: ${{ github.event.action != 'closed' }} + run: pnpm verify + - name: Deploy preview if: ${{ github.event.action != 'closed' }} run: pnpm run alchemy:deploy diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 69abf9b..ed97602 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,3 +1,4 @@ { - "*.{js,jsx,ts,tsx,mjs,cjs,json,jsonc,md,yml,yaml,css,astro}": ["oxfmt --check", "oxlint"] + "*.{js,jsx,ts,tsx,mjs,cjs,json,jsonc,md,yml,yaml,css,astro}": "oxfmt --check", + "*.{js,jsx,ts,tsx,mjs,cjs}": "oxlint" } diff --git a/AGENTS.md b/AGENTS.md index 84e2ebd..e8ce1a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Commands -- Use `pnpm` only; the repo pins `pnpm@11.0.9` and requires Node `>=22` while CI runs Node 24. +- Use `pnpm` only; the repo pins `pnpm@11.5.0` and requires Node `>=24`. - Install with `pnpm install --frozen-lockfile` when matching CI. - Full local gate and pre-push hook: `pnpm verify` (`format:check -> lint -> typecheck -> schema:check -> test -> benchmark:check -> build`). - Fast focused checks: `pnpm format:check`, `pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build`. @@ -36,3 +36,4 @@ - CI runs `pnpm verify` plus `pnpm changeset status --since=origin/main` on PRs unless the PR has the `no changeset` label. - User-facing package changes usually need a changeset; current versioning is handled by Changesets and `pnpm version-packages`/`pnpm release`. - Pre-commit only runs `pnpm lint-staged` (`oxfmt --check` and `oxlint` on staged JS/TS/config/docs files); pre-push runs the full `pnpm verify`. +- Core Alchemy deploys the public landing page only from `apps/landing`; it does not deploy Cloud Worker or dashboard paths. diff --git a/README.md b/README.md index 2c58e17..1211f0f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ npm CI MIT License - Node 22+ + Node 24+