kcg-code 0.0.0-stage → 0.2.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.
Files changed (307) hide show
  1. package/README.md +230 -2
  2. package/bin/kcgcode.ts +13 -0
  3. package/bun-env.d.ts +19 -0
  4. package/kcg-code.config.example.json +3 -0
  5. package/package.json +84 -4
  6. package/public/logo.svg +1 -0
  7. package/public/manifest.json +18 -0
  8. package/public/sw.js +49 -0
  9. package/src/App.tsx +39 -0
  10. package/src/__tests__/manifest.test.ts +61 -0
  11. package/src/build-config.ts +9 -0
  12. package/src/cli/__tests__/daemon.test.ts +84 -0
  13. package/src/cli/__tests__/dashboard.test.ts +224 -0
  14. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  15. package/src/cli/__tests__/port.test.ts +86 -0
  16. package/src/cli/args.ts +186 -0
  17. package/src/cli/attach.ts +154 -0
  18. package/src/cli/commands.ts +327 -0
  19. package/src/cli/daemon.ts +275 -0
  20. package/src/cli/dashboard.tsx +1060 -0
  21. package/src/cli/index.ts +47 -0
  22. package/src/cli/keep-awake.ts +148 -0
  23. package/src/cli/port.ts +92 -0
  24. package/src/cli/theme.ts +100 -0
  25. package/src/components/auth/AuthGate.tsx +182 -0
  26. package/src/components/auth/AvatarEditor.tsx +236 -0
  27. package/src/components/auth/LockScreen.tsx +714 -0
  28. package/src/components/auth/ProfileAvatar.tsx +60 -0
  29. package/src/components/auth/WelcomeScreen.tsx +672 -0
  30. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  31. package/src/components/auth/avatar-icons.tsx +114 -0
  32. package/src/components/layout/AppShell.tsx +147 -0
  33. package/src/components/layout/AppSidebar.tsx +419 -0
  34. package/src/components/layout/SearchDialog.tsx +189 -0
  35. package/src/components/layout/SidebarAccount.tsx +111 -0
  36. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  37. package/src/components/layout/ThemeToggle.tsx +67 -0
  38. package/src/components/projects/FolderBrowser.tsx +142 -0
  39. package/src/components/projects/HomeChat.tsx +287 -0
  40. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  41. package/src/components/projects/NewProjectDialog.tsx +249 -0
  42. package/src/components/projects/ProjectInstructions.tsx +218 -0
  43. package/src/components/projects/ProjectRow.tsx +58 -0
  44. package/src/components/projects/ProjectSidePanel.tsx +514 -0
  45. package/src/components/sessions/AgentLogo.tsx +42 -0
  46. package/src/components/sessions/AgentModeList.tsx +265 -0
  47. package/src/components/sessions/AgentPicker.tsx +132 -0
  48. package/src/components/sessions/AssistantTurn.tsx +145 -0
  49. package/src/components/sessions/AttachmentTile.tsx +161 -0
  50. package/src/components/sessions/CodeBlock.tsx +107 -0
  51. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  52. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  53. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
  54. package/src/components/sessions/ContextPanel.tsx +661 -0
  55. package/src/components/sessions/DropPlate.tsx +165 -0
  56. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  57. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  58. package/src/components/sessions/ImageLightbox.tsx +173 -0
  59. package/src/components/sessions/MarkdownContent.tsx +187 -0
  60. package/src/components/sessions/Mascot.tsx +47 -0
  61. package/src/components/sessions/MessageActions.tsx +60 -0
  62. package/src/components/sessions/MessageRail.tsx +121 -0
  63. package/src/components/sessions/ModelPicker.tsx +247 -0
  64. package/src/components/sessions/ModelSearchList.tsx +231 -0
  65. package/src/components/sessions/NewSessionDialog.tsx +172 -0
  66. package/src/components/sessions/PendingAttachments.tsx +127 -0
  67. package/src/components/sessions/PromptCard.tsx +1085 -0
  68. package/src/components/sessions/PromptPanel.tsx +112 -0
  69. package/src/components/sessions/SessionComposer.tsx +641 -0
  70. package/src/components/sessions/SessionHeader.tsx +176 -0
  71. package/src/components/sessions/SessionList.tsx +503 -0
  72. package/src/components/sessions/SessionRow.tsx +237 -0
  73. package/src/components/sessions/SessionTimeline.tsx +275 -0
  74. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  75. package/src/components/sessions/SessionView.tsx +240 -0
  76. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  77. package/src/components/sessions/UserMessage.tsx +177 -0
  78. package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
  79. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  80. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  81. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  82. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  83. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  84. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  85. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  86. package/src/components/settings/AboutGroup.tsx +170 -0
  87. package/src/components/settings/AccountGroup.tsx +122 -0
  88. package/src/components/settings/DevicesGroup.tsx +151 -0
  89. package/src/components/settings/LhrRemoteAccess.tsx +257 -0
  90. package/src/components/settings/PasskeysGroup.tsx +551 -0
  91. package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
  92. package/src/components/settings/SecurityGroup.tsx +566 -0
  93. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  94. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  95. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  96. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  97. package/src/components/settings/prefs.tsx +333 -0
  98. package/src/components/skills/InstallNotifications.tsx +326 -0
  99. package/src/components/skills/InstallSkillDialog.tsx +390 -0
  100. package/src/components/skills/SkillCards.tsx +254 -0
  101. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  102. package/src/components/ui/adw-dialog.tsx +114 -0
  103. package/src/components/ui/adw-toggle-group.tsx +75 -0
  104. package/src/components/ui/alert-dialog.tsx +179 -0
  105. package/src/components/ui/alert.tsx +60 -0
  106. package/src/components/ui/badge.tsx +46 -0
  107. package/src/components/ui/bubble.tsx +125 -0
  108. package/src/components/ui/button.tsx +62 -0
  109. package/src/components/ui/card.tsx +75 -0
  110. package/src/components/ui/collapsible.tsx +19 -0
  111. package/src/components/ui/dialog.tsx +142 -0
  112. package/src/components/ui/dropdown-menu.tsx +225 -0
  113. package/src/components/ui/empty.tsx +94 -0
  114. package/src/components/ui/field.tsx +234 -0
  115. package/src/components/ui/input-group.tsx +161 -0
  116. package/src/components/ui/input.tsx +21 -0
  117. package/src/components/ui/label.tsx +19 -0
  118. package/src/components/ui/message-scroller.tsx +131 -0
  119. package/src/components/ui/message.tsx +85 -0
  120. package/src/components/ui/popover.tsx +46 -0
  121. package/src/components/ui/search-entry.tsx +70 -0
  122. package/src/components/ui/select.tsx +172 -0
  123. package/src/components/ui/separator.tsx +28 -0
  124. package/src/components/ui/sheet.tsx +138 -0
  125. package/src/components/ui/sidebar.tsx +699 -0
  126. package/src/components/ui/skeleton.tsx +15 -0
  127. package/src/components/ui/sonner.tsx +45 -0
  128. package/src/components/ui/spinner.tsx +16 -0
  129. package/src/components/ui/switch.tsx +32 -0
  130. package/src/components/ui/textarea.tsx +18 -0
  131. package/src/components/ui/tooltip.tsx +51 -0
  132. package/src/config.ts +114 -0
  133. package/src/db.ts +146 -0
  134. package/src/frontend.tsx +51 -0
  135. package/src/hooks/__tests__/useMention.test.ts +79 -0
  136. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  137. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  138. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  139. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  140. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  141. package/src/hooks/use-mobile.ts +16 -0
  142. package/src/hooks/useAgentPicker.ts +101 -0
  143. package/src/hooks/useMediaQuery.ts +49 -0
  144. package/src/hooks/useMention.ts +236 -0
  145. package/src/hooks/useProjects.ts +88 -0
  146. package/src/hooks/useResyncOnReconnect.ts +28 -0
  147. package/src/hooks/useRouter.ts +92 -0
  148. package/src/hooks/useSessionChat.ts +427 -0
  149. package/src/hooks/useSessionContext.ts +106 -0
  150. package/src/hooks/useSessionList.ts +327 -0
  151. package/src/hooks/useSidebarData.ts +108 -0
  152. package/src/hooks/useSmoothText.ts +122 -0
  153. package/src/hooks/useTheme.ts +164 -0
  154. package/src/hooks/useWebSocket.ts +196 -0
  155. package/src/index.css +27 -0
  156. package/src/index.html +14 -0
  157. package/src/index.ts +49 -0
  158. package/src/lib/__tests__/attachments.test.ts +58 -0
  159. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  160. package/src/lib/__tests__/collapsible.test.ts +101 -0
  161. package/src/lib/__tests__/composer.test.ts +118 -0
  162. package/src/lib/__tests__/highlight.test.ts +42 -0
  163. package/src/lib/__tests__/home-chat.test.ts +64 -0
  164. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  165. package/src/lib/__tests__/model-picker.test.ts +205 -0
  166. package/src/lib/__tests__/onboarding.test.ts +41 -0
  167. package/src/lib/__tests__/permission-info.test.ts +61 -0
  168. package/src/lib/__tests__/project-extensions.test.ts +83 -0
  169. package/src/lib/__tests__/project-overview.test.ts +188 -0
  170. package/src/lib/__tests__/references.test.ts +121 -0
  171. package/src/lib/__tests__/routes.test.ts +134 -0
  172. package/src/lib/__tests__/session-selection.test.ts +41 -0
  173. package/src/lib/__tests__/session-summary.test.ts +115 -0
  174. package/src/lib/__tests__/session-title.test.ts +58 -0
  175. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  176. package/src/lib/__tests__/skill-installs.test.ts +135 -0
  177. package/src/lib/__tests__/skills-catalog.test.ts +124 -0
  178. package/src/lib/__tests__/tone.test.ts +37 -0
  179. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  180. package/src/lib/agent-types.ts +49 -0
  181. package/src/lib/api.ts +306 -0
  182. package/src/lib/attachments.ts +203 -0
  183. package/src/lib/auth.ts +92 -0
  184. package/src/lib/avatar-presets.ts +31 -0
  185. package/src/lib/collapsible.ts +75 -0
  186. package/src/lib/composer.ts +89 -0
  187. package/src/lib/data-events.ts +17 -0
  188. package/src/lib/headlines.ts +30 -0
  189. package/src/lib/highlight.ts +186 -0
  190. package/src/lib/layout.ts +20 -0
  191. package/src/lib/lock-screen.ts +54 -0
  192. package/src/lib/model-picker.ts +133 -0
  193. package/src/lib/onboarding.ts +38 -0
  194. package/src/lib/passkeys.ts +113 -0
  195. package/src/lib/pending-prompt.ts +53 -0
  196. package/src/lib/permission-info.ts +101 -0
  197. package/src/lib/project-extensions.ts +136 -0
  198. package/src/lib/project-overview.ts +123 -0
  199. package/src/lib/prompts.ts +87 -0
  200. package/src/lib/references.ts +106 -0
  201. package/src/lib/routes.ts +124 -0
  202. package/src/lib/session-handoff.ts +25 -0
  203. package/src/lib/session-selection.ts +40 -0
  204. package/src/lib/session-status.ts +45 -0
  205. package/src/lib/session-summary.ts +68 -0
  206. package/src/lib/session-title.ts +63 -0
  207. package/src/lib/sidebar-groups.ts +78 -0
  208. package/src/lib/skill-installs.ts +362 -0
  209. package/src/lib/skills-catalog.ts +138 -0
  210. package/src/lib/time.ts +22 -0
  211. package/src/lib/tone.ts +80 -0
  212. package/src/lib/turns.ts +320 -0
  213. package/src/lib/utils.ts +6 -0
  214. package/src/logo.svg +31 -0
  215. package/src/pages/projects/ProjectDetailPage.tsx +131 -0
  216. package/src/pages/projects/ProjectsPage.tsx +97 -0
  217. package/src/pages/sessions/SessionPage.tsx +122 -0
  218. package/src/pages/settings/SettingsPage.tsx +366 -0
  219. package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
  220. package/src/pages/skills/SkillsPage.tsx +745 -0
  221. package/src/paths.ts +121 -0
  222. package/src/runtime.ts +24 -0
  223. package/src/server/__tests__/app.test.ts +1044 -0
  224. package/src/server/__tests__/auth.test.ts +370 -0
  225. package/src/server/__tests__/config.test.ts +101 -0
  226. package/src/server/__tests__/db.test.ts +485 -0
  227. package/src/server/__tests__/host.test.ts +14 -0
  228. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  229. package/src/server/app.ts +427 -0
  230. package/src/server/db/auth.ts +311 -0
  231. package/src/server/db/messages.ts +50 -0
  232. package/src/server/db/projects.ts +106 -0
  233. package/src/server/db/prompts.ts +78 -0
  234. package/src/server/db/rows.ts +150 -0
  235. package/src/server/db/schema.ts +221 -0
  236. package/src/server/db/sessions.ts +239 -0
  237. package/src/server/db/tunnel.ts +124 -0
  238. package/src/server/host.ts +12 -0
  239. package/src/server/result.ts +15 -0
  240. package/src/server/routes/auth-guard.ts +156 -0
  241. package/src/server/routes/auth.routes.ts +287 -0
  242. package/src/server/routes/helpers.ts +165 -0
  243. package/src/server/routes/meta.routes.ts +24 -0
  244. package/src/server/routes/passkeys.routes.ts +161 -0
  245. package/src/server/routes/projects.routes.ts +167 -0
  246. package/src/server/routes/sessions.routes.ts +197 -0
  247. package/src/server/routes/skills.routes.ts +136 -0
  248. package/src/server/routes/tunnel.routes.ts +132 -0
  249. package/src/server/routes/types.ts +35 -0
  250. package/src/server/routes/uploads.routes.ts +54 -0
  251. package/src/server/routes/workspace.routes.ts +127 -0
  252. package/src/server/services/__tests__/app-info.test.ts +18 -0
  253. package/src/server/services/__tests__/attachments.test.ts +159 -0
  254. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  255. package/src/server/services/__tests__/auth.test.ts +341 -0
  256. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  257. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  258. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  259. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  260. package/src/server/services/__tests__/git-info.test.ts +74 -0
  261. package/src/server/services/__tests__/opencode.test.ts +320 -0
  262. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  263. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  264. package/src/server/services/__tests__/session-manager.test.ts +3509 -0
  265. package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
  266. package/src/server/services/__tests__/skills-registry.test.ts +365 -0
  267. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  268. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  269. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  270. package/src/server/services/__tests__/workspace.test.ts +249 -0
  271. package/src/server/services/app-info.ts +29 -0
  272. package/src/server/services/attachments.ts +193 -0
  273. package/src/server/services/auth-passkeys.ts +459 -0
  274. package/src/server/services/auth.ts +633 -0
  275. package/src/server/services/claude-code-client.ts +1093 -0
  276. package/src/server/services/claude-code-translate.ts +370 -0
  277. package/src/server/services/claude-mcp.ts +122 -0
  278. package/src/server/services/claude-skills.ts +102 -0
  279. package/src/server/services/frpc-installer.ts +183 -0
  280. package/src/server/services/git-info.ts +87 -0
  281. package/src/server/services/opencode-client.ts +714 -0
  282. package/src/server/services/opencode-server.ts +319 -0
  283. package/src/server/services/opencode-sse.ts +82 -0
  284. package/src/server/services/project-manager.ts +147 -0
  285. package/src/server/services/sandbox.ts +83 -0
  286. package/src/server/services/session-events.ts +256 -0
  287. package/src/server/services/session-manager.ts +1439 -0
  288. package/src/server/services/skill-install-jobs.ts +275 -0
  289. package/src/server/services/skills-registry.ts +480 -0
  290. package/src/server/services/tunnel-api.ts +207 -0
  291. package/src/server/services/tunnel-config.ts +45 -0
  292. package/src/server/services/tunnel-lhr.ts +387 -0
  293. package/src/server/services/tunnel.ts +633 -0
  294. package/src/server/services/turn-stream.ts +339 -0
  295. package/src/server/services/websocket-gateway.ts +374 -0
  296. package/src/server/services/workspace.ts +223 -0
  297. package/src/server/services/ws-transport.ts +121 -0
  298. package/src/types/context.ts +69 -0
  299. package/src/types/index.ts +13 -0
  300. package/src/types/message.ts +58 -0
  301. package/src/types/project.ts +16 -0
  302. package/src/types/prompt.ts +118 -0
  303. package/src/types/session.ts +52 -0
  304. package/src/types/turns.ts +47 -0
  305. package/src/ws-protocol.ts +93 -0
  306. package/styles/globals.css +311 -0
  307. package/tsconfig.json +37 -0
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Skills_Registry — integrasi katalog skill skills.sh + instalasi via CLI.
3
+ *
4
+ * - Pencarian: `GET https://skills.sh/api/search?q=` — endpoint tanpa auth
5
+ * yang dipakai CLI resmi (`skills find`). API v1 resmi mewajibkan token
6
+ * Vercel OIDC, tidak cocok untuk aplikasi lokal.
7
+ * - Audit keamanan: `GET https://skills.sh/api/v1/skills/audit/{id}` (saat
8
+ * ini dapat diakses tanpa token; 404 = belum pernah diaudit).
9
+ * - Instalasi: `bunx skills@<ver> add <source> --skill <name> --agent
10
+ * <opencode> <claude-code> -y --json` di direktori Project (satu atau
11
+ * kedua agent sekaligus). CLI memasang ke
12
+ * `.agents/skills/<name>/` (dibaca opencode; server headless Project perlu
13
+ * `POST /instance/dispose` agar daftar skill ter-refresh); untuk Claude
14
+ * Code ditambah symlink `.claude/skills/<name>/`.
15
+ *
16
+ * Argumen CLI dibangun sebagai array (tanpa shell) dan `source`/`skill`
17
+ * divalidasi ketat sebelum dipakai, sehingga tidak ada celah injeksi.
18
+ */
19
+ import type { AgentType } from "../../types";
20
+ import type { Result } from "../result";
21
+
22
+ /** Versi CLI `skills` yang dipin (hindari perubahan perilaku mendadak). */
23
+ export const SKILLS_CLI_VERSION = "1.7.0";
24
+
25
+ const DEFAULT_BASE_URL = "https://skills.sh";
26
+ const SEARCH_TIMEOUT_MS = 10_000;
27
+ const AUDIT_TIMEOUT_MS = 10_000;
28
+ /** Instalasi mengunduh repo — beri waktu longgar. */
29
+ const INSTALL_TIMEOUT_MS = 180_000;
30
+ /** Cache hasil pencarian/audit (skills.sh sendiri cache 30-60 detik). */
31
+ const CACHE_TTL_MS = 60_000;
32
+ const CACHE_MAX = 200;
33
+
34
+ export const MIN_QUERY_LENGTH = 2;
35
+ export const MAX_QUERY_LENGTH = 100;
36
+
37
+ /** Satu skill hasil pencarian skills.sh. */
38
+ export interface RegistrySkill {
39
+ /** `{source}/{skillId}` — stabil, dipakai untuk audit & detail. */
40
+ id: string;
41
+ name: string;
42
+ /** Repo GitHub `owner/repo` atau domain well-known. */
43
+ source: string;
44
+ /** Nama folder skill di repo (argumen `--skill`). */
45
+ skillId: string;
46
+ installs: number;
47
+ /** Halaman skill di skills.sh. */
48
+ url: string;
49
+ }
50
+
51
+ export type AuditStatus = "pass" | "warn" | "fail";
52
+
53
+ export interface SkillAuditEntry {
54
+ provider: string;
55
+ status: AuditStatus;
56
+ summary: string;
57
+ riskLevel: string | null;
58
+ }
59
+
60
+ export interface SkillAudit {
61
+ /** `null` bila belum ada partner yang mengaudit (404 dari skills.sh). */
62
+ audits: SkillAuditEntry[] | null;
63
+ /** Ringkasan terburuk dari seluruh partner; `null` bila tanpa audit. */
64
+ overall: AuditStatus | null;
65
+ }
66
+
67
+ export interface InstallResult {
68
+ name: string;
69
+ path: string | null;
70
+ }
71
+
72
+ // ---------------------------------------------------------------- validasi ----
73
+
74
+ /** `owner/repo` GitHub (karakter aman saja). */
75
+ const GITHUB_SOURCE_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
76
+ /** Sumber well-known berupa domain, mis. `mintlify.com`. */
77
+ const DOMAIN_SOURCE_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
78
+ /** Nama folder skill: huruf, angka, `.`, `_`, `-`; tidak diawali `-`/`.`. */
79
+ const SKILL_ID_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
80
+
81
+ export function isValidSource(source: string): boolean {
82
+ if (source.includes("..")) return false;
83
+ return GITHUB_SOURCE_RE.test(source) || DOMAIN_SOURCE_RE.test(source);
84
+ }
85
+
86
+ export function isValidSkillId(skillId: string): boolean {
87
+ return SKILL_ID_RE.test(skillId) && !skillId.includes("..");
88
+ }
89
+
90
+ /** Normalisasi query pencarian; `null` bila di luar batas panjang. */
91
+ export function normalizeQuery(raw: string): string | null {
92
+ const q = raw.trim().replace(/\s+/g, " ");
93
+ if (q.length < MIN_QUERY_LENGTH || q.length > MAX_QUERY_LENGTH) return null;
94
+ return q;
95
+ }
96
+
97
+ // ------------------------------------------------------------ parsing murni ----
98
+
99
+ function str(v: unknown): string | null {
100
+ return typeof v === "string" && v !== "" ? v : null;
101
+ }
102
+
103
+ /** Parse respons `/api/search`; entri tak valid / tak aman dibuang. */
104
+ export function parseSearchResponse(payload: unknown, baseUrl = DEFAULT_BASE_URL): RegistrySkill[] {
105
+ const skills = (payload as { skills?: unknown } | null)?.skills;
106
+ if (!Array.isArray(skills)) return [];
107
+ const out: RegistrySkill[] = [];
108
+ const seen = new Set<string>();
109
+ for (const raw of skills) {
110
+ if (typeof raw !== "object" || raw === null) continue;
111
+ const r = raw as Record<string, unknown>;
112
+ const source = str(r.source);
113
+ const skillId = str(r.skillId);
114
+ if (!source || !skillId || !isValidSource(source) || !isValidSkillId(skillId)) continue;
115
+ const id = `${source}/${skillId}`;
116
+ if (seen.has(id)) continue;
117
+ seen.add(id);
118
+ out.push({
119
+ id,
120
+ name: str(r.name) ?? skillId,
121
+ source,
122
+ skillId,
123
+ installs: typeof r.installs === "number" && r.installs >= 0 ? Math.floor(r.installs) : 0,
124
+ url: `${baseUrl}/${id}`,
125
+ });
126
+ }
127
+ return out.sort((a, b) => b.installs - a.installs);
128
+ }
129
+
130
+ const AUDIT_RANK: Record<AuditStatus, number> = { pass: 0, warn: 1, fail: 2 };
131
+
132
+ /** Parse respons audit; status tak dikenal dianggap `warn` (hati-hati). */
133
+ export function parseAuditResponse(payload: unknown): SkillAudit {
134
+ const list = (payload as { audits?: unknown } | null)?.audits;
135
+ if (!Array.isArray(list)) return { audits: null, overall: null };
136
+ const audits: SkillAuditEntry[] = [];
137
+ for (const raw of list) {
138
+ if (typeof raw !== "object" || raw === null) continue;
139
+ const r = raw as Record<string, unknown>;
140
+ const provider = str(r.provider);
141
+ if (!provider) continue;
142
+ const status: AuditStatus =
143
+ r.status === "pass" || r.status === "warn" || r.status === "fail" ? r.status : "warn";
144
+ audits.push({
145
+ provider,
146
+ status,
147
+ summary: str(r.summary) ?? "",
148
+ riskLevel: str(r.riskLevel),
149
+ });
150
+ }
151
+ if (audits.length === 0) return { audits: null, overall: null };
152
+ const overall = audits.reduce<AuditStatus>(
153
+ (worst, a) => (AUDIT_RANK[a.status] > AUDIT_RANK[worst] ? a.status : worst),
154
+ "pass",
155
+ );
156
+ return { audits, overall };
157
+ }
158
+
159
+ /** Argumen `bunx` untuk instalasi (array — tanpa shell). */
160
+ export function buildInstallArgs(
161
+ source: string,
162
+ skillId: string,
163
+ agentTypes: readonly AgentType[] = ["opencode"],
164
+ ): string[] {
165
+ return [
166
+ "bunx",
167
+ `skills@${SKILLS_CLI_VERSION}`,
168
+ "add",
169
+ source,
170
+ "--skill",
171
+ skillId,
172
+ // Id agent CLI `skills` kebetulan sama dengan `AgentType`; beberapa
173
+ // nilai setelah satu `--agent` diterima CLI.
174
+ "--agent",
175
+ ...agentTypes,
176
+ "-y",
177
+ "--json",
178
+ ];
179
+ }
180
+
181
+ /**
182
+ * Ambil hasil instalasi dari stdout `--json` (array hasil per skill).
183
+ * CLI bisa mencetak teks lain sebelum JSON, jadi cari `[` pertama.
184
+ */
185
+ export function parseInstallOutput(stdout: string, skillId: string): Result<InstallResult> {
186
+ const start = stdout.indexOf("[");
187
+ if (start === -1) return { ok: false, error: "SKILL_INSTALL_FAILED" };
188
+ let parsed: unknown;
189
+ try {
190
+ parsed = JSON.parse(stdout.slice(start));
191
+ } catch {
192
+ return { ok: false, error: "SKILL_INSTALL_FAILED" };
193
+ }
194
+ if (!Array.isArray(parsed)) return { ok: false, error: "SKILL_INSTALL_FAILED" };
195
+ const entry = parsed.find(
196
+ (e): e is Record<string, unknown> =>
197
+ typeof e === "object" && e !== null && (e as { name?: unknown }).name === skillId,
198
+ );
199
+ const hit = entry ?? (parsed[0] as Record<string, unknown> | undefined);
200
+ if (!hit || (hit.status !== "installed" && hit.status !== "updated")) {
201
+ return { ok: false, error: "SKILL_INSTALL_FAILED" };
202
+ }
203
+ return { ok: true, data: { name: str(hit.name) ?? skillId, path: str(hit.path) } };
204
+ }
205
+
206
+ // ----------------------------------------------------------------- service ----
207
+
208
+ export interface SpawnResult {
209
+ exitCode: number | null;
210
+ stdout: string;
211
+ stderr: string;
212
+ }
213
+
214
+ /**
215
+ * Jalankan proses. `onLine` menerima tiap baris stderr begitu tercetak —
216
+ * CLI `skills` mencetak progres ke stderr dan hasil `--json` ke stdout.
217
+ */
218
+ export type SpawnFn = (
219
+ args: string[],
220
+ cwd: string,
221
+ timeoutMs: number,
222
+ onLine?: (line: string) => void,
223
+ /** Batalkan proses (beserta seluruh anak-cucunya). */
224
+ signal?: AbortSignal,
225
+ ) => Promise<SpawnResult>;
226
+
227
+ export interface SkillsRegistryOptions {
228
+ fetch?: typeof fetch;
229
+ spawn?: SpawnFn;
230
+ baseUrl?: string;
231
+ now?: () => number;
232
+ }
233
+
234
+ export interface SkillsRegistry {
235
+ search(query: string): Promise<Result<RegistrySkill[]>>;
236
+ audit(source: string, skillId: string): Promise<Result<SkillAudit>>;
237
+ /**
238
+ * Pasang skill ke `projectPath`. Instalasi paralel per Project ditolak.
239
+ * `onLine` menerima output CLI (sudah dibersihkan) baris demi baris.
240
+ */
241
+ install(
242
+ projectPath: string,
243
+ source: string,
244
+ skillId: string,
245
+ onLine?: (line: string) => void,
246
+ signal?: AbortSignal,
247
+ /** Agent tujuan (`--agent`); default `["opencode"]`. */
248
+ agentTypes?: readonly AgentType[],
249
+ ): Promise<Result<InstallResult>>;
250
+ }
251
+
252
+ /** Hapus escape ANSI & karakter kontrol (selain tab) dari satu baris output. */
253
+ export function cleanOutputLine(raw: string): string {
254
+ return (
255
+ raw
256
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: memang menyaring escape ANSI
257
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
258
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: memang menyaring escape OSC
259
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
260
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: memang menyaring kontrol
261
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "")
262
+ .trimEnd()
263
+ );
264
+ }
265
+
266
+ /** Baca stream teks, panggil `onLine` per baris utuh; kembalikan seluruh teks. */
267
+ async function readLines(
268
+ stream: ReadableStream<Uint8Array>,
269
+ onLine?: (line: string) => void,
270
+ ): Promise<string> {
271
+ const decoder = new TextDecoder();
272
+ let all = "";
273
+ let buf = "";
274
+ for await (const chunk of stream) {
275
+ const text = decoder.decode(chunk, { stream: true });
276
+ all += text;
277
+ if (!onLine) continue;
278
+ buf += text;
279
+ // `\r` (spinner/progress yang menimpa baris) diperlakukan sebagai akhir baris.
280
+ const parts = buf.split(/\r\n|\n|\r/);
281
+ buf = parts.pop() ?? "";
282
+ for (const part of parts) onLine(part);
283
+ }
284
+ const tail = decoder.decode();
285
+ all += tail;
286
+ buf += tail;
287
+ if (onLine && buf !== "") onLine(buf);
288
+ return all;
289
+ }
290
+
291
+ /**
292
+ * Env proses CLI. Selain mematikan telemetry/warna, `git` (dipakai CLI untuk
293
+ * clone repo) dibuat gagal cepat alih-alih menggantung: tanpa prompt
294
+ * kredensial (repo privat) dan batalkan transfer yang macet (< 1 KB/s
295
+ * selama 30 detik).
296
+ */
297
+ export function installEnv(base: Record<string, string | undefined>): Record<string, string> {
298
+ const env: Record<string, string> = {};
299
+ for (const [k, v] of Object.entries(base)) if (v !== undefined) env[k] = v;
300
+ return {
301
+ ...env,
302
+ DISABLE_TELEMETRY: "1",
303
+ CI: "1",
304
+ NO_COLOR: "1",
305
+ GIT_TERMINAL_PROMPT: "0",
306
+ GCM_INTERACTIVE: "never",
307
+ GIT_HTTP_LOW_SPEED_LIMIT: "1000",
308
+ GIT_HTTP_LOW_SPEED_TIME: "30",
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Spawn default: `Bun.spawn` tanpa shell, di process group sendiri
314
+ * (`detached`). Timeout & pembatalan membunuh SELURUH group — `bunx` ->
315
+ * `node` -> `git` — karena membunuh anak langsung saja membuat cucu tetap
316
+ * hidup memegang pipe stdout/stderr sehingga proses tak pernah "selesai".
317
+ */
318
+ export const defaultSpawn: SpawnFn = async (args, cwd, timeoutMs, onLine, signal) => {
319
+ const proc = Bun.spawn(args, {
320
+ cwd,
321
+ stdin: "ignore",
322
+ stdout: "pipe",
323
+ stderr: "pipe",
324
+ env: installEnv(process.env),
325
+ detached: true,
326
+ });
327
+ let killed = false;
328
+ const killGroup = () => {
329
+ if (killed) return;
330
+ killed = true;
331
+ try {
332
+ process.kill(-proc.pid, "SIGKILL");
333
+ } catch {
334
+ // Group sudah tidak ada — cukup bunuh proses langsung (idempoten).
335
+ proc.kill("SIGKILL");
336
+ }
337
+ };
338
+ const timer = setTimeout(killGroup, timeoutMs);
339
+ const onAbort = () => killGroup();
340
+ if (signal?.aborted) killGroup();
341
+ else signal?.addEventListener("abort", onAbort, { once: true });
342
+ try {
343
+ const [stdout, stderr, exitCode] = await Promise.all([
344
+ readLines(proc.stdout),
345
+ readLines(proc.stderr, onLine),
346
+ proc.exited,
347
+ ]);
348
+ return { exitCode: killed ? null : exitCode, stdout, stderr };
349
+ } finally {
350
+ clearTimeout(timer);
351
+ signal?.removeEventListener("abort", onAbort);
352
+ }
353
+ };
354
+
355
+ export function createSkillsRegistry(opts: SkillsRegistryOptions = {}): SkillsRegistry {
356
+ const doFetch = opts.fetch ?? fetch;
357
+ const spawn = opts.spawn ?? defaultSpawn;
358
+ const baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL;
359
+ const now = opts.now ?? Date.now;
360
+ const cache = new Map<string, { at: number; value: unknown }>();
361
+ const installing = new Set<string>();
362
+
363
+ function cached<T>(key: string): T | undefined {
364
+ const hit = cache.get(key);
365
+ if (!hit) return undefined;
366
+ if (now() - hit.at > CACHE_TTL_MS) {
367
+ cache.delete(key);
368
+ return undefined;
369
+ }
370
+ return hit.value as T;
371
+ }
372
+
373
+ function remember(key: string, value: unknown): void {
374
+ if (cache.size >= CACHE_MAX) {
375
+ const oldest = cache.keys().next().value;
376
+ if (oldest !== undefined) cache.delete(oldest);
377
+ }
378
+ cache.set(key, { at: now(), value });
379
+ }
380
+
381
+ async function search(query: string): Promise<Result<RegistrySkill[]>> {
382
+ const q = normalizeQuery(query);
383
+ if (q === null) return { ok: false, error: "INVALID_SKILL_QUERY" };
384
+ const key = `search:${q.toLowerCase()}`;
385
+ const hit = cached<RegistrySkill[]>(key);
386
+ if (hit) return { ok: true, data: hit };
387
+ try {
388
+ const params = new URLSearchParams({ q, limit: "50" });
389
+ const res = await doFetch(`${baseUrl}/api/search?${params}`, {
390
+ signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
391
+ headers: { accept: "application/json" },
392
+ });
393
+ if (res.status === 429) return { ok: false, error: "SKILLS_RATE_LIMITED" };
394
+ if (!res.ok) return { ok: false, error: `SKILLS_SEARCH_FAILED(${res.status})` };
395
+ const data = parseSearchResponse(await res.json(), baseUrl);
396
+ remember(key, data);
397
+ return { ok: true, data };
398
+ } catch (e) {
399
+ return { ok: false, error: `SKILLS_SEARCH_FAILED: ${(e as Error).message}` };
400
+ }
401
+ }
402
+
403
+ async function audit(source: string, skillId: string): Promise<Result<SkillAudit>> {
404
+ if (!isValidSource(source) || !isValidSkillId(skillId)) {
405
+ return { ok: false, error: "INVALID_SKILL_ID" };
406
+ }
407
+ const key = `audit:${source}/${skillId}`;
408
+ const hit = cached<SkillAudit>(key);
409
+ if (hit) return { ok: true, data: hit };
410
+ try {
411
+ const res = await doFetch(`${baseUrl}/api/v1/skills/audit/${source}/${skillId}`, {
412
+ signal: AbortSignal.timeout(AUDIT_TIMEOUT_MS),
413
+ headers: { accept: "application/json" },
414
+ });
415
+ let data: SkillAudit;
416
+ if (res.status === 404) data = { audits: null, overall: null };
417
+ else if (res.ok) data = parseAuditResponse(await res.json());
418
+ else return { ok: false, error: `SKILLS_AUDIT_FAILED(${res.status})` };
419
+ remember(key, data);
420
+ return { ok: true, data };
421
+ } catch (e) {
422
+ return { ok: false, error: `SKILLS_AUDIT_FAILED: ${(e as Error).message}` };
423
+ }
424
+ }
425
+
426
+ async function install(
427
+ projectPath: string,
428
+ source: string,
429
+ skillId: string,
430
+ onLine?: (line: string) => void,
431
+ signal?: AbortSignal,
432
+ agentTypes: readonly AgentType[] = ["opencode"],
433
+ ): Promise<Result<InstallResult>> {
434
+ if (!isValidSource(source) || !isValidSkillId(skillId)) {
435
+ return { ok: false, error: "INVALID_SKILL_ID" };
436
+ }
437
+ if (installing.has(projectPath)) return { ok: false, error: "SKILL_INSTALL_IN_PROGRESS" };
438
+ installing.add(projectPath);
439
+ // Baris kosong beruntun dirapatkan agar log tetap ringkas.
440
+ let lastBlank = true;
441
+ const emit = (raw: string) => {
442
+ if (!onLine) return;
443
+ const line = cleanOutputLine(raw);
444
+ const blank = line.trim() === "";
445
+ if (blank && lastBlank) return;
446
+ lastBlank = blank;
447
+ onLine(line);
448
+ };
449
+ try {
450
+ const args = buildInstallArgs(source, skillId, agentTypes);
451
+ emit(`$ ${args.join(" ")}`);
452
+ const res = await spawn(args, projectPath, INSTALL_TIMEOUT_MS, emit, signal);
453
+ if (signal?.aborted) {
454
+ emit("Cancelled.");
455
+ return { ok: false, error: "SKILL_INSTALL_CANCELLED" };
456
+ }
457
+ if (res.exitCode !== 0) {
458
+ console.error(
459
+ `[kcg-code] skills add ${source} --skill ${skillId} gagal (exit ${res.exitCode}):`,
460
+ res.stderr.slice(-2000),
461
+ );
462
+ if (res.exitCode === null) {
463
+ emit(`Timed out after ${Math.round(INSTALL_TIMEOUT_MS / 1000)}s — process stopped.`);
464
+ return { ok: false, error: "SKILL_INSTALL_TIMEOUT" };
465
+ }
466
+ emit(`Process exited with code ${res.exitCode}.`);
467
+ return { ok: false, error: "SKILL_INSTALL_FAILED" };
468
+ }
469
+ return parseInstallOutput(res.stdout, skillId);
470
+ } catch (e) {
471
+ console.error("[kcg-code] skills add error:", e);
472
+ emit(`Error: ${(e as Error).message}`);
473
+ return { ok: false, error: "SKILL_INSTALL_FAILED" };
474
+ } finally {
475
+ installing.delete(projectPath);
476
+ }
477
+ }
478
+
479
+ return { search, audit, install };
480
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Client HTTP untuk API device kcgcode-rp (`reverse-proxy/REQUEST.md`).
3
+ *
4
+ * Kode error domain:
5
+ * - `TUNNEL_DEVICE_REVOKED` — device token ditolak (401): perangkat dicabut
6
+ * - `TUNNEL_RATE_LIMITED` — 429
7
+ * - `TUNNEL_API_UNREACHABLE` — jaringan / 5xx / respons tak dikenal
8
+ */
9
+ import type { Result } from "../result";
10
+
11
+ export interface DeviceStart {
12
+ deviceCode: string;
13
+ userCode: string;
14
+ verificationUri: string;
15
+ verificationUriComplete: string;
16
+ expiresIn: number;
17
+ interval: number;
18
+ }
19
+
20
+ export interface DeviceGrant {
21
+ deviceToken: string;
22
+ deviceId: string;
23
+ email: string;
24
+ username: string;
25
+ subdomain: string;
26
+ tunnelSecret: string;
27
+ }
28
+
29
+ /** Hasil satu kali polling `/device/token` (RFC 8628 §3.5). */
30
+ export type PollResult =
31
+ | { state: "granted"; grant: DeviceGrant }
32
+ | { state: "pending" }
33
+ | { state: "slow_down" }
34
+ | { state: "denied" }
35
+ | { state: "expired" };
36
+
37
+ export interface TunnelApi {
38
+ startDevice(deviceName: string, localPort: number): Promise<Result<DeviceStart>>;
39
+ pollDevice(deviceCode: string): Promise<Result<PollResult>>;
40
+ /** Isi `frpc.toml` (text) untuk port lokal ini. */
41
+ fetchConfig(deviceToken: string, localPort: number): Promise<Result<string>>;
42
+ rotateSecret(deviceToken: string): Promise<Result<string>>;
43
+ /** Cabut device ini di VPS (best effort saat sign out). */
44
+ revokeSelf(deviceToken: string): Promise<Result<null>>;
45
+ }
46
+
47
+ const TIMEOUT_MS = 15_000;
48
+
49
+ function str(v: unknown): string | null {
50
+ return typeof v === "string" && v !== "" ? v : null;
51
+ }
52
+
53
+ export function createTunnelApi(baseUrl: string, doFetch: typeof fetch = fetch): TunnelApi {
54
+ async function call(
55
+ path: string,
56
+ init: RequestInit & { token?: string } = {},
57
+ ): Promise<Result<Response>> {
58
+ const headers = new Headers(init.headers);
59
+ if (init.body != null) headers.set("content-type", "application/json");
60
+ if (init.token) headers.set("authorization", `Bearer ${init.token}`);
61
+ try {
62
+ const res = await doFetch(`${baseUrl}${path}`, {
63
+ ...init,
64
+ headers,
65
+ redirect: "error",
66
+ signal: AbortSignal.timeout(TIMEOUT_MS),
67
+ });
68
+ return { ok: true, data: res };
69
+ } catch (e) {
70
+ console.error(`[kcg-code] tunnel API ${path} gagal:`, (e as Error).message);
71
+ return { ok: false, error: "TUNNEL_API_UNREACHABLE" };
72
+ }
73
+ }
74
+
75
+ function httpError(res: Response): string {
76
+ if (res.status === 401) return "TUNNEL_DEVICE_REVOKED";
77
+ if (res.status === 429) return "TUNNEL_RATE_LIMITED";
78
+ return "TUNNEL_API_UNREACHABLE";
79
+ }
80
+
81
+ async function readJson(res: Response): Promise<Record<string, unknown> | null> {
82
+ try {
83
+ const j = await res.json();
84
+ return j && typeof j === "object" ? (j as Record<string, unknown>) : null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ return {
91
+ async startDevice(deviceName, localPort) {
92
+ const r = await call("/api/v1/device/start", {
93
+ method: "POST",
94
+ body: JSON.stringify({ device_name: deviceName, local_port: localPort }),
95
+ });
96
+ if (!r.ok) return r;
97
+ if (!r.data.ok) return { ok: false, error: httpError(r.data) };
98
+ const j = await readJson(r.data);
99
+ const deviceCode = str(j?.device_code);
100
+ const userCode = str(j?.user_code);
101
+ const verificationUri = str(j?.verification_uri);
102
+ const verificationUriComplete = str(j?.verification_uri_complete);
103
+ // Tautan hanya boleh ke origin API yang dikonfigurasi (bukan open redirect).
104
+ const sameOrigin = (u: string | null) => {
105
+ try {
106
+ return u !== null && new URL(u).origin === new URL(baseUrl).origin;
107
+ } catch {
108
+ return false;
109
+ }
110
+ };
111
+ if (
112
+ !deviceCode ||
113
+ !userCode ||
114
+ !sameOrigin(verificationUri) ||
115
+ !sameOrigin(verificationUriComplete)
116
+ ) {
117
+ return { ok: false, error: "TUNNEL_API_UNREACHABLE" };
118
+ }
119
+ return {
120
+ ok: true,
121
+ data: {
122
+ deviceCode,
123
+ userCode,
124
+ verificationUri: verificationUri as string,
125
+ verificationUriComplete: verificationUriComplete as string,
126
+ expiresIn: typeof j?.expires_in === "number" ? j.expires_in : 600,
127
+ interval: typeof j?.interval === "number" ? Math.max(1, j.interval) : 5,
128
+ },
129
+ };
130
+ },
131
+
132
+ async pollDevice(deviceCode) {
133
+ const r = await call("/api/v1/device/token", {
134
+ method: "POST",
135
+ body: JSON.stringify({ device_code: deviceCode }),
136
+ });
137
+ if (!r.ok) return r;
138
+ const j = await readJson(r.data);
139
+ if (r.data.ok) {
140
+ const deviceToken = str(j?.device_token);
141
+ const deviceId = str(j?.device_id);
142
+ const email = str(j?.email);
143
+ const username = str(j?.username);
144
+ const subdomain = str(j?.subdomain);
145
+ const tunnelSecret = str(j?.tunnel_secret);
146
+ if (!deviceToken || !deviceId || !email || !username || !subdomain || !tunnelSecret) {
147
+ return { ok: false, error: "TUNNEL_API_UNREACHABLE" };
148
+ }
149
+ return {
150
+ ok: true,
151
+ data: {
152
+ state: "granted",
153
+ grant: { deviceToken, deviceId, email, username, subdomain, tunnelSecret },
154
+ },
155
+ };
156
+ }
157
+ switch (j?.error) {
158
+ case "authorization_pending":
159
+ return { ok: true, data: { state: "pending" } };
160
+ case "slow_down":
161
+ return { ok: true, data: { state: "slow_down" } };
162
+ case "access_denied":
163
+ return { ok: true, data: { state: "denied" } };
164
+ case "expired_token":
165
+ case "invalid_grant":
166
+ return { ok: true, data: { state: "expired" } };
167
+ default:
168
+ return { ok: false, error: httpError(r.data) };
169
+ }
170
+ },
171
+
172
+ async fetchConfig(deviceToken, localPort) {
173
+ const r = await call(`/api/v1/device/tunnel/config?local_port=${localPort}`, {
174
+ token: deviceToken,
175
+ });
176
+ if (!r.ok) return r;
177
+ if (!r.data.ok) return { ok: false, error: httpError(r.data) };
178
+ const text = await r.data.text();
179
+ if (!/\[\[proxies\]\]/.test(text)) return { ok: false, error: "TUNNEL_API_UNREACHABLE" };
180
+ return { ok: true, data: text };
181
+ },
182
+
183
+ async rotateSecret(deviceToken) {
184
+ const r = await call("/api/v1/device/tunnel/rotate", {
185
+ method: "POST",
186
+ token: deviceToken,
187
+ body: "{}",
188
+ });
189
+ if (!r.ok) return r;
190
+ if (!r.data.ok) return { ok: false, error: httpError(r.data) };
191
+ const secret = str((await readJson(r.data))?.tunnel_secret);
192
+ if (!secret) return { ok: false, error: "TUNNEL_API_UNREACHABLE" };
193
+ return { ok: true, data: secret };
194
+ },
195
+
196
+ async revokeSelf(deviceToken) {
197
+ const r = await call("/api/v1/device/devices/self", {
198
+ method: "DELETE",
199
+ token: deviceToken,
200
+ });
201
+ if (!r.ok) return r;
202
+ // 401 = sudah dicabut — tujuan tercapai.
203
+ if (!r.data.ok && r.data.status !== 401) return { ok: false, error: httpError(r.data) };
204
+ return { ok: true, data: null };
205
+ },
206
+ };
207
+ }