@lynxship/cli 0.1.2 → 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 CHANGED
@@ -27,11 +27,11 @@ The package exposes the `lynxship` executable.
27
27
 
28
28
  ## Requirements
29
29
 
30
- - Node.js 22 LTS or newer; Node.js 24 LTS is the recommended production
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, `adb`, build tools and the project's
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. They are never written to `lynxship.json`.
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 Build, sign and upload an artifact
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
@@ -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":"AAIA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAEpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAUhD,UAAU,mBAAmB;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,YAAY,CAAC;IACtB,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,CAU7D;AAED,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,QAAQ,EACb,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,QAAQ,CAAC,CAoInB"}
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"}
@@ -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) => existsSync(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
- return access(join(root, "android", process.platform === "win32" ? "gradlew.bat" : "gradlew"))
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
- await loadR2(options.root);
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 runProcess(process.execPath, [join("android", "sync-bundle.mjs")], {
141
- cwd: options.root,
142
- env: environment,
143
- ...processOptions,
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
- await runProcess(wrapper, [artifact.task], {
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
- step("Uploading signed artifact to Cloudflare R2…", 80);
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", undefined, {
171
- onProgress: (uploadedBytes, totalBytes) => {
172
- const transfer = totalBytes === 0 ? 1 : uploadedBytes / totalBytes;
173
- options.onProgress?.(80 + transfer * 19, `Uploading signed artifact to Cloudflare R2… ${Math.round(transfer * 10000) / 100}%`);
174
- },
175
- });
176
- assert(uploaded.hash === hash, "BUILD_ARTIFACT_HASH", "R2 artifact hash mismatch");
177
- job.artifact = {
178
- name: artifactName,
179
- hash,
180
- path: artifactPath,
181
- key: uploaded.key,
182
- size: uploaded.size,
183
- contentType: uploaded.contentType,
184
- url: uploaded.url,
185
- expiresAt: uploaded.expiresAt,
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
  }
@@ -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;AA+ED,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,0BAA0B,CAAC,CAoCrC"}
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
- assert(status.r2, "CLI_R2_REQUIRED", "Cloudflare R2 must be configured first. Run `lynxship storage configure`.");
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 Create a local/cloud build job
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
- --bundle <path[,path]> Lynx bundle/assets to publish (default: discover dist/*.lynx.bundle)
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
- doctor --platform <p> Check Autolink for android or ios (default: android)
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", "local_project") }, null, 2)}\n`);
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: flag("--project-id", "local_project"),
526
+ value: initializedConfig.projectId ?? "unassigned",
477
527
  valueColor: "purple",
478
528
  },
479
529
  { label: "Configuration", value: file, valueColor: "muted" },
@@ -489,6 +539,9 @@ 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]);
493
546
  const nodeSupported = nodeMajor >= 22;
494
547
  const nodeRecommended = nodeMajor % 2 === 0;
@@ -519,6 +572,26 @@ async function main() {
519
572
  status: config.projectId ? "pass" : "fail",
520
573
  value: config.projectId ? "found" : "missing · fix: lynxship init",
521
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
+ : []),
522
595
  {
523
596
  name: "cloudflare-r2",
524
597
  ok: configuration.r2,
@@ -794,7 +867,7 @@ async function main() {
794
867
  const controlPlaneSubmission = candidate.artifact?.path
795
868
  ? await submitRealArtifact(config, state, candidate, latest)
796
869
  : await submissions.submit({
797
- projectId: config.projectId ?? "local_project",
870
+ projectId: configuredProjectId(config),
798
871
  organizationId: "local_org",
799
872
  platform,
800
873
  artifact: candidate.artifact ?? { hash: `local-${candidate.id}` },
@@ -855,11 +928,11 @@ async function main() {
855
928
  }
856
929
  return;
857
930
  }
858
- if (command === "update") {
931
+ if (command === "update" && args[0] !== "rollback") {
859
932
  const config = await loadConfig(root);
860
933
  const platform = platformValue(flag("--platform", "android"));
861
934
  await requireOperationalConfiguration(platform);
862
- const projectId = config.projectId ?? "local_project";
935
+ const projectId = configuredProjectId(config);
863
936
  const localMode = args.includes("--local") || process.env.LYNXSHIP_SUBMIT_MODE === "mock";
864
937
  const explicitBundles = flag("--bundle");
865
938
  const runtime = await inspectRuntimeFingerprint(root, platform, config);
@@ -974,10 +1047,82 @@ async function main() {
974
1047
  }
975
1048
  return;
976
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
+ }
977
1121
  assert(command === "build", "CLI_COMMAND", `Unknown command: ${command}`);
978
1122
  const subcommand = args[0] && !args[0].startsWith("--") ? args.shift() : "create";
979
1123
  const platform = platformValue(flag("--platform", "android"));
980
- await requireOperationalConfiguration(platform);
1124
+ const skipUpload = args.includes("--no-upload");
1125
+ await requireOperationalConfiguration(platform, { requireR2: !skipUpload });
981
1126
  if (subcommand === "list") {
982
1127
  printValue(builds.list());
983
1128
  return;
@@ -1005,7 +1150,7 @@ async function main() {
1005
1150
  await requireAutolinkReady(root, platform);
1006
1151
  const runtime = await inspectRuntimeFingerprint(root, platform, config);
1007
1152
  const job = await builds.create({
1008
- projectId: config.projectId ?? "local_project",
1153
+ projectId: configuredProjectId(config),
1009
1154
  organizationId: "local_org",
1010
1155
  platform,
1011
1156
  profile,
@@ -1018,14 +1163,23 @@ async function main() {
1018
1163
  try {
1019
1164
  progress.update(undefined, "Preparing build pipeline…");
1020
1165
  if (!args.includes("--no-wait")) {
1021
- const realAndroid = platform === "android" && (await hasAndroidHost(root));
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));
1022
1173
  const realIos = platform === "ios" && hasIosHost(root, config.build?.[profile]);
1023
1174
  if (platform === "ios" && !realIos && !args.includes("--local"))
1024
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.");
1025
1178
  if (realAndroid) {
1026
1179
  await runRealAndroidBuild(job, {
1027
1180
  root,
1028
1181
  profile: config.build?.[profile] ?? {},
1182
+ uploadArtifacts: !skipUpload,
1029
1183
  quiet: json,
1030
1184
  onEvent: (message) => progress.event(message),
1031
1185
  onProgress: (value, label) => progress.update(value, label),
@@ -1035,6 +1189,7 @@ async function main() {
1035
1189
  await runRealIosBuild(job, {
1036
1190
  root,
1037
1191
  profile: config.build?.[profile] ?? {},
1192
+ uploadArtifacts: !skipUpload,
1038
1193
  quiet: json,
1039
1194
  onEvent: (message) => progress.event(message),
1040
1195
  onProgress: (value, label) => progress.update(value, label),
@@ -3,6 +3,7 @@ import type { BuildProfile } from "./config.js";
3
3
  interface IosBuildOptions {
4
4
  root: string;
5
5
  profile: BuildProfile;
6
+ uploadArtifacts?: boolean;
6
7
  quiet?: boolean;
7
8
  onEvent?: (message: string) => void;
8
9
  onProgress?: (value?: number, label?: string) => void;
@@ -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,CAsLnB"}
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
- await loadR2(options.root);
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
- job.artifact = {
140
- name: artifactName,
141
- hash,
142
- path: artifactPath,
143
- key: uploaded.key,
144
- size: uploaded.size,
145
- contentType: uploaded.contentType,
146
- url: uploaded.url,
147
- expiresAt: uploaded.expiresAt,
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
  }
@@ -1 +1 @@
1
- {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../src/paths.ts"],"names":[],"mappings":"AAGA,wBAAgB,uBAAuB,IAAI,MAAM,CAYhD"}
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
- return join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "lynxship");
11
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME;
12
+ return join(xdgConfigHome && isAbsolute(xdgConfigHome)
13
+ ? xdgConfigHome
14
+ : join(home, ".config"), "lynxship");
12
15
  }
@@ -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":"AAKA,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,CA8Cf;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,CAWtD"}
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"}
@@ -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 lastLine = output.trim().split(/\r?\n/).at(-1) ?? "";
81
- reject(new Error(`${command} ${args.join(" ")} failed (${signal ?? `exit ${code}`})${lastLine ? `: ${lastLine}` : ""}`));
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
- execFileSync(process.platform === "win32" ? "where.exe" : "which", [command], { stdio: "ignore" });
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,CAYf;AAED,OAAO,EAAE,eAAe,EAAE,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;
@@ -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,CA+CxD;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,iBAAiB,CACrC,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC,CAQ/C"}
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: `${config.projectId ?? "local_project"} organization`,
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":"AAMA,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,CAIrD;AA6RD,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,CA6Bf"}
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"}
@@ -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.2",
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/contracts": "0.1.0",
70
+ "@lynxship/db": "0.1.0",
71
71
  "@lynxship/build-orchestrator": "0.1.0",
72
72
  "@lynxship/signing": "0.1.0",
73
- "@lynxship/submit": "0.1.0",
74
- "@lynxship/db": "0.1.0"
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",