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('')}
+ + artifact
+
${nav()}`;
+}
+function artifactRow(a,i){
+ const sel=(v,opts)=>opts.map(o=>`${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?'Remove ':''}
+
+
OS ${sel(a.os,['linux','darwin'])}
+
Arch ${sel(a.arch,['amd64','arm64'])}
+
Unpack ${info('tar.gz extracts the upload under the app dir; leave blank for a single binary.')} ${sel(a.unpack,['','tar.gz'])}
+
+
+
+
+
`;
+}
+// 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 GET POST
@@ -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));
}