Skip to content

macOS: Auto-install CLI to ~/.local/bin - #2937

Merged
fredrikekelund merged 30 commits into
trunkfrom
stu-1363-auto-install-cli-macos
Apr 8, 2026
Merged

macOS: Auto-install CLI to ~/.local/bin#2937
fredrikekelund merged 30 commits into
trunkfrom
stu-1363-auto-install-cli-macos

Conversation

@ivan-ottinger

@ivan-ottinger ivan-ottinger commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

Related issues

How AI was used in this PR

Claude Code was used to implement the changes, write tests, and create this PR. The solution was developed iteratively through discussion — we explored the existing codebase patterns (macOS and Windows installation managers), identified edge cases (e.g., respecting user preference when CLI is explicitly disabled, Dock-launched apps not inheriting shell PATH), and refined the approach based on the Linear issue description and comments.

Proposed Changes

  • Replace /usr/local/bin/studio symlink (requires sudo) with ~/.local/bin/studio (user-writable, no sudo needed)
  • Auto-install CLI on first app startup via autoInstallMacOSCliIfNeeded(), with a cliAutoInstalled flag in app config to avoid re-enabling if the user explicitly disabled it
  • Add shell profile PATH management: appends export PATH="$HOME/.local/bin:$PATH" to the user's shell profile if the export line isn't already present. The profile file is determined by process.env.SHELL (.zshrc for zsh, .bash_profile for bash, defaults to .zshrc). ~/.local/bin is prepended so the bundled CLI takes priority over any standalone npm-installed version
  • CLI installation status (isCliInstalled) checks the profile file content rather than runtime process.env.PATH, since Dock-launched Electron apps on macOS don't inherit shell PATH
  • Remove sudo shell scripts (install-studio-cli.sh, uninstall-studio-cli.sh) that are no longer needed
  • Add unit tests for the refactored installation manager
  • Auto-install is skipped in development mode (npm start) to avoid creating symlinks pointing to dev resources

Testing Instructions

Note: Auto-install only runs in production mode. You must test with a built app (npm run make), not npm start.

Setup

  1. Remove any existing CLI symlink: rm -f ~/.local/bin/studio
  2. Remove the auto-install flag if present (if you are testing this PR for the first time, it should not be present - since we are introducing it in this PR): edit ~/.studio/app.json and delete the "cliAutoInstalled": true entry
  3. Build and install the app: npm run make, then install the resulting DMG to /Applications/

