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,226 @@
1
+ /**
2
+ * Unit test Skill_Install_Jobs — registry & refresh di-mock.
3
+ */
4
+ import { describe, expect, test } from "bun:test";
5
+ import type { Result } from "../../result";
6
+ import { createSkillInstallJobs, FINISHED_TTL_MS, MAX_LOG_LINES } from "../skill-install-jobs";
7
+ import type { InstallResult, SkillsRegistry } from "../skills-registry";
8
+
9
+ interface Harness {
10
+ jobs: ReturnType<typeof createSkillInstallJobs>;
11
+ setTime: (t: number) => void;
12
+ release: (res: Result<InstallResult>) => void;
13
+ emit: (line: string) => void;
14
+ refreshCalls: string[];
15
+ }
16
+
17
+ /**
18
+ * Registry palsu: instalasi "menggantung" sampai `release` dipanggil — atau
19
+ * sampai signal dibatalkan (meniru proses CLI yang dibunuh).
20
+ */
21
+ function harness(refreshed = true): Harness {
22
+ let t = 1000;
23
+ let emitFn: (line: string) => void = () => {};
24
+ let releaseFn: (res: Result<InstallResult>) => void = () => {};
25
+ const refreshCalls: string[] = [];
26
+ const registry: SkillsRegistry = {
27
+ search: async () => ({ ok: true, data: [] }),
28
+ audit: async () => ({ ok: true, data: { audits: null, overall: null } }),
29
+ install: (_path, _source, _skill, onLine, signal) =>
30
+ new Promise((resolve) => {
31
+ emitFn = (l) => onLine?.(l);
32
+ releaseFn = resolve;
33
+ signal?.addEventListener("abort", () =>
34
+ resolve({ ok: false, error: "SKILL_INSTALL_CANCELLED" }),
35
+ );
36
+ }),
37
+ };
38
+ const jobs = createSkillInstallJobs({
39
+ registry,
40
+ refreshSkills: async (projectId) => {
41
+ refreshCalls.push(projectId);
42
+ return { ok: true, data: { refreshed } };
43
+ },
44
+ validate: (source, skill) => source === "a/b" && !skill.startsWith("-"),
45
+ now: () => t,
46
+ });
47
+ return {
48
+ jobs,
49
+ setTime: (v) => {
50
+ t = v;
51
+ },
52
+ release: (r) => releaseFn(r),
53
+ emit: (l) => emitFn(l),
54
+ refreshCalls,
55
+ };
56
+ }
57
+
58
+ const input = {
59
+ projectId: "p1",
60
+ projectName: "Proj",
61
+ projectPath: "/proj",
62
+ source: "a/b",
63
+ skillId: "c",
64
+ };
65
+
66
+ describe("createSkillInstallJobs", () => {
67
+ test("job berjalan, log bertahap via from, sukses + refresh", async () => {
68
+ const h = harness();
69
+ const started = h.jobs.start(input);
70
+ expect(started.ok).toBe(true);
71
+ if (!started.ok) return;
72
+ const id = started.data.id;
73
+ expect(started.data.status).toBe("running");
74
+
75
+ h.emit("line 1");
76
+ h.emit("line 2");
77
+ const first = h.jobs.get(id, 0);
78
+ expect(first?.lines).toEqual(["line 1", "line 2"]);
79
+ expect(first?.job.lineCount).toBe(2);
80
+
81
+ h.emit("line 3");
82
+ const next = h.jobs.get(id, 2);
83
+ expect(next?.from).toBe(2);
84
+ expect(next?.lines).toEqual(["line 3"]);
85
+
86
+ h.release({ ok: true, data: { name: "c", path: "/proj/.agents/skills/c" } });
87
+ await h.jobs.settled(id);
88
+ const done = h.jobs.get(id, 3);
89
+ expect(done?.job.status).toBe("succeeded");
90
+ expect(done?.job.refreshed).toBe(true);
91
+ expect(done?.job.installedPath).toBe("/proj/.agents/skills/c");
92
+ expect(done?.job.finishedAt).not.toBeNull();
93
+ expect(done?.lines.at(-1)).toBe("✓ Installed c.");
94
+ expect(h.refreshCalls).toEqual(["p1"]);
95
+ });
96
+
97
+ test("gagal -> status failed + kode error, tanpa refresh", async () => {
98
+ const h = harness();
99
+ const started = h.jobs.start(input);
100
+ if (!started.ok) throw new Error("start gagal");
101
+ h.release({ ok: false, error: "SKILL_INSTALL_FAILED" });
102
+ await h.jobs.settled(started.data.id);
103
+ const log = h.jobs.get(started.data.id);
104
+ expect(log?.job.status).toBe("failed");
105
+ expect(log?.job.error).toBe("SKILL_INSTALL_FAILED");
106
+ expect(log?.lines.at(-1)).toBe("✗ Installation failed.");
107
+ expect(h.refreshCalls).toHaveLength(0);
108
+ });
109
+
110
+ test("refresh dilewati (chat aktif) -> refreshed false + pesan", async () => {
111
+ const h = harness(false);
112
+ const started = h.jobs.start(input);
113
+ if (!started.ok) throw new Error("start gagal");
114
+ h.release({ ok: true, data: { name: "c", path: null } });
115
+ await h.jobs.settled(started.data.id);
116
+ const log = h.jobs.get(started.data.id);
117
+ expect(log?.job.refreshed).toBe(false);
118
+ expect(log?.lines.at(-1)).toContain("reloads skills automatically when it finishes");
119
+ });
120
+
121
+ test("markRefreshed: job sukses tertunda di Project itu jadi aktif + dicatat", async () => {
122
+ const h = harness(false);
123
+ const started = h.jobs.start(input);
124
+ if (!started.ok) throw new Error("start gagal");
125
+ h.release({ ok: true, data: { name: "c", path: null } });
126
+ await h.jobs.settled(started.data.id);
127
+
128
+ h.jobs.markRefreshed("p-lain");
129
+ expect(h.jobs.get(started.data.id)?.job.refreshed).toBe(false);
130
+
131
+ h.jobs.markRefreshed("p1");
132
+ const log = h.jobs.get(started.data.id);
133
+ expect(log?.job.refreshed).toBe(true);
134
+ expect(log?.lines.at(-1)).toBe("✓ Chat finished — c is now active.");
135
+ // Idempoten: tidak menambah baris lagi.
136
+ h.jobs.markRefreshed("p1");
137
+ expect(h.jobs.get(started.data.id)?.job.lineCount).toBe(log?.job.lineCount);
138
+ });
139
+
140
+ test("validasi & satu job berjalan per Project", async () => {
141
+ const h = harness();
142
+ expect(h.jobs.start({ ...input, skillId: "--global" })).toEqual({
143
+ ok: false,
144
+ error: "INVALID_SKILL_ID",
145
+ });
146
+ const first = h.jobs.start(input);
147
+ expect(first.ok).toBe(true);
148
+ expect(h.jobs.start({ ...input, skillId: "d" })).toEqual({
149
+ ok: false,
150
+ error: "SKILL_INSTALL_IN_PROGRESS",
151
+ });
152
+ // Project lain tetap boleh.
153
+ expect(h.jobs.start({ ...input, projectId: "p2" }).ok).toBe(true);
154
+ expect(
155
+ h.jobs
156
+ .list()
157
+ .map((j) => j.projectId)
158
+ .sort(),
159
+ ).toEqual(["p1", "p2"]);
160
+ });
161
+
162
+ test("log dibatasi MAX_LOG_LINES; from lama -> truncated", () => {
163
+ const h = harness();
164
+ const started = h.jobs.start(input);
165
+ if (!started.ok) throw new Error("start gagal");
166
+ for (let i = 0; i < MAX_LOG_LINES + 10; i++) h.emit(`l${i}`);
167
+ const log = h.jobs.get(started.data.id, 0);
168
+ expect(log?.truncated).toBe(true);
169
+ expect(log?.from).toBe(10);
170
+ expect(log?.lines).toHaveLength(MAX_LOG_LINES);
171
+ expect(log?.lines[0]).toBe("l10");
172
+ expect(log?.job.lineCount).toBe(MAX_LOG_LINES + 10);
173
+ });
174
+
175
+ test("job selesai dibuang setelah TTL; id tak dikenal -> null", async () => {
176
+ const h = harness();
177
+ const started = h.jobs.start(input);
178
+ if (!started.ok) throw new Error("start gagal");
179
+ h.release({ ok: true, data: { name: "c", path: null } });
180
+ await h.jobs.settled(started.data.id);
181
+ expect(h.jobs.list()).toHaveLength(1);
182
+ h.setTime(1000 + FINISHED_TTL_MS + 1);
183
+ expect(h.jobs.list()).toHaveLength(0);
184
+ expect(h.jobs.get(started.data.id)).toBeNull();
185
+ expect(h.jobs.get("nope")).toBeNull();
186
+ });
187
+
188
+ test("cancel: job berjalan -> cancelled, tanpa refresh; kedua kali -> false", async () => {
189
+ const h = harness();
190
+ const started = h.jobs.start(input);
191
+ if (!started.ok) throw new Error("start gagal");
192
+ const id = started.data.id;
193
+ expect(h.jobs.cancel(id)).toBe(true);
194
+ expect(h.jobs.cancel(id)).toBe(false);
195
+ await h.jobs.settled(id);
196
+ const log = h.jobs.get(id);
197
+ expect(log?.job.status).toBe("cancelled");
198
+ expect(log?.job.error).toBe("SKILL_INSTALL_CANCELLED");
199
+ expect(log?.lines).toContain("Cancelling…");
200
+ expect(log?.lines.at(-1)).toBe("✗ Installation cancelled.");
201
+ expect(h.refreshCalls).toHaveLength(0);
202
+ // Project bebas lagi setelah dibatalkan.
203
+ expect(h.jobs.start(input).ok).toBe(true);
204
+ expect(h.jobs.cancel("nope")).toBe(false);
205
+ });
206
+
207
+ test("cancelAll: membatalkan semua job berjalan dan menunggu selesai", async () => {
208
+ const h = harness();
209
+ const a = h.jobs.start(input);
210
+ const b = h.jobs.start({ ...input, projectId: "p2" });
211
+ if (!a.ok || !b.ok) throw new Error("start gagal");
212
+ await h.jobs.cancelAll();
213
+ expect(h.jobs.get(a.data.id)?.job.status).toBe("cancelled");
214
+ expect(h.jobs.get(b.data.id)?.job.status).toBe("cancelled");
215
+ });
216
+
217
+ test("lastOutputAt diperbarui tiap baris", () => {
218
+ const h = harness();
219
+ const started = h.jobs.start(input);
220
+ if (!started.ok) throw new Error("start gagal");
221
+ expect(started.data.lastOutputAt).toBe(1000);
222
+ h.setTime(5000);
223
+ h.emit("x");
224
+ expect(h.jobs.get(started.data.id)?.job.lastOutputAt).toBe(5000);
225
+ });
226
+ });
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Unit test Skills_Registry (skills.sh + CLI `skills`) — fetch & spawn di-mock,
3
+ * tanpa jaringan maupun proses nyata.
4
+ */
5
+ import { describe, expect, test } from "bun:test";
6
+ import {
7
+ buildInstallArgs,
8
+ cleanOutputLine,
9
+ createSkillsRegistry,
10
+ defaultSpawn,
11
+ installEnv,
12
+ isValidSkillId,
13
+ isValidSource,
14
+ normalizeQuery,
15
+ parseAuditResponse,
16
+ parseInstallOutput,
17
+ parseSearchResponse,
18
+ SKILLS_CLI_VERSION,
19
+ type SpawnFn,
20
+ } from "../skills-registry";
21
+
22
+ function jsonResponse(body: unknown, status = 200): Response {
23
+ return new Response(JSON.stringify(body), {
24
+ status,
25
+ headers: { "content-type": "application/json" },
26
+ });
27
+ }
28
+
29
+ /** Fetch mock yang mencatat URL dan membalas lewat `handler`. */
30
+ function mockFetch(handler: (url: string) => Response) {
31
+ const urls: string[] = [];
32
+ const fn = (async (input: RequestInfo | URL) => {
33
+ const url = String(input);
34
+ urls.push(url);
35
+ return handler(url);
36
+ }) as typeof fetch;
37
+ return { fn, urls };
38
+ }
39
+
40
+ describe("validasi", () => {
41
+ test("source GitHub owner/repo & domain well-known diterima", () => {
42
+ expect(isValidSource("vercel-labs/agent-skills")).toBe(true);
43
+ expect(isValidSource("anthropics/skills")).toBe(true);
44
+ expect(isValidSource("mintlify.com")).toBe(true);
45
+ });
46
+
47
+ test("source berbahaya / tak valid ditolak", () => {
48
+ for (const bad of [
49
+ "",
50
+ "owner",
51
+ "owner/repo/extra",
52
+ "../etc/passwd",
53
+ "owner/..",
54
+ "-rf/repo",
55
+ "owner/repo;rm -rf /",
56
+ "owner/repo --global",
57
+ "https://github.com/a/b",
58
+ "owner/re po",
59
+ ]) {
60
+ expect(isValidSource(bad)).toBe(false);
61
+ }
62
+ });
63
+
64
+ test("skillId aman diterima; opsi CLI / traversal ditolak", () => {
65
+ expect(isValidSkillId("find-skills")).toBe(true);
66
+ expect(isValidSkillId("vercel_react.v2")).toBe(true);
67
+ for (const bad of ["", "-g", "--global", ".hidden", "a/b", "a..b", "a b", "*"]) {
68
+ expect(isValidSkillId(bad)).toBe(false);
69
+ }
70
+ });
71
+
72
+ test("normalizeQuery: trim, rapatkan spasi, batas 2-100 karakter", () => {
73
+ expect(normalizeQuery(" react native ")).toBe("react native");
74
+ expect(normalizeQuery("a")).toBeNull();
75
+ expect(normalizeQuery(" ")).toBeNull();
76
+ expect(normalizeQuery("x".repeat(101))).toBeNull();
77
+ });
78
+ });
79
+
80
+ describe("parsing", () => {
81
+ test("parseSearchResponse: map, buang tak valid & duplikat, urut installs", () => {
82
+ const out = parseSearchResponse({
83
+ skills: [
84
+ { id: "a/b/low", source: "a/b", skillId: "low", name: "Low", installs: 5 },
85
+ { id: "a/b/high", source: "a/b", skillId: "high", name: "High", installs: 900 },
86
+ { id: "a/b/high", source: "a/b", skillId: "high", name: "High", installs: 900 },
87
+ { source: "bad source", skillId: "x", installs: 1 },
88
+ { source: "a/b", skillId: "--global", installs: 1 },
89
+ { source: "a/b", skillId: "noname", installs: -3 },
90
+ "junk",
91
+ ],
92
+ });
93
+ expect(out.map((s) => s.skillId)).toEqual(["high", "low", "noname"]);
94
+ expect(out[0]).toEqual({
95
+ id: "a/b/high",
96
+ name: "High",
97
+ source: "a/b",
98
+ skillId: "high",
99
+ installs: 900,
100
+ url: "https://skills.sh/a/b/high",
101
+ });
102
+ expect(out[2]?.name).toBe("noname");
103
+ expect(out[2]?.installs).toBe(0);
104
+ expect(parseSearchResponse(null)).toEqual([]);
105
+ expect(parseSearchResponse({ skills: "nope" })).toEqual([]);
106
+ });
107
+
108
+ test("parseAuditResponse: overall = status terburuk; status asing -> warn", () => {
109
+ const res = parseAuditResponse({
110
+ audits: [
111
+ { provider: "Socket", status: "pass", summary: "No alerts" },
112
+ { provider: "Snyk", status: "warn", summary: "Med", riskLevel: "MEDIUM" },
113
+ { provider: "Odd", status: "weird" },
114
+ { status: "fail" },
115
+ ],
116
+ });
117
+ expect(res.overall).toBe("warn");
118
+ expect(res.audits?.map((a) => [a.provider, a.status])).toEqual([
119
+ ["Socket", "pass"],
120
+ ["Snyk", "warn"],
121
+ ["Odd", "warn"],
122
+ ]);
123
+ expect(
124
+ parseAuditResponse({
125
+ audits: [
126
+ { provider: "X", status: "fail" },
127
+ { provider: "Y", status: "pass" },
128
+ ],
129
+ }).overall,
130
+ ).toBe("fail");
131
+ expect(parseAuditResponse({ audits: [] })).toEqual({ audits: null, overall: null });
132
+ });
133
+
134
+ test("buildInstallArgs: array argumen dengan versi CLI terpin", () => {
135
+ expect(buildInstallArgs("a/b", "c")).toEqual([
136
+ "bunx",
137
+ `skills@${SKILLS_CLI_VERSION}`,
138
+ "add",
139
+ "a/b",
140
+ "--skill",
141
+ "c",
142
+ "--agent",
143
+ "opencode",
144
+ "-y",
145
+ "--json",
146
+ ]);
147
+ });
148
+
149
+ test("parseInstallOutput: JSON setelah teks lain; status harus installed/updated", () => {
150
+ const ok = parseInstallOutput(
151
+ 'log line\n[{"name":"c","status":"installed","path":"/p/.agents/skills/c"}]',
152
+ "c",
153
+ );
154
+ expect(ok).toEqual({ ok: true, data: { name: "c", path: "/p/.agents/skills/c" } });
155
+ expect(parseInstallOutput("no json", "c").ok).toBe(false);
156
+ expect(parseInstallOutput("[not json", "c").ok).toBe(false);
157
+ expect(parseInstallOutput('[{"name":"c","status":"failed"}]', "c").ok).toBe(false);
158
+ expect(parseInstallOutput("[]", "c").ok).toBe(false);
159
+ });
160
+ });
161
+
162
+ describe("cleanOutputLine", () => {
163
+ test("buang ANSI, OSC, dan karakter kontrol; trim kanan", () => {
164
+ expect(cleanOutputLine("\u001b[1;32mok\u001b[0m ")).toBe("ok");
165
+ expect(cleanOutputLine("\u001b]8;;https://x\u0007link\u001b]8;;\u0007")).toBe("link");
166
+ expect(cleanOutputLine("a\u0008b\tc")).toBe("ab\tc");
167
+ expect(cleanOutputLine("\u001b[?25l◇ Done")).toBe("◇ Done");
168
+ });
169
+ });
170
+
171
+ describe("createSkillsRegistry", () => {
172
+ test("search memanggil /api/search, hasil di-cache 60 detik", async () => {
173
+ let t = 0;
174
+ const f = mockFetch(() =>
175
+ jsonResponse({ skills: [{ source: "a/b", skillId: "c", name: "c", installs: 1 }] }),
176
+ );
177
+ const reg = createSkillsRegistry({ fetch: f.fn, now: () => t });
178
+
179
+ const r1 = await reg.search(" React ");
180
+ expect(r1.ok && r1.data[0]?.id).toBe("a/b/c");
181
+ expect(f.urls[0]).toBe("https://skills.sh/api/search?q=React&limit=50");
182
+
183
+ await reg.search("react");
184
+ expect(f.urls).toHaveLength(1);
185
+
186
+ t = 61_000;
187
+ await reg.search("react");
188
+ expect(f.urls).toHaveLength(2);
189
+ });
190
+
191
+ test("search: query pendek ditolak tanpa fetch; 429 & error dipetakan", async () => {
192
+ const f = mockFetch(() => jsonResponse({ error: "x" }, 429));
193
+ const reg = createSkillsRegistry({ fetch: f.fn });
194
+ expect(await reg.search("a")).toEqual({ ok: false, error: "INVALID_SKILL_QUERY" });
195
+ expect(f.urls).toHaveLength(0);
196
+ expect(await reg.search("react")).toEqual({ ok: false, error: "SKILLS_RATE_LIMITED" });
197
+
198
+ const broken = createSkillsRegistry({
199
+ fetch: (async () => {
200
+ throw new Error("offline");
201
+ }) as unknown as typeof fetch,
202
+ });
203
+ const res = await broken.search("react");
204
+ expect(!res.ok && res.error.startsWith("SKILLS_SEARCH_FAILED")).toBe(true);
205
+ });
206
+
207
+ test("audit: URL v1, 404 -> tanpa audit, id tak valid ditolak", async () => {
208
+ const f = mockFetch((url) =>
209
+ url.endsWith("/none")
210
+ ? jsonResponse({ error: "not_found" }, 404)
211
+ : jsonResponse({ audits: [{ provider: "Socket", status: "pass", summary: "ok" }] }),
212
+ );
213
+ const reg = createSkillsRegistry({ fetch: f.fn });
214
+
215
+ const ok = await reg.audit("a/b", "c");
216
+ expect(ok.ok && ok.data.overall).toBe("pass");
217
+ expect(f.urls[0]).toBe("https://skills.sh/api/v1/skills/audit/a/b/c");
218
+
219
+ const none = await reg.audit("a/b", "none");
220
+ expect(none).toEqual({ ok: true, data: { audits: null, overall: null } });
221
+
222
+ expect(await reg.audit("../x", "c")).toEqual({ ok: false, error: "INVALID_SKILL_ID" });
223
+ expect(f.urls).toHaveLength(2);
224
+ });
225
+
226
+ test("install: spawn di direktori Project dengan argumen tervalidasi", async () => {
227
+ const calls: { args: string[]; cwd: string }[] = [];
228
+ const spawn: SpawnFn = async (args, cwd) => {
229
+ calls.push({ args, cwd });
230
+ return {
231
+ exitCode: 0,
232
+ stdout: '[{"name":"c","status":"installed","path":"/proj/.agents/skills/c"}]',
233
+ stderr: "",
234
+ };
235
+ };
236
+ const reg = createSkillsRegistry({ spawn });
237
+ const res = await reg.install("/proj", "a/b", "c");
238
+ expect(res).toEqual({ ok: true, data: { name: "c", path: "/proj/.agents/skills/c" } });
239
+ expect(calls).toEqual([{ args: buildInstallArgs("a/b", "c"), cwd: "/proj" }]);
240
+
241
+ expect(await reg.install("/proj", "a/b", "--global")).toEqual({
242
+ ok: false,
243
+ error: "INVALID_SKILL_ID",
244
+ });
245
+ expect(calls).toHaveLength(1);
246
+ });
247
+
248
+ test("install: onLine menerima perintah + output CLI yang sudah dibersihkan", async () => {
249
+ const spawn: SpawnFn = async (_args, _cwd, _t, onLine) => {
250
+ onLine?.("\u001b[32m◇ Installed\u001b[0m");
251
+ onLine?.("");
252
+ onLine?.("");
253
+ onLine?.("done");
254
+ return { exitCode: 0, stdout: '[{"name":"c","status":"installed"}]', stderr: "" };
255
+ };
256
+ const lines: string[] = [];
257
+ const reg = createSkillsRegistry({ spawn });
258
+ await reg.install("/proj", "a/b", "c", (l) => lines.push(l));
259
+ expect(lines).toEqual([
260
+ `$ ${buildInstallArgs("a/b", "c").join(" ")}`,
261
+ "◇ Installed",
262
+ "",
263
+ "done",
264
+ ]);
265
+ });
266
+
267
+ test("install: exit != 0 -> gagal; instalasi paralel di Project sama ditolak", async () => {
268
+ let release: () => void = () => {};
269
+ const gate = new Promise<void>((r) => {
270
+ release = r;
271
+ });
272
+ let exitCode = 0;
273
+ const spawn: SpawnFn = async () => {
274
+ await gate;
275
+ return { exitCode, stdout: '[{"name":"c","status":"installed"}]', stderr: "boom" };
276
+ };
277
+ const reg = createSkillsRegistry({ spawn });
278
+
279
+ const first = reg.install("/proj", "a/b", "c");
280
+ expect(await reg.install("/proj", "a/b", "d")).toEqual({
281
+ ok: false,
282
+ error: "SKILL_INSTALL_IN_PROGRESS",
283
+ });
284
+ release();
285
+ expect((await first).ok).toBe(true);
286
+
287
+ exitCode = 1;
288
+ expect(await reg.install("/proj", "a/b", "c")).toEqual({
289
+ ok: false,
290
+ error: "SKILL_INSTALL_FAILED",
291
+ });
292
+ });
293
+
294
+ test("install: signal dibatalkan -> SKILL_INSTALL_CANCELLED; exit null -> TIMEOUT", async () => {
295
+ const lines: string[] = [];
296
+ const ctrl = new AbortController();
297
+ const spawnAbort: SpawnFn = async (_a, _c, _t, _l, signal) => {
298
+ ctrl.abort();
299
+ expect(signal?.aborted).toBe(true);
300
+ return { exitCode: null, stdout: "", stderr: "" };
301
+ };
302
+ const reg = createSkillsRegistry({ spawn: spawnAbort });
303
+ expect(await reg.install("/p", "a/b", "c", (l) => lines.push(l), ctrl.signal)).toEqual({
304
+ ok: false,
305
+ error: "SKILL_INSTALL_CANCELLED",
306
+ });
307
+ expect(lines.at(-1)).toBe("Cancelled.");
308
+
309
+ const spawnTimeout: SpawnFn = async () => ({ exitCode: null, stdout: "", stderr: "" });
310
+ const reg2 = createSkillsRegistry({ spawn: spawnTimeout });
311
+ expect(await reg2.install("/p", "a/b", "c")).toEqual({
312
+ ok: false,
313
+ error: "SKILL_INSTALL_TIMEOUT",
314
+ });
315
+ });
316
+ });
317
+
318
+ describe("installEnv", () => {
319
+ test("mematikan telemetry & prompt git; env lain dipertahankan", () => {
320
+ const env = installEnv({ PATH: "/bin", HOME: "/h", DROP: undefined });
321
+ expect(env.PATH).toBe("/bin");
322
+ expect(env.HOME).toBe("/h");
323
+ expect("DROP" in env).toBe(false);
324
+ expect(env.DISABLE_TELEMETRY).toBe("1");
325
+ expect(env.GIT_TERMINAL_PROMPT).toBe("0");
326
+ expect(env.GIT_HTTP_LOW_SPEED_TIME).toBe("30");
327
+ });
328
+ });
329
+
330
+ describe("defaultSpawn (proses nyata)", () => {
331
+ // Regresi: proses cucu (git) yang memegang pipe membuat instalasi menggantung
332
+ // walau proses langsung sudah dibunuh. Kill process group harus mengakhirinya.
333
+ const hang = ["sh", "-c", "sh -c 'sleep 30' & echo started >&2; wait"];
334
+
335
+ test("pembatalan membunuh seluruh process group, termasuk cucu", async () => {
336
+ const ctrl = new AbortController();
337
+ const lines: string[] = [];
338
+ const t0 = Date.now();
339
+ setTimeout(() => ctrl.abort(), 200);
340
+ const res = await defaultSpawn(hang, "/tmp", 60_000, (l) => lines.push(l), ctrl.signal);
341
+ expect(Date.now() - t0).toBeLessThan(5_000);
342
+ expect(res.exitCode).toBeNull();
343
+ expect(lines).toContain("started");
344
+ });
345
+
346
+ test("timeout membunuh seluruh process group", async () => {
347
+ const t0 = Date.now();
348
+ const res = await defaultSpawn(hang, "/tmp", 300);
349
+ expect(Date.now() - t0).toBeLessThan(5_000);
350
+ expect(res.exitCode).toBeNull();
351
+ });
352
+
353
+ test("proses normal: exit code & stdout diteruskan", async () => {
354
+ const res = await defaultSpawn(["sh", "-c", "echo out; echo err >&2; exit 3"], "/tmp", 5_000);
355
+ expect(res.exitCode).toBe(3);
356
+ expect(res.stdout).toBe("out\n");
357
+ expect(res.stderr).toBe("err\n");
358
+ });
359
+ });