@shipvo/cli 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/adopt.js +1 -2
- package/dist/agents.d.ts +15 -0
- package/dist/agents.js +68 -9
- package/dist/approvals.d.ts +9 -0
- package/dist/approvals.js +106 -0
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +28 -11
- package/dist/autohalt.d.ts +1 -1
- package/dist/autohalt.js +28 -16
- package/dist/bsdiff.d.ts +4 -0
- package/dist/bsdiff.js +465 -0
- package/dist/ci-comment.d.ts +38 -0
- package/dist/ci-comment.js +169 -0
- package/dist/ci-compare.d.ts +10 -0
- package/dist/ci-compare.js +25 -0
- package/dist/ci-shots.d.ts +9 -0
- package/dist/ci-shots.js +81 -0
- package/dist/ci.d.ts +8 -0
- package/dist/ci.js +64 -6
- package/dist/cli.js +23 -1
- package/dist/config.d.ts +13 -0
- package/dist/config.js +43 -3
- package/dist/doctor.js +63 -7
- package/dist/ensure-playwright.d.ts +4 -0
- package/dist/ensure-playwright.js +7 -2
- package/dist/explore-issues.d.ts +12 -0
- package/dist/explore-issues.js +50 -0
- package/dist/explore.d.ts +83 -7
- package/dist/explore.js +326 -77
- package/dist/flows.d.ts +31 -0
- package/dist/flows.js +264 -0
- package/dist/help.d.ts +2 -2
- package/dist/help.js +19 -10
- package/dist/hermes.d.ts +10 -0
- package/dist/hermes.js +50 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +41 -6
- package/dist/json-out.d.ts +53 -0
- package/dist/json-out.js +91 -0
- package/dist/keys-json.d.ts +1 -0
- package/dist/keys-json.js +71 -0
- package/dist/login.js +27 -11
- package/dist/lumi.d.ts +104 -0
- package/dist/lumi.js +364 -0
- package/dist/migrate.d.ts +27 -0
- package/dist/migrate.js +521 -0
- package/dist/preview.d.ts +1 -0
- package/dist/preview.js +4 -2
- package/dist/promote.d.ts +1 -1
- package/dist/promote.js +38 -16
- package/dist/publish.d.ts +14 -2
- package/dist/publish.js +126 -62
- package/dist/qr.d.ts +9 -0
- package/dist/qr.js +213 -0
- package/dist/report-md.d.ts +1 -0
- package/dist/report-md.js +1 -1
- package/dist/rotation.d.ts +57 -0
- package/dist/rotation.js +157 -0
- package/dist/run-bridge.js +356 -2
- package/dist/run-net.d.ts +39 -0
- package/dist/run-net.js +266 -0
- package/dist/run.d.ts +23 -0
- package/dist/run.js +101 -17
- package/dist/signing.js +13 -0
- package/dist/sim.js +7 -3
- package/dist/smoke.d.ts +28 -0
- package/dist/smoke.js +117 -0
- package/dist/spawn-bin.d.ts +11 -0
- package/dist/spawn-bin.js +21 -0
- package/dist/status.d.ts +26 -4
- package/dist/status.js +69 -5
- package/dist/templates/github-action/shipvo-check.yml +18 -0
- package/dist/test.d.ts +1 -1
- package/dist/test.js +81 -18
- package/package.json +4 -3
- package/skills/shipvo-cli/SKILL.md +31 -0
package/dist/promote.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// and writes the same release (same bundle, hashes and patches) to the other bands with a fresh issued_at and signature.
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { signBytes } from "./index.js";
|
|
5
|
-
import {
|
|
5
|
+
import { CliFailure, UsageError, runJson } from "./json-out.js";
|
|
6
|
+
import { args as parse, hasStore, kv as kvFor, loadConfig, offlineKv, seed } from "./config.js";
|
|
6
7
|
const BANDS = ["b0", "b1", "b2"];
|
|
7
8
|
const REVERT_EVENTS = ["rolled_back", "crash_loop_revert"];
|
|
8
9
|
const pointerKey = (app, major, band, pf) => (pf === "ios" ? `p/${app}/${major}/ios/${band}` : `p/${app}/${major}/${band}`);
|
|
9
|
-
export const PROMOTE_FLAGS = ["to", "platform", "dry-run", "force", "report"];
|
|
10
|
+
export const PROMOTE_FLAGS = ["to", "platform", "dry-run", "force", "report", "json"];
|
|
10
11
|
export const PROMOTE_HELP = `Usage: shipvo promote [flags]
|
|
11
12
|
|
|
12
13
|
Widen a staged release to more bands. The release number, bundle and patches stay the same; the pointer is signed again.
|
|
@@ -18,6 +19,7 @@ Flags (write a value after a space, not =):
|
|
|
18
19
|
--dry-run Print the plan, write nothing
|
|
19
20
|
--report <file> Test report from shipvo test --report (default shipvo-test-report.json if it exists)
|
|
20
21
|
A report for this release with failing flows blocks promote
|
|
22
|
+
--json Print one JSON envelope on stdout; text goes to stderr
|
|
21
23
|
--force Promote even if the release is halted, has reverts or has a failing test report
|
|
22
24
|
--help Show this help
|
|
23
25
|
`;
|
|
@@ -26,34 +28,44 @@ function checkFlags(argv, known) {
|
|
|
26
28
|
if (bad.length) {
|
|
27
29
|
const eq = bad.find((x) => x.includes("="));
|
|
28
30
|
const hint = eq ? ` Write "${eq.replace("=", " ")}" (a space, not =).` : "";
|
|
29
|
-
throw new
|
|
31
|
+
throw new UsageError(`Unknown flag ${bad.join(", ")}.${hint} Known flags: ${known.map((k) => "--" + k).join(" ")}`);
|
|
30
32
|
}
|
|
31
33
|
}
|
|
34
|
+
const STATUS = [{ command: "shipvo status --json", why: "See how the release does on phones." }];
|
|
32
35
|
export async function promote(argv, deps = {}) {
|
|
33
36
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
34
37
|
console.log(PROMOTE_HELP);
|
|
35
38
|
return;
|
|
36
39
|
}
|
|
40
|
+
if (argv.includes("--json"))
|
|
41
|
+
return runJson(() => promoteRun(argv, deps), [{ command: "shipvo doctor --json", why: "Check your setup." }, ...STATUS]);
|
|
42
|
+
await promoteRun(argv, deps);
|
|
43
|
+
}
|
|
44
|
+
async function promoteRun(argv, deps) {
|
|
37
45
|
checkFlags(argv, PROMOTE_FLAGS);
|
|
38
46
|
const a = parse(argv), cfg = loadConfig();
|
|
39
47
|
const dry = a["dry-run"] !== undefined, force = a.force !== undefined;
|
|
40
48
|
const p = (a.platform || "android").toLowerCase();
|
|
41
49
|
if (p !== "android" && p !== "ios" && p !== "all")
|
|
42
|
-
throw new
|
|
50
|
+
throw new UsageError(`--platform must be android, ios or all (got "${a.platform}")`);
|
|
43
51
|
const platforms = p === "all" ? ["android", "ios"] : [p];
|
|
44
52
|
if (a.to === "true")
|
|
45
|
-
throw new
|
|
53
|
+
throw new UsageError("--to needs bands: pass --to b1,b2");
|
|
46
54
|
const explicit = a.to ? a.to.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
|
47
55
|
if (explicit) {
|
|
48
56
|
const bad = explicit.filter((b) => !BANDS.includes(b));
|
|
49
57
|
if (bad.length)
|
|
50
|
-
throw new
|
|
58
|
+
throw new UsageError(`Unknown band ${bad.join(", ")}. Bands are ${BANDS.join(", ")}.`);
|
|
51
59
|
}
|
|
52
60
|
if (a.report === "true")
|
|
53
|
-
throw new
|
|
61
|
+
throw new UsageError("--report needs a file name: pass --report <file>");
|
|
54
62
|
const reportFile = a.report ?? (existsSync("shipvo-test-report.json") ? "shipvo-test-report.json" : null);
|
|
55
63
|
const APP = cfg.appId, MAJOR = cfg.shellMajor;
|
|
56
|
-
|
|
64
|
+
// A dry run with no login and no Cloudflare keys reads the files `shipvo publish --dry-run` wrote.
|
|
65
|
+
const offline = !deps.kv && dry && !hasStore();
|
|
66
|
+
const store = deps.kv ?? (offline ? offlineKv() : kvFor(cfg));
|
|
67
|
+
if (offline)
|
|
68
|
+
console.log("Offline rehearsal: reading pointers from .shipvo/dry-run (written by shipvo publish --dry-run). No login used, nothing is sent.");
|
|
57
69
|
// Read every source pointer before touching the network or writing anything.
|
|
58
70
|
const plans = [];
|
|
59
71
|
for (const pf of platforms) {
|
|
@@ -74,10 +86,10 @@ export async function promote(argv, deps = {}) {
|
|
|
74
86
|
catch { /* unreadable pointer: skip */ }
|
|
75
87
|
}
|
|
76
88
|
if (!body)
|
|
77
|
-
throw new
|
|
89
|
+
throw new CliFailure(`No ${pf} pointer found on any band. Run shipvo publish --bands b0 first.`, "no_pointer", 1, [{ command: "shipvo publish --bands b0 --json", why: "Publish to the first cohort." }]);
|
|
78
90
|
const release = body.release;
|
|
79
91
|
if (explicit && explicit.length && explicit.every((b) => b === from))
|
|
80
|
-
throw new
|
|
92
|
+
throw new UsageError(`${from} is the source band (release ${release}). Pass --to with a different band.`);
|
|
81
93
|
// Never write an older release over a newer one: promote widens, it does not downgrade. --force does not change this.
|
|
82
94
|
const cand = (explicit ?? BANDS).filter((b) => b !== from && (explicit || have.get(b) !== release));
|
|
83
95
|
const newer = cand.filter((b) => (have.get(b) ?? 0) > release).map((b) => [b, have.get(b)]);
|
|
@@ -90,7 +102,7 @@ export async function promote(argv, deps = {}) {
|
|
|
90
102
|
rep = JSON.parse(readFileSync(reportFile, "utf8"));
|
|
91
103
|
}
|
|
92
104
|
catch (e) {
|
|
93
|
-
throw new
|
|
105
|
+
throw new CliFailure(`Could not read the test report ${reportFile} (${e instanceof Error ? e.message : String(e)}).`, "report_unreadable", 1, [{ command: "shipvo test --report shipvo-test-report.json --json", why: "Write a fresh test report." }]);
|
|
94
106
|
}
|
|
95
107
|
const rel = rep.release == null ? null : String(rep.release);
|
|
96
108
|
const failing = (Array.isArray(rep.flows) ? rep.flows : []).filter((f) => f.status === "failed").map((f) => f.name ?? "(unnamed flow)");
|
|
@@ -98,14 +110,14 @@ export async function promote(argv, deps = {}) {
|
|
|
98
110
|
if (mine.length && failing.length) {
|
|
99
111
|
const msg = `Test report ${reportFile} has ${failing.length} failing flow${failing.length === 1 ? "" : "s"} for release ${mine[0].release}: ${failing.join(", ")}.`;
|
|
100
112
|
if (!force)
|
|
101
|
-
throw new
|
|
113
|
+
throw new CliFailure(`${msg} Fix them and run shipvo test --report again, or pass --force to promote anyway.`, "tests_failed", 1, [{ command: `shipvo test --report ${reportFile} --json`, why: "Run the flows again after the fix." }]);
|
|
102
114
|
console.log(`${msg} Overridden by --force.`);
|
|
103
115
|
}
|
|
104
116
|
else if (!mine.length) {
|
|
105
117
|
console.log(`Warning: test report ${reportFile} is for ${rel === null ? "no release" : `release ${rel}`}, not release ${plans.map((x) => x.release).join(", ")}. It is stale and was not used.`);
|
|
106
118
|
}
|
|
107
119
|
}
|
|
108
|
-
if (!force) {
|
|
120
|
+
if (!force && !offline) {
|
|
109
121
|
const base = cfg.controlPlane.replace(/\/$/, "");
|
|
110
122
|
let data;
|
|
111
123
|
try {
|
|
@@ -115,18 +127,20 @@ export async function promote(argv, deps = {}) {
|
|
|
115
127
|
data = (await r.json());
|
|
116
128
|
}
|
|
117
129
|
catch (e) {
|
|
118
|
-
throw new
|
|
130
|
+
throw new CliFailure(`Could not read the release status from ${base} (${e instanceof Error ? e.message : String(e)}). Try again, or pass --force to promote without the check.`, "remote_failed", 1, [{ command: "shipvo doctor --json", why: "Check the control plane." }]);
|
|
119
131
|
}
|
|
120
132
|
for (const { release } of plans) {
|
|
121
133
|
if (data.halted && typeof data.halted === "object" && data.halted.release === release)
|
|
122
|
-
throw new
|
|
134
|
+
throw new CliFailure(`Release ${release} is halted. Publish a new release or run shipvo rollback, or pass --force to promote it anyway.`, "blocked", 1, STATUS);
|
|
123
135
|
const ev = data.releases?.find((r) => r.release === release)?.events ?? {};
|
|
124
136
|
const n = REVERT_EVENTS.reduce((s, k) => s + (ev[k] ?? 0), 0);
|
|
125
137
|
if (n > 0)
|
|
126
|
-
throw new
|
|
138
|
+
throw new CliFailure(`Release ${release} has ${n} reported revert${n === 1 ? "" : "s"}. Check shipvo status, publish a fix, or pass --force to promote it anyway.`, "blocked", 1, STATUS);
|
|
127
139
|
}
|
|
128
140
|
}
|
|
141
|
+
const result = [];
|
|
129
142
|
for (const { pf, release, from, body, targets, newer } of plans) {
|
|
143
|
+
result.push({ platform: pf, release, from, bands: targets, skipped_newer: newer.map(([band, release]) => ({ band, release })) });
|
|
130
144
|
for (const [b, r] of newer)
|
|
131
145
|
console.log(`Skipped ${b} (${pf}): it already has release ${r}, which is newer than ${release}.`);
|
|
132
146
|
if (!targets.length && newer.length) {
|
|
@@ -150,4 +164,12 @@ export async function promote(argv, deps = {}) {
|
|
|
150
164
|
}
|
|
151
165
|
if (dry)
|
|
152
166
|
console.log("Dry run: nothing was written.");
|
|
167
|
+
const count = result.reduce((n, r) => n + r.bands.length, 0);
|
|
168
|
+
const data = { dry_run: dry, promoted: result };
|
|
169
|
+
if (count === 0)
|
|
170
|
+
return { code: "nothing_to_promote", summary: "Nothing to promote: no band needs this release.", data, next: STATUS };
|
|
171
|
+
const list = result.map((r) => `${r.platform} release ${r.release} to ${r.bands.join(", ")}`).join("; ");
|
|
172
|
+
if (dry)
|
|
173
|
+
return { code: "dry_run", summary: `Would promote ${list}. Nothing was written.`, data, next: [{ command: `shipvo promote ${argv.filter((x) => x !== "--dry-run").join(" ")}`.trim(), why: "Do the promote." }] };
|
|
174
|
+
return { code: "promoted", summary: `Promoted ${list}.`, data, next: STATUS };
|
|
153
175
|
}
|
package/dist/publish.d.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/** True when `file` is strictly inside `root`. Uses the platform's separator, so it works with Windows paths too. */
|
|
3
|
+
export declare function isInside(root: string, file: string, p?: typeof path.posix): boolean;
|
|
4
|
+
export declare const PUBLISH_HELP = "Usage: shipvo publish [flags]\n\nBuild, sign and publish an over-the-air release.\n\nFlags (write a value after a space, not =; on/off flags can be bare, or true/false):\n --bands b0,b1,b2 Cohorts to publish to (default b0,b1,b2)\n --platform android|ios|all Platform to publish (default android)\n --release N Release number (default: the next one)\n --min-shell X.Y.Z Oldest app shell that can run this release (default: version in app.json)\n --hermes-bytecode-version V Refuse bundles built for another Hermes version, for example 96 or 96,98\n --max-bases N How many older releases to build patches from (default 6)\n --skip-export Reuse the last build in dist-ota/\n --skip-web Do not publish the web copy for the simulator\n --dry-run Build and sign, write files locally, send nothing\n --dry-run-policy Check the policy in shipvo.json for these --bands, print the result, publish nothing\n --out <dir> Where --dry-run writes files (default .shipvo/dry-run)\n --json Print one JSON envelope on stdout; text and build output go to stderr\n --help Show this help\n";
|
|
2
5
|
export declare function publish(argv: string[]): Promise<void>;
|
|
6
|
+
/** The publish result in plain words. `--json` carries the full detail. */
|
|
7
|
+
export declare function publishedSummary(release: number, bands: string[]): string;
|
|
8
|
+
/** The rollback result in plain words. */
|
|
9
|
+
export declare function rolledBackSummary(to: number, release: number): string;
|
|
3
10
|
/** Bytes in plain units: KB below 1 MB, so a small bundle never reads as 0.00 MB. */
|
|
4
11
|
export declare function formatSize(bytes: number): string;
|
|
5
12
|
/** The dry-run result in plain words. `info` says what happened with patches on Android. */
|
|
6
13
|
export declare function dryRunSummary(release: number, bytes: number, outDir: string, info?: {
|
|
7
14
|
bases: number;
|
|
8
|
-
bsdiff: boolean;
|
|
9
15
|
patches: number;
|
|
10
16
|
}): string;
|
|
17
|
+
/** The line for a listing file that is not there. It says the file is optional and what to do. */
|
|
18
|
+
export declare function missingListingNote(file: string, name: string): string;
|
|
19
|
+
/** Upload one listing file to the store prefix. A missing file prints a note and returns null. */
|
|
20
|
+
export declare function uploadListingFile(kv: {
|
|
21
|
+
put: (k: string, b: BodyInit, m: string) => Promise<unknown>;
|
|
22
|
+
}, store: string, file: string, name: string): Promise<string | null>;
|
|
11
23
|
export declare function rollback(argv: string[]): Promise<void>;
|
package/dist/publish.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// `shipvo publish`: build the Hermes bundle, sign a release pointer, publish bundle + patches + the agent-facing web
|
|
2
2
|
// artifact to the control plane. `shipvo rollback --to N`: republish an earlier bundle as a new release.
|
|
3
3
|
import { execSync } from "node:child_process";
|
|
4
|
-
import { existsSync, mkdirSync,
|
|
5
|
-
import {
|
|
6
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import path, { dirname, join, relative, resolve } from "node:path";
|
|
7
6
|
import { sha256Hex, signBytes } from "./index.js";
|
|
8
|
-
import {
|
|
7
|
+
import { bsdiff } from "./bsdiff.js";
|
|
8
|
+
import { checkBundle, checkEngine, installedHermesEngine, parseDeclared, parseEngine } from "./hermes.js";
|
|
9
9
|
import { requiredNativeModules } from "./native.js";
|
|
10
10
|
import { checkPublishPolicy } from "./policy.js";
|
|
11
|
-
import {
|
|
11
|
+
import { CliFailure, UsageError, runJson } from "./json-out.js";
|
|
12
|
+
import { args as parse, hasStore, kv as kvFor, loadConfig, offlineKv, seed } from "./config.js";
|
|
12
13
|
const MIME = { ".html": "text/html", ".js": "text/javascript", ".json": "application/json", ".css": "text/css", ".ico": "image/x-icon", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".ttf": "font/ttf", ".svg": "image/svg+xml" };
|
|
13
14
|
const walk = (d) => readdirSync(d).flatMap((f) => (statSync(join(d, f)).isDirectory() ? walk(join(d, f)) : [join(d, f)]));
|
|
14
15
|
/** Release numbers are shared across platforms, so one number means one commit everywhere. */
|
|
@@ -34,7 +35,7 @@ const platformsOf = (a) => {
|
|
|
34
35
|
return ["android", "ios"];
|
|
35
36
|
if (p === "android" || p === "ios")
|
|
36
37
|
return [p];
|
|
37
|
-
throw new
|
|
38
|
+
throw new UsageError(`--platform must be android, ios or all (got "${a.platform}")`);
|
|
38
39
|
};
|
|
39
40
|
/** Unknown flags are errors: a typo like --dryrun must not fall through to a real publish. */
|
|
40
41
|
function checkFlags(argv, known) {
|
|
@@ -42,7 +43,7 @@ function checkFlags(argv, known) {
|
|
|
42
43
|
if (bad.length) {
|
|
43
44
|
const eq = bad.find((x) => x.includes("="));
|
|
44
45
|
const hint = eq ? ` Write "${eq.replace("=", " ")}" (a space, not =).` : "";
|
|
45
|
-
throw new
|
|
46
|
+
throw new UsageError(`Unknown flag ${bad.join(", ")}.${hint} Known flags: ${known.map((k) => "--" + k).join(" ")}`);
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
/** An on/off flag: bare or "true" is on, "false" is off. Anything else is an error, so a typo never flips it silently. */
|
|
@@ -52,7 +53,12 @@ function switchFlag(a, name) {
|
|
|
52
53
|
return false;
|
|
53
54
|
if (v === "true")
|
|
54
55
|
return true;
|
|
55
|
-
throw new
|
|
56
|
+
throw new UsageError(`--${name} is an on/off flag: use --${name}, --${name} true or --${name} false (got "${v}")`);
|
|
57
|
+
}
|
|
58
|
+
/** True when `file` is strictly inside `root`. Uses the platform's separator, so it works with Windows paths too. */
|
|
59
|
+
export function isInside(root, file, p = path) {
|
|
60
|
+
const rel = p.relative(root, file);
|
|
61
|
+
return rel !== "" && !rel.startsWith("..") && !p.isAbsolute(rel);
|
|
56
62
|
}
|
|
57
63
|
/** A store that only writes files under `dir` (key `p/app/1/b0` becomes `dir/p/app/1/b0`). It reads back only what this run wrote (kept in memory), never old files, so the iOS pass can find the web artifact and a dry run never needs a control plane. */
|
|
58
64
|
function localKv(dir) {
|
|
@@ -65,7 +71,7 @@ function localKv(dir) {
|
|
|
65
71
|
async keys(prefix) { return [...mem.keys()].filter((k) => k.startsWith(prefix)); },
|
|
66
72
|
async put(k, body, _type) {
|
|
67
73
|
const f = resolve(root, k);
|
|
68
|
-
if (!
|
|
74
|
+
if (!isInside(root, f))
|
|
69
75
|
throw new Error(`refusing to write outside ${dir}: ${k}`);
|
|
70
76
|
const bytes = typeof body === "string" ? enc.encode(body) : new Uint8Array(body);
|
|
71
77
|
mkdirSync(dirname(f), { recursive: true });
|
|
@@ -74,7 +80,7 @@ function localKv(dir) {
|
|
|
74
80
|
},
|
|
75
81
|
};
|
|
76
82
|
}
|
|
77
|
-
const PUBLISH_FLAGS = ["bands", "platform", "release", "min-shell", "hermes-bytecode-version", "max-bases", "skip-export", "skip-web", "dry-run", "dry-run-policy", "out"];
|
|
83
|
+
const PUBLISH_FLAGS = ["bands", "platform", "release", "min-shell", "hermes-bytecode-version", "max-bases", "skip-export", "skip-web", "dry-run", "dry-run-policy", "out", "json"];
|
|
78
84
|
export const PUBLISH_HELP = `Usage: shipvo publish [flags]
|
|
79
85
|
|
|
80
86
|
Build, sign and publish an over-the-air release.
|
|
@@ -91,37 +97,46 @@ Flags (write a value after a space, not =; on/off flags can be bare, or true/fal
|
|
|
91
97
|
--dry-run Build and sign, write files locally, send nothing
|
|
92
98
|
--dry-run-policy Check the policy in shipvo.json for these --bands, print the result, publish nothing
|
|
93
99
|
--out <dir> Where --dry-run writes files (default .shipvo/dry-run)
|
|
100
|
+
--json Print one JSON envelope on stdout; text and build output go to stderr
|
|
94
101
|
--help Show this help
|
|
95
102
|
`;
|
|
103
|
+
const DOCTOR = [{ command: "shipvo doctor --json", why: "Check your setup." }];
|
|
96
104
|
export async function publish(argv) {
|
|
97
105
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
98
106
|
console.log(PUBLISH_HELP);
|
|
99
107
|
return;
|
|
100
108
|
}
|
|
109
|
+
if (argv.includes("--json"))
|
|
110
|
+
return runJson(() => publishRun(argv, true), DOCTOR);
|
|
111
|
+
await publishRun(argv, false);
|
|
112
|
+
}
|
|
113
|
+
async function publishRun(argv, json) {
|
|
114
|
+
// In --json mode the build tools must not write to stdout either.
|
|
115
|
+
const io = json ? ["inherit", 2, 2] : "inherit";
|
|
101
116
|
checkFlags(argv, PUBLISH_FLAGS);
|
|
102
117
|
const a = parse(argv), cfg = loadConfig(), SEED = seed();
|
|
103
118
|
if (switchFlag(a, "dry-run-policy")) {
|
|
104
119
|
const verdict = checkPublishPolicy(cfg.policy, (a.bands || "b0,b1,b2").split(","));
|
|
105
|
-
if (verdict.ok)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
process.exitCode = 1;
|
|
120
|
+
if (verdict.ok) {
|
|
121
|
+
const msg = cfg.policy === undefined ? "No policy in shipvo.json: nothing to check." : "Policy allows this publish.";
|
|
122
|
+
console.log(msg);
|
|
123
|
+
return { code: "policy_ok", summary: msg, data: { policy: cfg.policy === undefined ? "none" : "allowed", bands: (a.bands || "b0,b1,b2").split(",") }, next: [{ command: "shipvo publish --dry-run --json", why: "Build and sign without sending." }] };
|
|
110
124
|
}
|
|
111
|
-
return;
|
|
125
|
+
return refused(verdict, json);
|
|
112
126
|
}
|
|
113
127
|
const dry = switchFlag(a, "dry-run"), skipExport = switchFlag(a, "skip-export"), skipWeb = switchFlag(a, "skip-web");
|
|
114
128
|
if (a.out !== undefined && !dry)
|
|
115
|
-
throw new
|
|
129
|
+
throw new UsageError("--out only works with --dry-run");
|
|
116
130
|
if (a.out === "true")
|
|
117
|
-
throw new
|
|
131
|
+
throw new UsageError("--out needs a directory: pass --out <dir>");
|
|
118
132
|
const outDir = a.out || ".shipvo/dry-run";
|
|
119
133
|
const kv = dry ? localKv(outDir) : kvFor(cfg);
|
|
120
134
|
const APP = cfg.appId, MAJOR = cfg.shellMajor;
|
|
121
135
|
const MIN_SHELL = a["min-shell"] || JSON.parse(readFileSync("app.json", "utf8")).expo.version; // a bundle needs the shell it was built against
|
|
122
136
|
if (a["hermes-bytecode-version"] === "true")
|
|
123
|
-
throw new
|
|
137
|
+
throw new UsageError("--hermes-bytecode-version needs a value: pass a value like 96 (or 96,98)");
|
|
124
138
|
const declaredHermes = parseDeclared(a["hermes-bytecode-version"] ?? cfg.hermesBytecodeVersion);
|
|
139
|
+
const hermesEngine = checkEngine(installedHermesEngine(process.cwd()), parseEngine(cfg.hermesEngine)); // fails before anything is uploaded
|
|
125
140
|
const skippedNative = [];
|
|
126
141
|
const nativeModules = requiredNativeModules(".", skippedNative);
|
|
127
142
|
if (skippedNative.length)
|
|
@@ -131,11 +146,8 @@ export async function publish(argv) {
|
|
|
131
146
|
const bands = (a.bands || "b0,b1,b2").split(",");
|
|
132
147
|
if (dry) {
|
|
133
148
|
const verdict = checkPublishPolicy(cfg.policy, bands);
|
|
134
|
-
if (!verdict.ok)
|
|
135
|
-
|
|
136
|
-
process.exitCode = 1;
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
149
|
+
if (!verdict.ok)
|
|
150
|
+
return refused(verdict, json);
|
|
139
151
|
}
|
|
140
152
|
const platforms = platformsOf(a);
|
|
141
153
|
const release = Number(a.release || 0) || (await currentRelease(kv, APP, MAJOR)) + 1;
|
|
@@ -143,16 +155,16 @@ export async function publish(argv) {
|
|
|
143
155
|
if (dry)
|
|
144
156
|
console.error(`dry run: nothing is sent anywhere; files go to ${outDir}. No patches are built offline. Release is ${release}${a.release ? "" : " (pass --release N to match the control plane)"}.`);
|
|
145
157
|
let totalBytes = 0;
|
|
146
|
-
const patchInfo = { bases: 0,
|
|
158
|
+
const patchInfo = { bases: 0, patches: 0 };
|
|
147
159
|
for (const pf of platforms) {
|
|
148
160
|
// Android published alone keeps the original dist-ota/ layout, so existing CI keeps working.
|
|
149
161
|
const root = platforms.length === 1 && pf === "android" ? "dist-ota" : `dist-ota/${pf}`;
|
|
150
162
|
if (!skipExport)
|
|
151
|
-
execSync(`npx expo export --platform ${pf} --output-dir ${root} --clear`, { stdio:
|
|
163
|
+
execSync(`npx expo export --platform ${pf} --output-dir ${root} --clear`, { stdio: io });
|
|
152
164
|
const jsDir = `${root}/_expo/static/js/${pf}`;
|
|
153
165
|
const file = readdirSync(jsDir).find((f) => f.endsWith(".hbc")) || readdirSync(jsDir).find((f) => f.endsWith(".js"));
|
|
154
166
|
if (!file)
|
|
155
|
-
throw new
|
|
167
|
+
throw new CliFailure("no bundle in " + jsDir, "no_bundle", 1, [{ command: "shipvo publish --json", why: "Run again without --skip-export so the bundle is built." }]);
|
|
156
168
|
const bytes = new Uint8Array(readFileSync(join(jsDir, file)));
|
|
157
169
|
const hermesVersion = checkBundle(file, bytes, declaredHermes); // fails before anything is uploaded
|
|
158
170
|
const name = bundleName(release, pf), newSha = sha256Hex(bytes);
|
|
@@ -180,29 +192,23 @@ export async function publish(argv) {
|
|
|
180
192
|
}
|
|
181
193
|
catch { }
|
|
182
194
|
}
|
|
183
|
-
//
|
|
184
|
-
|
|
195
|
+
// Memory grows with bundle size (plain arrays); only a thrown error falls back to the full bundle.
|
|
196
|
+
// Patches are built in-process (no Python or other tool); a first release has no older bundle to patch from.
|
|
185
197
|
if (bases.size > 0) {
|
|
186
|
-
try {
|
|
187
|
-
execSync("python3 -c 'import bsdiff4'", { stdio: "ignore" });
|
|
188
|
-
bsdiff = true;
|
|
189
|
-
}
|
|
190
|
-
catch {
|
|
191
|
-
console.error("Small patches are off because the Python tool bsdiff4 is not installed (pip install bsdiff4). Full bundles are published instead. Updates still work, they are just bigger.");
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
if (bsdiff) {
|
|
195
|
-
const work = mkdtempSync(join(tmpdir(), "shipvo-"));
|
|
196
|
-
writeFileSync(join(work, "new.hbc"), bytes);
|
|
197
198
|
for (const [fromSha, key] of bases) {
|
|
198
199
|
if (fromSha === newSha)
|
|
199
200
|
continue;
|
|
200
201
|
const base = await kv.getBytes(key);
|
|
201
202
|
if (!base)
|
|
202
203
|
continue;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
204
|
+
let patch;
|
|
205
|
+
try {
|
|
206
|
+
patch = bsdiff(new Uint8Array(base), bytes);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
console.error(`Could not build a small patch from release ${fromSha.slice(0, 8)}; publishing the full bundle`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
206
212
|
if (patch.length >= bytes.length * 0.6)
|
|
207
213
|
continue;
|
|
208
214
|
const pkey = `b/${APP}/${pf === "ios" ? "ios-" : ""}d-${fromSha.slice(0, 16)}-r${release}.bsdiff`;
|
|
@@ -211,14 +217,13 @@ export async function publish(argv) {
|
|
|
211
217
|
}
|
|
212
218
|
}
|
|
213
219
|
patchInfo.bases = bases.size;
|
|
214
|
-
patchInfo.bsdiff = bsdiff;
|
|
215
220
|
patchInfo.patches = patches.length;
|
|
216
221
|
console.error(`patches: ${patches.map((p) => `${p.from_sha256.slice(0, 8)}=${(p.size / 1024).toFixed(1)}KB`).join(", ") || "none"}`);
|
|
217
222
|
}
|
|
218
223
|
// The agent-facing web artifact of the same commit, served at /w/<app>/r<N>/ for the simulator (built once, with the first platform).
|
|
219
224
|
let web = null;
|
|
220
225
|
if (!skipWeb && pf === platforms[0]) {
|
|
221
|
-
web = await publishWeb(kv, cfg, APP, release, dry ? outDir : undefined);
|
|
226
|
+
web = await publishWeb(kv, cfg, APP, release, dry ? outDir : undefined, io);
|
|
222
227
|
}
|
|
223
228
|
else if (!skipWeb) {
|
|
224
229
|
try {
|
|
@@ -230,10 +235,10 @@ export async function publish(argv) {
|
|
|
230
235
|
for (const band of bands) {
|
|
231
236
|
const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, ...(pf === "ios" ? { platform: "ios" } : {}), release, issued_at,
|
|
232
237
|
core: { version: `1.0.${release}`, sha256: newSha, size: bytes.length, url: `b/${APP}/${name}`, patches },
|
|
233
|
-
addons: [], screens: [], min_shell_version: MIN_SHELL, ...(hermesVersion ? { hermes_bytecode_version: hermesVersion } : {}), ...(nativeModules ? { required_native_modules: nativeModules } : {}), ...(web ? { web } : {}) });
|
|
238
|
+
addons: [], screens: [], min_shell_version: MIN_SHELL, ...(hermesVersion ? { hermes_bytecode_version: hermesVersion } : {}), ...(hermesVersion && hermesEngine ? { hermes_engine: hermesEngine } : {}), ...(nativeModules ? { required_native_modules: nativeModules } : {}), ...(web ? { web } : {}) });
|
|
234
239
|
await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
|
|
235
240
|
}
|
|
236
|
-
await kv.put(pf === "ios" ? `h/${APP}/ios-r${release}` : `h/${APP}/r${release}`, JSON.stringify({ release, issued_at, sha256: newSha, size: bytes.length, min_shell_version: MIN_SHELL, ...(hermesVersion ? { hermes_bytecode_version: hermesVersion } : {}), ...(nativeModules ? { required_native_modules: nativeModules } : {}), web }), "application/json");
|
|
241
|
+
await kv.put(pf === "ios" ? `h/${APP}/ios-r${release}` : `h/${APP}/r${release}`, JSON.stringify({ release, issued_at, sha256: newSha, size: bytes.length, min_shell_version: MIN_SHELL, ...(hermesVersion ? { hermes_bytecode_version: hermesVersion } : {}), ...(hermesVersion && hermesEngine ? { hermes_engine: hermesEngine } : {}), ...(nativeModules ? { required_native_modules: nativeModules } : {}), web }), "application/json");
|
|
237
242
|
totalBytes += bytes.length;
|
|
238
243
|
summary[pf] = { bytes: bytes.length, patches: patches.map((p) => ({ from: p.from_sha256.slice(0, 12), kb: +(p.size / 1024).toFixed(1) })) };
|
|
239
244
|
if (web)
|
|
@@ -247,14 +252,41 @@ export async function publish(argv) {
|
|
|
247
252
|
if (dry)
|
|
248
253
|
console.log(dryRunSummary(release, totalBytes, outDir, patchInfo));
|
|
249
254
|
else
|
|
250
|
-
console.log(
|
|
255
|
+
console.log(publishedSummary(release, bands));
|
|
256
|
+
const flags = [a.bands ? `--bands ${a.bands}` : "", a.platform ? `--platform ${a.platform}` : ""].filter(Boolean).join(" ");
|
|
257
|
+
const data = { ...summary, platforms, total_bytes: totalBytes };
|
|
258
|
+
if (dry)
|
|
259
|
+
return { code: "dry_run", summary: `Dry run complete: release ${release}, ${formatSize(totalBytes)}. Nothing was sent.`, data, next: [{ command: `shipvo publish ${flags} --json`.replace(/ +/g, " "), why: "Publish for real." }] };
|
|
260
|
+
const rest = ["b0", "b1", "b2"].filter((b) => !bands.includes(b));
|
|
261
|
+
const next = [{ command: "shipvo status --json", why: "See how the release does on phones." }];
|
|
262
|
+
if (rest.length)
|
|
263
|
+
next.push({ command: `shipvo promote --to ${rest.join(",")} --json`, why: `Widen release ${release} to ${rest.join(", ")} when it looks healthy.` });
|
|
264
|
+
return { code: "published", summary: `Published release ${release} to ${bands.join(", ")} (${platforms.join(", ")}).`, data, next };
|
|
265
|
+
}
|
|
266
|
+
function refused(verdict, json) {
|
|
267
|
+
const msg = `Refused by policy rule ${verdict.rule}: ${verdict.reason}`;
|
|
268
|
+
if (json)
|
|
269
|
+
throw new CliFailure(msg, "policy_refused", 1, [{ command: "shipvo publish --dry-run-policy --json", why: "Check the policy for other --bands." }]);
|
|
270
|
+
console.error(msg);
|
|
271
|
+
process.exitCode = 1;
|
|
272
|
+
return { code: "policy_refused", summary: msg, data: {}, next: [] };
|
|
273
|
+
}
|
|
274
|
+
/** The publish result in plain words. `--json` carries the full detail. */
|
|
275
|
+
export function publishedSummary(release, bands) {
|
|
276
|
+
const rest = ["b0", "b1", "b2"].filter((b) => !bands.includes(b));
|
|
277
|
+
const next = rest.length ? `Next: shipvo status, then shipvo promote --to ${rest.join(",")}.` : "Next: shipvo status.";
|
|
278
|
+
return `Published release ${release} to ${bands.join(", ")}. ${next}`;
|
|
279
|
+
}
|
|
280
|
+
/** The rollback result in plain words. */
|
|
281
|
+
export function rolledBackSummary(to, release) {
|
|
282
|
+
return `Rolled back to the code of release ${to} as new release ${release}. Next: shipvo status.`;
|
|
251
283
|
}
|
|
252
284
|
/** Bytes in plain units: KB below 1 MB, so a small bundle never reads as 0.00 MB. */
|
|
253
285
|
export function formatSize(bytes) {
|
|
254
286
|
return bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(2)} MB`;
|
|
255
287
|
}
|
|
256
288
|
/** The dry-run result in plain words. `info` says what happened with patches on Android. */
|
|
257
|
-
export function dryRunSummary(release, bytes, outDir, info = { bases: 0,
|
|
289
|
+
export function dryRunSummary(release, bytes, outDir, info = { bases: 0, patches: 0 }) {
|
|
258
290
|
const lines = [
|
|
259
291
|
`Dry run complete. Nothing was sent.`,
|
|
260
292
|
` Release: ${release}`,
|
|
@@ -265,14 +297,32 @@ export function dryRunSummary(release, bytes, outDir, info = { bases: 0, bsdiff:
|
|
|
265
297
|
lines.push(`Patches are not built in a dry run, because older releases are not read. Publish for real to build them.`);
|
|
266
298
|
else if (info.bases === 0)
|
|
267
299
|
lines.push(`Full bundles are fine for a first release. Small patches matter once you have older releases to compare against.`);
|
|
268
|
-
else if (
|
|
269
|
-
lines.push(`
|
|
300
|
+
else if (info.patches === 0)
|
|
301
|
+
lines.push(`No small patch was built, so this update is a full bundle. Full bundles still work.`);
|
|
270
302
|
else
|
|
271
303
|
lines.push(`Patches built: ${info.patches}.`);
|
|
272
304
|
return lines.join("\n");
|
|
273
305
|
}
|
|
274
|
-
|
|
275
|
-
|
|
306
|
+
/** The line for a listing file that is not there. It says the file is optional and what to do. */
|
|
307
|
+
export function missingListingNote(file, name) {
|
|
308
|
+
if (name === "icon")
|
|
309
|
+
return `No app icon yet (optional). Add ${file} to show one in the store.`;
|
|
310
|
+
if (name.startsWith("shot-"))
|
|
311
|
+
return `No screenshot yet (optional). Add ${file} to show it in the store.`;
|
|
312
|
+
return `No ${name} yet (optional). Add ${file} to show it in the store.`;
|
|
313
|
+
}
|
|
314
|
+
/** Upload one listing file to the store prefix. A missing file prints a note and returns null. */
|
|
315
|
+
export async function uploadListingFile(kv, store, file, name) {
|
|
316
|
+
if (!existsSync(file)) {
|
|
317
|
+
console.error(missingListingNote(file, name));
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
|
|
321
|
+
await kv.put(`${store}/${name}${ext}`, new Uint8Array(readFileSync(file)), MIME[ext] ?? "application/octet-stream");
|
|
322
|
+
return `${name}${ext}`;
|
|
323
|
+
}
|
|
324
|
+
async function publishWeb(kv, cfg, APP, release, outDir, io = "inherit") {
|
|
325
|
+
execSync("npx expo export --platform web --output-dir dist-web --clear", { stdio: io, env: { ...process.env, PN_SIM: "1" } });
|
|
276
326
|
const base = `w/${APP}/r${release}`;
|
|
277
327
|
const files = [];
|
|
278
328
|
let indexSha = "";
|
|
@@ -304,10 +354,7 @@ async function publishWeb(kv, cfg, APP, release, outDir) {
|
|
|
304
354
|
// Store listing assets (icon, screenshots) and the release note, read by shipvo.dev/apps and list_apps.
|
|
305
355
|
const L = cfg.listing ?? {};
|
|
306
356
|
const store = `w/${APP}/store`;
|
|
307
|
-
const up =
|
|
308
|
-
console.error(`listing: ${file} not found, skipped`);
|
|
309
|
-
return null;
|
|
310
|
-
} const ext = file.slice(file.lastIndexOf(".")).toLowerCase(); await kv.put(`${store}/${name}${ext}`, new Uint8Array(readFileSync(file)), MIME[ext] ?? "application/octet-stream"); return `${name}${ext}`; };
|
|
357
|
+
const up = (file, name) => uploadListingFile(kv, store, file, name);
|
|
311
358
|
const icon = L.icon ? await up(L.icon, "icon") : null;
|
|
312
359
|
const shots = [];
|
|
313
360
|
for (const [i, f] of (L.screenshots ?? []).entries()) {
|
|
@@ -325,11 +372,23 @@ async function publishWeb(kv, cfg, APP, release, outDir) {
|
|
|
325
372
|
return web;
|
|
326
373
|
}
|
|
327
374
|
export async function rollback(argv) {
|
|
328
|
-
|
|
329
|
-
|
|
375
|
+
if (argv.includes("--json"))
|
|
376
|
+
return runJson(() => rollbackRun(argv, true), [...DOCTOR, { command: "shipvo status --json", why: "See the newest releases." }]);
|
|
377
|
+
await rollbackRun(argv, false);
|
|
378
|
+
}
|
|
379
|
+
async function rollbackRun(argv, json) {
|
|
380
|
+
checkFlags(argv, ["to", "bands", "platform", "dry-run", "json"]);
|
|
381
|
+
const a = parse(argv), dry = a["dry-run"] !== undefined, cfg = loadConfig();
|
|
382
|
+
const offline = dry && !hasStore();
|
|
383
|
+
const SEED = dry ? "" : seed(), kv = offline ? offlineKv() : kvFor(cfg);
|
|
384
|
+
if (offline)
|
|
385
|
+
console.error("Offline rehearsal: reading releases from .shipvo/dry-run (written by shipvo publish --dry-run). No login used, nothing is sent.");
|
|
330
386
|
const APP = cfg.appId, MAJOR = cfg.shellMajor, to = Number(a.to);
|
|
331
387
|
if (!to) {
|
|
332
|
-
|
|
388
|
+
const usage = "Usage: shipvo rollback --to <release> [--bands b0,b1,b2] [--platform android|ios|all] [--dry-run] [--json]";
|
|
389
|
+
if (json)
|
|
390
|
+
throw new UsageError(usage);
|
|
391
|
+
console.error(usage);
|
|
333
392
|
process.exit(2);
|
|
334
393
|
}
|
|
335
394
|
const release = (await currentRelease(kv, APP, MAJOR)) + 1, issued_at = new Date().toISOString();
|
|
@@ -338,6 +397,8 @@ export async function rollback(argv) {
|
|
|
338
397
|
for (const pf of platforms) {
|
|
339
398
|
const bytes = await kv.getBytes(`b/${APP}/${bundleName(to, pf)}`);
|
|
340
399
|
if (!bytes) {
|
|
400
|
+
if (json)
|
|
401
|
+
throw new CliFailure(`Release ${to} ${pf} bundle not found`, "not_found", 1, [{ command: "shipvo status --json", why: "See which releases exist." }]);
|
|
341
402
|
console.error(`Release ${to} ${pf} bundle not found`);
|
|
342
403
|
process.exit(1);
|
|
343
404
|
}
|
|
@@ -355,9 +416,12 @@ export async function rollback(argv) {
|
|
|
355
416
|
await kv.put(pointerKey(APP, MAJOR, band, pf), JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
|
|
356
417
|
}
|
|
357
418
|
}
|
|
419
|
+
const data = { app: APP, rolled_back_to_code_of: to, new_release: release, min_shell_version: minShell, issued_at, platforms, bands, dry_run: dry };
|
|
358
420
|
if (dry) {
|
|
359
421
|
console.log([`Rollback preview for ${APP}:`, ` Rolling back to the code of release ${to}`, ` New release number: ${release}`, ` Platforms: ${platforms.join(", ")}`, ` Bands: ${bands.join(", ")}`, ` Min shell version: ${minShell}`, `Dry run: nothing was written.`].join("\n"));
|
|
360
|
-
|
|
422
|
+
const real = argv.filter((x) => x !== "--dry-run").join(" ");
|
|
423
|
+
return { code: "dry_run", summary: `Rollback preview: release ${to} code becomes release ${release}. Nothing was written.`, data, next: [{ command: `shipvo rollback ${real}`, why: "Do the rollback." }] };
|
|
361
424
|
}
|
|
362
|
-
console.log(
|
|
425
|
+
console.log(rolledBackSummary(to, release));
|
|
426
|
+
return { code: "rolled_back", summary: `Rolled back to the code of release ${to} as release ${release}.`, data, next: [{ command: "shipvo status --json", why: "Check that phones pick up the rollback." }] };
|
|
363
427
|
}
|
package/dist/qr.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const QR_MAX_BYTES = 134;
|
|
2
|
+
/** Reed-Solomon error codewords for `data`. */
|
|
3
|
+
export declare function rsRemainder(data: number[], degree: number): number[];
|
|
4
|
+
/** The 15 format bits for level L and a mask number. */
|
|
5
|
+
export declare function formatBits(mask: number): number;
|
|
6
|
+
/** Encodes text as a QR code. Returns rows of modules, true for dark. Throws when the text is over 134 bytes. */
|
|
7
|
+
export declare function qrMatrix(text: string): boolean[][];
|
|
8
|
+
/** Draws the matrix for a terminal: black on white with ANSI colours, two rows per line, a 2 module quiet zone. */
|
|
9
|
+
export declare function qrToTerminal(matrix: boolean[][], quiet?: number): string;
|