diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index a796bf0e..b7b99a57 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -29,7 +29,7 @@ Steps to reproduce the behavior: **Environment (please complete the following information):** - OS: - - Version: + - Version: - Node version: **Additional context** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db17c62a..ac798b80 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,10 +85,17 @@ This should open an Electron window with the application running. In development, all projects are created at `~/guppy-projects-dev` -You can build a macOS executable by running: +You can build an executable by running: ``` -yarn package +# MacOS +yarn package:mac + +# Windows +yarn package:win + +# Linux +yarn package:linux ``` The result will be in the `release-builds` folder. diff --git a/README.md b/README.md index 65b23a5a..36fe2944 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Guppy is a free-to-use desktop application designed to make it easier to get sta Guppy is made for beginners - folks who are just starting out with web development. We hope that it's powerful enough for advanced users as well, but we'll always prioritize the new-developer experience. We'll never charge money for Guppy, it'll always be free-to-use. -> **NOTE**: This is _super early pre-release alpha_. Truthfully it's probably not ready for beginner usage yet (there may be frustrating bugs, plus it only runs on MacOS). The goal is to build a community of folks to work on this and create something truly useful and wonderful for beginners. +> **NOTE**: This is _super early pre-release alpha_. Truthfully it's probably not ready for beginner usage yet (there may be some frustrating bugs). The goal is to build a community of folks to work on this and create something truly useful and wonderful for beginners. ### Current Status @@ -29,13 +29,11 @@ Also, important to note: this is a side-project worked on during spare time. We ### Installation -Right now, **Guppy only works for MacOS**. We hope to support Windows and Linux soon. - To use Guppy, you'll first need to have a modern version of Node (a Javascript runtime) installed. [Download Node](https://nodejs.org/en/download/current/). The "Current" version is recommended over LTS due to a bug in NPM 5.6.0 that can corrupt dependencies. -Once Node is installed, you can [download Guppy](https://github.com/joshwcomeau/guppy/releases/download/v0.0.1/Guppy-MacOS.zip) +Once Node is installed, you can [download Guppy](https://github.com/joshwcomeau/guppy/releases). -Double-click the downloaded executable to open Guppy. You may need to right-click and select "Open" if MacOS complains about the fact that this was downloaded from the internet. +Double-click the downloaded executable to open Guppy. Mac users may need to right-click and select "Open" if MacOS complains about the fact that this was downloaded from the internet. > Note: In future stable releases, I hope to remove the need to download Node by using the Node runtime that comes with Guppy (see [#44](https://github.com/joshwcomeau/guppy/issues/44)). I also plan to create a proper installer so that it's easy to copy Guppy to the Applications folder (see [#26](https://github.com/joshwcomeau/guppy/issues/26)). Contributions welcome! diff --git a/config/webpack.config.dev.js b/config/webpack.config.dev.js index db0c39ff..7b420294 100644 --- a/config/webpack.config.dev.js +++ b/config/webpack.config.dev.js @@ -14,7 +14,7 @@ const getClientEnvironment = require('./env'); const paths = require('./paths'); // List of packages not to bundle and just fall back to `require()` -const externals = ['ps-tree', 'electron-store']; +const externals = ['electron-store']; // Webpack uses `publicPath` to determine where the app is being served from. // In development, we always serve from the root. This makes config easier. @@ -356,7 +356,7 @@ module.exports = { return callback(null, 'commonjs ' + request); } callback(); - } + }, ], // Turn off performance hints during development because we don't do any // splitting or minification in interest of speed. These warnings become diff --git a/config/webpack.config.prod.js b/config/webpack.config.prod.js index 5d232c61..f40e20bc 100644 --- a/config/webpack.config.prod.js +++ b/config/webpack.config.prod.js @@ -16,7 +16,7 @@ const paths = require('./paths'); const getClientEnvironment = require('./env'); // List of packages not to bundle and just fall back to `require()` -const externals = ['ps-tree', 'electron-store']; +const externals = ['electron-store']; // Webpack uses `publicPath` to determine where the app is being served from. // It requires a trailing slash, or the file assets will get an incorrect path. @@ -482,6 +482,6 @@ module.exports = { return callback(null, 'commonjs ' + request); } callback(); - } + }, ], }; diff --git a/package.json b/package.json index 1eece747..6e8a70f0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "guppy", "productName": "Guppy", - "version": "0.1.0", + "version": "0.2.0", "private": true, "main": "src/main.js", "homepage": "./", @@ -11,16 +11,19 @@ }, "license": "ISC", "scripts": { - "start": "NODE_ENV=development npm run start:react", - "start:react": "BROWSER=none node scripts/start.js", + "start": "cross-env NODE_ENV=development npm run start:react", + "start:react": "cross-env BROWSER=none node scripts/start.js", "build": "node scripts/build.js", - "package:mac": "electron-packager . --platform=darwin --arch=x64 --icon=src/assets/icons/mac/logo.icns --prune=true --out=release-builds --overwrite", "package:linux": "electron-packager . --platform=linux --prune=true --out=release-builds --overwrite", - "package": "GENERATE_SOURCEMAP=false npm run build && npm run package:mac && npm run package:linux", + "package:mac": "electron-packager . --platform=darwin --arch=x64 --icon=src/assets/icons/mac/logo.icns --prune=true --out=release-builds --overwrite", + "package:win": "electron-packager . --platform=win32 --arch=x64 --icon=src/assets/icons/win/logo.ico --prune=true --out=release-builds --overwrite", + "dist:linux": "cross-env GENERATE_SOURCEMAP=false npm run build && npm run package:linux", + "dist:mac": "cross-env GENERATE_SOURCEMAP=false npm run build && npm run package:mac", + "dist:win": "cross-env GENERATE_SOURCEMAP=false npm run build && npm run package:win", "test": "node scripts/test.js --env=node", "flow": "flow", "prettier": "prettier --write", - "detect-port": "./node_modules/detect-port-alt/bin/detect-port 3000", + "detect-port": "cross-env ./node_modules/detect-port-alt/bin/detect-port 3000", "precommit": "yarn run flow && lint-staged" }, "lint-staged": { @@ -36,12 +39,13 @@ "electron-store": "2.0.0", "fix-path": "2.1.0", "gatsby-cli": "1.1.58", - "ps-tree": "1.1.0" + "ps-tree": "1.1.0", + "yarn": "1.9.2" }, "devDependencies": { "@babel/core": "7.0.0-beta.44", "@babel/runtime": "7.0.0-beta.44", - "ansi-to-html": "^0.6.4", + "ansi-to-html": "0.6.4", "async": "2.6.1", "autoprefixer": "7.2.5", "babel-core": "7.0.0-bridge.0", @@ -53,6 +57,7 @@ "case-sensitive-paths-webpack-plugin": "2.1.1", "chalk": "2.3.0", "color": "3.0.0", + "cross-env": "5.2.0", "css-loader": "0.28.9", "dotenv": "5.0.0", "dotenv-expand": "4.2.0", diff --git a/scripts/start.js b/scripts/start.js index ef959d86..2e62941a 100644 --- a/scripts/start.js +++ b/scripts/start.js @@ -14,8 +14,9 @@ process.on('unhandledRejection', err => { // Ensure environment variables are read. require('../config/env'); -const { exec } = require('child_process'); +const { spawn } = require('child_process'); const chalk = require('chalk'); +const path = require('path'); const webpack = require('webpack'); const WebpackDevServer = require('webpack-dev-server'); const clearConsole = require('react-dev-utils/clearConsole'); @@ -37,6 +38,8 @@ const isInteractive = process.stdout.isTTY; const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000; const HOST = process.env.HOST || '0.0.0.0'; +const IS_WIN = /^win/.test(process.platform); + /** * Flag to check whether Electron is * running already. @@ -47,26 +50,31 @@ let isElectronRunning = false; * Singleton-ish run of Electron * Prevents multiple re-runs of Electron App */ -function runElectronApp() { - if (isElectronRunning) - return; +function runElectronApp(port) { + if (isElectronRunning) return; isElectronRunning = true; + process.env['ELECTRON_START_URL'] = + process.env['ELECTRON_START_URL'] || `http://localhost:${port}`; + const electronCommand = IS_WIN ? 'electron.cmd' : 'electron'; - exec(`ELECTRON_START_URL=http://localhost:${DEFAULT_PORT} electron .`, - (err, stdout, stderr) => { - if (err) { - console.info(chalk.red('Electron app run failed: ') + stderr); - return; - } + const electronProcess = spawn(electronCommand, ['.']); + + electronProcess.stdout.on('data', data => { + // dont log blank output or empty newlines + const output = data.toString().trim(); + if (output.length) console.log(chalk.green('[ELECTRON]'), output); + }); + electronProcess.stderr.on('data', data => { + const output = data.toString(); + console.log(chalk.red(`[ELECTRON] ${output}`)); + }); - // Clear console for brevity - process.stdout.write('\x1bc'); + // close webpack server when electron quits + electronProcess.on('exit', code => process.exit(code)); - // Log output - console.info(stdout); - } - ); + // clear console for brevity + process.stdout.write('\x1bc'); } // Warn and crash if required files are missing @@ -137,8 +145,8 @@ checkBrowsers(paths.appPath) openBrowser(urls.localUrlForBrowser); }); - ['SIGINT', 'SIGTERM'].forEach(function (sig) { - process.on(sig, function () { + ['SIGINT', 'SIGTERM'].forEach(function(sig) { + process.on(sig, function() { devServer.close(); process.exit(); }); @@ -146,11 +154,12 @@ checkBrowsers(paths.appPath) /** * Hook runElectronApp() to 'done' (compile) event - * + * * Fails on error */ - compiler.plugin('done', - stats => !stats.hasErrors() && runElectronApp() + compiler.plugin( + 'done', + stats => !stats.hasErrors() && runElectronApp(port) ); }) .catch(err => { diff --git a/src/components/ApplicationMenu/ApplicationMenu.js b/src/components/ApplicationMenu/ApplicationMenu.js index c1638fc0..b33c4b6e 100644 --- a/src/components/ApplicationMenu/ApplicationMenu.js +++ b/src/components/ApplicationMenu/ApplicationMenu.js @@ -27,24 +27,29 @@ class ApplicationMenu extends Component { showImportExistingProjectPrompt, } = this.props; + const __DARWIN__ = process.platform === 'darwin'; const template = [ { - label: 'File', + label: __DARWIN__ ? 'File' : '&File', submenu: [ { - label: 'Create New Project', + label: __DARWIN__ + ? 'Create New Project...' + : 'Create &new project...', click: createNewProjectStart, accelerator: process.platform === 'darwin' ? 'Cmd+N' : 'Ctrl+N', }, { - label: 'Import Existing Project', + label: __DARWIN__ + ? 'Import Existing Project...' + : '&Import existing project...', click: showImportExistingProjectPrompt, accelerator: process.platform === 'darwin' ? 'Cmd+I' : 'Ctrl+I', }, ], }, { - label: 'Edit', + label: __DARWIN__ ? 'Edit' : '&Edit', submenu: [ { role: 'undo' }, { role: 'redo' }, @@ -53,28 +58,45 @@ class ApplicationMenu extends Component { { role: 'copy' }, { role: 'paste' }, { role: 'delete' }, - { role: 'selectall' }, + { + role: 'selectall', + label: __DARWIN__ ? 'Select All' : 'Select all', + }, ], }, { - label: 'View', + label: __DARWIN__ ? 'View' : '&View', submenu: [ { role: 'reload' }, - { role: 'forcereload' }, - { role: 'toggledevtools' }, + { + role: 'forcereload', + label: __DARWIN__ ? 'Force Reload' : 'Force reload', + }, + { + role: 'toggledevtools', + label: __DARWIN__ + ? 'Toggle Developer Tools' + : 'Toggle developer tools', + }, { type: 'separator' }, - { role: 'resetzoom' }, - { role: 'zoomin' }, - { role: 'zoomout' }, + { + role: 'resetzoom', + label: __DARWIN__ ? 'Actual Size' : 'Actual size', + }, + { role: 'zoomin', label: __DARWIN__ ? 'Zoom In' : 'Zoom in' }, + { role: 'zoomout', label: __DARWIN__ ? 'Zoom Out' : 'Zoom out' }, { type: 'separator' }, - { role: 'togglefullscreen' }, + { + role: 'togglefullscreen', + label: __DARWIN__ ? 'Toggle Full Screen' : 'Toggle full screen', + }, ], }, { - label: 'Help', + label: __DARWIN__ ? 'Help' : '&Help', submenu: [ { - label: 'Getting Started', + label: __DARWIN__ ? 'Getting Started' : 'Getting started', click: this.openGettingStartedDocs, }, ], diff --git a/src/components/CreateNewProjectWizard/BuildPane.js b/src/components/CreateNewProjectWizard/BuildPane.js index 3a411c9c..49cf3798 100644 --- a/src/components/CreateNewProjectWizard/BuildPane.js +++ b/src/components/CreateNewProjectWizard/BuildPane.js @@ -25,11 +25,11 @@ const BUILD_STEPS = { copy: 'Creating project directory', }, installingDependencies: { - copy: 'Installing dependencies.', + copy: 'Installing dependencies', additionalCopy: 'This step can take a while...', }, guppification: { - copy: 'Persisting project info.', + copy: 'Persisting project info', }, }; diff --git a/src/config/app.js b/src/config/app.js new file mode 100644 index 00000000..b1721229 --- /dev/null +++ b/src/config/app.js @@ -0,0 +1,4 @@ +// app-wide settings (no user changable settings here) +module.exports = { + PACKAGE_MANAGER: 'yarn', +}; diff --git a/src/main.js b/src/main.js index 1f23b65c..0821ac9e 100644 --- a/src/main.js +++ b/src/main.js @@ -6,9 +6,9 @@ const { app, BrowserWindow, ipcMain } = require('electron'); const path = require('path'); const url = require('url'); const childProcess = require('child_process'); +const killProcessId = require('./services/kill-process-id.service'); const fixPath = require('fix-path'); -const psTree = require('ps-tree'); // In production, we need to use `fixPath` to let Guppy use NPM. // For reasons unknown, the opposite is true in development; adding this breaks @@ -98,7 +98,8 @@ app.on('window-all-closed', function() { app.on('before-quit', ev => { if (processIds.length) { ev.preventDefault(); - killAllRunningProcesses().then(() => app.quit()); + killAllRunningProcesses(); + app.quit(); } }); @@ -118,37 +119,16 @@ ipcMain.on('removeProcessId', (event, processId) => { processIds = processIds.filter(id => id !== processId); }); -const killProcessId = doomedProcessId => { - childProcess.spawnSync('kill', ['-9', doomedProcessId]); - - // Remove the parent or any children PIDs from the list of tracked - // IDs, since they're killed now. - processIds = processIds.filter(id => id !== doomedProcessId); -}; - const killAllRunningProcesses = () => { - const processKillingPromises = processIds.map( - processId => - new Promise((resolve, reject) => { - killProcessId(processId); - - psTree(processId, (err, children) => { - if (err) { - return reject(err); - } - - if (!children || children.length === 0) { - return resolve(); - } + try { + processIds.forEach(processId => { + killProcessId(processId); - children.forEach(child => killProcessId(child.PID)); - - resolve(); - }); - }) - ); - - return Promise.all(processKillingPromises).catch(err => { + // Remove the parent or any children PIDs from the list of tracked + // IDs, since they're killed now. + processIds = processIds.filter(id => id !== processId); + }); + } catch (err) { console.error('Got error when trying to kill children', err); - }); + } }; diff --git a/src/middlewares/task.middleware.js b/src/middlewares/task.middleware.js index f4822103..37f72cd4 100644 --- a/src/middlewares/task.middleware.js +++ b/src/middlewares/task.middleware.js @@ -1,7 +1,7 @@ // @flow import { ipcRenderer } from 'electron'; import * as childProcess from 'child_process'; -import psTree from 'ps-tree'; +import * as path from 'path'; import { RUN_TASK, ABORT_TASK, @@ -16,6 +16,8 @@ import { getProjectById } from '../reducers/projects.reducer'; import { getPathForProjectId } from '../reducers/paths.reducer'; import { isDevServerTask } from '../reducers/tasks.reducer'; import findAvailablePort from '../services/find-available-port.service'; +import killProcessId from '../services/kill-process-id.service'; +import { isWin, PACKAGE_MANAGER_CMD } from '../services/platform.service'; import type { Task, ProjectType } from '../types'; @@ -36,37 +38,14 @@ export default (store: any) => (next: any) => (action: any) => { case LAUNCH_DEV_SERVER: { findAvailablePort() .then(port => { - const [instruction, ...args] = getDevServerCommand( - task, - project.type, - port - ); - - /** - * NOTE: A quirk in Electron means we can't use `env` to supply - * environment variables, as you would traditionally: - * - childProcess.spawn( - `npm`, - ['run', name], - { - cwd: projectPath, - env: { PORT: port }, - } - ); - * - * If I try to run this, I get a bunch of nonsensical errors about - * no commands (not even built-in ones like `ls`) existing. - * I added a comment here: - * https://github.com/electron/electron/issues/3627 - * - * As a workaround, I'm using "shell mode" to avoid having to - * specify environment variables: - */ - - const child = childProcess.spawn(instruction, args, { + const { args, env } = getDevServerCommand(task, project.type, port); + + const child = childProcess.spawn(PACKAGE_MANAGER_CMD, args, { cwd: projectPath, - shell: true, + env: { + ...getBaseProjectEnvironment(projectPath), + ...env, + }, }); // Now that we have a port/processId for the server, attach it to @@ -92,7 +71,10 @@ export default (store: any) => (next: any) => (action: any) => { }); child.on('exit', code => { - const wasSuccessful = code === 0 || code === null; + // For Windows Support + // Windows sends code 1 (I guess its because we foce kill??) + const successfulCode = isWin ? 1 : 0; + const wasSuccessful = code === successfulCode || code === null; const timestamp = new Date(); store.dispatch(completeTask(task, timestamp, wasSuccessful)); @@ -128,15 +110,17 @@ export default (store: any) => (next: any) => (action: any) => { // for now. const additionalArgs = []; if (project.type === 'create-react-app' && name === 'test') { - additionalArgs.push('--', '--coverage'); + additionalArgs.push('--coverage'); } + /* Bypasses 'Are you sure?' check when ejecting CRA + */ const child = childProcess.spawn( - 'npm', + PACKAGE_MANAGER_CMD, ['run', name, ...additionalArgs], { cwd: projectPath, - shell: true, + env: getBaseProjectEnvironment(projectPath), } ); @@ -187,41 +171,25 @@ export default (store: any) => (next: any) => (action: any) => { const { task } = action; const { processId, name } = task; - // Our child was spawned using `shell: true` to get around a quirk with - // electron not working when specifying environment variables the - // "correct" way (see comment above). - // - // Because of that, `child.pid` refers to the `sh` command that spawned - // the actual Node process, and so we need to use `psTree` to build a - // tree of descendent children and kill them that way. - psTree(processId, (err, children) => { - if (err) { - console.error('Could not gather process children:', err); - } - - const childrenPIDs = children.map(child => child.PID); - - childProcess.spawn('kill', ['-9', ...childrenPIDs]); - - ipcRenderer.send('removeProcessId', processId); + killProcessId(processId); + ipcRenderer.send('removeProcessId', processId); - // Once the children are killed, we should dispatch a notification - // so that the terminal shows something about this update. - // My initial thought was that all tasks would have the same message, - // but given that we're treating `start` as its own special thing, - // I'm realizing that it should vary depending on the task type. - // TODO: Find a better place for this to live. - const abortMessage = isDevServerTask(name) - ? 'Server stopped' - : 'Task aborted'; + // Once the task is killed, we should dispatch a notification + // so that the terminal shows something about this update. + // My initial thought was that all tasks would have the same message, + // but given that we're treating `start` as its own special thing, + // I'm realizing that it should vary depending on the task type. + // TODO: Find a better place for this to live. + const abortMessage = isDevServerTask(name) + ? 'Server stopped' + : 'Task aborted'; - next( - receiveDataFromTaskExecution( - task, - `\u001b[31;1m${abortMessage}\u001b[0m` - ) - ); - }); + next( + receiveDataFromTaskExecution( + task, + `\u001b[31;1m${abortMessage}\u001b[0m` + ) + ); break; } @@ -261,6 +229,17 @@ export default (store: any) => (next: any) => (action: any) => { return next(action); }; +const getBaseProjectEnvironment = (projectPath: string) => ({ + // Forward the host env, and append the + // project's .bin directory to PATH to allow + // package scripts to function properly. + ...window.process.env, + PATH: + window.process.env.PATH + + path.delimiter + + path.join(projectPath, 'node_modules', '.bin'), +}); + const getDevServerCommand = ( task: Task, projectType: ProjectType, @@ -268,9 +247,17 @@ const getDevServerCommand = ( ) => { switch (projectType) { case 'create-react-app': - return [`PORT=${port} npm`, 'run', task.name]; + return { + args: ['run', task.name], + env: { + PORT: port, + }, + }; case 'gatsby': - return ['npm', 'run', task.name, '--', `-p ${port}`]; + return { + args: ['run', task.name, '-p', port], + env: {}, + }; default: throw new Error('Unrecognized project type: ' + projectType); } diff --git a/src/reducers/dependencies.reducer.test.js b/src/reducers/dependencies.reducer.test.js index cfbb5d09..d09694c1 100644 --- a/src/reducers/dependencies.reducer.test.js +++ b/src/reducers/dependencies.reducer.test.js @@ -56,6 +56,7 @@ Object { "description": "", "homepage": "", "license": "", + "location": "dependencies", "name": "redux", "repository": Object { "type": "", diff --git a/src/reducers/paths.reducer.js b/src/reducers/paths.reducer.js index f7a37724..5accafe1 100644 --- a/src/reducers/paths.reducer.js +++ b/src/reducers/paths.reducer.js @@ -9,8 +9,10 @@ * to be tied to a specific project (the same project might exist at different * paths on different computers!). */ +import * as path from 'path'; import * as os from 'os'; import { ADD_PROJECT, IMPORT_EXISTING_PROJECT_FINISH } from '../actions'; +import { windowsHomeDir, isWin } from '../services/platform.service'; import type { Action } from 'redux'; @@ -41,12 +43,15 @@ export default (state: State = initialState, action: Action) => { // // // Helpers -export const getDefaultParentPath = () => +const homedir = isWin ? windowsHomeDir : os.homedir(); +export const getDefaultParentPath = () => { // Noticing some weird quirks when I try to use a dev project on the compiled // "production" app, so separating their home paths should help. - process.env.NODE_ENV === 'development' - ? `${os.homedir()}/guppy-projects-dev` - : `${os.homedir()}/guppy-projects`; + + return process.env.NODE_ENV === 'development' + ? path.join(homedir, '/guppy-projects-dev') + : path.join(homedir, '/guppy-projects'); +}; export const getDefaultPath = (projectId: string) => `${getDefaultParentPath()}/${projectId}`; diff --git a/src/reducers/tasks.reducer.js b/src/reducers/tasks.reducer.js index 232f27da..a2c0f258 100644 --- a/src/reducers/tasks.reducer.js +++ b/src/reducers/tasks.reducer.js @@ -150,6 +150,14 @@ export default (state: State = initialState, action: Action) => { case RECEIVE_DATA_FROM_TASK_EXECUTION: { const { task, text, isError, logId } = action; + if (task.name === 'eject' && !state[task.id]) { + // When ejecting a CRA project, the `eject` task is removed from the + // project, since it's a 1-time operation. + // TODO: We should avoid sending this action, we don't need to capture + // output for a deleted task + return state; + } + return produce(state, draftState => { draftState[task.id].logs.push({ id: logId, text }); diff --git a/src/reducers/tasks.reducer.test.js b/src/reducers/tasks.reducer.test.js index eab22e7f..52bee91a 100644 --- a/src/reducers/tasks.reducer.test.js +++ b/src/reducers/tasks.reducer.test.js @@ -8,6 +8,8 @@ import { import reducer, { getTaskDescription } from './tasks.reducer'; +jest.mock('electron'); + describe('Tasks reducer', () => { describe(REFRESH_PROJECTS, () => { test('captures task data from new projects', () => { diff --git a/src/services/__mocks__/electron.js b/src/services/__mocks__/electron.js new file mode 100644 index 00000000..46baef11 --- /dev/null +++ b/src/services/__mocks__/electron.js @@ -0,0 +1,13 @@ +import * as path from 'path'; +module.exports = { + remote: { + app: { + getAppPath: () => path.resolve(__dirname, '..', '..', '..'), + getPath: () => + process.env.APPDATA || + (process.platform == 'darwin' + ? process.env.HOME + 'Library/Preferences' + : '/var/local'), + }, + }, +}; diff --git a/src/services/create-project-service.test.js b/src/services/create-project-service.test.js index 4d8f8fd3..3de05127 100644 --- a/src/services/create-project-service.test.js +++ b/src/services/create-project-service.test.js @@ -1,4 +1,8 @@ -jest.mock('os', () => ({ homedir: jest.fn() })); +jest.mock('electron'); +jest.mock('os', () => ({ + homedir: jest.fn(), + platform: () => process.platform, +})); jest.mock('../reducers/paths.reducer.js', () => ({ getDefaultParentPath: jest.fn(), diff --git a/src/services/create-project.service.js b/src/services/create-project.service.js index 15d3af37..95f8642a 100644 --- a/src/services/create-project.service.js +++ b/src/services/create-project.service.js @@ -1,12 +1,15 @@ // @flow import slug from 'slug'; import random from 'random-seed'; -import * as fs from 'fs'; import * as childProcess from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; import { COLORS } from '../constants'; import { getDefaultParentPath } from '../reducers/paths.reducer'; +import { formatCommandForPlatform } from './platform.service'; + import { FAKE_CRA_PROJECT } from './create-project.fixtures'; import type { ProjectType } from '../types'; @@ -61,9 +64,11 @@ export default ( const id = slug(projectName).toLowerCase(); - const path = `${parentPath}/${id}`; + // For Windows Support + // To support cross platform with slashes and escapes + const projectPath = path.join(parentPath, id); - const [instruction, ...args] = getBuildInstructions(projectType, path); + const [instruction, ...args] = getBuildInstructions(projectType, projectPath); const process = childProcess.spawn(instruction, args); @@ -75,7 +80,7 @@ export default ( process.on('close', () => { onStatusUpdate('Dependencies installed'); - fs.readFile(`${path}/package.json`, 'utf8', (err, data) => { + fs.readFile(path.join(projectPath, 'package.json'), 'utf8', (err, data) => { if (err) { return console.error(err); } @@ -96,12 +101,16 @@ export default ( const prettyPrintedPackageJson = JSON.stringify(packageJson, null, 2); - fs.writeFile(`${path}/package.json`, prettyPrintedPackageJson, err => { - if (err) { - return console.error(err); + fs.writeFile( + path.join(projectPath, 'package.json'), + prettyPrintedPackageJson, + err => { + if (err) { + return console.error(err); + } + onComplete(packageJson); } - onComplete(packageJson); - }); + ); }); }); }; @@ -130,11 +139,15 @@ export const getBuildInstructions = ( projectType: ProjectType, path: string ) => { + // For Windows Support + // Windows tries to run command as a script rather than on a cmd + // To force it we add *.cmd to the commands + const command = formatCommandForPlatform('npx'); switch (projectType) { case 'create-react-app': - return ['npx', 'create-react-app', path]; + return [command, 'create-react-app', path]; case 'gatsby': - return ['npx', 'gatsby', 'new', path]; + return [command, 'gatsby', 'new', path]; default: throw new Error('Unrecognized project type: ' + projectType); } diff --git a/src/services/dependencies.service.js b/src/services/dependencies.service.js index 81ab512f..85275a1d 100644 --- a/src/services/dependencies.service.js +++ b/src/services/dependencies.service.js @@ -1,42 +1,34 @@ // @flow +import { PACKAGE_MANAGER_CMD } from './platform.service'; import * as childProcess from 'child_process'; +const spawnProcess = (cmd: string, cmdArgs: string[], projectPath: string) => + new Promise((resolve, reject) => { + const child = childProcess.spawn(cmd, cmdArgs, { + cwd: projectPath, + }); + child.on( + 'exit', + code => (code ? reject(child.stderr) : resolve(child.stdout)) + ); + // logger(child) // service will be used here later + }); + export const installDependency = ( projectPath: string, dependencyName: string, version: string -) => { - return new Promise((resolve, reject) => { - // TODO: yarn? - childProcess.exec( - `npm install ${dependencyName}@${version} -SE`, - { cwd: projectPath }, - (err, res) => { - err ? reject(err) : resolve(res); - } - ); - }); -}; +) => + spawnProcess( + PACKAGE_MANAGER_CMD, + ['add', `${dependencyName}@${version}`, '-SE'], + projectPath + ); export const uninstallDependency = ( projectPath: string, dependencyName: string -) => { - return new Promise((resolve, reject) => { - childProcess.exec( - `npm uninstall ${dependencyName}`, - { cwd: projectPath }, - (err, res) => { - err ? reject(err) : resolve(res); - } - ); - }); -}; +) => spawnProcess(PACKAGE_MANAGER_CMD, ['remove', dependencyName], projectPath); -export const reinstallDependencies = (projectPath: string) => { - return new Promise((resolve, reject) => { - childProcess.exec('npm install', { cwd: projectPath }, (err, res) => { - err ? reject(err) : resolve(res); - }); - }); -}; +export const reinstallDependencies = (projectPath: string) => + spawnProcess(PACKAGE_MANAGER_CMD, ['install'], projectPath); diff --git a/src/services/find-available-port.service.js b/src/services/find-available-port.service.js index 04d8dc0f..cb032959 100644 --- a/src/services/find-available-port.service.js +++ b/src/services/find-available-port.service.js @@ -1,24 +1,33 @@ /** * Find a clear port to run a server on. * - * NOTE: My initial approach to this problem was to copy create-react-app, - * and use the NPM package `detect-port-alt`. For some reason (maybe because - * this is electron, not a "pure" Node instance?), that module didn't work; it - * uses the Node module `net` to create a server, but the servers created - * always hang for me; no errors called, but no listeners called either. + * NOTE: Initially, we tried to copy create-react-app's approach, using the + * `detect-port-alt` NPM package. For some reason, maybe involving electron, + * that module didn't work; it would create a test server, but they'd hang; + * no errors called, but no listeners called either. * - * Instead I wrote up this quick approach that uses `lsof`. I have no idea how - * this'd work if we port this app to Windows :( but hopefully it won't be too - * hard of a problem! + * Instead, we're using platform-specific OS tools: + * - `lsof` on Mac/Linux + * - `netstat` on Windows */ import * as childProcess from 'child_process'; +import { isWin } from './platform.service'; const MAX_ATTEMPTS = 15; export default () => new Promise((resolve, reject) => { const checkPort = (port = 3000, attemptNum = 0) => { - childProcess.exec(`lsof -i :${port}`, (err, res) => { + // For Windows Support + // Similar command to lsof + // Finds if the specified port is in use + const command = isWin + ? `netstat -aon | find "${port}"` + : `lsof -i :${port}`; + const env = isWin && { + cwd: 'C:\\Windows\\System32', + }; + childProcess.exec(command, env, (err, res) => { // Ugh, childProcess assumes that no output means that there was an // error, and `lsof` emits nothing when the port is empty. So, // counterintuitively, an error is good news, and a response is bad. diff --git a/src/services/kill-process-id.service.js b/src/services/kill-process-id.service.js new file mode 100644 index 00000000..3a2dffd1 --- /dev/null +++ b/src/services/kill-process-id.service.js @@ -0,0 +1,38 @@ +/** + * NOTE: This service is used both by the Electron client + * and server, so it must only contain Node-compatible (read: non-ES6) + * code. If at some future time this requires some large amount + * of ES6 code, we can make a client-side wrapper. + */ +const childProcess = require('child_process'); +const os = require('os'); +const psTree = require('ps-tree'); + +const isWin = /^win/.test(os.platform()); + +// Kill the process with the given pid, as well as all +// its descendants down through the entire tree. +const killProcessId = doomedProcessId => { + if (isWin) { + // For Windows Support + // On Windows there is only one process so no need for psTree (see below) + // We use /f for focefully terminate process because it ask for confirmation + // We use /t to kill all child processes + // More info https://ss64.com/nt/taskkill.html + childProcess.spawn('taskkill', ['/pid', doomedProcessId, '/f', '/t']); + } else { + // Child node processes will persist after their parent's death + // if they are not killed first, so we need to use `psTree` to build + // a tree of children and kill them that way. + psTree(doomedProcessId, (err, children) => { + if (err) { + console.error('Could not gather process children:', err); + } + + const childrenPIDs = children.map(child => child.PID); + childProcess.spawn('kill', ['-9', doomedProcessId, ...childrenPIDs]); + }); + } +}; + +module.exports = killProcessId; diff --git a/src/services/platform.service.js b/src/services/platform.service.js new file mode 100644 index 00000000..211c30d6 --- /dev/null +++ b/src/services/platform.service.js @@ -0,0 +1,40 @@ +import * as childProcess from 'child_process'; +import * as os from 'os'; +import * as path from 'path'; +import { remote } from 'electron'; +import { PACKAGE_MANAGER } from '../config/app'; + +// Returns true if the OS is Windows +export const isWin = /^win/.test(os.platform()); + +// Returns path to the users Documents direactory +// For Windows Support +// Documents folder is much better place for project +// folders (Most programs use it as a default save location) +// Since there is a chance of being moved or users language +// might be different we are reading the value from Registry +// There might be a better solution but this seems ok so far +let winDocPath; +if (isWin) { + const winDocumentsRegRecord = childProcess.execSync( + 'REG QUERY "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders" /v Personal', + { + encoding: 'utf8', + } + ); + const winDocPathArray = winDocumentsRegRecord.split(' '); + winDocPath = winDocPathArray[winDocPathArray.length - 1] + .replace('%USERPROFILE%\\', '') + .replace(/\s/g, ''); +} +export const windowsHomeDir = isWin ? path.join(os.homedir(), winDocPath) : ''; + +// Returns formatted command for Windows +export const formatCommandForPlatform = (command: string): string => + isWin ? `${command}.cmd` : command; + +export const PACKAGE_MANAGER_CMD = path.join( + remote.app.getAppPath(), + './node_modules/yarn/bin', + formatCommandForPlatform(PACKAGE_MANAGER) +); diff --git a/src/services/read-from-disk.service.js b/src/services/read-from-disk.service.js index 645f8ca3..e5687e7c 100644 --- a/src/services/read-from-disk.service.js +++ b/src/services/read-from-disk.service.js @@ -13,15 +13,19 @@ const DEFAULT_PARENT_PATH = getDefaultParentPath(); /** * Load a project's package.json */ -export const loadPackageJson = (path: string) => { +export const loadPackageJson = (projectPath: string) => { return new Promise((resolve, reject) => { - return fs.readFile(`${path}/package.json`, 'utf8', (err, data) => { - if (err) { - return reject(err); - } + return fs.readFile( + path.join(projectPath, 'package.json'), + 'utf8', + (err, data) => { + if (err) { + return reject(err); + } - return resolve(JSON.parse(data)); - }); + return resolve(JSON.parse(data)); + } + ); }); }; @@ -33,7 +37,7 @@ export const writePackageJson = (projectPath: string, json: any) => { return new Promise((resolve, reject) => { fs.writeFile( - `${projectPath}/package.json`, + path.join(projectPath, 'package.json'), prettyPrintedPackageJson, err => { if (err) { @@ -147,8 +151,7 @@ export function loadProjectDependency( dependencyLocation: DependencyLocation = 'dependencies' ) { // prettier-ignore - const dependencyPath = - `${projectPath}/node_modules/${dependencyName}/package.json`; + const dependencyPath = path.join(projectPath, 'node_modules', dependencyName, 'package.json'); return new Promise((resolve, reject) => { fs.readFile(dependencyPath, 'utf8', (err, data) => { diff --git a/src/services/redux-persistence.service.js b/src/services/redux-persistence.service.js index eedd74ed..1f1e022d 100644 --- a/src/services/redux-persistence.service.js +++ b/src/services/redux-persistence.service.js @@ -8,7 +8,6 @@ const REDUX_STATE_KEY = // While debugging, it's helpful to be able to access the store. // This should only be used for debugging, don't write any code that uses this! window.electronStore = electronStore; - /** * updateElectronStore * When a non-null value is provided, updates the electronStore with the diff --git a/yarn.lock b/yarn.lock index d0064143..b2f13c16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -802,7 +802,7 @@ ansi-styles@^3.1.0, ansi-styles@^3.2.0, ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" -ansi-to-html@^0.6.4: +ansi-to-html@0.6.4: version "0.6.4" resolved "https://registry.yarnpkg.com/ansi-to-html/-/ansi-to-html-0.6.4.tgz#8b14ace87f8b3d25367d03cd5300d60be17cf9e0" dependencies: @@ -2207,6 +2207,13 @@ create-react-app@1.5.2: tmp "0.0.31" validate-npm-package-name "^3.0.0" +cross-env@5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.2.0.tgz#6ecd4c015d5773e614039ee529076669b9d126f2" + dependencies: + cross-spawn "^6.0.5" + is-windows "^1.0.0" + cross-spawn@5.1.0, cross-spawn@^5.0.1, cross-spawn@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" @@ -2222,6 +2229,16 @@ cross-spawn@^4.0.0: lru-cache "^4.0.1" which "^1.2.9" +cross-spawn@^6.0.5: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + cross-unzip@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/cross-unzip/-/cross-unzip-0.0.2.tgz#5183bc47a09559befcf98cc4657964999359372f" @@ -2786,14 +2803,14 @@ ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" -electron-debug@^2.0.0: +electron-debug@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/electron-debug/-/electron-debug-2.0.0.tgz#3059a6557acbfb091f138d83875f57bac80cea6d" dependencies: electron-is-dev "^0.3.0" electron-localshortcut "^3.0.0" -electron-devtools-installer@^2.2.4: +electron-devtools-installer@2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/electron-devtools-installer/-/electron-devtools-installer-2.2.4.tgz#261a50337e37121d338b966f07922eb4939a8763" dependencies: @@ -4921,7 +4938,7 @@ is-utf8@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" -is-windows@^1.0.1, is-windows@^1.0.2: +is-windows@^1.0.0, is-windows@^1.0.1, is-windows@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" @@ -6198,6 +6215,10 @@ next-tick@1: version "1.0.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" +nice-try@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.4.tgz#d93962f6c52f2c1558c0fbda6d512819f1efe1c4" + no-case@^2.2.0: version "2.3.2" resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac" @@ -6740,7 +6761,7 @@ path-is-inside@^1.0.1, path-is-inside@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" -path-key@^2.0.0: +path-key@^2.0.0, path-key@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" @@ -9782,6 +9803,10 @@ yargs@~3.10.0: decamelize "^1.0.0" window-size "0.1.0" +yarn@1.9.2: + version "1.9.2" + resolved "https://registry.yarnpkg.com/yarn/-/yarn-1.9.2.tgz#7666c6db0fed8dc090ae0dce11a8a28b1d82e391" + yauzl@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.4.1.tgz#9528f442dab1b2284e58b4379bb194e22e0c4005"