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,170 @@
1
+ import { v } from 'convex/values'
2
+ import { internal } from './_generated/api'
3
+ import type { Doc, Id } from './_generated/dataModel'
4
+ import { action, internalMutation, internalQuery } from './_generated/server'
5
+ import { assertRole, requireUserFromAction } from './lib/access'
6
+
7
+ const DEFAULT_BATCH_SIZE = 50
8
+ const MAX_BATCH_SIZE = 200
9
+ const SYNC_STATE_KEY = 'default'
10
+
11
+ type BackupPageItem =
12
+ | {
13
+ kind: 'ok'
14
+ skillId: Id<'skills'>
15
+ versionId: Id<'skillVersions'>
16
+ slug: string
17
+ displayName: string
18
+ version: string
19
+ ownerHandle: string
20
+ files: Doc<'skillVersions'>['files']
21
+ publishedAt: number
22
+ }
23
+ | { kind: 'missingLatestVersion'; skillId: Id<'skills'> }
24
+ | { kind: 'missingVersionDoc'; skillId: Id<'skills'>; versionId: Id<'skillVersions'> }
25
+ | { kind: 'missingOwner'; skillId: Id<'skills'>; ownerUserId: Id<'users'> }
26
+
27
+ type BackupPageResult = {
28
+ items: BackupPageItem[]
29
+ cursor: string | null
30
+ isDone: boolean
31
+ }
32
+
33
+ type BackupSyncState = {
34
+ cursor: string | null
35
+ }
36
+
37
+ export type SyncGitHubBackupsResult = {
38
+ stats: {
39
+ skillsScanned: number
40
+ skillsSkipped: number
41
+ skillsBackedUp: number
42
+ skillsMissingVersion: number
43
+ skillsMissingOwner: number
44
+ errors: number
45
+ }
46
+ cursor: string | null
47
+ isDone: boolean
48
+ }
49
+
50
+ export const getGitHubBackupPageInternal = internalQuery({
51
+ args: {
52
+ cursor: v.optional(v.string()),
53
+ batchSize: v.optional(v.number()),
54
+ },
55
+ handler: async (ctx, args): Promise<BackupPageResult> => {
56
+ const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
57
+ const { page, isDone, continueCursor } = await ctx.db
58
+ .query('skills')
59
+ .order('asc')
60
+ .paginate({ cursor: args.cursor ?? null, numItems: batchSize })
61
+
62
+ const items: BackupPageItem[] = []
63
+ for (const skill of page) {
64
+ if (skill.softDeletedAt) continue
65
+ if (!skill.latestVersionId) {
66
+ items.push({ kind: 'missingLatestVersion', skillId: skill._id })
67
+ continue
68
+ }
69
+
70
+ const version = await ctx.db.get(skill.latestVersionId)
71
+ if (!version) {
72
+ items.push({
73
+ kind: 'missingVersionDoc',
74
+ skillId: skill._id,
75
+ versionId: skill.latestVersionId,
76
+ })
77
+ continue
78
+ }
79
+
80
+ const owner = await ctx.db.get(skill.ownerUserId)
81
+ if (!owner || owner.deletedAt) {
82
+ items.push({ kind: 'missingOwner', skillId: skill._id, ownerUserId: skill.ownerUserId })
83
+ continue
84
+ }
85
+
86
+ items.push({
87
+ kind: 'ok',
88
+ skillId: skill._id,
89
+ versionId: version._id,
90
+ slug: skill.slug,
91
+ displayName: skill.displayName,
92
+ version: version.version,
93
+ ownerHandle: owner.handle ?? owner._id,
94
+ files: version.files,
95
+ publishedAt: version.createdAt,
96
+ })
97
+ }
98
+
99
+ return { items, cursor: continueCursor, isDone }
100
+ },
101
+ })
102
+
103
+ export const getGitHubBackupSyncStateInternal = internalQuery({
104
+ args: {},
105
+ handler: async (ctx): Promise<BackupSyncState> => {
106
+ const state = await ctx.db
107
+ .query('githubBackupSyncState')
108
+ .withIndex('by_key', (q) => q.eq('key', SYNC_STATE_KEY))
109
+ .unique()
110
+ return { cursor: state?.cursor ?? null }
111
+ },
112
+ })
113
+
114
+ export const setGitHubBackupSyncStateInternal = internalMutation({
115
+ args: {
116
+ cursor: v.optional(v.string()),
117
+ },
118
+ handler: async (ctx, args) => {
119
+ const now = Date.now()
120
+ const state = await ctx.db
121
+ .query('githubBackupSyncState')
122
+ .withIndex('by_key', (q) => q.eq('key', SYNC_STATE_KEY))
123
+ .unique()
124
+
125
+ if (!state) {
126
+ await ctx.db.insert('githubBackupSyncState', {
127
+ key: SYNC_STATE_KEY,
128
+ cursor: args.cursor,
129
+ updatedAt: now,
130
+ })
131
+ return { ok: true as const }
132
+ }
133
+
134
+ await ctx.db.patch(state._id, {
135
+ cursor: args.cursor,
136
+ updatedAt: now,
137
+ })
138
+
139
+ return { ok: true as const }
140
+ },
141
+ })
142
+
143
+ export const syncGitHubBackups: ReturnType<typeof action> = action({
144
+ args: {
145
+ dryRun: v.optional(v.boolean()),
146
+ batchSize: v.optional(v.number()),
147
+ maxBatches: v.optional(v.number()),
148
+ resetCursor: v.optional(v.boolean()),
149
+ },
150
+ handler: async (ctx, args): Promise<SyncGitHubBackupsResult> => {
151
+ const { user } = await requireUserFromAction(ctx)
152
+ assertRole(user, ['admin'])
153
+
154
+ if (args.resetCursor && !args.dryRun) {
155
+ await ctx.runMutation(internal.githubBackups.setGitHubBackupSyncStateInternal, {
156
+ cursor: undefined,
157
+ })
158
+ }
159
+
160
+ return ctx.runAction(internal.githubBackupsNode.syncGitHubBackupsInternal, {
161
+ dryRun: args.dryRun,
162
+ batchSize: args.batchSize,
163
+ maxBatches: args.maxBatches,
164
+ }) as Promise<SyncGitHubBackupsResult>
165
+ },
166
+ })
167
+
168
+ function clampInt(value: number, min: number, max: number) {
169
+ return Math.max(min, Math.min(max, Math.floor(value)))
170
+ }
@@ -0,0 +1,183 @@
1
+ 'use node'
2
+
3
+ import { v } from 'convex/values'
4
+ import { internal } from './_generated/api'
5
+ import type { Doc } from './_generated/dataModel'
6
+ import type { ActionCtx } from './_generated/server'
7
+ import { internalAction } from './_generated/server'
8
+ import {
9
+ backupSkillToGitHub,
10
+ fetchGitHubSkillMeta,
11
+ getGitHubBackupContext,
12
+ isGitHubBackupConfigured,
13
+ } from './lib/githubBackup'
14
+
15
+ const DEFAULT_BATCH_SIZE = 50
16
+ const MAX_BATCH_SIZE = 200
17
+ const DEFAULT_MAX_BATCHES = 5
18
+ const MAX_MAX_BATCHES = 200
19
+
20
+ type BackupPageItem =
21
+ | {
22
+ kind: 'ok'
23
+ slug: string
24
+ version: string
25
+ displayName: string
26
+ ownerHandle: string
27
+ files: Doc<'skillVersions'>['files']
28
+ publishedAt: number
29
+ }
30
+ | { kind: 'missingLatestVersion' }
31
+ | { kind: 'missingVersionDoc' }
32
+ | { kind: 'missingOwner' }
33
+
34
+ export type GitHubBackupSyncStats = {
35
+ skillsScanned: number
36
+ skillsSkipped: number
37
+ skillsBackedUp: number
38
+ skillsMissingVersion: number
39
+ skillsMissingOwner: number
40
+ errors: number
41
+ }
42
+
43
+ export type SyncGitHubBackupsInternalArgs = {
44
+ dryRun?: boolean
45
+ batchSize?: number
46
+ maxBatches?: number
47
+ }
48
+
49
+ export type SyncGitHubBackupsInternalResult = {
50
+ stats: GitHubBackupSyncStats
51
+ cursor: string | null
52
+ isDone: boolean
53
+ }
54
+
55
+ export const backupSkillForPublishInternal = internalAction({
56
+ args: {
57
+ slug: v.string(),
58
+ version: v.string(),
59
+ displayName: v.string(),
60
+ ownerHandle: v.string(),
61
+ files: v.array(
62
+ v.object({
63
+ path: v.string(),
64
+ size: v.number(),
65
+ storageId: v.id('_storage'),
66
+ sha256: v.string(),
67
+ contentType: v.optional(v.string()),
68
+ }),
69
+ ),
70
+ publishedAt: v.number(),
71
+ },
72
+ handler: async (ctx, args) => {
73
+ if (!isGitHubBackupConfigured()) {
74
+ return { skipped: true as const }
75
+ }
76
+ await backupSkillToGitHub(ctx, args)
77
+ return { skipped: false as const }
78
+ },
79
+ })
80
+
81
+ export async function syncGitHubBackupsInternalHandler(
82
+ ctx: ActionCtx,
83
+ args: SyncGitHubBackupsInternalArgs,
84
+ ): Promise<SyncGitHubBackupsInternalResult> {
85
+ const dryRun = Boolean(args.dryRun)
86
+ const stats: GitHubBackupSyncStats = {
87
+ skillsScanned: 0,
88
+ skillsSkipped: 0,
89
+ skillsBackedUp: 0,
90
+ skillsMissingVersion: 0,
91
+ skillsMissingOwner: 0,
92
+ errors: 0,
93
+ }
94
+
95
+ if (!isGitHubBackupConfigured()) {
96
+ return { stats, cursor: null, isDone: true }
97
+ }
98
+
99
+ const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE)
100
+ const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES)
101
+ const context = await getGitHubBackupContext()
102
+
103
+ const state = dryRun
104
+ ? { cursor: null as string | null }
105
+ : ((await ctx.runQuery(internal.githubBackups.getGitHubBackupSyncStateInternal, {})) as {
106
+ cursor: string | null
107
+ })
108
+
109
+ let cursor: string | null = state.cursor
110
+ let isDone = false
111
+
112
+ for (let batch = 0; batch < maxBatches; batch++) {
113
+ const page = (await ctx.runQuery(internal.githubBackups.getGitHubBackupPageInternal, {
114
+ cursor: cursor ?? undefined,
115
+ batchSize,
116
+ })) as { items: BackupPageItem[]; cursor: string | null; isDone: boolean }
117
+
118
+ cursor = page.cursor
119
+ isDone = page.isDone
120
+
121
+ for (const item of page.items) {
122
+ if (item.kind !== 'ok') {
123
+ if (item.kind === 'missingLatestVersion' || item.kind === 'missingVersionDoc') {
124
+ stats.skillsMissingVersion += 1
125
+ } else if (item.kind === 'missingOwner') {
126
+ stats.skillsMissingOwner += 1
127
+ }
128
+ continue
129
+ }
130
+
131
+ stats.skillsScanned += 1
132
+ try {
133
+ const meta = await fetchGitHubSkillMeta(context, item.ownerHandle, item.slug)
134
+ if (meta?.latest?.version === item.version) {
135
+ stats.skillsSkipped += 1
136
+ continue
137
+ }
138
+
139
+ if (!dryRun) {
140
+ await backupSkillToGitHub(
141
+ ctx,
142
+ {
143
+ slug: item.slug,
144
+ version: item.version,
145
+ displayName: item.displayName,
146
+ ownerHandle: item.ownerHandle,
147
+ files: item.files,
148
+ publishedAt: item.publishedAt,
149
+ },
150
+ context,
151
+ )
152
+ stats.skillsBackedUp += 1
153
+ }
154
+ } catch (error) {
155
+ console.error('GitHub backup sync failed', error)
156
+ stats.errors += 1
157
+ }
158
+ }
159
+
160
+ if (!dryRun) {
161
+ await ctx.runMutation(internal.githubBackups.setGitHubBackupSyncStateInternal, {
162
+ cursor: isDone ? undefined : (cursor ?? undefined),
163
+ })
164
+ }
165
+
166
+ if (isDone) break
167
+ }
168
+
169
+ return { stats, cursor, isDone }
170
+ }
171
+
172
+ export const syncGitHubBackupsInternal = internalAction({
173
+ args: {
174
+ dryRun: v.optional(v.boolean()),
175
+ batchSize: v.optional(v.number()),
176
+ maxBatches: v.optional(v.number()),
177
+ },
178
+ handler: syncGitHubBackupsInternalHandler,
179
+ })
180
+
181
+ function clampInt(value: number, min: number, max: number) {
182
+ return Math.max(min, Math.min(max, Math.floor(value)))
183
+ }
@@ -0,0 +1,317 @@
1
+ import { ConvexError, v } from 'convex/values'
2
+ import { unzipSync } from 'fflate'
3
+ import semver from 'semver'
4
+ import { api, internal } from './_generated/api'
5
+ import type { Id } from './_generated/dataModel'
6
+ import type { ActionCtx } from './_generated/server'
7
+ import { action } from './_generated/server'
8
+ import { requireUserFromAction } from './lib/access'
9
+ import {
10
+ buildGitHubImportFileList,
11
+ computeDefaultSelectedPaths,
12
+ detectGitHubImportCandidates,
13
+ fetchGitHubZipBytes,
14
+ listTextFilesUnderCandidate,
15
+ normalizeRepoPath,
16
+ parseGitHubImportUrl,
17
+ resolveGitHubCommit,
18
+ stripGitHubZipRoot,
19
+ suggestDisplayName,
20
+ suggestVersion,
21
+ } from './lib/githubImport'
22
+ import { publishVersionForUser } from './lib/skillPublish'
23
+ import { sanitizePath } from './lib/skills'
24
+
25
+ const MAX_SELECTED_BYTES = 50 * 1024 * 1024
26
+ const MAX_UNZIPPED_BYTES = 80 * 1024 * 1024
27
+ const MAX_FILE_COUNT = 7_500
28
+ const MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024
29
+
30
+ export const previewGitHubImport = action({
31
+ args: { url: v.string() },
32
+ handler: async (ctx, args) => {
33
+ await requireUserFromAction(ctx)
34
+
35
+ const parsed = parseGitHubImportUrl(args.url)
36
+ const resolved = await resolveGitHubCommit(parsed, fetch)
37
+ const zipBytes = await fetchGitHubZipBytes(resolved, fetch)
38
+ const entries = unzipToEntries(zipBytes)
39
+ const stripped = stripGitHubZipRoot(entries)
40
+ const candidates = detectGitHubImportCandidates(stripped).filter((candidate) =>
41
+ isCandidateUnderResolvedPath(candidate.path, resolved.path),
42
+ )
43
+ if (candidates.length === 0) throw new ConvexError('No SKILL.md found in this repo')
44
+
45
+ return {
46
+ resolved,
47
+ candidates: candidates.map((candidate) => ({
48
+ path: candidate.path,
49
+ readmePath: candidate.readmePath,
50
+ name: candidate.name ?? null,
51
+ description: candidate.description ?? null,
52
+ })),
53
+ }
54
+ },
55
+ })
56
+
57
+ export const previewGitHubImportCandidate = action({
58
+ args: { url: v.string(), candidatePath: v.string() },
59
+ handler: async (ctx, args) => {
60
+ const { userId } = await requireUserFromAction(ctx)
61
+
62
+ const parsed = parseGitHubImportUrl(args.url)
63
+ const resolved = await resolveGitHubCommit(parsed, fetch)
64
+ const zipBytes = await fetchGitHubZipBytes(resolved, fetch)
65
+ const entries = unzipToEntries(zipBytes)
66
+ const stripped = stripGitHubZipRoot(entries)
67
+
68
+ const normalizedCandidatePath = normalizeRepoPath(args.candidatePath)
69
+ if (!isCandidateUnderResolvedPath(normalizedCandidatePath, resolved.path)) {
70
+ throw new ConvexError('Candidate path is outside the requested import scope')
71
+ }
72
+
73
+ const candidates = detectGitHubImportCandidates(stripped).filter((candidate) =>
74
+ isCandidateUnderResolvedPath(candidate.path, resolved.path),
75
+ )
76
+
77
+ const candidate = candidates.find((item) => item.path === normalizedCandidatePath)
78
+ if (!candidate) throw new ConvexError('Candidate not found')
79
+
80
+ const files = listTextFilesUnderCandidate(stripped, candidate.path)
81
+ const defaultSelectedPaths = computeDefaultSelectedPaths({ candidate, files })
82
+ const fileList = buildGitHubImportFileList({
83
+ candidate,
84
+ files,
85
+ defaultSelectedPaths,
86
+ })
87
+
88
+ const baseForNaming = candidate.path ? (candidate.path.split('/').at(-1) ?? '') : resolved.repo
89
+ const suggestedDisplayName = suggestDisplayName(candidate, baseForNaming)
90
+
91
+ const rawSlugBase = sanitizeSlug(candidate.path ? baseForNaming : resolved.repo)
92
+ const suggestedSlug = await suggestAvailableSlug(ctx, userId, rawSlugBase)
93
+
94
+ const existing = await ctx.runQuery(api.skills.getBySlug, { slug: suggestedSlug })
95
+ const existingLatest =
96
+ existing?.skill && existing.skill.ownerUserId === userId
97
+ ? (existing.latestVersion?.version ?? null)
98
+ : null
99
+ const suggestedVersion = suggestVersion(existingLatest)
100
+
101
+ return {
102
+ resolved,
103
+ candidate: {
104
+ path: candidate.path,
105
+ readmePath: candidate.readmePath,
106
+ name: candidate.name ?? null,
107
+ description: candidate.description ?? null,
108
+ },
109
+ defaults: {
110
+ selectedPaths: defaultSelectedPaths,
111
+ slug: suggestedSlug,
112
+ displayName: suggestedDisplayName,
113
+ version: suggestedVersion,
114
+ tags: ['latest'],
115
+ },
116
+ files: fileList,
117
+ }
118
+ },
119
+ })
120
+
121
+ export const importGitHubSkill = action({
122
+ args: {
123
+ url: v.string(),
124
+ commit: v.string(),
125
+ candidatePath: v.string(),
126
+ selectedPaths: v.array(v.string()),
127
+ slug: v.optional(v.string()),
128
+ displayName: v.optional(v.string()),
129
+ version: v.optional(v.string()),
130
+ tags: v.optional(v.array(v.string())),
131
+ },
132
+ handler: async (ctx, args) => {
133
+ const { userId } = await requireUserFromAction(ctx)
134
+
135
+ const parsed = parseGitHubImportUrl(args.url)
136
+ const resolved = await resolveGitHubCommit(parsed, fetch)
137
+ if (!/^[a-f0-9]{40}$/i.test(args.commit)) throw new ConvexError('Invalid commit')
138
+ if (args.commit.toLowerCase() !== resolved.commit.toLowerCase()) {
139
+ throw new ConvexError('Import is out of date. Re-run preview.')
140
+ }
141
+
142
+ const normalizedCandidatePath = normalizeRepoPath(args.candidatePath)
143
+ if (!isCandidateUnderResolvedPath(normalizedCandidatePath, resolved.path)) {
144
+ throw new ConvexError('Candidate path is outside the requested import scope')
145
+ }
146
+
147
+ const zipBytes = await fetchGitHubZipBytes(resolved, fetch)
148
+ const entries = stripGitHubZipRoot(unzipToEntries(zipBytes))
149
+
150
+ const candidates = detectGitHubImportCandidates(entries).filter((candidate) =>
151
+ isCandidateUnderResolvedPath(candidate.path, resolved.path),
152
+ )
153
+ const candidate = candidates.find((item) => item.path === normalizedCandidatePath)
154
+ if (!candidate) throw new ConvexError('Candidate not found')
155
+
156
+ const filesUnderCandidate = listTextFilesUnderCandidate(entries, candidate.path)
157
+ const byPath = new Map(filesUnderCandidate.map((file) => [file.path, file.bytes]))
158
+
159
+ const selected = Array.from(
160
+ new Set(args.selectedPaths.map((path) => normalizeRepoPath(path)).filter(Boolean)),
161
+ )
162
+ if (selected.length === 0) throw new ConvexError('No files selected')
163
+
164
+ const candidateRoot = candidate.path ? `${candidate.path}/` : ''
165
+ const normalizedReadmePath = normalizeRepoPath(candidate.readmePath)
166
+ if (!selected.includes(normalizedReadmePath)) {
167
+ throw new ConvexError('SKILL.md must be selected')
168
+ }
169
+
170
+ let totalBytes = 0
171
+ const storedFiles: Array<{
172
+ path: string
173
+ size: number
174
+ storageId: Id<'_storage'>
175
+ sha256: string
176
+ contentType?: string
177
+ }> = []
178
+
179
+ for (const path of selected.sort()) {
180
+ if (candidateRoot && !path.startsWith(candidateRoot)) {
181
+ throw new ConvexError('Selected file is outside the chosen skill folder')
182
+ }
183
+
184
+ const bytes = byPath.get(path)
185
+ if (!bytes) continue
186
+ totalBytes += bytes.byteLength
187
+ if (totalBytes > MAX_SELECTED_BYTES) throw new ConvexError('Selected files exceed 50MB limit')
188
+
189
+ const relPath = candidateRoot ? path.slice(candidateRoot.length) : path
190
+ const sanitized = sanitizePath(relPath)
191
+ if (!sanitized) throw new ConvexError('Invalid file paths')
192
+
193
+ const sha256 = await sha256Hex(bytes)
194
+ const safeBytes = new Uint8Array(bytes)
195
+ const storageId = await ctx.storage.store(new Blob([safeBytes], { type: 'text/plain' }))
196
+ storedFiles.push({
197
+ path: sanitized,
198
+ size: bytes.byteLength,
199
+ storageId,
200
+ sha256,
201
+ contentType: 'text/plain',
202
+ })
203
+ }
204
+
205
+ if (storedFiles.length === 0) throw new ConvexError('No files selected')
206
+
207
+ const slugBase = (args.slug ?? '').trim().toLowerCase()
208
+ const displayName = (args.displayName ?? '').trim()
209
+ const tags = (args.tags ?? ['latest']).map((tag) => tag.trim()).filter(Boolean)
210
+ const version = (args.version ?? '').trim()
211
+
212
+ if (!slugBase) throw new ConvexError('Slug required')
213
+ if (!displayName) throw new ConvexError('Display name required')
214
+ if (!version || !semver.valid(version)) throw new ConvexError('Version must be valid semver')
215
+
216
+ const result = await publishVersionForUser(ctx, userId, {
217
+ slug: slugBase,
218
+ displayName,
219
+ version,
220
+ changelog: '',
221
+ tags,
222
+ files: storedFiles,
223
+ source: {
224
+ kind: 'github',
225
+ url: resolved.originalUrl,
226
+ repo: `${resolved.owner}/${resolved.repo}`,
227
+ ref: resolved.ref,
228
+ commit: resolved.commit,
229
+ path: candidate.path,
230
+ importedAt: Date.now(),
231
+ },
232
+ })
233
+
234
+ return { ok: true, slug: slugBase, version, ...result }
235
+ },
236
+ })
237
+
238
+ function unzipToEntries(zipBytes: Uint8Array) {
239
+ const entries = unzipSync(zipBytes)
240
+ const out: Record<string, Uint8Array> = {}
241
+ const rawPaths = Object.keys(entries)
242
+ if (rawPaths.length > MAX_FILE_COUNT) throw new ConvexError('Repo archive has too many files')
243
+ let totalBytes = 0
244
+ for (const [rawPath, bytes] of Object.entries(entries)) {
245
+ const normalizedPath = normalizeZipPath(rawPath)
246
+ if (!normalizedPath) continue
247
+ if (isJunkPath(normalizedPath)) continue
248
+ if (!bytes) continue
249
+ if (bytes.byteLength > MAX_SINGLE_FILE_BYTES) continue
250
+ totalBytes += bytes.byteLength
251
+ if (totalBytes > MAX_UNZIPPED_BYTES) throw new ConvexError('Repo archive is too large')
252
+ out[normalizedPath] = bytes
253
+ }
254
+ return out
255
+ }
256
+
257
+ function isCandidateUnderResolvedPath(candidatePath: string, resolvedPath: string) {
258
+ const root = normalizeRepoPath(resolvedPath)
259
+ if (!root) return true
260
+ if (!candidatePath) return false
261
+ if (candidatePath === root) return true
262
+ return candidatePath.startsWith(`${root}/`)
263
+ }
264
+
265
+ function sanitizeSlug(value: string) {
266
+ return value
267
+ .trim()
268
+ .toLowerCase()
269
+ .replace(/[^a-z0-9-]+/g, '-')
270
+ .replace(/^-+/, '')
271
+ .replace(/-+$/, '')
272
+ .replace(/--+/g, '-')
273
+ }
274
+
275
+ async function suggestAvailableSlug(ctx: ActionCtx, userId: Id<'users'>, base: string) {
276
+ const cleaned = sanitizeSlug(base)
277
+ if (!cleaned) throw new ConvexError('Could not derive slug')
278
+ for (let i = 0; i < 50; i += 1) {
279
+ const candidate = i === 0 ? cleaned : `${cleaned}-${i + 1}`
280
+ const existing = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug: candidate })
281
+ if (!existing) return candidate
282
+ if (existing.ownerUserId === userId) return candidate
283
+ }
284
+ throw new ConvexError('Could not find an available slug')
285
+ }
286
+
287
+ async function sha256Hex(bytes: Uint8Array) {
288
+ const normalized = new Uint8Array(bytes)
289
+ const digest = await crypto.subtle.digest('SHA-256', normalized.buffer)
290
+ return toHex(new Uint8Array(digest))
291
+ }
292
+
293
+ function toHex(bytes: Uint8Array) {
294
+ let out = ''
295
+ for (const byte of bytes) out += byte.toString(16).padStart(2, '0')
296
+ return out
297
+ }
298
+
299
+ function normalizeZipPath(path: string) {
300
+ const normalized = path
301
+ .replaceAll('\u0000', '')
302
+ .replaceAll('\\', '/')
303
+ .trim()
304
+ .replace(/^\.\/+/, '')
305
+ .replace(/^\/+/, '')
306
+ if (!normalized) return ''
307
+ if (normalized.includes('..')) return ''
308
+ return normalized
309
+ }
310
+
311
+ function isJunkPath(path: string) {
312
+ const normalized = path.toLowerCase()
313
+ if (normalized.startsWith('__macosx/')) return true
314
+ if (normalized.endsWith('/.ds_store')) return true
315
+ if (normalized === '.ds_store') return true
316
+ return false
317
+ }