@lynxship/cli 0.1.1 → 0.1.3
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 +50 -6
- package/dist/android-build.d.ts +10 -0
- package/dist/android-build.d.ts.map +1 -1
- package/dist/android-build.js +91 -31
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +3 -1
- package/dist/index.js +220 -33
- package/dist/ios-build.d.ts +1 -0
- package/dist/ios-build.d.ts.map +1 -1
- package/dist/ios-build.js +33 -18
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +5 -2
- package/dist/process-runner.d.ts +1 -0
- package/dist/process-runner.d.ts.map +1 -1
- package/dist/process-runner.js +44 -5
- package/dist/r2.d.ts.map +1 -1
- package/dist/r2.js +2 -1
- package/dist/remote.d.ts +8 -0
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +14 -2
- package/dist/secure-store.d.ts.map +1 -1
- package/dist/secure-store.js +90 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -27,11 +27,11 @@ The package exposes the `lynxship` executable.
|
|
|
27
27
|
|
|
28
28
|
## Requirements
|
|
29
29
|
|
|
30
|
-
- Node.js 22 LTS or
|
|
31
|
-
baseline.
|
|
30
|
+
- Node.js 22 or 24 LTS, or Node.js 26 Current; Node.js 24 LTS is the
|
|
31
|
+
recommended production baseline.
|
|
32
32
|
- A LynxJS project using Rspeedy.
|
|
33
|
-
- Android builds: JDK 17, Android SDK
|
|
34
|
-
Gradle wrapper.
|
|
33
|
+
- Android builds on Windows, macOS or Linux: JDK 17, Android SDK command-line
|
|
34
|
+
tools, `adb`, build tools and an executable project Gradle wrapper.
|
|
35
35
|
- iOS builds: macOS, Xcode and Xcode command-line tools.
|
|
36
36
|
- Cloudflare R2 credentials for artifact storage.
|
|
37
37
|
|
|
@@ -46,7 +46,10 @@ lynxship store configure --platform android
|
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
Secret inputs are hidden. Credentials are stored outside the project using the
|
|
49
|
-
OS-specific secure storage path.
|
|
49
|
+
OS-specific secure storage path. Linux uses Secret Service when `secret-tool`
|
|
50
|
+
is available and otherwise uses a mode-600 owner-only fallback suitable for
|
|
51
|
+
headless development; CI should use its secret manager or environment
|
|
52
|
+
variables. Credentials are never written to `lynxship.json`.
|
|
50
53
|
|
|
51
54
|
The project itself is initialized automatically by `build` when needed, or
|
|
52
55
|
explicitly with:
|
|
@@ -55,6 +58,10 @@ explicitly with:
|
|
|
55
58
|
lynxship init --project-dir ./my-lynx-app
|
|
56
59
|
```
|
|
57
60
|
|
|
61
|
+
`init` writes a stable UUID `projectId` to `lynxship.json`. Each project gets
|
|
62
|
+
its own generated ID; credentials remain machine-global and are not written
|
|
63
|
+
to the project file.
|
|
64
|
+
|
|
58
65
|
## Build a signed Android artifact
|
|
59
66
|
|
|
60
67
|
```bash
|
|
@@ -77,6 +84,15 @@ Rspeedy bundle
|
|
|
77
84
|
-> expiring download URL and QR code
|
|
78
85
|
```
|
|
79
86
|
|
|
87
|
+
CI can verify the complete local build without R2 credentials by adding
|
|
88
|
+
`--no-upload`. The signed artifact remains in `.lynxship/artifacts` and the
|
|
89
|
+
R2 transfer is the only skipped stage.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
lynxship build --platform android --profile production --no-upload \
|
|
93
|
+
--non-interactive
|
|
94
|
+
```
|
|
95
|
+
|
|
80
96
|
Progress percentages are shown only when LynxShip has a real measurement. Long
|
|
81
97
|
Rspeedy, Gradle and Xcode operations remain visible in the event journal until
|
|
82
98
|
their completion checkpoint is known; no timer-based percentage is invented.
|
|
@@ -88,9 +104,15 @@ init Initialize or link a project
|
|
|
88
104
|
doctor Check the local toolchain and project
|
|
89
105
|
dev Run the Rspeedy development server
|
|
90
106
|
preview Preview the production bundle locally
|
|
91
|
-
build
|
|
107
|
+
build create Build, sign and upload an artifact
|
|
108
|
+
build list List build jobs
|
|
109
|
+
build status <id> Inspect one build job
|
|
110
|
+
build cancel <id> Cancel a build job
|
|
111
|
+
build retry <id> Retry a failed build job
|
|
92
112
|
submit Submit the latest successful artifact
|
|
93
113
|
update Publish a signed OTA update
|
|
114
|
+
update rollback Roll back an OTA channel to a previous release
|
|
115
|
+
rollback Compatibility alias for update rollback
|
|
94
116
|
run Install an artifact on a target
|
|
95
117
|
logs Stream native logs
|
|
96
118
|
autolink check Check Lynx native-library wiring
|
|
@@ -105,12 +127,34 @@ Use `lynxship --help` or `lynxship <command> --help` for the complete option
|
|
|
105
127
|
list. `--json`, `--quiet`, `--no-color` and `--non-interactive` are available
|
|
106
128
|
for automation.
|
|
107
129
|
|
|
130
|
+
## OTA rollback
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
lynxship update rollback \
|
|
134
|
+
--platform android \
|
|
135
|
+
--release-id <release-id> \
|
|
136
|
+
--reason "Restore known-good release"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Rollback changes the current release pointer for the configured channel. It
|
|
140
|
+
does not delete the release or its R2 artifact, and it does not undo native
|
|
141
|
+
code. Native changes still require a new binary build and store submission.
|
|
142
|
+
|
|
108
143
|
## OTA safety
|
|
109
144
|
|
|
110
145
|
OTA updates are for JavaScript and assets compatible with the installed native
|
|
111
146
|
runtime. If native code, permissions, autolinked modules or other runtime
|
|
112
147
|
inputs change, LynxShip blocks the OTA and requires a new signed binary build.
|
|
113
148
|
|
|
149
|
+
## Linux host support
|
|
150
|
+
|
|
151
|
+
Android builds run on Windows, macOS and Linux, with device install/logs
|
|
152
|
+
through `adb` and the self-hosted control plane. Install the Android
|
|
153
|
+
command-line tools with `sdkmanager`, accept the required licenses, set
|
|
154
|
+
`ANDROID_HOME` or `ANDROID_SDK_ROOT` and `JAVA_HOME`, and ensure
|
|
155
|
+
`android/gradlew` is executable. macOS can build both Android and iOS; Windows
|
|
156
|
+
and Linux can build Android only.
|
|
157
|
+
|
|
114
158
|
## Package layout
|
|
115
159
|
|
|
116
160
|
The CLI is backed by the public `@lynxship/*` runtime packages in this
|
package/dist/android-build.d.ts
CHANGED
|
@@ -3,12 +3,22 @@ import type { BuildProfile } from "./config.js";
|
|
|
3
3
|
interface AndroidBuildOptions {
|
|
4
4
|
root: string;
|
|
5
5
|
profile: BuildProfile;
|
|
6
|
+
uploadArtifacts?: boolean;
|
|
6
7
|
quiet?: boolean;
|
|
7
8
|
onStep?: (message: string) => void;
|
|
8
9
|
onEvent?: (message: string) => void;
|
|
9
10
|
onProgress?: (value?: number, label?: string) => void;
|
|
10
11
|
}
|
|
11
12
|
export declare function hasAndroidHost(root: string): Promise<boolean>;
|
|
13
|
+
export declare function isSupportedAndroidPlatform(): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Copy the standard Rspeedy output into the Android host assets directory.
|
|
16
|
+
*
|
|
17
|
+
* This is intentionally handled by LynxShip instead of requiring every
|
|
18
|
+
* project to carry a helper script. Rspeedy writes bundles and static assets
|
|
19
|
+
* to `dist`, while the Lynx Android host reads them from `app/src/main/assets`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function syncLynxAssets(root: string): Promise<string[]>;
|
|
12
22
|
export declare function runRealAndroidBuild(job: BuildJob, options: AndroidBuildOptions): Promise<BuildJob>;
|
|
13
23
|
export {};
|
|
14
24
|
//# sourceMappingURL=android-build.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"android-build.d.ts","sourceRoot":"","sources":["../src/android-build.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"android-build.d.ts","sourceRoot":"","sources":["../src/android-build.ts"],"names":[],"mappings":"AAWA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAEpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAWhD,UAAU,mBAAmB;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,YAAY,CAAC;IACtB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,UAAU,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CACvD;AAoID,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAa7D;AAED,wBAAgB,0BAA0B,IAAI,OAAO,CAEpD;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAsCpE;AAED,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,QAAQ,CAAC,CAyJnB"}
|
package/dist/android-build.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { access, copyFile, mkdir, readFile } from "node:fs/promises";
|
|
1
|
+
import { access, copyFile, cp, mkdir, readFile, readdir, } from "node:fs/promises";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { constants as fsConstants, existsSync, readdirSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { assert, sha256 } from "@lynxship/contracts";
|
|
6
6
|
import { transitionBuild } from "@lynxship/build-orchestrator";
|
|
7
7
|
import { loadR2, uploadR2Artifact } from "./r2.js";
|
|
8
8
|
import { loadCredentials } from "./secure-store.js";
|
|
9
9
|
import { nativeArtifactName } from "./artifact-name.js";
|
|
10
|
-
import { commandExists, packageManagerScriptCommand, runProcess, } from "./process-runner.js";
|
|
10
|
+
import { commandExists, executableExists, packageManagerScriptCommand, runProcess, } from "./process-runner.js";
|
|
11
11
|
async function projectBuildCommand(root) {
|
|
12
12
|
try {
|
|
13
13
|
const packageJson = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
@@ -73,7 +73,7 @@ function androidTool(name) {
|
|
|
73
73
|
.sort()
|
|
74
74
|
.reverse()
|
|
75
75
|
.map((version) => join(sdk, "build-tools", version, executable))
|
|
76
|
-
.find((candidate) =>
|
|
76
|
+
.find((candidate) => executableExists(candidate));
|
|
77
77
|
}
|
|
78
78
|
catch {
|
|
79
79
|
return undefined;
|
|
@@ -93,15 +93,60 @@ function artifactDetails(root, profile) {
|
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
95
|
export function hasAndroidHost(root) {
|
|
96
|
-
|
|
96
|
+
if (!isSupportedAndroidPlatform())
|
|
97
|
+
return Promise.resolve(false);
|
|
98
|
+
const wrapper = join(root, "android", process.platform === "win32" ? "gradlew.bat" : "gradlew");
|
|
99
|
+
return access(wrapper, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK)
|
|
97
100
|
.then(() => true)
|
|
98
101
|
.catch(() => false);
|
|
99
102
|
}
|
|
103
|
+
export function isSupportedAndroidPlatform() {
|
|
104
|
+
return ["linux", "darwin", "win32"].includes(process.platform);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Copy the standard Rspeedy output into the Android host assets directory.
|
|
108
|
+
*
|
|
109
|
+
* This is intentionally handled by LynxShip instead of requiring every
|
|
110
|
+
* project to carry a helper script. Rspeedy writes bundles and static assets
|
|
111
|
+
* to `dist`, while the Lynx Android host reads them from `app/src/main/assets`.
|
|
112
|
+
*/
|
|
113
|
+
export async function syncLynxAssets(root) {
|
|
114
|
+
const distRoot = join(root, "dist");
|
|
115
|
+
const assetsRoot = join(root, "android", "app", "src", "main", "assets");
|
|
116
|
+
let entries;
|
|
117
|
+
try {
|
|
118
|
+
entries = await readdir(distRoot, { withFileTypes: true });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new Error(`Rspeedy output directory is missing: ${distRoot}. Run the project build first.`);
|
|
122
|
+
}
|
|
123
|
+
const bundles = entries
|
|
124
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".lynx.bundle"))
|
|
125
|
+
.map((entry) => entry.name);
|
|
126
|
+
assert(bundles.length > 0, "LYNX_BUNDLE_MISSING", `No .lynx.bundle was found in ${distRoot}. Check the Rspeedy output configuration.`);
|
|
127
|
+
await mkdir(assetsRoot, { recursive: true });
|
|
128
|
+
const outputNames = [...bundles, "async", "static"];
|
|
129
|
+
const copied = [];
|
|
130
|
+
for (const name of outputNames) {
|
|
131
|
+
const source = join(distRoot, name);
|
|
132
|
+
if (!existsSync(source))
|
|
133
|
+
continue;
|
|
134
|
+
await cp(source, join(assetsRoot, name), {
|
|
135
|
+
force: true,
|
|
136
|
+
recursive: true,
|
|
137
|
+
});
|
|
138
|
+
copied.push(name);
|
|
139
|
+
}
|
|
140
|
+
return copied;
|
|
141
|
+
}
|
|
100
142
|
export async function runRealAndroidBuild(job, options) {
|
|
143
|
+
assert(isSupportedAndroidPlatform(), "ANDROID_PLATFORM_UNSUPPORTED", "Android builds are supported only on Linux, macOS and Windows.");
|
|
101
144
|
const android = join(options.root, "android");
|
|
102
145
|
const wrapper = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
|
|
103
146
|
const artifact = artifactDetails(options.root, options.profile);
|
|
104
|
-
|
|
147
|
+
const uploadArtifacts = options.uploadArtifacts ?? true;
|
|
148
|
+
if (uploadArtifacts)
|
|
149
|
+
await loadR2(options.root);
|
|
105
150
|
const environment = {
|
|
106
151
|
...(await signingEnvironment(options.root)),
|
|
107
152
|
LYNXSHIP_RUNTIME_VERSION: process.env.LYNXSHIP_RUNTIME_VERSION ?? job.runtimeVersion ?? "",
|
|
@@ -137,15 +182,17 @@ export async function runRealAndroidBuild(job, options) {
|
|
|
137
182
|
step("Checking Android SDK and Gradle toolchain…");
|
|
138
183
|
transitionBuild(job, "installing_dependencies", "syncing Lynx bundle into Android assets");
|
|
139
184
|
step("Syncing bundle into the Android host…");
|
|
140
|
-
await
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
});
|
|
185
|
+
const copiedAssets = await syncLynxAssets(options.root);
|
|
186
|
+
for (const name of copiedAssets) {
|
|
187
|
+
options.onEvent?.(`Synced dist/${name} -> android/app/src/main/assets/${name}`);
|
|
188
|
+
}
|
|
145
189
|
step("Android host synchronized", 40);
|
|
146
190
|
transitionBuild(job, "building", `Gradle ${artifact.task}`);
|
|
147
191
|
step(`Running real Gradle task ${artifact.task}…`);
|
|
148
|
-
|
|
192
|
+
const gradleArgs = [artifact.task];
|
|
193
|
+
if (process.env.CI)
|
|
194
|
+
gradleArgs.push("--stacktrace");
|
|
195
|
+
await runProcess(wrapper, gradleArgs, {
|
|
149
196
|
cwd: android,
|
|
150
197
|
env: environment,
|
|
151
198
|
...processOptions,
|
|
@@ -164,26 +211,39 @@ export async function runRealAndroidBuild(job, options) {
|
|
|
164
211
|
const content = await readFile(artifactPath);
|
|
165
212
|
const hash = sha256(content);
|
|
166
213
|
job.attempts += 1;
|
|
167
|
-
|
|
168
|
-
const uploaded = await uploadR2Artifact(options.root, job.projectId, job.id, artifactPath, artifactName.endsWith(".aab")
|
|
214
|
+
const contentType = artifactName.endsWith(".aab")
|
|
169
215
|
? "application/octet-stream"
|
|
170
|
-
: "application/vnd.android.package-archive"
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
216
|
+
: "application/vnd.android.package-archive";
|
|
217
|
+
if (!uploadArtifacts) {
|
|
218
|
+
step("Artifact collected locally; R2 upload skipped", 100);
|
|
219
|
+
job.artifact = {
|
|
220
|
+
name: artifactName,
|
|
221
|
+
hash,
|
|
222
|
+
path: artifactPath,
|
|
223
|
+
size: content.length,
|
|
224
|
+
contentType,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
step("Uploading signed artifact to Cloudflare R2…", 80);
|
|
229
|
+
const uploaded = await uploadR2Artifact(options.root, job.projectId, job.id, artifactPath, contentType, undefined, {
|
|
230
|
+
onProgress: (uploadedBytes, totalBytes) => {
|
|
231
|
+
const transfer = totalBytes === 0 ? 1 : uploadedBytes / totalBytes;
|
|
232
|
+
options.onProgress?.(80 + transfer * 19, `Uploading signed artifact to Cloudflare R2… ${Math.round(transfer * 10000) / 100}%`);
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
assert(uploaded.hash === hash, "BUILD_ARTIFACT_HASH", "R2 artifact hash mismatch");
|
|
236
|
+
job.artifact = {
|
|
237
|
+
name: artifactName,
|
|
238
|
+
hash,
|
|
239
|
+
path: artifactPath,
|
|
240
|
+
key: uploaded.key,
|
|
241
|
+
size: uploaded.size,
|
|
242
|
+
contentType: uploaded.contentType,
|
|
243
|
+
url: uploaded.url,
|
|
244
|
+
expiresAt: uploaded.expiresAt,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
187
247
|
step(`Artifact ready: ${artifactName}`, 100);
|
|
188
248
|
return transitionBuild(job, "success", "real Android artifact created");
|
|
189
249
|
}
|
package/dist/configure.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"configure.d.ts","sourceRoot":"","sources":["../src/configure.ts"],"names":[],"mappings":"AAMA,OAAO,EAIL,KAAK,QAAQ,EACd,MAAM,SAAS,CAAC;AAQjB,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CA2BjE;AAED,UAAU,0BAA0B;IAClC,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA6CrE;AAED,wBAAsB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAuC1E;
|
|
1
|
+
{"version":3,"file":"configure.d.ts","sourceRoot":"","sources":["../src/configure.ts"],"names":[],"mappings":"AAMA,OAAO,EAIL,KAAK,QAAQ,EACd,MAAM,SAAS,CAAC;AAQjB,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CA2BjE;AAED,UAAU,0BAA0B;IAClC,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA6CrE;AAED,wBAAsB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAuC1E;AAiFD,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,0BAA0B,CAAC,CAoCrC"}
|
package/dist/configure.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { access, mkdir, readFile, unlink } from "node:fs/promises";
|
|
3
|
+
import { access, chmod, mkdir, readFile, unlink } from "node:fs/promises";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { prompt, secret } from "./prompt.js";
|
|
@@ -84,6 +84,7 @@ async function generateAndroidKeystore(root, keyAlias) {
|
|
|
84
84
|
const path = join(directory, "android-release.jks");
|
|
85
85
|
try {
|
|
86
86
|
await access(path);
|
|
87
|
+
await chmod(path, 0o600).catch(() => undefined);
|
|
87
88
|
throw new Error(`An Android keystore already exists at ${path}. Configure it explicitly or remove it before generating a new one.`);
|
|
88
89
|
}
|
|
89
90
|
catch (error) {
|
|
@@ -138,6 +139,7 @@ async function generateAndroidKeystore(root, keyAlias) {
|
|
|
138
139
|
await unlink(path).catch(() => undefined);
|
|
139
140
|
throw error;
|
|
140
141
|
}
|
|
142
|
+
await chmod(path, 0o600).catch(() => undefined);
|
|
141
143
|
return { path, password };
|
|
142
144
|
}
|
|
143
145
|
export async function configureAndroid(root) {
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
import { BuildOrchestrator } from "@lynxship/build-orchestrator";
|
|
7
7
|
import { JsonRepository } from "@lynxship/db";
|
|
8
8
|
import { assert, createId, sha256, } from "@lynxship/contracts";
|
|
9
9
|
import { createSigningKey, signManifest, } from "@lynxship/signing";
|
|
10
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";
|
|
11
|
+
import { DEFAULT_CONFIG, loadConfig, platformValue, } from "./config.js";
|
|
12
|
+
import { hasAndroidHost, isSupportedAndroidPlatform, runRealAndroidBuild, } from "./android-build.js";
|
|
13
13
|
import { hasIosHost, runRealIosBuild } from "./ios-build.js";
|
|
14
14
|
import { configureAndroid, configureAppStoreConnect, configureGooglePlay, configureR2, } from "./configure.js";
|
|
15
|
-
import { fetchOtaPublicKey, publishOtaRelease, submitRealArtifact, } from "./remote.js";
|
|
15
|
+
import { fetchOtaPublicKey, publishOtaRelease, rollbackOtaRelease, submitRealArtifact, } from "./remote.js";
|
|
16
16
|
import { uploadR2Artifact } from "./r2.js";
|
|
17
17
|
import { credentialStorageDescription, loadCredentials, } from "./secure-store.js";
|
|
18
18
|
import { createCliUi } from "./ui/index.js";
|
|
@@ -107,14 +107,24 @@ async function readConfigurationStatus() {
|
|
|
107
107
|
process.env.LYNXSHIP_KEY_PASSWORD);
|
|
108
108
|
return { r2: r2Configured, android: androidConfigured };
|
|
109
109
|
}
|
|
110
|
-
async function requireOperationalConfiguration(platform) {
|
|
110
|
+
async function requireOperationalConfiguration(platform, options = {}) {
|
|
111
111
|
await requireProjectRoot();
|
|
112
112
|
const status = await readConfigurationStatus();
|
|
113
|
-
|
|
113
|
+
if (options.requireR2 !== false)
|
|
114
|
+
assert(status.r2, "CLI_R2_REQUIRED", "Cloudflare R2 must be configured first. Run `lynxship storage configure`.");
|
|
114
115
|
if (platform !== "android")
|
|
115
116
|
return;
|
|
116
117
|
assert(status.android, "BUILD_SIGNING_REQUIRED", "Android signing must be configured first. Run `lynxship android configure` or provide an existing keystore.");
|
|
117
118
|
}
|
|
119
|
+
async function requireR2Configuration() {
|
|
120
|
+
await requireProjectRoot();
|
|
121
|
+
const status = await readConfigurationStatus();
|
|
122
|
+
assert(status.r2, "CLI_R2_REQUIRED", "Cloudflare R2 must be configured first. Run `lynxship storage configure`.");
|
|
123
|
+
}
|
|
124
|
+
function configuredProjectId(config) {
|
|
125
|
+
assert(typeof config.projectId === "string" && config.projectId.length > 0, "CLI_PROJECT_ID_REQUIRED", "lynxship.json must contain a generated projectId. Run `lynxship init` for a new project configuration.");
|
|
126
|
+
return config.projectId;
|
|
127
|
+
}
|
|
118
128
|
async function renderConfigurationFooter() {
|
|
119
129
|
if (!ui.interactive || ui.options.quiet)
|
|
120
130
|
return;
|
|
@@ -195,6 +205,7 @@ function commandTitle(command) {
|
|
|
195
205
|
build: "Cloud Build",
|
|
196
206
|
submit: "Store Submission",
|
|
197
207
|
update: "OTA Update",
|
|
208
|
+
rollback: "OTA Rollback",
|
|
198
209
|
"self-host": "Self-host setup",
|
|
199
210
|
storage: "Cloudflare R2 setup",
|
|
200
211
|
android: "Android signing setup",
|
|
@@ -216,19 +227,56 @@ Commands:
|
|
|
216
227
|
ota doctor Check native OTA host integration
|
|
217
228
|
run Install an artifact on an Android/iOS target
|
|
218
229
|
logs Stream Android/iOS native logs
|
|
219
|
-
build
|
|
230
|
+
build create Create a local/cloud build job
|
|
231
|
+
build list List build jobs
|
|
232
|
+
build status <id> Show one build job
|
|
233
|
+
build cancel <id> Cancel a build job
|
|
234
|
+
build retry <id> Retry a failed build job
|
|
220
235
|
submit Submit the latest successful build
|
|
221
236
|
update Upload and publish a signed OTA update
|
|
237
|
+
update rollback Roll back an OTA channel to a previous release
|
|
238
|
+
rollback Alias for update rollback
|
|
222
239
|
self-host init Generate local self-host credentials
|
|
223
240
|
storage configure Configure Cloudflare R2 and encrypted R2 credentials
|
|
224
241
|
android configure Configure or generate encrypted Android signing credentials
|
|
225
242
|
store configure Configure Google Play or App Store Connect submission
|
|
226
243
|
|
|
244
|
+
Build options:
|
|
245
|
+
--platform <p> Target android or ios (default: android)
|
|
246
|
+
--profile <name> Build profile (default: production)
|
|
247
|
+
--no-wait Queue the build without executing it locally
|
|
248
|
+
--no-upload Keep the signed artifact local and skip R2 (CI verification)
|
|
249
|
+
--local Use the contract-only build path for tests
|
|
250
|
+
|
|
251
|
+
Submit options:
|
|
252
|
+
--platform <p> Target android or ios (default: android)
|
|
253
|
+
--latest Submit the latest successful build
|
|
254
|
+
--local Use the mock submission provider for tests
|
|
255
|
+
|
|
227
256
|
Update options:
|
|
228
|
-
--
|
|
257
|
+
--platform <p> Target android or ios (default: android)
|
|
258
|
+
--bundle <path[,path]> Lynx bundle/assets (default: discover dist/*.lynx.bundle)
|
|
259
|
+
--message <text> Release message
|
|
229
260
|
--local Create a local contract-only update for tests
|
|
230
261
|
--policy-approval-id Required for an iOS OTA release
|
|
231
262
|
|
|
263
|
+
Rollback options:
|
|
264
|
+
--platform <p> Target android or ios (default: android)
|
|
265
|
+
--release-id <id> Previously published compatible release
|
|
266
|
+
--reason <text> Required audit reason for the rollback
|
|
267
|
+
--local Roll back local contract state for tests
|
|
268
|
+
|
|
269
|
+
Device and diagnostics options:
|
|
270
|
+
doctor --platform <p> Check the local toolchain (default: android)
|
|
271
|
+
autolink check --platform <p>
|
|
272
|
+
Check native-library wiring (default: android)
|
|
273
|
+
ota doctor --platform <p>
|
|
274
|
+
Check OTA host integration (default: android)
|
|
275
|
+
run --artifact <path> Install a specific APK, IPA or app artifact
|
|
276
|
+
run --device <id> Select the Android device or iOS simulator
|
|
277
|
+
run --simulator Install an iOS .app with simctl
|
|
278
|
+
logs --device <id> Select the device or simulator for native logs
|
|
279
|
+
|
|
232
280
|
Global options:
|
|
233
281
|
--json Emit one stable JSON result/error object
|
|
234
282
|
--quiet Print only the final machine-relevant result
|
|
@@ -237,9 +285,10 @@ Global options:
|
|
|
237
285
|
--non-interactive Never prompt; fail on missing inputs
|
|
238
286
|
--banner Show the Braille LynxShip logo in a TTY
|
|
239
287
|
--project-dir <path> Use a LynxShip project from any working directory
|
|
288
|
+
--project-id <id> Project ID used by init
|
|
289
|
+
--library-dir <path> Native library directory for autolink codegen
|
|
240
290
|
--simulator Install an iOS .app on a simulator with simctl
|
|
241
|
-
|
|
242
|
-
--local Use the mock submission provider for local tests only
|
|
291
|
+
--help Show this complete command reference
|
|
243
292
|
|
|
244
293
|
Node support: Node 22/24 LTS or Node 26 Current. Use Node 24 LTS for production.`;
|
|
245
294
|
}
|
|
@@ -270,7 +319,7 @@ async function initializeProject() {
|
|
|
270
319
|
if (await exists(file))
|
|
271
320
|
return file;
|
|
272
321
|
await mkdir(join(root, ".lynxship"), { recursive: true });
|
|
273
|
-
await writeFile(file, `${JSON.stringify({ ...DEFAULT_CONFIG, projectId: flag("--project-id"
|
|
322
|
+
await writeFile(file, `${JSON.stringify({ ...DEFAULT_CONFIG, projectId: flag("--project-id") ?? randomUUID() }, null, 2)}\n`);
|
|
274
323
|
return file;
|
|
275
324
|
}
|
|
276
325
|
async function initializeBuildProject() {
|
|
@@ -452,7 +501,7 @@ async function main() {
|
|
|
452
501
|
printValue(helpText());
|
|
453
502
|
return;
|
|
454
503
|
}
|
|
455
|
-
ui.header(commandTitle(command));
|
|
504
|
+
ui.header(commandTitle(command === "update" && args[0] === "rollback" ? "rollback" : command));
|
|
456
505
|
ui.debug(`cwd=${root}`);
|
|
457
506
|
if (command === "init") {
|
|
458
507
|
ui.info("Scanning project structure…");
|
|
@@ -467,13 +516,14 @@ async function main() {
|
|
|
467
516
|
return;
|
|
468
517
|
}
|
|
469
518
|
await initializeProject();
|
|
519
|
+
const initializedConfig = await loadConfig(root);
|
|
470
520
|
ui.success("Created lynxship.json");
|
|
471
521
|
printValue({ status: "created", file }, {
|
|
472
522
|
title: "Initialized",
|
|
473
523
|
rows: [
|
|
474
524
|
{
|
|
475
525
|
label: "Project ID",
|
|
476
|
-
value:
|
|
526
|
+
value: initializedConfig.projectId ?? "unassigned",
|
|
477
527
|
valueColor: "purple",
|
|
478
528
|
},
|
|
479
529
|
{ label: "Configuration", value: file, valueColor: "muted" },
|
|
@@ -489,28 +539,63 @@ async function main() {
|
|
|
489
539
|
const autolink = await inspectAutolink(root);
|
|
490
540
|
const autolinkForPlatform = autolink[doctorPlatform];
|
|
491
541
|
const lockfile = await findLockfile(root);
|
|
542
|
+
const credentialStore = credentialStorageDescription();
|
|
543
|
+
const nativeCredentialStore = !credentialStore.includes("owner-only");
|
|
544
|
+
const androidHost = doctorPlatform === "android" ? await hasAndroidHost(root) : false;
|
|
492
545
|
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
546
|
+
const nodeSupported = nodeMajor >= 22;
|
|
547
|
+
const nodeRecommended = nodeMajor % 2 === 0;
|
|
493
548
|
const checks = [
|
|
494
549
|
{
|
|
495
550
|
name: "node",
|
|
496
|
-
ok:
|
|
497
|
-
|
|
498
|
-
?
|
|
499
|
-
:
|
|
551
|
+
ok: nodeSupported,
|
|
552
|
+
status: !nodeSupported
|
|
553
|
+
? "fail"
|
|
554
|
+
: nodeRecommended
|
|
555
|
+
? "pass"
|
|
556
|
+
: "warn",
|
|
557
|
+
value: !nodeSupported
|
|
558
|
+
? `${process.version} · fix: use Node 24 LTS`
|
|
559
|
+
: nodeRecommended
|
|
560
|
+
? process.version
|
|
561
|
+
: `${process.version} · recommended: Node 24 LTS`,
|
|
500
562
|
},
|
|
501
563
|
{
|
|
502
564
|
name: "package-manager-lockfile",
|
|
503
565
|
ok: Boolean(lockfile),
|
|
504
|
-
|
|
566
|
+
status: lockfile ? "pass" : "fail",
|
|
567
|
+
value: lockfile ?? "missing · fix: npm install or pnpm install",
|
|
505
568
|
},
|
|
506
569
|
{
|
|
507
570
|
name: "lynxship.json",
|
|
508
|
-
ok:
|
|
509
|
-
|
|
571
|
+
ok: config.projectId !== undefined,
|
|
572
|
+
status: config.projectId ? "pass" : "fail",
|
|
573
|
+
value: config.projectId ? "found" : "missing · fix: lynxship init",
|
|
510
574
|
},
|
|
575
|
+
{
|
|
576
|
+
name: "credential-store",
|
|
577
|
+
ok: true,
|
|
578
|
+
status: nativeCredentialStore ? "pass" : "warn",
|
|
579
|
+
value: nativeCredentialStore
|
|
580
|
+
? credentialStore
|
|
581
|
+
: `${credentialStore} · use CI secret variables or install Linux Secret Service`,
|
|
582
|
+
},
|
|
583
|
+
...(doctorPlatform === "android"
|
|
584
|
+
? [
|
|
585
|
+
{
|
|
586
|
+
name: "android-host",
|
|
587
|
+
ok: androidHost,
|
|
588
|
+
status: androidHost ? "pass" : "fail",
|
|
589
|
+
value: androidHost
|
|
590
|
+
? "Gradle host found"
|
|
591
|
+
: "missing · add an Android host with android/gradlew",
|
|
592
|
+
},
|
|
593
|
+
]
|
|
594
|
+
: []),
|
|
511
595
|
{
|
|
512
596
|
name: "cloudflare-r2",
|
|
513
597
|
ok: configuration.r2,
|
|
598
|
+
status: configuration.r2 ? "pass" : "fail",
|
|
514
599
|
value: configuration.r2
|
|
515
600
|
? "configured"
|
|
516
601
|
: "run lynxship storage configure",
|
|
@@ -520,32 +605,52 @@ async function main() {
|
|
|
520
605
|
ok: doctorPlatform === "android"
|
|
521
606
|
? configuration.android
|
|
522
607
|
: process.platform === "darwin" && hasIosHost(root),
|
|
608
|
+
status: (doctorPlatform === "android"
|
|
609
|
+
? configuration.android
|
|
610
|
+
: process.platform === "darwin" && hasIosHost(root))
|
|
611
|
+
? "pass"
|
|
612
|
+
: "fail",
|
|
523
613
|
value: doctorPlatform === "android"
|
|
524
614
|
? configuration.android
|
|
525
615
|
? "configured"
|
|
526
616
|
: "run lynxship android configure"
|
|
527
617
|
: process.platform === "darwin" && hasIosHost(root)
|
|
528
618
|
? "Xcode host found"
|
|
529
|
-
: "macOS
|
|
619
|
+
: "missing · fix: use macOS with Xcode",
|
|
530
620
|
},
|
|
531
621
|
{
|
|
532
622
|
name: `lynx-autolink-${doctorPlatform}`,
|
|
533
623
|
ok: autolinkForPlatform.ready,
|
|
534
|
-
|
|
624
|
+
status: autolinkForPlatform.ready ? "pass" : "fail",
|
|
625
|
+
value: autolinkForPlatform.ready
|
|
626
|
+
? autolinkForPlatform.reason
|
|
627
|
+
: `${autolinkForPlatform.reason} · fix: install the native plugin, then run autolink codegen`,
|
|
535
628
|
},
|
|
536
629
|
];
|
|
537
|
-
const result = {
|
|
630
|
+
const result = {
|
|
631
|
+
ok: checks.every((check) => check.status !== "fail"),
|
|
632
|
+
checks,
|
|
633
|
+
};
|
|
634
|
+
const hasWarnings = checks.some((check) => check.status === "warn");
|
|
538
635
|
if (!result.ok)
|
|
539
|
-
ui.warn("One or more environment checks
|
|
636
|
+
ui.warn("One or more environment checks failed");
|
|
637
|
+
else if (hasWarnings)
|
|
638
|
+
ui.warn("Environment is usable, but one recommendation needs attention");
|
|
540
639
|
printValue(result, {
|
|
541
640
|
title: "Doctor result",
|
|
542
641
|
rows: checks.map((check) => ({
|
|
543
642
|
label: check.name,
|
|
544
|
-
value: `${check.
|
|
545
|
-
valueColor: check.
|
|
643
|
+
value: `${check.status} · ${check.value}`,
|
|
644
|
+
valueColor: check.status === "pass"
|
|
645
|
+
? "green"
|
|
646
|
+
: check.status === "warn"
|
|
647
|
+
? "yellow"
|
|
648
|
+
: "red",
|
|
546
649
|
})),
|
|
547
650
|
done: result.ok
|
|
548
|
-
?
|
|
651
|
+
? hasWarnings
|
|
652
|
+
? "Environment is usable; review the recommendation when convenient."
|
|
653
|
+
: "Environment looks ready."
|
|
549
654
|
: "Fix the failed checks before building.",
|
|
550
655
|
});
|
|
551
656
|
return;
|
|
@@ -762,7 +867,7 @@ async function main() {
|
|
|
762
867
|
const controlPlaneSubmission = candidate.artifact?.path
|
|
763
868
|
? await submitRealArtifact(config, state, candidate, latest)
|
|
764
869
|
: await submissions.submit({
|
|
765
|
-
projectId: config
|
|
870
|
+
projectId: configuredProjectId(config),
|
|
766
871
|
organizationId: "local_org",
|
|
767
872
|
platform,
|
|
768
873
|
artifact: candidate.artifact ?? { hash: `local-${candidate.id}` },
|
|
@@ -823,11 +928,11 @@ async function main() {
|
|
|
823
928
|
}
|
|
824
929
|
return;
|
|
825
930
|
}
|
|
826
|
-
if (command === "update") {
|
|
931
|
+
if (command === "update" && args[0] !== "rollback") {
|
|
827
932
|
const config = await loadConfig(root);
|
|
828
933
|
const platform = platformValue(flag("--platform", "android"));
|
|
829
934
|
await requireOperationalConfiguration(platform);
|
|
830
|
-
const projectId = config
|
|
935
|
+
const projectId = configuredProjectId(config);
|
|
831
936
|
const localMode = args.includes("--local") || process.env.LYNXSHIP_SUBMIT_MODE === "mock";
|
|
832
937
|
const explicitBundles = flag("--bundle");
|
|
833
938
|
const runtime = await inspectRuntimeFingerprint(root, platform, config);
|
|
@@ -942,10 +1047,82 @@ async function main() {
|
|
|
942
1047
|
}
|
|
943
1048
|
return;
|
|
944
1049
|
}
|
|
1050
|
+
if (command === "rollback" ||
|
|
1051
|
+
(command === "update" && args[0] === "rollback")) {
|
|
1052
|
+
if (command === "update")
|
|
1053
|
+
args.shift();
|
|
1054
|
+
const config = await loadConfig(root);
|
|
1055
|
+
const platform = platformValue(flag("--platform", "android"));
|
|
1056
|
+
const releaseId = flag("--release-id");
|
|
1057
|
+
const reason = flag("--reason");
|
|
1058
|
+
const channel = config.update?.channel ?? "production";
|
|
1059
|
+
const localMode = args.includes("--local");
|
|
1060
|
+
assert(Boolean(releaseId && releaseId !== "true"), "ROLLBACK_RELEASE_REQUIRED", "Pass the release to restore with `--release-id <id>`.");
|
|
1061
|
+
assert(Boolean(reason?.trim()) && reason !== "true", "ROLLBACK_REASON_REQUIRED", 'Pass an audit reason with `--reason "..."`.');
|
|
1062
|
+
await requireR2Configuration();
|
|
1063
|
+
const progress = ui.progress("OTA rollback");
|
|
1064
|
+
try {
|
|
1065
|
+
progress.update(10, `Selecting ${releaseId} for ${channel}…`);
|
|
1066
|
+
if (localMode) {
|
|
1067
|
+
const release = state.releases.find((candidate) => candidate.id === releaseId &&
|
|
1068
|
+
candidate.manifest.channel === channel &&
|
|
1069
|
+
candidate.manifest.platform === platform);
|
|
1070
|
+
assert(release, "RELEASE_NOT_FOUND", `Local release ${releaseId} was not found in channel ${channel} for ${platform}.`);
|
|
1071
|
+
state.lastRollback = {
|
|
1072
|
+
releaseId: release.id,
|
|
1073
|
+
reason: reason,
|
|
1074
|
+
at: new Date().toISOString(),
|
|
1075
|
+
};
|
|
1076
|
+
await saveState(state, repository, builds, submissions);
|
|
1077
|
+
progress.update(100, "Local OTA channel rolled back");
|
|
1078
|
+
printValue({ status: "rolled_back", release, rollback: state.lastRollback }, {
|
|
1079
|
+
title: "OTA rollback",
|
|
1080
|
+
rows: [
|
|
1081
|
+
{ label: "Release ID", value: release.id, valueColor: "purple" },
|
|
1082
|
+
{ label: "Platform", value: platform, valueColor: "blue" },
|
|
1083
|
+
{ label: "Channel", value: channel, valueColor: "orange" },
|
|
1084
|
+
{ label: "Reason", value: reason, valueColor: "muted" },
|
|
1085
|
+
],
|
|
1086
|
+
done: "Local OTA channel now points to the selected release.",
|
|
1087
|
+
});
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
progress.update(45, "Requesting rollback through LynxShip API…");
|
|
1091
|
+
const release = (await rollbackOtaRelease(config, state, {
|
|
1092
|
+
projectId: configuredProjectId(config),
|
|
1093
|
+
channel,
|
|
1094
|
+
platform,
|
|
1095
|
+
releaseId: releaseId,
|
|
1096
|
+
reason: reason,
|
|
1097
|
+
}));
|
|
1098
|
+
state.lastRollback = {
|
|
1099
|
+
releaseId: release.id,
|
|
1100
|
+
reason: reason,
|
|
1101
|
+
at: new Date().toISOString(),
|
|
1102
|
+
};
|
|
1103
|
+
await saveState(state, repository, builds, submissions);
|
|
1104
|
+
progress.update(100, "OTA channel rolled back");
|
|
1105
|
+
printValue({ status: "rolled_back", release, rollback: state.lastRollback }, {
|
|
1106
|
+
title: "OTA rollback",
|
|
1107
|
+
rows: [
|
|
1108
|
+
{ label: "Release ID", value: release.id, valueColor: "purple" },
|
|
1109
|
+
{ label: "Platform", value: platform, valueColor: "blue" },
|
|
1110
|
+
{ label: "Channel", value: channel, valueColor: "orange" },
|
|
1111
|
+
{ label: "Reason", value: reason, valueColor: "muted" },
|
|
1112
|
+
],
|
|
1113
|
+
done: "Devices will receive the selected compatible release on their next OTA check.",
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
finally {
|
|
1117
|
+
progress.stop();
|
|
1118
|
+
}
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
945
1121
|
assert(command === "build", "CLI_COMMAND", `Unknown command: ${command}`);
|
|
946
1122
|
const subcommand = args[0] && !args[0].startsWith("--") ? args.shift() : "create";
|
|
947
1123
|
const platform = platformValue(flag("--platform", "android"));
|
|
948
|
-
|
|
1124
|
+
const skipUpload = args.includes("--no-upload");
|
|
1125
|
+
await requireOperationalConfiguration(platform, { requireR2: !skipUpload });
|
|
949
1126
|
if (subcommand === "list") {
|
|
950
1127
|
printValue(builds.list());
|
|
951
1128
|
return;
|
|
@@ -973,7 +1150,7 @@ async function main() {
|
|
|
973
1150
|
await requireAutolinkReady(root, platform);
|
|
974
1151
|
const runtime = await inspectRuntimeFingerprint(root, platform, config);
|
|
975
1152
|
const job = await builds.create({
|
|
976
|
-
projectId: config
|
|
1153
|
+
projectId: configuredProjectId(config),
|
|
977
1154
|
organizationId: "local_org",
|
|
978
1155
|
platform,
|
|
979
1156
|
profile,
|
|
@@ -986,14 +1163,23 @@ async function main() {
|
|
|
986
1163
|
try {
|
|
987
1164
|
progress.update(undefined, "Preparing build pipeline…");
|
|
988
1165
|
if (!args.includes("--no-wait")) {
|
|
989
|
-
|
|
1166
|
+
if (platform === "android" &&
|
|
1167
|
+
!isSupportedAndroidPlatform() &&
|
|
1168
|
+
!args.includes("--local"))
|
|
1169
|
+
assert(false, "ANDROID_PLATFORM_UNSUPPORTED", "Android builds are supported only on Linux, macOS and Windows.");
|
|
1170
|
+
const realAndroid = platform === "android" &&
|
|
1171
|
+
isSupportedAndroidPlatform() &&
|
|
1172
|
+
(await hasAndroidHost(root));
|
|
990
1173
|
const realIos = platform === "ios" && hasIosHost(root, config.build?.[profile]);
|
|
991
1174
|
if (platform === "ios" && !realIos && !args.includes("--local"))
|
|
992
1175
|
assert(false, "IOS_HOST_REQUIRED", "A macOS Xcode host is required for a real iOS build. No local fake iOS build is created.");
|
|
1176
|
+
if (platform === "android" && !realAndroid && !args.includes("--local"))
|
|
1177
|
+
assert(false, "ANDROID_HOST_REQUIRED", "A real Android build requires an Android Gradle host. Add android/gradlew and the native Lynx host, or use --local for contract tests.");
|
|
993
1178
|
if (realAndroid) {
|
|
994
1179
|
await runRealAndroidBuild(job, {
|
|
995
1180
|
root,
|
|
996
1181
|
profile: config.build?.[profile] ?? {},
|
|
1182
|
+
uploadArtifacts: !skipUpload,
|
|
997
1183
|
quiet: json,
|
|
998
1184
|
onEvent: (message) => progress.event(message),
|
|
999
1185
|
onProgress: (value, label) => progress.update(value, label),
|
|
@@ -1003,6 +1189,7 @@ async function main() {
|
|
|
1003
1189
|
await runRealIosBuild(job, {
|
|
1004
1190
|
root,
|
|
1005
1191
|
profile: config.build?.[profile] ?? {},
|
|
1192
|
+
uploadArtifacts: !skipUpload,
|
|
1006
1193
|
quiet: json,
|
|
1007
1194
|
onEvent: (message) => progress.event(message),
|
|
1008
1195
|
onProgress: (value, label) => progress.update(value, label),
|
package/dist/ios-build.d.ts
CHANGED
package/dist/ios-build.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ios-build.d.ts","sourceRoot":"","sources":["../src/ios-build.ts"],"names":[],"mappings":"AAGA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAEpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAShD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,YAAY,CAAC;IACtB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,UAAU,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CACvD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAcxE;AA0BD,wBAAsB,eAAe,CACnC,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,QAAQ,CAAC,
|
|
1
|
+
{"version":3,"file":"ios-build.d.ts","sourceRoot":"","sources":["../src/ios-build.ts"],"names":[],"mappings":"AAGA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAEpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAShD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,YAAY,CAAC;IACtB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,UAAU,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CACvD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAcxE;AA0BD,wBAAsB,eAAe,CACnC,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,QAAQ,CAAC,CAmMnB"}
|
package/dist/ios-build.js
CHANGED
|
@@ -69,7 +69,9 @@ export async function runRealIosBuild(job, options) {
|
|
|
69
69
|
options.onProgress?.(progress, message);
|
|
70
70
|
};
|
|
71
71
|
try {
|
|
72
|
-
|
|
72
|
+
const uploadArtifacts = options.uploadArtifacts ?? true;
|
|
73
|
+
if (uploadArtifacts)
|
|
74
|
+
await loadR2(options.root);
|
|
73
75
|
await mkdir(archiveDirectory, { recursive: true });
|
|
74
76
|
transitionBuild(job, "uploading_source", "iOS source prepared");
|
|
75
77
|
step("Building Lynx bundle with Rspeedy…", 5);
|
|
@@ -128,24 +130,37 @@ export async function runRealIosBuild(job, options) {
|
|
|
128
130
|
await copyFile(exportedIpa, artifactPath);
|
|
129
131
|
const content = await readFile(artifactPath);
|
|
130
132
|
const hash = sha256(content);
|
|
131
|
-
const uploaded = await uploadR2Artifact(options.root, job.projectId, job.id, artifactPath, "application/octet-stream", undefined, {
|
|
132
|
-
onProgress: (uploadedBytes, totalBytes) => {
|
|
133
|
-
const transfer = totalBytes === 0 ? 1 : uploadedBytes / totalBytes;
|
|
134
|
-
options.onProgress?.(80 + transfer * 19, `Uploading signed artifact to Cloudflare R2… ${Math.round(transfer * 10000) / 100}%`);
|
|
135
|
-
},
|
|
136
|
-
});
|
|
137
|
-
assert(uploaded.hash === hash, "BUILD_ARTIFACT_HASH", "R2 artifact hash mismatch");
|
|
138
133
|
job.attempts += 1;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
134
|
+
if (!uploadArtifacts) {
|
|
135
|
+
step("Artifact collected locally; R2 upload skipped", 100);
|
|
136
|
+
job.artifact = {
|
|
137
|
+
name: artifactName,
|
|
138
|
+
hash,
|
|
139
|
+
path: artifactPath,
|
|
140
|
+
size: content.length,
|
|
141
|
+
contentType: "application/octet-stream",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
step("Uploading signed artifact to Cloudflare R2…", 80);
|
|
146
|
+
const uploaded = await uploadR2Artifact(options.root, job.projectId, job.id, artifactPath, "application/octet-stream", undefined, {
|
|
147
|
+
onProgress: (uploadedBytes, totalBytes) => {
|
|
148
|
+
const transfer = totalBytes === 0 ? 1 : uploadedBytes / totalBytes;
|
|
149
|
+
options.onProgress?.(80 + transfer * 19, `Uploading signed artifact to Cloudflare R2… ${Math.round(transfer * 10000) / 100}%`);
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
assert(uploaded.hash === hash, "BUILD_ARTIFACT_HASH", "R2 artifact hash mismatch");
|
|
153
|
+
job.artifact = {
|
|
154
|
+
name: artifactName,
|
|
155
|
+
hash,
|
|
156
|
+
path: artifactPath,
|
|
157
|
+
key: uploaded.key,
|
|
158
|
+
size: uploaded.size,
|
|
159
|
+
contentType: uploaded.contentType,
|
|
160
|
+
url: uploaded.url,
|
|
161
|
+
expiresAt: uploaded.expiresAt,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
149
164
|
step(`Artifact ready: ${artifactName}`, 100);
|
|
150
165
|
return transitionBuild(job, "success", "real iOS artifact created");
|
|
151
166
|
}
|
package/dist/paths.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAGA,wBAAgB,uBAAuB,IAAI,MAAM,
|
|
1
|
+
{"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAGA,wBAAgB,uBAAuB,IAAI,MAAM,CAkBhD"}
|
package/dist/paths.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
3
|
export function globalLynxShipDirectory() {
|
|
4
4
|
if (process.env.LYNXSHIP_CONFIG_DIR)
|
|
5
5
|
return process.env.LYNXSHIP_CONFIG_DIR;
|
|
@@ -8,5 +8,8 @@ export function globalLynxShipDirectory() {
|
|
|
8
8
|
return join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "LynxShip");
|
|
9
9
|
if (process.platform === "darwin")
|
|
10
10
|
return join(home, "Library", "Application Support", "LynxShip");
|
|
11
|
-
|
|
11
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
|
|
12
|
+
return join(xdgConfigHome && isAbsolute(xdgConfigHome)
|
|
13
|
+
? xdgConfigHome
|
|
14
|
+
: join(home, ".config"), "lynxship");
|
|
12
15
|
}
|
package/dist/process-runner.d.ts
CHANGED
|
@@ -15,4 +15,5 @@ export declare function packageManagerScriptCommand(root: string, script: string
|
|
|
15
15
|
export declare function runProcess(command: string, args: string[], options: ProcessOptions): Promise<void>;
|
|
16
16
|
export declare function runRspeedy(root: string, subcommand: string, args: string[], options: Omit<ProcessOptions, "cwd">): Promise<void>;
|
|
17
17
|
export declare function commandExists(command: string): boolean;
|
|
18
|
+
export declare function executableExists(file: string): boolean;
|
|
18
19
|
//# sourceMappingURL=process-runner.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"process-runner.d.ts","sourceRoot":"","sources":["../src/process-runner.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"process-runner.d.ts","sourceRoot":"","sources":["../src/process-runner.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB,CAcA;AAED,wBAAgB,2BAA2B,CACzC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,GAAE,MAAM,EAAO,GAClB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAGrC;AAsBD,wBAAgB,UAAU,CACxB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,IAAI,CAAC,CA4Df;AAED,wBAAsB,UAAU,CAC9B,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,IAAI,CAAC,cAAc,EAAE,KAAK,CAAC,GACnC,OAAO,CAAC,IAAI,CAAC,CAOf;AAED,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CA4BtD;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAUtD"}
|
package/dist/process-runner.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
1
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { accessSync, constants, existsSync, readFileSync } from "node:fs";
|
|
4
3
|
import { join } from "node:path";
|
|
5
4
|
export function packageManagerCommand(root) {
|
|
6
5
|
const configured = process.env.LYNXSHIP_PACKAGE_MANAGER ?? process.env.LYNXSHIP_PNPM_PATH;
|
|
@@ -77,8 +76,18 @@ export function runProcess(command, args, options) {
|
|
|
77
76
|
options.onOutput?.(pending.trim());
|
|
78
77
|
if (code === 0)
|
|
79
78
|
return resolve();
|
|
80
|
-
const
|
|
81
|
-
|
|
79
|
+
const outputLines = output
|
|
80
|
+
.trim()
|
|
81
|
+
.split(/\r?\n/)
|
|
82
|
+
.map((line) => line.trim())
|
|
83
|
+
.filter(Boolean);
|
|
84
|
+
const failureIndex = outputLines.findIndex((line) => /(?:FAILURE:|What went wrong:|Execution failed for task)/.test(line));
|
|
85
|
+
const recentOutput = (failureIndex >= 0
|
|
86
|
+
? outputLines.slice(failureIndex)
|
|
87
|
+
: outputLines.slice(-24))
|
|
88
|
+
.slice(-24)
|
|
89
|
+
.join("\n");
|
|
90
|
+
reject(new Error(`${command} ${args.join(" ")} failed (${signal ?? `exit ${code}`})${recentOutput ? `:\n${recentOutput}` : ""}`));
|
|
82
91
|
});
|
|
83
92
|
});
|
|
84
93
|
}
|
|
@@ -87,8 +96,38 @@ export async function runRspeedy(root, subcommand, args, options) {
|
|
|
87
96
|
await runProcess(manager.command, [...manager.prefix, "rspeedy", subcommand, ...args], { ...options, cwd: root });
|
|
88
97
|
}
|
|
89
98
|
export function commandExists(command) {
|
|
99
|
+
const pathValue = process.env.PATH ?? "";
|
|
100
|
+
const pathEntries = pathValue.split(process.platform === "win32" ? ";" : ":");
|
|
101
|
+
const hasPathSeparator = /[\\/]/.test(command);
|
|
102
|
+
const candidates = hasPathSeparator
|
|
103
|
+
? [command]
|
|
104
|
+
: pathEntries.flatMap((directory) => {
|
|
105
|
+
if (!directory)
|
|
106
|
+
return [];
|
|
107
|
+
if (process.platform !== "win32")
|
|
108
|
+
return [join(directory, command)];
|
|
109
|
+
const extensions = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
|
|
110
|
+
.split(";")
|
|
111
|
+
.filter(Boolean);
|
|
112
|
+
return [
|
|
113
|
+
join(directory, command),
|
|
114
|
+
...extensions.map((extension) => join(directory, `${command}${extension}`)),
|
|
115
|
+
];
|
|
116
|
+
});
|
|
117
|
+
const mode = process.platform === "win32" ? constants.F_OK : constants.X_OK;
|
|
118
|
+
return candidates.some((candidate) => {
|
|
119
|
+
try {
|
|
120
|
+
accessSync(candidate, mode);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
export function executableExists(file) {
|
|
90
129
|
try {
|
|
91
|
-
|
|
130
|
+
accessSync(file, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
92
131
|
return true;
|
|
93
132
|
}
|
|
94
133
|
catch {
|
package/dist/r2.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"r2.d.ts","sourceRoot":"","sources":["../src/r2.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,QAAQ,EACT,MAAM,oBAAoB,CAAC;AAI5B,OAAO,EAAmB,KAAK,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAG5E,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;CAClE;AAYD,iBAAS,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAElD;AA0BD,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IAClD,MAAM,EAAE,QAAQ,CAAC;IACjB,WAAW,EAAE,WAAW,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAC,CAAC;CACnD,CAAC,CA0CD;AAED,wBAAgB,cAAc,CAC5B,MAAM,EAAE,QAAQ,EAChB,WAAW,EAAE,WAAW,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAC,GAChD,QAAQ,CAMV;AAED,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,QAAQ,EAChB,WAAW,EAAE,WAAW,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAC,GAChD,OAAO,CAAC,IAAI,CAAC,CAOf;AAED,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,UAAU,CAAC,EAAE,MAAM,EACnB,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,UAAU,CAAC,CAkDrB;AAED,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,QAAQ,EAChB,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"r2.d.ts","sourceRoot":"","sources":["../src/r2.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,QAAQ,EACT,MAAM,oBAAoB,CAAC;AAI5B,OAAO,EAAmB,KAAK,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAG5E,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;CAClE;AAYD,iBAAS,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAElD;AA0BD,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IAClD,MAAM,EAAE,QAAQ,CAAC;IACjB,WAAW,EAAE,WAAW,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAC,CAAC;CACnD,CAAC,CA0CD;AAED,wBAAgB,cAAc,CAC5B,MAAM,EAAE,QAAQ,EAChB,WAAW,EAAE,WAAW,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAC,GAChD,QAAQ,CAMV;AAED,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,QAAQ,EAChB,WAAW,EAAE,WAAW,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAC,GAChD,OAAO,CAAC,IAAI,CAAC,CAOf;AAED,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,UAAU,CAAC,EAAE,MAAM,EACnB,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,UAAU,CAAC,CAkDrB;AAED,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,QAAQ,EAChB,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CAaf;AAED,OAAO,EAAE,eAAe,EAAE,CAAC"}
|
package/dist/r2.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { GetObjectCommand, HeadBucketCommand, S3Client, } from "@aws-sdk/client-s3";
|
|
4
4
|
import { Upload } from "@aws-sdk/lib-storage";
|
|
@@ -130,6 +130,7 @@ export async function writeR2Config(root, config, options = {}) {
|
|
|
130
130
|
encoding: "utf8",
|
|
131
131
|
mode: 0o600,
|
|
132
132
|
});
|
|
133
|
+
await chmod(file, 0o600).catch(() => undefined);
|
|
133
134
|
await access(file);
|
|
134
135
|
}
|
|
135
136
|
export { defaultEndpoint };
|
package/dist/remote.d.ts
CHANGED
|
@@ -20,12 +20,20 @@ export interface OtaPublishRequest {
|
|
|
20
20
|
rollout?: number;
|
|
21
21
|
policyApprovalId?: string | null;
|
|
22
22
|
}
|
|
23
|
+
export interface OtaRollbackRequest {
|
|
24
|
+
projectId: string;
|
|
25
|
+
channel: string;
|
|
26
|
+
platform: "android" | "ios";
|
|
27
|
+
releaseId: string;
|
|
28
|
+
reason: string;
|
|
29
|
+
}
|
|
23
30
|
export declare function ensureRemoteTarget(config: LynxShipConfig, state: RemoteCliState): Promise<{
|
|
24
31
|
organizationId: string;
|
|
25
32
|
projectId: string;
|
|
26
33
|
}>;
|
|
27
34
|
export declare function submitRealArtifact(config: LynxShipConfig, state: RemoteCliState, job: BuildJob, latest: boolean): Promise<unknown>;
|
|
28
35
|
export declare function publishOtaRelease(config: LynxShipConfig, state: RemoteCliState, input: OtaPublishRequest): Promise<unknown>;
|
|
36
|
+
export declare function rollbackOtaRelease(config: LynxShipConfig, state: RemoteCliState, input: OtaRollbackRequest): Promise<unknown>;
|
|
29
37
|
export declare function fetchOtaPublicKey(config: LynxShipConfig): Promise<{
|
|
30
38
|
keyId: string;
|
|
31
39
|
publicKey: string;
|
package/dist/remote.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote.d.ts","sourceRoot":"","sources":["../src/remote.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,MAAM,WAAW,cAAc;IAC7B,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,SAAS,GAAG,KAAK,CAAC;IAC5B,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAsCD,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,cAAc,GACpB,OAAO,CAAC;IAAE,cAAc,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC,
|
|
1
|
+
{"version":3,"file":"remote.d.ts","sourceRoot":"","sources":["../src/remote.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD,MAAM,WAAW,cAAc;IAC7B,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,SAAS,GAAG,KAAK,CAAC;IAC5B,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,SAAS,GAAG,KAAK,CAAC;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAChB;AAsCD,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,cAAc,GACpB,OAAO,CAAC;IAAE,cAAc,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC,CAmDxD;AAED,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,cAAc,EACrB,GAAG,EAAE,QAAQ,EACb,MAAM,EAAE,OAAO,GACd,OAAO,CAAC,OAAO,CAAC,CAoDlB;AAED,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,cAAc,EACrB,KAAK,EAAE,iBAAiB,GACvB,OAAO,CAAC,OAAO,CAAC,CAalB;AAED,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,cAAc,EACrB,KAAK,EAAE,kBAAkB,GACxB,OAAO,CAAC,OAAO,CAAC,CAalB;AAED,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC,CAQ/C"}
|
package/dist/remote.js
CHANGED
|
@@ -18,6 +18,9 @@ async function request(options, path, init = {}) {
|
|
|
18
18
|
return body;
|
|
19
19
|
}
|
|
20
20
|
export async function ensureRemoteTarget(config, state) {
|
|
21
|
+
const projectName = config.projectId;
|
|
22
|
+
if (!projectName)
|
|
23
|
+
throw new Error("lynxship.json must contain projectId. Run `lynxship init` first.");
|
|
21
24
|
const options = {
|
|
22
25
|
apiUrl: config.cli?.apiUrl,
|
|
23
26
|
token: config.cli?.token ?? process.env.LYNXSHIP_TOKEN,
|
|
@@ -29,7 +32,7 @@ export async function ensureRemoteTarget(config, state) {
|
|
|
29
32
|
const organization = await request(options, "/v1/organizations", {
|
|
30
33
|
method: "POST",
|
|
31
34
|
body: JSON.stringify({
|
|
32
|
-
name: `${
|
|
35
|
+
name: `${projectName} organization`,
|
|
33
36
|
ownerUserId: "cli",
|
|
34
37
|
}),
|
|
35
38
|
});
|
|
@@ -39,7 +42,6 @@ export async function ensureRemoteTarget(config, state) {
|
|
|
39
42
|
let projectId = state.remoteProjectId;
|
|
40
43
|
if (!projectId) {
|
|
41
44
|
const projects = await request(options, `/v1/projects?organizationId=${encodeURIComponent(organizationId)}`);
|
|
42
|
-
const projectName = config.projectId ?? "local_project";
|
|
43
45
|
projectId = projects.find((project) => project.name === projectName)?.id;
|
|
44
46
|
if (!projectId) {
|
|
45
47
|
const project = await request(options, "/v1/projects", {
|
|
@@ -108,6 +110,16 @@ export async function publishOtaRelease(config, state, input) {
|
|
|
108
110
|
body: JSON.stringify({ ...input, ...target }),
|
|
109
111
|
});
|
|
110
112
|
}
|
|
113
|
+
export async function rollbackOtaRelease(config, state, input) {
|
|
114
|
+
const target = await ensureRemoteTarget(config, state);
|
|
115
|
+
return request({
|
|
116
|
+
apiUrl: config.cli?.apiUrl,
|
|
117
|
+
token: config.cli?.token ?? process.env.LYNXSHIP_TOKEN,
|
|
118
|
+
}, "/v1/ota/rollback", {
|
|
119
|
+
method: "POST",
|
|
120
|
+
body: JSON.stringify({ ...input, ...target }),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
111
123
|
export async function fetchOtaPublicKey(config) {
|
|
112
124
|
return request({
|
|
113
125
|
apiUrl: config.cli?.apiUrl,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secure-store.d.ts","sourceRoot":"","sources":["../src/secure-store.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"secure-store.d.ts","sourceRoot":"","sources":["../src/secure-store.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,iBAAiB;IAChC,EAAE,CAAC,EAAE;QACH,WAAW,EAAE,MAAM,CAAC;QACpB,eAAe,EAAE,MAAM,CAAC;KACzB,CAAC;IACF,OAAO,CAAC,EAAE;QACR,YAAY,EAAE,MAAM,CAAC;QACrB,QAAQ,EAAE,MAAM,CAAC;QACjB,gBAAgB,EAAE,MAAM,CAAC;QACzB,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,UAAU,CAAC,EAAE;QACX,kBAAkB,EAAE,MAAM,CAAC;QAC3B,aAAa,EAAE,MAAM,CAAC;QACtB,KAAK,EAAE,MAAM,CAAC;QACd,aAAa,EAAE,OAAO,GAAG,WAAW,GAAG,YAAY,GAAG,QAAQ,CAAC;KAChE,CAAC;IACF,eAAe,CAAC,EAAE;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,UAAU,EAAE,MAAM,CAAC;QACnB,gBAAgB,EAAE,MAAM,CAAC;QACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,eAAe,CAAC,EAAE,MAAM,CAAC;KAC1B,CAAC;CACH;AAED,wBAAgB,4BAA4B,IAAI,MAAM,CAKrD;AAmYD,wBAAsB,eAAe,CACnC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,iBAAiB,CAAC,CAM5B;AAED,wBAAsB,eAAe,CACnC,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,iBAAiB,EAC9B,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,OAAO,CAAC,IAAI,CAAC,CAmCf"}
|
package/dist/secure-store.js
CHANGED
|
@@ -3,11 +3,14 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { assert } from "@lynxship/contracts";
|
|
5
5
|
import { globalLynxShipDirectory } from "./paths.js";
|
|
6
|
+
import { commandExists } from "./process-runner.js";
|
|
6
7
|
export function credentialStorageDescription() {
|
|
7
8
|
if (process.platform === "win32")
|
|
8
9
|
return "Windows DPAPI encrypted";
|
|
9
10
|
if (process.platform === "darwin")
|
|
10
11
|
return "macOS Keychain";
|
|
12
|
+
if (commandExists("secret-tool"))
|
|
13
|
+
return "Linux Secret Service";
|
|
11
14
|
return "owner-only credential file";
|
|
12
15
|
}
|
|
13
16
|
const fileName = ".credentials.dpapi.json";
|
|
@@ -71,9 +74,31 @@ function runSecurity(args) {
|
|
|
71
74
|
child.stdin.end();
|
|
72
75
|
});
|
|
73
76
|
}
|
|
77
|
+
function runSecretTool(args, input = "") {
|
|
78
|
+
return new Promise((resolveOutput, reject) => {
|
|
79
|
+
const child = spawn("secret-tool", args, {
|
|
80
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
81
|
+
});
|
|
82
|
+
let output = "";
|
|
83
|
+
let error = "";
|
|
84
|
+
child.stdout.on("data", (chunk) => (output += chunk.toString()));
|
|
85
|
+
child.stderr.on("data", (chunk) => (error += chunk.toString()));
|
|
86
|
+
child.once("error", reject);
|
|
87
|
+
child.once("close", (code) => {
|
|
88
|
+
if (code !== 0)
|
|
89
|
+
reject(new Error(error.trim() || "Linux Secret Service operation failed"));
|
|
90
|
+
else
|
|
91
|
+
resolveOutput(output.trim());
|
|
92
|
+
});
|
|
93
|
+
child.stdin.end(input);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
74
96
|
function keychainAccount(root, global) {
|
|
75
97
|
return global ? "global" : `project:${resolve(root)}`;
|
|
76
98
|
}
|
|
99
|
+
function secretServiceAccount(root, global) {
|
|
100
|
+
return keychainAccount(root, global);
|
|
101
|
+
}
|
|
77
102
|
function credentialValues(credentials) {
|
|
78
103
|
const values = {};
|
|
79
104
|
if (credentials.r2) {
|
|
@@ -185,6 +210,51 @@ async function saveKeychain(root, credentials, global) {
|
|
|
185
210
|
]);
|
|
186
211
|
await unlink(credentialFile(root, global)).catch(() => undefined);
|
|
187
212
|
}
|
|
213
|
+
async function readSecretService(root, global) {
|
|
214
|
+
if (process.platform !== "linux" || !commandExists("secret-tool"))
|
|
215
|
+
return undefined;
|
|
216
|
+
try {
|
|
217
|
+
const value = await runSecretTool([
|
|
218
|
+
"lookup",
|
|
219
|
+
"application",
|
|
220
|
+
keychainService,
|
|
221
|
+
"account",
|
|
222
|
+
secretServiceAccount(root, global),
|
|
223
|
+
]);
|
|
224
|
+
if (!value)
|
|
225
|
+
return undefined;
|
|
226
|
+
const stored = JSON.parse(value);
|
|
227
|
+
assert(stored.version === 1 && stored.platform === "linux-secret-service", "CLI_CREDENTIALS_INVALID", "Unsupported LynxShip Linux Secret Service record");
|
|
228
|
+
return credentialsFromValues(stored.values);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function saveSecretService(root, credentials, global) {
|
|
235
|
+
if (process.platform !== "linux" || !commandExists("secret-tool"))
|
|
236
|
+
return false;
|
|
237
|
+
const record = {
|
|
238
|
+
version: 1,
|
|
239
|
+
platform: "linux-secret-service",
|
|
240
|
+
values: credentialValues(credentials),
|
|
241
|
+
};
|
|
242
|
+
try {
|
|
243
|
+
await runSecretTool([
|
|
244
|
+
"store",
|
|
245
|
+
"--label",
|
|
246
|
+
"LynxShip CLI credentials",
|
|
247
|
+
"application",
|
|
248
|
+
keychainService,
|
|
249
|
+
"account",
|
|
250
|
+
secretServiceAccount(root, global),
|
|
251
|
+
], JSON.stringify(record));
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
188
258
|
function mergeCredentials(global, project) {
|
|
189
259
|
return {
|
|
190
260
|
r2: project.r2 ?? global.r2,
|
|
@@ -199,6 +269,7 @@ async function readCredentialFile(file) {
|
|
|
199
269
|
assert(encrypted.version === 1 &&
|
|
200
270
|
(encrypted.platform === "windows-dpapi" ||
|
|
201
271
|
encrypted.platform === "macos-keychain" ||
|
|
272
|
+
encrypted.platform === "linux-secret-service" ||
|
|
202
273
|
encrypted.platform === "file-mode-600"), "CLI_CREDENTIALS_INVALID", "Unsupported LynxShip credential store");
|
|
203
274
|
const values = Object.fromEntries(await Promise.all(Object.entries(encrypted.values).map(async ([key, value]) => [
|
|
204
275
|
key,
|
|
@@ -220,11 +291,24 @@ async function readCredentialStore(root, global) {
|
|
|
220
291
|
if (keychain)
|
|
221
292
|
return keychain;
|
|
222
293
|
}
|
|
294
|
+
if (process.platform === "linux") {
|
|
295
|
+
const secretService = await readSecretService(root, global);
|
|
296
|
+
if (secretService)
|
|
297
|
+
return secretService;
|
|
298
|
+
}
|
|
223
299
|
const legacy = await readCredentialFile(credentialFile(root, global));
|
|
224
300
|
if (process.platform === "darwin" &&
|
|
225
301
|
(legacy.r2 !== undefined || legacy.android !== undefined)) {
|
|
226
302
|
await saveKeychain(root, legacy, global).catch(() => undefined);
|
|
227
303
|
}
|
|
304
|
+
if (process.platform === "linux" &&
|
|
305
|
+
(legacy.r2 !== undefined ||
|
|
306
|
+
legacy.android !== undefined ||
|
|
307
|
+
legacy.googlePlay !== undefined ||
|
|
308
|
+
legacy.appStoreConnect !== undefined) &&
|
|
309
|
+
(await saveSecretService(root, legacy, global))) {
|
|
310
|
+
await unlink(credentialFile(root, global)).catch(() => undefined);
|
|
311
|
+
}
|
|
228
312
|
return legacy;
|
|
229
313
|
}
|
|
230
314
|
export async function loadCredentials(root) {
|
|
@@ -240,6 +324,12 @@ export async function saveCredentials(root, credentials, options = {}) {
|
|
|
240
324
|
await saveKeychain(root, credentials, global);
|
|
241
325
|
return;
|
|
242
326
|
}
|
|
327
|
+
if (process.platform === "linux") {
|
|
328
|
+
if (await saveSecretService(root, credentials, global)) {
|
|
329
|
+
await unlink(credentialFile(root, global)).catch(() => undefined);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
243
333
|
const values = credentialValues(credentials);
|
|
244
334
|
const encrypted = {
|
|
245
335
|
version: 1,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lynxship/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Build, sign, store, submit and update LynxJS applications from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -67,11 +67,11 @@
|
|
|
67
67
|
"cli-progress": "3.12.0",
|
|
68
68
|
"ora": "8.2.0",
|
|
69
69
|
"qrcode-terminal": "0.12.0",
|
|
70
|
+
"@lynxship/db": "0.1.0",
|
|
70
71
|
"@lynxship/build-orchestrator": "0.1.0",
|
|
71
|
-
"@lynxship/contracts": "0.1.0",
|
|
72
|
-
"@lynxship/submit": "0.1.0",
|
|
73
72
|
"@lynxship/signing": "0.1.0",
|
|
74
|
-
"@lynxship/
|
|
73
|
+
"@lynxship/contracts": "0.1.0",
|
|
74
|
+
"@lynxship/submit": "0.1.0"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
77
|
"@types/cli-progress": "3.11.6",
|