@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.
Files changed (77) hide show
  1. package/README.md +3 -0
  2. package/dist/adopt.js +1 -2
  3. package/dist/agents.d.ts +15 -0
  4. package/dist/agents.js +68 -9
  5. package/dist/approvals.d.ts +9 -0
  6. package/dist/approvals.js +106 -0
  7. package/dist/audit.d.ts +1 -1
  8. package/dist/audit.js +28 -11
  9. package/dist/autohalt.d.ts +1 -1
  10. package/dist/autohalt.js +28 -16
  11. package/dist/bsdiff.d.ts +4 -0
  12. package/dist/bsdiff.js +465 -0
  13. package/dist/ci-comment.d.ts +38 -0
  14. package/dist/ci-comment.js +169 -0
  15. package/dist/ci-compare.d.ts +10 -0
  16. package/dist/ci-compare.js +25 -0
  17. package/dist/ci-shots.d.ts +9 -0
  18. package/dist/ci-shots.js +81 -0
  19. package/dist/ci.d.ts +8 -0
  20. package/dist/ci.js +64 -6
  21. package/dist/cli.js +23 -1
  22. package/dist/config.d.ts +13 -0
  23. package/dist/config.js +43 -3
  24. package/dist/doctor.js +63 -7
  25. package/dist/ensure-playwright.d.ts +4 -0
  26. package/dist/ensure-playwright.js +7 -2
  27. package/dist/explore-issues.d.ts +12 -0
  28. package/dist/explore-issues.js +50 -0
  29. package/dist/explore.d.ts +83 -7
  30. package/dist/explore.js +326 -77
  31. package/dist/flows.d.ts +31 -0
  32. package/dist/flows.js +264 -0
  33. package/dist/help.d.ts +2 -2
  34. package/dist/help.js +19 -10
  35. package/dist/hermes.d.ts +10 -0
  36. package/dist/hermes.js +50 -0
  37. package/dist/init.d.ts +1 -0
  38. package/dist/init.js +41 -6
  39. package/dist/json-out.d.ts +53 -0
  40. package/dist/json-out.js +91 -0
  41. package/dist/keys-json.d.ts +1 -0
  42. package/dist/keys-json.js +71 -0
  43. package/dist/login.js +27 -11
  44. package/dist/lumi.d.ts +104 -0
  45. package/dist/lumi.js +364 -0
  46. package/dist/migrate.d.ts +27 -0
  47. package/dist/migrate.js +521 -0
  48. package/dist/preview.d.ts +1 -0
  49. package/dist/preview.js +4 -2
  50. package/dist/promote.d.ts +1 -1
  51. package/dist/promote.js +38 -16
  52. package/dist/publish.d.ts +14 -2
  53. package/dist/publish.js +126 -62
  54. package/dist/qr.d.ts +9 -0
  55. package/dist/qr.js +213 -0
  56. package/dist/report-md.d.ts +1 -0
  57. package/dist/report-md.js +1 -1
  58. package/dist/rotation.d.ts +57 -0
  59. package/dist/rotation.js +157 -0
  60. package/dist/run-bridge.js +356 -2
  61. package/dist/run-net.d.ts +39 -0
  62. package/dist/run-net.js +266 -0
  63. package/dist/run.d.ts +23 -0
  64. package/dist/run.js +101 -17
  65. package/dist/signing.js +13 -0
  66. package/dist/sim.js +7 -3
  67. package/dist/smoke.d.ts +28 -0
  68. package/dist/smoke.js +117 -0
  69. package/dist/spawn-bin.d.ts +11 -0
  70. package/dist/spawn-bin.js +21 -0
  71. package/dist/status.d.ts +26 -4
  72. package/dist/status.js +69 -5
  73. package/dist/templates/github-action/shipvo-check.yml +18 -0
  74. package/dist/test.d.ts +1 -1
  75. package/dist/test.js +81 -18
  76. package/package.json +4 -3
  77. package/skills/shipvo-cli/SKILL.md +31 -0
package/README.md CHANGED
@@ -18,6 +18,7 @@ npx -y @shipvo/cli sim # serve the app in the simulator for age
18
18
  | `sim [--port 4545]` | Build for the simulator and serve it |
19
19
  | `publish [--bands b0,b1,b2]` | Build, sign and publish a release plus the agent-facing web artifact |
20
20
  | `rollback --to <release>` | Republish an earlier release's code as a new release |
21
+ | `test [files] --report <file> --release <n> [--platform android\|ios] --upload` | Run plain-language tests and send the results to the control plane; `status` then shows "tests: N failing" per release |
21
22
  | `login` | Sign in with GitHub; issues a 30-day key |
22
23
  | `app create` / `list` / `delete <id> --yes` | Register the app (publish token), list yours, or remove one |
23
24
  | `keys` | Status and quota of the stored sign-in key |
@@ -28,6 +29,8 @@ npx -y @shipvo/cli sim # serve the app in the simulator for age
28
29
  | `pubkey` | Print the public key for the current seed |
29
30
  | `hash <file>`, `sign-file <file>`, `sign-json <file>`, `verify-json <file> <key>`, `verify <pointer> [key]` (release pointers) | Low-level signing helpers |
