liftbay 0.5.1 → 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,115 +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);
28585
- 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");
28938
+ var import_picocolors4 = __toESM(require_picocolors(), 1);
28939
+ function fit(text2, reserve = 8) {
28940
+ const width = Math.max(20, (process.stdout.columns || 80) - reserve);
28941
+ const plain = text2.replace(/\x1b\[[0-9;]*m/g, "");
28942
+ return plain.length <= width ? text2 : `${plain.slice(0, width - 1)}\u2026`;
28943
+ }
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");
28586
28945
  function checkboxes(opts) {
28587
28946
  const required = opts.required ?? true;
28588
28947
  return new a({
@@ -28594,121 +28953,28 @@ function checkboxes(opts) {
28594
28953
  },
28595
28954
  render() {
28596
28955
  const selected = this.value ?? [];
28597
- 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");
28598
- 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")}
28599
28958
  ${symbol2(this.state)} ${opts.message}
28600
28959
  `;
28601
28960
  if (this.state === "submit" || this.state === "cancel") {
28602
28961
  const chosen = this.options.filter((o) => selected.includes(o.value)).map((o) => o.label);
28603
28962
  const text2 = chosen.join(", ") || "none";
28604
- return `${title}${bar} ${this.state === "cancel" ? import_picocolors3.default.strikethrough(import_picocolors3.default.dim(text2)) : import_picocolors3.default.dim(text2)}`;
28963
+ return `${title}${bar} ${this.state === "cancel" ? import_picocolors4.default.strikethrough(import_picocolors4.default.dim(text2)) : import_picocolors4.default.dim(text2)}`;
28605
28964
  }
28606
- const lines = this.options.map((o, i2) => {
28607
- const on = selected.includes(o.value);
28608
- const active = i2 === this.cursor;
28609
- const box = on ? import_picocolors3.default.green("[\u2714]") : active ? import_picocolors3.default.cyan("[ ]") : import_picocolors3.default.dim("[ ]");
28610
- const label = active ? import_picocolors3.default.bold(o.label) : o.label;
28611
- return `${bar} ${box} ${label}${o.hint ? ` ${import_picocolors3.default.dim(`(${o.hint})`)}` : ""}`;
28612
- });
28613
- 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")}`;
28614
- return `${title}${lines.join("\n")}
28615
- ${footer}
28616
- `;
28617
- }
28618
- }).prompt();
28619
- }
28620
-
28621
- // src/commands/link.ts
28622
- var import_picocolors4 = __toESM(require_picocolors(), 1);
28623
- function cancelled(value) {
28624
- if (isCancel(value)) {
28625
- cancel("Cancelled");
28626
- process.exit(0);
28627
- }
28628
- return value;
28629
- }
28630
- var slugifyProject = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
28631
- var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
28632
- async function createFirstOrg(lb, given) {
28633
- const me = await unwrap(lb.GET("/v1/auth/me"));
28634
- const suggested = given ?? (me.user.name?.trim() || me.user.email.split("@")[0]);
28635
- 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;
28636
- const base = slugify(name).padEnd(2, "0");
28637
- for (const slug of [base, `${base.slice(0, 35)}-${Math.random().toString(36).slice(2, 6)}`]) {
28638
- const res = await lb.POST("/v1/orgs", { body: { name: name.slice(0, 64), slug } });
28639
- if (res.data) {
28640
- log.success(`Created organization ${import_picocolors4.default.bold(res.data.name)} ${import_picocolors4.default.dim(`(liftbay.dev/${res.data.slug})`)}`);
28641
- return res.data;
28642
- }
28643
- if (res.response.status !== 409) await unwrap(Promise.resolve(res));
28644
- }
28645
- return fail("Couldn't pick a free organization URL. Pass --org <slug>.");
28646
- }
28647
- async function ensureLinked(app, opts) {
28648
- const existing = readConfig();
28649
- if (existing) {
28650
- log.success(`Linked to ${import_picocolors4.default.bold(`${existing.org}/${existing.project}`)}`);
28651
- return existing;
28652
- }
28653
- const lb = client();
28654
- const orgs = await unwrap(lb.GET("/v1/orgs"));
28655
- if (!orgs.length) orgs.push(await createFirstOrg(lb, opts.org));
28656
- let org = opts.org ?? (orgs.length === 1 ? orgs[0].slug : void 0);
28657
- if (!org) {
28658
- if (!isInteractive()) fail(`Several organizations available; pass --org (${orgs.map((o) => o.slug).join(", ")})`);
28659
- org = cancelled(await select({ message: "Organization", options: orgs.map((o) => ({ value: o.slug, label: o.name, hint: o.slug })) }));
28660
- }
28661
- const projects = await unwrap(lb.GET("/v1/orgs/{org}/projects", { params: { path: { org } } }));
28662
- let slug = opts.project;
28663
- let projectName;
28664
- if (!slug) {
28665
- const match = projects.find((pr) => pr.slug === app.slug);
28666
- if (opts.yes || !isInteractive()) slug = match?.slug ?? app.slug;
28667
- else {
28668
- const taken = projects.some((pr) => pr.slug === app.slug);
28669
- const choice = cancelled(
28670
- await select({
28671
- message: "Liftbay project",
28672
- options: [
28673
- ...taken ? [] : [{ value: "__new__", label: `Create "${app.name}"`, hint: `new project \xB7 liftbay.dev/${org}/${app.slug}` }],
28674
- { value: "__named__", label: "Create a project with another name" },
28675
- ...projects.map((pr) => ({ value: pr.slug, label: pr.name, hint: `existing \xB7 ${pr.slug}` }))
28676
- ],
28677
- initialValue: match?.slug ?? (taken ? "__named__" : "__new__")
28678
- })
28679
- );
28680
- if (choice === "__named__") {
28681
- projectName = cancelled(
28682
- await text({
28683
- message: "Project name",
28684
- initialValue: app.name,
28685
- validate: (v) => {
28686
- const s = slugifyProject(v ?? "");
28687
- if (s.length < 2) return "At least 2 letters or digits";
28688
- if (projects.some((pr) => pr.slug === s)) return `${s} already exists: pick it from the list instead`;
28689
- }
28690
- })
28691
- );
28692
- slug = slugifyProject(projectName);
28693
- } else slug = choice === "__new__" ? app.slug : choice;
28694
- }
28695
- }
28696
- let project2 = projects.find((pr) => pr.slug === slug);
28697
- if (!project2) {
28698
- project2 = await unwrap(
28699
- lb.POST("/v1/orgs/{org}/projects", {
28700
- params: { path: { org } },
28701
- body: { name: projectName ?? app.name, slug, framework: app.framework, bundleId: app.bundleId, androidPackage: app.androidPackage }
28702
- })
28703
- );
28704
- log.success(`Created project ${import_picocolors4.default.bold(`${org}/${project2.slug}`)} with development, preview and production environments`);
28705
- } else {
28706
- log.success(`Linked to existing project ${import_picocolors4.default.bold(`${org}/${project2.slug}`)}`);
28707
- }
28708
- const config = { org, project: project2.slug, projectId: project2.id };
28709
- writeConfig(config);
28710
- log.info(`Wrote ${import_picocolors4.default.cyan(cmd("liftbay.json"))} ${import_picocolors4.default.dim("(commit it: it links this repository to the project)")}`);
28711
- return config;
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();
28712
28978
  }
28713
28979
 
28714
28980
  // src/commands/credentials.ts
@@ -28983,11 +29249,11 @@ async function status(lb, config, refresh = true) {
28983
29249
  }
28984
29250
  async function waitFor(lb, config, message, done) {
28985
29251
  const spin = spinner();
28986
- spin.start(`${message} ${import_picocolors6.default.dim("(Ctrl-C to stop)")}`);
29252
+ spin.start(fit(message));
28987
29253
  const until = Date.now() + WAIT_MINUTES * 6e4;
28988
29254
  while (Date.now() < until) {
28989
29255
  await sleep2(4e3);
28990
- const s = await status(lb, config);
29256
+ const s = await poll(() => status(lb, config));
28991
29257
  if (done(s)) {
28992
29258
  spin.stop(message.replace(/^Waiting for /, "Done: "));
28993
29259
  return s;
@@ -28998,27 +29264,36 @@ async function waitFor(lb, config, message, done) {
28998
29264
  }
28999
29265
  async function waitForInstall(lb, config, attempt) {
29000
29266
  const spin = spinner();
29001
- spin.start(`Waiting for the GitHub App to be installed ${import_picocolors6.default.dim("(Ctrl-C to stop)")}`);
29267
+ spin.start(fit("Waiting for the GitHub App to be installed"));
29268
+ const explain = (text2, spinnerText) => {
29269
+ spin.clear();
29270
+ log.info(text2);
29271
+ spin.start(fit(spinnerText));
29272
+ };
29273
+ let noRepoAccessShown = false;
29002
29274
  const until = Date.now() + WAIT_MINUTES * 6e4;
29003
29275
  let last = "waiting";
29004
29276
  while (Date.now() < until) {
29005
29277
  await sleep2(3e3);
29006
- 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;
29007
29279
  if (a2 && a2.status !== last) {
29008
29280
  last = a2.status;
29009
- if (a2.status === "needs_sign_in") spin.message(`GitHub is done. Sign in to Liftbay in that browser to finish linking ${import_picocolors6.default.dim("(Ctrl-C to stop)")}`);
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");
29010
29282
  else if (a2.status !== "waiting" && a2.status !== "connected") {
29011
29283
  spin.error("GitHub didn't connect");
29012
29284
  return { ok: false, message: a2.message };
29013
29285
  }
29014
29286
  }
29015
- const s = await status(lb, config);
29287
+ const s = await poll(() => status(lb, config));
29016
29288
  if (s.connected) {
29017
29289
  spin.stop(`GitHub App installed for ${s.repo}`);
29018
29290
  return { ok: true, status: s };
29019
29291
  }
29020
29292
  if (a2?.status === "connected") {
29021
- spin.message(`Installed on ${s.repo.split("/")[0]}, but without access to ${s.repo}: on GitHub, Configure the app and select the repository ${import_picocolors6.default.dim("(Ctrl-C to stop)")}`);
29293
+ if (!noRepoAccessShown) {
29294
+ noRepoAccessShown = true;
29295
+ explain(`Installed on ${s.repo.split("/")[0]}, but without access to ${s.repo}. On GitHub, Configure the app and select the repository.`, "Waiting for access to the repository");
29296
+ }
29022
29297
  }
29023
29298
  }
29024
29299
  spin.error("Stopped waiting");
@@ -29156,9 +29431,91 @@ function pushState(branch) {
29156
29431
  return { upstream: true, ahead: 0 };
29157
29432
  }
29158
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
+ }
29159
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"];
29160
29517
  function git(args) {
29161
- return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
29518
+ return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trimEnd();
29162
29519
  }
29163
29520
  function gitLoud(args) {
29164
29521
  try {
@@ -29168,6 +29525,20 @@ function gitLoud(args) {
29168
29525
  }
29169
29526
  }
29170
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
+ }
29171
29542
  const changed = git(["status", "--porcelain", "--", ...SETUP_FILES]).split("\n").filter(Boolean).map((l2) => l2.slice(3).replace(/^"|"$/g, ""));
29172
29543
  if (changed.some((f) => f === "liftbay.json" || f.startsWith(".github/"))) {
29173
29544
  const files = changed.filter((f) => f !== "" && SETUP_FILES.some((s) => f === s || f.startsWith(".github/")));
@@ -29323,32 +29694,8 @@ async function build(opts) {
29323
29694
  outro(`Queued. Follow along in the dashboard, or with ${import_picocolors7.default.cyan(cmd("liftbay builds view <number>"))}.`);
29324
29695
  return { builds: started.map(summary) };
29325
29696
  }
29326
- const prefix = started.length > 1;
29327
- const results = await Promise.all(
29328
- started.map(async (b) => {
29329
- let after = 0;
29330
- let phase = null;
29331
- for (; ; ) {
29332
- const page = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/builds/{number}/logs", { params: { path: { ...path2, number: b.number }, query: { after } } }));
29333
- for (const l2 of page.lines) {
29334
- const tag = prefix ? import_picocolors7.default.dim(`${b.platform.padEnd(8)}`) : "";
29335
- if (l2.phase && l2.phase !== phase) {
29336
- phase = l2.phase;
29337
- console.log(`${tag}${import_picocolors7.default.cyan(`==> ${phase}`)}`);
29338
- }
29339
- console.log(`${tag}${l2.stream === "stderr" ? import_picocolors7.default.red(l2.text) : l2.stream === "system" ? import_picocolors7.default.dim(l2.text) : l2.text}`);
29340
- }
29341
- after = page.nextSeq;
29342
- if (page.complete) break;
29343
- if (!page.lines.length) {
29344
- const fresh = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/builds/{number}", { params: { path: { ...path2, number: b.number } } }));
29345
- if (FINISHED.has(fresh.status) && fresh.logLineCount <= after) break;
29346
- await sleep3(2e3);
29347
- }
29348
- }
29349
- return unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/builds/{number}", { params: { path: { ...path2, number: b.number } } }));
29350
- })
29351
- );
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);
29352
29699
  const out = [];
29353
29700
  for (const r2 of results) {
29354
29701
  let installUrl = null;
@@ -29375,7 +29722,7 @@ async function build(opts) {
29375
29722
 
29376
29723
  // src/commands/builds.ts
29377
29724
  var import_picocolors8 = __toESM(require_picocolors(), 1);
29378
- import { createWriteStream, mkdirSync as mkdirSync4 } from "fs";
29725
+ import { createWriteStream, mkdirSync as mkdirSync5 } from "fs";
29379
29726
  import { join as join7, resolve as resolve2 } from "path";
29380
29727
  import { Readable } from "stream";
29381
29728
  import { pipeline } from "stream/promises";
@@ -29476,7 +29823,7 @@ async function buildsDownload(number, opts) {
29476
29823
  const ready = b.artifacts.filter((a2) => a2.status === "ready" && (!opts.kind || a2.kind === opts.kind));
29477
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})`);
29478
29825
  const dir = resolve2(opts.out ?? ".");
29479
- mkdirSync4(dir, { recursive: true });
29826
+ mkdirSync5(dir, { recursive: true });
29480
29827
  const files = [];
29481
29828
  for (const a2 of ready) {
29482
29829
  const link = await unwrap(
@@ -29544,7 +29891,7 @@ async function xcodeCloudConnect(opts) {
29544
29891
  last = s.step;
29545
29892
  if (!wait || s.step === "apple_key") fail(s.step === "apple_key" ? s.message : "Not connected yet. Run this again once the step above is done.");
29546
29893
  const spin = spinner();
29547
- spin.start("Waiting for that step (checking every 10 seconds; Ctrl-C to stop)");
29894
+ spin.start(fit("Waiting for that step (checking every 10 seconds)"));
29548
29895
  await sleep5(1e4);
29549
29896
  spin.stop("Checking again");
29550
29897
  }
@@ -29563,7 +29910,7 @@ async function xcodeCloudDisconnect() {
29563
29910
  }
29564
29911
 
29565
29912
  // src/commands/submit.ts
29566
- import { readFileSync as readFileSync6 } from "fs";
29913
+ import { readFileSync as readFileSync7 } from "fs";
29567
29914
  var import_picocolors10 = __toESM(require_picocolors(), 1);
29568
29915
  var TRACKS2 = ["internal", "alpha", "beta", "production"];
29569
29916
  var DONE = /* @__PURE__ */ new Set(["succeeded", "failed"]);
@@ -29593,7 +29940,7 @@ async function submit(opts) {
29593
29940
  queued.map(async (s) => {
29594
29941
  let shown = 0;
29595
29942
  for (; ; ) {
29596
- 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 } } })));
29597
29944
  for (const e of d.events.slice(shown)) log.message(`${import_picocolors10.default.dim(s.platform.padEnd(8))}${e.text}`);
29598
29945
  shown = d.events.length;
29599
29946
  if (DONE.has(d.status)) return d;
@@ -29623,7 +29970,7 @@ async function credentialsAndroidCommand(opts) {
29623
29970
  }
29624
29971
  let json2;
29625
29972
  try {
29626
- json2 = readFileSync6(opts.playKey.replace(/^~/, process.env.HOME ?? "~"), "utf8");
29973
+ json2 = readFileSync7(opts.playKey.replace(/^~/, process.env.HOME ?? "~"), "utf8");
29627
29974
  } catch {
29628
29975
  fail(`Can't read ${opts.playKey}`);
29629
29976
  }
@@ -29674,7 +30021,7 @@ ${reg.url}`, "Scan with the iPhone's camera, or open the link in Safari on it");
29674
30021
  const until = Date.parse(reg.expiresAt);
29675
30022
  while (Date.now() < until) {
29676
30023
  await sleep7(2500);
29677
- 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 } } })));
29678
30025
  if (status2.device) {
29679
30026
  spin.stop(`Registered ${import_picocolors11.default.bold(status2.device.name)} ${import_picocolors11.default.dim(`${status2.device.model ?? ""} ${status2.device.udid}`)}`);
29680
30027
  await nextStep(lb);
@@ -29703,171 +30050,16 @@ async function deviceRemove(id) {
29703
30050
  }
29704
30051
 
29705
30052
  // src/commands/setup.ts
29706
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
29707
- import { dirname as dirname3 } from "path";
29708
- var import_picocolors13 = __toESM(require_picocolors(), 1);
29709
-
29710
- // src/generated/workflow.ts
29711
- var WORKFLOW_YAML = `# Liftbay builds on your own GitHub Actions minutes.
29712
- # Liftbay starts this workflow; it does nothing on your own pushes or pull requests. With this file on the
29713
- # default branch, Liftbay uses workflow_dispatch. Otherwise (a branch you're trying out), Liftbay points a
29714
- # temporary liftbay/build/<platform>/<build id> branch at your commit, which runs this file from that commit,
29715
- # and deletes it once the build has started.
29716
- # The build gets no repository secrets: the runner exchanges this run's GitHub OIDC token for a
29717
- # one-time Liftbay job token, checked against this repository and this workflow file.
29718
- # Docs: https://github.com/liftbay/runner
29719
- name: Liftbay build
29720
- run-name: Liftbay build \${{ inputs.build_id || github.ref_name }}
29721
-
29722
- on:
29723
- workflow_dispatch:
29724
- inputs:
29725
- build_id:
29726
- description: Liftbay build id
29727
- required: true
29728
- type: string
29729
- platform:
29730
- description: Platform
29731
- required: true
29732
- type: choice
29733
- options: [android, ios]
29734
- api_url:
29735
- description: Liftbay API
29736
- required: false
29737
- type: string
29738
- default: https://api.liftbay.dev
29739
- push:
29740
- branches: ["liftbay/build/**"]
29741
-
29742
- permissions:
29743
- contents: read
29744
- id-token: write
29745
-
29746
- env:
29747
- # Pinned runner release. The archive's SHA-256 is checked before the runner starts.
29748
- # Release notes and provenance: https://github.com/liftbay/runner/releases/tag/v0.1.2
29749
- RUNNER_VERSION: "0.1.2"
29750
- RUNNER_SHA256_LINUX_AMD64: "9b37bfe534b6dc40f81182bdb9dc46461a672f2c28f048743dc8f13f630909b1"
29751
- RUNNER_SHA256_DARWIN_ARM64: "301bf09358a47b48df0cdbd6f60edbac1227e4d7f61e037959d4009ed6ae7f27"
29752
-
29753
- jobs:
29754
- android:
29755
- if: inputs.platform == 'android' || startsWith(github.ref_name, 'liftbay/build/android/')
29756
- runs-on: ubuntu-latest
29757
- timeout-minutes: 60
29758
- steps:
29759
- - uses: actions/checkout@v4
29760
-
29761
- - uses: actions/setup-java@v4
29762
- with:
29763
- distribution: temurin
29764
- java-version: "17"
29765
-
29766
- - uses: actions/setup-node@v4
29767
- with:
29768
- node-version: "22"
29769
-
29770
- - name: Cache JavaScript dependencies
29771
- uses: actions/cache@v4
29772
- with:
29773
- path: |
29774
- node_modules
29775
- ~/.npm
29776
- ~/.cache/yarn
29777
- ~/.local/share/pnpm/store
29778
- ~/.bun/install/cache
29779
- key: js-\${{ runner.os }}-\${{ hashFiles('package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb') }}
29780
- restore-keys: js-\${{ runner.os }}-
29781
-
29782
- - name: Cache Gradle
29783
- uses: actions/cache@v4
29784
- with:
29785
- path: |
29786
- ~/.gradle/caches
29787
- ~/.gradle/wrapper
29788
- 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') }}
29789
- restore-keys: gradle-\${{ runner.os }}-
29790
-
29791
- - name: Install the Liftbay runner
29792
- env:
29793
- # Bring your own runner: set the repository variables LIFTBAY_RUNNER_URL (a
29794
- # liftbay-runner tar.gz you built) and LIFTBAY_RUNNER_SHA256.
29795
- RUNNER_URL: \${{ vars.LIFTBAY_RUNNER_URL }}
29796
- RUNNER_SHA256: \${{ vars.LIFTBAY_RUNNER_SHA256 || env.RUNNER_SHA256_LINUX_AMD64 }}
29797
- run: |
29798
- set -euo pipefail
29799
- url="\${RUNNER_URL:-https://github.com/liftbay/runner/releases/download/v\${RUNNER_VERSION}/liftbay-runner_\${RUNNER_VERSION}_linux_amd64.tar.gz}"
29800
- curl -fsSL --retry 3 -o "$RUNNER_TEMP/liftbay-runner.tar.gz" "$url"
29801
- echo "\${RUNNER_SHA256} $RUNNER_TEMP/liftbay-runner.tar.gz" | sha256sum -c -
29802
- tar -xzf "$RUNNER_TEMP/liftbay-runner.tar.gz" -C "$RUNNER_TEMP" liftbay-runner
29803
- "$RUNNER_TEMP/liftbay-runner" version
29804
-
29805
- - name: Build
29806
- env:
29807
- LIFTBAY_BUILD_ID: \${{ inputs.build_id }}
29808
- LIFTBAY_API_URL: \${{ inputs.api_url || vars.LIFTBAY_API_URL || 'https://api.liftbay.dev' }}
29809
- # On a liftbay/build/<platform>/<build id> branch, the build id is the branch name's last part.
29810
- run: 'LIFTBAY_BUILD_ID="\${LIFTBAY_BUILD_ID:-\${GITHUB_REF_NAME##*/}}" "$RUNNER_TEMP/liftbay-runner" run'
29811
-
29812
- ios:
29813
- if: inputs.platform == 'ios' || startsWith(github.ref_name, 'liftbay/build/ios/')
29814
- runs-on: macos-15
29815
- timeout-minutes: 90
29816
- steps:
29817
- - uses: actions/checkout@v4
29818
-
29819
- - uses: maxim-lobanov/setup-xcode@v1
29820
- with:
29821
- xcode-version: latest-stable
29822
-
29823
- - uses: actions/setup-node@v4
29824
- with:
29825
- node-version: "22"
29826
-
29827
- - name: Cache JavaScript dependencies
29828
- uses: actions/cache@v4
29829
- with:
29830
- path: |
29831
- node_modules
29832
- ~/.npm
29833
- ~/Library/Caches/Yarn
29834
- ~/Library/pnpm/store
29835
- ~/.bun/install/cache
29836
- key: js-\${{ runner.os }}-\${{ hashFiles('package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb') }}
29837
- restore-keys: js-\${{ runner.os }}-
29838
-
29839
- # Only the download caches: a restored ios/Pods would make expo prebuild see a half project.
29840
- - name: Cache CocoaPods
29841
- uses: actions/cache@v4
29842
- with:
29843
- path: |
29844
- ~/Library/Caches/CocoaPods
29845
- ~/.cocoapods
29846
- key: pods-\${{ runner.os }}-\${{ hashFiles('ios/Podfile.lock', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb') }}
29847
- restore-keys: pods-\${{ runner.os }}-
29848
-
29849
- - name: Install the Liftbay runner
29850
- env:
29851
- RUNNER_URL: \${{ vars.LIFTBAY_RUNNER_URL_MACOS }}
29852
- RUNNER_SHA256: \${{ vars.LIFTBAY_RUNNER_SHA256_MACOS || env.RUNNER_SHA256_DARWIN_ARM64 }}
29853
- run: |
29854
- set -euo pipefail
29855
- url="\${RUNNER_URL:-https://github.com/liftbay/runner/releases/download/v\${RUNNER_VERSION}/liftbay-runner_\${RUNNER_VERSION}_darwin_arm64.tar.gz}"
29856
- curl -fsSL --retry 3 -o "$RUNNER_TEMP/liftbay-runner.tar.gz" "$url"
29857
- echo "\${RUNNER_SHA256} $RUNNER_TEMP/liftbay-runner.tar.gz" | shasum -a 256 -c -
29858
- tar -xzf "$RUNNER_TEMP/liftbay-runner.tar.gz" -C "$RUNNER_TEMP" liftbay-runner
29859
- "$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);
29860
30056
 
29861
- - name: Build
29862
- env:
29863
- LIFTBAY_BUILD_ID: \${{ inputs.build_id }}
29864
- LIFTBAY_API_URL: \${{ inputs.api_url || vars.LIFTBAY_API_URL || 'https://api.liftbay.dev' }}
29865
- # On a liftbay/build/<platform>/<build id> branch, the build id is the branch name's last part.
29866
- run: 'LIFTBAY_BUILD_ID="\${LIFTBAY_BUILD_ID:-\${GITHUB_REF_NAME##*/}}" "$RUNNER_TEMP/liftbay-runner" run'
29867
- `;
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);
29868
30060
 
29869
30061
  // src/commands/env.ts
29870
- 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";
29871
30063
  import { basename as basename2, join as join8, resolve as resolve3 } from "path";
29872
30064
  var import_picocolors12 = __toESM(require_picocolors(), 1);
29873
30065
  var VISIBILITIES = ["plain", "sensitive", "secret"];
@@ -29911,10 +30103,10 @@ function parseVisibility(v) {
29911
30103
  var slugify2 = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "default";
29912
30104
  function readEasProfiles(cwd = process.cwd()) {
29913
30105
  const path2 = join8(cwd, "eas.json");
29914
- if (!existsSync6(path2)) return null;
30106
+ if (!existsSync7(path2)) return null;
29915
30107
  let eas;
29916
30108
  try {
29917
- eas = JSON.parse(readFileSync7(path2, "utf8"));
30109
+ eas = JSON.parse(readFileSync8(path2, "utf8"));
29918
30110
  } catch {
29919
30111
  fail("eas.json isn't valid JSON");
29920
30112
  }
@@ -30035,7 +30227,7 @@ async function envImport(files, opts) {
30035
30227
  const results = [];
30036
30228
  for (const file2 of files) {
30037
30229
  const abs = resolve3(file2.replace(/^~/, process.env.HOME ?? "~"));
30038
- if (!existsSync6(abs)) fail(`No file at ${file2}`);
30230
+ if (!existsSync7(abs)) fail(`No file at ${file2}`);
30039
30231
  if (basename2(abs) === "eas.json") {
30040
30232
  const profiles = readEasProfiles(resolve3(abs, ".."));
30041
30233
  if (!profiles.length) fail("eas.json has no build profiles");
@@ -30053,7 +30245,7 @@ async function envImport(files, opts) {
30053
30245
  }
30054
30246
  continue;
30055
30247
  }
30056
- const { vars, invalid } = parseEnv(readFileSync7(abs, "utf8"));
30248
+ const { vars, invalid } = parseEnv(readFileSync8(abs, "utf8"));
30057
30249
  if (invalid.length) log.warn(`${file2}: skipped unreadable line${invalid.length > 1 ? "s" : ""} ${invalid.join(", ")}`);
30058
30250
  const { slug } = await pickEnvironment(lb, path2, opts.environment, file2);
30059
30251
  const r2 = await importVariables(lb, path2, slug, vars, { visibility, overwrite });
@@ -30072,13 +30264,165 @@ async function envExport(opts) {
30072
30264
  `);
30073
30265
  return { environment: slug, content };
30074
30266
  }
30075
- if (existsSync6(opts.out) && !opts.force) fail(`${opts.out} exists. Pass --force to overwrite it.`);
30076
- 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()}
30077
30269
  `, { mode: 384 });
30078
30270
  log.success(`Wrote ${opts.out} ${import_picocolors12.default.dim("(secret values are not exported)")}`);
30079
30271
  return { environment: slug, file: resolve3(opts.out) };
30080
30272
  }
30081
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
+
30082
30426
  // src/commands/setup.ts
30083
30427
  var DEFAULT_PROFILES = {
30084
30428
  development: { environment: "development", channel: "development", distribution: "internal" },
@@ -30121,11 +30465,11 @@ async function ciInit(app, config, opts) {
30121
30465
  })
30122
30466
  );
30123
30467
  }
30124
- 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.`);
30125
- 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.`);
30126
30470
  const path2 = { org: config.org, project: config.project };
30127
30471
  const repo = await githubRepo();
30128
- 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.`);
30129
30473
  await unwrap(
30130
30474
  lb.PATCH("/v1/orgs/{org}/projects/{project}", {
30131
30475
  params: { path: path2 },
@@ -30153,22 +30497,22 @@ async function ciInit(app, config, opts) {
30153
30497
  printImport(r2);
30154
30498
  imported.push(r2);
30155
30499
  }
30156
- 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."));
30157
- 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"))}.`));
30158
30502
  }
30159
30503
  const profiles = Object.fromEntries(Object.entries(base).map(([name, prof]) => [name, { ...prof, platforms }]));
30160
30504
  if (iosEngine) await unwrap(lb.PATCH("/v1/orgs/{org}/projects/{project}", { params: { path: path2 }, body: { iosEngine } }));
30161
30505
  const next = { ...config, build: { profiles, triggers: config.build?.triggers ?? [] } };
30162
30506
  writeConfig(next);
30163
30507
  note(
30164
- 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 ? `
30165
30509
 
30166
30510
  iOS store builds run on ${iosEngine === "xcode-cloud" ? "Xcode Cloud" : "GitHub Actions"} (project setting)` : ""),
30167
30511
  "Build profiles"
30168
30512
  );
30169
30513
  if (iosEngine === "xcode-cloud") {
30170
30514
  log.info(
30171
- `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"))}.`
30172
30516
  );
30173
30517
  }
30174
30518
  lastImport = imported;
@@ -30183,12 +30527,11 @@ async function githubStep() {
30183
30527
  } catch (e) {
30184
30528
  if (!(e instanceof CliError)) throw e;
30185
30529
  log.warn(e.message);
30186
- 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.`);
30187
30531
  return { error: e.message };
30188
30532
  }
30189
30533
  }
30190
30534
  var lastImport = [];
30191
- var WORKFLOW_PATH = ".github/workflows/liftbay.yml";
30192
30535
  async function githubRepo() {
30193
30536
  try {
30194
30537
  const url = (await output("git", ["remote", "get-url", "origin"])).trim();
@@ -30198,16 +30541,17 @@ async function githubRepo() {
30198
30541
  }
30199
30542
  }
30200
30543
  async function writeWorkflow(opts) {
30201
- const current = existsSync7(WORKFLOW_PATH) ? readFileSync8(WORKFLOW_PATH, "utf8") : null;
30202
- 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;
30203
30547
  if (current !== null) {
30204
30548
  const replace = opts.yes || isInteractive() && cancelled(await confirm({ message: `${WORKFLOW_PATH} differs from the current Liftbay workflow. Replace it?` }));
30205
30549
  if (!replace) return log.info(`Kept your ${WORKFLOW_PATH}.`);
30206
30550
  }
30207
- mkdirSync5(dirname3(WORKFLOW_PATH), { recursive: true });
30208
- writeFileSync7(WORKFLOW_PATH, WORKFLOW_YAML);
30209
- log.success(`Wrote ${import_picocolors13.default.cyan(WORKFLOW_PATH)}. Commit and push it: Liftbay starts it for every build, on your GitHub Actions minutes.`);
30210
- 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."));
30211
30555
  }
30212
30556
  var pluginSpec = () => process.env.LIFTBAY_EXPO_PLUGIN_SPEC ?? "@liftbay/expo-plugin";
30213
30557
  async function addDependency(app, spec, label) {
@@ -30246,12 +30590,12 @@ async function otaInit(app, config, opts) {
30246
30590
  if (!env2) fail(`Environment ${prof.environment} not found; run \`liftbay ci init\` first`);
30247
30591
  if (!existing) {
30248
30592
  await unwrap(lb.POST("/v1/orgs/{org}/projects/{project}/channels", { params: { path: path2 }, body: { name: prof.channel, environmentId: env2.id, protocols: ["expo"] } }));
30249
- 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}`);
30250
30594
  } else if (!existing.protocols.includes("expo")) {
30251
30595
  await unwrap(
30252
30596
  lb.PATCH("/v1/orgs/{org}/projects/{project}/channels/{channel}", { params: { path: { ...path2, channel: prof.channel } }, body: { protocols: [...existing.protocols, "expo"] } })
30253
30597
  );
30254
- 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)}`);
30255
30599
  }
30256
30600
  }
