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,46 @@
1
+ /* @vitest-environment node */
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+ import { __test, matchesExactTokens, tokenize } from './searchText'
5
+
6
+ describe('searchText', () => {
7
+ it('tokenize lowercases and splits on punctuation', () => {
8
+ expect(tokenize('Minimax Usage /minimax-usage')).toEqual([
9
+ 'minimax',
10
+ 'usage',
11
+ 'minimax',
12
+ 'usage',
13
+ ])
14
+ })
15
+
16
+ it('matchesExactTokens requires at least one query token to prefix-match', () => {
17
+ const queryTokens = tokenize('Remind Me')
18
+ expect(matchesExactTokens(queryTokens, ['Remind Me', '/remind-me', 'Short summary'])).toBe(true)
19
+ // "Reminder" starts with "remind", so it matches with prefix matching
20
+ expect(matchesExactTokens(queryTokens, ['Reminder tool', '/reminder', 'Short summary'])).toBe(
21
+ true,
22
+ )
23
+ // Matches because "remind" token is present
24
+ expect(matchesExactTokens(queryTokens, ['Remind tool', '/remind', 'Short summary'])).toBe(true)
25
+ // No matching tokens at all
26
+ expect(matchesExactTokens(queryTokens, ['Other tool', '/other', 'Short summary'])).toBe(false)
27
+ })
28
+
29
+ it('matchesExactTokens supports prefix matching for partial queries', () => {
30
+ // "go" should match "gohome" because "gohome" starts with "go"
31
+ expect(matchesExactTokens(['go'], ['GoHome', '/gohome', 'Navigate home'])).toBe(true)
32
+ // "pad" should match "padel"
33
+ expect(matchesExactTokens(['pad'], ['Padel', '/padel', 'Tennis-like sport'])).toBe(true)
34
+ // "xyz" should not match anything
35
+ expect(matchesExactTokens(['xyz'], ['GoHome', '/gohome', 'Navigate home'])).toBe(false)
36
+ })
37
+
38
+ it('matchesExactTokens ignores empty inputs', () => {
39
+ expect(matchesExactTokens([], ['text'])).toBe(false)
40
+ expect(matchesExactTokens(['token'], [' ', null, undefined])).toBe(false)
41
+ })
42
+
43
+ it('normalize uses lowercase', () => {
44
+ expect(__test.normalize('AbC')).toBe('abc')
45
+ })
46
+ })
@@ -0,0 +1,27 @@
1
+ const WORD_RE = /[a-z0-9]+/g
2
+
3
+ function normalize(value: string) {
4
+ return value.toLowerCase()
5
+ }
6
+
7
+ export function tokenize(value: string): string[] {
8
+ if (!value) return []
9
+ return normalize(value).match(WORD_RE) ?? []
10
+ }
11
+
12
+ export function matchesExactTokens(
13
+ queryTokens: string[],
14
+ parts: Array<string | null | undefined>,
15
+ ): boolean {
16
+ if (queryTokens.length === 0) return false
17
+ const text = parts.filter((part) => Boolean(part?.trim())).join(' ')
18
+ if (!text) return false
19
+ const textTokens = tokenize(text)
20
+ if (textTokens.length === 0) return false
21
+ // Require at least one token to prefix-match, allowing vector similarity to determine relevance
22
+ return queryTokens.some((queryToken) =>
23
+ textTokens.some((textToken) => textToken.includes(queryToken)),
24
+ )
25
+ }
26
+
27
+ export const __test = { normalize, tokenize, matchesExactTokens }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildSkillSummaryBackfillPatch } from './skillBackfill'
3
+
4
+ describe('skill backfill', () => {
5
+ it('produces summary + parsed patch from block scalar', () => {
6
+ const patch = buildSkillSummaryBackfillPatch({
7
+ readmeText: `---\ndescription: >\n Hello\n world.\n---\nBody`,
8
+ currentSummary: '>',
9
+ currentParsed: { frontmatter: { description: '>' } },
10
+ })
11
+ expect(patch.summary).toBe('Hello world.')
12
+ expect(patch.parsed?.frontmatter.description).toBe('Hello world.')
13
+ })
14
+
15
+ it('does not set summary when description is not a string', () => {
16
+ const patch = buildSkillSummaryBackfillPatch({
17
+ readmeText: `---\ndescription:\n - a\n---\nBody`,
18
+ currentSummary: 'Old',
19
+ currentParsed: { frontmatter: {} },
20
+ })
21
+ expect(patch.summary).toBeUndefined()
22
+ expect(patch.parsed?.frontmatter.description).toEqual(['a'])
23
+ })
24
+
25
+ it('keeps legacy summary when unchanged and still updates parsed', () => {
26
+ const patch = buildSkillSummaryBackfillPatch({
27
+ readmeText: `---\ndescription: Hello\n---\nBody`,
28
+ currentSummary: 'Hello',
29
+ currentParsed: { frontmatter: { description: 'nope' } },
30
+ })
31
+ expect(patch.summary).toBeUndefined()
32
+ expect(patch.parsed?.frontmatter.description).toBe('Hello')
33
+ })
34
+ })
@@ -0,0 +1,67 @@
1
+ import {
2
+ getFrontmatterMetadata,
3
+ getFrontmatterValue,
4
+ type ParsedSkillFrontmatter,
5
+ parseFrontmatter,
6
+ parsePilotbotMetadata,
7
+ } from './skills'
8
+
9
+ export type ParsedSkillData = {
10
+ frontmatter: ParsedSkillFrontmatter
11
+ metadata?: unknown
12
+ pilotbot?: unknown
13
+ }
14
+
15
+ export type SkillSummaryBackfillPatch = {
16
+ summary?: string
17
+ parsed?: ParsedSkillData
18
+ }
19
+
20
+ export function buildSkillSummaryBackfillPatch(args: {
21
+ readmeText: string
22
+ currentSummary?: string
23
+ currentParsed?: ParsedSkillData
24
+ }): SkillSummaryBackfillPatch {
25
+ const frontmatter = parseFrontmatter(args.readmeText)
26
+ const summary = getFrontmatterValue(frontmatter, 'description') ?? undefined
27
+ const metadata = getFrontmatterMetadata(frontmatter)
28
+ const pilotbot = parsePilotbotMetadata(frontmatter)
29
+ const parsed: ParsedSkillData = { frontmatter, metadata, pilotbot }
30
+
31
+ const patch: SkillSummaryBackfillPatch = {}
32
+ if (summary && summary !== args.currentSummary) {
33
+ patch.summary = summary
34
+ }
35
+ if (!deepEqual(parsed, args.currentParsed)) {
36
+ patch.parsed = parsed
37
+ }
38
+ return patch
39
+ }
40
+
41
+ function deepEqual(a: unknown, b: unknown): boolean {
42
+ if (a === b) return true
43
+ if (!a || !b) return a === b
44
+ if (typeof a !== typeof b) return false
45
+ if (Array.isArray(a) || Array.isArray(b)) {
46
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
47
+ if (a.length !== b.length) return false
48
+ for (let i = 0; i < a.length; i++) {
49
+ if (!deepEqual(a[i], b[i])) return false
50
+ }
51
+ return true
52
+ }
53
+ if (typeof a === 'object' && typeof b === 'object') {
54
+ const aObj = a as Record<string, unknown>
55
+ const bObj = b as Record<string, unknown>
56
+ const aKeys = Object.keys(aObj).sort()
57
+ const bKeys = Object.keys(bObj).sort()
58
+ if (aKeys.length !== bKeys.length) return false
59
+ for (let i = 0; i < aKeys.length; i++) {
60
+ if (aKeys[i] !== bKeys[i]) return false
61
+ const key = aKeys[i] as string
62
+ if (!deepEqual(aObj[key], bObj[key])) return false
63
+ }
64
+ return true
65
+ }
66
+ return false
67
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { __test } from './skillPublish'
3
+
4
+ describe('skillPublish', () => {
5
+ it('merges github source into metadata', () => {
6
+ const merged = __test.mergeSourceIntoMetadata(
7
+ { pilotbot: { emoji: 'x' } },
8
+ {
9
+ kind: 'github',
10
+ url: 'https://github.com/a/b',
11
+ repo: 'a/b',
12
+ ref: 'main',
13
+ commit: '0123456789012345678901234567890123456789',
14
+ path: 'skills/demo',
15
+ importedAt: 123,
16
+ },
17
+ )
18
+ expect((merged as Record<string, unknown>).pilotbot).toEqual({ emoji: 'x' })
19
+ const source = (merged as Record<string, unknown>).source
20
+ expect(source).toEqual(
21
+ expect.objectContaining({
22
+ kind: 'github',
23
+ repo: 'a/b',
24
+ path: 'skills/demo',
25
+ }),
26
+ )
27
+ })
28
+ })
@@ -0,0 +1,284 @@
1
+ import { ConvexError } from 'convex/values'
2
+ import semver from 'semver'
3
+ import { api, internal } from '../_generated/api'
4
+ import type { Doc, Id } from '../_generated/dataModel'
5
+ import type { ActionCtx, MutationCtx } from '../_generated/server'
6
+ import { getSkillBadgeMap, isSkillHighlighted } from './badges'
7
+ import { generateChangelogForPublish } from './changelog'
8
+ import { generateEmbedding } from './embeddings'
9
+ import type { PublicUser } from './public'
10
+ import {
11
+ buildEmbeddingText,
12
+ getFrontmatterMetadata,
13
+ hashSkillFiles,
14
+ isTextFile,
15
+ parseFrontmatter,
16
+ parsePilotbotMetadata,
17
+ sanitizePath,
18
+ } from './skills'
19
+ import type { WebhookSkillPayload } from './webhooks'
20
+
21
+ const MAX_TOTAL_BYTES = 50 * 1024 * 1024
22
+ const MAX_FILES_FOR_EMBEDDING = 40
23
+
24
+ export type PublishResult = {
25
+ skillId: Id<'skills'>
26
+ versionId: Id<'skillVersions'>
27
+ embeddingId: Id<'skillEmbeddings'>
28
+ }
29
+
30
+ export type PublishVersionArgs = {
31
+ slug: string
32
+ displayName: string
33
+ version: string
34
+ changelog: string
35
+ tags?: string[]
36
+ forkOf?: { slug: string; version?: string }
37
+ source?: {
38
+ kind: 'github'
39
+ url: string
40
+ repo: string
41
+ ref: string
42
+ commit: string
43
+ path: string
44
+ importedAt: number
45
+ }
46
+ files: Array<{
47
+ path: string
48
+ size: number
49
+ storageId: Id<'_storage'>
50
+ sha256: string
51
+ contentType?: string
52
+ }>
53
+ }
54
+
55
+ export async function publishVersionForUser(
56
+ ctx: ActionCtx,
57
+ userId: Id<'users'>,
58
+ args: PublishVersionArgs,
59
+ ): Promise<PublishResult> {
60
+ const version = args.version.trim()
61
+ const slug = args.slug.trim().toLowerCase()
62
+ const displayName = args.displayName.trim()
63
+ if (!slug || !displayName) throw new ConvexError('Slug and display name required')
64
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
65
+ throw new ConvexError('Slug must be lowercase and url-safe')
66
+ }
67
+ if (!semver.valid(version)) {
68
+ throw new ConvexError('Version must be valid semver')
69
+ }
70
+ const suppliedChangelog = args.changelog.trim()
71
+ const changelogSource = suppliedChangelog ? ('user' as const) : ('auto' as const)
72
+
73
+ const sanitizedFiles = args.files.map((file) => ({
74
+ ...file,
75
+ path: sanitizePath(file.path),
76
+ }))
77
+ if (sanitizedFiles.some((file) => !file.path)) {
78
+ throw new ConvexError('Invalid file paths')
79
+ }
80
+ const safeFiles = sanitizedFiles.map((file) => ({
81
+ ...file,
82
+ path: file.path as string,
83
+ }))
84
+ if (safeFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
85
+ throw new ConvexError('Only text-based files are allowed')
86
+ }
87
+
88
+ const totalBytes = safeFiles.reduce((sum, file) => sum + file.size, 0)
89
+ if (totalBytes > MAX_TOTAL_BYTES) {
90
+ throw new ConvexError('Skill bundle exceeds 50MB limit')
91
+ }
92
+
93
+ const readmeFile = safeFiles.find(
94
+ (file) => file.path?.toLowerCase() === 'skill.md' || file.path?.toLowerCase() === 'skills.md',
95
+ )
96
+ if (!readmeFile) throw new ConvexError('SKILL.md is required')
97
+
98
+ const readmeText = await fetchText(ctx, readmeFile.storageId)
99
+ const frontmatter = parseFrontmatter(readmeText)
100
+ const pilotbot = parsePilotbotMetadata(frontmatter)
101
+ const metadata = mergeSourceIntoMetadata(getFrontmatterMetadata(frontmatter), args.source)
102
+
103
+ const otherFiles = [] as Array<{ path: string; content: string }>
104
+ for (const file of safeFiles) {
105
+ if (!file.path || file.path.toLowerCase().endsWith('.md')) continue
106
+ if (!isTextFile(file.path, file.contentType ?? undefined)) continue
107
+ const content = await fetchText(ctx, file.storageId)
108
+ otherFiles.push({ path: file.path, content })
109
+ if (otherFiles.length >= MAX_FILES_FOR_EMBEDDING) break
110
+ }
111
+
112
+ const embeddingText = buildEmbeddingText({
113
+ frontmatter,
114
+ readme: readmeText,
115
+ otherFiles,
116
+ })
117
+
118
+ const fingerprintPromise = hashSkillFiles(
119
+ safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
120
+ )
121
+
122
+ const changelogPromise =
123
+ changelogSource === 'user'
124
+ ? Promise.resolve(suppliedChangelog)
125
+ : generateChangelogForPublish(ctx, {
126
+ slug,
127
+ version,
128
+ readmeText,
129
+ files: safeFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
130
+ })
131
+
132
+ const embeddingPromise = generateEmbedding(embeddingText)
133
+
134
+ const [fingerprint, changelogText, embedding] = await Promise.all([
135
+ fingerprintPromise,
136
+ changelogPromise,
137
+ embeddingPromise.catch((error) => {
138
+ throw new ConvexError(formatEmbeddingError(error))
139
+ }),
140
+ ])
141
+
142
+ const publishResult = (await ctx.runMutation(internal.skills.insertVersion, {
143
+ userId,
144
+ slug,
145
+ displayName,
146
+ version,
147
+ changelog: changelogText,
148
+ changelogSource,
149
+ tags: args.tags?.map((tag) => tag.trim()).filter(Boolean),
150
+ fingerprint,
151
+ forkOf: args.forkOf
152
+ ? {
153
+ slug: args.forkOf.slug.trim().toLowerCase(),
154
+ version: args.forkOf.version?.trim() || undefined,
155
+ }
156
+ : undefined,
157
+ files: safeFiles.map((file) => ({
158
+ ...file,
159
+ path: file.path,
160
+ })),
161
+ parsed: {
162
+ frontmatter,
163
+ metadata,
164
+ pilotbot,
165
+ },
166
+ embedding,
167
+ })) as PublishResult
168
+
169
+ const owner = (await ctx.runQuery(internal.users.getByIdInternal, {
170
+ userId,
171
+ })) as Doc<'users'> | null
172
+ const ownerHandle = owner?.handle ?? owner?.displayName ?? owner?.name ?? 'unknown'
173
+
174
+ void ctx.scheduler
175
+ .runAfter(0, internal.githubBackupsNode.backupSkillForPublishInternal, {
176
+ slug,
177
+ version,
178
+ displayName,
179
+ ownerHandle,
180
+ files: safeFiles,
181
+ publishedAt: Date.now(),
182
+ })
183
+ .catch((error) => {
184
+ console.error('GitHub backup scheduling failed', error)
185
+ })
186
+
187
+ void schedulePublishWebhook(ctx, {
188
+ slug,
189
+ version,
190
+ displayName,
191
+ })
192
+
193
+ return publishResult
194
+ }
195
+
196
+ function mergeSourceIntoMetadata(metadata: unknown, source: PublishVersionArgs['source']) {
197
+ if (!source) return metadata === undefined ? undefined : metadata
198
+ const sourceValue = {
199
+ kind: source.kind,
200
+ url: source.url,
201
+ repo: source.repo,
202
+ ref: source.ref,
203
+ commit: source.commit,
204
+ path: source.path,
205
+ importedAt: source.importedAt,
206
+ }
207
+
208
+ if (!metadata) return { source: sourceValue }
209
+ if (typeof metadata !== 'object' || Array.isArray(metadata)) return { source: sourceValue }
210
+ return { ...(metadata as Record<string, unknown>), source: sourceValue }
211
+ }
212
+
213
+ export const __test = {
214
+ mergeSourceIntoMetadata,
215
+ }
216
+
217
+ export async function queueHighlightedWebhook(ctx: MutationCtx, skillId: Id<'skills'>) {
218
+ const skill = await ctx.db.get(skillId)
219
+ if (!skill) return
220
+ const owner = await ctx.db.get(skill.ownerUserId)
221
+ const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
222
+
223
+ const badges = await getSkillBadgeMap(ctx, skillId)
224
+ const payload: WebhookSkillPayload = {
225
+ slug: skill.slug,
226
+ displayName: skill.displayName,
227
+ summary: skill.summary ?? undefined,
228
+ version: latestVersion?.version ?? undefined,
229
+ ownerHandle: owner?.handle ?? owner?.name ?? undefined,
230
+ highlighted: isSkillHighlighted({ badges }),
231
+ tags: Object.keys(skill.tags ?? {}),
232
+ }
233
+
234
+ await ctx.scheduler.runAfter(0, internal.webhooks.sendDiscordWebhook, {
235
+ event: 'skill.highlighted',
236
+ skill: payload,
237
+ })
238
+ }
239
+
240
+ export async function fetchText(
241
+ ctx: { storage: { get: (id: Id<'_storage'>) => Promise<Blob | null> } },
242
+ storageId: Id<'_storage'>,
243
+ ) {
244
+ const blob = await ctx.storage.get(storageId)
245
+ if (!blob) throw new Error('File missing in storage')
246
+ return blob.text()
247
+ }
248
+
249
+ function formatEmbeddingError(error: unknown) {
250
+ if (error instanceof Error) {
251
+ if (error.message.includes('OPENAI_API_KEY')) {
252
+ return 'OPENAI_API_KEY is not configured.'
253
+ }
254
+ if (error.message.startsWith('Embedding failed')) {
255
+ return error.message
256
+ }
257
+ }
258
+ return 'Embedding failed. Please try again.'
259
+ }
260
+
261
+ async function schedulePublishWebhook(
262
+ ctx: ActionCtx,
263
+ params: { slug: string; version: string; displayName: string },
264
+ ) {
265
+ const result = (await ctx.runQuery(api.skills.getBySlug, {
266
+ slug: params.slug,
267
+ })) as { skill: Doc<'skills'>; owner: PublicUser | null } | null
268
+ if (!result?.skill) return
269
+
270
+ const payload: WebhookSkillPayload = {
271
+ slug: result.skill.slug,
272
+ displayName: result.skill.displayName || params.displayName,
273
+ summary: result.skill.summary ?? undefined,
274
+ version: params.version,
275
+ ownerHandle: result.owner?.handle ?? result.owner?.name ?? undefined,
276
+ highlighted: isSkillHighlighted(result.skill),
277
+ tags: Object.keys(result.skill.tags ?? {}),
278
+ }
279
+
280
+ await ctx.scheduler.runAfter(0, internal.webhooks.sendDiscordWebhook, {
281
+ event: 'skill.publish',
282
+ skill: payload,
283
+ })
284
+ }
@@ -0,0 +1,80 @@
1
+ import type { Doc, Id } from '../_generated/dataModel'
2
+ import type { MutationCtx } from '../_generated/server'
3
+ import { toDayKey } from './leaderboards'
4
+
5
+ type SkillStatDeltas = {
6
+ downloads?: number
7
+ stars?: number
8
+ installsCurrent?: number
9
+ installsAllTime?: number
10
+ }
11
+
12
+ export function applySkillStatDeltas(skill: Doc<'skills'>, deltas: SkillStatDeltas) {
13
+ const currentDownloads =
14
+ typeof skill.statsDownloads === 'number' ? skill.statsDownloads : skill.stats.downloads
15
+ const currentStars = typeof skill.statsStars === 'number' ? skill.statsStars : skill.stats.stars
16
+ const currentInstallsCurrent =
17
+ typeof skill.statsInstallsCurrent === 'number'
18
+ ? skill.statsInstallsCurrent
19
+ : (skill.stats.installsCurrent ?? 0)
20
+ const currentInstallsAllTime =
21
+ typeof skill.statsInstallsAllTime === 'number'
22
+ ? skill.statsInstallsAllTime
23
+ : (skill.stats.installsAllTime ?? 0)
24
+
25
+ const nextDownloads = Math.max(0, currentDownloads + (deltas.downloads ?? 0))
26
+ const nextStars = Math.max(0, currentStars + (deltas.stars ?? 0))
27
+ const nextInstallsCurrent = Math.max(0, currentInstallsCurrent + (deltas.installsCurrent ?? 0))
28
+ const nextInstallsAllTime = Math.max(0, currentInstallsAllTime + (deltas.installsAllTime ?? 0))
29
+
30
+ return {
31
+ statsDownloads: nextDownloads,
32
+ statsStars: nextStars,
33
+ statsInstallsCurrent: nextInstallsCurrent,
34
+ statsInstallsAllTime: nextInstallsAllTime,
35
+ stats: {
36
+ ...skill.stats,
37
+ downloads: nextDownloads,
38
+ stars: nextStars,
39
+ installsCurrent: nextInstallsCurrent,
40
+ installsAllTime: nextInstallsAllTime,
41
+ },
42
+ }
43
+ }
44
+
45
+ export async function bumpDailySkillStats(
46
+ ctx: MutationCtx,
47
+ params: {
48
+ skillId: Id<'skills'>
49
+ now: number
50
+ downloads?: number
51
+ installs?: number
52
+ },
53
+ ) {
54
+ const downloads = params.downloads ?? 0
55
+ const installs = params.installs ?? 0
56
+ if (downloads === 0 && installs === 0) return
57
+
58
+ const day = toDayKey(params.now)
59
+ const existing = await ctx.db
60
+ .query('skillDailyStats')
61
+ .withIndex('by_skill_day', (q) => q.eq('skillId', params.skillId).eq('day', day))
62
+ .unique()
63
+
64
+ if (existing) {
65
+ await ctx.db.patch(existing._id, {
66
+ downloads: Math.max(0, existing.downloads + downloads),
67
+ installs: Math.max(0, existing.installs + installs),
68
+ updatedAt: params.now,
69
+ })
70
+ return
71
+ }
72
+
73
+ await ctx.db.insert('skillDailyStats', {
74
+ skillId: params.skillId,
75
+ day,
76
+ downloads: Math.max(0, downloads),
77
+ installs: Math.max(0, installs),
78
+ updatedAt: params.now,
79
+ })
80
+ }