@lynxship/cli 0.1.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 +122 -0
- package/dist/android-build.d.ts +14 -0
- package/dist/android-build.d.ts.map +1 -0
- package/dist/android-build.js +201 -0
- package/dist/artifact-name.d.ts +3 -0
- package/dist/artifact-name.d.ts.map +1 -0
- package/dist/artifact-name.js +4 -0
- package/dist/autolink.d.ts +14 -0
- package/dist/autolink.d.ts.map +1 -0
- package/dist/autolink.js +144 -0
- package/dist/config.d.ts +51 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +62 -0
- package/dist/configure.d.ts +11 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +172 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1082 -0
- package/dist/ios-build.d.ts +13 -0
- package/dist/ios-build.d.ts.map +1 -0
- package/dist/ios-build.js +174 -0
- package/dist/ota-assets.d.ts +3 -0
- package/dist/ota-assets.d.ts.map +1 -0
- package/dist/ota-assets.js +48 -0
- package/dist/ota-doctor.d.ts +10 -0
- package/dist/ota-doctor.d.ts.map +1 -0
- package/dist/ota-doctor.js +53 -0
- package/dist/paths.d.ts +2 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +12 -0
- package/dist/process-runner.d.ts +18 -0
- package/dist/process-runner.d.ts.map +1 -0
- package/dist/process-runner.js +97 -0
- package/dist/prompt.d.ts +3 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +58 -0
- package/dist/r2.d.ts +32 -0
- package/dist/r2.d.ts.map +1 -0
- package/dist/r2.js +135 -0
- package/dist/remote.d.ts +33 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +116 -0
- package/dist/runtime-fingerprint.d.ts +10 -0
- package/dist/runtime-fingerprint.d.ts.map +1 -0
- package/dist/runtime-fingerprint.js +238 -0
- package/dist/secure-store.d.ts +32 -0
- package/dist/secure-store.d.ts.map +1 -0
- package/dist/secure-store.js +263 -0
- package/dist/ui/colors.d.ts +24 -0
- package/dist/ui/colors.d.ts.map +1 -0
- package/dist/ui/colors.js +64 -0
- package/dist/ui/components.d.ts +36 -0
- package/dist/ui/components.d.ts.map +1 -0
- package/dist/ui/components.js +268 -0
- package/dist/ui/index.d.ts +24 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +68 -0
- package/dist/ui/logo.d.ts +3 -0
- package/dist/ui/logo.d.ts.map +1 -0
- package/dist/ui/logo.js +32 -0
- package/dist/ui/state.d.ts +5 -0
- package/dist/ui/state.d.ts.map +1 -0
- package/dist/ui/state.js +7 -0
- package/dist/ui/terminal.d.ts +12 -0
- package/dist/ui/terminal.d.ts.map +1 -0
- package/dist/ui/terminal.js +23 -0
- package/package.json +75 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1082 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { BuildOrchestrator } from "@lynxship/build-orchestrator";
|
|
7
|
+
import { JsonRepository } from "@lynxship/db";
|
|
8
|
+
import { assert, createId, sha256, } from "@lynxship/contracts";
|
|
9
|
+
import { createSigningKey, signManifest, } from "@lynxship/signing";
|
|
10
|
+
import { AppStoreConnectApiProvider, GooglePlayApiProvider, SubmissionService, } from "@lynxship/submit";
|
|
11
|
+
import { DEFAULT_CONFIG, loadConfig, platformValue } from "./config.js";
|
|
12
|
+
import { hasAndroidHost, runRealAndroidBuild } from "./android-build.js";
|
|
13
|
+
import { hasIosHost, runRealIosBuild } from "./ios-build.js";
|
|
14
|
+
import { configureAndroid, configureAppStoreConnect, configureGooglePlay, configureR2, } from "./configure.js";
|
|
15
|
+
import { fetchOtaPublicKey, publishOtaRelease, submitRealArtifact, } from "./remote.js";
|
|
16
|
+
import { uploadR2Artifact } from "./r2.js";
|
|
17
|
+
import { credentialStorageDescription, loadCredentials, } from "./secure-store.js";
|
|
18
|
+
import { createCliUi } from "./ui/index.js";
|
|
19
|
+
import { globalLynxShipDirectory } from "./paths.js";
|
|
20
|
+
import { inspectAutolink, requireAutolinkReady } from "./autolink.js";
|
|
21
|
+
import { assertCompatibleBinaryBuild, inspectRuntimeFingerprint, } from "./runtime-fingerprint.js";
|
|
22
|
+
import { inspectOtaHost } from "./ota-doctor.js";
|
|
23
|
+
import { otaAssetName, otaAssetPaths } from "./ota-assets.js";
|
|
24
|
+
import { commandExists, packageManagerCommand, runProcess, runRspeedy, } from "./process-runner.js";
|
|
25
|
+
const rawArgs = process.argv.slice(2);
|
|
26
|
+
const args = [...rawArgs];
|
|
27
|
+
const ui = createCliUi(rawArgs);
|
|
28
|
+
const json = ui.options.json;
|
|
29
|
+
function requestedProjectDirectory() {
|
|
30
|
+
const index = rawArgs.indexOf("--project-dir");
|
|
31
|
+
if (index >= 0)
|
|
32
|
+
return rawArgs[index + 1];
|
|
33
|
+
const inline = rawArgs.find((value) => value.startsWith("--project-dir="));
|
|
34
|
+
return inline?.slice("--project-dir=".length);
|
|
35
|
+
}
|
|
36
|
+
function findProjectRoot(start) {
|
|
37
|
+
const explicit = requestedProjectDirectory() ?? process.env.LYNXSHIP_PROJECT_DIR;
|
|
38
|
+
if (explicit)
|
|
39
|
+
return resolve(explicit);
|
|
40
|
+
let current = resolve(start);
|
|
41
|
+
while (true) {
|
|
42
|
+
if (existsSync(join(current, "lynxship.json")))
|
|
43
|
+
return current;
|
|
44
|
+
const parent = dirname(current);
|
|
45
|
+
if (parent === current)
|
|
46
|
+
return resolve(start);
|
|
47
|
+
current = parent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const root = findProjectRoot(process.cwd());
|
|
51
|
+
function flag(name, fallback = null) {
|
|
52
|
+
const index = args.indexOf(name);
|
|
53
|
+
return index >= 0 ? (args[index + 1] ?? "true") : fallback;
|
|
54
|
+
}
|
|
55
|
+
function printValue(value, view) {
|
|
56
|
+
if (json) {
|
|
57
|
+
console.log(JSON.stringify(typeof value === "string" ? { result: value } : value));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (json || !ui.interactive || !view) {
|
|
61
|
+
console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
ui.summary(view.title, view.rows);
|
|
65
|
+
ui.done(view.done);
|
|
66
|
+
}
|
|
67
|
+
async function exists(file) {
|
|
68
|
+
try {
|
|
69
|
+
await access(file);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function findLockfile(rootDirectory) {
|
|
77
|
+
let current = resolve(rootDirectory);
|
|
78
|
+
while (true) {
|
|
79
|
+
for (const file of ["pnpm-lock.yaml", "package-lock.json", "yarn.lock"]) {
|
|
80
|
+
if (await exists(join(current, file)))
|
|
81
|
+
return join(current, file);
|
|
82
|
+
}
|
|
83
|
+
const parent = dirname(current);
|
|
84
|
+
if (parent === current)
|
|
85
|
+
return null;
|
|
86
|
+
current = parent;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function requireProjectRoot() {
|
|
90
|
+
assert(await exists(join(root, "lynxship.json")), "CLI_PROJECT_REQUIRED", "Run this command from a LynxShip project directory containing lynxship.json, or run `lynxship init` first.");
|
|
91
|
+
}
|
|
92
|
+
async function readConfigurationStatus() {
|
|
93
|
+
const credentials = await loadCredentials(root);
|
|
94
|
+
const r2Configured = (((await exists(join(root, ".lynxship", "r2.json"))) ||
|
|
95
|
+
(await exists(join(globalLynxShipDirectory(), "r2.json")))) &&
|
|
96
|
+
Boolean(credentials.r2)) ||
|
|
97
|
+
Boolean(process.env.CLOUDFLARE_ACCOUNT_ID &&
|
|
98
|
+
process.env.R2_BUCKET &&
|
|
99
|
+
process.env.R2_ACCESS_KEY_ID &&
|
|
100
|
+
process.env.R2_SECRET_ACCESS_KEY);
|
|
101
|
+
const android = credentials.android;
|
|
102
|
+
const androidConfigured = Boolean(android?.keystorePath && (await exists(android.keystorePath))) ||
|
|
103
|
+
Boolean(process.env.LYNXSHIP_KEYSTORE_PATH &&
|
|
104
|
+
(await exists(process.env.LYNXSHIP_KEYSTORE_PATH)) &&
|
|
105
|
+
process.env.LYNXSHIP_KEY_ALIAS &&
|
|
106
|
+
process.env.LYNXSHIP_KEYSTORE_PASSWORD &&
|
|
107
|
+
process.env.LYNXSHIP_KEY_PASSWORD);
|
|
108
|
+
return { r2: r2Configured, android: androidConfigured };
|
|
109
|
+
}
|
|
110
|
+
async function requireOperationalConfiguration(platform) {
|
|
111
|
+
await requireProjectRoot();
|
|
112
|
+
const status = await readConfigurationStatus();
|
|
113
|
+
assert(status.r2, "CLI_R2_REQUIRED", "Cloudflare R2 must be configured first. Run `lynxship storage configure`.");
|
|
114
|
+
if (platform !== "android")
|
|
115
|
+
return;
|
|
116
|
+
assert(status.android, "BUILD_SIGNING_REQUIRED", "Android signing must be configured first. Run `lynxship android configure` or provide an existing keystore.");
|
|
117
|
+
}
|
|
118
|
+
async function renderConfigurationFooter() {
|
|
119
|
+
if (!ui.interactive || ui.options.quiet)
|
|
120
|
+
return;
|
|
121
|
+
const status = await readConfigurationStatus();
|
|
122
|
+
const ready = status.r2 && status.android;
|
|
123
|
+
if (ready)
|
|
124
|
+
return;
|
|
125
|
+
ui.configurationStatus([
|
|
126
|
+
{
|
|
127
|
+
label: "Cloudflare R2",
|
|
128
|
+
value: status.r2 ? "configured" : "required · storage configure",
|
|
129
|
+
valueColor: status.r2 ? "green" : "yellow",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
label: "Android signing",
|
|
133
|
+
value: status.android ? "configured" : "required · android configure",
|
|
134
|
+
valueColor: status.android ? "green" : "yellow",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
label: "Operational CLI",
|
|
138
|
+
value: ready ? "ready" : "blocked until setup is complete",
|
|
139
|
+
valueColor: ready ? "green" : "red",
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
}
|
|
143
|
+
async function loadState() {
|
|
144
|
+
const repository = new JsonRepository(join(root, ".lynxship", "state.json"), {
|
|
145
|
+
builds: [],
|
|
146
|
+
submissions: [],
|
|
147
|
+
releases: [],
|
|
148
|
+
signingKey: null,
|
|
149
|
+
});
|
|
150
|
+
const state = await repository.read();
|
|
151
|
+
state.builds ??= [];
|
|
152
|
+
state.submissions ??= [];
|
|
153
|
+
state.releases ??= [];
|
|
154
|
+
state.signingKey ??= createSigningKey();
|
|
155
|
+
const builds = new BuildOrchestrator();
|
|
156
|
+
for (const job of state.builds)
|
|
157
|
+
builds.jobs.set(job.id, job);
|
|
158
|
+
const submissions = new SubmissionService();
|
|
159
|
+
for (const job of state.submissions)
|
|
160
|
+
submissions.jobs.set(job.id, job);
|
|
161
|
+
return { state, repository, builds, submissions };
|
|
162
|
+
}
|
|
163
|
+
async function saveState(state, repository, builds, submissions) {
|
|
164
|
+
state.builds = builds.list();
|
|
165
|
+
state.submissions = submissions.list();
|
|
166
|
+
await repository.write(state);
|
|
167
|
+
}
|
|
168
|
+
async function initSelfHost() {
|
|
169
|
+
const directory = join(root, ".lynxship");
|
|
170
|
+
await mkdir(directory, { recursive: true });
|
|
171
|
+
const file = join(directory, ".env");
|
|
172
|
+
if (await exists(file))
|
|
173
|
+
return { status: "unchanged", file };
|
|
174
|
+
const values = {
|
|
175
|
+
POSTGRES_PASSWORD: randomBytes(24).toString("base64url"),
|
|
176
|
+
LYNXSHIP_TOKEN: randomBytes(32).toString("base64url"),
|
|
177
|
+
};
|
|
178
|
+
await writeFile(file, `${Object.entries(values)
|
|
179
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
180
|
+
.join("\n")}\n`, { mode: 0o600 });
|
|
181
|
+
return { status: "created", file };
|
|
182
|
+
}
|
|
183
|
+
function commandTitle(command) {
|
|
184
|
+
return ({
|
|
185
|
+
init: "Initialize project",
|
|
186
|
+
doctor: "Environment doctor",
|
|
187
|
+
dev: "Rspeedy development",
|
|
188
|
+
preview: "Rspeedy preview",
|
|
189
|
+
inspect: "Rspeedy inspection",
|
|
190
|
+
profile: "Rspeedy profiling",
|
|
191
|
+
autolink: "Lynx Autolink",
|
|
192
|
+
run: "Run on device",
|
|
193
|
+
logs: "Native logs",
|
|
194
|
+
ota: "OTA diagnostics",
|
|
195
|
+
build: "Cloud Build",
|
|
196
|
+
submit: "Store Submission",
|
|
197
|
+
update: "OTA Update",
|
|
198
|
+
"self-host": "Self-host setup",
|
|
199
|
+
storage: "Cloudflare R2 setup",
|
|
200
|
+
android: "Android signing setup",
|
|
201
|
+
store: "App store submission setup",
|
|
202
|
+
}[command] ?? command);
|
|
203
|
+
}
|
|
204
|
+
function helpText() {
|
|
205
|
+
return `lynxship <command> [options]
|
|
206
|
+
|
|
207
|
+
Commands:
|
|
208
|
+
init Initialize or link a LynxShip project
|
|
209
|
+
doctor Check the local toolchain and project
|
|
210
|
+
dev Run the project's Rspeedy development server
|
|
211
|
+
preview Preview the production Lynx bundle locally
|
|
212
|
+
inspect Inspect Rspeedy/Rspack configuration
|
|
213
|
+
profile Build with Rspack profiling enabled
|
|
214
|
+
autolink check Check Lynx native-library Autolink wiring
|
|
215
|
+
autolink codegen Run the project's Native Module codegen script
|
|
216
|
+
ota doctor Check native OTA host integration
|
|
217
|
+
run Install an artifact on an Android/iOS target
|
|
218
|
+
logs Stream Android/iOS native logs
|
|
219
|
+
build Create a local/cloud build job
|
|
220
|
+
submit Submit the latest successful build
|
|
221
|
+
update Upload and publish a signed OTA update
|
|
222
|
+
self-host init Generate local self-host credentials
|
|
223
|
+
storage configure Configure Cloudflare R2 and encrypted R2 credentials
|
|
224
|
+
android configure Configure or generate encrypted Android signing credentials
|
|
225
|
+
store configure Configure Google Play or App Store Connect submission
|
|
226
|
+
|
|
227
|
+
Update options:
|
|
228
|
+
--bundle <path[,path]> Lynx bundle/assets to publish (default: discover dist/*.lynx.bundle)
|
|
229
|
+
--local Create a local contract-only update for tests
|
|
230
|
+
--policy-approval-id Required for an iOS OTA release
|
|
231
|
+
|
|
232
|
+
Global options:
|
|
233
|
+
--json Emit one stable JSON result/error object
|
|
234
|
+
--quiet Print only the final machine-relevant result
|
|
235
|
+
--verbose Include extra operational context
|
|
236
|
+
--no-color Disable ANSI colors
|
|
237
|
+
--non-interactive Never prompt; fail on missing inputs
|
|
238
|
+
--banner Show the Braille LynxShip logo in a TTY
|
|
239
|
+
--project-dir <path> Use a LynxShip project from any working directory
|
|
240
|
+
--simulator Install an iOS .app on a simulator with simctl
|
|
241
|
+
doctor --platform <p> Check Autolink for android or ios (default: android)
|
|
242
|
+
--local Use the mock submission provider for local tests only
|
|
243
|
+
|
|
244
|
+
Node support: Node 22/24 LTS or Node 26 Current. Use Node 24 LTS for production.`;
|
|
245
|
+
}
|
|
246
|
+
async function looksLikeLynxProject() {
|
|
247
|
+
const configFiles = [
|
|
248
|
+
"lynx.config.ts",
|
|
249
|
+
"lynx.config.js",
|
|
250
|
+
"lynx.config.mjs",
|
|
251
|
+
"lynx.config.cjs",
|
|
252
|
+
];
|
|
253
|
+
if (await Promise.any(configFiles.map((file) => exists(join(root, file)))))
|
|
254
|
+
return true;
|
|
255
|
+
try {
|
|
256
|
+
const packageJson = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
257
|
+
const dependencies = {
|
|
258
|
+
...packageJson.dependencies,
|
|
259
|
+
...packageJson.devDependencies,
|
|
260
|
+
};
|
|
261
|
+
return (Object.keys(dependencies).some((name) => name.startsWith("@lynx-js/")) ||
|
|
262
|
+
Object.values(packageJson.scripts ?? {}).some((script) => script.includes("rspeedy")));
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async function initializeProject() {
|
|
269
|
+
const file = join(root, "lynxship.json");
|
|
270
|
+
if (await exists(file))
|
|
271
|
+
return file;
|
|
272
|
+
await mkdir(join(root, ".lynxship"), { recursive: true });
|
|
273
|
+
await writeFile(file, `${JSON.stringify({ ...DEFAULT_CONFIG, projectId: flag("--project-id", "local_project") }, null, 2)}\n`);
|
|
274
|
+
return file;
|
|
275
|
+
}
|
|
276
|
+
async function initializeBuildProject() {
|
|
277
|
+
if (await exists(join(root, "lynxship.json")))
|
|
278
|
+
return;
|
|
279
|
+
assert(await looksLikeLynxProject(), "CLI_PROJECT_REQUIRED", "No LynxJS project was detected. Run this command from the project directory or provide `--project-dir`.");
|
|
280
|
+
ui.info("No lynxship.json found. Running lynxship init automatically…");
|
|
281
|
+
await initializeProject();
|
|
282
|
+
ui.success("Created lynxship.json");
|
|
283
|
+
}
|
|
284
|
+
function forwardedToolArgs(values) {
|
|
285
|
+
const result = [];
|
|
286
|
+
const valueFlags = new Set([
|
|
287
|
+
"--project-dir",
|
|
288
|
+
"--platform",
|
|
289
|
+
"--profile",
|
|
290
|
+
"--json",
|
|
291
|
+
]);
|
|
292
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
293
|
+
const value = values[index];
|
|
294
|
+
if (!value)
|
|
295
|
+
continue;
|
|
296
|
+
if (valueFlags.has(value)) {
|
|
297
|
+
index += 1;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if ([
|
|
301
|
+
"--quiet",
|
|
302
|
+
"--verbose",
|
|
303
|
+
"--no-color",
|
|
304
|
+
"--non-interactive",
|
|
305
|
+
"--banner",
|
|
306
|
+
"--local",
|
|
307
|
+
].includes(value))
|
|
308
|
+
continue;
|
|
309
|
+
if (value.startsWith("--project-dir="))
|
|
310
|
+
continue;
|
|
311
|
+
result.push(value);
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
async function runRspeedyCommand(subcommand, environment) {
|
|
316
|
+
await initializeBuildProject();
|
|
317
|
+
const forwarded = forwardedToolArgs(args);
|
|
318
|
+
ui.info(`Running local Rspeedy ${subcommand}…`);
|
|
319
|
+
await runRspeedy(root, subcommand, forwarded, {
|
|
320
|
+
env: environment,
|
|
321
|
+
quiet: json,
|
|
322
|
+
onOutput: (line) => ui.info(`│ ${line}`),
|
|
323
|
+
});
|
|
324
|
+
printValue({ status: "success", command: `rspeedy ${subcommand}` }, {
|
|
325
|
+
title: `Rspeedy ${subcommand}`,
|
|
326
|
+
rows: [
|
|
327
|
+
{
|
|
328
|
+
label: "Command",
|
|
329
|
+
value: `rspeedy ${subcommand}`,
|
|
330
|
+
valueColor: "blue",
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
done: `Rspeedy ${subcommand} completed successfully.`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
async function runAutolinkCodegen() {
|
|
337
|
+
const libraryDirectory = resolve(root, flag("--library-dir", "."));
|
|
338
|
+
const packageJson = JSON.parse(await readFile(join(libraryDirectory, "package.json"), "utf8"));
|
|
339
|
+
assert(packageJson.scripts?.codegen, "LYNX_CODEGEN_SCRIPT_REQUIRED", "No codegen script was found. Add the official lynx-autolink-codegen script to the native library package.");
|
|
340
|
+
const manager = packageManagerCommand(libraryDirectory);
|
|
341
|
+
ui.info("Running the project's official Native Module codegen script…");
|
|
342
|
+
await runProcess(manager.command, ["run", "codegen"], {
|
|
343
|
+
cwd: libraryDirectory,
|
|
344
|
+
quiet: json,
|
|
345
|
+
onOutput: (line) => ui.info(`│ ${line}`),
|
|
346
|
+
});
|
|
347
|
+
printValue({ status: "success", directory: libraryDirectory }, {
|
|
348
|
+
title: "Lynx Autolink codegen",
|
|
349
|
+
rows: [{ label: "Library", value: libraryDirectory, valueColor: "blue" }],
|
|
350
|
+
done: "Native Module specifications were generated successfully.",
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
async function runDevice() {
|
|
354
|
+
const platform = platformValue(flag("--platform", "android"));
|
|
355
|
+
const artifact = flag("--artifact");
|
|
356
|
+
const artifactPath = artifact
|
|
357
|
+
? resolve(root, artifact)
|
|
358
|
+
: (await loadState()).builds
|
|
359
|
+
.list()
|
|
360
|
+
.filter((job) => job.platform === platform && job.state === "success")
|
|
361
|
+
.at(-1)?.artifact?.path;
|
|
362
|
+
assert(artifactPath, "DEVICE_ARTIFACT_REQUIRED", "Pass --artifact or create a successful build first");
|
|
363
|
+
if (platform === "android") {
|
|
364
|
+
assert(commandExists("adb"), "ANDROID_ADB_REQUIRED", "adb was not found in PATH");
|
|
365
|
+
const device = flag("--device");
|
|
366
|
+
const argsForAdb = device
|
|
367
|
+
? ["-s", device, "install", "-r", artifactPath]
|
|
368
|
+
: ["install", "-r", artifactPath];
|
|
369
|
+
await runProcess("adb", argsForAdb, {
|
|
370
|
+
cwd: root,
|
|
371
|
+
quiet: json,
|
|
372
|
+
onOutput: (line) => ui.info(`│ ${line}`),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
assert(process.platform === "darwin", "IOS_MACOS_REQUIRED", "iOS device/simulator commands require macOS");
|
|
377
|
+
assert(commandExists("xcrun"), "IOS_XCRUN_REQUIRED", "xcrun was not found in PATH");
|
|
378
|
+
const device = flag("--device", "booted");
|
|
379
|
+
const simulator = args.includes("--simulator");
|
|
380
|
+
if (simulator || artifactPath.endsWith(".app")) {
|
|
381
|
+
await runProcess("xcrun", ["simctl", "install", device, artifactPath], {
|
|
382
|
+
cwd: root,
|
|
383
|
+
quiet: json,
|
|
384
|
+
onOutput: (line) => ui.info(`│ ${line}`),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
assert(device !== "booted", "IOS_DEVICE_REQUIRED", "A physical iOS install requires --device <device-identifier>; use --simulator for a booted simulator.");
|
|
389
|
+
await runProcess("xcrun", [
|
|
390
|
+
"devicectl",
|
|
391
|
+
"device",
|
|
392
|
+
"install",
|
|
393
|
+
"app",
|
|
394
|
+
"--device",
|
|
395
|
+
device,
|
|
396
|
+
artifactPath,
|
|
397
|
+
], {
|
|
398
|
+
cwd: root,
|
|
399
|
+
quiet: json,
|
|
400
|
+
onOutput: (line) => ui.info(`│ ${line}`),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
printValue({ status: "installed", platform, artifact: artifactPath }, {
|
|
405
|
+
title: "Device install",
|
|
406
|
+
rows: [{ label: "Artifact", value: artifactPath, valueColor: "green" }],
|
|
407
|
+
done: "Artifact installed on the selected target.",
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
async function streamNativeLogs() {
|
|
411
|
+
const platform = platformValue(flag("--platform", "android"));
|
|
412
|
+
const device = flag("--device", platform === "android" ? undefined : "booted");
|
|
413
|
+
if (platform === "android") {
|
|
414
|
+
assert(commandExists("adb"), "ANDROID_ADB_REQUIRED", "adb was not found in PATH");
|
|
415
|
+
await runProcess("adb", device ? ["-s", device, "logcat"] : ["logcat"], {
|
|
416
|
+
cwd: root,
|
|
417
|
+
quiet: json,
|
|
418
|
+
onOutput: (line) => ui.info(`│ ${line}`),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
assert(process.platform === "darwin", "IOS_MACOS_REQUIRED", "iOS logs require macOS");
|
|
423
|
+
assert(commandExists("xcrun"), "IOS_XCRUN_REQUIRED", "xcrun was not found in PATH");
|
|
424
|
+
assert(device !== "booted", "IOS_DEVICE_LOGS_UNSUPPORTED", "Use --device <simulator-identifier> for iOS simulator logs.");
|
|
425
|
+
await runProcess("xcrun", [
|
|
426
|
+
"simctl",
|
|
427
|
+
"spawn",
|
|
428
|
+
device ?? "booted",
|
|
429
|
+
"log",
|
|
430
|
+
"stream",
|
|
431
|
+
"--style",
|
|
432
|
+
"compact",
|
|
433
|
+
"--level",
|
|
434
|
+
"debug",
|
|
435
|
+
], { cwd: root, quiet: json, onOutput: (line) => ui.info(`│ ${line}`) });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async function main() {
|
|
439
|
+
const command = args.shift() ?? "help";
|
|
440
|
+
const wantsHelp = command === "help" ||
|
|
441
|
+
command === "--help" ||
|
|
442
|
+
command === "-h" ||
|
|
443
|
+
rawArgs.includes("--help") ||
|
|
444
|
+
rawArgs.includes("-h");
|
|
445
|
+
const shouldShowBanner = !json &&
|
|
446
|
+
(wantsHelp || rawArgs.length === 0 || rawArgs.includes("--banner"));
|
|
447
|
+
if (shouldShowBanner)
|
|
448
|
+
ui.banner();
|
|
449
|
+
if (wantsHelp) {
|
|
450
|
+
if (ui.interactive)
|
|
451
|
+
ui.header("Help");
|
|
452
|
+
printValue(helpText());
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
ui.header(commandTitle(command));
|
|
456
|
+
ui.debug(`cwd=${root}`);
|
|
457
|
+
if (command === "init") {
|
|
458
|
+
ui.info("Scanning project structure…");
|
|
459
|
+
const file = join(root, "lynxship.json");
|
|
460
|
+
if (await exists(file)) {
|
|
461
|
+
ui.warn("lynxship.json already exists; leaving the project unchanged");
|
|
462
|
+
printValue({ status: "unchanged", file }, {
|
|
463
|
+
title: "Project",
|
|
464
|
+
rows: [{ label: "Configuration", value: file, valueColor: "muted" }],
|
|
465
|
+
done: "Project already initialized.",
|
|
466
|
+
});
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
await initializeProject();
|
|
470
|
+
ui.success("Created lynxship.json");
|
|
471
|
+
printValue({ status: "created", file }, {
|
|
472
|
+
title: "Initialized",
|
|
473
|
+
rows: [
|
|
474
|
+
{
|
|
475
|
+
label: "Project ID",
|
|
476
|
+
value: flag("--project-id", "local_project"),
|
|
477
|
+
valueColor: "purple",
|
|
478
|
+
},
|
|
479
|
+
{ label: "Configuration", value: file, valueColor: "muted" },
|
|
480
|
+
],
|
|
481
|
+
done: "Project initialized. Run lynxship build to get started.",
|
|
482
|
+
});
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (command === "doctor") {
|
|
486
|
+
const config = await loadConfig(root);
|
|
487
|
+
const configuration = await readConfigurationStatus();
|
|
488
|
+
const doctorPlatform = platformValue(flag("--platform", "android"));
|
|
489
|
+
const autolink = await inspectAutolink(root);
|
|
490
|
+
const autolinkForPlatform = autolink[doctorPlatform];
|
|
491
|
+
const lockfile = await findLockfile(root);
|
|
492
|
+
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
493
|
+
const checks = [
|
|
494
|
+
{
|
|
495
|
+
name: "node",
|
|
496
|
+
ok: nodeMajor >= 22 && nodeMajor % 2 === 0,
|
|
497
|
+
value: nodeMajor >= 22 && nodeMajor % 2 === 0
|
|
498
|
+
? process.version
|
|
499
|
+
: `${process.version} · use an even Active/Current LTS line (22, 24 or 26)`,
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
name: "package-manager-lockfile",
|
|
503
|
+
ok: Boolean(lockfile),
|
|
504
|
+
value: lockfile ?? "missing",
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
name: "lynxship.json",
|
|
508
|
+
ok: await exists(join(root, "lynxship.json")),
|
|
509
|
+
value: config.projectId ? "found" : "missing",
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
name: "cloudflare-r2",
|
|
513
|
+
ok: configuration.r2,
|
|
514
|
+
value: configuration.r2
|
|
515
|
+
? "configured"
|
|
516
|
+
: "run lynxship storage configure",
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
name: doctorPlatform === "android" ? "android-signing" : "ios-host",
|
|
520
|
+
ok: doctorPlatform === "android"
|
|
521
|
+
? configuration.android
|
|
522
|
+
: process.platform === "darwin" && hasIosHost(root),
|
|
523
|
+
value: doctorPlatform === "android"
|
|
524
|
+
? configuration.android
|
|
525
|
+
? "configured"
|
|
526
|
+
: "run lynxship android configure"
|
|
527
|
+
: process.platform === "darwin" && hasIosHost(root)
|
|
528
|
+
? "Xcode host found"
|
|
529
|
+
: "macOS/Xcode host required",
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
name: `lynx-autolink-${doctorPlatform}`,
|
|
533
|
+
ok: autolinkForPlatform.ready,
|
|
534
|
+
value: autolinkForPlatform.reason,
|
|
535
|
+
},
|
|
536
|
+
];
|
|
537
|
+
const result = { ok: checks.every((check) => check.ok), checks };
|
|
538
|
+
if (!result.ok)
|
|
539
|
+
ui.warn("One or more environment checks need attention");
|
|
540
|
+
printValue(result, {
|
|
541
|
+
title: "Doctor result",
|
|
542
|
+
rows: checks.map((check) => ({
|
|
543
|
+
label: check.name,
|
|
544
|
+
value: `${check.ok ? "pass" : "fail"} · ${check.value}`,
|
|
545
|
+
valueColor: check.ok ? "green" : "red",
|
|
546
|
+
})),
|
|
547
|
+
done: result.ok
|
|
548
|
+
? "Environment looks ready."
|
|
549
|
+
: "Fix the failed checks before building.",
|
|
550
|
+
});
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (["dev", "preview", "inspect"].includes(command)) {
|
|
554
|
+
await runRspeedyCommand(command);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (command === "profile") {
|
|
558
|
+
await runRspeedyCommand("build", {
|
|
559
|
+
...process.env,
|
|
560
|
+
RSPACK_PROFILE: process.env.RSPACK_PROFILE ?? "ALL",
|
|
561
|
+
});
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (command === "autolink") {
|
|
565
|
+
const subcommand = args.shift() ?? "check";
|
|
566
|
+
assert(["check", "codegen"].includes(subcommand), "CLI_AUTOLINK_COMMAND", "Use `lynxship autolink check` or `lynxship autolink codegen`");
|
|
567
|
+
if (subcommand === "codegen") {
|
|
568
|
+
await runAutolinkCodegen();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const platform = platformValue(flag("--platform", "android"));
|
|
572
|
+
const status = (await inspectAutolink(root))[platform];
|
|
573
|
+
printValue(status, {
|
|
574
|
+
title: `Lynx Autolink · ${platform}`,
|
|
575
|
+
rows: [
|
|
576
|
+
{
|
|
577
|
+
label: "Required",
|
|
578
|
+
value: String(status.required),
|
|
579
|
+
valueColor: "blue",
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
label: "Ready",
|
|
583
|
+
value: String(status.ready),
|
|
584
|
+
valueColor: status.ready ? "green" : "red",
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
label: "Status",
|
|
588
|
+
value: status.reason,
|
|
589
|
+
valueColor: status.ready ? "green" : "yellow",
|
|
590
|
+
},
|
|
591
|
+
],
|
|
592
|
+
done: status.ready
|
|
593
|
+
? "Autolink host integration is ready."
|
|
594
|
+
: "Autolink host integration needs attention.",
|
|
595
|
+
});
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (command === "ota") {
|
|
599
|
+
assert((args.shift() ?? "doctor") === "doctor", "CLI_OTA_COMMAND", "Only `lynxship ota doctor` is available");
|
|
600
|
+
const platform = platformValue(flag("--platform", "android"));
|
|
601
|
+
const status = await inspectOtaHost(root, platform);
|
|
602
|
+
printValue(status, {
|
|
603
|
+
title: `OTA host · ${platform}`,
|
|
604
|
+
rows: [
|
|
605
|
+
{
|
|
606
|
+
label: "Native files",
|
|
607
|
+
value: String(status.files.length),
|
|
608
|
+
valueColor: "blue",
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
label: "Missing hooks",
|
|
612
|
+
value: status.missing.length ? status.missing.join(", ") : "none",
|
|
613
|
+
valueColor: status.missing.length ? "red" : "green",
|
|
614
|
+
},
|
|
615
|
+
],
|
|
616
|
+
done: status.missing.length === 0
|
|
617
|
+
? "Native OTA integration looks ready."
|
|
618
|
+
: "Integrate the LynxShip OTA client before using device OTA.",
|
|
619
|
+
});
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (command === "run") {
|
|
623
|
+
await requireProjectRoot();
|
|
624
|
+
await runDevice();
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (command === "logs") {
|
|
628
|
+
await requireProjectRoot();
|
|
629
|
+
await streamNativeLogs();
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (command === "self-host") {
|
|
633
|
+
assert((args.shift() ?? "init") === "init", "CLI_SELF_HOST_COMMAND", "Only self-host init is available in this package");
|
|
634
|
+
ui.info("Preparing local self-host credentials…");
|
|
635
|
+
const result = await initSelfHost();
|
|
636
|
+
ui.success(result.status === "created"
|
|
637
|
+
? "Created protected local environment file"
|
|
638
|
+
: "Existing environment file preserved");
|
|
639
|
+
printValue(result, {
|
|
640
|
+
title: "Self-host setup",
|
|
641
|
+
rows: [
|
|
642
|
+
{
|
|
643
|
+
label: "Status",
|
|
644
|
+
value: result.status,
|
|
645
|
+
valueColor: result.status === "created" ? "green" : "yellow",
|
|
646
|
+
},
|
|
647
|
+
{ label: "Environment", value: result.file, valueColor: "muted" },
|
|
648
|
+
],
|
|
649
|
+
done: "Self-host environment is ready.",
|
|
650
|
+
});
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (command === "storage") {
|
|
654
|
+
assert(ui.interactive, "CLI_INTERACTIVE_REQUIRED", "Run `lynxship storage configure` in an interactive terminal");
|
|
655
|
+
assert((args.shift() ?? "configure") === "configure", "CLI_STORAGE_COMMAND", "Only storage configure is available");
|
|
656
|
+
ui.info("Configuring Cloudflare R2. Secret fields will stay invisible…");
|
|
657
|
+
const config = await configureR2(root);
|
|
658
|
+
ui.success(`R2 bucket verified: ${config.bucket}`);
|
|
659
|
+
printValue({
|
|
660
|
+
status: "configured",
|
|
661
|
+
provider: "cloudflare-r2",
|
|
662
|
+
bucket: config.bucket,
|
|
663
|
+
}, {
|
|
664
|
+
title: "Cloudflare R2",
|
|
665
|
+
rows: [
|
|
666
|
+
{ label: "Provider", value: "Cloudflare R2", valueColor: "orange" },
|
|
667
|
+
{ label: "Bucket", value: config.bucket, valueColor: "blue" },
|
|
668
|
+
{
|
|
669
|
+
label: "Credentials",
|
|
670
|
+
value: credentialStorageDescription(),
|
|
671
|
+
valueColor: "green",
|
|
672
|
+
},
|
|
673
|
+
],
|
|
674
|
+
done: "R2 is ready for signed build artifacts.",
|
|
675
|
+
});
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (command === "android") {
|
|
679
|
+
assert(ui.interactive, "CLI_INTERACTIVE_REQUIRED", "Run `lynxship android configure` in an interactive terminal");
|
|
680
|
+
assert((args.shift() ?? "configure") === "configure", "CLI_ANDROID_COMMAND", "Only android configure is available");
|
|
681
|
+
ui.info("Configuring Android signing. Secret fields will stay invisible…");
|
|
682
|
+
const result = await configureAndroid(root);
|
|
683
|
+
ui.success(result.generated
|
|
684
|
+
? `Android keystore generated: ${result.keystorePath}`
|
|
685
|
+
: `Android signing credentials saved in ${credentialStorageDescription()}`);
|
|
686
|
+
printValue({ status: "configured", provider: "android-keystore" }, {
|
|
687
|
+
title: "Android signing",
|
|
688
|
+
rows: [
|
|
689
|
+
{ label: "Keystore", value: "Configured", valueColor: "green" },
|
|
690
|
+
{
|
|
691
|
+
label: "Storage",
|
|
692
|
+
value: credentialStorageDescription(),
|
|
693
|
+
valueColor: "green",
|
|
694
|
+
},
|
|
695
|
+
],
|
|
696
|
+
done: "Android signing is ready for the next build.",
|
|
697
|
+
});
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (command === "store") {
|
|
701
|
+
assert(ui.interactive, "CLI_INTERACTIVE_REQUIRED", "Run store configure in an interactive terminal");
|
|
702
|
+
assert((args.shift() ?? "configure") === "configure", "CLI_STORE_COMMAND", "Only store configure is available");
|
|
703
|
+
const platform = platformValue(flag("--platform", "android"));
|
|
704
|
+
ui.info(platform === "android"
|
|
705
|
+
? "Configuring Google Play submission. Secret fields will stay invisible…"
|
|
706
|
+
: "Configuring App Store Connect submission. Private key contents stay protected…");
|
|
707
|
+
if (platform === "android")
|
|
708
|
+
await configureGooglePlay(root);
|
|
709
|
+
else
|
|
710
|
+
await configureAppStoreConnect(root);
|
|
711
|
+
ui.success(platform === "android"
|
|
712
|
+
? "Google Play submission credentials saved securely"
|
|
713
|
+
: "App Store Connect submission credentials saved securely");
|
|
714
|
+
printValue({
|
|
715
|
+
status: "configured",
|
|
716
|
+
provider: platform === "android" ? "google-play" : "app-store-connect",
|
|
717
|
+
storage: credentialStorageDescription(),
|
|
718
|
+
}, {
|
|
719
|
+
title: platform === "android"
|
|
720
|
+
? "Google Play submission"
|
|
721
|
+
: "App Store Connect submission",
|
|
722
|
+
rows: [
|
|
723
|
+
{
|
|
724
|
+
label: "Provider",
|
|
725
|
+
value: platform === "android" ? "Google Play" : "App Store Connect",
|
|
726
|
+
valueColor: "blue",
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
label: "Credentials",
|
|
730
|
+
value: credentialStorageDescription(),
|
|
731
|
+
valueColor: "green",
|
|
732
|
+
},
|
|
733
|
+
],
|
|
734
|
+
done: "Store submission is ready for the next build.",
|
|
735
|
+
});
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (command === "build")
|
|
739
|
+
await initializeBuildProject();
|
|
740
|
+
const { state, repository, builds, submissions } = await loadState();
|
|
741
|
+
if (command === "submit") {
|
|
742
|
+
const config = await loadConfig(root);
|
|
743
|
+
const platform = platformValue(flag("--platform", "android"));
|
|
744
|
+
await requireOperationalConfiguration(platform);
|
|
745
|
+
const credentials = await loadCredentials(root);
|
|
746
|
+
const localMode = args.includes("--local") || process.env.LYNXSHIP_SUBMIT_MODE === "mock";
|
|
747
|
+
const storeConfigured = platform === "android"
|
|
748
|
+
? Boolean(credentials.googlePlay)
|
|
749
|
+
: Boolean(credentials.appStoreConnect);
|
|
750
|
+
assert(localMode || storeConfigured, "STORE_SUBMISSION_REQUIRED", platform === "android"
|
|
751
|
+
? "Google Play is not configured. Run store configure --platform android."
|
|
752
|
+
: "App Store Connect is not configured. Run store configure --platform ios.");
|
|
753
|
+
const candidate = builds
|
|
754
|
+
.list()
|
|
755
|
+
.filter((job) => job.platform === platform && job.state === "success")
|
|
756
|
+
.at(-1);
|
|
757
|
+
assert(candidate, "BUILD_REQUIRED", "A successful build is required");
|
|
758
|
+
assert(localMode || candidate.artifact?.path, "STORE_ARTIFACT_REQUIRED", "A local signed artifact path is required for store submission");
|
|
759
|
+
const latest = args.includes("--latest");
|
|
760
|
+
const spinner = ui.spinner("Submitting artifact…");
|
|
761
|
+
try {
|
|
762
|
+
const controlPlaneSubmission = candidate.artifact?.path
|
|
763
|
+
? await submitRealArtifact(config, state, candidate, latest)
|
|
764
|
+
: await submissions.submit({
|
|
765
|
+
projectId: config.projectId ?? "local_project",
|
|
766
|
+
organizationId: "local_org",
|
|
767
|
+
platform,
|
|
768
|
+
artifact: candidate.artifact ?? { hash: `local-${candidate.id}` },
|
|
769
|
+
latest,
|
|
770
|
+
buildId: latest ? null : candidate.id,
|
|
771
|
+
});
|
|
772
|
+
const storeResult = !localMode && candidate.artifact?.path
|
|
773
|
+
? platform === "android"
|
|
774
|
+
? await new GooglePlayApiProvider(credentials.googlePlay).submit({
|
|
775
|
+
platform,
|
|
776
|
+
path: candidate.artifact.path,
|
|
777
|
+
hash: candidate.artifact.hash,
|
|
778
|
+
})
|
|
779
|
+
: await new AppStoreConnectApiProvider(credentials.appStoreConnect).submit({
|
|
780
|
+
platform,
|
|
781
|
+
path: candidate.artifact.path,
|
|
782
|
+
hash: candidate.artifact.hash,
|
|
783
|
+
})
|
|
784
|
+
: undefined;
|
|
785
|
+
const submission = storeResult
|
|
786
|
+
? {
|
|
787
|
+
...controlPlaneSubmission,
|
|
788
|
+
store: storeResult,
|
|
789
|
+
}
|
|
790
|
+
: controlPlaneSubmission;
|
|
791
|
+
const result = submission;
|
|
792
|
+
spinner.succeed(storeResult
|
|
793
|
+
? "Artifact submitted to the configured app store"
|
|
794
|
+
: "Local submission job accepted");
|
|
795
|
+
await saveState(state, repository, builds, submissions);
|
|
796
|
+
printValue(submission, {
|
|
797
|
+
title: "Submission result",
|
|
798
|
+
rows: [
|
|
799
|
+
{
|
|
800
|
+
label: "Submission ID",
|
|
801
|
+
value: result.id ?? "remote",
|
|
802
|
+
valueColor: "purple",
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
label: "Platform",
|
|
806
|
+
value: result.platform ?? platform,
|
|
807
|
+
valueColor: "blue",
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
label: "Status",
|
|
811
|
+
value: result.status ?? "accepted",
|
|
812
|
+
valueColor: "green",
|
|
813
|
+
},
|
|
814
|
+
],
|
|
815
|
+
done: "App submitted to the configured provider.",
|
|
816
|
+
});
|
|
817
|
+
if (result.downloadUrl)
|
|
818
|
+
ui.downloadArtifact(result.downloadUrl, result.downloadExpiresAt);
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
spinner.fail(error instanceof Error ? error.message : "Submission failed");
|
|
822
|
+
throw error;
|
|
823
|
+
}
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (command === "update") {
|
|
827
|
+
const config = await loadConfig(root);
|
|
828
|
+
const platform = platformValue(flag("--platform", "android"));
|
|
829
|
+
await requireOperationalConfiguration(platform);
|
|
830
|
+
const projectId = config.projectId ?? "local_project";
|
|
831
|
+
const localMode = args.includes("--local") || process.env.LYNXSHIP_SUBMIT_MODE === "mock";
|
|
832
|
+
const explicitBundles = flag("--bundle");
|
|
833
|
+
const runtime = await inspectRuntimeFingerprint(root, platform, config);
|
|
834
|
+
const progress = ui.progress("Sign manifest");
|
|
835
|
+
try {
|
|
836
|
+
if (localMode) {
|
|
837
|
+
const key = state.signingKey ?? createSigningKey();
|
|
838
|
+
const data = flag("--bundle", "local-bundle");
|
|
839
|
+
const manifest = {
|
|
840
|
+
protocolVersion: 1,
|
|
841
|
+
projectId,
|
|
842
|
+
channel: config.update?.channel ?? "production",
|
|
843
|
+
platform,
|
|
844
|
+
runtimeVersion: runtime.value,
|
|
845
|
+
sequence: state.releases.length + 1,
|
|
846
|
+
keyId: key.keyId,
|
|
847
|
+
assets: [
|
|
848
|
+
{
|
|
849
|
+
path: "main.js",
|
|
850
|
+
hash: sha256(data),
|
|
851
|
+
size: Buffer.byteLength(data),
|
|
852
|
+
},
|
|
853
|
+
],
|
|
854
|
+
};
|
|
855
|
+
const release = {
|
|
856
|
+
id: createId("rel"),
|
|
857
|
+
manifest,
|
|
858
|
+
signature: signManifest(manifest, key.privateKey),
|
|
859
|
+
message: flag("--message", "local update"),
|
|
860
|
+
createdAt: new Date().toISOString(),
|
|
861
|
+
};
|
|
862
|
+
state.releases.push(release);
|
|
863
|
+
await saveState(state, repository, builds, submissions);
|
|
864
|
+
progress.update(100);
|
|
865
|
+
printValue(release, {
|
|
866
|
+
title: "OTA update published locally",
|
|
867
|
+
rows: [
|
|
868
|
+
{ label: "Release ID", value: release.id, valueColor: "purple" },
|
|
869
|
+
{
|
|
870
|
+
label: "Platform",
|
|
871
|
+
value: release.manifest.platform,
|
|
872
|
+
valueColor: "blue",
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
label: "Signature",
|
|
876
|
+
value: "Ed25519 signed",
|
|
877
|
+
valueColor: "muted",
|
|
878
|
+
},
|
|
879
|
+
],
|
|
880
|
+
done: "Local update created. Use a real API and bundle for devices.",
|
|
881
|
+
});
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const bundlePaths = await otaAssetPaths(root, explicitBundles);
|
|
885
|
+
for (const bundlePath of bundlePaths)
|
|
886
|
+
assert(await exists(bundlePath), "OTA_BUNDLE_REQUIRED", `Bundle not found: ${bundlePath}. Build the Lynx bundle first or pass --bundle.`);
|
|
887
|
+
assertCompatibleBinaryBuild(builds, platform, runtime.value);
|
|
888
|
+
const releaseId = createId("ota");
|
|
889
|
+
progress.update(25, `Uploading ${bundlePaths.length} Lynx asset(s) to Cloudflare R2…`);
|
|
890
|
+
const uploadedAssets = [];
|
|
891
|
+
for (const [index, bundlePath] of bundlePaths.entries()) {
|
|
892
|
+
const uploaded = await uploadR2Artifact(root, projectId, releaseId, bundlePath, "application/octet-stream", otaAssetName(root, bundlePath));
|
|
893
|
+
uploadedAssets.push({
|
|
894
|
+
path: otaAssetName(root, bundlePath),
|
|
895
|
+
hash: uploaded.hash,
|
|
896
|
+
size: uploaded.size,
|
|
897
|
+
url: uploaded.url,
|
|
898
|
+
});
|
|
899
|
+
progress.update(25 + Math.round(((index + 1) / bundlePaths.length) * 35), `Uploaded ${index + 1}/${bundlePaths.length} Lynx asset(s)…`);
|
|
900
|
+
}
|
|
901
|
+
progress.update(65, "Publishing signed OTA release through LynxShip API…");
|
|
902
|
+
const remoteRelease = (await publishOtaRelease(config, state, {
|
|
903
|
+
projectId,
|
|
904
|
+
organizationId: "local_org",
|
|
905
|
+
channel: config.update?.channel ?? "production",
|
|
906
|
+
platform,
|
|
907
|
+
runtimeVersion: runtime.value,
|
|
908
|
+
assets: uploadedAssets,
|
|
909
|
+
message: flag("--message", "OTA update"),
|
|
910
|
+
rollout: config.update?.rollout?.defaultPercentage ?? 100,
|
|
911
|
+
policyApprovalId: flag("--policy-approval-id"),
|
|
912
|
+
}));
|
|
913
|
+
const publicKey = await fetchOtaPublicKey(config);
|
|
914
|
+
state.releases.push(remoteRelease);
|
|
915
|
+
await saveState(state, repository, builds, submissions);
|
|
916
|
+
progress.update(100, "OTA release signed and published");
|
|
917
|
+
printValue({ ...remoteRelease, signingKey: publicKey }, {
|
|
918
|
+
title: "OTA update published",
|
|
919
|
+
rows: [
|
|
920
|
+
{
|
|
921
|
+
label: "Release ID",
|
|
922
|
+
value: remoteRelease.id,
|
|
923
|
+
valueColor: "purple",
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
label: "Platform",
|
|
927
|
+
value: remoteRelease.manifest.platform,
|
|
928
|
+
valueColor: "blue",
|
|
929
|
+
},
|
|
930
|
+
{ label: "Bundle", value: "Cloudflare R2", valueColor: "orange" },
|
|
931
|
+
{
|
|
932
|
+
label: "Signature",
|
|
933
|
+
value: "Ed25519 signed by API",
|
|
934
|
+
valueColor: "green",
|
|
935
|
+
},
|
|
936
|
+
],
|
|
937
|
+
done: "Devices can check and install this compatible OTA release.",
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
finally {
|
|
941
|
+
progress.stop();
|
|
942
|
+
}
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
assert(command === "build", "CLI_COMMAND", `Unknown command: ${command}`);
|
|
946
|
+
const subcommand = args[0] && !args[0].startsWith("--") ? args.shift() : "create";
|
|
947
|
+
const platform = platformValue(flag("--platform", "android"));
|
|
948
|
+
await requireOperationalConfiguration(platform);
|
|
949
|
+
if (subcommand === "list") {
|
|
950
|
+
printValue(builds.list());
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const id = args[0];
|
|
954
|
+
if (subcommand === "status") {
|
|
955
|
+
printValue(builds.get(id ?? ""));
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (subcommand === "cancel") {
|
|
959
|
+
const job = builds.cancel(id ?? "");
|
|
960
|
+
await saveState(state, repository, builds, submissions);
|
|
961
|
+
printValue(job);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
if (subcommand === "retry") {
|
|
965
|
+
const job = builds.retry(id ?? "");
|
|
966
|
+
await saveState(state, repository, builds, submissions);
|
|
967
|
+
printValue(job);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
assert(subcommand === "create", "CLI_BUILD_COMMAND", `Unknown build command: ${subcommand}`);
|
|
971
|
+
const config = await loadConfig(root);
|
|
972
|
+
const profile = flag("--profile", "production");
|
|
973
|
+
await requireAutolinkReady(root, platform);
|
|
974
|
+
const runtime = await inspectRuntimeFingerprint(root, platform, config);
|
|
975
|
+
const job = await builds.create({
|
|
976
|
+
projectId: config.projectId ?? "local_project",
|
|
977
|
+
organizationId: "local_org",
|
|
978
|
+
platform,
|
|
979
|
+
profile,
|
|
980
|
+
sourceHash: createHash("sha256").update(root).digest("hex"),
|
|
981
|
+
runtimeVersion: runtime.value,
|
|
982
|
+
runtimeInputs: runtime.inputs,
|
|
983
|
+
});
|
|
984
|
+
ui.info(`Using profile: ${profile} · platform: ${platform}`);
|
|
985
|
+
const progress = ui.progress("Build execution");
|
|
986
|
+
try {
|
|
987
|
+
progress.update(undefined, "Preparing build pipeline…");
|
|
988
|
+
if (!args.includes("--no-wait")) {
|
|
989
|
+
const realAndroid = platform === "android" && (await hasAndroidHost(root));
|
|
990
|
+
const realIos = platform === "ios" && hasIosHost(root, config.build?.[profile]);
|
|
991
|
+
if (platform === "ios" && !realIos && !args.includes("--local"))
|
|
992
|
+
assert(false, "IOS_HOST_REQUIRED", "A macOS Xcode host is required for a real iOS build. No local fake iOS build is created.");
|
|
993
|
+
if (realAndroid) {
|
|
994
|
+
await runRealAndroidBuild(job, {
|
|
995
|
+
root,
|
|
996
|
+
profile: config.build?.[profile] ?? {},
|
|
997
|
+
quiet: json,
|
|
998
|
+
onEvent: (message) => progress.event(message),
|
|
999
|
+
onProgress: (value, label) => progress.update(value, label),
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
else if (realIos) {
|
|
1003
|
+
await runRealIosBuild(job, {
|
|
1004
|
+
root,
|
|
1005
|
+
profile: config.build?.[profile] ?? {},
|
|
1006
|
+
quiet: json,
|
|
1007
|
+
onEvent: (message) => progress.event(message),
|
|
1008
|
+
onProgress: (value, label) => progress.update(value, label),
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
else {
|
|
1012
|
+
await builds.run(job.id);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
progress.update(100);
|
|
1016
|
+
await saveState(state, repository, builds, submissions);
|
|
1017
|
+
}
|
|
1018
|
+
catch (error) {
|
|
1019
|
+
await saveState(state, repository, builds, submissions);
|
|
1020
|
+
throw error;
|
|
1021
|
+
}
|
|
1022
|
+
finally {
|
|
1023
|
+
progress.stop();
|
|
1024
|
+
}
|
|
1025
|
+
const result = builds.get(job.id);
|
|
1026
|
+
printValue(result, {
|
|
1027
|
+
title: "Build result",
|
|
1028
|
+
rows: [
|
|
1029
|
+
{ label: "Build ID", value: result.id, valueColor: "purple" },
|
|
1030
|
+
{ label: "Platform", value: result.platform, valueColor: "blue" },
|
|
1031
|
+
{ label: "Profile", value: result.profile, valueColor: "text" },
|
|
1032
|
+
{
|
|
1033
|
+
label: "Status",
|
|
1034
|
+
value: result.state,
|
|
1035
|
+
valueColor: result.state === "success" ? "green" : "yellow",
|
|
1036
|
+
},
|
|
1037
|
+
],
|
|
1038
|
+
done: result.state === "success"
|
|
1039
|
+
? "Build complete. Run lynxship submit to publish."
|
|
1040
|
+
: "Build queued.",
|
|
1041
|
+
});
|
|
1042
|
+
if (result.state === "success" && result.artifact?.url)
|
|
1043
|
+
ui.downloadArtifact(result.artifact.url, result.artifact.expiresAt);
|
|
1044
|
+
}
|
|
1045
|
+
function exitCode(error) {
|
|
1046
|
+
const code = error.code;
|
|
1047
|
+
if (code?.startsWith("CLI_") || code?.startsWith("CONFIG_"))
|
|
1048
|
+
return 2;
|
|
1049
|
+
if (code === "BUILD_SIGNING_REQUIRED")
|
|
1050
|
+
return 2;
|
|
1051
|
+
if (code?.startsWith("AUTH_"))
|
|
1052
|
+
return 4;
|
|
1053
|
+
if (code?.startsWith("BUILD_"))
|
|
1054
|
+
return 5;
|
|
1055
|
+
if (code?.startsWith("SUBMISSION_"))
|
|
1056
|
+
return 6;
|
|
1057
|
+
if (code?.startsWith("OTA_"))
|
|
1058
|
+
return 7;
|
|
1059
|
+
return 1;
|
|
1060
|
+
}
|
|
1061
|
+
void main()
|
|
1062
|
+
.catch((error) => {
|
|
1063
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1064
|
+
if (json) {
|
|
1065
|
+
console.log(JSON.stringify({
|
|
1066
|
+
error: message,
|
|
1067
|
+
code: error.code ?? "CLI_ERROR",
|
|
1068
|
+
}));
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
ui.error(message);
|
|
1072
|
+
}
|
|
1073
|
+
process.exitCode = exitCode(error);
|
|
1074
|
+
})
|
|
1075
|
+
.finally(async () => {
|
|
1076
|
+
try {
|
|
1077
|
+
await renderConfigurationFooter();
|
|
1078
|
+
}
|
|
1079
|
+
catch {
|
|
1080
|
+
// Configuration status must never hide the original command result.
|
|
1081
|
+
}
|
|
1082
|
+
});
|