Repository navigation
Official pack catalog #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
NovusEdge
wants to merge
13
commits into
main
Choose a base branch
from
feat/pack-catalog
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
0b8a067
feat(catalog): parse, version-gate and cache the pack index
NovusEdge 0e7a1e5
feat(catalog): publish the oxide packs in packs.json
NovusEdge 9ad7681
feat(catalog): install a catalog pack only after every file passes
NovusEdge 9bf888e
feat(catalog): pick, list and update official packs by name
NovusEdge 888b51d
feat(installer): list official packs and gate them on the installed g…
NovusEdge 8466167
chore(ledger): migrate to per-kind ids
NovusEdge 7960dbd
feat(catalog): read the version, refresh the catalog, install a confi…
NovusEdge c4b4368
feat(catalog): show official packs in the config pane, install with a…
NovusEdge b0a7096
docs: official packs
NovusEdge 303dba6
fix(catalog): install a /plugin-chosen official pack in the backgroun…
NovusEdge c719500
chore(ledger): record the pack catalog decisions
NovusEdge f1ed1f3
Merge remote-tracking branch 'origin/main' into feat/pack-catalog
NovusEdge 7044932
Merge remote-tracking branch 'origin/main' into feat/pack-catalog
NovusEdge File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| { | ||
| "format": 1, | ||
| "packs": [ | ||
| { | ||
| "name": "oxide", | ||
| "description": "bone text and red oxide on warm ink", | ||
| "pack": "https://raw.githubusercontent.com/NovusEdge/glowup-oxide/main/oxide.json", | ||
| "themes": ["https://raw.githubusercontent.com/NovusEdge/glowup-oxide/main/themes/oxide.json"], | ||
| "minGlowup": "0.8.1" | ||
| }, | ||
| { | ||
| "name": "oxide-paper", | ||
| "description": "red oxide and ink on bone, with a dithered orb", | ||
| "pack": "https://raw.githubusercontent.com/NovusEdge/glowup-oxide/main/oxide-paper.json", | ||
| "themes": [], | ||
| "minGlowup": "0.8.1" | ||
| } | ||
| ] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| // JSX-free: the docs site may import it. | ||
| import type { Host } from './host.ts' | ||
| import { parseJsonc, isUnsafe, shown } from './themes.ts' | ||
| import { packNameProblem } from './packs.ts' | ||
| import { checkThemeText, THEME_DIR } from './userthemes.ts' | ||
| import { checkPackText, PACK_DIR } from './userpacks.ts' | ||
|
|
||
| export type CatalogEntry = { name: string; description: string; pack: string; themes: string[]; minGlowup: string } | ||
|
|
||
| export const CATALOG_URL = 'https://glowup.khimani.dev/packs.json' | ||
| export const CATALOG_FILE = (configDir: string) => `${configDir}/glowup/catalog.json` | ||
|
|
||
| // The pack subcommands: a pack with one of these names could never be picked by name. | ||
| const RESERVED = ['list', 'save', 'update'] | ||
| const MAX_ENTRIES = 100 | ||
| const VERSION = /^\d+(\.\d+)*$/ | ||
| const isHttps = (v: unknown): v is string => typeof v === 'string' && v.startsWith('https://') | ||
| const printable = (s: string) => [...s].every(c => !isUnsafe(c.codePointAt(0)!)) | ||
|
|
||
| function entryOf(v: unknown): CatalogEntry | string { | ||
| if (typeof v !== 'object' || v === null || Array.isArray(v)) return 'an entry is not an object' | ||
| const e = v as Record<string, unknown> | ||
| const name = typeof e.name === 'string' ? e.name : '' | ||
| const label = `entry "${shown(name)}"` | ||
| const problem = packNameProblem(name) ?? (RESERVED.includes(name) ? `"${name}" is a /glowup pack subcommand` : undefined) | ||
| if (problem) return `${label}: ${problem}` | ||
| if (typeof e.description !== 'string' || e.description.length > 80 || !printable(e.description)) return `${label}: description must be printable text of at most 80 characters` | ||
| if (!isHttps(e.pack)) return `${label}: pack must be an https:// URL` | ||
| const themes = e.themes ?? [] | ||
| if (!Array.isArray(themes) || !themes.every(isHttps)) return `${label}: themes must be https:// URLs` | ||
| if (typeof e.minGlowup !== 'string' || !VERSION.test(e.minGlowup)) return `${label}: minGlowup must be a version such as 0.10.0` | ||
| return { name, description: e.description, pack: e.pack, themes, minGlowup: e.minGlowup } | ||
| } | ||
|
|
||
| export function parseCatalog(json: unknown): { entries: CatalogEntry[]; errors: string[] } | undefined { | ||
| if (typeof json !== 'object' || json === null || Array.isArray(json)) return undefined | ||
| const { format, packs } = json as { format?: unknown; packs?: unknown } | ||
| if (format !== 1 || !Array.isArray(packs)) return undefined | ||
| const entries: CatalogEntry[] = [], errors: string[] = [] | ||
| for (const p of packs.slice(0, MAX_ENTRIES)) { | ||
| const e = entryOf(p) | ||
| if (typeof e === 'string') errors.push(e) | ||
| else if (entries.some(x => x.name === e.name)) errors.push(`entry "${e.name}" is listed twice; the first is kept`) | ||
| else entries.push(e) | ||
| } | ||
| return { entries, errors } | ||
| } | ||
|
|
||
| export function compareVersions(a: string, b: string): number { | ||
| const pa = a.split('.').map(Number), pb = b.split('.').map(Number) | ||
| for (let i = 0; i < Math.max(pa.length, pb.length); i++) { | ||
| const d = (pa[i] ?? 0) - (pb[i] ?? 0) | ||
| if (d) return d | ||
| } | ||
| return 0 | ||
| } | ||
|
|
||
| // A glowup that cannot read its own version runs no catalog pack: it may be older than any of them. | ||
| export const canRun = (entry: CatalogEntry, own: string | undefined) => | ||
| own !== undefined && VERSION.test(own) && compareVersions(own, entry.minGlowup) >= 0 | ||
|
|
||
| export async function loadCatalog(host: Host): Promise<CatalogEntry[]> { | ||
| try { return parseCatalog(parseJsonc(await host.readFile(CATALOG_FILE(host.configDir))))?.entries ?? [] } catch { return [] } | ||
| } | ||
|
|
||
| export async function refreshCatalog(host: Host): Promise<CatalogEntry[] | undefined> { | ||
| let r: Awaited<ReturnType<Host['fetchText']>> | ||
| try { r = await host.fetchText(CATALOG_URL) } catch { return undefined } | ||
| if (!r.ok) return undefined | ||
| let parsed: ReturnType<typeof parseCatalog> | ||
| try { parsed = parseCatalog(parseJsonc(r.text)) } catch { return undefined } | ||
| if (!parsed) return undefined | ||
| await host.writeFile(CATALOG_FILE(host.configDir), r.text) | ||
| return parsed.entries | ||
| } | ||
|
|
||
| export const RECORD_FILE = (configDir: string) => `${configDir}/glowup/catalog-installed.json` | ||
| export type CatalogRecord = { packs: Record<string, { url: string; hash: string }>; themes: Record<string, { hash: string }> } | ||
|
|
||
| // FNV-1a: detects "the file is still what glowup wrote", not tampering. | ||
| export function textHash(text: string): string { | ||
| let h = 0x811c9dc5 | ||
| for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 0x01000193) } | ||
| return (h >>> 0).toString(16).padStart(8, '0') | ||
| } | ||
|
|
||
| export async function loadRecord(host: Host): Promise<CatalogRecord> { | ||
| const out: CatalogRecord = { packs: {}, themes: {} } | ||
| let v: any | ||
| try { v = parseJsonc(await host.readFile(RECORD_FILE(host.configDir))) } catch { return out } | ||
| for (const [k, p] of Object.entries(v?.packs ?? {})) if (typeof (p as any)?.url === 'string' && typeof (p as any)?.hash === 'string') out.packs[k] = { url: (p as any).url, hash: (p as any).hash } | ||
| for (const [k, t] of Object.entries(v?.themes ?? {})) if (typeof (t as any)?.hash === 'string') out.themes[k] = { hash: (t as any).hash } | ||
| return out | ||
| } | ||
|
|
||
| async function download(host: Host, url: string, what: string): Promise<string | { message: string }> { | ||
| try { | ||
| const r = await host.fetchText(url) | ||
| return r.ok ? r.text : { message: `Could not download ${what} (HTTP ${r.status}).` } | ||
| } catch (err) { return { message: `Could not download ${what}: ${err instanceof Error ? err.message : String(err)}` } } | ||
| } | ||
|
|
||
| // Unchanged since glowup wrote it: the only state in which glowup may replace a file. | ||
| async function untouched(host: Host, path: string, hash: string | undefined) { | ||
| if (hash === undefined) return false | ||
| try { return textHash(await host.readFile(path)) === hash } catch { return false } | ||
| } | ||
|
|
||
| // Everything is downloaded and checked before the first write, so a refused install leaves no file behind. | ||
| export async function installEntry(host: Host, entry: CatalogEntry, opts: { force: boolean }): Promise<{ name?: string; message: string }> { | ||
| const packText = await download(host, entry.pack, entry.name) | ||
| if (typeof packText !== 'string') return packText | ||
| let named: unknown | ||
| try { named = (parseJsonc(packText) as { name?: unknown } | null)?.name } catch { named = undefined } | ||
| if (typeof named !== 'string') return { message: `The catalog's ${entry.name} downloads something that is not a pack file; nothing was installed.` } | ||
| if (named.toLowerCase() !== entry.name) return { message: `The catalog's ${entry.name} downloads a pack named "${shown(String(named))}"; nothing was installed.` } | ||
| const record = await loadRecord(host) | ||
| const themes: { name: string; text: string; file: unknown }[] = [] | ||
| for (const url of entry.themes) { | ||
| const text = await download(host, url, `${entry.name}'s theme`) | ||
| if (typeof text !== 'string') return text | ||
| const checked = await checkThemeText(host, text, Object.fromEntries(themes.map(t => [t.name, t.file]))) | ||
| if ('error' in checked) return { message: `${entry.name}: ${checked.error}` } | ||
| themes.push({ name: checked.name, text, file: checked.file }) | ||
| } | ||
| const pack = await checkPackText(host, packText, opts.force, Object.fromEntries(themes.map(t => [t.name, t.file]))) | ||
| if ('message' in pack) return pack | ||
| const dir = THEME_DIR(host.configDir) | ||
| for (const t of themes) { | ||
| const path = `${dir}/${t.name}.json` | ||
| const own = Object.hasOwn(record.themes, t.name) ? record.themes[t.name]!.hash : undefined | ||
| if (await host.exists(path) && !(opts.force && await untouched(host, path, own))) continue | ||
| await host.writeFile(path, t.text) | ||
| record.themes[t.name] = { hash: textHash(t.text) } | ||
| } | ||
| await host.writeFile(`${PACK_DIR(host.configDir)}/${pack.name}.json`, packText) | ||
| record.packs[pack.name] = { url: entry.pack, hash: textHash(packText) } | ||
| await host.writeFile(RECORD_FILE(host.configDir), JSON.stringify(record, null, 2) + '\n') | ||
| return { name: pack.name, message: `Installed pack "${pack.name}".` } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Treat the catalog cache write as best-effort.
refreshCatalogawaitshost.writeFilewithout a handler. If the write fails (for example with EACCES), the function rejects. A successful fetch then returns no entries.installConfiguredcallsrefreshCatalog(host).catch(() => undefined), so a cache write failure discards fresh entries that were fetched and are valid. Catch the write error and returnparsed.entries.Proposed fix
Based on learnings: "If a live fetch succeeds and an optional cache write fails, log or swallow the write error and return the fresh result."
🤖 Prompt for AI Agents
Source: Learnings