30257
30601
  channels2 = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/channels", { params: { path: path2 } }));
@@ -30271,24 +30615,24 @@ runtimeVersion: { "policy": "${policy}" }`, "Add to your app config");
30271
30615
  else plugins.push(entry);
30272
30616
  expo.runtimeVersion = { policy };
30273
30617
  writeAppJson(appJson);
30274
- 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}"`);
30275
30619
  }
30276
30620
  const next = { ...config, updates: { client: "expo-updates", runtimeVersion: { policy } } };
30277
30621
  writeConfig(next);
30278
30622
  return next;
30279
30623
  }
30280
30624
  async function ciInitCommand(opts) {
30281
- 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 ")));
30282
30626
  await ensureSignedIn(opts);
30283
30627
  const app = await detectApp();
30284
30628
  const config = await ensureLinked(app, opts);
30285
30629
  const next = await ciInit(app, config, opts);
30286
30630
  const github2 = await githubStep();
30287
- 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"))}`);
30288
30632
  return { config: next, imported: lastImport, github: github2 };
30289
30633
  }
30290
30634
  async function otaInitCommand(opts) {
30291
- 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 ")));
30292
30636
  await ensureSignedIn(opts);
30293
30637
  const app = await detectApp();
30294
30638
  const config = await ensureLinked(app, opts);
@@ -30299,37 +30643,49 @@ async function otaInitCommand(opts) {
30299
30643
  var nextStepsOta = () => `OTA updates are configured.
30300
30644
 
30301
30645
  1. Build a new binary once (the update URL and channel are baked in)
30302
- 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"))}`;
30303
30647
  async function initCommand(opts) {
30304
- 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 ")));
30305
30657
  await ensureSignedIn(opts);
30306
30658
  const app = await detectApp();
30307
30659
  log.info(
30308
- `${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}` : ""}`
30309
30661
  );
30310
30662
  let config = await ensureLinked(app, opts);
30311
- log.step(import_picocolors13.default.bold("1/3 Builds"));
30663
+ log.step(import_picocolors14.default.bold("1/3 Builds"));
30312
30664
  config = await ciInit(app, config, opts);
30313
- log.step(import_picocolors13.default.bold("2/3 OTA updates"));
30665
+ log.step(import_picocolors14.default.bold("2/3 OTA updates"));
30314
30666
  const wantOta = opts.ota ?? (opts.yes || !isInteractive() ? true : cancelled(await confirm({ message: "Set up over-the-air updates with expo-updates?", initialValue: true })));
30315
30667
  if (wantOta) config = await otaInit(await detectApp(), config, opts);
30316
- else log.info(`Skipped. Run ${import_picocolors13.default.cyan(cmd("liftbay ota init"))} any time.`);
30317
- 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"));
30318
30674
  const github2 = await githubStep();
30319
30675
  outro(
30320
30676
  [
30321
- `Done. ${import_picocolors13.default.dim(`${dashboardUrl()}/${config.org}/${config.project}`)}`,
30677
+ `Done. ${import_picocolors14.default.dim(`${dashboardUrl()}/${config.org}/${config.project}`)}`,
30322
30678
  "",
30323
30679
  " Next:",
30324
- ` 1. Build: ${import_picocolors13.default.cyan(cmd("liftbay build"))} ${import_picocolors13.default.dim("(offers to commit the setup and push this branch)")}`,
30325
- ...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)")}`] : [],
30326
- ...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")}.`)] : []
30327
30683
  ].join("\n")
30328
30684
  );
