pilothub 0.0.1

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 (272) hide show
  1. package/.env.local.example +19 -0
  2. package/.github/workflows/ci.yml +40 -0
  3. package/.oxlintrc.json +3 -0
  4. package/AGENTS.md +45 -0
  5. package/CHANGELOG.md +138 -0
  6. package/DEPRECATIONS.md +7 -0
  7. package/LICENSE +21 -0
  8. package/README.md +150 -0
  9. package/biome.json +41 -0
  10. package/convex/_generated/api.d.ts +153 -0
  11. package/convex/_generated/api.js +23 -0
  12. package/convex/_generated/dataModel.d.ts +60 -0
  13. package/convex/_generated/server.d.ts +143 -0
  14. package/convex/_generated/server.js +93 -0
  15. package/convex/auth.config.ts +8 -0
  16. package/convex/auth.ts +19 -0
  17. package/convex/comments.ts +88 -0
  18. package/convex/crons.ts +34 -0
  19. package/convex/devSeed.ts +459 -0
  20. package/convex/devSeedExtra.ts +541 -0
  21. package/convex/downloads.ts +78 -0
  22. package/convex/githubBackups.ts +170 -0
  23. package/convex/githubBackupsNode.ts +183 -0
  24. package/convex/githubImport.ts +317 -0
  25. package/convex/githubSoulBackups.ts +170 -0
  26. package/convex/githubSoulBackupsNode.ts +186 -0
  27. package/convex/http.ts +194 -0
  28. package/convex/httpApi.handlers.test.ts +488 -0
  29. package/convex/httpApi.test.ts +70 -0
  30. package/convex/httpApi.ts +305 -0
  31. package/convex/httpApiV1.handlers.test.ts +584 -0
  32. package/convex/httpApiV1.ts +1172 -0
  33. package/convex/leaderboards.ts +39 -0
  34. package/convex/lib/access.ts +36 -0
  35. package/convex/lib/apiTokenAuth.ts +36 -0
  36. package/convex/lib/badges.ts +50 -0
  37. package/convex/lib/changelog.test.ts +34 -0
  38. package/convex/lib/changelog.ts +278 -0
  39. package/convex/lib/embeddings.ts +38 -0
  40. package/convex/lib/githubBackup.ts +443 -0
  41. package/convex/lib/githubImport.test.ts +247 -0
  42. package/convex/lib/githubImport.ts +425 -0
  43. package/convex/lib/githubSoulBackup.ts +443 -0
  44. package/convex/lib/leaderboards.ts +103 -0
  45. package/convex/lib/moderation.ts +42 -0
  46. package/convex/lib/public.ts +89 -0
  47. package/convex/lib/searchText.test.ts +46 -0
  48. package/convex/lib/searchText.ts +27 -0
  49. package/convex/lib/skillBackfill.test.ts +34 -0
  50. package/convex/lib/skillBackfill.ts +67 -0
  51. package/convex/lib/skillPublish.test.ts +28 -0
  52. package/convex/lib/skillPublish.ts +284 -0
  53. package/convex/lib/skillStats.ts +80 -0
  54. package/convex/lib/skills.test.ts +197 -0
  55. package/convex/lib/skills.ts +273 -0
  56. package/convex/lib/soulChangelog.ts +273 -0
  57. package/convex/lib/soulPublish.ts +236 -0
  58. package/convex/lib/tokens.test.ts +33 -0
  59. package/convex/lib/tokens.ts +51 -0
  60. package/convex/lib/webhooks.test.ts +91 -0
  61. package/convex/lib/webhooks.ts +112 -0
  62. package/convex/maintenance.test.ts +270 -0
  63. package/convex/maintenance.ts +840 -0
  64. package/convex/rateLimits.ts +50 -0
  65. package/convex/schema.ts +472 -0
  66. package/convex/search.test.ts +12 -0
  67. package/convex/search.ts +254 -0
  68. package/convex/seed.test.ts +37 -0
  69. package/convex/seed.ts +254 -0
  70. package/convex/seedSouls.ts +111 -0
  71. package/convex/skillStatEvents.ts +568 -0
  72. package/convex/skills.ts +1606 -0
  73. package/convex/soulComments.ts +88 -0
  74. package/convex/soulDownloads.ts +14 -0
  75. package/convex/soulStars.ts +71 -0
  76. package/convex/souls.ts +570 -0
  77. package/convex/stars.ts +108 -0
  78. package/convex/statsMaintenance.ts +205 -0
  79. package/convex/telemetry.ts +434 -0
  80. package/convex/tokens.ts +88 -0
  81. package/convex/tsconfig.json +7 -0
  82. package/convex/uploads.ts +20 -0
  83. package/convex/users.ts +122 -0
  84. package/convex/webhooks.ts +50 -0
  85. package/convex.json +3 -0
  86. package/docs/README.md +32 -0
  87. package/docs/api.md +51 -0
  88. package/docs/architecture.md +61 -0
  89. package/docs/auth.md +54 -0
  90. package/docs/cli.md +117 -0
  91. package/docs/deploy.md +78 -0
  92. package/docs/diffing.md +84 -0
  93. package/docs/github-import.md +171 -0
  94. package/docs/http-api.md +187 -0
  95. package/docs/manual-testing.md +64 -0
  96. package/docs/mintlify.md +43 -0
  97. package/docs/quickstart.md +120 -0
  98. package/docs/skill-format.md +58 -0
  99. package/docs/soul-format.md +37 -0
  100. package/docs/spec.md +177 -0
  101. package/docs/telemetry.md +91 -0
  102. package/docs/troubleshooting.md +49 -0
  103. package/docs/webhook.md +51 -0
  104. package/e2e/menu-smoke.pw.test.ts +49 -0
  105. package/e2e/pilothub.e2e.test.ts +494 -0
  106. package/e2e/search-exact.pw.test.ts +97 -0
  107. package/package.json +84 -0
  108. package/packages/pilothub/LICENSE +22 -0
  109. package/packages/pilothub/README.md +57 -0
  110. package/packages/pilothub/bin/pilothub.js +2 -0
  111. package/packages/pilothub/package.json +41 -0
  112. package/packages/pilothub/src/browserAuth.test.ts +96 -0
  113. package/packages/pilothub/src/browserAuth.ts +174 -0
  114. package/packages/pilothub/src/cli/buildInfo.ts +94 -0
  115. package/packages/pilothub/src/cli/commands/auth.ts +97 -0
  116. package/packages/pilothub/src/cli/commands/delete.test.ts +73 -0
  117. package/packages/pilothub/src/cli/commands/delete.ts +83 -0
  118. package/packages/pilothub/src/cli/commands/publish.test.ts +122 -0
  119. package/packages/pilothub/src/cli/commands/publish.ts +108 -0
  120. package/packages/pilothub/src/cli/commands/skills.test.ts +191 -0
  121. package/packages/pilothub/src/cli/commands/skills.ts +380 -0
  122. package/packages/pilothub/src/cli/commands/star.ts +46 -0
  123. package/packages/pilothub/src/cli/commands/sync.test.ts +310 -0
  124. package/packages/pilothub/src/cli/commands/sync.ts +200 -0
  125. package/packages/pilothub/src/cli/commands/syncHelpers.test.ts +26 -0
  126. package/packages/pilothub/src/cli/commands/syncHelpers.ts +427 -0
  127. package/packages/pilothub/src/cli/commands/syncTypes.ts +27 -0
  128. package/packages/pilothub/src/cli/commands/unstar.ts +48 -0
  129. package/packages/pilothub/src/cli/helpStyle.ts +45 -0
  130. package/packages/pilothub/src/cli/pilotbotConfig.test.ts +159 -0
  131. package/packages/pilothub/src/cli/pilotbotConfig.ts +147 -0
  132. package/packages/pilothub/src/cli/registry.test.ts +63 -0
  133. package/packages/pilothub/src/cli/registry.ts +43 -0
  134. package/packages/pilothub/src/cli/scanSkills.test.ts +64 -0
  135. package/packages/pilothub/src/cli/scanSkills.ts +84 -0
  136. package/packages/pilothub/src/cli/slug.ts +16 -0
  137. package/packages/pilothub/src/cli/types.ts +12 -0
  138. package/packages/pilothub/src/cli/ui.ts +75 -0
  139. package/packages/pilothub/src/cli.ts +311 -0
  140. package/packages/pilothub/src/config.ts +36 -0
  141. package/packages/pilothub/src/discovery.test.ts +75 -0
  142. package/packages/pilothub/src/discovery.ts +19 -0
  143. package/packages/pilothub/src/http.test.ts +156 -0
  144. package/packages/pilothub/src/http.ts +301 -0
  145. package/packages/pilothub/src/schema/ark.ts +29 -0
  146. package/packages/pilothub/src/schema/index.ts +5 -0
  147. package/packages/pilothub/src/schema/routes.ts +22 -0
  148. package/packages/pilothub/src/schema/schemas.ts +260 -0
  149. package/packages/pilothub/src/schema/textFiles.test.ts +23 -0
  150. package/packages/pilothub/src/schema/textFiles.ts +66 -0
  151. package/packages/pilothub/src/skills.test.ts +191 -0
  152. package/packages/pilothub/src/skills.ts +172 -0
  153. package/packages/pilothub/src/types.ts +10 -0
  154. package/packages/pilothub/tsconfig.json +14 -0
  155. package/packages/schema/README.md +3 -0
  156. package/packages/schema/dist/ark.d.ts +4 -0
  157. package/packages/schema/dist/ark.js +26 -0
  158. package/packages/schema/dist/ark.js.map +1 -0
  159. package/packages/schema/dist/index.d.ts +5 -0
  160. package/packages/schema/dist/index.js +5 -0
  161. package/packages/schema/dist/index.js.map +1 -0
  162. package/packages/schema/dist/routes.d.ts +21 -0
  163. package/packages/schema/dist/routes.js +22 -0
  164. package/packages/schema/dist/routes.js.map +1 -0
  165. package/packages/schema/dist/schemas.d.ts +297 -0
  166. package/packages/schema/dist/schemas.js +243 -0
  167. package/packages/schema/dist/schemas.js.map +1 -0
  168. package/packages/schema/dist/textFiles.d.ts +5 -0
  169. package/packages/schema/dist/textFiles.js +66 -0
  170. package/packages/schema/dist/textFiles.js.map +1 -0
  171. package/packages/schema/package.json +26 -0
  172. package/packages/schema/src/ark.ts +29 -0
  173. package/packages/schema/src/index.ts +5 -0
  174. package/packages/schema/src/routes.ts +22 -0
  175. package/packages/schema/src/schemas.test.ts +123 -0
  176. package/packages/schema/src/schemas.ts +287 -0
  177. package/packages/schema/src/textFiles.test.ts +23 -0
  178. package/packages/schema/src/textFiles.ts +66 -0
  179. package/packages/schema/tsconfig.json +15 -0
  180. package/pilothub +46 -0
  181. package/playwright.config.ts +33 -0
  182. package/public/.well-known/pilothub.json +6 -0
  183. package/public/api/v1/openapi.json +379 -0
  184. package/public/favicon.ico +0 -0
  185. package/public/logo192.png +0 -0
  186. package/public/logo512.png +0 -0
  187. package/public/manifest.json +25 -0
  188. package/public/og.png +0 -0
  189. package/public/og.svg +98 -0
  190. package/public/pilot-logo.png +0 -0
  191. package/public/pilot-mark.png +0 -0
  192. package/public/robots.txt +3 -0
  193. package/public/tanstack-circle-logo.png +0 -0
  194. package/public/tanstack-word-logo-white.svg +1 -0
  195. package/scripts/check-peer-deps.ts +56 -0
  196. package/scripts/docs-list.ts +148 -0
  197. package/scripts/run-playwright-local.sh +14 -0
  198. package/server/og/fetchSkillOgMeta.ts +27 -0
  199. package/server/og/fetchSoulOgMeta.ts +27 -0
  200. package/server/og/ogAssets.ts +80 -0
  201. package/server/og/skillOgSvg.test.ts +59 -0
  202. package/server/og/skillOgSvg.ts +258 -0
  203. package/server/og/soulOgSvg.ts +209 -0
  204. package/server/routes/og/skill.png.ts +103 -0
  205. package/server/routes/og/soul.png.ts +111 -0
  206. package/src/__tests__/skill-detail-page.test.tsx +86 -0
  207. package/src/__tests__/skills-index.test.tsx +145 -0
  208. package/src/__tests__/upload.route.test.tsx +228 -0
  209. package/src/components/AppProviders.tsx +19 -0
  210. package/src/components/ClientOnly.tsx +18 -0
  211. package/src/components/Footer.tsx +29 -0
  212. package/src/components/Header.tsx +295 -0
  213. package/src/components/InstallSwitcher.tsx +53 -0
  214. package/src/components/SkillCard.tsx +36 -0
  215. package/src/components/SkillDetailPage.tsx +817 -0
  216. package/src/components/SkillDiffCard.tsx +485 -0
  217. package/src/components/SoulCard.tsx +19 -0
  218. package/src/components/SoulDetailPage.tsx +263 -0
  219. package/src/components/UserBootstrap.tsx +18 -0
  220. package/src/components/ui/dropdown-menu.tsx +67 -0
  221. package/src/components/ui/toggle-group.tsx +35 -0
  222. package/src/convex/client.ts +3 -0
  223. package/src/lib/badges.ts +29 -0
  224. package/src/lib/diffing.test.ts +163 -0
  225. package/src/lib/diffing.ts +106 -0
  226. package/src/lib/gravatar.test.ts +9 -0
  227. package/src/lib/gravatar.ts +158 -0
  228. package/src/lib/og.test.ts +142 -0
  229. package/src/lib/og.ts +156 -0
  230. package/src/lib/publicUser.ts +39 -0
  231. package/src/lib/roles.ts +19 -0
  232. package/src/lib/site.test.ts +130 -0
  233. package/src/lib/site.ts +84 -0
  234. package/src/lib/theme-transition.test.ts +134 -0
  235. package/src/lib/theme-transition.ts +134 -0
  236. package/src/lib/theme.test.tsx +88 -0
  237. package/src/lib/theme.ts +43 -0
  238. package/src/lib/uploadFiles.jsdom.test.ts +33 -0
  239. package/src/lib/uploadFiles.test.ts +123 -0
  240. package/src/lib/uploadFiles.ts +245 -0
  241. package/src/lib/uploadUtils.test.ts +78 -0
  242. package/src/lib/uploadUtils.ts +93 -0
  243. package/src/lib/useAuthStatus.ts +12 -0
  244. package/src/lib/utils.test.ts +9 -0
  245. package/src/lib/utils.ts +6 -0
  246. package/src/logo.svg +12 -0
  247. package/src/routeTree.gen.ts +345 -0
  248. package/src/router.tsx +17 -0
  249. package/src/routes/$owner/$slug.tsx +55 -0
  250. package/src/routes/__root.tsx +136 -0
  251. package/src/routes/admin.tsx +11 -0
  252. package/src/routes/cli/auth.tsx +168 -0
  253. package/src/routes/dashboard.tsx +97 -0
  254. package/src/routes/import.tsx +415 -0
  255. package/src/routes/index.tsx +252 -0
  256. package/src/routes/management.tsx +529 -0
  257. package/src/routes/settings.tsx +203 -0
  258. package/src/routes/skills/index.tsx +422 -0
  259. package/src/routes/souls/$slug.tsx +55 -0
  260. package/src/routes/souls/index.tsx +243 -0
  261. package/src/routes/stars.tsx +68 -0
  262. package/src/routes/u/$handle.tsx +307 -0
  263. package/src/routes/upload/utils.ts +81 -0
  264. package/src/routes/upload.tsx +499 -0
  265. package/src/styles.css +2718 -0
  266. package/tsconfig.json +24 -0
  267. package/tsconfig.oxlint.json +16 -0
  268. package/vercel.json +8 -0
  269. package/vite.config.ts +48 -0
  270. package/vitest.config.ts +47 -0
  271. package/vitest.e2e.config.ts +11 -0
  272. package/vitest.setup.ts +1 -0
