pilothub 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/.env.local.example +19 -0
  2. package/.github/workflows/ci.yml +40 -0
  3. package/.oxlintrc.json +3 -0
  4. package/AGENTS.md +45 -0
  5. package/CHANGELOG.md +138 -0
  6. package/DEPRECATIONS.md +7 -0
  7. package/LICENSE +21 -0
  8. package/README.md +150 -0
  9. package/biome.json +41 -0
  10. package/convex/_generated/api.d.ts +153 -0
  11. package/convex/_generated/api.js +23 -0
  12. package/convex/_generated/dataModel.d.ts +60 -0
  13. package/convex/_generated/server.d.ts +143 -0
  14. package/convex/_generated/server.js +93 -0
  15. package/convex/auth.config.ts +8 -0
  16. package/convex/auth.ts +19 -0
  17. package/convex/comments.ts +88 -0
  18. package/convex/crons.ts +34 -0
  19. package/convex/devSeed.ts +459 -0
  20. package/convex/devSeedExtra.ts +541 -0
  21. package/convex/downloads.ts +78 -0
  22. package/convex/githubBackups.ts +170 -0
  23. package/convex/githubBackupsNode.ts +183 -0
  24. package/convex/githubImport.ts +317 -0
  25. package/convex/githubSoulBackups.ts +170 -0
  26. package/convex/githubSoulBackupsNode.ts +186 -0
  27. package/convex/http.ts +194 -0
  28. package/convex/httpApi.handlers.test.ts +488 -0
  29. package/convex/httpApi.test.ts +70 -0
  30. package/convex/httpApi.ts +305 -0
  31. package/convex/httpApiV1.handlers.test.ts +584 -0
  32. package/convex/httpApiV1.ts +1172 -0
  33. package/convex/leaderboards.ts +39 -0
  34. package/convex/lib/access.ts +36 -0
  35. package/convex/lib/apiTokenAuth.ts +36 -0
  36. package/convex/lib/badges.ts +50 -0
  37. package/convex/lib/changelog.test.ts +34 -0
  38. package/convex/lib/changelog.ts +278 -0
  39. package/convex/lib/embeddings.ts +38 -0
  40. package/convex/lib/githubBackup.ts +443 -0
  41. package/convex/lib/githubImport.test.ts +247 -0
  42. package/convex/lib/githubImport.ts +425 -0
  43. package/convex/lib/githubSoulBackup.ts +443 -0
  44. package/convex/lib/leaderboards.ts +103 -0
  45. package/convex/lib/moderation.ts +42 -0
  46. package/convex/lib/public.ts +89 -0
  47. package/convex/lib/searchText.test.ts +46 -0
  48. package/convex/lib/searchText.ts +27 -0
  49. package/convex/lib/skillBackfill.test.ts +34 -0
  50. package/convex/lib/skillBackfill.ts +67 -0
  51. package/convex/lib/skillPublish.test.ts +28 -0
  52. package/convex/lib/skillPublish.ts +284 -0
  53. package/convex/lib/skillStats.ts +80 -0
  54. package/convex/lib/skills.test.ts +197 -0
  55. package/convex/lib/skills.ts +273 -0
  56. package/convex/lib/soulChangelog.ts +273 -0
  57. package/convex/lib/soulPublish.ts +236 -0
  58. package/convex/lib/tokens.test.ts +33 -0
  59. package/convex/lib/tokens.ts +51 -0
  60. package/convex/lib/webhooks.test.ts +91 -0
  61. package/convex/lib/webhooks.ts +112 -0
  62. package/convex/maintenance.test.ts +270 -0
  63. package/convex/maintenance.ts +840 -0
  64. package/convex/rateLimits.ts +50 -0
  65. package/convex/schema.ts +472 -0
  66. package/convex/search.test.ts +12 -0
  67. package/convex/search.ts +254 -0
  68. package/convex/seed.test.ts +37 -0
  69. package/convex/seed.ts +254 -0
  70. package/convex/seedSouls.ts +111 -0
  71. package/convex/skillStatEvents.ts +568 -0
  72. package/convex/skills.ts +1606 -0
  73. package/convex/soulComments.ts +88 -0
  74. package/convex/soulDownloads.ts +14 -0
  75. package/convex/soulStars.ts +71 -0
  76. package/convex/souls.ts +570 -0
  77. package/convex/stars.ts +108 -0
  78. package/convex/statsMaintenance.ts +205 -0
  79. package/convex/telemetry.ts +434 -0
  80. package/convex/tokens.ts +88 -0
  81. package/convex/tsconfig.json +7 -0
  82. package/convex/uploads.ts +20 -0
  83. package/convex/users.ts +122 -0
  84. package/convex/webhooks.ts +50 -0
  85. package/convex.json +3 -0
  86. package/docs/README.md +32 -0
  87. package/docs/api.md +51 -0
  88. package/docs/architecture.md +61 -0
  89. package/docs/auth.md +54 -0
  90. package/docs/cli.md +117 -0
  91. package/docs/deploy.md +78 -0
  92. package/docs/diffing.md +84 -0
  93. package/docs/github-import.md +171 -0
  94. package/docs/http-api.md +187 -0
  95. package/docs/manual-testing.md +64 -0
  96. package/docs/mintlify.md +43 -0
  97. package/docs/quickstart.md +120 -0
  98. package/docs/skill-format.md +58 -0
  99. package/docs/soul-format.md +37 -0
  100. package/docs/spec.md +177 -0
  101. package/docs/telemetry.md +91 -0
  102. package/docs/troubleshooting.md +49 -0
  103. package/docs/webhook.md +51 -0
  104. package/e2e/menu-smoke.pw.test.ts +49 -0
  105. package/e2e/pilothub.e2e.test.ts +494 -0
  106. package/e2e/search-exact.pw.test.ts +97 -0
  107. package/package.json +84 -0
  108. package/packages/pilothub/LICENSE +22 -0
  109. package/packages/pilothub/README.md +57 -0
  110. package/packages/pilothub/bin/pilothub.js +2 -0
  111. package/packages/pilothub/package.json +41 -0
  112. package/packages/pilothub/src/browserAuth.test.ts +96 -0
  113. package/packages/pilothub/src/browserAuth.ts +174 -0
  114. package/packages/pilothub/src/cli/buildInfo.ts +94 -0
  115. package/packages/pilothub/src/cli/commands/auth.ts +97 -0
  116. package/packages/pilothub/src/cli/commands/delete.test.ts +73 -0
  117. package/packages/pilothub/src/cli/commands/delete.ts +83 -0
  118. package/packages/pilothub/src/cli/commands/publish.test.ts +122 -0
  119. package/packages/pilothub/src/cli/commands/publish.ts +108 -0
  120. package/packages/pilothub/src/cli/commands/skills.test.ts +191 -0
  121. package/packages/pilothub/src/cli/commands/skills.ts +380 -0
  122. package/packages/pilothub/src/cli/commands/star.ts +46 -0
  123. package/packages/pilothub/src/cli/commands/sync.test.ts +310 -0
  124. package/packages/pilothub/src/cli/commands/sync.ts +200 -0
  125. package/packages/pilothub/src/cli/commands/syncHelpers.test.ts +26 -0
  126. package/packages/pilothub/src/cli/commands/syncHelpers.ts +427 -0
  127. package/packages/pilothub/src/cli/commands/syncTypes.ts +27 -0
  128. package/packages/pilothub/src/cli/commands/unstar.ts +48 -0
  129. package/packages/pilothub/src/cli/helpStyle.ts +45 -0
  130. package/packages/pilothub/src/cli/pilotbotConfig.test.ts +159 -0
  131. package/packages/pilothub/src/cli/pilotbotConfig.ts +147 -0
  132. package/packages/pilothub/src/cli/registry.test.ts +63 -0
  133. package/packages/pilothub/src/cli/registry.ts +43 -0
  134. package/packages/pilothub/src/cli/scanSkills.test.ts +64 -0
  135. package/packages/pilothub/src/cli/scanSkills.ts +84 -0
  136. package/packages/pilothub/src/cli/slug.ts +16 -0
  137. package/packages/pilothub/src/cli/types.ts +12 -0
  138. package/packages/pilothub/src/cli/ui.ts +75 -0
  139. package/packages/pilothub/src/cli.ts +311 -0
  140. package/packages/pilothub/src/config.ts +36 -0
  141. package/packages/pilothub/src/discovery.test.ts +75 -0
  142. package/packages/pilothub/src/discovery.ts +19 -0
  143. package/packages/pilothub/src/http.test.ts +156 -0
  144. package/packages/pilothub/src/http.ts +301 -0
  145. package/packages/pilothub/src/schema/ark.ts +29 -0
  146. package/packages/pilothub/src/schema/index.ts +5 -0
  147. package/packages/pilothub/src/schema/routes.ts +22 -0
  148. package/packages/pilothub/src/schema/schemas.ts +260 -0
  149. package/packages/pilothub/src/schema/textFiles.test.ts +23 -0
  150. package/packages/pilothub/src/schema/textFiles.ts +66 -0
  151. package/packages/pilothub/src/skills.test.ts +191 -0
  152. package/packages/pilothub/src/skills.ts +172 -0
  153. package/packages/pilothub/src/types.ts +10 -0
  154. package/packages/pilothub/tsconfig.json +14 -0
  155. package/packages/schema/README.md +3 -0
  156. package/packages/schema/dist/ark.d.ts +4 -0
  157. package/packages/schema/dist/ark.js +26 -0
  158. package/packages/schema/dist/ark.js.map +1 -0
  159. package/packages/schema/dist/index.d.ts +5 -0
  160. package/packages/schema/dist/index.js +5 -0
  161. package/packages/schema/dist/index.js.map +1 -0
  162. package/packages/schema/dist/routes.d.ts +21 -0
  163. package/packages/schema/dist/routes.js +22 -0
  164. package/packages/schema/dist/routes.js.map +1 -0
  165. package/packages/schema/dist/schemas.d.ts +297 -0
  166. package/packages/schema/dist/schemas.js +243 -0
  167. package/packages/schema/dist/schemas.js.map +1 -0
  168. package/packages/schema/dist/textFiles.d.ts +5 -0
  169. package/packages/schema/dist/textFiles.js +66 -0
  170. package/packages/schema/dist/textFiles.js.map +1 -0
  171. package/packages/schema/package.json +26 -0
  172. package/packages/schema/src/ark.ts +29 -0
  173. package/packages/schema/src/index.ts +5 -0
  174. package/packages/schema/src/routes.ts +22 -0
  175. package/packages/schema/src/schemas.test.ts +123 -0
  176. package/packages/schema/src/schemas.ts +287 -0
  177. package/packages/schema/src/textFiles.test.ts +23 -0
  178. package/packages/schema/src/textFiles.ts +66 -0
  179. package/packages/schema/tsconfig.json +15 -0
  180. package/pilothub +46 -0
  181. package/playwright.config.ts +33 -0
  182. package/public/.well-known/pilothub.json +6 -0
  183. package/public/api/v1/openapi.json +379 -0
  184. package/public/favicon.ico +0 -0
  185. package/public/logo192.png +0 -0
  186. package/public/logo512.png +0 -0
  187. package/public/manifest.json +25 -0
  188. package/public/og.png +0 -0
  189. package/public/og.svg +98 -0
  190. package/public/pilot-logo.png +0 -0
  191. package/public/pilot-mark.png +0 -0
  192. package/public/robots.txt +3 -0
  193. package/public/tanstack-circle-logo.png +0 -0
  194. package/public/tanstack-word-logo-white.svg +1 -0
  195. package/scripts/check-peer-deps.ts +56 -0
  196. package/scripts/docs-list.ts +148 -0
  197. package/scripts/run-playwright-local.sh +14 -0
  198. package/server/og/fetchSkillOgMeta.ts +27 -0
  199. package/server/og/fetchSoulOgMeta.ts +27 -0
  200. package/server/og/ogAssets.ts +80 -0
  201. package/server/og/skillOgSvg.test.ts +59 -0
  202. package/server/og/skillOgSvg.ts +258 -0
  203. package/server/og/soulOgSvg.ts +209 -0
  204. package/server/routes/og/skill.png.ts +103 -0
  205. package/server/routes/og/soul.png.ts +111 -0
  206. package/src/__tests__/skill-detail-page.test.tsx +86 -0
  207. package/src/__tests__/skills-index.test.tsx +145 -0
  208. package/src/__tests__/upload.route.test.tsx +228 -0
  209. package/src/components/AppProviders.tsx +19 -0
  210. package/src/components/ClientOnly.tsx +18 -0
  211. package/src/components/Footer.tsx +29 -0
  212. package/src/components/Header.tsx +295 -0
  213. package/src/components/InstallSwitcher.tsx +53 -0
  214. package/src/components/SkillCard.tsx +36 -0
  215. package/src/components/SkillDetailPage.tsx +817 -0
  216. package/src/components/SkillDiffCard.tsx +485 -0
  217. package/src/components/SoulCard.tsx +19 -0
  218. package/src/components/SoulDetailPage.tsx +263 -0
  219. package/src/components/UserBootstrap.tsx +18 -0
  220. package/src/components/ui/dropdown-menu.tsx +67 -0
  221. package/src/components/ui/toggle-group.tsx +35 -0
  222. package/src/convex/client.ts +3 -0
  223. package/src/lib/badges.ts +29 -0
  224. package/src/lib/diffing.test.ts +163 -0
  225. package/src/lib/diffing.ts +106 -0
  226. package/src/lib/gravatar.test.ts +9 -0
  227. package/src/lib/gravatar.ts +158 -0
  228. package/src/lib/og.test.ts +142 -0
  229. package/src/lib/og.ts +156 -0
  230. package/src/lib/publicUser.ts +39 -0
  231. package/src/lib/roles.ts +19 -0
  232. package/src/lib/site.test.ts +130 -0
  233. package/src/lib/site.ts +84 -0
  234. package/src/lib/theme-transition.test.ts +134 -0
  235. package/src/lib/theme-transition.ts +134 -0
  236. package/src/lib/theme.test.tsx +88 -0
  237. package/src/lib/theme.ts +43 -0
  238. package/src/lib/uploadFiles.jsdom.test.ts +33 -0
  239. package/src/lib/uploadFiles.test.ts +123 -0
  240. package/src/lib/uploadFiles.ts +245 -0
  241. package/src/lib/uploadUtils.test.ts +78 -0
  242. package/src/lib/uploadUtils.ts +93 -0
  243. package/src/lib/useAuthStatus.ts +12 -0
  244. package/src/lib/utils.test.ts +9 -0
  245. package/src/lib/utils.ts +6 -0
  246. package/src/logo.svg +12 -0
  247. package/src/routeTree.gen.ts +345 -0
  248. package/src/router.tsx +17 -0
  249. package/src/routes/$owner/$slug.tsx +55 -0
  250. package/src/routes/__root.tsx +136 -0
  251. package/src/routes/admin.tsx +11 -0
  252. package/src/routes/cli/auth.tsx +168 -0
  253. package/src/routes/dashboard.tsx +97 -0
  254. package/src/routes/import.tsx +415 -0
  255. package/src/routes/index.tsx +252 -0
  256. package/src/routes/management.tsx +529 -0
  257. package/src/routes/settings.tsx +203 -0
  258. package/src/routes/skills/index.tsx +422 -0
  259. package/src/routes/souls/$slug.tsx +55 -0
  260. package/src/routes/souls/index.tsx +243 -0
  261. package/src/routes/stars.tsx +68 -0
  262. package/src/routes/u/$handle.tsx +307 -0
  263. package/src/routes/upload/utils.ts +81 -0
  264. package/src/routes/upload.tsx +499 -0
  265. package/src/styles.css +2718 -0
  266. package/tsconfig.json +24 -0
  267. package/tsconfig.oxlint.json +16 -0
  268. package/vercel.json +8 -0
  269. package/vite.config.ts +48 -0
  270. package/vitest.config.ts +47 -0
  271. package/vitest.e2e.config.ts +11 -0
  272. package/vitest.setup.ts +1 -0
@@ -0,0 +1,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
+ })