Skip to content

Commit c37a016

Browse files
authored
Merge pull request #776 from jupyterlab/setup-mac-cli-from-ui
setup CLI from UI on macOS
2 parents ad7c598 + c8ca289 commit c37a016

File tree

7 files changed

+194
-22
lines changed

7 files changed

+194
-22
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"dist:osx-64": "yarn dist:osx",
2323
"dist:osx-arm64": "yarn dist:osx",
2424
"dist:osx-dev": "yarn build && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --macos --publish never",
25+
"dist:osx-arm64-dev": "yarn build && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --macos --arm64 --publish never",
2526
"dist:win-64": "yarn build && electron-builder --win --publish never",
2627
"dist:win-arm64": "yarn build && yarn electron-builder --arm64 --publish never",
2728
"update_workflow_conda_lock": "cd workflow_env && rimraf *.lock && conda-lock --kind explicit -f publish_env.yaml && cd -",

src/main/app.ts

+24
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
installBundledEnvironment,
2525
isDarkTheme,
2626
pythonPathForEnvPath,
27+
setupJlabCLICommandWithElevatedRights,
2728
waitForDuration
2829
} from './utils';
2930
import { IServerFactory, JupyterServerFactory } from './server';
@@ -1201,6 +1202,29 @@ export class JupyterApplication implements IApplication, IDisposable {
12011202
return true;
12021203
}
12031204
);
1205+
1206+
this._evm.registerSyncEventHandler(
1207+
EventTypeMain.SetupCLICommandWithElevatedRights,
1208+
async event => {
1209+
const showSetupErrorMessage = () => {
1210+
dialog.showErrorBox(
1211+
'CLI setup error',
1212+
'Failed to setup jlab CLI command! Please see logs for details.'
1213+
);
1214+
};
1215+
1216+
try {
1217+
const succeeded = await setupJlabCLICommandWithElevatedRights();
1218+
if (!succeeded) {
1219+
showSetupErrorMessage();
1220+
}
1221+
return succeeded;
1222+
} catch (error) {
1223+
showSetupErrorMessage();
1224+
return false;
1225+
}
1226+
}
1227+
);
12041228
}
12051229

12061230
private _showUpdateDialog(

src/main/eventtypes.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ export enum EventTypeMain {
8181
SetSystemPythonPath = 'set-system-python-path',
8282
CopySessionInfoToClipboard = 'copy-session-info-to-clipboard',
8383
RestartSession = 'restart-session',
84-
SetSettings = 'set-settings'
84+
SetSettings = 'set-settings',
85+
SetupCLICommandWithElevatedRights = 'setup-cli-command'
8586
}
8687

8788
// events sent to Renderer process

src/main/main.ts

+4-18
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@ import * as semver from 'semver';
55
import {
66
bundledEnvironmentIsInstalled,
77
EnvironmentInstallStatus,
8-
getAppDir,
98
getBundledPythonEnvPath,
109
getBundledPythonPath,
1110
installBundledEnvironment,
1211
isDevMode,
12+
jlabCLICommandIsSetup,
13+
setupJlabCommandWithUserRights,
1314
versionWithoutSuffix,
1415
waitForDuration,
1516
waitForFunction
1617
} from './utils';
17-
import { execSync } from 'child_process';
1818
import { JupyterApplication } from './app';
1919
import { ICLIArguments } from './tokens';
2020
import { SessionConfig } from './config/sessionconfig';
@@ -130,25 +130,11 @@ function setupJLabCommand() {
130130
return;
131131
}
132132

133-
const symlinkPath = '/usr/local/bin/jlab';
134-
const targetPath = `${getAppDir()}/app/jlab`;
135-
136-
if (!fs.existsSync(targetPath)) {
133+
if (jlabCLICommandIsSetup()) {
137134
return;
138135
}
139136

140-
try {
141-
if (!fs.existsSync(symlinkPath)) {
142-
const cmd = `ln -s ${targetPath} ${symlinkPath}`;
143-
execSync(cmd, { shell: '/bin/bash' });
144-
fs.chmodSync(symlinkPath, 0o755);
145-
}
146-
147-
// after a DMG install, mode resets
148-
fs.chmodSync(targetPath, 0o755);
149-
} catch (error) {
150-
log.error(error);
151-
}
137+
setupJlabCommandWithUserRights();
152138
}
153139

154140
function createPythonEnvsDirectory() {

src/main/settingsdialog/preload.ts

+3
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
7878
},
7979
setSettings: (settings: { [key: string]: any }) => {
8080
ipcRenderer.send(EventTypeMain.SetSettings, settings);
81+
},
82+
setupCLICommand: () => {
83+
return ipcRenderer.invoke(EventTypeMain.SetupCLICommandWithElevatedRights);
8184
}
8285
});
8386

