diff --git a/apps/extensions/public/store.js b/apps/extensions/public/store.js index ab63b69..d70bf39 100644 --- a/apps/extensions/public/store.js +++ b/apps/extensions/public/store.js @@ -167,6 +167,66 @@ async function initBrowse() { load(''); } +/* Owner-only listing editor. Publishing a new build refreshes the bundle but + not the words around it, so without this a listing keeps describing whatever + the extension did the day it was created. */ +function editForm(ext) { + return ` +
`; +} + +function wireEditForm(ext, rerender) { + const btn = document.getElementById('editBtn'); + const form = document.getElementById('editForm'); + if (!btn || !form) return; + + btn.addEventListener('click', () => { + form.classList.toggle('hidden'); + if (!form.classList.contains('hidden')) form.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }); + document.getElementById('editCancel').addEventListener('click', () => form.classList.add('hidden')); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const out = document.getElementById('editOut'); + const save = form.querySelector('button[type="submit"]'); + const fd = new FormData(form); + // Blank optional fields clear the column; a blank name is rejected server-side. + const body = { + name: String(fd.get('name') || '').trim(), + summary: String(fd.get('summary') || '').trim() || null, + description: String(fd.get('description') || '').trim() || null, + homepageUrl: String(fd.get('homepageUrl') || '').trim() || null, + }; + save.disabled = true; + out.innerHTML = 'Saving…'; + try { + await api(`/extensions/${encodeURIComponent(ext.id)}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + out.innerHTML = 'Saved — listing updated.'; + await rerender(); + } catch (err) { + out.innerHTML = `${esc(err.message)}`; + save.disabled = false; + } + }); +} + /* ---------- detail (extension.html) ---------- */ async function initDetail() { const slug = qs('slug'); @@ -174,6 +234,14 @@ async function initDetail() { if (!slug) { root.innerHTML = 'No extension specified.
'; return; } if (qs('paid')) document.getElementById('paidNote')?.classList.remove('hidden'); try { + await renderDetail(slug, root); + } catch (e) { + root.innerHTML = `${e.status === 404 ? 'Extension not found.' : esc(e.message)}
`; + } +} + +async function renderDetail(slug, root) { + { const ext = await api(`/extensions/${encodeURIComponent(slug)}`); const v = ext.version; const perms = (v?.permissions || []).map((p) => `${esc(p)}`).join('') || 'none requested'; @@ -196,7 +264,9 @@ async function initDetail() { ⬇ Install / Download How to install + ${ext.isOwner ? '' : ''} + ${ext.isOwner ? editForm(ext) : ''} ${ext.homepageUrl ? `Homepage: ${esc(ext.homepageUrl)}
` : ''}${e.status === 404 ? 'Extension not found.' : esc(e.message)}
`; + + wireEditForm(ext, () => renderDetail(slug, root)); } } diff --git a/scripts/publish-extension.sh b/scripts/publish-extension.sh index 343ddba..4161e5d 100755 --- a/scripts/publish-extension.sh +++ b/scripts/publish-extension.sh @@ -20,6 +20,10 @@ # SCP_TARGET default files@files.profullstack.com # MANIFEST path to manifest.json (default: manifest.json) # BUNDLE dir to zip, or a .zip/.crx file (default: dist) +# LISTING path to a listing.json — {name?, summary?, description?, +# homepageUrl?, iconUrl?}. Sent as a PATCH after the version +# lands, so the store copy is versioned alongside the code +# instead of frozen at whatever it said the day you created it. set -euo pipefail STORE_URL="${STORE_URL:-https://tronbrowser.dev}" @@ -61,3 +65,17 @@ resp="$(curl -fsS -X POST "${STORE_URL}/api/store/extensions/${id}/versions" \ -H 'content-type: application/json' -d "$body")" echo "published ${STORE_SLUG}: $resp" + +# 4) Sync the listing copy, if the repo carries one. Publishing a version only +# refreshes the bundle — without this the description keeps describing an +# older release. +if [ -n "${LISTING:-}" ]; then + if [ ! -f "$LISTING" ]; then + echo "error: LISTING='$LISTING' not found" >&2 + exit 1 + fi + patch="$(curl -fsS -X PATCH "${STORE_URL}/api/store/extensions/${id}" \ + -H "authorization: Bearer ${TRONBROWSER_STORE_TOKEN}" \ + -H 'content-type: application/json' -d @"$LISTING")" + echo "listing copy synced: $patch" +fi diff --git a/services/api/src/store/db.test.ts b/services/api/src/store/db.test.ts index 5c1205d..2d8d6a0 100644 --- a/services/api/src/store/db.test.ts +++ b/services/api/src/store/db.test.ts @@ -1,5 +1,44 @@ import { describe, expect, it } from 'vitest'; -import { boundedInteger } from './db.js'; +import { boundedInteger, buildExtensionUpdate } from './db.js'; + +describe('buildExtensionUpdate', () => { + it('returns null when there is nothing to write', () => { + expect(buildExtensionUpdate({})).toBeNull(); + }); + + it('touches only the fields provided', () => { + const update = buildExtensionUpdate({ summary: 'Now with bulk payouts.' })!; + expect(update.set).toBe("summary = ?, updated_at = datetime('now')"); + expect(update.args).toEqual(['Now with bulk payouts.']); + }); + + it('maps camelCase fields to their columns', () => { + const update = buildExtensionUpdate({ homepageUrl: 'https://example.com', iconUrl: 'data:image/png;base64,AA' })!; + expect(update.set).toBe("homepage_url = ?, icon_url = ?, updated_at = datetime('now')"); + expect(update.args).toEqual(['https://example.com', 'data:image/png;base64,AA']); + }); + + it('distinguishes clearing a field from leaving it alone', () => { + const cleared = buildExtensionUpdate({ summary: null })!; + expect(cleared.args).toEqual([null]); + // `description` absent entirely — must not appear in the SET clause. + expect(cleared.set).not.toContain('description'); + }); + + it('always stamps updated_at so a copy edit is visible on the listing', () => { + const update = buildExtensionUpdate({ description: 'x' })!; + expect(update.set.endsWith("updated_at = datetime('now')")).toBe(true); + // updated_at is inlined SQL, not a bound arg. + expect(update.args).toHaveLength(1); + }); + + it('orders columns predictably regardless of key order', () => { + const a = buildExtensionUpdate({ description: 'd', name: 'n' })!; + const b = buildExtensionUpdate({ name: 'n', description: 'd' })!; + expect(a.set).toBe(b.set); + expect(a.args).toEqual(b.args); + }); +}); describe('boundedInteger', () => { it('falls back for non-finite pagination values', () => { diff --git a/services/api/src/store/db.ts b/services/api/src/store/db.ts index fdea9a7..fd8278c 100644 --- a/services/api/src/store/db.ts +++ b/services/api/src/store/db.ts @@ -65,6 +65,60 @@ export async function createExtension(x: { return (await extensionById(id))!; } +/** Editable listing copy. `undefined` leaves a column alone; `null` clears it. */ +export interface ExtensionPatch { + name?: string | null; + summary?: string | null; + description?: string | null; + homepageUrl?: string | null; + iconUrl?: string | null; +} + +const PATCH_COLUMNS: ReadonlyArray<[keyof ExtensionPatch, string]> = [ + ['name', 'name'], + ['summary', 'summary'], + ['description', 'description'], + ['homepageUrl', 'homepage_url'], + ['iconUrl', 'icon_url'], +]; + +/** + * Build the SET clause for a listing patch, skipping absent fields. Returns + * null when there is nothing to write, so callers can avoid a pointless UPDATE. + * Exported for tests — the SQL shape is the part worth pinning down. + */ +export function buildExtensionUpdate(patch: ExtensionPatch): { set: string; args: (string | null)[] } | null { + const set: string[] = []; + const args: (string | null)[] = []; + + for (const [key, column] of PATCH_COLUMNS) { + const value = patch[key]; + if (value === undefined) continue; + set.push(`${column} = ?`); + args.push(value === null ? null : String(value)); + } + + if (set.length === 0) return null; + set.push("updated_at = datetime('now')"); + return { set: set.join(', '), args }; +} + +/** + * Update a listing's copy. Without this a listing is write-once at creation: + * every later publish refreshes the bundle but leaves the marketing text + * frozen, so a listing keeps advertising whatever the extension did on day one. + */ +export async function updateExtension(id: string, patch: ExtensionPatch): Promise