diff --git a/app/app-info.ts b/app/app-info.ts index 8c8fd1acbb3..b1317a989da 100644 --- a/app/app-info.ts +++ b/app/app-info.ts @@ -1,27 +1,12 @@ -import * as fs from 'fs' -import * as Path from 'path' - import { getSHA } from './git-info' import { getUpdatesURL, getChannel } from '../script/dist-info' import { version, productName } from './package.json' -const projectRoot = Path.dirname(__dirname) - const devClientId = '3a723b10ac5575cc5bb9' const devClientSecret = '22c34d87789a365981ed921352a7b9a8c3f69d54' const channel = getChannel() -export function getCLICommands() { - return ( - // eslint-disable-next-line no-sync - fs - .readdirSync(Path.resolve(projectRoot, 'app', 'src', 'cli', 'commands')) - .filter(name => name.endsWith('.ts')) - .map(name => name.replace(/\.ts$/, '')) - ) -} - const s = JSON.stringify export function getReplacements() { @@ -41,7 +26,6 @@ export function getReplacements() { __RELEASE_CHANNEL__: s(channel), __UPDATES_URL__: s(getUpdatesURL()), __SHA__: s(getSHA()), - __CLI_COMMANDS__: s(getCLICommands()), 'process.platform': s(process.platform), 'process.env.NODE_ENV': s(process.env.NODE_ENV || 'development'), 'process.env.TEST_ENV': s(process.env.TEST_ENV), diff --git a/app/package.json b/app/package.json index cfaa279b249..2a72f2f2585 100644 --- a/app/package.json +++ b/app/package.json @@ -44,6 +44,7 @@ "marked": "^4.0.10", "mem": "^4.3.0", "memoize-one": "^4.0.3", + "minimist": "^1.2.8", "mri": "^1.1.0", "p-limit": "^2.2.0", "p-memoize": "^7.1.1", diff --git a/app/src/cli/commands/clone.ts b/app/src/cli/commands/clone.ts deleted file mode 100644 index 012c0531b77..00000000000 --- a/app/src/cli/commands/clone.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as QueryString from 'querystring' -import { URL } from 'url' - -import { CommandError } from '../util' -import { openDesktop } from '../open-desktop' -import { ICommandModule, mriArgv } from '../load-commands' - -interface ICloneArgs extends mriArgv { - readonly branch?: string -} - -export const command: ICommandModule = { - command: 'clone ', - description: 'Clone a repository', - args: [ - { - name: 'url|slug', - required: true, - description: 'The URL or the GitHub owner/name alias to clone', - type: 'string', - }, - ], - options: { - branch: { - type: 'string', - aliases: ['b'], - description: 'The branch to checkout after cloning', - }, - }, - handler({ _: [cloneUrl], branch }: ICloneArgs) { - if (!cloneUrl) { - throw new CommandError('Clone URL must be specified') - } - try { - const _ = new URL(cloneUrl) - _.toString() // don’t mark as unused - } catch (e) { - // invalid URL, assume a GitHub repo - cloneUrl = `https://github.com/${cloneUrl}` - } - const url = `openRepo/${cloneUrl}?${QueryString.stringify({ - branch, - })}` - openDesktop(url) - }, -} diff --git a/app/src/cli/commands/help.ts b/app/src/cli/commands/help.ts deleted file mode 100644 index 5396000901b..00000000000 --- a/app/src/cli/commands/help.ts +++ /dev/null @@ -1,79 +0,0 @@ -import chalk from 'chalk' - -import { commands, ICommandModule, IOption } from '../load-commands' - -import { dasherizeOption, printTable } from '../util' - -export const command: ICommandModule = { - command: 'help [command]', - description: 'Show the help page for a command', - handler({ _: [command] }) { - if (command) { - printCommandHelp(command, commands[command]) - } else { - printHelp() - } - }, -} - -function printHelp() { - console.log(chalk.underline('Commands:')) - const table: string[][] = [] - for (const commandName of Object.keys(commands)) { - const command = commands[commandName] - table.push([chalk.bold(command.command), command.description]) - } - printTable(table) - console.log( - `\nRun ${chalk.bold( - `github help ${chalk.gray('')}` - )} for details about each command` - ) -} - -function printCommandHelp(name: string, command: ICommandModule) { - if (!command) { - console.log(`Unrecognized command: ${chalk.bold.red.underline(name)}`) - printHelp() - return - } - console.log(`${chalk.gray('github')} ${command.command}`) - if (command.aliases) { - for (const alias of command.aliases) { - console.log(chalk.gray(`github ${alias}`)) - } - } - console.log() - const [title, body] = command.description.split('\n', 1) - console.log(chalk.bold(title)) - if (body) { - console.log(body) - } - const { options, args } = command - if (options) { - console.log(chalk.underline('\nOptions:')) - printTable( - Object.keys(options) - .map(k => [k, options[k]] as [string, IOption]) - .map(([optionName, option]) => [ - [optionName, ...(option.aliases || [])] - .map(dasherizeOption) - .map(x => chalk.bold.blue(x)) - .join(chalk.gray(', ')), - option.description, - chalk.gray(`[${chalk.underline(option.type)}]`), - ]) - ) - } - if (args && args.length) { - console.log(chalk.underline('\nArguments:')) - printTable( - args.map(arg => [ - (arg.required ? chalk.bold : chalk).blue(arg.name), - arg.required ? chalk.gray('(required)') : '', - arg.description, - chalk.gray(`[${chalk.underline(arg.type)}]`), - ]) - ) - } -} diff --git a/app/src/cli/commands/open.ts b/app/src/cli/commands/open.ts deleted file mode 100644 index b5c58d19f68..00000000000 --- a/app/src/cli/commands/open.ts +++ /dev/null @@ -1,39 +0,0 @@ -import chalk from 'chalk' -import * as Path from 'path' - -import { ICommandModule, mriArgv } from '../load-commands' -import { openDesktop } from '../open-desktop' -import { parseRemote } from '../../lib/remote-parsing' - -export const command: ICommandModule = { - command: 'open ', - aliases: [''], - description: 'Open a git repository in GitHub Desktop', - args: [ - { - name: 'path', - description: 'The path to the repository to open', - type: 'string', - required: false, - }, - ], - handler({ _: [pathArg] }: mriArgv) { - if (!pathArg) { - // just open Desktop - openDesktop() - return - } - //Check if the pathArg is a remote url - if (parseRemote(pathArg) != null) { - console.log( - `\nYou cannot open a remote URL in GitHub Desktop\n` + - `Use \`${chalk.bold(`git clone ` + pathArg)}\`` + - ` instead to initiate the clone` - ) - } else { - const repositoryPath = Path.resolve(process.cwd(), pathArg) - const url = `openLocalRepo/${encodeURIComponent(repositoryPath)}` - openDesktop(url) - } - }, -} diff --git a/app/src/cli/dev-commands-global.ts b/app/src/cli/dev-commands-global.ts deleted file mode 100644 index 2199e5e0c45..00000000000 --- a/app/src/cli/dev-commands-global.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { getCLICommands } from '../../../app/app-info' - -const g: any = global -g.__CLI_COMMANDS__ = getCLICommands() diff --git a/app/src/cli/load-commands.ts b/app/src/cli/load-commands.ts deleted file mode 100644 index f988ef4d17e..00000000000 --- a/app/src/cli/load-commands.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Argv as mriArgv } from 'mri' - -import { TypeName } from './util' - -type StringArray = ReadonlyArray - -export type CommandHandler = (args: mriArgv, argv: StringArray) => void -export { mriArgv } - -export interface IOption { - readonly type: TypeName - readonly aliases?: StringArray - readonly description: string - readonly default?: any -} - -interface IArgument { - readonly name: string - readonly required: boolean - readonly description: string - readonly type: TypeName -} - -export interface ICommandModule { - name?: string - readonly command: string - readonly description: string - readonly handler: CommandHandler - readonly aliases?: StringArray - readonly options?: { [flag: string]: IOption } - readonly args?: ReadonlyArray - readonly unknownOptionHandler?: (flag: string) => void -} - -function loadModule(name: string): ICommandModule { - return require(`./commands/${name}.ts`).command -} - -interface ICommands { - [command: string]: ICommandModule -} -export const commands: ICommands = {} - -for (const fileName of __CLI_COMMANDS__) { - const mod = loadModule(fileName) - if (!mod.name) { - mod.name = fileName - } - commands[mod.name] = mod -} diff --git a/app/src/cli/main.ts b/app/src/cli/main.ts index c9525da9b0c..48ecd76622d 100644 --- a/app/src/cli/main.ts +++ b/app/src/cli/main.ts @@ -1,107 +1,65 @@ -import mri, { - DictionaryObject, - Options as MriOptions, - ArrayOrString, -} from 'mri' -import chalk from 'chalk' +import { join, resolve } from 'path' +import parse from 'minimist' +import { execFile, ExecFileException } from 'child_process' -import { dasherizeOption, CommandError } from './util' -import { commands } from './load-commands' -const defaultCommand = 'open' - -let args = process.argv.slice(2) -if (!args[0]) { - args[0] = '.' -} -const commandArg = args[0] -args = args.slice(1) - -const supportsCommand = (name: string) => Object.hasOwn(commands, name) - -;(function attemptRun(name: string) { - try { - if (supportsCommand(name)) { - runCommand(name) - } else if (name.startsWith('--')) { - attemptRun(name.slice(2)) - } else { - try { - args.unshift(commandArg) - runCommand(defaultCommand) - } catch (err) { - logError(err) - args = [] - runCommand('help') - } +const run = (...args: Array) => { + function cb(e: ExecFileException | null, stderr: string) { + if (e) { + console.error(`Error running command ${args}`) + console.error(stderr) + process.exit(e.code) } - } catch (err) { - logError(err) - args = [name] - runCommand('help') } -})(commandArg) -function logError(err: CommandError) { - console.log(chalk.bgBlack.red('ERR!'), err.message) - if (err.stack && !err.pretty) { - console.log(chalk.gray(err.stack)) + if (process.platform === 'darwin') { + execFile('open', ['-n', join(__dirname, '../../..'), '--args', ...args], cb) + } else if (process.platform === 'win32') { + const exeName = `GitHubDesktop${__DEV__ ? '-dev' : ''}.exe` + execFile(join(__dirname, `../../${exeName}`), args, cb) + } else { + throw new Error('Unsupported platform') } } -console.log() // nice blank line before the command prompt +const args = parse(process.argv.slice(2), { + alias: { help: 'h', branch: 'b' }, + boolean: ['help'], +}) -interface IMRIOpts extends MriOptions { - alias: DictionaryObject - boolean: Array - default: DictionaryObject - string: Array +const usage = (exitCode = 1): never => { + process.stderr.write( + 'GitHub Desktop CLI usage: \n' + + ' github Open the current directory\n' + + ' github open [path] Open the provided path\n' + + ' github clone [-b branch] Clone the repository by url or name/owner\n' + + ' (ex torvalds/linux), optionally checking out\n' + + ' the branch\n' + ) + process.exit(exitCode) } -function runCommand(name: string) { - const command = commands[name] - const opts: IMRIOpts = { - alias: {}, - boolean: [], - default: {}, - string: [], - } - if (command.options) { - for (const flag of Object.keys(command.options)) { - const flagOptions = command.options[flag] - if (flagOptions.aliases) { - opts.alias[flag] = flagOptions.aliases - } - if (Object.hasOwn(flagOptions, 'default')) { - opts.default[flag] = flagOptions.default - } - switch (flagOptions.type) { - case 'string': - opts.string.push(flag) - break - case 'boolean': - opts.boolean.push(flag) - break - } - } - opts.unknown = command.unknownOptionHandler - } - const parsedArgs = mri(args, opts) - if (command.options) { - for (const flag of Object.keys(parsedArgs)) { - if (!(flag in command.options)) { - continue - } +delete process.env.ELECTRON_RUN_AS_NODE - const value = parsedArgs[flag] - const expectedType = command.options[flag].type - if (typeof value !== expectedType) { - throw new CommandError( - `Value passed to flag ${dasherizeOption( - flag - )} was of type ${typeof value}, but was expected to be of type ${expectedType}` - ) - } - } +if (args.help || args._.at(0) === 'help') { + usage(0) +} else if (args._.at(0) === 'clone') { + const urlArg = args._.at(1) + // Assume name with owner slug if it looks like it + const url = + urlArg && /^[^\/]+\/[^\/]+$/.test(urlArg) + ? `https://github.com/${urlArg}` + : urlArg + + if (!url) { + usage(1) + } else if (typeof args.branch === 'string') { + run(`--cli-clone=${url}`, `--cli-branch=${args.branch}`) + } else { + run(`--cli-clone=${url}`) } - command.handler(parsedArgs, args) +} else { + const [firstArg, secondArg] = args._ + const pathArg = firstArg === 'open' ? secondArg : firstArg + const path = resolve(pathArg ?? '.') + run(`--cli-open=${path}`) } diff --git a/app/src/cli/open-desktop.ts b/app/src/cli/open-desktop.ts deleted file mode 100644 index 49091d188c0..00000000000 --- a/app/src/cli/open-desktop.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as ChildProcess from 'child_process' - -export function openDesktop(url: string = '') { - const env = { ...process.env } - // NB: We're gonna launch Desktop and we definitely don't want to carry over - // `ELECTRON_RUN_AS_NODE`. This seems to only happen on Windows. - delete env['ELECTRON_RUN_AS_NODE'] - - url = 'x-github-client://' + url - - if (__DARWIN__) { - return ChildProcess.spawn('open', [url], { env }) - } else if (__WIN32__) { - // https://github.com/nodejs/node/blob/b39dabefe6d/lib/child_process.js#L565-L577 - const shell = process.env.comspec || 'cmd.exe' - return ChildProcess.spawn(shell, ['/d', '/c', 'start', url], { env }) - } else if (__LINUX__) { - return ChildProcess.spawn('xdg-open', [url], { env }) - } else { - throw new Error( - `Desktop command line interface not currently supported on platform ${process.platform}` - ) - } -} diff --git a/app/src/cli/util.ts b/app/src/cli/util.ts deleted file mode 100644 index 5bfabe4289d..00000000000 --- a/app/src/cli/util.ts +++ /dev/null @@ -1,48 +0,0 @@ -import stripAnsi from 'strip-ansi' - -export type TypeName = - | 'string' - | 'number' - | 'boolean' - | 'symbol' - | 'undefined' - | 'object' - | 'function' - -export class CommandError extends Error { - public pretty = true -} - -export const dasherizeOption = (option: string) => { - if (option.length === 1) { - return '-' + option - } else { - return '--' + option - } -} - -export function printTable(table: string[][]) { - const columnWidths = calculateColumnWidths(table) - for (const row of table) { - let rowStr = ' ' - row.forEach((item, i) => { - rowStr += item - const neededSpaces = columnWidths[i] - stripAnsi(item).length - rowStr += ' '.repeat(neededSpaces + 2) - }) - console.log(rowStr) - } -} - -function calculateColumnWidths(table: string[][]) { - const columnWidths: number[] = Array(table[0].length).fill(0) - for (const row of table) { - row.forEach((item, i) => { - const width = stripAnsi(item).length - if (columnWidths[i] < width) { - columnWidths[i] = width - } - }) - } - return columnWidths -} diff --git a/app/src/lib/cli-action.ts b/app/src/lib/cli-action.ts new file mode 100644 index 00000000000..2e0072b421c --- /dev/null +++ b/app/src/lib/cli-action.ts @@ -0,0 +1,10 @@ +export type CLIAction = + | { + readonly kind: 'open-repository' + readonly path: string + } + | { + readonly kind: 'clone-url' + readonly url: string + readonly branch?: string + } diff --git a/app/src/lib/globals.d.ts b/app/src/lib/globals.d.ts index e1d4aa4a046..7c065f2ed85 100644 --- a/app/src/lib/globals.d.ts +++ b/app/src/lib/globals.d.ts @@ -44,8 +44,6 @@ declare const __RELEASE_CHANNEL__: | 'test' | 'development' -declare const __CLI_COMMANDS__: ReadonlyArray - /** The URL for Squirrel's updates. */ declare const __UPDATES_URL__: string diff --git a/app/src/lib/ipc-shared.ts b/app/src/lib/ipc-shared.ts index 8b6387b37e8..f6539023518 100644 --- a/app/src/lib/ipc-shared.ts +++ b/app/src/lib/ipc-shared.ts @@ -16,6 +16,7 @@ import { ThemeSource } from '../ui/lib/theme-source' import { DesktopNotificationPermission } from 'desktop-notifications/dist/notification-permission' import { NotificationCallback } from 'desktop-notifications/dist/notification-callback' import { DesktopAliveEvent } from './stores/alive-store' +import { CLIAction } from './cli-action' /** * Defines the simplex IPC channel names we use from the renderer @@ -57,6 +58,7 @@ export type RequestChannels = { 'app-menu': (menu: IMenu) => void 'launch-timing-stats': (stats: ILaunchStats) => void 'url-action': (action: URLActionType) => void + 'cli-action': (action: CLIAction) => void 'certificate-error': ( certificate: Electron.Certificate, error: string, diff --git a/app/src/lib/parse-app-url.ts b/app/src/lib/parse-app-url.ts index 50f115e8c2f..a81d2f418ef 100644 --- a/app/src/lib/parse-app-url.ts +++ b/app/src/lib/parse-app-url.ts @@ -23,13 +23,6 @@ export interface IOpenRepositoryFromURLAction { readonly filepath: string | null } -export interface IOpenRepositoryFromPathAction { - readonly name: 'open-repository-from-path' - - /** The local path to open. */ - readonly path: string -} - export interface IUnknownAction { readonly name: 'unknown' readonly url: string @@ -38,7 +31,6 @@ export interface IUnknownAction { export type URLActionType = | IOAuthAction | IOpenRepositoryFromURLAction - | IOpenRepositoryFromPathAction | IUnknownAction // eslint-disable-next-line @typescript-eslint/naming-convention @@ -132,12 +124,5 @@ export function parseAppURL(url: string): URLActionType { } } - if (actionName === 'openlocalrepo') { - return { - name: 'open-repository-from-path', - path: decodeURIComponent(parsedPath), - } - } - return unknown } diff --git a/app/src/main-process/app-window.ts b/app/src/main-process/app-window.ts index c5ee06f0560..0a2435f7f9e 100644 --- a/app/src/main-process/app-window.ts +++ b/app/src/main-process/app-window.ts @@ -28,6 +28,7 @@ import { } from './notifications' import { addTrustedIPCSender } from './trusted-ipc-sender' import { getUpdaterGUID } from '../lib/get-updater-guid' +import { CLIAction } from '../lib/cli-action' export class AppWindow { private window: Electron.BrowserWindow @@ -312,6 +313,13 @@ export class AppWindow { ipcWebContents.send(this.window.webContents, 'url-action', action) } + /** Send the URL action to the renderer. */ + public sendCLIAction(action: CLIAction) { + this.show() + + ipcWebContents.send(this.window.webContents, 'cli-action', action) + } + /** Send the app launch timing stats to the renderer. */ public sendLaunchTimingStats(stats: ILaunchStats) { ipcWebContents.send(this.window.webContents, 'launch-timing-stats', stats) diff --git a/app/src/main-process/main.ts b/app/src/main-process/main.ts index 070663f9961..81cc75ffd68 100644 --- a/app/src/main-process/main.ts +++ b/app/src/main-process/main.ts @@ -10,7 +10,6 @@ import { nativeTheme, } from 'electron' import * as Fs from 'fs' -import * as URL from 'url' import { AppWindow } from './app-window' import { buildDefaultMenu, getAllMenuItems } from './menu' @@ -50,6 +49,8 @@ import { showNotification, } from 'desktop-notifications' import { initializeDesktopNotifications } from './notifications' +import parseCommandLineArgs from 'minimist' +import { CLIAction } from '../lib/cli-action' app.setAppLogsPath() enableSourceMaps() @@ -139,22 +140,20 @@ process.on('uncaughtException', (error: Error) => { let handlingSquirrelEvent = false if (__WIN32__ && process.argv.length > 1) { const arg = process.argv[1] - const promise = handleSquirrelEvent(arg) + if (promise) { handlingSquirrelEvent = true promise - .catch(e => { - log.error(`Failed handling Squirrel event: ${arg}`, e) - }) - .then(() => { - app.quit() - }) - } else { - handlePossibleProtocolLauncherArgs(process.argv) + .catch(e => log.error(`Failed handling Squirrel event: ${arg}`, e)) + .then(() => app.quit()) } } +if (!handlingSquirrelEvent) { + handleCommandLineArguments(process.argv) +} + initializeDesktopNotifications() function handleAppURL(url: string) { @@ -190,7 +189,7 @@ if (!handlingSquirrelEvent) { mainWindow.focus() } - handlePossibleProtocolLauncherArgs(args) + handleCommandLineArguments(args) }) if (isDuplicateInstance) { @@ -229,53 +228,46 @@ if (__DARWIN__) { return } - handleAppURL( - `x-github-client://openLocalRepo/${encodeURIComponent(path)}` - ) + // Yeah this isn't technically a CLI action we use it here to indicate + // that it's more trusted than a URL action. + handleCLIAction({ kind: 'open-repository', path }) }) }) } -/** - * Attempt to detect and handle any protocol handler arguments passed - * either via the command line directly to the current process or through - * IPC from a duplicate instance (see makeSingleInstance) - * - * @param args Essentially process.argv, i.e. the first element is the exec - * path - */ -function handlePossibleProtocolLauncherArgs(args: ReadonlyArray) { - log.info(`Received possible protocol arguments: ${args.length}`) +async function handleCommandLineArguments(argv: string[]) { + const args = parseCommandLineArgs(argv) - if (__WIN32__) { - // Desktop registers it's protocol handler callback on Windows as - // `[executable path] --protocol-launcher "%1"`. Note that extra command - // line arguments might be added by Chromium - // (https://electronjs.org/docs/api/app#event-second-instance). - // At launch Desktop checks for that exact scenario here before doing any - // processing. If there's more than one matching url argument because of a - // malformed or untrusted url then we bail out. - - const matchingUrls = args.filter(arg => { - // sometimes `URL.parse` throws an error - try { - const url = URL.parse(arg) - // i think this `slice` is just removing a trailing `:` - return url.protocol && possibleProtocols.has(url.protocol.slice(0, -1)) - } catch (e) { - log.error(`Unable to parse argument as URL: ${arg}`) - return false - } - }) + // Desktop registers it's protocol handler callback on Windows as + // `[executable path] --protocol-launcher "%1"`. Note that extra command + // line arguments might be added by Chromium + // (https://electronjs.org/docs/api/app#event-second-instance). + if (__WIN32__ && typeof args['protocol-launcher'] === 'string') { + handleAppURL(args['protocol-launcher']) + return + } - if (args.includes(protocolLauncherArg) && matchingUrls.length === 1) { - handleAppURL(matchingUrls[0]) - } else { - log.error(`Malformed launch arguments received: ${args}`) - } - } else if (args.length > 1) { - handleAppURL(args[1]) + if (typeof args['cli-open'] === 'string') { + handleCLIAction({ kind: 'open-repository', path: args['cli-open'] }) + } else if (typeof args['cli-clone'] === 'string') { + handleCLIAction({ + kind: 'clone-url', + url: args['cli-clone'], + branch: + typeof args['cli-branch'] === 'string' ? args['cli-branch'] : undefined, + }) } + + return +} + +function handleCLIAction(action: CLIAction) { + onDidLoad(window => { + // This manual focus call _shouldn't_ be necessary, but is for Chrome on + // macOS. See https://github.com/desktop/desktop/issues/973. + window.focus() + window.sendCLIAction(action) + }) } /** diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 06d19509152..622cfce8967 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -33,6 +33,7 @@ import { getCommitsBetweenCommits, getBranches, getRebaseSnapshot, + getRepositoryType, } from '../../lib/git' import { isGitOnPath } from '../../lib/is-git-on-path' import { @@ -121,7 +122,8 @@ import { UnreachableCommitsTab } from '../history/unreachable-commits-dialog' import { sendNonFatalException } from '../../lib/helpers/non-fatal-exception' import { SignInResult } from '../../lib/stores/sign-in-store' import { ICustomIntegration } from '../../lib/custom-integration' -import { dirname, isAbsolute } from 'path' +import { isAbsolute } from 'path' +import { CLIAction } from '../../lib/cli-action' /** * An error handler function. @@ -1860,6 +1862,39 @@ export class Dispatcher { return repository } + public async dispatchCLIAction(action: CLIAction) { + if (action.kind === 'clone-url') { + const { branch, url } = action + + if (branch) { + await this.openBranchNameFromUrl(url, branch) + } else { + await this.openOrCloneRepository(url) + } + } else if (action.kind === 'open-repository') { + // user may accidentally provide a folder within the repository + // this ensures we use the repository root, if it is actually a repository + // otherwise we consider it an untracked repository + const path = await getRepositoryType(action.path) + .then(t => + t.kind === 'regular' ? t.topLevelWorkingDirectory : action.path + ) + .catch(e => { + log.error('Could not determine repository type', e) + return action.path + }) + + const { repositories } = this.appStore.getState() + const existingRepository = matchExistingRepository(repositories, path) + + if (existingRepository) { + await this.selectRepository(existingRepository) + } else { + await this.showPopup({ type: PopupType.AddRepository, path }) + } + } + } + public async dispatchURLAction(action: URLActionType): Promise { switch (action.name) { case 'oauth': @@ -1882,33 +1917,6 @@ export class Dispatcher { this.openRepositoryFromUrl(action) break - case 'open-repository-from-path': - // user may accidentally provide a folder within the repository - // this ensures we use the repository root, if it is actually a repository - // otherwise we consider it an untracked repository - const { repositories } = this.appStore.getState() - - let repo - let path = action.path - - while (!(repo = matchExistingRepository(repositories, path))) { - const parent = dirname(path) - if (parent === path) { - break - } - path = parent - } - - if (repo) { - await this.selectRepository(repo) - } else { - await this.showPopup({ - type: PopupType.AddRepository, - path: action.path, - }) - } - break - default: const unknownAction: IUnknownAction = action log.warn( diff --git a/app/src/ui/index.tsx b/app/src/ui/index.tsx index 6ab8603a545..ad32d7dc7d7 100644 --- a/app/src/ui/index.tsx +++ b/app/src/ui/index.tsx @@ -349,7 +349,15 @@ ipcRenderer.on('blur', () => { }) ipcRenderer.on('url-action', (_, action) => - dispatcher.dispatchURLAction(action) + dispatcher + .dispatchURLAction(action) + .catch(e => log.error(`URL action ${action.name} failed`, e)) +) + +ipcRenderer.on('cli-action', (_, action) => + dispatcher + .dispatchCLIAction(action) + .catch(e => log.error(`CLI action ${action.kind} failed`, e)) ) // react-virtualized will use the literal string "grid" as the 'aria-label' diff --git a/app/test/unit/parse-app-url-test.ts b/app/test/unit/parse-app-url-test.ts index 058f2020fa4..b297658002f 100644 --- a/app/test/unit/parse-app-url-test.ts +++ b/app/test/unit/parse-app-url-test.ts @@ -1,7 +1,6 @@ import { parseAppURL, IOpenRepositoryFromURLAction, - IOpenRepositoryFromPathAction, IOAuthAction, } from '../../src/lib/parse-app-url' @@ -155,27 +154,4 @@ describe('parseAppURL', () => { expect(openRepo.filepath).toBe('Octokit.Reactive/Octokit.Reactive.csproj') }) }) - - describe('openLocalRepo', () => { - it('parses local paths', () => { - const path = __WIN32__ - ? 'C:\\Users\\johnsmith\\repo' - : '/Users/johnsmith/repo' - const result = parseAppURL( - `x-github-client://openLocalRepo/${encodeURIComponent(path)}` - ) - expect(result.name).toBe('open-repository-from-path') - - const openRepo = result as IOpenRepositoryFromPathAction - expect(openRepo.path).toBe(path) - }) - - it('deals with not having a local path', () => { - let result = parseAppURL(`x-github-client://openLocalRepo/`) - expect(result.name).toBe('unknown') - - result = parseAppURL(`x-github-client://openLocalRepo`) - expect(result.name).toBe('unknown') - }) - }) }) diff --git a/app/yarn.lock b/app/yarn.lock index 4bf5a613a2a..4466401f16b 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -765,6 +765,11 @@ minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== +minimist@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + mkdirp-classic@^0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" diff --git a/docs/technical/placeholders.md b/docs/technical/placeholders.md index 2488269da2b..8cecb5f8f67 100644 --- a/docs/technical/placeholders.md +++ b/docs/technical/placeholders.md @@ -46,7 +46,6 @@ function getReplacements() { __RELEASE_CHANNEL__: s(channel), __UPDATES_URL__: s(distInfo.getUpdatesURL()), __SHA__: s(gitInfo.getSHA()), - __CLI_COMMANDS__: s(getCLICommands()), 'process.platform': s(process.platform), 'process.env.NODE_ENV': s(process.env.NODE_ENV || 'development'), 'process.env.TEST_ENV': s(process.env.TEST_ENV), diff --git a/package.json b/package.json index ab8bd621697..d59d46799e7 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ }, "description": "GitHub Desktop build dependencies", "scripts": { - "cli": "ts-node --require ./app/test/globals.ts --require ./app/src/cli/dev-commands-global.ts app/src/cli/main.ts", + "cli": "ts-node app/src/cli/main.ts", "check:eslint": "tsc -P eslint-rules/", "test:eslint": "jest eslint-rules/tests/*.test.js", "test:unit": "cross-env ELECTRON_RUN_AS_NODE=1 NODE_OPTIONS='--max_old_space_size=4096' ./node_modules/.bin/electron ./node_modules/jest/bin/jest --detectOpenHandles --silent --testLocationInResults --config ./app/jest.unit.config.js", @@ -122,6 +122,7 @@ "@types/lodash": "^4.14.178", "@types/marked": "^4.0.1", "@types/memoize-one": "^3.1.1", + "@types/minimist": "^1.2.5", "@types/mri": "^1.1.0", "@types/node": "22.7.4", "@types/parse-dds": "^1.0.3", diff --git a/yarn.lock b/yarn.lock index ef3f4c8ad81..fc4c985c68d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1338,6 +1338,11 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== +"@types/minimist@^1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e" + integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== + "@types/mri@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@types/mri/-/mri-1.1.0.tgz#66555e4d797713789ea0fefdae0898d8170bf5af" @@ -7557,7 +7562,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -7575,15 +7580,6 @@ string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" -string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -7698,7 +7694,7 @@ string.prototype.trimstart@^1.0.7: define-properties "^1.2.0" es-abstract "^1.22.1" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7712,13 +7708,6 @@ strip-ansi@^3.0.0: dependencies: ansi-regex "^2.0.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -8501,16 +8490,7 @@ worker-farm@^1.3.1: errno "^0.1.4" xtend "^4.0.1" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==