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.
This commit is contained in:
Gleb Volkov
2026-07-08 18:40:18 +02:00
committed by GitHub
parent be12ac5c78
commit 75cddd2645
8 changed files with 273 additions and 1 deletions
+11
View File
@@ -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).
@@ -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: {
@@ -72,6 +72,8 @@ type CommandHandlers = {|
onOpenGlobalSearch: () => void,
onOpenMemoryTrackerRegistry: () => void,
onImportExtension: () => Promise<void>,
canInstallCliInPath: boolean,
onInstallCliInPath: () => void | Promise<void>,
|};
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(
() =>
+15
View File
@@ -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({
+25
View File
@@ -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<InstallCliInPathResult> => {
// 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');
};
+137
View File
@@ -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 };
+7
View File
@@ -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(
+65
View File
@@ -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