@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.
Files changed (58) hide show
  1. package/dist/adopt-bare.d.ts +39 -0
  2. package/dist/adopt-bare.js +236 -0
  3. package/dist/adopt.d.ts +26 -0
  4. package/dist/adopt.js +257 -0
  5. package/dist/agents.d.ts +14 -0
  6. package/dist/agents.js +124 -0
  7. package/dist/audit.d.ts +13 -0
  8. package/dist/audit.js +75 -0
  9. package/dist/autohalt.d.ts +5 -0
  10. package/dist/autohalt.js +84 -0
  11. package/dist/ci.d.ts +3 -0
  12. package/dist/ci.js +42 -0
  13. package/dist/cli.js +142 -88
  14. package/dist/config.d.ts +10 -0
  15. package/dist/config.js +38 -10
  16. package/dist/doctor.d.ts +14 -0
  17. package/dist/doctor.js +132 -7
  18. package/dist/eas.d.ts +29 -0
  19. package/dist/eas.js +258 -0
  20. package/dist/ensure-playwright.d.ts +21 -0
  21. package/dist/ensure-playwright.js +64 -0
  22. package/dist/explore.d.ts +84 -0
  23. package/dist/explore.js +247 -0
  24. package/dist/help.d.ts +4 -2
  25. package/dist/help.js +39 -3
  26. package/dist/init.js +501 -17
  27. package/dist/native.d.ts +3 -0
  28. package/dist/native.js +39 -0
  29. package/dist/policy.d.ts +4 -0
  30. package/dist/policy.js +17 -0
  31. package/dist/preview-recorder.d.ts +1 -0
  32. package/dist/preview-recorder.js +62 -0
  33. package/dist/preview.d.ts +35 -0
  34. package/dist/preview.js +346 -0
  35. package/dist/promote.d.ts +9 -0
  36. package/dist/promote.js +153 -0
  37. package/dist/publish.d.ts +1 -1
  38. package/dist/publish.js +73 -29
  39. package/dist/report-md.d.ts +26 -0
  40. package/dist/report-md.js +58 -0
  41. package/dist/run-bridge.d.ts +7 -0
  42. package/dist/run-bridge.js +374 -0
  43. package/dist/run-protocol.d.ts +32 -0
  44. package/dist/run-protocol.js +22 -0
  45. package/dist/run-ws.d.ts +8 -0
  46. package/dist/run-ws.js +89 -0
  47. package/dist/run.d.ts +86 -0
  48. package/dist/run.js +301 -0
  49. package/dist/sim.d.ts +31 -0
  50. package/dist/sim.js +124 -5
  51. package/dist/status.d.ts +64 -0
  52. package/dist/status.js +147 -0
  53. package/dist/store.d.ts +38 -0
  54. package/dist/store.js +297 -0
  55. package/dist/templates/capacitor/github-actions/build.yml +122 -0
  56. package/dist/test.d.ts +46 -0
  57. package/dist/test.js +190 -0
  58. package/package.json +10 -5
