Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,17 +182,11 @@ localmost jobs

### Installing the CLI

After installing localmost.app, create a symlink to add the CLI to your PATH:
From the app menu: **localmost → Install Command Line Tool...**

```bash
sudo ln -sf "/Applications/localmost.app/Contents/Resources/localmost-cli" /usr/local/bin/localmost
```

Or for development builds:
This creates a symlink in `/usr/local/bin` so you can use `localmost` from any terminal. You'll be prompted for your administrator password.

```bash
npm link
```
For development builds, use `npm link` instead.

The CLI communicates with the running app via a Unix socket. Most commands require the app to be running - use `localmost start` to launch it first.

Expand Down Expand Up @@ -239,6 +233,5 @@ Future feature ideas:

Bugs and quick improvements:

- Fix the CLI install process to be polished
- Fix "build on unknown" race where jobs don't get links

211 changes: 211 additions & 0 deletions src/main/cli-install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* CLI installation service.
* Handles installing/uninstalling the `localmost` command to /usr/local/bin.
*/

import { app, dialog } from 'electron';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs/promises';
import * as path from 'path';
import { getMainWindow } from './app-state';

const execAsync = promisify(exec);

/** Target path for the CLI symlink */
const CLI_INSTALL_PATH = '/usr/local/bin/localmost';

/** Name of the CLI wrapper script bundled in app resources */
const CLI_RESOURCE_NAME = 'localmost-cli';

/**
* Check if the app is running from a translocated path.
* App Translocation is a macOS security feature that copies apps to a
* temporary location when run directly from Downloads or other quarantined locations.
*/
export function isAppTranslocated(): boolean {
return app.getAppPath().includes('AppTranslocation');
}

/**
* Get the path to the CLI wrapper script in the app bundle.
*/
function getCliSourcePath(): string {
return path.join(process.resourcesPath, CLI_RESOURCE_NAME);
}

/**
* Check if the CLI is currently installed and points to this app.
*/
export async function getCliInstallStatus(): Promise<{
installed: boolean;
pointsToThisApp: boolean;
currentTarget?: string;
}> {
try {
const stats = await fs.lstat(CLI_INSTALL_PATH);
if (!stats.isSymbolicLink()) {
// It's a regular file, not our symlink
return { installed: true, pointsToThisApp: false, currentTarget: CLI_INSTALL_PATH };
}

const target = await fs.readlink(CLI_INSTALL_PATH);
const expectedTarget = getCliSourcePath();
const pointsToThisApp = target === expectedTarget;

return { installed: true, pointsToThisApp, currentTarget: target };
} catch (error) {

Check warning on line 57 in src/main/cli-install.ts

View workflow job for this annotation

GitHub Actions / build

'error' is defined but never used
// File doesn't exist or can't be read
return { installed: false, pointsToThisApp: false };
}
}

/**
* Install the CLI by creating a symlink in /usr/local/bin.
* Uses osascript with administrator privileges to handle the sudo requirement.
*/
export async function installCli(): Promise<{ success: boolean; error?: string }> {
const mainWindow = getMainWindow();

// Check for App Translocation
if (isAppTranslocated()) {
dialog.showMessageBox(mainWindow!, {
type: 'warning',
title: 'Cannot Install CLI',
message: 'Please move localmost to Applications first',
detail:
'The app is running from a temporary location. Drag localmost.app to your Applications folder, then relaunch it before installing the command line tool.',
buttons: ['OK'],
});
return { success: false, error: 'App is translocated' };
}

const sourcePath = getCliSourcePath();

// Verify the CLI script exists in the bundle
try {
await fs.access(sourcePath);
} catch {
return { success: false, error: 'CLI script not found in app bundle' };
}

// Check current status
const status = await getCliInstallStatus();
if (status.installed && status.pointsToThisApp) {
dialog.showMessageBox(mainWindow!, {
type: 'info',
title: 'CLI Already Installed',
message: 'The command line tool is already installed',
detail: `The 'localmost' command is available in your terminal.`,
buttons: ['OK'],
});
return { success: true };
}

// Build the shell command
// mkdir -p ensures /usr/local/bin exists; ln -sf overwrites any existing symlink
const shellCommand = `mkdir -p /usr/local/bin && ln -sf '${sourcePath}' '${CLI_INSTALL_PATH}'`;

// Use osascript to run with administrator privileges (triggers macOS password prompt)
const script = `do shell script "${shellCommand}" with administrator privileges`;

try {
await execAsync(`osascript -e '${script}'`);

dialog.showMessageBox(mainWindow!, {
type: 'info',
title: 'CLI Installed',
message: 'Command line tool installed successfully',
detail: `You can now use 'localmost' in your terminal.\n\nTry: localmost status`,
buttons: ['OK'],
});

return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);

// User cancelled the password prompt
if (errorMessage.includes('User canceled') || errorMessage.includes('-128')) {
return { success: false, error: 'Installation cancelled' };
}

dialog.showMessageBox(mainWindow!, {
type: 'error',
title: 'Installation Failed',
message: 'Could not install the command line tool',
detail: errorMessage,
buttons: ['OK'],
});

return { success: false, error: errorMessage };
}
}

