diff --git a/packages/updates/README.md b/packages/updates/README.md index 05366d56e3..e4ca686b40 100644 --- a/packages/updates/README.md +++ b/packages/updates/README.md @@ -4,6 +4,8 @@ The updates Worker serves all selected artifacts for a channel. ```sh curl 'https://update.opencode.ai/api/latest' +curl 'https://update.opencode.ai/api/latest/cli' +curl 'https://update.opencode.ai/api/latest/cli/npm' ``` The `/admin*` route must be protected by a Cloudflare Access self-hosted application. Configure the application with: diff --git a/packages/updates/migrations/0002_artifact_time_created.sql b/packages/updates/migrations/0002_artifact_time_created.sql new file mode 100644 index 0000000000..c3bd52023c --- /dev/null +++ b/packages/updates/migrations/0002_artifact_time_created.sql @@ -0,0 +1,3 @@ +ALTER TABLE artifact ADD COLUMN time_created INTEGER NOT NULL DEFAULT 0; + +UPDATE artifact SET time_created = time_updated WHERE time_created = 0; diff --git a/packages/updates/src/index.ts b/packages/updates/src/index.ts index 796837b143..2ec5f64d2b 100644 --- a/packages/updates/src/index.ts +++ b/packages/updates/src/index.ts @@ -11,6 +11,7 @@ type ArtifactRow = { version: string metadata: string active: number + time_created: number time_updated: number } @@ -25,7 +26,7 @@ type ArtifactInput = Pick @@ -57,8 +74,36 @@ async function channel(db: D1Database, channel: string) { return cached({ channel, artifacts: result.results.map(decodeArtifact) }) } +async function artifactName(db: D1Database, channel: string, name: string) { + const result = await db + .prepare(`${select} WHERE channel = ? AND name = ? AND active = 1 ORDER BY distribution`) + .bind(channel, name) + .all() + if (!result.results.length) return json({ error: "Artifact not found" }, 404) + return cached({ channel, name, artifacts: result.results.map(decodeArtifact) }) +} + +async function artifactDistribution(db: D1Database, channel: string, name: string, distribution: string) { + const artifact = await db + .prepare(`${select} WHERE channel = ? AND name = ? AND distribution = ? AND active = 1`) + .bind(channel, name, distribution) + .first() + if (!artifact) return json({ error: "Artifact not found" }, 404) + return cached(decodeArtifact(artifact)) +} + async function admin(request: Request, env: Env) { - const result = await env.DB.prepare(`${select} ORDER BY channel, name, distribution, active DESC, time_updated DESC`).all() + const url = new URL(request.url) + const requestedPage = Number.parseInt(url.searchParams.get("page") ?? "1", 10) + const page = Number.isSafeInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1 + const pageSize = 100 + const count = await env.DB.prepare("SELECT COUNT(*) AS total FROM artifact").first<{ total: number }>() + const pages = Math.max(1, Math.ceil((count?.total ?? 0) / pageSize)) + const currentPage = Math.min(page, pages) + const result = await env.DB + .prepare(`${select} ORDER BY time_created DESC LIMIT ? OFFSET ?`) + .bind(pageSize, (currentPage - 1) * pageSize) + .all() const rows = result.results .map( (artifact) => ` @@ -66,8 +111,8 @@ async function admin(request: Request, env: Env) { ${escape(artifact.name)} ${escape(artifact.distribution)} ${escape(artifact.version)} - ${artifact.active ? 'Active' : 'History'} - ${new Date(artifact.time_updated).toISOString()} + ${new Date(artifact.time_created).toISOString()} + ${artifact.active ? 'Active' : 'Inactive'} ${ artifact.active @@ -105,11 +150,10 @@ async function admin(request: Request, env: Env) { th { color: var(--muted-foreground); font-size: .75rem; font-weight: 500; text-transform: uppercase; letter-spacing: .08em; } tbody tr:last-child td { border-bottom: 0; } td form { margin: 0; } - .publish { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; } - .publish .metadata { grid-column: 1 / -1; } - textarea { min-height: 9rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } - .actions { display: flex; justify-content: end; grid-column: 1 / -1; } - @media (max-width: 760px) { main { padding: 2rem 0; } .masthead { align-items: start; flex-direction: column; } .publish { grid-template-columns: 1fr; } .publish .metadata, .actions { grid-column: 1; } } + .pagination { display: flex; align-items: center; justify-content: space-between; gap: 1rem; border-top: 1px solid var(--border); padding: 1rem; } + .pagination p { color: var(--muted-foreground); font-size: .875rem; } + .pagination nav { display: flex; gap: .5rem; } + @media (max-width: 760px) { main { padding: 2rem 0; } .masthead { align-items: start; flex-direction: column; } } @@ -119,26 +163,20 @@ async function admin(request: Request, env: Env) { ${escape(request.headers.get("Cf-Access-Authenticated-User-Email") ?? "Cloudflare Access pending")}
-

Published artifacts

Activate any successfully published version without changing its metadata.

+

Published builds

Every build received from the trusted publishing workflow, newest first.

- - ${rows || ''} + + ${rows || ''}
ChannelNameDistributionVersionStatusTime updated
No artifacts published yet.
ChannelNameDistributionVersionCreatedStatus
No builds have been published yet.
-
-
-

Publish artifact

Publishing stores the metadata and activates this version for its distribution.

-
-
- ${field("Channel", '')} - ${field("Name", '')} - ${field("Distribution", '')} - ${field("Version", '')} - -
-
-
+
+

Page ${currentPage} of ${pages} ยท ${count?.total ?? 0} builds

+ +
@@ -147,22 +185,6 @@ async function admin(request: Request, env: Env) { ) } -async function registerArtifact(request: Request, env: Env) { - const invalid = validMutation(request) - if (invalid) return invalid - const form = await request.formData() - const artifact = parseArtifact({ - channel: form.get("channel"), - name: form.get("name"), - distribution: form.get("distribution"), - version: form.get("version"), - metadata: form.get("metadata"), - }) - if (artifact instanceof Response) return artifact - await activate(env.DB, [artifact]) - return Response.redirect(new URL("/admin", request.url), 303) -} - async function publishArtifact(request: Request, env: Env) { const claims = await verifyGitHub(request) if (claims instanceof Response) return claims @@ -219,24 +241,28 @@ async function activateArtifact(request: Request, env: Env) { function activate(db: D1Database, artifacts: ArtifactInput[]) { return db.batch( - artifacts.flatMap((artifact) => [ - deactivateStatement(db, artifact), - db - .prepare( - `INSERT INTO artifact (channel, name, distribution, version, metadata, active, time_updated) - VALUES (?, ?, ?, ?, ?, 1, ?) - ON CONFLICT (channel, name, distribution, version) DO UPDATE SET - metadata = excluded.metadata, active = 1, time_updated = excluded.time_updated`, - ) - .bind( - artifact.channel, - artifact.name, - artifact.distribution, - artifact.version, - JSON.stringify(artifact.metadata), - Date.now(), - ), - ]), + artifacts.flatMap((artifact) => { + const time = Date.now() + return [ + deactivateStatement(db, artifact), + db + .prepare( + `INSERT INTO artifact (channel, name, distribution, version, metadata, active, time_created, time_updated) + VALUES (?, ?, ?, ?, ?, 1, ?, ?) + ON CONFLICT (channel, name, distribution, version) DO UPDATE SET + metadata = excluded.metadata, active = 1, time_updated = excluded.time_updated`, + ) + .bind( + artifact.channel, + artifact.name, + artifact.distribution, + artifact.version, + JSON.stringify(artifact.metadata), + time, + time, + ), + ] + }), ) } @@ -354,10 +380,6 @@ function json(value: unknown, status = 200, headers?: HeadersInit) { return Response.json(value, { status, headers }) } -function field(label: string, input: string) { - return `
${input}
` -} - function escape(value: string) { return value.replace(/[&<>"']/g, (character) => { if (character === "&") return "&"