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,45 @@
1
+ /**
2
+ * Tunnel server (kcgcode-rp) configuration. No domain is hard-coded: the API
3
+ * URL is set per build, and each tunnel's public address (`<username>.<domain>`)
4
+ * is sent by the server when the device connects.
5
+ *
6
+ * Order: env `KCG_TUNNEL_API_URL` (e.g. `.env` in development) -> the URL baked
7
+ * into the npm package at publish time (`src/build-config.ts`) -> not set
8
+ * (Remote access shows "Not configured").
9
+ */
10
+ import { BUILD_TUNNEL_API_URL } from "../../build-config";
11
+
12
+ export interface TunnelConfig {
13
+ /** kcgcode-rp API URL without a trailing slash; null = tunnel disabled. */
14
+ apiUrl: string | null;
15
+ }
16
+
17
+ /** Validate a tunnel API URL: http(s), no credentials/query/fragment. */
18
+ export function parseTunnelApiUrl(raw: unknown): { ok: true; url: string | null } | { ok: false } {
19
+ if (raw === undefined || raw === null || raw === "") return { ok: true, url: null };
20
+ if (typeof raw !== "string") return { ok: false };
21
+ let u: URL;
22
+ try {
23
+ u = new URL(raw.trim());
24
+ } catch {
25
+ return { ok: false };
26
+ }
27
+ if (u.protocol !== "https:" && u.protocol !== "http:") return { ok: false };
28
+ if (u.username || u.password || u.search || u.hash) return { ok: false };
29
+ return { ok: true, url: `${u.origin}${u.pathname}`.replace(/\/+$/, "") };
30
+ }
31
+
32
+ /** `env`/`built` are injectable for tests; pass "" to mean "not set". */
33
+ export function resolveTunnelConfig(
34
+ env: string | undefined = process.env.KCG_TUNNEL_API_URL,
35
+ built: string = BUILD_TUNNEL_API_URL,
36
+ ): TunnelConfig {
37
+ const fromEnv = env?.trim();
38
+ if (fromEnv) {
39
+ const parsed = parseTunnelApiUrl(fromEnv);
40
+ if (parsed.ok && parsed.url) return { apiUrl: parsed.url };
41
+ console.warn("[kcg-code] KCG_TUNNEL_API_URL is not a valid http(s) URL; ignoring it.");
42
+ }
43
+ const fromBuild = parseTunnelApiUrl(built);
44
+ return { apiUrl: fromBuild.ok ? fromBuild.url : null };
45
+ }
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Tunnel localhost.run — akses publik gratis tanpa akun lewat SSH
3
+ * (`ssh -R 80:<host>:<port> nokey@localhost.run -- --output json`).
4
+ *
5
+ * - Tidak butuh biner tambahan: memakai `ssh` bawaan OS.
6
+ * - `--output json`: tiap event berupa satu baris JSON di stdout. Event
7
+ * `tcpip-forward` dengan `authz_code: "OK"` membawa `address`
8
+ * (`<acak>.lhr.life`) — alamat publik HTTPS tunnel.
9
+ * - Domain gratis BERUBAH tiap sambung ulang / setelah beberapa jam: status
10
+ * selalu membawa alamat terbaru, UI menampilkannya apa adanya.
11
+ * - `ServerAliveInterval` + `ExitOnForwardFailure`: koneksi mati terdeteksi
12
+ * cepat lalu disambung ulang dengan backoff (pola sama dengan frpc).
13
+ * - Wajib app lock (alamat publik); kunci dihapus -> tunnel dimatikan.
14
+ *
15
+ * Host key: `StrictHostKeyChecking=accept-new` + `UserKnownHostsFile` khusus
16
+ * di direktori data kcgcode (tidak menyentuh `~/.ssh/known_hosts` pengguna).
17
+ */
18
+ import { mkdirSync } from "node:fs";
19
+ import path from "node:path";
20
+ import type { SessionStore } from "../../db";
21
+ import type { Result } from "../result";
22
+ import { frpcLocalIp, type TunnelPhase } from "./tunnel";
23
+
24
+ export interface LhrStatus {
25
+ phase: Exclude<TunnelPhase, "signed_out">;
26
+ enabled: boolean;
27
+ /** Alamat publik saat ini (`https://<acak>.lhr.life`), null bila belum ada. */
28
+ url: string | null;
29
+ error: string | null;
30
+ logOffset: number;
31
+ }
32
+
33
+ /** Proses ssh minimal (di-mock pada test). */
34
+ export interface SshProcessLike {
35
+ pid?: number;
36
+ exited: Promise<number | null>;
37
+ stdout: ReadableStream<Uint8Array>;
38
+ stderr: ReadableStream<Uint8Array>;
39
+ kill(signal?: "SIGTERM" | "SIGKILL"): void;
40
+ }
41
+
42
+ export interface LhrManagerOptions {
43
+ store: SessionStore;
44
+ localPort: number;
45
+ localHost: string;
46
+ /** Direktori data kcgcode (untuk known_hosts khusus). */
47
+ dataDir: string;
48
+ isProtected: () => boolean;
49
+ spawn?: (args: string[]) => SshProcessLike;
50
+ now?: () => number;
51
+ backoff?: (attempt: number) => number;
52
+ /** Ada tidaknya `ssh` di PATH (di-override test). */
53
+ hasSsh?: () => boolean;
54
+ }
55
+
56
+ export interface LhrManager {
57
+ status(): LhrStatus;
58
+ logs(from: number): { lines: string[]; next: number };
59
+ start(): Promise<Result<LhrStatus>>;
60
+ stop(): Promise<LhrStatus>;
61
+ handleLockChanged(protectedNow: boolean): void;
62
+ autoStart(): void;
63
+ shutdown(): Promise<void>;
64
+ }
65
+
66
+ const MAX_LOG_LINES = 300;
67
+ const ADDRESS_RE = /^[a-z0-9-]+(\.[a-z0-9-]+)*\.(lhr\.life|localhost\.run)$/i;
68
+
69
+ /** Argumen `ssh` untuk tunnel HTTP localhost.run (murni, diuji). */
70
+ export function buildSshArgs(p: { localIp: string; localPort: number; knownHosts: string }) {
71
+ return [
72
+ "-T",
73
+ "-o",
74
+ "BatchMode=yes",
75
+ "-o",
76
+ "StrictHostKeyChecking=accept-new",
77
+ "-o",
78
+ `UserKnownHostsFile=${p.knownHosts}`,
79
+ "-o",
80
+ "ServerAliveInterval=30",
81
+ "-o",
82
+ "ServerAliveCountMax=3",
83
+ "-o",
84
+ "ExitOnForwardFailure=yes",
85
+ "-R",
86
+ `80:${p.localIp}:${p.localPort}`,
87
+ "nokey@localhost.run",
88
+ "--",
89
+ "--output",
90
+ "json",
91
+ ];
92
+ }
93
+
94
+ export type LhrEvent =
95
+ | { kind: "online"; address: string }
96
+ | { kind: "denied"; message: string }
97
+ | { kind: "info"; message: string }
98
+ | null;
99
+
100
+ /**
101
+ * Tafsirkan satu baris stdout ssh (`--output json`). Baris non-JSON (mis.
102
+ * header "tunnel config: ...") -> null. Alamat divalidasi ketat agar server
103
+ * tidak bisa menyuntikkan URL sembarang ke UI.
104
+ */
105
+ export function parseLhrLine(line: string): LhrEvent {
106
+ const t = line.trim();
107
+ if (!t.startsWith("{")) return null;
108
+ let ev: Record<string, unknown>;
109
+ try {
110
+ ev = JSON.parse(t) as Record<string, unknown>;
111
+ } catch {
112
+ return null;
113
+ }
114
+ // Format CloudEvents (dipakai sebagian server localhost.run):
115
+ // `type: "v1.tcpip_forward.register.accepted"`, `id: "localhost/<alamat>"`.
116
+ if (typeof ev.type === "string" && ev.type.includes("tcpip_forward")) {
117
+ const data = typeof ev.data === "string" ? ev.data : "";
118
+ if (ev.type.endsWith(".accepted")) {
119
+ const fromId = typeof ev.id === "string" ? (ev.id.split("/").pop() ?? "") : "";
120
+ const address = ADDRESS_RE.test(fromId) ? fromId : (data.split(/[\s,]/)[0] ?? "");
121
+ if (ADDRESS_RE.test(address)) return { kind: "online", address: address.toLowerCase() };
122
+ }
123
+ return { kind: "denied", message: data.split(/\r?\n/)[0] || ev.type };
124
+ }
125
+ if (ev.event === "tcpip-forward") {
126
+ const address = typeof ev.address === "string" ? ev.address : "";
127
+ if (ev.authz_code === "OK" && ADDRESS_RE.test(address)) {
128
+ return { kind: "online", address: address.toLowerCase() };
129
+ }
130
+ return { kind: "denied", message: typeof ev.message === "string" ? ev.message : "denied" };
131
+ }
132
+ // Pesan pertama saja (baris pertama) — isi lain berupa QR kode ANSI.
133
+ const raw =
134
+ typeof ev.message === "string" ? ev.message : typeof ev.data === "string" ? ev.data : "";
135
+ const message = raw.split(/\r?\n/)[0] ?? "";
136
+ return message ? { kind: "info", message } : null;
137
+ }
138
+
139
+ const DEFAULT_SPAWN = (args: string[]): SshProcessLike => {
140
+ const proc = Bun.spawn(["ssh", ...args], {
141
+ stdin: "ignore",
142
+ stdout: "pipe",
143
+ stderr: "pipe",
144
+ });
145
+ return {
146
+ pid: proc.pid,
147
+ exited: proc.exited,
148
+ stdout: proc.stdout,
149
+ stderr: proc.stderr,
150
+ kill: (signal) => proc.kill(signal),
151
+ };
152
+ };
153
+
154
+ export function createLhrManager(opts: LhrManagerOptions): LhrManager {
155
+ const { store } = opts;
156
+ const now = opts.now ?? Date.now;
157
+ const spawn = opts.spawn ?? DEFAULT_SPAWN;
158
+ const backoff = opts.backoff ?? ((n: number) => Math.min(60_000, 3000 * 2 ** n));
159
+ const hasSsh = opts.hasSsh ?? (() => Bun.which("ssh") !== null);
160
+ const knownHosts = path.join(opts.dataDir, "lhr_known_hosts");
161
+
162
+ let phase: LhrStatus["phase"] = "stopped";
163
+ let url: string | null = null;
164
+ let lastError: string | null = null;
165
+ let proc: SshProcessLike | null = null;
166
+ let stopping = false;
167
+ let restartTimer: ReturnType<typeof setTimeout> | null = null;
168
+ let attempt = 0;
169
+ let chain: Promise<unknown> = Promise.resolve();
170
+
171
+ const lines: string[] = [];
172
+ let total = 0;
173
+
174
+ function log(line: string): void {
175
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: buang kode warna ANSI
176
+ const clean = line.replace(/\u001b\[[0-9;]*m/g, "").trimEnd();
177
+ if (clean === "") return;
178
+ lines.push(clean);
179
+ if (lines.length > MAX_LOG_LINES) lines.splice(0, lines.length - MAX_LOG_LINES);
180
+ total++;
181
+ }
182
+
183
+ function serialize<T>(fn: () => Promise<T>): Promise<T> {
184
+ const p = chain.then(fn, fn);
185
+ chain = p.catch(() => {});
186
+ return p;
187
+ }
188
+
189
+ function enabled(): boolean {
190
+ return store.getTunnelSettings(now()).lhrEnabled;
191
+ }
192
+
193
+ function status(): LhrStatus {
194
+ return { phase, enabled: enabled(), url, error: lastError, logOffset: total };
195
+ }
196
+
197
+ function clearRestart(): void {
198
+ if (restartTimer) clearTimeout(restartTimer);
199
+ restartTimer = null;
200
+ }
201
+
202
+ async function killProc(): Promise<void> {
203
+ const p = proc;
204
+ proc = null;
205
+ if (!p) return;
206
+ stopping = true;
207
+ try {
208
+ p.kill("SIGTERM");
209
+ const done = await Promise.race([
210
+ p.exited.then(() => true),
211
+ Bun.sleep(3000).then(() => false),
212
+ ]);
213
+ if (!done) p.kill("SIGKILL");
214
+ } catch {
215
+ /* sudah mati */
216
+ } finally {
217
+ stopping = false;
218
+ }
219
+ }
220
+
221
+ function drain(stream: ReadableStream<Uint8Array>, onLine: (l: string) => void): void {
222
+ void (async () => {
223
+ const decoder = new TextDecoder();
224
+ let buf = "";
225
+ try {
226
+ for await (const chunk of stream) {
227
+ buf += decoder.decode(chunk, { stream: true });
228
+ const parts = buf.split(/\r?\n/);
229
+ buf = parts.pop() ?? "";
230
+ for (const part of parts) onLine(part);
231
+ }
232
+ } catch {
233
+ /* stream ditutup */
234
+ }
235
+ if (buf) onLine(buf);
236
+ })();
237
+ }
238
+
239
+ function launch(): Result<null> {
240
+ if (!opts.isProtected()) return { ok: false, error: "TUNNEL_LOCK_REQUIRED" };
241
+ if (!hasSsh()) return { ok: false, error: "LHR_SSH_MISSING" };
242
+ mkdirSync(opts.dataDir, { recursive: true });
243
+
244
+ let p: SshProcessLike;
245
+ try {
246
+ p = spawn(
247
+ buildSshArgs({
248
+ localIp: frpcLocalIp(opts.localHost),
249
+ localPort: opts.localPort,
250
+ knownHosts,
251
+ }),
252
+ );
253
+ } catch (e) {
254
+ log(`Failed to start ssh: ${(e as Error).message}`);
255
+ return { ok: false, error: "LHR_START_FAILED" };
256
+ }
257
+ proc = p;
258
+ phase = "connecting";
259
+ url = null;
260
+ log("Connecting to localhost.run…");
261
+
262
+ let denied = false;
263
+ drain(p.stdout, (l) => {
264
+ if (proc !== p) return;
265
+ const ev = parseLhrLine(l);
266
+ if (!ev) return;
267
+ if (ev.kind === "online") {
268
+ url = `https://${ev.address}`;
269
+ phase = "online";
270
+ attempt = 0;
271
+ lastError = null;
272
+ log(`Online → ${url}`);
273
+ } else if (ev.kind === "denied") {
274
+ denied = true;
275
+ log(`Rejected by localhost.run: ${ev.message}`);
276
+ } else {
277
+ log(ev.message);
278
+ }
279
+ });
280
+ // stderr berisi banner sambutan & pesan ssh — cukup dicatat.
281
+ drain(p.stderr, (l) => {
282
+ if (proc === p) log(l);
283
+ });
284
+
285
+ void p.exited.then((code) => {
286
+ if (proc !== p || stopping) return;
287
+ proc = null;
288
+ url = null;
289
+ log(`ssh exited (code ${code ?? "?"}).`);
290
+ if (denied) {
291
+ phase = "error";
292
+ lastError = "LHR_DENIED";
293
+ return;
294
+ }
295
+ if (!enabled() || !opts.isProtected()) {
296
+ phase = "stopped";
297
+ return;
298
+ }
299
+ phase = "reconnecting";
300
+ const delay = backoff(attempt++);
301
+ log(`Reconnecting in ${Math.round(delay / 1000)}s…`);
302
+ clearRestart();
303
+ restartTimer = setTimeout(() => {
304
+ restartTimer = null;
305
+ void serialize(async () => {
306
+ if (proc || !enabled()) return;
307
+ const r = launch();
308
+ if (!r.ok) {
309
+ phase = "error";
310
+ lastError = r.error;
311
+ }
312
+ });
313
+ }, delay);
314
+ restartTimer.unref?.();
315
+ });
316
+ return { ok: true, data: null };
317
+ }
318
+
319
+ function start(): Promise<Result<LhrStatus>> {
320
+ return serialize(async () => {
321
+ if (!opts.isProtected()) return { ok: false, error: "TUNNEL_LOCK_REQUIRED" };
322
+ clearRestart();
323
+ await killProc();
324
+ attempt = 0;
325
+ lastError = null;
326
+ store.setLhrEnabled(true, now());
327
+ const r = launch();
328
+ if (!r.ok) {
329
+ phase = "error";
330
+ lastError = r.error;
331
+ return r;
332
+ }
333
+ return { ok: true, data: status() };
334
+ });
335
+ }
336
+
337
+ function stop(): Promise<LhrStatus> {
338
+ return serialize(async () => {
339
+ store.setLhrEnabled(false, now());
340
+ clearRestart();
341
+ await killProc();
342
+ phase = "stopped";
343
+ url = null;
344
+ log("Tunnel stopped.");
345
+ return status();
346
+ });
347
+ }
348
+
349
+ function handleLockChanged(protectedNow: boolean): void {
350
+ if (protectedNow) return;
351
+ if (!enabled() && !proc) return;
352
+ log("App lock removed — tunnel turned off.");
353
+ void stop().then(() => {
354
+ lastError = "TUNNEL_LOCK_REQUIRED";
355
+ });
356
+ }
357
+
358
+ function autoStart(): void {
359
+ if (!enabled()) return;
360
+ if (!opts.isProtected()) {
361
+ store.setLhrEnabled(false, now());
362
+ return;
363
+ }
364
+ void start().then((r) => {
365
+ if (!r.ok) console.error(`[kcg-code] localhost.run gagal dinyalakan: ${r.error}`);
366
+ });
367
+ }
368
+
369
+ async function shutdown(): Promise<void> {
370
+ clearRestart();
371
+ await serialize(() => killProc());
372
+ }
373
+
374
+ return {
375
+ status,
376
+ logs(from) {
377
+ const first = total - lines.length;
378
+ const startAt = Math.min(Math.max(from, first), total);
379
+ return { lines: lines.slice(startAt - first), next: total };
380
+ },
381
+ start,
382
+ stop,
383
+ handleLockChanged,
384
+ autoStart,
385
+ shutdown,
386
+ };
387
+ }