@shipvo/cli 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/adopt-bare.d.ts +39 -0
  2. package/dist/adopt-bare.js +236 -0
  3. package/dist/adopt.d.ts +26 -0
  4. package/dist/adopt.js +257 -0
  5. package/dist/agents.d.ts +14 -0
  6. package/dist/agents.js +124 -0
  7. package/dist/audit.d.ts +13 -0
  8. package/dist/audit.js +75 -0
  9. package/dist/autohalt.d.ts +5 -0
  10. package/dist/autohalt.js +84 -0
  11. package/dist/ci.d.ts +3 -0
  12. package/dist/ci.js +42 -0
  13. package/dist/cli.js +142 -88
  14. package/dist/config.d.ts +10 -0
  15. package/dist/config.js +38 -10
  16. package/dist/doctor.d.ts +14 -0
  17. package/dist/doctor.js +132 -7
  18. package/dist/eas.d.ts +29 -0
  19. package/dist/eas.js +258 -0
  20. package/dist/ensure-playwright.d.ts +21 -0
  21. package/dist/ensure-playwright.js +64 -0
  22. package/dist/explore.d.ts +84 -0
  23. package/dist/explore.js +247 -0
  24. package/dist/help.d.ts +4 -2
  25. package/dist/help.js +39 -3
  26. package/dist/init.js +501 -17
  27. package/dist/native.d.ts +3 -0
  28. package/dist/native.js +39 -0
  29. package/dist/policy.d.ts +4 -0
  30. package/dist/policy.js +17 -0
  31. package/dist/preview-recorder.d.ts +1 -0
  32. package/dist/preview-recorder.js +62 -0
  33. package/dist/preview.d.ts +35 -0
  34. package/dist/preview.js +346 -0
  35. package/dist/promote.d.ts +9 -0
  36. package/dist/promote.js +153 -0
  37. package/dist/publish.d.ts +1 -1
  38. package/dist/publish.js +73 -29
  39. package/dist/report-md.d.ts +26 -0
  40. package/dist/report-md.js +58 -0
  41. package/dist/run-bridge.d.ts +7 -0
  42. package/dist/run-bridge.js +374 -0
  43. package/dist/run-protocol.d.ts +32 -0
  44. package/dist/run-protocol.js +22 -0
  45. package/dist/run-ws.d.ts +8 -0
  46. package/dist/run-ws.js +89 -0
  47. package/dist/run.d.ts +86 -0
  48. package/dist/run.js +301 -0
  49. package/dist/sim.d.ts +31 -0
  50. package/dist/sim.js +124 -5
  51. package/dist/status.d.ts +64 -0
  52. package/dist/status.js +147 -0
  53. package/dist/store.d.ts +38 -0
  54. package/dist/store.js +297 -0
  55. package/dist/templates/capacitor/github-actions/build.yml +122 -0
  56. package/dist/test.d.ts +46 -0
  57. package/dist/test.js +190 -0
  58. package/package.json +10 -5
package/dist/config.js CHANGED
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  export function loadConfig(dir = process.cwd()) {
4
4
  const p = join(dir, "shipvo.json");
5
5
  if (!existsSync(p))
6
- throw new Error("No shipvo.json here. Run this inside an app created with `shipvo init`, or create one (see docs/getting-started.md).");
6
+ throw new Error("No shipvo.json in this folder. Run this inside your app's folder. To start a new app, run `shipvo init myshop`.");
7
7
  const c = JSON.parse(readFileSync(p, "utf8"));
8
8
  for (const k of ["appId", "controlPlane", "publicKey"])
9
9
  if (!c[k])
@@ -26,8 +26,8 @@ export function seed() {
26
26
  if (!s && local)
27
27
  return local;
28
28
  if (!s) {
29
- console.error("Set SHIPVO_SIGNING_SEED or SHIPVO_SIGNING_SEED_FILE (the seed `shipvo keygen` printed), or keep it at .shipvo/seed.txt.");
30
- process.exit(2);
29
+ console.error("Set SHIPVO_SIGNING_SEED or SHIPVO_SIGNING_SEED_FILE (the seed `shipvo keygen` printed), or keep it at .shipvo/seed.txt. No key yet? Run `shipvo keygen`.");
30
+ process.exit(1);
31
31
  }
32
32
  return s;
33
33
  }
@@ -42,18 +42,46 @@ export function publishToken() {
42
42
  return null;
43
43
  }
44
44
  }
45
+ const RETRIES = 2;
45
46
  /** Publishing through the hosted control plane: the same get/put/keys, scoped to this app and signed-pointer checked server side. */
