@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.
- package/README.md +3 -0
- package/dist/adopt.js +1 -2
- package/dist/agents.d.ts +15 -0
- package/dist/agents.js +68 -9
- package/dist/approvals.d.ts +9 -0
- package/dist/approvals.js +106 -0
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +28 -11
- package/dist/autohalt.d.ts +1 -1
- package/dist/autohalt.js +28 -16
- package/dist/bsdiff.d.ts +4 -0
- package/dist/bsdiff.js +465 -0
- package/dist/ci-comment.d.ts +38 -0
- package/dist/ci-comment.js +169 -0
- package/dist/ci-compare.d.ts +10 -0
- package/dist/ci-compare.js +25 -0
- package/dist/ci-shots.d.ts +9 -0
- package/dist/ci-shots.js +81 -0
- package/dist/ci.d.ts +8 -0
- package/dist/ci.js +64 -6
- package/dist/cli.js +23 -1
- package/dist/config.d.ts +13 -0
- package/dist/config.js +43 -3
- package/dist/doctor.js +63 -7
- package/dist/ensure-playwright.d.ts +4 -0
- package/dist/ensure-playwright.js +7 -2
- package/dist/explore-issues.d.ts +12 -0
- package/dist/explore-issues.js +50 -0
- package/dist/explore.d.ts +83 -7
- package/dist/explore.js +326 -77
- package/dist/flows.d.ts +31 -0
- package/dist/flows.js +264 -0
- package/dist/help.d.ts +2 -2
- package/dist/help.js +19 -10
- package/dist/hermes.d.ts +10 -0
- package/dist/hermes.js +50 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +41 -6
- package/dist/json-out.d.ts +53 -0
- package/dist/json-out.js +91 -0
- package/dist/keys-json.d.ts +1 -0
- package/dist/keys-json.js +71 -0
- package/dist/login.js +27 -11
- package/dist/lumi.d.ts +104 -0
- package/dist/lumi.js +364 -0
- package/dist/migrate.d.ts +27 -0
- package/dist/migrate.js +521 -0
- package/dist/preview.d.ts +1 -0
- package/dist/preview.js +4 -2
- package/dist/promote.d.ts +1 -1
- package/dist/promote.js +38 -16
- package/dist/publish.d.ts +14 -2
- package/dist/publish.js +126 -62
- package/dist/qr.d.ts +9 -0
- package/dist/qr.js +213 -0
- package/dist/report-md.d.ts +1 -0
- package/dist/report-md.js +1 -1
- package/dist/rotation.d.ts +57 -0
- package/dist/rotation.js +157 -0
- package/dist/run-bridge.js +356 -2
- package/dist/run-net.d.ts +39 -0
- package/dist/run-net.js +266 -0
- package/dist/run.d.ts +23 -0
- package/dist/run.js +101 -17
- package/dist/signing.js +13 -0
- package/dist/sim.js +7 -3
- package/dist/smoke.d.ts +28 -0
- package/dist/smoke.js +117 -0
- package/dist/spawn-bin.d.ts +11 -0
- package/dist/spawn-bin.js +21 -0
- package/dist/status.d.ts +26 -4
- package/dist/status.js +69 -5
- package/dist/templates/github-action/shipvo-check.yml +18 -0
- package/dist/test.d.ts +1 -1
- package/dist/test.js +81 -18
- package/package.json +4 -3
- package/skills/shipvo-cli/SKILL.md +31 -0
package/dist/smoke.d.ts
ADDED
|
@@ -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
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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) =>
|
|
137
|
+
const log = (s) => out(scrub(s));
|
|
113
138
|
let failed = 0, repaired = 0, unresolved = 0;
|
|
114
139
|
for (const file of files) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
16
|
-
"@shipvo/sim": "^0.1.
|
|
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.
|