@shipvo/cli 0.1.4 → 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 +29 -0
- package/dist/agents.js +183 -0
- package/dist/approvals.d.ts +9 -0
- package/dist/approvals.js +106 -0
- package/dist/audit.d.ts +13 -0
- package/dist/audit.js +92 -0
- 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 +11 -0
- package/dist/ci.js +100 -0
- package/dist/cli.js +51 -1
- package/dist/config.d.ts +15 -0
- package/dist/config.js +68 -13
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +88 -8
- package/dist/eas.d.ts +29 -0
- package/dist/eas.js +258 -0
- package/dist/ensure-playwright.d.ts +25 -0
- package/dist/ensure-playwright.js +69 -0
- package/dist/explore-issues.d.ts +12 -0
- package/dist/explore-issues.js +50 -0
- package/dist/explore.d.ts +160 -0
- package/dist/explore.js +496 -0
- package/dist/flows.d.ts +31 -0
- package/dist/flows.js +264 -0
- package/dist/help.d.ts +2 -2
- package/dist/help.js +37 -7
- package/dist/hermes.d.ts +10 -0
- package/dist/hermes.js +50 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +71 -19
- 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/policy.d.ts +2 -16
- package/dist/policy.js +2 -18
- package/dist/preview-recorder.d.ts +1 -0
- package/dist/preview-recorder.js +62 -0
- package/dist/preview.d.ts +4 -3
- package/dist/preview.js +114 -15
- package/dist/promote.d.ts +1 -1
- package/dist/promote.js +63 -14
- package/dist/publish.d.ts +14 -2
- package/dist/publish.js +131 -56
- package/dist/qr.d.ts +9 -0
- package/dist/qr.js +213 -0
- package/dist/report-md.d.ts +27 -0
- package/dist/report-md.js +58 -0
- package/dist/rotation.d.ts +57 -0
- package/dist/rotation.js +157 -0
- package/dist/run-bridge.d.ts +7 -0
- package/dist/run-bridge.js +728 -0
- package/dist/run-net.d.ts +39 -0
- package/dist/run-net.js +266 -0
- package/dist/run-protocol.d.ts +32 -0
- package/dist/run-protocol.js +22 -0
- package/dist/run-ws.d.ts +8 -0
- package/dist/run-ws.js +89 -0
- package/dist/run.d.ts +109 -0
- package/dist/run.js +385 -0
- package/dist/signing.js +13 -0
- package/dist/sim.d.ts +4 -0
- package/dist/sim.js +25 -7
- 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 +71 -1
- package/dist/status.js +162 -5
- package/dist/store.d.ts +36 -0
- package/dist/store.js +266 -3
- package/dist/templates/capacitor/github-actions/build.yml +122 -0
- package/dist/templates/github-action/shipvo-check.yml +18 -0
- package/dist/test.d.ts +46 -0
- package/dist/test.js +253 -0
- package/package.json +7 -4
- 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
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
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>;
|
|
9
|
+
/** `shipvo ci init --framework capacitor [--dry-run] [--force]`. Returns the exit code. */
|
|
10
|
+
export declare function ci(argv: string[], cwd?: string): Promise<number>;
|
|
11
|
+
export {};
|
package/dist/ci.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
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";
|
|
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
|
+
}
|
|
45
|
+
/** Find the template: next to the built CLI (npm), or in the repo's `templates/` (development). */
|
|
46
|
+
function templatePath(framework) {
|
|
47
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
const rel = join("templates", framework, "github-actions", "build.yml");
|
|
49
|
+
for (const c of [join(here, rel), join(here, "..", "..", "..", rel)])
|
|
50
|
+
if (existsSync(c))
|
|
51
|
+
return c;
|
|
52
|
+
throw new Error(`template for ${framework} not found`);
|
|
53
|
+
}
|
|
54
|
+
/** `shipvo ci init --framework capacitor [--dry-run] [--force]`. Returns the exit code. */
|
|
55
|
+
export async function ci(argv, cwd = process.cwd()) {
|
|
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);
|
|
70
|
+
if (sub !== "init") {
|
|
71
|
+
console.error("Usage: shipvo ci init --check | --framework capacitor [--dry-run] [--force]\n shipvo ci check");
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
const a = args(rest);
|
|
75
|
+
const isCheck = a.check !== undefined && a.check !== "false";
|
|
76
|
+
if (!isCheck && a.framework !== "capacitor") {
|
|
77
|
+
console.error("shipvo ci init: --framework capacitor is the only framework with a template so far");
|
|
78
|
+
return 2;
|
|
79
|
+
}
|
|
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);
|
|
83
|
+
if (a["dry-run"] !== undefined && a["dry-run"] !== "false") {
|
|
84
|
+
console.log(`Would write ${path}${existsSync(target) ? " (file exists; --force needed)" : ""}. Nothing was written.\n`);
|
|
85
|
+
console.log(body);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
if (existsSync(target) && (a.force === undefined || a.force === "false")) {
|
|
89
|
+
console.error(`${path} already exists. Use --force to overwrite it.`);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
93
|
+
writeFileSync(target, body);
|
|
94
|
+
if (isCheck) {
|
|
95
|
+
console.log(`Wrote ${path}. Commit it and open a pull request.`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
console.log(`Wrote ${WORKFLOW_PATH}. Add the secrets listed in docs/capacitor.md, then run the "Shipvo build" workflow.`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -7,15 +7,27 @@ import { init } from "./init.js";
|
|
|
7
7
|
import { publish, rollback } from "./publish.js";
|
|
8
8
|
import { promote } from "./promote.js";
|
|
9
9
|
import { autoHalt } from "./autohalt.js";
|
|
10
|
+
import { explore } from "./explore.js";
|
|
11
|
+
import { lumi } from "./lumi.js";
|
|
12
|
+
import { smoke } from "./smoke.js";
|
|
10
13
|
import { sim } from "./sim.js";
|
|
11
14
|
import { preview } from "./preview.js";
|
|
15
|
+
import { run } from "./run.js";
|
|
12
16
|
import { keys, login, mcp } from "./login.js";
|
|
13
17
|
import { verifyPointer } from "./verify.js";
|
|
14
18
|
import { signingKey } from "./signing.js";
|
|
19
|
+
import { keysJson } from "./keys-json.js";
|
|
15
20
|
import { appCmd } from "./app.js";
|
|
16
21
|
import { status } from "./status.js";
|
|
22
|
+
import { agentsCmd } from "./agents.js";
|
|
17
23
|
import { doctor } from "./doctor.js";
|
|
24
|
+
import { audit } from "./audit.js";
|
|
25
|
+
import { migrate } from "./migrate.js";
|
|
26
|
+
import { approvals } from "./approvals.js";
|
|
18
27
|
import { store } from "./store.js";
|
|
28
|
+
import { ci } from "./ci.js";
|
|
29
|
+
import { easCmd } from "./eas.js";
|
|
30
|
+
import { testCmd } from "./test.js";
|
|
19
31
|
import { failure, help, unknownCommand } from "./help.js";
|
|
20
32
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
21
33
|
try {
|
|
@@ -26,6 +38,18 @@ try {
|
|
|
26
38
|
case "sim":
|
|
27
39
|
await sim(rest);
|
|
28
40
|
break;
|
|
41
|
+
case "explore":
|
|
42
|
+
process.exitCode = await explore(rest);
|
|
43
|
+
break;
|
|
44
|
+
case "lumi":
|
|
45
|
+
process.exitCode = await lumi(rest);
|
|
46
|
+
break;
|
|
47
|
+
case "smoke":
|
|
48
|
+
process.exitCode = await smoke(rest);
|
|
49
|
+
break;
|
|
50
|
+
case "run":
|
|
51
|
+
process.exitCode = await run(rest);
|
|
52
|
+
break;
|
|
29
53
|
case "preview":
|
|
30
54
|
await preview(rest);
|
|
31
55
|
break;
|
|
@@ -50,14 +74,40 @@ try {
|
|
|
50
74
|
case "status":
|
|
51
75
|
process.exitCode = await status(rest);
|
|
52
76
|
break;
|
|
77
|
+
case "agents":
|
|
78
|
+
process.exitCode = await agentsCmd(rest);
|
|
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;
|
|
83
|
+
case "audit":
|
|
84
|
+
await audit(rest);
|
|
85
|
+
break;
|
|
86
|
+
case "migrate":
|
|
87
|
+
process.exitCode = await migrate(rest);
|
|
88
|
+
break;
|
|
89
|
+
case "approvals":
|
|
90
|
+
process.exitCode = await approvals(rest);
|
|
91
|
+
break;
|
|
92
|
+
case "test":
|
|
93
|
+
process.exitCode = await testCmd(rest);
|
|
94
|
+
break;
|
|
53
95
|
case "store":
|
|
54
96
|
await store(rest);
|
|
55
97
|
break;
|
|
98
|
+
case "ci":
|
|
99
|
+
process.exitCode = await ci(rest);
|
|
100
|
+
break;
|
|
101
|
+
case "eas":
|
|
102
|
+
process.exitCode = await easCmd(rest);
|
|
103
|
+
break;
|
|
56
104
|
case "app":
|
|
57
105
|
await appCmd(rest);
|
|
58
106
|
break;
|
|
59
107
|
case "keys":
|
|
60
|
-
if (rest
|
|
108
|
+
if (rest.includes("--json"))
|
|
109
|
+
await keysJson(rest);
|
|
110
|
+
else if (rest[0] === "export" || rest[0] === "import" || rest[0] === "rotate")
|
|
61
111
|
await signingKey(rest);
|
|
62
112
|
else
|
|
63
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. */
|
|
@@ -21,6 +23,8 @@ export type ShipvoConfig = {
|
|
|
21
23
|
preview?: "here.now" | "control-plane";
|
|
22
24
|
/** Release rules checked by `shipvo publish --dry-run`. Validated there, not at load, so a bad section never breaks other commands. */
|
|
23
25
|
policy?: unknown;
|
|
26
|
+
/** Deep links `shipvo explore` opens after the start screen (for example "tide://cart"). Needs an onUrl listener in the app. */
|
|
27
|
+
routes?: string[];
|
|
24
28
|
/** Marketplace listing (hosted control plane): shown to agents and on shipvo.dev once reviewed. */
|
|
25
29
|
listing?: {
|
|
26
30
|
public?: boolean;
|
|
@@ -34,11 +38,22 @@ export type ShipvoConfig = {
|
|
|
34
38
|
};
|
|
35
39
|
};
|
|
36
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>;
|
|
37
43
|
export declare const args: (argv: string[]) => Record<string, string | undefined>;
|
|
38
44
|
/** Signing seed: SHIPVO_SIGNING_SEED (PURENATIVE_SIGNING_SEED still accepted) or a *_FILE path. */
|
|
39
45
|
export declare function seed(): string;
|
|
40
46
|
/** The publish token `shipvo app create` issued: SHIPVO_PUBLISH_TOKEN (CI) or .shipvo/publish-token. */
|
|
41
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
|
+
};
|
|
42
57
|
/** Where publish/rollback write: your own Cloudflare KV (self-hosted control plane) or the hosted control plane with a publish token. */
|
|
43
58
|
export declare function kv(cfg: ShipvoConfig): {
|
|
44
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() {
|
|
@@ -42,31 +44,46 @@ export function publishToken() {
|
|
|
42
44
|
return null;
|
|
43
45
|
}
|
|
44
46
|
}
|
|
47
|
+
const RETRIES = 2;
|
|
45
48
|
/** Publishing through the hosted control plane: the same get/put/keys, scoped to this app and signed-pointer checked server side. */
|
|
46
49
|
function hostedKv(cfg, token) {
|
|
47
50
|
const base = `${cfg.controlPlane.replace(/\/$/, "")}/apps/${cfg.appId}/kv`;
|
|
48
|
-
const auth = { Authorization: `Bearer ${token}
|
|
51
|
+
const auth = { Authorization: `Bearer ${token}`, ...(process.env.SHIPVO_ACTOR === "agent" ? { "x-shipvo-actor": "agent" } : {}) };
|
|
49
52
|
const path = (k) => `${base}/${k.split("/").map(encodeURIComponent).join("/")}`;
|
|
50
53
|
const host = cfg.controlPlane.replace(/\/$/, "");
|
|
51
54
|
/** One place for the two first-run failures: the server is not there, or it does not accept the token. */
|
|
52
|
-
const call = async (url, init) => {
|
|
55
|
+
const call = async (url, init, what) => {
|
|
53
56
|
let r;
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
for (let attempt = 0;; attempt++) {
|
|
58
|
+
try {
|
|
59
|
+
r = await fetch(url, init);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
// Network errors only, never HTTP statuses. Every call here is a GET or a PUT of the same bytes to the same key, so repeating it is safe.
|
|
64
|
+
if (attempt < RETRIES) {
|
|
65
|
+
await new Promise((ok) => setTimeout(ok, 150 * 2 ** attempt));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const cause = err?.cause?.code ?? err?.code ?? err?.cause?.message ?? err?.message ?? "unknown error";
|
|
69
|
+
throw new Error(`Cannot reach the control plane at ${host} (${cause}) while ${init.method ?? "GET"} ${what}. Check "controlPlane" in shipvo.json, then run \`shipvo doctor\`.`);
|
|
70
|
+
}
|
|
56
71
|
}
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
if (r.status === 403) { // a typed policy refusal is not a bad token
|
|
73
|
+
const j = await r.clone().json().catch(() => null);
|
|
74
|
+
if (j?.code === "policy_refused")
|
|
75
|
+
throw new Error(`Refused by policy rule ${j.rule}: ${j.reason}`);
|
|
59
76
|
}
|
|
60
77
|
if (r.status === 401 || r.status === 403)
|
|
61
78
|
throw new Error(`The control plane at ${host} rejected your publish token (${r.status}). Run \`shipvo login\`, then \`shipvo app create\` for a new token.`);
|
|
62
79
|
return r;
|
|
63
80
|
};
|
|
64
81
|
return {
|
|
65
|
-
async get(k) { const r = await call(path(k), { headers: auth }); return r.ok ? r.text() : null; },
|
|
66
|
-
async getBytes(k) { const r = await call(path(k), { headers: auth }); return r.ok ? new Uint8Array(await r.arrayBuffer()) : null; },
|
|
67
|
-
async keys(prefix) { const r = await call(`${base}?prefix=${encodeURIComponent(prefix)}`, { headers: auth }); return r.ok ? (await r.json()).keys : []; },
|
|
82
|
+
async get(k) { const r = await call(path(k), { headers: auth }, k); return r.ok ? r.text() : null; },
|
|
83
|
+
async getBytes(k) { const r = await call(path(k), { headers: auth }, k); return r.ok ? new Uint8Array(await r.arrayBuffer()) : null; },
|
|
84
|
+
async keys(prefix) { const r = await call(`${base}?prefix=${encodeURIComponent(prefix)}`, { headers: auth }, `keys ${prefix}`); return r.ok ? (await r.json()).keys : []; },
|
|
68
85
|
async put(k, body, type) {
|
|
69
|
-
const r = await call(path(k), { method: "PUT", headers: { ...auth, "content-type": type }, body });
|
|
86
|
+
const r = await call(path(k), { method: "PUT", headers: { ...auth, "content-type": type }, body }, k);
|
|
70
87
|
if (!r.ok) {
|
|
71
88
|
let m = "";
|
|
72
89
|
try {
|
|
@@ -79,6 +96,44 @@ function hostedKv(cfg, token) {
|
|
|
79
96
|
},
|
|
80
97
|
};
|
|
81
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
|
+
}
|
|
82
137
|
/** Where publish/rollback write: your own Cloudflare KV (self-hosted control plane) or the hosted control plane with a publish token. */
|
|
83
138
|
export function kv(cfg) {
|
|
84
139
|
const ACC = process.env.CLOUDFLARE_ACCOUNT_ID, TOKEN = process.env.CLOUDFLARE_API_TOKEN;
|
package/dist/doctor.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ type DoctorDeps = {
|
|
|
18
18
|
port: number;
|
|
19
19
|
/** Path of the Playwright Chromium binary, or null if Playwright is not installed. */
|
|
20
20
|
chromiumPath: (cwd: string) => string | null;
|
|
21
|
+
/** Version of the playwright that chromiumPath resolved, or null if unknown. */
|
|
22
|
+
playwrightVersion: (cwd: string) => string | null;
|
|
21
23
|
portFree: (port: number) => Promise<boolean>;
|
|
22
24
|
/** Skip the checks that need the network (control plane reachable and /health). */
|
|
23
25
|
offline: boolean;
|
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,
|
|
@@ -22,6 +23,12 @@ const defaultDeps = () => ({
|
|
|
22
23
|
} },
|
|
23
24
|
port: 4545,
|
|
24
25
|
chromiumPath: playwrightChromiumPath,
|
|
26
|
+
playwrightVersion: (cwd) => { try {
|
|
27
|
+
return createRequire(join(cwd, "package.json"))("playwright/package.json").version;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
} },
|
|
25
32
|
portFree,
|
|
26
33
|
offline: false,
|
|
27
34
|
skipSim: false,
|
|
@@ -42,6 +49,19 @@ function playwrightChromiumPath(cwd) {
|
|
|
42
49
|
}
|
|
43
50
|
return null;
|
|
44
51
|
}
|
|
52
|
+
/** Where system Chrome or Edge lives. Same places as the simulator's browser resolver (packages/sim/browser-resolver.mjs). */
|
|
53
|
+
function systemBrowserPaths(platform, env) {
|
|
54
|
+
if (platform === "win32") {
|
|
55
|
+
const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean);
|
|
56
|
+
return roots.flatMap((r) => [["Chrome", r + "\\Google\\Chrome\\Application\\chrome.exe"], ["Edge", r + "\\Microsoft\\Edge\\Application\\msedge.exe"]]);
|
|
57
|
+
}
|
|
58
|
+
if (platform === "darwin")
|
|
59
|
+
return [["Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"], ["Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"]];
|
|
60
|
+
return [
|
|
61
|
+
["Chrome", "/usr/bin/google-chrome"], ["Chrome", "/usr/bin/google-chrome-stable"], ["Chrome", "/opt/google/chrome/chrome"],
|
|
62
|
+
["Edge", "/usr/bin/microsoft-edge"], ["Edge", "/usr/bin/microsoft-edge-stable"], ["Edge", "/opt/microsoft/msedge/msedge"],
|
|
63
|
+
];
|
|
64
|
+
}
|
|
45
65
|
function hasSimScript(deps) {
|
|
46
66
|
try {
|
|
47
67
|
const pkg = JSON.parse(deps.readText(join(deps.cwd, "package.json")));
|
|
@@ -150,6 +170,7 @@ export async function runDoctorChecks(overrides = {}) {
|
|
|
150
170
|
results.push({ name: keyName, ok: (mode & 0o077) === 0, fix: `run \`chmod 600 ${keyPath}\` (it is mode ${mode.toString(8).padStart(3, "0")})` });
|
|
151
171
|
}
|
|
152
172
|
let healthOk = false;
|
|
173
|
+
let healthNote;
|
|
153
174
|
let healthFix = "check shipvo.json controlPlane and your network access (5 second timeout)";
|
|
154
175
|
if (!deps.offline && cfg?.controlPlane && typeof cfg.controlPlane === "string") {
|
|
155
176
|
try {
|
|
@@ -158,8 +179,10 @@ export async function runDoctorChecks(overrides = {}) {
|
|
|
158
179
|
healthFix = `/health answered HTTP ${res.status}, expected 200; check the control plane URL and deploy`;
|
|
159
180
|
else {
|
|
160
181
|
try {
|
|
161
|
-
await res.json();
|
|
182
|
+
const j = await res.json();
|
|
162
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)" : ""}`;
|
|
163
186
|
}
|
|
164
187
|
catch {
|
|
165
188
|
healthFix = "/health answered 200 but not JSON; controlPlane may point at the wrong service";
|
|
@@ -173,7 +196,7 @@ export async function runDoctorChecks(overrides = {}) {
|
|
|
173
196
|
if (deps.offline)
|
|
174
197
|
results.push({ name: "control plane /health returns JSON", ok: true, fix: "none", note: skipped });
|
|
175
198
|
else
|
|
176
|
-
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 } : {}) });
|
|
177
200
|
let hermesOk = true;
|
|
178
201
|
if (cfg && Object.prototype.hasOwnProperty.call(cfg, "hermesBytecodeVersion")) {
|
|
179
202
|
try {
|
|
@@ -184,27 +207,84 @@ export async function runDoctorChecks(overrides = {}) {
|
|
|
184
207
|
}
|
|
185
208
|
}
|
|
186
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
|
+
}
|
|
187
229
|
// Simulator checks only apply to projects that have a `sim` script.
|
|
188
230
|
if (!deps.skipSim && hasSimScript(deps)) {
|
|
231
|
+
const system = systemBrowserPaths(deps.platform, deps.env).find(([, p]) => deps.fileExists(p));
|
|
189
232
|
const exe = deps.chromiumPath(deps.cwd);
|
|
190
|
-
|
|
233
|
+
const cached = !!exe && deps.fileExists(exe);
|
|
234
|
+
const noAuto = deps.env.SHIPVO_NO_AUTO_INSTALL === "1";
|
|
235
|
+
const ok = !!system || cached || !noAuto;
|
|
236
|
+
const note = system ? `system ${system[0]}` : cached ? "Playwright Chromium" : "none found yet; the first run downloads Chromium once";
|
|
237
|
+
results.push({ name: "Browser for the simulator", ok, fix: `SHIPVO_NO_AUTO_INSTALL=1 is set and no Chrome, Edge or Playwright Chromium was found; install Chrome or Edge, or run \`npx playwright install chromium\` in the app folder${deps.playwrightVersion(deps.cwd) ? ` (playwright ${deps.playwrightVersion(deps.cwd)})` : ""}. On Linux, if it then fails to start, run \`npx playwright install-deps chromium\``, note });
|
|
191
238
|
results.push({ name: `sim port ${deps.port} is free`, ok: await deps.portFree(deps.port), fix: "Stop the other process or pass --port <n>" });
|
|
192
239
|
}
|
|
193
240
|
return results;
|
|
194
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
|
+
}
|
|
195
261
|
export async function doctor(argv) {
|
|
262
|
+
const json = argv.includes("--json");
|
|
196
263
|
const a = parseArgs(argv);
|
|
197
264
|
const port = a.port === undefined ? 4545 : Number(a.port);
|
|
198
265
|
const offline = argv.includes("--offline");
|
|
199
266
|
const skipSim = argv.includes("--skip-sim");
|
|
200
267
|
const stray = argv.some((x, i) => !x.startsWith("--") && argv[i - 1] !== "--port");
|
|
201
268
|
if (stray || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
202
|
-
|
|
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);
|
|
203
274
|
process.exit(2);
|
|
204
275
|
}
|
|
205
276
|
const results = await runDoctorChecks({ port, offline, skipSim });
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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)
|
|
209
289
|
process.exit(1);
|
|
210
290
|
}
|