30329
30685
  return {
30330
30686
  github: github2,
30331
30687
  config,
30332
- imported: lastImport,
30688
+ imported: [...lastImport, ...imported],
30333
30689
  url: `${dashboardUrl()}/${config.org}/${config.project}`,
30334
30690
  next: ["Commit liftbay.json, .github/workflows/liftbay.yml and app.json, and push", "liftbay build --profile preview", ...wantOta ? ["liftbay update publish --channel production"] : []]
30335
30691
  };
@@ -30338,17 +30694,17 @@ async function initCommand(opts) {
30338
30694
  // src/commands/update.ts
30339
30695
  import { execFileSync as execFileSync2 } from "child_process";
30340
30696
  import { createHash as createHash4 } from "crypto";
30341
- import { mkdtempSync, readFileSync as readFileSync10, rmSync as rmSync4 } from "fs";
30697
+ import { mkdtempSync, readFileSync as readFileSync12, rmSync as rmSync4 } from "fs";
30342
30698
  import { tmpdir } from "os";
30343
30699
  import { join as join9 } from "path";
30344
- var import_picocolors15 = __toESM(require_picocolors(), 1);
30700
+ var import_picocolors16 = __toESM(require_picocolors(), 1);
30345
30701
 
30346
30702
  // src/commands/signing.ts
30347
30703
  import { createPublicKey, createSign, randomBytes as randomBytes2 } from "crypto";
30348
- import { chmodSync as chmodSync4, existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "fs";
30349
- 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";
30350
30706
  var import_node_forge = __toESM(require_lib2(), 1);
30351
- var import_picocolors14 = __toESM(require_picocolors(), 1);
30707
+ var import_picocolors15 = __toESM(require_picocolors(), 1);
30352
30708
  var DEFAULT_PRIVATE_KEY = "keys/liftbay-private-key.pem";
30353
30709
  var DEFAULT_PUBLIC_KEY = "certs/liftbay-public-key.pem";
30354
30710
  var DEFAULT_CERTIFICATE = "certs/liftbay-certificate.pem";
@@ -30378,18 +30734,18 @@ function generateSigningCertificate(commonName, years = 10) {
30378
30734
  var signRsaSha256 = (privateKeyPem, data) => createSign("RSA-SHA256").update(data).sign(privateKeyPem, "base64");
30379
30735
  function loadPrivateKey(path2) {
30380
30736
  const read = (file2, label) => {
30381
- if (!existsSync8(file2)) fail(`No private key at ${file2} (${label})`);
30382
- 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 };
30383
30739
  };
30384
30740
  if (path2) return read(path2, "--private-key");
30385
30741
  const env2 = process.env.LIFTBAY_UPDATE_PRIVATE_KEY;
30386
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");
30387
- 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;
30388
30744
  }
30389
30745
  function ensureIgnored(entry) {
30390
- const current = existsSync8(".gitignore") ? readFileSync9(".gitignore", "utf8") : "";
30746
+ const current = existsSync10(".gitignore") ? readFileSync11(".gitignore", "utf8") : "";
30391
30747
  if (current.split("\n").some((l2) => l2.trim() === entry || l2.trim() === entry.replace(/\/$/, ""))) return false;
30392
- writeFileSync8(".gitignore", `${current}${current && !current.endsWith("\n") ? "\n" : ""}
30748
+ writeFileSync9(".gitignore", `${current}${current && !current.endsWith("\n") ? "\n" : ""}
30393
30749
  # Liftbay update signing key: never commit it
30394
30750
  ${entry}
30395
30751
  `);
@@ -30411,7 +30767,7 @@ async function signingInit(opts) {
30411
30767
  const config = requireConfig();
30412
30768
  const lb = client();
30413
30769
  const path2 = { org: config.org, project: config.project };
30414
- 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 ")));
30415
30771
  const { signing: signing2 } = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/updates/signing", { params: { path: path2 } }));
30416
30772
  if (signing2 && !opts.force) {
30417
30773
  fail(
@@ -30420,16 +30776,16 @@ async function signingInit(opts) {
30420
30776
  }
30421
30777
  const privateKeyPath = opts.privateKey ?? DEFAULT_PRIVATE_KEY;
30422
30778
  const certificatePath = opts.certificate ?? DEFAULT_CERTIFICATE;
30423
- 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.`);
30424
30780
  const generated = generateSigningCertificate(opts.commonName ?? config.project);
30425
- for (const dir of /* @__PURE__ */ new Set([dirname4(privateKeyPath), dirname4(certificatePath), dirname4(DEFAULT_PUBLIC_KEY)])) mkdirSync6(dir, { recursive: true });
30426
- 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 });
30427
30783
  chmodSync4(privateKeyPath, 384);
30428
- writeFileSync8(certificatePath, generated.certificatePem);
30429
- writeFileSync8(DEFAULT_PUBLIC_KEY, generated.publicKeyPem);
30430
- if (ensureIgnored(`${dirname4(relative(".", privateKeyPath))}/`)) log.info(`Added ${import_picocolors14.default.cyan(`${dirname4(privateKeyPath)}/`)} to .gitignore`);
30431
- log.success(`Private key: ${import_picocolors14.default.cyan(privateKeyPath)} (stays on this machine)
30432
- 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)`);
30433
30789
  await unwrap(
30434
30790
  lb.PUT("/v1/orgs/{org}/projects/{project}/updates/signing", {
30435
30791
  params: { path: path2 },
@@ -30437,7 +30793,7 @@ async function signingInit(opts) {
30437
30793
  })
30438
30794
  );
30439
30795
  log.success("Certificate saved to Liftbay");
30440
- 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`);
30441
30797
  else note(`plugins: [["@liftbay/expo-plugin", { "codeSigningCertificate": "./${certificatePath}" }]]`, "Add to your app config");
30442
30798
  const channels2 = await unwrap(lb.GET("/v1/orgs/{org}/projects/{project}/channels", { params: { path: path2 } }));
30443
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 })));
@@ -30450,12 +30806,12 @@ async function signingInit(opts) {
30450
30806
  note(
30451
30807
  [
30452
30808
  `1. Rebuild your apps once: the certificate is part of the binary.`,
30453
- `2. ${import_picocolors14.default.cyan(cmd("liftbay update publish"))} signs automatically with ${privateKeyPath}.`,
30454
- `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")}.`,
30455
30811
  `4. Back the key up somewhere safe. If it's lost, apps need a rebuild with a new certificate.`,
30456
30812
  ``,
30457
- `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}.`,
30458
- `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}.`
30459
30815
  ].join("\n"),
30460
30816
  "Next"
30461
30817
  );
