Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Per-release README markdown. See sqlite migration for rationale.
ALTER TABLE `releases` ADD `readme` text;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Per-release README markdown. See sqlite migration for rationale.
ALTER TABLE "releases" ADD COLUMN "readme" text;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- README markdown captured at each release's tag, so an older version can be
-- read exactly as it shipped instead of showing whatever the plugin's current
-- README happens to be. Raw markdown, or a JSON locale map when the manifest
-- declares `readmes`. NULL on releases ingested before this column existed.
ALTER TABLE `releases` ADD `readme` text;
5 changes: 5 additions & 0 deletions apps/api/src/db/schema.mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ export const releases = mysqlTable(
manifestSha256: varchar('manifest_sha256', { length: 64 }),
// Canonical raw .tabularium bytes — see schema.ts for rationale.
manifestRaw: text('manifest_raw'),
// README markdown captured at this release's tag. Raw markdown, or a
// JSON locale map when the manifest declares `readmes` — same shape as
// plugins.readme, so pickReadme() handles both. NULL on releases
// ingested before this column existed.
readme: text('readme'),
createdAt: ts('created_at').notNull().$defaultFn(now),
},
(t) => ({
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/db/schema.pg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ export const releases = pgTable(
manifestSha256: text('manifest_sha256'),
// Canonical raw .tabularium bytes — see schema.ts for rationale.
manifestRaw: text('manifest_raw'),
// README markdown captured at this release's tag. Raw markdown, or a
// JSON locale map when the manifest declares `readmes` — same shape as
// plugins.readme, so pickReadme() handles both. NULL on releases
// ingested before this column existed.
readme: text('readme'),
createdAt: ts('created_at').notNull().$defaultFn(now),
},
(t) => ({
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ export const releases = sqliteTable(
// registry can serve the manifest itself — clients verify
// sha256(manifest_raw) === manifestSha256 (JWS-signed) without the forge.
manifestRaw: text('manifest_raw'),
// README markdown captured at this release's tag. Raw markdown, or a
// JSON locale map when the manifest declares `readmes` — same shape as
// plugins.readme, so pickReadme() handles both. NULL on releases
// ingested before this column existed.
readme: text('readme'),
createdAt: integer('created_at').notNull().$defaultFn(now),
},
(t) => ({
Expand Down
19 changes: 15 additions & 4 deletions apps/api/src/lib/manifest-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ export type PluginManifestUpdate = {
license: string | null
iconUrl: string | null
screenshots: string | null
readme: string | null
// Optional on purpose: a pass that resolved no README leaves the stored one
// alone instead of nulling it. Mirrors the manifestSha256/manifestRaw guard
// in persistRelease — a blank overwrite used to wipe the README on every
// asset-resolved release.
readme?: string | null
documentationUrl: string | null
supportEmail: string | null
issuesUrl: string | null
Expand All @@ -63,12 +67,19 @@ import { jsonArrayOrNull as jsonArray } from './util'
* Convert a parsed manifest into a column patch for the `plugins` row.
* `repoBase` is used to resolve relative icon/screenshot paths to absolute URLs.
*/
// Storage shape for a resolved README: a JSON locale map when the manifest
// declares `readmes`, otherwise plain markdown. Same encoding for the plugin
// column and the per-release column, so pickReadme() reads either one.
export function readmePayloadOf(m: ResolvedManifest): string | null {
return m.readmeLocales ? JSON.stringify(m.readmeLocales) : (m.readmeMarkdown ?? null)
}

export function manifestPatch(
m: ResolvedManifest,
opts: { repoBase: string; version: string | null },
): PluginManifestUpdate {
const { parsed, readmeMarkdown, readmeLocales } = m
const readmePayload = readmeLocales ? JSON.stringify(readmeLocales) : (readmeMarkdown ?? null)
const { parsed } = m
const readmePayload = readmePayloadOf(m)

const iconUrl = parsed.icon ? resolveAbsolute(opts.repoBase, parsed.icon) : null
const screenshots =
Expand All @@ -91,7 +102,6 @@ export function manifestPatch(
license: parsed.license ?? null,
iconUrl,
screenshots: jsonArray(screenshots),
readme: readmePayload,
documentationUrl: parsed.documentation_url ?? null,
supportEmail: parsed.support?.email ?? null,
issuesUrl: parsed.support?.issues_url ?? null,
Expand All @@ -100,6 +110,7 @@ export function manifestPatch(
manifestVersion: opts.version,
updatedAt: Date.now(),
}
if (readmePayload !== null) patch.readme = readmePayload
if (parsed.name) patch.name = parsed.name
if (parsed.description) patch.description = parsed.description
if (parsed.homepage) patch.homepage = parsed.homepage
Expand Down
127 changes: 90 additions & 37 deletions apps/api/src/lib/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,13 +238,96 @@ async function fetchAssetContent(url: string, accessToken: string): Promise<{ co
return { content: text, bytes: new TextEncoder().encode(text).length }
}

// Resolves the README a manifest points at: the localized `readmes` map first,
// then a single `readme` path, then the conventional root filenames. Shared by
// both manifest paths so an asset-resolved release captures the same README a
// git-ref-resolved one would.
async function resolveReadme(
fetch: FileFetcher,
parsed: Manifest,
): Promise<{ readmeMarkdown: string | null; readmeLocales: ReadmeMap | null }> {
let readmeMarkdown: string | null = null
let readmeLocales: ReadmeMap | null = null

if (parsed.readmes && Object.keys(parsed.readmes).length > 0) {
readmeLocales = {}
for (const [locale, path] of Object.entries(parsed.readmes)) {
try {
const r = await fetch(path)
if (r) readmeLocales[locale] = r.content
} catch (err) {
log.warn({ err, locale, path }, 'localized readme fetch failed')
}
}
if (Object.keys(readmeLocales).length === 0) readmeLocales = null
}
if (!readmeLocales && parsed.readme) {
try {
const r = await fetch(parsed.readme)
if (r) readmeMarkdown = r.content
} catch (err) {
log.warn({ err, readme: parsed.readme }, 'readme path fetch failed — manifest still applied')
}
}
if (!readmeLocales && !readmeMarkdown) {
for (const fallback of ['README.md', 'readme.md', 'README.markdown']) {
try {
const r = await fetch(fallback)
if (r) {
readmeMarkdown = r.content
break
}
} catch {
// try next
}
}
}
return { readmeMarkdown, readmeLocales }
}

// Reads the README for an already-ingested release, given the manifest that
// shipped with it. Used by the backfill: releases stored before the per-release
// README column existed still have their tag on the forge, and a tag is
// immutable, so this recovers exactly what that version shipped with.
export async function fetchReadmeAtTag(
accessToken: string | null,
ref: RepoRef,
tag: string,
parsed: Manifest,
): Promise<{ readmeMarkdown: string | null; readmeLocales: ReadmeMap | null }> {
return resolveReadme(accessToken ? fetcherFor(accessToken, ref, tag) : makePublicRawFetcher(ref, tag), parsed)
}

// Unauthenticated read from the forge's raw-content host. A README in a public
// repo needs no credentials, so the backfill can still recover history for a
// plugin whose owner has no usable OAuth token left. A private repo answers
// 404 here, which the caller treats as "nothing to recover" rather than an
// error.
function makePublicRawFetcher(ref: RepoRef, tag: string): FileFetcher {
const base = rawContentBase(ref, tag)
return async (path) => {
const res = await fetch(base + path.split('/').map(encodeURIComponent).join('/'))
if (!res.ok) return null
const len = Number(res.headers.get('content-length') ?? 0)
if (len > MAX_README_BYTES) throw new Error(`${path} exceeds size cap`)
const text = await res.text()
return { content: text, bytes: new TextEncoder().encode(text).length }
}
}

// Asset-first manifest resolution: scan the release's published assets for
// any filename in the configured candidate list and ingest that. Avoids the
// race + auth flakiness of the git-ref fetch path because release assets are
// immutable per release and served by the forge's CDN.
//
// The README is not a release asset, so it is read from the repo at the
// release's tag when `readmeAt` is supplied. A tag is immutable, so that is
// the same content the release shipped with — without it the plugin's README
// would be blank for every asset-resolved release.
export async function resolveManifestFromReleaseAssets(
accessToken: string,
assets: ReleaseAsset[],
readmeAt?: { ref: RepoRef; tag: string },
): Promise<ResolvedManifest | null> {
if (assets.length === 0) return null
const byName = new Map(assets.map((a) => [a.name, a]))
Expand All @@ -264,7 +347,12 @@ export async function resolveManifestFromReleaseAssets(
continue
}
const parsed = parseManifestText(got.content)
return { raw: got.content, parsed, readmeMarkdown: null, readmeLocales: null }
if (!readmeAt) return { raw: got.content, parsed, readmeMarkdown: null, readmeLocales: null }
const { readmeMarkdown, readmeLocales } = await resolveReadme(
fetcherFor(accessToken, readmeAt.ref, readmeAt.tag),
parsed,
)
return { raw: got.content, parsed, readmeMarkdown, readmeLocales }
} catch (err) {
if (err instanceof UpstreamUnauthorizedError) throw err
if (err instanceof ManifestValidationError) {
Expand Down Expand Up @@ -297,42 +385,7 @@ export async function resolveManifest(
continue
}
const parsed = parseManifestText(got.content)
let readmeMarkdown: string | null = null
let readmeLocales: ReadmeMap | null = null

if (parsed.readmes && Object.keys(parsed.readmes).length > 0) {
readmeLocales = {}
for (const [locale, path] of Object.entries(parsed.readmes)) {
try {
const r = await fetch(path)
if (r) readmeLocales[locale] = r.content
} catch (err) {
log.warn({ err, locale, path }, 'localized readme fetch failed')
}
}
if (Object.keys(readmeLocales).length === 0) readmeLocales = null
}
if (!readmeLocales && parsed.readme) {
try {
const r = await fetch(parsed.readme)
if (r) readmeMarkdown = r.content
} catch (err) {
log.warn({ err, readme: parsed.readme }, 'readme path fetch failed — manifest still applied')
}
}
if (!readmeLocales && !readmeMarkdown) {
for (const fallback of ['README.md', 'readme.md', 'README.markdown']) {
try {
const r = await fetch(fallback)
if (r) {
readmeMarkdown = r.content
break
}
} catch {
// try next
}
}
}
const { readmeMarkdown, readmeLocales } = await resolveReadme(fetch, parsed)
return { raw: got.content, parsed, readmeMarkdown, readmeLocales }
} catch (err) {
if (err instanceof UpstreamUnauthorizedError) throw err
Expand Down
26 changes: 26 additions & 0 deletions apps/api/src/lib/readme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// README storage is either plain markdown or a JSON locale map (written when
// the manifest declares `readmes`). Both the plugin column and the per-release
// column use that shape, so one reader serves both.
export function pickReadme(
raw: string | null,
preferredLocale: string | undefined,
): { markdown: string | null; locale: string | null; available: string[] } {
if (!raw) return { markdown: null, locale: null, available: [] }
if (!raw.startsWith('{')) return { markdown: raw, locale: null, available: [] }
try {
const map = JSON.parse(raw) as Record<string, unknown>
const available = Object.keys(map).filter((k) => typeof map[k] === 'string')
if (available.length === 0) return { markdown: null, locale: null, available: [] }
const pick = (locale: string | undefined): string | null => {
if (locale && typeof map[locale] === 'string') return locale
return null
}
const baseLocale = preferredLocale?.split('-')[0]
const chosen = pick(preferredLocale) ?? pick(baseLocale) ?? pick('en') ?? available[0]
return { markdown: map[chosen] as string, locale: chosen, available }
} catch {
return { markdown: raw, locale: null, available: [] }
}
}

export const README_TTL = 600
Loading
Loading