Auto-install on first launch

  1. Launch Studio
  2. Verify the symlink was created: ls -la ~/.local/bin/studio — should point to /Applications/Studio.app/Contents/Resources/bin/studio-cli.sh
  3. Verify the flag was set: grep cliAutoInstalled ~/.studio/app.json — should show "cliAutoInstalled": true
  4. Open a new terminal and run studio --version — should print the current version
  5. Check your shell profile: tail -3 ~/.zshrc — should contain export PATH="$HOME/.local/bin:$PATH" (if it wasn't already there)

Shell profile PATH management

  1. To test that the export line is added correctly, temporarily remove $HOME/.local/bin from your shell profile (e.g., edit ~/.zshrc and delete the export PATH="$HOME/.local/bin:$PATH" line), then remove the CLI symlink and reset the flag:
    • rm -f ~/.local/bin/studio
    • Edit ~/.studio/app.json and delete the "cliAutoInstalled": true entry
  2. Relaunch Studio
  3. Verify the export line was added back: grep '$HOME/.local/bin' ~/.zshrc — should show export PATH="$HOME/.local/bin:$PATH"
  4. Verify it was not duplicated: grep -c '$HOME/.local/bin' ~/.zshrc — should show 1

User preference is respected

  1. In Studio, go to Settings > Preferences, toggle the CLI off, and save
  2. Verify the symlink is gone: ls ~/.local/bin/studio — should not exist
  3. Quit and relaunch Studio
  4. Verify the CLI was not reinstalled: ls ~/.local/bin/studio — should still not exist
  5. Verify the flag is still set: grep cliAutoInstalled ~/.studio/app.json — should still show true

Re-enabling works without sudo

  1. Toggle the CLI back on in Settings > Preferences, and save
  2. Verify the symlink is back: ls -la ~/.local/bin/studio

No-op on subsequent launches

  1. Quit and relaunch Studio
  2. Verify the symlink is unchanged and no errors in the logs

Pre-merge Checklist

  • Have you checked for TypeScript, React or other console errors?
Replace the sudo-based /usr/local/bin symlink approach with a
user-writable ~/.local/bin directory. Auto-install CLI on app startup
and manage shell profile PATH if needed.
@ivan-ottinger ivan-ottinger self-assigned this Mar 27, 2026
Add cliAutoInstalled flag to app config so auto-install only runs on
first launch. If the user disables the CLI toggle, it won't be
re-enabled on the next app start.
Use path.join() for test constants and assertions so paths use the
correct separator on Windows CI (backslashes) vs macOS (forward slashes).
Use describe.skipIf for the MacOSCliInstallationManager tests since
they use macOS path conventions. Matches the existing codebase pattern
of using hardcoded forward-slash paths in test mocks.
The old /usr/local/bin/studio symlink almost always requires sudo to
remove, making cleanup a no-op for most users. The old symlink is
harmless since ~/.local/bin takes priority in PATH.
Prevents dev mode from creating a symlink pointing to dev resources
and setting the cliAutoInstalled flag, which would interfere with
production installs.

const ERROR_FILE_ALREADY_EXISTS = 'Studio CLI symlink path already occupied by non-symlink';
// Defined in @vscode/sudo-prompt
const ERROR_PERMISSION = 'User did not grant permission.';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Since we don't run sudo, the error message is no longer needed.

@ivan-ottinger
ivan-ottinger marked this pull request as ready for review March 27, 2026 15:53
@ivan-ottinger
ivan-ottinger requested a review from Copilot March 27, 2026 15:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Refactors the macOS Studio CLI installation flow to avoid sudo by installing the studio symlink into ~/.local/bin, adds first-run auto-install behavior, and updates user config/types to track whether auto-install has occurred.

Changes:

  • Switch macOS CLI symlink target from /usr/local/bin/studio to ~/.local/bin/studio and remove the legacy sudo-based shell scripts.
  • Add autoInstallMacOSCliIfNeeded() on app boot (production + darwin only) and persist cliAutoInstalled in app config.
  • Add PATH management by appending export PATH="$HOME/.local/bin:$PATH" to a detected shell profile when needed, plus new unit tests.

Reviewed changes

Copilot reviewed 5 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
apps/studio/src/storage/user-data.ts Allows cliAutoInstalled to be persisted via updateAppdata() safe keys.
apps/studio/src/storage/storage-types.ts Adds cliAutoInstalled?: boolean to the UserData type.
apps/studio/src/modules/cli/lib/macos-installation-manager.ts Implements ~/.local/bin symlink install, profile PATH updates, and auto-install entrypoint.
apps/studio/src/index.ts Calls autoInstallMacOSCliIfNeeded() during app boot.
apps/studio/src/modules/cli/lib/tests/macos-installation-manager.test.ts Adds unit tests for the refactored macOS installation manager and auto-install behavior.
apps/studio/bin/install-studio-cli.sh Removed legacy sudo install script.
apps/studio/bin/uninstall-studio-cli.sh Removed legacy sudo uninstall script.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/studio/src/modules/cli/lib/tests/macos-installation-manager.test.ts Outdated
Comment thread apps/studio/src/modules/cli/lib/macos-installation-manager.ts
Comment thread apps/studio/src/modules/cli/lib/macos-installation-manager.ts
Return early in uninstallCli() when the symlink doesn't exist instead
of proceeding to unlink (which would throw ENOENT). Also narrow the
test skip from all non-darwin platforms to Windows only, since the
POSIX paths work fine on Linux.
@ivan-ottinger
ivan-ottinger requested a review from a team March 27, 2026 17:11
@wpmobilebot

wpmobilebot commented Mar 27, 2026

Copy link
Copy Markdown
Collaborator

📊 Performance Test Results

Comparing fb6f9dc vs trunk

app-size

Metric trunk fb6f9dc Diff Change
App Size (Mac) 1252.06 MB 1252.03 MB 0.04 MB ⚪ 0.0%

site-editor

Metric trunk fb6f9dc Diff Change
load 1858 ms 1910 ms +52 ms 🔴 2.8%

site-startup

Metric trunk fb6f9dc Diff Change
siteCreation 9149 ms 9133 ms 16 ms ⚪ 0.0%
siteStartup 4943 ms 4944 ms +1 ms ⚪ 0.0%

Results are median values from multiple test runs.

Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff)