@@ -30486,13 +30842,13 @@ var contentTypes = {
30486
30842
  mp4: "video/mp4"
30487
30843
  };
30488
30844
  function file(dir, rel, virtualPath, contentType) {
30489
- const data = readFileSync10(join9(dir, rel));
30845
+ const data = readFileSync12(join9(dir, rel));
30490
30846
  return { path: virtualPath, abs: join9(dir, rel), sha256: createHash4("sha256").update(data).digest("hex"), sizeBytes: data.length, contentType };
30491
30847
  }
30492
30848
  async function exportBundles(platforms) {
30493
30849
  const out = mkdtempSync(join9(tmpdir(), "liftbay-export-"));
30494
30850
  await run("npx", ["expo", "export", "--output-dir", out, "--dump-assetmap", ...platforms.flatMap((pl) => ["--platform", pl])]);
30495
- const meta = JSON.parse(readFileSync10(join9(out, "metadata.json"), "utf8"));
30851
+ const meta = JSON.parse(readFileSync12(join9(out, "metadata.json"), "utf8"));
30496
30852
  const bundles = {};
30497
30853
  for (const pl of platforms) {
30498
30854
  const m = meta.fileMetadata[pl];
@@ -30531,7 +30887,7 @@ async function publish(opts) {
30531
30887
  return null;
30532
30888
  }
30533
30889
  })();
