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,168 @@
|
|
|
1
|
+
import { useAuthActions } from '@convex-dev/auth/react'
|
|
2
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
3
|
+
import { useMutation } from 'convex/react'
|
|
4
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
5
|
+
import { api } from '../../../convex/_generated/api'
|
|
6
|
+
import { useAuthStatus } from '../../lib/useAuthStatus'
|
|
7
|
+
|
|
8
|
+
export const Route = createFileRoute('/cli/auth')({
|
|
9
|
+
component: CliAuth,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
function CliAuth() {
|
|
13
|
+
const { isAuthenticated, isLoading, me } = useAuthStatus()
|
|
14
|
+
const { signIn } = useAuthActions()
|
|
15
|
+
const createToken = useMutation(api.tokens.create)
|
|
16
|
+
|
|
17
|
+
const search = Route.useSearch() as {
|
|
18
|
+
redirect_uri?: string
|
|
19
|
+
label?: string
|
|
20
|
+
label_b64?: string
|
|
21
|
+
state?: string
|
|
22
|
+
}
|
|
23
|
+
const [status, setStatus] = useState<string>('Preparing…')
|
|
24
|
+
const [token, setToken] = useState<string | null>(null)
|
|
25
|
+
const hasRun = useRef(false)
|
|
26
|
+
|
|
27
|
+
const redirectUri = search.redirect_uri ?? ''
|
|
28
|
+
const label = (decodeLabel(search.label_b64) ?? search.label ?? 'CLI token').trim() || 'CLI token'
|
|
29
|
+
const state = typeof search.state === 'string' ? search.state.trim() : ''
|
|
30
|
+
|
|
31
|
+
const safeRedirect = useMemo(() => isAllowedRedirectUri(redirectUri), [redirectUri])
|
|
32
|
+
const registry = import.meta.env.VITE_CONVEX_SITE_URL as string | undefined
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (hasRun.current) return
|
|
36
|
+
if (!safeRedirect) return
|
|
37
|
+
if (!state) return
|
|
38
|
+
if (!registry) return
|
|
39
|
+
if (!isAuthenticated || !me) return
|
|
40
|
+
hasRun.current = true
|
|
41
|
+
|
|
42
|
+
const run = async () => {
|
|
43
|
+
setStatus('Creating token…')
|
|
44
|
+
const result = await createToken({ label })
|
|
45
|
+
setToken(result.token)
|
|
46
|
+
setStatus('Redirecting to CLI…')
|
|
47
|
+
const hash = new URLSearchParams()
|
|
48
|
+
hash.set('token', result.token)
|
|
49
|
+
hash.set('registry', registry)
|
|
50
|
+
hash.set('state', state)
|
|
51
|
+
window.location.assign(`${redirectUri}#${hash.toString()}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
void run().catch((error) => {
|
|
55
|
+
const message = error instanceof Error ? error.message : 'Failed to create token'
|
|
56
|
+
setStatus(message)
|
|
57
|
+
setToken(null)
|
|
58
|
+
})
|
|
59
|
+
}, [createToken, isAuthenticated, label, me, redirectUri, safeRedirect, state])
|
|
60
|
+
|
|
61
|
+
if (!safeRedirect) {
|
|
62
|
+
return (
|
|
63
|
+
<main className="section">
|
|
64
|
+
<div className="card">
|
|
65
|
+
<h1 className="section-title" style={{ marginTop: 0 }}>
|
|
66
|
+
CLI login
|
|
67
|
+
</h1>
|
|
68
|
+
<p className="section-subtitle">Invalid redirect URL.</p>
|
|
69
|
+
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
|
70
|
+
Run the CLI again to start a fresh login.
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
</main>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!state) {
|
|
78
|
+
return (
|
|
79
|
+
<main className="section">
|
|
80
|
+
<div className="card">
|
|
81
|
+
<h1 className="section-title" style={{ marginTop: 0 }}>
|
|
82
|
+
CLI login
|
|
83
|
+
</h1>
|
|
84
|
+
<p className="section-subtitle">Missing state.</p>
|
|
85
|
+
<p className="section-subtitle" style={{ marginBottom: 0 }}>
|
|
86
|
+
Run the CLI again to start a fresh login.
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
</main>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!registry) {
|
|
94
|
+
return (
|
|
95
|
+
<main className="section">
|
|
96
|
+
<div className="card">Missing VITE_CONVEX_SITE_URL configuration.</div>
|
|
97
|
+
</main>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!isAuthenticated || !me) {
|
|
102
|
+
return (
|
|
103
|
+
<main className="section">
|
|
104
|
+
<div className="card">
|
|
105
|
+
<h1 className="section-title" style={{ marginTop: 0 }}>
|
|
106
|
+
CLI login
|
|
107
|
+
</h1>
|
|
108
|
+
<p className="section-subtitle">Sign in to create an API token for the CLI.</p>
|
|
109
|
+
<button
|
|
110
|
+
className="btn btn-primary"
|
|
111
|
+
type="button"
|
|
112
|
+
disabled={isLoading}
|
|
113
|
+
onClick={() => void signIn('github')}
|
|
114
|
+
>
|
|
115
|
+
Sign in with GitHub
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
</main>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<main className="section">
|
|
124
|
+
<div className="card">
|
|
125
|
+
<h1 className="section-title" style={{ marginTop: 0 }}>
|
|
126
|
+
CLI login
|
|
127
|
+
</h1>
|
|
128
|
+
<p className="section-subtitle">{status}</p>
|
|
129
|
+
{token ? (
|
|
130
|
+
<div className="stat" style={{ overflowX: 'auto' }}>
|
|
131
|
+
<div style={{ marginBottom: 8 }}>If redirect fails, copy this token:</div>
|
|
132
|
+
<code>{token}</code>
|
|
133
|
+
</div>
|
|
134
|
+
) : null}
|
|
135
|
+
</div>
|
|
136
|
+
</main>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isAllowedRedirectUri(value: string) {
|
|
141
|
+
if (!value) return false
|
|
142
|
+
let url: URL
|
|
143
|
+
try {
|
|
144
|
+
url = new URL(value)
|
|
145
|
+
} catch {
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
if (url.protocol !== 'http:') return false
|
|
149
|
+
const host = url.hostname.toLowerCase()
|
|
150
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function decodeLabel(value: string | undefined) {
|
|
154
|
+
if (!value) return null
|
|
155
|
+
try {
|
|
156
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
|
157
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
|
158
|
+
const binary = atob(padded)
|
|
159
|
+
const bytes = new Uint8Array(binary.length)
|
|
160
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
|
161
|
+
const decoded = new TextDecoder().decode(bytes)
|
|
162
|
+
const label = decoded.trim()
|
|
163
|
+
if (!label) return null
|
|
164
|
+
return label.slice(0, 80)
|
|
165
|
+
} catch {
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createFileRoute, Link } from '@tanstack/react-router'
|
|
2
|
+
import { useQuery } from 'convex/react'
|
|
3
|
+
import { Package, Plus, Upload } from 'lucide-react'
|
|
4
|
+
import { api } from '../../convex/_generated/api'
|
|
5
|
+
import type { Doc } from '../../convex/_generated/dataModel'
|
|
6
|
+
import type { PublicSkill } from '../lib/publicUser'
|
|
7
|
+
|
|
8
|
+
export const Route = createFileRoute('/dashboard')({
|
|
9
|
+
component: Dashboard,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
function Dashboard() {
|
|
13
|
+
const me = useQuery(api.users.me) as Doc<'users'> | null | undefined
|
|
14
|
+
const mySkills = useQuery(
|
|
15
|
+
api.skills.list,
|
|
16
|
+
me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip',
|
|
17
|
+
) as PublicSkill[] | undefined
|
|
18
|
+
|
|
19
|
+
if (!me) {
|
|
20
|
+
return (
|
|
21
|
+
<main className="section">
|
|
22
|
+
<div className="card">Sign in to access your dashboard.</div>
|
|
23
|
+
</main>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const skills = mySkills ?? []
|
|
28
|
+
const ownerHandle = me.handle ?? me.name ?? me.displayName ?? me._id
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<main className="section">
|
|
32
|
+
<div className="dashboard-header">
|
|
33
|
+
<h1 className="section-title" style={{ margin: 0 }}>
|
|
34
|
+
My Skills
|
|
35
|
+
</h1>
|
|
36
|
+
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
|
37
|
+
<Plus className="h-4 w-4" aria-hidden="true" />
|
|
38
|
+
Upload New Skill
|
|
39
|
+
</Link>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
{skills.length === 0 ? (
|
|
43
|
+
<div className="card dashboard-empty">
|
|
44
|
+
<Package className="dashboard-empty-icon" aria-hidden="true" />
|
|
45
|
+
<h2>No skills yet</h2>
|
|
46
|
+
<p>Upload your first skill to share it with the community.</p>
|
|
47
|
+
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
|
48
|
+
<Upload className="h-4 w-4" aria-hidden="true" />
|
|
49
|
+
Upload a Skill
|
|
50
|
+
</Link>
|
|
51
|
+
</div>
|
|
52
|
+
) : (
|
|
53
|
+
<div className="dashboard-grid">
|
|
54
|
+
{skills.map((skill) => (
|
|
55
|
+
<SkillCard key={skill._id} skill={skill} ownerHandle={ownerHandle} />
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
</main>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function SkillCard({ skill, ownerHandle }: { skill: PublicSkill; ownerHandle: string | null }) {
|
|
64
|
+
return (
|
|
65
|
+
<div className="dashboard-skill-card">
|
|
66
|
+
<div className="dashboard-skill-info">
|
|
67
|
+
<Link
|
|
68
|
+
to="/$owner/$slug"
|
|
69
|
+
params={{ owner: ownerHandle ?? 'unknown', slug: skill.slug }}
|
|
70
|
+
className="dashboard-skill-name"
|
|
71
|
+
>
|
|
72
|
+
{skill.displayName}
|
|
73
|
+
</Link>
|
|
74
|
+
<span className="dashboard-skill-slug">/{skill.slug}</span>
|
|
75
|
+
{skill.summary && <p className="dashboard-skill-description">{skill.summary}</p>}
|
|
76
|
+
<div className="dashboard-skill-stats">
|
|
77
|
+
<span>⤓ {skill.stats.downloads}</span>
|
|
78
|
+
<span>★ {skill.stats.stars}</span>
|
|
79
|
+
<span>{skill.stats.versions} v</span>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
<div className="dashboard-skill-actions">
|
|
83
|
+
<Link to="/upload" search={{ updateSlug: skill.slug }} className="btn btn-sm">
|
|
84
|
+
<Upload className="h-3 w-3" aria-hidden="true" />
|
|
85
|
+
New Version
|
|
86
|
+
</Link>
|
|
87
|
+
<Link
|
|
88
|
+
to="/$owner/$slug"
|
|
89
|
+
params={{ owner: ownerHandle ?? 'unknown', slug: skill.slug }}
|
|
90
|
+
className="btn btn-ghost btn-sm"
|
|
91
|
+
>
|
|
92
|
+
View
|
|
93
|
+
</Link>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
2
|
+
import { useAction } from 'convex/react'
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
import { api } from '../../convex/_generated/api'
|
|
5
|
+
import { formatBytes } from '../lib/uploadUtils'
|
|
6
|
+
import { useAuthStatus } from '../lib/useAuthStatus'
|
|
7
|
+
|
|
8
|
+
export const Route = createFileRoute('/import')({
|
|
9
|
+
component: ImportGitHub,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
type Candidate = {
|
|
13
|
+
path: string
|
|
14
|
+
readmePath: string
|
|
15
|
+
name: string | null
|
|
16
|
+
description: string | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type CandidatePreview = {
|
|
20
|
+
resolved: {
|
|
21
|
+
owner: string
|
|
22
|
+
repo: string
|
|
23
|
+
ref: string
|
|
24
|
+
commit: string
|
|
25
|
+
path: string
|
|
26
|
+
repoUrl: string
|
|
27
|
+
originalUrl: string
|
|
28
|
+
}
|
|
29
|
+
candidate: Candidate
|
|
30
|
+
defaults: {
|
|
31
|
+
selectedPaths: string[]
|
|
32
|
+
slug: string
|
|
33
|
+
displayName: string
|
|
34
|
+
version: string
|
|
35
|
+
tags: string[]
|
|
36
|
+
}
|
|
37
|
+
files: Array<{ path: string; size: number; defaultSelected: boolean }>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ImportGitHub() {
|
|
41
|
+
const { isAuthenticated, isLoading, me } = useAuthStatus()
|
|
42
|
+
const previewImport = useAction(api.githubImport.previewGitHubImport)
|
|
43
|
+
const previewCandidate = useAction(api.githubImport.previewGitHubImportCandidate)
|
|
44
|
+
const importSkill = useAction(api.githubImport.importGitHubSkill)
|
|
45
|
+
const navigate = useNavigate()
|
|
46
|
+
|
|
47
|
+
const [url, setUrl] = useState('')
|
|
48
|
+
const [candidates, setCandidates] = useState<Candidate[]>([])
|
|
49
|
+
const [selectedCandidatePath, setSelectedCandidatePath] = useState<string | null>(null)
|
|
50
|
+
const [preview, setPreview] = useState<CandidatePreview | null>(null)
|
|
51
|
+
const [selected, setSelected] = useState<Record<string, boolean>>({})
|
|
52
|
+
|
|
53
|
+
const [slug, setSlug] = useState('')
|
|
54
|
+
const [displayName, setDisplayName] = useState('')
|
|
55
|
+
const [version, setVersion] = useState('0.1.0')
|
|
56
|
+
const [tags, setTags] = useState('latest')
|
|
57
|
+
|
|
58
|
+
const [status, setStatus] = useState<string | null>(null)
|
|
59
|
+
const [error, setError] = useState<string | null>(null)
|
|
60
|
+
const [isBusy, setIsBusy] = useState(false)
|
|
61
|
+
|
|
62
|
+
const selectedCount = useMemo(() => Object.values(selected).filter(Boolean).length, [selected])
|
|
63
|
+
const selectedBytes = useMemo(() => {
|
|
64
|
+
if (!preview) return 0
|
|
65
|
+
let total = 0
|
|
66
|
+
for (const file of preview.files) {
|
|
67
|
+
if (selected[file.path]) total += file.size
|
|
68
|
+
}
|
|
69
|
+
return total
|
|
70
|
+
}, [preview, selected])
|
|
71
|
+
|
|
72
|
+
const detect = async () => {
|
|
73
|
+
setError(null)
|
|
74
|
+
setStatus(null)
|
|
75
|
+
setPreview(null)
|
|
76
|
+
setCandidates([])
|
|
77
|
+
setSelectedCandidatePath(null)
|
|
78
|
+
setSelected({})
|
|
79
|
+
setIsBusy(true)
|
|
80
|
+
try {
|
|
81
|
+
const result = await previewImport({ url: url.trim() })
|
|
82
|
+
const items = (result.candidates ?? []) as Candidate[]
|
|
83
|
+
setCandidates(items)
|
|
84
|
+
if (items.length === 1) {
|
|
85
|
+
const only = items[0]
|
|
86
|
+
if (only) await loadCandidate(only.path)
|
|
87
|
+
} else {
|
|
88
|
+
setStatus(`Found ${items.length} skills. Pick one.`)
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
setError(e instanceof Error ? e.message : 'Preview failed')
|
|
92
|
+
} finally {
|
|
93
|
+
setIsBusy(false)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const loadCandidate = async (candidatePath: string) => {
|
|
98
|
+
setError(null)
|
|
99
|
+
setStatus(null)
|
|
100
|
+
setPreview(null)
|
|
101
|
+
setSelected({})
|
|
102
|
+
setSelectedCandidatePath(candidatePath)
|
|
103
|
+
setIsBusy(true)
|
|
104
|
+
try {
|
|
105
|
+
const result = (await previewCandidate({
|
|
106
|
+
url: url.trim(),
|
|
107
|
+
candidatePath,
|
|
108
|
+
})) as CandidatePreview
|
|
109
|
+
setPreview(result)
|
|
110
|
+
setSlug(result.defaults.slug)
|
|
111
|
+
setDisplayName(result.defaults.displayName)
|
|
112
|
+
setVersion(result.defaults.version)
|
|
113
|
+
setTags((result.defaults.tags ?? ['latest']).join(','))
|
|
114
|
+
const nextSelected: Record<string, boolean> = {}
|
|
115
|
+
for (const file of result.files) nextSelected[file.path] = file.defaultSelected
|
|
116
|
+
setSelected(nextSelected)
|
|
117
|
+
setStatus('Ready to import.')
|
|
118
|
+
} catch (e) {
|
|
119
|
+
setError(e instanceof Error ? e.message : 'Preview failed')
|
|
120
|
+
} finally {
|
|
121
|
+
setIsBusy(false)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const applyDefaultSelection = () => {
|
|
126
|
+
if (!preview) return
|
|
127
|
+
const set = new Set(preview.defaults.selectedPaths)
|
|
128
|
+
const next: Record<string, boolean> = {}
|
|
129
|
+
for (const file of preview.files) next[file.path] = set.has(file.path)
|
|
130
|
+
setSelected(next)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const selectAll = () => {
|
|
134
|
+
if (!preview) return
|
|
135
|
+
const next: Record<string, boolean> = {}
|
|
136
|
+
for (const file of preview.files) next[file.path] = true
|
|
137
|
+
setSelected(next)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const clearAll = () => {
|
|
141
|
+
if (!preview) return
|
|
142
|
+
const next: Record<string, boolean> = {}
|
|
143
|
+
for (const file of preview.files) next[file.path] = false
|
|
144
|
+
setSelected(next)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const doImport = async () => {
|
|
148
|
+
if (!preview) return
|
|
149
|
+
setIsBusy(true)
|
|
150
|
+
setError(null)
|
|
151
|
+
setStatus('Importing…')
|
|
152
|
+
try {
|
|
153
|
+
const selectedPaths = preview.files.map((file) => file.path).filter((path) => selected[path])
|
|
154
|
+
const tagList = tags
|
|
155
|
+
.split(',')
|
|
156
|
+
.map((tag) => tag.trim())
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
const result = await importSkill({
|
|
159
|
+
url: url.trim(),
|
|
160
|
+
commit: preview.resolved.commit,
|
|
161
|
+
candidatePath: preview.candidate.path,
|
|
162
|
+
selectedPaths,
|
|
163
|
+
slug: slug.trim(),
|
|
164
|
+
displayName: displayName.trim(),
|
|
165
|
+
version: version.trim(),
|
|
166
|
+
tags: tagList,
|
|
167
|
+
})
|
|
168
|
+
const nextSlug = result.slug
|
|
169
|
+
setStatus('Imported.')
|
|
170
|
+
const ownerParam = me?.handle ?? (me?._id ? String(me._id) : 'unknown')
|
|
171
|
+
await navigate({ to: '/$owner/$slug', params: { owner: ownerParam, slug: nextSlug } })
|
|
172
|
+
} catch (e) {
|
|
173
|
+
setError(e instanceof Error ? e.message : 'Import failed')
|
|
174
|
+
setStatus(null)
|
|
175
|
+
} finally {
|
|
176
|
+
setIsBusy(false)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!isAuthenticated) {
|
|
181
|
+
return (
|
|
182
|
+
<main className="section">
|
|
183
|
+
<div className="card">
|
|
184
|
+
{isLoading ? 'Loading…' : 'Sign in to import and publish skills.'}
|
|
185
|
+
</div>
|
|
186
|
+
</main>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<main className="section upload-shell">
|
|
192
|
+
<div className="upload-header">
|
|
193
|
+
<div>
|
|
194
|
+
<div className="upload-kicker">GitHub import</div>
|
|
195
|
+
<h1 className="upload-title">Import from GitHub</h1>
|
|
196
|
+
<p className="upload-subtitle">Public repos only. Detects SKILL.md automatically.</p>
|
|
197
|
+
</div>
|
|
198
|
+
<div className="upload-badge">
|
|
199
|
+
<div>Public only</div>
|
|
200
|
+
<div className="upload-badge-sub">Commit pinned</div>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<div className="upload-card">
|
|
205
|
+
<div className="upload-fields">
|
|
206
|
+
<label className="upload-field" htmlFor="github-url">
|
|
207
|
+
<div className="upload-field-header">
|
|
208
|
+
<strong>GitHub URL</strong>
|
|
209
|
+
<span className="upload-field-hint">Repo, tree path, or blob</span>
|
|
210
|
+
</div>
|
|
211
|
+
<input
|
|
212
|
+
id="github-url"
|
|
213
|
+
className="upload-input"
|
|
214
|
+
value={url}
|
|
215
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
216
|
+
placeholder="https://github.com/owner/repo"
|
|
217
|
+
autoCapitalize="none"
|
|
218
|
+
autoCorrect="off"
|
|
219
|
+
spellCheck={false}
|
|
220
|
+
/>
|
|
221
|
+
</label>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div className="upload-footer">
|
|
225
|
+
<button
|
|
226
|
+
className="btn btn-primary"
|
|
227
|
+
type="button"
|
|
228
|
+
disabled={!url.trim() || isBusy}
|
|
229
|
+
onClick={() => void detect()}
|
|
230
|
+
>
|
|
231
|
+
Detect
|
|
232
|
+
</button>
|
|
233
|
+
{status ? <p className="upload-muted">{status}</p> : null}
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
{error ? (
|
|
237
|
+
<div className="upload-validation">
|
|
238
|
+
<div className="upload-validation-item upload-error">{error}</div>
|
|
239
|
+
</div>
|
|
240
|
+
) : null}
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
{candidates.length > 1 ? (
|
|
244
|
+
<div className="card">
|
|
245
|
+
<h2 style={{ margin: 0 }}>Pick a skill</h2>
|
|
246
|
+
<div className="upload-filelist">
|
|
247
|
+
{candidates.map((candidate) => (
|
|
248
|
+
<label key={candidate.path} className="upload-file">
|
|
249
|
+
<input
|
|
250
|
+
type="radio"
|
|
251
|
+
name="candidate"
|
|
252
|
+
checked={selectedCandidatePath === candidate.path}
|
|
253
|
+
onChange={() => void loadCandidate(candidate.path)}
|
|
254
|
+
disabled={isBusy}
|
|
255
|
+
/>
|
|
256
|
+
<span className="mono">{candidate.path || '(repo root)'}</span>
|
|
257
|
+
<span>
|
|
258
|
+
{candidate.name
|
|
259
|
+
? candidate.name
|
|
260
|
+
: candidate.description
|
|
261
|
+
? candidate.description
|
|
262
|
+
: ''}
|
|
263
|
+
</span>
|
|
264
|
+
</label>
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
) : null}
|
|
269
|
+
|
|
270
|
+
{preview ? (
|
|
271
|
+
<>
|
|
272
|
+
<div className="upload-card">
|
|
273
|
+
<div className="upload-grid">
|
|
274
|
+
<div className="upload-fields">
|
|
275
|
+
<label className="upload-field" htmlFor="slug">
|
|
276
|
+
<div className="upload-field-header">
|
|
277
|
+
<strong>Slug</strong>
|
|
278
|
+
<span className="upload-field-hint">Unique, lowercase</span>
|
|
279
|
+
</div>
|
|
280
|
+
<input
|
|
281
|
+
id="slug"
|
|
282
|
+
className="upload-input"
|
|
283
|
+
value={slug}
|
|
284
|
+
onChange={(e) => setSlug(e.target.value)}
|
|
285
|
+
autoCapitalize="none"
|
|
286
|
+
autoCorrect="off"
|
|
287
|
+
spellCheck={false}
|
|
288
|
+
/>
|
|
289
|
+
</label>
|
|
290
|
+
<label className="upload-field" htmlFor="name">
|
|
291
|
+
<div className="upload-field-header">
|
|
292
|
+
<strong>Display name</strong>
|
|
293
|
+
<span className="upload-field-hint">Shown in listings</span>
|
|
294
|
+
</div>
|
|
295
|
+
<input
|
|
296
|
+
id="name"
|
|
297
|
+
className="upload-input"
|
|
298
|
+
value={displayName}
|
|
299
|
+
onChange={(e) => setDisplayName(e.target.value)}
|
|
300
|
+
/>
|
|
301
|
+
</label>
|
|
302
|
+
<div className="upload-row">
|
|
303
|
+
<label className="upload-field" htmlFor="version">
|
|
304
|
+
<div className="upload-field-header">
|
|
305
|
+
<strong>Version</strong>
|
|
306
|
+
<span className="upload-field-hint">Semver</span>
|
|
307
|
+
</div>
|
|
308
|
+
<input
|
|
309
|
+
id="version"
|
|
310
|
+
className="upload-input"
|
|
311
|
+
value={version}
|
|
312
|
+
onChange={(e) => setVersion(e.target.value)}
|
|
313
|
+
autoCapitalize="none"
|
|
314
|
+
autoCorrect="off"
|
|
315
|
+
spellCheck={false}
|
|
316
|
+
/>
|
|
317
|
+
</label>
|
|
318
|
+
<label className="upload-field" htmlFor="tags">
|
|
319
|
+
<div className="upload-field-header">
|
|
320
|
+
<strong>Tags</strong>
|
|
321
|
+
<span className="upload-field-hint">Comma-separated</span>
|
|
322
|
+
</div>
|
|
323
|
+
<input
|
|
324
|
+
id="tags"
|
|
325
|
+
className="upload-input"
|
|
326
|
+
value={tags}
|
|
327
|
+
onChange={(e) => setTags(e.target.value)}
|
|
328
|
+
autoCapitalize="none"
|
|
329
|
+
autoCorrect="off"
|
|
330
|
+
spellCheck={false}
|
|
331
|
+
/>
|
|
332
|
+
</label>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
<aside className="upload-side">
|
|
336
|
+
<div className="upload-summary">
|
|
337
|
+
<div className="upload-requirement ok">Commit pinned</div>
|
|
338
|
+
<div className="upload-muted">
|
|
339
|
+
{preview.resolved.owner}/{preview.resolved.repo}@
|
|
340
|
+
{preview.resolved.commit.slice(0, 7)}
|
|
341
|
+
</div>
|
|
342
|
+
<div className="upload-muted mono">{preview.candidate.path || 'repo root'}</div>
|
|
343
|
+
</div>
|
|
344
|
+
</aside>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<div className="card">
|
|
349
|
+
<div
|
|
350
|
+
style={{
|
|
351
|
+
display: 'flex',
|
|
352
|
+
justifyContent: 'space-between',
|
|
353
|
+
gap: 12,
|
|
354
|
+
flexWrap: 'wrap',
|
|
355
|
+
}}
|
|
356
|
+
>
|
|
357
|
+
<h2 style={{ margin: 0 }}>Files</h2>
|
|
358
|
+
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
|
359
|
+
<button
|
|
360
|
+
className="btn"
|
|
361
|
+
type="button"
|
|
362
|
+
disabled={isBusy}
|
|
363
|
+
onClick={applyDefaultSelection}
|
|
364
|
+
>
|
|
365
|
+
Select referenced
|
|
366
|
+
</button>
|
|
367
|
+
<button className="btn" type="button" disabled={isBusy} onClick={selectAll}>
|
|
368
|
+
Select all
|
|
369
|
+
</button>
|
|
370
|
+
<button className="btn" type="button" disabled={isBusy} onClick={clearAll}>
|
|
371
|
+
Clear
|
|
372
|
+
</button>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
<div className="upload-muted">
|
|
376
|
+
Selected: {selectedCount}/{preview.files.length} • {formatBytes(selectedBytes)}
|
|
377
|
+
</div>
|
|
378
|
+
<div className="file-list">
|
|
379
|
+
{preview.files.map((file) => (
|
|
380
|
+
<label key={file.path} className="file-row">
|
|
381
|
+
<input
|
|
382
|
+
type="checkbox"
|
|
383
|
+
checked={Boolean(selected[file.path])}
|
|
384
|
+
onChange={() =>
|
|
385
|
+
setSelected((prev) => ({ ...prev, [file.path]: !prev[file.path] }))
|
|
386
|
+
}
|
|
387
|
+
disabled={isBusy}
|
|
388
|
+
/>
|
|
389
|
+
<span className="mono file-path">{file.path}</span>
|
|
390
|
+
<span className="file-meta">{formatBytes(file.size)}</span>
|
|
391
|
+
</label>
|
|
392
|
+
))}
|
|
393
|
+
</div>
|
|
394
|
+
<div className="upload-footer">
|
|
395
|
+
<button
|
|
396
|
+
className="btn btn-primary"
|
|
397
|
+
type="button"
|
|
398
|
+
disabled={
|
|
399
|
+
isBusy ||
|
|
400
|
+
!slug.trim() ||
|
|
401
|
+
!displayName.trim() ||
|
|
402
|
+
!version.trim() ||
|
|
403
|
+
selectedCount === 0
|
|
404
|
+
}
|
|
405
|
+
onClick={() => void doImport()}
|
|
406
|
+
>
|
|
407
|
+
Import + publish
|
|
408
|
+
</button>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
</>
|
|
412
|
+
) : null}
|
|
413
|
+
</main>
|
|
414
|
+
)
|
|
415
|
+
}
|