30
31
 
32
+ `init`, `login`, `audit` and `keys` take `--json`: one `{ ok, code, summary, data, next }` document on stdout, progress on stderr. Exit codes: 0 ok, 1 failed, 2 usage error. The codes and `data` shapes are in [docs/cli.md](../../docs/cli.md#--json-for-init-login-audit-and-keys). `keys export --json` needs `--out`, and no envelope holds a seed or token.
33
+
31
34
  Changing computers? See [Moving to a new machine](new-machine.md).
32
35
 
33
36
  Also usable as a library: `signDoc`, `verifyDoc`, `signBytes`, `sha256Hex`, `keyFromSeed`.
package/dist/adopt.js CHANGED
@@ -86,7 +86,7 @@ export async function adopt(argv, dir = process.cwd(), overrides = {}) {
86
86
  }
87
87
  if (p.expoUpdates || p.codePush) {
88
88
  const what = p.expoUpdates ? "expo-updates" : "react-native-code-push";
89
- return fail(`shipvo: this app uses ${what}. Two over-the-air systems would fight over the bundle, so I stopped and changed nothing.\nRemove ${what} first. Steps: docs/migrate-from-codepush.md`);
89
+ return fail(`shipvo: this app uses ${what}. Two over-the-air systems would fight over the bundle, so I stopped and changed nothing.\n${p.expoUpdates ? "Run `shipvo migrate --plan` to see the steps. Guide: docs/migrate-from-eas-update.md" : "Remove react-native-code-push first. Steps: docs/migrate-from-codepush.md"}`);
90
90
  }
91
91
  let manifest = null, infoPlist;
92
92
  if (bare) {
@@ -248,7 +248,6 @@ jobs:
248
248
  - uses: actions/setup-node@v4
249
249
  with: { node-version: 22 }
250
250
  - run: ${pm === "npm" ? "npm ci" : pm === "pnpm" ? "corepack enable && pnpm install --frozen-lockfile" : pm === "yarn" ? "corepack enable && yarn install --immutable" : "npm i -g bun && bun install --frozen-lockfile"}
251
- - run: pip install --break-system-packages bsdiff4
252
251
  - name: Build, sign and publish over the air
253
252
  env:
254
253
  SHIPVO_PUBLISH_TOKEN: \${{ secrets.SHIPVO_PUBLISH_TOKEN }}
package/dist/agents.d.ts CHANGED
@@ -10,5 +10,20 @@ export declare function agentsSection(kind: ProjectKind): string;
10
10
  export declare function upsertSection(text: string, section: string): string | null;
11
11
  /** Add the `shipvo` server and keep every other server and key. Returns null if the file is not valid JSON. */
12
12
  export declare function mergeMcp(text: string | null): string | null;
13
+ /** Where synced skills are copied to, relative to the project. */
14
+ export declare const SKILL_DIRS: string[];
15
+ export type SkillEntry = {
16
+ package: string;
17
+ name: string;
18
+ source: string;
19
+ };
20
+ /** Skills shipped by the `@shipvo/*` packages this project depends on and has installed. */
21
+ export declare function projectSkills(dir: string): SkillEntry[];
22
+ /** For `status --json`: each package skill and whether every target holds the current copy. */
23
+ export declare function skillStatus(dir: string): {
24
+ package: string;
25
+ name: string;
26
+ synced: boolean;
27
+ }[];
13
28
  /** `shipvo agents setup [--dry-run] [--json]`. Exit 0 done, 2 error. */
14
29
  export declare function agentsCmd(argv: string[], dir?: string): Promise<number>;
package/dist/agents.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { args } from "./config.js";
4
4
  export const BEGIN = "<!-- shipvo:begin -->";
@@ -68,13 +68,63 @@ export function mergeMcp(text) {
68
68
  servers.shipvo = SERVER;
69
69
  return JSON.stringify({ ...doc, mcpServers: servers }, null, 2) + "\n";
70
70
  }
71
+ /** Where synced skills are copied to, relative to the project. */
72
+ export const SKILL_DIRS = [".agents/skills", ".claude/skills"];
73
+ /** Skills shipped by the `@shipvo/*` packages this project depends on and has installed. */
74
+ export function projectSkills(dir) {
75
+ let deps = {};
76
+ try {
77
+ const p = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
78
+ deps = { ...p.peerDependencies, ...p.devDependencies, ...p.dependencies };
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ const out = [];
84
+ for (const pkg of Object.keys(deps).filter((d) => d.startsWith("@shipvo/")).sort()) {
85
+ let root = dir, found = null;
86
+ for (;;) {
87
+ const c = join(root, "node_modules", pkg, "skills");
88
+ if (existsSync(c)) {
89
+ found = c;
90
+ break;
91
+ }
92
+ const up = dirname(root);
93
+ if (up === root)
94
+ break;
95
+ root = up;
96
+ }
97
+ if (!found)
98
+ continue;
99
+ for (const n of readdirSync(found).sort()) {
100
+ const source = join(found, n, "SKILL.md");
101
+ if (existsSync(source))
102
+ out.push({ package: pkg, name: n, source });
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+ /** Which skill files (project-relative) are missing or out of date. */
108
+ function skillTargets(dir, e) {
109
+ const text = readFileSync(e.source, "utf8");
110
+ return SKILL_DIRS.map((d) => {
111
+ const file = `${d}/${e.name}/SKILL.md`;
112
+ const path = join(dir, file);
113
+ return { file, text, synced: existsSync(path) && readFileSync(path, "utf8") === text };
114
+ });
115
+ }
116
+ /** For `status --json`: each package skill and whether every target holds the current copy. */
117
+ export function skillStatus(dir) {
118
+ return projectSkills(dir).map((e) => ({ package: e.package, name: e.name, synced: skillTargets(dir, e).every((t) => t.synced) }));
119
+ }
71
120
  /** `shipvo agents setup [--dry-run] [--json]`. Exit 0 done, 2 error. */
72
121
  export async function agentsCmd(argv, dir = process.cwd()) {
73
122
  const [sub, ...rest] = argv;
74
- if (sub !== "setup") {
123
+ if (sub !== "setup" && sub !== "skills-sync") {
75
124
  console.error("Usage: shipvo agents setup [--dry-run] [--json]");
76
125
  return 2;
77
126
  }
127
+ const only = sub === "skills-sync";
78
128
  const a = args(rest);
79
129
  const dry = a["dry-run"] !== undefined, json = a.json !== undefined;
80
130
  const kind = detectProject(dir);
@@ -95,12 +145,18 @@ export async function agentsCmd(argv, dir = process.cwd()) {
95
145
  writes.push({ path, text: next });
96
146
  };
97
147
  const read = (f) => existsSync(join(dir, f)) ? readFileSync(join(dir, f), "utf8") : null;
98
- plan("AGENTS.md", upsertSection(read("AGENTS.md") ?? "", agentsSection(kind)), "AGENTS.md has a broken shipvo marker; fix it and run again.");
99
- const targets = [".mcp.json", ...(existsSync(join(dir, ".cursor")) ? [".cursor/mcp.json"] : [])];
100
- for (const f of targets) {
101
- const merged = mergeMcp(read(f));
102
- plan(f, merged, `${f} is not valid JSON; I left it alone. Fix it and run again.`);
148
+ if (!only) {
149
+ plan("AGENTS.md", upsertSection(read("AGENTS.md") ?? "", agentsSection(kind)), "AGENTS.md has a broken shipvo marker; fix it and run again.");
150
+ const targets = [".mcp.json", ...(existsSync(join(dir, ".cursor")) ? [".cursor/mcp.json"] : [])];
151
+ for (const f of targets) {
152
+ const merged = mergeMcp(read(f));
153
+ plan(f, merged, `${f} is not valid JSON; I left it alone. Fix it and run again.`);
154
+ }
103
155
  }
156
+ const skills = projectSkills(dir);
157
+ for (const e of skills)
158
+ for (const t of skillTargets(dir, e))
159
+ plan(t.file, t.text);
104
160
  const bad = changes.some((c) => c.action === "skip");
105
161
  if (!dry && !bad)
106
162
  for (const w of writes) {
@@ -109,7 +165,7 @@ export async function agentsCmd(argv, dir = process.cwd()) {
109
165
  }
110
166
  const wrote = !dry && !bad;
111
167
  if (json)
112
- console.log(JSON.stringify({ ok: !bad, project: kind, dry_run: dry, changes, skill_install: SKILL_LINE }));
168
+ console.log(JSON.stringify({ ok: !bad, project: kind, dry_run: dry, changes, skill_install: SKILL_LINE, skills: skills.map((e) => ({ package: e.package, name: e.name })) }));
113
169
  else {
114
170
  console.log(`Project: ${LABEL[kind]}.`);
115
171
  for (const c of changes)
@@ -118,7 +174,10 @@ export async function agentsCmd(argv, dir = process.cwd()) {
118
174
  console.log("Dry run: nothing was written.");
119
175
  else if (wrote && !changes.some((c) => c.action !== "unchanged"))
120
176
  console.log("Nothing to change.");
121
- console.log(`Install the skill: ${SKILL_LINE}`);
177
+ if (only && !skills.length)
178
+ console.log("No installed @shipvo/* dependency ships a skill.");
179
+ if (!only)
180
+ console.log(`Install the skill: ${SKILL_LINE}`);
122
181
  }
123
182
  return bad ? 2 : 0;
124
183
  }
@@ -0,0 +1,9 @@
1
+ export declare const APPROVALS_HELP = "Usage: shipvo approvals list [--app id] [--json]\n shipvo approvals approve <id> [--app id] [--json]\n\nList promotes held above policy.requireApprovalAbove, or approve one.\nA second owner must approve: the proposer cannot, and an agent cannot approve an agent's proposal.\n\nExit codes: 0 done, 1 refused by a rule (the rule is named), 2 error.\nWith --json, one line: { ok, code, summary, data, next }, the same envelope as status and doctor.\ncode is ok, a rule name such as self_approval, or usage, config, no_credentials, unreachable, http_<status>.\nThe list is in data.approvals.\n\nApproving a hold that an agent proposed (through the publish token) needs a signed-in person (run shipvo login).\nSHIPVO_OWNER_TOKEN counts as an agent, so it can approve only holds that a person proposed.\nlist also works with the publish token.\n";
2
+ /** "3m", "2h", "5d". */
3
+ export declare function age(from: string, now?: number): string;
4
+ /** Whole seconds since `from`, or null when the date does not parse. */
5
+ export declare function ageSeconds(from: string, now?: number): number | null;
6
+ export declare function approvals(argv: string[], deps?: {
7
+ fetchFn?: typeof fetch;
8
+ now?: number;
9
+ }): Promise<number>;
@@ -0,0 +1,106 @@
1
+ // `shipvo approvals list` and `shipvo approvals approve <id>`: see and release promotes held above the policy band.
2
+ import { loadConfig, publishToken } from "./config.js";
3
+ import { savedCreds } from "./login.js";
4
+ import { envelope } from "./json-out.js";
5
+ export const APPROVALS_HELP = `Usage: shipvo approvals list [--app id] [--json]
6
+ shipvo approvals approve <id> [--app id] [--json]
7
+
8
+ List promotes held above policy.requireApprovalAbove, or approve one.
9
+ A second owner must approve: the proposer cannot, and an agent cannot approve an agent's proposal.
10
+
11
+ Exit codes: 0 done, 1 refused by a rule (the rule is named), 2 error.
12
+ With --json, one line: { ok, code, summary, data, next }, the same envelope as status and doctor.
13
+ code is ok, a rule name such as self_approval, or usage, config, no_credentials, unreachable, http_<status>.
14
+ The list is in data.approvals.
15
+
16
+ Approving a hold that an agent proposed (through the publish token) needs a signed-in person (run shipvo login).
17
+ SHIPVO_OWNER_TOKEN counts as an agent, so it can approve only holds that a person proposed.
18
+ list also works with the publish token.
19
+ `;
20
+ /** "3m", "2h", "5d". */
21
+ export function age(from, now = Date.now()) {
22
+ const s = ageSeconds(from, now);
23
+ if (s === null)
24
+ return "?";
25
+ if (s < 3600)
26
+ return `${Math.floor(s / 60)}m`;
27
+ if (s < 86400)
28
+ return `${Math.floor(s / 3600)}h`;
29
+ return `${Math.floor(s / 86400)}d`;
30
+ }
31
+ /** Whole seconds since `from`, or null when the date does not parse. */
32
+ export function ageSeconds(from, now = Date.now()) {
33
+ const n = Math.round((now - Date.parse(from)) / 1000);
34
+ return Number.isFinite(n) ? Math.max(0, n) : null;
35
+ }
36
+ export async function approvals(argv, deps = {}) {
37
+ if (argv.includes("--help") || argv.includes("-h")) {
38
+ console.log(APPROVALS_HELP);
39
+ return 0;
40
+ }
41
+ const json = argv.includes("--json");
42
+ const out = (code, summary, data, next, exit, human = summary) => {
43
+ console.log(json ? JSON.stringify(envelope(code, summary, data, next, exit)) : human);
44
+ return exit;
45
+ };
46
+ const fail = (code, message, data = {}, exit = 2, next = []) => out(code, message, data, next, exit);
47
+ const pos = [];
48
+ let app;
49
+ for (let i = 0; i < argv.length; i++) {
50
+ if (argv[i] === "--app")
51
+ app = argv[++i];
52
+ else if (!argv[i].startsWith("--"))
53
+ pos.push(argv[i]);
54
+ }
55
+ const [sub, id] = pos;
56
+ if (sub !== "list" && sub !== "approve")
57
+ return fail("usage", "Usage: shipvo approvals list | approve <id> [--app id] [--json]");
58
+ if (sub === "approve" && !id)
59
+ return fail("usage", "Usage: shipvo approvals approve <id> [--app id] [--json]");
60
+ let cfg;
61
+ try {
62
+ cfg = loadConfig();
63
+ }
64
+ catch (e) {
65
+ return fail("config", e instanceof Error ? e.message : String(e));
66
+ }
67
+ const appId = app ?? cfg.appId;
68
+ const token = process.env.SHIPVO_OWNER_TOKEN?.trim() || savedCreds()?.key || publishToken();
69
+ if (!token)
70
+ return fail("no_credentials", "No credentials. Set SHIPVO_OWNER_TOKEN, run `shipvo login`, or use the publish token.");
71
+ const base = cfg.controlPlane.replace(/\/$/, "");
72
+ const f = deps.fetchFn ?? fetch;
73
+ const url = `${base}/apps/${encodeURIComponent(appId)}/approvals` + (sub === "approve" ? `/${encodeURIComponent(id)}/approve` : "");
74
+ let r;
75
+ try {
76
+ r = await f(url, { method: sub === "approve" ? "POST" : "GET", headers: { authorization: `Bearer ${token}` } });
77
+ }
78
+ catch (e) {
79
+ return fail("unreachable", `Could not reach the control plane at ${base} (${e instanceof Error ? e.message : String(e)}).`);
80
+ }
81
+ const body = (await r.json().catch(() => ({})));
82
+ if (!r.ok) {
83
+ const rule = body.rule;
84
+ const msg = body.message ?? body.reason ?? body.error ?? `HTTP ${r.status}`;
85
+ const exit = r.status === 403 || r.status === 409 ? 1 : 2;
86
+ const data = { status: r.status, ...(rule ? { rule } : {}) };
87
+ if (rule) {
88
+ const hint = rule === "self_approval" && sub === "approve" ? " If an agent proposed this hold, sign in as a person with shipvo login (SHIPVO_OWNER_TOKEN counts as an agent)." : "";
89
+ const next = rule === "self_approval" && sub === "approve" ? [{ command: "shipvo login", why: "A person's login can approve a hold that an agent proposed." }] : [];
90
+ return fail(rule, `Refused by rule ${rule}: ${msg}${hint}`, data, exit, next);
91
+ }
92
+ return fail(body.error ?? `http_${r.status}`, `Request failed: ${msg}${r.status === 401 ? " (the token was not accepted for this app)" : ""}`, data, exit);
93
+ }
94
+ if (sub === "approve") {
95
+ return out("ok", `Approved ${id}. The promote is live.`, { command: "approve", app: appId, approval_id: body.approval_id ?? id, status: body.status }, [{ command: "shipvo status", why: "Check the release is reporting." }], 0);
96
+ }
97
+ const rows = body.approvals ?? [];
98
+ const data = { command: "list", app: appId, approvals: rows.map((h) => ({ id: h.id, release: h.release, band: h.band, proposer: h.proposer, proposed_at: h.proposed_at, age_seconds: ageSeconds(h.proposed_at, deps.now) })) };
99
+ if (!rows.length)
100
+ return out("ok", "No promotes are waiting for approval.", data, [], 0);
101
+ const cols = rows.map((h) => [h.id, String(h.release), h.band, h.proposer, age(h.proposed_at, deps.now)]);
102
+ const head = ["ID", "RELEASE", "BAND", "PROPOSER", "AGE"];
103
+ const w = head.map((t, i) => Math.max(t.length, ...cols.map((c) => c[i].length)));
104
+ const line = (c) => c.map((v, i) => v.padEnd(w[i])).join(" ").trimEnd();
105
+ return out("ok", `${rows.length} promote${rows.length === 1 ? " is" : "s are"} waiting for approval.`, data, [{ command: `shipvo approvals approve ${rows[0].id}`, why: "Release the oldest hold if you are a second owner." }], 0, [line(head), ...cols.map(line)].join("\n"));
106
+ }
package/dist/audit.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export declare const AUDIT_FLAGS: string[];
2
2
  export declare const AUDIT_COLUMNS: readonly ["id", "app_id", "at", "actor", "actor_kind", "action", "release", "release_id", "before", "after"];
3
- export declare const AUDIT_HELP = "Usage: shipvo audit <app> [--format json|csv] [--limit n]\n\nPrint the app's audit log, newest first, to stdout. Pages through the control plane.\n\nFlags (write a value after a space, not =):\n --format json|csv Output format (default json)\n --limit n Stop after n entries (default: all)\n --help Show this help\n\nNeeds SHIPVO_OWNER_TOKEN, or the publish token from shipvo app create (SHIPVO_PUBLISH_TOKEN or .shipvo/publish-token).\n";
3
+ export declare const AUDIT_HELP = "Usage: shipvo audit <app> [--format json|csv] [--limit n] [--json]\n\nPrint the app's audit log, newest first, to stdout. Pages through the control plane.\n\nFlags (write a value after a space, not =):\n --format json|csv Output format (default json)\n --limit n Stop after n entries (default: all)\n --json Print one JSON envelope on stdout: { ok, code, summary, data, next }\n --help Show this help\n\nNeeds SHIPVO_OWNER_TOKEN, or the publish token from shipvo app create (SHIPVO_PUBLISH_TOKEN or .shipvo/publish-token).\n";
4
4
  type Row = Record<string, unknown> & {
5
5
  id: number;
6
6
  };
package/dist/audit.js CHANGED
@@ -1,15 +1,17 @@
1
1
  // `shipvo audit <app>`: export an app's audit log as JSON or CSV on stdout.
2
2
  import { args as parse, loadConfig, publishToken } from "./config.js";
3
- export const AUDIT_FLAGS = ["format", "limit"];
3
+ import { CliFailure, UsageError, runJson } from "./json-out.js";
4
+ export const AUDIT_FLAGS = ["format", "limit", "json"];
4
5
  export const AUDIT_COLUMNS = ["id", "app_id", "at", "actor", "actor_kind", "action", "release", "release_id", "before", "after"];
5
6
  const PAGE = 200;
6
- export const AUDIT_HELP = `Usage: shipvo audit <app> [--format json|csv] [--limit n]
7
+ export const AUDIT_HELP = `Usage: shipvo audit <app> [--format json|csv] [--limit n] [--json]
7
8
 
8
9
  Print the app's audit log, newest first, to stdout. Pages through the control plane.
9
10
 
10
11
  Flags (write a value after a space, not =):
11
12
  --format json|csv Output format (default json)
12
13
  --limit n Stop after n entries (default: all)
14
+ --json Print one JSON envelope on stdout: { ok, code, summary, data, next }
13
15
  --help Show this help
14
16
 
15
17
  Needs SHIPVO_OWNER_TOKEN, or the publish token from shipvo app create (SHIPVO_PUBLISH_TOKEN or .shipvo/publish-token).
@@ -29,26 +31,41 @@ export async function audit(argv, deps = {}) {
29
31
  console.log(AUDIT_HELP);
30
32
  return;
31
33
  }
34
+ if (argv.includes("--json")) {
35
+ return runJson(async () => {
36
+ const { appId, rows, format, limit } = await auditRows(argv, deps);
37
+ if (format === "csv")
38
+ throw new UsageError("--json always returns rows in data. Drop --format csv, or drop --json.");
39
+ const next = [{ command: "shipvo status --json", why: "See how the latest release does on phones." }];
40
+ if (limit !== undefined && rows.length === limit)
41
+ next.unshift({ command: `shipvo audit ${appId} --json`, why: "Read the whole log, not just the newest entries." });
42
+ return { code: "ok", summary: `${rows.length} audit ${rows.length === 1 ? "entry" : "entries"} for "${appId}", newest first.`, data: { app: appId, count: rows.length, rows }, next };
43
+ }, [{ command: "shipvo doctor --json", why: "Check your setup." }]);
44
+ }
45
+ const { rows, format } = await auditRows(argv, deps);
46
+ console.log(format === "csv" ? toCsv(rows) : JSON.stringify(rows, null, 2));
47
+ }
48
+ async function auditRows(argv, deps) {
32
49
  const bad = argv.filter((x) => x.startsWith("--") && !AUDIT_FLAGS.includes(x.slice(2)));
33
50
  if (bad.length)
34
- throw new Error(`Unknown flag ${bad.join(", ")}. Known flags: ${AUDIT_FLAGS.map((k) => "--" + k).join(" ")}`);
51
+ throw new UsageError(`Unknown flag ${bad.join(", ")}. Known flags: ${AUDIT_FLAGS.map((k) => "--" + k).join(" ")}`);
35
52
  const a = parse(argv);
36
53
  // Positional app id: the first argument that is not a flag or a flag's value.
37
- const appId = argv.find((x, i) => !x.startsWith("--") && !(i > 0 && AUDIT_FLAGS.includes(argv[i - 1].slice(2)) && argv[i - 1].startsWith("--")));
54
+ const appId = argv.find((x, i) => !x.startsWith("--") && !(i > 0 && argv[i - 1].startsWith("--") && argv[i - 1] !== "--json" && AUDIT_FLAGS.includes(argv[i - 1].slice(2))));
38
55
  if (!appId)
39
- throw new Error("Usage: shipvo audit <app> [--format json|csv] [--limit n]");
56
+ throw new UsageError("Usage: shipvo audit <app> [--format json|csv] [--limit n] [--json]");
40
57
  const format = a.format ?? "json";
41
58
  if (format !== "json" && format !== "csv")
42
- throw new Error(`--format must be json or csv (got "${a.format}").`);
59
+ throw new UsageError(`--format must be json or csv (got "${a.format}").`);
43
60
  let max = Infinity;
44
61
  if (argv.includes("--limit")) {
45
62
  max = Number(a.limit);
46
63
  if (!Number.isInteger(max) || max < 1)
47
- throw new Error(`--limit must be a positive whole number (got "${a.limit}").`);
64
+ throw new UsageError(`--limit must be a positive whole number (got "${a.limit}").`);
48
65
  }
49
66
  const token = process.env.SHIPVO_OWNER_TOKEN?.trim() || publishToken();
50
67
  if (!token)
51
- 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).");
68
+ throw new CliFailure("No credentials. Set SHIPVO_OWNER_TOKEN, or run `shipvo app create` to get a publish token (SHIPVO_PUBLISH_TOKEN or .shipvo/publish-token).", "no_credentials", 1, [{ command: "shipvo app create", why: "Get a publish token for this app." }]);
52
69
  const base = loadConfig().controlPlane.replace(/\/$/, "");
53
70
  const f = deps.fetchFn ?? fetch;
54
71
  const rows = [];
@@ -61,15 +78,15 @@ export async function audit(argv, deps = {}) {
61
78
  r = await f(url, { headers: { authorization: `Bearer ${token}` } });
62
79
  }
63
80
  catch (e) {
64
- throw new Error(`Could not reach the control plane at ${base} (${e instanceof Error ? e.message : String(e)}).`);
81
+ throw new CliFailure(`Could not reach the control plane at ${base} (${e instanceof Error ? e.message : String(e)}).`, "remote_failed", 1, [{ command: "shipvo doctor --json", why: "Check the control plane address." }]);
65
82
  }
66
83
  if (!r.ok)
67
- throw new Error(`Audit request failed: HTTP ${r.status}${r.status === 401 ? " (the token was not accepted for this app)" : ""}.`);
84
+ throw new CliFailure(`Audit request failed: HTTP ${r.status}${r.status === 401 ? " (the token was not accepted for this app)" : ""}.`, r.status === 401 ? "auth_failed" : "remote_failed", 1, [{ command: r.status === 401 ? "shipvo app create" : "shipvo doctor --json", why: r.status === 401 ? "Get a publish token for this app." : "Check the control plane." }]);
68
85
  const page = (await r.json()).rows ?? [];
69
86
  rows.push(...page);
70
87
  if (page.length < want)
71
88
  break;
72
89
  before = page[page.length - 1].id;
73
90
  }
74
- console.log(format === "csv" ? toCsv(rows) : JSON.stringify(rows, null, 2));
91
+ return { appId, rows, format, limit: Number.isFinite(max) ? max : undefined };
75
92
  }
@@ -1,5 +1,5 @@
1
1
  export declare const AUTOHALT_FLAGS: string[];
2
- export declare const AUTOHALT_HELP = "Usage: shipvo auto-halt [flags]\n\nStop serving a release on its own when too many devices fail to apply it.\nWith no flags, print the current setting.\n\nFlags (write a value after a space, not =):\n --threshold 0.2 Halt when more than this share of reports failed (above 0, at most 1)\n --min-reports 20 Wait for this many reports first (1 to 10000, default 20)\n --off Turn auto-halt off\n --dry-run Print what would change, write nothing\n --help Show this help\n\nNeeds SHIPVO_OWNER_TOKEN, or the publish token from shipvo app create (SHIPVO_PUBLISH_TOKEN or .shipvo/publish-token).\n";
2
+ export declare const AUTOHALT_HELP = "Usage: shipvo auto-halt [flags]\n\nStop serving a release on its own when too many devices fail to apply it.\nWith no flags, print the current setting.\n\nFlags (write a value after a space, not =):\n --threshold 0.2 Halt when more than this share of reports failed (above 0, at most 1)\n --min-reports 20 Wait for this many reports first (1 to 10000, default 20)\n --off Turn auto-halt off\n --dry-run Print what would change, write nothing\n --json Print one JSON envelope on stdout; text goes to stderr\n --help Show this help\n\nNeeds SHIPVO_OWNER_TOKEN, or the publish token from shipvo app create (SHIPVO_PUBLISH_TOKEN or .shipvo/publish-token).\n";
3
3
  export declare function autoHalt(argv: string[], deps?: {
4
4
  fetchFn?: typeof fetch;
5
5
  }): Promise<void>;
package/dist/autohalt.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // `shipvo auto-halt`: show, turn on or turn off auto-halt for this app, through the control plane.
2
2
  import { args as parse, loadConfig, publishToken } from "./config.js";
3
3
  import { describeAutoHalt } from "./status.js";
4
- export const AUTOHALT_FLAGS = ["threshold", "min-reports", "off", "dry-run"];
4
+ import { CliFailure, UsageError, runJson } from "./json-out.js";
5
+ export const AUTOHALT_FLAGS = ["threshold", "min-reports", "off", "dry-run", "json"];
5
6
  export const AUTOHALT_HELP = `Usage: shipvo auto-halt [flags]
6
7
 
7
8
  Stop serving a release on its own when too many devices fail to apply it.
@@ -12,6 +13,7 @@ Flags (write a value after a space, not =):
12
13
  --min-reports 20 Wait for this many reports first (1 to 10000, default 20)
13
14
  --off Turn auto-halt off
14
15
  --dry-run Print what would change, write nothing
16
+ --json Print one JSON envelope on stdout; text goes to stderr
15
17
  --help Show this help
16
18
 
17
19
  Needs SHIPVO_OWNER_TOKEN, or the publish token from shipvo app create (SHIPVO_PUBLISH_TOKEN or .shipvo/publish-token).
@@ -22,32 +24,37 @@ export async function autoHalt(argv, deps = {}) {
22
24
  console.log(AUTOHALT_HELP);
23
25
  return;
24
26
  }
27
+ if (argv.includes("--json"))
28
+ return runJson(() => autoHaltRun(argv, deps), [{ command: "shipvo doctor --json", why: "Check your setup." }]);
29
+ await autoHaltRun(argv, deps);
30
+ }
31
+ async function autoHaltRun(argv, deps) {
25
32
  const bad = argv.filter((x) => x.startsWith("--") && !AUTOHALT_FLAGS.includes(x.slice(2)));
26
33
  if (bad.length) {
27
34
  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(" ")}`);
35
+ throw new UsageError(`Unknown flag ${bad.join(", ")}.${eq ? ` Write "${eq.replace("=", " ")}" (a space, not =).` : ""} Known flags: ${AUTOHALT_FLAGS.map((k) => "--" + k).join(" ")}`);
29
36
  }
30
37
  const a = parse(argv), cfg = loadConfig();
31
38
  const dry = argv.includes("--dry-run"), off = argv.includes("--off");
32
39
  const hasT = argv.includes("--threshold"), hasM = argv.includes("--min-reports");
33
40
  if (off && (hasT || hasM))
34
- throw new Error("--off cannot be combined with --threshold or --min-reports.");
41
+ throw new UsageError("--off cannot be combined with --threshold or --min-reports.");
35
42
  let threshold, minReports = 20;
36
43
  if (hasT || hasM) {
37
44
  if (!hasT)
38
- throw new Error("--min-reports needs --threshold too, for example --threshold 0.2 --min-reports 20.");
45
+ throw new UsageError("--min-reports needs --threshold too, for example --threshold 0.2 --min-reports 20.");
39
46
  threshold = a.threshold === "true" || a.threshold === undefined ? NaN : Number(a.threshold);
40
47
  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}").`);
48
+ throw new UsageError(`--threshold must be a number above 0 and at most 1, for example 0.2 (got "${a.threshold}").`);
42
49
  if (hasM) {
43
50
  minReports = a["min-reports"] === "true" || a["min-reports"] === undefined ? NaN : Number(a["min-reports"]);
44
51
  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"]}").`);
52
+ throw new UsageError(`--min-reports must be a whole number from 1 to 10000 (got "${a["min-reports"]}").`);
46
53
  }
47
54
  }
48
55
  const token = process.env.SHIPVO_OWNER_TOKEN?.trim() || publishToken();
49
56
  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).");
57
+ throw new CliFailure("No credentials. Set SHIPVO_OWNER_TOKEN, or run `shipvo app create` to get a publish token (SHIPVO_PUBLISH_TOKEN or .shipvo/publish-token).", "no_credentials", 1, [{ command: "shipvo app create", why: "Get a publish token." }]);
51
58
  const base = cfg.controlPlane.replace(/\/$/, "");
52
59
  const url = `${base}/app/${encodeURIComponent(cfg.appId)}/api/halt-config`;
53
60
  const call = async (method, body) => {
@@ -56,29 +63,34 @@ export async function autoHalt(argv, deps = {}) {
56
63
  r = await (deps.fetchFn ?? fetch)(url, { method, headers: { authorization: `Bearer ${token}`, ...(body ? { "content-type": "application/json" } : {}) }, body: body ? JSON.stringify(body) : undefined });
57
64
  }
58
65
  catch (e) {
59
- throw new Error(`Could not reach the control plane at ${base} (${e instanceof Error ? e.message : String(e)}).`);
66
+ throw new CliFailure(`Could not reach the control plane at ${base} (${e instanceof Error ? e.message : String(e)}).`, "remote_failed", 1, [{ command: "shipvo doctor --json", why: "Check the control plane." }]);
60
67
  }
61
68
  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.`);
69
+ throw new CliFailure(`The control plane at ${base} rejected your token (${r.status}). Check SHIPVO_OWNER_TOKEN or your publish token.`, "auth_failed", 1, [{ command: "shipvo app create", why: "Get a publish token, or set SHIPVO_OWNER_TOKEN." }]);
63
70
  if (!r.ok) {
64
71
  let m = "";
65
72
  try {
66
73
  m = (await r.json()).message ?? "";
67
74
  }
68
75
  catch { /* no body */ }
69
- throw new Error(`Auto-halt request failed: HTTP ${r.status}${m ? ` ${m}` : ""}`);
76
+ throw new CliFailure(`Auto-halt request failed: HTTP ${r.status}${m ? ` ${m}` : ""}`, "remote_failed", 1, [{ command: "shipvo doctor --json", why: "Check the control plane." }]);
70
77
  }
71
78
  return (await r.json());
72
79
  };
73
- if (!off && threshold === undefined) {
74
- console.log(describe(await call("GET")));
75
- return;
76
- }
80
+ const status = [{ command: "shipvo status --json", why: "See how the newest releases do." }];
81
+ const done = (code, st, extra = {}) => {
82
+ console.log(describe(st));
83
+ return { code, summary: describe(st), data: { enabled: st.enabled, threshold: st.enabled ? st.threshold ?? null : null, min_reports: st.enabled ? st.min_reports ?? null : null, dry_run: false, changed: false, ...extra }, next: status };
84
+ };
85
+ if (!off && threshold === undefined)
86
+ return done("auto_halt_show", await call("GET"));
77
87
  const want = off ? { enabled: false } : { enabled: true, threshold, min_reports: minReports };
78
88
  if (dry) {
79
89
  console.log(`Would set ${describe(want)}`);
80
90
  console.log("Dry run: nothing was written.");
81
- return;
91
+ const real = argv.filter((x) => x !== "--dry-run");
92
+ return { code: "dry_run", summary: `Would set ${describe(want)}`, data: { enabled: want.enabled, threshold: want.threshold ?? null, min_reports: want.min_reports ?? null, dry_run: true, changed: false }, next: [{ command: `shipvo auto-halt ${real.join(" ")}`.trim(), why: "Apply this setting." }] };
82
93
  }
83
- console.log(describe(off ? await call("DELETE") : await call("PUT", { threshold, min_reports: minReports })));
94
+ const st = off ? await call("DELETE") : await call("PUT", { threshold, min_reports: minReports });
95
+ return done(off ? "auto_halt_off" : "auto_halt_on", st, { changed: true });
84
96
  }
@@ -0,0 +1,4 @@
1
+ /** Compress to a standard .bz2 stream. */
2
+ export declare function bzip2(data: Uint8Array): Uint8Array;
3
+ /** A BSDIFF40 patch that turns `oldData` into `newData`. */
4
+ export declare function bsdiff(oldData: Uint8Array, newData: Uint8Array): Uint8Array<ArrayBuffer>;