@shipvo/cli 0.1.2 → 0.1.4
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 +1 -1
- package/dist/adopt-bare.d.ts +39 -0
- package/dist/adopt-bare.js +236 -0
- package/dist/adopt.d.ts +26 -0
- package/dist/adopt.js +257 -0
- package/dist/autohalt.d.ts +5 -0
- package/dist/autohalt.js +84 -0
- package/dist/cli.js +119 -83
- package/dist/config.d.ts +10 -0
- package/dist/config.js +22 -9
- package/dist/doctor.d.ts +29 -0
- package/dist/doctor.js +210 -0
- package/dist/help.d.ts +8 -0
- package/dist/help.js +76 -0
- package/dist/hermes.d.ts +11 -0
- package/dist/hermes.js +41 -0
- package/dist/init.js +487 -20
- package/dist/login.js +5 -2
- package/dist/native.d.ts +3 -0
- package/dist/native.js +39 -0
- package/dist/policy.d.ts +18 -0
- package/dist/policy.js +33 -0
- package/dist/preview.d.ts +35 -0
- package/dist/preview.js +249 -0
- package/dist/promote.d.ts +9 -0
- package/dist/promote.js +126 -0
- package/dist/publish.d.ts +9 -0
- package/dist/publish.js +168 -33
- package/dist/sim.d.ts +27 -0
- package/dist/sim.js +138 -4
- package/dist/status.d.ts +16 -0
- package/dist/status.js +54 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +34 -0
- package/dist/verify.d.ts +1 -0
- package/dist/verify.js +15 -0
- package/package.json +7 -4
package/dist/autohalt.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// `shipvo auto-halt`: show, turn on or turn off auto-halt for this app, through the control plane.
|
|
2
|
+
import { args as parse, loadConfig, publishToken } from "./config.js";
|
|
3
|
+
import { describeAutoHalt } from "./status.js";
|
|
4
|
+
export const AUTOHALT_FLAGS = ["threshold", "min-reports", "off", "dry-run"];
|
|
5
|
+
export const AUTOHALT_HELP = `Usage: shipvo auto-halt [flags]
|
|
6
|
+
|
|
7
|
+
Stop serving a release on its own when too many devices fail to apply it.
|
|
8
|
+
With no flags, print the current setting.
|
|
9
|
+
|
|
10
|
+
Flags (write a value after a space, not =):
|
|
11
|
+
--threshold 0.2 Halt when more than this share of reports failed (above 0, at most 1)
|
|
12
|
+
--min-reports 20 Wait for this many reports first (1 to 10000, default 20)
|
|
13
|
+
--off Turn auto-halt off
|
|
14
|
+
--dry-run Print what would change, write nothing
|
|
15
|
+
--help Show this help
|
|
16
|
+
|
|
17
|
+
Needs SHIPVO_OWNER_TOKEN, or the publish token from shipvo app create (SHIPVO_PUBLISH_TOKEN or .shipvo/publish-token).
|
|
18
|
+
`;
|
|
19
|
+
const describe = (s) => describeAutoHalt(s.enabled ? { threshold: s.threshold, min_reports: s.min_reports } : null);
|
|
20
|
+
export async function autoHalt(argv, deps = {}) {
|
|
21
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
22
|
+
console.log(AUTOHALT_HELP);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const bad = argv.filter((x) => x.startsWith("--") && !AUTOHALT_FLAGS.includes(x.slice(2)));
|
|
26
|
+
if (bad.length) {
|
|
27
|
+
const eq = bad.find((x) => x.includes("="));
|
|
28
|
+
throw new Error(`Unknown flag ${bad.join(", ")}.${eq ? ` Write "${eq.replace("=", " ")}" (a space, not =).` : ""} Known flags: ${AUTOHALT_FLAGS.map((k) => "--" + k).join(" ")}`);
|
|
29
|
+
}
|
|
30
|
+
const a = parse(argv), cfg = loadConfig();
|
|
31
|
+
const dry = argv.includes("--dry-run"), off = argv.includes("--off");
|
|
32
|
+
const hasT = argv.includes("--threshold"), hasM = argv.includes("--min-reports");
|
|
33
|
+
if (off && (hasT || hasM))
|
|
34
|
+
throw new Error("--off cannot be combined with --threshold or --min-reports.");
|
|
35
|
+
let threshold, minReports = 20;
|
|
36
|
+
if (hasT || hasM) {
|
|
37
|
+
if (!hasT)
|
|
38
|
+
throw new Error("--min-reports needs --threshold too, for example --threshold 0.2 --min-reports 20.");
|
|
39
|
+
threshold = a.threshold === "true" || a.threshold === undefined ? NaN : Number(a.threshold);
|
|
40
|
+
if (!(threshold > 0 && threshold <= 1))
|
|
41
|
+
throw new Error(`--threshold must be a number above 0 and at most 1, for example 0.2 (got "${a.threshold}").`);
|
|
42
|
+
if (hasM) {
|
|
43
|
+
minReports = a["min-reports"] === "true" || a["min-reports"] === undefined ? NaN : Number(a["min-reports"]);
|
|
44
|
+
if (!Number.isInteger(minReports) || minReports < 1 || minReports > 10000)
|
|
45
|
+
throw new Error(`--min-reports must be a whole number from 1 to 10000 (got "${a["min-reports"]}").`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const token = process.env.SHIPVO_OWNER_TOKEN?.trim() || publishToken();
|
|
49
|
+
if (!token)
|
|
50
|
+
throw new Error("No credentials. Set SHIPVO_OWNER_TOKEN, or run `shipvo app create` to get a publish token (SHIPVO_PUBLISH_TOKEN or .shipvo/publish-token).");
|
|
51
|
+
const base = cfg.controlPlane.replace(/\/$/, "");
|
|
52
|
+
const url = `${base}/app/${encodeURIComponent(cfg.appId)}/api/halt-config`;
|
|
53
|
+
const call = async (method, body) => {
|
|
54
|
+
let r;
|
|
55
|
+
try {
|
|
56
|
+
r = await (deps.fetchFn ?? fetch)(url, { method, headers: { authorization: `Bearer ${token}`, ...(body ? { "content-type": "application/json" } : {}) }, body: body ? JSON.stringify(body) : undefined });
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
throw new Error(`Could not reach the control plane at ${base} (${e instanceof Error ? e.message : String(e)}).`);
|
|
60
|
+
}
|
|
61
|
+
if (r.status === 401 || r.status === 403)
|
|
62
|
+
throw new Error(`The control plane at ${base} rejected your token (${r.status}). Check SHIPVO_OWNER_TOKEN or your publish token.`);
|
|
63
|
+
if (!r.ok) {
|
|
64
|
+
let m = "";
|
|
65
|
+
try {
|
|
66
|
+
m = (await r.json()).message ?? "";
|
|
67
|
+
}
|
|
68
|
+
catch { /* no body */ }
|
|
69
|
+
throw new Error(`Auto-halt request failed: HTTP ${r.status}${m ? ` ${m}` : ""}`);
|
|
70
|
+
}
|
|
71
|
+
return (await r.json());
|
|
72
|
+
};
|
|
73
|
+
if (!off && threshold === undefined) {
|
|
74
|
+
console.log(describe(await call("GET")));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const want = off ? { enabled: false } : { enabled: true, threshold, min_reports: minReports };
|
|
78
|
+
if (dry) {
|
|
79
|
+
console.log(`Would set ${describe(want)}`);
|
|
80
|
+
console.log("Dry run: nothing was written.");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
console.log(describe(off ? await call("DELETE") : await call("PUT", { threshold, min_reports: minReports })));
|
|
84
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -5,94 +5,130 @@ import { b64, keyFromSeed, sha256Hex, signBytes, signDoc, verifyDoc } from "./in
|
|
|
5
5
|
import { seed } from "./config.js";
|
|
6
6
|
import { init } from "./init.js";
|
|
7
7
|
import { publish, rollback } from "./publish.js";
|
|
8
|
+
import { promote } from "./promote.js";
|
|
9
|
+
import { autoHalt } from "./autohalt.js";
|
|
8
10
|
import { sim } from "./sim.js";
|
|
11
|
+
import { preview } from "./preview.js";
|
|
9
12
|
import { keys, login, mcp } from "./login.js";
|
|
13
|
+
import { verifyPointer } from "./verify.js";
|
|
10
14
|
import { signingKey } from "./signing.js";
|
|
11
15
|
import { appCmd } from "./app.js";
|
|
16
|
+
import { status } from "./status.js";
|
|
17
|
+
import { doctor } from "./doctor.js";
|
|
18
|
+
import { store } from "./store.js";
|
|
19
|
+
import { failure, help, unknownCommand } from "./help.js";
|
|
12
20
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
21
|
+
try {
|
|
22
|
+
switch (cmd) {
|
|
23
|
+
case "init":
|
|
24
|
+
await init(rest);
|
|
25
|
+
break;
|
|
26
|
+
case "sim":
|
|
27
|
+
await sim(rest);
|
|
28
|
+
break;
|
|
29
|
+
case "preview":
|
|
30
|
+
await preview(rest);
|
|
31
|
+
break;
|
|
32
|
+
case "publish":
|
|
33
|
+
await publish(rest);
|
|
34
|
+
break;
|
|
35
|
+
case "promote":
|
|
36
|
+
await promote(rest);
|
|
37
|
+
break;
|
|
38
|
+
case "auto-halt":
|
|
39
|
+
await autoHalt(rest);
|
|
40
|
+
break;
|
|
41
|
+
case "rollback":
|
|
42
|
+
await rollback(rest);
|
|
43
|
+
break;
|
|
44
|
+
case "login":
|
|
45
|
+
await login(rest);
|
|
46
|
+
break;
|
|
47
|
+
case "doctor":
|
|
48
|
+
await doctor(rest);
|
|
49
|
+
break;
|
|
50
|
+
case "status":
|
|
51
|
+
process.exitCode = await status(rest);
|
|
52
|
+
break;
|
|
53
|
+
case "store":
|
|
54
|
+
await store(rest);
|
|
55
|
+
break;
|
|
56
|
+
case "app":
|
|
57
|
+
await appCmd(rest);
|
|
58
|
+
break;
|
|
59
|
+
case "keys":
|
|
60
|
+
if (rest[0] === "export" || rest[0] === "import")
|
|
61
|
+
await signingKey(rest);
|
|
62
|
+
else
|
|
63
|
+
await keys();
|
|
64
|
+
break;
|
|
65
|
+
case "mcp":
|
|
66
|
+
mcp();
|
|
67
|
+
break;
|
|
68
|
+
case "keygen": {
|
|
69
|
+
const s = b64(randomBytes(32));
|
|
70
|
+
const out = rest[rest.indexOf("--out") + 1];
|
|
71
|
+
if (rest.includes("--out") && out) {
|
|
72
|
+
writeFileSync(out, s, { mode: 0o600 });
|
|
73
|
+
console.error(`Seed written to ${out}. Back it up offline; never commit it.`);
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
console.log(`seed (secret): ${s}`);
|
|
77
|
+
console.log(`public key: ${b64(keyFromSeed(s).publicKey)}`);
|
|
78
|
+
break;
|
|
67
79
|
}
|
|
68
|
-
|
|
69
|
-
console.log(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
80
|
+
case "pubkey":
|
|
81
|
+
console.log(b64(keyFromSeed(seed()).publicKey));
|
|
82
|
+
break;
|
|
83
|
+
case "hash": {
|
|
84
|
+
const b = readFileSync(rest[0]);
|
|
85
|
+
console.log(JSON.stringify({ sha256: sha256Hex(b), size: b.length }));
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "sign-file":
|
|
89
|
+
console.log(signBytes(new Uint8Array(readFileSync(rest[0])), seed()));
|
|
90
|
+
break;
|
|
91
|
+
case "sign-json": {
|
|
92
|
+
const d = JSON.parse(readFileSync(rest[0], "utf8"));
|
|
93
|
+
writeFileSync(rest[0], JSON.stringify(signDoc(d, seed()), null, 2));
|
|
94
|
+
console.log("signed");
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case "verify-json": {
|
|
98
|
+
const ok = verifyDoc(JSON.parse(readFileSync(rest[0], "utf8")), rest[1]);
|
|
99
|
+
console.log(ok ? "valid" : "INVALID");
|
|
100
|
+
process.exit(ok ? 0 : 1);
|
|
101
|
+
}
|
|
102
|
+
case "verify": {
|
|
103
|
+
if (!rest[0]) {
|
|
104
|
+
console.error("Usage: shipvo verify <pointer file> [public-key-b64]");
|
|
105
|
+
process.exit(2);
|
|
106
|
+
}
|
|
107
|
+
const key = rest[1] ?? b64(keyFromSeed(seed()).publicKey);
|
|
108
|
+
let text;
|
|
109
|
+
try {
|
|
110
|
+
text = readFileSync(rest[0], "utf8");
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
console.error(`Cannot read ${rest[0]}`);
|
|
114
|
+
process.exit(2);
|
|
115
|
+
}
|
|
116
|
+
const ok = verifyPointer(text, key);
|
|
117
|
+
console.log(ok ? "valid" : "INVALID");
|
|
118
|
+
process.exit(ok ? 0 : 1);
|
|
119
|
+
}
|
|
120
|
+
case undefined:
|
|
121
|
+
case "--help":
|
|
122
|
+
case "-h":
|
|
123
|
+
case "help":
|
|
124
|
+
console.log(help);
|
|
125
|
+
break;
|
|
126
|
+
default:
|
|
127
|
+
console.error(unknownCommand(cmd));
|
|
128
|
+
process.exit(1);
|
|
94
129
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
console.error(failure(e));
|
|
133
|
+
process.exit(1);
|
|
98
134
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -7,10 +7,20 @@ export type ShipvoConfig = {
|
|
|
7
7
|
controlPlane: string;
|
|
8
8
|
/** Base64 Ed25519 public key built into the shell. */
|
|
9
9
|
publicKey: string;
|
|
10
|
+
/** Hermes bytecode version(s) the app's shells run. If set, `shipvo publish` refuses bundles that differ. */
|
|
11
|
+
hermesBytecodeVersion?: number | number[];
|
|
12
|
+
/** 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
|
+
agentPolicy?: "ask" | "deny" | "allow";
|
|
14
|
+
/** Owner commands agents may never run. */
|
|
15
|
+
agentDeny?: string[];
|
|
10
16
|
/** Where `shipvo publish` writes: the control plane's Cloudflare KV namespace. */
|
|
11
17
|
kv?: {
|
|
12
18
|
namespaceId: string;
|
|
13
19
|
};
|
|
20
|
+
/** Where `shipvo preview` publishes: "here.now" (default, no account) or "control-plane" (not built yet). */
|
|
21
|
+
preview?: "here.now" | "control-plane";
|
|
22
|
+
/** Release rules checked by `shipvo publish --dry-run`. Validated there, not at load, so a bad section never breaks other commands. */
|
|
23
|
+
policy?: unknown;
|
|
14
24
|
/** Marketplace listing (hosted control plane): shown to agents and on shipvo.dev once reviewed. */
|
|
15
25
|
listing?: {
|
|
16
26
|
public?: boolean;
|
package/dist/config.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
export function loadConfig(dir = process.cwd()) {
|
|
4
4
|
const p = join(dir, "shipvo.json");
|
|
5
5
|
if (!existsSync(p))
|
|
6
|
-
throw new Error("No shipvo.json
|
|
6
|
+
throw new Error("No shipvo.json in this folder. Run this inside your app's folder. To start a new app, run `shipvo init myshop`.");
|
|
7
7
|
const c = JSON.parse(readFileSync(p, "utf8"));
|
|
8
8
|
for (const k of ["appId", "controlPlane", "publicKey"])
|
|
9
9
|
if (!c[k])
|
|
@@ -26,8 +26,8 @@ export function seed() {
|
|
|
26
26
|
if (!s && local)
|
|
27
27
|
return local;
|
|
28
28
|
if (!s) {
|
|
29
|
-
console.error("Set SHIPVO_SIGNING_SEED or SHIPVO_SIGNING_SEED_FILE (the seed `shipvo keygen` printed), or keep it at .shipvo/seed.txt.");
|
|
30
|
-
process.exit(
|
|
29
|
+
console.error("Set SHIPVO_SIGNING_SEED or SHIPVO_SIGNING_SEED_FILE (the seed `shipvo keygen` printed), or keep it at .shipvo/seed.txt. No key yet? Run `shipvo keygen`.");
|
|
30
|
+
process.exit(1);
|
|
31
31
|
}
|
|
32
32
|
return s;
|
|
33
33
|
}
|
|
@@ -47,13 +47,26 @@ function hostedKv(cfg, token) {
|
|
|
47
47
|
const base = `${cfg.controlPlane.replace(/\/$/, "")}/apps/${cfg.appId}/kv`;
|
|
48
48
|
const auth = { Authorization: `Bearer ${token}` };
|
|
49
49
|
const path = (k) => `${base}/${k.split("/").map(encodeURIComponent).join("/")}`;
|
|
50
|
+
const host = cfg.controlPlane.replace(/\/$/, "");
|
|
51
|
+
/** 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) => {
|
|
53
|
+
let r;
|
|
54
|
+
try {
|
|
55
|
+
r = await fetch(url, init);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
throw new Error(`Cannot reach the control plane at ${host}. Check "controlPlane" in shipvo.json, then run \`shipvo doctor\`.`);
|
|
59
|
+
}
|
|
60
|
+
if (r.status === 401 || r.status === 403)
|
|
61
|
+
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
|
+
return r;
|
|
63
|
+
};
|
|
50
64
|
return {
|
|
51
|
-
async get(k) { const r = await
|
|
52
|
-
|
|
53
|
-
async
|
|
54
|
-
async keys(prefix) { const r = await fetch(`${base}?prefix=${encodeURIComponent(prefix)}`, { headers: auth }); return r.ok ? (await r.json()).keys : []; },
|
|
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 : []; },
|
|
55
68
|
async put(k, body, type) {
|
|
56
|
-
const r = await
|
|
69
|
+
const r = await call(path(k), { method: "PUT", headers: { ...auth, "content-type": type }, body });
|
|
57
70
|
if (!r.ok) {
|
|
58
71
|
let m = "";
|
|
59
72
|
try {
|
|
@@ -75,7 +88,7 @@ export function kv(cfg) {
|
|
|
75
88
|
if (pt)
|
|
76
89
|
return hostedKv(cfg, pt);
|
|
77
90
|
console.error("Nothing to publish with. Hosted: run `shipvo login`, then `shipvo app create` (or set SHIPVO_PUBLISH_TOKEN). Self-hosted: set CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN and SHIPVO_KV_ID.");
|
|
78
|
-
process.exit(
|
|
91
|
+
process.exit(1);
|
|
79
92
|
}
|
|
80
93
|
const url = (k) => `https://api.cloudflare.com/client/v4/accounts/${ACC}/storage/kv/namespaces/${NS}/values/${encodeURIComponent(k)}`;
|
|
81
94
|
const auth = { Authorization: `Bearer ${TOKEN}` };
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
type CheckResult = {
|
|
3
|
+
name: string;
|
|
4
|
+
ok: boolean;
|
|
5
|
+
fix: string;
|
|
6
|
+
note?: string;
|
|
7
|
+
};
|
|
8
|
+
type DoctorDeps = {
|
|
9
|
+
cwd: string;
|
|
10
|
+
env: NodeJS.ProcessEnv;
|
|
11
|
+
nodeVersion: string;
|
|
12
|
+
fetchFn: typeof fetch;
|
|
13
|
+
fileExists: typeof existsSync;
|
|
14
|
+
readText: (path: string) => string;
|
|
15
|
+
platform: NodeJS.Platform;
|
|
16
|
+
fileMode: (path: string) => number | null;
|
|
17
|
+
/** Port the simulator will use (default 4545). */
|
|
18
|
+
port: number;
|
|
19
|
+
/** Path of the Playwright Chromium binary, or null if Playwright is not installed. */
|
|
20
|
+
chromiumPath: (cwd: string) => string | null;
|
|
21
|
+
portFree: (port: number) => Promise<boolean>;
|
|
22
|
+
/** Skip the checks that need the network (control plane reachable and /health). */
|
|
23
|
+
offline: boolean;
|
|
24
|
+
/** Skip the simulator checks (Chromium, port) even when package.json has a `sim` script. */
|
|
25
|
+
skipSim: boolean;
|
|
26
|
+
};
|
|
27
|
+
export declare function runDoctorChecks(overrides?: Partial<DoctorDeps>): Promise<CheckResult[]>;
|
|
28
|
+
export declare function doctor(argv: string[]): Promise<void>;
|
|
29
|
+
export {};
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import { portFree } from "./sim.js";
|
|
6
|
+
import { args as parseArgs } from "./config.js";
|
|
7
|
+
import { b64, keyFromSeed } from "./index.js";
|
|
8
|
+
import { parseDeclared } from "./hermes.js";
|
|
9
|
+
const defaultDeps = () => ({
|
|
10
|
+
cwd: process.cwd(),
|
|
11
|
+
env: process.env,
|
|
12
|
+
nodeVersion: process.versions.node,
|
|
13
|
+
fetchFn: fetch,
|
|
14
|
+
fileExists: existsSync,
|
|
15
|
+
readText: (path) => readFileSync(path, "utf8"),
|
|
16
|
+
platform: process.platform,
|
|
17
|
+
fileMode: (path) => { try {
|
|
18
|
+
return statSync(path).mode & 0o777;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
} },
|
|
23
|
+
port: 4545,
|
|
24
|
+
chromiumPath: playwrightChromiumPath,
|
|
25
|
+
portFree,
|
|
26
|
+
offline: false,
|
|
27
|
+
skipSim: false,
|
|
28
|
+
});
|
|
29
|
+
/** Finds Playwright the way packages/sim/server.mjs does (the app's install, then this package's and @shipvo/sim's, then the global npm root) and asks it where Chromium should be. */
|
|
30
|
+
function playwrightChromiumPath(cwd) {
|
|
31
|
+
const tries = [
|
|
32
|
+
() => createRequire(join(cwd, "package.json"))("playwright"),
|
|
33
|
+
() => createRequire(import.meta.url)("playwright"),
|
|
34
|
+
() => createRequire(createRequire(import.meta.url).resolve("@shipvo/sim/package.json"))("playwright"),
|
|
35
|
+
() => createRequire(import.meta.url)(join(execSync("npm root -g", { stdio: ["ignore", "pipe", "ignore"] }).toString().trim(), "playwright")),
|
|
36
|
+
];
|
|
37
|
+
for (const get of tries) {
|
|
38
|
+
try {
|
|
39
|
+
return get().chromium.executablePath();
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function hasSimScript(deps) {
|
|
46
|
+
try {
|
|
47
|
+
const pkg = JSON.parse(deps.readText(join(deps.cwd, "package.json")));
|
|
48
|
+
return typeof pkg?.scripts?.sim === "string";
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const parseMajor = (v) => Number(v.split(".")[0] || 0);
|
|
55
|
+
const pub = (s) => b64(keyFromSeed(s).publicKey);
|
|
56
|
+
function seedFilePath(env, cwd) {
|
|
57
|
+
if (env.SHIPVO_SIGNING_SEED || env.PURENATIVE_SIGNING_SEED)
|
|
58
|
+
return null;
|
|
59
|
+
return env.SHIPVO_SIGNING_SEED_FILE || env.PURENATIVE_SIGNING_SEED_FILE || join(cwd, ".shipvo", "seed.txt");
|
|
60
|
+
}
|
|
61
|
+
function resolveSeed(env, cwd, readText) {
|
|
62
|
+
const direct = env.SHIPVO_SIGNING_SEED || env.PURENATIVE_SIGNING_SEED;
|
|
63
|
+
if (direct)
|
|
64
|
+
return direct;
|
|
65
|
+
const file = env.SHIPVO_SIGNING_SEED_FILE || env.PURENATIVE_SIGNING_SEED_FILE;
|
|
66
|
+
if (file) {
|
|
67
|
+
try {
|
|
68
|
+
return readText(file).trim() || null;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return readText(join(cwd, ".shipvo", "seed.txt")).trim() || null;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function loadShipvoConfig(deps) {
|
|
82
|
+
const path = join(deps.cwd, "shipvo.json");
|
|
83
|
+
if (!deps.fileExists(path)) {
|
|
84
|
+
return { cfg: null, parseCheck: { name: "shipvo.json parses", ok: false, fix: "run this in your app root (or create shipvo.json with `shipvo init`)" } };
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const cfg = JSON.parse(deps.readText(path));
|
|
88
|
+
return { cfg, parseCheck: { name: "shipvo.json parses", ok: true, fix: "none" } };
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { cfg: null, parseCheck: { name: "shipvo.json parses", ok: false, fix: "fix JSON syntax in shipvo.json" } };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export async function runDoctorChecks(overrides = {}) {
|
|
95
|
+
const deps = { ...defaultDeps(), ...overrides };
|
|
96
|
+
const results = [];
|
|
97
|
+
const nodeMajor = parseMajor(deps.nodeVersion);
|
|
98
|
+
results.push({
|
|
99
|
+
name: "Node version (>=22)",
|
|
100
|
+
ok: Number.isInteger(nodeMajor) && nodeMajor >= 22,
|
|
101
|
+
fix: "use Node 22 or newer (for example: `nvm use 22`)",
|
|
102
|
+
});
|
|
103
|
+
const { cfg, parseCheck } = loadShipvoConfig(deps);
|
|
104
|
+
results.push(parseCheck);
|
|
105
|
+
const signingOk = (() => {
|
|
106
|
+
if (!cfg?.publicKey || typeof cfg.publicKey !== "string")
|
|
107
|
+
return false;
|
|
108
|
+
const s = resolveSeed(deps.env, deps.cwd, deps.readText);
|
|
109
|
+
if (!s)
|
|
110
|
+
return false;
|
|
111
|
+
try {
|
|
112
|
+
return pub(s) === cfg.publicKey;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
})();
|
|
118
|
+
const needCfg = "fix shipvo.json first";
|
|
119
|
+
results.push({ name: "signing key matches shipvo.json publicKey", ok: signingOk, fix: !cfg ? needCfg : "restore the right seed with `shipvo keys import` (or set SHIPVO_SIGNING_SEED)" });
|
|
120
|
+
const skipped = "skipped, --offline";
|
|
121
|
+
let controlPlaneOk = false;
|
|
122
|
+
let cpFix = "check shipvo.json controlPlane and your network access (5 second timeout)";
|
|
123
|
+
if (!deps.offline && cfg?.controlPlane && typeof cfg.controlPlane === "string") {
|
|
124
|
+
try {
|
|
125
|
+
const res = await deps.fetchFn(cfg.controlPlane.replace(/\/$/, ""), { signal: AbortSignal.timeout(5000) });
|
|
126
|
+
if (res.status >= 500)
|
|
127
|
+
cpFix = `the control plane answered HTTP ${res.status}; check its deploy and logs`;
|
|
128
|
+
else
|
|
129
|
+
controlPlaneOk = true;
|
|
130
|
+
}
|
|
131
|
+
catch { }
|
|
132
|
+
}
|
|
133
|
+
if (deps.offline)
|
|
134
|
+
results.push({ name: "control plane reachable", ok: true, fix: "none", note: skipped });
|
|
135
|
+
else
|
|
136
|
+
results.push({ name: "control plane reachable", ok: controlPlaneOk, fix: !cfg ? needCfg : cpFix });
|
|
137
|
+
const keyName = "signing key file is private";
|
|
138
|
+
const keyPath = seedFilePath(deps.env, deps.cwd);
|
|
139
|
+
if (deps.platform === "win32") {
|
|
140
|
+
results.push({ name: keyName, ok: true, fix: "none", note: "skipped on Windows, file modes do not apply" });
|
|
141
|
+
}
|
|
142
|
+
else if (!keyPath) {
|
|
143
|
+
results.push({ name: keyName, ok: true, fix: "none", note: "skipped, the seed comes from an environment variable" });
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const mode = deps.fileMode(keyPath);
|
|
147
|
+
if (mode === null)
|
|
148
|
+
results.push({ name: keyName, ok: true, fix: "none", note: "skipped, no key file found" });
|
|
149
|
+
else
|
|
150
|
+
results.push({ name: keyName, ok: (mode & 0o077) === 0, fix: `run \`chmod 600 ${keyPath}\` (it is mode ${mode.toString(8).padStart(3, "0")})` });
|
|
151
|
+
}
|
|
152
|
+
let healthOk = false;
|
|
153
|
+
let healthFix = "check shipvo.json controlPlane and your network access (5 second timeout)";
|
|
154
|
+
if (!deps.offline && cfg?.controlPlane && typeof cfg.controlPlane === "string") {
|
|
155
|
+
try {
|
|
156
|
+
const res = await deps.fetchFn(cfg.controlPlane.replace(/\/$/, "") + "/health", { signal: AbortSignal.timeout(5000) });
|
|
157
|
+
if (res.status !== 200)
|
|
158
|
+
healthFix = `/health answered HTTP ${res.status}, expected 200; check the control plane URL and deploy`;
|
|
159
|
+
else {
|
|
160
|
+
try {
|
|
161
|
+
await res.json();
|
|
162
|
+
healthOk = true;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
healthFix = "/health answered 200 but not JSON; controlPlane may point at the wrong service";
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
healthFix = e instanceof Error && e.name === "TimeoutError" ? "/health did not answer within 5 seconds; check the URL and your network" : healthFix;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (deps.offline)
|
|
174
|
+
results.push({ name: "control plane /health returns JSON", ok: true, fix: "none", note: skipped });
|
|
175
|
+
else
|
|
176
|
+
results.push({ name: "control plane /health returns JSON", ok: healthOk, fix: !cfg ? needCfg : healthFix });
|
|
177
|
+
let hermesOk = true;
|
|
178
|
+
if (cfg && Object.prototype.hasOwnProperty.call(cfg, "hermesBytecodeVersion")) {
|
|
179
|
+
try {
|
|
180
|
+
parseDeclared(cfg.hermesBytecodeVersion); // undefined means "not declared", which passes
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
hermesOk = false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
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])" });
|
|
187
|
+
// Simulator checks only apply to projects that have a `sim` script.
|
|
188
|
+
if (!deps.skipSim && hasSimScript(deps)) {
|
|
189
|
+
const exe = deps.chromiumPath(deps.cwd);
|
|
190
|
+
results.push({ name: "Playwright Chromium installed", ok: !!exe && deps.fileExists(exe), fix: "npx playwright install chromium" });
|
|
191
|
+
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
|
+
}
|
|
193
|
+
return results;
|
|
194
|
+
}
|
|
195
|
+
export async function doctor(argv) {
|
|
196
|
+
const a = parseArgs(argv);
|
|
197
|
+
const port = a.port === undefined ? 4545 : Number(a.port);
|
|
198
|
+
const offline = argv.includes("--offline");
|
|
199
|
+
const skipSim = argv.includes("--skip-sim");
|
|
200
|
+
const stray = argv.some((x, i) => !x.startsWith("--") && argv[i - 1] !== "--port");
|
|
201
|
+
if (stray || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
202
|
+
console.error("Usage: shipvo doctor [--port <n>] [--offline] [--skip-sim]");
|
|
203
|
+
process.exit(2);
|
|
204
|
+
}
|
|
205
|
+
const results = await runDoctorChecks({ port, offline, skipSim });
|
|
206
|
+
for (const r of results)
|
|
207
|
+
console.log(`${r.ok ? `PASS ${r.name}${r.note ? ` (${r.note})` : ""}` : `FAIL ${r.name} — fix: ${r.fix}`}`);
|
|
208
|
+
if (results.some((r) => !r.ok))
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
package/dist/help.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const COMMANDS: readonly ["init", "sim", "preview", "publish", "promote", "auto-halt", "rollback", "app", "login", "doctor", "store", "status", "keys", "mcp", "keygen", "pubkey", "hash", "sign-file", "sign-json", "verify-json", "verify"];
|
|
2
|
+
export declare const help = "shipvo <command>\n\nStart\n init <dir> Create a new app wired to Shipvo\n sim [--port 4545] Build the app and run it in the simulator\n sim --watch [--verbose] Rebuild on file changes and keep serving; --verbose shows full build errors\n preview [--json] Publish a public 24-hour preview link (here.now, no account)\n\nShip\n publish [--bands b0,b1,b2] Build, sign and publish an over-the-air release\n publish --dry-run [--out d] Check a release offline: build and sign, write nothing remote\n promote [--to b1,b2] Widen a staged release to more bands (same release, new signature)\n promote --dry-run Preview a promote, write nothing\n auto-halt [--threshold 0.2 --min-reports 20 | --off] Show, turn on or turn off auto-halt\n rollback --to <release> Republish an earlier release as a new release\n rollback --to <n> --dry-run Preview a rollback, write nothing\n app create | list | delete Register, list or remove apps on the hosted control plane\n login Sign in with GitHub and get a key for the hosted simulator\n store details --dry-run Draft Play Data safety answers and a privacy policy from shipvo.json\n status [--app id] [--json] Show how the newest releases are doing on phones\n doctor [--port <n>] Check setup before first publish\n keys Show the stored key's status and quota\n keys export [--out f] Print or save this app's signing seed, to back it up\n keys import [seed|--file f] Restore the signing seed on a new machine\n mcp Print the MCP config that agents need\n\nAdvanced\n keygen [--out seed.txt] Create an Ed25519 signing key and print the public key\n pubkey Print the public key for SHIPVO_SIGNING_SEED\n hash <file> Print the SHA-256 and size of a file\n sign-file <file> Print the Ed25519 signature (base64) of a file\n sign-json <file.json> Sign a JSON document and write the signature into it\n verify <pointer> [public-key-b64]\n Check a release pointer (such as one from publish --dry-run)\n verify-json <file.json> <public-key-b64>\n Check the signature of a JSON document\n";
|
|
3
|
+
export declare function distance(a: string, b: string): number;
|
|
4
|
+
/** Closest command within edit distance 2, or undefined. */
|
|
5
|
+
export declare function suggest(input: string): string | undefined;
|
|
6
|
+
export declare function unknownCommand(input: string): string;
|
|
7
|
+
/** What to print when a command throws: the message, not a stack trace. Set SHIPVO_DEBUG=1 for the stack. */
|
|
8
|
+
export declare function failure(e: unknown, debug?: boolean): string;
|