@shipvo/cli 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +3 -0
  2. package/dist/adopt.js +1 -2
  3. package/dist/agents.d.ts +29 -0
  4. package/dist/agents.js +183 -0
  5. package/dist/approvals.d.ts +9 -0
  6. package/dist/approvals.js +106 -0
  7. package/dist/audit.d.ts +13 -0
  8. package/dist/audit.js +92 -0
  9. package/dist/autohalt.d.ts +1 -1
  10. package/dist/autohalt.js +28 -16
  11. package/dist/bsdiff.d.ts +4 -0
  12. package/dist/bsdiff.js +465 -0
  13. package/dist/ci-comment.d.ts +38 -0
  14. package/dist/ci-comment.js +169 -0
  15. package/dist/ci-compare.d.ts +10 -0
  16. package/dist/ci-compare.js +25 -0
  17. package/dist/ci-shots.d.ts +9 -0
  18. package/dist/ci-shots.js +81 -0
  19. package/dist/ci.d.ts +11 -0
  20. package/dist/ci.js +100 -0
  21. package/dist/cli.js +51 -1
  22. package/dist/config.d.ts +15 -0
  23. package/dist/config.js +68 -13
  24. package/dist/doctor.d.ts +2 -0
  25. package/dist/doctor.js +88 -8
  26. package/dist/eas.d.ts +29 -0
  27. package/dist/eas.js +258 -0
  28. package/dist/ensure-playwright.d.ts +25 -0
  29. package/dist/ensure-playwright.js +69 -0
  30. package/dist/explore-issues.d.ts +12 -0
  31. package/dist/explore-issues.js +50 -0
  32. package/dist/explore.d.ts +160 -0
  33. package/dist/explore.js +496 -0
  34. package/dist/flows.d.ts +31 -0
  35. package/dist/flows.js +264 -0
  36. package/dist/help.d.ts +2 -2
  37. package/dist/help.js +37 -7
  38. package/dist/hermes.d.ts +10 -0
  39. package/dist/hermes.js +50 -0
  40. package/dist/init.d.ts +1 -0
  41. package/dist/init.js +71 -19
  42. package/dist/json-out.d.ts +53 -0
  43. package/dist/json-out.js +91 -0
  44. package/dist/keys-json.d.ts +1 -0
  45. package/dist/keys-json.js +71 -0
  46. package/dist/login.js +27 -11
  47. package/dist/lumi.d.ts +104 -0
  48. package/dist/lumi.js +364 -0
  49. package/dist/migrate.d.ts +27 -0
  50. package/dist/migrate.js +521 -0
  51. package/dist/policy.d.ts +2 -16
  52. package/dist/policy.js +2 -18
  53. package/dist/preview-recorder.d.ts +1 -0
  54. package/dist/preview-recorder.js +62 -0
  55. package/dist/preview.d.ts +4 -3
  56. package/dist/preview.js +114 -15
  57. package/dist/promote.d.ts +1 -1
  58. package/dist/promote.js +63 -14
  59. package/dist/publish.d.ts +14 -2
  60. package/dist/publish.js +131 -56
  61. package/dist/qr.d.ts +9 -0
  62. package/dist/qr.js +213 -0
  63. package/dist/report-md.d.ts +27 -0
  64. package/dist/report-md.js +58 -0
  65. package/dist/rotation.d.ts +57 -0
  66. package/dist/rotation.js +157 -0
  67. package/dist/run-bridge.d.ts +7 -0
  68. package/dist/run-bridge.js +728 -0
  69. package/dist/run-net.d.ts +39 -0
  70. package/dist/run-net.js +266 -0
  71. package/dist/run-protocol.d.ts +32 -0
  72. package/dist/run-protocol.js +22 -0
  73. package/dist/run-ws.d.ts +8 -0
  74. package/dist/run-ws.js +89 -0
  75. package/dist/run.d.ts +109 -0
  76. package/dist/run.js +385 -0
  77. package/dist/signing.js +13 -0
  78. package/dist/sim.d.ts +4 -0
  79. package/dist/sim.js +25 -7
  80. package/dist/smoke.d.ts +28 -0
  81. package/dist/smoke.js +117 -0
  82. package/dist/spawn-bin.d.ts +11 -0
  83. package/dist/spawn-bin.js +21 -0
  84. package/dist/status.d.ts +71 -1
  85. package/dist/status.js +162 -5
  86. package/dist/store.d.ts +36 -0
  87. package/dist/store.js +266 -3
  88. package/dist/templates/capacitor/github-actions/build.yml +122 -0
  89. package/dist/templates/github-action/shipvo-check.yml +18 -0
  90. package/dist/test.d.ts +46 -0
  91. package/dist/test.js +253 -0
  92. package/package.json +7 -4
  93. package/skills/shipvo-cli/SKILL.md +31 -0
package/dist/preview.js CHANGED
@@ -4,6 +4,8 @@ import { spawn } from "node:child_process";
4
4
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
5
5
  import { join, relative, sep, extname } from "node:path";
6
6
  import { args as parse, loadConfig } from "./config.js";
7
+ import { spawnBin } from "./spawn-bin.js";
8
+ import { RECORDER_JS } from "./preview-recorder.js";
7
9
  const API = "https://here.now/api/v1";
8
10
  const CLIENT = "shipvo/cli";
