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,310 @@
|
|
|
1
|
+
/* @vitest-environment node */
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import type { GlobalOpts } from '../types.js'
|
|
5
|
+
|
|
6
|
+
const mockIntro = vi.fn()
|
|
7
|
+
const mockOutro = vi.fn()
|
|
8
|
+
const mockLog = vi.fn()
|
|
9
|
+
const mockMultiselect = vi.fn(async (_args?: unknown) => [] as string[])
|
|
10
|
+
let interactive = false
|
|
11
|
+
|
|
12
|
+
const defaultFindSkillFolders = async (root: string) => {
|
|
13
|
+
if (!root.endsWith('/scan')) return []
|
|
14
|
+
return [
|
|
15
|
+
{ folder: '/scan/new-skill', slug: 'new-skill', displayName: 'New Skill' },
|
|
16
|
+
{ folder: '/scan/synced-skill', slug: 'synced-skill', displayName: 'Synced Skill' },
|
|
17
|
+
{ folder: '/scan/update-skill', slug: 'update-skill', displayName: 'Update Skill' },
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
vi.mock('@clack/prompts', () => ({
|
|
22
|
+
intro: (value: string) => mockIntro(value),
|
|
23
|
+
outro: (value: string) => mockOutro(value),
|
|
24
|
+
multiselect: (args: unknown) => mockMultiselect(args),
|
|
25
|
+
text: vi.fn(async () => ''),
|
|
26
|
+
isCancel: () => false,
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
vi.mock('../../config.js', () => ({
|
|
30
|
+
readGlobalConfig: vi.fn(async () => ({ registry: 'https://pilothub.com', token: 'tkn' })),
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
const mockGetRegistry = vi.fn(async () => 'https://pilothub.com')
|
|
34
|
+
vi.mock('../registry.js', () => ({
|
|
35
|
+
getRegistry: () => mockGetRegistry(),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
const mockApiRequest = vi.fn()
|
|
39
|
+
vi.mock('../../http.js', () => ({
|
|
40
|
+
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
|
|
41
|
+
mockApiRequest(registry, args, schema),
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
const mockFail = vi.fn((message: string) => {
|
|
45
|
+
throw new Error(message)
|
|
46
|
+
})
|
|
47
|
+
const mockSpinner = { succeed: vi.fn(), fail: vi.fn(), stop: vi.fn() }
|
|
48
|
+
vi.mock('../ui.js', () => ({
|
|
49
|
+
createSpinner: vi.fn(() => mockSpinner),
|
|
50
|
+
fail: (message: string) => mockFail(message),
|
|
51
|
+
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
|
52
|
+
isInteractive: () => interactive,
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
vi.mock('../scanSkills.js', () => ({
|
|
56
|
+
findSkillFolders: vi.fn(defaultFindSkillFolders),
|
|
57
|
+
getFallbackSkillRoots: vi.fn(() => []),
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
const mockResolvePilotbotSkillRoots = vi.fn(
|
|
61
|
+
async () =>
|
|
62
|
+
({
|
|
63
|
+
roots: [] as string[],
|
|
64
|
+
labels: {} as Record<string, string>,
|
|
65
|
+
}) as const,
|
|
66
|
+
)
|
|
67
|
+
vi.mock('../pilotbotConfig.js', () => ({
|
|
68
|
+
resolvePilotbotSkillRoots: () => mockResolvePilotbotSkillRoots(),
|
|
69
|
+
}))
|
|
70
|
+
|
|
71
|
+
vi.mock('../../skills.js', async () => {
|
|
72
|
+
const actual = await vi.importActual<typeof import('../../skills.js')>('../../skills.js')
|
|
73
|
+
return {
|
|
74
|
+
...actual,
|
|
75
|
+
listTextFiles: vi.fn(async (folder: string) => [
|
|
76
|
+
{ relPath: 'SKILL.md', bytes: new TextEncoder().encode(folder) },
|
|
77
|
+
]),
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const mockCmdPublish = vi.fn()
|
|
82
|
+
vi.mock('./publish.js', () => ({
|
|
83
|
+
cmdPublish: (...args: unknown[]) => mockCmdPublish(...args),
|
|
84
|
+
}))
|
|
85
|
+
|
|
86
|
+
const { cmdSync } = await import('./sync.js')
|
|
87
|
+
|
|
88
|
+
function makeOpts(): GlobalOpts {
|
|
89
|
+
return {
|
|
90
|
+
workdir: '/work',
|
|
91
|
+
dir: '/work/skills',
|
|
92
|
+
site: 'https://pilothub.com',
|
|
93
|
+
registry: 'https://pilothub.com',
|
|
94
|
+
registrySource: 'default',
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
afterEach(async () => {
|
|
99
|
+
vi.clearAllMocks()
|
|
100
|
+
const { findSkillFolders } = await import('../scanSkills.js')
|
|
101
|
+
vi.mocked(findSkillFolders).mockImplementation(defaultFindSkillFolders)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
105
|
+
mockLog(args.map(String).join(' '))
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('cmdSync', () => {
|
|
109
|
+
it('classifies skills as new/update/synced (dry-run, mocked HTTP)', async () => {
|
|
110
|
+
interactive = false
|
|
111
|
+
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
|
112
|
+
if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } }
|
|
113
|
+
if (args.path === '/api/cli/telemetry/sync') return { ok: true }
|
|
114
|
+
if (args.path.startsWith('/api/v1/resolve?')) {
|
|
115
|
+
const u = new URL(`https://x.test${args.path}`)
|
|
116
|
+
const slug = u.searchParams.get('slug')
|
|
117
|
+
if (slug === 'new-skill') {
|
|
118
|
+
throw new Error('Skill not found')
|
|
119
|
+
}
|
|
120
|
+
if (slug === 'synced-skill') {
|
|
121
|
+
return { match: { version: '1.2.3' }, latestVersion: { version: '1.2.3' } }
|
|
122
|
+
}
|
|
123
|
+
if (slug === 'update-skill') {
|
|
124
|
+
return { match: null, latestVersion: { version: '1.0.0' } }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`Unexpected apiRequest: ${args.path}`)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
await cmdSync(makeOpts(), { root: ['/scan'], all: true, dryRun: true }, true)
|
|
131
|
+
|
|
132
|
+
expect(mockCmdPublish).not.toHaveBeenCalled()
|
|
133
|
+
|
|
134
|
+
const output = mockLog.mock.calls.map((call) => String(call[0])).join('\n')
|
|
135
|
+
expect(output).toMatch(/Already synced/)
|
|
136
|
+
expect(output).toMatch(/synced-skill/)
|
|
137
|
+
|
|
138
|
+
const dryRunOutro = mockOutro.mock.calls.at(-1)?.[0]
|
|
139
|
+
expect(String(dryRunOutro)).toMatch(/Dry run: would upload 2 skill/)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('prints bullet lists and selects all actionable by default', async () => {
|
|
143
|
+
interactive = true
|
|
144
|
+
mockMultiselect.mockImplementation(async (args?: unknown) => {
|
|
145
|
+
const { initialValues } = args as { initialValues: string[] }
|
|
146
|
+
return initialValues
|
|
147
|
+
})
|
|
148
|
+
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
|
149
|
+
if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } }
|
|
150
|
+
if (args.path === '/api/cli/telemetry/sync') return { ok: true }
|
|
151
|
+
if (args.path.startsWith('/api/v1/resolve?')) {
|
|
152
|
+
const u = new URL(`https://x.test${args.path}`)
|
|
153
|
+
const slug = u.searchParams.get('slug')
|
|
154
|
+
if (slug === 'new-skill') {
|
|
155
|
+
throw new Error('Skill not found')
|
|
156
|
+
}
|
|
157
|
+
if (slug === 'synced-skill') {
|
|
158
|
+
return { match: { version: '1.2.3' }, latestVersion: { version: '1.2.3' } }
|
|
159
|
+
}
|
|
160
|
+
if (slug === 'update-skill') {
|
|
161
|
+
return { match: null, latestVersion: { version: '1.0.0' } }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
throw new Error(`Unexpected apiRequest: ${args.path}`)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
await cmdSync(makeOpts(), { root: ['/scan'], all: false, dryRun: false, bump: 'patch' }, true)
|
|
168
|
+
|
|
169
|
+
const output = mockLog.mock.calls.map((call) => String(call[0])).join('\n')
|
|
170
|
+
expect(output).toMatch(/To sync/)
|
|
171
|
+
expect(output).toMatch(/- new-skill/)
|
|
172
|
+
expect(output).toMatch(/- update-skill/)
|
|
173
|
+
expect(output).toMatch(/Already synced/)
|
|
174
|
+
expect(output).toMatch(/- synced-skill/)
|
|
175
|
+
|
|
176
|
+
const lastCall = mockMultiselect.mock.calls.at(-1)
|
|
177
|
+
const promptArgs = lastCall ? (lastCall[0] as { initialValues: string[] }) : undefined
|
|
178
|
+
expect(promptArgs?.initialValues.length).toBe(2)
|
|
179
|
+
expect(mockCmdPublish).toHaveBeenCalledTimes(2)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('shows condensed synced list when nothing to sync', async () => {
|
|
183
|
+
interactive = false
|
|
184
|
+
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
|
185
|
+
if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } }
|
|
186
|
+
if (args.path === '/api/cli/telemetry/sync') return { ok: true }
|
|
187
|
+
if (args.path.startsWith('/api/v1/resolve?')) {
|
|
188
|
+
return { match: { version: '1.0.0' }, latestVersion: { version: '1.0.0' } }
|
|
189
|
+
}
|
|
190
|
+
throw new Error(`Unexpected apiRequest: ${args.path}`)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
await cmdSync(makeOpts(), { root: ['/scan'], all: true, dryRun: false }, true)
|
|
194
|
+
|
|
195
|
+
const output = mockLog.mock.calls.map((call) => String(call[0])).join('\n')
|
|
196
|
+
expect(output).toMatch(/Already synced/)
|
|
197
|
+
expect(output).toMatch(/new-skill@1.0.0/)
|
|
198
|
+
expect(output).toMatch(/synced-skill@1.0.0/)
|
|
199
|
+
expect(output).not.toMatch(/\n-/)
|
|
200
|
+
|
|
201
|
+
const outro = mockOutro.mock.calls.at(-1)?.[0]
|
|
202
|
+
expect(String(outro)).toMatch(/Nothing to sync/)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('dedupes duplicate slugs before publishing', async () => {
|
|
206
|
+
interactive = false
|
|
207
|
+
const { findSkillFolders } = await import('../scanSkills.js')
|
|
208
|
+
vi.mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
|
209
|
+
if (!root.endsWith('/scan')) return []
|
|
210
|
+
return [
|
|
211
|
+
{ folder: '/scan/dup-skill', slug: 'dup-skill', displayName: 'Dup Skill' },
|
|
212
|
+
{ folder: '/scan/dup-skill-copy', slug: 'dup-skill', displayName: 'Dup Skill' },
|
|
213
|
+
]
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
|
217
|
+
if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } }
|
|
218
|
+
if (args.path === '/api/cli/telemetry/sync') return { ok: true }
|
|
219
|
+
if (args.path.startsWith('/api/v1/resolve?')) {
|
|
220
|
+
return { match: null, latestVersion: null }
|
|
221
|
+
}
|
|
222
|
+
throw new Error(`Unexpected apiRequest: ${args.path}`)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
await cmdSync(makeOpts(), { root: ['/scan'], all: true, dryRun: false }, true)
|
|
226
|
+
|
|
227
|
+
expect(mockCmdPublish).toHaveBeenCalledTimes(1)
|
|
228
|
+
const output = mockLog.mock.calls.map((call) => String(call[0])).join('\n')
|
|
229
|
+
expect(output).toMatch(/Skipped duplicate slugs/)
|
|
230
|
+
expect(output).toMatch(/dup-skill/)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('prints labeled roots when pilotbot roots are detected', async () => {
|
|
234
|
+
interactive = false
|
|
235
|
+
mockResolvePilotbotSkillRoots.mockResolvedValueOnce({
|
|
236
|
+
roots: ['/auto'],
|
|
237
|
+
labels: { '/auto': 'Agent: Work' },
|
|
238
|
+
})
|
|
239
|
+
const { findSkillFolders } = await import('../scanSkills.js')
|
|
240
|
+
vi.mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
|
241
|
+
if (root === '/auto') {
|
|
242
|
+
return [{ folder: '/auto/alpha', slug: 'alpha', displayName: 'Alpha' }]
|
|
243
|
+
}
|
|
244
|
+
return []
|
|
245
|
+
})
|
|
246
|
+
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
|
247
|
+
if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } }
|
|
248
|
+
if (args.path === '/api/cli/telemetry/sync') return { ok: true }
|
|
249
|
+
if (args.path.startsWith('/api/v1/resolve?')) {
|
|
250
|
+
throw new Error('Skill not found')
|
|
251
|
+
}
|
|
252
|
+
throw new Error(`Unexpected apiRequest: ${args.path}`)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
await cmdSync(makeOpts(), { all: true, dryRun: true }, true)
|
|
256
|
+
|
|
257
|
+
const output = mockLog.mock.calls.map((call) => String(call[0])).join('\n')
|
|
258
|
+
expect(output).toMatch(/Roots with skills/)
|
|
259
|
+
expect(output).toMatch(/Agent: Work/)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('allows empty changelog for updates (interactive)', async () => {
|
|
263
|
+
interactive = true
|
|
264
|
+
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
|
265
|
+
if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } }
|
|
266
|
+
if (args.path === '/api/cli/telemetry/sync') return { ok: true }
|
|
267
|
+
if (args.path.startsWith('/api/v1/resolve?')) {
|
|
268
|
+
const u = new URL(`https://x.test${args.path}`)
|
|
269
|
+
const slug = u.searchParams.get('slug')
|
|
270
|
+
if (slug === 'new-skill') {
|
|
271
|
+
throw new Error('Skill not found')
|
|
272
|
+
}
|
|
273
|
+
if (slug === 'synced-skill') {
|
|
274
|
+
return { match: { version: '1.2.3' }, latestVersion: { version: '1.2.3' } }
|
|
275
|
+
}
|
|
276
|
+
if (slug === 'update-skill') {
|
|
277
|
+
return { match: null, latestVersion: { version: '1.0.0' } }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
throw new Error(`Unexpected apiRequest: ${args.path}`)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
await cmdSync(makeOpts(), { root: ['/scan'], all: true, dryRun: false, bump: 'patch' }, true)
|
|
284
|
+
|
|
285
|
+
const calls = mockCmdPublish.mock.calls.map(
|
|
286
|
+
(call) => call[2] as { slug: string; changelog: string },
|
|
287
|
+
)
|
|
288
|
+
const update = calls.find((c) => c.slug === 'update-skill')
|
|
289
|
+
if (!update) throw new Error('Missing update-skill publish')
|
|
290
|
+
expect(update.changelog).toBe('')
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('skips telemetry when PILOTHUB_DISABLE_TELEMETRY is set', async () => {
|
|
294
|
+
interactive = false
|
|
295
|
+
process.env.PILOTHUB_DISABLE_TELEMETRY = '1'
|
|
296
|
+
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
|
297
|
+
if (args.path === '/api/v1/whoami') return { user: { handle: 'steipete' } }
|
|
298
|
+
if (args.path.startsWith('/api/v1/resolve?')) {
|
|
299
|
+
return { match: { version: '1.0.0' }, latestVersion: { version: '1.0.0' } }
|
|
300
|
+
}
|
|
301
|
+
throw new Error(`Unexpected apiRequest: ${args.path}`)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
await cmdSync(makeOpts(), { root: ['/scan'], all: true, dryRun: true }, true)
|
|
305
|
+
expect(
|
|
306
|
+
mockApiRequest.mock.calls.some((call) => call[1]?.path === '/api/cli/telemetry/sync'),
|
|
307
|
+
).toBe(false)
|
|
308
|
+
delete process.env.PILOTHUB_DISABLE_TELEMETRY
|
|
309
|
+
})
|
|
310
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { intro, outro } from '@clack/prompts'
|
|
2
|
+
import { readGlobalConfig } from '../../config.js'
|
|
3
|
+
import { hashSkillFiles, listTextFiles, readSkillOrigin } from '../../skills.js'
|
|
4
|
+
import { resolvePilotbotSkillRoots } from '../pilotbotConfig.js'
|
|
5
|
+
import { getFallbackSkillRoots } from '../scanSkills.js'
|
|
6
|
+
import type { GlobalOpts } from '../types.js'
|
|
7
|
+
import { createSpinner, fail, formatError, isInteractive } from '../ui.js'
|
|
8
|
+
import { cmdPublish } from './publish.js'
|
|
9
|
+
import {
|
|
10
|
+
buildScanRoots,
|
|
11
|
+
checkRegistrySyncState,
|
|
12
|
+
dedupeSkillsBySlug,
|
|
13
|
+
formatActionableLine,
|
|
14
|
+
formatBulletList,
|
|
15
|
+
formatCommaList,
|
|
16
|
+
formatList,
|
|
17
|
+
formatSyncedDisplay,
|
|
18
|
+
formatSyncedSummary,
|
|
19
|
+
getRegistryWithAuth,
|
|
20
|
+
mapWithConcurrency,
|
|
21
|
+
mergeScan,
|
|
22
|
+
normalizeConcurrency,
|
|
23
|
+
printSection,
|
|
24
|
+
reportTelemetryIfEnabled,
|
|
25
|
+
resolvePublishMeta,
|
|
26
|
+
scanRootsWithLabels,
|
|
27
|
+
selectToUpload,
|
|
28
|
+
} from './syncHelpers.js'
|
|
29
|
+
import type { Candidate, LocalSkill, SyncOptions } from './syncTypes.js'
|
|
30
|
+
|
|
31
|
+
export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllowed: boolean) {
|
|
32
|
+
const allowPrompt = isInteractive() && inputAllowed !== false
|
|
33
|
+
intro('PilotHub sync')
|
|
34
|
+
|
|
35
|
+
const cfg = await readGlobalConfig()
|
|
36
|
+
const token = cfg?.token
|
|
37
|
+
if (!token) fail('Not logged in. Run: pilothub login')
|
|
38
|
+
|
|
39
|
+
const registry = await getRegistryWithAuth(opts, token)
|
|
40
|
+
const selectedRoots = buildScanRoots(opts, options.root)
|
|
41
|
+
const pilotbotRoots = await resolvePilotbotSkillRoots()
|
|
42
|
+
const combinedRoots = Array.from(
|
|
43
|
+
new Set([...selectedRoots, ...pilotbotRoots.roots].map((root) => root.trim()).filter(Boolean)),
|
|
44
|
+
)
|
|
45
|
+
const concurrency = normalizeConcurrency(options.concurrency)
|
|
46
|
+
|
|
47
|
+
const spinner = createSpinner('Scanning for local skills')
|
|
48
|
+
const primaryScan = await scanRootsWithLabels(combinedRoots, pilotbotRoots.labels)
|
|
49
|
+
let scan = primaryScan
|
|
50
|
+
let telemetryScan = primaryScan
|
|
51
|
+
if (primaryScan.skills.length === 0) {
|
|
52
|
+
const fallback = getFallbackSkillRoots(opts.workdir)
|
|
53
|
+
const fallbackScan = await scanRootsWithLabels(fallback)
|
|
54
|
+
spinner.stop()
|
|
55
|
+
telemetryScan = mergeScan(primaryScan, fallbackScan)
|
|
56
|
+
scan = fallbackScan
|
|
57
|
+
if (fallbackScan.skills.length === 0)
|
|
58
|
+
fail('No skills found (checked workdir and known Pilotbot/Pilot locations)')
|
|
59
|
+
printSection(
|
|
60
|
+
`No skills in workdir. Found ${fallbackScan.skills.length} in fallback locations.`,
|
|
61
|
+
formatList(fallbackScan.rootsWithSkills, 10),
|
|
62
|
+
)
|
|
63
|
+
} else {
|
|
64
|
+
spinner.stop()
|
|
65
|
+
const labeledRoots = primaryScan.rootsWithSkills
|
|
66
|
+
.map((root) => {
|
|
67
|
+
const label = primaryScan.rootLabels?.[root]
|
|
68
|
+
return label ? `${label} (${root})` : root
|
|
69
|
+
})
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
if (labeledRoots.length > 0) {
|
|
72
|
+
printSection('Roots with skills', formatList(labeledRoots, 10))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const deduped = dedupeSkillsBySlug(scan.skills)
|
|
76
|
+
const skills = deduped.skills
|
|
77
|
+
if (deduped.duplicates.length > 0) {
|
|
78
|
+
printSection('Skipped duplicate slugs', formatCommaList(deduped.duplicates, 16))
|
|
79
|
+
}
|
|
80
|
+
const parsingSpinner = createSpinner('Parsing local skills')
|
|
81
|
+
const locals: LocalSkill[] = []
|
|
82
|
+
try {
|
|
83
|
+
let done = 0
|
|
84
|
+
const parsed = await mapWithConcurrency(skills, Math.min(concurrency, 12), async (skill) => {
|
|
85
|
+
const filesOnDisk = await listTextFiles(skill.folder)
|
|
86
|
+
const hashed = hashSkillFiles(filesOnDisk)
|
|
87
|
+
const origin = await readSkillOrigin(skill.folder)
|
|
88
|
+
done += 1
|
|
89
|
+
parsingSpinner.text = `Parsing local skills ${done}/${skills.length}`
|
|
90
|
+
return {
|
|
91
|
+
...skill,
|
|
92
|
+
fingerprint: hashed.fingerprint,
|
|
93
|
+
fileCount: filesOnDisk.length,
|
|
94
|
+
origin,
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
locals.push(...parsed)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
parsingSpinner.fail(formatError(error))
|
|
100
|
+
throw error
|
|
101
|
+
} finally {
|
|
102
|
+
parsingSpinner.stop()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const candidatesSpinner = createSpinner('Checking registry sync state')
|
|
106
|
+
const candidates: Candidate[] = []
|
|
107
|
+
const resolveSupport: { value: boolean | null } = { value: null }
|
|
108
|
+
try {
|
|
109
|
+
let done = 0
|
|
110
|
+
const resolved = await mapWithConcurrency(locals, Math.min(concurrency, 16), async (skill) => {
|
|
111
|
+
try {
|
|
112
|
+
return await checkRegistrySyncState(registry, skill, resolveSupport)
|
|
113
|
+
} finally {
|
|
114
|
+
done += 1
|
|
115
|
+
candidatesSpinner.text = `Checking registry sync state ${done}/${locals.length}`
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
candidates.push(...resolved)
|
|
119
|
+
} catch (error) {
|
|
120
|
+
candidatesSpinner.fail(formatError(error))
|
|
121
|
+
throw error
|
|
122
|
+
} finally {
|
|
123
|
+
candidatesSpinner.stop()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await reportTelemetryIfEnabled({
|
|
127
|
+
token,
|
|
128
|
+
registry,
|
|
129
|
+
scan: telemetryScan,
|
|
130
|
+
candidates,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const synced = candidates.filter((candidate) => candidate.status === 'synced')
|
|
134
|
+
const actionable = candidates.filter((candidate) => candidate.status !== 'synced')
|
|
135
|
+
const bump = options.bump ?? 'patch'
|
|
136
|
+
|
|
137
|
+
if (actionable.length === 0) {
|
|
138
|
+
if (synced.length > 0) {
|
|
139
|
+
printSection('Already synced', formatCommaList(synced.map(formatSyncedSummary), 16))
|
|
140
|
+
}
|
|
141
|
+
outro('Nothing to sync.')
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
printSection(
|
|
146
|
+
'To sync',
|
|
147
|
+
formatBulletList(
|
|
148
|
+
actionable.map((candidate) => formatActionableLine(candidate, bump)),
|
|
149
|
+
20,
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
if (synced.length > 0) {
|
|
153
|
+
printSection('Already synced', formatSyncedDisplay(synced))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const selected = await selectToUpload(actionable, {
|
|
157
|
+
allowPrompt,
|
|
158
|
+
all: Boolean(options.all),
|
|
159
|
+
bump,
|
|
160
|
+
})
|
|
161
|
+
if (selected.length === 0) {
|
|
162
|
+
outro('Nothing selected.')
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (options.dryRun) {
|
|
167
|
+
outro(`Dry run: would upload ${selected.length} skill(s).`)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const tags = options.tags ?? 'latest'
|
|
172
|
+
|
|
173
|
+
for (const skill of selected) {
|
|
174
|
+
const { publishVersion, changelog } = await resolvePublishMeta(skill, {
|
|
175
|
+
bump,
|
|
176
|
+
allowPrompt,
|
|
177
|
+
changelogFlag: options.changelog,
|
|
178
|
+
})
|
|
179
|
+
const forkOf =
|
|
180
|
+
skill.origin && normalizeRegistry(skill.origin.registry) === normalizeRegistry(registry)
|
|
181
|
+
? skill.origin.slug !== skill.slug
|
|
182
|
+
? `${skill.origin.slug}@${skill.origin.installedVersion}`
|
|
183
|
+
: undefined
|
|
184
|
+
: undefined
|
|
185
|
+
await cmdPublish(opts, skill.folder, {
|
|
186
|
+
slug: skill.slug,
|
|
187
|
+
name: skill.displayName,
|
|
188
|
+
version: publishVersion,
|
|
189
|
+
changelog,
|
|
190
|
+
tags,
|
|
191
|
+
forkOf,
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
outro(`Uploaded ${selected.length} skill(s).`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeRegistry(value: string) {
|
|
199
|
+
return value.trim().replace(/\/+$/, '').toLowerCase()
|
|
200
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* @vitest-environment node */
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
vi.mock('../scanSkills.js', () => ({
|
|
5
|
+
findSkillFolders: vi.fn(async (root: string) => {
|
|
6
|
+
if (root.endsWith('/with-skill')) {
|
|
7
|
+
return [{ folder: `${root}/demo`, slug: 'demo', displayName: 'Demo' }]
|
|
8
|
+
}
|
|
9
|
+
return []
|
|
10
|
+
}),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
const { scanRootsWithLabels } = await import('./syncHelpers.js')
|
|
14
|
+
|
|
15
|
+
describe('scanRootsWithLabels', () => {
|
|
16
|
+
it('attaches labels to roots with skills', async () => {
|
|
17
|
+
const roots = ['/tmp/with-skill', '/tmp/empty', '/tmp/with-skill']
|
|
18
|
+
const labels = { '/tmp/with-skill': 'Agent: Work' }
|
|
19
|
+
|
|
20
|
+
const result = await scanRootsWithLabels(roots, labels)
|
|
21
|
+
|
|
22
|
+
expect(result.rootsWithSkills).toEqual(['/tmp/with-skill'])
|
|
23
|
+
expect(result.rootLabels).toEqual({ '/tmp/with-skill': 'Agent: Work' })
|
|
24
|
+
expect(result.skills.map((skill) => skill.slug)).toEqual(['demo'])
|
|
25
|
+
})
|
|
26
|
+
})
|