@shipvo/cli 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +3 -0
  2. package/dist/adopt.js +1 -2
  3. package/dist/agents.d.ts +15 -0
  4. package/dist/agents.js +68 -9
  5. package/dist/approvals.d.ts +9 -0
  6. package/dist/approvals.js +106 -0
  7. package/dist/audit.d.ts +1 -1
  8. package/dist/audit.js +28 -11
  9. package/dist/autohalt.d.ts +1 -1
  10. package/dist/autohalt.js +28 -16
  11. package/dist/bsdiff.d.ts +4 -0
  12. package/dist/bsdiff.js +465 -0
  13. package/dist/ci-comment.d.ts +38 -0
  14. package/dist/ci-comment.js +169 -0
  15. package/dist/ci-compare.d.ts +10 -0
  16. package/dist/ci-compare.js +25 -0
  17. package/dist/ci-shots.d.ts +9 -0
  18. package/dist/ci-shots.js +81 -0
  19. package/dist/ci.d.ts +8 -0
  20. package/dist/ci.js +64 -6
  21. package/dist/cli.js +23 -1
  22. package/dist/config.d.ts +13 -0
  23. package/dist/config.js +43 -3
  24. package/dist/doctor.js +63 -7
  25. package/dist/ensure-playwright.d.ts +4 -0
  26. package/dist/ensure-playwright.js +7 -2
  27. package/dist/explore-issues.d.ts +12 -0
  28. package/dist/explore-issues.js +50 -0
  29. package/dist/explore.d.ts +83 -7
  30. package/dist/explore.js +326 -77
  31. package/dist/flows.d.ts +31 -0
  32. package/dist/flows.js +264 -0
  33. package/dist/help.d.ts +2 -2
  34. package/dist/help.js +19 -10
  35. package/dist/hermes.d.ts +10 -0
  36. package/dist/hermes.js +50 -0
  37. package/dist/init.d.ts +1 -0
  38. package/dist/init.js +41 -6
  39. package/dist/json-out.d.ts +53 -0
  40. package/dist/json-out.js +91 -0
  41. package/dist/keys-json.d.ts +1 -0
  42. package/dist/keys-json.js +71 -0
  43. package/dist/login.js +27 -11
  44. package/dist/lumi.d.ts +104 -0
  45. package/dist/lumi.js +364 -0
  46. package/dist/migrate.d.ts +27 -0
  47. package/dist/migrate.js +521 -0
  48. package/dist/preview.d.ts +1 -0
  49. package/dist/preview.js +4 -2
  50. package/dist/promote.d.ts +1 -1
  51. package/dist/promote.js +38 -16
  52. package/dist/publish.d.ts +14 -2
  53. package/dist/publish.js +126 -62
  54. package/dist/qr.d.ts +9 -0
  55. package/dist/qr.js +213 -0
  56. package/dist/report-md.d.ts +1 -0
  57. package/dist/report-md.js +1 -1
  58. package/dist/rotation.d.ts +57 -0
  59. package/dist/rotation.js +157 -0
  60. package/dist/run-bridge.js +356 -2
  61. package/dist/run-net.d.ts +39 -0
  62. package/dist/run-net.js +266 -0
  63. package/dist/run.d.ts +23 -0
  64. package/dist/run.js +101 -17
  65. package/dist/signing.js +13 -0
  66. package/dist/sim.js +7 -3
  67. package/dist/smoke.d.ts +28 -0
  68. package/dist/smoke.js +117 -0
  69. package/dist/spawn-bin.d.ts +11 -0
  70. package/dist/spawn-bin.js +21 -0
  71. package/dist/status.d.ts +26 -4
  72. package/dist/status.js +69 -5
  73. package/dist/templates/github-action/shipvo-check.yml +18 -0
  74. package/dist/test.d.ts +1 -1
  75. package/dist/test.js +81 -18
  76. package/package.json +4 -3
  77. package/skills/shipvo-cli/SKILL.md +31 -0
