← 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;});