From c1825ffb645c502f77ccca77904a7264eb30d442 Mon Sep 17 00:00:00 2001 From: Alex Godoroja Date: Mon, 22 Jun 2026 13:05:00 -0700 Subject: [PATCH] =?UTF-8?q?feat(publish):=20Artifacts=20step=20=E2=80=94?= =?UTF-8?q?=20upload=20platform=20binaries=20to=20the=20R2=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an Artifacts wizard step (cli apps) to /publish: per-platform binary upload via POST /api/artifact/presign (direct-to-R2, sha256 computed in the browser), plus install order, dependencies, and install args. Submits an artifacts[] array in the Submission. HTTP apps see a short "no artifacts needed" note. Verified end-to-end in a real browser against the local publish-server: uploaded the 30 MB smolvm tarball, submitted, built. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pages/publish.astro | 92 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/src/pages/publish.astro b/src/pages/publish.astro index dcc833f..b713153 100644 --- a/src/pages/publish.astro +++ b/src/pages/publish.astro @@ -214,7 +214,7 @@ const LS = 'pilot.publish.draft.v1'; const LAT = { fast: 'under 5 seconds', med: 'up to 15 seconds', slow: 'up to 1 minute' }; -const STEPS = ['Email', 'Identity', 'Backend', 'Methods', 'Listing', 'Vendor', 'Review']; +const STEPS = ['Email', 'Identity', 'Backend', 'Methods', 'Artifacts', 'Listing', 'Vendor', 'Review']; let step = 0; @@ -233,6 +233,7 @@ let state = load() || { app_type: 'api', // 'api' (HTTP) | 'cli' (local command) backend: { base_url: '', headers: [blankHeader()], command: '', env_passthrough: '' }, methods: [blankMethod()], + artifacts: [], // native binary delivery (cli apps): per-platform uploads + install order/deps/args listing: { display_name: '', tagline: '', app_description: '', license: '', homepage: '', source_url: '', categories: '', keywords: '', requires_binary: false, binary_url: '' }, vendor: { name: '', url: '', agent_usage: '', capabilities: '' }, release: { signer_name: '', agreed: false }, @@ -243,6 +244,7 @@ if (!state.release) state.release = { signer_name: '', agreed: false }; if (state.app_type == null) state.app_type = 'api'; if (state.backend.command == null) state.backend.command = ''; if (state.backend.env_passthrough == null) state.backend.env_passthrough = ''; +if (!Array.isArray(state.artifacts)) state.artifacts = []; state.methods.forEach(m => { if (!m.cli) m.cli = blankCLI(); if (!m.http) m.http = { verb: 'GET', path: '' }; }); function load() { try { return JSON.parse(localStorage.getItem(LS)); } catch { return null; } } @@ -268,6 +270,10 @@ function submission() { else base.http = { verb: m.http.verb, path: m.http.path }; return base; }), + artifacts: cli ? state.artifacts.filter(a=>a.url && a.sha256 && a.exec_path).map(a=>({ + role: a.role||'binary', name: a.name||'', os: a.os, arch: a.arch, url: a.url, sha256: a.sha256, + unpack: a.unpack||'', exec_path: a.exec_path, deps: csv(a.deps), order: Number(a.order)||0, args: csv(a.args), + })) : [], listing: { ...state.listing, categories: csv(state.listing.categories), keywords: csv(state.listing.keywords) }, vendor: { ...state.vendor, contact: state.email }, release: { @@ -350,6 +356,8 @@ const STEPS_HTML = [
${nav()}`, + () => artifactsStepHTML(), + () => `
Step ${step+1} of ${STEPS.length}

Listing

How the app appears in the store.

@@ -434,6 +442,74 @@ function cliBackendHTML(){
`; } +// ── Artifacts step (native binary delivery for cli apps) ── +const blankArtifact = () => ({ os:'darwin', arch:'arm64', role:'binary', unpack:'', exec_path:'', name:'', deps:'', order:0, args:'', url:'', sha256:'', filename:'', status:'' }); +function artifactsStepHTML(){ + if (state.app_type !== 'cli') { + return `
Step ${step+1} of ${STEPS.length}

Artifacts

+
Artifacts deliver a binary from the Pilot registry to the host. Your HTTP API app runs on your servers and ships no binary, so there is nothing to upload here.
${nav()}`; + } + return `
Step ${step+1} of ${STEPS.length}

Artifacts

+

Upload the platform-specific binaries your CLI needs. Each is uploaded to the Pilot artifact registry, sha256-checksummed, and — at pilotctl appstore install — fetched, verified, and staged under the app dir in the order you set (with any dependencies and install args). The command you set in Backend (${esc((state.backend.command||'').split(',')[0].trim()||'cli')}) is run from the staged path.

+
Upload a single binary, or a .tar.gz bundle (set unpack = tar.gz and point exec_path at the binary inside it). Integrity is the sha256 — computed in your browser and pinned into the signed manifest.
+
${state.artifacts.map((a,i)=>artifactRow(a,i)).join('')}
+ +
${nav()}`; +} +function artifactRow(a,i){ + const sel=(v,opts)=>opts.map(o=>``).join(''); + const status = a.url + ? `uploaded ✓ ${esc((a.sha256||'').slice(0,16))}… ${esc(a.filename)}` + : (a.status ? `${esc(a.status)}` : `no file uploaded yet`); + return `
+
${esc(a.os)}/${esc(a.arch)}${a.exec_path?' — '+esc(a.exec_path):''} + ${state.artifacts.length>0?'':''}
+
+
OS
+
Arch
+
Unpack ${info('tar.gz extracts the upload under the app dir; leave blank for a single binary.')}
+
+
Binary file ${info('Uploaded directly to the Pilot registry. sha256 is computed in your browser.')} +
${status}
+
+
exec_path ${info('Where the binary lands under the app dir, or the path INSIDE a tar.gz (e.g. tool-1.0-darwin-arm64/tool).')}
+
Install order ${info('Lower installs first. Within a platform.')}
+
+
+
Name ${info('Optional id for this artifact, referenced by other artifacts as a dependency. Defaults to the exec_path basename.')}
+
Dependencies ${info('Comma-separated names of artifacts (same platform) that must install before this one.')}
+
Install args ${info('Optional one-time setup run after staging, e.g. --accept-license. Comma-separated.')}
+
+
`; +} +// sha256 hex of a File/Blob, computed in the browser. +async function sha256hex(buf){ + const d = await crypto.subtle.digest('SHA-256', buf); + return Array.from(new Uint8Array(d)).map(b=>b.toString(16).padStart(2,'0')).join(''); +} +async function uploadArtifact(i, file){ + const a = state.artifacts[i]; + const setStatus = (msg)=>{ a.status=msg; const el=document.querySelector(`[data-ai="${i}"] .a-status`); if(el) el.innerHTML=`${esc(msg)}`; }; + if (!/^io\.pilot\.[a-z0-9-]+$/.test(state.id) || !state.version) { setStatus('set a valid App ID + Version first'); return; } + try { + setStatus('requesting upload slot…'); + const pres = await fetch(API+'/api/artifact/presign',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({ id:state.id, version:state.version, os:a.os, arch:a.arch, filename:file.name })}); + if(!pres.ok){ setStatus('presign failed: '+(await pres.text()).slice(0,140)); return; } + const slot = await pres.json(); + const buf = await file.arrayBuffer(); + setStatus('hashing…'); const sha = await sha256hex(buf); + setStatus('uploading '+(file.size/1048576).toFixed(1)+' MB…'); + const put = await fetch(slot.put_url,{method:'PUT',body:buf}); + if(!put.ok){ setStatus('upload failed: HTTP '+put.status); return; } + a.url = slot.public_url; a.sha256 = sha; a.filename = file.name; a.status=''; + if(!a.exec_path){ a.exec_path = a.unpack==='tar.gz' ? '' : 'bin/'+(state.backend.command||'tool').split(',')[0].trim(); } + save(); + const el=document.querySelector(`[data-ai="${i}"] .a-status`); + if(el) el.innerHTML=`uploaded ✓ ${esc(sha.slice(0,16))}… ${esc(file.name)}`; + } catch(e){ setStatus('error: '+(e&&e.message||e)); } +} + // ── method route cells (per app type) ── function httpRouteCells(m){ return `
Verb
@@ -534,6 +610,20 @@ function wire() { }); }); } + if (cur==='Artifacts' && state.app_type==='cli'){ + const add=document.getElementById('add-artifact'); if(add) add.onclick=()=>{ state.artifacts.push(blankArtifact()); save(); render(); }; + document.querySelectorAll('#artifacts .mcard').forEach(cardEl=>{ const i=+cardEl.dataset.ai; const a=state.artifacts[i]; + const onv=(sel,fn)=>{ const el=cardEl.querySelector(sel); if(el) el.addEventListener('input',()=>{fn(el.value);save();}); }; + const onc=(sel,fn)=>{ const el=cardEl.querySelector(sel); if(el) el.addEventListener('change',()=>{fn(el.value);save();}); }; + onc('.a-os',v=>{a.os=v; cardEl.querySelector('.mname').textContent=a.os+'/'+a.arch+(a.exec_path?' — '+a.exec_path:'');}); + onc('.a-arch',v=>{a.arch=v; cardEl.querySelector('.mname').textContent=a.os+'/'+a.arch+(a.exec_path?' — '+a.exec_path:'');}); + onc('.a-unpack',v=>a.unpack=v); + onv('.a-execpath',v=>{a.exec_path=v; cardEl.querySelector('.mname').textContent=a.os+'/'+a.arch+(v?' — '+v:'');}); + onv('.a-order',v=>a.order=v); onv('.a-name',v=>a.name=v); onv('.a-deps',v=>a.deps=v); onv('.a-args',v=>a.args=v); + const fileEl=cardEl.querySelector('.a-file'); if(fileEl) fileEl.addEventListener('change',()=>{ if(fileEl.files&&fileEl.files[0]) uploadArtifact(i, fileEl.files[0]); }); + const del=cardEl.querySelector('.a-del'); if(del) del.onclick=()=>{ state.artifacts.splice(i,1); save(); render(); }; + }); + } if (cur==='Listing'){ ['display_name','tagline','app_description','license','homepage','source_url','categories','keywords'].forEach(k=>bindVal('f-l-'+k,v=>state.listing[k]=v)); }