@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,64 @@
|
|
|
1
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
const LOCKFILES = [
|
|
6
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
7
|
+
["yarn.lock", "yarn"],
|
|
8
|
+
["bun.lock", "bun"],
|
|
9
|
+
["bun.lockb", "bun"],
|
|
10
|
+
];
|
|
11
|
+
export function detectPackageManager(cwd, exists) {
|
|
12
|
+
for (const [file, pm] of LOCKFILES)
|
|
13
|
+
if (exists(join(cwd, file)))
|
|
14
|
+
return pm;
|
|
15
|
+
return "npm";
|
|
16
|
+
}
|
|
17
|
+
export function installCommand(pm) {
|
|
18
|
+
switch (pm) {
|
|
19
|
+
case "pnpm": return { cmd: "pnpm", args: ["add", "-D", "playwright"] };
|
|
20
|
+
case "yarn": return { cmd: "yarn", args: ["add", "-D", "playwright"] };
|
|
21
|
+
case "bun": return { cmd: "bun", args: ["add", "-d", "playwright"] };
|
|
22
|
+
default: return { cmd: "npm", args: ["install", "--save-dev", "playwright"] };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Same lookup order as doctor's playwrightChromiumPath: the app, this package, @shipvo/sim, then the global npm root. */
|
|
26
|
+
function playwrightResolves(cwd) {
|
|
27
|
+
const tries = [
|
|
28
|
+
() => createRequire(join(cwd, "package.json")).resolve("playwright"),
|
|
29
|
+
() => createRequire(import.meta.url).resolve("playwright"),
|
|
30
|
+
() => createRequire(createRequire(import.meta.url).resolve("@shipvo/sim/package.json")).resolve("playwright"),
|
|
31
|
+
() => createRequire(import.meta.url).resolve(join(execSync("npm root -g", { stdio: ["ignore", "pipe", "ignore"] }).toString().trim(), "playwright")),
|
|
32
|
+
];
|
|
33
|
+
for (const t of tries) {
|
|
34
|
+
try {
|
|
35
|
+
t();
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
/** Installs `playwright` as a dev dependency with the app's package manager when it is missing. */
|
|
43
|
+
export function ensurePlaywright(deps) {
|
|
44
|
+
const { cwd } = deps;
|
|
45
|
+
const env = deps.env ?? process.env;
|
|
46
|
+
const exists = deps.exists ?? existsSync;
|
|
47
|
+
const run = deps.run ?? ((cmd, args, dir) => { execFileSync(cmd, args, { cwd: dir, stdio: "inherit" }); });
|
|
48
|
+
const log = deps.log ?? ((line) => console.log(line));
|
|
49
|
+
if ((deps.resolves ?? playwrightResolves)(cwd))
|
|
50
|
+
return;
|
|
51
|
+
const { cmd, args } = installCommand(detectPackageManager(cwd, exists));
|
|
52
|
+
const line = [cmd, ...args].join(" ");
|
|
53
|
+
if (env.SHIPVO_NO_AUTO_INSTALL === "1") {
|
|
54
|
+
throw new Error(`playwright is not installed and SHIPVO_NO_AUTO_INSTALL=1 is set. Run \`${line}\` in ${cwd}.`);
|
|
55
|
+
}
|
|
56
|
+
log(`playwright is not installed. Running: ${line}`);
|
|
57
|
+
try {
|
|
58
|
+
run(cmd, args, cwd);
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
62
|
+
throw new Error(`\`${line}\` failed: ${msg}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export type Step = {
|
|
2
|
+
action: "open";
|
|
3
|
+
} | {
|
|
4
|
+
action: "open_url";
|
|
5
|
+
url: string;
|
|
6
|
+
} | {
|
|
7
|
+
action: "tap";
|
|
8
|
+
role: string;
|
|
9
|
+
name: string;
|
|
10
|
+
nth: number;
|
|
11
|
+
} | {
|
|
12
|
+
action: "back";
|
|
13
|
+
};
|
|
14
|
+
export type Reply = {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
screen?: string | null;
|
|
17
|
+
error?: string;
|
|
18
|
+
code?: string;
|
|
19
|
+
delivery?: string;
|
|
20
|
+
reason?: string;
|
|
21
|
+
};
|
|
22
|
+
/** What the walker needs from a simulator session. */
|
|
23
|
+
export type Driver = {
|
|
24
|
+
/** Start a fresh phone on the app. */
|
|
25
|
+
open(): Promise<Reply>;
|
|
26
|
+
openUrl(url: string): Promise<Reply>;
|
|
27
|
+
tap(ref: string): Promise<Reply>;
|
|
28
|
+
back(): Promise<Reply>;
|
|
29
|
+
/** Uncaught page errors since the last call. */
|
|
30
|
+
errors(): Promise<string[]>;
|
|
31
|
+
};
|
|
32
|
+
export type Kind = "error" | "not_delivered" | "action_failed" | "dead_end" | "unnamed";
|
|
33
|
+
export type Finding = {
|
|
34
|
+
kind: Kind;
|
|
35
|
+
message: string;
|
|
36
|
+
screen: string;
|
|
37
|
+
steps: Step[];
|
|
38
|
+
};
|
|
39
|
+
export type Report = {
|
|
40
|
+
app: string;
|
|
41
|
+
generatedAt: string;
|
|
42
|
+
steps: number;
|
|
43
|
+
screens: number;
|
|
44
|
+
maxSteps: number;
|
|
45
|
+
maxDepth: number;
|
|
46
|
+
stoppedEarly: string | null;
|
|
47
|
+
error: string | null;
|
|
48
|
+
findings: Finding[];
|
|
49
|
+
parityLimits: string[];
|
|
50
|
+
};
|
|
51
|
+
export declare const PARITY_LIMITS: string[];
|
|
52
|
+
type El = {
|
|
53
|
+
role: string;
|
|
54
|
+
name: string;
|
|
55
|
+
ref: string;
|
|
56
|
+
nth: number;
|
|
57
|
+
};
|
|
58
|
+
/** Actionable elements in a tree, in order. `nth` counts earlier elements with the same role and name. Disabled ones are left out. */
|
|
59
|
+
export declare function elements(tree: string): El[];
|
|
60
|
+
/** The tree without refs, to tell whether two screens are the same. */
|
|
61
|
+
export declare const signature: (tree: string) => string;
|
|
62
|
+
/** Steps in plain lines, for the report. */
|
|
63
|
+
export declare const describeStep: (s: Step) => string;
|
|
64
|
+
export declare function walk(driver: Driver, o?: {
|
|
65
|
+
routes?: string[];
|
|
66
|
+
maxDepth?: number;
|
|
67
|
+
maxSteps?: number;
|
|
68
|
+
app?: string;
|
|
69
|
+
}): Promise<Report>;
|
|
70
|
+
export declare function renderMarkdown(r: Report): string;
|
|
71
|
+
/** Driver for a running simulator. */
|
|
72
|
+
export declare function simDriver(base: string, app: string, session?: string): Driver;
|
|
73
|
+
/** Use the simulator at `--sim <url>`, or build the app and start one on `--port` (default 4546). `stop` kills the one we started. */
|
|
74
|
+
export declare function startSim(a: Record<string, string | undefined>, cfg: {
|
|
75
|
+
name: string;
|
|
76
|
+
appId: string;
|
|
77
|
+
controlPlane: string;
|
|
78
|
+
}): Promise<{
|
|
79
|
+
base: string;
|
|
80
|
+
stop: () => void;
|
|
81
|
+
}>;
|
|
82
|
+
/** Returns the exit code. */
|
|
83
|
+
export declare function explore(argv: string[]): Promise<number>;
|
|
84
|
+
export {};
|
package/dist/explore.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// `shipvo explore`: a deterministic walker over the simulator's accessibility tree. No LLM call.
|
|
2
|
+
// The walker (`walk`) only needs a `Driver`, so tests run it against a fake app. `explore` wires it to the simulator.
|
|
3
|
+
import { execSync, spawn } from "node:child_process";
|
|
4
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { args as parse, loadConfig } from "./config.js";
|
|
7
|
+
import { portFree, waitListening } from "./sim.js";
|
|
8
|
+
import { ensurePlaywright } from "./ensure-playwright.js";
|
|
9
|
+
export const PARITY_LIMITS = [
|
|
10
|
+
"The simulator runs the app's web build in headless Chromium. It is not a phone and not the native app.",
|
|
11
|
+
"Camera, location, biometrics, payments and notifications are simulated. Native crashes and native-only bugs cannot show up here.",
|
|
12
|
+
"The walker only taps. It does not type, scroll or swipe, so content below the fold and rails are only seen if on screen.",
|
|
13
|
+
"Layout, speed and memory are not measured.",
|
|
14
|
+
"See docs/parity-results.md for what was measured against Android emulators and the iOS Simulator.",
|
|
15
|
+
];
|
|
16
|
+
const ACTIONABLE = new Set(["button", "link", "tab", "checkbox", "switch", "radio", "menuitem"]);
|
|
17
|
+
const unquote = (raw) => { const q = /^(\s*- )'(.*)'(:.*)?$/.exec(raw); return q ? q[1] + q[2].replace(/''/g, "'") + (q[3] ?? "") : raw; };
|
|
18
|
+
/** Actionable elements in a tree, in order. `nth` counts earlier elements with the same role and name. Disabled ones are left out. */
|
|
19
|
+
export function elements(tree) {
|
|
20
|
+
const out = [];
|
|
21
|
+
const seen = new Map();
|
|
22
|
+
for (const raw of tree.split("\n")) {
|
|
23
|
+
const l = unquote(raw);
|
|
24
|
+
const m = /^\s*- ([\w-]+)(?: ("(?:[^"\\]|\\.)*"))?[^\n]*?\[ref=(\w+)\]/.exec(l);
|
|
25
|
+
if (!m || !ACTIONABLE.has(m[1]) || /\[disabled\]/.test(l))
|
|
26
|
+
continue;
|
|
27
|
+
let name = "";
|
|
28
|
+
try {
|
|
29
|
+
name = m[2] ? JSON.parse(m[2]).trim() : "";
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
name = m[2].slice(1, -1).trim();
|
|
33
|
+
}
|
|
34
|
+
const key = `${m[1]}|${name}`;
|
|
35
|
+
const nth = seen.get(key) ?? 0;
|
|
36
|
+
seen.set(key, nth + 1);
|
|
37
|
+
out.push({ role: m[1], name, ref: m[3], nth });
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** The tree without refs, to tell whether two screens are the same. */
|
|
42
|
+
export const signature = (tree) => tree.replace(/\s*\[ref=\w+\]/g, "");
|
|
43
|
+
const redScreen = (tree) => /Uncaught (\w*Error)|Unhandled Runtime Error|Application error|Something went wrong/i.exec(tree)?.[0];
|
|
44
|
+
// Accepts "close" too, but the sim `back` action only clicks a button named exactly "Back"; the replay fallback covers the rest.
|
|
45
|
+
const hasBack = (els) => els.some((e) => /^(back|go back|close)$/i.test(e.name));
|
|
46
|
+
const label = (e) => `${e.role} "${e.name}"`;
|
|
47
|
+
/** Steps in plain lines, for the report. */
|
|
48
|
+
export const describeStep = (s) => s.action === "open" ? "open the app" : s.action === "open_url" ? `open_url ${s.url}` : s.action === "tap" ? `tap ${label(s)}${s.nth ? ` (number ${s.nth + 1} with that name)` : ""}` : "back";
|
|
49
|
+
export async function walk(driver, o = {}) {
|
|
50
|
+
const maxDepth = o.maxDepth ?? 3, maxSteps = o.maxSteps ?? 100;
|
|
51
|
+
const findings = [];
|
|
52
|
+
const seenFinding = new Set();
|
|
53
|
+
const seenScreens = new Set();
|
|
54
|
+
let steps = 0;
|
|
55
|
+
let stopped = null;
|
|
56
|
+
let cut = 0;
|
|
57
|
+
const add = (f) => { const k = `${f.kind}|${f.message}|${signature(f.screen)}`; if (!seenFinding.has(k)) {
|
|
58
|
+
seenFinding.add(k);
|
|
59
|
+
findings.push(f);
|
|
60
|
+
} };
|
|
61
|
+
const need = (r, what) => { if (!r.ok || r.screen == null)
|
|
62
|
+
throw new Error(`explore: ${what} failed: ${r.error ?? "no screen"}`); return r.screen; };
|
|
63
|
+
/** Put the phone back on the screen reached by `path`. Replays are not counted as steps. */
|
|
64
|
+
async function replay(path) {
|
|
65
|
+
let screen = "";
|
|
66
|
+
for (const s of path) {
|
|
67
|
+
const r = s.action === "open" ? await driver.open() : s.action === "open_url" ? await driver.openUrl(s.url) : s.action === "back" ? await driver.back()
|
|
68
|
+
: await driver.tap(find(screen, s)?.ref ?? "missing");
|
|
69
|
+
screen = need(r, `replaying "${describeStep(s)}"`);
|
|
70
|
+
}
|
|
71
|
+
// Errors raised while replaying are drained here. On a later replay they would repeat an earlier finding.
|
|
72
|
+
// The caller of the first replay of a start path keeps them.
|
|
73
|
+
return { screen, errors: await driver.errors() };
|
|
74
|
+
}
|
|
75
|
+
const find = (tree, s) => elements(tree).find((e) => e.role === s.role && e.name === s.name && e.nth === s.nth);
|
|
76
|
+
async function checkErrors(screen, path) {
|
|
77
|
+
for (const e of await driver.errors())
|
|
78
|
+
add({ kind: "error", message: `Uncaught error: ${e}`, screen, steps: path });
|
|
79
|
+
const red = redScreen(screen);
|
|
80
|
+
if (red)
|
|
81
|
+
add({ kind: "error", message: `Red screen: the screen shows "${red}"`, screen, steps: path });
|
|
82
|
+
}
|
|
83
|
+
async function visit(screen, path, depth) {
|
|
84
|
+
const sig = signature(screen);
|
|
85
|
+
if (seenScreens.has(sig))
|
|
86
|
+
return;
|
|
87
|
+
seenScreens.add(sig);
|
|
88
|
+
await checkErrors(screen, path);
|
|
89
|
+
const els = elements(screen);
|
|
90
|
+
if (!els.length)
|
|
91
|
+
add({ kind: "dead_end", message: "Dead end: the screen has no actionable element", screen, steps: path });
|
|
92
|
+
for (const e of els)
|
|
93
|
+
if (!e.name)
|
|
94
|
+
add({ kind: "unnamed", message: `Unnamed ${e.role}: it has no accessible name`, screen, steps: path });
|
|
95
|
+
let current = screen;
|
|
96
|
+
for (const e of els) {
|
|
97
|
+
if (!e.name || /^(back|go back)$/i.test(e.name))
|
|
98
|
+
continue;
|
|
99
|
+
if (steps >= maxSteps) {
|
|
100
|
+
stopped ??= `max-steps (${maxSteps}) reached`;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const target = find(current, e);
|
|
104
|
+
if (!target) {
|
|
105
|
+
current = (await replay(path)).screen;
|
|
106
|
+
if (!find(current, e))
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const step = { action: "tap", role: e.role, name: e.name, nth: e.nth };
|
|
110
|
+
const here = [...path, step];
|
|
111
|
+
steps++;
|
|
112
|
+
const r = await driver.tap(find(current, e).ref);
|
|
113
|
+
if (r.delivery === "not_delivered")
|
|
114
|
+
add({ kind: "not_delivered", message: `Not delivered (${r.reason ?? "unknown reason"}): ${r.error ?? "the app did not receive the tap"}`, screen: current, steps: here });
|
|
115
|
+
else if (!r.ok)
|
|
116
|
+
add({ kind: "action_failed", message: `Action failed${r.code ? ` [${r.code}]` : ""}: ${r.error ?? "unknown"}`, screen: current, steps: here });
|
|
117
|
+
const after = r.screen ?? current;
|
|
118
|
+
await checkErrors(after, here);
|
|
119
|
+
const crashed = !r.ok || r.screen == null || !!redScreen(after);
|
|
120
|
+
const next = signature(after);
|
|
121
|
+
if (!crashed && next !== signature(current) && !seenScreens.has(next)) {
|
|
122
|
+
if (depth >= maxDepth) {
|
|
123
|
+
cut++;
|
|
124
|
+
seenScreens.add(next);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
await visit(after, here, depth + 1);
|
|
128
|
+
if (steps >= maxSteps && stopped)
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Back to this screen: Back button first, a full replay if that does not land here.
|
|
133
|
+
if (next !== signature(current) || crashed) {
|
|
134
|
+
let b;
|
|
135
|
+
if (!crashed && hasBack(elements(after)))
|
|
136
|
+
b = await driver.back();
|
|
137
|
+
current = b?.ok && b.screen != null && signature(b.screen) === sig ? b.screen : (await replay(path)).screen;
|
|
138
|
+
}
|
|
139
|
+
else
|
|
140
|
+
current = after;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const starts = [[{ action: "open" }], ...(o.routes ?? []).map((url) => [{ action: "open" }, { action: "open_url", url }])];
|
|
144
|
+
let error = null;
|
|
145
|
+
try {
|
|
146
|
+
for (const path of starts) {
|
|
147
|
+
if (stopped)
|
|
148
|
+
break;
|
|
149
|
+
const { screen, errors } = await replay(path);
|
|
150
|
+
// The first replay of a start path is the only time load-time errors are seen.
|
|
151
|
+
for (const e of errors)
|
|
152
|
+
add({ kind: "error", message: `Uncaught error: ${e}`, screen, steps: path });
|
|
153
|
+
await visit(screen, path, 1);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
error = e instanceof Error ? e.message : String(e);
|
|
158
|
+
stopped = `the walk failed: ${error}`;
|
|
159
|
+
}
|
|
160
|
+
if (!stopped && cut)
|
|
161
|
+
stopped = `max-depth (${maxDepth}) reached: ${cut} screen${cut === 1 ? "" : "s"} were not walked`;
|
|
162
|
+
return { app: o.app ?? "", generatedAt: new Date().toISOString(), steps, screens: seenScreens.size, maxSteps, maxDepth, stoppedEarly: stopped, error, findings, parityLimits: PARITY_LIMITS };
|
|
163
|
+
}
|
|
164
|
+
export function renderMarkdown(r) {
|
|
165
|
+
const L = [`# Explore report: ${r.app}`, "", "## Simulator limits", "", ...r.parityLimits.map((p) => `- ${p}`), "", "## Run", "",
|
|
166
|
+
`- Steps taken: ${r.steps} (limit ${r.maxSteps}). Screens seen: ${r.screens}. Depth limit: ${r.maxDepth}.`,
|
|
167
|
+
`- ${r.stoppedEarly ? `Stopped early: ${r.stoppedEarly}.` : "Stopped early: no. The walk ended because every reachable screen within the limits was visited."}`, ""];
|
|
168
|
+
L.push("## Findings", "");
|
|
169
|
+
if (!r.findings.length)
|
|
170
|
+
L.push(`No findings in ${r.steps} steps.`, "");
|
|
171
|
+
r.findings.forEach((f, i) => {
|
|
172
|
+
L.push(`### ${i + 1}. ${f.message}`, "", "Steps to reproduce:", "", ...f.steps.map((s, j) => `${j + 1}. ${describeStep(s)}`), "", "Typed steps:", "", "```json", JSON.stringify(f.steps), "```", "");
|
|
173
|
+
});
|
|
174
|
+
return L.join("\n");
|
|
175
|
+
}
|
|
176
|
+
/** Driver for a running simulator. */
|
|
177
|
+
export function simDriver(base, app, session = `explore-${Date.now().toString(36)}`) {
|
|
178
|
+
const post = async (path, body) => {
|
|
179
|
+
const r = await fetch(base.replace(/\/$/, "") + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(30000) });
|
|
180
|
+
if (!r.ok)
|
|
181
|
+
throw new Error(`simulator ${path} -> ${r.status}`);
|
|
182
|
+
return (await r.json());
|
|
183
|
+
};
|
|
184
|
+
const act = (body) => post("/act", { session, ...body });
|
|
185
|
+
return {
|
|
186
|
+
open: () => post("/session", { session, app }),
|
|
187
|
+
openUrl: (url) => act({ action: "open_url", url }),
|
|
188
|
+
tap: (ref) => act({ action: "tap", ref }),
|
|
189
|
+
back: () => act({ action: "back" }),
|
|
190
|
+
errors: async () => (await act({ action: "errors" })).errors ?? [],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** Use the simulator at `--sim <url>`, or build the app and start one on `--port` (default 4546). `stop` kills the one we started. */
|
|
194
|
+
export async function startSim(a, cfg) {
|
|
195
|
+
let base = a.sim;
|
|
196
|
+
let child;
|
|
197
|
+
if (!base) {
|
|
198
|
+
const port = a.port || "4546";
|
|
199
|
+
if (!(await portFree(Number(port))))
|
|
200
|
+
throw new Error(`Port ${port} is in use. Stop the other simulator, pass --port <n>, or pass --sim <url> to use one that is running.`);
|
|
201
|
+
if (a["skip-build"] !== "true" || !existsSync("sim-dist/index.html")) {
|
|
202
|
+
console.error(`Building ${cfg.name} for the simulator…`);
|
|
203
|
+
execSync("PN_SIM=1 npx expo export --platform web --output-dir sim-dist --clear", { stdio: "inherit", env: { ...process.env, PN_SIM: "1" } });
|
|
204
|
+
}
|
|
205
|
+
ensurePlaywright({ cwd: process.cwd() });
|
|
206
|
+
let server;
|
|
207
|
+
try {
|
|
208
|
+
server = createRequire(process.cwd() + "/package.json").resolve("@shipvo/sim/server.mjs");
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
throw new Error("@shipvo/sim is not installed in this app. Run `npm i -D @shipvo/sim` (or use your package manager).");
|
|
212
|
+
}
|
|
213
|
+
let exited = false;
|
|
214
|
+
child = spawn(process.execPath, [server, "--port", port, "--app", cfg.appId], { stdio: ["ignore", "ignore", "inherit"], env: { ...process.env, PN_CONTROL: cfg.controlPlane } });
|
|
215
|
+
child.on("exit", () => { exited = true; });
|
|
216
|
+
if (!(await waitListening(Number(port), () => exited)))
|
|
217
|
+
throw new Error("the simulator exited before it was ready");
|
|
218
|
+
base = `http://localhost:${port}`;
|
|
219
|
+
}
|
|
220
|
+
return { base, stop: () => { child?.kill(); } };
|
|
221
|
+
}
|
|
222
|
+
/** Returns the exit code. */
|
|
223
|
+
export async function explore(argv) {
|
|
224
|
+
const a = parse(argv);
|
|
225
|
+
const cfg = loadConfig();
|
|
226
|
+
const num = (k, d) => { if (a[k] === undefined)
|
|
227
|
+
return d; const n = Number(a[k]); if (!Number.isInteger(n) || n < 1)
|
|
228
|
+
throw new Error(`--${k} must be a whole number of 1 or more`); return n; };
|
|
229
|
+
const maxDepth = num("max-depth", 3), maxSteps = num("max-steps", 100);
|
|
230
|
+
const sim = await startSim(a, cfg);
|
|
231
|
+
const base = sim.base;
|
|
232
|
+
try {
|
|
233
|
+
const report = await walk(simDriver(base, cfg.appId), { routes: cfg.routes, maxDepth, maxSteps, app: cfg.appId });
|
|
234
|
+
const out = a.out ?? "explore-report";
|
|
235
|
+
writeFileSync(`${out}.md`, renderMarkdown(report));
|
|
236
|
+
writeFileSync(`${out}.json`, JSON.stringify(report, null, 2) + "\n");
|
|
237
|
+
console.log(`${report.findings.length ? `${report.findings.length} finding${report.findings.length === 1 ? "" : "s"}` : `No findings in ${report.steps} steps`}${report.stoppedEarly ? `. Stopped early: ${report.stoppedEarly}` : ""}. Wrote ${out}.md and ${out}.json`);
|
|
238
|
+
if (report.error) {
|
|
239
|
+
console.error(report.error);
|
|
240
|
+
return 1;
|
|
241
|
+
}
|
|
242
|
+
return report.findings.length && a["fail-on-findings"] === "true" ? 1 : 0;
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
sim.stop();
|
|
246
|
+
}
|
|
247
|
+
}
|
package/dist/help.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
export declare const COMMANDS: readonly ["init", "sim", "publish", "rollback", "app", "login", "doctor", "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\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 rollback --to <release> Republish an earlier release as a new release\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 doctor
|
|
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;
|
|
6
6
|
export declare function unknownCommand(input: string): string;
|
|
7
|
+
/** What to print when a command throws: the message, not a stack trace. Set SHIPVO_DEBUG=1 for the stack. */
|
|
8
|
+
export declare function failure(e: unknown, debug?: boolean): string;
|
package/dist/help.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const COMMANDS = [
|
|
2
|
-
"init", "sim", "publish", "rollback", "app", "login", "doctor", "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>
|
|
@@ -7,14 +7,43 @@ export const help = `shipvo <command>
|
|
|
7
7
|
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
|
+
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
|
|
16
|
+
preview [--json] Publish a public 24-hour preview link (here.now, no account)
|
|
10
17
|
|
|
11
18
|
Ship
|
|
12
|
-
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
|
|
13
20
|
publish --dry-run [--out d] Check a release offline: build and sign, write nothing remote
|
|
21
|
+
promote [--to b1,b2] Widen a staged release to more bands (same release, new signature)
|
|
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
|
|
24
|
+
auto-halt [--threshold 0.2 --min-reports 20 | --off] Show, turn on or turn off auto-halt
|
|
14
25
|
rollback --to <release> Republish an earlier release as a new release
|
|
26
|
+
rollback --to <n> --dry-run Preview a rollback, write nothing
|
|
15
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
|
|
16
30
|
login Sign in with GitHub and get a key for the hosted simulator
|
|
17
|
-
|
|
31
|
+
store details --dry-run Draft Play Data safety answers and a privacy policy from shipvo.json
|
|
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
|
|
46
|
+
doctor [--port <n>] Check setup before first publish
|
|
18
47
|
keys Show the stored key's status and quota
|
|
19
48
|
keys export [--out f] Print or save this app's signing seed, to back it up
|
|
20
49
|
keys import [seed|--file f] Restore the signing seed on a new machine
|
|
@@ -59,3 +88,10 @@ export function unknownCommand(input) {
|
|
|
59
88
|
const s = suggest(input);
|
|
60
89
|
return `shipvo: '${input}' is not a command.${s ? ` Did you mean '${s}'?` : ""}\nRun 'shipvo --help' to see all commands.\n`;
|
|
61
90
|
}
|
|
91
|
+
/** What to print when a command throws: the message, not a stack trace. Set SHIPVO_DEBUG=1 for the stack. */
|
|
92
|
+
export function failure(e, debug = !!process.env.SHIPVO_DEBUG) {
|
|
93
|
+
if (debug && e instanceof Error && e.stack)
|
|
94
|
+
return e.stack;
|
|
95
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
96
|
+
return `shipvo: ${m}\nRun 'shipvo doctor' to check your setup, or set SHIPVO_DEBUG=1 to see the full error.\n`;
|
|
97
|
+
}
|