@thevinci/web 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -0
- package/bin/cli-entry.js +55 -0
- package/bin/cli-output.js +145 -0
- package/bin/cli.js +4887 -0
- package/bin/cli.test.js +64 -0
- package/dist/apple-touch-icon-120x120.png +0 -0
- package/dist/apple-touch-icon-152x152.png +0 -0
- package/dist/apple-touch-icon-167x167.png +0 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/apple-touch-icon.svg +528 -0
- package/dist/assets/JsonTreeView-CSm9OzXG.js +1 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/MarkdownRendererImpl-DensKOLc.js +6 -0
- package/dist/assets/MultiRunWindow-Bo7THayo.js +1 -0
- package/dist/assets/OnboardingScreen-BDqmzTVR.js +2 -0
- package/dist/assets/SettingsWindow-coz__Ykw.js +1 -0
- package/dist/assets/TerminalView-DrZ-i3Dr.js +1 -0
- package/dist/assets/ToolOutputDialog-Eglzslt3.js +16 -0
- package/dist/assets/es-4o9ciP61.js +15 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/index-DLTDToSP.css +1 -0
- package/dist/assets/index-DgiFEKGN.js +1 -0
- package/dist/assets/ko-B20imCHE.js +15 -0
- package/dist/assets/main-BV3KOtdA.css +1 -0
- package/dist/assets/main-CDKJj0sH.js +226 -0
- package/dist/assets/main-LC-PSNVM.js +2 -0
- package/dist/assets/miniChat-CQUiG_cr.js +2 -0
- package/dist/assets/modelPrefsAutoSave-Dm799vzR.js +6986 -0
- package/dist/assets/pl-DQJ7LSzj.js +15 -0
- package/dist/assets/pt-BR-OmjHUz9y.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-CARbeW0G.js +2 -0
- package/dist/assets/uk-BNFxOlO4.js +15 -0
- package/dist/assets/vendor--DBfsbEis.css +1 -0
- package/dist/assets/vendor-.bun-B9l0ZNi2.js +4094 -0
- package/dist/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/assets/wasmSttWorker-Dtlxac_K.js +1 -0
- package/dist/assets/wasmSttWorker-oo7Dm_jy.js +1806 -0
- package/dist/assets/worker-CbT6TVo7.js +155 -0
- package/dist/assets/zh-CN-C6T-Ac7F.js +15 -0
- package/dist/favicon-16.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +528 -0
- package/dist/index.html +607 -0
- package/dist/logo-dark-192x192.png +0 -0
- package/dist/logo-dark-512x512.png +0 -0
- package/dist/logo-dark-512x512.svg +528 -0
- package/dist/logo-light-192x192.png +0 -0
- package/dist/logo-light-512x512.png +0 -0
- package/dist/logo-light-512x512.svg +528 -0
- package/dist/mini-chat.html +16 -0
- package/dist/pwa-192.png +0 -0
- package/dist/pwa-512.png +0 -0
- package/dist/pwa-maskable-192.png +0 -0
- package/dist/pwa-maskable-512.png +0 -0
- package/dist/site.webmanifest +21 -0
- package/dist/sw.js +1 -0
- package/package.json +118 -0
- package/public/apple-touch-icon-120x120.png +0 -0
- package/public/apple-touch-icon-152x152.png +0 -0
- package/public/apple-touch-icon-167x167.png +0 -0
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/apple-touch-icon.svg +528 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +528 -0
- package/public/logo-dark-192x192.png +0 -0
- package/public/logo-dark-512x512.png +0 -0
- package/public/logo-dark-512x512.svg +528 -0
- package/public/logo-light-192x192.png +0 -0
- package/public/logo-light-512x512.png +0 -0
- package/public/logo-light-512x512.svg +528 -0
- package/public/pwa-192.png +0 -0
- package/public/pwa-512.png +0 -0
- package/public/pwa-maskable-192.png +0 -0
- package/public/pwa-maskable-512.png +0 -0
- package/public/site.webmanifest +21 -0
- package/server/TERMINAL_WS_PROTOCOL.md +48 -0
- package/server/index.d.ts +39 -0
- package/server/index.js +1311 -0
- package/server/lib/cloudflare-tunnel.js +650 -0
- package/server/lib/event-stream/DOCUMENTATION.md +61 -0
- package/server/lib/event-stream/directory-ws-bridge.js +185 -0
- package/server/lib/event-stream/global-hub.js +158 -0
- package/server/lib/event-stream/global-hub.test.js +140 -0
- package/server/lib/event-stream/global-ws-bridge.js +206 -0
- package/server/lib/event-stream/index.js +25 -0
- package/server/lib/event-stream/protocol.js +131 -0
- package/server/lib/event-stream/protocol.test.js +182 -0
- package/server/lib/event-stream/runtime.js +180 -0
- package/server/lib/event-stream/runtime.test.js +512 -0
- package/server/lib/event-stream/upstream-reader.js +226 -0
- package/server/lib/event-stream/upstream-reader.test.js +276 -0
- package/server/lib/fs/DOCUMENTATION.md +36 -0
- package/server/lib/fs/routes.js +1040 -0
- package/server/lib/fs/search.js +238 -0
- package/server/lib/git/DOCUMENTATION.md +152 -0
- package/server/lib/git/credentials.js +74 -0
- package/server/lib/git/identity-storage.js +112 -0
- package/server/lib/git/index.js +6 -0
- package/server/lib/git/routes.js +972 -0
- package/server/lib/git/service.js +3432 -0
- package/server/lib/git/service.test.js +39 -0
- package/server/lib/github/DOCUMENTATION.md +171 -0
- package/server/lib/github/auth.js +307 -0
- package/server/lib/github/device-flow.js +50 -0
- package/server/lib/github/index.js +24 -0
- package/server/lib/github/octokit.js +10 -0
- package/server/lib/github/pr-status.js +519 -0
- package/server/lib/github/repo/fork-detection.js +102 -0
- package/server/lib/github/repo/index.js +55 -0
- package/server/lib/github/routes.js +1560 -0
- package/server/lib/magic-prompts/routes.js +63 -0
- package/server/lib/magic-prompts/runtime.js +119 -0
- package/server/lib/notifications/DOCUMENTATION.md +122 -0
- package/server/lib/notifications/emitter-runtime.js +102 -0
- package/server/lib/notifications/index.js +4 -0
- package/server/lib/notifications/message.js +52 -0
- package/server/lib/notifications/message.test.js +34 -0
- package/server/lib/notifications/push-runtime.js +304 -0
- package/server/lib/notifications/routes.js +315 -0
- package/server/lib/notifications/runtime.js +566 -0
- package/server/lib/notifications/template-runtime.js +349 -0
- package/server/lib/notifications/template-runtime.test.js +26 -0
- package/server/lib/opencode/DOCUMENTATION.md +362 -0
- package/server/lib/opencode/agents.js +634 -0
- package/server/lib/opencode/auth-state-runtime.js +88 -0
- package/server/lib/opencode/auth.js +83 -0
- package/server/lib/opencode/bootstrap-runtime.js +131 -0
- package/server/lib/opencode/cli-entry-runtime.js +43 -0
- package/server/lib/opencode/cli-options.js +128 -0
- package/server/lib/opencode/commands.js +339 -0
- package/server/lib/opencode/config-entity-routes.js +370 -0
- package/server/lib/opencode/core-routes.js +500 -0
- package/server/lib/opencode/core-routes.test.js +26 -0
- package/server/lib/opencode/env-config.js +74 -0
- package/server/lib/opencode/env-keys.js +68 -0
- package/server/lib/opencode/env-runtime.js +1162 -0
- package/server/lib/opencode/env-runtime.test.js +116 -0
- package/server/lib/opencode/feature-routes-runtime.js +244 -0
- package/server/lib/opencode/hmr-state-runtime.js +85 -0
- package/server/lib/opencode/index.js +66 -0
- package/server/lib/opencode/lifecycle.js +1019 -0
- package/server/lib/opencode/lifecycle.test.js +240 -0
- package/server/lib/opencode/mcp.js +278 -0
- package/server/lib/opencode/network-runtime.js +104 -0
- package/server/lib/opencode/network-runtime.test.js +37 -0
- package/server/lib/opencode/opencode-resolution-runtime.js +71 -0
- package/server/lib/opencode/path-utils.js +100 -0
- package/server/lib/opencode/path-utils.test.js +71 -0
- package/server/lib/opencode/project-directory-runtime.js +124 -0
- package/server/lib/opencode/project-icon-routes.js +399 -0
- package/server/lib/opencode/project-icon-routes.test.js +107 -0
- package/server/lib/opencode/providers.js +96 -0
- package/server/lib/opencode/proxy.js +445 -0
- package/server/lib/opencode/pwa-manifest-routes.js +257 -0
- package/server/lib/opencode/pwa-manifest-routes.test.js +133 -0
- package/server/lib/opencode/routes.js +541 -0
- package/server/lib/opencode/server-startup-runtime.js +156 -0
- package/server/lib/opencode/server-utils-runtime.js +168 -0
- package/server/lib/opencode/server-utils-runtime.test.js +135 -0
- package/server/lib/opencode/session-runtime.js +356 -0
- package/server/lib/opencode/session-runtime.test.js +151 -0
- package/server/lib/opencode/settings-helpers.js +770 -0
- package/server/lib/opencode/settings-helpers.test.js +109 -0
- package/server/lib/opencode/settings-normalization-runtime.js +428 -0
- package/server/lib/opencode/settings-runtime.js +826 -0
- package/server/lib/opencode/settings-runtime.test.js +85 -0
- package/server/lib/opencode/shared.js +615 -0
- package/server/lib/opencode/shutdown-runtime.js +139 -0
- package/server/lib/opencode/shutdown-runtime.test.js +58 -0
- package/server/lib/opencode/skill-routes.js +701 -0
- package/server/lib/opencode/skills.js +548 -0
- package/server/lib/opencode/startup-pipeline-runtime.js +130 -0
- package/server/lib/opencode/static-routes-runtime.js +65 -0
- package/server/lib/opencode/theme-runtime.js +167 -0
- package/server/lib/opencode/tunnel-auth.js +591 -0
- package/server/lib/opencode/tunnel-wiring-runtime.js +94 -0
- package/server/lib/opencode/vinci-routes.js +76 -0
- package/server/lib/opencode/watcher.js +115 -0
- package/server/lib/opencode/watcher.test.js +239 -0
- package/server/lib/preview/proxy-runtime.js +1333 -0
- package/server/lib/preview/proxy-runtime.test.js +144 -0
- package/server/lib/projects/project-config.js +567 -0
- package/server/lib/projects/project-config.test.js +175 -0
- package/server/lib/projects/project-id.js +13 -0
- package/server/lib/quota/DOCUMENTATION.md +58 -0
- package/server/lib/quota/index.js +25 -0
- package/server/lib/quota/providers/claude.js +107 -0
- package/server/lib/quota/providers/codex.js +113 -0
- package/server/lib/quota/providers/copilot.js +165 -0
- package/server/lib/quota/providers/google/api.js +92 -0
- package/server/lib/quota/providers/google/auth.js +108 -0
- package/server/lib/quota/providers/google/index.js +124 -0
- package/server/lib/quota/providers/google/transforms.js +109 -0
- package/server/lib/quota/providers/index.js +168 -0
- package/server/lib/quota/providers/interface.js +55 -0
- package/server/lib/quota/providers/kimi.js +108 -0
- package/server/lib/quota/providers/minimax-cn-coding-plan.js +140 -0
- package/server/lib/quota/providers/minimax-coding-plan.js +139 -0
- package/server/lib/quota/providers/nanogpt.js +124 -0
- package/server/lib/quota/providers/ollama-cloud.js +112 -0
- package/server/lib/quota/providers/openai.js +91 -0
- package/server/lib/quota/providers/openrouter.js +92 -0
- package/server/lib/quota/providers/zai.js +91 -0
- package/server/lib/quota/providers/zhipuai-coding-plan.js +133 -0
- package/server/lib/quota/providers/zhipuai.js +114 -0
- package/server/lib/quota/routes.js +27 -0
- package/server/lib/quota/utils/auth.js +50 -0
- package/server/lib/quota/utils/formatters.js +85 -0
- package/server/lib/quota/utils/formatters.test.js +54 -0
- package/server/lib/quota/utils/index.js +10 -0
- package/server/lib/quota/utils/transformers.js +55 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +44 -0
- package/server/lib/scheduled-tasks/routes.js +235 -0
- package/server/lib/scheduled-tasks/runtime.js +773 -0
- package/server/lib/scheduled-tasks/runtime.test.js +100 -0
- package/server/lib/security/request-security.js +115 -0
- package/server/lib/session-folders/routes.js +63 -0
- package/server/lib/session-folders/routes.test.js +102 -0
- package/server/lib/skills-catalog/DOCUMENTATION.md +178 -0
- package/server/lib/skills-catalog/cache.js +29 -0
- package/server/lib/skills-catalog/clawdhub/api.js +158 -0
- package/server/lib/skills-catalog/clawdhub/index.js +30 -0
- package/server/lib/skills-catalog/clawdhub/install.js +238 -0
- package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
- package/server/lib/skills-catalog/curated-sources.js +21 -0
- package/server/lib/skills-catalog/git.js +77 -0
- package/server/lib/skills-catalog/index.js +42 -0
- package/server/lib/skills-catalog/install.js +294 -0
- package/server/lib/skills-catalog/scan.js +221 -0
- package/server/lib/skills-catalog/source.js +87 -0
- package/server/lib/terminal/DOCUMENTATION.md +76 -0
- package/server/lib/terminal/index.js +31 -0
- package/server/lib/terminal/output-replay-buffer.js +78 -0
- package/server/lib/terminal/output-replay-buffer.test.js +75 -0
- package/server/lib/terminal/runtime.js +850 -0
- package/server/lib/terminal/runtime.test.js +96 -0
- package/server/lib/terminal/terminal-ws-protocol.js +68 -0
- package/server/lib/terminal/terminal-ws-protocol.test.js +145 -0
- package/server/lib/text/DOCUMENTATION.md +35 -0
- package/server/lib/text/summarization.js +138 -0
- package/server/lib/text/summarization.test.js +34 -0
- package/server/lib/tts/DOCUMENTATION.md +146 -0
- package/server/lib/tts/base-url.js +62 -0
- package/server/lib/tts/capability-runtime.js +31 -0
- package/server/lib/tts/index.js +19 -0
- package/server/lib/tts/routes.js +261 -0
- package/server/lib/tts/routes.test.js +53 -0
- package/server/lib/tts/service.js +178 -0
- package/server/lib/tts/stt.js +75 -0
- package/server/lib/tunnels/DOCUMENTATION.md +18 -0
- package/server/lib/tunnels/index.js +166 -0
- package/server/lib/tunnels/managed-config.js +201 -0
- package/server/lib/tunnels/providers/cloudflare.js +260 -0
- package/server/lib/tunnels/registry.js +51 -0
- package/server/lib/tunnels/routes.js +605 -0
- package/server/lib/tunnels/types.js +219 -0
- package/server/lib/ui-auth/DOCUMENTATION.md +38 -0
- package/server/lib/ui-auth/ui-auth.js +673 -0
- package/server/lib/ui-auth/ui-passkeys.js +545 -0
- package/server/opencode-proxy.test.js +151 -0
- package/server/proxy-headers.js +61 -0
- package/server/proxy-headers.test.js +58 -0
- package/server/sse-routes.test.js +152 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computeNextRunAt, formatScheduledSessionTitle, parseScheduledCommandPrompt } from './runtime.js';
|
|
3
|
+
|
|
4
|
+
describe('scheduled-tasks runtime helpers', () => {
|
|
5
|
+
it('computes next daily run in timezone', () => {
|
|
6
|
+
const nowUtc = Date.UTC(2025, 0, 1, 8, 0, 0);
|
|
7
|
+
const next = computeNextRunAt({
|
|
8
|
+
enabled: true,
|
|
9
|
+
schedule: {
|
|
10
|
+
kind: 'daily',
|
|
11
|
+
times: ['09:30'],
|
|
12
|
+
timezone: 'UTC',
|
|
13
|
+
},
|
|
14
|
+
}, nowUtc);
|
|
15
|
+
|
|
16
|
+
expect(next).toBe(Date.UTC(2025, 0, 1, 9, 30, 0));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('computes weekly next run using weekdays', () => {
|
|
20
|
+
// Monday 2025-01-06 10:00:00 UTC
|
|
21
|
+
const nowUtc = Date.UTC(2025, 0, 6, 10, 0, 0);
|
|
22
|
+
const next = computeNextRunAt({
|
|
23
|
+
enabled: true,
|
|
24
|
+
schedule: {
|
|
25
|
+
kind: 'weekly',
|
|
26
|
+
times: ['09:00'],
|
|
27
|
+
weekdays: [1, 3],
|
|
28
|
+
timezone: 'UTC',
|
|
29
|
+
},
|
|
30
|
+
}, nowUtc);
|
|
31
|
+
|
|
32
|
+
// Wednesday 2025-01-08 09:00:00 UTC
|
|
33
|
+
expect(next).toBe(Date.UTC(2025, 0, 8, 9, 0, 0));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('picks nearest time from multiple daily times', () => {
|
|
37
|
+
const nowUtc = Date.UTC(2025, 0, 1, 9, 20, 0);
|
|
38
|
+
const next = computeNextRunAt({
|
|
39
|
+
enabled: true,
|
|
40
|
+
schedule: {
|
|
41
|
+
kind: 'daily',
|
|
42
|
+
times: ['09:15', '09:45', '18:00'],
|
|
43
|
+
timezone: 'UTC',
|
|
44
|
+
},
|
|
45
|
+
}, nowUtc);
|
|
46
|
+
|
|
47
|
+
expect(next).toBe(Date.UTC(2025, 0, 1, 9, 45, 0));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('computes one-time next run for future date', () => {
|
|
51
|
+
const nowUtc = Date.UTC(2026, 3, 15, 10, 0, 0);
|
|
52
|
+
const next = computeNextRunAt({
|
|
53
|
+
enabled: true,
|
|
54
|
+
schedule: {
|
|
55
|
+
kind: 'once',
|
|
56
|
+
date: '2026-04-16',
|
|
57
|
+
time: '13:30',
|
|
58
|
+
timezone: 'UTC',
|
|
59
|
+
},
|
|
60
|
+
}, nowUtc);
|
|
61
|
+
|
|
62
|
+
expect(next).toBe(Date.UTC(2026, 3, 16, 13, 30, 0));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns null for past one-time schedule', () => {
|
|
66
|
+
const nowUtc = Date.UTC(2026, 3, 16, 14, 0, 0);
|
|
67
|
+
const next = computeNextRunAt({
|
|
68
|
+
enabled: true,
|
|
69
|
+
schedule: {
|
|
70
|
+
kind: 'once',
|
|
71
|
+
date: '2026-04-16',
|
|
72
|
+
time: '13:30',
|
|
73
|
+
timezone: 'UTC',
|
|
74
|
+
},
|
|
75
|
+
}, nowUtc);
|
|
76
|
+
|
|
77
|
+
expect(next).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('formats session title with timestamp suffix', () => {
|
|
81
|
+
const title = formatScheduledSessionTitle({
|
|
82
|
+
name: 'Morning Sync',
|
|
83
|
+
schedule: { timezone: 'UTC' },
|
|
84
|
+
}, Date.UTC(2025, 2, 10, 7, 5, 0));
|
|
85
|
+
|
|
86
|
+
expect(title).toBe('Morning Sync 2025-03-10 07:05');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('parses slash command prompt for scheduled command mode', () => {
|
|
90
|
+
expect(parseScheduledCommandPrompt('/review src/components')).toEqual({
|
|
91
|
+
command: 'review',
|
|
92
|
+
arguments: 'src/components',
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns null when prompt is not a slash command', () => {
|
|
97
|
+
expect(parseScheduledCommandPrompt('Summarize open issues')).toBeNull();
|
|
98
|
+
expect(parseScheduledCommandPrompt('/')).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export const createRequestSecurityRuntime = (deps) => {
|
|
2
|
+
const { readSettingsFromDiskMigrated } = deps;
|
|
3
|
+
|
|
4
|
+
const getUiSessionTokenFromRequest = (req) => {
|
|
5
|
+
const cookieHeader = req?.headers?.cookie;
|
|
6
|
+
if (!cookieHeader || typeof cookieHeader !== 'string') {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const segments = cookieHeader.split(';');
|
|
10
|
+
for (const segment of segments) {
|
|
11
|
+
const [rawName, ...rest] = segment.split('=');
|
|
12
|
+
const name = rawName?.trim();
|
|
13
|
+
if (!name) continue;
|
|
14
|
+
if (name !== 'oc_ui_session') continue;
|
|
15
|
+
const value = rest.join('=').trim();
|
|
16
|
+
try {
|
|
17
|
+
return decodeURIComponent(value || '');
|
|
18
|
+
} catch {
|
|
19
|
+
return value || null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const rejectWebSocketUpgrade = (socket, statusCode, reason) => {
|
|
26
|
+
if (!socket || socket.destroyed) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const message = typeof reason === 'string' && reason.trim().length > 0 ? reason.trim() : 'Bad Request';
|
|
31
|
+
const body = Buffer.from(message, 'utf8');
|
|
32
|
+
const statusText = {
|
|
33
|
+
400: 'Bad Request',
|
|
34
|
+
401: 'Unauthorized',
|
|
35
|
+
403: 'Forbidden',
|
|
36
|
+
404: 'Not Found',
|
|
37
|
+
500: 'Internal Server Error',
|
|
38
|
+
}[statusCode] || 'Bad Request';
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
socket.write(
|
|
42
|
+
`HTTP/1.1 ${statusCode} ${statusText}\r\n` +
|
|
43
|
+
'Connection: close\r\n' +
|
|
44
|
+
'Content-Type: text/plain; charset=utf-8\r\n' +
|
|
45
|
+
`Content-Length: ${body.length}\r\n\r\n`
|
|
46
|
+
);
|
|
47
|
+
socket.write(body);
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
socket.destroy();
|
|
53
|
+
} catch {
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const getRequestOriginCandidates = async (req) => {
|
|
58
|
+
const origins = new Set();
|
|
59
|
+
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
|
|
60
|
+
? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase()
|
|
61
|
+
: '';
|
|
62
|
+
const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http');
|
|
63
|
+
|
|
64
|
+
const forwardedHost = typeof req.headers['x-forwarded-host'] === 'string'
|
|
65
|
+
? req.headers['x-forwarded-host'].split(',')[0].trim()
|
|
66
|
+
: '';
|
|
67
|
+
const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
|
|
68
|
+
|
|
69
|
+
if (host) {
|
|
70
|
+
origins.add(`${protocol}://${host}`);
|
|
71
|
+
const [hostname, port] = host.split(':');
|
|
72
|
+
const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : '';
|
|
73
|
+
const portSuffix = typeof port === 'string' && port.length > 0 ? `:${port}` : '';
|
|
74
|
+
if (normalizedHost === 'localhost') {
|
|
75
|
+
origins.add(`${protocol}://127.0.0.1${portSuffix}`);
|
|
76
|
+
origins.add(`${protocol}://[::1]${portSuffix}`);
|
|
77
|
+
} else if (normalizedHost === '127.0.0.1' || normalizedHost === '[::1]') {
|
|
78
|
+
origins.add(`${protocol}://localhost${portSuffix}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const settings = await readSettingsFromDiskMigrated();
|
|
84
|
+
if (typeof settings?.publicOrigin === 'string' && settings.publicOrigin.trim().length > 0) {
|
|
85
|
+
origins.add(new URL(settings.publicOrigin.trim()).origin);
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return origins;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const isRequestOriginAllowed = async (req) => {
|
|
94
|
+
const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : '';
|
|
95
|
+
if (!originHeader) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let normalizedOrigin = '';
|
|
100
|
+
try {
|
|
101
|
+
normalizedOrigin = new URL(originHeader).origin;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const allowedOrigins = await getRequestOriginCandidates(req);
|
|
107
|
+
return allowedOrigins.has(normalizedOrigin);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
getUiSessionTokenFromRequest,
|
|
112
|
+
rejectWebSocketUpgrade,
|
|
113
|
+
isRequestOriginAllowed,
|
|
114
|
+
};
|
|
115
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
2
|
+
|
|
3
|
+
export const registerSessionFoldersRoutes = (app, dependencies) => {
|
|
4
|
+
const {
|
|
5
|
+
fsPromises,
|
|
6
|
+
path,
|
|
7
|
+
vinciDataDir,
|
|
8
|
+
} = dependencies;
|
|
9
|
+
|
|
10
|
+
const filePath = path.join(vinciDataDir, 'sessions-directories.json');
|
|
11
|
+
|
|
12
|
+
const ensureDir = async () => {
|
|
13
|
+
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
app.get('/api/session-folders', async (_req, res) => {
|
|
17
|
+
try {
|
|
18
|
+
const raw = await fsPromises.readFile(filePath, 'utf8').catch((error) => {
|
|
19
|
+
if (error && error.code === 'ENOENT') return null;
|
|
20
|
+
throw error;
|
|
21
|
+
});
|
|
22
|
+
if (!raw) {
|
|
23
|
+
return res.json({ version: 1, foldersMap: {}, collapsedFolderIds: [], updatedAt: 0 });
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
return res.json(parsed);
|
|
28
|
+
} catch {
|
|
29
|
+
return res.json({ version: 1, foldersMap: {}, collapsedFolderIds: [], updatedAt: 0 });
|
|
30
|
+
}
|
|
31
|
+
} catch (error) {
|
|
32
|
+
const message = error instanceof Error ? error.message : 'Failed to read session folders';
|
|
33
|
+
return res.status(500).json({ error: message });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
app.post('/api/session-folders', async (req, res) => {
|
|
38
|
+
const body = req.body;
|
|
39
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
40
|
+
return res.status(400).json({ error: 'Body must be an object' });
|
|
41
|
+
}
|
|
42
|
+
const serialized = JSON.stringify(body, null, 2);
|
|
43
|
+
if (Buffer.byteLength(serialized, 'utf8') > MAX_BODY_BYTES) {
|
|
44
|
+
return res.status(413).json({ error: 'Payload too large' });
|
|
45
|
+
}
|
|
46
|
+
let tmp;
|
|
47
|
+
let saved = false;
|
|
48
|
+
try {
|
|
49
|
+
await ensureDir();
|
|
50
|
+
tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
51
|
+
await fsPromises.writeFile(tmp, serialized, 'utf8');
|
|
52
|
+
await fsPromises.rename(tmp, filePath);
|
|
53
|
+
saved = true;
|
|
54
|
+
return res.json({ success: true });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (tmp && !saved) {
|
|
57
|
+
await fsPromises.unlink(tmp).catch(() => {});
|
|
58
|
+
}
|
|
59
|
+
const message = error instanceof Error ? error.message : 'Failed to write session folders';
|
|
60
|
+
return res.status(500).json({ error: message });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
import { registerSessionFoldersRoutes } from './routes.js';
|
|
5
|
+
|
|
6
|
+
const createRouteRegistry = () => {
|
|
7
|
+
const routes = new Map();
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
app: {
|
|
11
|
+
get(routePath, handler) {
|
|
12
|
+
routes.set(`GET ${routePath}`, handler);
|
|
13
|
+
},
|
|
14
|
+
post(routePath, handler) {
|
|
15
|
+
routes.set(`POST ${routePath}`, handler);
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
getRoute(method, routePath) {
|
|
19
|
+
return routes.get(`${method} ${routePath}`);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const createMockResponse = () => {
|
|
25
|
+
let statusCode = 200;
|
|
26
|
+
let body = null;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
status(code) {
|
|
30
|
+
statusCode = code;
|
|
31
|
+
return this;
|
|
32
|
+
},
|
|
33
|
+
json(payload) {
|
|
34
|
+
body = payload;
|
|
35
|
+
return this;
|
|
36
|
+
},
|
|
37
|
+
get statusCode() {
|
|
38
|
+
return statusCode;
|
|
39
|
+
},
|
|
40
|
+
get body() {
|
|
41
|
+
return body;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe('session folders routes', () => {
|
|
47
|
+
it('uses unique temp files for concurrent saves', async () => {
|
|
48
|
+
const { app, getRoute } = createRouteRegistry();
|
|
49
|
+
const tempPaths = [];
|
|
50
|
+
const fsPromises = {
|
|
51
|
+
mkdir: vi.fn(async () => {}),
|
|
52
|
+
writeFile: vi.fn(async (tempPath) => {
|
|
53
|
+
tempPaths.push(tempPath);
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
55
|
+
}),
|
|
56
|
+
rename: vi.fn(async () => {}),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
registerSessionFoldersRoutes(app, {
|
|
60
|
+
fsPromises,
|
|
61
|
+
path,
|
|
62
|
+
vinciDataDir: '/tmp/vinci-test',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const handler = getRoute('POST', '/api/session-folders');
|
|
66
|
+
|
|
67
|
+
await Promise.all([
|
|
68
|
+
handler({ body: { version: 1, updatedAt: 1 } }, createMockResponse()),
|
|
69
|
+
handler({ body: { version: 1, updatedAt: 2 } }, createMockResponse()),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
expect(tempPaths).toHaveLength(2);
|
|
73
|
+
expect(new Set(tempPaths).size).toBe(2);
|
|
74
|
+
expect(tempPaths.every((tempPath) => tempPath.includes('sessions-directories.json.tmp-'))).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('removes the temp file when rename fails', async () => {
|
|
78
|
+
const { app, getRoute } = createRouteRegistry();
|
|
79
|
+
const fsPromises = {
|
|
80
|
+
mkdir: vi.fn(async () => {}),
|
|
81
|
+
writeFile: vi.fn(async () => {}),
|
|
82
|
+
rename: vi.fn(async () => {
|
|
83
|
+
throw new Error('rename failed');
|
|
84
|
+
}),
|
|
85
|
+
unlink: vi.fn(async () => {}),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
registerSessionFoldersRoutes(app, {
|
|
89
|
+
fsPromises,
|
|
90
|
+
path,
|
|
91
|
+
vinciDataDir: '/tmp/vinci-test',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const handler = getRoute('POST', '/api/session-folders');
|
|
95
|
+
const response = createMockResponse();
|
|
96
|
+
|
|
97
|
+
await handler({ body: { version: 1, updatedAt: 1 } }, response);
|
|
98
|
+
|
|
99
|
+
expect(response.statusCode).toBe(500);
|
|
100
|
+
expect(fsPromises.unlink).toHaveBeenCalledWith(expect.stringContaining('sessions-directories.json.tmp-'));
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Skills Catalog Module Documentation
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
This module provides skill discovery, scanning, and installation capabilities for OpenCode. It supports multiple skill sources including git repositories and the ClawdHub registry, with caching and conflict resolution for skill installation.
|
|
5
|
+
|
|
6
|
+
## Entrypoints and structure
|
|
7
|
+
- `packages/web/server/lib/skills-catalog/`: Skills catalog module directory containing all skill-related functionality.
|
|
8
|
+
- `cache.js`: In-memory cache for scan results with TTL support.
|
|
9
|
+
- `curated-sources.js`: Predefined skill sources (Anthropic, ClawdHub).
|
|
10
|
+
- `git.js`: Git operations helpers for cloning and auth error detection.
|
|
11
|
+
- `install.js`: Skills installation from git repositories.
|
|
12
|
+
- `scan.js`: Skills scanning from git repositories.
|
|
13
|
+
- `source.js`: Source string parsing for git repositories.
|
|
14
|
+
- `clawdhub/`: ClawdHub registry integration.
|
|
15
|
+
- `index.js`: Public API exports for ClawdHub.
|
|
16
|
+
- `scan.js`: Scanning ClawdHub registry with pagination.
|
|
17
|
+
- `install.js`: Installation from ClawdHub (ZIP download).
|
|
18
|
+
- `api.js`: ClawdHub API client with rate limiting.
|
|
19
|
+
|
|
20
|
+
## Public API
|
|
21
|
+
|
|
22
|
+
The following functions are exported and used by the web server:
|
|
23
|
+
|
|
24
|
+
### Cache (`cache.js`)
|
|
25
|
+
- `getCacheKey({ normalizedRepo, subpath, identityId })`: Generate cache key for scan results.
|
|
26
|
+
- `getCachedScan(key)`: Retrieve cached scan result if not expired.
|
|
27
|
+
- `setCachedScan(key, value, ttlMs)`: Store scan result with TTL (default 30 minutes).
|
|
28
|
+
- `clearCache()`: Clear all cached scan results.
|
|
29
|
+
|
|
30
|
+
### Curated Sources (`curated-sources.js`)
|
|
31
|
+
- `getCuratedSkillsSources()`: Return list of curated skill sources (Anthropic, ClawdHub).
|
|
32
|
+
- `CURATED_SKILLS_SOURCES`: Constant array of predefined sources.
|
|
33
|
+
|
|
34
|
+
### Source Parsing (`source.js`)
|
|
35
|
+
- `parseSkillRepoSource(source, { subpath })`: Parse git repository source string into structured object with SSH/HTTPS clone URLs, normalized repo, and effective subpath. Supports SSH URLs, HTTPS URLs, and shorthand `owner/repo[/subpath]` format.
|
|
36
|
+
|
|
37
|
+
### Git Repository Scanning (`scan.js`)
|
|
38
|
+
- `scanSkillsRepository({ source, subpath, defaultSubpath, identity })`: Scan git repository for skills by cloning and analyzing SKILL.md files. Returns array of skill items with metadata.
|
|
39
|
+
|
|
40
|
+
### Git Repository Installation (`install.js`)
|
|
41
|
+
- `installSkillsFromRepository({ source, subpath, defaultSubpath, identity, scope, targetSource, workingDirectory, userSkillDir, selections, conflictPolicy, conflictDecisions })`: Install skills from git repository. Supports user/project scopes, opencode/agents targets, conflict resolution (prompt/skipAll/overwriteAll), and sparse checkout for efficiency.
|
|
42
|
+
|
|
43
|
+
### ClawdHub Integration (`clawdhub/index.js`)
|
|
44
|
+
- `isClawdHubSource(source)`: Check if source string refers to ClawdHub.
|
|
45
|
+
- `scanClawdHub()`: Scan entire ClawdHub registry for all skills (paginated, max 20 pages).
|
|
46
|
+
- `scanClawdHubPage({ cursor })`: Scan a single page of ClawdHub results with cursor-based pagination.
|
|
47
|
+
- `installSkillsFromClawdHub({ scope, targetSource, workingDirectory, userSkillDir, selections, conflictPolicy, conflictDecisions })`: Install skills from ClawdHub by downloading ZIP files.
|
|
48
|
+
- `fetchClawdHubSkills({ cursor })`: Fetch paginated skills list from ClawdHub API.
|
|
49
|
+
- `fetchClawdHubSkillVersion(slug, version)`: Fetch specific skill version details.
|
|
50
|
+
- `fetchClawdHubSkillInfo(slug)`: Fetch skill metadata without version details.
|
|
51
|
+
- `downloadClawdHubSkill(slug, version)`: Download skill package as ZIP buffer.
|
|
52
|
+
|
|
53
|
+
### ClawdHub Constants (`clawdhub/index.js`)
|
|
54
|
+
- `CLAWDHUB_SOURCE_ID`: Source identifier for curated sources.
|
|
55
|
+
- `CLAWDHUB_SOURCE_STRING`: Source string format.
|
|
56
|
+
|
|
57
|
+
## Internal Helpers
|
|
58
|
+
|
|
59
|
+
The following functions are internal helpers used by exported functions:
|
|
60
|
+
|
|
61
|
+
### Git Helpers (`git.js`)
|
|
62
|
+
- `runGit(args, options)`: Execute git command with optional SSH identity, timeout, and max buffer. Returns `{ ok, stdout, stderr, message, code, signal }`.
|
|
63
|
+
- `looksLikeAuthError(message)`: Detect if error message indicates authentication failure (permission denied, publickey, etc.).
|
|
64
|
+
- `assertGitAvailable()`: Check if git is available in PATH.
|
|
65
|
+
|
|
66
|
+
### Skill Name Validation (used in `install.js`, `scan.js`, `clawdhub/install.js`)
|
|
67
|
+
- `validateSkillName(skillName)`: Validate skill name against pattern `/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/` (1-64 chars, lowercase alphanumeric with hyphens).
|
|
68
|
+
|
|
69
|
+
### File System Helpers (`install.js`, `scan.js`, `clawdhub/install.js`)
|
|
70
|
+
- `safeRm(dir)`: Safely remove directory recursively (ignores errors).
|
|
71
|
+
- `ensureDir(dirPath)`: Ensure directory exists with recursive creation.
|
|
72
|
+
- `copyDirectoryNoSymlinks(srcDir, dstDir)`: Copy directory contents without symlinks, with path traversal protection.
|
|
73
|
+
- `normalizeUserSkillDir(userSkillDir)`: Normalize user skill directory path (handles legacy `~/.config/opencode/skill` → `~/.config/opencode/skills` migration).
|
|
74
|
+
|
|
75
|
+
### Git Clone Helpers (`install.js`, `scan.js`)
|
|
76
|
+
- `cloneRepo({ cloneUrl, identity, tempDir })`: Clone git repository with preferred partial clone (`--filter=blob:none`) and fallback. Uses non-interactive mode.
|
|
77
|
+
|
|
78
|
+
### SKILL.md Parsing (`scan.js`)
|
|
79
|
+
- `parseSkillMd(content)`: Parse YAML frontmatter from SKILL.md content. Returns `{ ok, frontmatter, warnings }`.
|
|
80
|
+
|
|
81
|
+
### Path Helpers (`install.js`)
|
|
82
|
+
- `toFsPath(repoDir, repoRelPosixPath)`: Convert POSIX path to filesystem path.
|
|
83
|
+
- `getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName })`: Determine target installation directory based on scope (user/project), targetSource (opencode/agents), and skill name.
|
|
84
|
+
|
|
85
|
+
### ClawdHub API Helpers (`clawdhub/api.js`)
|
|
86
|
+
- `rateLimitedFetch(url, options)`: Fetch with rate limiting (120 req/min limit, 100ms delay between requests, exponential backoff on 429/500 errors).
|
|
87
|
+
- `mapClawdHubItem(item)`: Transform ClawdHub API response to SkillsCatalogItem format.
|
|
88
|
+
|
|
89
|
+
## Response Contracts
|
|
90
|
+
|
|
91
|
+
### Scan Skills Repository Response
|
|
92
|
+
- `ok`: Boolean indicating success.
|
|
93
|
+
- `normalizedRepo`: Normalized repo string (`owner/repo`).
|
|
94
|
+
- `effectiveSubpath`: Effective subpath used for scanning (may be from source string or defaultSubpath).
|
|
95
|
+
- `items`: Array of skill items with `{ repoSource, repoSubpath, skillDir, skillName, frontmatterName, description, installable, warnings }`.
|
|
96
|
+
- `error`: Error object with `{ kind, message }` on failure.
|
|
97
|
+
|
|
98
|
+
### Install Skills Response
|
|
99
|
+
- `ok`: Boolean indicating success.
|
|
100
|
+
- `installed`: Array of installed skills with `{ skillName, scope, source }`.
|
|
101
|
+
- `skipped`: Array of skipped skills with `{ skillName, reason }`.
|
|
102
|
+
- `error`: Error object with `{ kind, message, conflicts? }` on failure. Kinds: `authRequired`, `networkError`, `conflicts`, `invalidSource`, `unknown`.
|
|
103
|
+
|
|
104
|
+
### ClawdHub Scan Response
|
|
105
|
+
- `ok`: Boolean indicating success.
|
|
106
|
+
- `items`: Array of skill items with ClawdHub-specific metadata in `clawdhub` property.
|
|
107
|
+
- `nextCursor`: Pagination cursor for next page (only for `scanClawdHubPage`).
|
|
108
|
+
- `error`: Error object with `{ kind, message }` on failure.
|
|
109
|
+
|
|
110
|
+
### Parse Source Response
|
|
111
|
+
- `ok`: Boolean indicating success.
|
|
112
|
+
- `host`: Git host (e.g., `github.com`, `gitlab.com`).
|
|
113
|
+
- `owner`: Repository owner.
|
|
114
|
+
- `repo`: Repository name.
|
|
115
|
+
- `cloneUrlSsh`: SSH clone URL.
|
|
116
|
+
- `cloneUrlHttps`: HTTPS clone URL.
|
|
117
|
+
- `effectiveSubpath`: Subpath for scanning (from source string or options).
|
|
118
|
+
- `normalizedRepo`: Normalized repo string (`owner/repo`).
|
|
119
|
+
- `error`: Error object with `{ kind, message }` on failure.
|
|
120
|
+
|
|
121
|
+
## Notes for Contributors
|
|
122
|
+
|
|
123
|
+
### Adding a New Skill Source
|
|
124
|
+
1. Create a new subdirectory under `packages/web/server/lib/skills-catalog/` (e.g., `newsource/`).
|
|
125
|
+
2. Implement `scan.js` with a function that returns `{ ok, items, error? }` matching the SkillsCatalogItem contract.
|
|
126
|
+
3. Implement `install.js` with a function that accepts selections and returns `{ ok, installed, skipped, error? }`.
|
|
127
|
+
4. Add the source to `CURATED_SKILLS_SOURCES` in `curated-sources.js` if it should appear in the default catalog.
|
|
128
|
+
5. Update `packages/web/server/index.js` to import and wire up the new source.
|
|
129
|
+
|
|
130
|
+
### Skill Name Validation
|
|
131
|
+
- All skill names must match `/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/` (1-64 chars).
|
|
132
|
+
- Skill names are derived from directory basenames for git repos and slugs for ClawdHub.
|
|
133
|
+
- Invalid names result in non-installable skills with appropriate warnings.
|
|
134
|
+
|
|
135
|
+
### Git Cloning Strategy
|
|
136
|
+
- Use sparse checkout to minimize clone size: `sparse-checkout init`, `sparse-checkout set`, `checkout HEAD`.
|
|
137
|
+
- Preferred clone uses `--depth=1 --filter=blob:none` for partial clone with fallback to `--depth=1`.
|
|
138
|
+
- Always use non-interactive mode (`GIT_TERMINAL_PROMPT=0`) to avoid hangs.
|
|
139
|
+
- SSH keys are injected via `core.sshCommand` in git config.
|
|
140
|
+
|
|
141
|
+
### Conflict Resolution
|
|
142
|
+
- Installation checks for existing skills before downloading/cloning.
|
|
143
|
+
- Three conflict policies: `prompt`, `skipAll`, `overwriteAll`.
|
|
144
|
+
- Per-skill decisions override global policy via `conflictDecisions` map.
|
|
145
|
+
- Conflict response includes `{ skillName, scope, source }` for each conflict.
|
|
146
|
+
|
|
147
|
+
### ClawdHub Integration
|
|
148
|
+
- ClawdHub API base URL: `https://clawdhub.com/api/v1`.
|
|
149
|
+
- Pagination uses cursor-based approach with `MAX_PAGES=20` safety limit.
|
|
150
|
+
- Rate limiting: 120 req/min with 100ms delay between requests.
|
|
151
|
+
- Downloaded skills are extracted from ZIP files using `adm-zip`.
|
|
152
|
+
- Always validate `SKILL.md` exists before installation.
|
|
153
|
+
|
|
154
|
+
### Cache Management
|
|
155
|
+
- Cache keys include `normalizedRepo`, `subpath`, and `identityId` for isolation.
|
|
156
|
+
- Default TTL is 30 minutes; can be overridden via `ttlMs` parameter.
|
|
157
|
+
- Cache is in-memory (not persisted across restarts).
|
|
158
|
+
|
|
159
|
+
### Security Considerations
|
|
160
|
+
- Path traversal protection in `copyDirectoryNoSymlinks`: resolves real paths and checks containment.
|
|
161
|
+
- Symlinks are explicitly rejected to prevent escape from skill directory.
|
|
162
|
+
- SSH key paths are trimmed but not escaped in `git.js` (assumes safe input from profiles).
|
|
163
|
+
- Temporary directories are cleaned up in `finally` blocks.
|
|
164
|
+
|
|
165
|
+
### Error Handling
|
|
166
|
+
- All exported functions return `{ ok, ... }` result objects, not throw.
|
|
167
|
+
- Error kinds: `authRequired`, `networkError`, `conflicts`, `invalidSource`, `unknown`.
|
|
168
|
+
- Use `looksLikeAuthError` to detect SSH/HTTPS auth failures for better UX.
|
|
169
|
+
- Log errors to console for debugging but return structured errors to callers.
|
|
170
|
+
|
|
171
|
+
### Testing
|
|
172
|
+
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes.
|
|
173
|
+
- Consider edge cases: non-existent repos, private repos without auth, missing SKILL.md files, invalid skill names, conflicts, network failures.
|
|
174
|
+
|
|
175
|
+
## Verification Commands
|
|
176
|
+
- Type-check: `bun run type-check`
|
|
177
|
+
- Lint: `bun run lint`
|
|
178
|
+
- Build: `bun run build`
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
const cache = new Map();
|
|
4
|
+
|
|
5
|
+
export function getCacheKey({ normalizedRepo, subpath, identityId }) {
|
|
6
|
+
const safeRepo = String(normalizedRepo || '').trim();
|
|
7
|
+
const safeSubpath = String(subpath || '').trim();
|
|
8
|
+
const safeIdentity = String(identityId || '').trim();
|
|
9
|
+
return `${safeRepo}::${safeSubpath}::${safeIdentity}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getCachedScan(key) {
|
|
13
|
+
const entry = cache.get(key);
|
|
14
|
+
if (!entry) return null;
|
|
15
|
+
if (Date.now() >= entry.expiresAt) {
|
|
16
|
+
cache.delete(key);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return entry.value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function setCachedScan(key, value, ttlMs = DEFAULT_TTL_MS) {
|
|
23
|
+
const ttl = Number.isFinite(ttlMs) ? ttlMs : DEFAULT_TTL_MS;
|
|
24
|
+
cache.set(key, { expiresAt: Date.now() + ttl, value });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function clearCache() {
|
|
28
|
+
cache.clear();
|
|
29
|
+
}
|