src/main/settingsdialog/settingsdialog.ts

+39-2
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ import {
1616
ThemeType
1717
} from '../config/settings';
1818
import { IRegistry } from '../registry';
19+
import { jlabCLICommandIsSetup } from '../utils';
1920

2021
export class SettingsDialog {
2122
constructor(options: SettingsDialog.IOptions, registry: IRegistry) {
2223
this._window = new ThemedWindow({
2324
isDarkTheme: options.isDarkTheme,
2425
title: 'Settings',
2526
width: 700,
26-
height: 450,
27+
height: 500,
2728
preload: path.join(__dirname, './preload.js')
2829
});
2930

@@ -45,6 +46,7 @@ export class SettingsDialog {
4546
const installUpdatesAutomaticallyEnabled = process.platform === 'darwin';
4647
const installUpdatesAutomatically =
4748
installUpdatesAutomaticallyEnabled && options.installUpdatesAutomatically;
49+
const cliCommandIsSetup = jlabCLICommandIsSetup();
4850

4951
let strServerEnvVars = '';
5052
if (Object.keys(serverEnvVars).length > 0) {
@@ -336,6 +338,19 @@ export class SettingsDialog {
336338
</jp-radio-group>
337339
</div>
338340
341+
<div class="row setting-section">
342+
<div class="row">
343+
<label>jlab CLI</label>
344+
</div>
345+
346+
<div class="row">
347+
<div id="setup-cli-command-button">
348+
<jp-button onclick='handleSetupCLICommand(this);'>Setup CLI</jp-button>
349+
</div>
350+
<div id="setup-cli-command-label" style="flex-grow: 1"></div>
351+
</div>
352+
</div>
353+
339354
<div class="row setting-section">
340355
<div class="row">
341356
<label for="log-level">Log level</label>
@@ -376,6 +391,9 @@ export class SettingsDialog {
376391
const autoInstallCheckbox = document.getElementById('checkbox-update-install');
377392
const notifyOnBundledEnvUpdatesCheckbox = document.getElementById('notify-on-bundled-env-updates');
378393
const updateBundledEnvAutomaticallyCheckbox = document.getElementById('update-bundled-env-automatically');
394+
const setupCLICommandButton = document.getElementById('setup-cli-command-button');
395+
const setupCLICommandLabel = document.getElementById('setup-cli-command-label');
396+
let cliCommandIsSetup = <%= cliCommandIsSetup %>;
379397
380398
function handleAutoCheckForUpdates(el) {
381399
updateAutoInstallCheckboxState();
@@ -398,11 +416,29 @@ export class SettingsDialog {
398416
window.electronAPI.showLogs();
399417
}
400418
419+
function handleSetupCLICommand(el) {
420+
window.electronAPI.setupCLICommand().then(result => {
421+
cliCommandIsSetup = result;
422+
updateCLICommandSetupStatus();
423+
});
424+
}
425+
426+
function updateCLICommandSetupStatus() {
427+
if (cliCommandIsSetup) {
428+
setupCLICommandButton.style.display = 'none';
429+
setupCLICommandLabel.innerHTML = '<b>jlab</b> CLI command is ready to use in your system terminal!';
430+
} else {
431+
setupCLICommandButton.style.display = 'block';
432+
setupCLICommandLabel.innerHTML = 'CLI command is not set up yet. Click to set up now. This requires elevated permissions.';
433+
}
434+
}
435+
401436
function onLogLevelChanged(el) {
402437
window.electronAPI.setLogLevel(el.value);
403438
}
404439
405440
document.addEventListener("DOMContentLoaded", () => {
441+
updateCLICommandSetupStatus();
406442
updateAutoInstallCheckboxState();
407443
});
408444
</script>
@@ -480,7 +516,8 @@ export class SettingsDialog {
480516
serverArgs,
481517
overrideDefaultServerArgs,
482518
serverEnvVars: strServerEnvVars,
483-
ctrlWBehavior
519+
ctrlWBehavior,
520+
cliCommandIsSetup
484521
});
485522
}
486523

src/main/utils.ts

+121-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import log from 'electron-log';
1010
import { AddressInfo, createServer, Socket } from 'net';
1111
import { app, nativeTheme } from 'electron';
1212
import { IPythonEnvironment } from './tokens';
13-
import { exec, execFile, ExecFileOptions, execFileSync } from 'child_process';
13+
import {
14+
exec,
15+
execFile,
16+
ExecFileOptions,
17+
execFileSync,
18+
execSync
19+
} from 'child_process';
1420

1521
export const DarkThemeBGColor = '#212121';
1622
export const LightThemeBGColor = '#ffffff';
@@ -710,3 +716,117 @@ export function launchTerminalInDirectory(
710716
exec(`gnome-terminal --working-directory="${dirPath}"${callCommands}`);
711717
}
712718
}
719+
720+
export function getJlabCLICommandSymlinkPath(): string {
721+
if (process.platform === 'darwin') {
722+
return '/usr/local/bin/jlab';
723+
}
724+
}
725+
726+
export function getJlabCLICommandTargetPath(): string {
727+
if (process.platform === 'darwin') {
728+
return `${getAppDir()}/app/jlab`;
729+
}
730+
}
731+
732+
export function jlabCLICommandIsSetup(): boolean {
733+
if (process.platform !== 'darwin') {
734+
return true;
735+
}
736+
737+
const symlinkPath = getJlabCLICommandSymlinkPath();
738+
const targetPath = getJlabCLICommandTargetPath();
739+
740+
if (!fs.existsSync(symlinkPath)) {
741+
return false;
742+
}
743+
744+
const stats = fs.lstatSync(symlinkPath);
745+
if (!stats.isSymbolicLink()) {
746+
return false;
747+
}
748+
749+
try {
750+
fs.accessSync(targetPath, fs.constants.X_OK);
751+
} catch (error) {
752+
log.error('App CLI is not executable', error);
753+
return false;
754+
}
755+
756+
return fs.readlinkSync(symlinkPath) === targetPath;
757+
}
758+
759+
export async function setupJlabCLICommandWithElevatedRights(): Promise<
760+
boolean
761+
> {
762+
if (process.platform !== 'darwin') {
763+
return false;
764+
}
765+
766+
const symlinkPath = getJlabCLICommandSymlinkPath();
767+
const targetPath = getJlabCLICommandTargetPath();
768+
769+
if (!fs.existsSync(targetPath)) {
770+
log.error(`Target path "${targetPath}" does not exist! `);
771+
return false;
772+
}
773+
774+
const shellCommands: string[] = [];
775+
const symlinkParentDir = path.dirname(symlinkPath);
776+
777+
// create parent directory
778+
if (!fs.existsSync(symlinkParentDir)) {
779+
shellCommands.push(`mkdir -p ${symlinkParentDir}`);
780+
}
781+
782+
// create symlink
783+
shellCommands.push(`ln -f -s \\"${targetPath}\\" \\"${symlinkPath}\\"`);
784+
785+
// make files executable
786+
shellCommands.push(`chmod 755 \\"${symlinkPath}\\"`);
787+
shellCommands.push(`chmod 755 \\"${targetPath}\\"`);
788+
789+
const command = `do shell script "${shellCommands.join(
790+
' && '
791+
)}" with administrator privileges`;
792+
793+
return new Promise<boolean>((resolve, reject) => {
794+
const cliSetupProc = exec(`osascript -e '${command}'`);
795+
796+
cliSetupProc.on('exit', (exitCode: number) => {
797+
if (exitCode === 0) {
798+
resolve(true);
799+
} else {
800+
log.error(`Failed to setup CLI with exit code ${exitCode}`);
801+
reject();
802+
}
803+
});
804+
805+
cliSetupProc.on('error', (err: Error) => {
806+
log.error(err);
807+
reject();
808+
});
809+
});
810+
}
811+
812+
export async function setupJlabCommandWithUserRights() {
813+
const symlinkPath = getJlabCLICommandSymlinkPath();
814+
const targetPath = getJlabCLICommandTargetPath();
815+
816+
if (!fs.existsSync(targetPath)) {
817+
return;
818+
}
819+
820+
try {
821+
if (!fs.existsSync(symlinkPath)) {
822+
const cmd = `ln -s ${targetPath} ${symlinkPath}`;
823+
execSync(cmd, { shell: '/bin/bash' });
824+
fs.chmodSync(symlinkPath, 0o755);
825+
}
826+
827+
// after a DMG install, mode resets
828+
fs.chmodSync(targetPath, 0o755);
829+
} catch (error) {
830+
log.error(error);
831+
}
832+
}

0 commit comments

Comments
 (0)