package/dist/status.js ADDED
@@ -0,0 +1,147 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { routeStore } from "@shipvo/store";
4
+ import { args, loadConfig } from "./config.js";
5
+ import { detectProject } from "./agents.js";
6
+ import { portFree } from "./sim.js";
7
+ const ms = (v) => (typeof v === "number" ? String(Math.round(v)) : "-");
8
+ /** Plain-text table: one row per release, one column per event name, then the two medians. */
9
+ export function formatStatus(releases) {
10
+ const names = [...new Set(releases.flatMap((r) => Object.keys(r.events ?? {})))].sort();
11
+ const rows = [["release", ...names, "apply ms", "cold start ms"],
12
+ ...releases.map((r) => [String(r.release), ...names.map((n) => String(r.events?.[n] ?? 0)), ms(r.apply_ms_median), ms(r.cold_start_ms_median)])];
13
+ const w = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
14
+ return rows.map((r) => r.map((c, i) => (i === 0 ? c.padEnd(w[i]) : c.padStart(w[i]))).join(" ")).join("\n");
15
+ }
16
+ /** "auto-halt: off" or "auto-halt: on at 20% after 20 reports". */
17
+ export function describeAutoHalt(c) {
18
+ if (!c || typeof c.threshold !== "number")
19
+ return "auto-halt: off";
20
+ const n = c.min_reports ?? 20;
21
+ return `auto-halt: on at ${Math.round(c.threshold * 1000) / 10}% after ${n} report${n === 1 ? "" : "s"}`;
22
+ }
23
+ const alive = (pid) => { try {
24
+ process.kill(pid, 0);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ } };
30
+ /** Local facts for `status --json`: no network. */
31
+ async function localFacts(dir, simPort = 4545) {
32
+ const hasKey = !!(process.env.SHIPVO_SIGNING_SEED || process.env.PURENATIVE_SIGNING_SEED || (process.env.SHIPVO_SIGNING_SEED_FILE && existsSync(process.env.SHIPVO_SIGNING_SEED_FILE)) || (process.env.PURENATIVE_SIGNING_SEED_FILE && existsSync(process.env.PURENATIVE_SIGNING_SEED_FILE)) || existsSync(join(dir, ".shipvo", "seed.txt")));
33
+ let run = { running: false, port: null };
34
+ try {
35
+ const r = JSON.parse(readFileSync(join(dir, ".shipvo", "run.json"), "utf8"));
36
+ if (typeof r.pid === "number" && alive(r.pid))
37
+ run = { running: true, port: typeof r.port === "number" ? r.port : null };
38
+ }
39
+ catch { /* not running */ }
40
+ let storeStep = null;
41
+ try {
42
+ const plan = JSON.parse(readFileSync(join(dir, "store-plan.json"), "utf8"));
43
+ const r = routeStore(plan.answers);
44
+ const step = [...r.play.steps, ...r.apple.steps].find((x) => (plan.marks?.[x.id] ?? "todo") !== "done");
45
+ if (step)
46
+ storeStep = { id: step.id, title: step.title, state: plan.marks?.[step.id] ?? "todo" };
47
+ }
48
+ catch { /* no plan */ }
49
+ return { hasKey, run, storeStep, sim: { running: !(await portFree(simPort)), port: simPort } };
50
+ }
51
+ /** `shipvo status --json`: exit 0 ok, 1 action needed, 2 error. */
52
+ export async function statusJson(appArg, fetchFn, dir = process.cwd()) {
53
+ const kind = detectProject(dir);
54
+ const f = await localFacts(dir);
55
+ const shared = { sim: f.sim, run_server: f.run, signing_key: { present: f.hasKey }, store_step: f.storeStep };
56
+ const mk = (code, summary, next, appId, pending, exit) => ({
57
+ doc: { ok: exit === 0, code, summary, data: { project: { kind, app_id: appId }, ...shared, pending_release: pending }, next }, exit,
58
+ });
59
+ if (!existsSync(join(dir, "shipvo.json"))) {
60
+ return mk("no_project", "No shipvo.json here.", [{ command: kind === "unknown" ? "shipvo init myapp" : "shipvo init .", why: "This folder is not set up for Shipvo yet." }], null, null, 1);
61
+ }
62
+ let cfg;
63
+ try {
64
+ cfg = loadConfig(dir);
65
+ }
66
+ catch (e) {
67
+ return mk("error", e instanceof Error ? e.message : String(e), [{ command: "shipvo doctor", why: "shipvo.json is missing a field." }], null, null, 2);
68
+ }
69
+ const appId = appArg ?? cfg.appId;
70
+ let data;
71
+ const baseUrl = cfg.controlPlane.replace(/\/$/, "");
72
+ try {
73
+ const r = await fetchFn(`${baseUrl}/t/${encodeURIComponent(appId)}/summary`);
74
+ if (!r.ok)
75
+ throw new Error(`HTTP ${r.status}`);
76
+ data = (await r.json());
77
+ }
78
+ catch (e) {
79
+ return mk("error", `Could not reach the control plane at ${baseUrl} (${e instanceof Error ? e.message : String(e)}).`, [{ command: "shipvo doctor", why: "Check the control plane URL and network." }], appId, null, 2);
80
+ }
81
+ const releases = data.releases ?? [];
82
+ const pending = { newest: releases.length ? Math.max(...releases.map((r) => r.release)) : null, halted: data.halted ?? null, auto_halt: data.auto_halt ?? null };
83
+ const next = [];
84
+ let code = "ok", exit = 0;
85
+ let summary = pending.newest === null ? `No releases with telemetry yet for "${appId}".` : `Newest release ${pending.newest} is reporting for "${appId}".`;
86
+ if (!f.hasKey) {
87
+ code = "no_signing_key";
88
+ exit = 1;
89
+ summary = "No signing key found.";
90
+ next.push({ command: "shipvo keygen --out .shipvo/seed.txt", why: "You cannot publish without a signing key." });
91
+ }
92
+ if (pending.halted) {
93
+ code = "release_halted";
94
+ exit = 1;
95
+ summary = `Release ${pending.halted.release} is halted: ${pending.halted.failed} of ${pending.halted.reports} devices failed.`;
96
+ const prev = releases.map((r) => r.release).filter((n) => n < pending.halted.release);
97
+ const to = prev.length ? String(Math.max(...prev)) : "<release>";
98
+ next.unshift({ command: `shipvo rollback --to ${to}`, why: "Send devices back to a release that works, or publish a fixed one." });
99
+ }
100
+ else if (code === "ok" && pending.newest === null) {
101
+ code = "no_release";
102
+ next.push({ command: "shipvo publish --dry-run", why: "Check a first release before you publish it." });
103
+ }
104
+ if (!f.sim.running && !f.run.running)
105
+ next.push({ command: "shipvo sim", why: "Start the simulator to try the app." });
106
+ if (f.storeStep)
107
+ next.push({ command: f.storeStep.state === "waiting" ? "shipvo store status" : `shipvo store status --mark ${f.storeStep.id}`, why: `Store step "${f.storeStep.title}" is ${f.storeStep.state === "todo" ? "next" : f.storeStep.state}.` });
108
+ return mk(code, summary, next, appId, pending, exit);
109
+ }
110
+ export async function status(argv, fetchFn = fetch) {
111
+ const a = args(argv);
112
+ if (argv.includes("--json")) {
113
+ const { doc, exit } = await statusJson(a.app && a.app !== "true" ? a.app : undefined, fetchFn);
114
+ console.log(JSON.stringify(doc));
115
+ return exit;
116
+ }
117
+ const cfg = loadConfig();
118
+ const appId = a.app && a.app !== "true" ? a.app : cfg.appId;
119
+ const base = cfg.controlPlane.replace(/\/$/, "");
120
+ const url = `${base}/t/${encodeURIComponent(appId)}/summary`;
121
+ let data;
122
+ try {
123
+ const r = await fetchFn(url);
124
+ if (!r.ok)
125
+ throw new Error(`HTTP ${r.status}`);
126
+ data = (await r.json());
127
+ }
128
+ catch (e) {
129
+ console.error(`Could not reach the control plane at ${base} (${e instanceof Error ? e.message : String(e)}).`);
130
+ return 1;
131
+ }
132
+ const releases = data.releases ?? [];
133
+ // An older control plane has no auto_halt field: say nothing rather than a wrong "off".
134
+ if ("auto_halt" in data)
135
+ console.log(describeAutoHalt(data.auto_halt));
136
+ if (!releases.length) {
137
+ console.log(`No telemetry yet for "${appId}".`);
138
+ return 0;
139
+ }
140
+ const h = data.halted;
141
+ if (h && typeof h === "object") {
142
+ const pct = Math.round(h.threshold * 1000) / 10;
143
+ console.log(`Release ${h.release} is halted: ${h.failed} of ${h.reports} devices failed (limit ${pct}%). Publish a new release or run shipvo rollback to clear.`);
144
+ }
145
+ console.log(formatStatus(releases));
146
+ return 0;
147
+ }
@@ -0,0 +1,38 @@
1
+ import type { ScreenshotSetId } from "@shipvo/store";
2
+ /** `shipvo store details --dry-run [--out dir]`: print the answers, write two files. Nothing is uploaded; no credentials are read. */
3
+ export declare function store(argv: string[]): Promise<void>;
4
+ /** Sets filled from app routes, with the one size we render each at and the pixel ratio. Feature graphic and icon are not app screens. */
5
+ export declare const RENDER_SETS: {
6
+ set: ScreenshotSetId;
7
+ width: number;
8
+ height: number;
9
+ scale: number;
10
+ }[];
11
+ /** Renders one screen at a size and returns JPEG bytes. `route` is undefined for the start screen. */
12
+ export type Renderer = (size: {
13
+ width: number;
14
+ height: number;
15
+ scale: number;
16
+ }, route: string | undefined) => Promise<Uint8Array>;
17
+ /** Fast mode: render each route at each set's size, write files, print the checker result per file. Returns the exit code. */
18
+ export declare function renderScreenshots(routes: string[] | undefined, render: Renderer, outDir: string): Promise<number>;
19
+ /** Add the group to a track's Google Groups through one Play edit, keeping groups already there. Returns the final list. */
20
+ export declare function addTesterGroup(opts: {
21
+ pkg: string;
22
+ token: string;
23
+ group: string;
24
+ track?: string;
25
+ fetch?: typeof fetch;
26
+ }): Promise<{
27
+ groups: string[];
28
+ added: boolean;
29
+ }>;
30
+ export declare function testersGuide(pkg: string, track?: string): {
31
+ package: string;
32
+ track: string;
33
+ group: string;
34
+ joinUrl: string;
35
+ optInUrl: string;
36
+ firstUploadByHand: string;
37
+ steps: string[];
38
+ };
package/dist/store.js ADDED
@@ -0,0 +1,297 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { join } from "node:path";
4
+ import { createSign } from "node:crypto";
5
+ import { checkScreenshot, checkSet, SCREENSHOT_SETS, deriveStoreDetails, renderDataSafetyCsv, renderPrivacyPolicy, routeStore, SHIPVO_TESTERS_GROUP, SHIPVO_TESTERS_JOIN_URL } from "@shipvo/store";
6
+ import { args, loadConfig } from "./config.js";
7
+ import { startSim } from "./explore.js";
8
+ /** `shipvo store details --dry-run [--out dir]`: print the answers, write two files. Nothing is uploaded; no credentials are read. */
9
+ export async function store(argv) {
10
+ const [sub, ...rest] = argv;
11
+ if (sub === "setup")
12
+ return setup(rest);
13
+ if (sub === "status")
14
+ return status(rest);
15
+ if (sub === "testers")
16
+ return testers(rest);
17
+ if (sub === "screenshots")
18
+ return screenshots(rest);
19
+ if (sub !== "details") {
20
+ console.error("Usage: shipvo store details --dry-run [--out dir] | store setup | store status | store screenshots | store testers");
21
+ process.exit(2);
22
+ }
23
+ const a = args(rest);
24
+ if (a["dry-run"] === undefined || a["dry-run"] === "false") {
25
+ console.error("shipvo store details: upload is not available yet; use --dry-run");
26
+ process.exit(2);
27
+ }
28
+ const cfg = loadConfig();
29
+ const d = deriveStoreDetails(cfg);
30
+ const out = a.out && a.out !== "true" ? a.out : ".shipvo/store";
31
+ mkdirSync(out, { recursive: true });
32
+ writeFileSync(join(out, "data-safety.csv"), renderDataSafetyCsv(d));
33
+ writeFileSync(join(out, "privacy-policy.md"), renderPrivacyPolicy(d));
34
+ console.log(`Data safety answers for ${d.appName} (derived from shipvo.json):`);
35
+ if (d.rows.length === 0)
36
+ console.log(" No declared capability touches user data.");
37
+ for (const r of d.rows)
38
+ console.log(` ${r.dataType}: collected ${r.collected}, shared ${r.shared}, purpose ${r.purpose}`);
39
+ if (d.fill.total > 0)
40
+ console.log(`Filled without you: ${d.fill.filled} of ${d.fill.total} fields.`);
41
+ console.log(`\nYou still need to answer (${d.unknown.length}):`);
42
+ for (const u of d.unknown)
43
+ console.log(` - ${u.question}`);
44
+ console.log(`\nWrote ${join(out, "data-safety.csv")} and ${join(out, "privacy-policy.md")}. Nothing was uploaded.`);
45
+ }
46
+ const PLAN_FILE = "store-plan.json";
47
+ function fail(msg) { console.error(msg); process.exit(2); }
48
+ function yesNo(flag, v) {
49
+ if (v === undefined)
50
+ return undefined;
51
+ if (["yes", "true"].includes(v))
52
+ return true;
53
+ if (["no", "false"].includes(v))
54
+ return false;
55
+ return fail(`shipvo store setup: --${flag} must be yes or no, not "${v}".`);
56
+ }
57
+ async function ask(rl, q, def) {
58
+ const r = (await rl.question(`${q} [${def ? "Y/n" : "y/N"}] `)).trim().toLowerCase();
59
+ return r === "" ? def : r.startsWith("y");
60
+ }
61
+ function describe(title, r, marks, withStatus) {
62
+ console.log(`${title}: ${r.account} account. ${r.accountNote}`);
63
+ let next;
64
+ for (const s of r.steps) {
65
+ const m = marks[s.id] ?? "todo";
66
+ if (!next && m !== "done")
67
+ next = s;
68
+ console.log(` ${withStatus ? `[${m}] ` : "- "}${s.id}: ${s.title}${s.wait ? ` (wait: ${s.wait})` : ""}`);
69
+ }
70
+ if (!withStatus)
71
+ return;
72
+ if (!next)
73
+ console.log(" Done: this store is live.");
74
+ else if ((marks[next.id] ?? "todo") === "waiting")
75
+ console.log(` Next: wait. ${next.wait ?? next.title}. Then run \`shipvo store status --mark ${next.id}\`.`);
76
+ else
77
+ console.log(` Next: ${next.action} Then run \`shipvo store status --mark ${next.id}\`.`);
78
+ }
79
+ function printPlan(plan, withStatus) {
80
+ const r = routeStore(plan.answers);
81
+ describe("Google Play", r.play, plan.marks, withStatus);
82
+ console.log("");
83
+ describe("App Store", r.apple, plan.marks, withStatus);
84
+ if (!withStatus)
85
+ for (const n of r.notes)
86
+ console.log(`\n${n}`);
87
+ }
88
+ /** `shipvo store setup [--company yes|no] [--android-now] [--play-account-before-2023-11-13]`: write store-plan.json. No store calls. */
89
+ async function setup(argv) {
90
+ const a = args(argv);
91
+ let company = yesNo("company", a.company);
92
+ let androidNow = yesNo("android-now", a["android-now"]);
93
+ let before = yesNo("play-account-before-2023-11-13", a["play-account-before-2023-11-13"]);
94
+ if (company === undefined && process.stdin.isTTY) {
95
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
96
+ company = await ask(rl, "Do you have a registered company?", false);
97
+ if (!company) {
98
+ androidNow ??= await ask(rl, "Do you need Android live right now?", false);
99
+ before ??= await ask(rl, "Did you make a Google Play developer account before 13 Nov 2023?", false);
100
+ }
101
+ rl.close();
102
+ }
103
+ const answers = { company: company ?? false, androidNow: androidNow ?? false, playAccountBefore20231113: before ?? false };
104
+ const plan = { version: 1, answers, marks: {} };
105
+ writeFileSync(PLAN_FILE, JSON.stringify(plan, null, 2) + "\n");
106
+ console.log("Your store plan:\n");
107
+ printPlan(plan, false);
108
+ console.log(`\nWrote ${PLAN_FILE}. Run \`shipvo store status\` to see what to do next. Nothing was uploaded.`);
109
+ }
110
+ /** `shipvo store status [--mark <step> [--as waiting|done]]`: one timeline per store. */
111
+ async function status(argv) {
112
+ if (!existsSync(PLAN_FILE))
113
+ fail(`No ${PLAN_FILE} here. Run \`shipvo store setup\` first.`);
114
+ const plan = JSON.parse(readFileSync(PLAN_FILE, "utf8"));
115
+ const a = args(argv);
116
+ if (a.mark !== undefined) {
117
+ const r = routeStore(plan.answers);
118
+ const ids = [...r.play.steps, ...r.apple.steps].map((s) => s.id);
119
+ if (!ids.includes(a.mark))
120
+ fail(`Unknown step "${a.mark}". Steps: ${ids.join(", ")}.`);
121
+ const as = a.as ?? "done";
122
+ if (as !== "waiting" && as !== "done")
123
+ fail(`--as must be waiting or done, not "${as}".`);
124
+ plan.marks[a.mark] = as;
125
+ writeFileSync(PLAN_FILE, JSON.stringify(plan, null, 2) + "\n");
126
+ console.log(`Marked ${a.mark} as ${as}.\n`);
127
+ }
128
+ printPlan(plan, true);
129
+ }
130
+ /** Sets filled from app routes, with the one size we render each at and the pixel ratio. Feature graphic and icon are not app screens. */
131
+ export const RENDER_SETS = [
132
+ { set: "iphone", width: 1290, height: 2796, scale: 3 },
133
+ { set: "ipad-13", width: 2064, height: 2752, scale: 2 },
134
+ { set: "play-phone", width: 1080, height: 1920, scale: 3 },
135
+ { set: "play-tablet-7", width: 1200, height: 1920, scale: 2 },
136
+ { set: "play-tablet-10", width: 1200, height: 1920, scale: 2 },
137
+ ];
138
+ /** Fast mode: render each route at each set's size, write files, print the checker result per file. Returns the exit code. */
139
+ export async function renderScreenshots(routes, render, outDir) {
140
+ console.log("fast mode: simulator render, not the native UI");
141
+ const screens = [undefined, ...(routes ?? [])];
142
+ let failed = 0;
143
+ for (const { set, width, height, scale } of RENDER_SETS) {
144
+ const spec = SCREENSHOT_SETS[set];
145
+ const dir = join(outDir, set);
146
+ mkdirSync(dir, { recursive: true });
147
+ const used = screens.slice(0, spec.max);
148
+ if (used.length < screens.length)
149
+ console.log(`${spec.label}: the store takes ${spec.max} at most; rendering the first ${spec.max} of ${screens.length} screens.`);
150
+ const files = [];
151
+ for (const [i, route] of used.entries()) {
152
+ const slug = route === undefined ? "start" : route.replace(/^[a-z][a-z0-9+.-]*:\/*/i, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase() || "route";
153
+ const base = `${String(i + 1).padStart(2, "0")}-${slug}`;
154
+ const bytes = await render({ width, height, scale }, route);
155
+ const r = checkScreenshot({ name: base, bytes }, set);
156
+ const file = join(dir, `${base}.${r.format === "png" ? "png" : r.format === "jpeg" ? "jpg" : "bin"}`);
157
+ writeFileSync(file, bytes);
158
+ files.push({ name: file, bytes });
159
+ if (r.ok)
160
+ console.log(`${file}: ok`);
161
+ else {
162
+ failed++;
163
+ console.log(`${file}: FAILED`);
164
+ for (const p of r.problems)
165
+ console.log(` - ${p}`);
166
+ }
167
+ }
168
+ const c = checkSet(files, set);
169
+ for (const p of c.problems.filter((p) => /file\(s\)/.test(p))) {
170
+ failed++;
171
+ console.log(`${spec.label}: ${p}`);
172
+ }
173
+ for (const n of c.notes ?? [])
174
+ console.log(`${spec.label}: note: ${n}`);
175
+ }
176
+ console.log(`\nFiles are in ${outDir}. Check the sizes against the store pages before you submit.`);
177
+ return failed ? 1 : 0;
178
+ }
179
+ /** `shipvo store screenshots [--out dir] [--sim url] [--port n] [--skip-build]`: fast mode only. */
180
+ async function screenshots(argv) {
181
+ const a = args(argv);
182
+ if (a.mode !== undefined && a.mode !== "fast")
183
+ fail(`shipvo store screenshots: only fast mode exists so far; --mode ${a.mode} is not available.`);
184
+ const cfg = loadConfig();
185
+ const out = a.out && a.out !== "true" ? a.out : "store-assets/raw";
186
+ const sim = await startSim(a, cfg);
187
+ let code = 1;
188
+ try {
189
+ const post = async (path, body) => {
190
+ const r = await fetch(sim.base.replace(/\/$/, "") + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(60000) });
191
+ if (!r.ok)
192
+ throw new Error(`simulator ${path} -> ${r.status}`);
193
+ return (await r.json());
194
+ };
195
+ const session = `shots-${Date.now().toString(36)}`;
196
+ code = await renderScreenshots(cfg.routes, async (size, route) => {
197
+ const open = await post("/session", { session, app: cfg.appId, ...size });
198
+ if (open.ok === false)
199
+ throw new Error(`could not open the app: ${open.error ?? "unknown error"}`);
200
+ if (route !== undefined) {
201
+ const r = await post("/act", { session, action: "open_url", url: route });
202
+ if (!r.ok)
203
+ throw new Error(`could not open ${route}: ${r.error ?? "unknown error"}`);
204
+ }
205
+ const shot = await post("/act", { session, action: "screenshot", format: "jpeg", inline: true });
206
+ if (!shot.ok || !shot.data)
207
+ throw new Error(`screenshot failed: ${shot.error ?? "no data"}`);
208
+ return new Uint8Array(Buffer.from(shot.data, "base64"));
209
+ }, out);
210
+ }
211
+ finally {
212
+ sim.stop();
213
+ }
214
+ process.exitCode = code;
215
+ }
216
+ const PLAY_API = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications";
217
+ const b64u = (b) => Buffer.from(b).toString("base64url");
218
+ /** Trade a Play service account key for an access token. The key stays in memory; it is never stored. */
219
+ async function playToken(key, f) {
220
+ const now = Math.floor(Date.now() / 1000);
221
+ const head = `${b64u(JSON.stringify({ alg: "RS256", typ: "JWT" }))}.${b64u(JSON.stringify({ iss: key.client_email, scope: "https://www.googleapis.com/auth/androidpublisher", aud: "https://oauth2.googleapis.com/token", iat: now, exp: now + 3600 }))}`;
222
+ const sig = createSign("RSA-SHA256").update(head).sign(key.private_key).toString("base64url");
223
+ const r = await f("https://oauth2.googleapis.com/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: `${head}.${sig}` }) });
224
+ const j = (await r.json().catch(() => ({})));
225
+ if (!r.ok || !j.access_token)
226
+ throw new Error(`Google sign-in with the service account key failed (${r.status})`);
227
+ return j.access_token;
228
+ }
229
+ /** Add the group to a track's Google Groups through one Play edit, keeping groups already there. Returns the final list. */
230
+ export async function addTesterGroup(opts) {
231
+ const f = opts.fetch ?? fetch, track = opts.track ?? "alpha", base = `${PLAY_API}/${encodeURIComponent(opts.pkg)}/edits`;
232
+ const call = async (url, method, body) => {
233
+ const r = await f(url, { method, headers: { authorization: `Bearer ${opts.token}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
234
+ const j = (await r.json().catch(() => ({})));
235
+ if (!r.ok)
236
+ throw new Error(`Play API ${method} ${url.replace(PLAY_API, "")} -> ${r.status}${j.error?.message ? `: ${j.error.message}` : ""}`);
237
+ return j;
238
+ };
239
+ const edit = await call(base, "POST", {});
240
+ const cur = await call(`${base}/${edit.id}/testers/${track}`, "GET");
241
+ const groups = Array.isArray(cur.googleGroups) ? cur.googleGroups : [];
242
+ if (groups.includes(opts.group)) {
243
+ await call(`${base}/${edit.id}`, "DELETE").catch(() => { });
244
+ return { groups, added: false };
245
+ }
246
+ const next = [...groups, opts.group];
247
+ await call(`${base}/${edit.id}/testers/${track}`, "PUT", { googleGroups: next });
248
+ await call(`${base}/${edit.id}:commit`, "POST");
249
+ return { groups: next, added: true };
250
+ }
251
+ export function testersGuide(pkg, track = "alpha") {
252
+ return {
253
+ package: pkg, track,
254
+ group: SHIPVO_TESTERS_GROUP, joinUrl: SHIPVO_TESTERS_JOIN_URL,
255
+ optInUrl: `https://play.google.com/apps/testing/${pkg}`,
256
+ firstUploadByHand: "Play requires the first upload of a new app to be made by hand in Play Console. This command cannot do that.",
257
+ steps: [
258
+ "In Play Console, open Testing > Closed testing and create a track (or use the default one).",
259
+ "Upload your first build by hand. Play does not allow the API to do the first upload.",
260
+ `Add testers as Google Groups: ${SHIPVO_TESTERS_GROUP}, plus a group of your own people. Or run \`shipvo store testers --play-key <file>\` to add it through the API.`,
261
+ `Share your opt-in link: https://play.google.com/apps/testing/${pkg}`,
262
+ `Join Shipvo Testers yourself (${SHIPVO_TESTERS_JOIN_URL}) and test other founders' apps. The community works because everyone tests.`,
263
+ "Keep at least 12 testers opted in for 14 days in a row. The clock starts when 12 have opted in; if the count drops below 12 it resets.",
264
+ ],
265
+ };
266
+ }
267
+ /** `shipvo store testers [--package id] [--track alpha] [--play-key file] [--json]`: print the steps. Calls Play only with --play-key. */
268
+ async function testers(argv) {
269
+ const a = args(argv);
270
+ const pkg = a.package && a.package !== "true" ? a.package : loadConfig().appId;
271
+ const track = a.track && a.track !== "true" ? a.track : "alpha";
272
+ const guide = testersGuide(pkg, track);
273
+ let api;
274
+ if (a["play-key"] !== undefined) {
275
+ if (a["play-key"] === "true")
276
+ fail("shipvo store testers: --play-key needs a file path.");
277
+ try {
278
+ const key = JSON.parse(readFileSync(a["play-key"], "utf8"));
279
+ api = await addTesterGroup({ pkg, track, group: SHIPVO_TESTERS_GROUP, token: await playToken(key, fetch) });
280
+ }
281
+ catch (e) {
282
+ api = { error: e.message };
283
+ process.exitCode = 1;
284
+ }
285
+ }
286
+ if (a.json !== undefined && a.json !== "false") {
287
+ console.log(JSON.stringify({ ...guide, ...(api ? { api } : {}) }, null, 2));
288
+ return;
289
+ }
290
+ console.log(`Get your 12 testers for ${pkg} (closed track "${track}"):\n`);
291
+ guide.steps.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
292
+ console.log(`\n${guide.firstUploadByHand}`);
293
+ if (api)
294
+ console.log("error" in api ? `\nCould not update the track: ${api.error}` : api.added ? `\nAdded ${SHIPVO_TESTERS_GROUP} to the "${track}" track. Groups now: ${api.groups.join(", ")}.` : `\n${SHIPVO_TESTERS_GROUP} was already on the "${track}" track. Nothing changed.`);
295
+ else
296
+ console.log("\nNo Play call was made. Pass --play-key <service-account.json> to add the group through the API; the key is read once and never stored.");
297
+ }
@@ -0,0 +1,122 @@
1
+ # Shipvo build template for Capacitor apps. Written by `shipvo ci init --framework capacitor`.
2
+ # Builds a signed Android bundle and a signed iOS app in your own GitHub Actions.
3
+ # Add the secrets listed in docs/capacitor.md (Settings > Secrets and variables > Actions).
4
+ name: Shipvo build
5
+
6
+ permissions:
7
+ contents: read
8
+
9
+ on:
10
+ workflow_dispatch:
11
+ push:
12
+ tags: ["v*"]
13
+
14
+ jobs:
15
+ android:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-node@v4
20
+ with:
21
+ node-version: 22
22
+ cache: npm
23
+ - uses: actions/setup-java@v4
24
+ with:
25
+ distribution: temurin
26
+ java-version: 21
27
+ - run: npm ci
28
+ - run: npm run build
29
+ - run: npx cap sync android
30
+ - name: Decode the upload keystore
31
+ env:
32
+ ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
33
+ run: echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/upload.keystore"
34
+ - name: Build the signed bundle
35
+ working-directory: android
36
+ env:
37
+ ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
38
+ ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
39
+ ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
40
+ run: >
41
+ ./gradlew bundleRelease
42
+ -Pandroid.injected.signing.store.file="$RUNNER_TEMP/upload.keystore"
43
+ -Pandroid.injected.signing.store.password="$ANDROID_KEYSTORE_PASSWORD"
44
+ -Pandroid.injected.signing.key.alias="$ANDROID_KEY_ALIAS"
45
+ -Pandroid.injected.signing.key.password="$ANDROID_KEY_PASSWORD"
46
+ - uses: actions/upload-artifact@v4
47
+ with:
48
+ name: android-release
49
+ path: android/app/build/outputs/bundle/release/*.aab
50
+ if-no-files-found: error
51
+
52
+ ios:
53
+ runs-on: macos-latest
54
+ steps:
55
+ - uses: actions/checkout@v4
56
+ - uses: actions/setup-node@v4
57
+ with:
58
+ node-version: 22
59
+ cache: npm
60
+ - run: npm ci
61
+ - run: npm run build
62
+ - run: npx cap sync ios
63
+ - name: Install the signing certificate and profile
64
+ env:
65
+ IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
66
+ IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
67
+ IOS_PROFILE_BASE64: ${{ secrets.IOS_PROFILE_BASE64 }}
68
+ KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
69
+ run: |
70
+ KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
71
+ echo "$IOS_CERTIFICATE_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
72
+ security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
73
+ security set-keychain-settings -lut 21600 "$KEYCHAIN"
74
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
75
+ security import "$RUNNER_TEMP/cert.p12" -P "$IOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN"
76
+ security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
77
+ security list-keychain -d user -s "$KEYCHAIN" "$HOME/Library/Keychains/login.keychain-db"
78
+ mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
79
+ echo "$IOS_PROFILE_BASE64" | base64 --decode > "$HOME/Library/MobileDevice/Provisioning Profiles/build.mobileprovision"
80
+ - name: Archive
81
+ env:
82
+ IOS_SCHEME: ${{ vars.IOS_SCHEME || 'App' }}
83
+ IOS_TEAM_ID: ${{ secrets.IOS_TEAM_ID }}
84
+ IOS_PROFILE_NAME: ${{ secrets.IOS_PROFILE_NAME }}
85
+ run: >
86
+ xcodebuild archive
87
+ -workspace ios/App/App.xcworkspace
88
+ -scheme "$IOS_SCHEME"
89
+ -configuration Release
90
+ -archivePath "$RUNNER_TEMP/App.xcarchive"
91
+ DEVELOPMENT_TEAM="$IOS_TEAM_ID"
92
+ CODE_SIGN_STYLE=Manual
93
+ PROVISIONING_PROFILE_SPECIFIER="$IOS_PROFILE_NAME"
94
+ CODE_SIGN_IDENTITY="Apple Distribution"
95
+ - name: Export the .ipa
96
+ env:
97
+ IOS_TEAM_ID: ${{ secrets.IOS_TEAM_ID }}
98
+ IOS_PROFILE_NAME: ${{ secrets.IOS_PROFILE_NAME }}
99
+ IOS_BUNDLE_ID: ${{ secrets.IOS_BUNDLE_ID }}
100
+ run: |
101
+ cat > "$RUNNER_TEMP/ExportOptions.plist" <<PLIST
102
+ <?xml version="1.0" encoding="UTF-8"?>
103
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
104
+ <plist version="1.0"><dict>
105
+ <key>method</key><string>app-store-connect</string>
106
+ <key>teamID</key><string>$IOS_TEAM_ID</string>
107
+ <key>signingStyle</key><string>manual</string>
108
+ <key>provisioningProfiles</key><dict><key>$IOS_BUNDLE_ID</key><string>$IOS_PROFILE_NAME</string></dict>
109
+ </dict></plist>
110
+ PLIST
111
+ xcodebuild -exportArchive -archivePath "$RUNNER_TEMP/App.xcarchive" -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" -exportPath "$RUNNER_TEMP/export"
112
+ mkdir -p dist-ios && cp "$RUNNER_TEMP"/export/*.ipa dist-ios/
113
+ - uses: actions/upload-artifact@v4
114
+ with:
115
+ name: ios-release
116
+ path: dist-ios/*.ipa
117
+ if-no-files-found: error
118
+ - name: Remove the keychain and certificate
119
+ if: always()
120
+ run: |
121
+ security delete-keychain "$RUNNER_TEMP/build.keychain-db" || true
122
+ rm -f "$RUNNER_TEMP/cert.p12"
package/dist/test.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ type Node = {
2
+ role: string;
3
+ name: string;
4
+ text: string;
5
+ ref: string;
6
+ };
7
+ type StepLike = {
8
+ kind: string;
9
+ target?: string;
10
+ text?: string;
11
+ enter?: boolean;
12
+ line: number;
13
+ };
14
+ type PhoneLike = {
15
+ screen: string;
16
+ };
17
+ export type TestKit = {
18
+ openApp(): Promise<PhoneLike>;
19
+ parseSteps(text: string): StepLike[];
20
+ runSteps(phone: PhoneLike, steps: StepLike[]): Promise<void>;
21
+ redact?(text: string): string;
22
+ proposeRepair(step: StepLike, tree: string): {
23
+ kind: "repaired";
24
+ step: StepLike;
25
+ reason: string;
26
+ } | {
27
+ kind: "unresolved";
28
+ candidates: Node[];
29
+ };
30
+ };
31
+ /** The same step as a plain-language line (only the target of a tap or type step is ever rewritten). */
32
+ export declare function stepLine(s: StepLike): string;
33
+ /** A unified diff that replaces single lines (line numbers are 1-based, old and new have the same count). Empty when nothing changes. */
34
+ export declare function unifiedDiff(file: string, text: string, edits: Map<number, string>): string;
35
+ /** Apply line edits, keeping indentation. Every other line, including comments and blank lines, is untouched. */
36
+ export declare function applyEdits(text: string, edits: Map<number, string>): string;
37
+ export type FlowReport = {
38
+ name: string;
39
+ status: "passed" | "failed";
40
+ steps: StepLike[];
41
+ failedStep: StepLike | null;
42
+ message: string | null;
43
+ };
44
+ /** Runs the suite. Returns the exit code: 0 when every step passed (or every failure was repaired and written), 1 otherwise. */
45
+ export declare function testCmd(argv: string[], kit?: TestKit, rawLog?: (s: string) => void): Promise<number>;
46
+ export {};