@shipvo/cli 0.1.3 → 0.1.5
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.
- package/dist/adopt-bare.d.ts +39 -0
- package/dist/adopt-bare.js +236 -0
- package/dist/adopt.d.ts +26 -0
- package/dist/adopt.js +257 -0
- package/dist/agents.d.ts +14 -0
- package/dist/agents.js +124 -0
- package/dist/audit.d.ts +13 -0
- package/dist/audit.js +75 -0
- package/dist/autohalt.d.ts +5 -0
- package/dist/autohalt.js +84 -0
- package/dist/ci.d.ts +3 -0
- package/dist/ci.js +42 -0
- package/dist/cli.js +142 -88
- package/dist/config.d.ts +10 -0
- package/dist/config.js +38 -10
- package/dist/doctor.d.ts +14 -0
- package/dist/doctor.js +132 -7
- package/dist/eas.d.ts +29 -0
- package/dist/eas.js +258 -0
- package/dist/ensure-playwright.d.ts +21 -0
- package/dist/ensure-playwright.js +64 -0
- package/dist/explore.d.ts +84 -0
- package/dist/explore.js +247 -0
- package/dist/help.d.ts +4 -2
- package/dist/help.js +39 -3
- package/dist/init.js +501 -17
- package/dist/native.d.ts +3 -0
- package/dist/native.js +39 -0
- package/dist/policy.d.ts +4 -0
- package/dist/policy.js +17 -0
- package/dist/preview-recorder.d.ts +1 -0
- package/dist/preview-recorder.js +62 -0
- package/dist/preview.d.ts +35 -0
- package/dist/preview.js +346 -0
- package/dist/promote.d.ts +9 -0
- package/dist/promote.js +153 -0
- package/dist/publish.d.ts +1 -1
- package/dist/publish.js +73 -29
- package/dist/report-md.d.ts +26 -0
- package/dist/report-md.js +58 -0
- package/dist/run-bridge.d.ts +7 -0
- package/dist/run-bridge.js +374 -0
- package/dist/run-protocol.d.ts +32 -0
- package/dist/run-protocol.js +22 -0
- package/dist/run-ws.d.ts +8 -0
- package/dist/run-ws.js +89 -0
- package/dist/run.d.ts +86 -0
- package/dist/run.js +301 -0
- package/dist/sim.d.ts +31 -0
- package/dist/sim.js +124 -5
- package/dist/status.d.ts +64 -0
- package/dist/status.js +147 -0
- package/dist/store.d.ts +38 -0
- package/dist/store.js +297 -0
- package/dist/templates/capacitor/github-actions/build.yml +122 -0
- package/dist/test.d.ts +46 -0
- package/dist/test.js +190 -0
- package/package.json +10 -5
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The recorder that runs inside the preview frame page. It is plain browser JavaScript kept as a string,
|
|
2
|
+
// so `shipvo preview` can inline it. `shipvoExport` mirrors `exportTest` in packages/mcp/src/export.ts;
|
|
3
|
+
// a test keeps the two outputs identical.
|
|
4
|
+
export const RECORDER_JS = String.raw `
|
|
5
|
+
var shipvoSteps=[];
|
|
6
|
+
function shipvoQ(s){return JSON.stringify(s).replace(new RegExp("\\u2028","g"),"\\\\u2028").replace(new RegExp("\\u2029","g"),"\\\\u2029")}
|
|
7
|
+
function shipvoOneLine(s){return s.replace(new RegExp("[\\r\\n\\u2028\\u2029]+","g")," ")}
|
|
8
|
+
function shipvoEnv(l){return "SHIPVO_TEST_"+(l.replace(/[^A-Za-z0-9]+/g,"_").replace(/^_+|_+$/g,"").toUpperCase()||"FIELD")}
|
|
9
|
+
function shipvoLine(s){
|
|
10
|
+
var t=s.target;
|
|
11
|
+
if(s.action==="note")return ["// "+shipvoOneLine(s.text||"")];
|
|
12
|
+
if(s.action==="set_device")return ["await phone.device.set("+shipvoQ(s.device||{})+");"];
|
|
13
|
+
if(s.action==="back")return ["await phone.back();"];
|
|
14
|
+
if(s.action==="scroll")return ["await phone.scroll("+shipvoQ(["down","up","left","right"].indexOf(s.direction)>=0?s.direction:"down")+");"];
|
|
15
|
+
if(s.action==="tap"){
|
|
16
|
+
if(!t)return ["// TODO: tap without a target"];
|
|
17
|
+
if(t.matches>1)return ["// TODO: ambiguous target "+shipvoOneLine(shipvoQ(t.text))];
|
|
18
|
+
return ["await phone.tap("+shipvoQ(t.text)+");"];
|
|
19
|
+
}
|
|
20
|
+
if(s.action==="type"){
|
|
21
|
+
var label=s.fieldLabel||(t&&t.text);
|
|
22
|
+
if(!label)return ["// TODO: type without a field label"];
|
|
23
|
+
if(t&&t.matches>1)return ["// TODO: ambiguous target "+shipvoOneLine(shipvoQ(label))];
|
|
24
|
+
return ["await phone.type("+shipvoQ(label)+", "+(s.passwordLike?"process.env."+shipvoEnv(label)+"!":shipvoQ(s.text||""))+");"];
|
|
25
|
+
}
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
function shipvoExport(app,steps,title){
|
|
29
|
+
var out=['import { test } from "@shipvo/test/vitest";','import { openApp } from "@shipvo/test";','','test('+shipvoQ("recorded session for "+(title||app))+', async () => {',' const phone = await openApp({ app: '+shipvoQ(app)+' });'];
|
|
30
|
+
steps.forEach(function(s){
|
|
31
|
+
var lines=shipvoLine(s);
|
|
32
|
+
lines.forEach(function(l){out.push(" "+l)});
|
|
33
|
+
if(s.changed&&lines.length&&lines[0].indexOf("//")!==0&&s.action!=="open")out.push(" await phone.waitFor(/\\S/); // TODO: name the text that should appear");
|
|
34
|
+
});
|
|
35
|
+
out.push("});","");
|
|
36
|
+
return out.join("\n");
|
|
37
|
+
}
|
|
38
|
+
function shipvoLabel(el){
|
|
39
|
+
var l=el.getAttribute("aria-label")||(el.labels&&el.labels[0]&&el.labels[0].textContent)||el.getAttribute("placeholder")||el.textContent||"";
|
|
40
|
+
return l.replace(/\s+/g," ").trim();
|
|
41
|
+
}
|
|
42
|
+
function shipvoCount(doc,label){
|
|
43
|
+
var n=0;doc.querySelectorAll("button,a,[role=button],[role=link],[role=tab],input,textarea,[aria-label]").forEach(function(e){if(shipvoLabel(e)===label)n++});
|
|
44
|
+
return n;
|
|
45
|
+
}
|
|
46
|
+
function shipvoAttach(frame,onStep){
|
|
47
|
+
var doc=frame.contentDocument;if(!doc||doc.__shipvoAttached)return;
|
|
48
|
+
doc.__shipvoAttached=true;
|
|
49
|
+
var sel="button,a,[role=button],[role=link],[role=tab],[role=switch],[role=checkbox]";
|
|
50
|
+
doc.addEventListener("click",function(e){
|
|
51
|
+
var el=e.target&&e.target.closest&&e.target.closest(sel);
|
|
52
|
+
if(!el||el.matches("input,textarea"))return;
|
|
53
|
+
var text=shipvoLabel(el);if(!text){onStep({action:"note",text:"TODO: tap without a label"});return}
|
|
54
|
+
onStep({action:"tap",target:{text:text,role:el.getAttribute("role")||el.tagName.toLowerCase(),matches:shipvoCount(doc,text)},changed:true});
|
|
55
|
+
},true);
|
|
56
|
+
doc.addEventListener("change",function(e){
|
|
57
|
+
var el=e.target;if(!el||!/^(INPUT|TEXTAREA)$/.test(el.tagName))return;
|
|
58
|
+
var label=shipvoLabel(el);if(!label)return;
|
|
59
|
+
onStep({action:"type",fieldLabel:label,text:el.value,passwordLike:el.type==="password",target:{text:label,role:"textbox",matches:shipvoCount(doc,label)}});
|
|
60
|
+
},true);
|
|
61
|
+
}
|
|
62
|
+
`;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type PreviewFile = {
|
|
2
|
+
path: string;
|
|
3
|
+
bytes: Uint8Array;
|
|
4
|
+
contentType: string;
|
|
5
|
+
};
|
|
6
|
+
export type PreviewResult = {
|
|
7
|
+
url: string;
|
|
8
|
+
claimUrl: string | null;
|
|
9
|
+
expiresAt: string | null;
|
|
10
|
+
files: number;
|
|
11
|
+
warnings: string[];
|
|
12
|
+
};
|
|
13
|
+
export type PreviewOpts = {
|
|
14
|
+
fetch?: typeof fetch;
|
|
15
|
+
sleep?: (ms: number) => Promise<void>;
|
|
16
|
+
maxRetries?: number;
|
|
17
|
+
};
|
|
18
|
+
export declare const blockedPath: (rel: string) => boolean;
|
|
19
|
+
/** Throws if any file would leak a secret. Names the file and the kind, never the value. */
|
|
20
|
+
export declare function refuseSecrets(files: PreviewFile[], secrets?: string[]): void;
|
|
21
|
+
/** Reads a build folder into upload entries, skipping files that must never be uploaded. */
|
|
22
|
+
export declare function collectFiles(dir: string): PreviewFile[];
|
|
23
|
+
/** 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. */
|
|
24
|
+
export declare function framePage(name: string, appId?: string): string;
|
|
25
|
+
/** 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). */
|
|
26
|
+
export declare function wrapBuild(buildDir: string, name: string, appId?: string): PreviewFile[];
|
|
27
|
+
/** Publishes to here.now anonymously: create, upload, finalize. Refreshes expired upload URLs and waits out 429s. */
|
|
28
|
+
export declare function publishToHereNow(files: PreviewFile[], o?: PreviewOpts): Promise<PreviewResult>;
|
|
29
|
+
export declare const NOTICE = "This link is public: anyone who has it can open the preview. It expires in 24 hours unless you open the claim link and sign in to here.now, which keeps it.";
|
|
30
|
+
/** Builds, wraps and publishes. Used by the CLI and by the MCP `publish_preview` tool (through `shipvo preview --json`). */
|
|
31
|
+
export declare function runPreview(root: string, o?: PreviewOpts & {
|
|
32
|
+
skipBuild?: boolean;
|
|
33
|
+
buildDir?: string;
|
|
34
|
+
}): Promise<PreviewResult>;
|
|
35
|
+
export declare function preview(argv: string[]): Promise<void>;
|
package/dist/preview.js
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// `shipvo preview`: build the web artifact with the simulated device, wrap it in a phone-sized frame page
|
|
2
|
+
// and publish it to here.now with no account. The link is public and lasts 24 hours unless claimed.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
5
|
+
import { join, relative, sep, extname } from "node:path";
|
|
6
|
+
import { args as parse, loadConfig } from "./config.js";
|
|
7
|
+
import { RECORDER_JS } from "./preview-recorder.js";
|
|
8
|
+
const API = "https://here.now/api/v1";
|
|
9
|
+
const CLIENT = "shipvo/cli";
|
|
10
|
+
const TYPES = {
|
|
11
|
+
".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json",
|
|
12
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".webp": "image/webp",
|
|
13
|
+
".ico": "image/x-icon", ".ttf": "font/ttf", ".otf": "font/otf", ".woff": "font/woff", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8", ".wasm": "application/wasm",
|
|
14
|
+
};
|
|
15
|
+
const typeOf = (p) => TYPES[extname(p).toLowerCase()] ?? "application/octet-stream";
|
|
16
|
+
// Files that never leave the machine, wherever they sit in the tree.
|
|
17
|
+
const BLOCKED_SEGMENTS = new Set([".shipvo", ".git", "node_modules", ".herenow"]);
|
|
18
|
+
const BLOCKED_NAME = /^(\.env.*|.*\.map|.*\.(pem|key|p8|p12|jks|keystore|mobileprovision)|seed\.txt|publish-token|id_rsa|id_ed25519)$/i;
|
|
19
|
+
export const blockedPath = (rel) => {
|
|
20
|
+
const segs = rel.split(/[\\/]/);
|
|
21
|
+
return segs.some((s) => BLOCKED_SEGMENTS.has(s)) || BLOCKED_NAME.test(segs[segs.length - 1]);
|
|
22
|
+
};
|
|
23
|
+
const SECRET_PATTERNS = [
|
|
24
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key"],
|
|
25
|
+
[/SHIPVO_SIGNING_SEED\s*["']?\s*[:=]/, "SHIPVO_SIGNING_SEED"],
|
|
26
|
+
[/SHIPVO_PUBLISH_TOKEN\s*["']?\s*[:=]/, "SHIPVO_PUBLISH_TOKEN"],
|
|
27
|
+
[/CLOUDFLARE_API_TOKEN\s*["']?\s*[:=]/, "CLOUDFLARE_API_TOKEN"],
|
|
28
|
+
[/\bgh[pousr]_[A-Za-z0-9]{30,}/, "a GitHub token"],
|
|
29
|
+
[/\bAKIA[0-9A-Z]{16}\b/, "an AWS access key"],
|
|
30
|
+
[/\bsk-[A-Za-z0-9_-]{32,}/, "an API key"],
|
|
31
|
+
];
|
|
32
|
+
const TEXT = /\.(html|js|mjs|css|json|txt|svg)$/i;
|
|
33
|
+
/** Secret values this process knows: they must not appear in anything we upload. */
|
|
34
|
+
function knownSecrets() {
|
|
35
|
+
const e = process.env;
|
|
36
|
+
const out = [e.SHIPVO_SIGNING_SEED, e.PURENATIVE_SIGNING_SEED, e.SHIPVO_PUBLISH_TOKEN, e.CLOUDFLARE_API_TOKEN];
|
|
37
|
+
for (const f of [join(process.cwd(), ".shipvo", "seed.txt"), join(process.cwd(), ".shipvo", "publish-token")]) {
|
|
38
|
+
try {
|
|
39
|
+
out.push(readFileSync(f, "utf8").trim());
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
}
|
|
43
|
+
return out.filter((s) => !!s && s.length >= 16);
|
|
44
|
+
}
|
|
45
|
+
/** Throws if any file would leak a secret. Names the file and the kind, never the value. */
|
|
46
|
+
export function refuseSecrets(files, secrets = knownSecrets()) {
|
|
47
|
+
for (const f of files) {
|
|
48
|
+
if (!TEXT.test(f.path))
|
|
49
|
+
continue;
|
|
50
|
+
const text = Buffer.from(f.bytes).toString("utf8");
|
|
51
|
+
for (const s of secrets)
|
|
52
|
+
if (text.includes(s))
|
|
53
|
+
throw new Error(`Refusing to publish: ${f.path} contains a secret value from your environment or .shipvo/. Remove it from the app's web build and run again.`);
|
|
54
|
+
for (const [re, what] of SECRET_PATTERNS)
|
|
55
|
+
if (re.test(text))
|
|
56
|
+
throw new Error(`Refusing to publish: ${f.path} references ${what}. Keep secrets out of the web build and run again.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Reads a build folder into upload entries, skipping files that must never be uploaded. */
|
|
60
|
+
export function collectFiles(dir) {
|
|
61
|
+
const out = [];
|
|
62
|
+
const walk = (d) => {
|
|
63
|
+
for (const name of readdirSync(d)) {
|
|
64
|
+
const full = join(d, name), rel = relative(dir, full).split(sep).join("/");
|
|
65
|
+
if (blockedPath(rel))
|
|
66
|
+
continue;
|
|
67
|
+
if (statSync(full).isDirectory())
|
|
68
|
+
walk(full);
|
|
69
|
+
else
|
|
70
|
+
out.push({ path: rel, bytes: new Uint8Array(readFileSync(full)), contentType: typeOf(rel) });
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
walk(dir);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** 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. */
|
|
77
|
+
export function framePage(name, appId = name) {
|
|
78
|
+
const title = name.replace(/[<>&"']/g, "");
|
|
79
|
+
return `<!doctype html>
|
|
80
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
81
|
+
<title>${title} preview</title>
|
|
82
|
+
<style>
|
|
83
|
+
:root{color-scheme:light}body.dark{color-scheme:dark}
|
|
84
|
+
html,body{margin:0;height:100%;font:14px system-ui,sans-serif;background:#e8eaed;color:#111}
|
|
85
|
+
body.dark{background:#15171a;color:#eee}
|
|
86
|
+
.bar{display:flex;gap:8px;justify-content:center;align-items:center;padding:10px;flex-wrap:wrap}
|
|
87
|
+
.bar button,.back{font:inherit;padding:6px 12px;border:1px solid #888;border-radius:8px;background:transparent;color:inherit;cursor:pointer}
|
|
88
|
+
.bar button[aria-pressed=true]{background:#2563eb;border-color:#2563eb;color:#fff}
|
|
89
|
+
.stage{display:flex;justify-content:center;align-items:flex-start;gap:16px;padding:0 10px 20px;flex-wrap:wrap}
|
|
90
|
+
.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}
|
|
91
|
+
.device[data-skin=iphone]{border-radius:44px;padding:0 10px}
|
|
92
|
+
.status{display:flex;justify-content:space-between;align-items:center;height:28px;padding:0 14px;font-size:12px;flex:none}
|
|
93
|
+
.device[data-skin=iphone] .status{height:44px;padding:0 24px}
|
|
94
|
+
.device[data-skin=android] .status .notch{display:none}
|
|
95
|
+
.device[data-skin=iphone] .status .icons{letter-spacing:2px}
|
|
96
|
+
.notch{width:90px;height:24px;background:#000;border-radius:14px;position:absolute}
|
|
97
|
+
.device[data-skin=iphone] .status{position:relative}
|
|
98
|
+
.device[data-skin=iphone] .status .notch{left:50%;margin-left:-45px;top:8px;background:#111}
|
|
99
|
+
iframe{border:0;background:#fff;width:100%;flex:1;min-height:0;display:block}
|
|
100
|
+
.nav{flex:none;display:flex;justify-content:center;align-items:center;height:44px}
|
|
101
|
+
.device[data-skin=iphone] .nav{height:44px}
|
|
102
|
+
.back{color:#fff;border-color:#888;padding:4px 14px}
|
|
103
|
+
.device[data-skin=iphone] .back{display:block;position:relative;width:134px;height:44px;padding:0;border:0;background:transparent;font-size:0}
|
|
104
|
+
.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}
|
|
105
|
+
.back:focus-visible{outline:2px solid #fff;outline-offset:2px}
|
|
106
|
+
.device[data-skin=android] .back{border:0}
|
|
107
|
+
.side{max-width:360px;margin:0 auto 20px;padding:0 10px;box-sizing:border-box}
|
|
108
|
+
.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}
|
|
109
|
+
.side button,.side input{font:inherit}
|
|
110
|
+
.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}
|
|
111
|
+
body.dark .side input{background:#15171a;color:#eee}body.dark .side small{opacity:.85}
|
|
112
|
+
.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}
|
|
113
|
+
.fab[aria-pressed=true]{background:#2563eb;border-color:#2563eb;color:#fff}
|
|
114
|
+
.fab:focus-visible{outline:3px solid #f59e0b;outline-offset:2px}
|
|
115
|
+
body.dark .fab{background:#2a2d31;color:#eee}body.dark .fab[aria-pressed=true]{background:#2563eb;color:#fff}
|
|
116
|
+
.device{position:relative}
|
|
117
|
+
#pin{position:absolute;inset:0;display:none;cursor:crosshair;background:#2563eb22;z-index:5}
|
|
118
|
+
#pin.on{display:block}
|
|
119
|
+
#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}
|
|
120
|
+
.note{text-align:center;opacity:.7;padding:0 12px 16px}
|
|
121
|
+
.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}
|
|
122
|
+
.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}
|
|
123
|
+
.panel{width:280px;max-width:100%;box-sizing:border-box;display:grid;gap:10px;padding:12px;border:1px solid #8886;border-radius:12px}
|
|
124
|
+
.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}
|
|
125
|
+
.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}
|
|
126
|
+
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}
|
|
127
|
+
@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}}
|
|
128
|
+
</style></head><body>
|
|
129
|
+
<div class="bar" role="toolbar" aria-label="Preview options">
|
|
130
|
+
<button data-skin="android" aria-pressed="true">Android</button><button data-skin="iphone" aria-pressed="false">iPhone</button>
|
|
131
|
+
<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>
|
|
132
|
+
<button id="theme" aria-pressed="false">Dark</button></div>
|
|
133
|
+
<button id="comment" class="fab" aria-pressed="false">Comment</button>
|
|
134
|
+
<div class="stage"><div class="device" id="device" data-skin="android" style="width:360px;height:760px">
|
|
135
|
+
<div class="status" id="status"><span id="clock">9:41</span><span class="notch"></span><span class="icons" id="icons">●● 5G 100%</span></div>
|
|
136
|
+
<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>
|
|
137
|
+
<div class="nav"><button class="back" id="back" aria-label="Back" title="Back">◀ Back</button></div>
|
|
138
|
+
</div>
|
|
139
|
+
<aside class="panel" id="device-panel" aria-label="Device controls"><h2>Device</h2>
|
|
140
|
+
<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>
|
|
141
|
+
<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>
|
|
142
|
+
<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>
|
|
143
|
+
<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>
|
|
144
|
+
<fieldset><legend>Payment</legend><div class="row"><button data-control="pay-approve">Approve</button><button data-control="pay-decline">Decline</button></div></fieldset>
|
|
145
|
+
<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>
|
|
146
|
+
<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>
|
|
147
|
+
<p class="sim" id="panel-status" role="status">All device events here are simulated.</p></aside>
|
|
148
|
+
</div>
|
|
149
|
+
<div class="side" id="side"><h2>Comments (<span id="count">0</span>)</h2>
|
|
150
|
+
<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>
|
|
151
|
+
<ol id="notelist"></ol>
|
|
152
|
+
<button id="export">Export comments.json</button>
|
|
153
|
+
<p><small>Notes live in this browser until you export them. Nothing is uploaded.</small></p></div>
|
|
154
|
+
<p class="note">${title}: simulated device, public preview. System sheets such as permissions and payments are simulated.</p>
|
|
155
|
+
<script data-notes>
|
|
156
|
+
(function(){
|
|
157
|
+
var KEY="shipvo-preview-comments",steps=[],pending=null,notes=[];
|
|
158
|
+
function $(id){return document.getElementById(id)}
|
|
159
|
+
try{notes=JSON.parse(localStorage.getItem(KEY)||"[]");if(!Array.isArray(notes))notes=[]}catch(e){notes=[]}
|
|
160
|
+
function save(){try{localStorage.setItem(KEY,JSON.stringify(notes))}catch(e){}}
|
|
161
|
+
function esc(t){return String(t).replace(/[&<>"']/g,function(c){return"&#"+c.charCodeAt(0)+";"})}
|
|
162
|
+
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"}}
|
|
163
|
+
function step(text){steps.push(text)}
|
|
164
|
+
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}
|
|
165
|
+
function exportJson(){return JSON.stringify(notes,null,2)}
|
|
166
|
+
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("")}
|
|
167
|
+
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)}
|
|
168
|
+
function label(e){var t=e.getAttribute&&(e.getAttribute("aria-label")||e.textContent)||"";return String(t).trim().slice(0,40)}
|
|
169
|
+
var seen=typeof WeakSet==="function"?new WeakSet():null;
|
|
170
|
+
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){}}
|
|
171
|
+
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()}
|
|
172
|
+
var cb=$("comment");if(cb)cb.onclick=function(){setMode($("pin").className!=="on")};
|
|
173
|
+
function place(x,y){pending={x:x,y:y};setMode(false);$("noteform").hidden=false;if($("notetext").focus)$("notetext").focus()}
|
|
174
|
+
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)};
|
|
175
|
+
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)}}}
|
|
176
|
+
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};
|
|
177
|
+
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)}}});
|
|
178
|
+
var ex=$("export");if(ex)ex.onclick=download;
|
|
179
|
+
var bk=$("back");if(bk&&bk.addEventListener)bk.addEventListener("click",function(){step("back")});
|
|
180
|
+
var ap=$("app");if(ap){if(ap.addEventListener)ap.addEventListener("load",watch);watch()}
|
|
181
|
+
window.shipvoNotes={add:add,step:step,list:function(){return notes.slice()},exportJson:exportJson,download:download};
|
|
182
|
+
render();
|
|
183
|
+
})();
|
|
184
|
+
</script>
|
|
185
|
+
<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>
|
|
186
|
+
<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>
|
|
187
|
+
<script>${RECORDER_JS}
|
|
188
|
+
var APP=${JSON.stringify(appId).replace(/</g, "\\u003c")},TITLE=${JSON.stringify(title).replace(/</g, "\\u003c")},recording=false;
|
|
189
|
+
function step(s){if(recording){shipvoSteps.push(s);document.getElementById("stepcount").textContent=shipvoSteps.length+(shipvoSteps.length===1?" step":" steps");document.getElementById("dl").disabled=false}}
|
|
190
|
+
document.getElementById("app").addEventListener("load",function(){shipvoAttach(this,step)});
|
|
191
|
+
(function(a){if(a.contentDocument&&a.contentDocument.readyState==="complete")shipvoAttach(a,step)})(document.getElementById("app"));
|
|
192
|
+
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)};
|
|
193
|
+
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()};
|
|
194
|
+
var SIZES={phone:[360,760],large:[412,892],tablet:[820,1180]},IPHONE={phone:[390,844],large:[430,932],tablet:[820,1180]};
|
|
195
|
+
var skin="android",size="phone",d=document.getElementById("device"),f=document.getElementById("app"),bk=document.getElementById("back");
|
|
196
|
+
function pressed(sel,val,key){document.querySelectorAll(sel).forEach(function(o){o.setAttribute("aria-pressed",o.dataset[key]===val)})}
|
|
197
|
+
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":"◀ Back";bk.setAttribute("aria-label","Back");document.getElementById("icons").innerHTML=skin==="iphone"?"5G ▮ 100%":"●● 5G 100%";pressed("[data-skin]",skin,"skin");pressed("[data-size]",size,"size")}
|
|
198
|
+
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()}});
|
|
199
|
+
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()}});
|
|
200
|
+
// Back does what the simulator's back action does: press the screen's Back button, else go back in history.
|
|
201
|
+
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){}}
|
|
202
|
+
bk.onclick=goBack;
|
|
203
|
+
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"};
|
|
204
|
+
var P=document.getElementById("panel-status");
|
|
205
|
+
function pnSay(m){P.textContent=m+" (simulated)"}
|
|
206
|
+
function pnDev(){var d=f.contentWindow&&f.contentWindow.__pnDevice;if(!d){pnSay("The app is still loading");return null}return d}
|
|
207
|
+
function pnNum(c){var v=pnVal(c).trim();return v===""?NaN:Number(v)}
|
|
208
|
+
function pnVal(c){return document.querySelector('[data-control="'+c+'"]').value}
|
|
209
|
+
function pnSet(patch,msg){var d=pnDev();if(d){d.set(patch);step({action:"set_device",device:patch});pnSay(msg)}}
|
|
210
|
+
var pnDelay=0;
|
|
211
|
+
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)})}}
|
|
212
|
+
var A={
|
|
213
|
+
"location-set":function(){var v=pnVal("location-list").split(",");pnSet({location:{latitude:+v[0],longitude:+v[1],accuracy:20}},"Location set")},
|
|
214
|
+
"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")},
|
|
215
|
+
"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]))},
|
|
216
|
+
"bio-success":function(){pnSet({biometrics:{nextResult:"success"}},"Fingerprint and Face ID match from now on")},
|
|
217
|
+
"bio-fail":function(){pnSet({biometrics:{nextResult:"fail"}},"Fingerprint and Face ID do not match from now on")},
|
|
218
|
+
"pay-approve":function(){pnSet({payment:{nextResult:"approve"}},"Payments are approved from now on")},
|
|
219
|
+
"pay-decline":function(){pnSet({payment:{nextResult:"decline"}},"Payments are declined from now on")},
|
|
220
|
+
"net-online":function(){pnDelay=0;pnSet({online:true},"Network online")},
|
|
221
|
+
"net-slow":function(){if(!pnDev())return;pnDelay=1500;pnSlow();pnSet({online:true},"Network slow (requests wait 1.5 s)")},
|
|
222
|
+
"net-offline":function(){pnDelay=0;pnSet({online:false},"Network offline")},
|
|
223
|
+
"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")}
|
|
224
|
+
};
|
|
225
|
+
var pnGroups=[["bio-success","bio-fail"],["pay-approve","pay-decline"],["net-online","net-slow","net-offline"]];
|
|
226
|
+
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)})})}});
|
|
227
|
+
apply();
|
|
228
|
+
</script></body></html>
|
|
229
|
+
`;
|
|
230
|
+
}
|
|
231
|
+
/** 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). */
|
|
232
|
+
export function wrapBuild(buildDir, name, appId = name) {
|
|
233
|
+
const files = collectFiles(buildDir).filter((f) => f.path !== "index.html");
|
|
234
|
+
const entry = join(buildDir, "index.html");
|
|
235
|
+
if (!existsSync(entry))
|
|
236
|
+
throw new Error("The web build has no index.html. Run `npx expo export --platform web` and check it works.");
|
|
237
|
+
files.push({ path: "app.html", bytes: new Uint8Array(readFileSync(entry)), contentType: typeOf("x.html") });
|
|
238
|
+
files.push({ path: "index.html", bytes: new TextEncoder().encode(framePage(name, appId)), contentType: typeOf("x.html") });
|
|
239
|
+
return files;
|
|
240
|
+
}
|
|
241
|
+
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
242
|
+
/** Publishes to here.now anonymously: create, upload, finalize. Refreshes expired upload URLs and waits out 429s. */
|
|
243
|
+
export async function publishToHereNow(files, o = {}) {
|
|
244
|
+
const f = o.fetch ?? fetch, sleep = o.sleep ?? realSleep, max = o.maxRetries ?? 3;
|
|
245
|
+
refuseSecrets(files);
|
|
246
|
+
const blocked = files.find((x) => blockedPath(x.path));
|
|
247
|
+
if (blocked)
|
|
248
|
+
throw new Error(`Refusing to upload ${blocked.path}.`);
|
|
249
|
+
const headers = { "content-type": "application/json", "X-HereNow-Client": CLIENT };
|
|
250
|
+
/** One JSON call; on 429 waits retry_after seconds (or Retry-After) and tries again, up to `max` times. */
|
|
251
|
+
const call = async (url, body, what) => {
|
|
252
|
+
for (let attempt = 0;; attempt++) {
|
|
253
|
+
const r = await f(url, { method: "POST", headers, body: JSON.stringify(body) });
|
|
254
|
+
if (r.status === 429 && attempt < max) {
|
|
255
|
+
let j = {};
|
|
256
|
+
try {
|
|
257
|
+
j = await r.json();
|
|
258
|
+
}
|
|
259
|
+
catch { }
|
|
260
|
+
const secs = Number(j.retry_after ?? r.headers.get("retry-after") ?? 5);
|
|
261
|
+
await sleep((Number.isFinite(secs) && secs > 0 ? Math.min(secs, 120) : 5) * 1000);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
let j = {};
|
|
265
|
+
try {
|
|
266
|
+
j = await r.json();
|
|
267
|
+
}
|
|
268
|
+
catch { }
|
|
269
|
+
if (!r.ok)
|
|
270
|
+
throw new Error(r.status === 429 ? `here.now is rate limiting this publish (${what}). Wait ${j.retry_after ?? "a few"} seconds and run again.` : `here.now ${what} failed (${r.status}): ${j.message ?? j.error ?? "no details"}`);
|
|
271
|
+
return j;
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const created = await call(`${API}/publish`, { files: files.map((x) => ({ path: x.path, size: x.bytes.length, contentType: x.contentType })) }, "create");
|
|
275
|
+
const slug = created.slug;
|
|
276
|
+
let targets = created.upload.uploads;
|
|
277
|
+
const byPath = new Map(files.map((x) => [x.path, x]));
|
|
278
|
+
const putAll = async (list) => {
|
|
279
|
+
const expired = [];
|
|
280
|
+
for (const t of list) {
|
|
281
|
+
const file = byPath.get(t.path);
|
|
282
|
+
if (!file)
|
|
283
|
+
continue;
|
|
284
|
+
const r = await f(t.url, { method: "PUT", headers: { "content-type": file.contentType, ...(t.headers ?? {}) }, body: file.bytes });
|
|
285
|
+
if (r.status === 403 || r.status === 401 || r.status === 400)
|
|
286
|
+
expired.push(t);
|
|
287
|
+
else if (!r.ok)
|
|
288
|
+
throw new Error(`Upload of ${t.path} failed (${r.status}).`);
|
|
289
|
+
}
|
|
290
|
+
return expired;
|
|
291
|
+
};
|
|
292
|
+
let pending = await putAll(targets);
|
|
293
|
+
for (let i = 0; pending.length && i < 2; i++) {
|
|
294
|
+
const fresh = await call(`${API}/publish/${slug}/uploads/refresh`, { claimToken: created.claimToken }, "refresh upload URLs");
|
|
295
|
+
const want = new Set(pending.map((p) => p.path));
|
|
296
|
+
targets = fresh.upload.uploads.filter((t) => want.has(t.path));
|
|
297
|
+
pending = await putAll(targets);
|
|
298
|
+
}
|
|
299
|
+
if (pending.length)
|
|
300
|
+
throw new Error(`Could not upload ${pending.length} file(s), e.g. ${pending[0].path}. Check that *.r2.cloudflarestorage.com is reachable, then run again.`);
|
|
301
|
+
const done = await call(`${API}/publish/${slug}/finalize`, { versionId: created.upload.versionId }, "finalize");
|
|
302
|
+
const warnings = (done.warnings ?? []).map((w) => (typeof w === "string" ? w : JSON.stringify(w)));
|
|
303
|
+
return { url: done.siteUrl ?? created.siteUrl, claimUrl: created.claimUrl ?? null, expiresAt: created.expiresAt ?? null, files: files.length, warnings };
|
|
304
|
+
}
|
|
305
|
+
function build(root, out) {
|
|
306
|
+
rmSync(out, { recursive: true, force: true });
|
|
307
|
+
mkdirSync(join(root, ".shipvo"), { recursive: true });
|
|
308
|
+
return new Promise((resolve, reject) => {
|
|
309
|
+
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
|
+
p.on("error", reject);
|
|
311
|
+
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.`))));
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
export const NOTICE = "This link is public: anyone who has it can open the preview. It expires in 24 hours unless you open the claim link and sign in to here.now, which keeps it.";
|
|
315
|
+
/** Builds, wraps and publishes. Used by the CLI and by the MCP `publish_preview` tool (through `shipvo preview --json`). */
|
|
316
|
+
export async function runPreview(root, o = {}) {
|
|
317
|
+
const cfg = loadConfig(root);
|
|
318
|
+
const provider = cfg.preview ?? "here.now";
|
|
319
|
+
if (provider === "control-plane")
|
|
320
|
+
throw new Error('shipvo.json sets "preview": "control-plane". That provider is not built yet. Remove the setting or use "here.now".');
|
|
321
|
+
if (provider !== "here.now")
|
|
322
|
+
throw new Error(`shipvo.json: "preview" must be "here.now" or "control-plane", not "${provider}".`);
|
|
323
|
+
const dir = o.buildDir ?? join(root, ".shipvo", "preview-build");
|
|
324
|
+
if (!o.skipBuild)
|
|
325
|
+
await build(root, dir);
|
|
326
|
+
return publishToHereNow(wrapBuild(dir, cfg.name, cfg.appId), o);
|
|
327
|
+
}
|
|
328
|
+
export async function preview(argv) {
|
|
329
|
+
const a = parse(argv);
|
|
330
|
+
const json = a.json === "true";
|
|
331
|
+
if (!json)
|
|
332
|
+
console.error("Building the web app with the simulated device…");
|
|
333
|
+
const r = await runPreview(process.cwd(), { skipBuild: a["skip-build"] === "true" });
|
|
334
|
+
if (json) {
|
|
335
|
+
console.log(JSON.stringify({ url: r.url, claimUrl: r.claimUrl, expiresAt: r.expiresAt, files: r.files, warnings: r.warnings, public: true, notice: NOTICE }));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
console.log(`Preview: ${r.url}`);
|
|
339
|
+
if (r.claimUrl)
|
|
340
|
+
console.log(`Keep it (claim link): ${r.claimUrl}`);
|
|
341
|
+
if (r.expiresAt)
|
|
342
|
+
console.log(`Expires: ${r.expiresAt}`);
|
|
343
|
+
console.log(NOTICE);
|
|
344
|
+
for (const w of r.warnings)
|
|
345
|
+
console.log(`Warning from here.now: ${w}`);
|
|
346
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { kv as kvFor } from "./config.js";
|
|
2
|
+
type Store = Pick<ReturnType<typeof kvFor>, "get" | "put">;
|
|
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 --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 --force Promote even if the release is halted, has reverts or has a failing test report\n --help Show this help\n";
|
|
5
|
+
export declare function promote(argv: string[], deps?: {
|
|
6
|
+
kv?: Store;
|
|
7
|
+
fetchFn?: typeof fetch;
|
|
8
|
+
}): Promise<void>;
|
|
9
|
+
export {};
|
package/dist/promote.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// `shipvo promote`: widen a staged release to more bands. Reads the pointer on the narrowest band that has one,
|
|
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";
|
|
4
|
+
import { signBytes } from "./index.js";
|
|
5
|
+
import { args as parse, kv as kvFor, loadConfig, seed } from "./config.js";
|
|
6
|
+
const BANDS = ["b0", "b1", "b2"];
|
|
7
|
+
const REVERT_EVENTS = ["rolled_back", "crash_loop_revert"];
|
|
8
|
+
const pointerKey = (app, major, band, pf) => (pf === "ios" ? `p/${app}/${major}/ios/${band}` : `p/${app}/${major}/${band}`);
|
|
9
|
+
export const PROMOTE_FLAGS = ["to", "platform", "dry-run", "force", "report"];
|
|
10
|
+
export const PROMOTE_HELP = `Usage: shipvo promote [flags]
|
|
11
|
+
|
|
12
|
+
Widen a staged release to more bands. The release number, bundle and patches stay the same; the pointer is signed again.
|
|
13
|
+
|
|
14
|
+
Flags (write a value after a space, not =):
|
|
15
|
+
--to b1,b2 Bands to write (default: every band with no pointer for this release)
|
|
16
|
+
Bands that already hold a newer release are always skipped
|
|
17
|
+
--platform android|ios|all Platform to promote (default android)
|
|
18
|
+
--dry-run Print the plan, write nothing
|
|
19
|
+
--report <file> Test report from shipvo test --report (default shipvo-test-report.json if it exists)
|
|
20
|
+
A report for this release with failing flows blocks promote
|
|
21
|
+
--force Promote even if the release is halted, has reverts or has a failing test report
|
|
22
|
+
--help Show this help
|
|
23
|
+
`;
|
|
24
|
+
function checkFlags(argv, known) {
|
|
25
|
+
const bad = argv.filter((x) => x.startsWith("--") && !known.includes(x.slice(2)));
|
|
26
|
+
if (bad.length) {
|
|
27
|
+
const eq = bad.find((x) => x.includes("="));
|
|
28
|
+
const hint = eq ? ` Write "${eq.replace("=", " ")}" (a space, not =).` : "";
|
|
29
|
+
throw new Error(`Unknown flag ${bad.join(", ")}.${hint} Known flags: ${known.map((k) => "--" + k).join(" ")}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function promote(argv, deps = {}) {
|
|
33
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
34
|
+
console.log(PROMOTE_HELP);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
checkFlags(argv, PROMOTE_FLAGS);
|
|
38
|
+
const a = parse(argv), cfg = loadConfig();
|
|
39
|
+
const dry = a["dry-run"] !== undefined, force = a.force !== undefined;
|
|
40
|
+
const p = (a.platform || "android").toLowerCase();
|
|
41
|
+
if (p !== "android" && p !== "ios" && p !== "all")
|
|
42
|
+
throw new Error(`--platform must be android, ios or all (got "${a.platform}")`);
|
|
43
|
+
const platforms = p === "all" ? ["android", "ios"] : [p];
|
|
44
|
+
if (a.to === "true")
|
|
45
|
+
throw new Error("--to needs bands: pass --to b1,b2");
|
|
46
|
+
const explicit = a.to ? a.to.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
|
47
|
+
if (explicit) {
|
|
48
|
+
const bad = explicit.filter((b) => !BANDS.includes(b));
|
|
49
|
+
if (bad.length)
|
|
50
|
+
throw new Error(`Unknown band ${bad.join(", ")}. Bands are ${BANDS.join(", ")}.`);
|
|
51
|
+
}
|
|
52
|
+
if (a.report === "true")
|
|
53
|
+
throw new Error("--report needs a file name: pass --report <file>");
|
|
54
|
+
const reportFile = a.report ?? (existsSync("shipvo-test-report.json") ? "shipvo-test-report.json" : null);
|
|
55
|
+
const APP = cfg.appId, MAJOR = cfg.shellMajor;
|
|
56
|
+
const store = deps.kv ?? kvFor(cfg);
|
|
57
|
+
// Read every source pointer before touching the network or writing anything.
|
|
58
|
+
const plans = [];
|
|
59
|
+
for (const pf of platforms) {
|
|
60
|
+
let from = "", body = null;
|
|
61
|
+
const have = new Map();
|
|
62
|
+
for (const band of BANDS) {
|
|
63
|
+
const raw = await store.get(pointerKey(APP, MAJOR, band, pf));
|
|
64
|
+
if (!raw)
|
|
65
|
+
continue;
|
|
66
|
+
try {
|
|
67
|
+
const b = JSON.parse(JSON.parse(raw).body);
|
|
68
|
+
have.set(band, b.release);
|
|
69
|
+
if (!body) {
|
|
70
|
+
from = band;
|
|
71
|
+
body = b;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch { /* unreadable pointer: skip */ }
|
|
75
|
+
}
|
|
76
|
+
if (!body)
|
|
77
|
+
throw new Error(`No ${pf} pointer found on any band. Run shipvo publish --bands b0 first.`);
|
|
78
|
+
const release = body.release;
|
|
79
|
+
if (explicit && explicit.length && explicit.every((b) => b === from))
|
|
80
|
+
throw new Error(`${from} is the source band (release ${release}). Pass --to with a different band.`);
|
|
81
|
+
// Never write an older release over a newer one: promote widens, it does not downgrade. --force does not change this.
|
|
82
|
+
const cand = (explicit ?? BANDS).filter((b) => b !== from && (explicit || have.get(b) !== release));
|
|
83
|
+
const newer = cand.filter((b) => (have.get(b) ?? 0) > release).map((b) => [b, have.get(b)]);
|
|
84
|
+
const targets = cand.filter((b) => !((have.get(b) ?? 0) > release));
|
|
85
|
+
plans.push({ pf, release, from, body, targets, newer });
|
|
86
|
+
}
|
|
87
|
+
if (reportFile) {
|
|
88
|
+
let rep;
|
|
89
|
+
try {
|
|
90
|
+
rep = JSON.parse(readFileSync(reportFile, "utf8"));
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
throw new Error(`Could not read the test report ${reportFile} (${e instanceof Error ? e.message : String(e)}).`);
|
|
94
|
+
}
|
|
95
|
+
const rel = rep.release == null ? null : String(rep.release);
|
|
96
|
+
const failing = (Array.isArray(rep.flows) ? rep.flows : []).filter((f) => f.status === "failed").map((f) => f.name ?? "(unnamed flow)");
|
|
97
|
+
const mine = plans.filter((x) => String(x.release) === rel);
|
|
98
|
+
if (mine.length && failing.length) {
|
|
99
|
+
const msg = `Test report ${reportFile} has ${failing.length} failing flow${failing.length === 1 ? "" : "s"} for release ${mine[0].release}: ${failing.join(", ")}.`;
|
|
100
|
+
if (!force)
|
|
101
|
+
throw new Error(`${msg} Fix them and run shipvo test --report again, or pass --force to promote anyway.`);
|
|
102
|
+
console.log(`${msg} Overridden by --force.`);
|
|
103
|
+
}
|
|
104
|
+
else if (!mine.length) {
|
|
105
|
+
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.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!force) {
|
|
109
|
+
const base = cfg.controlPlane.replace(/\/$/, "");
|
|
110
|
+
let data;
|
|
111
|
+
try {
|
|
112
|
+
const r = await (deps.fetchFn ?? fetch)(`${base}/t/${encodeURIComponent(APP)}/summary`);
|
|
113
|
+
if (!r.ok)
|
|
114
|
+
throw new Error(`HTTP ${r.status}`);
|
|
115
|
+
data = (await r.json());
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
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.`);
|
|
119
|
+
}
|
|
120
|
+
for (const { release } of plans) {
|
|
121
|
+
if (data.halted && typeof data.halted === "object" && data.halted.release === release)
|
|
122
|
+
throw new Error(`Release ${release} is halted. Publish a new release or run shipvo rollback, or pass --force to promote it anyway.`);
|
|
123
|
+
const ev = data.releases?.find((r) => r.release === release)?.events ?? {};
|
|
124
|
+
const n = REVERT_EVENTS.reduce((s, k) => s + (ev[k] ?? 0), 0);
|
|
125
|
+
if (n > 0)
|
|
126
|
+
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.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const { pf, release, from, body, targets, newer } of plans) {
|
|
130
|
+
for (const [b, r] of newer)
|
|
131
|
+
console.log(`Skipped ${b} (${pf}): it already has release ${r}, which is newer than ${release}.`);
|
|
132
|
+
if (!targets.length && newer.length) {
|
|
133
|
+
console.log(`Nothing to promote: every other band already has a newer release than ${release} (${pf}).`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!targets.length) {
|
|
137
|
+
console.log(`Nothing to promote: release ${release} (${pf}) already has a pointer on every band.`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const issued_at = new Date().toISOString();
|
|
141
|
+
for (const band of targets) {
|
|
142
|
+
if (dry) {
|
|
143
|
+
console.log(`Would write ${band} (${pf}): release ${release}, copied from ${from}`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const text = JSON.stringify({ ...body, band, issued_at });
|
|
147
|
+
await store.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body: text, signature: signBytes(new TextEncoder().encode(text), seed()) }), "application/json");
|
|
148
|
+
console.log(`Wrote ${band} (${pf}): release ${release}, copied from ${from}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (dry)
|
|
152
|
+
console.log("Dry run: nothing was written.");
|
|
153
|
+
}
|