30534
- 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 ")));
30535
30891
  if (!platforms.every((x) => x === "ios" || x === "android")) fail("--platform must be ios, android or all");
30536
30892
  log.step(`Exporting ${platforms.join(" and ")} bundles`);
30537
30893
  const { out, bundles } = await exportBundles(platforms);
@@ -30552,14 +30908,14 @@ async function publish(opts) {
30552
30908
  let done = 0;
30553
30909
  for (const m of prep.missing) {
30554
30910
  const f = bySha.get(m.sha256);
30555
- spin.message(`Uploading ${++done}/${prep.missing.length}: ${f.path}`);
30556
- const res = await fetch(m.uploadUrl, { method: "PUT", body: readFileSync10(f.abs), headers: { "Content-Type": f.contentType } });
30911
+ spin.message(fit(`Uploading ${++done}/${prep.missing.length}: ${f.path}`));
30912
+ const res = await fetch(m.uploadUrl, { method: "PUT", body: readFileSync12(f.abs), headers: { "Content-Type": f.contentType } });
30557
30913
  if (!res.ok) fail(`Upload failed for ${f.path} (${res.status})`);
30558
30914
  }
30559
30915
  spin.stop(`${all.length} files: ${prep.missing.length} uploaded, ${prep.alreadyStored} already stored`);
30560
30916
  const key = loadPrivateKey(opts.privateKey);
30561
30917
  const published = [];
30562
- 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)}`);
30563
30919
  for (const [runtime, group] of groups) {
30564
30920
  const body = {
30565
30921
  message,
@@ -30585,11 +30941,11 @@ async function publish(opts) {
30585
30941
  if (notice) log.warn(notice);
30586
30942
  published.push({ id: release.id, channel, runtimeVersion: runtime, platforms: group, rolloutPercent: release.rolloutPercent, signed: release.signed, message, notice });
30587
30943
  log.success(
30588
- `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" : ""}`
30589
30945
  );