@fredrikekelund fredrikekelund left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I left a couple of comments on the implementation. We also need to consider users updating from older Studio versions. We have two options there, as I see it:

  1. Write a data migration that respects the old CLI installation setting, and only runs the auto-installer logic if the CLI was already installed. The downside is that this is probably not a conscious decision for a lot of users.
  2. Run the auto-installer logic for all users: upgraders and new installers alike. The downside is that this will modify the environment for users who previously consciously uninstalled the CLI.

I'm leaning towards the second option.

As for the old CLI path, it's unfortunately not so easy to clean up, since we need elevated privileges to modify it. We could either leave it lying around, or add a function to the CLI un/-install flow that checks the old path and cleans it up if needed.

Comment thread apps/studio/src/modules/cli/lib/macos-installation-manager.ts Outdated
Comment thread apps/studio/src/modules/cli/lib/macos-installation-manager.ts
Comment thread apps/studio/src/modules/cli/lib/macos-installation-manager.ts Outdated
Comment thread apps/studio/src/modules/cli/lib/macos-installation-manager.ts Outdated
Comment thread apps/studio/src/modules/cli/lib/macos-installation-manager.ts Outdated
Comment on lines 238 to 240
if ( existingContent.includes( '$HOME/.local/bin' ) ) {
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It makes sense not to append the same content repeatedly to the file, but at the same time, we also know that it doesn't have the intended effect at this point. Is there a fallback strategy we can use in this case..? 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Do you think a case like this one where the path would be overwritten?

export PATH="$HOME/.local/bin:$PATH"
# ... bunch of other stuff ...
export PATH="/usr/bin:/bin"

I think it would be quite an edge case, no? But maybe you mean some different case? 🤔

@ivan-ottinger

Copy link
Copy Markdown
Contributor Author

Thank you for your review and feedback, Fredrik!

We also need to consider users updating from older Studio versions. We have two options there, as I see it:

  1. Write a data migration that respects the old CLI installation setting, and only runs the auto-installer logic if the CLI was already installed. The downside is that this is probably not a conscious decision for a lot of users.
  2. Run the auto-installer logic for all users: upgraders and new installers alike. The downside is that this will modify the environment for users who previously consciously uninstalled the CLI.

I'm leaning towards the second option.

Yes, I see it the same way and so we can keep the logic as-is. 🙂

As for the old CLI path, it's unfortunately not so easy to clean up, since we need elevated privileges to modify it. We could either leave it lying around, or add a function to the CLI un/-install flow that checks the old path and cleans it up if needed.

Yes, I was actually trying to clean it up before, but then decided to remove the whole logic in 20e9dae - as it would run for a very few users only + keeping the old path there should not cause any issues.


I will check out your other review comments as well.

installCli() already checks isCliInstalled() internally, so the check
in autoInstallIfNeeded() was redundant.
isCliInstalled now also verifies that ~/.local/bin is in PATH, not just
that the symlink exists. PATH entries are normalized by expanding ~ and
$HOME and resolving to absolute paths before comparison.
Instead of checking which profile files exist on disk, map the user's
SHELL environment variable to the corresponding profile file. Defaults
to .zshrc when SHELL is empty or unrecognized.
@ivan-ottinger

Copy link
Copy Markdown
Contributor Author

I will check out your other review comments as well.

I have applied the changes and will retest everything tomorrow with fresh. 🧠 🙂

Dock-launched Electron apps on macOS don't inherit shell PATH, so
checking process.env.PATH always returned false. Instead, read the
shell profile file to verify the export line is present. Refactor
shared helpers to avoid duplicate file reads and logic.
@ivan-ottinger

Copy link
Copy Markdown
Contributor Author

I ended up checking the shell profile file content instead of process.env.PATH. Turns out that dock-launched Electron apps on macOS inherit their environment from the GUI session (launchd), not from the user’s shell. As a result, process.env.PATH typically does not include entries defined in ~/.zshrc (such as ~/.local/bin).

There's also a this package that helps with that, but I think checking the file contents itself should be enough.

Use mockResolvedValue instead of chaining mockResolvedValueOnce twice
with the same content. Removes confusing comments about call order.
@ivan-ottinger ivan-ottinger changed the title Refactor macOS CLI installation to use ~/.local/bin Apr 2, 2026
@ivan-ottinger
ivan-ottinger requested a review from Copilot April 2, 2026 12:36
@ivan-ottinger

Copy link
Copy Markdown
Contributor Author

Note: Failed E2E tests are unrelated to this PR.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 7 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

apps/studio/src/modules/cli/lib/macos-installation-manager.ts:170

  • installCli() treats any existing symlink at ~/.local/bin/studio as safe to replace: it only errors when the path is a non-symlink, then later unlinks and recreates the symlink if isCliInstalled() is false. This can silently overwrite a user’s pre-existing studio symlink that points elsewhere. Consider validating the current symlink destination with readlink() and (a) no-op if it already points to cliPackagedPath, or (b) surface a clear “path is occupied” error (or ask for confirmation) if it points somewhere else, rather than overwriting.
		try {
			const stats = await lstat( cliSymlinkPath );

			if ( ! stats.isSymbolicLink() ) {
				throw new Error( ERROR_FILE_ALREADY_EXISTS );
			}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/studio/src/modules/cli/lib/macos-installation-manager.ts Outdated
Comment thread apps/studio/src/modules/cli/lib/macos-installation-manager.ts
Prevents silently overwriting the user's shell profile if readFile
fails for a reason other than the file not existing (e.g. permissions
or I/O errors).
@ivan-ottinger

Copy link
Copy Markdown
Contributor Author

Hey @fredrikekelund 👋🏼🙂 The PR is now ready for another review round. I have tested the updated logic on the built macOS app and did not observe any issues.

I think we will probably won't get to merge today so considering my coming limited availability due to WordCamp I have also added the Reviewer can merge tag.

ivan-ottinger and others added 10 commits April 2, 2026 17:56

@fredrikekelund fredrikekelund left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I made a bunch of additional changes here, informed by my own review and local AI agent reviews.

  • Removed the tests. MacOSCliInstallationManager is not well-suited for testing, IMO. We simply have to mock too much for the tests to really give us any real assurances.
  • I brought back the old uninstallation script and added an uninstallLegacyCliIfNeeded method. Without this, /usr/local/bin/studio would be left intact, meaning the studio command would not truly be uninstalled.
  • Started using os.userInfo() to look up the user's shell. This is a more reliable indicator than process.env.SHELL. We still fall back to the environment variable, though.
  • Started looking up shells by basenames rather than complete paths. This helps if the user has installed their current shell with Homebrew, for example.
  • Removed the "CLI Installed" modal. This is subjective on my part, but I don't think this really makes sense when we're no longer prompting the user for their password. We should only interrupt them with a modal if there was an error.
@fredrikekelund
fredrikekelund enabled auto-merge (squash) April 8, 2026 12:45
@fredrikekelund
fredrikekelund merged commit c1f918f into trunk Apr 8, 2026
8 of 10 checks passed
@fredrikekelund
fredrikekelund deleted the stu-1363-auto-install-cli-macos branch April 8, 2026 12:48
@ivan-ottinger

Copy link
Copy Markdown
Contributor Author

Thank you for the updates to the PR and pushing it to the finish line while I was AFK, Fredrik!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

4 participants