@@ -0,0 +1,28 @@
1
+ import { type Driver, type Skin } from "./explore.js";
2
+ import { type Envelope } from "./json-out.js";
3
+ export type SmokeData = {
4
+ platform?: Skin;
5
+ time_to_first_screen_ms: number | null;
6
+ elements: number;
7
+ errors: string[];
8
+ error_count: number;
9
+ };
10
+ /** What smoke needs from the environment. Tests replace it. */
11
+ export type SmokeDeps = {
12
+ start: () => Promise<{
13
+ base: string;
14
+ stop: () => void;
15
+ }>;
16
+ driver: (base: string, app: string, session: string, platform: Skin) => Driver;
17
+ /** Current accessibility tree. Defaults to the simulator's `screen` action. */
18
+ screen: (base: string, session: string) => Promise<string>;
19
+ };
20
+ /** Elements in an accessibility tree: lines that carry a ref. */
21
+ export declare const countElements: (tree: string | null | undefined) => number;
22
+ /** Runs the check and returns the envelope and exit code. Never prints. */
23
+ export declare function smokeCheck(argv: string[], deps?: Partial<SmokeDeps>): Promise<{
24
+ doc: Envelope<string, unknown>;
25
+ exit: number;
26
+ }>;
27
+ /** Returns the exit code: 0 loads, 1 failed, 2 setup error. */
28
+ export declare function smoke(argv: string[]): Promise<number>;
package/dist/smoke.js ADDED
@@ -0,0 +1,117 @@
1
+ // `shipvo smoke`: does the app load? Starts the simulator (startSim), opens the app, waits for the first screen to settle.
2
+ import { existsSync } from "node:fs";
3
+ import { args as parse, BOOLEAN_FLAGS, loadConfig } from "./config.js";
4
+ import { simDriver, startSim } from "./explore.js";
5
+ import { detectPackageManager, installCommand } from "./ensure-playwright.js";
6
+ import { emit, envelope } from "./json-out.js";
7
+ const MAX_ERRORS = 5;
8
+ /** Elements in an accessibility tree: lines that carry a ref. */
9
+ export const countElements = (tree) => (tree ?? "").split("\n").filter((l) => /\[ref=/.test(l)).length;
10
+ async function screenOf(base, session) {
11
+ const r = await fetch(base.replace(/\/$/, "") + "/act", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ session, action: "screen" }), signal: AbortSignal.timeout(30000) });
12
+ if (!r.ok)
13
+ throw new Error(`simulator /act -> ${r.status}`);
14
+ return (await r.json()).screen ?? "";
15
+ }
16
+ const installNext = () => { const { cmd, args } = installCommand(detectPackageManager(process.cwd(), existsSync)); return { command: `${cmd} ${args.join(" ")} && npx playwright install chromium`, why: "Install the browser the simulator needs." }; };
17
+ /** Words that are not flags or flag values: the platform. */
18
+ const positionals = (argv) => { const out = []; for (let i = 0; i < argv.length; i++) {
19
+ const x = argv[i];
20
+ if (x.startsWith("--")) {
21
+ if (!BOOLEAN_FLAGS.has(x.slice(2)) && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--"))
22
+ i++;
23
+ }
24
+ else
25
+ out.push(x);
26
+ } return out; };
27
+ /** Runs the check and returns the envelope and exit code. Never prints. */
28
+ export async function smokeCheck(argv, deps) {
29
+ const a = parse(argv);
30
+ const setup = (msg, next = [{ command: "shipvo doctor --json", why: "Check the setup." }]) => ({ doc: envelope("setup_error", msg, {}, next, 2), exit: 2 });
31
+ const pos = positionals(argv);
32
+ if (pos.length > 1 || (pos[0] !== undefined && pos[0] !== "android" && pos[0] !== "ios"))
33
+ return setup(`Unknown platform "${pos.join(" ")}". Use android or ios.`, [{ command: "shipvo smoke android", why: "Pick android or ios." }, { command: "shipvo smoke ios", why: "Or the iPhone style." }]);
34
+ const platform = pos[0] === "ios" ? "ios" : "android";
35
+ const t = a.timeout === undefined ? 30 : Number(a.timeout);
36
+ if (!(t > 0) || !Number.isFinite(t))
37
+ return setup("--timeout must be a number of seconds above 0", [{ command: "shipvo smoke --timeout 30", why: "Use a positive number." }]);
38
+ let cfg;
39
+ try {
40
+ cfg = loadConfig();
41
+ }
42
+ catch (e) {
43
+ return setup(e instanceof Error ? e.message : String(e), [{ command: "shipvo init .", why: "Set up this folder." }]);
44
+ }
45
+ const json = argv.includes("--json");
46
+ let sim;
47
+ try {
48
+ sim = await (deps?.start ?? (() => startSim(a, cfg, json)))();
49
+ }
50
+ catch (e) {
51
+ const msg = e instanceof Error ? e.message : String(e);
52
+ const browser = /playwright|chromium|browser|exited before it was ready/i.test(msg);
53
+ return setup(msg, browser ? [installNext(), { command: "shipvo doctor --json", why: "Check the setup." }] : undefined);
54
+ }
55
+ let timer;
56
+ try {
57
+ const session = `smoke-${Date.now().toString(36)}`;
58
+ const driver = (deps?.driver ?? simDriver)(sim.base, cfg.appId, session, platform);
59
+ const get = deps?.screen ?? screenOf;
60
+ const started = Date.now();
61
+ const timedOut = new Promise((ok) => { timer = setTimeout(() => ok("timeout"), t * 1000); });
62
+ const run = (async () => {
63
+ const opened = await driver.open();
64
+ if (!opened.ok)
65
+ throw new Error(opened.error ?? "the simulator could not open the app");
66
+ let tree = opened.screen ?? "";
67
+ // Settled: two snapshots in a row are the same.
68
+ for (let i = 0; i < 20; i++) {
69
+ const next = await get(sim.base, session);
70
+ const same = next === tree;
71
+ tree = next;
72
+ if (same)
73
+ break;
74
+ await new Promise((r) => setTimeout(r, 100));
75
+ }
76
+ return { ms: Date.now() - started, tree, errors: await driver.errors() };
77
+ })();
78
+ run.catch(() => { });
79
+ const r = await Promise.race([run, timedOut]);
80
+ if (r === "timeout") {
81
+ const data = { platform, time_to_first_screen_ms: null, elements: 0, errors: [], error_count: 0 };
82
+ return { doc: envelope("timeout", `The app did not show a first screen within ${t} s`, data, [{ command: `shipvo smoke ${platform} --timeout ${Math.ceil(t * 2)} --json`, why: "Try a longer timeout." }], 1), exit: 1 };
83
+ }
84
+ const elements = countElements(r.tree);
85
+ const data = { platform, time_to_first_screen_ms: r.ms, elements, errors: r.errors.slice(0, MAX_ERRORS), error_count: r.errors.length };
86
+ if (r.errors.length)
87
+ return { doc: envelope("errors", `The app threw ${r.errors.length} error${r.errors.length === 1 ? "" : "s"} while loading: ${r.errors[0]}`, data, [{ command: `shipvo smoke ${platform} --json`, why: "Run again after fixing." }], 1), exit: 1 };
88
+ if (!elements)
89
+ return { doc: envelope("empty_screen", "The first screen has no elements", data, [{ command: `shipvo smoke ${platform} --json`, why: "Run again after fixing." }], 1), exit: 1 };
90
+ return { doc: envelope("ok", `The app loads on ${platform}: first screen in ${r.ms} ms, ${elements} element${elements === 1 ? "" : "s"}, no errors`, data, [{ command: "shipvo test --json", why: "Run your step files." }], 0), exit: 0 };
91
+ }
92
+ catch (e) {
93
+ return setup(e instanceof Error ? e.message : String(e));
94
+ }
95
+ finally {
96
+ clearTimeout(timer);
97
+ sim.stop();
98
+ }
99
+ }
100
+ /** Returns the exit code: 0 loads, 1 failed, 2 setup error. */
101
+ export async function smoke(argv) {
102
+ const { doc, exit } = await smokeCheck(argv);
103
+ if (argv.includes("--json"))
104
+ emit(doc);
105
+ else {
106
+ console.log(doc.summary);
107
+ const d = doc.data;
108
+ for (const e of d.errors ?? [])
109
+ console.log(` error: ${e}`);
110
+ if ((d.error_count ?? 0) > (d.errors?.length ?? 0))
111
+ console.log(` …and ${d.error_count - d.errors.length} more`);
112
+ if (exit)
113
+ for (const n of doc.next)
114
+ console.error(`Next: ${n.command} (${n.why})`);
115
+ }
116
+ return exit;
117
+ }
@@ -0,0 +1,11 @@
1
+ export interface SpawnCall {
2
+ command: string;
3
+ args: string[];
4
+ shell?: boolean;
5
+ }
6
+ /**
7
+ * Build the arguments for `spawn`. On Windows, npx, npm and pnpm are `.cmd`
8
+ * shims, which need a shell, so the args are quoted for cmd.exe.
9
+ * On other platforms the call is returned unchanged.
10
+ */
11
+ export declare function spawnBin(command: string, args: string[], platform?: NodeJS.Platform): SpawnCall;
@@ -0,0 +1,21 @@
1
+ const WIN_CMD_SHIMS = new Set(["npx", "npm", "pnpm"]);
2
+ const NEEDS_QUOTES = /[\s&|<>^(),;=]/;
3
+ function quoteForCmd(arg) {
4
+ if (!NEEDS_QUOTES.test(arg))
5
+ return arg;
6
+ return `"${arg.replace(/"/g, '\\"')}"`;
7
+ }
8
+ /**
9
+ * Build the arguments for `spawn`. On Windows, npx, npm and pnpm are `.cmd`
10
+ * shims, which need a shell, so the args are quoted for cmd.exe.
11
+ * On other platforms the call is returned unchanged.
12
+ */
13
+ export function spawnBin(command, args, platform = process.platform) {
14
+ if (platform !== "win32")
15
+ return { command, args };
16
+ return {
17
+ command: WIN_CMD_SHIMS.has(command) ? `${command}.cmd` : command,
18
+ args: args.map(quoteForCmd),
19
+ shell: true,
20
+ };
21
+ }
package/dist/status.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type Next } from "./json-out.js";
2
+ export type { Next };
1
3
  type Row = {
2
4
  release: number;
3
5
  events: Record<string, number>;
@@ -16,12 +18,27 @@ type Halted = {
16
18
  };
17
19
  /** Plain-text table: one row per release, one column per event name, then the two medians. */
18
20
  export declare function formatStatus(releases: Row[]): string;
21
+ /** What a band serves: a release number, null when it has none (404), or "?" when it cannot be read. */
22
+ export type BandValue = number | null | "?";
23
+ /** Release each band serves, from the public pointer. A halted release or a failed read gives "?", not "no release". */
24
+ export declare function fetchBands(base: string, appId: string, major: number, fetchFn: typeof fetch): Promise<Record<string, BandValue>>;
25
+ /** "bands: b0=4 b1=4 b2=4", plus a hint to promote when readable bands differ. "-" is no release, "?" is unreadable. */
26
+ export declare function describeBands(bands: Record<string, BandValue>): string[];
19
27
  /** "auto-halt: off" or "auto-halt: on at 20% after 20 reports". */
20
28
  export declare function describeAutoHalt(c: AutoHalt | undefined): string;
21
- export type Next = {
22
- command: string;
23
- why: string;
29
+ export type TestRelease = {
30
+ release: number;
31
+ passing: number;
32
+ failing: number;
33
+ failing_flows?: {
34
+ name: string;
35
+ first_failed_release: number;
36
+ }[];
24
37
  };
38
+ /** Test results the app uploaded with shipvo test --upload. Empty when none exist or the control plane cannot say. */
39
+ export declare function fetchTestResults(base: string, appId: string, fetchFn: typeof fetch): Promise<TestRelease[]>;
40
+ /** "tests: release 3: 2 failing (checkout, since release 2)" lines, newest first. */
41
+ export declare function describeTests(tests: TestRelease[]): string[];
25
42
  export type StatusJson = {
26
43
  ok: boolean;
27
44
  code: "ok" | "no_project" | "no_signing_key" | "release_halted" | "no_release" | "error";
@@ -52,6 +69,12 @@ export type StatusJson = {
52
69
  title: string;
53
70
  state: string;
54
71
  } | null;
72
+ skills: {
73
+ package: string;
74
+ name: string;
75
+ synced: boolean;
76
+ }[];
77
+ tests: TestRelease[];
55
78
  };
56
79
  next: Next[];
57
80
  };
@@ -61,4 +84,3 @@ export declare function statusJson(appArg: string | undefined, fetchFn: typeof f
61
84
  exit: number;
62
85
  }>;
63
86
  export declare function status(argv: string[], fetchFn?: typeof fetch): Promise<number>;
64
- export {};
package/dist/status.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { routeStore } from "@shipvo/store";
4
- import { args, loadConfig } from "./config.js";
5
- import { detectProject } from "./agents.js";
4
+ import { args, loadConfig, publishToken } from "./config.js";
5
+ import { detectProject, skillStatus } from "./agents.js";
6
6
  import { portFree } from "./sim.js";
7
+ import { emit } from "./json-out.js";
7
8
  const ms = (v) => (typeof v === "number" ? String(Math.round(v)) : "-");
8
9
  /** Plain-text table: one row per release, one column per event name, then the two medians. */
9
10
  export function formatStatus(releases) {
@@ -13,6 +14,41 @@ export function formatStatus(releases) {
13
14
  const w = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
14
15
  return rows.map((r) => r.map((c, i) => (i === 0 ? c.padEnd(w[i]) : c.padStart(w[i]))).join(" ")).join("\n");
15
16
  }
17
+ const BANDS = ["b0", "b1", "b2"];
18
+ /** Release each band serves, from the public pointer. A halted release or a failed read gives "?", not "no release". */
19
+ export async function fetchBands(base, appId, major, fetchFn) {
20
+ const out = {};
21
+ await Promise.all(BANDS.map(async (b) => {
22
+ out[b] = "?";
23
+ try {
24
+ const r = await fetchFn(`${base}/p/${encodeURIComponent(appId)}/${major}/${b}`);
25
+ if (r.status === 404) {
26
+ // Only "no_release" means none. release_halted and mismatch errors also answer 404.
27
+ const e = (await r.json().catch(() => null));
28
+ if (e?.error === "no_release")
29
+ out[b] = null;
30
+ return;
31
+ }
32
+ if (!r.ok)
33
+ return;
34
+ const env = (await r.json());
35
+ const rel = JSON.parse(String(env.body)).release;
36
+ if (typeof rel === "number")
37
+ out[b] = rel;
38
+ }
39
+ catch { /* unreadable: show "?" */ }
40
+ }));
41
+ return out;
42
+ }
43
+ /** "bands: b0=4 b1=4 b2=4", plus a hint to promote when readable bands differ. "-" is no release, "?" is unreadable. */
44
+ export function describeBands(bands) {
45
+ const line = `bands: ${BANDS.map((b) => `${b}=${bands[b] ?? "-"}`).join(" ")}`;
46
+ const readable = BANDS.map((b) => bands[b] ?? null).filter((v) => v !== "?");
47
+ if (new Set(readable).size < 2)
48
+ return [line];
49
+ const newest = Math.max(...readable.filter((v) => v !== null));
50
+ return [line, `Bands differ. Run shipvo promote to give every band the newest release (${newest}).`];
51
+ }
16
52
  /** "auto-halt: off" or "auto-halt: on at 20% after 20 reports". */
17
53
  export function describeAutoHalt(c) {
18
54
  if (!c || typeof c.threshold !== "number")
@@ -20,6 +56,26 @@ export function describeAutoHalt(c) {
20
56
  const n = c.min_reports ?? 20;
21
57
  return `auto-halt: on at ${Math.round(c.threshold * 1000) / 10}% after ${n} report${n === 1 ? "" : "s"}`;
22
58
  }
59
+ /** Test results the app uploaded with shipvo test --upload. Empty when none exist or the control plane cannot say. */
60
+ export async function fetchTestResults(base, appId, fetchFn) {
61
+ const token = publishToken();
62
+ if (!token)
63
+ return [];
64
+ try {
65
+ const r = await fetchFn(`${base}/r/${encodeURIComponent(appId)}/tests`, { headers: { authorization: `Bearer ${token}` } });
66
+ if (!r.ok)
67
+ return [];
68
+ const d = (await r.json());
69
+ return Array.isArray(d.releases) ? d.releases : [];
70
+ }
71
+ catch {
72
+ return [];
73
+ }
74
+ }
75
+ /** "tests: release 3: 2 failing (checkout, since release 2)" lines, newest first. */
76
+ export function describeTests(tests) {
77
+ return tests.map((t) => `tests: release ${t.release}: ${t.failing ? `${t.failing} failing (${(t.failing_flows ?? []).map((f) => `${f.name}${f.first_failed_release === t.release ? "" : `, since release ${f.first_failed_release}`}`).join("; ")})` : `all ${t.passing} passing`}`);
78
+ }
23
79
  const alive = (pid) => { try {
24
80
  process.kill(pid, 0);
25
81
  return true;
@@ -52,9 +108,10 @@ async function localFacts(dir, simPort = 4545) {
52
108
  export async function statusJson(appArg, fetchFn, dir = process.cwd()) {
53
109
  const kind = detectProject(dir);
54
110
  const f = await localFacts(dir);
55
- const shared = { sim: f.sim, run_server: f.run, signing_key: { present: f.hasKey }, store_step: f.storeStep };
111
+ const shared = { sim: f.sim, run_server: f.run, signing_key: { present: f.hasKey }, store_step: f.storeStep, skills: skillStatus(dir) };
112
+ let tests = [];
56
113
  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,
114
+ doc: { ok: exit === 0, code, summary, data: { project: { kind, app_id: appId }, ...shared, pending_release: pending, tests }, next }, exit,
58
115
  });
59
116
  if (!existsSync(join(dir, "shipvo.json"))) {
60
117
  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);
@@ -79,6 +136,7 @@ export async function statusJson(appArg, fetchFn, dir = process.cwd()) {
79
136
  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
137
  }
81
138
  const releases = data.releases ?? [];
139
+ tests = await fetchTestResults(baseUrl, appId, fetchFn);
82
140
  const pending = { newest: releases.length ? Math.max(...releases.map((r) => r.release)) : null, halted: data.halted ?? null, auto_halt: data.auto_halt ?? null };
83
141
  const next = [];
84
142
  let code = "ok", exit = 0;
@@ -111,7 +169,7 @@ export async function status(argv, fetchFn = fetch) {
111
169
  const a = args(argv);
112
170
  if (argv.includes("--json")) {
113
171
  const { doc, exit } = await statusJson(a.app && a.app !== "true" ? a.app : undefined, fetchFn);
114
- console.log(JSON.stringify(doc));
172
+ emit(doc);
115
173
  return exit;
116
174
  }
117
175
  const cfg = loadConfig();
@@ -133,8 +191,11 @@ export async function status(argv, fetchFn = fetch) {
133
191
  // An older control plane has no auto_halt field: say nothing rather than a wrong "off".
134
192
  if ("auto_halt" in data)
135
193
  console.log(describeAutoHalt(data.auto_halt));
194
+ const printBands = async () => { for (const l of describeBands(await fetchBands(base, appId, cfg.shellMajor, fetchFn)))
195
+ console.log(l); };
136
196
  if (!releases.length) {
137
197
  console.log(`No telemetry yet for "${appId}".`);
198
+ await printBands();
138
199
  return 0;
139
200
  }
140
201
  const h = data.halted;
@@ -143,5 +204,8 @@ export async function status(argv, fetchFn = fetch) {
143
204
  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
205
  }
145
206
  console.log(formatStatus(releases));
207
+ for (const l of describeTests(await fetchTestResults(base, appId, fetchFn)))
208
+ console.log(l);
209
+ await printBands();
146
210
  return 0;
147
211
  }
@@ -0,0 +1,18 @@
1
+ name: Shipvo check
2
+
3
+ on:
4
+ pull_request:
5
+
6
+ permissions:
7
+ contents: read
8
+ pull-requests: write
9
+
10
+ jobs:
11
+ check:
12
+ runs-on: ubuntu-latest
13
+ timeout-minutes: 30
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ with:
17
+ fetch-depth: 0
18
+ - uses: shipvo/shipvo/actions/check@main
package/dist/test.d.ts CHANGED
@@ -42,5 +42,5 @@ export type FlowReport = {
42
42
  message: string | null;
43
43
  };
44
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>;
45
+ export declare function testCmd(argv: string[], kit?: TestKit, rawLog?: (s: string) => void, fetchFn?: typeof fetch): Promise<number>;
46
46
  export {};
package/dist/test.js CHANGED
@@ -6,6 +6,8 @@ import { execFileSync } from "node:child_process";
6
6
  import { PARITY_LIMITS } from "./explore.js";
7
7
  import { renderReportMarkdown } from "./report-md.js";
8
8
  import { pathToFileURL } from "node:url";
9
+ import { emit, envelope } from "./json-out.js";
10
+ import { loadConfig, publishToken } from "./config.js";
9
11
  /** The same step as a plain-language line (only the target of a tap or type step is ever rewritten). */
10
12
  export function stepLine(s) {
11
13
  const bad = (v) => v !== undefined && /["\r\n]/.test(v);
@@ -80,41 +82,72 @@ function flagValue(argv, name) {
80
82
  return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : undefined;
81
83
  }
82
84
  /** Runs the suite. Returns the exit code: 0 when every step passed (or every failure was repaired and written), 1 otherwise. */
83
- export async function testCmd(argv, kit, rawLog = console.log) {
85
+ export async function testCmd(argv, kit, rawLog, fetchFn = fetch) {
86
+ const json = argv.includes("--json");
87
+ // With --json, stdout holds only the document: progress goes to stderr.
88
+ const out = rawLog ?? (json ? (s) => console.error(s) : console.log);
89
+ const flows = [];
90
+ const fail = (msg, next, code = "usage") => {
91
+ if (json)
92
+ emit(envelope(code, msg, { flows }, next, 2));
93
+ else
94
+ console.error(msg);
95
+ return 2;
96
+ };
84
97
  const repair = argv.includes("--repair");
85
98
  const write = argv.includes("--write");
86
99
  if (write && !repair) {
87
- console.error("--write only works with --repair");
88
- return 2;
100
+ return fail("--write only works with --repair", [{ command: "shipvo test --repair --write", why: "--write needs --repair." }]);
89
101
  }
90
102
  const reportPath = flagValue(argv, "--report");
91
103
  const release = flagValue(argv, "--release");
92
104
  const reportMdPath = flagValue(argv, "--report-md");
105
+ const platform = flagValue(argv, "--platform") ?? "android";
106
+ const upload = argv.includes("--upload");
107
+ if (platform !== "android" && platform !== "ios") {
108
+ return fail('--platform must be "android" or "ios"', [{ command: "shipvo test --platform ios", why: "Pass android or ios." }]);
109
+ }
110
+ if (upload && !reportPath) {
111
+ return fail("--upload needs --report <file>", [{ command: "shipvo test --report report.json --release 12 --upload", why: "Upload sends the report file." }]);
112
+ }
113
+ if (upload && !release) {
114
+ return fail("--upload needs --release <number>", [{ command: "shipvo test --report report.json --release 12 --upload", why: "Results are stored per release." }]);
115
+ }
93
116
  if (argv.includes("--report") && !reportPath) {
94
- console.error("--report needs a file name");
95
- return 2;
117
+ return fail("--report needs a file name", [{ command: "shipvo test --report report.json", why: "Pass a file name." }]);
96
118
  }
97
119
  if (argv.includes("--report-md") && !reportMdPath) {
98
- console.error("--report-md needs a file name");
99
- return 2;
120
+ return fail("--report-md needs a file name", [{ command: "shipvo test --report-md report.md", why: "Pass a file name." }]);
100
121
  }
101
- const valueIdx = new Set(["--report", "--report-md", "--release"].map((f) => argv.indexOf(f) + 1).filter((i) => i > 0));
122
+ const valueIdx = new Set(["--report", "--report-md", "--release", "--platform"].map((f) => argv.indexOf(f) + 1).filter((i) => i > 0));
102
123
  const given = argv.filter((a, i) => !a.startsWith("--") && !valueIdx.has(i));
103
- const flows = [];
104
124
  const files = given.length ? given : findStepFiles("tests");
105
125
  if (!files.length) {
106
- console.error("No tests found. Put plain-language steps in tests/*.steps, or pass file names.");
107
- return 2;
126
+ return fail("No tests found. Put plain-language steps in tests/*.steps, or pass file names.", [{ command: "shipvo test tests/example.steps", why: "Pass a steps file, or add tests/*.steps." }]);
127
+ }
128
+ let k;
129
+ try {
130
+ k = kit ?? (await loadKit());
131
+ }
132
+ catch (e) {
133
+ return fail(e.message, [{ command: "npm i -D @shipvo/test", why: "shipvo test needs @shipvo/test in the app." }]);
108
134
  }
109
- const k = kit ?? (await loadKit());
110
135
  const scrub = (t) => k.redact?.(t) ?? t;
111
136
  // Every line goes through scrub: screen text and errors can echo a secret.
112
- const log = (s) => rawLog(scrub(s));
137
+ const log = (s) => out(scrub(s));
113
138
  let failed = 0, repaired = 0, unresolved = 0;
114
139
  for (const file of files) {
115
- const text = readFileSync(file, "utf8");
116
- const steps = k.parseSteps(text);
117
- const phone = await k.openApp();
140
+ let text, steps, phone;
141
+ try {
142
+ text = readFileSync(file, "utf8");
143
+ steps = k.parseSteps(text);
144
+ phone = await k.openApp();
145
+ }
146
+ catch (e) {
147
+ if (!json)
148
+ throw e;
149
+ return fail(`${file}: ${scrub(e.message.split("\n")[0])}`, [{ command: "shipvo doctor --json", why: "Check the steps file path, the step syntax and the simulator." }], "error");
150
+ }
118
151
  const edits = new Map();
119
152
  const flow = { name: file, status: "passed", steps, failedStep: null, message: null };
120
153
  flows.push(flow);
@@ -176,7 +209,7 @@ export async function testCmd(argv, kit, rawLog = console.log) {
176
209
  else
177
210
  log(failed ? `${failed} failed` : "all steps passed");
178
211
  if (reportPath || reportMdPath) {
179
- const report = { app: appIdFromConfig(), commit: gitCommit(), release: release ?? null, time: new Date().toISOString(), parityLimits: PARITY_LIMITS, flows };
212
+ const report = { app: appIdFromConfig(), commit: gitCommit(), release: release ?? null, platform, time: new Date().toISOString(), parityLimits: PARITY_LIMITS, flows };
180
213
  if (reportPath) {
181
214
  writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n");
182
215
  log(`wrote report ${reportPath}`);
@@ -186,5 +219,35 @@ export async function testCmd(argv, kit, rawLog = console.log) {
186
219
  log(`wrote Markdown report ${reportMdPath}`);
187
220
  }
188
221
  }
189
- return failed === 0 || (write && repaired === failed) ? 0 : 1;
222
+ let uploadError = null;
223
+ if (upload && reportPath) {
224
+ const token = publishToken();
225
+ try {
226
+ if (!token)
227
+ throw new Error("No publish token. Set SHIPVO_PUBLISH_TOKEN, or run shipvo app create.");
228
+ const base = loadConfig().controlPlane.replace(/\/$/, "");
229
+ const appId = appIdFromConfig();
230
+ if (!appId)
231
+ throw new Error("No appId in shipvo.json.");
232
+ const r = await fetchFn(`${base}/r/${encodeURIComponent(appId)}/${encodeURIComponent(release)}/tests`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: readFileSync(reportPath, "utf8") });
233
+ if (!r.ok) {
234
+ const e = (await r.json().catch(() => null));
235
+ throw new Error(e?.message ?? e?.error ?? `HTTP ${r.status}`);
236
+ }
237
+ log(`uploaded report for release ${release} (${platform})`);
238
+ }
239
+ catch (e) {
240
+ uploadError = e.message;
241
+ log(`Could not upload the report: ${uploadError}`);
242
+ }
243
+ }
244
+ const exit = failed === 0 || (write && repaired === failed) ? 0 : 1;
245
+ if (json) {
246
+ const bad = flows.filter((f) => f.status === "failed");
247
+ const data = { uploaded: upload ? !uploadError : undefined, upload_error: uploadError ?? undefined, flows: flows.map((f) => ({ name: f.name, status: f.status, steps: f.steps.length, failedStep: f.failedStep, message: f.message })), failed, repaired, unresolved };
248
+ emit(exit === 0
249
+ ? envelope("ok", failed ? `${failed} failed step(s) repaired and written.` : `All ${flows.length} flow(s) passed.`, data, [{ command: "shipvo explore --json", why: "Walk the app for crashes and accessibility problems." }], 0)
250
+ : envelope("tests_failed", `${bad.length} of ${flows.length} flow(s) failed.`, data, [repair ? { command: "shipvo test --repair --write", why: "Write the proposed repairs to the steps files." } : { command: `shipvo test ${bad[0]?.name ?? ""} --repair --json`.replace(" ", " "), why: "Show a diff that fixes broken steps." }], 1));
251
+ }
252
+ return exit;
190
253
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -12,8 +12,8 @@
12
12
  "@noble/ed25519": "^2.1.0",
13
13
  "@noble/hashes": "^1.5.0",
14
14
  "plist": "^3.1.1",
15
- "@shipvo/schema": "0.0.5",
16
- "@shipvo/sim": "^0.1.5",
15
+ "@shipvo/schema": "0.0.6",
16
+ "@shipvo/sim": "^0.1.6",
17
17
  "@shipvo/store": "^0.0.2"
18
18
  },
19
19
  "devDependencies": {
@@ -52,6 +52,7 @@
52
52
  "directory": "packages/cli"
53
53
  },
54
54
  "files": [
55
+ "skills",
55
56
  "dist",
56
57
  "README.md"
57
58
  ],
@@ -0,0 +1,31 @@
1
+ ---
2
+ name: shipvo-cli
3
+ description: Use the shipvo command line. Use when the project depends on @shipvo/cli and you need the right command to set up, check status, publish, roll back or configure agents.
4
+ ---
5
+
6
+ # @shipvo/cli
7
+
8
+ Run from the app's root folder. Use `npx -y @shipvo/cli <command>` if `shipvo` is not on the path.
9
+
10
+ When unsure, run these two:
11
+
12
+ ```bash
13
+ shipvo doctor
14
+ shipvo status --json
15
+ ```
16
+
17
+ `status --json` prints `ok`, `code`, `summary`, `data` and `next`. `next` is a list of `{ command, why }`: run those. Exit codes: 0 ok, 1 action needed, 2 error. `data.skills` lists the package skills and whether each is synced.
18
+
19
+ ## Common commands
20
+
21
+ ```bash
22
+ shipvo init --dry-run # print the plan, change nothing
23
+ shipvo init --yes # apply it
24
+ shipvo sim # simulator on http://localhost:4545
25
+ shipvo publish --dry-run # check a release before you publish
26
+ shipvo rollback --to <release>
27
+ shipvo agents setup # AGENTS.md, .mcp.json and package skills
28
+ shipvo skills sync # only copy package skills
29
+ ```
30
+
31
+ Add `--dry-run` to `agents setup` and `skills sync` to see what would change. Both are safe to run again. Run `shipvo help` for every command.