From 75cddd26459c28f1ca54f4e3bc7aeeaf1708413a Mon Sep 17 00:00:00 2001 From: Gleb Volkov Date: Wed, 8 Jul 2026 18:40:18 +0200 Subject: [PATCH] Allow to run GDevelop from command line by adding it in PATH at installation (#8796) - Done automatically on Windows. For portable builds or Linux/macOS, use the command palette and search "CLI" to install it. --- newIDE/README.md | 11 ++ newIDE/app/src/CommandPalette/CommandsList.js | 8 +- newIDE/app/src/MainFrame/MainFrameCommands.js | 6 + newIDE/app/src/MainFrame/index.js | 15 ++ newIDE/app/src/Utils/InstallCliInPath.js | 25 ++++ newIDE/electron-app/app/InstallCliInPath.js | 137 ++++++++++++++++++ newIDE/electron-app/app/main.js | 7 + newIDE/electron-app/build/installer.nsh | 65 +++++++++ 8 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 newIDE/app/src/Utils/InstallCliInPath.js create mode 100644 newIDE/electron-app/app/InstallCliInPath.js create mode 100644 newIDE/electron-app/build/installer.nsh diff --git a/newIDE/README.md b/newIDE/README.md index 907aa937b4..4df5c0e6b6 100644 --- a/newIDE/README.md +++ b/newIDE/README.md @@ -142,6 +142,17 @@ GDevelop.exe --disable-update-check --run-command IMPORT_EXTENSION_AND_SAVE path Extra flags: `--keep-open` (don't quit after command), `--dev-tools` (open DevTools). +### Making the CLI available on PATH + +When GDevelop is installed with the Windows NSIS installer, the install +directory is added to the current user's `PATH` automatically. Open a new +terminal after installing, then run `GDevelop --run-command ...`. + +For other builds (macOS `.dmg`, Linux AppImage/`.deb`), run the command +palette command **"Install GDevelop CLI in PATH"** from the running app: +on Windows it appends the install directory to the user `PATH`; on +macOS/Linux it creates a `gdevelop` symlink in `/usr/local/bin`. + ## (Optional) Building and deploying the standalone app 📦 > 🖐 This section is only for maintainers that want to deploy the "official app" on the GDevelop website. If you're working on contributions for GDevelop, you won't need it. You can download ["Nightly Builds" of GDevelop here too](./docs/Nightly-Builds-and-continuous-deployment.md). diff --git a/newIDE/app/src/CommandPalette/CommandsList.js b/newIDE/app/src/CommandPalette/CommandsList.js index 2b4908e625..131a3984fb 100644 --- a/newIDE/app/src/CommandPalette/CommandsList.js +++ b/newIDE/app/src/CommandPalette/CommandsList.js @@ -71,7 +71,8 @@ export type CommandName = | 'SEARCH_EVENTS' | 'OPEN_EXTENSION_SETTINGS' | 'OPEN_PROFILE' - | 'OPEN_MEMORY_TRACKER_REGISTRY'; + | 'OPEN_MEMORY_TRACKER_REGISTRY' + | 'INSTALL_CLI_IN_PATH'; export const commandAreas = { GENERAL: (t`General`: any), @@ -186,6 +187,11 @@ const commandsList: { [CommandName]: CommandMetadata } = { area: 'IDE', displayText: t`Restart 3D editor`, }, + INSTALL_CLI_IN_PATH: { + area: 'IDE', + displayText: t`Install GDevelop CLI in PATH`, + noShortcut: true, + }, // Project manager commands OPEN_PROJECT_PROPERTIES: { diff --git a/newIDE/app/src/MainFrame/MainFrameCommands.js b/newIDE/app/src/MainFrame/MainFrameCommands.js index 2dd630a320..e49cac7421 100644 --- a/newIDE/app/src/MainFrame/MainFrameCommands.js +++ b/newIDE/app/src/MainFrame/MainFrameCommands.js @@ -72,6 +72,8 @@ type CommandHandlers = {| onOpenGlobalSearch: () => void, onOpenMemoryTrackerRegistry: () => void, onImportExtension: () => Promise, + canInstallCliInPath: boolean, + onInstallCliInPath: () => void | Promise, |}; const useMainFrameCommands = (handlers: CommandHandlers) => { @@ -193,6 +195,10 @@ const useMainFrameCommands = (handlers: CommandHandlers) => { handler: handlers.onOpenMemoryTrackerRegistry, }); + useCommand('INSTALL_CLI_IN_PATH', handlers.canInstallCliInPath, { + handler: handlers.onInstallCliInPath, + }); + useCommandWithOptions('OPEN_LAYOUT', !!handlers.project, { generateOptions: React.useCallback( () => diff --git a/newIDE/app/src/MainFrame/index.js b/newIDE/app/src/MainFrame/index.js index 69f42bd3a5..788788f3f9 100644 --- a/newIDE/app/src/MainFrame/index.js +++ b/newIDE/app/src/MainFrame/index.js @@ -152,6 +152,10 @@ import useForceUpdate from '../Utils/UseForceUpdate'; import useStateWithCallback from '../Utils/UseSetStateWithCallback'; import { useKeyboardShortcuts, useShortcutMap } from '../KeyboardShortcuts'; import useMainFrameCommands from './MainFrameCommands'; +import { + installCliInPath, + isCliInPathInstallSupported, +} from '../Utils/InstallCliInPath'; import { useImportExtension } from '../AssetStore/ExtensionStore/InstallExtension'; import CommandPalette, { type CommandPaletteInterface, @@ -5258,6 +5262,17 @@ const MainFrame = (props: Props): React.MixedElement => { onOpenGlobalSearch: openGlobalSearch, onOpenMemoryTrackerRegistry: () => setMemoryTrackedRegistryDialogOpen(true), onImportExtension, + canInstallCliInPath: isCliInPathInstallSupported(), + onInstallCliInPath: async () => { + const result = await installCliInPath(); + // Main-process message isn't localized but carries OS-specific nuance + // (e.g. "open a new terminal" on Windows) that a generic string would drop. + _showSnackMessage( + result.status === 'success' + ? result.message + : i18n._(t`Couldn't set up the GDevelop CLI: ${result.message}`) + ); + }, }); useCliCommandRunner({ diff --git a/newIDE/app/src/Utils/InstallCliInPath.js b/newIDE/app/src/Utils/InstallCliInPath.js new file mode 100644 index 0000000000..27d8950a4b --- /dev/null +++ b/newIDE/app/src/Utils/InstallCliInPath.js @@ -0,0 +1,25 @@ +// @flow +import optionalRequire from './OptionalRequire'; + +const electron = optionalRequire('electron'); +const ipcRenderer = electron ? electron.ipcRenderer : null; + +export type InstallCliInPathResult = {| + status: 'success' | 'error', + message: string, +|}; + +export const isCliInPathInstallSupported = (): boolean => !!ipcRenderer; + +export const installCliInPath = async (): Promise => { + // Guard on `ipcRenderer` directly (not the predicate) so Flow refines its + // type below; the calling command is only registered when supported anyway. + if (!ipcRenderer) { + return { + status: 'error', + message: + 'Installing the CLI in PATH is only available on the desktop app.', + }; + } + return ipcRenderer.invoke('install-cli-in-path'); +}; diff --git a/newIDE/electron-app/app/InstallCliInPath.js b/newIDE/electron-app/app/InstallCliInPath.js new file mode 100644 index 0000000000..0d9e516de7 --- /dev/null +++ b/newIDE/electron-app/app/InstallCliInPath.js @@ -0,0 +1,137 @@ +const fs = require('fs'); +const path = require('path'); +const child_process = require('child_process'); + +const CLI_SYMLINK_NAME = 'gdevelop'; +const UNIX_BIN_DIR = '/usr/local/bin'; + +const shellQuote = value => `'${value.replace(/'/g, `'\\''`)}'`; + +const installCliInPathOnWindows = appExecPath => { + const installDir = path.dirname(appExecPath); + const escapedDir = installDir.replace(/'/g, `''`); + // [Environment]::SetEnvironmentVariable rewrites Path as REG_SZ, corrupting REG_EXPAND_SZ + // entries like "%USERPROFILE%\bin"; write the raw registry value to keep its type, and + // broadcast WM_SETTINGCHANGE manually since a direct registry write doesn't. + const psCommand = [ + `$dir = '${escapedDir}'`, + `$envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)`, + `if (-not $envKey) { $envKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') }`, + `$path = [string]$envKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)`, + `$parts = @($path -split ';' | Where-Object { $_ -ne '' })`, + `if ($parts -notcontains $dir) { $envKey.SetValue('Path', (($parts + $dir) -join ';'), [Microsoft.Win32.RegistryValueKind]::ExpandString) }`, + `$envKey.Close()`, + `Add-Type -Namespace GDevelopCli -Name NativeMethods -MemberDefinition '[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);'`, + `$HWND_BROADCAST = [IntPtr]0xffff`, + `$WM_SETTINGCHANGE = 0x1A`, + `$SMTO_ABORTIFHUNG = 0x2`, + `$broadcastTimeoutMs = 5000`, + `$broadcastResult = [UIntPtr]::Zero`, + `[GDevelopCli.NativeMethods]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, 'Environment', $SMTO_ABORTIFHUNG, $broadcastTimeoutMs, [ref]$broadcastResult) | Out-Null`, + ].join('; '); + + return new Promise(resolve => { + child_process.execFile( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psCommand], + error => { + if (error) { + resolve({ + status: 'error', + message: `Failed to update PATH: ${error.message}`, + }); + return; + } + resolve({ + status: 'success', + message: `GDevelop added to PATH. Open a new terminal to use the CLI.`, + }); + } + ); + }); +}; + +// osascript reports a dismissed admin prompt as error -128 ("User canceled"). +// pkexec exits 126 (dialog dismissed) or 127 (not authorized) when the user declines. +const isElevationCancelled = error => + process.platform === 'darwin' + ? /-128|user canceled/i.test(error.message) + : error.code === 126 || error.code === 127; + +const elevateUnixSymlink = (appExecPath, symlinkPath) => { + const shellCommand = `mkdir -p ${shellQuote( + UNIX_BIN_DIR + )} && ln -sf ${shellQuote(appExecPath)} ${shellQuote(symlinkPath)}`; + const manualCommand = `sudo mkdir -p "${UNIX_BIN_DIR}" && sudo ln -sf "${appExecPath}" "${symlinkPath}"`; + + return new Promise(resolve => { + const onDone = error => { + if (error) { + if (isElevationCancelled(error)) { + resolve({ + status: 'error', + message: 'GDevelop CLI installation was cancelled.', + }); + return; + } + resolve({ + status: 'error', + message: `Could not create "${symlinkPath}". Run manually: ${manualCommand}`, + }); + return; + } + resolve({ + status: 'success', + message: `GDevelop CLI available as "${CLI_SYMLINK_NAME}".`, + }); + }; + + if (process.platform === 'darwin') { + const appleScript = `do shell script ${JSON.stringify( + shellCommand + )} with administrator privileges`; + child_process.execFile('osascript', ['-e', appleScript], onDone); + } else { + child_process.execFile('pkexec', ['sh', '-c', shellCommand], onDone); + } + }); +}; + +const installCliInPathOnUnix = appExecPath => { + const symlinkPath = path.join(UNIX_BIN_DIR, CLI_SYMLINK_NAME); + + return new Promise(resolve => { + try { + fs.mkdirSync(UNIX_BIN_DIR, { recursive: true }); + fs.rmSync(symlinkPath, { force: true }); + fs.symlinkSync(appExecPath, symlinkPath); + resolve({ + status: 'success', + message: `GDevelop CLI available as "${CLI_SYMLINK_NAME}".`, + }); + } catch (error) { + if (error.code === 'EACCES' || error.code === 'EPERM') { + // /usr/local/bin is not writable: retry with elevation. + elevateUnixSymlink(appExecPath, symlinkPath).then(resolve); + return; + } + resolve({ + status: 'error', + message: `Could not create "${symlinkPath}": ${error.message}`, + }); + } + }); +}; + +/** + * Register the GDevelop binary in the system PATH so the CLI works anywhere. + * @returns {Promise<{status: 'success' | 'error', message: string}>} + */ +const installCliInPath = appExecPath => { + if (process.platform === 'win32') { + return installCliInPathOnWindows(appExecPath); + } + return installCliInPathOnUnix(appExecPath); +}; + +module.exports = { installCliInPath }; diff --git a/newIDE/electron-app/app/main.js b/newIDE/electron-app/app/main.js index 632007c73c..469d8f384c 100644 --- a/newIDE/electron-app/app/main.js +++ b/newIDE/electron-app/app/main.js @@ -45,6 +45,7 @@ const { onLocalGDJSDevelopmentWatcherRuntimeUpdated, } = require('./LocalGDJSDevelopmentWatcher'); const { setupWatcher, disableWatcher } = require('./LocalFilesystemWatcher'); +const { installCliInPath } = require('./InstallCliInPath'); // Initialize `@electron/remote` module require('@electron/remote/main').initialize(); @@ -421,6 +422,12 @@ app.on('ready', function() { app.exit(typeof exitCode === 'number' ? exitCode : 0); }); + ipcMain.handle('install-cli-in-path', async () => { + // Inside an AppImage, process.execPath points into a transient mount that + // vanishes on quit; APPIMAGE is the stable file path (still breaks if moved). + return installCliInPath(process.env.APPIMAGE || process.execPath); + }); + ipcMain.on('set-main-menu', (event, mainMenuTemplate) => { const window = BrowserWindow.fromWebContents(event.sender); Menu.setApplicationMenu( diff --git a/newIDE/electron-app/build/installer.nsh b/newIDE/electron-app/build/installer.nsh new file mode 100644 index 0000000000..7e63444fd4 --- /dev/null +++ b/newIDE/electron-app/build/installer.nsh @@ -0,0 +1,65 @@ +; Auto-included by electron-builder (build/installer.nsh). +; Adds/removes the install dir in the current user's PATH so the GDevelop CLI +; works from any shell, without admin rights or a logoff. + +!macro customInstall + InitPluginsDir + Push $9 + ; $INSTDIR may contain an apostrophe (e.g. username "O'Brien") that would break a + ; PowerShell single-quoted string, so pass it via a data file instead of inlining it. + FileOpen $9 "$PLUGINSDIR\gd-install-dir.txt" w + FileWrite $9 "$INSTDIR" + FileClose $9 + FileOpen $9 "$PLUGINSDIR\gd-add-to-path.ps1" w + FileWrite $9 "$$dataFile = Join-Path $$PSScriptRoot 'gd-install-dir.txt'$\r$\n" + FileWrite $9 "$$dir = (Get-Content -LiteralPath $$dataFile -Raw).Trim()$\r$\n" + ; [Environment]::SetEnvironmentVariable rewrites Path as REG_SZ, corrupting REG_EXPAND_SZ + ; entries like "%USERPROFILE%\bin"; read/write the raw registry value to keep its type. + FileWrite $9 "$$envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $$true)$\r$\n" + FileWrite $9 "if (-not $$envKey) { $$envKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') }$\r$\n" + FileWrite $9 "$$path = [string]$$envKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)$\r$\n" + FileWrite $9 "$$parts = @($$path -split ';' | Where-Object { $$_ -ne '' })$\r$\n" + FileWrite $9 "if ($$parts -notcontains $$dir) { $$envKey.SetValue('Path', (($$parts + $$dir) -join ';'), [Microsoft.Win32.RegistryValueKind]::ExpandString) }$\r$\n" + FileWrite $9 "$$envKey.Close()$\r$\n" + ; A raw registry write doesn't notify running processes, so broadcast WM_SETTINGCHANGE. + FileWrite $9 "Add-Type -Namespace GDevelopCli -Name NativeMethods -MemberDefinition '[DllImport($\"user32.dll$\", SetLastError = true, CharSet = CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);'$\r$\n" + FileWrite $9 "$$HWND_BROADCAST = [IntPtr]0xffff$\r$\n" + FileWrite $9 "$$WM_SETTINGCHANGE = 0x1A$\r$\n" + FileWrite $9 "$$SMTO_ABORTIFHUNG = 0x2$\r$\n" + FileWrite $9 "$$broadcastTimeoutMs = 5000$\r$\n" + FileWrite $9 "$$broadcastResult = [UIntPtr]::Zero$\r$\n" + FileWrite $9 "[GDevelopCli.NativeMethods]::SendMessageTimeout($$HWND_BROADCAST, $$WM_SETTINGCHANGE, [UIntPtr]::Zero, 'Environment', $$SMTO_ABORTIFHUNG, $$broadcastTimeoutMs, [ref]$$broadcastResult) | Out-Null$\r$\n" + FileClose $9 + nsExec::Exec 'powershell -NoProfile -ExecutionPolicy Bypass -File "$PLUGINSDIR\gd-add-to-path.ps1"' + Pop $9 + Pop $9 +!macroend + +!macro customUnInstall + InitPluginsDir + Push $9 + FileOpen $9 "$PLUGINSDIR\gd-install-dir.txt" w + FileWrite $9 "$INSTDIR" + FileClose $9 + FileOpen $9 "$PLUGINSDIR\gd-remove-from-path.ps1" w + FileWrite $9 "$$dataFile = Join-Path $$PSScriptRoot 'gd-install-dir.txt'$\r$\n" + FileWrite $9 "$$dir = (Get-Content -LiteralPath $$dataFile -Raw).Trim()$\r$\n" + FileWrite $9 "$$envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $$true)$\r$\n" + FileWrite $9 "if ($$envKey) {$\r$\n" + FileWrite $9 " $$path = [string]$$envKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)$\r$\n" + FileWrite $9 " $$parts = @($$path -split ';' | Where-Object { $$_ -ne $$dir -and $$_ -ne '' })$\r$\n" + FileWrite $9 " $$envKey.SetValue('Path', ($$parts -join ';'), [Microsoft.Win32.RegistryValueKind]::ExpandString)$\r$\n" + FileWrite $9 " $$envKey.Close()$\r$\n" + FileWrite $9 "}$\r$\n" + FileWrite $9 "Add-Type -Namespace GDevelopCli -Name NativeMethods -MemberDefinition '[DllImport($\"user32.dll$\", SetLastError = true, CharSet = CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);'$\r$\n" + FileWrite $9 "$$HWND_BROADCAST = [IntPtr]0xffff$\r$\n" + FileWrite $9 "$$WM_SETTINGCHANGE = 0x1A$\r$\n" + FileWrite $9 "$$SMTO_ABORTIFHUNG = 0x2$\r$\n" + FileWrite $9 "$$broadcastTimeoutMs = 5000$\r$\n" + FileWrite $9 "$$broadcastResult = [UIntPtr]::Zero$\r$\n" + FileWrite $9 "[GDevelopCli.NativeMethods]::SendMessageTimeout($$HWND_BROADCAST, $$WM_SETTINGCHANGE, [UIntPtr]::Zero, 'Environment', $$SMTO_ABORTIFHUNG, $$broadcastTimeoutMs, [ref]$$broadcastResult) | Out-Null$\r$\n" + FileClose $9 + nsExec::Exec 'powershell -NoProfile -ExecutionPolicy Bypass -File "$PLUGINSDIR\gd-remove-from-path.ps1"' + Pop $9 + Pop $9 +!macroend