30590
30946
  }
30591
30947
  const url = `${dashboardUrl()}/${config.org}/${config.project}/updates/${channel}`;
30592
- outro(import_picocolors15.default.dim(url));
30948
+ outro(import_picocolors16.default.dim(url));
30593
30949
  return { releases: published, url };
30594
30950
  } finally {
30595
30951
  rmSync4(out, { recursive: true, force: true });
@@ -30601,8 +30957,8 @@ async function list(opts) {
30601
30957
  const releases = await unwrap(client().GET("/v1/orgs/{org}/projects/{project}/channels/{channel}/releases", { params: { path: { org: config.org, project: config.project, channel } } }));
30602
30958
  if (!releases.length) console.log(`No releases on ${channel} yet.`);
30603
30959
  for (const r2 of releases.slice(0, 20)) {
30604
- 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");
30605
- 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}`);
30606
30962
  }
30607
30963
  return { channel, releases: releases.slice(0, 20) };
30608
30964
  }
@@ -30617,9 +30973,9 @@ async function rollback(opts) {
30617
30973
  const { path: path2, active } = await activeRelease(opts.channel ?? "production");
30618
30974
  const request = client().POST("/v1/orgs/{org}/projects/{project}/channels/{channel}/releases/{release}/rollback", { params: { path: { ...path2, release: active.id } } });
30619
30975
  const res = await unwrap(request);
30620
- 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."}`);
30621
30977
  const notice = (await request).response.headers.get("Liftbay-Notice");
30622
- if (notice) console.log(`${import_picocolors15.default.yellow("!")} ${notice}`);
30978
+ if (notice) console.log(`${import_picocolors16.default.yellow("!")} ${notice}`);
30623
30979
  return { rolledBack: { id: active.id, message: active.message }, active: res.active, notice };
30624
30980
  }