@@ -0,0 +1,273 @@
1
+ import { internal } from '../_generated/api'
2
+ import type { Doc } from '../_generated/dataModel'
3
+ import type { ActionCtx } from '../_generated/server'
4
+
5
+ const CHANGELOG_MODEL = process.env.OPENAI_CHANGELOG_MODEL ?? 'gpt-4.1'
6
+ const MAX_README_CHARS = 8_000
7
+ const MAX_PATHS_IN_PROMPT = 30
8
+
9
+ type FileMeta = { path: string; sha256?: string }
10
+
11
+ type FileDiffSummary = {
12
+ added: string[]
13
+ removed: string[]
14
+ changed: string[]
15
+ }
16
+
17
+ function clampText(value: string, maxChars: number) {
18
+ const trimmed = value.trim()
19
+ if (trimmed.length <= maxChars) return trimmed
20
+ return `${trimmed.slice(0, maxChars).trimEnd()}\n…`
21
+ }
22
+
23
+ function summarizeFileDiff(oldFiles: FileMeta[], nextFiles: FileMeta[]): FileDiffSummary {
24
+ const oldByPath = new Map(oldFiles.map((f) => [f.path, f] as const))
25
+ const nextByPath = new Map(nextFiles.map((f) => [f.path, f] as const))
26
+
27
+ const added: string[] = []
28
+ const removed: string[] = []
29
+ const changed: string[] = []
30
+
31
+ for (const [path, file] of nextByPath.entries()) {
32
+ const prev = oldByPath.get(path)
33
+ if (!prev) {
34
+ added.push(path)
35
+ continue
36
+ }
37
+ if (file.sha256 && prev.sha256 && file.sha256 !== prev.sha256) changed.push(path)
38
+ }
39
+ for (const path of oldByPath.keys()) {
40
+ if (!nextByPath.has(path)) removed.push(path)
41
+ }
42
+
43
+ added.sort()
44
+ removed.sort()
45
+ changed.sort()
46
+ return { added, removed, changed }
47
+ }
48
+
49
+ function formatDiffSummary(diff: FileDiffSummary) {
50
+ const parts: string[] = []
51
+ if (diff.added.length) parts.push(`${diff.added.length} added`)
52
+ if (diff.changed.length) parts.push(`${diff.changed.length} changed`)
53
+ if (diff.removed.length) parts.push(`${diff.removed.length} removed`)
54
+ return parts.join(', ') || 'no file changes detected'
55
+ }
56
+
57
+ function pickPaths(values: string[]) {
58
+ if (values.length <= MAX_PATHS_IN_PROMPT) return values
59
+ return values.slice(0, MAX_PATHS_IN_PROMPT)
60
+ }
61
+
62
+ function extractResponseText(payload: unknown) {
63
+ if (!payload || typeof payload !== 'object') return null
64
+ const output = (payload as { output?: unknown }).output
65
+ if (!Array.isArray(output)) return null
66
+ const chunks: string[] = []
67
+ for (const item of output) {
68
+ if (!item || typeof item !== 'object') continue
69
+ if ((item as { type?: unknown }).type !== 'message') continue
70
+ const content = (item as { content?: unknown }).content
71
+ if (!Array.isArray(content)) continue
72
+ for (const part of content) {
73
+ if (!part || typeof part !== 'object') continue
74
+ if ((part as { type?: unknown }).type !== 'output_text') continue
75
+ const text = (part as { text?: unknown }).text
76
+ if (typeof text === 'string' && text.trim()) chunks.push(text)
77
+ }
78
+ }
79
+ const joined = chunks.join('\n').trim()
80
+ return joined || null
81
+ }
82
+
83
+ async function generateWithOpenAI(args: {
84
+ slug: string
85
+ version: string
86
+ oldReadme: string | null
87
+ nextReadme: string
88
+ fileDiff: FileDiffSummary | null
89
+ }) {
90
+ const apiKey = process.env.OPENAI_API_KEY
91
+ if (!apiKey) return null
92
+
93
+ const oldReadme = args.oldReadme ? clampText(args.oldReadme, MAX_README_CHARS) : ''
94
+ const nextReadme = clampText(args.nextReadme, MAX_README_CHARS)
95
+
96
+ const fileDiff = args.fileDiff
97
+ const diffSummary = fileDiff ? formatDiffSummary(fileDiff) : 'unknown'
98
+ const changedPaths = fileDiff ? pickPaths(fileDiff.changed) : []
99
+ const addedPaths = fileDiff ? pickPaths(fileDiff.added) : []
100
+ const removedPaths = fileDiff ? pickPaths(fileDiff.removed) : []
101
+
102
+ const input = [
103
+ `Soul: ${args.slug}`,
104
+ `Version: ${args.version}`,
105
+ `File changes: ${diffSummary}`,
106
+ changedPaths.length ? `Changed files (sample): ${changedPaths.join(', ')}` : null,
107
+ addedPaths.length ? `Added files (sample): ${addedPaths.join(', ')}` : null,
108
+ removedPaths.length ? `Removed files (sample): ${removedPaths.join(', ')}` : null,
109
+ oldReadme ? `Previous SOUL.md:\n${oldReadme}` : null,
110
+ `New SOUL.md:\n${nextReadme}`,
111
+ ]
112
+ .filter(Boolean)
113
+ .join('\n\n')
114
+
115
+ const response = await fetch('https://api.openai.com/v1/responses', {
116
+ method: 'POST',
117
+ headers: {
118
+ 'Content-Type': 'application/json',
119
+ Authorization: `Bearer ${apiKey}`,
120
+ },
121
+ body: JSON.stringify({
122
+ model: CHANGELOG_MODEL,
123
+ instructions:
124
+ 'Write a concise changelog for this soul version. Audience: everyone. Output plain text. Prefer 2–6 bullet points. If it is a big change, include a short 1-line summary first, then bullets. Don’t mention that you are AI. Don’t invent details; only use the inputs.',
125
+ input,
126
+ max_output_tokens: 220,
127
+ }),
128
+ })
129
+
130
+ if (!response.ok) return null
131
+ const payload = (await response.json()) as unknown
132
+ return extractResponseText(payload)
133
+ }
134
+
135
+ function generateFallback(args: {
136
+ slug: string
137
+ version: string
138
+ oldReadme: string | null
139
+ nextReadme: string
140
+ fileDiff: FileDiffSummary | null
141
+ }) {
142
+ const lines: string[] = []
143
+ if (!args.oldReadme) {
144
+ lines.push(`- Initial release.`)
145
+ return lines.join('\n')
146
+ }
147
+
148
+ const diff = args.fileDiff
149
+ if (diff) {
150
+ const parts: string[] = []
151
+ if (diff.added.length) parts.push(`added ${diff.added.length}`)
152
+ if (diff.changed.length) parts.push(`updated ${diff.changed.length}`)
153
+ if (diff.removed.length) parts.push(`removed ${diff.removed.length}`)
154
+ if (parts.length) lines.push(`- ${parts.join(', ')} file(s).`)
155
+ }
156
+
157
+ lines.push(`- Updated SOUL.md.`)
158
+ return lines.join('\n')
159
+ }
160
+
161
+ export async function generateSoulChangelogForPublish(
162
+ ctx: ActionCtx,
163
+ args: { slug: string; version: string; readmeText: string; files: FileMeta[] },
164
+ ): Promise<string> {
165
+ try {
166
+ const soul = (await ctx.runQuery(internal.souls.getSoulBySlugInternal, {
167
+ slug: args.slug,
168
+ })) as Doc<'souls'> | null
169
+ const previous: Doc<'soulVersions'> | null =
170
+ soul?.latestVersionId && !soul.softDeletedAt
171
+ ? ((await ctx.runQuery(internal.souls.getVersionByIdInternal, {
172
+ versionId: soul.latestVersionId,
173
+ })) as Doc<'soulVersions'> | null)
174
+ : null
175
+
176
+ const oldReadmeText: string | null = previous
177
+ ? await readReadmeFromVersion(ctx, previous)
178
+ : null
179
+ const oldFiles = previous
180
+ ? previous.files.map((file) => ({ path: file.path, sha256: file.sha256 }))
181
+ : []
182
+ const fileDiff = previous ? summarizeFileDiff(oldFiles, args.files) : null
183
+
184
+ const ai = await generateWithOpenAI({
185
+ slug: args.slug,
186
+ version: args.version,
187
+ oldReadme: oldReadmeText,
188
+ nextReadme: args.readmeText,
189
+ fileDiff,
190
+ }).catch(() => null)
191
+
192
+ return (
193
+ ai ??
194
+ generateFallback({
195
+ slug: args.slug,
196
+ version: args.version,
197
+ oldReadme: oldReadmeText,
198
+ nextReadme: args.readmeText,
199
+ fileDiff,
200
+ })
201
+ )
202
+ } catch {
203
+ return '- Updated soul.'
204
+ }
205
+ }
206
+
207
+ export async function generateSoulChangelogPreview(
208
+ ctx: ActionCtx,
209
+ args: {
210
+ slug: string
211
+ version: string
212
+ readmeText: string
213
+ filePaths?: string[]
214
+ },
215
+ ): Promise<string> {
216
+ try {
217
+ const soul = (await ctx.runQuery(internal.souls.getSoulBySlugInternal, {
218
+ slug: args.slug,
219
+ })) as Doc<'souls'> | null
220
+ const previous: Doc<'soulVersions'> | null =
221
+ soul?.latestVersionId && !soul.softDeletedAt
222
+ ? ((await ctx.runQuery(internal.souls.getVersionByIdInternal, {
223
+ versionId: soul.latestVersionId,
224
+ })) as Doc<'soulVersions'> | null)
225
+ : null
226
+
227
+ const oldReadmeText: string | null = previous
228
+ ? await readReadmeFromVersion(ctx, previous)
229
+ : null
230
+ const oldPaths = previous ? previous.files.map((file) => file.path) : []
231
+ const nextPaths = args.filePaths ?? []
232
+ const diff = previous ? summarizeFileDiffFromPaths(oldPaths, nextPaths) : null
233
+
234
+ const ai = await generateWithOpenAI({
235
+ slug: args.slug,
236
+ version: args.version,
237
+ oldReadme: oldReadmeText,
238
+ nextReadme: args.readmeText,
239
+ fileDiff: diff,
240
+ }).catch(() => null)
241
+
242
+ return (
243
+ ai ??
244
+ generateFallback({
245
+ slug: args.slug,
246
+ version: args.version,
247
+ oldReadme: oldReadmeText,
248
+ nextReadme: args.readmeText,
249
+ fileDiff: diff,
250
+ })
251
+ )
252
+ } catch {
253
+ return '- Updated soul.'
254
+ }
255
+ }
256
+
257
+ async function readReadmeFromVersion(ctx: ActionCtx, version: Doc<'soulVersions'>) {
258
+ const file = version.files.find((entry) => entry.path.toLowerCase() === 'soul.md')
259
+ if (!file) return null
260
+ const blob = await ctx.storage.get(file.storageId)
261
+ if (!blob) return null
262
+ return blob.text()
263
+ }
264
+
265
+ function summarizeFileDiffFromPaths(oldPaths: string[], nextPaths: string[]) {
266
+ const oldFiles = oldPaths.map((path) => ({ path }))
267
+ const nextFiles = nextPaths.map((path) => ({ path }))
268
+ return summarizeFileDiff(oldFiles, nextFiles)
269
+ }
270
+
271
+ export const __test = {
272
+ summarizeFileDiff,
273
+ }
@@ -0,0 +1,236 @@
1
+ import { ConvexError } from 'convex/values'
2
+ import semver from 'semver'
3
+ import { internal } from '../_generated/api'
4
+ import type { Doc, Id } from '../_generated/dataModel'
5
+ import type { ActionCtx } from '../_generated/server'
6
+ import { generateEmbedding } from './embeddings'
7
+ import {
8
+ buildEmbeddingText,
9
+ getFrontmatterMetadata,
10
+ getFrontmatterValue,
11
+ hashSkillFiles,
12
+ isTextFile,
13
+ parseFrontmatter,
14
+ sanitizePath,
15
+ } from './skills'
16
+ import { generateSoulChangelogForPublish } from './soulChangelog'
17
+
18
+ const MAX_TOTAL_BYTES = 50 * 1024 * 1024
19
+
20
+ const MAX_SUMMARY_LENGTH = 160
21
+
22
+ function deriveSoulSummary(readmeText: string) {
23
+ const lines = readmeText.split(/\r?\n/)
24
+ let inFrontmatter = false
25
+ for (const raw of lines) {
26
+ const trimmed = raw.trim()
27
+ if (!trimmed) continue
28
+ if (!inFrontmatter && trimmed === '---') {
29
+ inFrontmatter = true
30
+ continue
31
+ }
32
+ if (inFrontmatter) {
33
+ if (trimmed === '---') {
34
+ inFrontmatter = false
35
+ }
36
+ continue
37
+ }
38
+ const cleaned = trimmed.replace(/^#+\s*/, '')
39
+ if (!cleaned) continue
40
+ if (cleaned.length > MAX_SUMMARY_LENGTH) {
41
+ return `${cleaned.slice(0, MAX_SUMMARY_LENGTH - 3).trimEnd()}...`
42
+ }
43
+ return cleaned
44
+ }
45
+ return undefined
46
+ }
47
+
48
+ export type PublishResult = {
49
+ soulId: Id<'souls'>
50
+ versionId: Id<'soulVersions'>
51
+ embeddingId: Id<'soulEmbeddings'>
52
+ }
53
+
54
+ export type PublishVersionArgs = {
55
+ slug: string
56
+ displayName: string
57
+ version: string
58
+ changelog: string
59
+ tags?: string[]
60
+ source?: {
61
+ kind: 'github'
62
+ url: string
63
+ repo: string
64
+ ref: string
65
+ commit: string
66
+ path: string
67
+ importedAt: number
68
+ }
69
+ files: Array<{
70
+ path: string
71
+ size: number
72
+ storageId: Id<'_storage'>
73
+ sha256: string
74
+ contentType?: string
75
+ }>
76
+ }
77
+
78
+ export async function publishSoulVersionForUser(
79
+ ctx: ActionCtx,
80
+ userId: Id<'users'>,
81
+ args: PublishVersionArgs,
82
+ ): Promise<PublishResult> {
83
+ const version = args.version.trim()
84
+ const slug = args.slug.trim().toLowerCase()
85
+ const displayName = args.displayName.trim()
86
+ if (!slug || !displayName) throw new ConvexError('Slug and display name required')
87
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
88
+ throw new ConvexError('Slug must be lowercase and url-safe')
89
+ }
90
+ if (!semver.valid(version)) {
91
+ throw new ConvexError('Version must be valid semver')
92
+ }
93
+ const suppliedChangelog = args.changelog.trim()
94
+ const changelogSource = suppliedChangelog ? ('user' as const) : ('auto' as const)
95
+
96
+ const sanitizedFiles = args.files.map((file) => {
97
+ const path = sanitizePath(file.path)
98
+ if (!path) throw new ConvexError('Invalid file paths')
99
+ if (!isTextFile(path, file.contentType ?? undefined)) {
100
+ throw new ConvexError('Only text-based files are allowed')
101
+ }
102
+ return { ...file, path }
103
+ })
104
+
105
+ const totalBytes = sanitizedFiles.reduce((sum, file) => sum + file.size, 0)
106
+ if (totalBytes > MAX_TOTAL_BYTES) {
107
+ throw new ConvexError('Soul bundle exceeds 50MB limit')
108
+ }
109
+
110
+ const isSoulFile = (path: string) => path.toLowerCase() === 'soul.md'
111
+ const readmeFile = sanitizedFiles.find((file) => isSoulFile(file.path))
112
+ if (!readmeFile) throw new ConvexError('SOUL.md is required')
113
+
114
+ const nonSoulFiles = sanitizedFiles.filter((file) => !isSoulFile(file.path))
115
+ if (nonSoulFiles.length > 0) {
116
+ throw new ConvexError('Only SOUL.md is allowed for soul bundles')
117
+ }
118
+
119
+ const readmeText = await fetchText(ctx, readmeFile.storageId)
120
+ const frontmatter = parseFrontmatter(readmeText)
121
+ const summary = getFrontmatterValue(frontmatter, 'description') ?? deriveSoulSummary(readmeText)
122
+ const metadata = mergeSourceIntoMetadata(getFrontmatterMetadata(frontmatter), args.source)
123
+
124
+ const embeddingText = buildEmbeddingText({
125
+ frontmatter,
126
+ readme: readmeText,
127
+ otherFiles: [],
128
+ })
129
+
130
+ const fingerprint = await hashSkillFiles(
131
+ sanitizedFiles.map((file) => ({
132
+ path: file.path ?? '',
133
+ sha256: file.sha256,
134
+ })),
135
+ )
136
+
137
+ const changelogPromise =
138
+ changelogSource === 'user'
139
+ ? Promise.resolve(suppliedChangelog)
140
+ : generateSoulChangelogForPublish(ctx, {
141
+ slug,
142
+ version,
143
+ readmeText,
144
+ files: sanitizedFiles.map((file) => ({ path: file.path ?? '', sha256: file.sha256 })),
145
+ })
146
+
147
+ const embeddingPromise = generateEmbedding(embeddingText)
148
+
149
+ const [changelogText, embedding] = await Promise.all([
150
+ changelogPromise,
151
+ embeddingPromise.catch((error) => {
152
+ throw new ConvexError(formatEmbeddingError(error))
153
+ }),
154
+ ])
155
+
156
+ const publishResult = (await ctx.runMutation(internal.souls.insertVersion, {
157
+ userId,
158
+ slug,
159
+ displayName,
160
+ version,
161
+ changelog: changelogText,
162
+ changelogSource,
163
+ tags: args.tags?.map((tag) => tag.trim()).filter(Boolean),
164
+ fingerprint,
165
+ files: sanitizedFiles,
166
+ parsed: {
167
+ frontmatter,
168
+ metadata,
169
+ },
170
+ summary,
171
+ embedding,
172
+ })) as PublishResult
173
+
174
+ const owner = (await ctx.runQuery(internal.users.getByIdInternal, {
175
+ userId,
176
+ })) as Doc<'users'> | null
177
+ const ownerHandle = owner?.handle ?? owner?.name ?? userId
178
+
179
+ void ctx.scheduler
180
+ .runAfter(0, internal.githubSoulBackupsNode.backupSoulForPublishInternal, {
181
+ slug,
182
+ version,
183
+ displayName,
184
+ ownerHandle,
185
+ files: sanitizedFiles,
186
+ publishedAt: Date.now(),
187
+ })
188
+ .catch((error) => {
189
+ console.error('GitHub soul backup scheduling failed', error)
190
+ })
191
+
192
+ return publishResult
193
+ }
194
+
195
+ function mergeSourceIntoMetadata(metadata: unknown, source: PublishVersionArgs['source']) {
196
+ if (!source) return metadata === undefined ? undefined : metadata
197
+ const sourceValue = {
198
+ kind: source.kind,
199
+ url: source.url,
200
+ repo: source.repo,
201
+ ref: source.ref,
202
+ commit: source.commit,
203
+ path: source.path,
204
+ importedAt: source.importedAt,
205
+ }
206
+
207
+ if (!metadata) return { source: sourceValue }
208
+ if (typeof metadata !== 'object' || Array.isArray(metadata)) return { source: sourceValue }
209
+ return { ...(metadata as Record<string, unknown>), source: sourceValue }
210
+ }
211
+
212
+ export async function fetchText(
213
+ ctx: { storage: { get: (id: Id<'_storage'>) => Promise<Blob | null> } },
214
+ storageId: Id<'_storage'>,
215
+ ) {
216
+ const blob = await ctx.storage.get(storageId)
217
+ if (!blob) throw new Error('File missing in storage')
218
+ return blob.text()
219
+ }
220
+
221
+ function formatEmbeddingError(error: unknown) {
222
+ if (error instanceof Error) {
223
+ if (error.message.includes('OPENAI_API_KEY')) {
224
+ return 'OPENAI_API_KEY is not configured.'
225
+ }
226
+ if (error.message.startsWith('Embedding failed')) {
227
+ return error.message
228
+ }
229
+ }
230
+ return 'Embedding failed. Please try again.'
231
+ }
232
+
233
+ export const __test = {
234
+ getSummary: (frontmatter: Record<string, unknown>) =>
235
+ getFrontmatterValue(frontmatter, 'description'),
236
+ }
@@ -0,0 +1,33 @@
1
+ /* @vitest-environment node */
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+ import { __test, generateToken, hashToken } from './tokens'
5
+
6
+ describe('tokens', () => {
7
+ it('hashToken returns sha256 hex', async () => {
8
+ await expect(hashToken('test')).resolves.toBe(
9
+ '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
10
+ )
11
+ })
12
+
13
+ it('generateToken returns token + prefix', () => {
14
+ const { token, prefix } = generateToken()
15
+ expect(token).toMatch(/^clh_[A-Za-z0-9_-]+$/)
16
+ expect(prefix).toBe(token.slice(0, 12))
17
+ })
18
+
19
+ it('toHex encodes bytes', () => {
20
+ expect(__test.toHex(new Uint8Array([0, 15, 255]))).toBe('000fff')
21
+ })
22
+
23
+ it('toBase64 encodes 1/2/3-byte tails', () => {
24
+ expect(__test.toBase64(new Uint8Array([0xff]))).toBe('/w==')
25
+ expect(__test.toBase64(new Uint8Array([0xff, 0xee]))).toBe('/+4=')
26
+ expect(__test.toBase64(new Uint8Array([0xff, 0xee, 0xdd]))).toBe('/+7d')
27
+ })
28
+
29
+ it('toBase64Url replaces alphabet and strips padding', () => {
30
+ expect(__test.toBase64Url(new Uint8Array([0xff]))).toBe('_w')
31
+ expect(__test.toBase64Url(new Uint8Array([0xfa, 0x00, 0x00]))).toBe('-gAA')
32
+ })
33
+ })
@@ -0,0 +1,51 @@
1
+ const encoder = new TextEncoder()
2
+
3
+ export const API_TOKEN_PREFIX = 'clh_'
4
+
5
+ export async function hashToken(token: string) {
6
+ const bytes = encoder.encode(token)
7
+ const digest = await crypto.subtle.digest('SHA-256', bytes)
8
+ return toHex(new Uint8Array(digest))
9
+ }
10
+
11
+ export function generateToken() {
12
+ const bytes = new Uint8Array(32)
13
+ crypto.getRandomValues(bytes)
14
+ const token = `${API_TOKEN_PREFIX}${toBase64Url(bytes)}`
15
+ const prefix = token.slice(0, 12)
16
+ return { token, prefix }
17
+ }
18
+
19
+ function toHex(bytes: Uint8Array) {
20
+ let out = ''
21
+ for (const byte of bytes) out += byte.toString(16).padStart(2, '0')
22
+ return out
23
+ }
24
+
25
+ function toBase64Url(bytes: Uint8Array) {
26
+ const base64 = toBase64(bytes)
27
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
28
+ }
29
+
30
+ const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
31
+
32
+ function toBase64(bytes: Uint8Array) {
33
+ let output = ''
34
+ for (let i = 0; i < bytes.length; i += 3) {
35
+ const a = bytes[i] ?? 0
36
+ const b = bytes[i + 1] ?? 0
37
+ const c = bytes[i + 2] ?? 0
38
+ const triple = (a << 16) | (b << 8) | c
39
+ output += BASE64_ALPHABET[(triple >> 18) & 63]
40
+ output += BASE64_ALPHABET[(triple >> 12) & 63]
41
+ output += i + 1 < bytes.length ? BASE64_ALPHABET[(triple >> 6) & 63] : '='
42
+ output += i + 2 < bytes.length ? BASE64_ALPHABET[triple & 63] : '='
43
+ }
44
+ return output
45
+ }
46
+
47
+ export const __test = {
48
+ toHex,
49
+ toBase64,
50
+ toBase64Url,
51
+ }
@@ -0,0 +1,91 @@
1
+ /* @vitest-environment node */
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+ import { buildDiscordPayload, buildSkillUrl, getWebhookConfig, shouldSendWebhook } from './webhooks'
4
+
5
+ const originalEnv = { ...process.env }
6
+
7
+ afterEach(() => {
8
+ process.env = { ...originalEnv }
9
+ })
10
+
11
+ describe('webhook config', () => {
12
+ it('parses highlighted-only flag', () => {
13
+ process.env.DISCORD_WEBHOOK_URL = 'https://example.com'
14
+ process.env.DISCORD_WEBHOOK_HIGHLIGHTED_ONLY = 'true'
15
+ const config = getWebhookConfig()
16
+ expect(config.highlightedOnly).toBe(true)
17
+ })
18
+
19
+ it('defaults site url when missing', () => {
20
+ delete process.env.SITE_URL
21
+ process.env.DISCORD_WEBHOOK_URL = 'https://example.com'
22
+ const config = getWebhookConfig()
23
+ expect(config.siteUrl).toBe('https://pilothub.com')
24
+ })
25
+ })
26
+
27
+ describe('webhook filtering', () => {
28
+ it('skips when url missing', () => {
29
+ const config = getWebhookConfig({} as NodeJS.ProcessEnv)
30
+ expect(shouldSendWebhook('skill.publish', { slug: 'demo', displayName: 'Demo' }, config)).toBe(
31
+ false,
32
+ )
33
+ })
34
+
35
+ it('filters non-highlighted when highlighted-only', () => {
36
+ const config = {
37
+ url: 'https://example.com',
38
+ highlightedOnly: true,
39
+ siteUrl: 'https://pilothub.com',
40
+ }
41
+ const allowed = shouldSendWebhook(
42
+ 'skill.publish',
43
+ { slug: 'demo', displayName: 'Demo', highlighted: false },
44
+ config,
45
+ )
46
+ expect(allowed).toBe(false)
47
+ })
48
+
49
+ it('allows highlighted event when highlighted-only', () => {
50
+ const config = {
51
+ url: 'https://example.com',
52
+ highlightedOnly: true,
53
+ siteUrl: 'https://pilothub.com',
54
+ }
55
+ const allowed = shouldSendWebhook(
56
+ 'skill.highlighted',
57
+ { slug: 'demo', displayName: 'Demo', highlighted: true },
58
+ config,
59
+ )
60
+ expect(allowed).toBe(true)
61
+ })
62
+ })
63
+
64
+ describe('payload building', () => {
65
+ it('builds canonical url with owner', () => {
66
+ const url = buildSkillUrl(
67
+ { slug: 'beeper', displayName: 'Beeper', ownerHandle: 'KrauseFx' },
68
+ 'https://pilothub.com',
69
+ )
70
+ expect(url).toBe('https://pilothub.com/KrauseFx/beeper')
71
+ })
72
+
73
+ it('builds a publish embed', () => {
74
+ const payload = buildDiscordPayload(
75
+ 'skill.publish',
76
+ {
77
+ slug: 'demo',
78
+ displayName: 'Demo Skill',
79
+ summary: 'Nice skill',
80
+ version: '1.2.3',
81
+ ownerHandle: 'steipete',
82
+ tags: ['latest', 'discord'],
83
+ },
84
+ { url: 'https://example.com', highlightedOnly: false, siteUrl: 'https://pilothub.com' },
85
+ )
86
+ const embed = payload.embeds[0]
87
+ expect(embed.title).toBe('Demo Skill')
88
+ expect(embed.description).toBe('Nice skill')
89
+ expect(embed.fields[0].value).toBe('v1.2.3')
90
+ })
91
+ })