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,633 @@
1
+ /**
2
+ * Tunnel_Manager — akses publik `https://<username>.<domain>` lewat
3
+ * kcgcode-rp (API di VPS) + frpc.
4
+ *
5
+ * Alur (device authorization, mirip `gh auth login` / RFC 8628):
6
+ * 1. `connect()` — `POST /device/start` -> `user_code` + URL verifikasi.
7
+ * Pengguna membuka URL di perangkat mana pun, login Google & (pengguna
8
+ * baru) memilih nama di VPS, lalu menyetujui perangkat ini secara eksplisit.
9
+ * 2. Manager mem-poll `/device/token` (hormati `interval` & `slow_down`)
10
+ * sampai disetujui -> device_token + subdomain + tunnel_secret disimpan.
11
+ * Tidak ada kredensial Google di kcgcode sama sekali.
12
+ * 3. `start()` — wajib app lock aktif. Ambil `frpc.toml` dari VPS, tapi config
13
+ * yang dijalankan DIBANGUN ULANG di sini (hanya serverAddr/serverPort yang
14
+ * diambil dari VPS) agar VPS tidak bisa menyisipkan proxy/plugin lain
15
+ * (mis. `static_file`). File ditulis mode 0600.
16
+ * 4. frpc diawasi: status dari log, restart dengan backoff, berhenti permanen
17
+ * bila ditolak server. Kunci dihapus -> tunnel dimatikan. Device dicabut di
18
+ * VPS -> akun lokal dilupakan.
19
+ *
20
+ * Token & secret tidak pernah keluar lewat `status()` (dikirim ke browser).
21
+ */
22
+
23
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
24
+ import { hostname as osHostname } from "node:os";
25
+ import path from "node:path";
26
+ import type { SessionStore } from "../../db";
27
+ import type { TunnelAccount } from "../db/tunnel";
28
+ import type { Result } from "../result";
29
+ import type { FrpcInstaller } from "./frpc-installer";
30
+ import type { TunnelApi } from "./tunnel-api";
31
+ import type { TunnelConfig } from "./tunnel-config";
32
+
33
+ export type TunnelPhase =
34
+ | "signed_out"
35
+ | "stopped"
36
+ | "starting"
37
+ | "connecting"
38
+ | "online"
39
+ | "reconnecting"
40
+ | "error";
41
+
42
+ export interface TunnelStatus {
43
+ phase: TunnelPhase;
44
+ enabled: boolean;
45
+ /** Tunnel API URL is set (KCG_TUNNEL_API_URL, or baked into the npm package). */
46
+ configured: boolean;
47
+ account: { email: string; username: string; url: string } | null;
48
+ /** Device flow berjalan: tampilkan kode + tautan ke pengguna. */
49
+ pairing: {
50
+ userCode: string;
51
+ verificationUri: string;
52
+ verificationUriComplete: string;
53
+ expiresAt: number;
54
+ } | null;
55
+ /** Kode error terakhir (lihat `apiErrorMessage`). */
56
+ error: string | null;
57
+ /** Nomor urut baris log terakhir (klien minta `?from=`). */
58
+ logOffset: number;
59
+ }
60
+
61
+ /** Proses frpc minimal (di-mock pada test). */
62
+ export interface FrpcProcessLike {
63
+ pid?: number;
64
+ exited: Promise<number | null>;
65
+ stdout: ReadableStream<Uint8Array>;
66
+ stderr: ReadableStream<Uint8Array>;
67
+ kill(signal?: "SIGTERM" | "SIGKILL"): void;
68
+ }
69
+
70
+ export interface TunnelManagerOptions {
71
+ store: SessionStore;
72
+ config: TunnelConfig;
73
+ api: TunnelApi;
74
+ installer: FrpcInstaller;
75
+ /** Port & host kcgcode yang diekspos. */
76
+ localPort: number;
77
+ localHost: string;
78
+ configPath: string;
79
+ isProtected: () => boolean;
80
+ /** Nama perangkat di halaman persetujuan (default: hostname OS). */
81
+ deviceName?: string;
82
+ spawn?: (bin: string, configPath: string) => FrpcProcessLike;
83
+ now?: () => number;
84
+ /** Backoff restart frpc (ms); di-override test. */
85
+ backoff?: (attempt: number) => number;
86
+ /** Tidur antar-poll device flow (ms); di-override test. */
87
+ sleep?: (ms: number) => Promise<void>;
88
+ }
89
+
90
+ export interface TunnelManager {
91
+ status(): TunnelStatus;
92
+ logs(from: number): { lines: string[]; next: number };
93
+ /** Mulai device flow (membatalkan yang sedang berjalan). */
94
+ connect(): Promise<Result<TunnelStatus>>;
95
+ cancelConnect(): void;
96
+ start(): Promise<Result<TunnelStatus>>;
97
+ stop(): Promise<TunnelStatus>;
98
+ signOut(): Promise<TunnelStatus>;
99
+ rotateSecret(): Promise<Result<TunnelStatus>>;
100
+ /** Kunci aplikasi berubah; tanpa kunci tunnel dimatikan. */
101
+ handleLockChanged(protectedNow: boolean): void;
102
+ /** Dipanggil saat server siap: nyalakan ulang bila sebelumnya aktif. */
103
+ autoStart(): void;
104
+ shutdown(): Promise<void>;
105
+ }
106
+
107
+ const MAX_LOG_LINES = 300;
108
+ const SERVER_ADDR_RE = /^[A-Za-z0-9.-]{1,253}$/;
109
+
110
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: buang kode warna ANSI
111
+ const ANSI_RE = /\u001b\[[0-9;]*m/g;
112
+
113
+ function tomlString(v: string): string {
114
+ return JSON.stringify(v); // subset aman: kita sudah memvalidasi karakter
115
+ }
116
+
117
+ /** Ambil `serverAddr` & `serverPort` dari frpc.toml buatan VPS. */
118
+ export function parseFrpServer(toml: string): { addr: string; port: number } | null {
119
+ const addr = toml.match(/^\s*serverAddr\s*=\s*"([^"]+)"/m)?.[1];
120
+ const portRaw = toml.match(/^\s*serverPort\s*=\s*(\d+)/m)?.[1];
121
+ const port = portRaw ? Number(portRaw) : 7000;
122
+ if (!addr || !SERVER_ADDR_RE.test(addr)) return null;
123
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
124
+ return { addr, port };
125
+ }
126
+
127
+ /** Host lokal yang dituju frpc: bind wildcard -> loopback. */
128
+ export function frpcLocalIp(host: string): string {
129
+ if (host === "0.0.0.0" || host === "::" || host === "localhost" || host === "") {
130
+ return "127.0.0.1";
131
+ }
132
+ return /^[0-9A-Fa-f:.]+$/.test(host) ? host : "127.0.0.1";
133
+ }
134
+
135
+ export function buildFrpcConfig(p: {
136
+ serverAddr: string;
137
+ serverPort: number;
138
+ username: string;
139
+ tunnelSecret: string;
140
+ localIp: string;
141
+ localPort: number;
142
+ }): string {
143
+ return `# Generated by kcgcode — do not edit (rewritten on every start)
144
+ serverAddr = ${tomlString(p.serverAddr)}
145
+ serverPort = ${p.serverPort}
146
+ loginFailExit = true
147
+ log.to = "console"
148
+ log.level = "info"
149
+ log.disablePrintColor = true
150
+
151
+ # fp-multiuser: per-user token
152
+ user = ${tomlString(p.username)}
153
+ metadatas.token = ${tomlString(p.tunnelSecret)}
154
+
155
+ [[proxies]]
156
+ name = ${tomlString(p.username)}
157
+ type = "http"
158
+ localIP = ${tomlString(p.localIp)}
159
+ localPort = ${p.localPort}
160
+ subdomain = ${tomlString(p.username)}
161
+ `;
162
+ }
163
+
164
+ /** Klasifikasi baris log frpc -> transisi status. */
165
+ export function classifyFrpcLine(
166
+ line: string,
167
+ ): "online" | "logged_in" | "reconnecting" | "auth_failed" | "proxy_failed" | null {
168
+ if (/start proxy success/i.test(line)) return "online";
169
+ if (/login to server success/i.test(line)) return "logged_in";
170
+ if (/(start error|proxy .*error|subdomain .*(used|exist))/i.test(line)) return "proxy_failed";
171
+ if (/(reject|authorization|invalid.*token|token.*invalid)/i.test(line)) return "auth_failed";
172
+ if (
173
+ /(try to reconnect|reconnect|connect to server error|login to the server failed)/i.test(line)
174
+ ) {
175
+ return "reconnecting";
176
+ }
177
+ return null;
178
+ }
179
+
180
+ const DEFAULT_SPAWN = (bin: string, configPath: string): FrpcProcessLike => {
181
+ const proc = Bun.spawn([bin, "-c", configPath], {
182
+ stdin: "ignore",
183
+ stdout: "pipe",
184
+ stderr: "pipe",
185
+ });
186
+ return {
187
+ pid: proc.pid,
188
+ exited: proc.exited,
189
+ stdout: proc.stdout,
190
+ stderr: proc.stderr,
191
+ kill: (signal) => proc.kill(signal),
192
+ };
193
+ };
194
+
195
+ export function createTunnelManager(opts: TunnelManagerOptions): TunnelManager {
196
+ const { store, config, api, installer } = opts;
197
+ const now = opts.now ?? Date.now;
198
+ const spawn = opts.spawn ?? DEFAULT_SPAWN;
199
+ const backoff = opts.backoff ?? ((n: number) => Math.min(60_000, 2000 * 2 ** n));
200
+ const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
201
+ const deviceName = (opts.deviceName ?? `kcgcode on ${osHostname()}`).slice(0, 60);
202
+
203
+ let phase: Exclude<TunnelPhase, "signed_out"> = "stopped";
204
+ let lastError: string | null = null;
205
+ let pairing: (TunnelStatus["pairing"] & { deviceCode: string; id: number }) | null = null;
206
+ let pairingSeq = 0;
207
+
208
+ let proc: FrpcProcessLike | null = null;
209
+ let stopping = false;
210
+ let restartTimer: ReturnType<typeof setTimeout> | null = null;
211
+ let attempt = 0;
212
+ /** Serialisasi start/stop agar tidak ada dua frpc. */
213
+ let chain: Promise<unknown> = Promise.resolve();
214
+
215
+ // Buffer log bergulir dengan kursor absolut: `total` = jumlah baris yang
216
+ // pernah ditulis; baris pertama di `lines` berindeks `total - lines.length`.
217
+ const lines: string[] = [];
218
+ let total = 0;
219
+
220
+ function settings() {
221
+ return store.getTunnelSettings(now());
222
+ }
223
+
224
+ function log(line: string): void {
225
+ // Jangan pernah mencatat secret/token (frpc tidak mencetaknya, tapi jaga-jaga).
226
+ const a = settings().account;
227
+ let clean = line.replace(ANSI_RE, "").trimEnd();
228
+ for (const s of [a?.tunnelSecret, a?.deviceToken]) if (s) clean = clean.split(s).join("***");
229
+ if (clean === "") return;
230
+ lines.push(clean);
231
+ if (lines.length > MAX_LOG_LINES) lines.splice(0, lines.length - MAX_LOG_LINES);
232
+ total++;
233
+ }
234
+
235
+ function serialize<T>(fn: () => Promise<T>): Promise<T> {
236
+ const p = chain.then(fn, fn);
237
+ chain = p.catch(() => {});
238
+ return p;
239
+ }
240
+
241
+ function status(): TunnelStatus {
242
+ const s = settings();
243
+ const a = s.account;
244
+ if (pairing && pairing.expiresAt <= now()) pairing = null;
245
+ return {
246
+ phase: a ? phase : "signed_out",
247
+ enabled: s.enabled,
248
+ configured: config.apiUrl !== null,
249
+ account: a ? { email: a.email, username: a.username, url: `https://${a.subdomain}` } : null,
250
+ pairing: pairing
251
+ ? {
252
+ userCode: pairing.userCode,
253
+ verificationUri: pairing.verificationUri,
254
+ verificationUriComplete: pairing.verificationUriComplete,
255
+ expiresAt: pairing.expiresAt,
256
+ }
257
+ : null,
258
+ error: lastError,
259
+ logOffset: total,
260
+ };
261
+ }
262
+
263
+ // -------------------------------------------------------- device flow ----
264
+
265
+ async function connect(): Promise<Result<TunnelStatus>> {
266
+ if (config.apiUrl === null) return { ok: false, error: "TUNNEL_NOT_CONFIGURED" };
267
+ const res = await api.startDevice(deviceName, opts.localPort);
268
+ if (!res.ok) {
269
+ lastError = res.error;
270
+ return res;
271
+ }
272
+ const id = ++pairingSeq;
273
+ pairing = {
274
+ id,
275
+ deviceCode: res.data.deviceCode,
276
+ userCode: res.data.userCode,
277
+ verificationUri: res.data.verificationUri,
278
+ verificationUriComplete: res.data.verificationUriComplete,
279
+ expiresAt: now() + res.data.expiresIn * 1000,
280
+ };
281
+ lastError = null;
282
+ log(`Waiting for approval — code ${res.data.userCode}`);
283
+ void pollLoop(id, res.data.interval);
284
+ return { ok: true, data: status() };
285
+ }
286
+
287
+ /** Poll sampai disetujui / ditolak / kedaluwarsa / dibatalkan. */
288
+ async function pollLoop(id: number, initialInterval: number): Promise<void> {
289
+ let interval = initialInterval;
290
+ let failures = 0;
291
+ while (pairing?.id === id) {
292
+ await sleep(interval * 1000);
293
+ if (pairing?.id !== id) return;
294
+ if (pairing.expiresAt <= now()) {
295
+ pairing = null;
296
+ lastError = "TUNNEL_PAIRING_EXPIRED";
297
+ return;
298
+ }
299
+ const r = await api.pollDevice(pairing.deviceCode);
300
+ if (pairing?.id !== id) return;
301
+ if (!r.ok) {
302
+ // Gangguan jaringan sementara: coba lagi, menyerah setelah beberapa kali.
303
+ if (++failures >= 5) {
304
+ pairing = null;
305
+ lastError = r.error;
306
+ return;
307
+ }
308
+ continue;
309
+ }
310
+ failures = 0;
311
+ switch (r.data.state) {
312
+ case "pending":
313
+ break;
314
+ case "slow_down":
315
+ interval += 5; // RFC 8628 §3.5
316
+ break;
317
+ case "denied":
318
+ pairing = null;
319
+ lastError = "TUNNEL_PAIRING_DENIED";
320
+ return;
321
+ case "expired":
322
+ pairing = null;
323
+ lastError = "TUNNEL_PAIRING_EXPIRED";
324
+ return;
325
+ case "granted": {
326
+ const g = r.data.grant;
327
+ pairing = null;
328
+ store.setTunnelAccount(
329
+ {
330
+ email: g.email,
331
+ username: g.username,
332
+ subdomain: g.subdomain,
333
+ deviceId: g.deviceId,
334
+ deviceToken: g.deviceToken,
335
+ tunnelSecret: g.tunnelSecret,
336
+ },
337
+ now(),
338
+ );
339
+ phase = "stopped";
340
+ lastError = null;
341
+ log(`Connected as ${g.email} → https://${g.subdomain}`);
342
+ // Nyalakan langsung bila kunci sudah ada (pengguna baru saja meminta).
343
+ if (opts.isProtected()) void start();
344
+ return;
345
+ }
346
+ }
347
+ }
348
+ }
349
+
350
+ function cancelConnect(): void {
351
+ pairing = null;
352
+ }
353
+
354
+ /** Device dicabut di VPS: lupakan akun lokal. */
355
+ function forgetRevoked(): void {
356
+ store.setTunnelAccount(null, now());
357
+ store.setTunnelEnabled(false, now());
358
+ rmSync(opts.configPath, { force: true });
359
+ lastError = "TUNNEL_DEVICE_REVOKED";
360
+ log("This device was disconnected from your account.");
361
+ }
362
+
363
+ // ------------------------------------------------------------- frpc ----
364
+
365
+ function clearRestart(): void {
366
+ if (restartTimer) clearTimeout(restartTimer);
367
+ restartTimer = null;
368
+ }
369
+
370
+ async function killProc(): Promise<void> {
371
+ const p = proc;
372
+ proc = null;
373
+ if (!p) return;
374
+ stopping = true;
375
+ try {
376
+ p.kill("SIGTERM");
377
+ const done = await Promise.race([
378
+ p.exited.then(() => true),
379
+ Bun.sleep(3000).then(() => false),
380
+ ]);
381
+ if (!done) p.kill("SIGKILL");
382
+ } catch {
383
+ /* sudah mati */
384
+ } finally {
385
+ stopping = false;
386
+ }
387
+ }
388
+
389
+ /** Siapkan config lokal: segarkan dari VPS bila bisa, pakai cache bila tidak. */
390
+ async function prepareConfig(account: TunnelAccount): Promise<Result<string>> {
391
+ let server: { addr: string; port: number } | null = null;
392
+ const fresh = await api.fetchConfig(account.deviceToken, opts.localPort);
393
+ if (fresh.ok) {
394
+ server = parseFrpServer(fresh.data);
395
+ if (!server) return { ok: false, error: "TUNNEL_API_UNREACHABLE" };
396
+ } else if (fresh.error === "TUNNEL_DEVICE_REVOKED") {
397
+ return fresh;
398
+ } else if (existsSync(opts.configPath)) {
399
+ // VPS tak terjangkau: secret lokal masih berlaku.
400
+ server = parseFrpServer(readFileSync(opts.configPath, "utf8"));
401
+ if (server) log(`Using cached tunnel config (${fresh.error}).`);
402
+ }
403
+ if (!server) return { ok: false, error: fresh.ok ? "TUNNEL_API_UNREACHABLE" : fresh.error };
404
+
405
+ const toml = buildFrpcConfig({
406
+ serverAddr: server.addr,
407
+ serverPort: server.port,
408
+ username: account.username,
409
+ tunnelSecret: account.tunnelSecret,
410
+ localIp: frpcLocalIp(opts.localHost),
411
+ localPort: opts.localPort,
412
+ });
413
+ mkdirSync(path.dirname(opts.configPath), { recursive: true });
414
+ writeFileSync(opts.configPath, toml, { encoding: "utf8", mode: 0o600 });
415
+ chmodSync(opts.configPath, 0o600);
416
+ return { ok: true, data: opts.configPath };
417
+ }
418
+
419
+ function drain(stream: ReadableStream<Uint8Array>, onLine: (l: string) => void): void {
420
+ void (async () => {
421
+ const decoder = new TextDecoder();
422
+ let buf = "";
423
+ try {
424
+ for await (const chunk of stream) {
425
+ buf += decoder.decode(chunk, { stream: true });
426
+ const parts = buf.split(/\r?\n/);
427
+ buf = parts.pop() ?? "";
428
+ for (const p of parts) onLine(p);
429
+ }
430
+ } catch {
431
+ /* stream ditutup */
432
+ }
433
+ if (buf) onLine(buf);
434
+ })();
435
+ }
436
+
437
+ async function launch(): Promise<Result<null>> {
438
+ const account = settings().account;
439
+ if (!account) return { ok: false, error: "TUNNEL_SIGNED_OUT" };
440
+ if (!opts.isProtected()) return { ok: false, error: "TUNNEL_LOCK_REQUIRED" };
441
+
442
+ phase = "starting";
443
+ const bin = await installer.ensure(log);
444
+ if (!bin.ok) return bin;
445
+ const cfg = await prepareConfig(account);
446
+ if (!cfg.ok) return cfg;
447
+
448
+ let p: FrpcProcessLike;
449
+ try {
450
+ p = spawn(bin.data, cfg.data);
451
+ } catch (e) {
452
+ log(`Failed to start frpc: ${(e as Error).message}`);
453
+ return { ok: false, error: "FRPC_START_FAILED" };
454
+ }
455
+ proc = p;
456
+ phase = "connecting";
457
+ log(`frpc started (pid ${p.pid ?? "?"}) → https://${account.subdomain}`);
458
+
459
+ let fatal: string | null = null;
460
+ const onLine = (l: string) => {
461
+ log(l);
462
+ if (proc !== p) return;
463
+ switch (classifyFrpcLine(l)) {
464
+ case "online":
465
+ phase = "online";
466
+ attempt = 0;
467
+ lastError = null;
468
+ break;
469
+ case "logged_in":
470
+ if (phase !== "online") phase = "connecting";
471
+ break;
472
+ case "reconnecting":
473
+ phase = "reconnecting";
474
+ break;
475
+ case "auth_failed":
476
+ fatal = "TUNNEL_AUTH_FAILED";
477
+ break;
478
+ case "proxy_failed":
479
+ fatal = "TUNNEL_PROXY_FAILED";
480
+ break;
481
+ }
482
+ };
483
+ drain(p.stdout, onLine);
484
+ drain(p.stderr, onLine);
485
+
486
+ void p.exited.then((code) => {
487
+ if (proc !== p || stopping) return; // dihentikan sengaja / diganti
488
+ proc = null;
489
+ log(`frpc exited (code ${code ?? "?"}).`);
490
+ if (fatal) {
491
+ // Ditolak server: restart tidak akan menolong.
492
+ phase = "error";
493
+ lastError = fatal;
494
+ return;
495
+ }
496
+ if (!settings().enabled || !opts.isProtected()) {
497
+ phase = "stopped";
498
+ return;
499
+ }
500
+ phase = "reconnecting";
501
+ const delay = backoff(attempt++);
502
+ log(`Restarting frpc in ${Math.round(delay / 1000)}s…`);
503
+ clearRestart();
504
+ restartTimer = setTimeout(() => {
505
+ restartTimer = null;
506
+ void serialize(async () => {
507
+ if (proc || !settings().enabled) return;
508
+ const r = await launch();
509
+ if (!r.ok) {
510
+ phase = "error";
511
+ lastError = r.error;
512
+ }
513
+ });
514
+ }, delay);
515
+ restartTimer.unref?.();
516
+ });
517
+ return { ok: true, data: null };
518
+ }
519
+
520
+ function start(): Promise<Result<TunnelStatus>> {
521
+ return serialize(async () => {
522
+ if (!settings().account) return { ok: false, error: "TUNNEL_SIGNED_OUT" };
523
+ if (!opts.isProtected()) return { ok: false, error: "TUNNEL_LOCK_REQUIRED" };
524
+ clearRestart();
525
+ await killProc();
526
+ attempt = 0;
527
+ lastError = null;
528
+ store.setTunnelEnabled(true, now());
529
+ const r = await launch();
530
+ if (!r.ok) {
531
+ if (r.error === "TUNNEL_DEVICE_REVOKED") {
532
+ forgetRevoked();
533
+ return r;
534
+ }
535
+ phase = "error";
536
+ lastError = r.error;
537
+ return r;
538
+ }
539
+ return { ok: true, data: status() };
540
+ });
541
+ }
542
+
543
+ function stop(): Promise<TunnelStatus> {
544
+ return serialize(async () => {
545
+ store.setTunnelEnabled(false, now());
546
+ clearRestart();
547
+ await killProc();
548
+ phase = "stopped";
549
+ log("Tunnel stopped.");
550
+ return status();
551
+ });
552
+ }
553
+
554
+ async function signOut(): Promise<TunnelStatus> {
555
+ cancelConnect();
556
+ const token = settings().account?.deviceToken;
557
+ await stop();
558
+ // Cabut device token di VPS juga (best effort; offline tetap dihapus lokal).
559
+ if (token) {
560
+ const r = await api.revokeSelf(token);
561
+ if (!r.ok) log(`Couldn't revoke this device on the server (${r.error}).`);
562
+ }
563
+ store.setTunnelAccount(null, now());
564
+ rmSync(opts.configPath, { force: true });
565
+ lastError = null;
566
+ log("Signed out.");
567
+ return status();
568
+ }
569
+
570
+ async function rotateSecret(): Promise<Result<TunnelStatus>> {
571
+ const account = settings().account;
572
+ if (!account) return { ok: false, error: "TUNNEL_SIGNED_OUT" };
573
+ const res = await api.rotateSecret(account.deviceToken);
574
+ if (!res.ok) {
575
+ if (res.error === "TUNNEL_DEVICE_REVOKED") {
576
+ await stop();
577
+ forgetRevoked();
578
+ }
579
+ return res;
580
+ }
581
+ store.setTunnelSecret(res.data, now());
582
+ log("Tunnel secret rotated.");
583
+ // Secret lama langsung ditolak VPS: jalankan ulang bila sedang aktif.
584
+ if (settings().enabled) return start();
585
+ return { ok: true, data: status() };
586
+ }
587
+
588
+ function handleLockChanged(protectedNow: boolean): void {
589
+ if (protectedNow) return;
590
+ if (!settings().enabled && !proc) return;
591
+ log("App lock removed — tunnel turned off.");
592
+ void stop().then(() => {
593
+ lastError = "TUNNEL_LOCK_REQUIRED";
594
+ });
595
+ }
596
+
597
+ function autoStart(): void {
598
+ const s = settings();
599
+ if (!s.enabled || !s.account) return;
600
+ if (!opts.isProtected()) {
601
+ store.setTunnelEnabled(false, now());
602
+ return;
603
+ }
604
+ void start().then((r) => {
605
+ if (!r.ok) console.error(`[kcg-code] tunnel gagal dinyalakan: ${r.error}`);
606
+ });
607
+ }
608
+
609
+ async function shutdown(): Promise<void> {
610
+ cancelConnect();
611
+ clearRestart();
612
+ // `enabled` dibiarkan -> tunnel menyala lagi di start berikutnya.
613
+ await serialize(() => killProc());
614
+ }
615
+
616
+ return {
617
+ status,
618
+ logs(from) {
619
+ const first = total - lines.length;
620
+ const startAt = Math.min(Math.max(from, first), total);
621
+ return { lines: lines.slice(startAt - first), next: total };
622
+ },
623
+ connect,
624
+ cancelConnect,
625
+ start,
626
+ stop,
627
+ signOut,
628
+ rotateSecret,
629
+ handleLockChanged,
630
+ autoStart,
631
+ shutdown,
632
+ };
633
+ }