diff --git a/apps/api/src/lib/manifest.ts b/apps/api/src/lib/manifest.ts index 1c1eb02..100b62a 100644 --- a/apps/api/src/lib/manifest.ts +++ b/apps/api/src/lib/manifest.ts @@ -249,7 +249,12 @@ export async function resolveManifestFromReleaseAssets( if (assets.length === 0) return null const byName = new Map(assets.map((a) => [a.name, a])) for (const candidate of manifestCandidates()) { - const asset = byName.get(candidate.path) + // GitHub rejects release asset names with a leading dot and silently + // renames them on upload (".tabularium" → "default.tabularium"), so a + // dotfile candidate must also match its renamed form. + const asset = + byName.get(candidate.path) ?? + (candidate.path.startsWith('.') ? byName.get(`default${candidate.path}`) : undefined) if (!asset) continue try { const got = await fetchAssetContent(asset.url, accessToken) diff --git a/apps/api/src/lib/refresh-manifest.ts b/apps/api/src/lib/refresh-manifest.ts index db71f5a..380179f 100644 --- a/apps/api/src/lib/refresh-manifest.ts +++ b/apps/api/src/lib/refresh-manifest.ts @@ -39,17 +39,28 @@ export async function refreshManifestForPlugin( if (e instanceof OAuthExpiredError) return { status: 401, body: reauthErrorBody(e) } throw e } - const branch = options.branch ?? plugin.latestVersion ?? 'HEAD' - let manifest + // latestVersion stores the bare semver ("0.1.1") while release tags are + // commonly v-prefixed ("v0.1.1") — try both when we derived the ref ourselves. + const gitRefs = options.branch + ? [options.branch] + : plugin.latestVersion + ? [plugin.latestVersion, `v${plugin.latestVersion}`] + : ['HEAD'] + let manifest: Awaited> = null + let branch = gitRefs[0] try { - manifest = await resolveManifest(token, ref, { ref: branch }) + for (const gitRef of gitRefs) { + branch = gitRef + manifest = await resolveManifest(token, ref, { ref: gitRef }) + if (manifest) break + } } catch (e) { if (e instanceof UpstreamUnauthorizedError) return { status: 401, body: reauthErrorBody(e) } if (e instanceof ManifestValidationError) return { status: 422, body: { error: e.message } } throw e } if (!manifest) { - return { status: 404, body: { error: `No .tabularium file found in ${plugin.repoUrl} @ ${branch}` } } + return { status: 404, body: { error: `No .tabularium file found in ${plugin.repoUrl} @ ${gitRefs.join(' or ')}` } } } const patch = manifestPatch(manifest, { repoBase: rawContentBase(ref, branch), version: branch }) await applyManifestToPlugin(plugin.id, patch) diff --git a/apps/api/tests/lib/asset-manifest.test.ts b/apps/api/tests/lib/asset-manifest.test.ts index 1e86532..aa92ebd 100644 --- a/apps/api/tests/lib/asset-manifest.test.ts +++ b/apps/api/tests/lib/asset-manifest.test.ts @@ -42,6 +42,17 @@ describe('resolveManifestFromReleaseAssets', () => { spy.mockRestore() }) + it('matches the GitHub-renamed form of a dotfile candidate (default.tabularium)', async () => { + const spy = mockAssetFetch({ + 'https://example.com/default.tabularium': { body: SAMPLE_JSON }, + }) + const manifest = await resolveManifestFromReleaseAssets('test-token', [ + { name: 'default.tabularium', url: 'https://example.com/default.tabularium' }, + ]) + expect(manifest?.parsed.name).toBe('alpha') + spy.mockRestore() + }) + it('returns null when no asset name matches the candidate list', async () => { const spy = mockAssetFetch({}) const manifest = await resolveManifestFromReleaseAssets('test-token', [ diff --git a/apps/api/tests/lib/refresh-manifest-tag.test.ts b/apps/api/tests/lib/refresh-manifest-tag.test.ts new file mode 100644 index 0000000..b188d5d --- /dev/null +++ b/apps/api/tests/lib/refresh-manifest-tag.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, spyOn } from 'bun:test' +import { clearDb, makeUser, makePlugin } from '../helpers' +import { db } from '../../src/db' +import { refreshManifestForPlugin } from '../../src/lib/refresh-manifest' + +const SAMPLE_JSON = JSON.stringify({ + name: 'alpha', + version: '1.0.0', + description: 'A test plugin.', + category: 'misc', + icon: 'https://example.com/icon.svg', +}) + +// latestVersion is the bare semver ("1.0.0") but the git tag is v-prefixed +// ("v1.0.0") — the refresh must fall back to the v-prefixed ref. +describe('refreshManifestForPlugin tag fallback', () => { + beforeEach(clearDb) + + it('falls back to v-prefixed tag when the bare version ref 404s', async () => { + const u = await makeUser() + const plugin = await makePlugin(u.id, { id: 'alpha', latestVersion: '1.0.0' }) + const spy = spyOn(global, 'fetch').mockImplementation((async (url: string | URL | Request) => { + const key = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url + if (key.includes('contents/.tabularium') && key.includes('ref=v1.0.0')) { + return new Response(SAMPLE_JSON, { status: 200 }) + } + return new Response('not found', { status: 404 }) + }) as unknown as typeof fetch) + + const result = await refreshManifestForPlugin(plugin, {}) + expect(result).toEqual({ ok: true, slug: 'alpha', ref: 'v1.0.0' }) + const row = await db.query.plugins.findFirst({ where: { id: 'alpha' } }) + expect(row?.iconUrl).toBe('https://example.com/icon.svg') + spy.mockRestore() + }) + + it('still 404s with both tried refs in the message when neither exists', async () => { + const u = await makeUser() + const plugin = await makePlugin(u.id, { id: 'alpha', latestVersion: '1.0.0' }) + const spy = spyOn(global, 'fetch').mockImplementation((async () => + new Response('not found', { status: 404 })) as unknown as typeof fetch) + + const result = await refreshManifestForPlugin(plugin, {}) + expect(result).toMatchObject({ status: 404 }) + expect((result as { body: { error: string } }).body.error).toContain('1.0.0 or v1.0.0') + spy.mockRestore() + }) +})