@shipvo/cli 0.1.4 → 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/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/ci.d.ts +3 -0
- package/dist/ci.js +42 -0
- package/dist/cli.js +28 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +25 -10
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +25 -1
- 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 +2 -2
- package/dist/help.js +24 -3
- package/dist/init.js +31 -14
- package/dist/policy.d.ts +2 -16
- package/dist/policy.js +2 -18
- package/dist/preview-recorder.d.ts +1 -0
- package/dist/preview-recorder.js +62 -0
- package/dist/preview.d.ts +3 -3
- package/dist/preview.js +110 -13
- package/dist/promote.d.ts +1 -1
- package/dist/promote.js +29 -2
- package/dist/publish.d.ts +1 -1
- package/dist/publish.js +12 -1
- 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 +4 -0
- package/dist/sim.js +19 -5
- package/dist/status.d.ts +48 -0
- package/dist/status.js +97 -4
- package/dist/store.d.ts +36 -0
- package/dist/store.js +266 -3
- 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 +6 -4
package/dist/help.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export declare const COMMANDS: readonly ["init", "sim", "preview", "publish", "promote", "auto-halt", "rollback", "app", "login", "doctor", "store", "status", "keys", "mcp", "keygen", "pubkey", "hash", "sign-file", "sign-json", "verify-json", "verify"];
|
|
2
|
-
export declare const help = "shipvo <command>\n\nStart\n init <dir> Create a new app wired to Shipvo\n sim [--port 4545] Build the app and run it in the simulator\n sim --watch [--verbose] Rebuild on file changes and keep serving; --verbose shows full build errors\n preview [--json] Publish a public 24-hour preview link (here.now, no account)\n\nShip\n publish [--bands b0,b1,b2] Build, sign and publish an over-the-air release\n publish --dry-run [--out d] Check a release offline: build and sign, write nothing remote\n promote [--to b1,b2] Widen a staged release to more bands (same release, new signature)\n promote --dry-run Preview a promote, write nothing\n auto-halt [--threshold 0.2 --min-reports 20 | --off] Show, turn on or turn off auto-halt\n rollback --to <release> Republish an earlier release as a new release\n rollback --to <n> --dry-run Preview a rollback, write nothing\n app create | list | delete Register, list or remove apps on the hosted control plane\n login Sign in with GitHub and get a key for the hosted simulator\n store details --dry-run Draft Play Data safety answers and a privacy policy from shipvo.json\n status [--app id] [--json] Show how the
|
|
1
|
+
export declare const COMMANDS: readonly ["init", "sim", "run", "explore", "preview", "publish", "promote", "auto-halt", "rollback", "app", "eas", "login", "doctor", "store", "ci", "status", "agents", "audit", "test", "keys", "mcp", "keygen", "pubkey", "hash", "sign-file", "sign-json", "verify-json", "verify"];
|
|
2
|
+
export declare const help = "shipvo <command>\n\nStart\n init <dir> Create a new app wired to Shipvo\n sim [--port 4545] Build the app and run it in the simulator\n sim --watch [--verbose] Rebuild on file changes and keep serving; --verbose shows full build errors\n First run installs playwright and, if no Chrome or Edge is found, downloads Chromium once.\n Set SHIPVO_NO_AUTO_INSTALL=1 to turn the automatic installs off.\n run android|ios [--no-open] [--port n] Open the app in a phone frame in your browser (no Playwright)\n run android|ios --native Run on an emulator or simulator with npx expo run:android / run:ios\n explore [--max-depth 3] [--max-steps 100] [--fail-on-findings] Walk the app in the simulator, write explore-report.md and .json\n preview [--json] Publish a public 24-hour preview link (here.now, no account)\n\nShip\n publish [--bands b0,b1,b2] [--release N] Build, sign and publish an over-the-air release\n publish --dry-run [--out d] Check a release offline: build and sign, write nothing remote\n promote [--to b1,b2] Widen a staged release to more bands (same release, new signature)\n promote --dry-run Preview a promote, write nothing\n promote --report <file> Refuse to widen when the test report has a failing flow for this release\n auto-halt [--threshold 0.2 --min-reports 20 | --off] Show, turn on or turn off auto-halt\n rollback --to <release> Republish an earlier release as a new release\n rollback --to <n> --dry-run Preview a rollback, write nothing\n app create | list | delete Register, list or remove apps on the hosted control plane\n eas setup [--dry-run] [--run] Make a Capacitor repo ready for eas build and submit (your own Expo account)\n eas build [args] Link the iOS scheme, then run eas build\n login Sign in with GitHub and get a key for the hosted simulator\n store details --dry-run Draft Play Data safety answers and a privacy policy from shipvo.json\n store setup [--company yes|no] [--android-now] [--play-account-before-2023-11-13]\n Pick the store accounts to open and write store-plan.json\n store screenshots [--out dir] [--sim url]\n Render each route at store sizes (fast mode: simulator, not the native UI)\n store testers [--package id] [--track alpha] [--play-key file] [--json]\n Steps to get your 12 testers; add the Shipvo Testers group to the closed track\n store status [--mark <step> [--as waiting|done]]\n Show each store's steps and what to do next\n ci init --framework capacitor [--dry-run] [--force]\n Write .github/workflows/shipvo-build.yml (signed Android and iOS builds)\n status [--app id] [--json] Show how releases do; --json lists what to do next (exit 0 ok, 1 action needed, 2 error)\n agents setup [--dry-run] [--json] Write AGENTS.md section and .mcp.json for AI agents\n audit <app> [--format json|csv] [--limit n] Export the audit log to stdout\n test [files] [--repair [--write]] [--report <file> [--release <id>]] Run tests/*.steps; --repair shows a diff for broken steps, --write saves it; --report writes JSON results keyed by commit and release\n doctor [--port <n>] Check setup before first publish\n keys Show the stored key's status and quota\n keys export [--out f] Print or save this app's signing seed, to back it up\n keys import [seed|--file f] Restore the signing seed on a new machine\n mcp Print the MCP config that agents need\n\nAdvanced\n keygen [--out seed.txt] Create an Ed25519 signing key and print the public key\n pubkey Print the public key for SHIPVO_SIGNING_SEED\n hash <file> Print the SHA-256 and size of a file\n sign-file <file> Print the Ed25519 signature (base64) of a file\n sign-json <file.json> Sign a JSON document and write the signature into it\n verify <pointer> [public-key-b64]\n Check a release pointer (such as one from publish --dry-run)\n verify-json <file.json> <public-key-b64>\n Check the signature of a JSON document\n";
|
|
3
3
|
export declare function distance(a: string, b: string): number;
|
|
4
4
|
/** Closest command within edit distance 2, or undefined. */
|
|
5
5
|
export declare function suggest(input: string): string | undefined;
|
package/dist/help.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const COMMANDS = [
|
|
2
|
-
"init", "sim", "preview", "publish", "promote", "auto-halt", "rollback", "app", "login", "doctor", "store", "status", "keys", "mcp",
|
|
2
|
+
"init", "sim", "run", "explore", "preview", "publish", "promote", "auto-halt", "rollback", "app", "eas", "login", "doctor", "store", "ci", "status", "agents", "audit", "test", "keys", "mcp",
|
|
3
3
|
"keygen", "pubkey", "hash", "sign-file", "sign-json", "verify-json", "verify",
|
|
4
4
|
];
|
|
5
5
|
export const help = `shipvo <command>
|
|
@@ -8,20 +8,41 @@ Start
|
|
|
8
8
|
init <dir> Create a new app wired to Shipvo
|
|
9
9
|
sim [--port 4545] Build the app and run it in the simulator
|
|
10
10
|
sim --watch [--verbose] Rebuild on file changes and keep serving; --verbose shows full build errors
|
|
11
|
+
First run installs playwright and, if no Chrome or Edge is found, downloads Chromium once.
|
|
12
|
+
Set SHIPVO_NO_AUTO_INSTALL=1 to turn the automatic installs off.
|
|
13
|
+
run android|ios [--no-open] [--port n] Open the app in a phone frame in your browser (no Playwright)
|
|
14
|
+
run android|ios --native Run on an emulator or simulator with npx expo run:android / run:ios
|
|
15
|
+
explore [--max-depth 3] [--max-steps 100] [--fail-on-findings] Walk the app in the simulator, write explore-report.md and .json
|
|
11
16
|
preview [--json] Publish a public 24-hour preview link (here.now, no account)
|
|
12
17
|
|
|
13
18
|
Ship
|
|
14
|
-
publish [--bands b0,b1,b2] Build, sign and publish an over-the-air release
|
|
19
|
+
publish [--bands b0,b1,b2] [--release N] Build, sign and publish an over-the-air release
|
|
15
20
|
publish --dry-run [--out d] Check a release offline: build and sign, write nothing remote
|
|
16
21
|
promote [--to b1,b2] Widen a staged release to more bands (same release, new signature)
|
|
17
22
|
promote --dry-run Preview a promote, write nothing
|
|
23
|
+
promote --report <file> Refuse to widen when the test report has a failing flow for this release
|
|
18
24
|
auto-halt [--threshold 0.2 --min-reports 20 | --off] Show, turn on or turn off auto-halt
|
|
19
25
|
rollback --to <release> Republish an earlier release as a new release
|
|
20
26
|
rollback --to <n> --dry-run Preview a rollback, write nothing
|
|
21
27
|
app create | list | delete Register, list or remove apps on the hosted control plane
|
|
28
|
+
eas setup [--dry-run] [--run] Make a Capacitor repo ready for eas build and submit (your own Expo account)
|
|
29
|
+
eas build [args] Link the iOS scheme, then run eas build
|
|
22
30
|
login Sign in with GitHub and get a key for the hosted simulator
|
|
23
31
|
store details --dry-run Draft Play Data safety answers and a privacy policy from shipvo.json
|
|
24
|
-
|
|
32
|
+
store setup [--company yes|no] [--android-now] [--play-account-before-2023-11-13]
|
|
33
|
+
Pick the store accounts to open and write store-plan.json
|
|
34
|
+
store screenshots [--out dir] [--sim url]
|
|
35
|
+
Render each route at store sizes (fast mode: simulator, not the native UI)
|
|
36
|
+
store testers [--package id] [--track alpha] [--play-key file] [--json]
|
|
37
|
+
Steps to get your 12 testers; add the Shipvo Testers group to the closed track
|
|
38
|
+
store status [--mark <step> [--as waiting|done]]
|
|
39
|
+
Show each store's steps and what to do next
|
|
40
|
+
ci init --framework capacitor [--dry-run] [--force]
|
|
41
|
+
Write .github/workflows/shipvo-build.yml (signed Android and iOS builds)
|
|
42
|
+
status [--app id] [--json] Show how releases do; --json lists what to do next (exit 0 ok, 1 action needed, 2 error)
|
|
43
|
+
agents setup [--dry-run] [--json] Write AGENTS.md section and .mcp.json for AI agents
|
|
44
|
+
audit <app> [--format json|csv] [--limit n] Export the audit log to stdout
|
|
45
|
+
test [files] [--repair [--write]] [--report <file> [--release <id>]] Run tests/*.steps; --repair shows a diff for broken steps, --write saves it; --report writes JSON results keyed by commit and release
|
|
25
46
|
doctor [--port <n>] Check setup before first publish
|
|
26
47
|
keys Show the stored key's status and quota
|
|
27
48
|
keys export [--out f] Print or save this app's signing seed, to back it up
|
package/dist/init.js
CHANGED
|
@@ -97,7 +97,7 @@ export async function init(argv) {
|
|
|
97
97
|
react: SDK.react, "react-dom": SDK.react, "react-native": SDK.rn, "react-native-web": SDK.rnw,
|
|
98
98
|
"@shipvo/device": "^0.1.0", "@shipvo/runtime": "^0.1.0",
|
|
99
99
|
},
|
|
100
|
-
devDependencies: { "@shipvo/cli": "^0.1.0", "@shipvo/sim": "^0.1.0", "@shipvo/test": "^0.1.0", "@types/react": "~19.3.0", playwright: "
|
|
100
|
+
devDependencies: { "@shipvo/cli": "^0.1.0", "@shipvo/sim": "^0.1.0", "@shipvo/test": "^0.1.0", "@types/react": "~19.3.0", playwright: ">=1.63.0 <1.65.0", typescript: "~6.0.3", vitest: "^2.1.4" },
|
|
101
101
|
}, null, 2) + "\n");
|
|
102
102
|
w("app.json", JSON.stringify({ expo: {
|
|
103
103
|
name, slug: appId, version: "1.0.0", orientation: "portrait", userInterfaceStyle: "automatic",
|
|
@@ -133,7 +133,7 @@ export const RELEASE = { headline: "Hello from ${name}.", note: "Edit src/releas
|
|
|
133
133
|
w("App.tsx", SHOP_APP_TSX(name));
|
|
134
134
|
w("src/cart.tsx", SHOP_CART_STATE);
|
|
135
135
|
w("src/money.ts", SHOP_MONEY);
|
|
136
|
-
w("src/data/shop.json", SHOP_CONFIG);
|
|
136
|
+
w("src/data/shop.json", SHOP_CONFIG(name));
|
|
137
137
|
w("src/screens/List.tsx", SHOP_LIST);
|
|
138
138
|
w("src/screens/Detail.tsx", SHOP_DETAIL);
|
|
139
139
|
w("src/screens/Cart.tsx", SHOP_CART);
|
|
@@ -158,14 +158,14 @@ Publish: shipvo login, then shipvo app create (hosted), or set CLOUDFLARE_API_TO
|
|
|
158
158
|
(self-hosting instead? see docs/self-hosting.md)
|
|
159
159
|
AI agents: npx -y @shipvo/mcp points Claude / Cursor / ChatGPT at the simulator; then open_app("${appId}")
|
|
160
160
|
|
|
161
|
-
Install first: npm install (or pnpm / bun), then npx playwright install chromium
|
|
162
|
-
|
|
163
161
|
Next steps:
|
|
164
162
|
1. cd ${dirArg}
|
|
165
|
-
2.
|
|
166
|
-
3.
|
|
167
|
-
4. npx shipvo
|
|
168
|
-
5. npx shipvo
|
|
163
|
+
2. npm install # or pnpm install / bun install; needed before any shipvo command
|
|
164
|
+
3. npx shipvo sim # open the app on a simulated phone in your browser
|
|
165
|
+
4. npx shipvo doctor # setup checks
|
|
166
|
+
5. npx shipvo app create ${appId} # register the app (run npx shipvo login first)
|
|
167
|
+
6. npx shipvo publish # send your first update
|
|
168
|
+
7. npx shipvo preview # get a link you can open on any phone
|
|
169
169
|
`);
|
|
170
170
|
}
|
|
171
171
|
const APP_TSX = (name, appId) => `import { useEffect, useRef, useState } from "react";
|
|
@@ -482,7 +482,7 @@ const SHOP_DATA = JSON.stringify([
|
|
|
482
482
|
{ id: "wool-beanie", name: "Wool beanie", price: 2400, description: "A warm hat in undyed wool." },
|
|
483
483
|
{ id: "steel-bottle", name: "Steel bottle", price: 2900, description: "A one litre bottle with a screw cap." },
|
|
484
484
|
], null, 2) + "\n";
|
|
485
|
-
const SHOP_CONFIG = JSON.stringify({ currency: "$", code: "USD", position: "before", decimals: 2, locale: "en-US" }, null, 2) + "\n";
|
|
485
|
+
const SHOP_CONFIG = (name) => JSON.stringify({ name, currency: "$", code: "USD", position: "before", decimals: 2, locale: "en-US" }, null, 2) + "\n";
|
|
486
486
|
const SHOP_MONEY = `// Prices are whole minor units (cents) in src/data/products.json. This is the one place that turns them into text.
|
|
487
487
|
// To change the currency, edit src/data/shop.json. "code" is the ISO 4217 code sent to the payment provider ("$" is only a symbol). For FCFA: { "currency": "FCFA", "code": "XAF", "position": "after", "decimals": 0, "locale": "fr-FR" }
|
|
488
488
|
import shop from "./data/shop.json";
|
|
@@ -496,6 +496,9 @@ export const formatPrice = (minor: number) => {
|
|
|
496
496
|
return position === "after" ? num + " " + currency : currency + num;
|
|
497
497
|
};
|
|
498
498
|
|
|
499
|
+
// The payee shown on the payment sheet and the receipt. It is "name" in shop.json.
|
|
500
|
+
export const shopName = (shop as { name?: string }).name ?? "Shop";
|
|
501
|
+
|
|
499
502
|
// What device.pay.charge takes: "amount" is in whole currency units (10710 for 10710 FCFA, 12.5 for $12.50), as in the device docs.
|
|
500
503
|
// Prices are minor units, so divide by 10 ** decimals. "currency" is the ISO code from shop.json.
|
|
501
504
|
export const chargeRequest = (minor: number) => {
|
|
@@ -538,6 +541,15 @@ import products from "./src/data/products.json";
|
|
|
538
541
|
|
|
539
542
|
type Route = { name: "list" } | { name: "detail"; id: string } | { name: "cart" };
|
|
540
543
|
|
|
544
|
+
// Send a placed order to the backend. The simulator records it, so end_session lists it. Add your own backend call here.
|
|
545
|
+
async function sendOrder(order: Record<string, unknown>) {
|
|
546
|
+
try {
|
|
547
|
+
const st = Runtime.getState();
|
|
548
|
+
if (!st?.baseUrl) return;
|
|
549
|
+
await fetch(st.baseUrl + "/o/" + st.appId, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ order, release: st.runningRelease ?? 0 }) });
|
|
550
|
+
} catch {}
|
|
551
|
+
}
|
|
552
|
+
|
|
541
553
|
export default function App() {
|
|
542
554
|
const [route, setRoute] = useState<Route>({ name: "list" });
|
|
543
555
|
// Tell the runtime the first screen rendered, so this release is kept.
|
|
@@ -560,7 +572,7 @@ export default function App() {
|
|
|
560
572
|
<CartProvider>
|
|
561
573
|
<View style={s.root}>
|
|
562
574
|
<StatusBar style="dark" />
|
|
563
|
-
{route.name === "cart" ? <Cart onBack={toList} />
|
|
575
|
+
{route.name === "cart" ? <Cart onBack={toList} onOrder={sendOrder} />
|
|
564
576
|
: product ? <Detail product={product} onBack={toList} onCart={toCart} />
|
|
565
577
|
: <List title="${name}" products={products} onOpen={(id) => setRoute({ name: "detail", id })} onCart={toCart} />}
|
|
566
578
|
</View>
|
|
@@ -637,10 +649,10 @@ const SHOP_CART = `import { useState } from "react";
|
|
|
637
649
|
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
|
638
650
|
import { device } from "@shipvo/device";
|
|
639
651
|
import { useCart } from "../cart";
|
|
640
|
-
import { chargeRequest, formatPrice } from "../money";
|
|
652
|
+
import { chargeRequest, formatPrice, shopName } from "../money";
|
|
641
653
|
import products from "../data/products.json";
|
|
642
654
|
|
|
643
|
-
export function Cart({ onBack }: { onBack: () => void }) {
|
|
655
|
+
export function Cart({ onBack, onOrder }: { onBack: () => void; onOrder?: (order: Record<string, unknown>) => void | Promise<void> }) {
|
|
644
656
|
const { lines, remove, clear, totalCents } = useCart();
|
|
645
657
|
const [status, setStatus] = useState<string | null>(null);
|
|
646
658
|
const [busy, setBusy] = useState(false);
|
|
@@ -650,8 +662,13 @@ export function Cart({ onBack }: { onBack: () => void }) {
|
|
|
650
662
|
setBusy(true);
|
|
651
663
|
setStatus("Paying...");
|
|
652
664
|
try {
|
|
653
|
-
const
|
|
654
|
-
|
|
665
|
+
const reference = "order-" + Date.now();
|
|
666
|
+
const r = await device.pay.charge({ provider: "mobile_money", ...chargeRequest(totalCents), to: shopName, reference }, "1234");
|
|
667
|
+
if (r.ok) {
|
|
668
|
+
// Only a paid order is sent to the backend.
|
|
669
|
+
await onOrder?.({ id: reference, total: totalCents, payment: "mobile_money", receipt: r.receipt, lines: lines.map((l) => ({ product_id: l.id, quantity: l.qty })) });
|
|
670
|
+
clear(); setStatus("Order placed");
|
|
671
|
+
} else setStatus("Payment failed: " + r.reason);
|
|
655
672
|
} catch (e: any) { setStatus("Payment failed: " + String(e?.message ?? e)); }
|
|
656
673
|
finally { setBusy(false); }
|
|
657
674
|
};
|
package/dist/policy.d.ts
CHANGED
|
@@ -1,18 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
export type PolicyResult = {
|
|
4
|
-
ok: true;
|
|
5
|
-
} | {
|
|
6
|
-
ok: false;
|
|
7
|
-
rule: string;
|
|
8
|
-
reason: string;
|
|
9
|
-
};
|
|
10
|
-
/** Checks one publish against the policy. Pure: `now` is read in local time. No policy means no rules. */
|
|
11
|
-
export declare function checkPolicy(policy: ShipvoPolicy | undefined, ctx: {
|
|
12
|
-
band: BandName;
|
|
13
|
-
actorKind: "human" | "agent";
|
|
14
|
-
now: Date;
|
|
15
|
-
}): PolicyResult;
|
|
1
|
+
import { checkPolicy, type PolicyResult } from "@shipvo/schema";
|
|
2
|
+
export { checkPolicy, type PolicyResult };
|
|
16
3
|
/** Reads the `policy` value from a parsed shipvo.json (throws naming the bad field) and checks it. The widest band in `bands` counts. */
|
|
17
4
|
export declare function checkPublishPolicy(rawPolicy: unknown, bands: string[], now?: Date, env?: NodeJS.ProcessEnv): PolicyResult;
|
|
18
|
-
export {};
|
package/dist/policy.js
CHANGED
|
@@ -1,22 +1,6 @@
|
|
|
1
|
-
import { Band, parsePolicy } from "@shipvo/schema";
|
|
1
|
+
import { Band, checkPolicy, parsePolicy } from "@shipvo/schema";
|
|
2
2
|
const ORDER = ["b0", "b1", "b2"];
|
|
3
|
-
|
|
4
|
-
/** Checks one publish against the policy. Pure: `now` is read in local time. No policy means no rules. */
|
|
5
|
-
export function checkPolicy(policy, ctx) {
|
|
6
|
-
if (!policy)
|
|
7
|
-
return { ok: true };
|
|
8
|
-
const { band, actorKind, now } = ctx;
|
|
9
|
-
if (policy.maxBand && ORDER.indexOf(band) > ORDER.indexOf(policy.maxBand))
|
|
10
|
-
return { ok: false, rule: "maxBand", reason: `this publish reaches ${band}, but policy.maxBand is ${policy.maxBand}` };
|
|
11
|
-
if (actorKind === "agent" && policy.agentMaxBand && ORDER.indexOf(band) > ORDER.indexOf(policy.agentMaxBand))
|
|
12
|
-
return { ok: false, rule: "agentMaxBand", reason: `an agent-run publish reaches ${band}, but policy.agentMaxBand is ${policy.agentMaxBand}` };
|
|
13
|
-
if (policy.blockedWeekdays) {
|
|
14
|
-
const day = DAYS[now.getDay()], from = policy.blockedAfterHour ?? 0;
|
|
15
|
-
if (policy.blockedWeekdays.includes(day) && now.getHours() >= from)
|
|
16
|
-
return { ok: false, rule: "blockedWeekdays", reason: `publishing is blocked on ${day} from ${String(from).padStart(2, "0")}:00 local time (policy.blockedWeekdays${policy.blockedAfterHour !== undefined ? ", policy.blockedAfterHour" : ""})` };
|
|
17
|
-
}
|
|
18
|
-
return { ok: true };
|
|
19
|
-
}
|
|
3
|
+
export { checkPolicy };
|
|
20
4
|
/** Reads the `policy` value from a parsed shipvo.json (throws naming the bad field) and checks it. The widest band in `bands` counts. */
|
|
21
5
|
export function checkPublishPolicy(rawPolicy, bands, now = new Date(), env = process.env) {
|
|
22
6
|
if (rawPolicy === undefined)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const RECORDER_JS: string;
|
|
@@ -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
|
+
`;
|
package/dist/preview.d.ts
CHANGED
|
@@ -20,10 +20,10 @@ export declare const blockedPath: (rel: string) => boolean;
|
|
|
20
20
|
export declare function refuseSecrets(files: PreviewFile[], secrets?: string[]): void;
|
|
21
21
|
/** Reads a build folder into upload entries, skipping files that must never be uploaded. */
|
|
22
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 and a
|
|
24
|
-
export declare function framePage(name: string): string;
|
|
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
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): PreviewFile[];
|
|
26
|
+
export declare function wrapBuild(buildDir: string, name: string, appId?: string): PreviewFile[];
|
|
27
27
|
/** Publishes to here.now anonymously: create, upload, finalize. Refreshes expired upload URLs and waits out 429s. */
|
|
28
28
|
export declare function publishToHereNow(files: PreviewFile[], o?: PreviewOpts): Promise<PreviewResult>;
|
|
29
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.";
|
package/dist/preview.js
CHANGED
|
@@ -4,6 +4,7 @@ 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 { RECORDER_JS } from "./preview-recorder.js";
|
|
7
8
|
const API = "https://here.now/api/v1";
|
|
8
9
|
const CLIENT = "shipvo/cli";
|
|
9
10
|
const TYPES = {
|
|
@@ -72,8 +73,8 @@ export function collectFiles(dir) {
|
|
|
72
73
|
walk(dir);
|
|
73
74
|
return out;
|
|
74
75
|
}
|
|
75
|
-
/** The page people open: the app in an Android or iPhone frame, with skin, size and theme switches and a
|
|
76
|
-
export function framePage(name) {
|
|
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) {
|
|
77
78
|
const title = name.replace(/[<>&"']/g, "");
|
|
78
79
|
return `<!doctype html>
|
|
79
80
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
@@ -85,7 +86,7 @@ body.dark{background:#15171a;color:#eee}
|
|
|
85
86
|
.bar{display:flex;gap:8px;justify-content:center;align-items:center;padding:10px;flex-wrap:wrap}
|
|
86
87
|
.bar button,.back{font:inherit;padding:6px 12px;border:1px solid #888;border-radius:8px;background:transparent;color:inherit;cursor:pointer}
|
|
87
88
|
.bar button[aria-pressed=true]{background:#2563eb;border-color:#2563eb;color:#fff}
|
|
88
|
-
.stage{display:flex;justify-content:center;padding:0 10px 20px}
|
|
89
|
+
.stage{display:flex;justify-content:center;align-items:flex-start;gap:16px;padding:0 10px 20px;flex-wrap:wrap}
|
|
89
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}
|
|
90
91
|
.device[data-skin=iphone]{border-radius:44px;padding:0 10px}
|
|
91
92
|
.status{display:flex;justify-content:space-between;align-items:center;height:28px;padding:0 14px;font-size:12px;flex:none}
|
|
@@ -103,42 +104,138 @@ iframe{border:0;background:#fff;width:100%;flex:1;min-height:0;display:block}
|
|
|
103
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}
|
|
104
105
|
.back:focus-visible{outline:2px solid #fff;outline-offset:2px}
|
|
105
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}
|
|
106
120
|
.note{text-align:center;opacity:.7;padding:0 12px 16px}
|
|
107
|
-
|
|
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}}
|
|
108
128
|
</style></head><body>
|
|
109
129
|
<div class="bar" role="toolbar" aria-label="Preview options">
|
|
110
130
|
<button data-skin="android" aria-pressed="true">Android</button><button data-skin="iphone" aria-pressed="false">iPhone</button>
|
|
111
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>
|
|
112
132
|
<button id="theme" aria-pressed="false">Dark</button></div>
|
|
133
|
+
<button id="comment" class="fab" aria-pressed="false">Comment</button>
|
|
113
134
|
<div class="stage"><div class="device" id="device" data-skin="android" style="width:360px;height:760px">
|
|
114
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>
|
|
115
|
-
<iframe id="app" src="app.html" title="${title}"></iframe>
|
|
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>
|
|
116
137
|
<div class="nav"><button class="back" id="back" aria-label="Back" title="Back">◀ Back</button></div>
|
|
117
|
-
</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>
|
|
118
154
|
<p class="note">${title}: simulated device, public preview. System sheets such as permissions and payments are simulated.</p>
|
|
119
|
-
<script>
|
|
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()};
|
|
120
194
|
var SIZES={phone:[360,760],large:[412,892],tablet:[820,1180]},IPHONE={phone:[390,844],large:[430,932],tablet:[820,1180]};
|
|
121
195
|
var skin="android",size="phone",d=document.getElementById("device"),f=document.getElementById("app"),bk=document.getElementById("back");
|
|
122
196
|
function pressed(sel,val,key){document.querySelectorAll(sel).forEach(function(o){o.setAttribute("aria-pressed",o.dataset[key]===val)})}
|
|
123
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")}
|
|
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()}});
|
|
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()}});
|
|
126
200
|
// Back does what the simulator's back action does: press the screen's Back button, else go back in history.
|
|
127
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){}}
|
|
128
202
|
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"};
|
|
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)})})}});
|
|
130
227
|
apply();
|
|
131
228
|
</script></body></html>
|
|
132
229
|
`;
|
|
133
230
|
}
|
|
134
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). */
|
|
135
|
-
export function wrapBuild(buildDir, name) {
|
|
232
|
+
export function wrapBuild(buildDir, name, appId = name) {
|
|
136
233
|
const files = collectFiles(buildDir).filter((f) => f.path !== "index.html");
|
|
137
234
|
const entry = join(buildDir, "index.html");
|
|
138
235
|
if (!existsSync(entry))
|
|
139
236
|
throw new Error("The web build has no index.html. Run `npx expo export --platform web` and check it works.");
|
|
140
237
|
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") });
|
|
238
|
+
files.push({ path: "index.html", bytes: new TextEncoder().encode(framePage(name, appId)), contentType: typeOf("x.html") });
|
|
142
239
|
return files;
|
|
143
240
|
}
|
|
144
241
|
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -226,7 +323,7 @@ export async function runPreview(root, o = {}) {
|
|
|
226
323
|
const dir = o.buildDir ?? join(root, ".shipvo", "preview-build");
|
|
227
324
|
if (!o.skipBuild)
|
|
228
325
|
await build(root, dir);
|
|
229
|
-
return publishToHereNow(wrapBuild(dir, cfg.name), o);
|
|
326
|
+
return publishToHereNow(wrapBuild(dir, cfg.name, cfg.appId), o);
|
|
230
327
|
}
|
|
231
328
|
export async function preview(argv) {
|
|
232
329
|
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
|
|
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
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,12 @@
|
|
|
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
5
|
import { args as parse, kv as kvFor, loadConfig, seed } from "./config.js";
|
|
5
6
|
const BANDS = ["b0", "b1", "b2"];
|
|
6
7
|
const REVERT_EVENTS = ["rolled_back", "crash_loop_revert"];
|
|
7
8
|
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"];
|
|
9
|
+
export const PROMOTE_FLAGS = ["to", "platform", "dry-run", "force", "report"];
|
|
9
10
|
export const PROMOTE_HELP = `Usage: shipvo promote [flags]
|
|
10
11
|
|
|
11
12
|
Widen a staged release to more bands. The release number, bundle and patches stay the same; the pointer is signed again.
|
|
@@ -15,7 +16,9 @@ Flags (write a value after a space, not =):
|
|
|
15
16
|
Bands that already hold a newer release are always skipped
|
|
16
17
|
--platform android|ios|all Platform to promote (default android)
|
|
17
18
|
--dry-run Print the plan, write nothing
|
|
18
|
-
--
|
|
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
|
|
19
22
|
--help Show this help
|
|
20
23
|
`;
|
|
21
24
|
function checkFlags(argv, known) {
|
|
@@ -46,6 +49,9 @@ export async function promote(argv, deps = {}) {
|
|
|
46
49
|
if (bad.length)
|
|
47
50
|
throw new Error(`Unknown band ${bad.join(", ")}. Bands are ${BANDS.join(", ")}.`);
|
|
48
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);
|
|
49
55
|
const APP = cfg.appId, MAJOR = cfg.shellMajor;
|
|
50
56
|
const store = deps.kv ?? kvFor(cfg);
|
|
51
57
|
// Read every source pointer before touching the network or writing anything.
|
|
@@ -78,6 +84,27 @@ export async function promote(argv, deps = {}) {
|
|
|
78
84
|
const targets = cand.filter((b) => !((have.get(b) ?? 0) > release));
|
|
79
85
|
plans.push({ pf, release, from, body, targets, newer });
|
|
80
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
|
+
}
|
|
81
108
|
if (!force) {
|
|
82
109
|
const base = cfg.controlPlane.replace(/\/$/, "");
|
|
83
110
|
let data;
|
package/dist/publish.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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
|
+
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 --help Show this help\n";
|
|
2
2
|
export declare function publish(argv: string[]): Promise<void>;
|
|
3
3
|
/** Bytes in plain units: KB below 1 MB, so a small bundle never reads as 0.00 MB. */
|
|
4
4
|
export declare function formatSize(bytes: number): string;
|