← back to Gmc Titlefix
TK-11448: remediation plan for 19 placeholder-featured products + dry-run-default executor
82465898fed415fa2627c50f82c4ce2055ea3ad2 · 2026-09-11 11:44:06 -0700 · Steve
tk11448-build-plan.mjs builds the per-product before/after plan (8 Sandberg
reorder-only, 11 Rebel Walls add-real-image-and-feature) with identity
cross-checked against staging pattern/color and the product's own asset prefix.
tk11448-apply-plan.mjs is DRY-RUN BY DEFAULT: --apply is required, --canary N
supported, and every row writes its undo to data/tk11448-apply-ledger.jsonl
before the write. Gated to Steve; not fired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwXCqw4PdXfTY729wX8BFw
Files touched
A tk11448-apply-plan.mjsA tk11448-build-plan.mjs
Diff
commit 82465898fed415fa2627c50f82c4ce2055ea3ad2
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Sep 11 11:44:06 2026 -0700
TK-11448: remediation plan for 19 placeholder-featured products + dry-run-default executor
tk11448-build-plan.mjs builds the per-product before/after plan (8 Sandberg
reorder-only, 11 Rebel Walls add-real-image-and-feature) with identity
cross-checked against staging pattern/color and the product's own asset prefix.
tk11448-apply-plan.mjs is DRY-RUN BY DEFAULT: --apply is required, --canary N
supported, and every row writes its undo to data/tk11448-apply-ledger.jsonl
before the write. Gated to Steve; not fired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwXCqw4PdXfTY729wX8BFw
---
tk11448-apply-plan.mjs | 44 ++++++++++++++++++++++++++++++++++++++++++++
tk11448-build-plan.mjs | 35 +++++++++++++++++++++++++++++++++++
2 files changed, 79 insertions(+)
diff --git a/tk11448-apply-plan.mjs b/tk11448-apply-plan.mjs
new file mode 100644
index 0000000..f6b2992
--- /dev/null
+++ b/tk11448-apply-plan.mjs
@@ -0,0 +1,44 @@
+#!/usr/bin/env node
+/** TK-11448 executor for data/tk11448-remediation-plan.json.
+ * DRY-RUN BY DEFAULT. Requires --apply AND ticket approval to write.
+ * Records a per-row undo into data/tk11448-apply-ledger.jsonl BEFORE each write.
+ * Canary-first: writes the first --canary N rows, verifies, then stops for a human look. */
+import fs from 'fs';
+const APPLY=process.argv.includes('--apply');
+const CAN=(()=>{const i=process.argv.indexOf('--canary');return i>0?+process.argv[i+1]:0;})();
+const SHOP='designer-laboratory-sandbox';
+const tok=(fs.readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8').match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m)||[])[1];
+async function gql(q,v){for(let a=0;a<6;a++){const r=await fetch(`https://${SHOP}.myshopify.com/admin/api/2024-10/graphql.json`,{method:'POST',headers:{'X-Shopify-Access-Token':tok,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v}),signal:AbortSignal.timeout(60000)});const j=await r.json();if(j.errors){if(JSON.stringify(j.errors).includes('THROTTLED')&&a<5){await new Promise(z=>setTimeout(z,2500*(a+1)));continue;}throw new Error(JSON.stringify(j.errors).slice(0,300));}return j.data;}throw new Error('throttled');}
+const plan=JSON.parse(fs.readFileSync('data/tk11448-remediation-plan.json','utf8'));
+let rows=plan.rows; if(CAN) rows=rows.slice(0,CAN);
+console.log(`${APPLY?'APPLY':'DRY-RUN'} | rows ${rows.length} of ${plan.rows.length}`);
+if(!APPLY){ for(const r of rows) console.log(' would',r.action,r.handle,'->',String(r.after_featured).slice(0,95));
+ console.log('\nDRY-RUN ONLY. Nothing written. Re-run with --apply after ticket approval.'); process.exit(0); }
+const led=fs.createWriteStream('data/tk11448-apply-ledger.jsonl',{flags:'a'});
+for(const r of rows){
+ const cur=await gql(`query($h:String!){productByHandle(handle:$h){id media(first:30){edges{node{id ... on MediaImage{image{url}}}}}}}`,{h:r.handle});
+ const p=cur.productByHandle; const before=p.media.edges.map(e=>e.node.id);
+ led.write(JSON.stringify({ts:new Date().toISOString(),handle:r.handle,productId:p.id,action:r.action,
+ before_media_order:before,before_featured:r.before_featured,
+ undo:r.action==='REORDER_FEATURED'?'productReorderMedia to before_media_order':'productDeleteMedia the created id'})+'\n');
+ if(r.action==='REORDER_FEATURED'){
+ const target=p.media.edges.find(e=>e.node.image&&e.node.image.url.split('?')[0]===r.after_featured.split('?')[0]);
+ if(!target){console.log(' SKIP (target media not found)',r.handle);continue;}
+ const rest=before.filter(id=>id!==target.node.id);
+ const moves=[{id:target.node.id,newPosition:'0'}];
+ const res=await gql(`mutation($id:ID!,$m:[MoveInput!]!){productReorderMedia(id:$id,moves:$m){userErrors{field message}}}`,{id:p.id,m:moves});
+ console.log(' REORDER',r.handle,JSON.stringify(res.productReorderMedia.userErrors));
+ } else {
+ const res=await gql(`mutation($id:ID!,$m:[CreateMediaInput!]!){productCreateMedia(productId:$id,media:$m){media{id status} mediaUserErrors{field message}}}`,
+ {id:p.id,m:[{originalSource:r.after_featured,mediaContentType:'IMAGE',alt:r.title}]});
+ const err=res.productCreateMedia.mediaUserErrors;
+ if(err&&err.length){console.log(' ERR',r.handle,JSON.stringify(err));continue;}
+ const newId=res.productCreateMedia.media[0].id;
+ led.write(JSON.stringify({ts:new Date().toISOString(),handle:r.handle,created_media_id:newId,undo:`productDeleteMedia ${newId}`})+'\n');
+ await new Promise(z=>setTimeout(z,4000)); // let Shopify finish processing before reorder
+ const res2=await gql(`mutation($id:ID!,$m:[MoveInput!]!){productReorderMedia(id:$id,moves:$m){userErrors{field message}}}`,{id:p.id,m:[{id:newId,newPosition:'0'}]});
+ console.log(' ADD+FEATURE',r.handle,newId,JSON.stringify(res2.productReorderMedia.userErrors));
+ }
+ await new Promise(z=>setTimeout(z,800));
+}
+led.end(); console.log('ledger -> data/tk11448-apply-ledger.jsonl');
diff --git a/tk11448-build-plan.mjs b/tk11448-build-plan.mjs
new file mode 100644
index 0000000..47084d9
--- /dev/null
+++ b/tk11448-build-plan.mjs
@@ -0,0 +1,35 @@
+#!/usr/bin/env node
+/** TK-11448 READ-ONLY: build the exact remediation plan for the 19 fixable placeholder products.
+ * Emits a per-product before/after record with an undo for each. WRITES NOTHING. */
+import fs from 'fs';
+const alt=JSON.parse(fs.readFileSync('data/tk11448-placeholder-alternatives.json','utf8'));
+const cand=JSON.parse(fs.readFileSync('data/tk11448-real-image-candidates.json','utf8'));
+const candBy=new Map(cand.rows.map(r=>[r.handle,r]));
+const plan=[];
+for(const r of alt.rows){
+ if(r.class==='REORDER_ONLY_ge500'){
+ const ph=r.images.find(i=>i.placeholder), real=r.images.filter(i=>!i.placeholder&&Math.min(i.w,i.h)>=500)[0];
+ plan.push({handle:r.handle,vendor:r.vendor,title:r.title,action:'REORDER_FEATURED',
+ before_featured:ph&&ph.url, after_featured:real&&real.url, after_px:real&&`${real.w}x${real.h}`,
+ shopify_op:'productReorderMedia (promote existing media to position 1)',
+ undo:'productReorderMedia back to the recorded prior order',
+ spend:'$0 — no upload, image already on the product'});
+ } else if(r.class==='NEEDS_IMAGE_SOURCING'){
+ const c=candBy.get(r.handle);
+ if(c&&c.class==='REPLACEMENT_READY_ge500'){
+ plan.push({handle:r.handle,vendor:r.vendor,title:r.title,action:'ADD_REAL_IMAGE_AND_FEATURE',
+ before_featured:r.images.find(i=>i.placeholder)?.url,
+ after_featured:c.candidate, after_px:`${c.w}x${c.h}`, source:'dw_unified.rebelwalls_catalog.image_url',
+ mfr_sku:c.parsed_mfr_sku, verified:`fetched live, md5 ${c.md5}, ${c.bytes} bytes, min-edge ${c.min_edge}`,
+ shopify_op:'productCreateMedia(IMAGE) then productReorderMedia to position 1',
+ undo:'delete the created media id (recorded per row) — restores the prior featured image',
+ spend:'$0 — Shopify-hosted upload from an existing vendor CDN URL'});
+ }
+ }
+}
+const t={};for(const p of plan) t[p.action]=(t[p.action]||0)+1;
+console.log('plan rows:',plan.length,t);
+fs.writeFileSync('data/tk11448-remediation-plan.json',JSON.stringify({ticket:'TK-11448',built_at:new Date().toISOString(),
+ blast_radius:plan.length,tally:t,gated:true,fired:false,rows:plan},null,2));
+for(const p of plan) console.log(' ',p.action,p.handle,p.vendor,'|',(p.after_px||''),'|',p.title.slice(0,40));
+console.log('-> data/tk11448-remediation-plan.json (PLAN ONLY — nothing fired)');
← 5931b12 TK-11449: post-dedup read-only measurement of the tobacco-16
·
back to Gmc Titlefix
·
TK-11448: prove vendor absence 3 ways, build archive plan (3 df15890 →