liftbay 0.2.0 → 0.3.1

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 CHANGED
@@ -31,6 +31,7 @@ It's published at [liftbay/skills](https://github.com/liftbay/skills), and ships
31
31
  | `builds list`, `builds view`, `builds logs`, `builds download`, `builds cancel` | Look at builds and their artifacts |
32
32
  | `submit`, `submissions list`, `submissions view` | TestFlight and Google Play uploads |
33
33
  | `device add`, `device list`, `device remove` | iOS test devices for ad hoc builds |
34
+ | `env list`, `env import`, `env set`, `env unset`, `env export` | Environment variables; `env import` reads `.env` files and `eas.json` |
34
35
  | `credentials`, `credentials ios` | Show signing; set up iOS signing with your Apple ID or a `.p8` key |
35
36
  | `update publish`, `update list`, `update rollout`, `update rollback` | Over-the-air updates |
36
37
  | `channels` | List update channels |
package/dist/index.js CHANGED
@@ -38,8 +38,8 @@ var require_picocolors = __commonJS({
38
38
  "use strict";
39
39
  var p = process || {};
40
40
  var argv = p.argv || [];
41
- var env = p.env || {};
42
- var isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI);
41
+ var env2 = p.env || {};
42
+ var isColorSupported = !(!!env2.NO_COLOR || argv.includes("--no-color")) && (!!env2.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env2.TERM !== "dumb" || !!env2.CI);
43
43
  var formatter = (open, close, replace = open) => (input) => {
44
44
  let string = "" + input, index = string.indexOf(close, open.length);
45
45
  return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close;
@@ -4575,10 +4575,10 @@ var require_browser = __commonJS({
4575
4575
  text2 = canvas;
4576
4576
  canvas = void 0;
4577
4577
  }
4578
- return new Promise(function(resolve3, reject) {
4578
+ return new Promise(function(resolve4, reject) {
4579
4579
  try {
4580
4580
  const data = QRCode3.create(text2, opts);
4581
- resolve3(renderFunc(data, canvas, opts));
4581
+ resolve4(renderFunc(data, canvas, opts));
4582
4582
  } catch (e) {
4583
4583
  reject(e);
4584
4584
  }
@@ -4660,11 +4660,11 @@ var require_server = __commonJS({
4660
4660
  }
4661
4661
  function render(renderFunc, text2, params) {
4662
4662
  if (!params.cb) {
4663
- return new Promise(function(resolve3, reject) {
4663
+ return new Promise(function(resolve4, reject) {
4664
4664
  try {
4665
4665
  const data = QRCode3.create(text2, params.opts);
4666
4666
  return renderFunc(data, params.opts, function(err, data2) {
4667
- return err ? reject(err) : resolve3(data2);
4667
+ return err ? reject(err) : resolve4(data2);
4668
4668
  });
4669
4669
  } catch (e) {
4670
4670
  reject(e);
@@ -28647,11 +28647,11 @@ async function detectApp(cwd = process.cwd()) {
28647
28647
  appJson: existsSync4(join5(cwd, "app.json"))
28648
28648
  };
28649
28649
  }
28650
- async function run(cmd, args, cwd = process.cwd(), env = {}) {
28651
- const code = await new Promise((resolve3, reject) => {
28652
- const child = spawn2(cmd, args, { cwd, stdio: "inherit", env: { ...process.env, ...env } });
28650
+ async function run(cmd, args, cwd = process.cwd(), env2 = {}) {
28651
+ const code = await new Promise((resolve4, reject) => {
28652
+ const child = spawn2(cmd, args, { cwd, stdio: "inherit", env: { ...process.env, ...env2 } });
28653
28653
  child.on("error", reject);
28654
- child.on("close", resolve3);
28654
+ child.on("close", resolve4);
28655
28655
  });
28656
28656
  if (code !== 0) fail(`\`${cmd} ${args.join(" ")}\` failed (exit ${code})`);
28657
28657
  }
@@ -28672,6 +28672,22 @@ function cancelled(value) {
28672
28672
  }
28673
28673
  return value;
28674
28674
  }
28675
+ var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
28676
+ async function createFirstOrg(lb, given) {
28677
+ const me = await unwrap(lb.GET("/v1/auth/me"));
28678
+ const suggested = given ?? (me.user.name?.trim() || me.user.email.split("@")[0]);
28679
+ const name = isInteractive() && !given ? cancelled(await text({ message: "Name your organization", initialValue: suggested, validate: (v) => slugify(v ?? "").length >= 2 ? void 0 : "At least 2 letters or digits" })) : suggested;
28680
+ const base = slugify(name).padEnd(2, "0");
28681
+ for (const slug of [base, `${base.slice(0, 35)}-${Math.random().toString(36).slice(2, 6)}`]) {
28682
+ const res = await lb.POST("/v1/orgs", { body: { name: name.slice(0, 64), slug } });
28683
+ if (res.data) {
28684
+ log.success(`Created organization ${import_picocolors3.default.bold(res.data.name)} ${import_picocolors3.default.dim(`(liftbay.dev/${res.data.slug})`)}`);
28685
+ return res.data;
28686
+ }
28687
+ if (res.response.status !== 409) await unwrap(Promise.resolve(res));
28688
+ }
28689
+ return fail("Couldn't pick a free organization URL. Pass --org <slug>.");
28690
+ }
28675
28691
  async function ensureLinked(app, opts) {
28676
28692
  const existing = readConfig();
28677
28693
  if (existing) {
@@ -28680,7 +28696,7 @@ async function ensureLinked(app, opts) {
28680
28696
  }
28681
28697
  const lb = client();
28682
28698
  const orgs = await unwrap(lb.GET("/v1/orgs"));
28683
- if (!orgs.length) fail("You're not in any organization yet. Create one at the dashboard first.");
28699
+ if (!orgs.length) orgs.push(await createFirstOrg(lb, opts.org));
28684
28700
  let org = opts.org ?? (orgs.length === 1 ? orgs[0].slug : void 0);
28685
28701
  if (!org) {
28686
28702
  if (!isInteractive()) fail(`Several organizations available; pass --org (${orgs.map((o) => o.slug).join(", ")})`);
@@ -29411,9 +29427,9 @@ async function deviceRemove(id) {
29411
29427
  }
29412
29428
 
29413
29429
  // src/commands/setup.ts
29414
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
29430
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
29415
29431
  import { dirname as dirname3 } from "path";
29416
- var import_picocolors10 = __toESM(require_picocolors(), 1);
29432
+ var import_picocolors11 = __toESM(require_picocolors(), 1);
29417
29433
 
29418
29434
  // src/generated/workflow.ts
29419
29435
  var WORKFLOW_YAML = `# Liftbay builds on your own GitHub Actions minutes.
@@ -29567,6 +29583,219 @@ jobs:
29567
29583
  run: '"$RUNNER_TEMP/liftbay-runner" run'
29568
29584
  `;
29569
29585
 
29586
+ // src/commands/env.ts
29587
+ import { existsSync as existsSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
29588
+ import { basename as basename2, join as join8, resolve as resolve3 } from "path";
29589
+ var import_picocolors10 = __toESM(require_picocolors(), 1);
29590
+ var VISIBILITIES = ["plain", "sensitive", "secret"];
29591
+ var KEY = /^[A-Z_][A-Z0-9_]*$/;
29592
+ function parseEnv(content) {
29593
+ const vars = [];
29594
+ const invalid = [];
29595
+ content.split(/\r?\n/).forEach((raw, i2) => {
29596
+ const line = raw.trim();
29597
+ if (!line || line.startsWith("#")) return;
29598
+ const m2 = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
29599
+ if (!m2) return void invalid.push(i2 + 1);
29600
+ let value = m2[2] ?? "";
29601
+ const q = value[0];
29602
+ if ((q === '"' || q === "'") && value.length >= 2 && value.endsWith(q)) {
29603
+ value = value.slice(1, -1);
29604
+ if (q === '"') value = value.replace(/\\(n|"|\\)/g, (_, c2) => c2 === "n" ? "\n" : c2);
29605
+ } else {
29606
+ const hash = value.indexOf(" #");
29607
+ if (hash >= 0) value = value.slice(0, hash).trimEnd();
29608
+ }
29609
+ vars.push({ key: m2[1], value });
29610
+ });
29611
+ return { vars, invalid };
29612
+ }
29613
+ function serializeEnv(vars) {
29614
+ return vars.map(({ key, value }) => {
29615
+ const quote = /[\s#"'\\]/.test(value) || value === "";
29616
+ return `${key}=${quote ? `"${value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"')}"` : value}`;
29617
+ }).join("\n");
29618
+ }
29619
+ function visibilityFor(key, forced) {
29620
+ if (key.startsWith("EXPO_PUBLIC_")) return forced === "sensitive" ? "sensitive" : "plain";
29621
+ if (forced) return forced;
29622
+ return /(TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|AUTH|API_KEY|ACCESS_KEY|_KEY$)/.test(key) ? "secret" : "sensitive";
29623
+ }
29624
+ function parseVisibility(v) {
29625
+ if (v && !VISIBILITIES.includes(v)) fail(`--visibility must be ${VISIBILITIES.join(", ")}`);
29626
+ return v;
29627
+ }
29628
+ var slugify2 = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "default";
29629
+ function readEasProfiles(cwd = process.cwd()) {
29630
+ const path2 = join8(cwd, "eas.json");
29631
+ if (!existsSync6(path2)) return null;
29632
+ let eas;
29633
+ try {
29634
+ eas = JSON.parse(readFileSync7(path2, "utf8"));
29635
+ } catch {
29636
+ fail("eas.json isn't valid JSON");
29637
+ }
29638
+ const build2 = eas.build ?? {};
29639
+ const resolveProfile = (name, seen = []) => {
29640
+ const own = build2[name] ?? fail(`eas.json: profile "${seen.at(-1)}" extends unknown profile "${name}"`);
29641
+ if (seen.includes(name)) fail(`eas.json: profiles extend each other in a loop (${[...seen, name].join(" \u2192 ")})`);
29642
+ const parent = own.extends ? resolveProfile(own.extends, [...seen, name]) : {};
29643
+ return { ...parent, ...own, env: { ...parent.env, ...own.env } };
29644
+ };
29645
+ const extended = new Set(Object.values(build2).map((b) => b.extends).filter(Boolean));
29646
+ return Object.keys(build2).filter((name) => !(extended.has(name) && !build2[name].channel && !build2[name].distribution && !build2[name].developmentClient)).map((name) => {
29647
+ const r2 = resolveProfile(name);
29648
+ const env2 = Object.fromEntries(Object.entries(r2.env ?? {}).filter(([k2, v]) => KEY.test(k2) && v !== void 0 && v !== null).map(([k2, v]) => [k2, String(v)]));
29649
+ return { name, slug: slugify2(name), distribution: r2.distribution === "internal" ? "internal" : "store", channel: slugify2(r2.channel ?? name), env: env2, developmentClient: !!r2.developmentClient };
29650
+ });
29651
+ }
29652
+ async function environments(lb, path2) {
29653
+ return unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/environments", { params: { path: path2 } }));
29654
+ }
29655
+ async function ensureEnvironment(lb, path2, slug, existing) {
29656
+ const envs = existing ?? await environments(lb, path2);
29657
+ if (envs.some((e) => e.slug === slug)) return false;
29658
+ await unwrap(lb.POST("/v1/orgs/{org}/projects/{project}/environments", { params: { path: path2 }, body: { name: slug, slug } }));
29659
+ return true;
29660
+ }
29661
+ async function importVariables(lb, path2, env2, vars, opts) {
29662
+ const result = { environment: env2, added: [], updated: [], skipped: [], visibility: {} };
29663
+ const bad = vars.filter((v) => !KEY.test(v.key));
29664
+ for (const v of bad) result.skipped.push({ key: v.key, reason: "names must be UPPER_SNAKE_CASE" });
29665
+ const byVisibility = /* @__PURE__ */ new Map();
29666
+ for (const v of vars.filter((v2) => KEY.test(v2.key))) {
29667
+ const vis = visibilityFor(v.key, opts.visibility);
29668
+ result.visibility[v.key] = vis;
29669
+ byVisibility.set(vis, [...(byVisibility.get(vis) ?? []).filter((x) => x.key !== v.key), v]);
29670
+ }
29671
+ for (const [visibility, group] of byVisibility) {
29672
+ const r2 = await unwrap(
29673
+ lb.POST("/v1/orgs/{org}/projects/{project}/environments/{env}/import", { params: { path: { ...path2, env: env2 } }, body: { content: serializeEnv(group), visibility, overwrite: opts.overwrite } })
29674
+ );
29675
+ result.added.push(...r2.added);
29676
+ result.updated.push(...r2.updated);
29677
+ result.skipped.push(...r2.skipped);
29678
+ }
29679
+ return result;
29680
+ }
29681
+ function printImport(r2) {
29682
+ const count = (n3, word) => `${n3} ${word}`;
29683
+ log.success(`${import_picocolors10.default.bold(r2.environment)}: ${count(r2.added.length, "added")}, ${count(r2.updated.length, "updated")}${r2.skipped.length ? `, ${r2.skipped.length} skipped` : ""}`);
29684
+ for (const key of [...r2.added, ...r2.updated]) log.message(` ${key.padEnd(36)} ${import_picocolors10.default.dim(r2.visibility[key] ?? "")}`);
29685
+ for (const s of r2.skipped) log.message(import_picocolors10.default.dim(` ${s.key.padEnd(36)} skipped: ${s.reason}`));
29686
+ }
29687
+ async function pickEnvironment(lb, path2, given, file2) {
29688
+ const envs = await environments(lb, path2);
29689
+ const fromName = file2 ? basename2(file2).match(/^\.env\.([a-z0-9-]+?)(?:\.local)?$/)?.[1] : void 0;
29690
+ const slug = given ?? (fromName && envs.some((e) => e.slug === fromName) ? fromName : void 0);
29691
+ if (slug) return { slug, envs };
29692
+ const names = envs.map((e) => e.slug).join(", ");
29693
+ if (!isInteractive()) fail(`Pass --environment (${names})${file2 ? ` for ${file2}` : ""}`);
29694
+ return { slug: cancelled(await select({ message: file2 ? `Environment for ${file2}` : "Environment", options: envs.map((e) => ({ value: e.slug, label: e.name, hint: `${e.variableCount} variables` })) })), envs };
29695
+ }
29696
+ var projectPath = () => {
29697
+ const config = requireConfig();
29698
+ return { org: config.org, project: config.project };
29699
+ };
29700
+ async function envList(opts) {
29701
+ const lb = client();
29702
+ const path2 = projectPath();
29703
+ if (!opts.environment) {
29704
+ const envs = await environments(lb, path2);
29705
+ for (const e of envs) console.log(`${import_picocolors10.default.bold(e.slug.padEnd(16))} ${String(e.variableCount).padStart(3)} variables ${import_picocolors10.default.dim(`revision ${e.revision}`)}`);
29706
+ return { environments: envs.map(({ slug, name, variableCount, revision, updatedAt }) => ({ slug, name, variableCount, revision, updatedAt })) };
29707
+ }
29708
+ const vars = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/environments/{env}/variables", { params: { path: { ...path2, env: opts.environment } } }));
29709
+ if (!vars.length) console.log(`No variables in ${opts.environment}. Add some with ${import_picocolors10.default.cyan(`liftbay env import .env --environment ${opts.environment}`)}`);
29710
+ for (const v of vars) {
29711
+ const shown = v.visibility === "plain" ? v.value ?? "" : v.visibility === "secret" ? import_picocolors10.default.dim("(secret)") : import_picocolors10.default.dim("\u2022\u2022\u2022\u2022\u2022\u2022 (sensitive)");
29712
+ console.log(`${v.key.padEnd(36)} ${shown}`);
29713
+ }
29714
+ return { environment: opts.environment, variables: vars.map((v) => ({ key: v.key, visibility: v.visibility, value: v.visibility === "plain" ? v.value : null, updatedAt: v.updatedAt })) };
29715
+ }
29716
+ async function envSet(pairs, opts) {
29717
+ const lb = client();
29718
+ const path2 = projectPath();
29719
+ const vars = pairs.map((pair) => {
29720
+ const at = pair.indexOf("=");
29721
+ if (at < 1) fail(`"${pair}" isn't KEY=value`);
29722
+ return { key: pair.slice(0, at), value: pair.slice(at + 1) };
29723
+ });
29724
+ const { slug } = await pickEnvironment(lb, path2, opts.environment);
29725
+ const r2 = await importVariables(lb, path2, slug, vars, { visibility: parseVisibility(opts.visibility), overwrite: true });
29726
+ printImport(r2);
29727
+ return r2;
29728
+ }
29729
+ async function envUnset(keys, opts) {
29730
+ const lb = client();
29731
+ const path2 = projectPath();
29732
+ const { slug } = await pickEnvironment(lb, path2, opts.environment);
29733
+ const vars = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/environments/{env}/variables", { params: { path: { ...path2, env: slug } } }));
29734
+ const removed = [];
29735
+ for (const key of keys) {
29736
+ const v = vars.find((x) => x.key === key);
29737
+ if (!v) {
29738
+ log.warn(`${key} isn't set in ${slug}`);
29739
+ continue;
29740
+ }
29741
+ await unwrap(lb.DELETE("/v1/orgs/{org}/projects/{project}/environments/{env}/variables/{id}", { params: { path: { ...path2, env: slug, id: v.id } } }));
29742
+ log.success(`Removed ${key} from ${slug}`);
29743
+ removed.push(key);
29744
+ }
29745
+ return { environment: slug, removed };
29746
+ }
29747
+ async function envImport(files, opts) {
29748
+ const lb = client();
29749
+ const path2 = projectPath();
29750
+ const visibility = parseVisibility(opts.visibility);
29751
+ const overwrite = opts.overwrite !== false;
29752
+ const results = [];
29753
+ for (const file2 of files) {
29754
+ const abs = resolve3(file2.replace(/^~/, process.env.HOME ?? "~"));
29755
+ if (!existsSync6(abs)) fail(`No file at ${file2}`);
29756
+ if (basename2(abs) === "eas.json") {
29757
+ const profiles = readEasProfiles(resolve3(abs, ".."));
29758
+ if (!profiles.length) fail("eas.json has no build profiles");
29759
+ const envs = await environments(lb, path2);
29760
+ for (const prof of opts.environment ? profiles.filter((x) => x.slug === opts.environment) : profiles) {
29761
+ if (await ensureEnvironment(lb, path2, prof.slug, envs)) log.success(`Created environment ${import_picocolors10.default.bold(prof.slug)}`);
29762
+ const vars2 = Object.entries(prof.env).map(([key, value]) => ({ key, value }));
29763
+ if (!vars2.length) {
29764
+ log.info(`${import_picocolors10.default.bold(prof.slug)}: no env in eas.json`);
29765
+ continue;
29766
+ }
29767
+ const r3 = await importVariables(lb, path2, prof.slug, vars2, { visibility, overwrite });
29768
+ printImport(r3);
29769
+ results.push(r3);
29770
+ }
29771
+ continue;
29772
+ }
29773
+ const { vars, invalid } = parseEnv(readFileSync7(abs, "utf8"));
29774
+ if (invalid.length) log.warn(`${file2}: skipped unreadable line${invalid.length > 1 ? "s" : ""} ${invalid.join(", ")}`);
29775
+ const { slug } = await pickEnvironment(lb, path2, opts.environment, file2);
29776
+ const r2 = await importVariables(lb, path2, slug, vars, { visibility, overwrite });
29777
+ printImport(r2);
29778
+ results.push(r2);
29779
+ }
29780
+ return { imported: results };
29781
+ }
29782
+ async function envExport(opts) {
29783
+ const lb = client();
29784
+ const path2 = projectPath();
29785
+ const { slug } = await pickEnvironment(lb, path2, opts.environment);
29786
+ const content = String(await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/environments/{env}/export", { params: { path: { ...path2, env: slug } }, parseAs: "text" })));
29787
+ if (!opts.out) {
29788
+ process.stdout.write(`${content.trimEnd()}
29789
+ `);
29790
+ return { environment: slug, content };
29791
+ }
29792
+ if (existsSync6(opts.out) && !opts.force) fail(`${opts.out} exists. Pass --force to overwrite it.`);
29793
+ writeFileSync6(opts.out, `${content.trimEnd()}
29794
+ `, { mode: 384 });
29795
+ log.success(`Wrote ${opts.out} ${import_picocolors10.default.dim("(secret values are not exported)")}`);
29796
+ return { environment: slug, file: resolve3(opts.out) };
29797
+ }
29798
+
29570
29799
  // src/commands/setup.ts
29571
29800
  var DEFAULT_PROFILES = {
29572
29801
  development: { environment: "development", channel: "development", distribution: "internal" },
@@ -29595,11 +29824,11 @@ async function ciInit(app, config, opts) {
29595
29824
  })
29596
29825
  );
29597
29826
  }
29598
- if (platforms.includes("ios") && !app.bundleId) log.warn(`No iOS bundle identifier found. Set ${import_picocolors10.default.cyan("expo.ios.bundleIdentifier")} in app.json before the first iOS build.`);
29599
- if (platforms.includes("android") && !app.androidPackage) log.warn(`No Android package found. Set ${import_picocolors10.default.cyan("expo.android.package")} in app.json before the first Android build.`);
29827
+ if (platforms.includes("ios") && !app.bundleId) log.warn(`No iOS bundle identifier found. Set ${import_picocolors11.default.cyan("expo.ios.bundleIdentifier")} in app.json before the first iOS build.`);
29828
+ if (platforms.includes("android") && !app.androidPackage) log.warn(`No Android package found. Set ${import_picocolors11.default.cyan("expo.android.package")} in app.json before the first Android build.`);
29600
29829
  const path2 = { org: config.org, project: config.project };
29601
29830
  const repo = await githubRepo();
29602
- if (!repo) log.warn(`No GitHub remote found. Builds run on GitHub Actions: push this app to GitHub, then run ${import_picocolors10.default.cyan("liftbay ci init")} again.`);
29831
+ if (!repo) log.warn(`No GitHub remote found. Builds run on GitHub Actions: push this app to GitHub, then run ${import_picocolors11.default.cyan("liftbay ci init")} again.`);
29603
29832
  await unwrap(
29604
29833
  lb.PATCH("/v1/orgs/{org}/projects/{project}", {
29605
29834
  params: { path: path2 },
@@ -29607,21 +29836,40 @@ async function ciInit(app, config, opts) {
29607
29836
  })
29608
29837
  );
29609
29838
  await writeWorkflow(opts);
29610
- const envs = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/environments", { params: { path: path2 } }));
29611
- for (const profile of Object.values(DEFAULT_PROFILES)) {
29612
- if (!envs.some((e) => e.slug === profile.environment)) {
29613
- await unwrap(lb.POST("/v1/orgs/{org}/projects/{project}/environments", { params: { path: path2 }, body: { name: profile.environment, slug: profile.environment } }));
29614
- }
29839
+ const eas = opts.eas === false ? null : readEasProfiles();
29840
+ let fromEas = false;
29841
+ if (eas?.length) {
29842
+ fromEas = opts.yes || !isInteractive() || cancelled(await confirm({ message: `Found eas.json (profiles ${eas.map((e) => e.name).join(", ")}). Use its build profiles, channels and env variables?`, initialValue: true }));
29615
29843
  }
29616
- const profiles = Object.fromEntries(Object.entries(DEFAULT_PROFILES).map(([name, prof]) => [name, { ...prof, platforms }]));
29844
+ const base = fromEas ? Object.fromEntries(eas.map((e) => [e.name, { environment: e.slug, channel: e.channel, distribution: e.distribution }])) : DEFAULT_PROFILES;
29845
+ const envs = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/environments", { params: { path: path2 } }));
29846
+ for (const profile of Object.values(base)) {
29847
+ if (await ensureEnvironment(lb, path2, profile.environment, envs)) envs.push({ slug: profile.environment });
29848
+ }
29849
+ const imported = [];
29850
+ if (fromEas) {
29851
+ log.step("Importing env variables from eas.json");
29852
+ for (const e of eas) {
29853
+ const vars = Object.entries(e.env).map(([key, value]) => ({ key, value }));
29854
+ if (!vars.length) continue;
29855
+ const r2 = await importVariables(lb, path2, e.slug, vars, { overwrite: false });
29856
+ printImport(r2);
29857
+ imported.push(r2);
29858
+ }
29859
+ log.info(import_picocolors11.default.dim("EXPO_PUBLIC_* values are plain (they're in the app bundle); names like *_TOKEN or *_SECRET are secret; the rest sensitive."));
29860
+ log.info(import_picocolors11.default.dim(`Variables kept on expo.dev (EAS environment variables) aren't in eas.json: ${import_picocolors11.default.cyan("npx eas env:pull --environment production")}, then ${import_picocolors11.default.cyan("liftbay env import .env.local --environment production")}.`));
29861
+ }
29862
+ const profiles = Object.fromEntries(Object.entries(base).map(([name, prof]) => [name, { ...prof, platforms }]));
29617
29863
  const next = { ...config, build: { profiles, triggers: config.build?.triggers ?? [] } };
29618
29864
  writeConfig(next);
29619
29865
  note(
29620
- Object.entries(profiles).map(([name, prof]) => `${import_picocolors10.default.bold(name.padEnd(12))} env ${prof.environment.padEnd(12)} ${prof.distribution === "store" ? "store" : "internal"} \xB7 ${prof.platforms.join(", ")}`).join("\n"),
29866
+ Object.entries(profiles).map(([name, prof]) => `${import_picocolors11.default.bold(name.padEnd(12))} env ${prof.environment.padEnd(12)} ${prof.distribution === "store" ? "store" : "internal"} \xB7 ${prof.platforms.join(", ")}`).join("\n"),
29621
29867
  "Build profiles"
29622
29868
  );
29869
+ lastImport = imported;
29623
29870
  return next;
29624
29871
  }
29872
+ var lastImport = [];
29625
29873
  var WORKFLOW_PATH = ".github/workflows/liftbay.yml";
29626
29874
  async function githubRepo() {
29627
29875
  try {
@@ -29632,16 +29880,16 @@ async function githubRepo() {
29632
29880
  }
29633
29881
  }
29634
29882
  async function writeWorkflow(opts) {
29635
- const current = existsSync6(WORKFLOW_PATH) ? readFileSync7(WORKFLOW_PATH, "utf8") : null;
29883
+ const current = existsSync7(WORKFLOW_PATH) ? readFileSync8(WORKFLOW_PATH, "utf8") : null;
29636
29884
  if (current === WORKFLOW_YAML) return;
29637
29885
  if (current !== null) {
29638
29886
  const replace = opts.yes || isInteractive() && cancelled(await confirm({ message: `${WORKFLOW_PATH} differs from the current Liftbay workflow. Replace it?` }));
29639
29887
  if (!replace) return log.info(`Kept your ${WORKFLOW_PATH}.`);
29640
29888
  }
29641
29889
  mkdirSync5(dirname3(WORKFLOW_PATH), { recursive: true });
29642
- writeFileSync6(WORKFLOW_PATH, WORKFLOW_YAML);
29643
- log.success(`Wrote ${import_picocolors10.default.cyan(WORKFLOW_PATH)}. Commit and push it: Liftbay starts it for every build, on your GitHub Actions minutes.`);
29644
- log.info(import_picocolors10.default.dim("GitHub counts macOS minutes about 10\xD7 faster than Linux; on a private repo with GitHub Free that is roughly 10 iOS builds a month."));
29890
+ writeFileSync7(WORKFLOW_PATH, WORKFLOW_YAML);
29891
+ log.success(`Wrote ${import_picocolors11.default.cyan(WORKFLOW_PATH)}. Commit and push it: Liftbay starts it for every build, on your GitHub Actions minutes.`);
29892
+ log.info(import_picocolors11.default.dim("GitHub counts macOS minutes about 10\xD7 faster than Linux; on a private repo with GitHub Free that is roughly 10 iOS builds a month."));
29645
29893
  }
29646
29894
  var pluginSpec = () => process.env.LIFTBAY_EXPO_PLUGIN_SPEC ?? "@liftbay/expo-plugin";
29647
29895
  async function addDependency(app, spec) {
@@ -29662,7 +29910,8 @@ async function otaInit(app, config, opts) {
29662
29910
  }
29663
29911
  log.step("Installing the Liftbay config plugin");
29664
29912
  await addDependency(app, pluginSpec());
29665
- let policy = opts.runtimePolicy;
29913
+ const existingPolicy = readAppJson()?.expo?.runtimeVersion?.policy;
29914
+ let policy = opts.runtimePolicy ?? (existingPolicy === "appVersion" || existingPolicy === "fingerprint" ? existingPolicy : void 0);
29666
29915
  if (!policy) {
29667
29916
  policy = opts.yes || !isInteractive() ? "appVersion" : cancelled(
29668
29917
  await select({
@@ -29679,16 +29928,16 @@ async function otaInit(app, config, opts) {
29679
29928
  let channels2 = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/channels", { params: { path: path2 } }));
29680
29929
  for (const prof of Object.values(profiles)) {
29681
29930
  const existing = channels2.find((c2) => c2.name === prof.channel);
29682
- const env = envs.find((e) => e.slug === prof.environment);
29683
- if (!env) fail(`Environment ${prof.environment} not found; run \`liftbay ci init\` first`);
29931
+ const env2 = envs.find((e) => e.slug === prof.environment);
29932
+ if (!env2) fail(`Environment ${prof.environment} not found; run \`liftbay ci init\` first`);
29684
29933
  if (!existing) {
29685
- await unwrap(lb.POST("/v1/orgs/{org}/projects/{project}/channels", { params: { path: path2 }, body: { name: prof.channel, environmentId: env.id, protocols: ["expo"] } }));
29686
- log.success(`Created channel ${import_picocolors10.default.bold(prof.channel)} \u2192 ${prof.environment}`);
29934
+ await unwrap(lb.POST("/v1/orgs/{org}/projects/{project}/channels", { params: { path: path2 }, body: { name: prof.channel, environmentId: env2.id, protocols: ["expo"] } }));
29935
+ log.success(`Created channel ${import_picocolors11.default.bold(prof.channel)} \u2192 ${prof.environment}`);
29687
29936
  } else if (!existing.protocols.includes("expo")) {
29688
29937
  await unwrap(
29689
29938
  lb.PATCH("/v1/orgs/{org}/projects/{project}/channels/{channel}", { params: { path: { ...path2, channel: prof.channel } }, body: { protocols: [...existing.protocols, "expo"] } })
29690
29939
  );
29691
- log.success(`Enabled expo-updates on channel ${import_picocolors10.default.bold(prof.channel)}`);
29940
+ log.success(`Enabled expo-updates on channel ${import_picocolors11.default.bold(prof.channel)}`);
29692
29941
  }
29693
29942
  }
29694
29943
  channels2 = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/channels", { params: { path: path2 } }));
@@ -29708,23 +29957,23 @@ runtimeVersion: { "policy": "${policy}" }`, "Add to your app config");
29708
29957
  else plugins.push(entry);
29709
29958
  expo.runtimeVersion = { policy };
29710
29959
  writeAppJson(appJson);
29711
- log.success(`Updated ${import_picocolors10.default.cyan("app.json")}: Liftbay plugin, runtimeVersion policy "${policy}"`);
29960
+ log.success(`Updated ${import_picocolors11.default.cyan("app.json")}: Liftbay plugin, runtimeVersion policy "${policy}"`);
29712
29961
  }
29713
29962
  const next = { ...config, updates: { client: "expo-updates", runtimeVersion: { policy } } };
29714
29963
  writeConfig(next);
29715
29964
  return next;
29716
29965
  }
29717
29966
  async function ciInitCommand(opts) {
29718
- intro(import_picocolors10.default.bgCyan(import_picocolors10.default.black(" liftbay ci init ")));
29967
+ intro(import_picocolors11.default.bgCyan(import_picocolors11.default.black(" liftbay ci init ")));
29719
29968
  await ensureSignedIn(opts);
29720
29969
  const app = await detectApp();
29721
29970
  const config = await ensureLinked(app, opts);
29722
29971
  const next = await ciInit(app, config, opts);
29723
- outro(`Builds are configured. Start one with ${import_picocolors10.default.cyan("liftbay build --profile preview")}`);
29724
- return { config: next };
29972
+ outro(`Builds are configured. Start one with ${import_picocolors11.default.cyan("liftbay build --profile preview")}`);
29973
+ return { config: next, imported: lastImport };
29725
29974
  }
29726
29975
  async function otaInitCommand(opts) {
29727
- intro(import_picocolors10.default.bgCyan(import_picocolors10.default.black(" liftbay ota init ")));
29976
+ intro(import_picocolors11.default.bgCyan(import_picocolors11.default.black(" liftbay ota init ")));
29728
29977
  await ensureSignedIn(opts);
29729
29978
  const app = await detectApp();
29730
29979
  const config = await ensureLinked(app, opts);
@@ -29735,29 +29984,30 @@ async function otaInitCommand(opts) {
29735
29984
  var nextStepsOta = () => `OTA updates are configured.
29736
29985
 
29737
29986
  1. Build a new binary once (the update URL and channel are baked in)
29738
- 2. Ship JS changes with ${import_picocolors10.default.cyan("liftbay update publish --channel production")}`;
29987
+ 2. Ship JS changes with ${import_picocolors11.default.cyan("liftbay update publish --channel production")}`;
29739
29988
  async function initCommand(opts) {
29740
- intro(import_picocolors10.default.bgCyan(import_picocolors10.default.black(" liftbay init ")));
29989
+ intro(import_picocolors11.default.bgCyan(import_picocolors11.default.black(" liftbay init ")));
29741
29990
  await ensureSignedIn(opts);
29742
29991
  const app = await detectApp();
29743
29992
  log.info(
29744
- `${import_picocolors10.default.bold(app.name)}: ${app.framework === "expo" ? "Expo" : "React Native"}${app.version ? ` ${app.version}` : ""}${app.bundleId ? ` \xB7 ${app.bundleId}` : ""}${app.androidPackage && app.androidPackage !== app.bundleId ? ` \xB7 ${app.androidPackage}` : ""}`
29993
+ `${import_picocolors11.default.bold(app.name)}: ${app.framework === "expo" ? "Expo" : "React Native"}${app.version ? ` ${app.version}` : ""}${app.bundleId ? ` \xB7 ${app.bundleId}` : ""}${app.androidPackage && app.androidPackage !== app.bundleId ? ` \xB7 ${app.androidPackage}` : ""}`
29745
29994
  );
29746
29995
  let config = await ensureLinked(app, opts);
29747
- log.step(import_picocolors10.default.bold("1/2 Builds"));
29996
+ log.step(import_picocolors11.default.bold("1/2 Builds"));
29748
29997
  config = await ciInit(app, config, opts);
29749
- log.step(import_picocolors10.default.bold("2/2 OTA updates"));
29998
+ log.step(import_picocolors11.default.bold("2/2 OTA updates"));
29750
29999
  const wantOta = opts.ota ?? (opts.yes || !isInteractive() ? true : cancelled(await confirm({ message: "Set up over-the-air updates with expo-updates?", initialValue: true })));
29751
30000
  if (wantOta) config = await otaInit(await detectApp(), config, opts);
29752
- else log.info(`Skipped. Run ${import_picocolors10.default.cyan("liftbay ota init")} any time.`);
30001
+ else log.info(`Skipped. Run ${import_picocolors11.default.cyan("liftbay ota init")} any time.`);
29753
30002
  outro(
29754
- `Done. ${import_picocolors10.default.dim(`${dashboardUrl()}/${config.org}/${config.project}`)}
30003
+ `Done. ${import_picocolors11.default.dim(`${dashboardUrl()}/${config.org}/${config.project}`)}
29755
30004
 
29756
- Build: ${import_picocolors10.default.cyan("liftbay build --profile preview")}${wantOta ? `
29757
- Update: ${import_picocolors10.default.cyan("liftbay update publish --channel production")}` : ""}`
30005
+ Build: ${import_picocolors11.default.cyan("liftbay build --profile preview")}${wantOta ? `
30006
+ Update: ${import_picocolors11.default.cyan("liftbay update publish --channel production")}` : ""}`
29758
30007
  );
29759
30008
  return {
29760
30009
  config,
30010
+ imported: lastImport,
29761
30011
  url: `${dashboardUrl()}/${config.org}/${config.project}`,
29762
30012
  next: ["Commit liftbay.json, .github/workflows/liftbay.yml and app.json, and push", "liftbay build --profile preview", ...wantOta ? ["liftbay update publish --channel production"] : []]
29763
30013
  };
@@ -29766,17 +30016,17 @@ async function initCommand(opts) {
29766
30016
  // src/commands/update.ts
29767
30017
  import { execFileSync as execFileSync2 } from "child_process";
29768
30018
  import { createHash as createHash4 } from "crypto";
29769
- import { mkdtempSync, readFileSync as readFileSync9, rmSync as rmSync4 } from "fs";
30019
+ import { mkdtempSync, readFileSync as readFileSync10, rmSync as rmSync4 } from "fs";
29770
30020
  import { tmpdir } from "os";
29771
- import { join as join8 } from "path";
29772
- var import_picocolors12 = __toESM(require_picocolors(), 1);
30021
+ import { join as join9 } from "path";
30022
+ var import_picocolors13 = __toESM(require_picocolors(), 1);
29773
30023
 
29774
30024
  // src/commands/signing.ts
29775
30025
  import { createPublicKey, createSign, randomBytes as randomBytes2 } from "crypto";
29776
- import { chmodSync as chmodSync4, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
30026
+ import { chmodSync as chmodSync4, existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "fs";
29777
30027
  import { dirname as dirname4, relative } from "path";
29778
30028
  var import_node_forge = __toESM(require_lib2(), 1);
29779
- var import_picocolors11 = __toESM(require_picocolors(), 1);
30029
+ var import_picocolors12 = __toESM(require_picocolors(), 1);
29780
30030
  var DEFAULT_PRIVATE_KEY = "keys/liftbay-private-key.pem";
29781
30031
  var DEFAULT_PUBLIC_KEY = "certs/liftbay-public-key.pem";
29782
30032
  var DEFAULT_CERTIFICATE = "certs/liftbay-certificate.pem";
@@ -29806,18 +30056,18 @@ function generateSigningCertificate(commonName, years = 10) {
29806
30056
  var signRsaSha256 = (privateKeyPem, data) => createSign("RSA-SHA256").update(data).sign(privateKeyPem, "base64");
29807
30057
  function loadPrivateKey(path2) {
29808
30058
  const read = (file2, label) => {
29809
- if (!existsSync7(file2)) fail(`No private key at ${file2} (${label})`);
29810
- return { pem: readFileSync8(file2, "utf8"), source: file2 };
30059
+ if (!existsSync8(file2)) fail(`No private key at ${file2} (${label})`);
30060
+ return { pem: readFileSync9(file2, "utf8"), source: file2 };
29811
30061
  };
29812
30062
  if (path2) return read(path2, "--private-key");
29813
- const env = process.env.LIFTBAY_UPDATE_PRIVATE_KEY;
29814
- if (env) return env.includes("PRIVATE KEY") ? { pem: env.replace(/\\n/g, "\n"), source: "LIFTBAY_UPDATE_PRIVATE_KEY" } : read(env, "LIFTBAY_UPDATE_PRIVATE_KEY");
29815
- return existsSync7(DEFAULT_PRIVATE_KEY) ? { pem: readFileSync8(DEFAULT_PRIVATE_KEY, "utf8"), source: DEFAULT_PRIVATE_KEY } : null;
30063
+ const env2 = process.env.LIFTBAY_UPDATE_PRIVATE_KEY;
30064
+ if (env2) return env2.includes("PRIVATE KEY") ? { pem: env2.replace(/\\n/g, "\n"), source: "LIFTBAY_UPDATE_PRIVATE_KEY" } : read(env2, "LIFTBAY_UPDATE_PRIVATE_KEY");
30065
+ return existsSync8(DEFAULT_PRIVATE_KEY) ? { pem: readFileSync9(DEFAULT_PRIVATE_KEY, "utf8"), source: DEFAULT_PRIVATE_KEY } : null;
29816
30066
  }
29817
30067
  function ensureIgnored(entry) {
29818
- const current = existsSync7(".gitignore") ? readFileSync8(".gitignore", "utf8") : "";
30068
+ const current = existsSync8(".gitignore") ? readFileSync9(".gitignore", "utf8") : "";
29819
30069
  if (current.split("\n").some((l2) => l2.trim() === entry || l2.trim() === entry.replace(/\/$/, ""))) return false;
29820
- writeFileSync7(".gitignore", `${current}${current && !current.endsWith("\n") ? "\n" : ""}
30070
+ writeFileSync8(".gitignore", `${current}${current && !current.endsWith("\n") ? "\n" : ""}
29821
30071
  # Liftbay update signing key: never commit it
29822
30072
  ${entry}
29823
30073
  `);
@@ -29839,7 +30089,7 @@ async function signingInit(opts) {
29839
30089
  const config = requireConfig();
29840
30090
  const lb = client();
29841
30091
  const path2 = { org: config.org, project: config.project };
29842
- intro(import_picocolors11.default.bgCyan(import_picocolors11.default.black(" liftbay update signing init ")));
30092
+ intro(import_picocolors12.default.bgCyan(import_picocolors12.default.black(" liftbay update signing init ")));
29843
30093
  const { signing: signing2 } = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/updates/signing", { params: { path: path2 } }));
29844
30094
  if (signing2 && !opts.force) {
29845
30095
  fail(
@@ -29848,16 +30098,16 @@ async function signingInit(opts) {
29848
30098
  }
29849
30099
  const privateKeyPath = opts.privateKey ?? DEFAULT_PRIVATE_KEY;
29850
30100
  const certificatePath = opts.certificate ?? DEFAULT_CERTIFICATE;
29851
- if (existsSync7(privateKeyPath) && !opts.force) fail(`${privateKeyPath} already exists. Pass --force to overwrite it, or --private-key for another path.`);
30101
+ if (existsSync8(privateKeyPath) && !opts.force) fail(`${privateKeyPath} already exists. Pass --force to overwrite it, or --private-key for another path.`);
29852
30102
  const generated = generateSigningCertificate(opts.commonName ?? config.project);
29853
30103
  for (const dir of /* @__PURE__ */ new Set([dirname4(privateKeyPath), dirname4(certificatePath), dirname4(DEFAULT_PUBLIC_KEY)])) mkdirSync6(dir, { recursive: true });
29854
- writeFileSync7(privateKeyPath, generated.privateKeyPem, { mode: 384 });
30104
+ writeFileSync8(privateKeyPath, generated.privateKeyPem, { mode: 384 });
29855
30105
  chmodSync4(privateKeyPath, 384);
29856
- writeFileSync7(certificatePath, generated.certificatePem);
29857
- writeFileSync7(DEFAULT_PUBLIC_KEY, generated.publicKeyPem);
29858
- if (ensureIgnored(`${dirname4(relative(".", privateKeyPath))}/`)) log.info(`Added ${import_picocolors11.default.cyan(`${dirname4(privateKeyPath)}/`)} to .gitignore`);
29859
- log.success(`Private key: ${import_picocolors11.default.cyan(privateKeyPath)} (stays on this machine)
29860
- Certificate: ${import_picocolors11.default.cyan(certificatePath)} (commit it; it's embedded in your app)`);
30106
+ writeFileSync8(certificatePath, generated.certificatePem);
30107
+ writeFileSync8(DEFAULT_PUBLIC_KEY, generated.publicKeyPem);
30108
+ if (ensureIgnored(`${dirname4(relative(".", privateKeyPath))}/`)) log.info(`Added ${import_picocolors12.default.cyan(`${dirname4(privateKeyPath)}/`)} to .gitignore`);
30109
+ log.success(`Private key: ${import_picocolors12.default.cyan(privateKeyPath)} (stays on this machine)
30110
+ Certificate: ${import_picocolors12.default.cyan(certificatePath)} (commit it; it's embedded in your app)`);
29861
30111
  await unwrap(
29862
30112
  lb.PUT("/v1/orgs/{org}/projects/{project}/updates/signing", {
29863
30113
  params: { path: path2 },
@@ -29865,7 +30115,7 @@ async function signingInit(opts) {
29865
30115
  })
29866
30116
  );
29867
30117
  log.success("Certificate saved to Liftbay");
29868
- if (configureAppJson(certificatePath)) log.success(`Updated ${import_picocolors11.default.cyan("app.json")}: the Liftbay plugin embeds the certificate in your app`);
30118
+ if (configureAppJson(certificatePath)) log.success(`Updated ${import_picocolors12.default.cyan("app.json")}: the Liftbay plugin embeds the certificate in your app`);
29869
30119
  else note(`plugins: [["@liftbay/expo-plugin", { "codeSigningCertificate": "./${certificatePath}" }]]`, "Add to your app config");
29870
30120
  const channels2 = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/channels", { params: { path: path2 } }));
29871
30121
  const signedOnly = !!channels2.length && (opts.yes || !isInteractive() || cancelled(await confirm({ message: "Require signed updates on every channel? Unsigned publishes will be refused.", initialValue: true })));
@@ -29878,12 +30128,12 @@ async function signingInit(opts) {
29878
30128
  note(
29879
30129
  [
29880
30130
  `1. Rebuild your apps once: the certificate is part of the binary.`,
29881
- `2. ${import_picocolors11.default.cyan("liftbay update publish")} signs automatically with ${privateKeyPath}.`,
29882
- `3. In CI, store the private key as a secret named ${import_picocolors11.default.cyan("LIFTBAY_UPDATE_PRIVATE_KEY")}.`,
30131
+ `2. ${import_picocolors12.default.cyan("liftbay update publish")} signs automatically with ${privateKeyPath}.`,
30132
+ `3. In CI, store the private key as a secret named ${import_picocolors12.default.cyan("LIFTBAY_UPDATE_PRIVATE_KEY")}.`,
29883
30133
  `4. Back the key up somewhere safe. If it's lost, apps need a rebuild with a new certificate.`,
29884
30134
  ``,
29885
- `hot-updater: set ${import_picocolors11.default.cyan("HOT_UPDATER_PUBLIC_KEY")} (iOS Info.plist) / ${import_picocolors11.default.cyan("com.hotupdater.PUBLIC_KEY")} (Android) to ${DEFAULT_PUBLIC_KEY}.`,
29886
- `CodePush: set ${import_picocolors11.default.cyan("CodePushPublicKey")} (iOS) / ${import_picocolors11.default.cyan("CodePushPublicKey")} string resource (Android) to ${DEFAULT_PUBLIC_KEY}.`
30135
+ `hot-updater: set ${import_picocolors12.default.cyan("HOT_UPDATER_PUBLIC_KEY")} (iOS Info.plist) / ${import_picocolors12.default.cyan("com.hotupdater.PUBLIC_KEY")} (Android) to ${DEFAULT_PUBLIC_KEY}.`,
30136
+ `CodePush: set ${import_picocolors12.default.cyan("CodePushPublicKey")} (iOS) / ${import_picocolors12.default.cyan("CodePushPublicKey")} string resource (Android) to ${DEFAULT_PUBLIC_KEY}.`
29887
30137
  ].join("\n"),
29888
30138
  "Next"
29889
30139
  );
@@ -29914,13 +30164,13 @@ var contentTypes = {
29914
30164
  mp4: "video/mp4"
29915
30165
  };
29916
30166
  function file(dir, rel, virtualPath, contentType) {
29917
- const data = readFileSync9(join8(dir, rel));
29918
- return { path: virtualPath, abs: join8(dir, rel), sha256: createHash4("sha256").update(data).digest("hex"), sizeBytes: data.length, contentType };
30167
+ const data = readFileSync10(join9(dir, rel));
30168
+ return { path: virtualPath, abs: join9(dir, rel), sha256: createHash4("sha256").update(data).digest("hex"), sizeBytes: data.length, contentType };
29919
30169
  }
29920
30170
  async function exportBundles(platforms) {
29921
- const out = mkdtempSync(join8(tmpdir(), "liftbay-export-"));
30171
+ const out = mkdtempSync(join9(tmpdir(), "liftbay-export-"));
29922
30172
  await run("npx", ["expo", "export", "--output-dir", out, "--dump-assetmap", ...platforms.flatMap((pl) => ["--platform", pl])]);
29923
- const meta = JSON.parse(readFileSync9(join8(out, "metadata.json"), "utf8"));
30173
+ const meta = JSON.parse(readFileSync10(join9(out, "metadata.json"), "utf8"));
29924
30174
  const bundles = {};
29925
30175
  for (const pl of platforms) {
29926
30176
  const m2 = meta.fileMetadata[pl];
@@ -29959,7 +30209,7 @@ async function publish(opts) {
29959
30209
  return null;
29960
30210
  }
29961
30211
  })();
29962
- intro(import_picocolors12.default.bgCyan(import_picocolors12.default.black(" liftbay update publish ")));
30212
+ intro(import_picocolors13.default.bgCyan(import_picocolors13.default.black(" liftbay update publish ")));
29963
30213
  if (!platforms.every((x) => x === "ios" || x === "android")) fail("--platform must be ios, android or all");
29964
30214
  log.step(`Exporting ${platforms.join(" and ")} bundles`);
29965
30215
  const { out, bundles } = await exportBundles(platforms);
@@ -29981,13 +30231,13 @@ async function publish(opts) {
29981
30231
  for (const m2 of prep.missing) {
29982
30232
  const f = bySha.get(m2.sha256);
29983
30233
  spin.message(`Uploading ${++done}/${prep.missing.length}: ${f.path}`);
29984
- const res = await fetch(m2.uploadUrl, { method: "PUT", body: readFileSync9(f.abs), headers: { "Content-Type": f.contentType } });
30234
+ const res = await fetch(m2.uploadUrl, { method: "PUT", body: readFileSync10(f.abs), headers: { "Content-Type": f.contentType } });
29985
30235
  if (!res.ok) fail(`Upload failed for ${f.path} (${res.status})`);
29986
30236
  }
29987
30237
  spin.stop(`${all.length} files: ${prep.missing.length} uploaded, ${prep.alreadyStored} already stored`);
29988
30238
  const key = loadPrivateKey(opts.privateKey);
29989
30239
  const published = [];
29990
- if (key) log.info(`Signing with ${import_picocolors12.default.cyan(key.source)}`);
30240
+ if (key) log.info(`Signing with ${import_picocolors13.default.cyan(key.source)}`);
29991
30241
  for (const [runtime, group] of groups) {
29992
30242
  const body = {
29993
30243
  message,
@@ -30013,11 +30263,11 @@ async function publish(opts) {
30013
30263
  if (notice) log.warn(notice);
30014
30264
  published.push({ id: release.id, channel, runtimeVersion: runtime, platforms: group, rolloutPercent: release.rolloutPercent, signed: release.signed, message, notice });
30015
30265
  log.success(
30016
- `Published to ${import_picocolors12.default.bold(channel)} \xB7 runtime ${runtime} \xB7 ${group.join(", ")}${release.rolloutPercent < 100 ? ` \xB7 ${release.rolloutPercent}% rollout` : ""}${release.signed ? " \xB7 signed" : ""}`
30266
+ `Published to ${import_picocolors13.default.bold(channel)} \xB7 runtime ${runtime} \xB7 ${group.join(", ")}${release.rolloutPercent < 100 ? ` \xB7 ${release.rolloutPercent}% rollout` : ""}${release.signed ? " \xB7 signed" : ""}`
30017
30267
  );
30018
30268
  }
30019
30269
  const url = `${dashboardUrl()}/${config.org}/${config.project}/updates/${channel}`;
30020
- outro(import_picocolors12.default.dim(url));
30270
+ outro(import_picocolors13.default.dim(url));
30021
30271
  return { releases: published, url };
30022
30272
  } finally {
30023
30273
  rmSync4(out, { recursive: true, force: true });
@@ -30029,8 +30279,8 @@ async function list(opts) {
30029
30279
  const releases = await unwrap(client().GET("/v1/orgs/{org}/projects/{project}/channels/{channel}/releases", { params: { path: { org: config.org, project: config.project, channel } } }));
30030
30280
  if (!releases.length) console.log(`No releases on ${channel} yet.`);
30031
30281
  for (const r2 of releases.slice(0, 20)) {
30032
- const status = r2.status === "active" ? import_picocolors12.default.green("active") : r2.status === "rolled_back" ? import_picocolors12.default.red("rolled back") : import_picocolors12.default.dim("superseded");
30033
- console.log(`${new Date(r2.createdAt).toLocaleString().padEnd(24)} ${status.padEnd(22)} ${String(r2.rolloutPercent).padStart(3)}% ${import_picocolors12.default.dim(r2.runtimeVersion.padEnd(10))} ${r2.message}`);
30282
+ const status = r2.status === "active" ? import_picocolors13.default.green("active") : r2.status === "rolled_back" ? import_picocolors13.default.red("rolled back") : import_picocolors13.default.dim("superseded");
30283
+ console.log(`${new Date(r2.createdAt).toLocaleString().padEnd(24)} ${status.padEnd(22)} ${String(r2.rolloutPercent).padStart(3)}% ${import_picocolors13.default.dim(r2.runtimeVersion.padEnd(10))} ${r2.message}`);
30034
30284
  }
30035
30285
  return { channel, releases: releases.slice(0, 20) };
30036
30286
  }
@@ -30045,9 +30295,9 @@ async function rollback(opts) {
30045
30295
  const { path: path2, active } = await activeRelease(opts.channel ?? "production");
30046
30296
  const request = client().POST("/v1/orgs/{org}/projects/{project}/channels/{channel}/releases/{release}/rollback", { params: { path: { ...path2, release: active.id } } });
30047
30297
  const res = await unwrap(request);
30048
- console.log(`${import_picocolors12.default.green("\u2714")} Rolled back "${active.message}". ${res.active ? "The previous release is active again." : "Devices return to the bundle built into the app."}`);
30298
+ console.log(`${import_picocolors13.default.green("\u2714")} Rolled back "${active.message}". ${res.active ? "The previous release is active again." : "Devices return to the bundle built into the app."}`);
30049
30299
  const notice = (await request).response.headers.get("Liftbay-Notice");
30050
- if (notice) console.log(`${import_picocolors12.default.yellow("!")} ${notice}`);
30300
+ if (notice) console.log(`${import_picocolors13.default.yellow("!")} ${notice}`);
30051
30301
  return { rolledBack: { id: active.id, message: active.message }, active: res.active, notice };
30052
30302
  }
30053
30303
  async function rollout(percent, opts) {
@@ -30055,18 +30305,18 @@ async function rollout(percent, opts) {
30055
30305
  const r2 = await unwrap(
30056
30306
  client().PATCH("/v1/orgs/{org}/projects/{project}/channels/{channel}/releases/{release}/rollout", { params: { path: { ...path2, release: active.id } }, body: { rolloutPercent: Number(percent) } })
30057
30307
  );
30058
- console.log(`${import_picocolors12.default.green("\u2714")} "${r2.message}" now rolls out to ${r2.rolloutPercent}%`);
30308
+ console.log(`${import_picocolors13.default.green("\u2714")} "${r2.message}" now rolls out to ${r2.rolloutPercent}%`);
30059
30309
  return { id: r2.id, message: r2.message, rolloutPercent: r2.rolloutPercent };
30060
30310
  }
30061
30311
  async function channels() {
30062
30312
  const config = requireConfig();
30063
30313
  const list2 = await unwrap(client().GET("/v1/orgs/{org}/projects/{project}/channels", { params: { path: { org: config.org, project: config.project } } }));
30064
- for (const c2 of list2) console.log(`${import_picocolors12.default.bold(c2.name.padEnd(16))} env ${c2.environmentSlug.padEnd(12)} ${c2.protocols.join(", ").padEnd(32)} ${import_picocolors12.default.dim(c2.currentRelease?.message ?? "no active release")}`);
30314
+ for (const c2 of list2) console.log(`${import_picocolors13.default.bold(c2.name.padEnd(16))} env ${c2.environmentSlug.padEnd(12)} ${c2.protocols.join(", ").padEnd(32)} ${import_picocolors13.default.dim(c2.currentRelease?.message ?? "no active release")}`);
30065
30315
  return { channels: list2 };
30066
30316
  }
30067
30317
 
30068
30318
  // src/index.ts
30069
- var program2 = new Command("liftbay").description("Build, ship and update React Native apps").version("0.2.0").option("--json", "print the result as JSON on stdout (progress goes to stderr; never prompts). Also LIFTBAY_JSON=1").hook("preAction", () => {
30319
+ var program2 = new Command("liftbay").description("Build, ship and update React Native apps").version("0.3.1").option("--json", "print the result as JSON on stdout (progress goes to stderr; never prompts). Also LIFTBAY_JSON=1").hook("preAction", () => {
30070
30320
  if (program2.opts().json || process.env.LIFTBAY_JSON === "1") enableJson();
30071
30321
  });
30072
30322
  var wrap = (fn) => async (...args) => {
@@ -30076,7 +30326,7 @@ var wrap = (fn) => async (...args) => {
30076
30326
  reportError(e);
30077
30327
  }
30078
30328
  };
30079
- var setupOptions = (cmd) => cmd.option("--org <slug>", "organization").option("--project <slug>", "project slug (created if it doesn't exist)").option("--platforms <list>", "ios, android or all").option("--runtime-policy <policy>", "appVersion or fingerprint").option("-y, --yes", "accept defaults, no prompts").option("--api-url <url>", "API URL (for self-hosted or local development)").option("--dashboard-url <url>", "dashboard URL");
30329
+ var setupOptions = (cmd) => cmd.option("--org <slug>", "organization").option("--project <slug>", "project slug (created if it doesn't exist)").option("--platforms <list>", "ios, android or all").option("--runtime-policy <policy>", "appVersion or fingerprint").option("--no-eas", "don't use eas.json's build profiles and env, even if there is one").option("-y, --yes", "accept defaults, no prompts").option("--api-url <url>", "API URL (for self-hosted or local development)").option("--dashboard-url <url>", "dashboard URL");
30080
30330
  program2.command("login").description("Sign in through the browser").option("--api-url <url>", "API URL").option("--dashboard-url <url>", "dashboard URL").option("--no-open", "don't open the browser").option("--no-wait", "start the sign-in and exit; run `liftbay login` again after approving to finish (for agents)").action(wrap(login));
30081
30331
  program2.command("logout").description("Sign out and remove the saved token").action(wrap(logout));
30082
30332
  program2.command("whoami").description("Show the signed-in user and organizations").action(wrap(whoami));
@@ -30108,6 +30358,12 @@ var device = program2.command("device").description("iOS test devices for intern
30108
30358
  device.command("add").description("Register an iPhone or iPad: shows a QR code to open on it, or pass --udid").option("--udid <udid>", "add by UDID instead of a registration link").option("--name <name>", "device name (with --udid)").option("--team-id <id>", "Apple team, when the organization has several").option("--no-wait", "print the registration link and exit instead of waiting for the device (for agents)").action(wrap(deviceAdd));
30109
30359
  device.command("list").description("List registered devices").action(wrap(deviceList));
30110
30360
  device.command("remove <id>").description("Remove a device (disabled at Apple)").action(wrap(deviceRemove));
30361
+ var env = program2.command("env").description("Environment variables of the project's environments (development, preview, production, \u2026)");
30362
+ env.command("list").description("Environments, or with --environment its variables (sensitive values masked, secrets never shown)").option("-e, --environment <slug>", "environment").action(wrap(envList));
30363
+ env.command("import <files...>").description("Import .env files (into the environment each is named for, e.g. .env.production, or --environment), or eas.json (every build profile's env)").option("-e, --environment <slug>", "environment (for eas.json: only that profile)").option("--visibility <visibility>", "plain, sensitive or secret for all keys (default: EXPO_PUBLIC_* plain, *_TOKEN/*_SECRET/\u2026 secret, the rest sensitive)").option("--no-overwrite", "keep values that are already set").action(wrap(envImport));
30364
+ env.command("set <pairs...>").description("Set KEY=value pairs (values end up in shell history: prefer `env import` for secrets)").option("-e, --environment <slug>", "environment").option("--visibility <visibility>", "plain, sensitive or secret (default: from the name)").action(wrap(envSet));
30365
+ env.command("unset <keys...>").description("Remove variables").option("-e, --environment <slug>", "environment").action(wrap(envUnset));
30366
+ env.command("export").description("Print an environment as a .env file, or write it with --out (secret values are never exported)").option("-e, --environment <slug>", "environment").option("--out <file>", "file to write").option("--force", "overwrite the file").action(wrap(envExport));
30111
30367
  var ota = program2.command("ota").description("Over-the-air updates");
30112
30368
  setupOptions(ota.command("init").description("Set up expo-updates, the Liftbay plugin and channels")).action(wrap(otaInitCommand));
30113
30369
  var update = program2.command("update").description("Publish and manage OTA updates");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "liftbay",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Build, sign, submit and update React Native apps from your own CI.",
5
5
  "keywords": [
6
6
  "react-native",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: liftbay
3
- description: Build, sign, submit and update React Native and Expo apps with Liftbay (liftbay.dev). Use when the user wants to set up CI/CD for a React Native or Expo app, build an iOS .ipa or Android .apk/.aab, set up iOS or Android signing, upload to TestFlight or Google Play, register test iPhones for ad hoc installs, or publish, roll out or roll back over-the-air (OTA) updates. Also use when a repo has a liftbay.json file or the user mentions Liftbay.
3
+ description: Build, sign, submit and update React Native and Expo apps with Liftbay (liftbay.dev). Use when the user wants to set up CI/CD for a React Native or Expo app, move an app from EAS (eas.json) to Liftbay, manage build environment variables or .env files, build an iOS .ipa or Android .apk/.aab, set up iOS or Android signing, upload to TestFlight or Google Play, register test iPhones for ad hoc installs, or publish, roll out or roll back over-the-air (OTA) updates. Also use when a repo has a liftbay.json file or the user mentions Liftbay.
4
4
  ---
5
5
 
6
6
  # Liftbay
@@ -14,7 +14,7 @@ You drive it through the `liftbay` CLI. Run it as `npx liftbay@latest <command>`
14
14
  - **Always pass `--json`.** Stdout then holds exactly one JSON document: the result, or `{"error": {"code", "message", ...}}` with exit code 1. Progress and logs go to stderr. With `--json` the CLI never prompts; when it needs something, it fails with a message naming the flag to pass.
15
15
  - **Never block on long waits.** Use `--no-wait` on `build`, `submit`, `login` and `device add`, then check back with `builds view`, `submissions list` or `whoami`.
16
16
  - **Ask the user before anything that reaches real users or stores:** an OTA publish, rollout change or rollback on `production`; submitting to TestFlight; any Google Play track other than `internal`; cancelling a build someone else started.
17
- - **Never handle secrets yourself.** Don't ask for, print, pass or commit Apple passwords, keystore passwords or the update signing key (`keys/liftbay-private-key.pem`). When a step needs one, ask the user to run that command in their own terminal.
17
+ - **Never handle secrets yourself.** Importing `.env` files the user points you to is fine; never print their values. Don't ask for, print, pass or commit Apple passwords, keystore passwords or the update signing key (`keys/liftbay-private-key.pem`). When a step needs one, ask the user to run that command in their own terminal.
18
18
  - Read error messages: they say exactly what to do next.
19
19
 
20
20
  ## 1. Sign in
@@ -49,8 +49,10 @@ liftbay init --yes --json
49
49
 
50
50
  This detects the app (Expo or bare React Native), creates or links the Liftbay project, and writes `liftbay.json`. It also writes `.github/workflows/liftbay.yml` and sets up OTA updates with expo-updates and `@liftbay/expo-plugin` in `app.json`.
51
51
 
52
+ **Coming from EAS:** when the app has an `eas.json`, `init` uses it. Each EAS build profile becomes a Liftbay build profile with the same name, channel and distribution, and its `env` (with `extends` resolved) goes into an environment of the same name. Pass `--no-eas` to use Liftbay's default profiles instead.
53
+
52
54
  Useful flags:
53
- - `--org <slug>`: required if the user is in several organizations; the error lists them.
55
+ - `--org <slug>`: required if the user is in several organizations; the error lists them. For a new account with no organization yet, `init` creates one, named after `--org` or the person's name.
54
56
  - `--project <slug>`
55
57
  - `--platforms ios|android|all`
56
58
  - `--runtime-policy appVersion|fingerprint`
@@ -72,7 +74,37 @@ Then:
72
74
 
73
75
  Each profile has an `environment` (its secrets and variables) and an OTA `channel` with the same name.
74
76
 
75
- ## 3. Signing
77
+ ## 3. Environment variables
78
+
79
+ Builds get the variables of their profile's environment, as real environment variables and in `.env` for Expo. Set them before the first build, or the app will build without its API URLs and keys.
80
+
81
+ ```bash
82
+ liftbay env list --json # environments and how many variables each has
83
+ liftbay env list -e production --json # one environment's variables
84
+ liftbay env import .env.production .env.preview --json # each file into the environment it's named for
85
+ liftbay env import .env.staging -e preview --json # any file into a named environment
86
+ liftbay env import eas.json --json # every EAS profile's env (overwrites)
87
+ liftbay env set API_REGION=us -e production --json
88
+ liftbay env unset OLD_KEY -e production --json
89
+ liftbay env export -e production --out .env.production.liftbay --json
90
+ ```
91
+
92
+ When the user gives you `.env` files or paths, import them. Find them with `ls -a`: `.env`, `.env.local`, `.env.development`, `.env.production` and so on.
93
+ - Map a file to an environment by its name. Ask when it's unclear: `.env` and `.env.local` are often local development settings, not production.
94
+ - Don't print values or read secrets aloud. Report key names and counts only.
95
+
96
+ Visibility is chosen per key:
97
+ - `EXPO_PUBLIC_*` → plain, because it's inlined into the app bundle.
98
+ - Names like `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_KEY` → secret (write-only).
99
+ - Everything else → sensitive (encrypted, revealable).
100
+
101
+ Use `--visibility` to override this for a whole import.
102
+
103
+ If the app used EAS environment variables stored on expo.dev (`eas env:*`), they aren't in `eas.json`. Pull them first with `npx eas env:pull --environment production --path .env.eas.production`, then `liftbay env import .env.eas.production -e production`.
104
+
105
+ A common build failure after moving from EAS: with `SENTRY_ORG`/`SENTRY_PROJECT` set, the Sentry plugin uploads source maps and needs `SENTRY_AUTH_TOKEN`. The user adds it as a secret (`liftbay env import` from a file, or the dashboard), or removes the two Sentry variables.
106
+
107
+ ## 4. Signing
76
108
 
77
109
  **Android:** nothing to do. Liftbay creates the upload key on the first Android build and stores it encrypted.
78
110
  - To bring an existing key, the user runs `liftbay credentials android import --keystore <file> --alias <alias>`, with the password in `LIFTBAY_KEYSTORE_PASSWORD`.
@@ -94,7 +126,7 @@ Check what's stored:
94
126
  liftbay credentials --json
95
127
  ```
96
128
 
97
- ## 4. Build
129
+ ## 5. Build
98
130
 
99
131
  ```bash
100
132
  liftbay build --platform android --profile preview --no-wait --json
@@ -121,7 +153,7 @@ Other flags:
121
153
 
122
154
  **Xcode Cloud (optional):** `liftbay xcode-cloud connect --json`. Its error says which one-time step the user has to do in App Store Connect.
123
155
 
124
- ## 5. Submit to the stores
156
+ ## 6. Submit to the stores
125
157
 
126
158
  Submit as part of the build:
127
159
 
@@ -142,7 +174,7 @@ liftbay submissions view <id> --json
142
174
  - Play tracks are `internal`, `alpha`, `beta` and `production`. Use `--rollout <percent>` for a staged rollout.
143
175
  - The first upload of a brand-new Play app must be done by hand in Play Console. Until then, uploads are saved as drafts.
144
176
 
145
- ## 6. Ad hoc iOS installs on test devices
177
+ ## 7. Ad hoc iOS installs on test devices
146
178
 
147
179
  1. Register the device:
148
180
  ```bash
@@ -160,7 +192,7 @@ liftbay submissions view <id> --json
160
192
  ```
161
193
  4. When the build has succeeded, `liftbay builds view <n> --json` returns `installUrl`. The user opens it on the registered device.
162
194
 
163
- ## 7. OTA updates (JS-only changes, no store review)
195
+ ## 8. OTA updates (JS-only changes, no store review)
164
196
 
165
197
  The app must have been built once after `liftbay ota init`, so the update URL is inside the binary. Then publish:
166
198
 
@@ -199,7 +231,9 @@ An update only reaches binaries with the same runtime version. When native code
199
231
  | Build fails at once: no GitHub repository | Push the app to GitHub, then `liftbay ci init --yes --json` |
200
232
  | Build fails: workflow not found / 404 | Commit and push `.github/workflows/liftbay.yml` on the branch you build |
201
233
  | `github_not_connected` | User installs the Liftbay GitHub App at `liftbay.dev/<org>/github` |
202
- | iOS build is a simulator build | No iOS signing yet: section 3 |
234
+ | iOS build is a simulator build | No iOS signing yet: section 4 |
235
+ | App builds but can't reach its backend | Missing env variables: `liftbay env list -e <env>`, then section 3 |
236
+ | `Pass --environment` | The file name doesn't match an environment; pass `-e <slug>` |
203
237
  | `Apple ID sign-in needs a person` / two-factor code | Ask the user to run `npx liftbay credentials ios` in their terminal |
204
238
  | `certificate_limit` | Apple allows a few distribution certificates per team. The user runs `liftbay credentials ios` interactively to pick one to revoke |
205
239
  | `no_store_build` | Build with `--profile production` first |