|
| 1 | +import { resolve } from 'node:path' |
| 2 | +import { intro, isCancel, multiselect, note, outro, text } from '@clack/prompts' |
| 3 | +import { |
| 4 | + ApiCliWhoamiResponseSchema, |
| 5 | + ApiRoutes, |
| 6 | + ApiSkillMetaResponseSchema, |
| 7 | + ApiSkillResolveResponseSchema, |
| 8 | +} from '@clawdhub/schema' |
| 9 | +import semver from 'semver' |
| 10 | +import { readGlobalConfig } from '../../config.js' |
| 11 | +import { apiRequest } from '../../http.js' |
| 12 | +import { hashSkillFiles, listTextFiles } from '../../skills.js' |
| 13 | +import { getRegistry } from '../registry.js' |
| 14 | +import { findSkillFolders, getFallbackSkillRoots, type SkillFolder } from '../scanSkills.js' |
| 15 | +import type { GlobalOpts } from '../types.js' |
| 16 | +import { createSpinner, fail, formatError, isInteractive } from '../ui.js' |
| 17 | +import { cmdPublish } from './publish.js' |
| 18 | + |
| 19 | +type SyncOptions = { |
| 20 | + root?: string[] |
| 21 | + all?: boolean |
| 22 | + dryRun?: boolean |
| 23 | + bump?: 'patch' | 'minor' | 'major' |
| 24 | + changelog?: string |
| 25 | + tags?: string |
| 26 | +} |
| 27 | + |
| 28 | +type Candidate = SkillFolder & { |
| 29 | + fingerprint: string |
| 30 | + fileCount: number |
| 31 | + status: 'synced' | 'new' | 'update' |
| 32 | + matchVersion: string | null |
| 33 | + latestVersion: string | null |
| 34 | +} |
| 35 | + |
| 36 | +export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllowed: boolean) { |
| 37 | + const allowPrompt = isInteractive() && inputAllowed !== false |
| 38 | + |
| 39 | + const cfg = await readGlobalConfig() |
| 40 | + const token = cfg?.token |
| 41 | + if (!token) fail('Not logged in. Run: clawdhub login') |
| 42 | + |
| 43 | + const registry = await getRegistryWithAuth(opts, token) |
| 44 | + const selectedRoots = buildScanRoots(opts, options.root) |
| 45 | + |
| 46 | + const spinner = createSpinner('Scanning for local skills') |
| 47 | + let skills = await scanRoots(selectedRoots) |
| 48 | + if (skills.length === 0) { |
| 49 | + const fallback = getFallbackSkillRoots(opts.workdir) |
| 50 | + skills = await scanRoots(fallback) |
| 51 | + spinner.stop() |
| 52 | + if (skills.length === 0) |
| 53 | + fail('No skills found (checked workdir and known Clawdis/Clawd locations)') |
| 54 | + note(`No skills in workdir. Found ${skills.length} in legacy locations.`, fallback.join('\n')) |
| 55 | + } else { |
| 56 | + spinner.stop() |
| 57 | + } |
| 58 | + |
| 59 | + intro('ClawdHub sync') |
| 60 | + |
| 61 | + const candidatesSpinner = createSpinner('Checking registry sync state') |
| 62 | + const candidates: Candidate[] = [] |
| 63 | + try { |
| 64 | + for (const skill of skills) { |
| 65 | + const filesOnDisk = await listTextFiles(skill.folder) |
| 66 | + const hashed = hashSkillFiles(filesOnDisk) |
| 67 | + const fingerprint = hashed.fingerprint |
| 68 | + |
| 69 | + const meta = await apiRequest( |
| 70 | + registry, |
| 71 | + { method: 'GET', path: `${ApiRoutes.skill}?slug=${encodeURIComponent(skill.slug)}` }, |
| 72 | + ApiSkillMetaResponseSchema, |
| 73 | + ).catch(() => null) |
| 74 | + |
| 75 | + const latestVersion = meta?.latestVersion?.version ?? null |
| 76 | + if (!latestVersion) { |
| 77 | + candidates.push({ |
| 78 | + ...skill, |
| 79 | + fingerprint, |
| 80 | + fileCount: filesOnDisk.length, |
| 81 | + status: 'new', |
| 82 | + matchVersion: null, |
| 83 | + latestVersion: null, |
| 84 | + }) |
| 85 | + continue |
| 86 | + } |
| 87 | + |
| 88 | + const resolved = await apiRequest( |
| 89 | + registry, |
| 90 | + { |
| 91 | + method: 'GET', |
| 92 | + path: `${ApiRoutes.skillResolve}?slug=${encodeURIComponent(skill.slug)}&hash=${encodeURIComponent(fingerprint)}`, |
| 93 | + }, |
| 94 | + ApiSkillResolveResponseSchema, |
| 95 | + ).catch((error) => { |
| 96 | + const message = formatError(error) |
| 97 | + if (/skill not found/i.test(message)) return { match: null, latestVersion: null } |
| 98 | + throw error |
| 99 | + }) |
| 100 | + |
| 101 | + const matchVersion = resolved.match?.version ?? null |
| 102 | + candidates.push({ |
| 103 | + ...skill, |
| 104 | + fingerprint, |
| 105 | + fileCount: filesOnDisk.length, |
| 106 | + status: matchVersion ? 'synced' : 'update', |
| 107 | + matchVersion, |
| 108 | + latestVersion, |
| 109 | + }) |
| 110 | + } |
| 111 | + } catch (error) { |
| 112 | + candidatesSpinner.fail(formatError(error)) |
| 113 | + throw error |
| 114 | + } finally { |
| 115 | + candidatesSpinner.stop() |
| 116 | + } |
| 117 | + |
| 118 | + const synced = candidates.filter((candidate) => candidate.status === 'synced') |
| 119 | + if (synced.length > 0) { |
| 120 | + const lines = synced |
| 121 | + .map((candidate) => `${candidate.slug} synced (${candidate.matchVersion ?? 'unknown'})`) |
| 122 | + .join('\n') |
| 123 | + note('Already synced', lines) |
| 124 | + } |
| 125 | + |
| 126 | + const actionable = candidates.filter((candidate) => candidate.status !== 'synced') |
| 127 | + if (actionable.length === 0) { |
| 128 | + outro('Everything is already synced.') |
| 129 | + return |
| 130 | + } |
| 131 | + |
| 132 | + const selected = await selectToUpload(actionable, { |
| 133 | + allowPrompt, |
| 134 | + all: Boolean(options.all), |
| 135 | + bump: options.bump ?? 'patch', |
| 136 | + }) |
| 137 | + if (selected.length === 0) { |
| 138 | + outro('Nothing selected.') |
| 139 | + return |
| 140 | + } |
| 141 | + |
| 142 | + if (options.dryRun) { |
| 143 | + outro(`Dry run: would upload ${selected.length} skill(s).`) |
| 144 | + return |
| 145 | + } |
| 146 | + |
| 147 | + const bump = options.bump ?? 'patch' |
| 148 | + const tags = options.tags ?? 'latest' |
| 149 | + |
| 150 | + for (const skill of selected) { |
| 151 | + const { publishVersion, changelog } = await resolvePublishMeta(skill, { |
| 152 | + bump, |
| 153 | + allowPrompt, |
| 154 | + changelogFlag: options.changelog, |
| 155 | + }) |
| 156 | + await cmdPublish(opts, skill.folder, { |
| 157 | + slug: skill.slug, |
| 158 | + name: skill.displayName, |
| 159 | + version: publishVersion, |
| 160 | + changelog, |
| 161 | + tags, |
| 162 | + }) |
| 163 | + } |
| 164 | + |
| 165 | + outro(`Uploaded ${selected.length} skill(s).`) |
| 166 | +} |
| 167 | + |
| 168 | +function buildScanRoots(opts: GlobalOpts, extraRoots: string[] | undefined) { |
| 169 | + const roots = [opts.workdir, opts.dir, ...(extraRoots ?? [])] |
| 170 | + return Array.from(new Set(roots.map((root) => resolve(root)))) |
| 171 | +} |
| 172 | + |
| 173 | +async function scanRoots(roots: string[]) { |
| 174 | + const all: SkillFolder[] = [] |
| 175 | + for (const root of roots) { |
| 176 | + const found = await findSkillFolders(root) |
| 177 | + all.push(...found) |
| 178 | + } |
| 179 | + const byFolder = new Map<string, SkillFolder>() |
| 180 | + for (const folder of all) { |
| 181 | + byFolder.set(folder.folder, folder) |
| 182 | + } |
| 183 | + return Array.from(byFolder.values()) |
| 184 | +} |
| 185 | + |
| 186 | +async function selectToUpload( |
| 187 | + candidates: Candidate[], |
| 188 | + params: { allowPrompt: boolean; all: boolean; bump: 'patch' | 'minor' | 'major' }, |
| 189 | +): Promise<Candidate[]> { |
| 190 | + if (params.all || !params.allowPrompt) return candidates |
| 191 | + |
| 192 | + const valueByKey = new Map<string, Candidate>() |
| 193 | + const choices = candidates.map((candidate) => { |
| 194 | + const key = candidate.folder |
| 195 | + valueByKey.set(key, candidate) |
| 196 | + const latest = candidate.latestVersion |
| 197 | + const next = latest ? semver.inc(latest, params.bump) : null |
| 198 | + const status = |
| 199 | + candidate.status === 'new' ? 'NEW' : latest && next ? `UPDATE ${latest} → ${next}` : 'UPDATE' |
| 200 | + return { |
| 201 | + value: key, |
| 202 | + label: `${candidate.slug} ${status}`, |
| 203 | + hint: candidate.folder, |
| 204 | + } |
| 205 | + }) |
| 206 | + |
| 207 | + const picked = await multiselect({ |
| 208 | + message: 'Select skills to upload', |
| 209 | + options: choices, |
| 210 | + initialValues: choices.map((choice) => choice.value), |
| 211 | + required: false, |
| 212 | + }) |
| 213 | + if (isCancel(picked)) fail('Canceled') |
| 214 | + const selected = picked.map((key) => valueByKey.get(String(key))).filter(Boolean) as Candidate[] |
| 215 | + return selected |
| 216 | +} |
| 217 | + |
| 218 | +async function resolvePublishMeta( |
| 219 | + skill: Candidate, |
| 220 | + params: { bump: 'patch' | 'minor' | 'major'; allowPrompt: boolean; changelogFlag?: string }, |
| 221 | +) { |
| 222 | + if (skill.status === 'new') { |
| 223 | + return { publishVersion: '1.0.0', changelog: '' } |
| 224 | + } |
| 225 | + |
| 226 | + const latest = skill.latestVersion |
| 227 | + if (!latest) fail(`Could not resolve latest version for ${skill.slug}`) |
| 228 | + const publishVersion = semver.inc(latest, params.bump) |
| 229 | + if (!publishVersion) fail(`Could not bump version for ${skill.slug}`) |
| 230 | + |
| 231 | + const fromFlag = params.changelogFlag?.trim() |
| 232 | + if (fromFlag) return { publishVersion, changelog: fromFlag } |
| 233 | + |
| 234 | + if (!params.allowPrompt) { |
| 235 | + return { publishVersion, changelog: 'Sync update' } |
| 236 | + } |
| 237 | + |
| 238 | + const entered = await text({ |
| 239 | + message: `Changelog for ${skill.slug}@${publishVersion}`, |
| 240 | + placeholder: 'What changed?', |
| 241 | + defaultValue: 'Sync update', |
| 242 | + }) |
| 243 | + if (isCancel(entered)) fail('Canceled') |
| 244 | + const changelog = String(entered ?? '').trim() |
| 245 | + if (!changelog) fail('--changelog required for updates') |
| 246 | + return { publishVersion, changelog } |
| 247 | +} |
| 248 | + |
| 249 | +async function getRegistryWithAuth(opts: GlobalOpts, token: string) { |
| 250 | + const registry = await getRegistry(opts, { cache: true }) |
| 251 | + await apiRequest( |
| 252 | + registry, |
| 253 | + { method: 'GET', path: ApiRoutes.cliWhoami, token }, |
| 254 | + ApiCliWhoamiResponseSchema, |
| 255 | + ) |
| 256 | + return registry |
| 257 | +} |
0 commit comments