@shipvo/cli 0.0.0-stage → 0.1.0
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/LICENSE +73 -0
- package/README.md +12 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +87 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +49 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +31 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +195 -0
- package/dist/login.d.ts +9 -0
- package/dist/login.js +77 -0
- package/dist/publish.d.ts +2 -0
- package/dist/publish.js +153 -0
- package/dist/sim.d.ts +1 -0
- package/dist/sim.js +25 -0
- package/package.json +61 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
|
10
|
+
|
|
11
|
+
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
|
12
|
+
|
|
13
|
+
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
|
14
|
+
|
|
15
|
+
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
|
16
|
+
|
|
17
|
+
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
|
18
|
+
|
|
19
|
+
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
|
20
|
+
|
|
21
|
+
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
|
22
|
+
|
|
23
|
+
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
|
24
|
+
|
|
25
|
+
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
|
26
|
+
|
|
27
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
|
28
|
+
|
|
29
|
+
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
|
30
|
+
|
|
31
|
+
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
|
32
|
+
|
|
33
|
+
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
|
34
|
+
|
|
35
|
+
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
|
36
|
+
|
|
37
|
+
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
|
38
|
+
|
|
39
|
+
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
|
40
|
+
|
|
41
|
+
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
|
42
|
+
|
|
43
|
+
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
|
44
|
+
|
|
45
|
+
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
|
46
|
+
|
|
47
|
+
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
|
48
|
+
|
|
49
|
+
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
|
50
|
+
|
|
51
|
+
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
|
52
|
+
|
|
53
|
+
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
|
54
|
+
|
|
55
|
+
END OF TERMS AND CONDITIONS
|
|
56
|
+
|
|
57
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
58
|
+
|
|
59
|
+
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
|
60
|
+
|
|
61
|
+
Copyright [yyyy] [name of copyright owner]
|
|
62
|
+
|
|
63
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
64
|
+
you may not use this file except in compliance with the License.
|
|
65
|
+
You may obtain a copy of the License at
|
|
66
|
+
|
|
67
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
68
|
+
|
|
69
|
+
Unless required by applicable law or agreed to in writing, software
|
|
70
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
71
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
72
|
+
See the License for the specific language governing permissions and
|
|
73
|
+
limitations under the License.
|
package/README.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @shipvo/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Command-line tools for [Shipvo](https://github.com/shipvo/shipvo), the open-source React Native framework for signed over-the-air updates.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @shipvo/cli keygen --out seed.txt # Ed25519 release signing key
|
|
7
|
+
npx @shipvo/cli hash bundle.hbc # SHA-256 and size
|
|
8
|
+
npx @shipvo/cli sign-file bundle.hbc # signature over bytes
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Also usable as a library: `signDoc`, `verifyDoc`, `signBytes`, `sha256Hex`, `keyFromSeed`.
|
|
12
|
+
|
|
13
|
+
License: Apache-2.0
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { b64, keyFromSeed, sha256Hex, signBytes, signDoc, verifyDoc } from "./index.js";
|
|
5
|
+
import { seed } from "./config.js";
|
|
6
|
+
import { init } from "./init.js";
|
|
7
|
+
import { publish, rollback } from "./publish.js";
|
|
8
|
+
import { sim } from "./sim.js";
|
|
9
|
+
import { keys, login, mcp } from "./login.js";
|
|
10
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
11
|
+
const help = `shipvo <command>
|
|
12
|
+
|
|
13
|
+
init <dir> New app wired to Shipvo: OTA runtime, device API, simulator, publish workflow
|
|
14
|
+
sim [--port 4545] Build the app for the simulator and serve it (agents: open_app("<appId>"))
|
|
15
|
+
publish [--bands b0,b1,b2] Build, sign and publish an over-the-air release (+ the agent-facing web artifact)
|
|
16
|
+
rollback --to <release> Republish an earlier release's code as a new release
|
|
17
|
+
login Sign in with GitHub and get a key for the hosted simulator
|
|
18
|
+
keys Show the stored key's status and quota
|
|
19
|
+
mcp Print the MCP config agents need (hosted and local)
|
|
20
|
+
|
|
21
|
+
keygen [--out seed.txt] Create an Ed25519 release signing key; prints the public key
|
|
22
|
+
pubkey Print the public key for the seed in SHIPVO_SIGNING_SEED
|
|
23
|
+
hash <file> SHA-256 and size of a bundle
|
|
24
|
+
sign-file <file> Ed25519 signature (base64) over a file's bytes
|
|
25
|
+
sign-json <file.json> Sign a JSON document (canonical form); writes signature into it
|
|
26
|
+
verify-json <file.json> <public-key-b64>
|
|
27
|
+
`;
|
|
28
|
+
switch (cmd) {
|
|
29
|
+
case "init":
|
|
30
|
+
await init(rest);
|
|
31
|
+
break;
|
|
32
|
+
case "sim":
|
|
33
|
+
await sim(rest);
|
|
34
|
+
break;
|
|
35
|
+
case "publish":
|
|
36
|
+
await publish(rest);
|
|
37
|
+
break;
|
|
38
|
+
case "rollback":
|
|
39
|
+
await rollback(rest);
|
|
40
|
+
break;
|
|
41
|
+
case "login":
|
|
42
|
+
await login(rest);
|
|
43
|
+
break;
|
|
44
|
+
case "keys":
|
|
45
|
+
await keys();
|
|
46
|
+
break;
|
|
47
|
+
case "mcp":
|
|
48
|
+
mcp();
|
|
49
|
+
break;
|
|
50
|
+
case "keygen": {
|
|
51
|
+
const s = b64(randomBytes(32));
|
|
52
|
+
const out = rest[rest.indexOf("--out") + 1];
|
|
53
|
+
if (rest.includes("--out") && out) {
|
|
54
|
+
writeFileSync(out, s, { mode: 0o600 });
|
|
55
|
+
console.error(`Seed written to ${out}. Back it up offline; never commit it.`);
|
|
56
|
+
}
|
|
57
|
+
else
|
|
58
|
+
console.log(`seed (secret): ${s}`);
|
|
59
|
+
console.log(`public key: ${b64(keyFromSeed(s).publicKey)}`);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case "pubkey":
|
|
63
|
+
console.log(b64(keyFromSeed(seed()).publicKey));
|
|
64
|
+
break;
|
|
65
|
+
case "hash": {
|
|
66
|
+
const b = readFileSync(rest[0]);
|
|
67
|
+
console.log(JSON.stringify({ sha256: sha256Hex(b), size: b.length }));
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case "sign-file":
|
|
71
|
+
console.log(signBytes(new Uint8Array(readFileSync(rest[0])), seed()));
|
|
72
|
+
break;
|
|
73
|
+
case "sign-json": {
|
|
74
|
+
const d = JSON.parse(readFileSync(rest[0], "utf8"));
|
|
75
|
+
writeFileSync(rest[0], JSON.stringify(signDoc(d, seed()), null, 2));
|
|
76
|
+
console.log("signed");
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "verify-json": {
|
|
80
|
+
const ok = verifyDoc(JSON.parse(readFileSync(rest[0], "utf8")), rest[1]);
|
|
81
|
+
console.log(ok ? "valid" : "INVALID");
|
|
82
|
+
process.exit(ok ? 0 : 1);
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
console.log(help);
|
|
86
|
+
process.exit(cmd ? 1 : 0);
|
|
87
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** shipvo.json at the app root: what `shipvo init` writes and every other command reads. */
|
|
2
|
+
export type ShipvoConfig = {
|
|
3
|
+
appId: string;
|
|
4
|
+
name: string;
|
|
5
|
+
shellMajor: number;
|
|
6
|
+
/** Control plane the shell trusts (also in app.json's @shipvo/runtime plugin props). */
|
|
7
|
+
controlPlane: string;
|
|
8
|
+
/** Base64 Ed25519 public key built into the shell. */
|
|
9
|
+
publicKey: string;
|
|
10
|
+
/** Where `shipvo publish` writes: the control plane's Cloudflare KV namespace. */
|
|
11
|
+
kv?: {
|
|
12
|
+
namespaceId: string;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export declare function loadConfig(dir?: string): ShipvoConfig;
|
|
16
|
+
export declare const args: (argv: string[]) => Record<string, string | undefined>;
|
|
17
|
+
/** Signing seed: SHIPVO_SIGNING_SEED (PURENATIVE_SIGNING_SEED still accepted) or a *_FILE path. */
|
|
18
|
+
export declare function seed(): string;
|
|
19
|
+
/** Cloudflare KV access for publish/rollback. */
|
|
20
|
+
export declare function kv(cfg: ShipvoConfig): {
|
|
21
|
+
get(k: string): Promise<string | null>;
|
|
22
|
+
getBytes(k: string): Promise<Uint8Array<ArrayBuffer> | null>;
|
|
23
|
+
keys(prefix: string): Promise<any>;
|
|
24
|
+
put(k: string, body: BodyInit, type: string): Promise<void>;
|
|
25
|
+
};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export function loadConfig(dir = process.cwd()) {
|
|
4
|
+
const p = join(dir, "shipvo.json");
|
|
5
|
+
if (!existsSync(p))
|
|
6
|
+
throw new Error("No shipvo.json here. Run this inside an app created with `shipvo init`, or create one (see docs/getting-started.md).");
|
|
7
|
+
const c = JSON.parse(readFileSync(p, "utf8"));
|
|
8
|
+
for (const k of ["appId", "controlPlane", "publicKey"])
|
|
9
|
+
if (!c[k])
|
|
10
|
+
throw new Error(`shipvo.json: "${k}" is required`);
|
|
11
|
+
return { shellMajor: 1, name: c.appId, ...c };
|
|
12
|
+
}
|
|
13
|
+
const argPairs = (argv) => Object.fromEntries(argv.reduce((a, x, i, all) => (x.startsWith("--") ? [...a, [x.slice(2), all[i + 1] === undefined || all[i + 1].startsWith("--") ? "true" : all[i + 1]]] : a), []));
|
|
14
|
+
export const args = (argv) => argPairs(argv);
|
|
15
|
+
/** Signing seed: SHIPVO_SIGNING_SEED (PURENATIVE_SIGNING_SEED still accepted) or a *_FILE path. */
|
|
16
|
+
export function seed() {
|
|
17
|
+
const e = process.env;
|
|
18
|
+
const s = e.SHIPVO_SIGNING_SEED || e.PURENATIVE_SIGNING_SEED
|
|
19
|
+
|| ((e.SHIPVO_SIGNING_SEED_FILE || e.PURENATIVE_SIGNING_SEED_FILE) && readFileSync((e.SHIPVO_SIGNING_SEED_FILE || e.PURENATIVE_SIGNING_SEED_FILE), "utf8").trim());
|
|
20
|
+
if (!s) {
|
|
21
|
+
console.error("Set SHIPVO_SIGNING_SEED or SHIPVO_SIGNING_SEED_FILE (the seed `shipvo keygen` printed).");
|
|
22
|
+
process.exit(2);
|
|
23
|
+
}
|
|
24
|
+
return s;
|
|
25
|
+
}
|
|
26
|
+
/** Cloudflare KV access for publish/rollback. */
|
|
27
|
+
export function kv(cfg) {
|
|
28
|
+
const ACC = process.env.CLOUDFLARE_ACCOUNT_ID, TOKEN = process.env.CLOUDFLARE_API_TOKEN;
|
|
29
|
+
const NS = process.env.SHIPVO_KV_ID || process.env.PURENATIVE_KV_ID || cfg.kv?.namespaceId;
|
|
30
|
+
if (!ACC || !TOKEN || !NS) {
|
|
31
|
+
console.error("Need CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN and the control plane's KV namespace (shipvo.json kv.namespaceId or SHIPVO_KV_ID).");
|
|
32
|
+
process.exit(2);
|
|
33
|
+
}
|
|
34
|
+
const url = (k) => `https://api.cloudflare.com/client/v4/accounts/${ACC}/storage/kv/namespaces/${NS}/values/${encodeURIComponent(k)}`;
|
|
35
|
+
const auth = { Authorization: `Bearer ${TOKEN}` };
|
|
36
|
+
return {
|
|
37
|
+
async get(k) { const r = await fetch(url(k), { headers: auth }); return r.ok ? r.text() : null; },
|
|
38
|
+
async getBytes(k) { const r = await fetch(url(k), { headers: auth }); return r.ok ? new Uint8Array(await r.arrayBuffer()) : null; },
|
|
39
|
+
async keys(prefix) {
|
|
40
|
+
const r = await fetch(`https://api.cloudflare.com/client/v4/accounts/${ACC}/storage/kv/namespaces/${NS}/keys?prefix=${encodeURIComponent(prefix)}&limit=1000`, { headers: auth });
|
|
41
|
+
return r.ok ? (await r.json()).result.map((k) => k.name) : [];
|
|
42
|
+
},
|
|
43
|
+
async put(k, body, type) {
|
|
44
|
+
const r = await fetch(url(k), { method: "PUT", headers: { ...auth, "content-type": type }, body });
|
|
45
|
+
if (!r.ok)
|
|
46
|
+
throw new Error(`KV put ${k}: ${r.status} ${await r.text()}`);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as ed from "@noble/ed25519";
|
|
2
|
+
export declare const b64: (u: Uint8Array) => string;
|
|
3
|
+
export declare const unb64: (s: string) => Uint8Array<ArrayBuffer>;
|
|
4
|
+
/** Seed is 32 random bytes, base64. Public key is derived. */
|
|
5
|
+
export declare function keyFromSeed(seedB64: string): {
|
|
6
|
+
seed: Uint8Array<ArrayBuffer>;
|
|
7
|
+
publicKey: ed.Bytes;
|
|
8
|
+
};
|
|
9
|
+
export declare function signDoc<T extends object>(doc: T, seedB64: string): T & {
|
|
10
|
+
signature: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function verifyDoc(doc: {
|
|
13
|
+
signature: string;
|
|
14
|
+
}, publicKeyB64: string): boolean;
|
|
15
|
+
export declare function signBytes(bytes: Uint8Array, seedB64: string): string;
|
|
16
|
+
export declare function sha256Hex(bytes: Uint8Array): string;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as ed from "@noble/ed25519";
|
|
2
|
+
import { sha512 } from "@noble/hashes/sha512";
|
|
3
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
4
|
+
import { bytesToHex } from "@noble/hashes/utils";
|
|
5
|
+
import { canonicalize } from "@shipvo/schema";
|
|
6
|
+
ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m));
|
|
7
|
+
const enc = new TextEncoder();
|
|
8
|
+
export const b64 = (u) => Buffer.from(u).toString("base64");
|
|
9
|
+
export const unb64 = (s) => new Uint8Array(Buffer.from(s, "base64"));
|
|
10
|
+
/** Seed is 32 random bytes, base64. Public key is derived. */
|
|
11
|
+
export function keyFromSeed(seedB64) {
|
|
12
|
+
const seed = unb64(seedB64);
|
|
13
|
+
if (seed.length !== 32)
|
|
14
|
+
throw new Error("signing seed must be 32 bytes (base64)");
|
|
15
|
+
return { seed, publicKey: ed.getPublicKey(seed) };
|
|
16
|
+
}
|
|
17
|
+
export function signDoc(doc, seedB64) {
|
|
18
|
+
const { seed } = keyFromSeed(seedB64);
|
|
19
|
+
const sig = ed.sign(enc.encode(canonicalize(doc)), seed);
|
|
20
|
+
return { ...doc, signature: b64(sig) };
|
|
21
|
+
}
|
|
22
|
+
export function verifyDoc(doc, publicKeyB64) {
|
|
23
|
+
try {
|
|
24
|
+
return ed.verify(unb64(doc.signature), enc.encode(canonicalize(doc)), unb64(publicKeyB64));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function signBytes(bytes, seedB64) { return b64(ed.sign(bytes, keyFromSeed(seedB64).seed)); }
|
|
31
|
+
export function sha256Hex(bytes) { return bytesToHex(sha256(bytes)); }
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function init(argv: string[]): Promise<void>;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// `shipvo init <dir>`: a new Expo app wired to Shipvo: signed OTA runtime + plugin, the device API, the simulator,
|
|
2
|
+
// a publish workflow and a shipvo.json. Everything an agent needs to open it is in place on the first commit.
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { b64, keyFromSeed } from "./index.js";
|
|
7
|
+
import { args as parse } from "./config.js";
|
|
8
|
+
const HOSTED = "https://purenative-control.purenative.workers.dev";
|
|
9
|
+
const SDK = { expo: "~58.0.21", camera: "~58.0.14", clipboard: "~58.0.4", localAuth: "~58.0.2", location: "~58.0.20", notifications: "~58.1.7", statusBar: "~58.0.4", constants: "~58.0.0", metroRuntime: "~58.0.15", react: "19.3.0", rn: "0.88.1", rnw: "^0.21.4" };
|
|
10
|
+
const title = (s) => s.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
11
|
+
export async function init(argv) {
|
|
12
|
+
const [dirArg] = argv.filter((x) => !x.startsWith("--") && !argv[argv.indexOf(x) - 1]?.startsWith("--"));
|
|
13
|
+
const a = parse(argv);
|
|
14
|
+
if (!dirArg) {
|
|
15
|
+
console.error("Usage: shipvo init <dir> [--app-id myshop] [--name \"My Shop\"] [--control-plane URL] [--public-key B64] [--bundle-id com.example.myshop]");
|
|
16
|
+
process.exit(2);
|
|
17
|
+
}
|
|
18
|
+
const dir = resolve(dirArg);
|
|
19
|
+
if (existsSync(dir)) {
|
|
20
|
+
console.error(`${dir} already exists`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const appId = (a["app-id"] || dirArg.split("/").pop().toLowerCase().replace(/[^a-z0-9-]/g, "-")).slice(0, 32);
|
|
24
|
+
const name = a.name || title(appId);
|
|
25
|
+
const controlPlane = (a["control-plane"] || HOSTED).replace(/\/$/, "");
|
|
26
|
+
const bundleId = a["bundle-id"] || `dev.shipvo.${appId.replace(/-/g, "")}`;
|
|
27
|
+
// Signing key: use the one given, or make one. The seed never enters the repo; the public key is pinned in the shell.
|
|
28
|
+
let publicKey = a["public-key"], seedNote = "";
|
|
29
|
+
if (!publicKey) {
|
|
30
|
+
const seed = b64(randomBytes(32));
|
|
31
|
+
publicKey = b64(keyFromSeed(seed).publicKey);
|
|
32
|
+
mkdirSync(join(dir, ".shipvo"), { recursive: true });
|
|
33
|
+
writeFileSync(join(dir, ".shipvo", "seed.txt"), seed, { mode: 0o600 });
|
|
34
|
+
seedNote = `A release signing key was created at .shipvo/seed.txt (git-ignored). Back it up offline and put it in the SHIPVO_SIGNING_SEED secret of your CI. Lose it and you cannot publish to installed shells.`;
|
|
35
|
+
}
|
|
36
|
+
const w = (p, body) => { mkdirSync(join(dir, p, ".."), { recursive: true }); writeFileSync(join(dir, p), body); };
|
|
37
|
+
w("shipvo.json", JSON.stringify({ appId, name, shellMajor: 1, controlPlane, publicKey }, null, 2) + "\n");
|
|
38
|
+
w("package.json", JSON.stringify({
|
|
39
|
+
name: appId, version: "1.0.0", main: "index.ts", private: true,
|
|
40
|
+
scripts: { start: "expo start", android: "expo run:android", ios: "expo run:ios", web: "expo start --web", sim: "shipvo sim", publish: "shipvo publish", rollback: "shipvo rollback", typecheck: "tsc --noEmit" },
|
|
41
|
+
dependencies: {
|
|
42
|
+
"@expo/metro-runtime": SDK.metroRuntime, expo: SDK.expo, "expo-camera": SDK.camera, "expo-clipboard": SDK.clipboard, "expo-constants": SDK.constants,
|
|
43
|
+
"expo-local-authentication": SDK.localAuth, "expo-location": SDK.location, "expo-notifications": SDK.notifications, "expo-status-bar": SDK.statusBar,
|
|
44
|
+
react: SDK.react, "react-dom": SDK.react, "react-native": SDK.rn, "react-native-web": SDK.rnw,
|
|
45
|
+
"@shipvo/device": "^0.1.0", "@shipvo/runtime": "^0.1.0",
|
|
46
|
+
},
|
|
47
|
+
devDependencies: { "@shipvo/cli": "^0.1.0", "@shipvo/sim": "^0.1.0", "@types/react": "~19.3.0", playwright: "^1.63.0", typescript: "~6.0.3" },
|
|
48
|
+
}, null, 2) + "\n");
|
|
49
|
+
w("app.json", JSON.stringify({ expo: {
|
|
50
|
+
name, slug: appId, version: "1.0.0", orientation: "portrait", userInterfaceStyle: "automatic",
|
|
51
|
+
ios: { bundleIdentifier: bundleId, supportsTablet: false },
|
|
52
|
+
android: { package: bundleId, versionCode: 1, predictiveBackGestureEnabled: false },
|
|
53
|
+
web: { output: "single" },
|
|
54
|
+
plugins: [
|
|
55
|
+
["@shipvo/runtime", { baseUrl: controlPlane, publicKey, appId, shellMajor: 1 }],
|
|
56
|
+
["expo-camera", { cameraPermission: `${name} uses the camera to scan codes.` }],
|
|
57
|
+
["expo-location", { locationWhenInUsePermission: `${name} uses your location to find what is near you.` }],
|
|
58
|
+
["expo-local-authentication", { faceIDPermission: `${name} uses Face ID to confirm actions.` }],
|
|
59
|
+
["expo-notifications", {}],
|
|
60
|
+
],
|
|
61
|
+
} }, null, 2) + "\n");
|
|
62
|
+
w("tsconfig.json", JSON.stringify({ extends: "expo/tsconfig.base", compilerOptions: { strict: true, allowImportingTsExtensions: true, noEmit: true }, exclude: ["node_modules"] }, null, 2) + "\n");
|
|
63
|
+
w("metro.config.js", `// Shipvo: PN_SIM=1 web builds use the simulated device (system sheets in the accessibility tree) instead of Expo's native modules.
|
|
64
|
+
const { getDefaultConfig } = require("expo/metro-config");
|
|
65
|
+
const withShipvoSim = require("@shipvo/device/metro");
|
|
66
|
+
module.exports = withShipvoSim(getDefaultConfig(__dirname));
|
|
67
|
+
`);
|
|
68
|
+
w("index.ts", `import { registerRootComponent } from "expo";\nimport App from "./App";\nregisterRootComponent(App);\n`);
|
|
69
|
+
w(".gitignore", `node_modules/\n.expo/\ndist/\ndist-ota/\ndist-web/\nsim-dist/\nweb-build/\n/ios\n/android\n.kotlin/\n*.jks\n*.p8\n*.p12\n*.key\n*.mobileprovision\n*.tsbuildinfo\n.env*.local\n.DS_Store\n# never commit the release signing seed\n.shipvo/\n`);
|
|
70
|
+
w("src/release.ts", `// Edit and push: the publish workflow signs and ships this to every installed phone and to the simulator.
|
|
71
|
+
export const RELEASE = { headline: "Release 1: hello from ${name}.", note: "Edit src/release.ts and push to main." };
|
|
72
|
+
`);
|
|
73
|
+
w("App.tsx", APP_TSX(name, appId));
|
|
74
|
+
w(".github/workflows/publish.yml", WORKFLOW);
|
|
75
|
+
w("README.md", README(name, appId, controlPlane));
|
|
76
|
+
console.log(`
|
|
77
|
+
Created ${name} in ${dirArg}
|
|
78
|
+
|
|
79
|
+
cd ${dirArg}
|
|
80
|
+
npm install # or pnpm / bun
|
|
81
|
+
npx playwright install chromium
|
|
82
|
+
npm run sim # the app in the simulator on http://localhost:4545
|
|
83
|
+
npx -y @shipvo/mcp # point Claude / Cursor / ChatGPT at it and open_app("${appId}")
|
|
84
|
+
|
|
85
|
+
Phones: npx expo run:android (needs Android Studio once; the OTA runtime is Android-first)
|
|
86
|
+
Publish: set CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, SHIPVO_KV_ID and SHIPVO_SIGNING_SEED, then npm run publish
|
|
87
|
+
(self-host the control plane from services/control-plane, or ask for a hosted app id during the beta)
|
|
88
|
+
|
|
89
|
+
${seedNote}
|
|
90
|
+
Public key pinned in the shell: ${publicKey}
|
|
91
|
+
`);
|
|
92
|
+
}
|
|
93
|
+
const APP_TSX = (name, appId) => `import { useEffect, useRef, useState } from "react";
|
|
94
|
+
import { AppState, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
|
|
95
|
+
import { StatusBar } from "expo-status-bar";
|
|
96
|
+
import { Runtime } from "@shipvo/runtime";
|
|
97
|
+
import { device } from "@shipvo/device";
|
|
98
|
+
import { ScanHost } from "@shipvo/device/scan";
|
|
99
|
+
import { RELEASE } from "./src/release";
|
|
100
|
+
|
|
101
|
+
const POLL_MS = 10_000;
|
|
102
|
+
|
|
103
|
+
export default function App() {
|
|
104
|
+
const [state] = useState(() => { try { return Runtime.getState(); } catch { return null; } });
|
|
105
|
+
const [log, setLog] = useState<string[]>([]);
|
|
106
|
+
const busy = useRef(false);
|
|
107
|
+
const add = (t: string) => setLog((l) => [t, ...l].slice(0, 20));
|
|
108
|
+
|
|
109
|
+
// Over-the-air updates: tell the runtime the first screen rendered (so this release is kept), then poll while open.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
try { const h = Runtime.markHealthy(); add(\`Running release \${h.release}\`); } catch {}
|
|
112
|
+
const check = async () => {
|
|
113
|
+
if (busy.current || !state?.baseUrl) return; busy.current = true;
|
|
114
|
+
try { const r = await Runtime.checkForUpdate(true); if (r.status === "staged") add(\`Applied release \${r.release} (\${r.via ?? "full"})\`); }
|
|
115
|
+
finally { busy.current = false; }
|
|
116
|
+
};
|
|
117
|
+
check(); const id = setInterval(() => { if (AppState.currentState === "active") check(); }, POLL_MS);
|
|
118
|
+
return () => clearInterval(id);
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
// Device API: real on phones, simulated in the simulator (agents answer the system sheets and set what the camera sees).
|
|
122
|
+
const scan = async () => { try { const r = await device.camera.scan({ prompt: "Point at a code" }); add(r ? \`Scanned \${r.data}\` : "Scan cancelled"); } catch (e: any) { add(String(e.message)); } };
|
|
123
|
+
const locate = async () => { try { const c = await device.location.current(); add(\`At \${c.latitude.toFixed(4)}, \${c.longitude.toFixed(4)}\`); } catch (e: any) { add(String(e.message)); } };
|
|
124
|
+
const notify = async () => { await device.notifications.show({ title: "${name}", body: "Hello from a notification.", data: {} }); add("Notification sent"); };
|
|
125
|
+
const confirm = async () => { const r = await device.auth.confirm("Confirm it is you"); add(r.ok ? "Confirmed" : \`Not confirmed (\${r.reason})\`); };
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<View style={s.root}>
|
|
129
|
+
<StatusBar style="dark" />
|
|
130
|
+
<ScrollView contentContainerStyle={s.page}>
|
|
131
|
+
<Text style={s.h1} accessibilityRole="header">${name}</Text>
|
|
132
|
+
<View style={s.hero}><Text style={s.heroText}>{RELEASE.headline}</Text><Text style={s.heroNote}>{RELEASE.note}</Text></View>
|
|
133
|
+
<Text style={s.h2} accessibilityRole="header">Try the device</Text>
|
|
134
|
+
<Btn label="Scan a code" onPress={scan} />
|
|
135
|
+
<Btn label="Use my location" onPress={locate} />
|
|
136
|
+
<Btn label="Confirm with fingerprint" onPress={confirm} />
|
|
137
|
+
<Btn label="Send a notification" onPress={notify} />
|
|
138
|
+
<Text style={s.h2} accessibilityRole="header">Log</Text>
|
|
139
|
+
{log.length === 0 ? <Text style={s.muted}>Nothing yet.</Text> : log.map((l, i) => <Text key={i} style={s.line}>{l}</Text>)}
|
|
140
|
+
<Text style={s.muted}>App id ${appId} · shell {state?.shellVersion ?? "none"} · release {state?.runningRelease ?? 0}</Text>
|
|
141
|
+
</ScrollView>
|
|
142
|
+
<ScanHost accent="#1F4E5F" />
|
|
143
|
+
</View>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function Btn({ label, onPress }: { label: string; onPress: () => void }) {
|
|
148
|
+
return <Pressable accessibilityRole="button" accessibilityLabel={label} onPress={onPress} style={s.btn}><Text style={s.btnText}>{label}</Text></Pressable>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const s = StyleSheet.create({
|
|
152
|
+
root: { flex: 1, backgroundColor: "#F6F8F7" },
|
|
153
|
+
page: { padding: 20, paddingTop: 56, gap: 12 },
|
|
154
|
+
h1: { fontSize: 28, fontWeight: "800", color: "#111" }, h2: { fontSize: 17, fontWeight: "700", color: "#111", marginTop: 8 },
|
|
155
|
+
hero: { backgroundColor: "#1F4E5F", borderRadius: 16, padding: 20, gap: 6 },
|
|
156
|
+
heroText: { color: "#fff", fontSize: 20, fontWeight: "800" }, heroNote: { color: "#fff", opacity: 0.9 },
|
|
157
|
+
btn: { minHeight: 48, borderRadius: 10, borderWidth: 1, borderColor: "#CBD5D1", alignItems: "center", justifyContent: "center", backgroundColor: "#fff" },
|
|
158
|
+
btnText: { fontSize: 16, fontWeight: "700", color: "#111" },
|
|
159
|
+
line: { fontSize: 14, color: "#111" }, muted: { fontSize: 13, color: "#5B6B66" },
|
|
160
|
+
});
|
|
161
|
+
`;
|
|
162
|
+
const WORKFLOW = `name: publish
|
|
163
|
+
on:
|
|
164
|
+
push:
|
|
165
|
+
branches: [main]
|
|
166
|
+
paths: ["src/**", "App.tsx"]
|
|
167
|
+
workflow_dispatch:
|
|
168
|
+
jobs:
|
|
169
|
+
publish:
|
|
170
|
+
runs-on: ubuntu-latest
|
|
171
|
+
concurrency: shipvo-publish
|
|
172
|
+
if: \${{ github.event_name != 'push' || !contains(github.event.head_commit.message, '[no-ota]') }}
|
|
173
|
+
steps:
|
|
174
|
+
- uses: actions/checkout@v4
|
|
175
|
+
- uses: actions/setup-node@v4
|
|
176
|
+
with: { node-version: 22 }
|
|
177
|
+
- run: npm ci
|
|
178
|
+
- run: pip install --break-system-packages bsdiff4
|
|
179
|
+
- name: Build, sign and publish over the air
|
|
180
|
+
env:
|
|
181
|
+
CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
182
|
+
CLOUDFLARE_ACCOUNT_ID: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
183
|
+
SHIPVO_KV_ID: \${{ secrets.SHIPVO_KV_ID }}
|
|
184
|
+
SHIPVO_SIGNING_SEED: \${{ secrets.SHIPVO_SIGNING_SEED }}
|
|
185
|
+
run: npx shipvo publish | tee -a "$GITHUB_STEP_SUMMARY"
|
|
186
|
+
`;
|
|
187
|
+
const README = (name, appId, cp) => `# ${name}
|
|
188
|
+
|
|
189
|
+
A [Shipvo](https://github.com/shipvo/shipvo) app: one React Native codebase that people install from the stores and AI agents operate in a simulator, kept current with signed over-the-air releases.
|
|
190
|
+
|
|
191
|
+
- \`npm run sim\` opens it in the simulator; \`npx -y @shipvo/mcp\` lets Claude, Cursor or ChatGPT use it (\`open_app("${appId}")\`).
|
|
192
|
+
- \`npx expo run:android\` builds the native shell (the trust root and control plane \`${cp}\` are pinned in it).
|
|
193
|
+
- Push to \`main\`: the publish workflow signs the new JavaScript and every phone and simulator gets it.
|
|
194
|
+
- \`src/release.ts\` is the first thing to edit; \`App.tsx\` shows the device API (camera, location, biometrics, notifications).
|
|
195
|
+
`;
|
package/dist/login.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function savedCreds(): {
|
|
2
|
+
key: string;
|
|
3
|
+
control: string;
|
|
4
|
+
mcp_url: string;
|
|
5
|
+
login?: string;
|
|
6
|
+
} | null;
|
|
7
|
+
export declare function login(argv: string[]): Promise<void>;
|
|
8
|
+
export declare function keys(): Promise<void>;
|
|
9
|
+
export declare function mcp(): void;
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// `shipvo login`: sign in with GitHub (device flow) and get an API key for the hosted simulator.
|
|
2
|
+
// `shipvo keys`: show the stored key's status. `shipvo mcp`: print the MCP config for agents.
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { args as parse } from "./config.js";
|
|
7
|
+
const HOSTED = "https://purenative-control.purenative.workers.dev";
|
|
8
|
+
const credFile = () => join(process.env.SHIPVO_HOME || join(homedir(), ".shipvo"), "credentials.json");
|
|
9
|
+
export function savedCreds() {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(credFile(), "utf8"));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function login(argv) {
|
|
18
|
+
const a = parse(argv);
|
|
19
|
+
const control = (a["control-plane"] || process.env.SHIPVO_CONTROL || HOSTED).replace(/\/$/, "");
|
|
20
|
+
const start = await fetch(`${control}/auth/device/start`, { method: "POST" });
|
|
21
|
+
const s = await start.json();
|
|
22
|
+
if (!start.ok) {
|
|
23
|
+
console.error(`Login is not available on ${control}: ${s.message ?? s.error}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
console.log(`\nOpen ${s.verification_uri} and enter the code:\n\n ${s.user_code}\n\nWaiting for GitHub…`);
|
|
27
|
+
const deadline = Date.now() + (s.expires_in ?? 900) * 1000;
|
|
28
|
+
let interval = (s.interval ?? 5) * 1000;
|
|
29
|
+
while (Date.now() < deadline) {
|
|
30
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
31
|
+
const r = await fetch(`${control}/auth/device/poll`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ device_code: s.device_code }) });
|
|
32
|
+
const d = await r.json();
|
|
33
|
+
if (r.status === 202) {
|
|
34
|
+
if (d.status === "slow_down")
|
|
35
|
+
interval += 5000;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!r.ok) {
|
|
39
|
+
console.error(`Login failed: ${d.status ?? d.error}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
mkdirSync(join(credFile(), ".."), { recursive: true });
|
|
43
|
+
writeFileSync(credFile(), JSON.stringify({ key: d.key, control, mcp_url: d.mcp.url, login: d.login, apps: d.apps, saved_at: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
|
44
|
+
console.log(`\nSigned in as ${d.login}. Key ${d.prefix}… saved to ${credFile()} (quota: ${d.quota_minutes} sandbox minutes; expires ${String(d.expires_at).slice(0, 10)}; apps: ${d.apps.join(", ")}).\n`);
|
|
45
|
+
printMcp(d.mcp.url, d.key);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
console.error("Timed out waiting for GitHub.");
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
export async function keys() {
|
|
52
|
+
const c = savedCreds();
|
|
53
|
+
if (!c) {
|
|
54
|
+
console.error("Not signed in. Run `shipvo login`.");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
const r = await fetch(`${c.control}/auth/me`, { headers: { authorization: `Bearer ${c.key}` } });
|
|
58
|
+
const d = await r.json();
|
|
59
|
+
if (!r.ok) {
|
|
60
|
+
console.error(d.error === "key_expired" ? `Key ${c.key.slice(0, 12)}… expired on ${d.expires_at}. Run \`shipvo login\` to get a new one.` : `Key ${c.key.slice(0, 12)}… is not valid any more (${d.error}). Run \`shipvo login\`.`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
console.log(JSON.stringify({ login: d.github_login, prefix: d.prefix, scope: d.scope, app: d.app_id, expires_at: d.expires_at, used_minutes: d.used_minutes, quota_minutes: d.quota_minutes, last_used_at: d.last_used_at, mcp_url: c.mcp_url }, null, 2));
|
|
64
|
+
}
|
|
65
|
+
export function mcp() {
|
|
66
|
+
const c = savedCreds();
|
|
67
|
+
if (!c) {
|
|
68
|
+
console.error("Not signed in. Run `shipvo login`.");
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
printMcp(c.mcp_url, c.key);
|
|
72
|
+
}
|
|
73
|
+
function printMcp(url, key) {
|
|
74
|
+
console.log(`Claude Desktop / Cursor / Claude Code (remote):\n${JSON.stringify({ mcpServers: { shipvo: { url, headers: { Authorization: `Bearer ${key}` } } } }, null, 2)}\n`);
|
|
75
|
+
console.log(`ChatGPT (custom MCP connector, cannot send headers): ${url}/${key}\n`);
|
|
76
|
+
console.log(`Local simulator instead: { "mcpServers": { "shipvo": { "command": "npx", "args": ["-y", "@shipvo/mcp"] } } } after \`shipvo sim\`.`);
|
|
77
|
+
}
|
package/dist/publish.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// `shipvo publish`: build the Hermes bundle, sign a release pointer, publish bundle + patches + the agent-facing web
|
|
2
|
+
// artifact to the control plane. `shipvo rollback --to N`: republish an earlier bundle as a new release.
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { sha256Hex, signBytes } from "./index.js";
|
|
8
|
+
import { args as parse, kv as kvFor, loadConfig, seed } from "./config.js";
|
|
9
|
+
const MIME = { ".html": "text/html", ".js": "text/javascript", ".json": "application/json", ".css": "text/css", ".ico": "image/x-icon", ".png": "image/png", ".ttf": "font/ttf", ".svg": "image/svg+xml" };
|
|
10
|
+
const walk = (d) => readdirSync(d).flatMap((f) => (statSync(join(d, f)).isDirectory() ? walk(join(d, f)) : [join(d, f)]));
|
|
11
|
+
async function currentRelease(kv, app, major) {
|
|
12
|
+
let max = 0;
|
|
13
|
+
for (const b of ["b0", "b1", "b2"]) {
|
|
14
|
+
const raw = await kv.get(`p/${app}/${major}/${b}`);
|
|
15
|
+
if (raw)
|
|
16
|
+
try {
|
|
17
|
+
max = Math.max(max, JSON.parse(JSON.parse(raw).body).release);
|
|
18
|
+
}
|
|
19
|
+
catch { }
|
|
20
|
+
}
|
|
21
|
+
return max;
|
|
22
|
+
}
|
|
23
|
+
export async function publish(argv) {
|
|
24
|
+
const a = parse(argv), cfg = loadConfig(), SEED = seed(), kv = kvFor(cfg);
|
|
25
|
+
const APP = cfg.appId, MAJOR = cfg.shellMajor;
|
|
26
|
+
const MIN_SHELL = a["min-shell"] || JSON.parse(readFileSync("app.json", "utf8")).expo.version; // a bundle needs the shell it was built against
|
|
27
|
+
const bands = (a.bands || "b0,b1,b2").split(",");
|
|
28
|
+
const release = Number(a.release || 0) || (await currentRelease(kv, APP, MAJOR)) + 1;
|
|
29
|
+
if (a["skip-export"] !== "true")
|
|
30
|
+
execSync("npx expo export --platform android --output-dir dist-ota --clear", { stdio: "inherit" });
|
|
31
|
+
const jsDir = "dist-ota/_expo/static/js/android";
|
|
32
|
+
const file = readdirSync(jsDir).find((f) => f.endsWith(".hbc")) || readdirSync(jsDir).find((f) => f.endsWith(".js"));
|
|
33
|
+
if (!file)
|
|
34
|
+
throw new Error("no bundle in " + jsDir);
|
|
35
|
+
const bytes = new Uint8Array(readFileSync(join(jsDir, file)));
|
|
36
|
+
const name = `r${release}.hbc`, newSha = sha256Hex(bytes);
|
|
37
|
+
await kv.put(`b/${APP}/${name}`, bytes, "application/octet-stream");
|
|
38
|
+
// Patches from recent releases and from each shell's embedded bundle; devices verify the patched result against the signed sha256.
|
|
39
|
+
const MAX_BASES = Number(a["max-bases"] || 6);
|
|
40
|
+
const bases = new Map();
|
|
41
|
+
const hist = (await Promise.all((await kv.keys(`h/${APP}/r`)).map(async (k) => { try {
|
|
42
|
+
return JSON.parse((await kv.get(k)));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
} })))
|
|
47
|
+
.filter((h) => h && h.release < release).sort((x, y) => y.release - x.release).slice(0, MAX_BASES - 2);
|
|
48
|
+
for (const h of hist)
|
|
49
|
+
bases.set(h.sha256, `b/${APP}/r${h.release}.hbc`);
|
|
50
|
+
for (const k of await kv.keys(`base/${APP}/`)) {
|
|
51
|
+
try {
|
|
52
|
+
const b = JSON.parse((await kv.get(k)));
|
|
53
|
+
bases.set(b.sha256, b.key);
|
|
54
|
+
}
|
|
55
|
+
catch { }
|
|
56
|
+
}
|
|
57
|
+
const patches = [];
|
|
58
|
+
let bsdiff = true;
|
|
59
|
+
try {
|
|
60
|
+
execSync("python3 -c 'import bsdiff4'", { stdio: "ignore" });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
bsdiff = false;
|
|
64
|
+
console.error("bsdiff4 not installed (pip install bsdiff4): publishing full bundles only");
|
|
65
|
+
}
|
|
66
|
+
if (bsdiff) {
|
|
67
|
+
const work = mkdtempSync(join(tmpdir(), "shipvo-"));
|
|
68
|
+
writeFileSync(join(work, "new.hbc"), bytes);
|
|
69
|
+
for (const [fromSha, key] of bases) {
|
|
70
|
+
if (fromSha === newSha)
|
|
71
|
+
continue;
|
|
72
|
+
const base = await kv.getBytes(key);
|
|
73
|
+
if (!base)
|
|
74
|
+
continue;
|
|
75
|
+
writeFileSync(join(work, "old.hbc"), base);
|
|
76
|
+
execSync(`python3 -c "import bsdiff4; bsdiff4.file_diff('${join(work, "old.hbc")}', '${join(work, "new.hbc")}', '${join(work, "p.bsdiff")}')"`);
|
|
77
|
+
const patch = new Uint8Array(readFileSync(join(work, "p.bsdiff")));
|
|
78
|
+
if (patch.length >= bytes.length * 0.6)
|
|
79
|
+
continue;
|
|
80
|
+
const pkey = `b/${APP}/d-${fromSha.slice(0, 16)}-r${release}.bsdiff`;
|
|
81
|
+
await kv.put(pkey, patch, "application/octet-stream");
|
|
82
|
+
patches.push({ from_sha256: fromSha, url: pkey, size: patch.length, sha256: sha256Hex(patch) });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
console.error(`patches: ${patches.map((p) => `${p.from_sha256.slice(0, 8)}=${(p.size / 1024).toFixed(1)}KB`).join(", ") || "none"}`);
|
|
86
|
+
// The agent-facing web artifact of the same commit, served at /w/<app>/r<N>/ for the simulator.
|
|
87
|
+
let web = null;
|
|
88
|
+
if (a["skip-web"] !== "true") {
|
|
89
|
+
execSync("PN_SIM=1 npx expo export --platform web --output-dir dist-web --clear", { stdio: "inherit" });
|
|
90
|
+
const base = `w/${APP}/r${release}`;
|
|
91
|
+
const files = [];
|
|
92
|
+
let indexSha = "";
|
|
93
|
+
for (const f of walk("dist-web")) {
|
|
94
|
+
const rel = f.slice("dist-web/".length);
|
|
95
|
+
if (rel === "metadata.json")
|
|
96
|
+
continue;
|
|
97
|
+
let body = readFileSync(f);
|
|
98
|
+
if (rel === "index.html") {
|
|
99
|
+
body = Buffer.from(body.toString("utf8").replace(/(src|href)="\//g, `$1="/${base}/`));
|
|
100
|
+
indexSha = sha256Hex(new Uint8Array(body));
|
|
101
|
+
}
|
|
102
|
+
await kv.put(`${base}/${rel}`, new Uint8Array(body), MIME[rel.slice(rel.lastIndexOf("."))] ?? "application/octet-stream");
|
|
103
|
+
files.push(rel);
|
|
104
|
+
}
|
|
105
|
+
web = { base: `/${base}/`, entry: "index.html", sha256: indexSha, files: files.length };
|
|
106
|
+
const idxKey = `w/${APP}/releases`;
|
|
107
|
+
let idx = { releases: [] };
|
|
108
|
+
try {
|
|
109
|
+
idx = JSON.parse((await kv.get(idxKey))) ?? idx;
|
|
110
|
+
}
|
|
111
|
+
catch { }
|
|
112
|
+
idx.releases = [...new Set([...idx.releases, release])].sort((x, y) => x - y);
|
|
113
|
+
idx.latest = Math.max(...idx.releases);
|
|
114
|
+
await kv.put(idxKey, JSON.stringify(idx), "application/json");
|
|
115
|
+
console.error(`web artifact: ${files.length} files at /${base}/`);
|
|
116
|
+
}
|
|
117
|
+
const issued_at = new Date().toISOString();
|
|
118
|
+
for (const band of bands) {
|
|
119
|
+
const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, release, issued_at,
|
|
120
|
+
core: { version: `1.0.${release}`, sha256: newSha, size: bytes.length, url: `b/${APP}/${name}`, patches },
|
|
121
|
+
addons: [], screens: [], min_shell_version: MIN_SHELL, ...(web ? { web } : {}) });
|
|
122
|
+
await kv.put(`p/${APP}/${MAJOR}/${band}`, JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
|
|
123
|
+
}
|
|
124
|
+
await kv.put(`h/${APP}/r${release}`, JSON.stringify({ release, issued_at, sha256: newSha, size: bytes.length, min_shell_version: MIN_SHELL, web }), "application/json");
|
|
125
|
+
console.log(JSON.stringify({ app: APP, release, bands, bytes: bytes.length, min_shell_version: MIN_SHELL, web, patches: patches.map((p) => ({ from: p.from_sha256.slice(0, 12), kb: +(p.size / 1024).toFixed(1) })) }));
|
|
126
|
+
}
|
|
127
|
+
export async function rollback(argv) {
|
|
128
|
+
const a = parse(argv), cfg = loadConfig(), SEED = seed(), kv = kvFor(cfg);
|
|
129
|
+
const APP = cfg.appId, MAJOR = cfg.shellMajor, to = Number(a.to);
|
|
130
|
+
if (!to) {
|
|
131
|
+
console.error("Usage: shipvo rollback --to <release> [--bands b0,b1,b2]");
|
|
132
|
+
process.exit(2);
|
|
133
|
+
}
|
|
134
|
+
const bytes = await kv.getBytes(`b/${APP}/r${to}.hbc`);
|
|
135
|
+
if (!bytes) {
|
|
136
|
+
console.error(`Release ${to} bundle not found`);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
const release = (await currentRelease(kv, APP, MAJOR)) + 1, issued_at = new Date().toISOString();
|
|
140
|
+
let minShell = "1.0.0", web = undefined;
|
|
141
|
+
try {
|
|
142
|
+
const h = JSON.parse((await kv.get(`h/${APP}/r${to}`)));
|
|
143
|
+
minShell = h.min_shell_version || minShell;
|
|
144
|
+
web = h.web ?? undefined;
|
|
145
|
+
}
|
|
146
|
+
catch { }
|
|
147
|
+
for (const band of (a.bands || "b0,b1,b2").split(",")) {
|
|
148
|
+
const body = JSON.stringify({ schema_version: "1.0", app_id: APP, shell_major: MAJOR, band, release, issued_at,
|
|
149
|
+
core: { version: `1.0.${release}`, sha256: sha256Hex(bytes), size: bytes.length, url: `b/${APP}/r${to}.hbc` }, addons: [], screens: [], min_shell_version: minShell, ...(web ? { web } : {}) });
|
|
150
|
+
await kv.put(`p/${APP}/${MAJOR}/${band}`, JSON.stringify({ body, signature: signBytes(new TextEncoder().encode(body), SEED) }), "application/json");
|
|
151
|
+
}
|
|
152
|
+
console.log(JSON.stringify({ app: APP, rolled_back_to_code_of: to, new_release: release, min_shell_version: minShell, issued_at }));
|
|
153
|
+
}
|
package/dist/sim.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function sim(argv: string[]): Promise<void>;
|
package/dist/sim.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// `shipvo sim`: build the app's web bundle with the simulated device and serve it in the simulator.
|
|
2
|
+
import { execSync, spawn } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { args as parse, loadConfig } from "./config.js";
|
|
6
|
+
export async function sim(argv) {
|
|
7
|
+
const a = parse(argv);
|
|
8
|
+
const cfg = loadConfig();
|
|
9
|
+
if (a["skip-build"] !== "true" || !existsSync("sim-dist/index.html")) {
|
|
10
|
+
console.error(`Building ${cfg.name} for the simulator…`);
|
|
11
|
+
execSync("PN_SIM=1 npx expo export --platform web --output-dir sim-dist --clear", { stdio: "inherit", env: { ...process.env, PN_SIM: "1" } });
|
|
12
|
+
}
|
|
13
|
+
let server;
|
|
14
|
+
try {
|
|
15
|
+
server = createRequire(process.cwd() + "/package.json").resolve("@shipvo/sim/server.mjs");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
console.error("Install @shipvo/sim (and playwright) in this app: npm i -D @shipvo/sim playwright && npx playwright install chromium");
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
const port = a.port || "4545";
|
|
22
|
+
console.error(`Simulator on http://localhost:${port} (open_app("${cfg.appId}") from @shipvo/mcp; Ctrl-C to stop)`);
|
|
23
|
+
const p = spawn(process.execPath, [server, "--port", port, "--app", cfg.appId, ...(a.dir ? ["--dir", a.dir] : [])], { stdio: "inherit", env: { ...process.env, PN_CONTROL: cfg.controlPlane } });
|
|
24
|
+
p.on("exit", (c) => process.exit(c ?? 0));
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipvo/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"bin": {
|
|
7
|
+
"shipvo": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@noble/ed25519": "^2.1.0",
|
|
13
|
+
"@noble/hashes": "^1.5.0",
|
|
14
|
+
"@shipvo/schema": "0.0.1"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"typescript": "^5.6.3",
|
|
18
|
+
"vitest": "^2.1.4",
|
|
19
|
+
"@types/node": "^22.8.0"
|
|
20
|
+
},
|
|
21
|
+
"description": "Shipvo CLI: create an app (init), run it in the simulator (sim), sign and publish over-the-air releases (publish, rollback), manage signing keys.",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"react-native",
|
|
24
|
+
"expo",
|
|
25
|
+
"ota",
|
|
26
|
+
"over-the-air-updates",
|
|
27
|
+
"codepush",
|
|
28
|
+
"codepush-alternative",
|
|
29
|
+
"hot-updates",
|
|
30
|
+
"mobile-app-updates",
|
|
31
|
+
"android",
|
|
32
|
+
"ios",
|
|
33
|
+
"hermes",
|
|
34
|
+
"cloudflare-workers",
|
|
35
|
+
"self-hosted",
|
|
36
|
+
"staged-rollout",
|
|
37
|
+
"mobile-cms",
|
|
38
|
+
"ai-agents"
|
|
39
|
+
],
|
|
40
|
+
"homepage": "https://github.com/shipvo/shipvo#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/shipvo/shipvo/issues"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/shipvo/shipvo.git",
|
|
47
|
+
"directory": "packages/cli"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"dist",
|
|
51
|
+
"README.md"
|
|
52
|
+
],
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public",
|
|
55
|
+
"provenance": true
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=20"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
|
|
62
|
+
"test": "vitest run"
|
|
63
|
+
}
|
|
6
64
|
}
|