9
11
  const TYPES = {
@@ -72,8 +74,8 @@ export function collectFiles(dir) {
72
74
  walk(dir);
73
75
  return out;
74
76
  }
75
- /** The page people open: the app in an Android or iPhone frame, with skin, size and theme switches and a back control. */
76
- export function framePage(name) {
77
+ /** The page people open: the app in an Android or iPhone frame, with skin, size and theme switches, a back control and a recorder panel. */
78
+ export function framePage(name, appId = name) {
77
79
  const title = name.replace(/[<>&"']/g, "");
78
80
  return `<!doctype html>
79
81
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
@@ -85,7 +87,7 @@ body.dark{background:#15171a;color:#eee}
85
87
  .bar{display:flex;gap:8px;justify-content:center;align-items:center;padding:10px;flex-wrap:wrap}
86
88
  .bar button,.back{font:inherit;padding:6px 12px;border:1px solid #888;border-radius:8px;background:transparent;color:inherit;cursor:pointer}
87
89
  .bar button[aria-pressed=true]{background:#2563eb;border-color:#2563eb;color:#fff}
88
- .stage{display:flex;justify-content:center;padding:0 10px 20px}
90
+ .stage{display:flex;justify-content:center;align-items:flex-start;gap:16px;padding:0 10px 20px;flex-wrap:wrap}
89
91
  .device{display:flex;flex-direction:column;background:#000;color:#fff;border-radius:28px;box-shadow:0 8px 30px #0005;overflow:hidden;width:360px;max-width:100%;box-sizing:border-box;padding:0 6px}
90
92
  .device[data-skin=iphone]{border-radius:44px;padding:0 10px}
91
93
  .status{display:flex;justify-content:space-between;align-items:center;height:28px;padding:0 14px;font-size:12px;flex:none}
@@ -103,42 +105,138 @@ iframe{border:0;background:#fff;width:100%;flex:1;min-height:0;display:block}
103
105
  .device[data-skin=iphone] .back::after{content:"";position:absolute;left:0;right:0;top:50%;height:5px;margin-top:-2px;border-radius:3px;background:#fff}
104
106
  .back:focus-visible{outline:2px solid #fff;outline-offset:2px}
105
107
  .device[data-skin=android] .back{border:0}
108
+ .side{max-width:360px;margin:0 auto 20px;padding:0 10px;box-sizing:border-box}
109
+ .side h2{font-size:14px;margin:0 0 6px}.side ol{padding-left:20px;margin:6px 0}.side li{margin-bottom:6px}.side small{opacity:.7}
110
+ .side button,.side input{font:inherit}
111
+ .side input{width:100%;box-sizing:border-box;min-height:36px;background:transparent;color:inherit;border:1px solid #888;border-radius:6px;padding:4px 8px;margin:4px 0}
112
+ body.dark .side input{background:#15171a;color:#eee}body.dark .side small{opacity:.85}
113
+ .fab{position:fixed;right:12px;bottom:12px;z-index:10;min-width:44px;min-height:44px;padding:8px 14px;font:inherit;border:1px solid #888;border-radius:22px;background:#fff;color:#111;cursor:pointer;box-shadow:0 2px 8px #0004}
114
+ .fab[aria-pressed=true]{background:#2563eb;border-color:#2563eb;color:#fff}
115
+ .fab:focus-visible{outline:3px solid #f59e0b;outline-offset:2px}
116
+ body.dark .fab{background:#2a2d31;color:#eee}body.dark .fab[aria-pressed=true]{background:#2563eb;color:#fff}
117
+ .device{position:relative}
118
+ #pin{position:absolute;inset:0;display:none;cursor:crosshair;background:#2563eb22;z-index:5}
119
+ #pin.on{display:block}
120
+ #pin .hint{position:absolute;left:8px;right:8px;top:8px;padding:6px 8px;border-radius:6px;background:#111;color:#fff;font-size:13px;text-align:center}
106
121
  .note{text-align:center;opacity:.7;padding:0 12px 16px}
107
- @media (max-width:500px){.bar,.note{display:none}.stage{padding:0}.device{padding:0!important;width:100vw!important;height:100vh!important;height:100dvh!important;border-radius:0!important;box-shadow:none}}
122
+ .recp a{color:inherit;text-decoration:underline}body.dark .recp a{color:#93c5fd}.recp button:focus-visible,.bar button:focus-visible,.recp a:focus-visible{outline:2px solid #60a5fa;outline-offset:2px}
123
+ .recp{max-width:520px;margin:0 auto;padding:0 12px 24px;text-align:center}.recp p{opacity:.8}.recp button{font:inherit;padding:6px 12px;border:1px solid #888;border-radius:8px;background:transparent;color:inherit;cursor:pointer}
124
+ .panel{width:280px;max-width:100%;box-sizing:border-box;display:grid;gap:10px;padding:12px;border:1px solid #8886;border-radius:12px}
125
+ .panel h2{font-size:15px;margin:0}.panel fieldset{border:0;margin:0;padding:0;display:grid;gap:6px}.panel legend{font-weight:600;padding:0 0 4px}
126
+ .panel .row{display:flex;gap:6px;flex-wrap:wrap}.panel input,.panel select,.panel button{font:inherit;padding:5px 8px;border:1px solid #888;border-radius:8px;background:transparent;color:inherit;min-width:0}
127
+ body.dark .panel select,body.dark .panel option{background:#15171a;color:#eee}.panel button[aria-pressed=true]{background:#2563eb;border-color:#2563eb;color:#fff}.panel input{flex:1 1 80px}.panel button{cursor:pointer}.panel .sim{opacity:.7;font-size:12px;margin:0}
128
+ @media (max-width:500px){.bar,.note{display:none}.stage{padding:0;display:block}.panel{width:auto;margin:10px;max-width:none}.device{padding:0!important;width:100vw!important;height:100vh!important;height:100dvh!important;border-radius:0!important;box-shadow:none}}
108
129
  </style></head><body>
109
130
  <div class="bar" role="toolbar" aria-label="Preview options">
110
131
  <button data-skin="android" aria-pressed="true">Android</button><button data-skin="iphone" aria-pressed="false">iPhone</button>
111
132
  <button data-size="phone" aria-pressed="true">Phone</button><button data-size="large" aria-pressed="false">Large phone</button><button data-size="tablet" aria-pressed="false">Tablet</button>
112
133
  <button id="theme" aria-pressed="false">Dark</button></div>
134
+ <button id="comment" class="fab" aria-pressed="false">Comment</button>
113
135
  <div class="stage"><div class="device" id="device" data-skin="android" style="width:360px;height:760px">
114
136
  <div class="status" id="status"><span id="clock">9:41</span><span class="notch"></span><span class="icons" id="icons">&#9679;&#9679; 5G 100%</span></div>
115
- <iframe id="app" src="app.html" title="${title}"></iframe>
137
+ <iframe id="app" src="app.html" title="${title}"></iframe><div id="pin" role="button" tabindex="0" aria-label="Tap where the problem is, or press Enter to mark the centre of the device"><span class="hint">Tap where the problem is. Press Escape to cancel.</span></div>
116
138
  <div class="nav"><button class="back" id="back" aria-label="Back" title="Back">&#9664; Back</button></div>
117
- </div></div>
139
+ </div>
140
+ <aside class="panel" id="device-panel" aria-label="Device controls"><h2>Device</h2>
141
+ <fieldset><legend>Location</legend><div class="row"><select data-control="location-list" aria-label="Place"><option value="4.0511,9.7679">Douala</option><option value="48.8566,2.3522">Paris</option><option value="40.7128,-74.006">New York</option><option value="-1.2921,36.8219">Nairobi</option></select><button data-control="location-set">Set</button></div>
142
+ <div class="row"><input data-control="lat" placeholder="Latitude" inputmode="decimal" aria-label="Latitude"><input data-control="lng" placeholder="Longitude" inputmode="decimal" aria-label="Longitude"><button data-control="coords-set">Go</button></div></fieldset>
143
+ <fieldset><legend>Camera</legend><div class="row"><select data-control="camera-list" aria-label="Barcode"><option value="ean13|6181234567890">EAN-13 6181234567890</option><option value="qr|https://example.com">QR https://example.com</option><option value="none|">Nothing in view</option></select><button data-control="camera-set">Show</button></div></fieldset>
144
+ <fieldset><legend>Fingerprint / Face ID</legend><div class="row"><button data-control="bio-success">Match</button><button data-control="bio-fail">No match</button></div></fieldset>
145
+ <fieldset><legend>Payment</legend><div class="row"><button data-control="pay-approve">Approve</button><button data-control="pay-decline">Decline</button></div></fieldset>
146
+ <fieldset><legend>Network</legend><div class="row"><button data-control="net-online">Online</button><button data-control="net-slow">Slow</button><button data-control="net-offline">Offline</button></div></fieldset>
147
+ <fieldset><legend>Push notification</legend><input data-control="push-title" placeholder="Title" aria-label="Title"><input data-control="push-body" placeholder="Body" aria-label="Body"><button data-control="push-send">Send</button></fieldset>
148
+ <p class="sim" id="panel-status" role="status">All device events here are simulated.</p></aside>
149
+ </div>
150
+ <div class="side" id="side"><h2>Comments (<span id="count">0</span>)</h2>
151
+ <form id="noteform" hidden><label for="notetext">Note for the point you tapped</label><br><input id="notetext"> <button type="submit">Save</button></form><span id="notesaved" role="status"></span>
152
+ <ol id="notelist"></ol>
153
+ <button id="export">Export comments.json</button>
154
+ <p><small>Notes live in this browser until you export them. Nothing is uploaded.</small></p></div>
118
155
  <p class="note">${title}: simulated device, public preview. System sheets such as permissions and payments are simulated.</p>
119
- <script>
156
+ <script data-notes>
157
+ (function(){
158
+ var KEY="shipvo-preview-comments",steps=[],pending=null,notes=[];
159
+ function $(id){return document.getElementById(id)}
160
+ try{notes=JSON.parse(localStorage.getItem(KEY)||"[]");if(!Array.isArray(notes))notes=[]}catch(e){notes=[]}
161
+ function save(){try{localStorage.setItem(KEY,JSON.stringify(notes))}catch(e){}}
162
+ function esc(t){return String(t).replace(/[&<>"']/g,function(c){return"&#"+c.charCodeAt(0)+";"})}
163
+ function screenName(){try{var w=$("app").contentWindow,t=w.document.title,r=w.location.pathname+(w.location.hash||"");return t?t+" ("+r+")":r}catch(e){return"unknown"}}
164
+ function step(text){steps.push(text)}
165
+ function add(note,x,y){var n={note:String(note),x:Math.round(x),y:Math.round(y),screen:screenName(),steps:steps.slice()};notes.push(n);save();render();return n}
166
+ function exportJson(){return JSON.stringify(notes,null,2)}
167
+ function render(){var l=$("notelist"),c=$("count");if(c)c.textContent=notes.length;if(l)l.innerHTML=notes.map(function(n){return"<li>"+esc(n.note)+"<br><small>"+esc(n.screen)+" at "+Number(n.x)+","+Number(n.y)+", "+(n.steps||[]).length+" steps</small></li>"}).join("")}
168
+ function download(){var b=new Blob([exportJson()],{type:"application/json"}),u=URL.createObjectURL(b),a=document.createElement("a");a.href=u;a.download="comments.json";document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(u)}
169
+ function label(e){var t=e.getAttribute&&(e.getAttribute("aria-label")||e.textContent)||"";return String(t).trim().slice(0,40)}
170
+ var seen=typeof WeakSet==="function"?new WeakSet():null;
171
+ function watch(){try{var d=$("app").contentWindow.document;if(seen){if(seen.has(d))return;seen.add(d)}d.addEventListener("click",function(ev){var e=ev.target,k=e&&e.closest&&e.closest("button,[role=button],a,input");step("tap "+(k?label(k)||"control":"screen"))},true)}catch(e){}}
172
+ function setMode(on,refocus){var p=$("pin"),b=$("comment");if(p)p.className=on?"on":"";if(b){b.setAttribute("aria-pressed",on);if(refocus&&b.focus)b.focus()}if(on&&p&&p.focus)p.focus()}
173
+ var cb=$("comment");if(cb)cb.onclick=function(){setMode($("pin").className!=="on")};
174
+ function place(x,y){pending={x:x,y:y};setMode(false);$("noteform").hidden=false;if($("notetext").focus)$("notetext").focus()}
175
+ var pin=$("pin");if(pin){pin.onclick=function(ev){var r=pin.getBoundingClientRect?pin.getBoundingClientRect():{left:0,top:0};place(ev.clientX-r.left,ev.clientY-r.top)};
176
+ pin.onkeydown=function(ev){if(ev.key==="Enter"||ev.key===" "){if(ev.preventDefault)ev.preventDefault();var r=pin.getBoundingClientRect?pin.getBoundingClientRect():{width:0,height:0};place((r.width||0)/2,(r.height||0)/2)}}}
177
+ var fm=$("noteform");if(fm)fm.onsubmit=function(ev){if(ev&&ev.preventDefault)ev.preventDefault();var t=$("notetext");if(pending&&t.value.trim()){add(t.value.trim(),pending.x,pending.y)}t.value="";pending=null;fm.hidden=true;var st=$("notesaved");if(st)st.textContent="Saved";setMode(false,true);return false};
178
+ if(document.addEventListener)document.addEventListener("keydown",function(ev){if(ev.key==="Escape"){var was=$("pin")&&$("pin").className==="on",f=$("noteform");if(was||(f&&!f.hidden)){if(f)f.hidden=true;pending=null;setMode(false,true)}}});
179
+ var ex=$("export");if(ex)ex.onclick=download;
180
+ var bk=$("back");if(bk&&bk.addEventListener)bk.addEventListener("click",function(){step("back")});
181
+ var ap=$("app");if(ap){if(ap.addEventListener)ap.addEventListener("load",watch);watch()}
182
+ window.shipvoNotes={add:add,step:step,list:function(){return notes.slice()},exportJson:exportJson,download:download};
183
+ render();
184
+ })();
185
+ </script>
186
+ <section class="recp" aria-label="Recorder"><p id="honesty">This is the app's web build on Shipvo's simulated device, measured against Android and iOS in <a href="https://github.com/shipvo/shipvo/blob/main/docs/parity-results.md">docs/parity-results.md</a>. Native-only UI can differ.</p>
187
+ <button id="rec" aria-pressed="false">Record</button> <button id="dl" disabled>Download test</button> <span id="stepcount" aria-live="polite">0 steps</span></section>
188
+ <script>${RECORDER_JS}
189
+ var APP=${JSON.stringify(appId).replace(/</g, "\\u003c")},TITLE=${JSON.stringify(title).replace(/</g, "\\u003c")},recording=false;
190
+ function step(s){if(recording){shipvoSteps.push(s);document.getElementById("stepcount").textContent=shipvoSteps.length+(shipvoSteps.length===1?" step":" steps");document.getElementById("dl").disabled=false}}
191
+ document.getElementById("app").addEventListener("load",function(){shipvoAttach(this,step)});
192
+ (function(a){if(a.contentDocument&&a.contentDocument.readyState==="complete")shipvoAttach(a,step)})(document.getElementById("app"));
193
+ document.getElementById("rec").onclick=function(){recording=!recording;if(recording){shipvoSteps.length=0;document.getElementById("stepcount").textContent="0 steps";document.getElementById("dl").disabled=true}this.setAttribute("aria-pressed",recording)};
194
+ document.getElementById("dl").onclick=function(){var a=document.createElement("a");a.href=URL.createObjectURL(new Blob([shipvoExport(APP,shipvoSteps,TITLE)],{type:"text/typescript"}));a.download="recorded-session.test.ts";a.click()};
120
195
  var SIZES={phone:[360,760],large:[412,892],tablet:[820,1180]},IPHONE={phone:[390,844],large:[430,932],tablet:[820,1180]};
121
196
  var skin="android",size="phone",d=document.getElementById("device"),f=document.getElementById("app"),bk=document.getElementById("back");
122
197
  function pressed(sel,val,key){document.querySelectorAll(sel).forEach(function(o){o.setAttribute("aria-pressed",o.dataset[key]===val)})}
123
198
  function apply(){var s=(skin==="iphone"?IPHONE:SIZES)[size];d.dataset.skin=skin;d.style.width=s[0]+"px";d.style.height=s[1]+"px";bk.innerHTML=skin==="iphone"?"Back":"&#9664; Back";bk.setAttribute("aria-label","Back");document.getElementById("icons").innerHTML=skin==="iphone"?"5G &#9646; 100%":"&#9679;&#9679; 5G 100%";pressed("[data-skin]",skin,"skin");pressed("[data-size]",size,"size")}
124
- document.querySelectorAll("[data-skin]").forEach(function(b){b.onclick=function(){skin=b.dataset.skin;apply()}});
125
- document.querySelectorAll("[data-size]").forEach(function(b){b.onclick=function(){size=b.dataset.size;apply()}});
199
+ document.querySelectorAll("[data-skin]").forEach(function(b){b.onclick=function(){step({action:"note",text:"panel: skin set to "+b.dataset.skin});skin=b.dataset.skin;apply()}});
200
+ document.querySelectorAll("[data-size]").forEach(function(b){b.onclick=function(){step({action:"note",text:"panel: size set to "+b.dataset.size});size=b.dataset.size;apply()}});
126
201
  // Back does what the simulator's back action does: press the screen's Back button, else go back in history.
127
202
  function goBack(){var w=f.contentWindow;try{var doc=w&&w.document;if(!doc)return;var els=doc.querySelectorAll("button,[role=button],a");for(var i=0;i<els.length;i++){var e=els[i],t=(e.getAttribute("aria-label")||e.textContent||"").trim();if(t==="Back"){e.click();return}}w.history.back()}catch(x){}}
128
203
  bk.onclick=goBack;
129
- var t=document.getElementById("theme");t.onclick=function(){var k=!document.body.classList.contains("dark");document.body.classList.toggle("dark",k);t.setAttribute("aria-pressed",k);t.textContent=k?"Light":"Dark";f.style.colorScheme=k?"dark":"light"};
204
+ var t=document.getElementById("theme");t.onclick=function(){step({action:"note",text:"panel: theme toggled"});var k=!document.body.classList.contains("dark");document.body.classList.toggle("dark",k);t.setAttribute("aria-pressed",k);t.textContent=k?"Light":"Dark";f.style.colorScheme=k?"dark":"light"};
205
+ var P=document.getElementById("panel-status");
206
+ function pnSay(m){P.textContent=m+" (simulated)"}
207
+ function pnDev(){var d=f.contentWindow&&f.contentWindow.__pnDevice;if(!d){pnSay("The app is still loading");return null}return d}
208
+ function pnNum(c){var v=pnVal(c).trim();return v===""?NaN:Number(v)}
209
+ function pnVal(c){return document.querySelector('[data-control="'+c+'"]').value}
210
+ function pnSet(patch,msg){var d=pnDev();if(d){d.set(patch);step({action:"set_device",device:patch});pnSay(msg)}}
211
+ var pnDelay=0;
212
+ function pnSlow(){var w=f.contentWindow;if(!w||w.__pnSlow||!w.fetch)return;w.__pnSlow=1;var o=w.fetch.bind(w);w.fetch=function(){var a=arguments;return new Promise(function(r){setTimeout(r,pnDelay)}).then(function(){return o.apply(null,a)})}}
213
+ var A={
214
+ "location-set":function(){var v=pnVal("location-list").split(",");pnSet({location:{latitude:+v[0],longitude:+v[1],accuracy:20}},"Location set")},
215
+ "coords-set":function(){var a=pnNum("lat"),o=pnNum("lng");if(!(a>=-90&&a<=90&&o>=-180&&o<=180)){pnSay("Enter latitude -90 to 90 and longitude -180 to 180");return}pnSet({location:{latitude:a,longitude:o,accuracy:20}},"Location set")},
216
+ "camera-set":function(){var v=pnVal("camera-list").split("|");pnSet({camera:{barcode:v[0]==="none"?null:{type:v[0],data:v.slice(1).join("|")}}},"Camera shows "+(v[0]==="none"?"nothing":v[0]))},
217
+ "bio-success":function(){pnSet({biometrics:{nextResult:"success"}},"Fingerprint and Face ID match from now on")},
218
+ "bio-fail":function(){pnSet({biometrics:{nextResult:"fail"}},"Fingerprint and Face ID do not match from now on")},
219
+ "pay-approve":function(){pnSet({payment:{nextResult:"approve"}},"Payments are approved from now on")},
220
+ "pay-decline":function(){pnSet({payment:{nextResult:"decline"}},"Payments are declined from now on")},
221
+ "net-online":function(){pnDelay=0;pnSet({online:true},"Network online")},
222
+ "net-slow":function(){if(!pnDev())return;pnDelay=1500;pnSlow();pnSet({online:true},"Network slow (requests wait 1.5 s)")},
223
+ "net-offline":function(){pnDelay=0;pnSet({online:false},"Network offline")},
224
+ "push-send":function(){var d=pnDev();if(!d)return;var t=pnVal("push-title")||"Notification",b=pnVal("push-body");var ok=d.push({title:t,body:b})!==null;if(ok)step({action:"note",text:"panel: push sent"});pnSay(ok?"Push sent":"Not delivered: the app has not been granted notification permission")}
225
+ };
226
+ var pnGroups=[["bio-success","bio-fail"],["pay-approve","pay-decline"],["net-online","net-slow","net-offline"]];
227
+ document.querySelectorAll("[data-control]").forEach(function(e){var c=e.dataset.control;if(A[c])e.onclick=function(){A[c]();pnGroups.forEach(function(g){if(g.indexOf(c)<0)return;g.forEach(function(k){var b=document.querySelector('[data-control="'+k+'"]');if(b.setAttribute)b.setAttribute("aria-pressed",k===c)})})}});
130
228
  apply();
131
229
  </script></body></html>
132
230
  `;
133
231
  }
134
232
  /** Puts the frame page at index.html and the app's own entry at app.html (its asset paths are absolute, so it stays at the site root). */
135
- export function wrapBuild(buildDir, name) {
233
+ export function wrapBuild(buildDir, name, appId = name) {
136
234
  const files = collectFiles(buildDir).filter((f) => f.path !== "index.html");
137
235
  const entry = join(buildDir, "index.html");
138
236
  if (!existsSync(entry))
139
237
  throw new Error("The web build has no index.html. Run `npx expo export --platform web` and check it works.");
140
238
  files.push({ path: "app.html", bytes: new Uint8Array(readFileSync(entry)), contentType: typeOf("x.html") });
141
- files.push({ path: "index.html", bytes: new TextEncoder().encode(framePage(name)), contentType: typeOf("x.html") });
239
+ files.push({ path: "index.html", bytes: new TextEncoder().encode(framePage(name, appId)), contentType: typeOf("x.html") });
142
240
  return files;
143
241
  }
144
242
  const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -205,11 +303,12 @@ export async function publishToHereNow(files, o = {}) {
205
303
  const warnings = (done.warnings ?? []).map((w) => (typeof w === "string" ? w : JSON.stringify(w)));
206
304
  return { url: done.siteUrl ?? created.siteUrl, claimUrl: created.claimUrl ?? null, expiresAt: created.expiresAt ?? null, files: files.length, warnings };
207
305
  }
208
- function build(root, out) {
306
+ export function build(root, out) {
209
307
  rmSync(out, { recursive: true, force: true });
210
308
  mkdirSync(join(root, ".shipvo"), { recursive: true });
211
309
  return new Promise((resolve, reject) => {
212
- const p = spawn("npx", ["expo", "export", "--platform", "web", "--clear", "--output-dir", out], { cwd: root, env: { ...process.env, PN_SIM: "1" }, stdio: ["ignore", "ignore", "inherit"] });
310
+ const call = spawnBin("npx", ["expo", "export", "--platform", "web", "--clear", "--output-dir", out]);
311
+ const p = spawn(call.command, call.args, { cwd: root, shell: call.shell, env: { ...process.env, PN_SIM: "1" }, stdio: ["ignore", "ignore", "inherit"] });
213
312
  p.on("error", reject);
214
313
  p.on("exit", (c) => (c === 0 ? resolve() : reject(new Error(`The web build failed (exit ${c}). Run \`npx expo export --platform web\` to see why.`))));
215
314
  });
@@ -226,7 +325,7 @@ export async function runPreview(root, o = {}) {
226
325
  const dir = o.buildDir ?? join(root, ".shipvo", "preview-build");
227
326
  if (!o.skipBuild)
228
327
  await build(root, dir);
229
- return publishToHereNow(wrapBuild(dir, cfg.name), o);
328
+ return publishToHereNow(wrapBuild(dir, cfg.name, cfg.appId), o);
230
329
  }
231
330
  export async function preview(argv) {
232
331
  const a = parse(argv);
package/dist/promote.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { kv as kvFor } from "./config.js";
2
2
  type Store = Pick<ReturnType<typeof kvFor>, "get" | "put">;
3
3
  export declare const PROMOTE_FLAGS: string[];
4
- export declare const PROMOTE_HELP = "Usage: shipvo promote [flags]\n\nWiden a staged release to more bands. The release number, bundle and patches stay the same; the pointer is signed again.\n\nFlags (write a value after a space, not =):\n --to b1,b2 Bands to write (default: every band with no pointer for this release)\n Bands that already hold a newer release are always skipped\n --platform android|ios|all Platform to promote (default android)\n --dry-run Print the plan, write nothing\n --force Promote even if the release is halted or has reverts\n --help Show this help\n";
4
+ export declare const PROMOTE_HELP = "Usage: shipvo promote [flags]\n\nWiden a staged release to more bands. The release number, bundle and patches stay the same; the pointer is signed again.\n\nFlags (write a value after a space, not =):\n --to b1,b2 Bands to write (default: every band with no pointer for this release)\n Bands that already hold a newer release are always skipped\n --platform android|ios|all Platform to promote (default android)\n --dry-run Print the plan, write nothing\n --report <file> Test report from shipvo test --report (default shipvo-test-report.json if it exists)\n A report for this release with failing flows blocks promote\n --json Print one JSON envelope on stdout; text goes to stderr\n --force Promote even if the release is halted, has reverts or has a failing test report\n --help Show this help\n";
5
5
  export declare function promote(argv: string[], deps?: {
6
6
  kv?: Store;
7
7
  fetchFn?: typeof fetch;
package/dist/promote.js CHANGED
@@ -1,11 +1,13 @@
1
1
  // `shipvo promote`: widen a staged release to more bands. Reads the pointer on the narrowest band that has one,
2
2
  // and writes the same release (same bundle, hashes and patches) to the other bands with a fresh issued_at and signature.
3
+ import { existsSync, readFileSync } from "node:fs";
3
4
  import { signBytes } from "./index.js";
4
- import { args as parse, kv as kvFor, loadConfig, seed } from "./config.js";
5
+ import { CliFailure, UsageError, runJson } from "./json-out.js";
6
+ import { args as parse, hasStore, kv as kvFor, loadConfig, offlineKv, seed } from "./config.js";
5
7
  const BANDS = ["b0", "b1", "b2"];
6
8
  const REVERT_EVENTS = ["rolled_back", "crash_loop_revert"];
7
9
  const pointerKey = (app, major, band, pf) => (pf === "ios" ? `p/${app}/${major}/ios/${band}` : `p/${app}/${major}/${band}`);
8
- export const PROMOTE_FLAGS = ["to", "platform", "dry-run", "force"];
10
+ export const PROMOTE_FLAGS = ["to", "platform", "dry-run", "force", "report", "json"];
9
11
  export const PROMOTE_HELP = `Usage: shipvo promote [flags]
10
12
 
11
13
  Widen a staged release to more bands. The release number, bundle and patches stay the same; the pointer is signed again.
@@ -15,7 +17,10 @@ Flags (write a value after a space, not =):
15
17
  Bands that already hold a newer release are always skipped
16
18
  --platform android|ios|all Platform to promote (default android)
17
19
  --dry-run Print the plan, write nothing
18
- --force Promote even if the release is halted or has reverts
20
+ --report <file> Test report from shipvo test --report (default shipvo-test-report.json if it exists)
21
+ A report for this release with failing flows blocks promote
22
+ --json Print one JSON envelope on stdout; text goes to stderr
23
+ --force Promote even if the release is halted, has reverts or has a failing test report
19
24
  --help Show this help
20
25
  `;
21
26
  function checkFlags(argv, known) {
@@ -23,31 +28,44 @@ function checkFlags(argv, known) {
23
28
  if (bad.length) {
24
29
  const eq = bad.find((x) => x.includes("="));
25
30
  const hint = eq ? ` Write "${eq.replace("=", " ")}" (a space, not =).` : "";
26
- throw new Error(`Unknown flag ${bad.join(", ")}.${hint} Known flags: ${known.map((k) => "--" + k).join(" ")}`);
31
+ throw new UsageError(`Unknown flag ${bad.join(", ")}.${hint} Known flags: ${known.map((k) => "--" + k).join(" ")}`);
27
32
  }
28
33
  }
34
+ const STATUS = [{ command: "shipvo status --json", why: "See how the release does on phones." }];
29
35
  export async function promote(argv, deps = {}) {
30
36
  if (argv.includes("--help") || argv.includes("-h")) {
31
37
  console.log(PROMOTE_HELP);
32
38
  return;
33
39
  }
40
+ if (argv.includes("--json"))
41
+ return runJson(() => promoteRun(argv, deps), [{ command: "shipvo doctor --json", why: "Check your setup." }, ...STATUS]);
42
+ await promoteRun(argv, deps);
43
+ }
44
+ async function promoteRun(argv, deps) {
34
45
  checkFlags(argv, PROMOTE_FLAGS);
35
46
  const a = parse(argv), cfg = loadConfig();
36
47
  const dry = a["dry-run"] !== undefined, force = a.force !== undefined;
37
48
  const p = (a.platform || "android").toLowerCase();
38
49
  if (p !== "android" && p !== "ios" && p !== "all")
39
- throw new Error(`--platform must be android, ios or all (got "${a.platform}")`);
50
+ throw new UsageError(`--platform must be android, ios or all (got "${a.platform}")`);
40
51
  const platforms = p === "all" ? ["android", "ios"] : [p];
41
52
  if (a.to === "true")
42
- throw new Error("--to needs bands: pass --to b1,b2");
53
+ throw new UsageError("--to needs bands: pass --to b1,b2");
43
54
  const explicit = a.to ? a.to.split(",").map((s) => s.trim()).filter(Boolean) : null;
44
55
  if (explicit) {
45
56
  const bad = explicit.filter((b) => !BANDS.includes(b));
46
57
  if (bad.length)
47
- throw new Error(`Unknown band ${bad.join(", ")}. Bands are ${BANDS.join(", ")}.`);
58
+ throw new UsageError(`Unknown band ${bad.join(", ")}. Bands are ${BANDS.join(", ")}.`);
48
59
  }
60
+ if (a.report === "true")
61
+ throw new UsageError("--report needs a file name: pass --report <file>");
62
+ const reportFile = a.report ?? (existsSync("shipvo-test-report.json") ? "shipvo-test-report.json" : null);
49
63
  const APP = cfg.appId, MAJOR = cfg.shellMajor;
50
- const store = deps.kv ?? kvFor(cfg);
64
+ // A dry run with no login and no Cloudflare keys reads the files `shipvo publish --dry-run` wrote.
65
+ const offline = !deps.kv && dry && !hasStore();
66
+ const store = deps.kv ?? (offline ? offlineKv() : kvFor(cfg));
67
+ if (offline)
68
+ console.log("Offline rehearsal: reading pointers from .shipvo/dry-run (written by shipvo publish --dry-run). No login used, nothing is sent.");
51
69
  // Read every source pointer before touching the network or writing anything.
52
70
  const plans = [];
53
71
  for (const pf of platforms) {
@@ -68,17 +86,38 @@ export async function promote(argv, deps = {}) {
68
86
  catch { /* unreadable pointer: skip */ }
69
87
  }
70
88
  if (!body)
71
- throw new Error(`No ${pf} pointer found on any band. Run shipvo publish --bands b0 first.`);
89
+ throw new CliFailure(`No ${pf} pointer found on any band. Run shipvo publish --bands b0 first.`, "no_pointer", 1, [{ command: "shipvo publish --bands b0 --json", why: "Publish to the first cohort." }]);
72
90
  const release = body.release;
73
91
  if (explicit && explicit.length && explicit.every((b) => b === from))
74
- throw new Error(`${from} is the source band (release ${release}). Pass --to with a different band.`);
92
+ throw new UsageError(`${from} is the source band (release ${release}). Pass --to with a different band.`);
75
93
  // Never write an older release over a newer one: promote widens, it does not downgrade. --force does not change this.
76
94
  const cand = (explicit ?? BANDS).filter((b) => b !== from && (explicit || have.get(b) !== release));
77
95
  const newer = cand.filter((b) => (have.get(b) ?? 0) > release).map((b) => [b, have.get(b)]);
78
96
  const targets = cand.filter((b) => !((have.get(b) ?? 0) > release));
79
97
  plans.push({ pf, release, from, body, targets, newer });
80
98
  }
81
- if (!force) {
99
+ if (reportFile) {
100
+ let rep;
101
+ try {
102
+ rep = JSON.parse(readFileSync(reportFile, "utf8"));
103
+ }
104
+ catch (e) {
105
+ throw new CliFailure(`Could not read the test report ${reportFile} (${e instanceof Error ? e.message : String(e)}).`, "report_unreadable", 1, [{ command: "shipvo test --report shipvo-test-report.json --json", why: "Write a fresh test report." }]);
106
+ }
107
+ const rel = rep.release == null ? null : String(rep.release);
108
+ const failing = (Array.isArray(rep.flows) ? rep.flows : []).filter((f) => f.status === "failed").map((f) => f.name ?? "(unnamed flow)");
109
+ const mine = plans.filter((x) => String(x.release) === rel);
110
+ if (mine.length && failing.length) {
111
+ const msg = `Test report ${reportFile} has ${failing.length} failing flow${failing.length === 1 ? "" : "s"} for release ${mine[0].release}: ${failing.join(", ")}.`;
112
+ if (!force)
113
+ throw new CliFailure(`${msg} Fix them and run shipvo test --report again, or pass --force to promote anyway.`, "tests_failed", 1, [{ command: `shipvo test --report ${reportFile} --json`, why: "Run the flows again after the fix." }]);
114
+ console.log(`${msg} Overridden by --force.`);
115
+ }
116
+ else if (!mine.length) {
117
+ console.log(`Warning: test report ${reportFile} is for ${rel === null ? "no release" : `release ${rel}`}, not release ${plans.map((x) => x.release).join(", ")}. It is stale and was not used.`);
118
+ }
119
+ }
120
+ if (!force && !offline) {
82
121
  const base = cfg.controlPlane.replace(/\/$/, "");
83
122
  let data;
84
123
  try {
@@ -88,18 +127,20 @@ export async function promote(argv, deps = {}) {
88
127
  data = (await r.json());
89
128
  }
90
129
  catch (e) {
91
- throw new Error(`Could not read the release status from ${base} (${e instanceof Error ? e.message : String(e)}). Try again, or pass --force to promote without the check.`);
130
+ throw new CliFailure(`Could not read the release status from ${base} (${e instanceof Error ? e.message : String(e)}). Try again, or pass --force to promote without the check.`, "remote_failed", 1, [{ command: "shipvo doctor --json", why: "Check the control plane." }]);
92
131
  }
93
132
  for (const { release } of plans) {
94
133
  if (data.halted && typeof data.halted === "object" && data.halted.release === release)
95
- throw new Error(`Release ${release} is halted. Publish a new release or run shipvo rollback, or pass --force to promote it anyway.`);
134
+ throw new CliFailure(`Release ${release} is halted. Publish a new release or run shipvo rollback, or pass --force to promote it anyway.`, "blocked", 1, STATUS);
96
135
  const ev = data.releases?.find((r) => r.release === release)?.events ?? {};
97
136
  const n = REVERT_EVENTS.reduce((s, k) => s + (ev[k] ?? 0), 0);
98
137
  if (n > 0)
99
- throw new Error(`Release ${release} has ${n} reported revert${n === 1 ? "" : "s"}. Check shipvo status, publish a fix, or pass --force to promote it anyway.`);
138
+ throw new CliFailure(`Release ${release} has ${n} reported revert${n === 1 ? "" : "s"}. Check shipvo status, publish a fix, or pass --force to promote it anyway.`, "blocked", 1, STATUS);
100
139
  }
101
140
  }
141
+ const result = [];
102
142
  for (const { pf, release, from, body, targets, newer } of plans) {
143
+ result.push({ platform: pf, release, from, bands: targets, skipped_newer: newer.map(([band, release]) => ({ band, release })) });
103
144
  for (const [b, r] of newer)
104
145
  console.log(`Skipped ${b} (${pf}): it already has release ${r}, which is newer than ${release}.`);
105
146
  if (!targets.length && newer.length) {
@@ -123,4 +164,12 @@ export async function promote(argv, deps = {}) {
123
164
  }
124
165
  if (dry)
125
166
  console.log("Dry run: nothing was written.");
167
+ const count = result.reduce((n, r) => n + r.bands.length, 0);
168
+ const data = { dry_run: dry, promoted: result };
169
+ if (count === 0)
170
+ return { code: "nothing_to_promote", summary: "Nothing to promote: no band needs this release.", data, next: STATUS };
171
+ const list = result.map((r) => `${r.platform} release ${r.release} to ${r.bands.join(", ")}`).join("; ");
172
+ if (dry)
173
+ return { code: "dry_run", summary: `Would promote ${list}. Nothing was written.`, data, next: [{ command: `shipvo promote ${argv.filter((x) => x !== "--dry-run").join(" ")}`.trim(), why: "Do the promote." }] };
174
+ return { code: "promoted", summary: `Promoted ${list}.`, data, next: STATUS };
126
175
  }
package/dist/publish.d.ts CHANGED
@@ -1,11 +1,23 @@
1
- export declare const PUBLISH_HELP = "Usage: shipvo publish [flags]\n\nBuild, sign and publish an over-the-air release.\n\nFlags (write a value after a space, not =; on/off flags can be bare, or true/false):\n --bands b0,b1,b2 Cohorts to publish to (default b0,b1,b2)\n --platform android|ios|all Platform to publish (default android)\n --release N Release number (default: the next one)\n --min-shell X.Y.Z Oldest app shell that can run this release (default: version in app.json)\n --hermes-bytecode-version V Refuse bundles built for another Hermes version, for example 96 or 96,98\n --max-bases N How many older releases to build patches from (default 6)\n --skip-export Reuse the last build in dist-ota/\n --skip-web Do not publish the web copy for the simulator\n --dry-run Build and sign, write files locally, send nothing\n --out <dir> Where --dry-run writes files (default .shipvo/dry-run)\n --help Show this help\n";
1
+ import path from "node:path";
2
+ /** True when `file` is strictly inside `root`. Uses the platform's separator, so it works with Windows paths too. */
3
+ export declare function isInside(root: string, file: string, p?: typeof path.posix): boolean;
4
+ export declare const PUBLISH_HELP = "Usage: shipvo publish [flags]\n\nBuild, sign and publish an over-the-air release.\n\nFlags (write a value after a space, not =; on/off flags can be bare, or true/false):\n --bands b0,b1,b2 Cohorts to publish to (default b0,b1,b2)\n --platform android|ios|all Platform to publish (default android)\n --release N Release number (default: the next one)\n --min-shell X.Y.Z Oldest app shell that can run this release (default: version in app.json)\n --hermes-bytecode-version V Refuse bundles built for another Hermes version, for example 96 or 96,98\n --max-bases N How many older releases to build patches from (default 6)\n --skip-export Reuse the last build in dist-ota/\n --skip-web Do not publish the web copy for the simulator\n --dry-run Build and sign, write files locally, send nothing\n --dry-run-policy Check the policy in shipvo.json for these --bands, print the result, publish nothing\n --out <dir> Where --dry-run writes files (default .shipvo/dry-run)\n --json Print one JSON envelope on stdout; text and build output go to stderr\n --help Show this help\n";
2
5
  export declare function publish(argv: string[]): Promise<void>;
6
+ /** The publish result in plain words. `--json` carries the full detail. */
7
+ export declare function publishedSummary(release: number, bands: string[]): string;
8
+ /** The rollback result in plain words. */
9
+ export declare function rolledBackSummary(to: number, release: number): string;
3
10
  /** Bytes in plain units: KB below 1 MB, so a small bundle never reads as 0.00 MB. */
4
11
  export declare function formatSize(bytes: number): string;
5
12
  /** The dry-run result in plain words. `info` says what happened with patches on Android. */
6
13
  export declare function dryRunSummary(release: number, bytes: number, outDir: string, info?: {
7
14
  bases: number;
8
- bsdiff: boolean;
9
15
  patches: number;
10
16
  }): string;
17
+ /** The line for a listing file that is not there. It says the file is optional and what to do. */
18
+ export declare function missingListingNote(file: string, name: string): string;
19
+ /** Upload one listing file to the store prefix. A missing file prints a note and returns null. */
20
+ export declare function uploadListingFile(kv: {
21
+ put: (k: string, b: BodyInit, m: string) => Promise<unknown>;
22
+ }, store: string, file: string, name: string): Promise<string | null>;
11
23
  export declare function rollback(argv: string[]): Promise<void>;