liftbay 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -26526,6 +26526,26 @@ function emit(result) {
26526
26526
  var cliName = () => process.env.LIFTBAY_CLI_NAME ?? (/[\\/]_npx[\\/]/.test(process.argv[1] ?? "") || process.env.npm_command === "exec" ? "npx liftbay" : "liftbay");
26527
26527
  var cmd = (command) => command.replace(/^liftbay\b/, cliName());
26528
26528
  var withCliName = (text2) => cliName() === "liftbay" ? text2 : text2.replace(/`liftbay /g, `\`${cliName()} `);
26529
+ function isTransient(e) {
26530
+ if (e instanceof LiftbayApiError) return e.status >= 500 || e.status === 429;
26531
+ const err = e;
26532
+ const code = err?.cause?.code ?? err?.code ?? "";
26533
+ return err?.name === "TypeError" && err.message === "fetch failed" || /^(ECONN|EADDR|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EPIPE|ENETUNREACH|EHOSTUNREACH|UND_ERR)/.test(code);
26534
+ }
26535
+ async function poll(fn, onRetry) {
26536
+ const until = Date.now() + 5 * 6e4;
26537
+ for (let attempt = 1; ; attempt++) {
26538
+ try {
26539
+ return await fn();
26540
+ } catch (e) {
26541
+ if (!isTransient(e) || Date.now() > until) throw e;
26542
+ if (attempt === 1) process.stderr.write(`${import_picocolors.default.yellow("!")} Connection to Liftbay lost; retrying\u2026
26543
+ `);
26544
+ onRetry?.(attempt);
26545
+ await new Promise((r2) => setTimeout(r2, Math.min(15e3, 1e3 * 2 ** Math.min(attempt, 4))));
26546
+ }
26547
+ }
26548
+ }
26529
26549
  function reportError(e) {
26530
26550
  if (json) {
26531
26551
  const error = e instanceof LiftbayApiError ? { code: e.code, message: e.message, status: e.status, fields: e.fields } : e instanceof CliError ? { code: "cli_error", message: e.message } : { code: "internal", message: e instanceof Error ? e.message : String(e) };
@@ -26541,15 +26561,19 @@ function reportError(e) {
26541
26561
  } else console.error(e);
26542
26562
  process.exit(1);
26543
26563
  }
26544
- var isInteractive = () => !json && !!process.stdin.isTTY && !!process.stdout.isTTY && !process.env.CI;
26564
+ var promptsOff = false;
26565
+ function disablePrompts() {
26566
+ promptsOff = true;
26567
+ }
26568
+ var isInteractive = () => !json && !promptsOff && !!process.stdin.isTTY && !!process.stdout.isTTY && !process.env.CI;
26545
26569
  function needInteractive(instead) {
26546
26570
  if (!isInteractive()) fail(instead);
26547
26571
  }
26548
26572
 
26549
26573
  // src/commands/auth.ts
26550
- import { spawn } from "child_process";
26551
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
26552
- import { join as join3 } from "path";
26574
+ import { spawn as spawn2 } from "child_process";
26575
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
26576
+ import { join as join4 } from "path";
26553
26577
 
26554
26578
  // node_modules/@clack/core/dist/index.mjs
26555
26579
  import { styleText } from "util";
@@ -27886,29 +27910,279 @@ ${i2}
27886
27910
  }).prompt();
27887
27911
 
27888
27912
  // src/commands/auth.ts
27913
+ var import_picocolors3 = __toESM(require_picocolors(), 1);
27914
+
27915
+ // src/commands/link.ts
27889
27916
  var import_picocolors2 = __toESM(require_picocolors(), 1);
27917
+
27918
+ // src/project.ts
27919
+ import { execFile, spawn } from "child_process";
27920
+ import { promisify } from "util";
27921
+ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
27922
+ import { join as join3 } from "path";
27923
+ var execFileAsync = promisify(execFile);
27924
+ var CONFIG_FILE = "liftbay.json";
27925
+ function readConfig(cwd = process.cwd()) {
27926
+ const path2 = join3(cwd, CONFIG_FILE);
27927
+ return existsSync3(path2) ? JSON.parse(readFileSync2(path2, "utf8")) : null;
27928
+ }
27929
+ function requireConfig(cwd = process.cwd()) {
27930
+ return readConfig(cwd) ?? fail(`No ${CONFIG_FILE} here. Run \`liftbay init\` in your app's folder.`);
27931
+ }
27932
+ function writeConfig(config, cwd = process.cwd()) {
27933
+ writeFileSync2(join3(cwd, CONFIG_FILE), `${JSON.stringify({ $schema: "https://liftbay.dev/schema/liftbay.json", ...config }, null, 2)}
27934
+ `);
27935
+ }
27936
+ function readJson(path2) {
27937
+ return existsSync3(path2) ? JSON.parse(readFileSync2(path2, "utf8")) : null;
27938
+ }
27939
+ function packageManager(cwd = process.cwd()) {
27940
+ if (existsSync3(join3(cwd, "bun.lock")) || existsSync3(join3(cwd, "bun.lockb"))) return "bun";
27941
+ if (existsSync3(join3(cwd, "pnpm-lock.yaml"))) return "pnpm";
27942
+ if (existsSync3(join3(cwd, "yarn.lock"))) return "yarn";
27943
+ return "npm";
27944
+ }
27945
+ async function output(cmd2, args, cwd = process.cwd()) {
27946
+ const { stdout: stdout2 } = await execFileAsync(cmd2, args, { cwd, encoding: "utf8", maxBuffer: 50 * 1024 * 1024 });
27947
+ return stdout2;
27948
+ }
27949
+ async function expoConfig(cwd = process.cwd()) {
27950
+ return JSON.parse(await output("npx", ["expo", "config", "--json", "--type", "public"], cwd));
27951
+ }
27952
+ async function detectApp(cwd = process.cwd()) {
27953
+ const pkg = readJson(join3(cwd, "package.json"));
27954
+ if (!pkg) fail("No package.json here. Run this in your React Native app's folder.");
27955
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
27956
+ if (!deps["react-native"]) fail("This doesn't look like a React Native app (react-native is not a dependency).");
27957
+ const hasExpo = !!deps.expo;
27958
+ let name = pkg.name ?? "app";
27959
+ let slug = name;
27960
+ let version = pkg.version ?? null;
27961
+ let bundleId = null;
27962
+ let androidPackage = null;
27963
+ if (hasExpo) {
27964
+ const cfg = await expoConfig(cwd);
27965
+ name = cfg.name ?? name;
27966
+ slug = cfg.slug ?? slug;
27967
+ version = cfg.version ?? version;
27968
+ bundleId = cfg.ios?.bundleIdentifier ?? null;
27969
+ androidPackage = cfg.android?.package ?? null;
27970
+ } else {
27971
+ const gradle = join3(cwd, "android/app/build.gradle");
27972
+ if (existsSync3(gradle)) androidPackage = readFileSync2(gradle, "utf8").match(/applicationId\s+["']([^"']+)["']/)?.[1] ?? null;
27973
+ }
27974
+ return {
27975
+ framework: hasExpo ? "expo" : "react-native",
27976
+ name,
27977
+ slug: String(slug).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "app",
27978
+ version,
27979
+ bundleId,
27980
+ androidPackage,
27981
+ hasExpoModules: hasExpo,
27982
+ hasExpoUpdates: !!deps["expo-updates"],
27983
+ packageManager: packageManager(cwd),
27984
+ appJson: existsSync3(join3(cwd, "app.json"))
27985
+ };
27986
+ }
27987
+ async function run(cmd2, args, cwd = process.cwd(), env2 = {}) {
27988
+ const code = await new Promise((resolve4, reject) => {
27989
+ const child = spawn(cmd2, args, { cwd, stdio: "inherit", env: { ...process.env, ...env2 } });
27990
+ child.on("error", reject);
27991
+ child.on("close", resolve4);
27992
+ });
27993
+ if (code !== 0) fail(`\`${cmd2} ${args.join(" ")}\` failed (exit ${code})`);
27994
+ }
27995
+ async function runQuiet(label, cmd2, args, cwd = process.cwd()) {
27996
+ const spin = spinner();
27997
+ spin.start(label);
27998
+ const lines = [];
27999
+ const code = await new Promise((resolve4, reject) => {
28000
+ const child = spawn(cmd2, args, {
28001
+ cwd,
28002
+ stdio: ["ignore", "pipe", "pipe"],
28003
+ env: { ...process.env, npm_config_audit: "false", npm_config_fund: "false", npm_config_update_notifier: "false" }
28004
+ });
28005
+ const keep = (chunk) => lines.push(...chunk.toString().split(/\r?\n/).filter(Boolean));
28006
+ child.stdout.on("data", keep);
28007
+ child.stderr.on("data", keep);
28008
+ child.on("error", reject);
28009
+ child.on("close", resolve4);
28010
+ });
28011
+ if (code === 0) return spin.stop(label.replace(/^Installing/, "Installed").replace(/^Setting up/, "Set up"));
28012
+ spin.error(`${label} failed`);
28013
+ console.error(lines.slice(-40).join("\n"));
28014
+ fail(`\`${cmd2} ${args.join(" ")}\` failed (exit ${code})`);
28015
+ }
28016
+ function readAppJson(cwd = process.cwd()) {
28017
+ return readJson(join3(cwd, "app.json"));
28018
+ }
28019
+ function writeAppJson(data, cwd = process.cwd()) {
28020
+ writeFileSync2(join3(cwd, "app.json"), `${JSON.stringify(data, null, 2)}
28021
+ `);
28022
+ }
28023
+
28024
+ // src/commands/link.ts
28025
+ function cancelled(value) {
28026
+ if (isCancel(value)) {
28027
+ cancel("Cancelled");
28028
+ process.exit(0);
28029
+ }
28030
+ return value;
28031
+ }
28032
+ var slugifyProject = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
28033
+ var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
28034
+ async function createFirstOrg(lb, given) {
28035
+ const me = await unwrap(lb.GET("/v1/auth/me"));
28036
+ const suggested = given ?? (me.user.name?.trim() || me.user.email.split("@")[0]);
28037
+ 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;
28038
+ const base = slugify(name).padEnd(2, "0");
28039
+ for (const slug of [base, `${base.slice(0, 35)}-${Math.random().toString(36).slice(2, 6)}`]) {
28040
+ const res = await lb.POST("/v1/orgs", { body: { name: name.slice(0, 64), slug } });
28041
+ if (res.data) {
28042
+ log.success(`Created organization ${import_picocolors2.default.bold(res.data.name)} ${import_picocolors2.default.dim(`(liftbay.dev/${res.data.slug})`)}`);
28043
+ return res.data;
28044
+ }
28045
+ if (res.response.status !== 409) await unwrap(Promise.resolve(res));
28046
+ }
28047
+ return fail("Couldn't pick a free organization URL. Pass --org <slug>.");
28048
+ }
28049
+ async function ensureLinked(app, opts) {
28050
+ const existing = readConfig();
28051
+ if (existing) {
28052
+ log.success(`Linked to ${import_picocolors2.default.bold(`${existing.org}/${existing.project}`)}`);
28053
+ return existing;
28054
+ }
28055
+ const lb = client();
28056
+ const orgs = await unwrap(lb.GET("/v1/orgs"));
28057
+ let org;
28058
+ if (opts.newOrg || !orgs.length) {
28059
+ const created = await createFirstOrg(lb, opts.newOrg ?? opts.org);
28060
+ orgs.push(created);
28061
+ org = created.slug;
28062
+ }
28063
+ org ??= opts.org ?? (orgs.length === 1 ? orgs[0].slug : void 0);
28064
+ if (opts.org && !orgs.some((o) => o.slug === org)) fail(`You're not in an organization "${opts.org}" (${orgs.map((o) => o.slug).join(", ")}). To create one, pass --new-org "<name>".`);
28065
+ if (!org) {
28066
+ if (!isInteractive()) fail(`Several organizations available; pass --org (${orgs.map((o) => o.slug).join(", ")})`);
28067
+ org = cancelled(await select({ message: "Organization", options: orgs.map((o) => ({ value: o.slug, label: o.name, hint: o.slug })) }));
28068
+ }
28069
+ const projects = await unwrap(lb.GET("/v1/orgs/{org}/projects", { params: { path: { org } } }));
28070
+ let projectName = opts.projectName;
28071
+ let slug = opts.project ?? (projectName ? slugifyProject(projectName) : void 0);
28072
+ if (!slug) {
28073
+ const match = projects.find((pr) => pr.slug === app.slug);
28074
+ if (opts.yes || !isInteractive()) slug = match?.slug ?? app.slug;
28075
+ else {
28076
+ const taken = projects.some((pr) => pr.slug === app.slug);
28077
+ const choice = cancelled(
28078
+ await select({
28079
+ message: "Liftbay project",
28080
+ options: [
28081
+ ...taken ? [] : [{ value: "__new__", label: `Create "${app.name}"`, hint: `new project \xB7 liftbay.dev/${org}/${app.slug}` }],
28082
+ { value: "__named__", label: "Create a project with another name" },
28083
+ ...projects.map((pr) => ({ value: pr.slug, label: pr.name, hint: `existing \xB7 ${pr.slug}` }))
28084
+ ],
28085
+ initialValue: match?.slug ?? (taken ? "__named__" : "__new__")
28086
+ })
28087
+ );
28088
+ if (choice === "__named__") {
28089
+ projectName = cancelled(
28090
+ await text({
28091
+ message: "Project name",
28092
+ initialValue: app.name,
28093
+ validate: (v) => {
28094
+ const s = slugifyProject(v ?? "");
28095
+ if (s.length < 2) return "At least 2 letters or digits";
28096
+ if (projects.some((pr) => pr.slug === s)) return `${s} already exists: pick it from the list instead`;
28097
+ }
28098
+ })
28099
+ );
28100
+ slug = slugifyProject(projectName);
28101
+ } else slug = choice === "__new__" ? app.slug : choice;
28102
+ }
28103
+ }
28104
+ let project2 = projects.find((pr) => pr.slug === slug);
28105
+ if (!project2) {
28106
+ project2 = await unwrap(
28107
+ lb.POST("/v1/orgs/{org}/projects", {
28108
+ params: { path: { org } },
28109
+ body: { name: projectName ?? app.name, slug, framework: app.framework, bundleId: app.bundleId, androidPackage: app.androidPackage }
28110
+ })
28111
+ );
28112
+ log.success(`Created project ${import_picocolors2.default.bold(`${org}/${project2.slug}`)} with development, preview and production environments`);
28113
+ } else {
28114
+ log.success(`Linked to existing project ${import_picocolors2.default.bold(`${org}/${project2.slug}`)}`);
28115
+ }
28116
+ const config = { org, project: project2.slug, projectId: project2.id };
28117
+ writeConfig(config);
28118
+ log.info(`Wrote ${import_picocolors2.default.cyan(cmd("liftbay.json"))} ${import_picocolors2.default.dim("(commit it: it links this repository to the project)")}`);
28119
+ return config;
28120
+ }
28121
+
28122
+ // src/commands/auth.ts
27890
28123
  var sleep = (ms) => new Promise((r2) => setTimeout(r2, ms));
27891
28124
  function openBrowser(url) {
27892
28125
  const cmd2 = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
27893
28126
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
27894
28127
  try {
27895
- spawn(cmd2, args, { stdio: "ignore", detached: true }).unref();
28128
+ spawn2(cmd2, args, { stdio: "ignore", detached: true }).unref();
27896
28129
  } catch {
27897
28130
  }
27898
28131
  }
27899
- var PENDING = join3(CONFIG_DIR, "pending-login.json");
28132
+ var PENDING = join4(CONFIG_DIR, "pending-login.json");
27900
28133
  function readPending(api) {
27901
28134
  try {
27902
- const pending = JSON.parse(readFileSync2(PENDING, "utf8"));
28135
+ const pending = JSON.parse(readFileSync3(PENDING, "utf8"));
27903
28136
  return pending.apiUrl === api && Date.parse(pending.expiresAt) > Date.now() + 5e3 ? pending : null;
27904
28137
  } catch {
27905
28138
  return null;
27906
28139
  }
27907
28140
  }
27908
28141
  var clearPending = () => rmSync2(PENDING, { force: true });
