@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
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Returns null when either report is missing: there is nothing to compare. */
|
|
2
|
+
export function diffReports(base, pr) {
|
|
3
|
+
if (!base || !pr)
|
|
4
|
+
return null;
|
|
5
|
+
const before = new Map();
|
|
6
|
+
for (const f of base.flows ?? [])
|
|
7
|
+
before.set(f.name ?? "unnamed flow", f);
|
|
8
|
+
const out = { regressions: [], fixed: [], stillFailing: [], newFlows: [] };
|
|
9
|
+
for (const f of pr.flows ?? []) {
|
|
10
|
+
const old = before.get(f.name ?? "unnamed flow");
|
|
11
|
+
const failed = f.status === "failed";
|
|
12
|
+
if (!old) {
|
|
13
|
+
out.newFlows.push(f);
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const wasFailed = old.status === "failed";
|
|
17
|
+
if (failed && !wasFailed)
|
|
18
|
+
out.regressions.push(f);
|
|
19
|
+
else if (!failed && wasFailed)
|
|
20
|
+
out.fixed.push(f);
|
|
21
|
+
else if (failed && wasFailed)
|
|
22
|
+
out.stillFailing.push(f);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Route for a screen name from `changedScreens`. Dynamic screens (`[id]`) and layouts are skipped. */
|
|
2
|
+
export declare function screenRoute(name: string): string | null;
|
|
3
|
+
/** The screens to capture or walk: routable ones only, first four. Shared by `ci shots` and `explore --changed-from`. */
|
|
4
|
+
export declare function routableScreens(screens: string[]): (readonly [string, string])[];
|
|
5
|
+
export type Shoot = (route: string) => Promise<Uint8Array>;
|
|
6
|
+
/** Capture up to four screens. A screen that fails is reported and skipped. Returns the files written. */
|
|
7
|
+
export declare function captureScreens(screens: string[], shoot: Shoot, outDir: string, log?: (s: string) => void): Promise<string[]>;
|
|
8
|
+
/** `shipvo ci shots --changed-from <sha> [--sim url] [--out dir]`. Never fails the check. */
|
|
9
|
+
export declare function shotsCmd(argv: string[]): Promise<number>;
|
package/dist/ci-shots.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// `shipvo ci shots`: one simulator screenshot per changed screen, written to shipvo-shots/ for the check comment.
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { changedFiles, changedScreens } from "./ci-comment.js";
|
|
5
|
+
const MAX = 4;
|
|
6
|
+
/** Route for a screen name from `changedScreens`. Dynamic screens (`[id]`) and layouts are skipped. */
|
|
7
|
+
export function screenRoute(name) {
|
|
8
|
+
if (/\[|\(|(?:^|\/)_/.test(name) || name === "+not-found")
|
|
9
|
+
return null;
|
|
10
|
+
return name === "home" ? "/" : `/${name}`;
|
|
11
|
+
}
|
|
12
|
+
/** The screens to capture or walk: routable ones only, first four. Shared by `ci shots` and `explore --changed-from`. */
|
|
13
|
+
export function routableScreens(screens) {
|
|
14
|
+
return screens.map((s) => [s, screenRoute(s)]).filter((r) => r[1] !== null).slice(0, MAX);
|
|
15
|
+
}
|
|
16
|
+
/** Capture up to four screens. A screen that fails is reported and skipped. Returns the files written. */
|
|
17
|
+
export async function captureScreens(screens, shoot, outDir, log = console.log) {
|
|
18
|
+
const written = [];
|
|
19
|
+
const routes = routableScreens(screens);
|
|
20
|
+
if (!routes.length)
|
|
21
|
+
return written;
|
|
22
|
+
mkdirSync(outDir, { recursive: true });
|
|
23
|
+
for (const [name, route] of routes) {
|
|
24
|
+
try {
|
|
25
|
+
const file = join(outDir, `${name.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "") || "screen"}.png`);
|
|
26
|
+
writeFileSync(file, await shoot(route));
|
|
27
|
+
written.push(file);
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
log(`screenshot of ${name} skipped: ${e instanceof Error ? e.message : e}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return written;
|
|
34
|
+
}
|
|
35
|
+
/** `shipvo ci shots --changed-from <sha> [--sim url] [--out dir]`. Never fails the check. */
|
|
36
|
+
export async function shotsCmd(argv) {
|
|
37
|
+
const val = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; };
|
|
38
|
+
const base = val("--changed-from"), sim = (val("--sim") ?? "http://localhost:4545").replace(/\/$/, ""), out = val("--out") ?? "shipvo-shots";
|
|
39
|
+
if (!base) {
|
|
40
|
+
console.error("shipvo ci shots: --changed-from <sha> is required");
|
|
41
|
+
return 2;
|
|
42
|
+
}
|
|
43
|
+
let screens = [];
|
|
44
|
+
try {
|
|
45
|
+
screens = changedScreens(changedFiles(base));
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
console.error(`could not read the diff: ${e instanceof Error ? e.message : e}`);
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
const post = async (path, body) => {
|
|
52
|
+
const r = await fetch(sim + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(60000) });
|
|
53
|
+
if (!r.ok)
|
|
54
|
+
throw new Error(`simulator ${path} -> ${r.status}`);
|
|
55
|
+
return (await r.json());
|
|
56
|
+
};
|
|
57
|
+
const { loadConfig } = await import("./config.js");
|
|
58
|
+
let appId;
|
|
59
|
+
try {
|
|
60
|
+
appId = loadConfig().appId;
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
console.error(`could not read shipvo.json: ${e instanceof Error ? e.message : e}`);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
const session = `ci-shots-${Date.now().toString(36)}`;
|
|
67
|
+
const files = await captureScreens(screens, async (route) => {
|
|
68
|
+
const open = await post("/session", { session, app: appId, width: 390, height: 844 });
|
|
69
|
+
if (open.ok === false)
|
|
70
|
+
throw new Error(`could not open the app: ${open.error ?? "unknown error"}`);
|
|
71
|
+
const nav = await post("/act", { session, action: "open_url", url: route });
|
|
72
|
+
if (!nav.ok)
|
|
73
|
+
throw new Error(`could not open ${route}: ${nav.error ?? "unknown error"}`);
|
|
74
|
+
const shot = await post("/act", { session, action: "screenshot", format: "png", inline: true });
|
|
75
|
+
if (!shot.ok || !shot.data)
|
|
76
|
+
throw new Error(`screenshot failed: ${shot.error ?? "no data"}`);
|
|
77
|
+
return new Uint8Array(Buffer.from(shot.data, "base64"));
|
|
78
|
+
}, out, (s) => console.error(s));
|
|
79
|
+
console.log(`Captured ${files.length} screenshot${files.length === 1 ? "" : "s"} in ${out}/.`);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
package/dist/ci.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
export declare const WORKFLOW_PATH: string;
|
|
2
|
+
export declare const CHECK_WORKFLOW_PATH: string;
|
|
3
|
+
/** The Shipvo check workflow. A test asserts this equals `templates/github-action/shipvo-check.yml`. */
|
|
4
|
+
export declare const CHECK_WORKFLOW = "name: Shipvo check\n\non:\n pull_request:\n\npermissions:\n contents: read\n pull-requests: write\n\njobs:\n check:\n runs-on: ubuntu-latest\n timeout-minutes: 30\n steps:\n - uses: actions/checkout@v4\n with:\n fetch-depth: 0\n - uses: shipvo/shipvo/actions/check@main\n";
|
|
5
|
+
type Run = (argv: string[]) => Promise<number>;
|
|
6
|
+
/** `shipvo ci check`: run `shipvo test`, then `shipvo explore` (so the report exists even when a flow fails).
|
|
7
|
+
* Fails on a test regression or a crash. Exploration findings alone do not fail it. */
|
|
8
|
+
export declare function check(runTest?: Run, runExplore?: Run, changedFrom?: string): Promise<number>;
|
|
2
9
|
/** `shipvo ci init --framework capacitor [--dry-run] [--force]`. Returns the exit code. */
|
|
3
10
|
export declare function ci(argv: string[], cwd?: string): Promise<number>;
|
|
11
|
+
export {};
|
package/dist/ci.js
CHANGED
|
@@ -2,7 +2,46 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { args } from "./config.js";
|
|
5
|
+
import { explore } from "./explore.js";
|
|
6
|
+
import { testCmd } from "./test.js";
|
|
7
|
+
import { commentCmd } from "./ci-comment.js";
|
|
8
|
+
import { shotsCmd } from "./ci-shots.js";
|
|
5
9
|
export const WORKFLOW_PATH = join(".github", "workflows", "shipvo-build.yml");
|
|
10
|
+
export const CHECK_WORKFLOW_PATH = join(".github", "workflows", "shipvo-check.yml");
|
|
11
|
+
/** The Shipvo check workflow. A test asserts this equals `templates/github-action/shipvo-check.yml`. */
|
|
12
|
+
export const CHECK_WORKFLOW = `name: Shipvo check
|
|
13
|
+
|
|
14
|
+
on:
|
|
15
|
+
pull_request:
|
|
16
|
+
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
pull-requests: write
|
|
20
|
+
|
|
21
|
+
jobs:
|
|
22
|
+
check:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
timeout-minutes: 30
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v4
|
|
27
|
+
with:
|
|
28
|
+
fetch-depth: 0
|
|
29
|
+
- uses: shipvo/shipvo/actions/check@main
|
|
30
|
+
`;
|
|
31
|
+
/** `shipvo ci check`: run `shipvo test`, then `shipvo explore` (so the report exists even when a flow fails).
|
|
32
|
+
* Fails on a test regression or a crash. Exploration findings alone do not fail it. */
|
|
33
|
+
export async function check(runTest = testCmd, runExplore = explore, changedFrom) {
|
|
34
|
+
const t = await runTest(["--report", "shipvo-report.json"]);
|
|
35
|
+
let e;
|
|
36
|
+
try {
|
|
37
|
+
e = await runExplore(["--sim", "http://localhost:4545", ...(changedFrom ? ["--changed-from", changedFrom] : [])]);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
41
|
+
e = 1;
|
|
42
|
+
}
|
|
43
|
+
return t !== 0 ? t : e;
|
|
44
|
+
}
|
|
6
45
|
/** Find the template: next to the built CLI (npm), or in the repo's `templates/` (development). */
|
|
7
46
|
function templatePath(framework) {
|
|
8
47
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -15,28 +54,47 @@ function templatePath(framework) {
|
|
|
15
54
|
/** `shipvo ci init --framework capacitor [--dry-run] [--force]`. Returns the exit code. */
|
|
16
55
|
export async function ci(argv, cwd = process.cwd()) {
|
|
17
56
|
const [sub, ...rest] = argv;
|
|
57
|
+
if (sub === "check") {
|
|
58
|
+
const i = rest.indexOf("--changed-from");
|
|
59
|
+
const v = i >= 0 ? rest[i + 1] : undefined;
|
|
60
|
+
if (i >= 0 && (v === undefined || v.startsWith("-"))) {
|
|
61
|
+
console.error("shipvo ci check: --changed-from needs a commit sha");
|
|
62
|
+
return 2;
|
|
63
|
+
}
|
|
64
|
+
return check(testCmd, explore, v);
|
|
65
|
+
}
|
|
66
|
+
if (sub === "shots")
|
|
67
|
+
return shotsCmd(rest);
|
|
68
|
+
if (sub === "comment")
|
|
69
|
+
return commentCmd(rest);
|
|
18
70
|
if (sub !== "init") {
|
|
19
|
-
console.error("Usage: shipvo ci init --framework capacitor [--dry-run] [--force]");
|
|
71
|
+
console.error("Usage: shipvo ci init --check | --framework capacitor [--dry-run] [--force]\n shipvo ci check");
|
|
20
72
|
return 2;
|
|
21
73
|
}
|
|
22
74
|
const a = args(rest);
|
|
23
|
-
|
|
75
|
+
const isCheck = a.check !== undefined && a.check !== "false";
|
|
76
|
+
if (!isCheck && a.framework !== "capacitor") {
|
|
24
77
|
console.error("shipvo ci init: --framework capacitor is the only framework with a template so far");
|
|
25
78
|
return 2;
|
|
26
79
|
}
|
|
27
|
-
const body = readFileSync(templatePath("capacitor"), "utf8");
|
|
28
|
-
const
|
|
80
|
+
const body = isCheck ? CHECK_WORKFLOW : readFileSync(templatePath("capacitor"), "utf8");
|
|
81
|
+
const path = isCheck ? CHECK_WORKFLOW_PATH : WORKFLOW_PATH;
|
|
82
|
+
const target = resolve(cwd, path);
|
|
29
83
|
if (a["dry-run"] !== undefined && a["dry-run"] !== "false") {
|
|
30
|
-
console.log(`Would write ${
|
|
84
|
+
console.log(`Would write ${path}${existsSync(target) ? " (file exists; --force needed)" : ""}. Nothing was written.\n`);
|
|
31
85
|
console.log(body);
|
|
32
86
|
return 0;
|
|
33
87
|
}
|
|
34
88
|
if (existsSync(target) && (a.force === undefined || a.force === "false")) {
|
|
35
|
-
console.error(`${
|
|
89
|
+
console.error(`${path} already exists. Use --force to overwrite it.`);
|
|
36
90
|
return 1;
|
|
37
91
|
}
|
|
38
92
|
mkdirSync(dirname(target), { recursive: true });
|
|
39
93
|
writeFileSync(target, body);
|
|
94
|
+
if (isCheck) {
|
|
95
|
+
console.log(`Wrote ${path}. Commit it and open a pull request.`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
40
98
|
console.log(`Wrote ${WORKFLOW_PATH}. Add the secrets listed in docs/capacitor.md, then run the "Shipvo build" workflow.`);
|
|
41
99
|
return 0;
|
|
42
100
|
}
|
package/dist/cli.js
CHANGED
|
@@ -8,17 +8,22 @@ import { publish, rollback } from "./publish.js";
|
|
|
8
8
|
import { promote } from "./promote.js";
|
|
9
9
|
import { autoHalt } from "./autohalt.js";
|
|
10
10
|
import { explore } from "./explore.js";
|
|
11
|
+
import { lumi } from "./lumi.js";
|
|
12
|
+
import { smoke } from "./smoke.js";
|
|
11
13
|
import { sim } from "./sim.js";
|
|
12
14
|
import { preview } from "./preview.js";
|
|
13
15
|
import { run } from "./run.js";
|
|
14
16
|
import { keys, login, mcp } from "./login.js";
|
|
15
17
|
import { verifyPointer } from "./verify.js";
|
|
16
18
|
import { signingKey } from "./signing.js";
|
|
19
|
+
import { keysJson } from "./keys-json.js";
|
|
17
20
|
import { appCmd } from "./app.js";
|
|
18
21
|
import { status } from "./status.js";
|
|
19
22
|
import { agentsCmd } from "./agents.js";
|
|
20
23
|
import { doctor } from "./doctor.js";
|
|
21
24
|
import { audit } from "./audit.js";
|
|
25
|
+
import { migrate } from "./migrate.js";
|
|
26
|
+
import { approvals } from "./approvals.js";
|
|
22
27
|
import { store } from "./store.js";
|
|
23
28
|
import { ci } from "./ci.js";
|
|
24
29
|
import { easCmd } from "./eas.js";
|
|
@@ -36,6 +41,12 @@ try {
|
|
|
36
41
|
case "explore":
|
|
37
42
|
process.exitCode = await explore(rest);
|
|
38
43
|
break;
|
|
44
|
+
case "lumi":
|
|
45
|
+
process.exitCode = await lumi(rest);
|
|
46
|
+
break;
|
|
47
|
+
case "smoke":
|
|
48
|
+
process.exitCode = await smoke(rest);
|
|
49
|
+
break;
|
|
39
50
|
case "run":
|
|
40
51
|
process.exitCode = await run(rest);
|
|
41
52
|
break;
|
|
@@ -66,9 +77,18 @@ try {
|
|
|
66
77
|
case "agents":
|
|
67
78
|
process.exitCode = await agentsCmd(rest);
|
|
68
79
|
break;
|
|
80
|
+
case "skills":
|
|
81
|
+
process.exitCode = rest[0] === "sync" ? await agentsCmd(["skills-sync", ...rest.slice(1)]) : (console.error("Usage: shipvo skills sync [--dry-run] [--json]"), 2);
|
|
82
|
+
break;
|
|
69
83
|
case "audit":
|
|
70
84
|
await audit(rest);
|
|
71
85
|
break;
|
|
86
|
+
case "migrate":
|
|
87
|
+
process.exitCode = await migrate(rest);
|
|
88
|
+
break;
|
|
89
|
+
case "approvals":
|
|
90
|
+
process.exitCode = await approvals(rest);
|
|
91
|
+
break;
|
|
72
92
|
case "test":
|
|
73
93
|
process.exitCode = await testCmd(rest);
|
|
74
94
|
break;
|
|
@@ -85,7 +105,9 @@ try {
|
|
|
85
105
|
await appCmd(rest);
|
|
86
106
|
break;
|
|
87
107
|
case "keys":
|
|
88
|
-
if (rest
|
|
108
|
+
if (rest.includes("--json"))
|
|
109
|
+
await keysJson(rest);
|
|
110
|
+
else if (rest[0] === "export" || rest[0] === "import" || rest[0] === "rotate")
|
|
89
111
|
await signingKey(rest);
|
|
90
112
|
else
|
|
91
113
|
await keys();
|
package/dist/config.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type ShipvoConfig = {
|
|
|
9
9
|
publicKey: string;
|
|
10
10
|
/** Hermes bytecode version(s) the app's shells run. If set, `shipvo publish` refuses bundles that differ. */
|
|
11
11
|
hermesBytecodeVersion?: number | number[];
|
|
12
|
+
/** Hermes engine build the app's shells run, such as "250829098.0.10". If set, `shipvo publish` refuses to run with another hermes-compiler. */
|
|
13
|
+
hermesEngine?: string;
|
|
12
14
|
/** What AI agents may do through `@shipvo/mcp` owner commands. "ask" (default): irreversible commands need confirm: true. "deny": they are refused. "allow": no confirm step (the owner must set it). */
|
|
13
15
|
agentPolicy?: "ask" | "deny" | "allow";
|
|
14
16
|
/** Owner commands agents may never run. */
|
|
@@ -36,11 +38,22 @@ export type ShipvoConfig = {
|
|
|
36
38
|
};
|
|
37
39
|
};
|
|
38
40
|
export declare function loadConfig(dir?: string): ShipvoConfig;
|
|
41
|
+
/** Flags that never take a value, so the word after one stays a positional. Add new boolean flags here. */
|
|
42
|
+
export declare const BOOLEAN_FLAGS: ReadonlySet<string>;
|
|
39
43
|
export declare const args: (argv: string[]) => Record<string, string | undefined>;
|
|
40
44
|
/** Signing seed: SHIPVO_SIGNING_SEED (PURENATIVE_SIGNING_SEED still accepted) or a *_FILE path. */
|
|
41
45
|
export declare function seed(): string;
|
|
42
46
|
/** The publish token `shipvo app create` issued: SHIPVO_PUBLISH_TOKEN (CI) or .shipvo/publish-token. */
|
|
43
47
|
export declare function publishToken(): string | null;
|
|
48
|
+
/** True when publish/rollback/promote have somewhere real to read and write: Cloudflare KV or a publish token. */
|
|
49
|
+
export declare function hasStore(): boolean;
|
|
50
|
+
/** A read-only store over the files `shipvo publish --dry-run` wrote. Lets `promote --dry-run` and `rollback --dry-run` run with no login. Writes throw. */
|
|
51
|
+
export declare function offlineKv(dir?: string): {
|
|
52
|
+
get(k: string): Promise<string | null>;
|
|
53
|
+
getBytes(k: string): Promise<Uint8Array<ArrayBuffer> | null>;
|
|
54
|
+
keys(prefix: string): Promise<string[]>;
|
|
55
|
+
put(k: string, _body: BodyInit, _type: string): Promise<void>;
|
|
56
|
+
};
|
|
44
57
|
/** Where publish/rollback write: your own Cloudflare KV (self-hosted control plane) or the hosted control plane with a publish token. */
|
|
45
58
|
export declare function kv(cfg: ShipvoConfig): {
|
|
46
59
|
get(k: string): Promise<string | null>;
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
3
3
|
export function loadConfig(dir = process.cwd()) {
|
|
4
4
|
const p = join(dir, "shipvo.json");
|
|
5
5
|
if (!existsSync(p))
|
|
@@ -10,7 +10,9 @@ export function loadConfig(dir = process.cwd()) {
|
|
|
10
10
|
throw new Error(`shipvo.json: "${k}" is required`);
|
|
11
11
|
return { shellMajor: 1, name: c.appId, ...c };
|
|
12
12
|
}
|
|
13
|
-
|
|
13
|
+
/** Flags that never take a value, so the word after one stays a positional. Add new boolean flags here. */
|
|
14
|
+
export const BOOLEAN_FLAGS = new Set(["json", "skip-build", "fail-on-findings", "no-open"]);
|
|
15
|
+
const argPairs = (argv) => Object.fromEntries(argv.reduce((a, x, i, all) => (x.startsWith("--") ? [...a, [x.slice(2), BOOLEAN_FLAGS.has(x.slice(2)) || all[i + 1] === undefined || all[i + 1].startsWith("--") ? "true" : all[i + 1]]] : a), []));
|
|
14
16
|
export const args = (argv) => argPairs(argv);
|
|
15
17
|
/** Signing seed: SHIPVO_SIGNING_SEED (PURENATIVE_SIGNING_SEED still accepted) or a *_FILE path. */
|
|
16
18
|
export function seed() {
|
|
@@ -94,6 +96,44 @@ function hostedKv(cfg, token) {
|
|
|
94
96
|
},
|
|
95
97
|
};
|
|
96
98
|
}
|
|
99
|
+
/** True when publish/rollback/promote have somewhere real to read and write: Cloudflare KV or a publish token. */
|
|
100
|
+
export function hasStore() {
|
|
101
|
+
const NS = process.env.SHIPVO_KV_ID || process.env.PURENATIVE_KV_ID || readConfigKvId();
|
|
102
|
+
return !!(process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_API_TOKEN && NS) || !!publishToken();
|
|
103
|
+
}
|
|
104
|
+
function readConfigKvId() { try {
|
|
105
|
+
return loadConfig().kv?.namespaceId;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
} }
|
|
110
|
+
/** A read-only store over the files `shipvo publish --dry-run` wrote. Lets `promote --dry-run` and `rollback --dry-run` run with no login. Writes throw. */
|
|
111
|
+
export function offlineKv(dir = ".shipvo/dry-run") {
|
|
112
|
+
const root = resolve(dir);
|
|
113
|
+
const file = (k) => { const f = resolve(root, k); return f.startsWith(root + sep) ? f : null; };
|
|
114
|
+
const walk = (d) => readdirSync(d).flatMap((n) => (statSync(join(d, n)).isDirectory() ? walk(join(d, n)) : [join(d, n)]));
|
|
115
|
+
return {
|
|
116
|
+
async get(k) { const f = file(k); try {
|
|
117
|
+
return f ? readFileSync(f, "utf8") : null;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
} },
|
|
122
|
+
async getBytes(k) { const f = file(k); try {
|
|
123
|
+
return f ? new Uint8Array(readFileSync(f)) : null;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
} },
|
|
128
|
+
async keys(prefix) { try {
|
|
129
|
+
return walk(root).map((f) => relative(root, f).split(sep).join("/")).filter((k) => k.startsWith(prefix));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return [];
|
|
133
|
+
} },
|
|
134
|
+
async put(k, _body, _type) { throw new Error(`offline dry run cannot write ${k}`); },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
97
137
|
/** Where publish/rollback write: your own Cloudflare KV (self-hosted control plane) or the hosted control plane with a publish token. */
|
|
98
138
|
export function kv(cfg) {
|
|
99
139
|
const ACC = process.env.CLOUDFLARE_ACCOUNT_ID, TOKEN = process.env.CLOUDFLARE_API_TOKEN;
|
package/dist/doctor.js
CHANGED
|
@@ -5,7 +5,8 @@ import { execSync } from "node:child_process";
|
|
|
5
5
|
import { portFree } from "./sim.js";
|
|
6
6
|
import { args as parseArgs } from "./config.js";
|
|
7
7
|
import { b64, keyFromSeed } from "./index.js";
|
|
8
|
-
import { parseDeclared } from "./hermes.js";
|
|
8
|
+
import { installedHermesEngine, parseDeclared, parseEngine } from "./hermes.js";
|
|
9
|
+
import { emit, envelope } from "./json-out.js";
|
|
9
10
|
const defaultDeps = () => ({
|
|
10
11
|
cwd: process.cwd(),
|
|
11
12
|
env: process.env,
|
|
@@ -169,6 +170,7 @@ export async function runDoctorChecks(overrides = {}) {
|
|
|
169
170
|
results.push({ name: keyName, ok: (mode & 0o077) === 0, fix: `run \`chmod 600 ${keyPath}\` (it is mode ${mode.toString(8).padStart(3, "0")})` });
|
|
170
171
|
}
|
|
171
172
|
let healthOk = false;
|
|
173
|
+
let healthNote;
|
|
172
174
|
let healthFix = "check shipvo.json controlPlane and your network access (5 second timeout)";
|
|
173
175
|
if (!deps.offline && cfg?.controlPlane && typeof cfg.controlPlane === "string") {
|
|
174
176
|
try {
|
|
@@ -177,8 +179,10 @@ export async function runDoctorChecks(overrides = {}) {
|
|
|
177
179
|
healthFix = `/health answered HTTP ${res.status}, expected 200; check the control plane URL and deploy`;
|
|
178
180
|
else {
|
|
179
181
|
try {
|
|
180
|
-
await res.json();
|
|
182
|
+
const j = await res.json();
|
|
181
183
|
healthOk = true;
|
|
184
|
+
if (typeof j?.telemetry_dropped_today === "number")
|
|
185
|
+
healthNote = `telemetry_dropped_today: ${j.telemetry_dropped_today}${j.telemetry_dropped_today > 0 ? " (D1 refused telemetry writes; see docs/self-hosting.md)" : ""}`;
|
|
182
186
|
}
|
|
183
187
|
catch {
|
|
184
188
|
healthFix = "/health answered 200 but not JSON; controlPlane may point at the wrong service";
|
|
@@ -192,7 +196,7 @@ export async function runDoctorChecks(overrides = {}) {
|
|
|
192
196
|
if (deps.offline)
|
|
193
197
|
results.push({ name: "control plane /health returns JSON", ok: true, fix: "none", note: skipped });
|
|
194
198
|
else
|
|
195
|
-
results.push({ name: "control plane /health returns JSON", ok: healthOk, fix: !cfg ? needCfg : healthFix });
|
|
199
|
+
results.push({ name: "control plane /health returns JSON", ok: healthOk, fix: !cfg ? needCfg : healthFix, ...(healthOk && healthNote ? { note: healthNote } : {}) });
|
|
196
200
|
let hermesOk = true;
|
|
197
201
|
if (cfg && Object.prototype.hasOwnProperty.call(cfg, "hermesBytecodeVersion")) {
|
|
198
202
|
try {
|
|
@@ -203,6 +207,25 @@ export async function runDoctorChecks(overrides = {}) {
|
|
|
203
207
|
}
|
|
204
208
|
}
|
|
205
209
|
results.push({ name: "hermesBytecodeVersion (if present) is set", ok: hermesOk, fix: "set hermesBytecodeVersion to a positive integer or list (for example 96 or [96, 98])" });
|
|
210
|
+
// Engine build: only shown when the project declares one or has a hermes-compiler, so older setups see no new row.
|
|
211
|
+
const installedEngine = installedHermesEngine(deps.cwd);
|
|
212
|
+
let declaredEngine, engineBad = false;
|
|
213
|
+
try {
|
|
214
|
+
declaredEngine = cfg ? parseEngine(cfg.hermesEngine) : undefined;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
engineBad = true;
|
|
218
|
+
}
|
|
219
|
+
if (engineBad)
|
|
220
|
+
results.push({ name: "Hermes engine build", ok: false, fix: "set hermesEngine in shipvo.json to an id like 250829098.0.10" });
|
|
221
|
+
else if (declaredEngine || installedEngine) {
|
|
222
|
+
const mismatch = !!declaredEngine && declaredEngine !== installedEngine;
|
|
223
|
+
results.push({
|
|
224
|
+
name: "Hermes engine build", ok: !mismatch,
|
|
225
|
+
fix: installedEngine ? `shipvo.json declares ${declaredEngine} but hermes-compiler is ${installedEngine}; install the declared build or update hermesEngine if the shell changed` : `shipvo.json declares ${declaredEngine} but hermes-compiler was not found; install it or remove hermesEngine`,
|
|
226
|
+
note: mismatch ? undefined : declaredEngine ? `${installedEngine}, matches hermesEngine` : `${installedEngine}, hermesEngine not declared in shipvo.json`,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
206
229
|
// Simulator checks only apply to projects that have a `sim` script.
|
|
207
230
|
if (!deps.skipSim && hasSimScript(deps)) {
|
|
208
231
|
const system = systemBrowserPaths(deps.platform, deps.env).find(([, p]) => deps.fileExists(p));
|
|
@@ -216,19 +239,52 @@ export async function runDoctorChecks(overrides = {}) {
|
|
|
216
239
|
}
|
|
217
240
|
return results;
|
|
218
241
|
}
|
|
242
|
+
/** The command that fixes a failed check, with the fix text as the reason. */
|
|
243
|
+
function nextFor(r) {
|
|
244
|
+
const why = `${r.name}: ${r.fix}`;
|
|
245
|
+
if (r.name === "shipvo.json parses")
|
|
246
|
+
return { command: r.fix.startsWith("run this") ? "shipvo init ." : "shipvo doctor", why };
|
|
247
|
+
if (r.name.startsWith("signing key matches"))
|
|
248
|
+
return { command: "shipvo keys import", why };
|
|
249
|
+
if (r.name === "signing key file is private")
|
|
250
|
+
return { command: r.fix.match(/`(chmod [^`]+)`/)?.[1] ?? "shipvo doctor", why };
|
|
251
|
+
if (r.name.startsWith("sim port")) {
|
|
252
|
+
const n = Number(r.name.match(/\d+/)?.[0]);
|
|
253
|
+
return { command: `shipvo doctor --json --port ${n < 65535 ? n + 1 : 4546}`, why: `${why}. Try another port.` };
|
|
254
|
+
}
|
|
255
|
+
if (r.name === "Browser for the simulator")
|
|
256
|
+
return { command: "npx playwright install chromium", why };
|
|
257
|
+
if (r.name.startsWith("control plane"))
|
|
258
|
+
return { command: "shipvo doctor --json --offline", why: `${why}. Check everything except the network.` };
|
|
259
|
+
return { command: "shipvo help", why };
|
|
260
|
+
}
|
|
219
261
|
export async function doctor(argv) {
|
|
262
|
+
const json = argv.includes("--json");
|
|
220
263
|
const a = parseArgs(argv);
|
|
221
264
|
const port = a.port === undefined ? 4545 : Number(a.port);
|
|
222
265
|
const offline = argv.includes("--offline");
|
|
223
266
|
const skipSim = argv.includes("--skip-sim");
|
|
224
267
|
const stray = argv.some((x, i) => !x.startsWith("--") && argv[i - 1] !== "--port");
|
|
225
268
|
if (stray || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
226
|
-
|
|
269
|
+
const usage = "Usage: shipvo doctor [--port <n>] [--offline] [--skip-sim] [--json]";
|
|
270
|
+
if (json)
|
|
271
|
+
emit(envelope("usage", usage, { checks: [] }, [{ command: "shipvo help", why: "See the options for doctor." }], 2));
|
|
272
|
+
else
|
|
273
|
+
console.error(usage);
|
|
227
274
|
process.exit(2);
|
|
228
275
|
}
|
|
229
276
|
const results = await runDoctorChecks({ port, offline, skipSim });
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
277
|
+
const failed = results.filter((r) => !r.ok);
|
|
278
|
+
if (json) {
|
|
279
|
+
const checks = results.map((r) => ({ name: r.name, ok: r.ok, fix: r.ok ? null : r.fix, note: r.note ?? null }));
|
|
280
|
+
emit(failed.length
|
|
281
|
+
? envelope("checks_failed", `${failed.length} of ${results.length} checks failed.`, { checks }, failed.map(nextFor), 1)
|
|
282
|
+
: envelope("ok", `All ${results.length} checks passed.`, { checks }, [{ command: "shipvo status --json", why: "See what to do next." }], 0));
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
for (const r of results)
|
|
286
|
+
console.log(`${r.ok ? `PASS ${r.name}${r.note ? ` (${r.note})` : ""}` : `FAIL ${r.name} — fix: ${r.fix}`}`);
|
|
287
|
+
}
|
|
288
|
+
if (failed.length)
|
|
233
289
|
process.exit(1);
|
|
234
290
|
}
|
|
@@ -9,7 +9,11 @@ export interface EnsurePlaywrightDeps {
|
|
|
9
9
|
log: (line: string) => void;
|
|
10
10
|
/** True when `playwright` resolves from the app. Defaults to the same lookup order as `doctor`. */
|
|
11
11
|
resolves?: (cwd: string) => boolean;
|
|
12
|
+
/** Keep stdout clean (for `--json`): the notice and the installer's output go to stderr. */
|
|
13
|
+
quiet?: boolean;
|
|
12
14
|
}
|
|
15
|
+
/** stdio for the installer: stderr for both streams when quiet, so stdout stays clean. */
|
|
16
|
+
export declare function installerStdio(quiet: boolean): "inherit" | ["ignore", 2, 2];
|
|
13
17
|
export declare function detectPackageManager(cwd: string, exists: (path: string) => boolean): PackageManager;
|
|
14
18
|
export declare function installCommand(pm: PackageManager): {
|
|
15
19
|
cmd: string;
|
|
@@ -2,6 +2,10 @@ import { execFileSync, execSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
/** stdio for the installer: stderr for both streams when quiet, so stdout stays clean. */
|
|
6
|
+
export function installerStdio(quiet) {
|
|
7
|
+
return quiet ? ["ignore", 2, 2] : "inherit";
|
|
8
|
+
}
|
|
5
9
|
const LOCKFILES = [
|
|
6
10
|
["pnpm-lock.yaml", "pnpm"],
|
|
7
11
|
["yarn.lock", "yarn"],
|
|
@@ -44,8 +48,9 @@ export function ensurePlaywright(deps) {
|
|
|
44
48
|
const { cwd } = deps;
|
|
45
49
|
const env = deps.env ?? process.env;
|
|
46
50
|
const exists = deps.exists ?? existsSync;
|
|
47
|
-
const
|
|
48
|
-
const
|
|
51
|
+
const quiet = deps.quiet ?? false;
|
|
52
|
+
const run = deps.run ?? ((cmd, args, dir) => { execFileSync(cmd, args, { cwd: dir, stdio: installerStdio(quiet) }); });
|
|
53
|
+
const log = deps.log ?? ((line) => (quiet ? console.error(line) : console.log(line)));
|
|
49
54
|
if ((deps.resolves ?? playwrightResolves)(cwd))
|
|
50
55
|
return;
|
|
51
56
|
const { cmd, args } = installCommand(detectPackageManager(cwd, exists));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Finding, type Skin } from "./explore.js";
|
|
2
|
+
/** A short name for the screen: its first heading, else its first named line, else "unnamed screen". */
|
|
3
|
+
export declare function screenName(tree: string): string;
|
|
4
|
+
export declare function findingToIssue(f: Finding, skin: Skin): {
|
|
5
|
+
title: string;
|
|
6
|
+
body: string;
|
|
7
|
+
};
|
|
8
|
+
/** Writes `<n>-<kind>.md` per distinct finding. Returns the files and the `gh issue create` lines. */
|
|
9
|
+
export declare function writeIssues(findings: Finding[], skin: Skin, dir: string): {
|
|
10
|
+
files: string[];
|
|
11
|
+
commands: string[];
|
|
12
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// `shipvo explore --issues-dir`: one ready-to-file Markdown issue per finding. Pure, so tests need no simulator.
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describeStep, PARITY_LIMITS, signature } from "./explore.js";
|
|
5
|
+
/** A short name for the screen: its first heading, else its first named line, else "unnamed screen". */
|
|
6
|
+
export function screenName(tree) {
|
|
7
|
+
const h = /^\s*- heading "((?:[^"\\]|\\.)*)"/m.exec(tree);
|
|
8
|
+
if (h) {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(`"${h[1]}"`).trim() || "unnamed screen";
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return h[1];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const any = /^\s*- [\w-]+ "((?:[^"\\]|\\.)*)"/m.exec(tree);
|
|
17
|
+
return any ? any[1] : "unnamed screen";
|
|
18
|
+
}
|
|
19
|
+
export function findingToIssue(f, skin) {
|
|
20
|
+
const title = `explore: ${f.kind} on ${screenName(f.screen)}`;
|
|
21
|
+
const body = [
|
|
22
|
+
f.message, "",
|
|
23
|
+
"Steps to reproduce:", "",
|
|
24
|
+
...f.steps.map((s, i) => `${i + 1}. ${describeStep(s)}`), "",
|
|
25
|
+
`Platform skin: ${skin}`, "",
|
|
26
|
+
"Simulator limits:", "",
|
|
27
|
+
...PARITY_LIMITS.map((p) => `- ${p}`), "",
|
|
28
|
+
].join("\n");
|
|
29
|
+
return { title, body };
|
|
30
|
+
}
|
|
31
|
+
const quote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
32
|
+
/** Writes `<n>-<kind>.md` per distinct finding. Returns the files and the `gh issue create` lines. */
|
|
33
|
+
export function writeIssues(findings, skin, dir) {
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
const files = [];
|
|
36
|
+
const commands = [];
|
|
37
|
+
const unique = findings.filter((f) => { const k = `${f.kind}|${f.message}|${signature(f.screen)}`; if (seen.has(k))
|
|
38
|
+
return false; seen.add(k); return true; });
|
|
39
|
+
if (!unique.length)
|
|
40
|
+
return { files, commands };
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
unique.forEach((f, i) => {
|
|
43
|
+
const { title, body } = findingToIssue(f, skin);
|
|
44
|
+
const file = join(dir, `${i + 1}-${f.kind}.md`);
|
|
45
|
+
writeFileSync(file, `${title}\n\n${body}`);
|
|
46
|
+
files.push(file);
|
|
47
|
+
commands.push(`gh issue create --title ${quote(title)} --body-file ${quote(file)}`);
|
|
48
|
+
});
|
|
49
|
+
return { files, commands };
|
|
50
|
+
}
|