46
47
  function hostedKv(cfg, token) {
47
48
  const base = `${cfg.controlPlane.replace(/\/$/, "")}/apps/${cfg.appId}/kv`;
48
- const auth = { Authorization: `Bearer ${token}` };
49
+ const auth = { Authorization: `Bearer ${token}`, ...(process.env.SHIPVO_ACTOR === "agent" ? { "x-shipvo-actor": "agent" } : {}) };
49
50
  const path = (k) => `${base}/${k.split("/").map(encodeURIComponent).join("/")}`;
51
+ const host = cfg.controlPlane.replace(/\/$/, "");
52
+ /** One place for the two first-run failures: the server is not there, or it does not accept the token. */
53
+ const call = async (url, init, what) => {
54
+ let r;
55
+ for (let attempt = 0;; attempt++) {
56
+ try {
57
+ r = await fetch(url, init);
58
+ break;
59
+ }
60
+ catch (err) {
61
+ // Network errors only, never HTTP statuses. Every call here is a GET or a PUT of the same bytes to the same key, so repeating it is safe.
62
+ if (attempt < RETRIES) {
63
+ await new Promise((ok) => setTimeout(ok, 150 * 2 ** attempt));
64
+ continue;
65
+ }
66
+ const cause = err?.cause?.code ?? err?.code ?? err?.cause?.message ?? err?.message ?? "unknown error";
67
+ throw new Error(`Cannot reach the control plane at ${host} (${cause}) while ${init.method ?? "GET"} ${what}. Check "controlPlane" in shipvo.json, then run \`shipvo doctor\`.`);
68
+ }
69
+ }
70
+ if (r.status === 403) { // a typed policy refusal is not a bad token
71
+ const j = await r.clone().json().catch(() => null);
72
+ if (j?.code === "policy_refused")
73
+ throw new Error(`Refused by policy rule ${j.rule}: ${j.reason}`);
74
+ }
75
+ if (r.status === 401 || r.status === 403)
76
+ throw new Error(`The control plane at ${host} rejected your publish token (${r.status}). Run \`shipvo login\`, then \`shipvo app create\` for a new token.`);
77
+ return r;
78
+ };
50
79
  return {
51
- async get(k) { const r = await fetch(path(k), { headers: auth }); if (r.status === 401)
52
- throw new Error("publish token rejected: run `shipvo app create` for a new one"); return r.ok ? r.text() : null; },
53
- async getBytes(k) { const r = await fetch(path(k), { headers: auth }); return r.ok ? new Uint8Array(await r.arrayBuffer()) : null; },
54
- async keys(prefix) { const r = await fetch(`${base}?prefix=${encodeURIComponent(prefix)}`, { headers: auth }); return r.ok ? (await r.json()).keys : []; },
80
+ async get(k) { const r = await call(path(k), { headers: auth }, k); return r.ok ? r.text() : null; },
81
+ async getBytes(k) { const r = await call(path(k), { headers: auth }, k); return r.ok ? new Uint8Array(await r.arrayBuffer()) : null; },
82
+ async keys(prefix) { const r = await call(`${base}?prefix=${encodeURIComponent(prefix)}`, { headers: auth }, `keys ${prefix}`); return r.ok ? (await r.json()).keys : []; },
55
83
  async put(k, body, type) {
56
- const r = await fetch(path(k), { method: "PUT", headers: { ...auth, "content-type": type }, body });
84
+ const r = await call(path(k), { method: "PUT", headers: { ...auth, "content-type": type }, body }, k);
57
85
  if (!r.ok) {
58
86
  let m = "";
59
87
  try {
@@ -75,7 +103,7 @@ export function kv(cfg) {
75
103
  if (pt)
76
104
  return hostedKv(cfg, pt);
77
105
  console.error("Nothing to publish with. Hosted: run `shipvo login`, then `shipvo app create` (or set SHIPVO_PUBLISH_TOKEN). Self-hosted: set CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN and SHIPVO_KV_ID.");
78
- process.exit(2);
106
+ process.exit(1);
79
107
  }
80
108
  const url = (k) => `https://api.cloudflare.com/client/v4/accounts/${ACC}/storage/kv/namespaces/${NS}/values/${encodeURIComponent(k)}`;
81
109
  const auth = { Authorization: `Bearer ${TOKEN}` };
package/dist/doctor.d.ts CHANGED
@@ -3,6 +3,7 @@ type CheckResult = {
3
3
  name: string;
4
4
  ok: boolean;
5
5
  fix: string;
6
+ note?: string;
6
7
  };
7
8
  type DoctorDeps = {
8
9
  cwd: string;
@@ -11,6 +12,19 @@ type DoctorDeps = {
11
12
  fetchFn: typeof fetch;
12
13
  fileExists: typeof existsSync;
13
14
  readText: (path: string) => string;
15
+ platform: NodeJS.Platform;
16
+ fileMode: (path: string) => number | null;
17
+ /** Port the simulator will use (default 4545). */
18
+ port: number;
19
+ /** Path of the Playwright Chromium binary, or null if Playwright is not installed. */
20
+ chromiumPath: (cwd: string) => string | null;
21
+ /** Version of the playwright that chromiumPath resolved, or null if unknown. */
22
+ playwrightVersion: (cwd: string) => string | null;
23
+ portFree: (port: number) => Promise<boolean>;
24
+ /** Skip the checks that need the network (control plane reachable and /health). */
25
+ offline: boolean;
26
+ /** Skip the simulator checks (Chromium, port) even when package.json has a `sim` script. */
27
+ skipSim: boolean;
14
28
  };
15
29
  export declare function runDoctorChecks(overrides?: Partial<DoctorDeps>): Promise<CheckResult[]>;
16
30
  export declare function doctor(argv: string[]): Promise<void>;
package/dist/doctor.js CHANGED
@@ -1,5 +1,9 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import { execSync } from "node:child_process";
5
+ import { portFree } from "./sim.js";
6
+ import { args as parseArgs } from "./config.js";
3
7
  import { b64, keyFromSeed } from "./index.js";
4
8
  import { parseDeclared } from "./hermes.js";
5
9
  const defaultDeps = () => ({
@@ -9,9 +13,70 @@ const defaultDeps = () => ({
9
13
  fetchFn: fetch,
10
14
  fileExists: existsSync,
11
15
  readText: (path) => readFileSync(path, "utf8"),
16
+ platform: process.platform,
17
+ fileMode: (path) => { try {
18
+ return statSync(path).mode & 0o777;
19
+ }
20
+ catch {
21
+ return null;
22
+ } },
23
+ port: 4545,
24
+ chromiumPath: playwrightChromiumPath,
25
+ playwrightVersion: (cwd) => { try {
26
+ return createRequire(join(cwd, "package.json"))("playwright/package.json").version;
27
+ }
28
+ catch {
29
+ return null;
30
+ } },
31
+ portFree,
32
+ offline: false,
33
+ skipSim: false,
12
34
  });
35
+ /** Finds Playwright the way packages/sim/server.mjs does (the app's install, then this package's and @shipvo/sim's, then the global npm root) and asks it where Chromium should be. */
36
+ function playwrightChromiumPath(cwd) {
37
+ const tries = [
38
+ () => createRequire(join(cwd, "package.json"))("playwright"),
39
+ () => createRequire(import.meta.url)("playwright"),
40
+ () => createRequire(createRequire(import.meta.url).resolve("@shipvo/sim/package.json"))("playwright"),
41
+ () => createRequire(import.meta.url)(join(execSync("npm root -g", { stdio: ["ignore", "pipe", "ignore"] }).toString().trim(), "playwright")),
42
+ ];
43
+ for (const get of tries) {
44
+ try {
45
+ return get().chromium.executablePath();
46
+ }
47
+ catch { }
48
+ }
49
+ return null;
50
+ }
51
+ /** Where system Chrome or Edge lives. Same places as the simulator's browser resolver (packages/sim/browser-resolver.mjs). */
52
+ function systemBrowserPaths(platform, env) {
53
+ if (platform === "win32") {
54
+ const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean);
55
+ return roots.flatMap((r) => [["Chrome", r + "\\Google\\Chrome\\Application\\chrome.exe"], ["Edge", r + "\\Microsoft\\Edge\\Application\\msedge.exe"]]);
56
+ }
57
+ if (platform === "darwin")
58
+ return [["Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"], ["Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"]];
59
+ return [
60
+ ["Chrome", "/usr/bin/google-chrome"], ["Chrome", "/usr/bin/google-chrome-stable"], ["Chrome", "/opt/google/chrome/chrome"],
61
+ ["Edge", "/usr/bin/microsoft-edge"], ["Edge", "/usr/bin/microsoft-edge-stable"], ["Edge", "/opt/microsoft/msedge/msedge"],
62
+ ];
63
+ }
64
+ function hasSimScript(deps) {
65
+ try {
66
+ const pkg = JSON.parse(deps.readText(join(deps.cwd, "package.json")));
67
+ return typeof pkg?.scripts?.sim === "string";
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
13
73
  const parseMajor = (v) => Number(v.split(".")[0] || 0);
14
74
  const pub = (s) => b64(keyFromSeed(s).publicKey);
75
+ function seedFilePath(env, cwd) {
76
+ if (env.SHIPVO_SIGNING_SEED || env.PURENATIVE_SIGNING_SEED)
77
+ return null;
78
+ return env.SHIPVO_SIGNING_SEED_FILE || env.PURENATIVE_SIGNING_SEED_FILE || join(cwd, ".shipvo", "seed.txt");
79
+ }
15
80
  function resolveSeed(env, cwd, readText) {
16
81
  const direct = env.SHIPVO_SIGNING_SEED || env.PURENATIVE_SIGNING_SEED;
17
82
  if (direct)
@@ -71,9 +136,10 @@ export async function runDoctorChecks(overrides = {}) {
71
136
  })();
72
137
  const needCfg = "fix shipvo.json first";
73
138
  results.push({ name: "signing key matches shipvo.json publicKey", ok: signingOk, fix: !cfg ? needCfg : "restore the right seed with `shipvo keys import` (or set SHIPVO_SIGNING_SEED)" });
139
+ const skipped = "skipped, --offline";
74
140
  let controlPlaneOk = false;
75
141
  let cpFix = "check shipvo.json controlPlane and your network access (5 second timeout)";
76
- if (cfg?.controlPlane && typeof cfg.controlPlane === "string") {
142
+ if (!deps.offline && cfg?.controlPlane && typeof cfg.controlPlane === "string") {
77
143
  try {
78
144
  const res = await deps.fetchFn(cfg.controlPlane.replace(/\/$/, ""), { signal: AbortSignal.timeout(5000) });
79
145
  if (res.status >= 500)
@@ -83,7 +149,50 @@ export async function runDoctorChecks(overrides = {}) {
83
149
  }
84
150
  catch { }
85
151
  }
86
- results.push({ name: "control plane reachable", ok: controlPlaneOk, fix: !cfg ? needCfg : cpFix });
152
+ if (deps.offline)
153
+ results.push({ name: "control plane reachable", ok: true, fix: "none", note: skipped });
154
+ else
155
+ results.push({ name: "control plane reachable", ok: controlPlaneOk, fix: !cfg ? needCfg : cpFix });
156
+ const keyName = "signing key file is private";
157
+ const keyPath = seedFilePath(deps.env, deps.cwd);
158
+ if (deps.platform === "win32") {
159
+ results.push({ name: keyName, ok: true, fix: "none", note: "skipped on Windows, file modes do not apply" });
160
+ }
161
+ else if (!keyPath) {
162
+ results.push({ name: keyName, ok: true, fix: "none", note: "skipped, the seed comes from an environment variable" });
163
+ }
164
+ else {
165
+ const mode = deps.fileMode(keyPath);
166
+ if (mode === null)
167
+ results.push({ name: keyName, ok: true, fix: "none", note: "skipped, no key file found" });
168
+ else
169
+ results.push({ name: keyName, ok: (mode & 0o077) === 0, fix: `run \`chmod 600 ${keyPath}\` (it is mode ${mode.toString(8).padStart(3, "0")})` });
170
+ }
171
+ let healthOk = false;
172
+ let healthFix = "check shipvo.json controlPlane and your network access (5 second timeout)";
173
+ if (!deps.offline && cfg?.controlPlane && typeof cfg.controlPlane === "string") {
174
+ try {
175
+ const res = await deps.fetchFn(cfg.controlPlane.replace(/\/$/, "") + "/health", { signal: AbortSignal.timeout(5000) });
176
+ if (res.status !== 200)
177
+ healthFix = `/health answered HTTP ${res.status}, expected 200; check the control plane URL and deploy`;
178
+ else {
179
+ try {
180
+ await res.json();
181
+ healthOk = true;
182
+ }
183
+ catch {
184
+ healthFix = "/health answered 200 but not JSON; controlPlane may point at the wrong service";
185
+ }
186
+ }
187
+ }
188
+ catch (e) {
189
+ healthFix = e instanceof Error && e.name === "TimeoutError" ? "/health did not answer within 5 seconds; check the URL and your network" : healthFix;
190
+ }
191
+ }
192
+ if (deps.offline)
193
+ results.push({ name: "control plane /health returns JSON", ok: true, fix: "none", note: skipped });
194
+ else
195
+ results.push({ name: "control plane /health returns JSON", ok: healthOk, fix: !cfg ? needCfg : healthFix });
87
196
  let hermesOk = true;
88
197
  if (cfg && Object.prototype.hasOwnProperty.call(cfg, "hermesBytecodeVersion")) {
89
198
  try {
@@ -94,16 +203,32 @@ export async function runDoctorChecks(overrides = {}) {
94
203
  }
95
204
  }
96
205
  results.push({ name: "hermesBytecodeVersion (if present) is set", ok: hermesOk, fix: "set hermesBytecodeVersion to a positive integer or list (for example 96 or [96, 98])" });
206
+ // Simulator checks only apply to projects that have a `sim` script.
207
+ if (!deps.skipSim && hasSimScript(deps)) {
208
+ const system = systemBrowserPaths(deps.platform, deps.env).find(([, p]) => deps.fileExists(p));
209
+ const exe = deps.chromiumPath(deps.cwd);
210
+ const cached = !!exe && deps.fileExists(exe);
211
+ const noAuto = deps.env.SHIPVO_NO_AUTO_INSTALL === "1";
212
+ const ok = !!system || cached || !noAuto;
213
+ const note = system ? `system ${system[0]}` : cached ? "Playwright Chromium" : "none found yet; the first run downloads Chromium once";
214
+ results.push({ name: "Browser for the simulator", ok, fix: `SHIPVO_NO_AUTO_INSTALL=1 is set and no Chrome, Edge or Playwright Chromium was found; install Chrome or Edge, or run \`npx playwright install chromium\` in the app folder${deps.playwrightVersion(deps.cwd) ? ` (playwright ${deps.playwrightVersion(deps.cwd)})` : ""}. On Linux, if it then fails to start, run \`npx playwright install-deps chromium\``, note });
215
+ results.push({ name: `sim port ${deps.port} is free`, ok: await deps.portFree(deps.port), fix: "Stop the other process or pass --port <n>" });
216
+ }
97
217
  return results;
98
218
  }
99
219
  export async function doctor(argv) {
100
- if (argv.length) {
101
- console.error("Usage: shipvo doctor");
220
+ const a = parseArgs(argv);
221
+ const port = a.port === undefined ? 4545 : Number(a.port);
222
+ const offline = argv.includes("--offline");
223
+ const skipSim = argv.includes("--skip-sim");
224
+ const stray = argv.some((x, i) => !x.startsWith("--") && argv[i - 1] !== "--port");
225
+ if (stray || !Number.isInteger(port) || port < 1 || port > 65535) {
226
+ console.error("Usage: shipvo doctor [--port <n>] [--offline] [--skip-sim]");
102
227
  process.exit(2);
103
228
  }
104
- const results = await runDoctorChecks();
229
+ const results = await runDoctorChecks({ port, offline, skipSim });
105
230
  for (const r of results)
106
- console.log(`${r.ok ? `PASS ${r.name}` : `FAIL ${r.name} — fix: ${r.fix}`}`);
231
+ console.log(`${r.ok ? `PASS ${r.name}${r.note ? ` (${r.note})` : ""}` : `FAIL ${r.name} — fix: ${r.fix}`}`);
107
232
  if (results.some((r) => !r.ok))
108
233
  process.exit(1);
109
234
  }
package/dist/eas.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ export declare const ANDROID_IMAGE = "latest";
2
+ export declare const IOS_IMAGE = "macos-tahoe-26.5-xcode-26.6";
3
+ export declare const NODE_VERSION = "22";
4
+ export declare const JDK_VERSION = "21";
5
+ export interface CapacitorProject {
6
+ configFile: string;
7
+ android: boolean;
8
+ ios: boolean;
9
+ }
10
+ /** Look for a Capacitor project at `root`. Returns undefined when it is not one. */
11
+ export declare function detectCapacitor(root: string): CapacitorProject | undefined;
12
+ export interface PlannedFile {
13
+ path: string;
14
+ content: string;
15
+ action: "create" | "update";
16
+ }
17
+ /** Work out which files need writing. Existing build YAMLs and schemes are never overwritten. */
18
+ export declare function planEasSetup(root: string, p: CapacitorProject): PlannedFile[];
19
+ /** Create the `ios/App.xcodeproj -> App/App.xcodeproj` link the EAS CLI scheme check needs. Returns true if it made one. */
20
+ export declare function ensureSchemeShim(root: string): boolean;
21
+ export declare const ONE_TIME_STEPS: readonly [readonly ["eas-cli", "init"], readonly ["eas-cli", "build:version:set", "-p", "ios"]];
22
+ export declare const IOS_UNPROVEN = "Note: the iOS signing steps in .eas/build/ios.yml are unproven. The step names were not checked against Expo's custom build functions (https://docs.expo.dev/custom-builds/schema/), so your first iOS build is the check.";
23
+ export type Runner = (cmd: string, args: string[], cwd: string) => number;
24
+ export interface EasOptions {
25
+ cwd?: string;
26
+ log?: (s: string) => void;
27
+ runner?: Runner;
28
+ }
29
+ export declare function easCmd(args: string[], o?: EasOptions): Promise<number>;
package/dist/eas.js ADDED
@@ -0,0 +1,258 @@
1
+ // `shipvo eas setup` and `shipvo eas build`: make a Capacitor repo ready for `eas build` and `eas submit`
2
+ // with the customer's own Expo account. Shipvo writes files; it never holds Expo credentials.
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ export const ANDROID_IMAGE = "latest";
7
+ export const IOS_IMAGE = "macos-tahoe-26.5-xcode-26.6";
8
+ export const NODE_VERSION = "22";
9
+ export const JDK_VERSION = "21";
10
+ const SCHEME_PATH = "ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme";
11
+ const SHIM = "ios/App.xcodeproj";
12
+ const SHIM_TARGET = "App/App.xcodeproj";
13
+ /** Look for a Capacitor project at `root`. Returns undefined when it is not one. */
14
+ export function detectCapacitor(root) {
15
+ const configFile = readdirSync(root).find((f) => /^capacitor\.config\.(ts|js|json|mjs|cjs)$/.test(f));
16
+ const android = existsSync(join(root, "android"));
17
+ const ios = existsSync(join(root, "ios/App/App.xcodeproj"));
18
+ if (!configFile || (!android && !ios))
19
+ return undefined;
20
+ return { configFile, android, ios };
21
+ }
22
+ const androidYaml = `# Written by \`shipvo eas setup\`. Safe to edit; setup will not overwrite this file.
23
+ # Docs: https://docs.expo.dev/custom-builds/get-started/
24
+ build:
25
+ name: Capacitor Android release
26
+ steps:
27
+ - eas/checkout
28
+ - eas/install_node_modules
29
+ - run:
30
+ name: Install JDK ${JDK_VERSION}
31
+ command: |
32
+ source "$HOME/.sdkman/bin/sdkman-init.sh"
33
+ sdk install java ${JDK_VERSION}-tem < /dev/null
34
+ set-env JAVA_HOME "$(sdk home java ${JDK_VERSION}-tem)"
35
+ - run:
36
+ name: Build the web app
37
+ command: npm run build
38
+ - run:
39
+ name: Sync Capacitor
40
+ command: npx cap sync android
41
+ - eas/inject_android_credentials
42
+ - eas/run_gradle:
43
+ inputs:
44
+ command: :app:bundleRelease
45
+ - eas/find_and_upload_build_artifacts
46
+ `;
47
+ const iosYaml = `# Written by \`shipvo eas setup\`. Safe to edit; setup will not overwrite this file.
48
+ # Docs: https://docs.expo.dev/custom-builds/get-started/
49
+ # Before the first build, run \`shipvo eas build\`: it creates the ios/App.xcodeproj link that the EAS CLI scheme check needs on your machine before upload.
50
+ build:
51
+ name: Capacitor iOS release
52
+ steps:
53
+ - eas/checkout
54
+ - eas/install_node_modules
55
+ - run:
56
+ name: Build the web app
57
+ command: npm run build
58
+ - run:
59
+ name: Sync Capacitor
60
+ command: npx cap sync ios
61
+ - run:
62
+ name: Install CocoaPods dependencies
63
+ command: cd ios/App && pod install
64
+ - eas/resolve_apple_team_id_from_credentials:
65
+ id: resolve_apple_team_id
66
+ - eas/prebuild_credentials_ios:
67
+ inputs:
68
+ project_dir: ios
69
+ - eas/configure_ios_credentials
70
+ - run:
71
+ name: Write ExportOptions.plist
72
+ # Nothing else creates this file. The team id comes from the EAS credentials step above;
73
+ # the profile name is read from the Xcode project that eas/configure_ios_credentials updated.
74
+ command: |
75
+ BUNDLE_ID="$(xcodebuild -project ios/App/App.xcodeproj -scheme App -configuration Release -showBuildSettings | awk -F' = ' '/ PRODUCT_BUNDLE_IDENTIFIER /{print $2; exit}')"
76
+ PROFILE="$(xcodebuild -project ios/App/App.xcodeproj -scheme App -configuration Release -showBuildSettings | awk -F' = ' '/ PROVISIONING_PROFILE_SPECIFIER /{print $2; exit}')"
77
+ cat > ios/App/ExportOptions.plist <<PLIST
78
+ <?xml version="1.0" encoding="UTF-8"?>
79
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
80
+ <plist version="1.0">
81
+ <dict>
82
+ <key>method</key><string>app-store</string>
83
+ <key>teamID</key><string>\${{ steps.resolve_apple_team_id.outputs.apple_team_id }}</string>
84
+ <key>signingStyle</key><string>manual</string>
85
+ <key>provisioningProfiles</key>
86
+ <dict><key>$BUNDLE_ID</key><string>$PROFILE</string></dict>
87
+ </dict>
88
+ </plist>
89
+ PLIST
90
+ - run:
91
+ name: Archive with distribution signing
92
+ command: |
93
+ xcodebuild archive \\
94
+ -workspace ios/App/App.xcworkspace \\
95
+ -scheme App \\
96
+ -configuration Release \\
97
+ -archivePath build/App.xcarchive \\
98
+ DEVELOPMENT_TEAM="\${{ steps.resolve_apple_team_id.outputs.apple_team_id }}" \\
99
+ CODE_SIGN_STYLE=Manual
100
+ xcodebuild -exportArchive \\
101
+ -archivePath build/App.xcarchive \\
102
+ -exportPath build/export \\
103
+ -exportOptionsPlist ios/App/ExportOptions.plist
104
+ - eas/find_and_upload_build_artifacts
105
+ `;
106
+ const scheme = `<?xml version="1.0" encoding="UTF-8"?>
107
+ <Scheme LastUpgradeVersion="1600" version="1.7">
108
+ <BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
109
+ <BuildActionEntries>
110
+ <BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
111
+ <BuildableReference BuildableIdentifier="primary" BlueprintName="App" BuildableName="App.app" BlueprintIdentifier="504EC3011FED79650016851F" ReferencedContainer="container:App.xcodeproj"/>
112
+ </BuildActionEntry>
113
+ </BuildActionEntries>
114
+ </BuildAction>
115
+ <LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES"/>
116
+ <ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
117
+ </Scheme>
118
+ `;
119
+ function readJson(path) {
120
+ try {
121
+ return JSON.parse(readFileSync(path, "utf8"));
122
+ }
123
+ catch (e) {
124
+ if (e.code === "ENOENT")
125
+ return {};
126
+ throw new Error(`${path} is not valid JSON. Fix it and run the command again.`);
127
+ }
128
+ }
129
+ /** Merge our profiles into an existing eas.json without touching the customer's own settings. */
130
+ function easJson(root, p) {
131
+ const j = readJson(join(root, "eas.json"));
132
+ for (const k of ["cli", "build", "submit"]) {
133
+ if (j[k] !== undefined && (j[k] === null || typeof j[k] !== "object" || Array.isArray(j[k]))) {
134
+ throw new Error(`${join(root, "eas.json")} is not valid: "${k}" must be an object. Fix it and run the command again.`);
135
+ }
136
+ }
137
+ j.cli = { appVersionSource: "remote", ...(j.cli ?? {}) };
138
+ j.build ??= {};
139
+ if (p.android)
140
+ j.build["shipvo-android"] ??= { android: { image: ANDROID_IMAGE, buildType: "app-bundle", config: "android.yml" }, distribution: "store" };
141
+ if (p.ios)
142
+ j.build["shipvo-ios"] ??= { ios: { image: IOS_IMAGE, node: NODE_VERSION, config: "ios.yml" }, distribution: "store", autoIncrement: true };
143
+ j.submit ??= {};
144
+ if (p.android)
145
+ j.submit["shipvo-android"] ??= { android: {} };
146
+ if (p.ios)
147
+ j.submit["shipvo-ios"] ??= { ios: {} };
148
+ return JSON.stringify(j, null, 2) + "\n";
149
+ }
150
+ function gitignore(root) {
151
+ const path = join(root, ".gitignore");
152
+ const cur = existsSync(path) ? readFileSync(path, "utf8") : "";
153
+ if (cur.split(/\r?\n/).some((l) => l.trim() === SHIM))
154
+ return undefined;
155
+ const sep = cur === "" || cur.endsWith("\n") ? "" : "\n";
156
+ return `${cur}${sep}# Created by \`shipvo eas build\` for EAS; never commit\n${SHIM}\n`;
157
+ }
158
+ /** Work out which files need writing. Existing build YAMLs and schemes are never overwritten. */
159
+ export function planEasSetup(root, p) {
160
+ const out = [];
161
+ const add = (path, content, overwrite) => {
162
+ const exists = existsSync(join(root, path));
163
+ if (exists && !overwrite)
164
+ return;
165
+ if (exists && readFileSync(join(root, path), "utf8") === content)
166
+ return;
167
+ out.push({ path, content, action: exists ? "update" : "create" });
168
+ };
169
+ add("eas.json", easJson(root, p), true);
170
+ if (p.android)
171
+ add(".eas/build/android.yml", androidYaml, false);
172
+ if (p.ios) {
173
+ add(".eas/build/ios.yml", iosYaml, false);
174
+ add(SCHEME_PATH, scheme, false);
175
+ }
176
+ const gi = p.ios ? gitignore(root) : undefined;
177
+ if (gi !== undefined)
178
+ out.push({ path: ".gitignore", content: gi, action: existsSync(join(root, ".gitignore")) ? "update" : "create" });
179
+ return out;
180
+ }
181
+ /** Create the `ios/App.xcodeproj -> App/App.xcodeproj` link the EAS CLI scheme check needs. Returns true if it made one. */
182
+ export function ensureSchemeShim(root) {
183
+ const link = join(root, SHIM);
184
+ try {
185
+ lstatSync(link);
186
+ return false;
187
+ }
188
+ catch { /* missing: create it */ }
189
+ try {
190
+ symlinkSync(SHIM_TARGET, link, "dir");
191
+ }
192
+ catch (e) {
193
+ throw new Error(`Could not create the link ${SHIM} -> ${SHIM_TARGET} (${e.message}). Create it yourself (on Windows, run as administrator or turn on Developer Mode), then run the command again.`);
194
+ }
195
+ return true;
196
+ }
197
+ export const ONE_TIME_STEPS = [
198
+ ["eas-cli", "init"],
199
+ ["eas-cli", "build:version:set", "-p", "ios"],
200
+ ];
201
+ export const IOS_UNPROVEN = "Note: the iOS signing steps in .eas/build/ios.yml are unproven. The step names were not checked against Expo's custom build functions (https://docs.expo.dev/custom-builds/schema/), so your first iOS build is the check.";
202
+ const defaultRunner = (cmd, args, cwd) => spawnSync(cmd, args, { cwd, stdio: "inherit" }).status ?? 1;
203
+ export async function easCmd(args, o = {}) {
204
+ const cwd = o.cwd ?? process.cwd();
205
+ const log = o.log ?? ((s) => console.log(s));
206
+ const runner = o.runner ?? defaultRunner;
207
+ const [sub, ...rest] = args;
208
+ if (sub !== "setup" && sub !== "build") {
209
+ throw new Error("Usage: shipvo eas setup [--dry-run] [--run] | shipvo eas build [eas build args]");
210
+ }
211
+ const project = detectCapacitor(cwd);
212
+ if (!project)
213
+ throw new Error("This is not a Capacitor project. Run this in a folder with capacitor.config.* and an android/ or ios/App/App.xcodeproj folder.");
214
+ if (sub === "build") {
215
+ if (project.ios && ensureSchemeShim(cwd))
216
+ log(`Linked ${SHIM} -> ${SHIM_TARGET} (git-ignored).`);
217
+ return runner("npx", ["eas-cli", "build", ...rest], cwd);
218
+ }
219
+ const known = ["--dry-run", "--run"];
220
+ const bad = rest.filter((a) => !known.includes(a));
221
+ if (bad.length)
222
+ throw new Error(`Unknown option for shipvo eas setup: ${bad.join(" ")}. Use --dry-run or --run.`);
223
+ const dry = rest.includes("--dry-run");
224
+ const runSteps = rest.includes("--run") && !dry;
225
+ const plan = planEasSetup(cwd, project);
226
+ for (const f of plan) {
227
+ log(`${dry ? "Would " + f.action : f.action === "create" ? "Created" : "Updated"} ${f.path}`);
228
+ if (dry)
229
+ log(f.content.replace(/^/gm, " "));
230
+ else {
231
+ mkdirSync(dirname(join(cwd, f.path)), { recursive: true });
232
+ writeFileSync(join(cwd, f.path), f.content);
233
+ }
234
+ }
235
+ if (!plan.length)
236
+ log("Nothing to change: this repo is already set up for EAS.");
237
+ if (project.ios)
238
+ log(IOS_UNPROVEN);
239
+ if (dry)
240
+ return 0;
241
+ log("\nRemaining one-time steps:");
242
+ for (const s of ONE_TIME_STEPS)
243
+ log(` npx ${s.join(" ")}`);
244
+ if (project.ios)
245
+ log("Before an iOS build, build with `shipvo eas build -p ios --profile shipvo-ios`: it creates the git-ignored ios/App.xcodeproj link first.");
246
+ if (runSteps) {
247
+ for (const s of ONE_TIME_STEPS) {
248
+ if (s[1] === "build:version:set" && !project.ios)
249
+ continue;
250
+ const code = runner("npx", [...s], cwd);
251
+ if (code !== 0) {
252
+ log(`\`npx ${s.join(" ")}\` failed (exit ${code}). Fix that and run it again.`);
253
+ return code;
254
+ }
255
+ }
256
+ }
257
+ return 0;
258
+ }
@@ -0,0 +1,21 @@
1
+ export type PackageManager = "pnpm" | "yarn" | "bun" | "npm";
2
+ export interface EnsurePlaywrightDeps {
3
+ cwd: string;
4
+ env: NodeJS.ProcessEnv;
5
+ /** Does this path exist? Used to find the lockfile. */
6
+ exists: (path: string) => boolean;
7
+ /** Runs a command in `cwd`. Throws if it fails. */
8
+ run: (cmd: string, args: string[], cwd: string) => void;
9
+ log: (line: string) => void;
10
+ /** True when `playwright` resolves from the app. Defaults to the same lookup order as `doctor`. */
11
+ resolves?: (cwd: string) => boolean;
12
+ }
13
+ export declare function detectPackageManager(cwd: string, exists: (path: string) => boolean): PackageManager;
14
+ export declare function installCommand(pm: PackageManager): {
15
+ cmd: string;
16
+ args: string[];
17
+ };
18
+ /** Installs `playwright` as a dev dependency with the app's package manager when it is missing. */
19
+ export declare function ensurePlaywright(deps: Partial<EnsurePlaywrightDeps> & {
20
+ cwd: string;
21
+ }): void;