Skip to content

Finish open issues: global config (#8), remote doctor+verify (#6), shared picker (#11), key cleanup (#4)#12

Merged
YosefHayim merged 4 commits into
mainfrom
fix/finish-open-issues
Jun 14, 2026
Merged

Finish open issues: global config (#8), remote doctor+verify (#6), shared picker (#11), key cleanup (#4)#12
YosefHayim merged 4 commits into
mainfrom
fix/finish-open-issues

Conversation

@YosefHayim

@YosefHayim YosefHayim commented Jun 14, 2026

Copy link
Copy Markdown
Owner

User description

Closes #8. Closes #6. Closes #11. Closes #4.

Four open issues, each driven to a finishable spec via /grill-me, then implemented. (#9 was already shipped in v0.5.0 and was closed separately.) Every commit passes the full gate: typecheck && lint && test && build.

#8fix(config): resolve the launch-store import on global installs

A globally-installed launch loaded the user's launch.config.ts through jiti, which resolved its import { defineConfig } from "launch-store" from the user's project — so a project with no local launch-store dep threw Cannot find package 'launch-store'. Now a jiti alias pins the specifier to the running CLI's own public entry: zero local dep needed, and the config always binds to the exact defineConfig of the CLI consuming it (no dual-package skew). Editor types stay an opt-in devDependency. Covered by a no-node_modules loader test.

#6feat(remote): run the toolchain doctor + verify the artifact on remote builds

The remote-Mac build assumed every tool was present and grabbed the artifact with a blind ls *.ipa.

  • Doctor: a host preflight generated from the canonical REQUIRED_TOOLS (single source of truth) runs before every remote build — an AWS host we own brew-installs gaps; a BYO-SSH host is only asserted (never mutated) and fails with the same brew install … hints launch doctor prints. The one-time golden-AMI bootstrap is realigned to the same list.
  • Verify: fail fast on the host when gym produced no non-empty .ipa, then reuse the exported, tested assertDeviceArtifact after pull as the authoritative simulator/.app/empty guard.

#11refactor(prompt): share one type-to-search picker

The app picker already fuzzy-searched past a threshold (shipped in 83364b8), but the same flat-select pattern was duplicated in the .p8 key picker. Extracted a single core/prompt.ts pickOne (+ the fuzzyMatch filter) and routed both selectApp and the credentials key picker through it. The non-interactive policy is explicit per call site: selectApp now refuses to guess which app to build with no TTY and points at --app (it previously hung on a prompt), while the key picker falls back to the newest match with a note.

#4feat(creds): offer to remove the imported key's plaintext source

set-key left the plaintext .p8 in ~/Downloads after importing it into the keychain. Now, after a verified import, Launch offers to delete the source file when it sits in a scanned discovery dir (default yes); a key deliberately placed elsewhere (--p8 ~/vault/…) is never touched, and nothing is moved into ~/.launch (which would break the secrets-only-in-keychain invariant — the issue's "relocate" option was unsafe). The iOS offer is gated on Apple verifying the stored key (a .p8 downloads exactly once); the re-downloadable Play service-account JSON gets the same offer.

🤖 Generated with Claude Code


Summary by cubic

Finishes four open items: fixes global launch-store config resolution, adds remote build doctor and artifact verification, unifies the picker UX, and offers to delete imported keys’ plaintext files for better security.

  • New Features

    • Remote builds: run an on-host doctor generated from the canonical REQUIRED_TOOLS; AWS hosts install gaps via Homebrew, BYO hosts only assert with clear hints. AMI bootstrap now uses the same list.
    • Artifact verification: fail fast when gym produces no non-empty .ipa, then re-verify after pull to block simulator/.app/empty archives.
    • Credentials: after a verified import, offer to delete the plaintext .p8 or Play service-account JSON when found in discovery dirs (default yes); never delete non-interactively and never relocate files.
    • Shared picker: new pickOne with fuzzy search for large lists. App picker refuses to guess without a TTY and points to --app; key picker falls back to the newest match with a note.
  • Bug Fixes

    • Global config: resolve import { defineConfig } from "launch-store" via a jiti alias to the running CLI’s entry, so global installs work without a local launch-store dependency (covered by a no-node_modules loader test).

Written for commit ac75e28. Summary will update on new commits.

Review in cubic


CodeAnt-AI Description

Finish open fixes for config loading, remote builds, shared pickers, and key cleanup

What Changed

  • Global installs can now load launch.config.ts even when the project does not have a local launch-store dependency
  • App selection and credential key selection now use the same picker behavior, including fuzzy search for long lists and a clear --app hint when running without a terminal
  • Remote builds now check the host toolchain before starting, install missing brew-based tools on Launch-owned Macs, and fail with clear install hints on BYO hosts
  • Remote builds now reject missing or empty .ipa files before transfer, and the same artifact check is used after download
  • Importing credentials can now offer to delete the source key file after it has been stored safely, while leaving deliberately placed files untouched

Impact

✅ Fewer global install config failures
✅ Clearer app and key selection
✅ Fewer remote build and artifact issues

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added interactive prompts to safely remove plaintext credential files after import.
    • Added unified selection system with fuzzy search when multiple options are available.
    • Remote Mac builds now include preflight toolchain verification.
  • Improvements

    • Enhanced app selection with interactive prompting or explicit flag requirement in non-interactive mode.
    • Improved non-interactive mode with clearer error messages for ambiguous scenarios.
    • Config loading now properly supports all helper imports.

YosefHayim and others added 4 commits June 14, 2026 05:40
A globally-installed `launch` loads the user's launch.config.ts through jiti, which
resolved its `import { defineConfig } from "launch-store"` from the user's project —
so a project with no local launch-store dep threw "Cannot find package 'launch-store'".

Pin the specifier to the running CLI's own public entry via a jiti alias, so the
config loads with zero local dependency and always binds to the exact defineConfig
of the CLI consuming it (no dual-package version skew). Editor types stay an opt-in
devDependency. Covered by a no-node_modules loader test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e builds (#6)

The remote-Mac build assumed every tool was present and grabbed the artifact with a
blind `ls *.ipa` — a missing fastlane/pod failed cryptically mid-build, and a dead
export was transferred (and submitted) unguarded.

Doctor: generate a host preflight from the canonical REQUIRED_TOOLS (single source of
truth) and run it before every remote build. An AWS host we own brew-installs any
gaps; a BYO-SSH host is only asserted (never mutated) and fails with the same
`brew install …` hints `launch doctor` prints. The one-time golden-AMI bootstrap is
realigned to the same list so images stop being a hand-maintained subset.

Verify: fail fast on the host when gym produced no non-empty .ipa (no wasted
transfer), then reuse the exported assertDeviceArtifact after pull as the
authoritative simulator/.app/empty guard — identical errors to local builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…key pickers (#11)

The app picker already fuzzy-searched past a threshold, but the same flat-select
pattern was duplicated in the .p8 key picker. Extract a single core/prompt.ts
`pickOne` (+ the `fuzzyMatch` filter) — flat select for short lists, fuzzy
autocomplete past the threshold — and route both selectApp and the credentials key
picker through it.

The non-interactive policy is explicit per call site: selectApp now REFUSES to guess
which app to build with no TTY and points at --app (it would previously hang on a
prompt), while the key picker falls back to the newest match with a note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
set-key imported the .p8 into the keychain but left the plaintext key sitting in
~/Downloads — a sync'd, backed-up location where a private key shouldn't linger.

After a verified import, offer to delete the source file when it's in a scanned
discovery dir (~/Downloads, cwd, …), defaulting to yes; a key the user deliberately
placed elsewhere (--p8 ~/vault/…) is never touched, and nothing is moved into
~/.launch (secrets stay only in the keychain). Non-interactive runs keep the file and
print its path — a key is never deleted without consent.

The iOS offer is gated on Apple actually verifying the stored key (teamId resolved),
since a .p8 downloads from Apple exactly once; the re-downloadable Play
service-account JSON gets the same offer without that gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codeant-ai

codeant-ai Bot commented Jun 14, 2026

Copy link
Copy Markdown

CodeAnt AI is reviewing your PR.


Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@changeset-bot

changeset-bot Bot commented Jun 14, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: ac75e28

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@qodo-code-review

qodo-code-review Bot commented Jun 14, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (2) 📎 Requirement gaps (0)

Context used
✅ Compliance rules (platform): 13 rules

Grey Divider


Action required

1. remoteBuild.ts lacks colocated tests 📘 Rule violation ▣ Testability
Description
src/core/remoteBuild.ts adds/changes non-trivial remote-build behavior (remote doctor execution
and artifact verification) but there is no corresponding src/core/remoteBuild.test.ts file. This
violates the requirement to co-locate tests with new/significantly modified logic, reducing change
safety and regression detection.
Code

src/core/remoteBuild.ts[R137-155]

+/**
+ * Run the toolchain doctor ON the remote Mac before building — the remote twin of `launch doctor`.
+ * Uploads {@link remoteToolchainPreflight} and executes it: `"install"` for an AWS host we own (brew-
+ * installs any gaps) or `"assert"` for a BYO-SSH host (checks + fails with hints, never mutates the
+ * user's machine). A missing required tool exits the preflight non-zero, so `sshRun` rejects and the
+ * build fails fast with the gaps listed instead of a cryptic error deep inside fastlane.
+ */
+export async function runDoctorOnHost(session: RemoteSession, mode: "install" | "assert"): Promise<void> {
+  const staging = mkdtempSync(join(tmpdir(), "launch-remote-"));
+  const scriptLocal = join(staging, "doctor.sh");
+  writeFileSync(scriptLocal, remoteToolchainPreflight(mode));
+  const scriptRemote = `${session.credsDir}/doctor.sh`;
+  try {
+    await scpUp(session.target, scriptLocal, scriptRemote);
+  } finally {
+    rmSync(staging, { recursive: true, force: true });
+  }
+  await sshRun(session.target, `bash ${shellQuote(scriptRemote)}`);
+}
Evidence
PR Compliance ID 1015978 requires a co-located *.test.ts for newly added or significantly modified
TypeScript logic. The diff adds new remote-build logic in src/core/remoteBuild.ts (e.g.,
runDoctorOnHost), but no matching src/core/remoteBuild.test.ts is present to test these
behaviors.

Rule 1015978: Co-locate tests with new logic using *.test.ts files
src/core/remoteBuild.ts[137-155]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`src/core/remoteBuild.ts` was significantly extended/modified (e.g., `runDoctorOnHost` and remote artifact verification behavior), but there is no co-located `src/core/remoteBuild.test.ts`.

## Issue Context
Compliance requires co-located `*.test.ts` files for new/significantly modified TypeScript modules containing executable logic.

## Fix Focus Areas
- src/core/remoteBuild.ts[137-155]
- src/core/remoteBuild.ts[220-224]
- src/core/remoteBuild.ts[320-326]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Broken config self-alias 🐞 Bug ≡ Correctness
Description
loadConfig pins import "launch-store" to SELF_ENTRY, but SELF_ENTRY is hard-coded as
../index.js relative to src/core/config.ts, while this repo’s public entry is src/index.ts
(and source checkouts typically won’t have src/index.js). This makes config loading rely on
nonstandard resolution and can fail in dev/test or any run-from-source environment.
Code

src/core/config.ts[R20-31]

+const SELF_ENTRY = fileURLToPath(new URL("../index.js", import.meta.url));
+
/**
 * On-the-fly loader for the user's config. The compiled `launch` binary runs on plain Node, which
- * can't `import()` a TypeScript file — jiti transpiles `launch.config.ts` in memory and resolves its
- * `launch-store` import from the user's project. (Chosen over bundling a TS toolchain ourselves; it's
- * the same loader Nuxt/ESLint use for config files.)
+ * can't `import()` a TypeScript file — jiti transpiles `launch.config.ts` in memory. The `alias` pins
+ * the config's `import { defineConfig } from "launch-store"` to {@link SELF_ENTRY}, so a globally
+ * installed `launch` loads the config even when the user's project has no local `launch-store`
+ * dependency (issue #8), and the config always binds to the exact `defineConfig` of the CLI consuming
+ * it — no dual-package version skew. (jiti chosen over bundling a TS toolchain ourselves; it's the
+ * same loader Nuxt/ESLint use for config files.)
 */
-const jiti = createJiti(import.meta.url);
+const jiti = createJiti(import.meta.url, { alias: { "launch-store": SELF_ENTRY } });
Evidence
SELF_ENTRY is set to an index.js path and used as the alias target, while the repo’s public
entrypoint is src/index.ts exporting defineConfig, so a source checkout without src/index.js
can’t satisfy the alias path directly.

src/core/config.ts[14-32]
src/index.ts[1-11]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`SELF_ENTRY` is computed as `../index.js` relative to `src/core/config.ts`, but in the source tree the public entry is `src/index.ts` (with TS importing `.js` specifiers). If `src/index.js` is absent, the jiti alias can point at a non-existent file and break loading a user `launch.config.ts` that imports `defineConfig` from `launch-store`.

### Issue Context
This alias is used by jiti when importing user configs, so it must resolve reliably both in production (`dist/`) and in source/test environments.

### Fix Focus Areas
- src/core/config.ts[14-32]

### Suggested fix
Implement a runtime fallback that selects an entry file that actually exists, e.g.:
- compute `indexJs = fileURLToPath(new URL("../index.js", import.meta.url))`
- if `!existsSync(indexJs)`, fall back to `fileURLToPath(new URL("../index.ts", import.meta.url))`
- use the resolved path in the jiti alias

(Alternatively, use a resolver-based approach like `import.meta.resolve` for the package entry if your Node target supports it.)

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. AMI bootstrap misses brew failure 🐞 Bug ☼ Reliability
Description
The golden-AMI bootstrap script emits LAUNCH_NO_BREW when Homebrew is missing, but
bootstrapToolchain only throws on LAUNCH_NO_XCODE. This can snapshot an AMI that silently failed
to install required brew tools, causing remote builds to fail later with harder-to-diagnose errors.
Code

src/providers/compute/awsEc2Mac.ts[R451-468]

+const BOOTSTRAP_BREW_LINES = REQUIRED_TOOLS.flatMap((tool) => {
+  if (tool.install.kind !== "brew") return [];
+  const fallback = tool.command === "fastlane" ? " || sudo gem install fastlane" : "";
+  return [`command -v ${tool.command} >/dev/null || brew install ${tool.install.formula}${fallback} || true`];
+});
+
/** Toolchain bootstrap script run once on a base AMI before snapshotting a golden image. */
const BOOTSTRAP_SCRIPT = [
  "set -e",
  "command -v brew >/dev/null || echo LAUNCH_NO_BREW",
-  "command -v node >/dev/null || brew install node || true",
-  "command -v fastlane >/dev/null || brew install fastlane || sudo gem install fastlane || true",
+  ...BOOTSTRAP_BREW_LINES,
  "xcodebuild -version >/dev/null 2>&1 || echo LAUNCH_NO_XCODE",
].join("\n");

-/** Install node/fastlane and assert full Xcode is present (the one part Launch can't legally redistribute). */
+/** Install the brew-able toolchain and assert full Xcode is present (the one part Launch can't legally redistribute). */
async function bootstrapToolchain(ssh: SshTarget): Promise<void> {
  const output = await sshCapture(ssh, BOOTSTRAP_SCRIPT);
  if (output.includes("LAUNCH_NO_XCODE")) {
Evidence
The script explicitly signals a missing Homebrew install, but the bootstrap function only checks for
the Xcode sentinel and would therefore proceed even if brew was absent.

src/providers/compute/awsEc2Mac.ts[446-474]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`BOOTSTRAP_SCRIPT` prints a sentinel when `brew` is missing, but `bootstrapToolchain()` doesn’t check it, so provisioning can appear successful even though none of the brew installs actually happened.

### Issue Context
The script uses `|| true` in install lines, so missing/failed installs won’t necessarily cause a non-zero exit. Relying on sentinels requires the caller to enforce them.

### Fix Focus Areas
- src/providers/compute/awsEc2Mac.ts[457-474]

### Suggested fix
Either:
- Make the script fail hard when brew is missing (e.g. `command -v brew >/dev/null || { echo LAUNCH_NO_BREW; exit 1; }`), or
- Add an explicit check in `bootstrapToolchain()`:
 - if output includes `LAUNCH_NO_BREW`, throw an actionable error telling the user to ensure Homebrew is installed on the base AMI.

Optionally also verify at least one required brew tool is present after bootstrap to catch install failures early.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. offerToRemoveImportedSecret in CLI 📘 Rule violation ⌂ Architecture
Description
src/cli/commands/creds.ts introduces a non-trivial security/policy decision (isInDiscoveryDir +
deletion-offer behavior) directly in the CLI command module instead of delegating to src/core.
This violates the architectural constraint to keep the CLI layer thin and pushes reusable
domain/policy logic into the CLI surface.
Code

src/cli/commands/creds.ts[R84-125]

+/**
+ * Whether `file` sits directly in one of the {@link SEARCH_DIRS} Launch scans — i.e. a sync'd/backed-up
+ * "dumping ground" (chiefly `~/Downloads`) where a private key shouldn't be left lying around once it's
+ * safely in the keychain. A key the user deliberately placed elsewhere (an explicit `--p8 ~/vault/…`)
+ * is NOT in a discovery dir, so it's never offered for deletion. Exported for the unit test.
+ */
+export function isInDiscoveryDir(file: string): boolean {
+  const parent = resolve(dirname(file));
+  return SEARCH_DIRS.some((dir) => resolve(dir) === parent);
+}
+
+/**
+ * After a secret has been imported into the OS keychain (now the source of truth), offer to remove the
+ * plaintext source file when it's sitting in a discovery directory. Honors the project invariant that
+ * secrets live only in the keychain — it never relocates the file into `~/.launch`, only deletes it.
+ *
+ * Interactive: prompt, defaulting to delete. Non-interactive: keep it and print the exact path, so a key
+ * is never deleted without consent (and the user still learns it's safe to remove).
+ */
+async function offerToRemoveImportedSecret(file: string, label: string, canPrompt: boolean): Promise<void> {
+  if (!isInDiscoveryDir(file)) return;
+  if (!canPrompt) {
+    console.log(
+      `The plaintext ${label} is still at ${tildify(file)} — it's in your keychain now, so you can delete it.`,
+    );
+    return;
+  }
+  const remove = await confirm({
+    message: `Remove the plaintext ${label} from ${tildify(file)}? It's now stored securely in your keychain.`,
+    initialValue: true,
+  });
+  if (isCancel(remove)) {
+    cancel("Cancelled.");
+    process.exit(0);
+  }
+  if (!remove) {
+    console.log(`Kept ${tildify(file)}.`);
+    return;
+  }
+  rmSync(file, { force: true });
+  console.log(`Removed ${tildify(file)}.`);
+}
Evidence
PR Compliance ID 1017170 requires the CLI layer (src/cli/...) to avoid implementing business
rules/policy logic and to delegate those concerns to src/core. The added helpers implement the
policy for when deletion should be offered and how non-interactive runs behave, all within the CLI
command file.

Rule 1017170: Keep CLI layer free of domain logic and delegate to core module
src/cli/commands/creds.ts[84-125]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The CLI command module `src/cli/commands/creds.ts` now contains policy/domain logic (`isInDiscoveryDir` and the deletion-offer rules) rather than delegating to a `src/core` module.

## Issue Context
The compliance rule requires CLI modules to focus on argument parsing and minimal orchestration, delegating business rules and reusable policies to `src/core`.

## Fix Focus Areas
- src/cli/commands/creds.ts[84-125]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Risky secret deletion default 🐞 Bug ☼ Reliability
Description
offerToRemoveImportedSecret offers deletion (defaulting to “yes”) for any file whose direct parent
directory equals one of SEARCH_DIRS, and SEARCH_DIRS includes process.cwd() and other commonly
intentional key locations (e.g. ~/.appstoreconnect/private_keys). This can lead to accidental
deletion of an intentionally-stored or even checked-in secret file if the user accepts the default.
Code

src/cli/commands/creds.ts[R81-125]

  process.cwd(),
];

+/**
+ * Whether `file` sits directly in one of the {@link SEARCH_DIRS} Launch scans — i.e. a sync'd/backed-up
+ * "dumping ground" (chiefly `~/Downloads`) where a private key shouldn't be left lying around once it's
+ * safely in the keychain. A key the user deliberately placed elsewhere (an explicit `--p8 ~/vault/…`)
+ * is NOT in a discovery dir, so it's never offered for deletion. Exported for the unit test.
+ */
+export function isInDiscoveryDir(file: string): boolean {
+  const parent = resolve(dirname(file));
+  return SEARCH_DIRS.some((dir) => resolve(dir) === parent);
+}
+
+/**
+ * After a secret has been imported into the OS keychain (now the source of truth), offer to remove the
+ * plaintext source file when it's sitting in a discovery directory. Honors the project invariant that
+ * secrets live only in the keychain — it never relocates the file into `~/.launch`, only deletes it.
+ *
+ * Interactive: prompt, defaulting to delete. Non-interactive: keep it and print the exact path, so a key
+ * is never deleted without consent (and the user still learns it's safe to remove).
+ */
+async function offerToRemoveImportedSecret(file: string, label: string, canPrompt: boolean): Promise<void> {
+  if (!isInDiscoveryDir(file)) return;
+  if (!canPrompt) {
+    console.log(
+      `The plaintext ${label} is still at ${tildify(file)} — it's in your keychain now, so you can delete it.`,
+    );
+    return;
+  }
+  const remove = await confirm({
+    message: `Remove the plaintext ${label} from ${tildify(file)}? It's now stored securely in your keychain.`,
+    initialValue: true,
+  });
+  if (isCancel(remove)) {
+    cancel("Cancelled.");
+    process.exit(0);
+  }
+  if (!remove) {
+    console.log(`Kept ${tildify(file)}.`);
+    return;
+  }
+  rmSync(file, { force: true });
+  console.log(`Removed ${tildify(file)}.`);
+}
Evidence
The discovery directories include cwd and standard key dirs, and the deletion prompt defaults to
true and calls rmSync, so a file directly inside those locations is eligible for default-yes
deletion.

src/cli/commands/creds.ts[71-125]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The deletion offer is gated by `isInDiscoveryDir()`, but `SEARCH_DIRS` includes directories that are plausibly deliberate long-term storage locations (project root/cwd, `./private_keys`, `~/.appstoreconnect/private_keys`). Because the confirm prompt defaults to delete (`initialValue: true`), it’s easy to accidentally remove a file the user meant to keep.

### Issue Context
The current logic only matches files directly inside these directories (by comparing `resolve(dirname(file))`), which is good, but the set of directories is broad and includes non-"dumping ground" locations.

### Fix Focus Areas
- src/cli/commands/creds.ts[71-125]

### Suggested fix
Pick one of these approaches:
1) Restrict `isInDiscoveryDir` to truly "dumping ground" dirs (e.g. `~/Downloads`), and/or
2) Track whether the secret was auto-discovered vs explicitly provided; only offer deletion for auto-discovered files, and/or
3) Keep the broader set but change the default to **not** delete (e.g. `initialValue: false`) for cwd/other dirs.

Update/extend unit tests accordingly to match the chosen policy.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces a shared pickOne/fuzzyMatch prompt helper and wires it into the creds command (plaintext credential cleanup, multi-p8 disambiguation) and the pipeline app picker. Adds remoteToolchainPreflight to generate a bash doctor script run on SSH hosts before builds, wires it into the remote pipeline, strengthens artifact validation, and refactors the AWS AMI bootstrap to derive install lines from REQUIRED_TOOLS. Fixes global-install launch.config.ts loading by aliasing launch-store in jiti.

Changes

Shared pickOne prompt helper, creds UX, and app picker refactor

Layer / File(s) Summary
pickOne helper: types, fuzzyMatch, and implementation
src/core/prompt.ts, src/core/prompt.test.ts
New module exports PICK_SEARCH_THRESHOLD, a subsequence fuzzyMatch, PickOption/NonInteractivePolicy/PickOneArgs types, and pickOne<T> with non-interactive require/fallback branching, adaptive select/autocomplete switching on option count, cancellation via process.exit(0), and index-to-value mapping. Tests cover fuzzyMatch edge cases and pickOne non-interactive paths.
creds: isInDiscoveryDir, offerToRemoveImportedSecret, and pickOne wiring
src/cli/commands/creds.ts, src/cli/commands/creds.test.ts
Exports isInDiscoveryDir to check if a credential file is directly under a discovery directory. Adds offerToRemoveImportedSecret for interactive/non-interactive plaintext-cleanup prompting. Replaces manual select in resolveP8Path with pickOne, and calls the cleanup helper after successful iOS and Android imports. Tests assert the four path-matching cases.
pipeline: replace in-file fuzzyMatch/app picker with pickOne
src/core/pipeline.ts, src/core/pipeline.test.ts
Removes the exported fuzzyMatch and APP_PICKER_SEARCH_THRESHOLD from pipeline.ts, imports pickOne, constructs options from app name/bundleId/packageName, gates prompting on process.stdin.isTTY, and requires --app in non-interactive mode. Tests drop the fuzzyMatch block and add a no-TTY selectApp regression.

Remote toolchain preflight and AWS bootstrap dynamic generation

Layer / File(s) Summary
remoteToolchainPreflight bash script generator
src/core/toolchain.ts, src/core/toolchain.test.ts
New exported remoteToolchainPreflight(mode) generates a bash script that checks each REQUIRED_TOOLS entry via command -v, installs missing brew-installable tools in install mode, warns for recommended-tool gaps, and exits with LAUNCH_PREFLIGHT_OK or LAUNCH_PREFLIGHT_FAILED. Tests cover required/recommended behaviors, assert-mode host-safety guarantees, and Xcode backtick hint quoting.
remoteBuild: runDoctorOnHost, pullArtifact validation, no-artifact guard
src/core/remoteBuild.ts
Exports runDoctorOnHost which stages the preflight script locally, uploads it to the per-run creds dir on the SSH host, executes it remotely, and cleans up via try/finally. Updates pullArtifact to compute .ipa byte size and call assertDeviceArtifact. Extends REMOTE_BUILD_SCRIPT with a post-fastlane gym check that emits LAUNCH_NO_ARTIFACT and exits non-zero if the .ipa is missing or empty.
remotePipeline: insert doctor step before signing
src/core/remotePipeline.ts
Imports runDoctorOnHost and inserts a logged toolchain-check step after project sync and before uploading signing material, passing install for AWS targets and assert for BYO SSH targets.
AWS AMI bootstrap: dynamic REQUIRED_TOOLS-based install lines
src/providers/compute/awsEc2Mac.ts
Imports REQUIRED_TOOLS and replaces hard-coded node/fastlane install lines in BOOTSTRAP_SCRIPT with dynamically generated brew install commands, retaining the fastlane gem-install fallback.

Config jiti alias fix for global launch-store installs

Layer / File(s) Summary
config.ts: SELF_ENTRY jiti alias and integration test
src/core/config.ts, src/core/config.test.ts
Computes SELF_ENTRY from fileURLToPath(import.meta.url) pointing to ../index.js and passes alias: { "launch-store": SELF_ENTRY } to createJiti, so defineConfig imports in user configs resolve to the running CLI. Integration test writes a launch.config.ts importing from launch-store in a project without node_modules and asserts the loaded config has expected profile values.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as launch CLI
    participant remotePipeline as runRemoteBuild
    participant runDoctorOnHost
    participant remoteToolchainPreflight as remoteToolchainPreflight(mode)
    participant SSHHost as EC2 / BYO Mac

    rect rgba(70, 130, 180, 0.5)
        note over remotePipeline,SSHHost: Project sync
        remotePipeline->>SSHHost: rsync project files
    end

    rect rgba(60, 179, 113, 0.5)
        note over remotePipeline,SSHHost: Toolchain preflight (new)
        remotePipeline->>runDoctorOnHost: runDoctorOnHost(session, "install"|"assert")
        runDoctorOnHost->>remoteToolchainPreflight: generate bash script string
        runDoctorOnHost->>runDoctorOnHost: write script to local tmp dir
        runDoctorOnHost->>SSHHost: scp script to per-run creds dir
        runDoctorOnHost->>SSHHost: bash <preflight-script>
        SSHHost-->>runDoctorOnHost: LAUNCH_PREFLIGHT_OK or LAUNCH_PREFLIGHT_FAILED
        runDoctorOnHost->>runDoctorOnHost: rm local tmp dir (finally)
        runDoctorOnHost-->>remotePipeline: void or throw
    end

    rect rgba(210, 105, 30, 0.5)
        note over remotePipeline,SSHHost: Sign + build + pull artifact
        remotePipeline->>SSHHost: upload signing credentials
        remotePipeline->>SSHHost: fastlane gym (REMOTE_BUILD_SCRIPT)
        SSHHost-->>remotePipeline: .ipa (assertDeviceArtifact validates byte size)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 A rabbit once lost keys in Downloads galore,
Now pickOne helps choose, no more scrolling to bore.
Remote Macs get a doctor before the build runs,
SELF_ENTRY aliases — global install now stuns!
Configs load cleanly, no node_modules required,
The warren is tidy, the toolchain admired. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly summarizes the main change: closing four linked issues (#8, #6, #11, #4) across global config, remote doctor verification, shared picker, and key cleanup.
Linked Issues check ✅ Passed All four linked issues are addressed: global config aliasing [#8], remote doctor preflight and artifact verification [#6], shared pickOne prompt helper [#11], and post-import key cleanup [#4].
Out of Scope Changes check ✅ Passed All code changes align with the linked issue objectives; no unrelated modifications detected in config loading, remote build toolchain checks, shared picker, or credential cleanup.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/finish-open-issues

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codeant-ai codeant-ai Bot added the size:L This PR changes 100-499 lines, ignoring generated files label Jun 14, 2026
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Finish open issues: global config alias, remote doctor+verify, shared picker, key cleanup
🐞 Bug fix ✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

Walkthroughs

Description
• **#8 (bug fix):** Adds a jiti alias pinning launch-store to the CLI's own entry point, so
  launch.config.ts loads correctly on global installs with no local launch-store dependency.
• **#6 (enhancement):** Runs a generated toolchain preflight (remoteToolchainPreflight) on the
  remote Mac before every build — installs gaps on AWS hosts, asserts-only on BYO-SSH — and fails fast
  on the host when gym produces no non-empty .ipa, then validates the pulled artifact with
  assertDeviceArtifact.
• **#11 (refactor):** Extracts pickOne + fuzzyMatch into core/prompt.ts as a shared generic
  picker; routes both selectApp (refuses to guess without TTY) and the .p8 key picker (falls back
  to newest) through it with explicit non-interactive policies.
• **#4 (enhancement):** After a verified key import, offers to delete the plaintext source file when
  it sits in a scanned discovery dir (~/Downloads, cwd); gated on Apple verification for .p8
  (downloaded once), ungated for re-downloadable Play JSON; never touches files outside discovery
  dirs.
• All four changes are covered by new unit tests (config loader, remote preflight, shared picker,
  isInDiscoveryDir).
Diagram
graph TD
    subgraph Config["#8 — Global Config Fix"]
        A["launch.config.ts"] -->|"import defineConfig"| B["jiti loader\n(config.ts)"]
        B -->|"alias: launch-store → SELF_ENTRY"| C["CLI index.js\n(defineConfig)"]
    end

    subgraph Remote["#6 — Remote Doctor + Verify"]
        D["remotePipeline.ts"] --> E["runDoctorOnHost\n(remoteBuild.ts)"]
        E -->|"generates"| F["remoteToolchainPreflight\n(toolchain.ts)"]
        F -->|"REQUIRED_TOOLS"| G[("Canonical\nTool List")]
        E -->|"scp + ssh"| H["Remote Mac"]
        D --> I["pullArtifact\n(remoteBuild.ts)"]
        I -->|"assertDeviceArtifact"| J["fastlane.ts"]
        G -->|"also drives"| K["awsEc2Mac.ts\nBOOTSTRAP_SCRIPT"]
    end

    subgraph Picker["#11 — Shared Picker"]
        L["core/prompt.ts\npickOne + fuzzyMatch"] --> M["selectApp\n(pipeline.ts)"]
        L --> N["resolveP8Path\n(creds.ts)"]
    end

    subgraph Cleanup["#4 — Key Cleanup"]
        O["importIosKeyFromPath\n(creds.ts)"] -->|"teamId verified"| P["offerToRemoveImportedSecret"]
        Q["setAndroidKey\n(creds.ts)"] --> P
        P -->|"isInDiscoveryDir?"| R["rmSync source file"]
    end

    subgraph Legend
        direction LR
        _mod["Module/Function"] ~~~ _db[("Data/Config")] ~~~ _ext["External Host"]
    end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Bundle launch-store into the CLI dist (#8)
  • ➕ No jiti alias needed; config always resolves regardless of module resolution quirks
  • ➖ Increases bundle size
  • ➖ Requires bundler config changes
  • ➖ Loses the ability to have the user's config bind to the exact running CLI version via a simple alias
2. Run doctor as inline SSH commands instead of an uploaded script (#6)
  • ➕ No temp file or scp step needed
  • ➖ Shell quoting becomes extremely complex for multi-command logic with conditionals
  • ➖ Harder to test the generated logic in isolation
  • ➖ Script upload approach is already used for the build script — consistent pattern

Recommendation: The PR's approach is well-suited for each issue. The jiti alias is the correct minimal fix for #8 — alternatives like bundling the config or requiring a local dep would be heavier. For #6, generating the preflight from REQUIRED_TOOLS is the right single-source-of-truth approach; the main alternative worth noting is running the doctor as a separate SSH command vs. uploading a script, but the script approach is more robust for non-interactive SSH. For #11, the shared pickOne abstraction is clean; the only alternative would be a higher-order function or a class, but the functional approach fits the codebase style. For #4, the current approach (delete-only, never relocate) correctly honors the secrets-in-keychain invariant.

Grey Divider

File Changes

Enhancement (4)
toolchain.ts Add remoteToolchainPreflight: bash doctor script generated from REQUIRED_TOOLS +43/-0

Add remoteToolchainPreflight: bash doctor script generated from REQUIRED_TOOLS

• New exported 'remoteToolchainPreflight(mode)' function that generates a bash script from 'REQUIRED_TOOLS' — in 'install' mode it brew-installs gaps on AWS hosts; in 'assert' mode it only checks and fails with hints. Single-quotes all echo messages to prevent backtick substitution. Prints 'LAUNCH_PREFLIGHT_OK' on success, exits non-zero listing gaps on failure.

src/core/toolchain.ts


remoteBuild.ts Add runDoctorOnHost; fail fast on empty .ipa; validate artifact after pull +34/-3

Add runDoctorOnHost; fail fast on empty .ipa; validate artifact after pull

• Adds 'runDoctorOnHost' which uploads and executes the generated preflight script on the remote Mac. Fixes the remote build script to check for a non-empty '.ipa' before reporting success. Calls 'assertDeviceArtifact' after pulling the artifact locally, sharing the same guard as local builds.

src/core/remoteBuild.ts


remotePipeline.ts Wire runDoctorOnHost into the remote build pipeline +5/-0

Wire runDoctorOnHost into the remote build pipeline

• Imports 'runDoctorOnHost' and calls it between project sync and signing material upload, using 'install' mode for AWS hosts and 'assert' mode for BYO-SSH hosts. Logs the outcome as a build step.

src/core/remotePipeline.ts


creds.ts Offer to delete plaintext key source after verified import +60/-13

Offer to delete plaintext key source after verified import

• Adds 'isInDiscoveryDir' (exported for tests) and 'offerToRemoveImportedSecret'. After a successful iOS key import with a resolved 'teamId', offers to 'rmSync' the '.p8' from '~/Downloads'; after a Play JSON import, offers the same unconditionally. Non-interactive mode prints the path and skips deletion. Also routes the multi-key picker through 'pickOne' with a 'fallback' policy.

src/cli/commands/creds.ts


Bug fix (2)
config.ts Pin launch-store import to CLI's own entry via jiti alias +16/-4

Pin launch-store import to CLI's own entry via jiti alias

• Adds a 'SELF_ENTRY' constant resolved via 'import.meta.url' pointing to the package's own 'index.js'. Passes it as a jiti 'alias' so 'import { defineConfig } from "launch-store"' in user configs always resolves to the running CLI's copy, eliminating the 'Cannot find package 'launch-store'' error on global installs with no local dependency.

src/core/config.ts


awsEc2Mac.ts Derive AMI bootstrap script from REQUIRED_TOOLS to prevent drift +14/-3

Derive AMI bootstrap script from REQUIRED_TOOLS to prevent drift

• Replaces the hand-maintained 'node'/'fastlane' install lines in 'BOOTSTRAP_SCRIPT' with 'BOOTSTRAP_BREW_LINES' computed from 'REQUIRED_TOOLS', so the golden AMI bootstrap always installs the same toolchain the per-build doctor checks. Keeps the 'gem install fastlane' fallback for hosts where the formula is unavailable.

src/providers/compute/awsEc2Mac.ts


Refactor (2)
prompt.ts New shared generic picker: pickOne + fuzzyMatch +105/-0

New shared generic picker: pickOne + fuzzyMatch

• New module exporting 'fuzzyMatch' (subsequence filter), 'PickOption<T>', 'NonInteractivePolicy<T>' (require vs fallback), and 'pickOne<T>'. Uses clack 'select' for small lists and 'autocomplete' past 'PICK_SEARCH_THRESHOLD=8', with explicit non-interactive handling per call site. Drives the prompt with string indices to stay generic over any value type.

src/core/prompt.ts


pipeline.ts Route selectApp through shared pickOne; refuse to guess without TTY +14/-43

Route selectApp through shared pickOne; refuse to guess without TTY

• Removes the inline 'fuzzyMatch' and 'APP_PICKER_SEARCH_THRESHOLD' (moved to 'core/prompt.ts') and replaces the manual 'autocomplete'/'select' branching with a 'pickOne' call. Sets 'nonInteractive: { kind: 'require' }' so a no-TTY run with multiple apps throws an actionable error pointing at '--app' instead of hanging.

src/core/pipeline.ts


Tests (5)
config.test.ts Add no-node_modules loader test for the launch-store alias fix +20/-0

Add no-node_modules loader test for the launch-store alias fix

• Adds a test that writes a 'launch.config.ts' importing 'defineConfig' from 'launch-store' into a temp repo with no 'node_modules', then asserts the config loads successfully and the custom profile is applied — proving the jiti alias resolves without a local dependency.

src/core/config.test.ts


prompt.test.ts Tests for fuzzyMatch and pickOne non-interactive policies +57/-0

Tests for fuzzyMatch and pickOne non-interactive policies

• New test file covering 'fuzzyMatch' (subsequence matching, blank query, case-insensitivity) and 'pickOne''s two non-interactive policies: 'require' throws with the flag hint, 'fallback' returns the default value and prints the note.

src/core/prompt.test.ts


pipeline.test.ts Move fuzzyMatch tests to prompt.test.ts; add no-TTY selectApp assertion +4/-17

Move fuzzyMatch tests to prompt.test.ts; add no-TTY selectApp assertion

• Removes the 'fuzzyMatch' import and its test suite (now in 'prompt.test.ts'). Adds a test asserting that 'selectApp' with multiple apps and no TTY rejects with an error mentioning '--app'.

src/core/pipeline.test.ts


toolchain.test.ts Tests for remoteToolchainPreflight: coverage, mode separation, shell safety +40/-1

Tests for remoteToolchainPreflight: coverage, mode separation, shell safety

• Adds three test cases: verifies every 'REQUIRED_TOOLS' command appears in the script with the success marker; confirms 'assert' mode never executes 'brew install' while 'install' mode does; checks Xcode's backtick-bearing hint is single-quoted and never executed as a command substitution.

src/core/toolchain.test.ts


creds.test.ts Unit tests for isInDiscoveryDir boundary conditions +28/-0

Unit tests for isInDiscoveryDir boundary conditions

• New test file with four cases: key directly in '~/Downloads' (match), service-account JSON in cwd (match), key in '~/vault' (no match), key one level deeper than a discovery dir (no match).

src/cli/commands/creds.test.ts


Grey Divider

Qodo Logo

Comment thread src/core/remoteBuild.ts
Comment on lines +322 to +326
IPA="$(ls "$OUT"/*.ipa 2>/dev/null | head -1)"
if [ -z "$IPA" ] || [ ! -s "$IPA" ]; then
echo "LAUNCH_NO_ARTIFACT: gym produced no non-empty .ipa in $OUT" >&2
exit 1
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: With set -euo pipefail enabled, the command substitution in IPA="$(ls "$OUT"/*.ipa 2>/dev/null | head -1)" can terminate the script immediately when no .ipa exists, so the explicit LAUNCH_NO_ARTIFACT error branch may never run. Capture the path with a non-failing glob/check pattern (or temporarily disable errexit for that probe) so missing artifacts reliably produce the intended actionable error message. [logic error]

Severity Level: Major ⚠️
- ⚠️ Remote Mac builds with missing .ipa fail opaquely.
- ⚠️ LAUNCH_NO_ARTIFACT hint never reaches CLI users.
- ⚠️ Debugging remote fastlane export issues becomes harder.
Steps of Reproduction ✅
1. Run a remote iOS build via the CLI, for example `launch build ios --remote aws` which
is wired in `src/cli/commands/build.ts:80-119` by calling `runBuild({... remote })` in
`src/core/pipeline.ts:10-27`.

2. `runBuild` dispatches to the remote pipeline (`src/core/pipeline.ts:17-21`), which
calls `runRemoteBuild` and then `runBuildOnHost` in `src/core/remotePipeline.ts:15-26`,
uploading and executing the `REMOTE_BUILD_SCRIPT` via `sshRun` as shown in
`src/core/remoteBuild.ts:14-27`.

3. On the remote Mac, the uploaded bash script (`REMOTE_BUILD_SCRIPT` at
`src/core/remoteBuild.ts:52-72`) runs with `set -euo pipefail` (`line 53`) and eventually
executes fastlane gym (`lines 45-55` in the script body) to produce an `.ipa` under
`$OUT`; assume a misconfiguration or upstream issue causes gym to complete without leaving
any `.ipa` in `$OUT`, which is precisely the situation the "Fail fast on the host if gym
produced no non-empty .ipa" guard (comments at `lines 61-62` of the script) is intended to
detect.

4. The guard executes `IPA="$(ls "$OUT"/*.ipa 2>/dev/null | head -1)"`
(`src/core/remoteBuild.ts:63`), but with no matching `.ipa` the `ls "$OUT"/*.ipa` command
in the pipeline exits non-zero; under `set -euo pipefail` this non-zero status causes the
script to terminate immediately, so the subsequent `if [ -z "$IPA" ] || [ ! -s "$IPA" ];
then ... echo "LAUNCH_NO_ARTIFACT: …" ... fi` block (`lines 64-67`) never runs, and the
remote build fails with a generic SSH/exit-code error instead of the intended actionable
`LAUNCH_NO_ARTIFACT` message.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/core/remoteBuild.ts
**Line:** 322:326
**Comment:**
	*Logic Error: With `set -euo pipefail` enabled, the command substitution in `IPA="$(ls "$OUT"/*.ipa 2>/dev/null | head -1)"` can terminate the script immediately when no `.ipa` exists, so the explicit `LAUNCH_NO_ARTIFACT` error branch may never run. Capture the path with a non-failing glob/check pattern (or temporarily disable errexit for that probe) so missing artifacts reliably produce the intended actionable error message.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread src/core/remoteBuild.ts
Comment on lines +137 to +155
/**
* Run the toolchain doctor ON the remote Mac before building — the remote twin of `launch doctor`.
* Uploads {@link remoteToolchainPreflight} and executes it: `"install"` for an AWS host we own (brew-
* installs any gaps) or `"assert"` for a BYO-SSH host (checks + fails with hints, never mutates the
* user's machine). A missing required tool exits the preflight non-zero, so `sshRun` rejects and the
* build fails fast with the gaps listed instead of a cryptic error deep inside fastlane.
*/
export async function runDoctorOnHost(session: RemoteSession, mode: "install" | "assert"): Promise<void> {
const staging = mkdtempSync(join(tmpdir(), "launch-remote-"));
const scriptLocal = join(staging, "doctor.sh");
writeFileSync(scriptLocal, remoteToolchainPreflight(mode));
const scriptRemote = `${session.credsDir}/doctor.sh`;
try {
await scpUp(session.target, scriptLocal, scriptRemote);
} finally {
rmSync(staging, { recursive: true, force: true });
}
await sshRun(session.target, `bash ${shellQuote(scriptRemote)}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. remotebuild.ts lacks colocated tests 📘 Rule violation ▣ Testability

src/core/remoteBuild.ts adds/changes non-trivial remote-build behavior (remote doctor execution
and artifact verification) but there is no corresponding src/core/remoteBuild.test.ts file. This
violates the requirement to co-locate tests with new/significantly modified logic, reducing change
safety and regression detection.
Agent Prompt
## Issue description
`src/core/remoteBuild.ts` was significantly extended/modified (e.g., `runDoctorOnHost` and remote artifact verification behavior), but there is no co-located `src/core/remoteBuild.test.ts`.

## Issue Context
Compliance requires co-located `*.test.ts` files for new/significantly modified TypeScript modules containing executable logic.

## Fix Focus Areas
- src/core/remoteBuild.ts[137-155]
- src/core/remoteBuild.ts[220-224]
- src/core/remoteBuild.ts[320-326]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/core/config.ts
Comment on lines +20 to +31
const SELF_ENTRY = fileURLToPath(new URL("../index.js", import.meta.url));

/**
* On-the-fly loader for the user's config. The compiled `launch` binary runs on plain Node, which
* can't `import()` a TypeScript file — jiti transpiles `launch.config.ts` in memory and resolves its
* `launch-store` import from the user's project. (Chosen over bundling a TS toolchain ourselves; it's
* the same loader Nuxt/ESLint use for config files.)
* can't `import()` a TypeScript file — jiti transpiles `launch.config.ts` in memory. The `alias` pins
* the config's `import { defineConfig } from "launch-store"` to {@link SELF_ENTRY}, so a globally
* installed `launch` loads the config even when the user's project has no local `launch-store`
* dependency (issue #8), and the config always binds to the exact `defineConfig` of the CLI consuming
* it — no dual-package version skew. (jiti chosen over bundling a TS toolchain ourselves; it's the
* same loader Nuxt/ESLint use for config files.)
*/
const jiti = createJiti(import.meta.url);
const jiti = createJiti(import.meta.url, { alias: { "launch-store": SELF_ENTRY } });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Broken config self-alias 🐞 Bug ≡ Correctness

loadConfig pins import "launch-store" to SELF_ENTRY, but SELF_ENTRY is hard-coded as
../index.js relative to src/core/config.ts, while this repo’s public entry is src/index.ts
(and source checkouts typically won’t have src/index.js). This makes config loading rely on
nonstandard resolution and can fail in dev/test or any run-from-source environment.
Agent Prompt
### Issue description
`SELF_ENTRY` is computed as `../index.js` relative to `src/core/config.ts`, but in the source tree the public entry is `src/index.ts` (with TS importing `.js` specifiers). If `src/index.js` is absent, the jiti alias can point at a non-existent file and break loading a user `launch.config.ts` that imports `defineConfig` from `launch-store`.

### Issue Context
This alias is used by jiti when importing user configs, so it must resolve reliably both in production (`dist/`) and in source/test environments.

### Fix Focus Areas
- src/core/config.ts[14-32]

### Suggested fix
Implement a runtime fallback that selects an entry file that actually exists, e.g.:
- compute `indexJs = fileURLToPath(new URL("../index.js", import.meta.url))`
- if `!existsSync(indexJs)`, fall back to `fileURLToPath(new URL("../index.ts", import.meta.url))`
- use the resolved path in the jiti alias

(Alternatively, use a resolver-based approach like `import.meta.resolve` for the package entry if your Node target supports it.)

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +451 to 468
const BOOTSTRAP_BREW_LINES = REQUIRED_TOOLS.flatMap((tool) => {
if (tool.install.kind !== "brew") return [];
const fallback = tool.command === "fastlane" ? " || sudo gem install fastlane" : "";
return [`command -v ${tool.command} >/dev/null || brew install ${tool.install.formula}${fallback} || true`];
});

/** Toolchain bootstrap script run once on a base AMI before snapshotting a golden image. */
const BOOTSTRAP_SCRIPT = [
"set -e",
"command -v brew >/dev/null || echo LAUNCH_NO_BREW",
"command -v node >/dev/null || brew install node || true",
"command -v fastlane >/dev/null || brew install fastlane || sudo gem install fastlane || true",
...BOOTSTRAP_BREW_LINES,
"xcodebuild -version >/dev/null 2>&1 || echo LAUNCH_NO_XCODE",
].join("\n");

/** Install node/fastlane and assert full Xcode is present (the one part Launch can't legally redistribute). */
/** Install the brew-able toolchain and assert full Xcode is present (the one part Launch can't legally redistribute). */
async function bootstrapToolchain(ssh: SshTarget): Promise<void> {
const output = await sshCapture(ssh, BOOTSTRAP_SCRIPT);
if (output.includes("LAUNCH_NO_XCODE")) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

5. Ami bootstrap misses brew failure 🐞 Bug ☼ Reliability

The golden-AMI bootstrap script emits LAUNCH_NO_BREW when Homebrew is missing, but
bootstrapToolchain only throws on LAUNCH_NO_XCODE. This can snapshot an AMI that silently failed
to install required brew tools, causing remote builds to fail later with harder-to-diagnose errors.
Agent Prompt
### Issue description
`BOOTSTRAP_SCRIPT` prints a sentinel when `brew` is missing, but `bootstrapToolchain()` doesn’t check it, so provisioning can appear successful even though none of the brew installs actually happened.

### Issue Context
The script uses `|| true` in install lines, so missing/failed installs won’t necessarily cause a non-zero exit. Relying on sentinels requires the caller to enforce them.

### Fix Focus Areas
- src/providers/compute/awsEc2Mac.ts[457-474]

### Suggested fix
Either:
- Make the script fail hard when brew is missing (e.g. `command -v brew >/dev/null || { echo LAUNCH_NO_BREW; exit 1; }`), or
- Add an explicit check in `bootstrapToolchain()`:
  - if output includes `LAUNCH_NO_BREW`, throw an actionable error telling the user to ensure Homebrew is installed on the base AMI.

Optionally also verify at least one required brew tool is present after bootstrap to catch install failures early.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/cli/commands/creds.ts
Comment on lines +123 to +124
rmSync(file, { force: true });
console.log(`Removed ${tildify(file)}.`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Deletion of the plaintext key file is performed with rmSync without error handling, so permission/filesystem errors after a successful import will throw and make the command fail even though credentials were already stored. Catch deletion errors and log a warning so post-import cleanup failures do not break the main operation. [logic error]

Severity Level: Major ⚠️
-`launch creds set-key` can fail after persisting account.
- ⚠️ Users see an error despite credentials stored successfully.
- ⚠️ Android `set-key` suffers identical fragile cleanup behavior.
Steps of Reproduction ✅
1. Run the CLI command `launch creds set-key` in an interactive terminal so
`process.stdin.isTTY` is true and `--yes` is not set; this command is wired up in
`registerCredsCommand` at `src/cli/commands/creds.ts:223-251` and calls `setKey()` at
`src/cli/commands/creds.ts:8-13` for iOS.

2. Ensure the `.p8` file to import (e.g. `~/Downloads/AuthKey_XXXX.p8`) lives directly in
one of the discovery directories listed in `SEARCH_DIRS` at
`src/cli/commands/creds.ts:76-82`, so `isInDiscoveryDir()` at
`src/cli/commands/creds.ts:90-93` returns true.

3. During the run, `setKey()` calls `resolveP8Path()` at
`src/cli/commands/creds.ts:172-204` to find the `.p8`, then `importIosKeyFromPath()` at
`src/cli/commands/creds.ts:237-260` which validates the key, calls `addAccount(...)` at
line 254 to persist credentials, and logs success at line 260.

4. After a successful import, `importIosKeyFromPath()` invokes
`offerToRemoveImportedSecret(p8Path, "App Store Connect key", canPrompt)` at
`src/cli/commands/creds.ts:3-5`, which, in interactive mode, prompts the user and, if they
confirm, executes `rmSync(file, { force: true });` followed by `console.log(...)` at
`src/cli/commands/creds.ts:123-124`.

5. If deletion of the `.p8` file fails due to a filesystem or permission error (e.g.
directory not writable, ACL denies delete), `rmSync` throws synchronously; there is no
try/catch around this call in `offerToRemoveImportedSecret` or its callers, so the error
propagates and causes the overall `launch creds set-key` command to fail even though
`addAccount` already stored the credentials.

6. The same pattern affects the Android `set-key` flow: `setAndroidKey()` at
`src/cli/commands/creds.ts:15-30` stores the Play service-account JSON via
`storeServiceAccount` at line 26, then calls `offerToRemoveImportedSecret(path, "Play
service-account key", canPrompt)` at line 29, so a deletion error there will also turn a
successful secret import into a failed command.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/cli/commands/creds.ts
**Line:** 123:124
**Comment:**
	*Logic Error: Deletion of the plaintext key file is performed with `rmSync` without error handling, so permission/filesystem errors after a successful import will throw and make the command fail even though credentials were already stored. Catch deletion errors and log a warning so post-import cleanup failures do not break the main operation.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread src/cli/commands/creds.ts
Comment on lines +191 to +195
nonInteractive: {
kind: "fallback",
value: first,
note: `Multiple API keys found; using ${tildify(first)}. Pass --p8 to choose another.`,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: In non-interactive mode with multiple discovered .p8 files, this silently picks first, but first is just search-order dependent and not guaranteed to be the intended key. That can import the wrong Apple account in automation. Either require explicit --p8 when multiple matches exist or deterministically choose by a strong criterion (e.g., newest mtime) and document that rule. [logic error]

Severity Level: Critical 🚨
- ❌ Non-interactive `set-key` may import the wrong Apple account.
- ⚠️ Builds that follow can target an unintended Apple team.
- ⚠️ CI runs rely on search-order-dependent, weak key selection.
Steps of Reproduction ✅
1. Prepare a machine with at least two different `AuthKey_*.p8` files in the discovery
directories defined by `SEARCH_DIRS` at `src/cli/commands/creds.ts:76-82` (for example,
two keys in `~/Downloads` or one in `~/Downloads` and one in `~/.launch/credentials`).

2. Run the iOS onboarding command non-interactively, e.g. `launch creds set-key --yes` (or
from CI with no TTY), without specifying `--p8` and without `ASC_API_KEY_PATH` set; this
invokes `setKey()` at `src/cli/commands/creds.ts:8-13`, which sets `canPrompt =
options.yes !== true && process.stdin.isTTY` at line 10.

3. Inside `setKey()`, `resolveP8Path(options, canPrompt)` at
`src/cli/commands/creds.ts:172-204` is called; with no explicit path, it computes `found =
SEARCH_DIRS.flatMap(findAuthKeyFiles)` at line 180, and destructures `[first, ...rest]` at
line 181, where both `first` and `rest[0]` are real but different `.p8` files.

4. Because multiple keys are found, the `if (first)` branch at
`src/cli/commands/creds.ts:186-197` executes, calling `pickOne<string>({...})` from
`src/core/prompt.ts:69-105` with `canPrompt` (false in this non-interactive scenario) and
a `nonInteractive` policy of `{ kind: "fallback", value: first, note: ... }` at lines
191-195.

5. `pickOne()` in `src/core/prompt.ts:69-76` sees `!args.canPrompt` and
`nonInteractive.kind === "fallback"`, prints the note if present, and returns
`args.nonInteractive.value`, i.e. the `first` discovered path — which is purely
search-order dependent, not necessarily the intended or newest key.

6. `importIosKeyFromPath(p8Path, ...)` at `src/cli/commands/creds.ts:237-260` then imports
and activates the account associated with this arbitrary `first` key, so in automation the
wrong Apple account can be onboarded and set active without any explicit selection based
on a strong, documented rule like newest mtime or requiring `--p8`.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/cli/commands/creds.ts
**Line:** 191:195
**Comment:**
	*Logic Error: In non-interactive mode with multiple discovered `.p8` files, this silently picks `first`, but `first` is just search-order dependent and not guaranteed to be the intended key. That can import the wrong Apple account in automation. Either require explicit `--p8` when multiple matches exist or deterministically choose by a strong criterion (e.g., newest mtime) and document that rule.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread src/core/pipeline.ts
const hint = app.bundleId ?? app.packageName;
return hint ? { value: app, label: app.name, hint } : { value: app, label: app.name };
}),
canPrompt: process.stdin.isTTY,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: selectApp now decides promptability with process.stdin.isTTY, which ignores CI. In CI environments that still expose a TTY, this can trigger an interactive picker and hang builds instead of failing fast with a --app hint. Use the same interactivity gate used elsewhere (isInteractive()) so CI is always treated as non-interactive. [incorrect condition logic]

Severity Level: Critical 🚨
-`launch build` can hang waiting for app selection in CI.
-`launch release` may also block on interactive app picking.
- ⚠️ Creds `setup` flows mis-handle CI interactivity similarly.
Steps of Reproduction ✅
1. In a CI environment where `process.env.CI` is set (e.g. a hosted runner) but stdin is
attached to a TTY (so `process.stdin.isTTY === true`), invoke the main build entrypoint
`runBuild(options)` exported from `src/core/pipeline.ts:50-59` via the CLI `launch build`
command (wired to `prepareBuild`/`runBuild``prepareBuild` is at
`src/core/pipeline.ts:11-42`).

2. Run `launch build` without the `--app` flag while the project has multiple apps
discovered by `loadConfig()` (called inside `prepareBuild` at
`src/core/pipeline.ts:15-16`), so `apps.length > 1` and there is no explicit `appName`.

3. `prepareBuild()` calls `selectApp(apps, options.appName)` at
`src/core/pipeline.ts:15-17`; inside `selectApp` at `src/core/pipeline.ts:149-167`,
because `apps.length > 1` and `appName` is undefined, it falls through to the
`pickOne<AppDescriptor>({ ... })` call at lines 159-167.

4. The `pickOne` call sets `canPrompt: process.stdin.isTTY` at `src/core/pipeline.ts:165`,
which is true in this CI scenario, so `pickOne()` in `src/core/prompt.ts:69-105` goes down
the interactive `select`/`autocomplete` path instead of applying its `nonInteractive: {
kind: "require", flagHint: "— pass --app <name> to choose one non-interactively." }`
policy.

5. Because CI has no human to answer the interactive prompt, the process blocks waiting
for input, and the build hangs or eventually times out instead of failing fast with a
clear `--app` hint, in contrast to other interactive decisions (like account selection)
that correctly use the CI-aware `isInteractive()` helper from
`src/core/progress.ts:157-159`.

6. This affects all codepaths that rely on `selectApp`, including `prepareBuild` for
`launch build` (`src/core/pipeline.ts:11-42`), `runRelease` in
`src/cli/commands/release.ts:58-64`, and the iOS/Android `setup` flows in
`src/cli/commands/creds.ts:161-176` and `src/cli/commands/creds.ts:208-220`, all of which
can misclassify CI as interactive when a TTY is present.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/core/pipeline.ts
**Line:** 165:165
**Comment:**
	*Incorrect Condition Logic: `selectApp` now decides promptability with `process.stdin.isTTY`, which ignores `CI`. In CI environments that still expose a TTY, this can trigger an interactive picker and hang builds instead of failing fast with a `--app` hint. Use the same interactivity gate used elsewhere (`isInteractive()`) so CI is always treated as non-interactive.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

if (tool.install.kind !== "brew") return [];
const fallback = tool.command === "fastlane" ? " || sudo gem install fastlane" : "";
return [`command -v ${tool.command} >/dev/null || brew install ${tool.install.formula}${fallback} || true`];
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The bootstrap commands always end with || true, so failed installs are silently ignored and the bootstrap can still succeed, after which a golden AMI is snapshotted without required tools. This creates a persistently broken base image that fails later during remote builds. Remove unconditional success swallowing for required tools or add an explicit post-check that aborts snapshot creation when required tools are still missing. [incomplete implementation]

Severity Level: Major ⚠️
- ❌ Golden AMI may miss required iOS build tools.
- ⚠️ Subsequent AWS remote builds fail doctor on every run.
- ⚠️ Debugging harder since bootstrap never reported failures.
Steps of Reproduction ✅
1. Trigger an AWS remote build using `runRemoteBuild()` in
`src/core/remotePipeline.ts:92-139` with `remote.kind === "aws"`. This selects the
`aws-ec2-mac` compute host via `hostFor()` at `src/core/remotePipeline.ts:20-22` and calls
its `allocate()` implementation in `src/providers/compute/awsEc2Mac.ts:1-77`.

2. On the first run when no golden AMI id is stored, `allocate()` in
`src/providers/compute/awsEc2Mac.ts:15-26` detects `!goldenAmi` and invokes
`bootstrapToolchain(ssh)` at line 24 before snapshotting a golden AMI with
`snapshotGoldenAmi()`.

3. `bootstrapToolchain()` at `src/providers/compute/awsEc2Mac.ts:466-475` runs
`BOOTSTRAP_SCRIPT`, which is built from `BOOTSTRAP_BREW_LINES` at lines 52-56. Each
brew-install line is `command -v ${tool.command} >/dev/null || brew install
${tool.install.formula}${fallback} || true` (line 55) under a script-wide `set -e` (line
59), but `bootstrapToolchain()` only examines the output for `LAUNCH_NO_XCODE` and ignores
brew failures or missing tools.

4. If any `brew install` (and optional `sudo gem install fastlane` fallback) fails—e.g.,
due to network issues or a broken formula—the `|| true` at the end of the line causes that
failure to be silently swallowed, allowing `BOOTSTRAP_SCRIPT` to complete and
`snapshotGoldenAmi()` to snapshot an AMI without the required tool. Later AWS remote
builds allocate from this golden AMI and rely on `runDoctorOnHost(session, "install")` at
`src/core/remotePipeline.ts:200`, which will repeatedly find missing tools and fail the
doctor, even though the bootstrap step previously claimed success and persisted a broken
base image.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/providers/compute/awsEc2Mac.ts
**Line:** 455:455
**Comment:**
	*Incomplete Implementation: The bootstrap commands always end with `|| true`, so failed installs are silently ignored and the bootstrap can still succeed, after which a golden AMI is snapshotted without required tools. This creates a persistently broken base image that fails later during remote builds. Remove unconditional success swallowing for required tools or add an explicit post-check that aborts snapshot creation when required tools are still missing.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@codeant-ai

codeant-ai Bot commented Jun 14, 2026

Copy link
Copy Markdown

CodeAnt AI finished reviewing your PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/cli/commands/creds.ts (1)

180-195: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The non-interactive .p8 fallback is directory-first, not newest-first.

found preserves SEARCH_DIRS order, so value: first picks the earliest directory hit, not the newest key across all matches. In a non-TTY run that can silently import an older key just because it lives in ~/Downloads while a newer one exists later in the scan set.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/commands/creds.ts` around lines 180 - 195, The non-interactive
fallback in the pickOne function currently uses `first` as the fallback value,
which picks the earliest directory in SEARCH_DIRS order rather than the newest
key. Replace the `value: first` in the nonInteractive fallback object with logic
that selects the most recently modified key from the `found` array by checking
file modification times, ensuring that in non-TTY environments, the newest
available key is used instead of the directory-ordered first match.
src/core/pipeline.ts (1)

149-167: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

selectApp() still prompts under --yes.

Promptability is derived from process.stdin.isTTY only, but both prepareBuild() and the launch creds setup paths call selectApp() without any way to thread their yes flag through. On a real terminal, launch build --yes can still open the picker when multiple apps exist, which breaks the command’s non-interactive contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/pipeline.ts` around lines 149 - 167, The selectApp() function
determines promptability only from process.stdin.isTTY without considering the
yes flag from calling commands, causing it to still prompt even when --yes is
passed. Add a boolean parameter to selectApp() to accept the yes flag, then use
it to compute canPrompt as process.stdin.isTTY && !yes (or similar logic to
disable prompting when yes is true). Update all callers of selectApp() (in
prepareBuild() and launch creds setup paths) to pass their yes flag to this new
parameter.
src/providers/compute/awsEc2Mac.ts (1)

458-474: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail the AMI bootstrap when Homebrew is unavailable.

BOOTSTRAP_SCRIPT emits LAUNCH_NO_BREW, but bootstrapToolchain() never checks it and every generated brew install … is swallowed with || true. A base AMI without brew—or with brew only outside the default SSH PATH—can still be snapshotted as a “golden” image with none of the brew-managed tools installed.

Suggested fix
 const BOOTSTRAP_SCRIPT = [
   "set -e",
+  `eval "$(/opt/homebrew/bin/brew shellenv 2>/dev/null || /usr/local/bin/brew shellenv 2>/dev/null || true)"`,
   "command -v brew >/dev/null || echo LAUNCH_NO_BREW",
   ...BOOTSTRAP_BREW_LINES,
   "xcodebuild -version >/dev/null 2>&1 || echo LAUNCH_NO_XCODE",
 ].join("\n");
@@
 async function bootstrapToolchain(ssh: SshTarget): Promise<void> {
   const output = await sshCapture(ssh, BOOTSTRAP_SCRIPT);
+  if (output.includes("LAUNCH_NO_BREW")) {
+    throw new Error("The base AMI has no usable Homebrew on PATH. Provide an AMI with Homebrew installed, or fix the bootstrap PATH setup before snapshotting.");
+  }
   if (output.includes("LAUNCH_NO_XCODE")) {
     throw new Error(
       "The base AMI has no full Xcode (gym needs it). Provide a BYO golden AMI with Xcode preinstalled " +
         "via aws.amiId — Xcode can't be redistributed in a shared image.",
     );
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/compute/awsEc2Mac.ts` around lines 458 - 474, The
bootstrapToolchain() function checks for LAUNCH_NO_XCODE in the output but does
not check for LAUNCH_NO_BREW, which is emitted by BOOTSTRAP_SCRIPT when Homebrew
is unavailable. This allows bootstrap to complete successfully even when
Homebrew is missing or not in the PATH, resulting in a golden AMI without the
necessary brew-managed tools installed. Add a check in bootstrapToolchain() to
detect if the output includes LAUNCH_NO_BREW and throw an appropriate error
message, mirroring the existing LAUNCH_NO_XCODE validation logic.
🧹 Nitpick comments (1)
src/core/pipeline.test.ts (1)

121-124: ⚡ Quick win

Add a --yes regression here too.

This only exercises the no-TTY branch, so the broken “TTY + --yes” path in selectApp() is still uncovered. A dedicated regression would lock in the non-interactive contract once the picker fix lands.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/pipeline.test.ts` around lines 121 - 124, Add a new regression test
case in the test suite for selectApp() that exercises the TTY + --yes flag path
with multiple apps. Create a test following the same pattern as the existing
test (refusing to guess with multiple apps), but pass the --yes flag (as true or
equivalent) instead of undefined for the second parameter, and verify it throws
an error referencing the --app flag requirement. This will ensure the
non-interactive contract is maintained when both TTY and --yes are present.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/cli/commands/creds.ts`:
- Around line 95-125: The offerToRemoveImportedSecret function currently
implements only a two-way contract (delete or keep), but it needs to support a
three-way contract that includes moving the imported key to a managed location
(~/.launch/credentials/). Extend the function to offer three options: move to
managed location (default), delete, or keep. When moving, construct the target
path as ~/.launch/credentials/AuthKey_<KEYID>.p8 (extracting the key ID from the
file), create the directory if needed, move the file there, and enforce 700
permissions on the directory and 600 on the file. Update the prompts and logic
in offerToRemoveImportedSecret to reflect this three-way flow, ensuring the
managed copy is the default choice. This ensures the iOS path can satisfy the
requirements described in Issue `#4` for moving/deleting/keeping with proper
permission enforcement.

In `@src/core/remoteBuild.ts`:
- Around line 320-325: The code uses a wildcard glob to find any .ipa file in
$OUT and check it, which can pick up stale IPA files from previous builds if the
current gym export fails, and may cause the assignment to exit before the
LAUNCH_NO_ARTIFACT error message under set -euo pipefail. Before running gym,
remove the expected output file "$OUT/$APP_NAME.ipa" to ensure it doesn't exist
from a previous build. Then after gym completes, replace the ls wildcard pattern
and head command with a direct test of the specific expected file: validate
"$OUT/$APP_NAME.ipa" exists and is non-empty using the -s test operator, rather
than searching for any matching .ipa file.

In `@src/core/toolchain.ts`:
- Around line 121-126: The Homebrew shellenv setup is currently only executed
when canInstall is true, causing subsequent SSH processes (for assert runs,
builds, or when runBuildOnHost() starts a fresh shell) to fail to find
Homebrew-installed tools. Move the lines.push() call containing the brew
shellenv command outside and before the if (canInstall) block so that the
Homebrew PATH initialization runs unconditionally for all remote SSH scripts
regardless of the mode. This ensures that node, pod, fastlane, and other
Homebrew-installed tools are available on the PATH for every SSH process
initiated, not just during install mode.

---

Outside diff comments:
In `@src/cli/commands/creds.ts`:
- Around line 180-195: The non-interactive fallback in the pickOne function
currently uses `first` as the fallback value, which picks the earliest directory
in SEARCH_DIRS order rather than the newest key. Replace the `value: first` in
the nonInteractive fallback object with logic that selects the most recently
modified key from the `found` array by checking file modification times,
ensuring that in non-TTY environments, the newest available key is used instead
of the directory-ordered first match.

In `@src/core/pipeline.ts`:
- Around line 149-167: The selectApp() function determines promptability only
from process.stdin.isTTY without considering the yes flag from calling commands,
causing it to still prompt even when --yes is passed. Add a boolean parameter to
selectApp() to accept the yes flag, then use it to compute canPrompt as
process.stdin.isTTY && !yes (or similar logic to disable prompting when yes is
true). Update all callers of selectApp() (in prepareBuild() and launch creds
setup paths) to pass their yes flag to this new parameter.

In `@src/providers/compute/awsEc2Mac.ts`:
- Around line 458-474: The bootstrapToolchain() function checks for
LAUNCH_NO_XCODE in the output but does not check for LAUNCH_NO_BREW, which is
emitted by BOOTSTRAP_SCRIPT when Homebrew is unavailable. This allows bootstrap
to complete successfully even when Homebrew is missing or not in the PATH,
resulting in a golden AMI without the necessary brew-managed tools installed.
Add a check in bootstrapToolchain() to detect if the output includes
LAUNCH_NO_BREW and throw an appropriate error message, mirroring the existing
LAUNCH_NO_XCODE validation logic.

---

Nitpick comments:
In `@src/core/pipeline.test.ts`:
- Around line 121-124: Add a new regression test case in the test suite for
selectApp() that exercises the TTY + --yes flag path with multiple apps. Create
a test following the same pattern as the existing test (refusing to guess with
multiple apps), but pass the --yes flag (as true or equivalent) instead of
undefined for the second parameter, and verify it throws an error referencing
the --app flag requirement. This will ensure the non-interactive contract is
maintained when both TTY and --yes are present.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 357a00d7-69a2-49f0-8c29-d78b1ba217ba

📥 Commits

Reviewing files that changed from the base of the PR and between 0de7227 and ac75e28.

📒 Files selected for processing (13)
  • src/cli/commands/creds.test.ts
  • src/cli/commands/creds.ts
  • src/core/config.test.ts
  • src/core/config.ts
  • src/core/pipeline.test.ts
  • src/core/pipeline.ts
  • src/core/prompt.test.ts
  • src/core/prompt.ts
  • src/core/remoteBuild.ts
  • src/core/remotePipeline.ts
  • src/core/toolchain.test.ts
  • src/core/toolchain.ts
  • src/providers/compute/awsEc2Mac.ts

Comment thread src/cli/commands/creds.ts
Comment on lines +95 to +125
/**
* After a secret has been imported into the OS keychain (now the source of truth), offer to remove the
* plaintext source file when it's sitting in a discovery directory. Honors the project invariant that
* secrets live only in the keychain — it never relocates the file into `~/.launch`, only deletes it.
*
* Interactive: prompt, defaulting to delete. Non-interactive: keep it and print the exact path, so a key
* is never deleted without consent (and the user still learns it's safe to remove).
*/
async function offerToRemoveImportedSecret(file: string, label: string, canPrompt: boolean): Promise<void> {
if (!isInDiscoveryDir(file)) return;
if (!canPrompt) {
console.log(
`The plaintext ${label} is still at ${tildify(file)} — it's in your keychain now, so you can delete it.`,
);
return;
}
const remove = await confirm({
message: `Remove the plaintext ${label} from ${tildify(file)}? It's now stored securely in your keychain.`,
initialValue: true,
});
if (isCancel(remove)) {
cancel("Cancelled.");
process.exit(0);
}
if (!remove) {
console.log(`Kept ${tildify(file)}.`);
return;
}
rmSync(file, { force: true });
console.log(`Removed ${tildify(file)}.`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This abstraction hard-codes the wrong .p8 cleanup contract.

The helper only offers delete-or-keep, and the implementation/comment explicitly rule out moving the imported key into ~/.launch/credentials/AuthKey_<KEYID>.p8. That means the iOS path still cannot satisfy Issue #4’s required move / delete / leave flow, default to the managed copy, or enforce the 700/600 permission contract for the retained plaintext file. This is called out in the linked Issue #4 objective.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/commands/creds.ts` around lines 95 - 125, The
offerToRemoveImportedSecret function currently implements only a two-way
contract (delete or keep), but it needs to support a three-way contract that
includes moving the imported key to a managed location (~/.launch/credentials/).
Extend the function to offer three options: move to managed location (default),
delete, or keep. When moving, construct the target path as
~/.launch/credentials/AuthKey_<KEYID>.p8 (extracting the key ID from the file),
create the directory if needed, move the file there, and enforce 700 permissions
on the directory and 600 on the file. Update the prompts and logic in
offerToRemoveImportedSecret to reflect this three-way flow, ensuring the managed
copy is the default choice. This ensures the iOS path can satisfy the
requirements described in Issue `#4` for moving/deleting/keeping with proper
permission enforcement.

Comment thread src/core/remoteBuild.ts
Comment on lines +320 to +325
# Fail fast on the host if gym produced no non-empty .ipa, so we don't waste a transfer on a dead
# export — the authoritative device-archive guard runs locally after pull.
IPA="$(ls "$OUT"/*.ipa 2>/dev/null | head -1)"
if [ -z "$IPA" ] || [ ! -s "$IPA" ]; then
echo "LAUNCH_NO_ARTIFACT: gym produced no non-empty .ipa in $OUT" >&2
exit 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Check the exact output file, not “any IPA in $OUT”.

$OUT persists between runs, so this ls … | head -1 can pick yesterday’s ${APP_NAME}.ipa when the current export fails. pullArtifact() then downloads that stale file from the same directory and can store or submit it under the new build number. Under set -euo pipefail, the assignment can also exit before LAUNCH_NO_ARTIFACT when no file matches. Remove the expected output before gym and validate "$OUT/$APP_NAME.ipa" with -s afterwards.

Suggested fix
 mkdir -p "$OUT"
+rm -f "$OUT/$APP_NAME.ipa" "$OUT/App Thinning Size Report.txt"
@@
-IPA="$(ls "$OUT"/*.ipa 2>/dev/null | head -1)"
-if [ -z "$IPA" ] || [ ! -s "$IPA" ]; then
+IPA="$OUT/$APP_NAME.ipa"
+if [ ! -s "$IPA" ]; then
   echo "LAUNCH_NO_ARTIFACT: gym produced no non-empty .ipa in $OUT" >&2
   exit 1
 fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/remoteBuild.ts` around lines 320 - 325, The code uses a wildcard
glob to find any .ipa file in $OUT and check it, which can pick up stale IPA
files from previous builds if the current gym export fails, and may cause the
assignment to exit before the LAUNCH_NO_ARTIFACT error message under set -euo
pipefail. Before running gym, remove the expected output file
"$OUT/$APP_NAME.ipa" to ensure it doesn't exist from a previous build. Then
after gym completes, replace the ls wildcard pattern and head command with a
direct test of the specific expected file: validate "$OUT/$APP_NAME.ipa" exists
and is non-empty using the -s test operator, rather than searching for any
matching .ipa file.

Comment thread src/core/toolchain.ts
Comment on lines +121 to +126
if (canInstall) {
// Put Homebrew on PATH in a non-interactive SSH shell before any install attempt.
lines.push(
`eval "$(/opt/homebrew/bin/brew shellenv 2>/dev/null || /usr/local/bin/brew shellenv 2>/dev/null || true)"`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Normalize Homebrew PATH in every remote SSH script, not just install-mode doctor.

brew shellenv only runs here when mode === "install", and it only affects this one SSH process. BYO assert runs will falsely report missing node/pod/fastlane on hosts where Homebrew lives under /opt/homebrew/bin, and AWS builds can still fail later because runBuildOnHost() starts a fresh shell. Hoist this PATH bootstrap out of the install-only branch and prepend the same snippet to the build/bootstrap scripts as well.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/toolchain.ts` around lines 121 - 126, The Homebrew shellenv setup is
currently only executed when canInstall is true, causing subsequent SSH
processes (for assert runs, builds, or when runBuildOnHost() starts a fresh
shell) to fail to find Homebrew-installed tools. Move the lines.push() call
containing the brew shellenv command outside and before the if (canInstall)
block so that the Homebrew PATH initialization runs unconditionally for all
remote SSH scripts regardless of the mode. This ensures that node, pod,
fastlane, and other Homebrew-installed tools are available on the PATH for every
SSH process initiated, not just during install mode.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 13 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/core/remoteBuild.ts">

<violation number="1" location="src/core/remoteBuild.ts:322">
P2: The new IPA lookup can terminate the script before the explicit `LAUNCH_NO_ARTIFACT` check runs, due to `set -euo pipefail` and `ls` failing when no match exists.</violation>
</file>

<file name="src/core/pipeline.ts">

<violation number="1" location="src/core/pipeline.ts:165">
P1: `canPrompt: process.stdin.isTTY` does not account for CI environments that expose a TTY (e.g., some hosted runners set `CI=true` but still have a pseudo-TTY). This can trigger an interactive picker and hang CI builds. Use a CI-aware interactivity check (e.g., `process.stdin.isTTY && !process.env.CI`) to ensure CI is always treated as non-interactive.</violation>
</file>

<file name="src/cli/commands/creds.ts">

<violation number="1" location="src/cli/commands/creds.ts:123">
P2: `rmSync` can throw on permission or filesystem errors (e.g., read-only directory, ACL restrictions). Since the credential import already succeeded at this point, a deletion failure should not fail the command. Wrap in a try/catch and log a warning instead so post-import cleanup errors are non-fatal.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/core/pipeline.ts
const hint = app.bundleId ?? app.packageName;
return hint ? { value: app, label: app.name, hint } : { value: app, label: app.name };
}),
canPrompt: process.stdin.isTTY,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: canPrompt: process.stdin.isTTY does not account for CI environments that expose a TTY (e.g., some hosted runners set CI=true but still have a pseudo-TTY). This can trigger an interactive picker and hang CI builds. Use a CI-aware interactivity check (e.g., process.stdin.isTTY && !process.env.CI) to ensure CI is always treated as non-interactive.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/core/pipeline.ts, line 165:

<comment>`canPrompt: process.stdin.isTTY` does not account for CI environments that expose a TTY (e.g., some hosted runners set `CI=true` but still have a pseudo-TTY). This can trigger an interactive picker and hang CI builds. Use a CI-aware interactivity check (e.g., `process.stdin.isTTY && !process.env.CI`) to ensure CI is always treated as non-interactive.</comment>

<file context>
@@ -173,27 +156,15 @@ export async function selectApp(apps: AppDescriptor[], appName: string | undefin
+      const hint = app.bundleId ?? app.packageName;
+      return hint ? { value: app, label: app.name, hint } : { value: app, label: app.name };
+    }),
+    canPrompt: process.stdin.isTTY,
+    nonInteractive: { kind: "require", flagHint: "— pass --app <name> to choose one non-interactively." },
   });
</file context>
Suggested change
canPrompt: process.stdin.isTTY,
canPrompt: process.stdin.isTTY && !process.env.CI,

Comment thread src/core/remoteBuild.ts
IPA="$(ls "$OUT"/*.ipa | head -1)"
# Fail fast on the host if gym produced no non-empty .ipa, so we don't waste a transfer on a dead
# export — the authoritative device-archive guard runs locally after pull.
IPA="$(ls "$OUT"/*.ipa 2>/dev/null | head -1)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new IPA lookup can terminate the script before the explicit LAUNCH_NO_ARTIFACT check runs, due to set -euo pipefail and ls failing when no match exists.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/core/remoteBuild.ts, line 322:

<comment>The new IPA lookup can terminate the script before the explicit `LAUNCH_NO_ARTIFACT` check runs, due to `set -euo pipefail` and `ls` failing when no match exists.</comment>

<file context>
@@ -292,7 +317,13 @@ fastlane gym \
-IPA="$(ls "$OUT"/*.ipa | head -1)"
+# Fail fast on the host if gym produced no non-empty .ipa, so we don't waste a transfer on a dead
+# export — the authoritative device-archive guard runs locally after pull.
+IPA="$(ls "$OUT"/*.ipa 2>/dev/null | head -1)"
+if [ -z "$IPA" ] || [ ! -s "$IPA" ]; then
+  echo "LAUNCH_NO_ARTIFACT: gym produced no non-empty .ipa in $OUT" >&2
</file context>
Suggested change
IPA="$(ls "$OUT"/*.ipa 2>/dev/null | head -1)"
IPA="$(find "$OUT" -maxdepth 1 -type f -name '*.ipa' -print -quit)"

Comment thread src/cli/commands/creds.ts
console.log(`Kept ${tildify(file)}.`);
return;
}
rmSync(file, { force: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: rmSync can throw on permission or filesystem errors (e.g., read-only directory, ACL restrictions). Since the credential import already succeeded at this point, a deletion failure should not fail the command. Wrap in a try/catch and log a warning instead so post-import cleanup errors are non-fatal.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/cli/commands/creds.ts, line 123:

<comment>`rmSync` can throw on permission or filesystem errors (e.g., read-only directory, ACL restrictions). Since the credential import already succeeded at this point, a deletion failure should not fail the command. Wrap in a try/catch and log a warning instead so post-import cleanup errors are non-fatal.</comment>

<file context>
@@ -80,6 +81,49 @@ const SEARCH_DIRS = [
+    console.log(`Kept ${tildify(file)}.`);
+    return;
+  }
+  rmSync(file, { force: true });
+  console.log(`Removed ${tildify(file)}.`);
+}
</file context>
Suggested change
rmSync(file, { force: true });
try {
rmSync(file, { force: true });
} catch (err) {
console.log(`Warning: could not remove ${tildify(file)}: ${(err as Error).message}`);
return;
}

@YosefHayim
YosefHayim merged commit dad1924 into main Jun 14, 2026
5 checks passed
@YosefHayim
YosefHayim deleted the fix/finish-open-issues branch June 14, 2026 05:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

1 participant