30625
30981
  async function rollout(percent, opts) {
@@ -30627,18 +30983,18 @@ async function rollout(percent, opts) {
30627
30983
  const r2 = await unwrap(
30628
30984
  client().PATCH("/v1/orgs/{org}/projects/{project}/channels/{channel}/releases/{release}/rollout", { params: { path: { ...path2, release: active.id } }, body: { rolloutPercent: Number(percent) } })
30629
30985
  );
30630
- 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}%`);
30631
30987
  return { id: r2.id, message: r2.message, rolloutPercent: r2.rolloutPercent };
30632
30988
  }
30633
30989
  async function channels() {
30634
30990
  const config = requireConfig();
30635
30991
  const list2 = await unwrap(client().GET("/v1/orgs/{org}/projects/{project}/channels", { params: { path: { org: config.org, project: config.project } } }));
30636
- 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")}`);
30637
30993
  return { channels: list2 };
30638
30994
  }
30639
30995
 
30640
30996
  // src/index.ts
30641
- var program2 = new Command("liftbay").description("Build, ship and update React Native apps").version("0.5.1").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", () => {
30642
30998
  if (program2.opts().json || process.env.LIFTBAY_JSON === "1") enableJson();
30643
30999
  });
30644
31000
  var wrap = (fn) => async (...args) => {
@@ -30649,13 +31005,15 @@ var wrap = (fn) => async (...args) => {
30649
31005
  }
30650
31006
  };
30651
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");
30652
- 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));
30653
31009
  program2.command("logout").description("Sign out and remove the saved token").action(wrap(logout));
30654
31010
  program2.command("whoami").description("Show the signed-in user and organizations").action(wrap(whoami));
30655
- 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));
30656
31014
  var ci = program2.command("ci").description("Builds");
30657
31015
  setupOptions(ci.command("init").description("Set up build profiles, environments and app identifiers")).action(wrap(ciInitCommand));
30658
- 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));
30659
31017
  var builds = program2.command("builds").description("Look at builds: list, status, logs, artifacts");
30660
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));
30661
31019
  builds.command("view <number>").description("Status, phases and artifacts of one build").action(wrap(buildsView));