28142
+ async function loginWithEmail(opts, api, dashboard) {
28143
+ const anon = createLiftbayClient({ baseUrl: api });
28144
+ const email = opts.email.trim();
28145
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) fail(`"${email}" isn't an email address`);
28146
+ let code = opts.code?.replace(/\s/g, "");
28147
+ if (!code) {
28148
+ await unwrap(anon.POST("/v1/auth/email/start", { body: { email } }));
28149
+ if (!isInteractive()) {
28150
+ log.info(`Sent a 6-digit sign-in code to ${import_picocolors3.default.bold(email)}.`);
28151
+ return {
28152
+ status: "code_sent",
28153
+ email,
28154
+ next: `Ask the person for the 6-digit code from the email, then run: liftbay login --email ${email} --code <code>`
28155
+ };
28156
+ }
28157
+ log.info(`Sent a 6-digit code to ${import_picocolors3.default.bold(email)}. ${import_picocolors3.default.dim("New to Liftbay? Signing in creates your account.")}`);
28158
+ code = cancelled(await text({ message: "Code from the email", validate: (v) => /^\d{6}$/.test((v ?? "").replace(/\s/g, "")) ? void 0 : "Enter the 6-digit code" })).replace(/\s/g, "");
28159
+ }
28160
+ const res = await unwrap(anon.POST("/v1/auth/email/token", { body: { email, code } }));
28161
+ clearPending();
28162
+ saveCredentials({ apiUrl: api, dashboardUrl: dashboard, token: res.token, email: res.user.email });
28163
+ log.success(`Signed in as ${import_picocolors3.default.bold(res.user.email)}`);
28164
+ return { status: "signed_in", user: res.user };
28165
+ }
27909
28166
  async function login(opts) {
27910
28167
  const api = opts.apiUrl ?? apiUrl();
27911
28168
  const dashboard = opts.dashboardUrl ?? dashboardUrl();
28169
+ if (opts.code && !opts.email) fail("Pass --email with --code");
28170
+ if (opts.email) return loginWithEmail(opts, api, dashboard);
28171
+ if (isInteractive() && !readPending(api)) {
28172
+ const how = cancelled(
28173
+ await select({
28174
+ message: "How do you want to sign in?",
28175
+ options: [
28176
+ { value: "browser", label: "In the browser", hint: "Google, GitHub or email" },
28177
+ { value: "email", label: "With a code sent to my email", hint: "all in this terminal" }
28178
+ ]
28179
+ })
28180
+ );
28181
+ if (how === "email") {
28182
+ const email = cancelled(await text({ message: "Email", validate: (v) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test((v ?? "").trim()) ? void 0 : "Enter your email address" }));
28183
+ return loginWithEmail({ ...opts, email }, api, dashboard);
28184
+ }
28185
+ }
27912
28186
  const anon = createLiftbayClient({ baseUrl: api });
27913
28187
  let pending = readPending(api);
27914
28188
  if (!pending) {
@@ -27923,15 +28197,15 @@ async function login(opts) {
27923
28197
  interval: start.interval
27924
28198
  };
27925
28199
  mkdirSync2(CONFIG_DIR, { recursive: true, mode: 448 });
27926
- writeFileSync2(PENDING, JSON.stringify(pending), { mode: 384 });
28200
+ writeFileSync3(PENDING, JSON.stringify(pending), { mode: 384 });
27927
28201
  if (opts.open !== false) openBrowser(pending.url);
27928
28202
  }
27929
- note(`${import_picocolors2.default.bold(pending.userCode)}
28203
+ note(`${import_picocolors3.default.bold(pending.userCode)}
27930
28204
 
27931
- ${import_picocolors2.default.dim("Confirm this code in your browser:")}
28205
+ ${import_picocolors3.default.dim("Confirm this code in your browser:")}
27932
28206
  ${pending.url}`, "Sign in");
27933
28207
  if (opts.wait === false) {
27934
- log.info(`Approve it in the browser, then run ${import_picocolors2.default.cyan(cmd("liftbay login"))} again to finish.`);
28208
+ log.info(`Approve it in the browser, then run ${import_picocolors3.default.cyan(cmd("liftbay login"))} again to finish.`);
27935
28209
  return { status: "pending", userCode: pending.userCode, url: pending.url, expiresAt: pending.expiresAt, next: "Approve in the browser, then run `liftbay login` to finish signing in" };
27936
28210
  }
27937
28211
  const spin = spinner();
@@ -27941,10 +28215,10 @@ ${pending.url}`, "Sign in");
27941
28215
  const res = await unwrap(anon.POST("/v1/auth/device/token", { body: { deviceCode: pending.deviceCode } }));
27942
28216
  clearPending();
27943
28217
  saveCredentials({ apiUrl: api, dashboardUrl: pending.dashboardUrl, token: res.token, email: res.user.email });
27944
- spin.stop(`Signed in as ${import_picocolors2.default.bold(res.user.email)}`);
28218
+ spin.stop(`Signed in as ${import_picocolors3.default.bold(res.user.email)}`);
27945
28219
  return { status: "signed_in", user: res.user };
27946
28220
  } catch (e) {
27947
- if (e instanceof LiftbayApiError && e.code === "authorization_pending") {
28221
+ if (e instanceof LiftbayApiError && e.code === "authorization_pending" || isTransient(e)) {
27948
28222
  await sleep(pending.interval * 1e3);
27949
28223
  continue;
27950
28224
  }
@@ -27975,8 +28249,8 @@ async function whoami() {
27975
28249
  return { kind: "org_token", orgs: orgs.map((o) => ({ slug: o.slug, name: o.name })) };
27976
28250
  }
27977
28251
  const me = await unwrap(client().GET("/v1/auth/me"));
27978
- console.log(`${import_picocolors2.default.bold(me.user.email)}${me.user.name ? ` (${me.user.name})` : ""}`);
27979
- for (const m of me.memberships) console.log(` ${m.orgSlug.padEnd(24)} ${import_picocolors2.default.dim(m.role)}`);
28252
+ console.log(`${import_picocolors3.default.bold(me.user.email)}${me.user.name ? ` (${me.user.name})` : ""}`);
28253
+ for (const m of me.memberships) console.log(` ${m.orgSlug.padEnd(24)} ${import_picocolors3.default.dim(m.role)}`);
27980
28254
  return { kind: "user", user: me.user, memberships: me.memberships };
27981
28255
  }
27982
28256
  async function ensureSignedIn(opts) {
@@ -27984,13 +28258,15 @@ async function ensureSignedIn(opts) {
27984
28258
  try {
27985
28259
  if (process.env.LIFTBAY_TOKEN) return;
27986
28260
  const me = await unwrap(client().GET("/v1/auth/me"));
27987
- log.success(`Signed in as ${import_picocolors2.default.bold(me.user.email)}`);
28261
+ log.success(`Signed in as ${import_picocolors3.default.bold(me.user.email)}`);
27988
28262
  return;
27989
28263
  } catch (e) {
27990
28264
  if (!(e instanceof LiftbayApiError && e.status === 401)) throw e;
27991
28265
  }
27992
28266
  }
27993
- if (!isInteractive()) fail("Not signed in. Run `liftbay login --no-wait`, approve in the browser, run `liftbay login`, then run this again (or set LIFTBAY_TOKEN).");
28267
+ if (!isInteractive()) {
28268
+ fail("Not signed in. Sign in with an email code: `liftbay login --email <address>`, then `liftbay login --email <address> --code <code>` (or approve in the browser: `liftbay login --no-wait`, then `liftbay login`; or set LIFTBAY_TOKEN). Then run this again.");
28269
+ }
27994
28270
  await login(opts);
27995
28271
  }
27996
28272
 
@@ -27998,33 +28274,217 @@ async function ensureSignedIn(opts) {
27998
28274
  var import_picocolors7 = __toESM(require_picocolors(), 1);
27999
28275
  var import_qrcode = __toESM(require_lib(), 1);
28000
28276
  import { execFileSync } from "child_process";
28277
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "fs";
28278
+ import { dirname as dirname3 } from "path";
28001
28279
 
28002
- // src/commands/credentials.ts
28003
- import { chmodSync as chmodSync3, existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "fs";
28004
- import { basename, dirname as dirname2, extname, join as join6, resolve } from "path";
28005
- var import_picocolors5 = __toESM(require_picocolors(), 1);
28280
+ // src/generated/workflow.ts
28281
+ var WORKFLOW_YAML = `# Liftbay builds on your own GitHub Actions minutes.
28282
+ # Liftbay starts this workflow; it does nothing on your own pushes or pull requests. With this file on the
28283
+ # default branch, Liftbay uses workflow_dispatch. Otherwise (a branch you're trying out), Liftbay points a
28284
+ # temporary liftbay/build/<platform>/<build id> branch at your commit, which runs this file from that commit,
28285
+ # and deletes it once the build has started.
28286
+ # The build gets no repository secrets: the runner exchanges this run's GitHub OIDC token for a
28287
+ # one-time Liftbay job token, checked against this repository and this workflow file.
28288
+ # Docs: https://github.com/liftbay/runner
28289
+ name: Liftbay build
28290
+ run-name: Liftbay build \${{ inputs.build_id || github.ref_name }}
28006
28291
 
28007
- // src/apple/session.ts
28008
- import { createHash as createHash3 } from "crypto";
28009
- import { chmodSync as chmodSync2, existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
28010
- import { homedir as homedir2 } from "os";
28011
- import { join as join4 } from "path";
28292
+ on:
28293
+ workflow_dispatch:
28294
+ inputs:
28295
+ build_id:
28296
+ description: Liftbay build id
28297
+ required: true
28298
+ type: string
28299
+ platform:
28300
+ description: Platform
28301
+ required: true
28302
+ type: choice
28303
+ options: [android, ios]
28304
+ api_url:
28305
+ description: Liftbay API
28306
+ required: false
28307
+ type: string
28308
+ default: https://api.liftbay.dev
28309
+ push:
28310
+ branches: ["liftbay/build/**"]
28012
28311
 
28013
- // src/apple/cookies.ts
28014
- var CookieJar = class {
28015
- cookies = [];
28016
- constructor(saved = []) {
28017
- this.cookies = saved.filter((c2) => !isExpired(c2));
28018
- }
28019
- /** Stores every Set-Cookie of a response from `url`. */
28020
- store(url, setCookies, now = Date.now()) {
28021
- const { hostname, pathname } = new URL(url);
28022
- for (const header of setCookies) {
28023
- const c2 = parse(header, hostname, pathname, now);
28024
- if (!c2 || !domainMatches(hostname, c2)) continue;
28025
- this.cookies = this.cookies.filter((o) => !(o.name === c2.name && o.domain === c2.domain && o.path === c2.path));
28026
- if (!isExpired(c2, now)) this.cookies.push(c2);
28027
- }
28312
+ permissions:
28313
+ contents: read
28314
+ id-token: write
28315
+
28316
+ env:
28317
+ # Pinned runner release. The archive's SHA-256 is checked before the runner starts.
28318
+ # Release notes and provenance: https://github.com/liftbay/runner/releases/tag/v0.1.2
28319
+ RUNNER_VERSION: "0.1.2"
28320
+ RUNNER_SHA256_LINUX_AMD64: "9b37bfe534b6dc40f81182bdb9dc46461a672f2c28f048743dc8f13f630909b1"
28321
+ RUNNER_SHA256_DARWIN_ARM64: "301bf09358a47b48df0cdbd6f60edbac1227e4d7f61e037959d4009ed6ae7f27"
28322
+
28323
+ jobs:
28324
+ android:
28325
+ if: inputs.platform == 'android' || startsWith(github.ref_name, 'liftbay/build/android/')
28326
+ runs-on: ubuntu-latest
28327
+ timeout-minutes: 60
28328
+ steps:
28329
+ # GitHub's Linux runners start with about 14 GB free; a React Native release build (NDKs, CMake, four
28330
+ # architectures, Gradle caches) can use more. Remove preinstalled toolchains no app build uses.
28331
+ - name: Free disk space
28332
+ run: |
28333
+ df -h / | tail -1
28334
+ sudo rm -rf /usr/share/dotnet /usr/local/.ghcup /opt/ghc /opt/hostedtoolcache/CodeQL /usr/local/share/boost /usr/share/swift /usr/local/lib/node_modules /usr/local/share/powershell /usr/local/share/chromium /opt/microsoft /opt/google/chrome || true
28335
+ sudo docker image prune --all --force >/dev/null 2>&1 || true
28336
+ df -h / | tail -1
28337
+
28338
+ - uses: actions/checkout@v4
28339
+
28340
+ - uses: actions/setup-java@v4
28341
+ with:
28342
+ distribution: temurin
28343
+ java-version: "17"
28344
+
28345
+ - uses: actions/setup-node@v4
28346
+ with:
28347
+ node-version: "22"
28348
+
28349
+ - name: Cache JavaScript dependencies
28350
+ uses: actions/cache@v4
28351
+ with:
28352
+ path: |
28353
+ node_modules
28354
+ ~/.npm
28355
+ ~/.cache/yarn
28356
+ ~/.local/share/pnpm/store
28357
+ ~/.bun/install/cache
28358
+ key: js-\${{ runner.os }}-\${{ hashFiles('package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb') }}
28359
+ restore-keys: js-\${{ runner.os }}-
28360
+
28361
+ - name: Cache Gradle
28362
+ uses: actions/cache@v4
28363
+ with:
28364
+ path: |
28365
+ ~/.gradle/caches
28366
+ ~/.gradle/wrapper
28367
+ key: gradle-\${{ runner.os }}-\${{ hashFiles('package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb', 'android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }}
28368
+ restore-keys: gradle-\${{ runner.os }}-
28369
+
28370
+ - name: Install the Liftbay runner
28371
+ env:
28372
+ # Bring your own runner: set the repository variables LIFTBAY_RUNNER_URL (a
28373
+ # liftbay-runner tar.gz you built) and LIFTBAY_RUNNER_SHA256.
28374
+ RUNNER_URL: \${{ vars.LIFTBAY_RUNNER_URL }}
28375
+ RUNNER_SHA256: \${{ vars.LIFTBAY_RUNNER_SHA256 || env.RUNNER_SHA256_LINUX_AMD64 }}
28376
+ run: |
28377
+ set -euo pipefail
28378
+ url="\${RUNNER_URL:-https://github.com/liftbay/runner/releases/download/v\${RUNNER_VERSION}/liftbay-runner_\${RUNNER_VERSION}_linux_amd64.tar.gz}"
28379
+ curl -fsSL --retry 3 -o "$RUNNER_TEMP/liftbay-runner.tar.gz" "$url"
28380
+ echo "\${RUNNER_SHA256} $RUNNER_TEMP/liftbay-runner.tar.gz" | sha256sum -c -
28381
+ tar -xzf "$RUNNER_TEMP/liftbay-runner.tar.gz" -C "$RUNNER_TEMP" liftbay-runner
28382
+ "$RUNNER_TEMP/liftbay-runner" version
28383
+
28384
+ - name: Build
28385
+ env:
28386
+ LIFTBAY_BUILD_ID: \${{ inputs.build_id }}
28387
+ LIFTBAY_API_URL: \${{ inputs.api_url || vars.LIFTBAY_API_URL || 'https://api.liftbay.dev' }}
28388
+ # On a liftbay/build/<platform>/<build id> branch, the build id is the branch name's last part.
28389
+ run: 'LIFTBAY_BUILD_ID="\${LIFTBAY_BUILD_ID:-\${GITHUB_REF_NAME##*/}}" "$RUNNER_TEMP/liftbay-runner" run'
28390
+
28391
+ ios:
28392
+ if: inputs.platform == 'ios' || startsWith(github.ref_name, 'liftbay/build/ios/')
28393
+ runs-on: macos-15
28394
+ timeout-minutes: 90
28395
+ steps:
28396
+ - uses: actions/checkout@v4
28397
+
28398
+ - uses: maxim-lobanov/setup-xcode@v1
28399
+ with:
28400
+ xcode-version: latest-stable
28401
+
28402
+ - uses: actions/setup-node@v4
28403
+ with:
28404
+ node-version: "22"
28405
+
28406
+ - name: Cache JavaScript dependencies
28407
+ uses: actions/cache@v4
28408
+ with:
28409
+ path: |
28410
+ node_modules
28411
+ ~/.npm
28412
+ ~/Library/Caches/Yarn
28413
+ ~/Library/pnpm/store
28414
+ ~/.bun/install/cache
28415
+ key: js-\${{ runner.os }}-\${{ hashFiles('package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb') }}
28416
+ restore-keys: js-\${{ runner.os }}-
28417
+
28418
+ # Only the download caches: a restored ios/Pods would make expo prebuild see a half project.
28419
+ - name: Cache CocoaPods
28420
+ uses: actions/cache@v4
28421
+ with:
28422
+ path: |
28423
+ ~/Library/Caches/CocoaPods
28424
+ ~/.cocoapods
28425
+ key: pods-\${{ runner.os }}-\${{ hashFiles('ios/Podfile.lock', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb') }}
28426
+ restore-keys: pods-\${{ runner.os }}-
28427
+
28428
+ - name: Install the Liftbay runner
28429
+ env:
28430
+ RUNNER_URL: \${{ vars.LIFTBAY_RUNNER_URL_MACOS }}
28431
+ RUNNER_SHA256: \${{ vars.LIFTBAY_RUNNER_SHA256_MACOS || env.RUNNER_SHA256_DARWIN_ARM64 }}
28432
+ run: |
28433
+ set -euo pipefail
28434
+ url="\${RUNNER_URL:-https://github.com/liftbay/runner/releases/download/v\${RUNNER_VERSION}/liftbay-runner_\${RUNNER_VERSION}_darwin_arm64.tar.gz}"
28435
+ curl -fsSL --retry 3 -o "$RUNNER_TEMP/liftbay-runner.tar.gz" "$url"
28436
+ echo "\${RUNNER_SHA256} $RUNNER_TEMP/liftbay-runner.tar.gz" | shasum -a 256 -c -
28437
+ tar -xzf "$RUNNER_TEMP/liftbay-runner.tar.gz" -C "$RUNNER_TEMP" liftbay-runner
28438
+ "$RUNNER_TEMP/liftbay-runner" version
28439
+
28440
+ - name: Build
28441
+ env:
28442
+ LIFTBAY_BUILD_ID: \${{ inputs.build_id }}
28443
+ LIFTBAY_API_URL: \${{ inputs.api_url || vars.LIFTBAY_API_URL || 'https://api.liftbay.dev' }}
28444
+ # On a liftbay/build/<platform>/<build id> branch, the build id is the branch name's last part.
28445
+ run: 'LIFTBAY_BUILD_ID="\${LIFTBAY_BUILD_ID:-\${GITHUB_REF_NAME##*/}}" "$RUNNER_TEMP/liftbay-runner" run'
28446
+ `;
28447
+
28448
+ // src/workflow.ts
28449
+ var WORKFLOW_PATH = ".github/workflows/liftbay.yml";
28450
+ async function currentWorkflow() {
28451
+ try {
28452
+ const res = await fetch(`${apiUrl()}/v1/github/workflow`, { signal: AbortSignal.timeout(5e3) });
28453
+ if (res.ok) {
28454
+ const body = await res.json();
28455
+ if (typeof body.content === "string" && body.content.includes("liftbay")) return body.content;
28456
+ }
28457
+ } catch {
28458
+ }
28459
+ return WORKFLOW_YAML;
28460
+ }
28461
+
28462
+ // src/commands/credentials.ts
28463
+ import { chmodSync as chmodSync3, existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "fs";
28464
+ import { basename, dirname as dirname2, extname, join as join6, resolve } from "path";
28465
+ var import_picocolors5 = __toESM(require_picocolors(), 1);
28466
+
28467
+ // src/apple/session.ts
28468
+ import { createHash as createHash3 } from "crypto";
28469
+ import { chmodSync as chmodSync2, existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
28470
+ import { homedir as homedir2 } from "os";
28471
+ import { join as join5 } from "path";
28472
+
28473
+ // src/apple/cookies.ts
28474
+ var CookieJar = class {
28475
+ cookies = [];
28476
+ constructor(saved = []) {
28477
+ this.cookies = saved.filter((c2) => !isExpired(c2));
28478
+ }
28479
+ /** Stores every Set-Cookie of a response from `url`. */
28480
+ store(url, setCookies, now = Date.now()) {
28481
+ const { hostname, pathname } = new URL(url);
28482
+ for (const header of setCookies) {
28483
+ const c2 = parse(header, hostname, pathname, now);
28484
+ if (!c2 || !domainMatches(hostname, c2)) continue;
28485
+ this.cookies = this.cookies.filter((o) => !(o.name === c2.name && o.domain === c2.domain && o.path === c2.path));
28486
+ if (!isExpired(c2, now)) this.cookies.push(c2);
28487
+ }
28028
28488
  }
28029
28489
  /** The Cookie header for a request to `url`, or "" when none apply. */
28030
28490
  header(url, now = Date.now()) {
@@ -28186,9 +28646,9 @@ var IDMSA = "https://idmsa.apple.com/appleauth";
28186
28646
  var ASC = "https://appstoreconnect.apple.com";
28187
28647
  var PORTALS = ["https://developer.apple.com", "https://developer-mdn.apple.com"];
28188
28648
  var MAX_CODE_ATTEMPTS = 3;
28189
- var sessionDir = (configDir) => configDir ?? join4(process.env.LIFTBAY_CONFIG_DIR ?? join4(homedir2(), ".liftbay"), "apple");
28649
+ var sessionDir = (configDir) => configDir ?? join5(process.env.LIFTBAY_CONFIG_DIR ?? join5(homedir2(), ".liftbay"), "apple");
28190
28650
  var accountName = (appleId) => appleId.trim().toLowerCase();
28191
- var sessionFile = (appleId, configDir) => join4(sessionDir(configDir), `${createHash3("sha256").update(accountName(appleId)).digest("hex")}.json`);
28651
+ var sessionFile = (appleId, configDir) => join5(sessionDir(configDir), `${createHash3("sha256").update(accountName(appleId)).digest("hex")}.json`);
28192
28652
  var b64 = (b) => Buffer.from(b).toString("base64");
28193
28653
  function serviceError(data) {
28194
28654
  if (!data || typeof data !== "object") return null;
@@ -28211,9 +28671,9 @@ var AppleSession = class _AppleSession {
28211
28671
  /** Signs in (SRP + hashcash + 2FA), reusing a saved session from ~/.liftbay/apple/<sha256(appleId)>.json (0600) when still valid. */
28212
28672
  static async signIn(appleId, prompts, opts = {}) {
28213
28673
  const file2 = sessionFile(appleId, opts.configDir);
28214
- if (existsSync3(file2)) {
28674
+ if (existsSync4(file2)) {
28215
28675
  try {
28216
- const saved = JSON.parse(readFileSync3(file2, "utf8"));
28676
+ const saved = JSON.parse(readFileSync4(file2, "utf8"));
28217
28677
  const restored = new _AppleSession(appleId, new CookieJar(saved.cookies), opts);
28218
28678
  if (await restored.fetchOlympus()) {
28219
28679
  opts.log?.(`Using the saved Apple session for ${appleId}`);
@@ -28242,7 +28702,7 @@ var AppleSession = class _AppleSession {
28242
28702
  const dir = sessionDir(this.opts.configDir);
28243
28703
  mkdirSync3(dir, { recursive: true, mode: 448 });
28244
28704
  const file2 = sessionFile(this.appleId, this.opts.configDir);
28245
- writeFileSync3(file2, JSON.stringify({ appleId: accountName(this.appleId), cookies: this.jar.toJSON() }), { mode: 384 });
28705
+ writeFileSync4(file2, JSON.stringify({ appleId: accountName(this.appleId), cookies: this.jar.toJSON() }), { mode: 384 });
28246
28706
  chmodSync2(file2, 384);
28247
28707
  }
28248
28708
  async login(password2, prompts) {
@@ -28474,120 +28934,14 @@ function apiKeyError(r2) {
28474
28934
  return new AppleAuthError("unexpected", `App Store Connect refused to create an API key (${r2.status})${text2.trim() ? `: ${text2.trim()}` : ""}`);
28475
28935
  }
28476
28936
 
28477
- // src/project.ts
28478
- import { execFile, spawn as spawn2 } from "child_process";
28479
- import { promisify } from "util";
28480
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
28481
- import { join as join5 } from "path";
28482
- var execFileAsync = promisify(execFile);
28483
- var CONFIG_FILE = "liftbay.json";
28484
- function readConfig(cwd = process.cwd()) {
28485
- const path2 = join5(cwd, CONFIG_FILE);
28486
- return existsSync4(path2) ? JSON.parse(readFileSync4(path2, "utf8")) : null;
28487
- }
28488
- function requireConfig(cwd = process.cwd()) {
28489
- return readConfig(cwd) ?? fail(`No ${CONFIG_FILE} here. Run \`liftbay init\` in your app's folder.`);
28490
- }
28491
- function writeConfig(config, cwd = process.cwd()) {
28492
- writeFileSync4(join5(cwd, CONFIG_FILE), `${JSON.stringify({ $schema: "https://liftbay.dev/schema/liftbay.json", ...config }, null, 2)}
28493
- `);
28494
- }
28495
- function readJson(path2) {
28496
- return existsSync4(path2) ? JSON.parse(readFileSync4(path2, "utf8")) : null;
28497
- }
28498
- function packageManager(cwd = process.cwd()) {
28499
- if (existsSync4(join5(cwd, "bun.lock")) || existsSync4(join5(cwd, "bun.lockb"))) return "bun";
28500
- if (existsSync4(join5(cwd, "pnpm-lock.yaml"))) return "pnpm";
28501
- if (existsSync4(join5(cwd, "yarn.lock"))) return "yarn";
28502
- return "npm";
28503
- }
28504
- async function output(cmd2, args, cwd = process.cwd()) {
28505
- const { stdout: stdout2 } = await execFileAsync(cmd2, args, { cwd, encoding: "utf8", maxBuffer: 50 * 1024 * 1024 });
28506
- return stdout2;
28507
- }
28508
- async function expoConfig(cwd = process.cwd()) {
28509
- return JSON.parse(await output("npx", ["expo", "config", "--json", "--type", "public"], cwd));
28510
- }
28511
- async function detectApp(cwd = process.cwd()) {
28512
- const pkg = readJson(join5(cwd, "package.json"));
28513
- if (!pkg) fail("No package.json here. Run this in your React Native app's folder.");
28514
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
28515
- if (!deps["react-native"]) fail("This doesn't look like a React Native app (react-native is not a dependency).");
28516
- const hasExpo = !!deps.expo;
28517
- let name = pkg.name ?? "app";
28518
- let slug = name;
28519
- let version = pkg.version ?? null;
28520
- let bundleId = null;
28521
- let androidPackage = null;
28522
- if (hasExpo) {
28523
- const cfg = await expoConfig(cwd);
28524
- name = cfg.name ?? name;
28525
- slug = cfg.slug ?? slug;
28526
- version = cfg.version ?? version;
28527
- bundleId = cfg.ios?.bundleIdentifier ?? null;
28528
- androidPackage = cfg.android?.package ?? null;
28529
- } else {
28530
- const gradle = join5(cwd, "android/app/build.gradle");
28531
- if (existsSync4(gradle)) androidPackage = readFileSync4(gradle, "utf8").match(/applicationId\s+["']([^"']+)["']/)?.[1] ?? null;
28532
- }
28533
- return {
28534
- framework: hasExpo ? "expo" : "react-native",
28535
- name,
28536
- slug: String(slug).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "app",
28537
- version,
28538
- bundleId,
28539
- androidPackage,
28540
- hasExpoModules: hasExpo,
28541
- hasExpoUpdates: !!deps["expo-updates"],
28542
- packageManager: packageManager(cwd),
28543
- appJson: existsSync4(join5(cwd, "app.json"))
28544
- };
28545
- }
28546
- async function run(cmd2, args, cwd = process.cwd(), env2 = {}) {
28547
- const code = await new Promise((resolve4, reject) => {
28548
- const child = spawn2(cmd2, args, { cwd, stdio: "inherit", env: { ...process.env, ...env2 } });
28549
- child.on("error", reject);
28550
- child.on("close", resolve4);
28551
- });
28552
- if (code !== 0) fail(`\`${cmd2} ${args.join(" ")}\` failed (exit ${code})`);
28553
- }
28554
- async function runQuiet(label, cmd2, args, cwd = process.cwd()) {
28555
- const spin = spinner();
28556
- spin.start(label);
28557
- const lines = [];
28558
- const code = await new Promise((resolve4, reject) => {
28559
- const child = spawn2(cmd2, args, {
28560
- cwd,
28561
- stdio: ["ignore", "pipe", "pipe"],
28562
- env: { ...process.env, npm_config_audit: "false", npm_config_fund: "false", npm_config_update_notifier: "false" }
28563
- });
28564
- const keep = (chunk) => lines.push(...chunk.toString().split(/\r?\n/).filter(Boolean));
28565
- child.stdout.on("data", keep);
28566
- child.stderr.on("data", keep);
28567
- child.on("error", reject);
28568
- child.on("close", resolve4);
28569
- });
28570
- if (code === 0) return spin.stop(label.replace(/^Installing/, "Installed").replace(/^Setting up/, "Set up"));
28571
- spin.error(`${label} failed`);
28572
- console.error(lines.slice(-40).join("\n"));
28573
- fail(`\`${cmd2} ${args.join(" ")}\` failed (exit ${code})`);
28574
- }
28575
- function readAppJson(cwd = process.cwd()) {
28576
- return readJson(join5(cwd, "app.json"));
28577
- }
28578
- function writeAppJson(data, cwd = process.cwd()) {
28579
- writeFileSync4(join5(cwd, "app.json"), `${JSON.stringify(data, null, 2)}
28580
- `);
28581
- }
28582
-
28583
28937
  // src/prompts.ts
28584
- var import_picocolors3 = __toESM(require_picocolors(), 1);
28938
+ var import_picocolors4 = __toESM(require_picocolors(), 1);
28585
28939
  function fit(text2, reserve = 8) {
28586
28940
  const width = Math.max(20, (process.stdout.columns || 80) - reserve);
28587
28941
  const plain = text2.replace(/\x1b\[[0-9;]*m/g, "");
28588
28942
  return plain.length <= width ? text2 : `${plain.slice(0, width - 1)}\u2026`;
28589
28943
  }
28590
- var symbol2 = (state) => state === "submit" ? import_picocolors3.default.green("\u25C7") : state === "cancel" ? import_picocolors3.default.red("\u25A0") : state === "error" ? import_picocolors3.default.yellow("\u25B2") : import_picocolors3.default.cyan("\u25C6");
28944
+ var symbol2 = (state) => state === "submit" ? import_picocolors4.default.green("\u25C7") : state === "cancel" ? import_picocolors4.default.red("\u25A0") : state === "error" ? import_picocolors4.default.yellow("\u25B2") : import_picocolors4.default.cyan("\u25C6");
28591
28945
  function checkboxes(opts) {
28592
28946
  const required = opts.required ?? true;
28593
28947
  return new a({
@@ -28599,121 +28953,28 @@ function checkboxes(opts) {
28599
28953
  },
28600
28954
  render() {
28601
28955
  const selected = this.value ?? [];
28602
- const bar = this.state === "error" ? import_picocolors3.default.yellow("\u2502") : this.state === "submit" || this.state === "cancel" ? import_picocolors3.default.gray("\u2502") : import_picocolors3.default.cyan("\u2502");
28603
- const title = `${import_picocolors3.default.gray("\u2502")}
28956
+ const bar = this.state === "error" ? import_picocolors4.default.yellow("\u2502") : this.state === "submit" || this.state === "cancel" ? import_picocolors4.default.gray("\u2502") : import_picocolors4.default.cyan("\u2502");
28957
+ const title = `${import_picocolors4.default.gray("\u2502")}
28604
28958
  ${symbol2(this.state)} ${opts.message}
28605
28959
  `;
28606
28960
  if (this.state === "submit" || this.state === "cancel") {
28607
28961
  const chosen = this.options.filter((o) => selected.includes(o.value)).map((o) => o.label);
28608
- const text2 = chosen.join(", ") || "none";
28609
- return `${title}${bar} ${this.state === "cancel" ? import_picocolors3.default.strikethrough(import_picocolors3.default.dim(text2)) : import_picocolors3.default.dim(text2)}`;
28610
- }
28611
- const lines = this.options.map((o, i2) => {
28612
- const on = selected.includes(o.value);
28613
- const active = i2 === this.cursor;
28614
- const box = on ? import_picocolors3.default.green("[\u2714]") : active ? import_picocolors3.default.cyan("[ ]") : import_picocolors3.default.dim("[ ]");
28615
- const label = active ? import_picocolors3.default.bold(o.label) : o.label;
28616
- return `${bar} ${box} ${label}${o.hint ? ` ${import_picocolors3.default.dim(`(${o.hint})`)}` : ""}`;
28617
- });
28618
- const footer = this.state === "error" ? `${import_picocolors3.default.yellow("\u2514")} ${import_picocolors3.default.yellow(this.error)}` : `${import_picocolors3.default.cyan("\u2514")} ${import_picocolors3.default.dim("\u2191/\u2193 move \xB7 space select \xB7 enter confirm")}`;
28619
- return `${title}${lines.join("\n")}
28620
- ${footer}
28621
- `;
28622
- }
28623
- }).prompt();
28624
- }
28625
-
28626
- // src/commands/link.ts
28627
- var import_picocolors4 = __toESM(require_picocolors(), 1);
28628
- function cancelled(value) {
28629
- if (isCancel(value)) {
28630
- cancel("Cancelled");
28631
- process.exit(0);
28632
- }
28633
- return value;
28634
- }
28635
- var slugifyProject = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
28636
- var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
28637
- async function createFirstOrg(lb, given) {
28638
- const me = await unwrap(lb.GET("/v1/auth/me"));
28639
- const suggested = given ?? (me.user.name?.trim() || me.user.email.split("@")[0]);
28640
- 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;
28641
- const base = slugify(name).padEnd(2, "0");
28642
- for (const slug of [base, `${base.slice(0, 35)}-${Math.random().toString(36).slice(2, 6)}`]) {
28643
- const res = await lb.POST("/v1/orgs", { body: { name: name.slice(0, 64), slug } });
28644
- if (res.data) {
28645
- log.success(`Created organization ${import_picocolors4.default.bold(res.data.name)} ${import_picocolors4.default.dim(`(liftbay.dev/${res.data.slug})`)}`);
28646
- return res.data;
28647
- }
28648
- if (res.response.status !== 409) await unwrap(Promise.resolve(res));
28649
- }
28650
- return fail("Couldn't pick a free organization URL. Pass --org <slug>.");
28651
- }
28652
- async function ensureLinked(app, opts) {
28653
- const existing = readConfig();
28654
- if (existing) {
28655
- log.success(`Linked to ${import_picocolors4.default.bold(`${existing.org}/${existing.project}`)}`);
28656
- return existing;
28657
- }
28658
- const lb = client();
28659
- const orgs = await unwrap(lb.GET("/v1/orgs"));
28660
- if (!orgs.length) orgs.push(await createFirstOrg(lb, opts.org));
28661
- let org = opts.org ?? (orgs.length === 1 ? orgs[0].slug : void 0);
28662
- if (!org) {
28663
- if (!isInteractive()) fail(`Several organizations available; pass --org (${orgs.map((o) => o.slug).join(", ")})`);
28664
- org = cancelled(await select({ message: "Organization", options: orgs.map((o) => ({ value: o.slug, label: o.name, hint: o.slug })) }));
28665
- }
28666
- const projects = await unwrap(lb.GET("/v1/orgs/{org}/projects", { params: { path: { org } } }));
28667
- let slug = opts.project;
28668
- let projectName;
28669
- if (!slug) {
28670
- const match = projects.find((pr) => pr.slug === app.slug);
28671
- if (opts.yes || !isInteractive()) slug = match?.slug ?? app.slug;
28672
- else {
28673
- const taken = projects.some((pr) => pr.slug === app.slug);
28674
- const choice = cancelled(
28675
- await select({
28676
- message: "Liftbay project",
28677
- options: [
28678
- ...taken ? [] : [{ value: "__new__", label: `Create "${app.name}"`, hint: `new project \xB7 liftbay.dev/${org}/${app.slug}` }],
28679
- { value: "__named__", label: "Create a project with another name" },
28680
- ...projects.map((pr) => ({ value: pr.slug, label: pr.name, hint: `existing \xB7 ${pr.slug}` }))
28681
- ],
28682
- initialValue: match?.slug ?? (taken ? "__named__" : "__new__")
28683
- })
28684
- );
28685
- if (choice === "__named__") {
28686
- projectName = cancelled(
28687
- await text({
28688
- message: "Project name",
28689
- initialValue: app.name,
28690
- validate: (v) => {
28691
- const s = slugifyProject(v ?? "");
28692
- if (s.length < 2) return "At least 2 letters or digits";
28693
- if (projects.some((pr) => pr.slug === s)) return `${s} already exists: pick it from the list instead`;
28694
- }
28695
- })
28696
- );
28697
- slug = slugifyProject(projectName);
28698
- } else slug = choice === "__new__" ? app.slug : choice;
28699
- }
28700
- }
28701
- let project2 = projects.find((pr) => pr.slug === slug);
28702
- if (!project2) {
28703
- project2 = await unwrap(
28704
- lb.POST("/v1/orgs/{org}/projects", {
28705
- params: { path: { org } },
28706
- body: { name: projectName ?? app.name, slug, framework: app.framework, bundleId: app.bundleId, androidPackage: app.androidPackage }
28707
- })
28708
- );
28709
- log.success(`Created project ${import_picocolors4.default.bold(`${org}/${project2.slug}`)} with development, preview and production environments`);
28710
- } else {
28711
- log.success(`Linked to existing project ${import_picocolors4.default.bold(`${org}/${project2.slug}`)}`);
28712
- }
28713
- const config = { org, project: project2.slug, projectId: project2.id };
28714
- writeConfig(config);
28715
- log.info(`Wrote ${import_picocolors4.default.cyan(cmd("liftbay.json"))} ${import_picocolors4.default.dim("(commit it: it links this repository to the project)")}`);
28716
- return config;
28962
+ const text2 = chosen.join(", ") || "none";
28963
+ return `${title}${bar} ${this.state === "cancel" ? import_picocolors4.default.strikethrough(import_picocolors4.default.dim(text2)) : import_picocolors4.default.dim(text2)}`;
28964
+ }
28965
+ const lines = this.options.map((o, i2) => {
28966
+ const on = selected.includes(o.value);
28967
+ const active = i2 === this.cursor;
28968
+ const box = on ? import_picocolors4.default.green("[\u2714]") : active ? import_picocolors4.default.cyan("[ ]") : import_picocolors4.default.dim("[ ]");
28969
+ const label = active ? import_picocolors4.default.bold(o.label) : o.label;
28970
+ return `${bar} ${box} ${label}${o.hint ? ` ${import_picocolors4.default.dim(`(${o.hint})`)}` : ""}`;
28971
+ });
28972
+ const footer = this.state === "error" ? `${import_picocolors4.default.yellow("\u2514")} ${import_picocolors4.default.yellow(this.error)}` : `${import_picocolors4.default.cyan("\u2514")} ${import_picocolors4.default.dim("\u2191/\u2193 move \xB7 space select \xB7 enter confirm")}`;
28973
+ return `${title}${lines.join("\n")}
28974
+ ${footer}
28975
+ `;
28976
+ }
28977
+ }).prompt();
28717
28978
  }
28718
28979
 
28719
28980
  // src/commands/credentials.ts
@@ -28992,7 +29253,7 @@ async function waitFor(lb, config, message, done) {
28992
29253
  const until = Date.now() + WAIT_MINUTES * 6e4;
28993
29254
  while (Date.now() < until) {
28994
29255
  await sleep2(4e3);
28995
- const s = await status(lb, config);
29256
+ const s = await poll(() => status(lb, config));
28996
29257
  if (done(s)) {
28997
29258
  spin.stop(message.replace(/^Waiting for /, "Done: "));
28998
29259
  return s;
@@ -29005,7 +29266,7 @@ async function waitForInstall(lb, config, attempt) {
29005
29266
  const spin = spinner();
29006
29267
  spin.start(fit("Waiting for the GitHub App to be installed"));
29007
29268
  const explain = (text2, spinnerText) => {
29008
- spin.stop(import_picocolors6.default.dim("\u2026"));
29269
+ spin.clear();
29009
29270
  log.info(text2);
29010
29271
  spin.start(fit(spinnerText));
29011
29272
  };
@@ -29014,7 +29275,7 @@ async function waitForInstall(lb, config, attempt) {
29014
29275
  let last = "waiting";
29015
29276
  while (Date.now() < until) {
29016
29277
  await sleep2(3e3);
29017
- const a2 = attempt ? (await lb.GET("/v1/orgs/{org}/github/install/{attempt}", { params: { path: { org: config.org, attempt } } })).data : void 0;
29278
+ const a2 = attempt ? (await poll(() => lb.GET("/v1/orgs/{org}/github/install/{attempt}", { params: { path: { org: config.org, attempt } } }))).data : void 0;
29018
29279
  if (a2 && a2.status !== last) {
29019
29280
  last = a2.status;
29020
29281
  if (a2.status === "needs_sign_in") explain("GitHub is done. Sign in to Liftbay in that browser to finish linking.", "Waiting for you to sign in to Liftbay in the browser");
@@ -29023,7 +29284,7 @@ async function waitForInstall(lb, config, attempt) {
29023
29284
  return { ok: false, message: a2.message };
29024
29285
  }
29025
29286
  }
29026
- const s = await status(lb, config);
29287
+ const s = await poll(() => status(lb, config));
29027
29288
  if (s.connected) {
29028
29289
  spin.stop(`GitHub App installed for ${s.repo}`);
29029
29290
  return { ok: true, status: s };
@@ -29170,9 +29431,91 @@ function pushState(branch) {
29170
29431
  return { upstream: true, ahead: 0 };
29171
29432
  }
29172
29433
  }
29434
+ var minutes = (sec) => sec ? sec >= 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s` : "";
29435
+ function activity(b) {
29436
+ if (b.status === "queued") return b.engine === "xcode-cloud" ? "Waiting for Xcode Cloud" : "Waiting for a runner";
29437
+ if (b.status === "preparing") return "Starting";
29438
+ if (b.status === "uploading") return "Uploading";
29439
+ return b.phases.find((ph) => ph.status === "running")?.name ?? "Building";
29440
+ }
29441
+ async function followSteps(lb, path2, started) {
29442
+ const tag = (b) => started.length > 1 ? `${b.platform === "ios" ? "iOS" : "Android"} \xB7 ` : "";
29443
+ const shown = new Map(started.map((b) => [b.number, /* @__PURE__ */ new Set()]));
29444
+ const latest = new Map(started.map((b) => [b.number, b]));
29445
+ const runnerSeen = /* @__PURE__ */ new Set();
29446
+ const spin = spinner({ withGuide: false });
29447
+ spin.start(fit(started.map((b) => `${tag(b)}${activity(b)}`).join(" ")));
29448
+ const say = (line) => {
29449
+ spin.clear();
29450
+ process.stdout.write(`${import_picocolors7.default.gray("\u2502")} ${line}
29451
+ `);
29452
+ spin.start(fit(started.map((b) => `${tag(b)}${activity(latest.get(b.number))}`).join(" ")));
29453
+ };
29454
+ for (; ; ) {
29455
+ for (const b of started) {
29456
+ const prev = latest.get(b.number);
29457
+ if (FINISHED.has(prev.status)) continue;
29458
+ const fresh = await poll(() => unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/builds/{number}", { params: { path: { ...path2, number: b.number } } })));
29459
+ latest.set(b.number, fresh);
29460
+ if (fresh.status !== "queued" && !runnerSeen.has(b.number)) {
29461
+ runnerSeen.add(b.number);
29462
+ say(`${import_picocolors7.default.green("\u2714")} ${tag(b)}${fresh.engine === "xcode-cloud" ? "Xcode Cloud started" : "Runner started"}`);
29463
+ }
29464
+ for (const ph of fresh.phases) {
29465
+ if (ph.status === "running" || ph.status === "queued" || shown.get(b.number).has(ph.name)) continue;
29466
+ shown.get(b.number).add(ph.name);
29467
+ const mark = ph.status === "succeeded" ? import_picocolors7.default.green("\u2714") : ph.status === "cancelled" ? import_picocolors7.default.yellow("\u25A0") : import_picocolors7.default.red("\u2716");
29468
+ say(`${mark} ${tag(b)}${ph.name} ${import_picocolors7.default.dim(minutes(ph.durationSec))}`);
29469
+ }
29470
+ }
29471
+ const all = [...latest.values()];
29472
+ if (all.every((b) => FINISHED.has(b.status))) {
29473
+ spin.stop(all.every((b) => b.status === "succeeded") ? "Finished" : "Finished with problems");
29474
+ return all;
29475
+ }
29476
+ spin.message(fit(all.map((b) => `${tag(b)}${FINISHED.has(b.status) ? b.status : activity(b)}`).join(" ")));
29477
+ await sleep3(3e3);
29478
+ }
29479
+ }
29480
+ async function followLogs(lb, path2, started) {
29481
+ const prefix = started.length > 1;
29482
+ return Promise.all(
29483
+ started.map(async (b) => {
29484
+ let after = 0;
29485
+ let phase = null;
29486
+ for (; ; ) {
29487
+ const page = await poll(() => unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/builds/{number}/logs", { params: { path: { ...path2, number: b.number }, query: { after } } })));
29488
+ for (const l2 of page.lines) {
29489
+ const tag = prefix ? import_picocolors7.default.dim(`${b.platform.padEnd(8)}`) : "";
29490
+ if (l2.phase && l2.phase !== phase) {
29491
+ phase = l2.phase;
29492
+ console.log(`${tag}${import_picocolors7.default.cyan(`==> ${phase}`)}`);
29493
+ }
29494
+ console.log(`${tag}${l2.stream === "stderr" ? import_picocolors7.default.red(l2.text) : l2.stream === "system" ? import_picocolors7.default.dim(l2.text) : l2.text}`);
29495
+ }
29496
+ after = page.nextSeq;
29497
+ if (page.complete) break;
29498
+ if (!page.lines.length) {
29499
+ const fresh = await poll(() => unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/builds/{number}", { params: { path: { ...path2, number: b.number } } })));
29500
+ if (FINISHED.has(fresh.status) && fresh.logLineCount <= after) break;
29501
+ await sleep3(2e3);
29502
+ }
29503
+ }
29504
+ return poll(() => unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/builds/{number}", { params: { path: { ...path2, number: b.number } } })));
29505
+ })
29506
+ );
29507
+ }
29508
+ async function printLogTail(lb, path2, b, tagged, count = 30) {
29509
+ const after = Math.max(0, b.logLineCount - count);
29510
+ const page = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/builds/{number}/logs", { params: { path: { ...path2, number: b.number }, query: { after } } })).catch(() => null);
29511
+ if (!page?.lines.length) return;
29512
+ log.error(`${tagged ? `${b.platform === "ios" ? "iOS" : "Android"} \xB7 ` : ""}build #${b.number} failed. The end of its log:`);
29513
+ console.log(page.lines.slice(-count).map((l2) => l2.stream === "stderr" ? import_picocolors7.default.red(l2.text) : import_picocolors7.default.dim(l2.text)).join("\n"));
29514
+ log.info(`Whole log: ${import_picocolors7.default.cyan(cmd(`liftbay builds logs ${b.number}`))}`);
29515
+ }
29173
29516
  var SETUP_FILES = ["liftbay.json", ".github/workflows/liftbay.yml", "app.json", "package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb"];
29174
29517
  function git(args) {
29175
- return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
29518
+ return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trimEnd();
29176
29519
  }
29177
29520
  function gitLoud(args) {
29178
29521
  try {
@@ -29182,6 +29525,20 @@ function gitLoud(args) {
29182
29525
  }
29183
29526
  }
29184
29527
  async function readyToBuild(branch, interactive) {
29528
+ const wanted = await currentWorkflow();
29529
+ const present = existsSync6(WORKFLOW_PATH) ? readFileSync6(WORKFLOW_PATH, "utf8") : null;
29530
+ if (present !== wanted) {
29531
+ const what = present === null ? `${WORKFLOW_PATH} isn't in this repository` : `${WORKFLOW_PATH} is out of date`;
29532
+ if (!interactive) fail(`${what}. Update it with \`liftbay ci init --yes\`, then commit and push it.`);
29533
+ log.warn(`${what}${present === null ? "" : " (Liftbay changed how builds run)"}.`);
29534
+ if (cancelled(await confirm({ message: present === null ? "Add it?" : "Update it?", initialValue: true }))) {
29535
+ mkdirSync4(dirname3(WORKFLOW_PATH), { recursive: true });
29536
+ writeFileSync6(WORKFLOW_PATH, wanted);
29537
+ log.success(`${present === null ? "Added" : "Updated"} ${WORKFLOW_PATH}`);
29538
+ } else if (present === null) {
29539
+ fail(`Builds need ${WORKFLOW_PATH} on ${branch}. Run \`liftbay ci init\` to add it.`);
29540
+ }
29541
+ }
29185
29542
  const changed = git(["status", "--porcelain", "--", ...SETUP_FILES]).split("\n").filter(Boolean).map((l2) => l2.slice(3).replace(/^"|"$/g, ""));
29186
29543
  if (changed.some((f) => f === "liftbay.json" || f.startsWith(".github/"))) {
29187
29544
  const files = changed.filter((f) => f !== "" && SETUP_FILES.some((s) => f === s || f.startsWith(".github/")));
@@ -29337,32 +29694,8 @@ async function build(opts) {
29337
29694
  outro(`Queued. Follow along in the dashboard, or with ${import_picocolors7.default.cyan(cmd("liftbay builds view <number>"))}.`);
29338
29695
  return { builds: started.map(summary) };
29339
29696
  }
29340
- const prefix = started.length > 1;
29341
- const results = await Promise.all(
29342
- started.map(async (b) => {
29343
- let after = 0;
29344
- let phase = null;
29345
- for (; ; ) {
29346
- const page = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/builds/{number}/logs", { params: { path: { ...path2, number: b.number }, query: { after } } }));
29347
- for (const l2 of page.lines) {
29348
- const tag = prefix ? import_picocolors7.default.dim(`${b.platform.padEnd(8)}`) : "";
29349
- if (l2.phase && l2.phase !== phase) {
29350
- phase = l2.phase;
29351
- console.log(`${tag}${import_picocolors7.default.cyan(`==> ${phase}`)}`);
29352
- }
29353
- console.log(`${tag}${l2.stream === "stderr" ? import_picocolors7.default.red(l2.text) : l2.stream === "system" ? import_picocolors7.default.dim(l2.text) : l2.text}`);
29354
- }
29355
- after = page.nextSeq;
29356
- if (page.complete) break;
29357
- if (!page.lines.length) {
29358
- const fresh = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/builds/{number}", { params: { path: { ...path2, number: b.number } } }));
29359
- if (FINISHED.has(fresh.status) && fresh.logLineCount <= after) break;
29360
- await sleep3(2e3);
29361
- }
29362
- }
29363
- return unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/builds/{number}", { params: { path: { ...path2, number: b.number } } }));
29364
- })
29365
- );
29697
+ const results = opts.logs ? await followLogs(lb, path2, started) : await followSteps(lb, path2, started);
29698
+ for (const r2 of results.filter((r3) => r3.status === "failed")) await printLogTail(lb, path2, r2, started.length > 1);
29366
29699
  const out = [];
29367
29700
  for (const r2 of results) {
29368
29701
  let installUrl = null;
@@ -29389,7 +29722,7 @@ async function build(opts) {
29389
29722
 
29390
29723
  // src/commands/builds.ts
29391
29724
  var import_picocolors8 = __toESM(require_picocolors(), 1);
29392
- import { createWriteStream, mkdirSync as mkdirSync4 } from "fs";
29725
+ import { createWriteStream, mkdirSync as mkdirSync5 } from "fs";
29393
29726
  import { join as join7, resolve as resolve2 } from "path";
29394
29727
  import { Readable } from "stream";
29395
29728
  import { pipeline } from "stream/promises";
@@ -29490,7 +29823,7 @@ async function buildsDownload(number, opts) {
29490
29823
  const ready = b.artifacts.filter((a2) => a2.status === "ready" && (!opts.kind || a2.kind === opts.kind));
29491
29824
  if (!ready.length) fail(opts.kind ? `Build #${b.number} has no ready ${opts.kind} artifact` : `Build #${b.number} has no ready artifacts (${b.status})`);
29492
29825
  const dir = resolve2(opts.out ?? ".");
29493
- mkdirSync4(dir, { recursive: true });
29826
+ mkdirSync5(dir, { recursive: true });
29494
29827
  const files = [];
29495
29828
  for (const a2 of ready) {
29496
29829
  const link = await unwrap(
@@ -29577,7 +29910,7 @@ async function xcodeCloudDisconnect() {
29577
29910
  }
29578
29911
 
29579
29912
  // src/commands/submit.ts
29580
- import { readFileSync as readFileSync6 } from "fs";
29913
+ import { readFileSync as readFileSync7 } from "fs";
29581
29914
  var import_picocolors10 = __toESM(require_picocolors(), 1);
29582
29915
  var TRACKS2 = ["internal", "alpha", "beta", "production"];
29583
29916
  var DONE = /* @__PURE__ */ new Set(["succeeded", "failed"]);
@@ -29607,7 +29940,7 @@ async function submit(opts) {
29607
29940
  queued.map(async (s) => {
29608
29941
  let shown = 0;
29609
29942
  for (; ; ) {
29610
- const d = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/submissions/{submission}", { params: { path: { ...path2, submission: s.id } } }));
29943
+ const d = await poll(() => unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/submissions/{submission}", { params: { path: { ...path2, submission: s.id } } })));
29611
29944
  for (const e of d.events.slice(shown)) log.message(`${import_picocolors10.default.dim(s.platform.padEnd(8))}${e.text}`);
29612
29945
  shown = d.events.length;
29613
29946
  if (DONE.has(d.status)) return d;
@@ -29637,7 +29970,7 @@ async function credentialsAndroidCommand(opts) {
29637
29970
  }
29638
29971
  let json2;
29639
29972
  try {
29640
- json2 = readFileSync6(opts.playKey.replace(/^~/, process.env.HOME ?? "~"), "utf8");
29973
+ json2 = readFileSync7(opts.playKey.replace(/^~/, process.env.HOME ?? "~"), "utf8");
29641
29974
  } catch {
29642
29975
  fail(`Can't read ${opts.playKey}`);
29643
29976
  }
@@ -29688,7 +30021,7 @@ ${reg.url}`, "Scan with the iPhone's camera, or open the link in Safari on it");
29688
30021
  const until = Date.parse(reg.expiresAt);
29689
30022
  while (Date.now() < until) {
29690
30023
  await sleep7(2500);
29691
- const status2 = await unwrap(lb.GET("/v1/orgs/{org}/devices/registrations/{registration}", { params: { path: { ...org, registration: reg.id } } }));
30024
+ const status2 = await poll(() => unwrap(lb.GET("/v1/orgs/{org}/devices/registrations/{registration}", { params: { path: { ...org, registration: reg.id } } })));
29692
30025
  if (status2.device) {
29693
30026
  spin.stop(`Registered ${import_picocolors11.default.bold(status2.device.name)} ${import_picocolors11.default.dim(`${status2.device.model ?? ""} ${status2.device.udid}`)}`);
29694
30027
  await nextStep(lb);
@@ -29717,171 +30050,16 @@ async function deviceRemove(id) {
29717
30050
  }
29718
30051
 
29719
30052
  // src/commands/setup.ts
29720
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
29721
- import { dirname as dirname3 } from "path";
29722
- var import_picocolors13 = __toESM(require_picocolors(), 1);
29723
-
29724
- // src/generated/workflow.ts
29725
- var WORKFLOW_YAML = `# Liftbay builds on your own GitHub Actions minutes.
29726
- # Liftbay starts this workflow; it does nothing on your own pushes or pull requests. With this file on the
29727
- # default branch, Liftbay uses workflow_dispatch. Otherwise (a branch you're trying out), Liftbay points a
29728
- # temporary liftbay/build/<platform>/<build id> branch at your commit, which runs this file from that commit,
29729
- # and deletes it once the build has started.
29730
- # The build gets no repository secrets: the runner exchanges this run's GitHub OIDC token for a
29731
- # one-time Liftbay job token, checked against this repository and this workflow file.
29732
- # Docs: https://github.com/liftbay/runner
29733
- name: Liftbay build
29734
- run-name: Liftbay build \${{ inputs.build_id || github.ref_name }}
29735
-
29736
- on:
29737
- workflow_dispatch:
29738
- inputs:
29739
- build_id:
29740
- description: Liftbay build id
29741
- required: true
29742
- type: string
29743
- platform:
29744
- description: Platform
29745
- required: true
29746
- type: choice
29747
- options: [android, ios]
29748
- api_url:
29749
- description: Liftbay API
29750
- required: false
29751
- type: string
29752
- default: https://api.liftbay.dev
29753
- push:
29754
- branches: ["liftbay/build/**"]
29755
-
29756
- permissions:
29757
- contents: read
29758
- id-token: write
29759
-
29760
- env:
29761
- # Pinned runner release. The archive's SHA-256 is checked before the runner starts.
29762
- # Release notes and provenance: https://github.com/liftbay/runner/releases/tag/v0.1.2
29763
- RUNNER_VERSION: "0.1.2"
29764
- RUNNER_SHA256_LINUX_AMD64: "9b37bfe534b6dc40f81182bdb9dc46461a672f2c28f048743dc8f13f630909b1"
29765
- RUNNER_SHA256_DARWIN_ARM64: "301bf09358a47b48df0cdbd6f60edbac1227e4d7f61e037959d4009ed6ae7f27"
29766
-
29767
- jobs:
29768
- android:
29769
- if: inputs.platform == 'android' || startsWith(github.ref_name, 'liftbay/build/android/')
29770
- runs-on: ubuntu-latest
29771
- timeout-minutes: 60
29772
- steps:
29773
- - uses: actions/checkout@v4
29774
-
29775
- - uses: actions/setup-java@v4
29776
- with:
29777
- distribution: temurin
29778
- java-version: "17"
29779
-
29780
- - uses: actions/setup-node@v4
29781
- with:
29782
- node-version: "22"
29783
-
29784
- - name: Cache JavaScript dependencies
29785
- uses: actions/cache@v4
29786
- with:
29787
- path: |
29788
- node_modules
29789
- ~/.npm
29790
- ~/.cache/yarn
29791
- ~/.local/share/pnpm/store
29792
- ~/.bun/install/cache
29793
- key: js-\${{ runner.os }}-\${{ hashFiles('package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb') }}
29794
- restore-keys: js-\${{ runner.os }}-
29795
-
29796
- - name: Cache Gradle
29797
- uses: actions/cache@v4
29798
- with:
29799
- path: |
29800
- ~/.gradle/caches
29801
- ~/.gradle/wrapper
29802
- key: gradle-\${{ runner.os }}-\${{ hashFiles('package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb', 'android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }}
29803
- restore-keys: gradle-\${{ runner.os }}-
29804
-
29805
- - name: Install the Liftbay runner
29806
- env:
29807
- # Bring your own runner: set the repository variables LIFTBAY_RUNNER_URL (a
29808
- # liftbay-runner tar.gz you built) and LIFTBAY_RUNNER_SHA256.
29809
- RUNNER_URL: \${{ vars.LIFTBAY_RUNNER_URL }}
29810
- RUNNER_SHA256: \${{ vars.LIFTBAY_RUNNER_SHA256 || env.RUNNER_SHA256_LINUX_AMD64 }}
29811
- run: |
29812
- set -euo pipefail
29813
- url="\${RUNNER_URL:-https://github.com/liftbay/runner/releases/download/v\${RUNNER_VERSION}/liftbay-runner_\${RUNNER_VERSION}_linux_amd64.tar.gz}"
29814
- curl -fsSL --retry 3 -o "$RUNNER_TEMP/liftbay-runner.tar.gz" "$url"
29815
- echo "\${RUNNER_SHA256} $RUNNER_TEMP/liftbay-runner.tar.gz" | sha256sum -c -
29816
- tar -xzf "$RUNNER_TEMP/liftbay-runner.tar.gz" -C "$RUNNER_TEMP" liftbay-runner
29817
- "$RUNNER_TEMP/liftbay-runner" version
29818
-
29819
- - name: Build
29820
- env:
29821
- LIFTBAY_BUILD_ID: \${{ inputs.build_id }}
29822
- LIFTBAY_API_URL: \${{ inputs.api_url || vars.LIFTBAY_API_URL || 'https://api.liftbay.dev' }}
29823
- # On a liftbay/build/<platform>/<build id> branch, the build id is the branch name's last part.
29824
- run: 'LIFTBAY_BUILD_ID="\${LIFTBAY_BUILD_ID:-\${GITHUB_REF_NAME##*/}}" "$RUNNER_TEMP/liftbay-runner" run'
29825
-
29826
- ios:
29827
- if: inputs.platform == 'ios' || startsWith(github.ref_name, 'liftbay/build/ios/')
29828
- runs-on: macos-15
29829
- timeout-minutes: 90
29830
- steps:
29831
- - uses: actions/checkout@v4
29832
-
29833
- - uses: maxim-lobanov/setup-xcode@v1
29834
- with:
29835
- xcode-version: latest-stable
29836
-
29837
- - uses: actions/setup-node@v4
29838
- with:
29839
- node-version: "22"
29840
-
29841
- - name: Cache JavaScript dependencies
29842
- uses: actions/cache@v4
29843
- with:
29844
- path: |
29845
- node_modules
29846
- ~/.npm
29847
- ~/Library/Caches/Yarn
29848
- ~/Library/pnpm/store
29849
- ~/.bun/install/cache
29850
- key: js-\${{ runner.os }}-\${{ hashFiles('package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb') }}
29851
- restore-keys: js-\${{ runner.os }}-
29852
-
29853
- # Only the download caches: a restored ios/Pods would make expo prebuild see a half project.
29854
- - name: Cache CocoaPods
29855
- uses: actions/cache@v4
29856
- with:
29857
- path: |
29858
- ~/Library/Caches/CocoaPods
29859
- ~/.cocoapods
29860
- key: pods-\${{ runner.os }}-\${{ hashFiles('ios/Podfile.lock', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb') }}
29861
- restore-keys: pods-\${{ runner.os }}-
29862
-
29863
- - name: Install the Liftbay runner
29864
- env:
29865
- RUNNER_URL: \${{ vars.LIFTBAY_RUNNER_URL_MACOS }}
29866
- RUNNER_SHA256: \${{ vars.LIFTBAY_RUNNER_SHA256_MACOS || env.RUNNER_SHA256_DARWIN_ARM64 }}
29867
- run: |
29868
- set -euo pipefail
29869
- url="\${RUNNER_URL:-https://github.com/liftbay/runner/releases/download/v\${RUNNER_VERSION}/liftbay-runner_\${RUNNER_VERSION}_darwin_arm64.tar.gz}"
29870
- curl -fsSL --retry 3 -o "$RUNNER_TEMP/liftbay-runner.tar.gz" "$url"
29871
- echo "\${RUNNER_SHA256} $RUNNER_TEMP/liftbay-runner.tar.gz" | shasum -a 256 -c -
29872
- tar -xzf "$RUNNER_TEMP/liftbay-runner.tar.gz" -C "$RUNNER_TEMP" liftbay-runner
29873
- "$RUNNER_TEMP/liftbay-runner" version
30053
+ import { existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as readFileSync10, writeFileSync as writeFileSync8 } from "fs";
30054
+ import { dirname as dirname4 } from "path";
30055
+ var import_picocolors14 = __toESM(require_picocolors(), 1);
29874
30056
 
29875
- - name: Build
29876
- env:
29877
- LIFTBAY_BUILD_ID: \${{ inputs.build_id }}
29878
- LIFTBAY_API_URL: \${{ inputs.api_url || vars.LIFTBAY_API_URL || 'https://api.liftbay.dev' }}
29879
- # On a liftbay/build/<platform>/<build id> branch, the build id is the branch name's last part.
29880
- run: 'LIFTBAY_BUILD_ID="\${LIFTBAY_BUILD_ID:-\${GITHUB_REF_NAME##*/}}" "$RUNNER_TEMP/liftbay-runner" run'
29881
- `;
30057
+ // src/commands/answers.ts
30058
+ import { existsSync as existsSync8, readdirSync as readdirSync3, readFileSync as readFileSync9 } from "fs";
30059
+ var import_picocolors13 = __toESM(require_picocolors(), 1);
29882
30060
 
29883
30061
  // src/commands/env.ts
29884
- import { existsSync as existsSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
30062
+ import { existsSync as existsSync7, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
29885
30063
  import { basename as basename2, join as join8, resolve as resolve3 } from "path";
29886
30064
  var import_picocolors12 = __toESM(require_picocolors(), 1);
29887
30065
  var VISIBILITIES = ["plain", "sensitive", "secret"];
@@ -29925,10 +30103,10 @@ function parseVisibility(v) {
29925
30103
  var slugify2 = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "default";
29926
30104
  function readEasProfiles(cwd = process.cwd()) {
29927
30105
  const path2 = join8(cwd, "eas.json");
29928
- if (!existsSync6(path2)) return null;
30106
+ if (!existsSync7(path2)) return null;
29929
30107
  let eas;
29930
30108
  try {
29931
- eas = JSON.parse(readFileSync7(path2, "utf8"));
30109
+ eas = JSON.parse(readFileSync8(path2, "utf8"));
29932
30110
  } catch {
29933
30111
  fail("eas.json isn't valid JSON");
29934
30112
  }
@@ -30049,7 +30227,7 @@ async function envImport(files, opts) {
30049
30227
  const results = [];
30050
30228
  for (const file2 of files) {
30051
30229
  const abs = resolve3(file2.replace(/^~/, process.env.HOME ?? "~"));
30052
- if (!existsSync6(abs)) fail(`No file at ${file2}`);
30230
+ if (!existsSync7(abs)) fail(`No file at ${file2}`);
30053
30231
  if (basename2(abs) === "eas.json") {
30054
30232
  const profiles = readEasProfiles(resolve3(abs, ".."));
30055
30233
  if (!profiles.length) fail("eas.json has no build profiles");
@@ -30067,7 +30245,7 @@ async function envImport(files, opts) {
30067
30245
  }
30068
30246
  continue;
30069
30247
  }
30070
- const { vars, invalid } = parseEnv(readFileSync7(abs, "utf8"));
30248
+ const { vars, invalid } = parseEnv(readFileSync8(abs, "utf8"));
30071
30249
  if (invalid.length) log.warn(`${file2}: skipped unreadable line${invalid.length > 1 ? "s" : ""} ${invalid.join(", ")}`);
30072
30250
  const { slug } = await pickEnvironment(lb, path2, opts.environment, file2);
30073
30251
  const r2 = await importVariables(lb, path2, slug, vars, { visibility, overwrite });
@@ -30086,13 +30264,165 @@ async function envExport(opts) {
30086
30264
  `);
30087
30265
  return { environment: slug, content };
30088
30266
  }
30089
- if (existsSync6(opts.out) && !opts.force) fail(`${opts.out} exists. Pass --force to overwrite it.`);
30090
- writeFileSync6(opts.out, `${content.trimEnd()}
30267
+ if (existsSync7(opts.out) && !opts.force) fail(`${opts.out} exists. Pass --force to overwrite it.`);
30268
+ writeFileSync7(opts.out, `${content.trimEnd()}
30091
30269
  `, { mode: 384 });
30092
30270
  log.success(`Wrote ${opts.out} ${import_picocolors12.default.dim("(secret values are not exported)")}`);
30093
30271
  return { environment: slug, file: resolve3(opts.out) };
30094
30272
  }
30095
30273
 
30274
+ // src/commands/answers.ts
30275
+ var KEYS = ["org", "newOrg", "project", "newProject", "platforms", "iosEngine", "eas", "ota", "runtimePolicy", "envFiles"];
30276
+ function readAnswers(path2) {
30277
+ if (!existsSync8(path2)) fail(`No answers file at ${path2}`);
30278
+ let a2;
30279
+ try {
30280
+ a2 = JSON.parse(readFileSync9(path2, "utf8"));
30281
+ } catch {
30282
+ fail(`${path2} isn't valid JSON`);
30283
+ }
30284
+ const problems = [];
30285
+ const unknown = Object.keys(a2).filter((k2) => !KEYS.includes(k2));
30286
+ if (unknown.length) problems.push(`unknown field${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} (allowed: ${KEYS.join(", ")})`);
30287
+ const str = (k2) => a2[k2] !== void 0 && (typeof a2[k2] !== "string" || !a2[k2].trim()) && problems.push(`${k2} must be a non-empty string`);
30288
+ const bool = (k2) => a2[k2] !== void 0 && typeof a2[k2] !== "boolean" && problems.push(`${k2} must be true or false`);
30289
+ const oneOf = (k2, values) => a2[k2] !== void 0 && !values.includes(a2[k2]) && problems.push(`${k2} must be ${values.join(" or ")}`);
30290
+ ["org", "newOrg", "project", "newProject"].forEach(str);
30291
+ ["eas", "ota"].forEach(bool);
30292
+ oneOf("iosEngine", ["github-actions", "xcode-cloud"]);
30293
+ oneOf("runtimePolicy", ["appVersion", "fingerprint"]);
30294
+ if (a2.org && a2.newOrg) problems.push("give org or newOrg, not both");
30295
+ if (a2.project && a2.newProject) problems.push("give project or newProject, not both");
30296
+ if (a2.platforms !== void 0 && (!Array.isArray(a2.platforms) || !a2.platforms.length || !a2.platforms.every((x) => x === "ios" || x === "android"))) {
30297
+ problems.push(`platforms must be a list of "ios" and/or "android"`);
30298
+ }
30299
+ if (a2.envFiles !== void 0) {
30300
+ const ok = typeof a2.envFiles === "object" && a2.envFiles !== null && Object.values(a2.envFiles).every((v) => Array.isArray(v) && v.every((f) => typeof f === "string"));
30301
+ if (!ok) problems.push(`envFiles must look like { "production": [".env.production"] }`);
30302
+ else for (const f of Object.values(a2.envFiles).flat()) if (!existsSync8(f)) problems.push(`envFiles: no file at ${f}`);
30303
+ }
30304
+ if (problems.length) fail(`${path2}:
30305
+ ${problems.join("\n ")}`);
30306
+ return a2;
30307
+ }
30308
+ function answersToOptions(a2) {
30309
+ return {
30310
+ org: a2.org,
30311
+ newOrg: a2.newOrg,
30312
+ project: a2.project,
30313
+ projectName: a2.newProject,
30314
+ platforms: a2.platforms?.join(","),
30315
+ iosEngine: a2.iosEngine,
30316
+ eas: a2.eas,
30317
+ ota: a2.ota,
30318
+ runtimePolicy: a2.runtimePolicy,
30319
+ yes: true
30320
+ };
30321
+ }
30322
+ async function initQuestions() {
30323
+ const app = await detectApp();
30324
+ const linked = readConfig();
30325
+ const signedIn = !!(process.env.LIFTBAY_TOKEN || readCredentials());
30326
+ let orgs = [];
30327
+ const projects = {};
30328
+ if (signedIn) {
30329
+ const lb = client();
30330
+ orgs = await unwrap(lb.GET("/v1/orgs")).catch(() => []);
30331
+ for (const o of orgs) projects[o.slug] = await unwrap(lb.GET("/v1/orgs/{org}/projects", { params: { path: { org: o.slug } } })).catch(() => []);
30332
+ }
30333
+ const eas = readEasProfiles();
30334
+ const envFiles = readdirSync3(".").filter((f) => /^\.env(\..+)?$/.test(f) && !/\.(example|sample|template)$/.test(f));
30335
+ const policy = readAppJson()?.expo?.runtimeVersion?.policy;
30336
+ const questions = [];
30337
+ if (!linked) {
30338
+ questions.push(
30339
+ orgs.length ? {
30340
+ id: "org",
30341
+ question: "Which Liftbay organization should own this app?",
30342
+ type: "choice",
30343
+ choices: orgs.map((o) => ({ value: o.slug, label: o.name })),
30344
+ default: orgs.length === 1 ? orgs[0].slug : void 0,
30345
+ note: "Or answer newOrg with a name to create another organization."
30346
+ } : {
30347
+ id: "newOrg",
30348
+ question: "What should the new Liftbay organization be called?",
30349
+ type: "text",
30350
+ note: signedIn ? "This account has no organization yet; init creates it." : "Not signed in yet, so existing organizations can't be listed. Sign in first to see them."
30351
+ },
30352
+ {
30353
+ id: "project",
30354
+ question: "Use an existing Liftbay project, or create a new one?",
30355
+ type: "choice",
30356
+ choices: Object.values(projects).flat().map((pr) => ({ value: pr.slug, label: pr.name })),
30357
+ default: void 0,
30358
+ note: `For a new project, answer newProject with its name instead (suggested: "${app.name}").`
30359
+ }
30360
+ );
30361
+ }
30362
+ questions.push({ id: "platforms", question: "Which platforms should Liftbay build?", type: "multi", choices: [{ value: "ios", label: "iOS" }, { value: "android", label: "Android" }], default: ["ios", "android"] });
30363
+ questions.push({
30364
+ id: "iosEngine",
30365
+ question: "Where should iOS store builds run by default?",
30366
+ type: "choice",
30367
+ when: "platforms includes ios",
30368
+ choices: [
30369
+ { value: "github-actions", label: "GitHub Actions (macOS runners on your GitHub minutes; ready now)" },
30370
+ { value: "xcode-cloud", label: "Xcode Cloud (25 free hours a month with the Apple Developer Program; one-time setup after iOS signing)" }
30371
+ ],
30372
+ default: "github-actions"
30373
+ });
30374
+ if (eas?.length) {
30375
+ questions.push({
30376
+ id: "eas",
30377
+ question: `Use eas.json's build profiles (${eas.map((e) => e.name).join(", ")}), their channels and env variables?`,
30378
+ type: "boolean",
30379
+ default: true
30380
+ });
30381
+ }
30382
+ questions.push({ id: "ota", question: "Set up over-the-air updates (expo-updates)?", type: "boolean", default: true });
30383
+ questions.push({
30384
+ id: "runtimePolicy",
30385
+ question: "Which installed apps can receive an update?",
30386
+ type: "choice",
30387
+ when: "ota is true",
30388
+ choices: [
30389
+ { value: "appVersion", label: "Same app version (bump the version when native code changes)" },
30390
+ { value: "fingerprint", label: "Same native fingerprint (computed from native code and config)" }
30391
+ ],
30392
+ default: policy === "fingerprint" ? "fingerprint" : "appVersion"
30393
+ });
30394
+ if (envFiles.length) {
30395
+ questions.push({
30396
+ id: "envFiles",
30397
+ question: `Which of these files should go into which environment? ${envFiles.join(", ")}`,
30398
+ type: "files",
30399
+ default: {},
30400
+ note: 'Like { "production": [".env.production"], "preview": [".env.preview"] }. Files like .env or .env.local often hold local development values: ask before sending them.'
30401
+ });
30402
+ }
30403
+ const example = {
30404
+ ...linked ? {} : orgs.length ? { org: orgs[0].slug } : { newOrg: "Acme" },
30405
+ ...linked ? {} : { newProject: app.name },
30406
+ platforms: ["ios", "android"],
30407
+ iosEngine: "github-actions",
30408
+ ...eas?.length ? { eas: true } : {},
30409
+ ota: true,
30410
+ runtimePolicy: "appVersion"
30411
+ };
30412
+ const next = `Ask the person these questions, write their answers to a JSON file, then run: ${cmd("liftbay init --answers <file> --json")}`;
30413
+ intro(import_picocolors13.default.bgCyan(import_picocolors13.default.black(" liftbay init --questions ")));
30414
+ log.info(`${import_picocolors13.default.bold(app.name)} \xB7 ${app.framework === "expo" ? "Expo" : "React Native"}${app.bundleId ? ` \xB7 ${app.bundleId}` : ""}${linked ? ` \xB7 linked to ${linked.org}/${linked.project}` : ""}`);
30415
+ if (!signedIn) log.warn(`Not signed in: ${cmd("liftbay login")} first to list organizations and projects.`);
30416
+ for (const q of questions) {
30417
+ const choices = q.choices?.length ? `
30418
+ ${q.choices.map((c2) => ` ${import_picocolors13.default.cyan(c2.value)} ${import_picocolors13.default.dim(c2.label)}`).join("\n")}` : "";
30419
+ log.message(`${import_picocolors13.default.bold(q.id)}${q.when ? import_picocolors13.default.dim(` (if ${q.when})`) : ""}: ${q.question}${choices}${q.note ? `
30420
+ ${import_picocolors13.default.dim(q.note)}` : ""}`);
30421
+ }
30422
+ outro(next);
30423
+ return { app: { name: app.name, framework: app.framework, bundleId: app.bundleId, androidPackage: app.androidPackage }, linked: linked ? { org: linked.org, project: linked.project } : null, signedIn, questions, example, next };
30424
+ }
30425
+
30096
30426
  // src/commands/setup.ts
30097
30427
  var DEFAULT_PROFILES = {
30098
30428
  development: { environment: "development", channel: "development", distribution: "internal" },
@@ -30135,11 +30465,11 @@ async function ciInit(app, config, opts) {
30135
30465
  })
30136
30466
  );
30137
30467
  }
30138
- if (platforms.includes("ios") && !app.bundleId) log.warn(`No iOS bundle identifier found. Set ${import_picocolors13.default.cyan("expo.ios.bundleIdentifier")} in app.json before the first iOS build.`);
30139
- if (platforms.includes("android") && !app.androidPackage) log.warn(`No Android package found. Set ${import_picocolors13.default.cyan("expo.android.package")} in app.json before the first Android build.`);
30468
+ if (platforms.includes("ios") && !app.bundleId) log.warn(`No iOS bundle identifier found. Set ${import_picocolors14.default.cyan("expo.ios.bundleIdentifier")} in app.json before the first iOS build.`);
30469
+ if (platforms.includes("android") && !app.androidPackage) log.warn(`No Android package found. Set ${import_picocolors14.default.cyan("expo.android.package")} in app.json before the first Android build.`);
30140
30470
  const path2 = { org: config.org, project: config.project };
30141
30471
  const repo = await githubRepo();
30142
- if (!repo) log.warn(`No GitHub remote found. Builds run on GitHub Actions: push this app to GitHub, then run ${import_picocolors13.default.cyan(cmd("liftbay ci init"))} again.`);
30472
+ if (!repo) log.warn(`No GitHub remote found. Builds run on GitHub Actions: push this app to GitHub, then run ${import_picocolors14.default.cyan(cmd("liftbay ci init"))} again.`);
30143
30473
  await unwrap(
30144
30474
  lb.PATCH("/v1/orgs/{org}/projects/{project}", {
30145
30475
  params: { path: path2 },
@@ -30167,22 +30497,22 @@ async function ciInit(app, config, opts) {
30167
30497
  printImport(r2);
30168
30498
  imported.push(r2);
30169
30499
  }
30170
- log.info(import_picocolors13.default.dim("EXPO_PUBLIC_* values are plain (they're in the app bundle); names like *_TOKEN or *_SECRET are secret; the rest sensitive."));
30171
- log.info(import_picocolors13.default.dim(`Variables kept on expo.dev (EAS environment variables) aren't in eas.json: ${import_picocolors13.default.cyan("npx eas env:pull --environment production")}, then ${import_picocolors13.default.cyan(cmd("liftbay env import .env.local --environment production"))}.`));
30500
+ log.info(import_picocolors14.default.dim("EXPO_PUBLIC_* values are plain (they're in the app bundle); names like *_TOKEN or *_SECRET are secret; the rest sensitive."));
30501
+ log.info(import_picocolors14.default.dim(`Variables kept on expo.dev (EAS environment variables) aren't in eas.json: ${import_picocolors14.default.cyan("npx eas env:pull --environment production")}, then ${import_picocolors14.default.cyan(cmd("liftbay env import .env.local --environment production"))}.`));
30172
30502
  }
30173
30503
  const profiles = Object.fromEntries(Object.entries(base).map(([name, prof]) => [name, { ...prof, platforms }]));
30174
30504
  if (iosEngine) await unwrap(lb.PATCH("/v1/orgs/{org}/projects/{project}", { params: { path: path2 }, body: { iosEngine } }));
30175
30505
  const next = { ...config, build: { profiles, triggers: config.build?.triggers ?? [] } };
30176
30506
  writeConfig(next);
30177
30507
  note(
30178
- Object.entries(profiles).map(([name, prof]) => `${import_picocolors13.default.bold(name.padEnd(12))} env ${prof.environment.padEnd(12)} ${prof.distribution === "store" ? "store" : "internal"} \xB7 ${prof.platforms.join(", ")}`).join("\n") + (iosEngine ? `
30508
+ Object.entries(profiles).map(([name, prof]) => `${import_picocolors14.default.bold(name.padEnd(12))} env ${prof.environment.padEnd(12)} ${prof.distribution === "store" ? "store" : "internal"} \xB7 ${prof.platforms.join(", ")}`).join("\n") + (iosEngine ? `
30179
30509
 
30180
30510
  iOS store builds run on ${iosEngine === "xcode-cloud" ? "Xcode Cloud" : "GitHub Actions"} (project setting)` : ""),
30181
30511
  "Build profiles"
30182
30512
  );
30183
30513
  if (iosEngine === "xcode-cloud") {
30184
30514
  log.info(
30185
- `Xcode Cloud makes iOS store builds (TestFlight); internal and simulator builds stay on GitHub Actions. It needs iOS signing first: ${import_picocolors13.default.cyan(cmd("liftbay credentials ios"))}, then ${import_picocolors13.default.cyan(cmd("liftbay xcode-cloud connect"))}.`
30515
+ `Xcode Cloud makes iOS store builds (TestFlight); internal and simulator builds stay on GitHub Actions. It needs iOS signing first: ${import_picocolors14.default.cyan(cmd("liftbay credentials ios"))}, then ${import_picocolors14.default.cyan(cmd("liftbay xcode-cloud connect"))}.`
30186
30516
  );
30187
30517
  }
30188
30518
  lastImport = imported;
@@ -30197,12 +30527,11 @@ async function githubStep() {
30197
30527
  } catch (e) {
30198
30528
  if (!(e instanceof CliError)) throw e;
30199
30529
  log.warn(e.message);
30200
- log.info(`Finish any time with ${import_picocolors13.default.cyan(cmd("liftbay github connect"))}; ${cmd("liftbay build")} checks it too.`);
30530
+ log.info(`Finish any time with ${import_picocolors14.default.cyan(cmd("liftbay github connect"))}; ${cmd("liftbay build")} checks it too.`);
30201
30531
  return { error: e.message };
30202
30532
  }
30203
30533
  }
30204
30534
  var lastImport = [];
30205
- var WORKFLOW_PATH = ".github/workflows/liftbay.yml";
30206
30535
  async function githubRepo() {
30207
30536
  try {
30208
30537
  const url = (await output("git", ["remote", "get-url", "origin"])).trim();
@@ -30212,16 +30541,17 @@ async function githubRepo() {
30212
30541
  }
30213
30542
  }
30214
30543
  async function writeWorkflow(opts) {
30215
- const current = existsSync7(WORKFLOW_PATH) ? readFileSync8(WORKFLOW_PATH, "utf8") : null;
30216
- if (current === WORKFLOW_YAML) return;
30544
+ const wanted = await currentWorkflow();
30545
+ const current = existsSync9(WORKFLOW_PATH) ? readFileSync10(WORKFLOW_PATH, "utf8") : null;
30546
+ if (current === wanted) return;
30217
30547
  if (current !== null) {
30218
30548
  const replace = opts.yes || isInteractive() && cancelled(await confirm({ message: `${WORKFLOW_PATH} differs from the current Liftbay workflow. Replace it?` }));
30219
30549
  if (!replace) return log.info(`Kept your ${WORKFLOW_PATH}.`);
30220
30550
  }
30221
- mkdirSync5(dirname3(WORKFLOW_PATH), { recursive: true });
30222
- writeFileSync7(WORKFLOW_PATH, WORKFLOW_YAML);
30223
- log.success(`Wrote ${import_picocolors13.default.cyan(WORKFLOW_PATH)}. Commit and push it: Liftbay starts it for every build, on your GitHub Actions minutes.`);
30224
- log.info(import_picocolors13.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."));
30551
+ mkdirSync6(dirname4(WORKFLOW_PATH), { recursive: true });
30552
+ writeFileSync8(WORKFLOW_PATH, wanted);
30553
+ log.success(`Wrote ${import_picocolors14.default.cyan(WORKFLOW_PATH)}. Commit and push it: Liftbay starts it for every build, on your GitHub Actions minutes.`);
30554
+ log.info(import_picocolors14.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."));
30225
30555
  }
30226
30556
  var pluginSpec = () => process.env.LIFTBAY_EXPO_PLUGIN_SPEC ?? "@liftbay/expo-plugin";
30227
30557
  async function addDependency(app, spec, label) {
@@ -30260,12 +30590,12 @@ async function otaInit(app, config, opts) {
30260
30590
  if (!env2) fail(`Environment ${prof.environment} not found; run \`liftbay ci init\` first`);
30261
30591
  if (!existing) {
30262
30592
  await unwrap(lb.POST("/v1/orgs/{org}/projects/{project}/channels", { params: { path: path2 }, body: { name: prof.channel, environmentId: env2.id, protocols: ["expo"] } }));
30263
- log.success(`Created channel ${import_picocolors13.default.bold(prof.channel)} \u2192 ${prof.environment}`);
30593
+ log.success(`Created channel ${import_picocolors14.default.bold(prof.channel)} \u2192 ${prof.environment}`);
30264
30594
  } else if (!existing.protocols.includes("expo")) {
30265
30595
  await unwrap(
30266
30596
  lb.PATCH("/v1/orgs/{org}/projects/{project}/channels/{channel}", { params: { path: { ...path2, channel: prof.channel } }, body: { protocols: [...existing.protocols, "expo"] } })
30267
30597
  );
30268
- log.success(`Enabled expo-updates on channel ${import_picocolors13.default.bold(prof.channel)}`);
30598
+ log.success(`Enabled expo-updates on channel ${import_picocolors14.default.bold(prof.channel)}`);
30269
30599
  }
30270
30600
  }
30271
30601
  channels2 = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/channels", { params: { path: path2 } }));
@@ -30285,24 +30615,24 @@ runtimeVersion: { "policy": "${policy}" }`, "Add to your app config");
30285
30615
  else plugins.push(entry);
30286
30616
  expo.runtimeVersion = { policy };
30287
30617
  writeAppJson(appJson);
30288
- log.success(`Updated ${import_picocolors13.default.cyan("app.json")}: Liftbay plugin, runtimeVersion policy "${policy}"`);
30618
+ log.success(`Updated ${import_picocolors14.default.cyan("app.json")}: Liftbay plugin, runtimeVersion policy "${policy}"`);
30289
30619
  }
30290
30620
  const next = { ...config, updates: { client: "expo-updates", runtimeVersion: { policy } } };
30291
30621
  writeConfig(next);
30292
30622
  return next;
30293
30623
  }
30294
30624
  async function ciInitCommand(opts) {
30295
- intro(import_picocolors13.default.bgCyan(import_picocolors13.default.black(" liftbay ci init ")));
30625
+ intro(import_picocolors14.default.bgCyan(import_picocolors14.default.black(" liftbay ci init ")));
30296
30626
  await ensureSignedIn(opts);
30297
30627
  const app = await detectApp();
30298
30628
  const config = await ensureLinked(app, opts);
30299
30629
  const next = await ciInit(app, config, opts);
30300
30630
  const github2 = await githubStep();
30301
- outro(`Builds are configured. Start one with ${import_picocolors13.default.cyan(cmd("liftbay build"))}`);
30631
+ outro(`Builds are configured. Start one with ${import_picocolors14.default.cyan(cmd("liftbay build"))}`);
30302
30632
  return { config: next, imported: lastImport, github: github2 };
30303
30633
  }
30304
30634
  async function otaInitCommand(opts) {
30305
- intro(import_picocolors13.default.bgCyan(import_picocolors13.default.black(" liftbay ota init ")));
30635
+ intro(import_picocolors14.default.bgCyan(import_picocolors14.default.black(" liftbay ota init ")));
30306
30636
  await ensureSignedIn(opts);
30307
30637
  const app = await detectApp();
30308
30638
  const config = await ensureLinked(app, opts);
@@ -30313,37 +30643,49 @@ async function otaInitCommand(opts) {
30313
30643
  var nextStepsOta = () => `OTA updates are configured.
30314
30644
 
30315
30645
  1. Build a new binary once (the update URL and channel are baked in)
30316
- 2. Ship JS changes with ${import_picocolors13.default.cyan(cmd("liftbay update publish --channel production"))}`;
30646
+ 2. Ship JS changes with ${import_picocolors14.default.cyan(cmd("liftbay update publish --channel production"))}`;
30317
30647
  async function initCommand(opts) {
30318
- intro(import_picocolors13.default.bgCyan(import_picocolors13.default.black(" liftbay init ")));
30648
+ if (opts.questions) return initQuestions();
30649
+ let envFiles;
30650
+ if (opts.answers) {
30651
+ const answers = readAnswers(opts.answers);
30652
+ Object.assign(opts, answersToOptions(answers));
30653
+ envFiles = answers.envFiles;
30654
+ disablePrompts();
30655
+ }
30656
+ intro(import_picocolors14.default.bgCyan(import_picocolors14.default.black(" liftbay init ")));
30319
30657
  await ensureSignedIn(opts);
30320
30658
  const app = await detectApp();
30321
30659
  log.info(
30322
- `${import_picocolors13.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}` : ""}`
30660
+ `${import_picocolors14.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}` : ""}`
30323
30661
  );
30324
30662
  let config = await ensureLinked(app, opts);
30325
- log.step(import_picocolors13.default.bold("1/3 Builds"));
30663
+ log.step(import_picocolors14.default.bold("1/3 Builds"));
30326
30664
  config = await ciInit(app, config, opts);
30327
- log.step(import_picocolors13.default.bold("2/3 OTA updates"));
30665
+ log.step(import_picocolors14.default.bold("2/3 OTA updates"));
30328
30666
  const wantOta = opts.ota ?? (opts.yes || !isInteractive() ? true : cancelled(await confirm({ message: "Set up over-the-air updates with expo-updates?", initialValue: true })));
30329
30667
  if (wantOta) config = await otaInit(await detectApp(), config, opts);
30330
- else log.info(`Skipped. Run ${import_picocolors13.default.cyan(cmd("liftbay ota init"))} any time.`);
30331
- log.step(import_picocolors13.default.bold("3/3 GitHub"));
30668
+ else log.info(`Skipped. Run ${import_picocolors14.default.cyan(cmd("liftbay ota init"))} any time.`);
30669
+ const imported = [];
30670
+ for (const [environment, files] of Object.entries(envFiles ?? {})) {
30671
+ if (files.length) imported.push(...(await envImport(files, { environment })).imported);
30672
+ }
30673
+ log.step(import_picocolors14.default.bold("3/3 GitHub"));
30332
30674
  const github2 = await githubStep();
30333
30675
  outro(
30334
30676
  [
30335
- `Done. ${import_picocolors13.default.dim(`${dashboardUrl()}/${config.org}/${config.project}`)}`,
30677
+ `Done. ${import_picocolors14.default.dim(`${dashboardUrl()}/${config.org}/${config.project}`)}`,
30336
30678
  "",
30337
30679
  " Next:",
30338
- ` 1. Build: ${import_picocolors13.default.cyan(cmd("liftbay build"))} ${import_picocolors13.default.dim("(offers to commit the setup and push this branch)")}`,
30339
- ...wantOta ? [` 2. Update: ${import_picocolors13.default.cyan(cmd("liftbay update publish --channel preview"))} ${import_picocolors13.default.dim("(after a build from this setup is installed)")}`] : [],
30340
- ...cmd("liftbay") !== "liftbay" ? ["", import_picocolors13.default.dim(` Tip: ${import_picocolors13.default.cyan("npm i -g liftbay")} to type ${import_picocolors13.default.cyan("liftbay")} instead of ${import_picocolors13.default.cyan("npx liftbay")}.`)] : []
30680
+ ` 1. Build: ${import_picocolors14.default.cyan(cmd("liftbay build"))} ${import_picocolors14.default.dim("(offers to commit the setup and push this branch)")}`,
30681
+ ...wantOta ? [` 2. Update: ${import_picocolors14.default.cyan(cmd("liftbay update publish --channel preview"))} ${import_picocolors14.default.dim("(after a build from this setup is installed)")}`] : [],
30682
+ ...cmd("liftbay") !== "liftbay" ? ["", import_picocolors14.default.dim(` Tip: ${import_picocolors14.default.cyan("npm i -g liftbay")} to type ${import_picocolors14.default.cyan("liftbay")} instead of ${import_picocolors14.default.cyan("npx liftbay")}.`)] : []
30341
30683
  ].join("\n")
30342
30684
  );
30343
30685
  return {
30344
30686
  github: github2,
30345
30687
  config,
30346
- imported: lastImport,
30688
+ imported: [...lastImport, ...imported],
30347
30689
  url: `${dashboardUrl()}/${config.org}/${config.project}`,
30348
30690
  next: ["Commit liftbay.json, .github/workflows/liftbay.yml and app.json, and push", "liftbay build --profile preview", ...wantOta ? ["liftbay update publish --channel production"] : []]
30349
30691
  };
@@ -30352,17 +30694,17 @@ async function initCommand(opts) {
30352
30694
  // src/commands/update.ts
30353
30695
  import { execFileSync as execFileSync2 } from "child_process";
30354
30696
  import { createHash as createHash4 } from "crypto";
30355
- import { mkdtempSync, readFileSync as readFileSync10, rmSync as rmSync4 } from "fs";
30697
+ import { mkdtempSync, readFileSync as readFileSync12, rmSync as rmSync4 } from "fs";
30356
30698
  import { tmpdir } from "os";
30357
30699
  import { join as join9 } from "path";
30358
- var import_picocolors15 = __toESM(require_picocolors(), 1);
30700
+ var import_picocolors16 = __toESM(require_picocolors(), 1);
30359
30701
 
30360
30702
  // src/commands/signing.ts
30361
30703
  import { createPublicKey, createSign, randomBytes as randomBytes2 } from "crypto";
30362
- import { chmodSync as chmodSync4, existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "fs";
30363
- import { dirname as dirname4, relative } from "path";
30704
+ import { chmodSync as chmodSync4, existsSync as existsSync10, mkdirSync as mkdirSync7, readFileSync as readFileSync11, writeFileSync as writeFileSync9 } from "fs";
30705
+ import { dirname as dirname5, relative } from "path";
30364
30706
  var import_node_forge = __toESM(require_lib2(), 1);
30365
- var import_picocolors14 = __toESM(require_picocolors(), 1);
30707
+ var import_picocolors15 = __toESM(require_picocolors(), 1);
30366
30708
  var DEFAULT_PRIVATE_KEY = "keys/liftbay-private-key.pem";
30367
30709
  var DEFAULT_PUBLIC_KEY = "certs/liftbay-public-key.pem";
30368
30710
  var DEFAULT_CERTIFICATE = "certs/liftbay-certificate.pem";
@@ -30392,18 +30734,18 @@ function generateSigningCertificate(commonName, years = 10) {
30392
30734
  var signRsaSha256 = (privateKeyPem, data) => createSign("RSA-SHA256").update(data).sign(privateKeyPem, "base64");
30393
30735
  function loadPrivateKey(path2) {
30394
30736
  const read = (file2, label) => {
30395
- if (!existsSync8(file2)) fail(`No private key at ${file2} (${label})`);
30396
- return { pem: readFileSync9(file2, "utf8"), source: file2 };
30737
+ if (!existsSync10(file2)) fail(`No private key at ${file2} (${label})`);
30738
+ return { pem: readFileSync11(file2, "utf8"), source: file2 };
30397
30739
  };
30398
30740
  if (path2) return read(path2, "--private-key");
30399
30741
  const env2 = process.env.LIFTBAY_UPDATE_PRIVATE_KEY;
30400
30742
  if (env2) return env2.includes("PRIVATE KEY") ? { pem: env2.replace(/\\n/g, "\n"), source: "LIFTBAY_UPDATE_PRIVATE_KEY" } : read(env2, "LIFTBAY_UPDATE_PRIVATE_KEY");
30401
- return existsSync8(DEFAULT_PRIVATE_KEY) ? { pem: readFileSync9(DEFAULT_PRIVATE_KEY, "utf8"), source: DEFAULT_PRIVATE_KEY } : null;
30743
+ return existsSync10(DEFAULT_PRIVATE_KEY) ? { pem: readFileSync11(DEFAULT_PRIVATE_KEY, "utf8"), source: DEFAULT_PRIVATE_KEY } : null;
30402
30744
  }
30403
30745
  function ensureIgnored(entry) {
30404
- const current = existsSync8(".gitignore") ? readFileSync9(".gitignore", "utf8") : "";
30746
+ const current = existsSync10(".gitignore") ? readFileSync11(".gitignore", "utf8") : "";
30405
30747
  if (current.split("\n").some((l2) => l2.trim() === entry || l2.trim() === entry.replace(/\/$/, ""))) return false;
30406
- writeFileSync8(".gitignore", `${current}${current && !current.endsWith("\n") ? "\n" : ""}
30748
+ writeFileSync9(".gitignore", `${current}${current && !current.endsWith("\n") ? "\n" : ""}
30407
30749
  # Liftbay update signing key: never commit it
30408
30750
  ${entry}
30409
30751
  `);
@@ -30425,7 +30767,7 @@ async function signingInit(opts) {
30425
30767
  const config = requireConfig();
30426
30768
  const lb = client();
30427
30769
  const path2 = { org: config.org, project: config.project };
30428
- intro(import_picocolors14.default.bgCyan(import_picocolors14.default.black(" liftbay update signing init ")));
30770
+ intro(import_picocolors15.default.bgCyan(import_picocolors15.default.black(" liftbay update signing init ")));
30429
30771
  const { signing: signing2 } = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/updates/signing", { params: { path: path2 } }));
30430
30772
  if (signing2 && !opts.force) {
30431
30773
  fail(
@@ -30434,16 +30776,16 @@ async function signingInit(opts) {
30434
30776
  }
30435
30777
  const privateKeyPath = opts.privateKey ?? DEFAULT_PRIVATE_KEY;
30436
30778
  const certificatePath = opts.certificate ?? DEFAULT_CERTIFICATE;
30437
- if (existsSync8(privateKeyPath) && !opts.force) fail(`${privateKeyPath} already exists. Pass --force to overwrite it, or --private-key for another path.`);
30779
+ if (existsSync10(privateKeyPath) && !opts.force) fail(`${privateKeyPath} already exists. Pass --force to overwrite it, or --private-key for another path.`);
30438
30780
  const generated = generateSigningCertificate(opts.commonName ?? config.project);
30439
- for (const dir of /* @__PURE__ */ new Set([dirname4(privateKeyPath), dirname4(certificatePath), dirname4(DEFAULT_PUBLIC_KEY)])) mkdirSync6(dir, { recursive: true });
30440
- writeFileSync8(privateKeyPath, generated.privateKeyPem, { mode: 384 });
30781
+ for (const dir of /* @__PURE__ */ new Set([dirname5(privateKeyPath), dirname5(certificatePath), dirname5(DEFAULT_PUBLIC_KEY)])) mkdirSync7(dir, { recursive: true });
30782
+ writeFileSync9(privateKeyPath, generated.privateKeyPem, { mode: 384 });
30441
30783
  chmodSync4(privateKeyPath, 384);
30442
- writeFileSync8(certificatePath, generated.certificatePem);
30443
- writeFileSync8(DEFAULT_PUBLIC_KEY, generated.publicKeyPem);
30444
- if (ensureIgnored(`${dirname4(relative(".", privateKeyPath))}/`)) log.info(`Added ${import_picocolors14.default.cyan(`${dirname4(privateKeyPath)}/`)} to .gitignore`);
30445
- log.success(`Private key: ${import_picocolors14.default.cyan(privateKeyPath)} (stays on this machine)
30446
- Certificate: ${import_picocolors14.default.cyan(certificatePath)} (commit it; it's embedded in your app)`);
30784
+ writeFileSync9(certificatePath, generated.certificatePem);
30785
+ writeFileSync9(DEFAULT_PUBLIC_KEY, generated.publicKeyPem);
30786
+ if (ensureIgnored(`${dirname5(relative(".", privateKeyPath))}/`)) log.info(`Added ${import_picocolors15.default.cyan(`${dirname5(privateKeyPath)}/`)} to .gitignore`);
30787
+ log.success(`Private key: ${import_picocolors15.default.cyan(privateKeyPath)} (stays on this machine)
30788
+ Certificate: ${import_picocolors15.default.cyan(certificatePath)} (commit it; it's embedded in your app)`);
30447
30789
  await unwrap(
30448
30790
  lb.PUT("/v1/orgs/{org}/projects/{project}/updates/signing", {
30449
30791
  params: { path: path2 },
@@ -30451,7 +30793,7 @@ async function signingInit(opts) {
30451
30793
  })
30452
30794
  );
30453
30795
  log.success("Certificate saved to Liftbay");
30454
- if (configureAppJson(certificatePath)) log.success(`Updated ${import_picocolors14.default.cyan("app.json")}: the Liftbay plugin embeds the certificate in your app`);
30796
+ if (configureAppJson(certificatePath)) log.success(`Updated ${import_picocolors15.default.cyan("app.json")}: the Liftbay plugin embeds the certificate in your app`);
30455
30797
  else note(`plugins: [["@liftbay/expo-plugin", { "codeSigningCertificate": "./${certificatePath}" }]]`, "Add to your app config");
30456
30798
  const channels2 = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/channels", { params: { path: path2 } }));
30457
30799
  const signedOnly = !!channels2.length && (opts.yes || !isInteractive() || cancelled(await confirm({ message: "Require signed updates on every channel? Unsigned publishes will be refused.", initialValue: true })));
@@ -30464,12 +30806,12 @@ async function signingInit(opts) {
30464
30806
  note(
30465
30807
  [
30466
30808
  `1. Rebuild your apps once: the certificate is part of the binary.`,
30467
- `2. ${import_picocolors14.default.cyan(cmd("liftbay update publish"))} signs automatically with ${privateKeyPath}.`,
30468
- `3. In CI, store the private key as a secret named ${import_picocolors14.default.cyan("LIFTBAY_UPDATE_PRIVATE_KEY")}.`,
30809
+ `2. ${import_picocolors15.default.cyan(cmd("liftbay update publish"))} signs automatically with ${privateKeyPath}.`,
30810
+ `3. In CI, store the private key as a secret named ${import_picocolors15.default.cyan("LIFTBAY_UPDATE_PRIVATE_KEY")}.`,
30469
30811
  `4. Back the key up somewhere safe. If it's lost, apps need a rebuild with a new certificate.`,
30470
30812
  ``,
30471
- `hot-updater: set ${import_picocolors14.default.cyan("HOT_UPDATER_PUBLIC_KEY")} (iOS Info.plist) / ${import_picocolors14.default.cyan("com.hotupdater.PUBLIC_KEY")} (Android) to ${DEFAULT_PUBLIC_KEY}.`,
30472
- `CodePush: set ${import_picocolors14.default.cyan("CodePushPublicKey")} (iOS) / ${import_picocolors14.default.cyan("CodePushPublicKey")} string resource (Android) to ${DEFAULT_PUBLIC_KEY}.`
30813
+ `hot-updater: set ${import_picocolors15.default.cyan("HOT_UPDATER_PUBLIC_KEY")} (iOS Info.plist) / ${import_picocolors15.default.cyan("com.hotupdater.PUBLIC_KEY")} (Android) to ${DEFAULT_PUBLIC_KEY}.`,
30814
+ `CodePush: set ${import_picocolors15.default.cyan("CodePushPublicKey")} (iOS) / ${import_picocolors15.default.cyan("CodePushPublicKey")} string resource (Android) to ${DEFAULT_PUBLIC_KEY}.`
30473
30815
  ].join("\n"),
30474
30816
  "Next"
30475
30817
  );
@@ -30500,13 +30842,13 @@ var contentTypes = {
30500
30842
  mp4: "video/mp4"
30501
30843
  };
30502
30844
  function file(dir, rel, virtualPath, contentType) {
30503
- const data = readFileSync10(join9(dir, rel));
30845
+ const data = readFileSync12(join9(dir, rel));
30504
30846
  return { path: virtualPath, abs: join9(dir, rel), sha256: createHash4("sha256").update(data).digest("hex"), sizeBytes: data.length, contentType };
30505
30847
  }
30506
30848
  async function exportBundles(platforms) {
30507
30849
  const out = mkdtempSync(join9(tmpdir(), "liftbay-export-"));
30508
30850
  await run("npx", ["expo", "export", "--output-dir", out, "--dump-assetmap", ...platforms.flatMap((pl) => ["--platform", pl])]);
30509
- const meta = JSON.parse(readFileSync10(join9(out, "metadata.json"), "utf8"));
30851
+ const meta = JSON.parse(readFileSync12(join9(out, "metadata.json"), "utf8"));
30510
30852
  const bundles = {};
30511
30853
  for (const pl of platforms) {
30512
30854
  const m = meta.fileMetadata[pl];
@@ -30545,7 +30887,7 @@ async function publish(opts) {
30545
30887
  return null;
30546
30888
  }
30547
30889
  })();
30548
- intro(import_picocolors15.default.bgCyan(import_picocolors15.default.black(" liftbay update publish ")));
30890
+ intro(import_picocolors16.default.bgCyan(import_picocolors16.default.black(" liftbay update publish ")));
30549
30891
  if (!platforms.every((x) => x === "ios" || x === "android")) fail("--platform must be ios, android or all");
30550
30892
  log.step(`Exporting ${platforms.join(" and ")} bundles`);
30551
30893
  const { out, bundles } = await exportBundles(platforms);
@@ -30567,13 +30909,13 @@ async function publish(opts) {
30567
30909
  for (const m of prep.missing) {
30568
30910
  const f = bySha.get(m.sha256);
30569
30911
  spin.message(fit(`Uploading ${++done}/${prep.missing.length}: ${f.path}`));
30570
- const res = await fetch(m.uploadUrl, { method: "PUT", body: readFileSync10(f.abs), headers: { "Content-Type": f.contentType } });
30912
+ const res = await fetch(m.uploadUrl, { method: "PUT", body: readFileSync12(f.abs), headers: { "Content-Type": f.contentType } });
30571
30913
  if (!res.ok) fail(`Upload failed for ${f.path} (${res.status})`);
30572
30914
  }
30573
30915
  spin.stop(`${all.length} files: ${prep.missing.length} uploaded, ${prep.alreadyStored} already stored`);
30574
30916
  const key = loadPrivateKey(opts.privateKey);
30575
30917
  const published = [];
30576
- if (key) log.info(`Signing with ${import_picocolors15.default.cyan(key.source)}`);
30918
+ if (key) log.info(`Signing with ${import_picocolors16.default.cyan(key.source)}`);
30577
30919
  for (const [runtime, group] of groups) {
30578
30920
  const body = {
30579
30921
  message,
@@ -30599,11 +30941,11 @@ async function publish(opts) {
30599
30941
  if (notice) log.warn(notice);
30600
30942
  published.push({ id: release.id, channel, runtimeVersion: runtime, platforms: group, rolloutPercent: release.rolloutPercent, signed: release.signed, message, notice });
30601
30943
  log.success(
30602
- `Published to ${import_picocolors15.default.bold(channel)} \xB7 runtime ${runtime} \xB7 ${group.join(", ")}${release.rolloutPercent < 100 ? ` \xB7 ${release.rolloutPercent}% rollout` : ""}${release.signed ? " \xB7 signed" : ""}`
30944
+ `Published to ${import_picocolors16.default.bold(channel)} \xB7 runtime ${runtime} \xB7 ${group.join(", ")}${release.rolloutPercent < 100 ? ` \xB7 ${release.rolloutPercent}% rollout` : ""}${release.signed ? " \xB7 signed" : ""}`
30603
30945
  );
30604
30946
  }
30605
30947
  const url = `${dashboardUrl()}/${config.org}/${config.project}/updates/${channel}`;
30606
- outro(import_picocolors15.default.dim(url));
30948
+ outro(import_picocolors16.default.dim(url));
30607
30949
  return { releases: published, url };
30608
30950
  } finally {
30609
30951
  rmSync4(out, { recursive: true, force: true });
@@ -30615,8 +30957,8 @@ async function list(opts) {
30615
30957
  const releases = await unwrap(client().GET("/v1/orgs/{org}/projects/{project}/channels/{channel}/releases", { params: { path: { org: config.org, project: config.project, channel } } }));
30616
30958
  if (!releases.length) console.log(`No releases on ${channel} yet.`);
30617
30959
  for (const r2 of releases.slice(0, 20)) {
30618
- const status2 = r2.status === "active" ? import_picocolors15.default.green("active") : r2.status === "rolled_back" ? import_picocolors15.default.red("rolled back") : import_picocolors15.default.dim("superseded");
30619
- console.log(`${new Date(r2.createdAt).toLocaleString().padEnd(24)} ${status2.padEnd(22)} ${String(r2.rolloutPercent).padStart(3)}% ${import_picocolors15.default.dim(r2.runtimeVersion.padEnd(10))} ${r2.message}`);
30960
+ const status2 = r2.status === "active" ? import_picocolors16.default.green("active") : r2.status === "rolled_back" ? import_picocolors16.default.red("rolled back") : import_picocolors16.default.dim("superseded");
30961
+ console.log(`${new Date(r2.createdAt).toLocaleString().padEnd(24)} ${status2.padEnd(22)} ${String(r2.rolloutPercent).padStart(3)}% ${import_picocolors16.default.dim(r2.runtimeVersion.padEnd(10))} ${r2.message}`);
30620
30962
  }
30621
30963
  return { channel, releases: releases.slice(0, 20) };
30622
30964
  }
@@ -30631,9 +30973,9 @@ async function rollback(opts) {
30631
30973
  const { path: path2, active } = await activeRelease(opts.channel ?? "production");
30632
30974
  const request = client().POST("/v1/orgs/{org}/projects/{project}/channels/{channel}/releases/{release}/rollback", { params: { path: { ...path2, release: active.id } } });
30633
30975
  const res = await unwrap(request);
30634
- console.log(`${import_picocolors15.default.green("\u2714")} Rolled back "${active.message}". ${res.active ? "The previous release is active again." : "Devices return to the bundle built into the app."}`);
30976
+ console.log(`${import_picocolors16.default.green("\u2714")} Rolled back "${active.message}". ${res.active ? "The previous release is active again." : "Devices return to the bundle built into the app."}`);
30635
30977
  const notice = (await request).response.headers.get("Liftbay-Notice");
30636
- if (notice) console.log(`${import_picocolors15.default.yellow("!")} ${notice}`);
30978
+ if (notice) console.log(`${import_picocolors16.default.yellow("!")} ${notice}`);
30637
30979
  return { rolledBack: { id: active.id, message: active.message }, active: res.active, notice };
30638
30980
  }
30639
30981
  async function rollout(percent, opts) {
@@ -30641,18 +30983,18 @@ async function rollout(percent, opts) {
30641
30983
  const r2 = await unwrap(
30642
30984
  client().PATCH("/v1/orgs/{org}/projects/{project}/channels/{channel}/releases/{release}/rollout", { params: { path: { ...path2, release: active.id } }, body: { rolloutPercent: Number(percent) } })
30643
30985
  );
30644
- console.log(`${import_picocolors15.default.green("\u2714")} "${r2.message}" now rolls out to ${r2.rolloutPercent}%`);
30986
+ console.log(`${import_picocolors16.default.green("\u2714")} "${r2.message}" now rolls out to ${r2.rolloutPercent}%`);
30645
30987
  return { id: r2.id, message: r2.message, rolloutPercent: r2.rolloutPercent };
30646
30988
  }
30647
30989
  async function channels() {
30648
30990
  const config = requireConfig();
30649
30991
  const list2 = await unwrap(client().GET("/v1/orgs/{org}/projects/{project}/channels", { params: { path: { org: config.org, project: config.project } } }));
30650
- for (const c2 of list2) console.log(`${import_picocolors15.default.bold(c2.name.padEnd(16))} env ${c2.environmentSlug.padEnd(12)} ${c2.protocols.join(", ").padEnd(32)} ${import_picocolors15.default.dim(c2.currentRelease?.message ?? "no active release")}`);
30992
+ for (const c2 of list2) console.log(`${import_picocolors16.default.bold(c2.name.padEnd(16))} env ${c2.environmentSlug.padEnd(12)} ${c2.protocols.join(", ").padEnd(32)} ${import_picocolors16.default.dim(c2.currentRelease?.message ?? "no active release")}`);
30651
30993
  return { channels: list2 };
30652
30994
  }
30653
30995
 
30654
30996
  // src/index.ts
30655
- var program2 = new Command("liftbay").description("Build, ship and update React Native apps").version("0.5.2").option("--json", "print the result as JSON on stdout (progress goes to stderr; never prompts). Also LIFTBAY_JSON=1").hook("preAction", () => {
30997
+ var program2 = new Command("liftbay").description("Build, ship and update React Native apps").version("0.7.0").option("--json", "print the result as JSON on stdout (progress goes to stderr; never prompts). Also LIFTBAY_JSON=1").hook("preAction", () => {
30656
30998
  if (program2.opts().json || process.env.LIFTBAY_JSON === "1") enableJson();
30657
30999
  });
30658
31000
  var wrap = (fn) => async (...args) => {
@@ -30663,13 +31005,15 @@ var wrap = (fn) => async (...args) => {
30663
31005
  }
30664
31006
  };
30665
31007
  var setupOptions = (cmd2) => cmd2.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("--ios-engine <engine>", "project setting: where iOS store builds run, github-actions or xcode-cloud").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");
30666
- 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));
31008
+ 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)").option("--email <address>", "sign in with a code sent to this email, in the terminal (creates the account the first time)").option("--code <code>", "with --email: the 6-digit code from the email").action(wrap(login));
30667
31009
  program2.command("logout").description("Sign out and remove the saved token").action(wrap(logout));
30668
31010
  program2.command("whoami").description("Show the signed-in user and organizations").action(wrap(whoami));
30669
- setupOptions(program2.command("init").description("Set up this app: link the project, builds, then OTA updates").option("--no-ota", "skip OTA updates")).action(wrap(initCommand));
31011
+ setupOptions(
31012
+ program2.command("init").description("Set up this app: link the project, builds, then OTA updates").option("--no-ota", "skip OTA updates").option("--new-org <name>", "create an organization with this name").option("--project-name <name>", "create the project with this name").option("--questions", "list every question init would ask, with the choices for this app, and exit (for agents: interview the person)").option("--answers <file>", "run with the answers in this JSON file, without any prompts (see --questions)")
31013
+ ).action(wrap(initCommand));
30670
31014
  var ci = program2.command("ci").description("Builds");
30671
31015
  setupOptions(ci.command("init").description("Set up build profiles, environments and app identifiers")).action(wrap(ciInitCommand));
30672
- program2.command("build").description("Build on your GitHub Actions (Android on Linux, iOS on macOS) and follow the logs").option("--platform <platform>", "ios, android or all (asked in a terminal; otherwise the profile's platforms)").option("--profile <name>", "build profile from liftbay.json (asked in a terminal; otherwise production)").option("--ref <ref>", "branch, tag or commit to build (default: current branch)").option("-m, --message <text>", "build message").option("--simulator", "iOS: build for the simulator instead of a signed .ipa").option("--no-wait", "queue and exit without following the logs").option("--submit", "submit store builds when they succeed: TestFlight, or a Google Play track").option("--track <track>", "Play track for --submit: internal, alpha, beta or production").option("--engine <engine>", "where this iOS store build runs: github-actions or xcode-cloud (asked in a terminal; otherwise the project's setting)").action(wrap(build));
31016
+ program2.command("build").description("Build on your GitHub Actions (Android on Linux, iOS on macOS) and follow the logs").option("--platform <platform>", "ios, android or all (asked in a terminal; otherwise the profile's platforms)").option("--profile <name>", "build profile from liftbay.json (asked in a terminal; otherwise production)").option("--ref <ref>", "branch, tag or commit to build (default: current branch)").option("-m, --message <text>", "build message").option("--simulator", "iOS: build for the simulator instead of a signed .ipa").option("--no-wait", "queue and exit without following the build").option("--logs", "show the whole build log as it runs, instead of just the steps").option("--submit", "submit store builds when they succeed: TestFlight, or a Google Play track").option("--track <track>", "Play track for --submit: internal, alpha, beta or production").option("--engine <engine>", "where this iOS store build runs: github-actions or xcode-cloud (asked in a terminal; otherwise the project's setting)").action(wrap(build));
30673
31017
  var builds = program2.command("builds").description("Look at builds: list, status, logs, artifacts");
30674
31018
  builds.command("list").description("Recent builds, newest first").option("--platform <platform>", "ios or android").option("--status <list>", "comma-separated: queued, preparing, running, uploading, succeeded, failed, cancelled").option("--limit <n>", "how many", "20").action(wrap(buildsList));
30675
31019
  builds.command("view <number>").description("Status, phases and artifacts of one build").action(wrap(buildsView));