@shipvo/cli 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/adopt-bare.d.ts +39 -0
- package/dist/adopt-bare.js +236 -0
- package/dist/adopt.d.ts +26 -0
- package/dist/adopt.js +257 -0
- package/dist/autohalt.d.ts +5 -0
- package/dist/autohalt.js +84 -0
- package/dist/cli.js +119 -83
- package/dist/config.d.ts +10 -0
- package/dist/config.js +22 -9
- package/dist/doctor.d.ts +29 -0
- package/dist/doctor.js +210 -0
- package/dist/help.d.ts +8 -0
- package/dist/help.js +76 -0
- package/dist/hermes.d.ts +11 -0
- package/dist/hermes.js +41 -0
- package/dist/init.js +487 -20
- package/dist/login.js +5 -2
- package/dist/native.d.ts +3 -0
- package/dist/native.js +39 -0
- package/dist/policy.d.ts +18 -0
- package/dist/policy.js +33 -0
- package/dist/preview.d.ts +35 -0
- package/dist/preview.js +249 -0
- package/dist/promote.d.ts +9 -0
- package/dist/promote.js +126 -0
- package/dist/publish.d.ts +9 -0
- package/dist/publish.js +168 -33
- package/dist/sim.d.ts +27 -0
- package/dist/sim.js +138 -4
- package/dist/status.d.ts +16 -0
- package/dist/status.js +54 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +34 -0
- package/dist/verify.d.ts +1 -0
- package/dist/verify.js +15 -0
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ npx -y @shipvo/cli sim # serve the app in the simulator for age
|
|
|
26
26
|
| `mcp` | Print the MCP config agents need |
|
|
27
27
|
| `keygen [--out seed.txt]` | Create an Ed25519 release signing key |
|
|
28
28
|
| `pubkey` | Print the public key for the current seed |
|
|
29
|
-
| `hash <file>`, `sign-file <file>`, `sign-json <file>`, `verify-json <file> <key
|
|
29
|
+
| `hash <file>`, `sign-file <file>`, `sign-json <file>`, `verify-json <file> <key>`, `verify <pointer> [key]` (release pointers) | Low-level signing helpers |
|
|
30
30
|
|
|
31
31
|
Changing computers? See [Moving to a new machine](new-machine.md).
|
|
32
32
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface ShipvoNativeValues {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
publicKey: string;
|
|
4
|
+
appId: string;
|
|
5
|
+
shellMajor?: number;
|
|
6
|
+
hermesBytecodeVersions?: number[];
|
|
7
|
+
nativeModules?: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
/** Name and value pairs, in the order and format app.plugin.js writes them. */
|
|
10
|
+
export declare function androidMetaData(v: ShipvoNativeValues): Array<[string, string]>;
|
|
11
|
+
/** Returns the manifest path if `dir/android/app/src/main/AndroidManifest.xml` exists, else null. */
|
|
12
|
+
export declare function findAndroidManifest(dir: string): string | null;
|
|
13
|
+
/** Adds or updates the dev.shipvo.* meta-data in `<application>`. Returns true if the file changed. */
|
|
14
|
+
export declare function writeAndroidConfig(manifestPath: string, values: ShipvoNativeValues): boolean;
|
|
15
|
+
export declare function applyAndroidConfig(xml: string, values: ShipvoNativeValues): string;
|
|
16
|
+
export interface ExpoModulesCheck {
|
|
17
|
+
installed: boolean;
|
|
18
|
+
expoPackage: boolean;
|
|
19
|
+
android: boolean;
|
|
20
|
+
ios: boolean;
|
|
21
|
+
/** Steps to run by hand. Never run by this function. */
|
|
22
|
+
plan: string[];
|
|
23
|
+
}
|
|
24
|
+
/** Reports whether Expo Modules are set up in a bare app. Read-only. */
|
|
25
|
+
export declare function checkExpoModules(dir: string): ExpoModulesCheck;
|
|
26
|
+
export type IosConfigValues = ShipvoNativeValues;
|
|
27
|
+
/** The Info.plist keys for these values. Optional keys are left out when their value is omitted. */
|
|
28
|
+
export declare function iosPlistEntries(values: IosConfigValues): Record<string, string>;
|
|
29
|
+
/**
|
|
30
|
+
* Set the Shipvo keys in an Info.plist. Other keys and their order stay. New keys go at the end.
|
|
31
|
+
* Returns true when the file changed; a second run with the same values writes nothing.
|
|
32
|
+
*/
|
|
33
|
+
export declare function writeIosConfig(infoPlistPath: string, values: IosConfigValues): boolean;
|
|
34
|
+
/** The app target's Info.plist: `ios/<name>/Info.plist`. Skips Pods and test targets. Undefined when none is found. */
|
|
35
|
+
export declare function findIosInfoPlist(dir?: string): string | undefined;
|
|
36
|
+
/** What to tell the user once both platforms are configured. */
|
|
37
|
+
export declare function adoptNextSteps(): string;
|
|
38
|
+
/** True when writeIosConfig would change the file. Reads only. */
|
|
39
|
+
export declare function iosConfigChanged(infoPlistPath: string, values: IosConfigValues): boolean;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Bare React Native adopt: write the native config by hand, without a prebuild.
|
|
2
|
+
// Android manifest and the Expo Modules check, then the iOS Info.plist.
|
|
3
|
+
// Writes the same values as packages/runtime/app.plugin.js.
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import plist from "plist";
|
|
7
|
+
/** Name and value pairs, in the order and format app.plugin.js writes them. */
|
|
8
|
+
export function androidMetaData(v) {
|
|
9
|
+
if (!v.baseUrl || !v.publicKey || !v.appId)
|
|
10
|
+
throw new Error("baseUrl, publicKey and appId are required");
|
|
11
|
+
const out = [
|
|
12
|
+
["dev.shipvo.BASE_URL", v.baseUrl],
|
|
13
|
+
["dev.shipvo.PUBLIC_KEY", v.publicKey],
|
|
14
|
+
["dev.shipvo.APP_ID", v.appId],
|
|
15
|
+
["dev.shipvo.SHELL_MAJOR", String(v.shellMajor ?? 1)],
|
|
16
|
+
];
|
|
17
|
+
if (v.hermesBytecodeVersions !== undefined)
|
|
18
|
+
out.push(["dev.shipvo.HERMES_BYTECODE_VERSIONS", formatBytecodeVersions(v.hermesBytecodeVersions)]);
|
|
19
|
+
if (v.nativeModules !== undefined)
|
|
20
|
+
out.push(["dev.shipvo.NATIVE_MODULES", formatNativeModules(v.nativeModules)]);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
// Same rules as formatBytecodeVersions in packages/runtime/app.plugin.js.
|
|
24
|
+
function formatBytecodeVersions(value) {
|
|
25
|
+
if (!Array.isArray(value))
|
|
26
|
+
throw new Error("adopt-bare: hermesBytecodeVersions must be an array of integers");
|
|
27
|
+
for (const v of value) {
|
|
28
|
+
if (!Number.isInteger(v) || v < 0) {
|
|
29
|
+
throw new Error(`adopt-bare: hermesBytecodeVersions must be non-negative integers, got ${JSON.stringify(v)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return value.join(",");
|
|
33
|
+
}
|
|
34
|
+
// Same rules as formatNativeModules in packages/runtime/app.plugin.js.
|
|
35
|
+
function formatNativeModules(value) {
|
|
36
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
37
|
+
throw new Error("adopt-bare: nativeModules must be an object of package name to version");
|
|
38
|
+
}
|
|
39
|
+
return Object.entries(value)
|
|
40
|
+
.map(([name, version]) => {
|
|
41
|
+
if (!name.trim())
|
|
42
|
+
throw new Error("adopt-bare: nativeModules has an empty package name");
|
|
43
|
+
if (typeof version !== "string" || !version.trim()) {
|
|
44
|
+
throw new Error(`adopt-bare: nativeModules version for "${name}" must be a non-empty string`);
|
|
45
|
+
}
|
|
46
|
+
if (/[,\s]/.test(version) || /[,\s]/.test(name)) {
|
|
47
|
+
throw new Error(`adopt-bare: nativeModules entry "${name}": "${version}" has a comma or space where it cannot`);
|
|
48
|
+
}
|
|
49
|
+
return `${name}@${version}`;
|
|
50
|
+
})
|
|
51
|
+
.join(",");
|
|
52
|
+
}
|
|
53
|
+
/** Returns the manifest path if `dir/android/app/src/main/AndroidManifest.xml` exists, else null. */
|
|
54
|
+
export function findAndroidManifest(dir) {
|
|
55
|
+
const p = join(dir, "android", "app", "src", "main", "AndroidManifest.xml");
|
|
56
|
+
return existsSync(p) ? p : null;
|
|
57
|
+
}
|
|
58
|
+
const escAttr = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
|
59
|
+
const unescAttr = (s) => s.replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
60
|
+
// Same length as the input, with comments and CDATA blanked, so offsets match the original.
|
|
61
|
+
const mask = (s) => s.replace(/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>/g, (m) => " ".repeat(m.length));
|
|
62
|
+
/** Adds or updates the dev.shipvo.* meta-data in `<application>`. Returns true if the file changed. */
|
|
63
|
+
export function writeAndroidConfig(manifestPath, values) {
|
|
64
|
+
const original = readFileSync(manifestPath, "utf8");
|
|
65
|
+
const next = applyAndroidConfig(original, values);
|
|
66
|
+
if (next === original)
|
|
67
|
+
return false;
|
|
68
|
+
writeFileSync(manifestPath, next);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
export function applyAndroidConfig(xml, values) {
|
|
72
|
+
const items = androidMetaData(values);
|
|
73
|
+
let text = xml;
|
|
74
|
+
let m = /<application(?=[\s/>])/.exec(mask(text));
|
|
75
|
+
if (!m)
|
|
76
|
+
throw new Error("AndroidManifest.xml has no <application> element");
|
|
77
|
+
const start = m.index;
|
|
78
|
+
// End of the opening tag, skipping quoted attribute values.
|
|
79
|
+
let i = start + "<application".length;
|
|
80
|
+
let quote = "";
|
|
81
|
+
for (; i < text.length; i++) {
|
|
82
|
+
const c = text[i];
|
|
83
|
+
if (quote) {
|
|
84
|
+
if (c === quote)
|
|
85
|
+
quote = "";
|
|
86
|
+
}
|
|
87
|
+
else if (c === '"' || c === "'")
|
|
88
|
+
quote = c;
|
|
89
|
+
else if (c === ">")
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (i >= text.length)
|
|
93
|
+
throw new Error("AndroidManifest.xml has an unterminated <application> tag");
|
|
94
|
+
const lineStart = text.lastIndexOf("\n", start) + 1;
|
|
95
|
+
const indent = /^[ \t]*/.exec(text.slice(lineStart, start))[0];
|
|
96
|
+
const inner = indent + " ";
|
|
97
|
+
const line = ([n, v]) => `${inner}<meta-data android:name="${n}" android:value="${escAttr(v)}"/>`;
|
|
98
|
+
if (text[i - 1] === "/") {
|
|
99
|
+
// Self-closed: open it up. Keep the attributes exactly as they were.
|
|
100
|
+
const head = text.slice(start, i - 1).replace(/[ \t]+$/, "");
|
|
101
|
+
return text.slice(0, start) + head + ">\n" + items.map(line).join("\n") + "\n" + indent + "</application>" + text.slice(i + 1);
|
|
102
|
+
}
|
|
103
|
+
const close = mask(text).indexOf("</application", i);
|
|
104
|
+
if (close < 0)
|
|
105
|
+
throw new Error("AndroidManifest.xml has no </application>");
|
|
106
|
+
let body = text.slice(i + 1, close);
|
|
107
|
+
const maskedBody = mask(body);
|
|
108
|
+
const missing = [];
|
|
109
|
+
for (const [name, value] of items) {
|
|
110
|
+
const re = /<meta-data\b(?:"[^"]*"|'[^']*'|[^>"'])*>/g;
|
|
111
|
+
let found = false;
|
|
112
|
+
let e;
|
|
113
|
+
while ((e = re.exec(maskedBody))) {
|
|
114
|
+
const nm = /android:name\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(e[0]);
|
|
115
|
+
if (!nm || unescAttr(nm[1] ?? nm[2]) !== name)
|
|
116
|
+
continue;
|
|
117
|
+
found = true;
|
|
118
|
+
const tag = body.slice(e.index, e.index + e[0].length);
|
|
119
|
+
const vm = /(android:value\s*=\s*)(?:"([^"]*)"|'([^']*)')/.exec(tag);
|
|
120
|
+
let newTag;
|
|
121
|
+
if (vm) {
|
|
122
|
+
if (unescAttr(vm[2] ?? vm[3]) === value)
|
|
123
|
+
break;
|
|
124
|
+
newTag = tag.slice(0, vm.index) + `${vm[1]}"${escAttr(value)}"` + tag.slice(vm.index + vm[0].length);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
newTag = tag.replace(/\s*\/?>$/, (end) => ` android:value="${escAttr(value)}"${end.trim()}`);
|
|
128
|
+
}
|
|
129
|
+
body = body.slice(0, e.index) + newTag + body.slice(e.index + tag.length);
|
|
130
|
+
// Offsets after this point moved; rebuild is simplest, so stop and re-run for the rest.
|
|
131
|
+
return applyAndroidConfig(text.slice(0, i + 1) + body + text.slice(close), values);
|
|
132
|
+
}
|
|
133
|
+
if (!found)
|
|
134
|
+
missing.push([name, value]);
|
|
135
|
+
}
|
|
136
|
+
if (missing.length === 0)
|
|
137
|
+
return text;
|
|
138
|
+
const closeLineStart = text.lastIndexOf("\n", close) + 1;
|
|
139
|
+
const onOwnLine = /^[ \t]*$/.test(text.slice(closeLineStart, close));
|
|
140
|
+
const add = missing.map(line).join("\n") + "\n";
|
|
141
|
+
if (onOwnLine)
|
|
142
|
+
return text.slice(0, closeLineStart) + add + text.slice(closeLineStart);
|
|
143
|
+
return text.slice(0, close) + "\n" + add + indent + text.slice(close);
|
|
144
|
+
}
|
|
145
|
+
function walk(dir, match, depth = 8) {
|
|
146
|
+
if (depth < 0 || !existsSync(dir))
|
|
147
|
+
return null;
|
|
148
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
149
|
+
if (e.name === "node_modules" || e.name === "Pods" || e.name === "build")
|
|
150
|
+
continue;
|
|
151
|
+
const p = join(dir, e.name);
|
|
152
|
+
if (e.isDirectory()) {
|
|
153
|
+
const r = walk(p, match, depth - 1);
|
|
154
|
+
if (r)
|
|
155
|
+
return r;
|
|
156
|
+
}
|
|
157
|
+
else if (match.test(e.name))
|
|
158
|
+
return p;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
/** Reports whether Expo Modules are set up in a bare app. Read-only. */
|
|
163
|
+
export function checkExpoModules(dir) {
|
|
164
|
+
let expoPackage = false;
|
|
165
|
+
try {
|
|
166
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
167
|
+
expoPackage = Boolean(pkg.dependencies?.expo ?? pkg.devDependencies?.expo);
|
|
168
|
+
}
|
|
169
|
+
catch { /* no package.json: not installed */ }
|
|
170
|
+
const has = (file, re) => (file ? re.test(readFileSync(file, "utf8")) : false);
|
|
171
|
+
const android = has(walk(join(dir, "android", "app", "src", "main", "java"), /^MainApplication\.(kt|java)$/), /expo\.modules\.(ApplicationLifecycleDispatcher|ReactNativeHostWrapper)|ExpoModulesPackage/);
|
|
172
|
+
const ios = has(walk(join(dir, "ios"), /^AppDelegate\.(swift|mm?|h)$/), /\bExpo(AppDelegate|ReactDelegate|Modules)?\b|import Expo/);
|
|
173
|
+
const installed = expoPackage && android && ios;
|
|
174
|
+
return { installed, expoPackage, android, ios, plan: installed ? [] : ["npx install-expo-modules@latest"] };
|
|
175
|
+
}
|
|
176
|
+
/** The Info.plist keys for these values. Optional keys are left out when their value is omitted. */
|
|
177
|
+
export function iosPlistEntries(values) {
|
|
178
|
+
const { baseUrl, publicKey, appId, shellMajor = 1 } = values;
|
|
179
|
+
if (!baseUrl || !publicKey || !appId)
|
|
180
|
+
throw new Error("baseUrl, publicKey and appId are required");
|
|
181
|
+
const out = {
|
|
182
|
+
ShipvoBaseURL: baseUrl,
|
|
183
|
+
ShipvoPublicKey: publicKey,
|
|
184
|
+
ShipvoAppId: appId,
|
|
185
|
+
ShipvoShellMajor: String(shellMajor),
|
|
186
|
+
};
|
|
187
|
+
if (values.hermesBytecodeVersions !== undefined)
|
|
188
|
+
out.ShipvoHermesBytecodeVersions = formatBytecodeVersions(values.hermesBytecodeVersions);
|
|
189
|
+
if (values.nativeModules !== undefined)
|
|
190
|
+
out.ShipvoNativeModules = formatNativeModules(values.nativeModules);
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Set the Shipvo keys in an Info.plist. Other keys and their order stay. New keys go at the end.
|
|
195
|
+
* Returns true when the file changed; a second run with the same values writes nothing.
|
|
196
|
+
*/
|
|
197
|
+
export function writeIosConfig(infoPlistPath, values) {
|
|
198
|
+
const entries = iosPlistEntries(values);
|
|
199
|
+
const dict = plist.parse(readFileSync(infoPlistPath, "utf8"));
|
|
200
|
+
if (Object.entries(entries).every(([k, v]) => dict[k] === v))
|
|
201
|
+
return false;
|
|
202
|
+
Object.assign(dict, entries);
|
|
203
|
+
writeFileSync(infoPlistPath, plist.build(dict, { indent: "\t", newline: "\n" }) + "\n");
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
/** The app target's Info.plist: `ios/<name>/Info.plist`. Skips Pods and test targets. Undefined when none is found. */
|
|
207
|
+
export function findIosInfoPlist(dir = ".") {
|
|
208
|
+
const ios = join(dir, "ios");
|
|
209
|
+
let entries;
|
|
210
|
+
try {
|
|
211
|
+
entries = readdirSync(ios, { withFileTypes: true });
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
const names = entries.filter((e) => e.isDirectory() && !/^Pods$|Tests$|\.(xcodeproj|xcworkspace|xcassets)$/.test(e.name)).map((e) => e.name).sort();
|
|
217
|
+
const withPlist = names.filter((n) => existsSync(join(ios, n, "Info.plist")));
|
|
218
|
+
// Prefer the folder named like the Xcode project.
|
|
219
|
+
const project = entries.find((e) => e.name.endsWith(".xcodeproj"))?.name.replace(/\.xcodeproj$/, "");
|
|
220
|
+
const pick = withPlist.find((n) => n === project) ?? withPlist[0];
|
|
221
|
+
return pick ? join(ios, pick, "Info.plist") : undefined;
|
|
222
|
+
}
|
|
223
|
+
/** What to tell the user once both platforms are configured. */
|
|
224
|
+
export function adoptNextSteps() {
|
|
225
|
+
return [
|
|
226
|
+
"Next steps:",
|
|
227
|
+
" 1. cd ios && pod install",
|
|
228
|
+
" 2. Rebuild both apps (Android and iOS) so the native shells carry the new config.",
|
|
229
|
+
" 3. shipvo publish --dry-run",
|
|
230
|
+
].join("\n");
|
|
231
|
+
}
|
|
232
|
+
/** True when writeIosConfig would change the file. Reads only. */
|
|
233
|
+
export function iosConfigChanged(infoPlistPath, values) {
|
|
234
|
+
const dict = plist.parse(readFileSync(infoPlistPath, "utf8"));
|
|
235
|
+
return !Object.entries(iosPlistEntries(values)).every(([k, v]) => dict[k] === v);
|
|
236
|
+
}
|
package/dist/adopt.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";
|
|
2
|
+
export type Project = {
|
|
3
|
+
dir: string;
|
|
4
|
+
isExpo: boolean;
|
|
5
|
+
isBare: boolean;
|
|
6
|
+
expoSdk: number | null;
|
|
7
|
+
packageManager: PackageManager;
|
|
8
|
+
configKind: "app.json" | "app.config.js" | "app.config.ts" | null;
|
|
9
|
+
configFile: string | null;
|
|
10
|
+
expoUpdates: boolean;
|
|
11
|
+
codePush: boolean;
|
|
12
|
+
};
|
|
13
|
+
/** Reads the folder and says what kind of app it is. Changes nothing. */
|
|
14
|
+
export declare function detectProject(dir: string): Project;
|
|
15
|
+
export type AdoptDeps = {
|
|
16
|
+
install: (pm: PackageManager, pkgs: string[], dir: string, dev?: boolean) => void;
|
|
17
|
+
installExpoModules: (dir: string) => void;
|
|
18
|
+
confirm: (q: string) => Promise<boolean>;
|
|
19
|
+
doctor: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** A package.json with committed ios/ and android/ folders and no Expo config plugins or config code: a bare React Native app. */
|
|
22
|
+
export declare const isBareApp: (dir: string) => boolean;
|
|
23
|
+
/** Does argv ask for adopt mode: no folder, or ".". */
|
|
24
|
+
export declare const isAdoptArgs: (argv: string[]) => boolean;
|
|
25
|
+
export declare const positional: (argv: string[]) => string[];
|
|
26
|
+
export declare function adopt(argv: string[], dir?: string, overrides?: Partial<AdoptDeps>): Promise<undefined>;
|
package/dist/adopt.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// `shipvo init` inside an existing app ("adopt mode"): detect the project, show a plan, apply it only after
|
|
2
|
+
// confirmation. Never overwrites a file. In an Expo app it never touches ios/ or android/. In a bare React Native app
|
|
3
|
+
// it writes the Shipvo values into AndroidManifest.xml and Info.plist (adopt-bare.ts).
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { b64, keyFromSeed } from "./index.js";
|
|
10
|
+
import { runDoctorChecks } from "./doctor.js";
|
|
11
|
+
import { adoptNextSteps, applyAndroidConfig, checkExpoModules, findAndroidManifest, findIosInfoPlist, iosConfigChanged, writeAndroidConfig, writeIosConfig } from "./adopt-bare.js";
|
|
12
|
+
const HOSTED = "https://api.shipvo.dev";
|
|
13
|
+
const readJson = (p) => { try {
|
|
14
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
} };
|
|
19
|
+
/** Reads the folder and says what kind of app it is. Changes nothing. */
|
|
20
|
+
export function detectProject(dir) {
|
|
21
|
+
const pkg = readJson(join(dir, "package.json"));
|
|
22
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
23
|
+
const configFile = ["app.json", "app.config.ts", "app.config.js"].find((f) => existsSync(join(dir, f))) ?? null;
|
|
24
|
+
const lock = [["pnpm-lock.yaml", "pnpm"], ["yarn.lock", "yarn"], ["bun.lock", "bun"], ["bun.lockb", "bun"], ["package-lock.json", "npm"]];
|
|
25
|
+
const packageManager = lock.find(([f]) => existsSync(join(dir, f)))?.[1] ?? "npm";
|
|
26
|
+
const sdk = /(\d+)\./.exec(String(deps.expo ?? ""))?.[1];
|
|
27
|
+
const appJson = configFile === "app.json" ? readJson(join(dir, "app.json")) : null;
|
|
28
|
+
const plugins = appJson?.expo?.plugins ?? appJson?.plugins ?? [];
|
|
29
|
+
const hasNative = existsSync(join(dir, "ios")) && existsSync(join(dir, "android"));
|
|
30
|
+
const isExpo = !!pkg && !!deps.expo && !!configFile;
|
|
31
|
+
return {
|
|
32
|
+
dir, isExpo, isBare: hasNative && plugins.length === 0 && configFile !== "app.config.js" && configFile !== "app.config.ts",
|
|
33
|
+
expoSdk: sdk ? Number(sdk) : null, packageManager, configKind: configFile, configFile,
|
|
34
|
+
expoUpdates: !!deps["expo-updates"] || plugins.some((p) => (Array.isArray(p) ? p[0] : p) === "expo-updates"),
|
|
35
|
+
codePush: !!deps["react-native-code-push"] || !!deps["@revopush/react-native-code-push"],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const ADOPT_FLAGS = new Set(["yes", "dry-run", "device"]);
|
|
39
|
+
const defaultDeps = {
|
|
40
|
+
install(pm, pkgs, dir, dev = false) {
|
|
41
|
+
const devFlag = dev ? [pm === "npm" ? "--save-dev" : pm === "yarn" || pm === "bun" ? "--dev" : "-D"] : [];
|
|
42
|
+
const r = spawnSync(pm, [pm === "npm" ? "install" : "add", ...devFlag, ...pkgs], { cwd: dir, stdio: "inherit" });
|
|
43
|
+
if (r.status !== 0)
|
|
44
|
+
throw new Error(`${pm} could not install ${pkgs.join(", ")}`);
|
|
45
|
+
},
|
|
46
|
+
installExpoModules(dir) {
|
|
47
|
+
const r = spawnSync("npx", ["install-expo-modules@latest", "--non-interactive"], { cwd: dir, stdio: "inherit" });
|
|
48
|
+
if (r.status !== 0)
|
|
49
|
+
throw new Error("npx install-expo-modules@latest failed");
|
|
50
|
+
},
|
|
51
|
+
async confirm(q) {
|
|
52
|
+
if (!process.stdin.isTTY) {
|
|
53
|
+
console.error("shipvo: this is not a terminal, so I cannot ask. Re-run with --yes to apply the plan, or --dry-run to only read it.");
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
57
|
+
const ans = (await rl.question(q)).trim().toLowerCase();
|
|
58
|
+
rl.close();
|
|
59
|
+
return ans === "y" || ans === "yes";
|
|
60
|
+
},
|
|
61
|
+
doctor: true,
|
|
62
|
+
};
|
|
63
|
+
/** A package.json with committed ios/ and android/ folders and no Expo config plugins or config code: a bare React Native app. */
|
|
64
|
+
export const isBareApp = (dir) => {
|
|
65
|
+
if (!existsSync(join(dir, "package.json")) || !existsSync(join(dir, "ios")) || !existsSync(join(dir, "android")))
|
|
66
|
+
return false;
|
|
67
|
+
const p = detectProject(dir);
|
|
68
|
+
return !p.isExpo || p.isBare;
|
|
69
|
+
};
|
|
70
|
+
/** Does argv ask for adopt mode: no folder, or ".". */
|
|
71
|
+
export const isAdoptArgs = (argv) => {
|
|
72
|
+
const pos = positional(argv);
|
|
73
|
+
return pos.length === 0 || (pos.length === 1 && pos[0] === ".");
|
|
74
|
+
};
|
|
75
|
+
export const positional = (argv) => argv.filter((x, i) => !x.startsWith("-") && !(argv[i - 1]?.startsWith("--") && !ADOPT_FLAGS.has(argv[i - 1].slice(2))));
|
|
76
|
+
const flag = (argv, n) => { const i = argv.indexOf(`--${n}`); return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : undefined; };
|
|
77
|
+
export async function adopt(argv, dir = process.cwd(), overrides = {}) {
|
|
78
|
+
const deps = { ...defaultDeps, ...overrides };
|
|
79
|
+
dir = resolve(dir);
|
|
80
|
+
const yes = argv.includes("--yes"), dry = argv.includes("--dry-run"), withDevice = argv.includes("--device");
|
|
81
|
+
const fail = (msg, code = 1) => { console.error(msg); process.exit(code); };
|
|
82
|
+
const p = detectProject(dir);
|
|
83
|
+
const bare = isBareApp(dir);
|
|
84
|
+
if (!bare && (!existsSync(join(dir, "package.json")) || !p.isExpo)) {
|
|
85
|
+
return fail(`shipvo: ${dir} is not an Expo app. Adopt mode needs a package.json with "expo" and an app.json or app.config.js/ts.\nTo start a new app, run: shipvo init myshop`);
|
|
86
|
+
}
|
|
87
|
+
if (p.expoUpdates || p.codePush) {
|
|
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`);
|
|
90
|
+
}
|
|
91
|
+
let manifest = null, infoPlist;
|
|
92
|
+
if (bare) {
|
|
93
|
+
manifest = findAndroidManifest(dir);
|
|
94
|
+
infoPlist = findIosInfoPlist(dir);
|
|
95
|
+
if (!manifest || !infoPlist)
|
|
96
|
+
return fail(`shipvo: this looks like a bare React Native app, but I could not find ${[!manifest && "android/app/src/main/AndroidManifest.xml", !infoPlist && "ios/<app>/Info.plist"].filter(Boolean).join(" or ")}. I changed nothing.\nIf this is an Expo managed app, add any config plugin to app.json (or app.config.*), or remove the ios/ and android/ folders, and run this again.`);
|
|
97
|
+
}
|
|
98
|
+
if (!bare && p.configKind === "app.json" && !readJson(join(dir, "app.json")))
|
|
99
|
+
return fail("shipvo: app.json is not valid JSON, so I changed nothing. Fix it and run this again.");
|
|
100
|
+
const pkg = readJson(join(dir, "package.json"));
|
|
101
|
+
const have = (n) => !!(pkg.dependencies?.[n] ?? pkg.devDependencies?.[n]);
|
|
102
|
+
const cfgPath = join(dir, "shipvo.json");
|
|
103
|
+
const existingCfg = existsSync(cfgPath) ? readJson(cfgPath) : null;
|
|
104
|
+
const seedPath = join(dir, ".shipvo", "seed.txt");
|
|
105
|
+
const appJson = p.configKind === "app.json" ? readJson(join(dir, "app.json")) : null;
|
|
106
|
+
const expo = appJson ? (appJson.expo ?? appJson) : null;
|
|
107
|
+
if (bare && !readJson(cfgPath) && existsSync(cfgPath))
|
|
108
|
+
return fail("shipvo: shipvo.json is not valid JSON, so I changed nothing.");
|
|
109
|
+
const appId = (existingCfg?.appId ?? expo?.slug ?? String(pkg.name ?? "app").replace(/^@[^/]+\//, "")).toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 32);
|
|
110
|
+
const name = existingCfg?.name ?? expo?.name ?? appId;
|
|
111
|
+
const controlPlane = (flag(argv, "control-plane") ?? existingCfg?.controlPlane ?? HOSTED).replace(/\/$/, "");
|
|
112
|
+
// Signing key: the one given, the one in shipvo.json, the seed already on disk, or a new one.
|
|
113
|
+
const givenKey = flag(argv, "public-key");
|
|
114
|
+
if (givenKey !== undefined && !isPublicKey(givenKey))
|
|
115
|
+
return fail(`shipvo: --public-key must be a base64 string that decodes to 32 bytes. I changed nothing.`, 2);
|
|
116
|
+
const seedKey = existsSync(seedPath) ? b64(keyFromSeed(readFileSync(seedPath, "utf8").trim()).publicKey) : null;
|
|
117
|
+
if (existingCfg?.publicKey && seedKey && existingCfg.publicKey !== seedKey)
|
|
118
|
+
return fail(`shipvo: the publicKey in shipvo.json does not match the seed in .shipvo/seed.txt. Updates signed with the seed would be rejected by the app. I changed nothing.\nFix: restore the matching seed, or set publicKey in shipvo.json to the key of this seed.`);
|
|
119
|
+
let publicKey = givenKey ?? existingCfg?.publicKey;
|
|
120
|
+
let newSeed = null;
|
|
121
|
+
if (!publicKey) {
|
|
122
|
+
if (seedKey)
|
|
123
|
+
publicKey = seedKey;
|
|
124
|
+
else {
|
|
125
|
+
newSeed = b64(randomBytes(32));
|
|
126
|
+
publicKey = b64(keyFromSeed(newSeed).publicKey);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (existingCfg?.publicKey && givenKey && givenKey !== existingCfg.publicKey)
|
|
130
|
+
console.log(`note: shipvo.json already has a publicKey, and --public-key differs. The flag goes into the plugin props; shipvo.json is kept. Make them match before you ship.`);
|
|
131
|
+
if (existingCfg?.controlPlane && flag(argv, "control-plane") && controlPlane !== String(existingCfg.controlPlane).replace(/\/$/, ""))
|
|
132
|
+
console.log(`note: --control-plane differs from shipvo.json; the flag is used for the plugin, the file is kept.`);
|
|
133
|
+
const pluginProps = { baseUrl: controlPlane, publicKey, appId, shellMajor: 1 };
|
|
134
|
+
const steps = [];
|
|
135
|
+
const wantPkgs = ["@shipvo/runtime", ...(withDevice ? ["@shipvo/device"] : [])].filter((n) => !have(n));
|
|
136
|
+
if (wantPkgs.length)
|
|
137
|
+
steps.push({ text: `Install ${wantPkgs.join(" and ")} with ${p.packageManager}`, run: () => deps.install(p.packageManager, wantPkgs, dir) });
|
|
138
|
+
// The scripts and the workflow run `shipvo`; without @shipvo/cli, npx would fetch an unrelated package.
|
|
139
|
+
const wantDev = ["@shipvo/cli", "@shipvo/sim"].filter((n) => !have(n));
|
|
140
|
+
if (wantDev.length)
|
|
141
|
+
steps.push({ text: `Install ${wantDev.join(" and ")} as dev dependencies with ${p.packageManager}`, run: () => deps.install(p.packageManager, wantDev, dir, true) });
|
|
142
|
+
const gi = join(dir, ".gitignore");
|
|
143
|
+
const giText = existsSync(gi) ? readFileSync(gi, "utf8") : "";
|
|
144
|
+
if (!giText.split(/\r?\n/).some((l) => /^\/?\.shipvo\/?$/.test(l.trim())))
|
|
145
|
+
steps.push({ text: "Add .shipvo/ to .gitignore", run: () => writeFileSync(gi, giText + (giText && !giText.endsWith("\n") ? "\n" : "") + "# never commit the release signing seed\n.shipvo/\n") });
|
|
146
|
+
// .shipvo/ is ignored before the seed exists, so a stopped run never leaves it unignored.
|
|
147
|
+
if (newSeed)
|
|
148
|
+
steps.push({ text: "Create a signing key in .shipvo/seed.txt (git-ignored)", run: () => { mkdirSync(join(dir, ".shipvo"), { recursive: true }); writeFileSync(seedPath, newSeed, { mode: 0o600 }); } });
|
|
149
|
+
let printLines = false;
|
|
150
|
+
if (bare) {
|
|
151
|
+
const check = checkExpoModules(dir);
|
|
152
|
+
if (!check.installed)
|
|
153
|
+
steps.push({ text: "Run npx install-expo-modules@latest (adds Expo Modules to the native projects)", run: () => deps.installExpoModules(dir) });
|
|
154
|
+
if (applyAndroidConfig(readFileSync(manifest, "utf8"), pluginProps) !== readFileSync(manifest, "utf8"))
|
|
155
|
+
steps.push({ text: "Write the Shipvo values into android/app/src/main/AndroidManifest.xml", run: () => { writeAndroidConfig(manifest, pluginProps); } });
|
|
156
|
+
if (iosConfigChanged(infoPlist, pluginProps))
|
|
157
|
+
steps.push({ text: `Write the Shipvo values into ${infoPlist.slice(dir.length + 1)}`, run: () => { writeIosConfig(infoPlist, pluginProps); } });
|
|
158
|
+
}
|
|
159
|
+
else if (p.configKind === "app.json") {
|
|
160
|
+
const list = expo.plugins ?? [];
|
|
161
|
+
if (!list.some((x) => (Array.isArray(x) ? x[0] : x) === "@shipvo/runtime"))
|
|
162
|
+
steps.push({ text: "Add the @shipvo/runtime config plugin to app.json (with your public key)", run: () => {
|
|
163
|
+
const raw = readFileSync(join(dir, "app.json"), "utf8");
|
|
164
|
+
const j = JSON.parse(raw), target = j.expo ?? j;
|
|
165
|
+
target.plugins = [...(target.plugins ?? []), ["@shipvo/runtime", pluginProps]];
|
|
166
|
+
writeFileSync(join(dir, "app.json"), JSON.stringify(j, null, /\n\t/.test(raw) ? "\t" : 2) + "\n");
|
|
167
|
+
} });
|
|
168
|
+
}
|
|
169
|
+
else
|
|
170
|
+
printLines = true;
|
|
171
|
+
if (!existingCfg)
|
|
172
|
+
steps.push({ text: `Write shipvo.json (app id "${appId}")`, run: () => writeFileSync(cfgPath, JSON.stringify({ appId, name, shellMajor: 1, controlPlane, publicKey }, null, 2) + "\n") });
|
|
173
|
+
const wantScripts = { sim: "shipvo sim", publish: "shipvo publish", rollback: "shipvo rollback" };
|
|
174
|
+
const missing = Object.entries(wantScripts).filter(([k]) => !pkg.scripts?.[k]);
|
|
175
|
+
if (missing.length)
|
|
176
|
+
steps.push({ text: `Add package.json scripts: ${missing.map(([k]) => k).join(", ")}`, run: () => {
|
|
177
|
+
const path = join(dir, "package.json"), raw = readFileSync(path, "utf8"), j = JSON.parse(raw);
|
|
178
|
+
j.scripts = { ...j.scripts };
|
|
179
|
+
for (const [k, v] of missing)
|
|
180
|
+
if (!j.scripts[k])
|
|
181
|
+
j.scripts[k] = v;
|
|
182
|
+
writeFileSync(path, JSON.stringify(j, null, /\n\t/.test(raw) ? "\t" : 2) + "\n");
|
|
183
|
+
} });
|
|
184
|
+
const wf = join(dir, ".github", "workflows", "publish.yml");
|
|
185
|
+
if (!existsSync(wf))
|
|
186
|
+
steps.push({ text: "Add .github/workflows/publish.yml", run: () => { mkdirSync(join(dir, ".github", "workflows"), { recursive: true }); writeFileSync(wf, PUBLISH_WORKFLOW(p.packageManager)); } });
|
|
187
|
+
console.log(`Shipvo found ${bare ? "a bare React Native app" : `an Expo app${p.expoSdk ? ` (SDK ${p.expoSdk})` : ""}`} in ${dir}\n package manager: ${p.packageManager} config: ${p.configFile} expo-updates: ${p.expoUpdates ? "yes" : "no"} CodePush: ${p.codePush ? "yes" : "no"}\n`);
|
|
188
|
+
if (!steps.length) {
|
|
189
|
+
console.log("Nothing to change: this app is already set up for Shipvo.");
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
console.log("Plan:");
|
|
193
|
+
steps.forEach((s, i) => console.log(` ${i + 1}. ${s.text}`));
|
|
194
|
+
}
|
|
195
|
+
if (printLines && newSeed)
|
|
196
|
+
console.log(`\n(The public key below is new and is only written when you apply the plan. Do not paste it before then.)`);
|
|
197
|
+
if (printLines)
|
|
198
|
+
console.log(`\n${p.configFile} is code, so I will not rewrite it. Add this to its "plugins" list:\n\n ["@shipvo/runtime", ${JSON.stringify(pluginProps)}],\n`);
|
|
199
|
+
console.log(bare ? "\nI only add or update the Shipvo keys in AndroidManifest.xml and Info.plist, and I will not overwrite any other file." : "\nI will not touch ios/ or android/, and I will not overwrite any file.");
|
|
200
|
+
if (dry) {
|
|
201
|
+
console.log("\n--dry-run: nothing was changed.");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (!steps.length)
|
|
205
|
+
return;
|
|
206
|
+
if (!yes && !(await deps.confirm("\nApply this plan? [y/N] "))) {
|
|
207
|
+
console.log("Nothing was changed.");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const applied = [];
|
|
211
|
+
for (const s of steps) {
|
|
212
|
+
try {
|
|
213
|
+
s.run();
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
console.error(`shipvo: a step failed: ${s.text}\n${e.message}${applied.length ? `\nAlready applied:\n${applied.map((a) => ` - ${a}`).join("\n")}` : ""}`);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
applied.push(s.text);
|
|
220
|
+
console.log(`done: ${s.text}`);
|
|
221
|
+
}
|
|
222
|
+
if (newSeed)
|
|
223
|
+
console.log(`\nBack up .shipvo/seed.txt offline and put it in the SHIPVO_SIGNING_SEED secret of your CI. Lose it and you cannot publish to installed shells.`);
|
|
224
|
+
if (deps.doctor) {
|
|
225
|
+
console.log("\nshipvo doctor:");
|
|
226
|
+
for (const r of await runDoctorChecks({ cwd: dir }))
|
|
227
|
+
console.log(r.ok ? `PASS ${r.name}${r.note ? ` (${r.note})` : ""}` : `FAIL ${r.name} — fix: ${r.fix}`);
|
|
228
|
+
}
|
|
229
|
+
if (bare) {
|
|
230
|
+
console.log(`\n${adoptNextSteps()}`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
console.log(`\nNext steps:
|
|
234
|
+
1. Rebuild the native app once so the runtime is in the shell: npx expo prebuild, then npx expo run:android (or run:ios)
|
|
235
|
+
2. npx shipvo publish --dry-run # see what a release would contain`);
|
|
236
|
+
}
|
|
237
|
+
const isPublicKey = (k) => /^[A-Za-z0-9+/]+={0,2}$/.test(k) && Buffer.from(k, "base64").length === 32;
|
|
238
|
+
const PUBLISH_WORKFLOW = (pm) => `name: publish
|
|
239
|
+
on:
|
|
240
|
+
workflow_dispatch: # manual only; add a push trigger with a paths filter when you are ready to ship on merge
|
|
241
|
+
jobs:
|
|
242
|
+
publish:
|
|
243
|
+
runs-on: ubuntu-latest
|
|
244
|
+
concurrency: shipvo-publish
|
|
245
|
+
if: \${{ github.event_name != 'push' || !contains(github.event.head_commit.message, '[no-ota]') }}
|
|
246
|
+
steps:
|
|
247
|
+
- uses: actions/checkout@v4
|
|
248
|
+
- uses: actions/setup-node@v4
|
|
249
|
+
with: { node-version: 22 }
|
|
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
|
+
- name: Build, sign and publish over the air
|
|
253
|
+
env:
|
|
254
|
+
SHIPVO_PUBLISH_TOKEN: \${{ secrets.SHIPVO_PUBLISH_TOKEN }}
|
|
255
|
+
SHIPVO_SIGNING_SEED: \${{ secrets.SHIPVO_SIGNING_SEED }}
|
|
256
|
+
run: npx shipvo publish | tee -a "$GITHUB_STEP_SUMMARY"
|
|
257
|
+
`;
|
|
@@ -0,0 +1,5 @@
|
|
|
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";
|
|
3
|
+
export declare function autoHalt(argv: string[], deps?: {
|
|
4
|
+
fetchFn?: typeof fetch;
|
|
5
|
+
}): Promise<void>;
|