kcg-code 0.0.0-stage → 0.1.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 (286) hide show
  1. package/README.md +228 -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 +83 -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__/dashboard.test.ts +62 -0
  13. package/src/cli/args.ts +123 -0
  14. package/src/cli/commands.ts +174 -0
  15. package/src/cli/dashboard.tsx +343 -0
  16. package/src/cli/index.ts +37 -0
  17. package/src/cli/theme.ts +100 -0
  18. package/src/components/auth/AuthGate.tsx +182 -0
  19. package/src/components/auth/AvatarEditor.tsx +236 -0
  20. package/src/components/auth/LockScreen.tsx +714 -0
  21. package/src/components/auth/ProfileAvatar.tsx +60 -0
  22. package/src/components/auth/WelcomeScreen.tsx +672 -0
  23. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  24. package/src/components/auth/avatar-icons.tsx +114 -0
  25. package/src/components/layout/AppShell.tsx +142 -0
  26. package/src/components/layout/AppSidebar.tsx +419 -0
  27. package/src/components/layout/SearchDialog.tsx +189 -0
  28. package/src/components/layout/SidebarAccount.tsx +111 -0
  29. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  30. package/src/components/layout/ThemeToggle.tsx +67 -0
  31. package/src/components/projects/FolderBrowser.tsx +142 -0
  32. package/src/components/projects/HomeChat.tsx +250 -0
  33. package/src/components/projects/NewProjectDialog.tsx +249 -0
  34. package/src/components/projects/PanelSection.tsx +102 -0
  35. package/src/components/projects/ProjectInstructions.tsx +229 -0
  36. package/src/components/projects/ProjectRow.tsx +58 -0
  37. package/src/components/projects/ProjectSidePanel.tsx +448 -0
  38. package/src/components/sessions/AgentModeList.tsx +265 -0
  39. package/src/components/sessions/AgentPicker.tsx +123 -0
  40. package/src/components/sessions/AssistantTurn.tsx +145 -0
  41. package/src/components/sessions/AttachmentTile.tsx +161 -0
  42. package/src/components/sessions/CodeBlock.tsx +107 -0
  43. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  44. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  45. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
  46. package/src/components/sessions/ContextPanel.tsx +561 -0
  47. package/src/components/sessions/DropPlate.tsx +165 -0
  48. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  49. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  50. package/src/components/sessions/ImageLightbox.tsx +173 -0
  51. package/src/components/sessions/MarkdownContent.tsx +187 -0
  52. package/src/components/sessions/Mascot.tsx +47 -0
  53. package/src/components/sessions/MessageActions.tsx +60 -0
  54. package/src/components/sessions/MessageRail.tsx +121 -0
  55. package/src/components/sessions/ModelPicker.tsx +209 -0
  56. package/src/components/sessions/ModelSearchList.tsx +151 -0
  57. package/src/components/sessions/NewSessionDialog.tsx +189 -0
  58. package/src/components/sessions/PendingAttachments.tsx +127 -0
  59. package/src/components/sessions/PromptCard.tsx +1085 -0
  60. package/src/components/sessions/PromptPanel.tsx +112 -0
  61. package/src/components/sessions/SessionComposer.tsx +640 -0
  62. package/src/components/sessions/SessionHeader.tsx +175 -0
  63. package/src/components/sessions/SessionList.tsx +396 -0
  64. package/src/components/sessions/SessionRow.tsx +137 -0
  65. package/src/components/sessions/SessionTimeline.tsx +275 -0
  66. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  67. package/src/components/sessions/SessionView.tsx +236 -0
  68. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  69. package/src/components/sessions/UserMessage.tsx +177 -0
  70. package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
  71. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  72. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  73. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  74. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  75. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  76. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  77. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  78. package/src/components/settings/AboutGroup.tsx +170 -0
  79. package/src/components/settings/AccountGroup.tsx +122 -0
  80. package/src/components/settings/DevicesGroup.tsx +151 -0
  81. package/src/components/settings/LhrRemoteAccess.tsx +262 -0
  82. package/src/components/settings/PasskeysGroup.tsx +551 -0
  83. package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
  84. package/src/components/settings/SecurityGroup.tsx +566 -0
  85. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  86. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  87. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  88. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  89. package/src/components/settings/prefs.tsx +265 -0
  90. package/src/components/skills/InstallNotifications.tsx +325 -0
  91. package/src/components/skills/InstallSkillDialog.tsx +233 -0
  92. package/src/components/skills/SkillCards.tsx +210 -0
  93. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  94. package/src/components/ui/alert-dialog.tsx +179 -0
  95. package/src/components/ui/alert.tsx +60 -0
  96. package/src/components/ui/badge.tsx +46 -0
  97. package/src/components/ui/bubble.tsx +125 -0
  98. package/src/components/ui/button.tsx +62 -0
  99. package/src/components/ui/card.tsx +75 -0
  100. package/src/components/ui/collapsible.tsx +19 -0
  101. package/src/components/ui/dialog.tsx +142 -0
  102. package/src/components/ui/dropdown-menu.tsx +225 -0
  103. package/src/components/ui/empty.tsx +94 -0
  104. package/src/components/ui/field.tsx +234 -0
  105. package/src/components/ui/input-group.tsx +161 -0
  106. package/src/components/ui/input.tsx +21 -0
  107. package/src/components/ui/label.tsx +19 -0
  108. package/src/components/ui/message-scroller.tsx +131 -0
  109. package/src/components/ui/message.tsx +85 -0
  110. package/src/components/ui/popover.tsx +46 -0
  111. package/src/components/ui/select.tsx +172 -0
  112. package/src/components/ui/separator.tsx +28 -0
  113. package/src/components/ui/sheet.tsx +138 -0
  114. package/src/components/ui/sidebar.tsx +699 -0
  115. package/src/components/ui/skeleton.tsx +15 -0
  116. package/src/components/ui/sonner.tsx +45 -0
  117. package/src/components/ui/spinner.tsx +16 -0
  118. package/src/components/ui/switch.tsx +32 -0
  119. package/src/components/ui/textarea.tsx +18 -0
  120. package/src/components/ui/tooltip.tsx +51 -0
  121. package/src/config.ts +85 -0
  122. package/src/db.ts +146 -0
  123. package/src/frontend.tsx +51 -0
  124. package/src/hooks/__tests__/useMention.test.ts +79 -0
  125. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  126. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  127. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  128. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  129. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  130. package/src/hooks/use-mobile.ts +16 -0
  131. package/src/hooks/useAgentPicker.ts +97 -0
  132. package/src/hooks/useMediaQuery.ts +49 -0
  133. package/src/hooks/useMention.ts +236 -0
  134. package/src/hooks/useProjects.ts +88 -0
  135. package/src/hooks/useResyncOnReconnect.ts +28 -0
  136. package/src/hooks/useRouter.ts +92 -0
  137. package/src/hooks/useSessionChat.ts +427 -0
  138. package/src/hooks/useSessionContext.ts +106 -0
  139. package/src/hooks/useSessionList.ts +234 -0
  140. package/src/hooks/useSidebarData.ts +108 -0
  141. package/src/hooks/useSmoothText.ts +122 -0
  142. package/src/hooks/useTheme.ts +164 -0
  143. package/src/hooks/useWebSocket.ts +196 -0
  144. package/src/index.css +27 -0
  145. package/src/index.html +14 -0
  146. package/src/index.ts +49 -0
  147. package/src/lib/__tests__/attachments.test.ts +58 -0
  148. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  149. package/src/lib/__tests__/collapsible.test.ts +101 -0
  150. package/src/lib/__tests__/composer.test.ts +118 -0
  151. package/src/lib/__tests__/highlight.test.ts +42 -0
  152. package/src/lib/__tests__/home-chat.test.ts +64 -0
  153. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  154. package/src/lib/__tests__/model-picker.test.ts +205 -0
  155. package/src/lib/__tests__/onboarding.test.ts +41 -0
  156. package/src/lib/__tests__/permission-info.test.ts +61 -0
  157. package/src/lib/__tests__/project-extensions.test.ts +52 -0
  158. package/src/lib/__tests__/project-overview.test.ts +173 -0
  159. package/src/lib/__tests__/references.test.ts +121 -0
  160. package/src/lib/__tests__/routes.test.ts +134 -0
  161. package/src/lib/__tests__/session-summary.test.ts +115 -0
  162. package/src/lib/__tests__/session-title.test.ts +58 -0
  163. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  164. package/src/lib/__tests__/skill-installs.test.ts +134 -0
  165. package/src/lib/__tests__/skills-catalog.test.ts +96 -0
  166. package/src/lib/__tests__/tone.test.ts +37 -0
  167. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  168. package/src/lib/api.ts +306 -0
  169. package/src/lib/attachments.ts +203 -0
  170. package/src/lib/auth.ts +92 -0
  171. package/src/lib/avatar-presets.ts +31 -0
  172. package/src/lib/collapsible.ts +75 -0
  173. package/src/lib/composer.ts +89 -0
  174. package/src/lib/data-events.ts +17 -0
  175. package/src/lib/headlines.ts +30 -0
  176. package/src/lib/highlight.ts +186 -0
  177. package/src/lib/layout.ts +20 -0
  178. package/src/lib/lock-screen.ts +54 -0
  179. package/src/lib/model-picker.ts +133 -0
  180. package/src/lib/onboarding.ts +38 -0
  181. package/src/lib/passkeys.ts +113 -0
  182. package/src/lib/pending-prompt.ts +53 -0
  183. package/src/lib/permission-info.ts +101 -0
  184. package/src/lib/project-extensions.ts +74 -0
  185. package/src/lib/project-overview.ts +117 -0
  186. package/src/lib/prompts.ts +87 -0
  187. package/src/lib/references.ts +106 -0
  188. package/src/lib/routes.ts +115 -0
  189. package/src/lib/session-handoff.ts +25 -0
  190. package/src/lib/session-status.ts +45 -0
  191. package/src/lib/session-summary.ts +68 -0
  192. package/src/lib/session-title.ts +63 -0
  193. package/src/lib/sidebar-groups.ts +78 -0
  194. package/src/lib/skill-installs.ts +360 -0
  195. package/src/lib/skills-catalog.ts +99 -0
  196. package/src/lib/time.ts +22 -0
  197. package/src/lib/tone.ts +80 -0
  198. package/src/lib/turns.ts +320 -0
  199. package/src/lib/utils.ts +6 -0
  200. package/src/logo.svg +31 -0
  201. package/src/pages/projects/ProjectDetailPage.tsx +130 -0
  202. package/src/pages/projects/ProjectsPage.tsx +97 -0
  203. package/src/pages/sessions/SessionPage.tsx +122 -0
  204. package/src/pages/settings/SettingsPage.tsx +299 -0
  205. package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
  206. package/src/pages/skills/SkillsPage.tsx +701 -0
  207. package/src/paths.ts +121 -0
  208. package/src/runtime.ts +24 -0
  209. package/src/server/__tests__/app.test.ts +1031 -0
  210. package/src/server/__tests__/auth.test.ts +370 -0
  211. package/src/server/__tests__/config.test.ts +101 -0
  212. package/src/server/__tests__/db.test.ts +485 -0
  213. package/src/server/__tests__/host.test.ts +14 -0
  214. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  215. package/src/server/app.ts +415 -0
  216. package/src/server/db/auth.ts +311 -0
  217. package/src/server/db/messages.ts +50 -0
  218. package/src/server/db/projects.ts +106 -0
  219. package/src/server/db/prompts.ts +78 -0
  220. package/src/server/db/rows.ts +150 -0
  221. package/src/server/db/schema.ts +221 -0
  222. package/src/server/db/sessions.ts +239 -0
  223. package/src/server/db/tunnel.ts +124 -0
  224. package/src/server/host.ts +12 -0
  225. package/src/server/result.ts +15 -0
  226. package/src/server/routes/auth-guard.ts +156 -0
  227. package/src/server/routes/auth.routes.ts +287 -0
  228. package/src/server/routes/helpers.ts +165 -0
  229. package/src/server/routes/meta.routes.ts +24 -0
  230. package/src/server/routes/passkeys.routes.ts +161 -0
  231. package/src/server/routes/projects.routes.ts +157 -0
  232. package/src/server/routes/sessions.routes.ts +197 -0
  233. package/src/server/routes/skills.routes.ts +117 -0
  234. package/src/server/routes/tunnel.routes.ts +132 -0
  235. package/src/server/routes/types.ts +35 -0
  236. package/src/server/routes/uploads.routes.ts +54 -0
  237. package/src/server/routes/workspace.routes.ts +127 -0
  238. package/src/server/services/__tests__/app-info.test.ts +18 -0
  239. package/src/server/services/__tests__/attachments.test.ts +159 -0
  240. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  241. package/src/server/services/__tests__/auth.test.ts +341 -0
  242. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  243. package/src/server/services/__tests__/git-info.test.ts +74 -0
  244. package/src/server/services/__tests__/opencode.test.ts +320 -0
  245. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  246. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  247. package/src/server/services/__tests__/session-manager.test.ts +3469 -0
  248. package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
  249. package/src/server/services/__tests__/skills-registry.test.ts +359 -0
  250. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  251. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  252. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  253. package/src/server/services/__tests__/workspace.test.ts +249 -0
  254. package/src/server/services/app-info.ts +29 -0
  255. package/src/server/services/attachments.ts +193 -0
  256. package/src/server/services/auth-passkeys.ts +459 -0
  257. package/src/server/services/auth.ts +633 -0
  258. package/src/server/services/frpc-installer.ts +183 -0
  259. package/src/server/services/git-info.ts +87 -0
  260. package/src/server/services/opencode-client.ts +714 -0
  261. package/src/server/services/opencode-server.ts +319 -0
  262. package/src/server/services/opencode-sse.ts +82 -0
  263. package/src/server/services/project-manager.ts +147 -0
  264. package/src/server/services/sandbox.ts +83 -0
  265. package/src/server/services/session-events.ts +256 -0
  266. package/src/server/services/session-manager.ts +1374 -0
  267. package/src/server/services/skill-install-jobs.ts +262 -0
  268. package/src/server/services/skills-registry.ts +468 -0
  269. package/src/server/services/tunnel-api.ts +207 -0
  270. package/src/server/services/tunnel-config.ts +45 -0
  271. package/src/server/services/tunnel-lhr.ts +387 -0
  272. package/src/server/services/tunnel.ts +633 -0
  273. package/src/server/services/turn-stream.ts +339 -0
  274. package/src/server/services/websocket-gateway.ts +374 -0
  275. package/src/server/services/workspace.ts +223 -0
  276. package/src/server/services/ws-transport.ts +121 -0
  277. package/src/types/context.ts +69 -0
  278. package/src/types/index.ts +13 -0
  279. package/src/types/message.ts +58 -0
  280. package/src/types/project.ts +16 -0
  281. package/src/types/prompt.ts +118 -0
  282. package/src/types/session.ts +52 -0
  283. package/src/types/turns.ts +47 -0
  284. package/src/ws-protocol.ts +93 -0
  285. package/styles/globals.css +311 -0
  286. package/tsconfig.json +37 -0
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Unit test helper murni `opencode-client.ts` & `opencode-server.ts`.
3
+ *
4
+ * Yang diuji (tanpa jaringan/proses sungguhan):
5
+ * - `parseSseFrame` / `normalizeEvent`: parsing & normalisasi event SSE.
6
+ * - `parseListeningPort`: ekstraksi port dari baris log server.
7
+ * - `flattenProviders`: respons GET /config/providers -> daftar model UI.
8
+ * - `flattenAgents`: respons GET /agent -> daftar mode (primary/all saja).
9
+ * - `configFingerprint`: deteksi perubahan file config opencode.
10
+ * - `ensureFreshServer`: restart saat fingerprint config berubah.
11
+ */
12
+ import { expect, test } from "bun:test";
13
+ import { mkdtempSync, utimesSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import fc from "fast-check";
17
+ import type { OpenCodeClient } from "../opencode-client";
18
+ import { flattenAgents, flattenMcp, flattenProviders, flattenSkills } from "../opencode-client";
19
+ import {
20
+ configFingerprint,
21
+ createOpenCodeServerManager,
22
+ parseListeningPort,
23
+ type ServerProcessLike,
24
+ } from "../opencode-server";
25
+ import { normalizeEvent, parseSseFrame } from "../opencode-sse";
26
+
27
+ test("parseSseFrame: mengambil baris data; frame tanpa data -> null", () => {
28
+ expect(parseSseFrame('data: {"a":1}\n\n')).toBe('{"a":1}');
29
+ expect(parseSseFrame("event: x\ndata: hello\n")).toBe("hello");
30
+ expect(parseSseFrame("comment saja\n\n")).toBeNull();
31
+ expect(parseSseFrame("")).toBeNull();
32
+ });
33
+
34
+ test("normalizeEvent: {id,type,properties} -> event datar (type + properties)", () => {
35
+ const ev = normalizeEvent({
36
+ id: "evt_1",
37
+ type: "permission.asked",
38
+ properties: { requestID: "per_1", sessionID: "ses_1", permission: "bash:ls" },
39
+ });
40
+ expect(ev).toEqual({
41
+ id: "evt_1",
42
+ type: "permission.asked",
43
+ requestID: "per_1",
44
+ sessionID: "ses_1",
45
+ permission: "bash:ls",
46
+ });
47
+
48
+ // Payload tanpa properties -> tetap aman.
49
+ expect(normalizeEvent({ type: "server.connected" })).toEqual({ type: "server.connected" });
50
+
51
+ // Payload invalid -> null.
52
+ expect(normalizeEvent(null)).toBeNull();
53
+ expect(normalizeEvent("x")).toBeNull();
54
+ expect(normalizeEvent({})).toBeNull();
55
+ expect(normalizeEvent({ id: 1 })).toBeNull();
56
+ });
57
+
58
+ test("flattenAgents: hanya primary/all; subagent murni disaring", () => {
59
+ const agents = flattenAgents([
60
+ { name: "build", mode: "primary", description: "Full tool access" },
61
+ { name: "plan", mode: "primary", description: "Planning only" },
62
+ { name: "general", mode: "subagent", description: "Sub-agent" },
63
+ { name: "explore", mode: "all", description: null },
64
+ { name: "", mode: "primary" },
65
+ "rusak",
66
+ ]);
67
+ expect(agents.map((a) => a.name)).toEqual(["build", "plan", "explore"]);
68
+ expect(agents[2]).toEqual({ name: "explore", mode: "all", description: null });
69
+ });
70
+
71
+ test("flattenAgents: payload bukan array / kosong -> []", () => {
72
+ expect(flattenAgents(null)).toEqual([]);
73
+ expect(flattenAgents({})).toEqual([]);
74
+ expect(flattenAgents([])).toEqual([]);
75
+ });
76
+
77
+ test("parseListeningPort: mengekstrak port dari baris log opencode", () => {
78
+ expect(parseListeningPort("opencode server listening on http://127.0.0.1:4096")).toBe(4096);
79
+ expect(parseListeningPort("listening on http://0.0.0.0:4599")).toBe(4599);
80
+ expect(parseListeningPort("beberapa teks tanpa port")).toBeNull();
81
+ expect(parseListeningPort("port 80 di tengah teks")).toBeNull();
82
+ });
83
+
84
+ test("parseListeningPort: properti — port valid apa pun selalu diekstrak", () => {
85
+ fc.assert(
86
+ fc.property(fc.integer({ min: 1024, max: 65535 }), (p) => {
87
+ expect(parseListeningPort(`opencode server listening on http://127.0.0.1:${p}`)).toBe(p);
88
+ }),
89
+ { numRuns: 50 },
90
+ );
91
+ });
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // flattenProviders (GET /config/providers -> daftar model untuk UI)
95
+ // ---------------------------------------------------------------------------
96
+
97
+ test("flattenProviders: flatten provider + model dengan nama tampilan", () => {
98
+ const payload = {
99
+ providers: [
100
+ {
101
+ id: "kcgcode",
102
+ name: "kcgcode",
103
+ models: {
104
+ "kiro/claude-opus-5": { id: "kiro/claude-opus-5", name: "Claude Opus 5" },
105
+ "mimo/mimo-v2.5": { id: "mimo/mimo-v2.5" }, // tanpa name -> fallback id
106
+ },
107
+ },
108
+ ],
109
+ };
110
+ expect(flattenProviders(payload)).toEqual([
111
+ {
112
+ providerID: "kcgcode",
113
+ providerName: "kcgcode",
114
+ modelID: "kiro/claude-opus-5",
115
+ name: "Claude Opus 5",
116
+ },
117
+ {
118
+ providerID: "kcgcode",
119
+ providerName: "kcgcode",
120
+ modelID: "mimo/mimo-v2.5",
121
+ name: "mimo/mimo-v2.5",
122
+ },
123
+ ]);
124
+ });
125
+
126
+ test("flattenProviders: model deprecated disaring", () => {
127
+ const payload = {
128
+ providers: [
129
+ {
130
+ id: "p",
131
+ name: "P",
132
+ models: {
133
+ lama: { id: "lama", status: "deprecated" },
134
+ baru: { id: "baru", status: "active" },
135
+ },
136
+ },
137
+ ],
138
+ };
139
+ const out = flattenProviders(payload);
140
+ expect(out.map((m) => m.modelID)).toEqual(["baru"]);
141
+ });
142
+
143
+ test("flattenProviders: payload tidak valid / kosong -> []", () => {
144
+ expect(flattenProviders(null)).toEqual([]);
145
+ expect(flattenProviders({})).toEqual([]);
146
+ expect(flattenProviders({ providers: "bukan array" })).toEqual([]);
147
+ // provider tanpa id / tanpa models diabaikan
148
+ expect(flattenProviders({ providers: [{ name: "tanpa-id" }, { id: "kosong" }] })).toEqual([]);
149
+ });
150
+
151
+ test("flattenProviders: provider name kosong fallback ke id", () => {
152
+ const out = flattenProviders({
153
+ providers: [{ id: "opencode", name: "", models: { m1: { id: "m1", name: "M1" } } }],
154
+ });
155
+ expect(out[0]).toMatchObject({ providerID: "opencode", providerName: "opencode" });
156
+ });
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // configFingerprint / ensureFreshServer (live reread config opencode)
160
+ // ---------------------------------------------------------------------------
161
+
162
+ function makeFakeProc(port: number): ServerProcessLike & { kills: string[] } {
163
+ const kills: string[] = [];
164
+ let resolveExited: (code: number | null) => void = () => {};
165
+ const exited = new Promise<number | null>((res) => {
166
+ resolveExited = res;
167
+ });
168
+ const stdout = new ReadableStream<Uint8Array>({
169
+ start(controller) {
170
+ controller.close();
171
+ },
172
+ });
173
+ // stderr sengaja tidak di-close: ensureServer menunggu baris "listening".
174
+ const stderr = new ReadableStream<Uint8Array>({
175
+ start(controller) {
176
+ const enc = new TextEncoder();
177
+ controller.enqueue(enc.encode(`opencode server listening on http://127.0.0.1:${port}\n`));
178
+ },
179
+ });
180
+ return {
181
+ kills,
182
+ kill(signal) {
183
+ kills.push(signal);
184
+ resolveExited(0);
185
+ },
186
+ exited,
187
+ stderr,
188
+ stdout,
189
+ pid: undefined,
190
+ };
191
+ }
192
+
193
+ function makeFakeClientFactory() {
194
+ const clients: OpenCodeClient[] = [];
195
+ const factory = (): OpenCodeClient => {
196
+ const client = {
197
+ health: async () => true,
198
+ subscribeEvents: () => () => {},
199
+ } as unknown as OpenCodeClient;
200
+ clients.push(client);
201
+ return client;
202
+ };
203
+ return { clients, factory };
204
+ }
205
+
206
+ function makeFastManager() {
207
+ const procs: ReturnType<typeof makeFakeProc>[] = [];
208
+ const { clients, factory } = makeFakeClientFactory();
209
+ let nextPort = 9000;
210
+ const manager = createOpenCodeServerManager({
211
+ portTimeoutMs: 2000,
212
+ readyTimeoutMs: 2000,
213
+ spawn: () => {
214
+ const proc = makeFakeProc(nextPort++);
215
+ procs.push(proc);
216
+ return proc;
217
+ },
218
+ clientFactory: factory,
219
+ });
220
+ return { manager, procs, clients };
221
+ }
222
+
223
+ test("configFingerprint: file project ikut masuk dan berubah saat disentuh", () => {
224
+ const dir = mkdtempSync(join(tmpdir(), "kcg-fp-"));
225
+ const base = configFingerprint(dir);
226
+ const p = join(dir, "opencode.json");
227
+ writeFileSync(p, "{}");
228
+ const t = new Date("2026-01-01T00:00:00Z");
229
+ utimesSync(p, t, t);
230
+ const fp1 = configFingerprint(dir);
231
+ expect(fp1).toContain("opencode.json");
232
+ expect(fp1).not.toBe(base);
233
+ // Stabil bila file tidak disentuh.
234
+ expect(configFingerprint(dir)).toBe(fp1);
235
+ // Sentuh file -> fingerprint berubah.
236
+ utimesSync(p, new Date("2026-01-02T00:00:00Z"), new Date("2026-01-02T00:00:00Z"));
237
+ expect(configFingerprint(dir)).not.toBe(fp1);
238
+ });
239
+
240
+ test("ensureFreshServer: config tak berubah -> instance sama; berubah -> restart", async () => {
241
+ const dir = mkdtempSync(join(tmpdir(), "kcg-fresh-"));
242
+ const { manager, procs } = makeFastManager();
243
+
244
+ const first = await manager.ensureFreshServer("p1", dir);
245
+ expect(first.ok).toBe(true);
246
+ if (!first.ok) return;
247
+ expect(procs).toHaveLength(1);
248
+
249
+ // Config tidak berubah: tidak spawn ulang.
250
+ const second = await manager.ensureFreshServer("p1", dir);
251
+ expect(second.ok).toBe(true);
252
+ expect(procs).toHaveLength(1);
253
+ if (second.ok) expect(second.data.baseUrl).toBe(first.data.baseUrl);
254
+
255
+ // Ubah config project -> fingerprint beda -> stop + spawn baru.
256
+ writeFileSync(join(dir, "opencode.json"), "{}");
257
+ const third = await manager.ensureFreshServer("p1", dir);
258
+ expect(third.ok).toBe(true);
259
+ if (!third.ok) return;
260
+ expect(procs).toHaveLength(2);
261
+ expect(procs[0]?.kills).toContain("SIGTERM");
262
+ expect(third.data.baseUrl).not.toBe(first.data.baseUrl);
263
+ });
264
+
265
+ test("flattenMcp: map nama -> status, terurut; status tak dikenal -> unknown", () => {
266
+ expect(
267
+ flattenMcp({
268
+ zeta: { status: "connected" },
269
+ alpha: { status: "failed", error: "spawn ENOENT" },
270
+ mid: { status: "weird" },
271
+ "": { status: "connected" },
272
+ }),
273
+ ).toEqual([
274
+ { name: "alpha", status: "failed", error: "spawn ENOENT" },
275
+ { name: "mid", status: "unknown", error: null },
276
+ { name: "zeta", status: "connected", error: null },
277
+ ]);
278
+ expect(flattenMcp(null)).toEqual([]);
279
+ expect(flattenMcp([])).toEqual([]);
280
+ });
281
+
282
+ test("flattenSkills: klasifikasi asal (builtin/project/global), content dibuang", () => {
283
+ const out = flattenSkills(
284
+ [
285
+ {
286
+ name: "shadcn",
287
+ description: "UI",
288
+ location: "/work/app/.agents/skills/shadcn/SKILL.md",
289
+ content: "x",
290
+ },
291
+ { name: "customize", description: "", location: "<built-in>", content: "x" },
292
+ {
293
+ name: "firecrawl",
294
+ description: "Web",
295
+ location: "/home/u/.config/opencode/skills/f/SKILL.md",
296
+ },
297
+ // Prefix path mirip tapi bukan di dalam Project -> global.
298
+ { name: "other", location: "/work/app-2/SKILL.md" },
299
+ { description: "tanpa nama" },
300
+ ],
301
+ "/work/app/",
302
+ );
303
+ expect(out).toEqual([
304
+ { name: "customize", description: null, location: null, source: "builtin" },
305
+ {
306
+ name: "firecrawl",
307
+ description: "Web",
308
+ location: "/home/u/.config/opencode/skills/f/SKILL.md",
309
+ source: "global",
310
+ },
311
+ { name: "other", description: null, location: "/work/app-2/SKILL.md", source: "global" },
312
+ {
313
+ name: "shadcn",
314
+ description: "UI",
315
+ location: "/work/app/.agents/skills/shadcn/SKILL.md",
316
+ source: "project",
317
+ },
318
+ ]);
319
+ expect(flattenSkills({})).toEqual([]);
320
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Property & unit test `project-manager.ts` (task 8.2, 8.3).
3
+ * - Property 29: Pembuatan Project unik berhasil round-trip (10.4)
4
+ * - Property 30: Nama/path Project duplikat selalu ditolak (10.5, 10.6)
5
+ * - Unit 8.3: edge case path & nama (10.3, 10.7)
6
+ */
7
+ import { afterAll, beforeAll, expect, test } from "bun:test";
8
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import path from "node:path";
11
+ import fc from "fast-check";
12
+ import { openSessionStore } from "../../../db";
13
+ import { createProjectManager } from "../project-manager";
14
+
15
+ let root: string;
16
+
17
+ const SAFE_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789_-";
18
+
19
+ function safeNameArb() {
20
+ return fc
21
+ .array(fc.constantFrom(...SAFE_CHARS.split("")), { minLength: 1, maxLength: 12 })
22
+ .map((segs) => segs.join(""));
23
+ }
24
+
25
+ function safePathArb() {
26
+ return fc
27
+ .array(
28
+ fc
29
+ .array(fc.constantFrom(...SAFE_CHARS.split("")), { minLength: 1, maxLength: 8 })
30
+ .map((segs) => segs.join("")),
31
+ { minLength: 1, maxLength: 3 },
32
+ )
33
+ .map((segs) => segs.join("/"));
34
+ }
35
+
36
+ beforeAll(() => {
37
+ root = mkdtempSync(path.join(tmpdir(), "kcg-proj-"));
38
+ });
39
+
40
+ afterAll(() => {
41
+ rmSync(root, { recursive: true, force: true });
42
+ });
43
+
44
+ /** Store fresh per-run agar nama/path antar-run tidak konflik. */
45
+ function freshManager() {
46
+ const store = openSessionStore(":memory:");
47
+ return { store, pm: createProjectManager(root, store) };
48
+ }
49
+
50
+ // Feature: kcg-code, Property 29: Pembuatan Project unik berhasil round-trip
51
+ test("Property 29: createProject sukses -> direktori ada & listProjects menyertakannya", () => {
52
+ fc.assert(
53
+ fc.property(safeNameArb(), safePathArb(), (name, relPath) => {
54
+ const { store, pm } = freshManager();
55
+ try {
56
+ const res = pm.createProject(name, relPath);
57
+ expect(res.ok).toBe(true);
58
+ if (res.ok) {
59
+ // Direktori dibuat bila belum ada (Requirement 10.4)
60
+ expect(existsSync(res.data.path)).toBe(true);
61
+ // Round-trip: listProjects menyertakan Project baru
62
+ const listed = pm.listProjects();
63
+ expect(listed.some((p) => p.id === res.data.id && p.name === name)).toBe(true);
64
+ // Store menyimpan nama & path yang sama
65
+ expect(res.data.path.startsWith(path.resolve(root))).toBe(true);
66
+ }
67
+ } finally {
68
+ store.close();
69
+ }
70
+ }),
71
+ { numRuns: 100 },
72
+ );
73
+ });
74
+
75
+ // Feature: kcg-code, Property 30: Nama atau path Project duplikat selalu ditolak
76
+ test("Property 30: nama duplikat -> NAME_TAKEN; path duplikat -> PATH_TAKEN", () => {
77
+ fc.assert(
78
+ fc.property(safeNameArb(), safePathArb(), (name, relPath) => {
79
+ const { store, pm } = freshManager();
80
+ try {
81
+ const first = pm.createProject(name, relPath);
82
+ expect(first.ok).toBe(true);
83
+ const before = pm.listProjects().length;
84
+
85
+ // Nama sama, path berbeda
86
+ const dupName = pm.createProject(name, `${relPath}-x`);
87
+ expect(dupName.ok).toBe(false);
88
+ if (!dupName.ok) expect(dupName.error).toBe("NAME_TAKEN");
89
+
90
+ // Path sama, nama berbeda
91
+ const dupPath = pm.createProject(`${name}-x`, relPath);
92
+ expect(dupPath.ok).toBe(false);
93
+ if (!dupPath.ok) expect(dupPath.error).toBe("PATH_TAKEN");
94
+
95
+ // Tidak menambah baris projects baru
96
+ expect(pm.listProjects().length).toBe(before);
97
+ } finally {
98
+ store.close();
99
+ }
100
+ }),
101
+ { numRuns: 100 },
102
+ );
103
+ });
104
+
105
+ // ---- Task 8.3: unit test edge case path & nama ----
106
+ test("8.3: path mengandung ../etc ditolak", () => {
107
+ const { pm } = freshManager();
108
+ const res = pm.createProject("proj", "../etc");
109
+ expect(res.ok).toBe(false);
110
+ if (!res.ok) expect(res.error).toBe("PATH_OUTSIDE_SANDBOX");
111
+ });
112
+
113
+ test("8.3: path absolut di luar sandbox ditolak", () => {
114
+ const { pm } = freshManager();
115
+ const res = pm.createProject("proj", "/etc");
116
+ expect(res.ok).toBe(false);
117
+ if (!res.ok) expect(res.error).toBe("PATH_OUTSIDE_SANDBOX");
118
+ });
119
+
120
+ test("8.3: nama direktori dengan karakter unicode diterima", () => {
121
+ const { store, pm } = freshManager();
122
+ const res = pm.createProject("proj-unicode", "proyék-✓/sub");
123
+ expect(res.ok).toBe(true);
124
+ if (res.ok) {
125
+ expect(existsSync(res.data.path)).toBe(true);
126
+ expect(res.data.path).toContain("proyék-✓");
127
+ }
128
+ store.close();
129
+ });
130
+
131
+ test("8.3: nama/path dengan karakter terlarang OS ditolak", () => {
132
+ const { pm } = freshManager();
133
+ const res = pm.createProject("proj", `bad\0dir`);
134
+ expect(res.ok).toBe(false);
135
+ if (!res.ok) expect(res.error).toBe("INVALID_PATH_CHARS");
136
+ });
137
+
138
+ test("8.3: listDirectory path tidak ditemukan -> PATH_NOT_FOUND", () => {
139
+ const { pm } = freshManager();
140
+ const res = pm.listDirectory("tidak-ada");
141
+ expect(res.ok).toBe(false);
142
+ if (!res.ok) expect(res.error).toBe("PATH_NOT_FOUND");
143
+ });
144
+
145
+ test("deleteProject: pendaftaran hilang tapi direktori kerja tetap ada", () => {
146
+ const { store, pm } = freshManager();
147
+ const created = pm.createProject("hapus-saya", "hapus-saya");
148
+ expect(created.ok).toBe(true);
149
+ if (!created.ok) return;
150
+
151
+ expect(pm.deleteProject(created.data.id).ok).toBe(true);
152
+ expect(pm.listProjects().some((p) => p.id === created.data.id)).toBe(false);
153
+ // Project hanyalah referensi ke folder — isinya milik pengguna, jangan dihapus.
154
+ expect(existsSync(created.data.path)).toBe(true);
155
+ store.close();
156
+ });
157
+
158
+ test("deleteProject: id tak dikenal -> PROJECT_NOT_FOUND", () => {
159
+ const { store, pm } = freshManager();
160
+ const res = pm.deleteProject("tidak-ada");
161
+ expect(res.ok).toBe(false);
162
+ if (!res.ok) expect(res.error).toBe("PROJECT_NOT_FOUND");
163
+ store.close();
164
+ });
165
+
166
+ test("getProject: mengembalikan Project yang baru dibuat", () => {
167
+ const { store, pm } = freshManager();
168
+ const created = pm.createProject("ambil-saya", "ambil-saya");
169
+ expect(created.ok).toBe(true);
170
+ if (!created.ok) return;
171
+
172
+ const got = pm.getProject(created.data.id);
173
+ expect(got.ok).toBe(true);
174
+ if (got.ok) expect(got.data.name).toBe("ambil-saya");
175
+ expect(pm.getProject("tidak-ada").ok).toBe(false);
176
+ store.close();
177
+ });
178
+
179
+ test("8.3: listDirectory hanya mengembalikan direktori (bukan file)", () => {
180
+ const { pm } = freshManager();
181
+ const sub = mkdtempSync(path.join(root, "listtest-"));
182
+ mkdirSync(path.join(sub, "dir1"));
183
+ writeFileSync(path.join(sub, "file.txt"), "x");
184
+ const rel = path.relative(root, sub);
185
+ const res = pm.listDirectory(rel);
186
+ expect(res.ok).toBe(true);
187
+ if (res.ok) {
188
+ expect(res.data.entries).toEqual(["dir1"]);
189
+ }
190
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Property test `sandbox.ts` (task 7.2).
3
+ * - Property 27: Folder_Browser mengembalikan persis sub-direktori (10.2)
4
+ * - Property 28: Path di luar Sandbox_Root selalu ditolak (10.3, 10.7)
5
+ */
6
+ import { afterAll, beforeAll, expect, test } from "bun:test";
7
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import path from "node:path";
10
+ import fc from "fast-check";
11
+ import { openSessionStore } from "../../../db";
12
+ import { createProjectManager } from "../project-manager";
13
+ import { resolveWithinSandbox } from "../sandbox";
14
+
15
+ let root: string;
16
+ let outsideDir: string;
17
+ let store: ReturnType<typeof openSessionStore>;
18
+
19
+ beforeAll(() => {
20
+ root = mkdtempSync(path.join(tmpdir(), "kcg-sandbox-"));
21
+ outsideDir = mkdtempSync(path.join(tmpdir(), "kcg-outside-"));
22
+ mkdirSync(path.join(root, "a", "a1"), { recursive: true });
23
+ mkdirSync(path.join(root, "a", "a2"), { recursive: true });
24
+ mkdirSync(path.join(root, "b", "b1"), { recursive: true });
25
+ mkdirSync(path.join(root, "b", "b2"), { recursive: true });
26
+ writeFileSync(path.join(root, "a", "file.txt"), "x");
27
+ writeFileSync(path.join(root, "top.txt"), "x");
28
+ // Symlink yang mengarah keluar sandbox (harus ditolak).
29
+ symlinkSync(outsideDir, path.join(root, "evil-link"));
30
+ store = openSessionStore(":memory:");
31
+ });
32
+
33
+ afterAll(() => {
34
+ store?.close();
35
+ rmSync(root, { recursive: true, force: true });
36
+ rmSync(outsideDir, { recursive: true, force: true });
37
+ });
38
+
39
+ // Feature: kcg-code, Property 27: Folder_Browser mengembalikan persis sub-direktori dalam sandbox
40
+ test("Property 27: listDirectory mengembalikan persis sub-direktori untuk sub-path valid", () => {
41
+ const pm = createProjectManager(root, store);
42
+ const validPaths = ["", "a", "a/a1", "a/a2", "b", "b/b1", "b/b2"];
43
+ const expected = new Map<string, string[]>([
44
+ ["", ["a", "b"]],
45
+ ["a", ["a1", "a2"]],
46
+ ["a/a1", []],
47
+ ["a/a2", []],
48
+ ["b", ["b1", "b2"]],
49
+ ["b/b1", []],
50
+ ["b/b2", []],
51
+ ]);
52
+
53
+ fc.assert(
54
+ fc.property(fc.constantFrom(...validPaths), (p) => {
55
+ const res = pm.listDirectory(p);
56
+ expect(res.ok).toBe(true);
57
+ if (res.ok) {
58
+ // Tepat himpunan sub-direktori: tidak ada yang hilang, tidak ada tambahan
59
+ expect(res.data.entries).toEqual(expected.get(p) ?? []);
60
+ // Tidak ada file, tidak ada entri dari luar sandbox
61
+ for (const entry of res.data.entries) {
62
+ expect(entry).not.toBe("file.txt");
63
+ expect(entry).not.toBe("top.txt");
64
+ expect(entry).not.toBe("evil-link");
65
+ }
66
+ }
67
+ }),
68
+ { numRuns: 100 },
69
+ );
70
+ });
71
+
72
+ // Feature: kcg-code, Property 28: Path di luar Sandbox_Root selalu ditolak
73
+ test("Property 28: path dengan '..' selalu ditolak sebagai outside_sandbox", () => {
74
+ fc.assert(
75
+ fc.property(
76
+ fc.array(fc.constantFrom("a", "b", "c")).map((segs) => [...segs, "..", ...segs]),
77
+ (segs) => {
78
+ const p = segs.join("/");
79
+ const res = resolveWithinSandbox(root, p);
80
+ expect(res.ok).toBe(false);
81
+ if (!res.ok) expect(res.reason).toBe("outside_sandbox");
82
+ },
83
+ ),
84
+ { numRuns: 100 },
85
+ );
86
+ });
87
+
88
+ test("Property 28: path absolut di luar sandbox selalu ditolak", () => {
89
+ fc.assert(
90
+ fc.property(fc.constantFrom("/etc", "/tmp", "/usr", "/home"), (p) => {
91
+ const res = resolveWithinSandbox(root, p);
92
+ expect(res.ok).toBe(false);
93
+ if (!res.ok) expect(res.reason).toBe("outside_sandbox");
94
+ }),
95
+ { numRuns: 100 },
96
+ );
97
+ });
98
+
99
+ test("Property 28: symlink ke luar sandbox selalu ditolak", () => {
100
+ fc.assert(
101
+ fc.property(fc.constantFrom("evil-link", "evil-link/child", "evil-link/../.."), (p) => {
102
+ const res = resolveWithinSandbox(root, p);
103
+ expect(res.ok).toBe(false);
104
+ if (!res.ok) expect(res.reason).toBe("outside_sandbox");
105
+ }),
106
+ { numRuns: 100 },
107
+ );
108
+ });
109
+
110
+ test("Property 28: karakter terlarang OS -> invalid_chars", () => {
111
+ const forbiddenSeg = fc.array(fc.constantFrom("a", "b", "\0")).map((segs) => segs.join(""));
112
+ fc.assert(
113
+ fc.property(
114
+ fc
115
+ .array(forbiddenSeg, { minLength: 1, maxLength: 5 })
116
+ .filter((segs) => segs.some((s) => s.includes("\0"))),
117
+ (segs) => {
118
+ const res = resolveWithinSandbox(root, segs.join("/"));
119
+ expect(res.ok).toBe(false);
120
+ if (!res.ok) expect(res.reason).toBe("invalid_chars");
121
+ },
122
+ ),
123
+ { numRuns: 100 },
124
+ );
125
+ });
126
+
127
+ test("Property 28: listDirectory & createProject menolak path luar sandbox tanpa efek filesystem", () => {
128
+ const pm = createProjectManager(root, store);
129
+
130
+ const listRes = pm.listDirectory("../etc");
131
+ expect(listRes.ok).toBe(false);
132
+ if (!listRes.ok) expect(listRes.error).toBe("PATH_OUTSIDE_SANDBOX");
133
+
134
+ const createRes = pm.createProject("evils", "/etc/escape");
135
+ expect(createRes.ok).toBe(false);
136
+ if (!createRes.ok) expect(createRes.error).toBe("PATH_OUTSIDE_SANDBOX");
137
+
138
+ // Tidak ada direktori baru dibuat di luar sandbox
139
+ const outsideProbe = path.join(outsideDir, "escape");
140
+ expect(existsSync(outsideProbe)).toBe(false);
141
+ });