/**
* Uninstall the CLI by removing the symlink from /usr/local/bin.
*/
export async function uninstallCli(): Promise<{ success: boolean; error?: string }> {
const mainWindow = getMainWindow();

const status = await getCliInstallStatus();
if (!status.installed) {
dialog.showMessageBox(mainWindow!, {
type: 'info',
title: 'CLI Not Installed',
message: 'The command line tool is not installed',
detail: `There is no 'localmost' command to remove.`,
buttons: ['OK'],
});
return { success: true };
}

// Only remove if it's our symlink (safety check)
if (!status.pointsToThisApp) {
const result = await dialog.showMessageBox(mainWindow!, {
type: 'warning',
title: 'Different CLI Installed',
message: 'A different localmost CLI is installed',
detail: `The current CLI points to:\n${status.currentTarget}\n\nThis may be from a different installation. Remove it anyway?`,
buttons: ['Cancel', 'Remove Anyway'],
defaultId: 0,
cancelId: 0,
});

if (result.response === 0) {
return { success: false, error: 'Cancelled by user' };
}
}

// Use osascript to remove with administrator privileges
const script = `do shell script "rm -f '${CLI_INSTALL_PATH}'" with administrator privileges`;

try {
await execAsync(`osascript -e '${script}'`);

dialog.showMessageBox(mainWindow!, {
type: 'info',
title: 'CLI Uninstalled',
message: 'Command line tool removed',
detail: `The 'localmost' command has been removed from your PATH.`,
buttons: ['OK'],
});

return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);

if (errorMessage.includes('User canceled') || errorMessage.includes('-128')) {
return { success: false, error: 'Uninstall cancelled' };
}

dialog.showMessageBox(mainWindow!, {
type: 'error',
title: 'Uninstall Failed',
message: 'Could not remove the command line tool',
detail: errorMessage,
buttons: ['OK'],
});

return { success: false, error: errorMessage };
}
}
10 changes: 10 additions & 0 deletions src/main/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { app, Menu, MenuItemConstructorOptions, shell } from 'electron';
import { getMainWindow, getRunnerManager } from './app-state';
import { showAboutDialog, confirmQuitIfBusy } from './window';
import { installCli, uninstallCli } from './cli-install';
import { REPOSITORY_URL, PRIVACY_POLICY_URL } from '../shared/constants';

/**
Expand Down Expand Up @@ -43,6 +44,15 @@ export const createMenu = (): void => {
},
},
{ type: 'separator' as const },
{
label: 'Install Command Line Tool...',
click: () => installCli(),
},
{
label: 'Uninstall Command Line Tool...',
click: () => uninstallCli(),
},
{ type: 'separator' as const },
{ role: 'hide' as const, label: 'Hide localmost' },
{ role: 'hideOthers' as const },
{ role: 'unhide' as const },
Expand Down
Loading