← back to Dw Signup Fulfillment
verification/tk11283/publish-theme.cjs
77 lines
'use strict';
// Exactly two reviewed theme assets, with optimistic concurrency and rollback.
// Default is a read-only preflight; live mutation requires explicit --apply/--rollback.
const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto');
const config=require('../../lib/config');
const baseline=JSON.parse(fs.readFileSync(path.join(__dirname,'live-read.json'),'utf8'));
const allowed=['snippets/dw-trade-apply.liquid','snippets/dw-signin-modal.liquid'];
const sha=s=>crypto.createHash('sha256').update(s).digest('hex');
async function main(){
const mode=process.argv[2]||'--check';
if(!['--check','--status','--apply','--rollback'].includes(mode))throw new Error('Unknown mode');
if(mode==='--apply'){
const proof=JSON.parse(fs.readFileSync(path.join(__dirname,'journey.json'),'utf8'));
if(proof.verdict!=='PASS'||!proof.checks.some(x=>x.name==='safari scripts'))throw new Error('Chrome and Safari evidence required');
}
const env=fs.readFileSync(path.join(require('node:os').homedir(),'Projects/secrets-manager/.env'),'utf8');
const token=env.match(/^SHOPIFY_THEME_TOKEN=(.*)$/m)?.[1].replace(/^["']|["']$/g,'').trim();
if(!token)throw new Error('No theme credential');
async function request(method,endpoint,body){
if(method!=='GET'&&method!=='PUT')throw new Error('Forbidden verb');
const r=await fetch(`https://${config.SHOP_DOMAIN}/admin/api/2026-07${endpoint}`,{method,headers:{'X-Shopify-Access-Token':token,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
if(!r.ok)throw new Error(`Shopify HTTP ${r.status}`);return r.json();
}
const themes=await request('GET','/themes.json');
if(themes.themes.find(t=>t.role==='main')?.id!==baseline.theme.id)throw new Error('Live theme changed; stop for review');
const endpoint=`/themes/${baseline.theme.id}/assets.json`;
async function read(key){return(await request('GET',endpoint+'?asset[key]='+encodeURIComponent(key))).asset.value;}
const items=[];
for(const key of allowed){
const meta=baseline.assets.find(x=>x.key===key);
const old=fs.readFileSync(path.join(__dirname,meta.filename),'utf8');
const next=fs.readFileSync(path.resolve(__dirname,'../../theme-proposals/designer-signin-tk11283',key),'utf8');
if(sha(old)!==meta.sha256)throw new Error('Baseline backup changed');
const current=await read(key);
if(mode==='--status'){
fs.writeFileSync(path.join(__dirname,'current-'+key.replaceAll('/','__')),current);
items.push({key,current:sha(current),baseline:sha(old),proposed:sha(next)});
continue;
}
const expected=mode==='--rollback'?next:old;
const alreadyApplied=mode==='--apply'&&sha(current)===sha(next);
if(!alreadyApplied&&sha(current)!==sha(expected))throw new Error('Concurrent theme change at '+key+'; stopped');
items.push({key,old,next,before:sha(current),after:sha(mode==='--rollback'?old:next),alreadyApplied});
}
if(mode==='--status'){console.log(JSON.stringify({mode,assets:items},null,2));return;}
if(mode==='--check'){console.log(JSON.stringify({mode,theme:baseline.theme.id,assets:items.map(({key,before,after})=>({key,before,after}))},null,2));return;}
const attempted=[];
try{
for(const item of items){
if(item.alreadyApplied)continue;
// Recheck each asset immediately before its write.
if(sha(await read(item.key))!==item.before)throw new Error('Concurrent edit before write: '+item.key);
attempted.push(item);
await request('PUT',endpoint,{asset:{key:item.key,value:mode==='--rollback'?item.old:item.next}});
// Theme reads can briefly return the previous value after a successful PUT.
let matched=false;
for(let attempt=0;attempt<15;attempt++){
if(sha(await read(item.key))===item.after){matched=true;break;}
await new Promise(resolve=>setTimeout(resolve,1000));
}
if(!matched)throw new Error('Post-write parity failed after waiting: '+item.key);
}
}catch(error){
// Restore only a value still equal to this run's proposed value.
for(const item of attempted.reverse()){
const current=sha(await read(item.key));
if(current===item.after)await request('PUT',endpoint,{asset:{key:item.key,value:mode==='--rollback'?item.next:item.old}});
else if(current!==item.before)throw new Error('Concurrent change prevents safe automatic rollback: '+item.key);
}
throw error;
}
const evidence={timestamp:new Date().toISOString(),mode,theme:baseline.theme.id,assets:items.map(({key,before,after})=>({key,before,after})),verdict:'PASS'};
fs.writeFileSync(path.join(__dirname,mode==='--apply'?'publish.json':'rollback.json'),JSON.stringify(evidence,null,2)+'\n');
console.log(JSON.stringify(evidence,null,2));
}
main().catch(e=>{console.error(e.message);process.exitCode=1;});