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.
- package/.env.local.example +19 -0
- package/.github/workflows/ci.yml +40 -0
- package/.oxlintrc.json +3 -0
- package/AGENTS.md +45 -0
- package/CHANGELOG.md +138 -0
- package/DEPRECATIONS.md +7 -0
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/biome.json +41 -0
- package/convex/_generated/api.d.ts +153 -0
- package/convex/_generated/api.js +23 -0
- package/convex/_generated/dataModel.d.ts +60 -0
- package/convex/_generated/server.d.ts +143 -0
- package/convex/_generated/server.js +93 -0
- package/convex/auth.config.ts +8 -0
- package/convex/auth.ts +19 -0
- package/convex/comments.ts +88 -0
- package/convex/crons.ts +34 -0
- package/convex/devSeed.ts +459 -0
- package/convex/devSeedExtra.ts +541 -0
- package/convex/downloads.ts +78 -0
- package/convex/githubBackups.ts +170 -0
- package/convex/githubBackupsNode.ts +183 -0
- package/convex/githubImport.ts +317 -0
- package/convex/githubSoulBackups.ts +170 -0
- package/convex/githubSoulBackupsNode.ts +186 -0
- package/convex/http.ts +194 -0
- package/convex/httpApi.handlers.test.ts +488 -0
- package/convex/httpApi.test.ts +70 -0
- package/convex/httpApi.ts +305 -0
- package/convex/httpApiV1.handlers.test.ts +584 -0
- package/convex/httpApiV1.ts +1172 -0
- package/convex/leaderboards.ts +39 -0
- package/convex/lib/access.ts +36 -0
- package/convex/lib/apiTokenAuth.ts +36 -0
- package/convex/lib/badges.ts +50 -0
- package/convex/lib/changelog.test.ts +34 -0
- package/convex/lib/changelog.ts +278 -0
- package/convex/lib/embeddings.ts +38 -0
- package/convex/lib/githubBackup.ts +443 -0
- package/convex/lib/githubImport.test.ts +247 -0
- package/convex/lib/githubImport.ts +425 -0
- package/convex/lib/githubSoulBackup.ts +443 -0
- package/convex/lib/leaderboards.ts +103 -0
- package/convex/lib/moderation.ts +42 -0
- package/convex/lib/public.ts +89 -0
- package/convex/lib/searchText.test.ts +46 -0
- package/convex/lib/searchText.ts +27 -0
- package/convex/lib/skillBackfill.test.ts +34 -0
- package/convex/lib/skillBackfill.ts +67 -0
- package/convex/lib/skillPublish.test.ts +28 -0
- package/convex/lib/skillPublish.ts +284 -0
- package/convex/lib/skillStats.ts +80 -0
- package/convex/lib/skills.test.ts +197 -0
- package/convex/lib/skills.ts +273 -0
- package/convex/lib/soulChangelog.ts +273 -0
- package/convex/lib/soulPublish.ts +236 -0
- package/convex/lib/tokens.test.ts +33 -0
- package/convex/lib/tokens.ts +51 -0
- package/convex/lib/webhooks.test.ts +91 -0
- package/convex/lib/webhooks.ts +112 -0
- package/convex/maintenance.test.ts +270 -0
- package/convex/maintenance.ts +840 -0
- package/convex/rateLimits.ts +50 -0
- package/convex/schema.ts +472 -0
- package/convex/search.test.ts +12 -0
- package/convex/search.ts +254 -0
- package/convex/seed.test.ts +37 -0
- package/convex/seed.ts +254 -0
- package/convex/seedSouls.ts +111 -0
- package/convex/skillStatEvents.ts +568 -0
- package/convex/skills.ts +1606 -0
- package/convex/soulComments.ts +88 -0
- package/convex/soulDownloads.ts +14 -0
- package/convex/soulStars.ts +71 -0
- package/convex/souls.ts +570 -0
- package/convex/stars.ts +108 -0
- package/convex/statsMaintenance.ts +205 -0
- package/convex/telemetry.ts +434 -0
- package/convex/tokens.ts +88 -0
- package/convex/tsconfig.json +7 -0
- package/convex/uploads.ts +20 -0
- package/convex/users.ts +122 -0
- package/convex/webhooks.ts +50 -0
- package/convex.json +3 -0
- package/docs/README.md +32 -0
- package/docs/api.md +51 -0
- package/docs/architecture.md +61 -0
- package/docs/auth.md +54 -0
- package/docs/cli.md +117 -0
- package/docs/deploy.md +78 -0
- package/docs/diffing.md +84 -0
- package/docs/github-import.md +171 -0
- package/docs/http-api.md +187 -0
- package/docs/manual-testing.md +64 -0
- package/docs/mintlify.md +43 -0
- package/docs/quickstart.md +120 -0
- package/docs/skill-format.md +58 -0
- package/docs/soul-format.md +37 -0
- package/docs/spec.md +177 -0
- package/docs/telemetry.md +91 -0
- package/docs/troubleshooting.md +49 -0
- package/docs/webhook.md +51 -0
- package/e2e/menu-smoke.pw.test.ts +49 -0
- package/e2e/pilothub.e2e.test.ts +494 -0
- package/e2e/search-exact.pw.test.ts +97 -0
- package/package.json +84 -0
- package/packages/pilothub/LICENSE +22 -0
- package/packages/pilothub/README.md +57 -0
- package/packages/pilothub/bin/pilothub.js +2 -0
- package/packages/pilothub/package.json +41 -0
- package/packages/pilothub/src/browserAuth.test.ts +96 -0
- package/packages/pilothub/src/browserAuth.ts +174 -0
- package/packages/pilothub/src/cli/buildInfo.ts +94 -0
- package/packages/pilothub/src/cli/commands/auth.ts +97 -0
- package/packages/pilothub/src/cli/commands/delete.test.ts +73 -0
- package/packages/pilothub/src/cli/commands/delete.ts +83 -0
- package/packages/pilothub/src/cli/commands/publish.test.ts +122 -0
- package/packages/pilothub/src/cli/commands/publish.ts +108 -0
- package/packages/pilothub/src/cli/commands/skills.test.ts +191 -0
- package/packages/pilothub/src/cli/commands/skills.ts +380 -0
- package/packages/pilothub/src/cli/commands/star.ts +46 -0
- package/packages/pilothub/src/cli/commands/sync.test.ts +310 -0
- package/packages/pilothub/src/cli/commands/sync.ts +200 -0
- package/packages/pilothub/src/cli/commands/syncHelpers.test.ts +26 -0
- package/packages/pilothub/src/cli/commands/syncHelpers.ts +427 -0
- package/packages/pilothub/src/cli/commands/syncTypes.ts +27 -0
- package/packages/pilothub/src/cli/commands/unstar.ts +48 -0
- package/packages/pilothub/src/cli/helpStyle.ts +45 -0
- package/packages/pilothub/src/cli/pilotbotConfig.test.ts +159 -0
- package/packages/pilothub/src/cli/pilotbotConfig.ts +147 -0
- package/packages/pilothub/src/cli/registry.test.ts +63 -0
- package/packages/pilothub/src/cli/registry.ts +43 -0
- package/packages/pilothub/src/cli/scanSkills.test.ts +64 -0
- package/packages/pilothub/src/cli/scanSkills.ts +84 -0
- package/packages/pilothub/src/cli/slug.ts +16 -0
- package/packages/pilothub/src/cli/types.ts +12 -0
- package/packages/pilothub/src/cli/ui.ts +75 -0
- package/packages/pilothub/src/cli.ts +311 -0
- package/packages/pilothub/src/config.ts +36 -0
- package/packages/pilothub/src/discovery.test.ts +75 -0
- package/packages/pilothub/src/discovery.ts +19 -0
- package/packages/pilothub/src/http.test.ts +156 -0
- package/packages/pilothub/src/http.ts +301 -0
- package/packages/pilothub/src/schema/ark.ts +29 -0
- package/packages/pilothub/src/schema/index.ts +5 -0
- package/packages/pilothub/src/schema/routes.ts +22 -0
- package/packages/pilothub/src/schema/schemas.ts +260 -0
- package/packages/pilothub/src/schema/textFiles.test.ts +23 -0
- package/packages/pilothub/src/schema/textFiles.ts +66 -0
- package/packages/pilothub/src/skills.test.ts +191 -0
- package/packages/pilothub/src/skills.ts +172 -0
- package/packages/pilothub/src/types.ts +10 -0
- package/packages/pilothub/tsconfig.json +14 -0
- package/packages/schema/README.md +3 -0
- package/packages/schema/dist/ark.d.ts +4 -0
- package/packages/schema/dist/ark.js +26 -0
- package/packages/schema/dist/ark.js.map +1 -0
- package/packages/schema/dist/index.d.ts +5 -0
- package/packages/schema/dist/index.js +5 -0
- package/packages/schema/dist/index.js.map +1 -0
- package/packages/schema/dist/routes.d.ts +21 -0
- package/packages/schema/dist/routes.js +22 -0
- package/packages/schema/dist/routes.js.map +1 -0
- package/packages/schema/dist/schemas.d.ts +297 -0
- package/packages/schema/dist/schemas.js +243 -0
- package/packages/schema/dist/schemas.js.map +1 -0
- package/packages/schema/dist/textFiles.d.ts +5 -0
- package/packages/schema/dist/textFiles.js +66 -0
- package/packages/schema/dist/textFiles.js.map +1 -0
- package/packages/schema/package.json +26 -0
- package/packages/schema/src/ark.ts +29 -0
- package/packages/schema/src/index.ts +5 -0
- package/packages/schema/src/routes.ts +22 -0
- package/packages/schema/src/schemas.test.ts +123 -0
- package/packages/schema/src/schemas.ts +287 -0
- package/packages/schema/src/textFiles.test.ts +23 -0
- package/packages/schema/src/textFiles.ts +66 -0
- package/packages/schema/tsconfig.json +15 -0
- package/pilothub +46 -0
- package/playwright.config.ts +33 -0
- package/public/.well-known/pilothub.json +6 -0
- package/public/api/v1/openapi.json +379 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/og.png +0 -0
- package/public/og.svg +98 -0
- package/public/pilot-logo.png +0 -0
- package/public/pilot-mark.png +0 -0
- package/public/robots.txt +3 -0
- package/public/tanstack-circle-logo.png +0 -0
- package/public/tanstack-word-logo-white.svg +1 -0
- package/scripts/check-peer-deps.ts +56 -0
- package/scripts/docs-list.ts +148 -0
- package/scripts/run-playwright-local.sh +14 -0
- package/server/og/fetchSkillOgMeta.ts +27 -0
- package/server/og/fetchSoulOgMeta.ts +27 -0
- package/server/og/ogAssets.ts +80 -0
- package/server/og/skillOgSvg.test.ts +59 -0
- package/server/og/skillOgSvg.ts +258 -0
- package/server/og/soulOgSvg.ts +209 -0
- package/server/routes/og/skill.png.ts +103 -0
- package/server/routes/og/soul.png.ts +111 -0
- package/src/__tests__/skill-detail-page.test.tsx +86 -0
- package/src/__tests__/skills-index.test.tsx +145 -0
- package/src/__tests__/upload.route.test.tsx +228 -0
- package/src/components/AppProviders.tsx +19 -0
- package/src/components/ClientOnly.tsx +18 -0
- package/src/components/Footer.tsx +29 -0
- package/src/components/Header.tsx +295 -0
- package/src/components/InstallSwitcher.tsx +53 -0
- package/src/components/SkillCard.tsx +36 -0
- package/src/components/SkillDetailPage.tsx +817 -0
- package/src/components/SkillDiffCard.tsx +485 -0
- package/src/components/SoulCard.tsx +19 -0
- package/src/components/SoulDetailPage.tsx +263 -0
- package/src/components/UserBootstrap.tsx +18 -0
- package/src/components/ui/dropdown-menu.tsx +67 -0
- package/src/components/ui/toggle-group.tsx +35 -0
- package/src/convex/client.ts +3 -0
- package/src/lib/badges.ts +29 -0
- package/src/lib/diffing.test.ts +163 -0
- package/src/lib/diffing.ts +106 -0
- package/src/lib/gravatar.test.ts +9 -0
- package/src/lib/gravatar.ts +158 -0
- package/src/lib/og.test.ts +142 -0
- package/src/lib/og.ts +156 -0
- package/src/lib/publicUser.ts +39 -0
- package/src/lib/roles.ts +19 -0
- package/src/lib/site.test.ts +130 -0
- package/src/lib/site.ts +84 -0
- package/src/lib/theme-transition.test.ts +134 -0
- package/src/lib/theme-transition.ts +134 -0
- package/src/lib/theme.test.tsx +88 -0
- package/src/lib/theme.ts +43 -0
- package/src/lib/uploadFiles.jsdom.test.ts +33 -0
- package/src/lib/uploadFiles.test.ts +123 -0
- package/src/lib/uploadFiles.ts +245 -0
- package/src/lib/uploadUtils.test.ts +78 -0
- package/src/lib/uploadUtils.ts +93 -0
- package/src/lib/useAuthStatus.ts +12 -0
- package/src/lib/utils.test.ts +9 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +12 -0
- package/src/routeTree.gen.ts +345 -0
- package/src/router.tsx +17 -0
- package/src/routes/$owner/$slug.tsx +55 -0
- package/src/routes/__root.tsx +136 -0
- package/src/routes/admin.tsx +11 -0
- package/src/routes/cli/auth.tsx +168 -0
- package/src/routes/dashboard.tsx +97 -0
- package/src/routes/import.tsx +415 -0
- package/src/routes/index.tsx +252 -0
- package/src/routes/management.tsx +529 -0
- package/src/routes/settings.tsx +203 -0
- package/src/routes/skills/index.tsx +422 -0
- package/src/routes/souls/$slug.tsx +55 -0
- package/src/routes/souls/index.tsx +243 -0
- package/src/routes/stars.tsx +68 -0
- package/src/routes/u/$handle.tsx +307 -0
- package/src/routes/upload/utils.ts +81 -0
- package/src/routes/upload.tsx +499 -0
- package/src/styles.css +2718 -0
- package/tsconfig.json +24 -0
- package/tsconfig.oxlint.json +16 -0
- package/vercel.json +8 -0
- package/vite.config.ts +48 -0
- package/vitest.config.ts +47 -0
- package/vitest.e2e.config.ts +11 -0
- 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
|
+
}
|