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