diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 980040b0..39726003 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -39,6 +39,8 @@ jobs: - if: "!contains(matrix.os, 'windows')" name: Run tests 👩🏾‍💻 run: ./bin/npm test + # Q: Why are we using some random test section when the package.json has a test script? + # A: So that we ensure we use the bundled version of node to run our tests Skip: if: contains(github.event.head_commit.message, '[skip ci]') diff --git a/bin/apm b/bin/apm index f64f0420..66f78fea 100755 --- a/bin/apm +++ b/bin/apm @@ -31,7 +31,7 @@ binDir=`pwd -P` # Force npm to use its builtin node-gyp unset npm_config_node_gyp -cliPath="$binDir/../lib/cli.js" +cliPath="$binDir/../src/cli.js" if [[ $(uname -r) == *-Microsoft ]]; then cliPath="$(echo $cliPath | sed 's/\/mnt\/\([a-z]*\)\(.*\)/\1:\2/')" cliPath="${cliPath////\\}" diff --git a/bin/apm.cmd b/bin/apm.cmd index 6b677d13..4f747b9a 100644 --- a/bin/apm.cmd +++ b/bin/apm.cmd @@ -16,7 +16,7 @@ if not defined apm_git_path ( set npm_config_node_gyp= if exist "%~dp0\node.exe" ( - "%~dp0\node.exe" "%~dp0/../lib/cli.js" %* + "%~dp0\node.exe" "%~dp0/../src/cli.js" %* ) else ( - node.exe "%~dp0/../lib/cli.js" %* + node.exe "%~dp0/../src/cli.js" %* ) diff --git a/bin/ppm b/bin/ppm index f64f0420..66f78fea 100755 --- a/bin/ppm +++ b/bin/ppm @@ -31,7 +31,7 @@ binDir=`pwd -P` # Force npm to use its builtin node-gyp unset npm_config_node_gyp -cliPath="$binDir/../lib/cli.js" +cliPath="$binDir/../src/cli.js" if [[ $(uname -r) == *-Microsoft ]]; then cliPath="$(echo $cliPath | sed 's/\/mnt\/\([a-z]*\)\(.*\)/\1:\2/')" cliPath="${cliPath////\\}" diff --git a/bin/ppm.cmd b/bin/ppm.cmd index 6b677d13..4f747b9a 100644 --- a/bin/ppm.cmd +++ b/bin/ppm.cmd @@ -16,7 +16,7 @@ if not defined apm_git_path ( set npm_config_node_gyp= if exist "%~dp0\node.exe" ( - "%~dp0\node.exe" "%~dp0/../lib/cli.js" %* + "%~dp0\node.exe" "%~dp0/../src/cli.js" %* ) else ( - node.exe "%~dp0/../lib/cli.js" %* + node.exe "%~dp0/../src/cli.js" %* ) diff --git a/package.json b/package.json index 7b36154c..63531c2f 100644 --- a/package.json +++ b/package.json @@ -10,21 +10,16 @@ "bugs": { "url": "https://github.com/pulsar-edit/ppm/issues" }, - "main": "./lib/apm.js", + "main": "./src/apm.js", "bin": { "apm": "bin/apm" }, "scripts": { "check-version": "node script/check-version.js", "clean:bin": "shx rm -rf bin/node_darwin_x64 bin/node.exe bin/node", - "clean:lib": "shx rm -rf lib && shx mkdir -p lib", - "clean": "npm run clean:lib && npm run clean:bin", - "lint": "coffeelint src spec", - "coffee": "coffee --compile --output lib src", - "build": "npm run clean:lib && npm run coffee", - "prepare": "npm run build", + "clean": "npm run clean:bin", "postinstall": "node script/postinstall.js", - "test": "npm run check-version && npm run lint && jasmine-focused --captureExceptions --coffee spec" + "test": "npm run check-version && jasmine-focused --captureExceptions spec" }, "dependencies": { "@atom/plist": "0.4.4", diff --git a/spec/apm-cli.js b/spec/apm-cli.js index 499a92a5..b43c1c9f 100644 --- a/spec/apm-cli.js +++ b/spec/apm-cli.js @@ -1,7 +1,7 @@ const path = require('path'); const temp = require('temp'); const fs = require('fs'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); describe('apm command line interface', () => { beforeEach(() => { diff --git a/spec/ci-spec.js b/spec/ci-spec.js index 557b596e..3329401c 100644 --- a/spec/ci-spec.js +++ b/spec/ci-spec.js @@ -5,7 +5,7 @@ const temp = require('temp'); const express = require('express'); const wrench = require('wrench'); const CSON = require('season'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); const { nodeVersion } = JSON.parse(fs.readFileSync(path.join(__dirname,'config.json'))); describe('apm ci', () => { diff --git a/spec/clean-spec.js b/spec/clean-spec.js index 1e83da0a..ce3ce015 100644 --- a/spec/clean-spec.js +++ b/spec/clean-spec.js @@ -4,7 +4,7 @@ const temp = require('temp'); const express = require('express'); const http = require('http'); const wrench = require('wrench'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); const { nodeVersion } = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.json'))); describe('apm clean', () => { diff --git a/spec/command-spec.js b/spec/command-spec.js index ba55c7d7..0ef210b3 100644 --- a/spec/command-spec.js +++ b/spec/command-spec.js @@ -1,4 +1,4 @@ -const Command = require('../lib/command'); +const Command = require('../src/command'); describe('Command', () => { describe('::spawn', () => { diff --git a/spec/config-spec.js b/spec/config-spec.js index 03e64c0a..c6bccc26 100644 --- a/spec/config-spec.js +++ b/spec/config-spec.js @@ -1,7 +1,7 @@ const path = require('path'); const fs = require('fs-plus'); const temp = require('temp'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); describe('apm config', () => { let userConfigPath; diff --git a/spec/develop-spec.js b/spec/develop-spec.js index a2e1493a..63e85cd4 100644 --- a/spec/develop-spec.js +++ b/spec/develop-spec.js @@ -1,7 +1,7 @@ const path = require('path'); const fs = require('fs-plus'); const temp = require('temp'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); describe('apm develop', () => { let linkedRepoPath, repoPath; @@ -19,7 +19,7 @@ describe('apm develop', () => { describe("when the package doesn't have a published repository url", () => { it('logs an error', () => { - const Develop = require('../lib/develop'); + const Develop = require('../src/develop'); spyOn(Develop.prototype, 'getRepositoryUrl').andCallFake((packageName, callback) => { callback('Here is the error'); }); @@ -36,7 +36,7 @@ describe('apm develop', () => { describe("when the repository hasn't been cloned", () => { it('clones the repository to ATOM_REPOS_HOME and links it to ATOM_HOME/dev/packages', () => { - const Develop = require('../lib/develop'); + const Develop = require('../src/develop'); spyOn(Develop.prototype, 'getRepositoryUrl').andCallFake((packageName, callback) => { const repoUrl = path.join(__dirname, 'fixtures', 'repo.git'); callback(null, repoUrl); diff --git a/spec/disable-spec.js b/spec/disable-spec.js index 3eabe4d4..2bba9f8b 100644 --- a/spec/disable-spec.js +++ b/spec/disable-spec.js @@ -3,7 +3,7 @@ const wrench = require('wrench'); const path = require('path'); const temp = require('temp'); const CSON = require('season'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); describe('apm disable', () => { beforeEach(() => { diff --git a/spec/docs-spec.js b/spec/docs-spec.js index d4b87891..4212e1b4 100644 --- a/spec/docs-spec.js +++ b/spec/docs-spec.js @@ -1,8 +1,8 @@ const path = require('path'); const express = require('express'); const http = require('http'); -const apm = require('../lib/apm-cli'); -let Docs = require('../lib/docs'); +const apm = require('../src/apm-cli'); +let Docs = require('../src/docs'); describe('apm docs', () => { let server = null; @@ -75,7 +75,7 @@ describe('apm docs', () => { }); it('prints the package URL if called with the -p short option (and does not open it)', () => { - Docs = require('../lib/docs'); + Docs = require('../src/docs'); spyOn(Docs.prototype, 'openRepositoryUrl'); const callback = jasmine.createSpy('callback'); apm.run(['docs', '-p', 'wrap-guide'], callback); diff --git a/spec/enable-spec.js b/spec/enable-spec.js index 54cc43d1..3c6e4d0c 100644 --- a/spec/enable-spec.js +++ b/spec/enable-spec.js @@ -2,7 +2,7 @@ const fs = require('fs'); const path = require('path'); const temp = require('temp'); const CSON = require('season'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); describe('apm enable', () => { beforeEach(() => { @@ -15,7 +15,7 @@ describe('apm enable', () => { process.env.ATOM_HOME = atomHome; const callback = jasmine.createSpy('callback'); const configFilePath = path.join(atomHome, 'config.cson'); - + CSON.writeFileSync(configFilePath, { '*': { core: { @@ -23,19 +23,19 @@ describe('apm enable', () => { } } }); - + runs(() => { apm.run(['enable', 'vim-mode', 'not-installed', 'file-icons'], callback); }); - + waitsFor('waiting for enable to complete', () => callback.callCount > 0); - + runs(() => { expect(console.log).toHaveBeenCalled(); expect(console.log.argsForCall[0][0]).toMatch(/Not Disabled:\s*not-installed/); expect(console.log.argsForCall[1][0]).toMatch(/Enabled:\s*vim-mode/); const config = CSON.readFileSync(configFilePath); - + expect(config).toEqual({ '*': { core: { @@ -51,7 +51,7 @@ describe('apm enable', () => { process.env.ATOM_HOME = atomHome; const callback = jasmine.createSpy('callback'); const configFilePath = path.join(atomHome, 'config.cson'); - + CSON.writeFileSync(configFilePath, { '*': { core: { @@ -59,18 +59,18 @@ describe('apm enable', () => { } } }); - + runs(() => { apm.run(['enable', 'vim-mode'], callback); }); - + waitsFor('waiting for enable to complete', () => callback.callCount > 0); - + runs(() => { expect(console.log).toHaveBeenCalled(); expect(console.log.argsForCall[0][0]).toMatch(/Not Disabled:\s*vim-mode/); const config = CSON.readFileSync(configFilePath); - + expect(config).toEqual({ '*': { core: { @@ -85,13 +85,13 @@ describe('apm enable', () => { const atomHome = temp.mkdirSync('apm-home-dir-'); process.env.ATOM_HOME = atomHome; const callback = jasmine.createSpy('callback'); - + runs(() => { apm.run(['enable', 'vim-mode'], callback); }); - + waitsFor('waiting for enable to complete', () => callback.callCount > 0); - + runs(() => { expect(console.error).toHaveBeenCalled(); expect(console.error.argsForCall[0][0].length).toBeGreaterThan(0); @@ -102,7 +102,7 @@ describe('apm enable', () => { const atomHome = temp.mkdirSync('apm-home-dir-'); process.env.ATOM_HOME = atomHome; const callback = jasmine.createSpy('callback'); - + runs(() => { apm.run(['enable'], callback); }); diff --git a/spec/featured-spec.js b/spec/featured-spec.js index 22a537f3..3f7eb08b 100644 --- a/spec/featured-spec.js +++ b/spec/featured-spec.js @@ -1,7 +1,7 @@ const path = require('path'); const express = require('express'); const http = require('http'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); describe('apm featured', () => { let server = null; @@ -10,23 +10,23 @@ describe('apm featured', () => { silenceOutput(); spyOnToken(); const app = express(); - + app.get('/packages/featured', (request, response) => { response.sendFile(path.join(__dirname, 'fixtures', 'packages.json')); }); - + app.get('/themes/featured', (request, response) => { response.sendFile(path.join(__dirname, 'fixtures', 'themes.json')); }); - + server = http.createServer(app); let live = false; - + server.listen(3000, '127.0.0.1', () => { process.env.ATOM_API_URL = 'http://localhost:3000'; live = true; }); - + waitsFor(() => live); }); diff --git a/spec/help-spec.js b/spec/help-spec.js index c05d8cb9..b02c66d4 100644 --- a/spec/help-spec.js +++ b/spec/help-spec.js @@ -1,4 +1,4 @@ -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); describe('command help', () => { beforeEach(() => { diff --git a/spec/init-spec.js b/spec/init-spec.js index f45fbdf5..61966cae 100644 --- a/spec/init-spec.js +++ b/spec/init-spec.js @@ -1,8 +1,8 @@ const path = require('path'); const temp = require('temp'); const CSON = require('season'); -const apm = require('../lib/apm-cli'); -const fs = require('../lib/fs'); +const apm = require('../src/apm-cli'); +const fs = require('../src/fs'); describe('apm init', () => { let languagePath, packagePath, themePath; diff --git a/spec/install-spec.js b/spec/install-spec.js index 5d72cf9e..36d01a70 100644 --- a/spec/install-spec.js +++ b/spec/install-spec.js @@ -1,12 +1,12 @@ const path = require('path'); const CSON = require('season'); -const fs = require('../lib/fs'); +const fs = require('../src/fs'); const temp = require('temp'); const express = require('express'); const http = require('http'); const wrench = require('wrench'); -const apm = require('../lib/apm-cli'); -const Install = require('../lib/install'); +const apm = require('../src/apm-cli'); +const Install = require('../src/install'); const { nodeVersion } = JSON.parse(fs.readFileSync(path.join(__dirname,'config.json'))); describe('apm install', () => { @@ -15,7 +15,7 @@ describe('apm install', () => { beforeEach(() => { spyOnToken(); silenceOutput(); - + atomHome = temp.mkdirSync('apm-home-dir-'); process.env.ATOM_HOME = atomHome; diff --git a/spec/link-spec.js b/spec/link-spec.js index b0f5db78..9d399e36 100644 --- a/spec/link-spec.js +++ b/spec/link-spec.js @@ -1,7 +1,7 @@ const fs = require('fs'); const path = require('path'); const temp = require('temp'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); describe('apm link/unlink', () => { beforeEach(() => { diff --git a/spec/list-spec.js b/spec/list-spec.js index 35721478..e3bbb8cf 100644 --- a/spec/list-spec.js +++ b/spec/list-spec.js @@ -2,7 +2,7 @@ const path = require('path'); const fs = require('fs-plus'); const temp = require('temp'); const wrench = require('wrench'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); const CSON = require('season'); const listPackages = (args, doneCallback) => { diff --git a/spec/packages-spec.js b/spec/packages-spec.js index 17c54b76..c1f63516 100644 --- a/spec/packages-spec.js +++ b/spec/packages-spec.js @@ -1,4 +1,4 @@ -const Packages = require('../lib/packages'); +const Packages = require('../src/packages'); describe('getRemote', () => { it('returns origin if remote could not be determined', () => { diff --git a/spec/publish-spec.js b/spec/publish-spec.js index 52ed4ded..b2810148 100644 --- a/spec/publish-spec.js +++ b/spec/publish-spec.js @@ -3,7 +3,7 @@ const fs = require('fs-plus'); const temp = require('temp'); const express = require('express'); const http = require('http'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); describe('apm publish', () => { let server; diff --git a/spec/rebuild-spec.js b/spec/rebuild-spec.js index ef5524bf..8d49a1ab 100644 --- a/spec/rebuild-spec.js +++ b/spec/rebuild-spec.js @@ -2,7 +2,7 @@ const path = require('path'); const temp = require('temp'); const express = require('express'); const http = require('http'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); const fs = require('fs-plus'); const { nodeVersion } = JSON.parse(fs.readFileSync(path.join(__dirname,'config.json'))); diff --git a/spec/search-spec.js b/spec/search-spec.js index c858cc14..85f57f35 100644 --- a/spec/search-spec.js +++ b/spec/search-spec.js @@ -1,7 +1,7 @@ const path = require('path'); const express = require('express'); const http = require('http'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); describe('apm search', () => { let server; diff --git a/spec/spec-helper.js b/spec/spec-helper.js index 9fbb3269..3ae4cac9 100644 --- a/spec/spec-helper.js +++ b/spec/spec-helper.js @@ -1,4 +1,4 @@ -const auth = require('../lib/auth'); +const auth = require('../src/auth'); global.silenceOutput = (callThrough = false) => { spyOn(console, 'log'); diff --git a/spec/stars-spec.js b/spec/stars-spec.js index 14843b23..11b81b72 100644 --- a/spec/stars-spec.js +++ b/spec/stars-spec.js @@ -3,7 +3,7 @@ const express = require('express'); const fs = require('fs-plus'); const http = require('http'); const temp = require('temp'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); const { nodeVersion } = JSON.parse(fs.readFileSync(path.join(__dirname,'config.json'))); describe('apm stars', () => { diff --git a/spec/test-spec.js b/spec/test-spec.js index 310d3011..6a4ea894 100644 --- a/spec/test-spec.js +++ b/spec/test-spec.js @@ -1,7 +1,7 @@ const child_process = require('child_process'); const path = require('path'); const temp = require('temp'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); describe('apm test', () => { let specPath; diff --git a/spec/uninstall-spec.js b/spec/uninstall-spec.js index b70339d6..8ebb8957 100644 --- a/spec/uninstall-spec.js +++ b/spec/uninstall-spec.js @@ -1,7 +1,7 @@ const path = require('path'); const fs = require('fs-plus'); const temp = require('temp'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); const createPackage = (packageName, includeDev) => { let devPackagePath; diff --git a/spec/unpublish-spec.js b/spec/unpublish-spec.js index 4af14375..bfd42b2d 100644 --- a/spec/unpublish-spec.js +++ b/spec/unpublish-spec.js @@ -1,8 +1,8 @@ const express = require('express'); const http = require('http'); const temp = require('temp'); -const apm = require('../lib/apm-cli'); -const Unpublish = require('../lib/unpublish'); +const apm = require('../src/apm-cli'); +const Unpublish = require('../src/unpublish'); describe('apm unpublish', () => { let server, unpublishPackageCallback, unpublishVersionCallback; diff --git a/spec/upgrade-spec.js b/spec/upgrade-spec.js index fa491fcb..7b0b5b77 100644 --- a/spec/upgrade-spec.js +++ b/spec/upgrade-spec.js @@ -4,7 +4,7 @@ const temp = require('temp'); const express = require('express'); const http = require('http'); const wrench = require('wrench'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); const { nodeVersion } = JSON.parse(fs.readFileSync(path.join(__dirname,'config.json'))); diff --git a/spec/view-spec.js b/spec/view-spec.js index a58f661c..830eebe7 100644 --- a/spec/view-spec.js +++ b/spec/view-spec.js @@ -1,7 +1,7 @@ const path = require('path'); const express = require('express'); const http = require('http'); -const apm = require('../lib/apm-cli'); +const apm = require('../src/apm-cli'); describe('apm view', () => { let server = null; diff --git a/src/apm-cli.coffee b/src/apm-cli.coffee deleted file mode 100644 index b581ffa8..00000000 --- a/src/apm-cli.coffee +++ /dev/null @@ -1,219 +0,0 @@ -{spawn} = require 'child_process' -path = require 'path' - -_ = require 'underscore-plus' -colors = require 'colors' -npm = require 'npm' -yargs = require 'yargs' -wordwrap = require 'wordwrap' - -# Enable "require" scripts in asar archives -require 'asar-require' - -config = require './apm' -fs = require './fs' -git = require './git' - -setupTempDirectory = -> - temp = require 'temp' - tempDirectory = require('os').tmpdir() - # Resolve ~ in tmp dir atom/atom#2271 - tempDirectory = path.resolve(fs.absolute(tempDirectory)) - temp.dir = tempDirectory - try - fs.makeTreeSync(temp.dir) - temp.track() - -setupTempDirectory() - -commandClasses = [ - require './ci' - require './clean' - require './config' - require './dedupe' - require './develop' - require './disable' - require './docs' - require './enable' - require './featured' - require './init' - require './install' - require './links' - require './link' - require './list' - require './login' - require './publish' - require './rebuild' - require './rebuild-module-cache' - require './search' - require './star' - require './stars' - require './test' - require './uninstall' - require './unlink' - require './unpublish' - require './unstar' - require './upgrade' - require './view' -] - -commands = {} -for commandClass in commandClasses - for name in commandClass.commandNames ? [] - commands[name] = commandClass - -parseOptions = (args=[]) -> - options = yargs(args).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Pulsar Package Manager powered by https://pulsar-edit.dev - - Usage: pulsar --package - - where is one of: - #{wordwrap(4, 80)(Object.keys(commands).sort().join(', '))}. - - Run `pulsar --package help ` to see the more details about a specific command. - """ - options.alias('v', 'version').describe('version', 'Print the ppm version') - options.alias('h', 'help').describe('help', 'Print this usage message') - options.boolean('color').default('color', true).describe('color', 'Enable colored output') - options.command = options.argv._[0] - for arg, index in args when arg is options.command - options.commandArgs = args[index+1..] - break - options - -showHelp = (options) -> - return unless options? - - help = options.help() - if help.indexOf('Options:') >= 0 - help += "\n Prefix an option with `no-` to set it to false such as --no-color to disable" - help += "\n colored output." - - console.error(help) - -printVersions = (args, callback) -> - apmVersion = require('../package.json').version ? '' - npmVersion = require('npm/package.json').version ? '' - nodeVersion = process.versions.node ? '' - - getPythonVersion (pythonVersion) -> - git.getGitVersion (gitVersion) -> - getAtomVersion (atomVersion) -> - if args.json - versions = - apm: apmVersion - npm: npmVersion - node: nodeVersion - atom: atomVersion - python: pythonVersion - git: gitVersion - nodeArch: process.arch - if config.isWin32() - versions.visualStudio = config.getInstalledVisualStudioFlag() - console.log JSON.stringify(versions) - else - pythonVersion ?= '' - gitVersion ?= '' - atomVersion ?= '' - versions = """ - #{'apm'.red} #{apmVersion.red} - #{'npm'.green} #{npmVersion.green} - #{'node'.blue} #{nodeVersion.blue} #{process.arch.blue} - #{'atom'.cyan} #{atomVersion.cyan} - #{'python'.yellow} #{pythonVersion.yellow} - #{'git'.magenta} #{gitVersion.magenta} - """ - - if config.isWin32() - visualStudioVersion = config.getInstalledVisualStudioFlag() ? '' - versions += "\n#{'visual studio'.cyan} #{visualStudioVersion.cyan}" - - console.log versions - callback() - -getAtomVersion = (callback) -> - config.getResourcePath (resourcePath) -> - unknownVersion = 'unknown' - try - {version} = require(path.join(resourcePath, 'package.json')) ? unknownVersion - callback(version) - catch error - callback(unknownVersion) - -getPythonVersion = (callback) -> - npmOptions = - userconfig: config.getUserConfigPath() - globalconfig: config.getGlobalConfigPath() - npm.load npmOptions, -> - python = npm.config.get('python') ? process.env.PYTHON - if config.isWin32() and not python - rootDir = process.env.SystemDrive ? 'C:\\' - rootDir += '\\' unless rootDir[rootDir.length - 1] is '\\' - pythonExe = path.resolve(rootDir, 'Python27', 'python.exe') - python = pythonExe if fs.isFileSync(pythonExe) - - python ?= 'python' - - spawned = spawn(python, ['--version']) - outputChunks = [] - spawned.stderr.on 'data', (chunk) -> outputChunks.push(chunk) - spawned.stdout.on 'data', (chunk) -> outputChunks.push(chunk) - spawned.on 'error', -> - spawned.on 'close', (code) -> - if code is 0 - [name, version] = Buffer.concat(outputChunks).toString().split(' ') - version = version?.trim() - callback(version) - -module.exports = - run: (args, callback) -> - config.setupApmRcFile() - options = parseOptions(args) - - unless options.argv.color - colors.disable() - - callbackCalled = false - options.callback = (error) -> - return if callbackCalled - callbackCalled = true - if error? - if _.isString(error) - message = error - else - message = error.message ? error - - if message is 'canceled' - # A prompt was canceled so just log an empty line - console.log() - else if message - console.error(message.red) - callback?(error) - - args = options.argv - command = options.command - if args.version - printVersions(args, options.callback) - else if args.help - if Command = commands[options.command] - showHelp(new Command().parseOptions?(options.command)) - else - showHelp(options) - options.callback() - else if command - if command is 'help' - if Command = commands[options.commandArgs] - showHelp(new Command().parseOptions?(options.commandArgs)) - else - showHelp(options) - options.callback() - else if Command = commands[command] - new Command().run(options) - else - options.callback("Unrecognized command: #{command}") - else - showHelp(options) - options.callback() diff --git a/src/apm-cli.js b/src/apm-cli.js new file mode 100644 index 00000000..56739d31 --- /dev/null +++ b/src/apm-cli.js @@ -0,0 +1,260 @@ +const {spawn} = require('child_process'); +const path = require('path'); + +const _ = require('underscore-plus'); +const colors = require('colors'); +const npm = require('npm'); +const yargs = require('yargs'); +const wordwrap = require('wordwrap'); + +// Enable "require" scripts in asar archives +require('asar-require'); + +const config = require('./apm.js'); +const fs = require('./fs.js'); +const git = require('./git.js'); + +const setupTempDirectory = function() { + const temp = require('temp'); + let tempDirectory = require('os').tmpdir(); + // Resolve ~ in tmp dir atom/atom#2271 + tempDirectory = path.resolve(fs.absolute(tempDirectory)); + temp.dir = tempDirectory; + try { + fs.makeTreeSync(temp.dir); + } catch (error) {} + return temp.track(); +}; + +setupTempDirectory(); + +const commandClasses = [ + require('./ci.js'), + require('./clean.js'), + require('./config.js'), + require('./dedupe.js'), + require('./develop.js'), + require('./disable.js'), + require('./docs.js'), + require('./enable.js'), + require('./featured.js'), + require('./init.js'), + require('./install.js'), + require('./links.js'), + require('./link.js'), + require('./list.js'), + require('./login.js'), + require('./publish.js'), + require('./rebuild.js'), + require('./rebuild-module-cache.js'), + require('./search.js'), + require('./star.js'), + require('./stars.js'), + require('./test.js'), + require('./uninstall.js'), + require('./unlink.js'), + require('./unpublish.js'), + require('./unstar.js'), + require('./upgrade.js'), + require('./view.js') +]; + +const commands = {}; +for (let commandClass of commandClasses) { + for (let name of commandClass.commandNames != null ? commandClass.commandNames : []) { + commands[name] = commandClass; + } +} + +const parseOptions = function(args) { + if (args == null) { args = []; } + const options = yargs(args).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Pulsar Package Manager powered by https://pulsar-edit.dev + + Usage: pulsar --package + + where is one of: + ${wordwrap(4, 80)(Object.keys(commands).sort().join(', '))}. + + Run \`pulsar --package help \` to see the more details about a specific command.\ +` + ); + options.alias('v', 'version').describe('version', 'Print the ppm version'); + options.alias('h', 'help').describe('help', 'Print this usage message'); + options.boolean('color').default('color', true).describe('color', 'Enable colored output'); + options.command = options.argv._[0]; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === options.command) { + options.commandArgs = args.slice(index+1); + break; + } + } + return options; +}; + +const showHelp = function(options) { + if (options == null) { return; } + + let help = options.help(); + if (help.indexOf('Options:') >= 0) { + help += "\n Prefix an option with `no-` to set it to false such as --no-color to disable"; + help += "\n colored output."; + } + + return console.error(help); +}; + +const printVersions = function(args, callback) { + const apmVersion = require("../package.json").version ?? ""; + const npmVersion = require("npm/package.json").version ?? ""; + const nodeVersion = process.versions.node ?? ""; + + return getPythonVersion(pythonVersion => git.getGitVersion(gitVersion => getAtomVersion(function(atomVersion) { + let versions; + if (args.json) { + versions = { + apm: apmVersion, + npm: npmVersion, + node: nodeVersion, + atom: atomVersion, + python: pythonVersion, + git: gitVersion, + nodeArch: process.arch + }; + if (config.isWin32()) { + versions.visualStudio = config.getInstalledVisualStudioFlag(); + } + console.log(JSON.stringify(versions)); + } else { + if (pythonVersion == null) { pythonVersion = ''; } + if (gitVersion == null) { gitVersion = ''; } + if (atomVersion == null) { atomVersion = ''; } + versions = `\ +${'apm'.red} ${apmVersion.red} +${'npm'.green} ${npmVersion.green} +${'node'.blue} ${nodeVersion.blue} ${process.arch.blue} +${'atom'.cyan} ${atomVersion.cyan} +${'python'.yellow} ${pythonVersion.yellow} +${'git'.magenta} ${gitVersion.magenta}\ +`; + + if (config.isWin32()) { + const visualStudioVersion = config.getInstalledVisualStudioFlag() ?? ""; + versions += `\n${'visual studio'.cyan} ${visualStudioVersion.cyan}`; + } + + console.log(versions); + } + return callback(); + }))); +}; + +var getAtomVersion = callback => config.getResourcePath(function(resourcePath) { + const unknownVersion = 'unknown'; + try { + const { version } = require(path.join(resourcePath, "package.json")) ?? unknownVersion; + return callback(version); + } catch (error) { + return callback(unknownVersion); + } +}); + +var getPythonVersion = function(callback) { + const npmOptions = { + userconfig: config.getUserConfigPath(), + globalconfig: config.getGlobalConfigPath() + }; + return npm.load(npmOptions, function() { + let python = npm.config.get("python") ?? process.env.PYTHON; + if (config.isWin32() && !python) { + let rootDir = process.env.SystemDrive != null ? process.env.SystemDrive : 'C:\\'; + if (rootDir[rootDir.length - 1] !== '\\') { rootDir += '\\'; } + const pythonExe = path.resolve(rootDir, 'Python27', 'python.exe'); + if (fs.isFileSync(pythonExe)) { python = pythonExe; } + } + + if (python == null) { python = 'python'; } + + const spawned = spawn(python, ['--version']); + const outputChunks = []; + spawned.stderr.on('data', chunk => outputChunks.push(chunk)); + spawned.stdout.on('data', chunk => outputChunks.push(chunk)); + spawned.on('error', function() {}); + return spawned.on('close', function(code) { + let version, name; + if (code === 0) { + [name, version] = Buffer.concat(outputChunks).toString().split(' '); + version = version?.trim(); + } + return callback(version); + }); + }); +}; + +module.exports = { + run(args, callback) { + let Command; + config.setupApmRcFile(); + const options = parseOptions(args); + + if (!options.argv.color) { + colors.disable(); + } + + let callbackCalled = false; + options.callback = function(error) { + if (callbackCalled) { return; } + callbackCalled = true; + if (error != null) { + let message; + if (_.isString(error)) { + message = error; + } else { + message = error.message != null ? error.message : error; + } + + if (message === 'canceled') { + // A prompt was canceled so just log an empty line + console.log(); + } else if (message) { + console.error(message.red); + } + } + return callback?.(error); + }; + + args = options.argv; + const { + command + } = options; + if (args.version) { + return printVersions(args, options.callback); + } else if (args.help) { + if ((Command = commands[options.command])) { + showHelp(new Command().parseOptions?.(options.command)); + } else { + showHelp(options); + } + return options.callback(); + } else if (command) { + if (command === 'help') { + if ((Command = commands[options.commandArgs])) { + showHelp(new Command().parseOptions?.(options.commandArgs)); + } else { + showHelp(options); + } + return options.callback(); + } else if ((Command = commands[command])) { + return new Command().run(options); + } else { + return options.callback(`Unrecognized command: ${command}`); + } + } else { + showHelp(options); + return options.callback(); + } + } +}; diff --git a/src/apm.coffee b/src/apm.coffee deleted file mode 100644 index 920828f9..00000000 --- a/src/apm.coffee +++ /dev/null @@ -1,127 +0,0 @@ -child_process = require 'child_process' -fs = require './fs' -path = require 'path' -npm = require 'npm' -semver = require 'semver' -asarPath = null - -module.exports = - getHomeDirectory: -> - if process.platform is 'win32' then process.env.USERPROFILE else process.env.HOME - - getAtomDirectory: -> - process.env.ATOM_HOME ? path.join(@getHomeDirectory(), '.pulsar') - - getRustupHomeDirPath: -> - if process.env.RUSTUP_HOME - process.env.RUSTUP_HOME - else - path.join(@getHomeDirectory(), '.multirust') - - getCacheDirectory: -> - path.join(@getAtomDirectory(), '.apm') - - getResourcePath: (callback) -> - if process.env.ATOM_RESOURCE_PATH - return process.nextTick -> callback(process.env.ATOM_RESOURCE_PATH) - - if asarPath # already calculated - return process.nextTick -> callback(asarPath) - - apmFolder = path.resolve(__dirname, '..') - appFolder = path.dirname(apmFolder) - if path.basename(apmFolder) is 'ppm' and path.basename(appFolder) is 'app' - asarPath = "#{appFolder}.asar" - if fs.existsSync(asarPath) - return process.nextTick -> callback(asarPath) - - apmFolder = path.resolve(__dirname, '..', '..', '..') - appFolder = path.dirname(apmFolder) - if path.basename(apmFolder) is 'ppm' and path.basename(appFolder) is 'app' - asarPath = "#{appFolder}.asar" - if fs.existsSync(asarPath) - return process.nextTick -> callback(asarPath) - - switch process.platform - when 'darwin' - child_process.exec 'mdfind "kMDItemCFBundleIdentifier == \'dev.pulsar-edit.pulsar\'"', (error, stdout='', stderr) -> - [appLocation] = stdout.split('\n') unless error - appLocation = '/Applications/Pulsar.app' unless appLocation - asarPath = "#{appLocation}/Contents/Resources/app.asar" - return process.nextTick -> callback(asarPath) - when 'linux' - asarPath = '/opt/Pulsar/resources/app.asar' - return process.nextTick -> callback(asarPath) - when 'win32' - asarPath = "/Users/#{process.env.USERNAME}/AppData/Local/Programs/Pulsar/resources/app.asar" - unless fs.existsSync(asarPath) - asarPath = "/Program Files/Pulsar/resources/app.asar" - return process.nextTick -> callback(asarPath) - else - return process.nextTick -> callback('') - - getReposDirectory: -> - process.env.ATOM_REPOS_HOME ? path.join(@getHomeDirectory(), 'github') - - getElectronUrl: -> - process.env.ATOM_ELECTRON_URL ? 'https://artifacts.electronjs.org/headers/dist' - - getAtomPackagesUrl: -> - process.env.ATOM_PACKAGES_URL ? "#{@getAtomApiUrl()}/packages" - - getAtomApiUrl: -> - process.env.ATOM_API_URL ? 'https://api.pulsar-edit.dev/api' - - getElectronArch: -> - switch process.platform - when 'darwin' then 'x64' - else process.env.ATOM_ARCH ? process.arch - - getUserConfigPath: -> - path.resolve(@getAtomDirectory(), '.apmrc') - - getGlobalConfigPath: -> - path.resolve(@getAtomDirectory(), '.apm', '.apmrc') - - isWin32: -> - process.platform is 'win32' - - x86ProgramFilesDirectory: -> - process.env["ProgramFiles(x86)"] or process.env["ProgramFiles"] - - getInstalledVisualStudioFlag: -> - return null unless @isWin32() - - # Use the explictly-configured version when set - return process.env.GYP_MSVS_VERSION if process.env.GYP_MSVS_VERSION - - return '2019' if @visualStudioIsInstalled("2019") - return '2017' if @visualStudioIsInstalled("2017") - return '2015' if @visualStudioIsInstalled("14.0") - - visualStudioIsInstalled: (version) -> - if version < 2017 - fs.existsSync(path.join(@x86ProgramFilesDirectory(), "Microsoft Visual Studio #{version}", "Common7", "IDE")) - else - fs.existsSync(path.join(@x86ProgramFilesDirectory(), "Microsoft Visual Studio", "#{version}", "BuildTools", "Common7", "IDE")) or fs.existsSync(path.join(@x86ProgramFilesDirectory(), "Microsoft Visual Studio", "#{version}", "Community", "Common7", "IDE")) or fs.existsSync(path.join(@x86ProgramFilesDirectory(), "Microsoft Visual Studio", "#{version}", "Enterprise", "Common7", "IDE")) or fs.existsSync(path.join(@x86ProgramFilesDirectory(), "Microsoft Visual Studio", "#{version}", "Professional", "Common7", "IDE")) or fs.existsSync(path.join(@x86ProgramFilesDirectory(), "Microsoft Visual Studio", "#{version}", "WDExpress", "Common7", "IDE")) - - loadNpm: (callback) -> - npmOptions = - userconfig: @getUserConfigPath() - globalconfig: @getGlobalConfigPath() - npm.load npmOptions, -> callback(null, npm) - - getSetting: (key, callback) -> - @loadNpm -> callback(npm.config.get(key)) - - setupApmRcFile: -> - try - fs.writeFileSync @getGlobalConfigPath(), """ - ; This file is auto-generated and should not be edited since any - ; modifications will be lost the next time any apm command is run. - ; - ; You should instead edit your .apmrc config located in ~/.pulsar/.apmrc - cache = #{@getCacheDirectory()} - ; Hide progress-bar to prevent npm from altering apm console output. - progress = false - """ diff --git a/src/apm.js b/src/apm.js new file mode 100644 index 00000000..9408f9e2 --- /dev/null +++ b/src/apm.js @@ -0,0 +1,177 @@ +const child_process = require('child_process'); +const fs = require('./fs'); +const path = require('path'); +const npm = require('npm'); +const semver = require('semver'); +let asarPath = null; + +module.exports = { + getHomeDirectory() { + if (process.platform === 'win32') { return process.env.USERPROFILE; } else { return process.env.HOME; } + }, + + getAtomDirectory() { + return process.env.ATOM_HOME ?? path.join(this.getHomeDirectory(), ".pulsar"); + }, + + getRustupHomeDirPath() { + if (process.env.RUSTUP_HOME) { + return process.env.RUSTUP_HOME; + } else { + return path.join(this.getHomeDirectory(), '.multirust'); + } + }, + + getCacheDirectory() { + return path.join(this.getAtomDirectory(), '.apm'); + }, + + getResourcePath(callback) { + if (process.env.ATOM_RESOURCE_PATH) { + return process.nextTick(() => callback(process.env.ATOM_RESOURCE_PATH)); + } + + if (asarPath) { // already calculated + return process.nextTick(() => callback(asarPath)); + } + + let apmFolder = path.resolve(__dirname, '..'); + let appFolder = path.dirname(apmFolder); + if ((path.basename(apmFolder) === 'ppm') && (path.basename(appFolder) === 'app')) { + asarPath = `${appFolder}.asar`; + if (fs.existsSync(asarPath)) { + return process.nextTick(() => callback(asarPath)); + } + } + + apmFolder = path.resolve(__dirname, '..', '..', '..'); + appFolder = path.dirname(apmFolder); + if ((path.basename(apmFolder) === 'ppm') && (path.basename(appFolder) === 'app')) { + asarPath = `${appFolder}.asar`; + if (fs.existsSync(asarPath)) { + return process.nextTick(() => callback(asarPath)); + } + } + + switch (process.platform) { + case 'darwin': + return child_process.exec('mdfind "kMDItemCFBundleIdentifier == \'dev.pulsar-edit.pulsar\'"', function(error, stdout, stderr) { + let appLocation; + if (stdout == null) { stdout = ''; } + if (!error) { [appLocation] = stdout.split('\n'); } + if (!appLocation) { appLocation = '/Applications/Pulsar.app'; } + asarPath = `${appLocation}/Contents/Resources/app.asar`; + return process.nextTick(() => callback(asarPath)); + }); + case 'linux': + asarPath = '/opt/Pulsar/resources/app.asar'; + return process.nextTick(() => callback(asarPath)); + case 'win32': + asarPath = `/Users/${process.env.USERNAME}/AppData/Local/Programs/Pulsar/resources/app.asar`; + if (!fs.existsSync(asarPath)) { + asarPath = "/Program Files/Pulsar/resources/app.asar"; + } + return process.nextTick(() => callback(asarPath)); + default: + return process.nextTick(() => callback('')); + } + }, + + getReposDirectory() { + return process.env.ATOM_REPOS_HOME ?? path.join(this.getHomeDirectory(), "github"); + }, + + getElectronUrl() { + return process.env.ATOM_ELECTRON_URL ?? "https://artifacts.electronjs.org/headers/dist"; + }, + + getAtomPackagesUrl() { + return process.env.ATOM_PACKAGES_URL ?? `${this.getAtomApiUrl()}/packages`; + }, + + getAtomApiUrl() { + return process.env.ATOM_API_URL ?? "https://api.pulsar-edit.dev/api"; + }, + + getElectronArch() { + switch (process.platform) { + case 'darwin': + return 'x64'; + default: + return process.env.ATOM_ARCH ?? process.arch; + } + }, + + getUserConfigPath() { + return path.resolve(this.getAtomDirectory(), '.apmrc'); + }, + + getGlobalConfigPath() { + return path.resolve(this.getAtomDirectory(), '.apm', '.apmrc'); + }, + + isWin32() { + return process.platform === 'win32'; + }, + + x86ProgramFilesDirectory() { + return process.env["ProgramFiles(x86)"] || process.env["ProgramFiles"]; + }, + + getInstalledVisualStudioFlag() { + if (!this.isWin32()) { + return null; + } + + // Use the explictly-configured version when set + if (process.env.GYP_MSVS_VERSION) { + return process.env.GYP_MSVS_VERSION; + } + + if (this.visualStudioIsInstalled("2019")) { + return '2019'; + } + if (this.visualStudioIsInstalled("2017")) { + return '2017'; + } + if (this.visualStudioIsInstalled("14.0")) { + return '2015'; + } + }, + + visualStudioIsInstalled(version) { + if (version < 2017) { + return fs.existsSync(path.join(this.x86ProgramFilesDirectory(), `Microsoft Visual Studio ${version}`, "Common7", "IDE")); + } else { + // TODO Clean up this mess, https://github.com/pulsar-edit/pulsar/blob/master/packages/autocomplete-html/update/update.js#L216 + return fs.existsSync(path.join(this.x86ProgramFilesDirectory(), "Microsoft Visual Studio", `${version}`, "BuildTools", "Common7", "IDE")) || fs.existsSync(path.join(this.x86ProgramFilesDirectory(), "Microsoft Visual Studio", `${version}`, "Community", "Common7", "IDE")) || fs.existsSync(path.join(this.x86ProgramFilesDirectory(), "Microsoft Visual Studio", `${version}`, "Enterprise", "Common7", "IDE")) || fs.existsSync(path.join(this.x86ProgramFilesDirectory(), "Microsoft Visual Studio", `${version}`, "Professional", "Common7", "IDE")) || fs.existsSync(path.join(this.x86ProgramFilesDirectory(), "Microsoft Visual Studio", `${version}`, "WDExpress", "Common7", "IDE")); + } + }, + + loadNpm(callback) { + const npmOptions = { + userconfig: this.getUserConfigPath(), + globalconfig: this.getGlobalConfigPath() + }; + return npm.load(npmOptions, () => callback(null, npm)); + }, + + getSetting(key, callback) { + return this.loadNpm(() => callback(npm.config.get(key))); + }, + + setupApmRcFile() { + try { + return fs.writeFileSync(this.getGlobalConfigPath(), `\ +; This file is auto-generated and should not be edited since any +; modifications will be lost the next time any apm command is run. +; +; You should instead edit your .apmrc config located in ~/.pulsar/.apmrc +cache = ${this.getCacheDirectory()} +; Hide progress-bar to prevent npm from altering apm console output. +progress = false\ +` + ); + } catch (error) {} + } +}; diff --git a/src/auth.coffee b/src/auth.coffee deleted file mode 100644 index 14335d37..00000000 --- a/src/auth.coffee +++ /dev/null @@ -1,39 +0,0 @@ -try - keytar = require 'keytar' -catch error - # Gracefully handle keytar failing to load due to missing library on Linux - if process.platform is 'linux' - keytar = - findPassword: -> Promise.reject() - setPassword: -> Promise.reject() - else - throw error - -tokenName = 'pulsar-edit.dev Package API Token' - -module.exports = - # Get the package API token from the keychain. - # - # callback - A function to call with an error as the first argument and a - # string token as the second argument. - getToken: (callback) -> - keytar.findPassword(tokenName) - .then (token) -> - if token - callback(null, token) - else - Promise.reject() - .catch -> - if token = process.env.ATOM_ACCESS_TOKEN - callback(null, token) - else - callback """ - No package API token in keychain - Run `ppm login` or set the `ATOM_ACCESS_TOKEN` environment variable. - """ - - # Save the given token to the keychain. - # - # token - A string token to save. - saveToken: (token) -> - keytar.setPassword(tokenName, 'pulsar-edit.dev', token) diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 00000000..215fa92e --- /dev/null +++ b/src/auth.js @@ -0,0 +1,51 @@ + +let keytar; +try { + keytar = require('keytar'); +} catch (error) { + // Gracefully handle keytar failing to load due to missing library on Linux + if (process.platform === 'linux') { + keytar = { + findPassword() { return Promise.reject(); }, + setPassword() { return Promise.reject(); } + }; + } else { + throw error; + } +} + +const tokenName = 'pulsar-edit.dev Package API Token'; + +module.exports = { + // Get the package API token from the keychain. + // + // callback - A function to call with an error as the first argument and a + // string token as the second argument. + getToken(callback) { + keytar.findPassword(tokenName) + .then(function(token) { + if (token) { + return callback(null, token); + } else { + return Promise.reject(); + }}).catch(function() { + let token; + if ((token = process.env.ATOM_ACCESS_TOKEN)) { + return callback(null, token); + } else { + return callback(`\ +No package API token in keychain +Run \`ppm login\` or set the \`ATOM_ACCESS_TOKEN\` environment variable.\ +` + ); + } + }); + }, + + // Save the given token to the keychain. + // + // token - A string token to save. + saveToken(token) { + return keytar.setPassword(tokenName, 'pulsar-edit.dev', token); + } +}; diff --git a/src/ci.coffee b/src/ci.coffee deleted file mode 100644 index 59e7ebe5..00000000 --- a/src/ci.coffee +++ /dev/null @@ -1,73 +0,0 @@ -path = require 'path' -fs = require './fs' -yargs = require 'yargs' -async = require 'async' -_ = require 'underscore-plus' - -config = require './apm' -Command = require './command' - -module.exports = -class Ci extends Command - @commandNames: ['ci'] - - constructor: -> - super() - @atomDirectory = config.getAtomDirectory() - @atomNodeDirectory = path.join(@atomDirectory, '.node-gyp') - @atomNpmPath = require.resolve('npm/bin/npm-cli') - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - Usage: ppm ci - - Install a package with a clean slate. - - If you have an up-to-date package-lock.json file created by ppm install, - ppm ci will install its locked contents exactly. It is substantially - faster than ppm install and produces consistently reproduceable builds, - but cannot be used to install new packages or dependencies. - """ - - options.alias('h', 'help').describe('help', 'Print this usage message') - options.boolean('verbose').default('verbose', false).describe('verbose', 'Show verbose debug information') - - installModules: (options, callback) -> - process.stdout.write 'Installing locked modules' - if options.argv.verbose - process.stdout.write '\n' - else - process.stdout.write ' ' - - installArgs = [ - 'ci' - '--globalconfig', config.getGlobalConfigPath() - '--userconfig', config.getUserConfigPath() - @getNpmBuildFlags()... - ] - installArgs.push('--verbose') if options.argv.verbose - - fs.makeTreeSync(@atomDirectory) - - env = _.extend({}, process.env, {HOME: @atomNodeDirectory, RUSTUP_HOME: config.getRustupHomeDirPath()}) - @addBuildEnvVars(env) - - installOptions = {env, streaming: options.argv.verbose} - - @fork @atomNpmPath, installArgs, installOptions, (args...) => - @logCommandResults(callback, args...) - - run: (options) -> - {callback} = options - opts = @parseOptions(options.commandArgs) - - commands = [] - commands.push (callback) => config.loadNpm (error, @npm) => callback(error) - commands.push (cb) => @loadInstalledAtomMetadata(cb) - commands.push (cb) => @installModules(opts, cb) - - iteratee = (item, next) -> item(next) - async.mapSeries commands, iteratee, (err) -> - return callback(err) if err - callback(null) diff --git a/src/ci.js b/src/ci.js new file mode 100644 index 00000000..0f8165d4 --- /dev/null +++ b/src/ci.js @@ -0,0 +1,82 @@ + +const path = require('path'); +const fs = require('./fs'); +const yargs = require('yargs'); +const async = require('async'); +const _ = require('underscore-plus'); + +const config = require('./apm'); +const Command = require('./command'); + +module.exports = +class Ci extends Command { + static commandNames = ["ci"]; + + constructor() { + super(); + this.atomDirectory = config.getAtomDirectory(); + this.atomNodeDirectory = path.join(this.atomDirectory, '.node-gyp'); + this.atomNpmPath = require.resolve('npm/bin/npm-cli'); + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ +Usage: ppm ci + +Install a package with a clean slate. + +If you have an up-to-date package-lock.json file created by ppm install, +ppm ci will install its locked contents exactly. It is substantially +faster than ppm install and produces consistently reproduceable builds, +but cannot be used to install new packages or dependencies.\ +` +); + + options.alias('h', 'help').describe('help', 'Print this usage message'); + return options.boolean('verbose').default('verbose', false).describe('verbose', 'Show verbose debug information'); + } + + installModules(options, callback) { + process.stdout.write('Installing locked modules'); + if (options.argv.verbose) { + process.stdout.write('\n'); + } else { + process.stdout.write(' '); + } + + const installArgs = [ + 'ci', + '--globalconfig', config.getGlobalConfigPath(), + '--userconfig', config.getUserConfigPath(), + ...this.getNpmBuildFlags() + ]; + if (options.argv.verbose) { installArgs.push('--verbose'); } + + fs.makeTreeSync(this.atomDirectory); + + const env = _.extend({}, process.env, {HOME: this.atomNodeDirectory, RUSTUP_HOME: config.getRustupHomeDirPath()}); + this.addBuildEnvVars(env); + + const installOptions = {env, streaming: options.argv.verbose}; + + return this.fork(this.atomNpmPath, installArgs, installOptions, (...args) => { + return this.logCommandResults(callback, ...args); + }); + } + + run(options) { + const {callback} = options; + const opts = this.parseOptions(options.commandArgs); + + const commands = []; + commands.push(callback => { return config.loadNpm((error, npm) => { this.npm = npm; return callback(error); }); }); + commands.push(cb => this.loadInstalledAtomMetadata(cb)); + commands.push(cb => this.installModules(opts, cb)); + const iteratee = (item, next) => item(next); + return async.mapSeries(commands, iteratee, function(err) { + if (err) { return callback(err); } + return callback(null); + }); + } +}; diff --git a/src/clean.coffee b/src/clean.coffee deleted file mode 100644 index 261caca9..00000000 --- a/src/clean.coffee +++ /dev/null @@ -1,34 +0,0 @@ -path = require 'path' - -async = require 'async' -CSON = require 'season' -yargs = require 'yargs' -_ = require 'underscore-plus' - -Command = require './command' -config = require './apm' -fs = require './fs' - -module.exports = -class Clean extends Command - @commandNames: ['clean', 'prune'] - - constructor: -> - super() - @atomNpmPath = require.resolve('npm/bin/npm-cli') - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - - options.usage """ - Usage: ppm clean - - Deletes all packages in the node_modules folder that are not referenced - as a dependency in the package.json file. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - - run: (options) -> - process.stdout.write("Removing extraneous modules ") - @fork @atomNpmPath, ['prune'], (args...) => - @logCommandResults(options.callback, args...) diff --git a/src/clean.js b/src/clean.js new file mode 100644 index 00000000..844fc588 --- /dev/null +++ b/src/clean.js @@ -0,0 +1,42 @@ + +let Clean; +const path = require('path'); + +const async = require('async'); +const CSON = require('season'); +const yargs = require('yargs'); +const _ = require('underscore-plus'); + +const Command = require('./command'); +const config = require('./apm'); +const fs = require('./fs'); + +module.exports = +class Clean extends Command { + static commandNames = ["clean", "prune"]; + + constructor() { + super(); + this.atomNpmPath = require.resolve('npm/bin/npm-cli'); + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + + options.usage(`\ +Usage: ppm clean + +Deletes all packages in the node_modules folder that are not referenced +as a dependency in the package.json file.\ +` + ); + return options.alias('h', 'help').describe('help', 'Print this usage message'); + } + + run(options) { + process.stdout.write("Removing extraneous modules "); + return this.fork(this.atomNpmPath, ['prune'], (...args) => { + return this.logCommandResults(options.callback, ...args); + }); + } +}; diff --git a/src/cli.coffee b/src/cli.coffee deleted file mode 100644 index e56d9415..00000000 --- a/src/cli.coffee +++ /dev/null @@ -1,6 +0,0 @@ -apm = require './apm-cli' - -process.title = 'apm' - -apm.run process.argv.slice(2), (error) -> - process.exitCode = if error? then 1 else 0 diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 00000000..877e7c4a --- /dev/null +++ b/src/cli.js @@ -0,0 +1,6 @@ + +const apm = require('./apm-cli'); + +process.title = 'apm'; + +apm.run(process.argv.slice(2), error => process.exitCode = (error != null) ? 1 : 0); diff --git a/src/command.coffee b/src/command.coffee deleted file mode 100644 index e143cf3f..00000000 --- a/src/command.coffee +++ /dev/null @@ -1,154 +0,0 @@ -child_process = require 'child_process' -path = require 'path' -_ = require 'underscore-plus' -semver = require 'semver' -config = require './apm' -git = require './git' - -module.exports = -class Command - spawn: (command, args, remaining...) -> - options = remaining.shift() if remaining.length >= 2 - callback = remaining.shift() - - spawned = child_process.spawn(command, args, options) - - errorChunks = [] - outputChunks = [] - - spawned.stdout.on 'data', (chunk) -> - if options?.streaming - process.stdout.write chunk - else - outputChunks.push(chunk) - - spawned.stderr.on 'data', (chunk) -> - if options?.streaming - process.stderr.write chunk - else - errorChunks.push(chunk) - - onChildExit = (errorOrExitCode) -> - spawned.removeListener 'error', onChildExit - spawned.removeListener 'close', onChildExit - callback?(errorOrExitCode, Buffer.concat(errorChunks).toString(), Buffer.concat(outputChunks).toString()) - - spawned.on 'error', onChildExit - spawned.on 'close', onChildExit - - spawned - - fork: (script, args, remaining...) -> - args.unshift(script) - @spawn(process.execPath, args, remaining...) - - packageNamesFromArgv: (argv) -> - @sanitizePackageNames(argv._) - - sanitizePackageNames: (packageNames=[]) -> - packageNames = packageNames.map (packageName) -> packageName.trim() - _.compact(_.uniq(packageNames)) - - logSuccess: -> - if process.platform is 'win32' - process.stdout.write 'done\n'.green - else - process.stdout.write '\u2713\n'.green - - logFailure: -> - if process.platform is 'win32' - process.stdout.write 'failed\n'.red - else - process.stdout.write '\u2717\n'.red - - logCommandResults: (callback, code, stderr='', stdout='') => - if code is 0 - @logSuccess() - callback() - else - @logFailure() - callback("#{stdout}\n#{stderr}".trim()) - - logCommandResultsIfFail: (callback, code, stderr='', stdout='') => - if code is 0 - callback() - else - @logFailure() - callback("#{stdout}\n#{stderr}".trim()) - - normalizeVersion: (version) -> - if typeof version is 'string' - # Remove commit SHA suffix - version.replace(/-.*$/, '') - else - version - - loadInstalledAtomMetadata: (callback) -> - @getResourcePath (resourcePath) => - try - {version, electronVersion} = require(path.join(resourcePath, 'package.json')) ? {} - version = @normalizeVersion(version) - @installedAtomVersion = version if semver.valid(version) - - @electronVersion = process.env.ATOM_ELECTRON_VERSION ? electronVersion - unless @electronVersion? - throw new Error('Could not determine Electron version') - - callback() - - getResourcePath: (callback) -> - if @resourcePath - process.nextTick => callback(@resourcePath) - else - config.getResourcePath (@resourcePath) => callback(@resourcePath) - - addBuildEnvVars: (env) -> - @updateWindowsEnv(env) if config.isWin32() - @addNodeBinToEnv(env) - @addProxyToEnv(env) - env.npm_config_runtime = "electron" - env.npm_config_target = @electronVersion - env.npm_config_disturl = config.getElectronUrl() - env.npm_config_arch = config.getElectronArch() - env.npm_config_target_arch = config.getElectronArch() # for node-pre-gyp - env.npm_config_force_process_config = "true" # for node-gyp - # node-gyp >=8.4.0 needs --force-process-config=true set for older Electron. - # For more details, see: https://github.com/nodejs/node-gyp/pull/2497, - # and also see the issues/PRs linked from that one for more context. - - getNpmBuildFlags: -> - ["--target=#{@electronVersion}", "--disturl=#{config.getElectronUrl()}", "--arch=#{config.getElectronArch()}", "--force-process-config"] - - updateWindowsEnv: (env) -> - env.USERPROFILE = env.HOME - - git.addGitToEnv(env) - - addNodeBinToEnv: (env) -> - nodeBinFolder = path.resolve(__dirname, '..', 'bin') - pathKey = if config.isWin32() then 'Path' else 'PATH' - if env[pathKey] - env[pathKey] = "#{nodeBinFolder}#{path.delimiter}#{env[pathKey]}" - else - env[pathKey]= nodeBinFolder - - addProxyToEnv: (env) -> - httpProxy = @npm.config.get('proxy') - if httpProxy - env.HTTP_PROXY ?= httpProxy - env.http_proxy ?= httpProxy - - httpsProxy = @npm.config.get('https-proxy') - if httpsProxy - env.HTTPS_PROXY ?= httpsProxy - env.https_proxy ?= httpsProxy - - # node-gyp only checks HTTP_PROXY (as of node-gyp@4.0.0) - env.HTTP_PROXY ?= httpsProxy - env.http_proxy ?= httpsProxy - - # node-gyp doesn't currently have an option for this so just set the - # environment variable to bypass strict SSL - # https://github.com/nodejs/node-gyp/issues/448 - useStrictSsl = @npm.config.get('strict-ssl') ? true - env.NODE_TLS_REJECT_UNAUTHORIZED = 0 unless useStrictSsl diff --git a/src/command.js b/src/command.js new file mode 100644 index 00000000..4972bc5f --- /dev/null +++ b/src/command.js @@ -0,0 +1,202 @@ + +const child_process = require('child_process'); +const path = require('path'); +const _ = require('underscore-plus'); +const semver = require('semver'); +const config = require('./apm'); +const git = require('./git'); + +module.exports = +class Command { + constructor() { + this.logCommandResults = this.logCommandResults.bind(this); + this.logCommandResultsIfFail = this.logCommandResultsIfFail.bind(this); + } + + spawn(command, args, ...remaining) { + let options; + if (remaining.length >= 2) { options = remaining.shift(); } + const callback = remaining.shift(); + + const spawned = child_process.spawn(command, args, options); + + const errorChunks = []; + const outputChunks = []; + + spawned.stdout.on('data', function(chunk) { + if ((options != null ? options.streaming : undefined)) { + return process.stdout.write(chunk); + } else { + return outputChunks.push(chunk); + } + }); + + spawned.stderr.on('data', function(chunk) { + if ((options != null ? options.streaming : undefined)) { + return process.stderr.write(chunk); + } else { + return errorChunks.push(chunk); + } + }); + + const onChildExit = function(errorOrExitCode) { + spawned.removeListener('error', onChildExit); + spawned.removeListener('close', onChildExit); + return (typeof callback === 'function' ? callback(errorOrExitCode, Buffer.concat(errorChunks).toString(), Buffer.concat(outputChunks).toString()) : undefined); + }; + + spawned.on('error', onChildExit); + spawned.on('close', onChildExit); + + return spawned; + } + + fork(script, args, ...remaining) { + args.unshift(script); + return this.spawn(process.execPath, args, ...remaining); + } + + packageNamesFromArgv(argv) { + return this.sanitizePackageNames(argv._); + } + + sanitizePackageNames(packageNames) { + if (packageNames == null) { packageNames = []; } + packageNames = packageNames.map(packageName => packageName.trim()); + return _.compact(_.uniq(packageNames)); + } + + logSuccess() { + if (process.platform === 'win32') { + return process.stdout.write('done\n'.green); + } else { + return process.stdout.write('\u2713\n'.green); + } + } + + logFailure() { + if (process.platform === 'win32') { + return process.stdout.write('failed\n'.red); + } else { + return process.stdout.write('\u2717\n'.red); + } + } + + logCommandResults(callback, code, stderr, stdout) { + if (stderr == null) { stderr = ''; } + if (stdout == null) { stdout = ''; } + if (code === 0) { + this.logSuccess(); + return callback(); + } else { + this.logFailure(); + return callback(`${stdout}\n${stderr}`.trim()); + } + } + + logCommandResultsIfFail(callback, code, stderr, stdout) { + if (stderr == null) { stderr = ''; } + if (stdout == null) { stdout = ''; } + if (code === 0) { + return callback(); + } else { + this.logFailure(); + return callback(`${stdout}\n${stderr}`.trim()); + } + } + + normalizeVersion(version) { + if (typeof version === 'string') { + // Remove commit SHA suffix + return version.replace(/-.*$/, ''); + } else { + return version; + } + } + + loadInstalledAtomMetadata(callback) { + this.getResourcePath(resourcePath => { + let electronVersion; + try { + let version; + ({ version, electronVersion } = require(path.join(resourcePath, "package.json")) ?? {}); + version = this.normalizeVersion(version); + if (semver.valid(version)) { this.installedAtomVersion = version; } + } catch (error) {} + + this.electronVersion = process.env.ATOM_ELECTRON_VERSION ?? electronVersion; + if (this.electronVersion == null) { + throw new Error('Could not determine Electron version'); + } + + return callback(); + }); + } + + getResourcePath(callback) { + if (this.resourcePath) { + return process.nextTick(() => callback(this.resourcePath)); + } else { + return config.getResourcePath(resourcePath => { this.resourcePath = resourcePath; return callback(this.resourcePath); }); + } + } + + addBuildEnvVars(env) { + if (config.isWin32()) { this.updateWindowsEnv(env); } + this.addNodeBinToEnv(env); + this.addProxyToEnv(env); + env.npm_config_runtime = "electron"; + env.npm_config_target = this.electronVersion; + env.npm_config_disturl = config.getElectronUrl(); + env.npm_config_arch = config.getElectronArch(); + env.npm_config_target_arch = config.getElectronArch(); // for node-pre-gyp + env.npm_config_force_process_config = "true"; // for node-gyp + } + // node-gyp >=8.4.0 needs --force-process-config=true set for older Electron. + // For more details, see: https://github.com/nodejs/node-gyp/pull/2497, + // and also see the issues/PRs linked from that one for more context. + + getNpmBuildFlags() { + return [`--target=${this.electronVersion}`, `--disturl=${config.getElectronUrl()}`, `--arch=${config.getElectronArch()}`, "--force-process-config"]; + } + + updateWindowsEnv(env) { + env.USERPROFILE = env.HOME; + + return git.addGitToEnv(env); + } + + addNodeBinToEnv(env) { + const nodeBinFolder = path.resolve(__dirname, '..', 'bin'); + const pathKey = config.isWin32() ? 'Path' : 'PATH'; + if (env[pathKey]) { + return env[pathKey] = `${nodeBinFolder}${path.delimiter}${env[pathKey]}`; + } else { + return env[pathKey]= nodeBinFolder; + } + } + + addProxyToEnv(env) { + const httpProxy = this.npm.config.get('proxy'); + if (httpProxy) { + if (env.HTTP_PROXY == null) { env.HTTP_PROXY = httpProxy; } + if (env.http_proxy == null) { env.http_proxy = httpProxy; } + } + + const httpsProxy = this.npm.config.get('https-proxy'); + if (httpsProxy) { + if (env.HTTPS_PROXY == null) { env.HTTPS_PROXY = httpsProxy; } + if (env.https_proxy == null) { env.https_proxy = httpsProxy; } + + // node-gyp only checks HTTP_PROXY (as of node-gyp@4.0.0) + if (env.HTTP_PROXY == null) { env.HTTP_PROXY = httpsProxy; } + if (env.http_proxy == null) { env.http_proxy = httpsProxy; } + } + + // node-gyp doesn't currently have an option for this so just set the + // environment variable to bypass strict SSL + // https://github.com/nodejs/node-gyp/issues/448 + const useStrictSsl = this.npm.config.get("strict-ssl") ?? true; + if (!useStrictSsl) { return env.NODE_TLS_REJECT_UNAUTHORIZED = 0; } + } +}; diff --git a/src/config.coffee b/src/config.coffee deleted file mode 100644 index fbd5fa37..00000000 --- a/src/config.coffee +++ /dev/null @@ -1,46 +0,0 @@ -path = require 'path' -_ = require 'underscore-plus' -yargs = require 'yargs' -apm = require './apm' -Command = require './command' - -module.exports = -class Config extends Command - @commandNames: ['config'] - - constructor: -> - super() - atomDirectory = apm.getAtomDirectory() - @atomNodeDirectory = path.join(atomDirectory, '.node-gyp') - @atomNpmPath = require.resolve('npm/bin/npm-cli') - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm config set - ppm config get - ppm config delete - ppm config list - ppm config edit - - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - - configArgs = ['--globalconfig', apm.getGlobalConfigPath(), '--userconfig', apm.getUserConfigPath(), 'config'] - configArgs = configArgs.concat(options.argv._) - - env = _.extend({}, process.env, {HOME: @atomNodeDirectory, RUSTUP_HOME: apm.getRustupHomeDirPath()}) - configOptions = {env} - - @fork @atomNpmPath, configArgs, configOptions, (code, stderr='', stdout='') -> - if code is 0 - process.stdout.write(stdout) if stdout - callback() - else - process.stdout.write(stderr) if stderr - callback(new Error("npm config failed: #{code}")) diff --git a/src/config.js b/src/config.js new file mode 100644 index 00000000..1b6de278 --- /dev/null +++ b/src/config.js @@ -0,0 +1,56 @@ + +const path = require('path'); +const _ = require('underscore-plus'); +const yargs = require('yargs'); +const apm = require('./apm'); +const Command = require('./command'); + +module.exports = +class Config extends Command { + static commandNames = [ "config" ]; + + constructor() { + super(); + const atomDirectory = apm.getAtomDirectory(); + this.atomNodeDirectory = path.join(atomDirectory, '.node-gyp'); + this.atomNpmPath = require.resolve('npm/bin/npm-cli'); + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm config set + ppm config get + ppm config delete + ppm config list + ppm config edit +\ +` + ); + return options.alias('h', 'help').describe('help', 'Print this usage message'); + } + + run(options) { + const {callback} = options; + options = this.parseOptions(options.commandArgs); + + let configArgs = ['--globalconfig', apm.getGlobalConfigPath(), '--userconfig', apm.getUserConfigPath(), 'config']; + configArgs = configArgs.concat(options.argv._); + + const env = _.extend({}, process.env, {HOME: this.atomNodeDirectory, RUSTUP_HOME: apm.getRustupHomeDirPath()}); + const configOptions = {env}; + + return this.fork(this.atomNpmPath, configArgs, configOptions, function(code, stderr, stdout) { + if (stderr == null) { stderr = ''; } + if (stdout == null) { stdout = ''; } + if (code === 0) { + if (stdout) { process.stdout.write(stdout); } + return callback(); + } else { + if (stderr) { process.stdout.write(stderr); } + return callback(new Error(`npm config failed: ${code}`)); + } + }); + } +} diff --git a/src/dedupe.coffee b/src/dedupe.coffee deleted file mode 100644 index bda0cee5..00000000 --- a/src/dedupe.coffee +++ /dev/null @@ -1,72 +0,0 @@ -path = require 'path' - -async = require 'async' -_ = require 'underscore-plus' -yargs = require 'yargs' - -config = require './apm' -Command = require './command' -fs = require './fs' - -module.exports = -class Dedupe extends Command - @commandNames: ['dedupe'] - - constructor: -> - super() - @atomDirectory = config.getAtomDirectory() - @atomPackagesDirectory = path.join(@atomDirectory, 'packages') - @atomNodeDirectory = path.join(@atomDirectory, '.node-gyp') - @atomNpmPath = require.resolve('npm/bin/npm-cli') - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm dedupe [...] - - Reduce duplication in the node_modules folder in the current directory. - - This command is experimental. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - - dedupeModules: (options, callback) -> - process.stdout.write 'Deduping modules ' - - @forkDedupeCommand options, (args...) => - @logCommandResults(callback, args...) - - forkDedupeCommand: (options, callback) -> - dedupeArgs = ['--globalconfig', config.getGlobalConfigPath(), '--userconfig', config.getUserConfigPath(), 'dedupe'] - dedupeArgs.push(@getNpmBuildFlags()...) - dedupeArgs.push('--silent') if options.argv.silent - dedupeArgs.push('--quiet') if options.argv.quiet - - dedupeArgs.push(packageName) for packageName in options.argv._ - - fs.makeTreeSync(@atomDirectory) - - env = _.extend({}, process.env, {HOME: @atomNodeDirectory, RUSTUP_HOME: config.getRustupHomeDirPath()}) - @addBuildEnvVars(env) - - dedupeOptions = {env} - dedupeOptions.cwd = options.cwd if options.cwd - - @fork(@atomNpmPath, dedupeArgs, dedupeOptions, callback) - - createAtomDirectories: -> - fs.makeTreeSync(@atomDirectory) - fs.makeTreeSync(@atomNodeDirectory) - - run: (options) -> - {callback, cwd} = options - options = @parseOptions(options.commandArgs) - options.cwd = cwd - - @createAtomDirectories() - - commands = [] - commands.push (callback) => @loadInstalledAtomMetadata(callback) - commands.push (callback) => @dedupeModules(options, callback) - async.waterfall commands, callback diff --git a/src/dedupe.js b/src/dedupe.js new file mode 100644 index 00000000..8c8c3c3e --- /dev/null +++ b/src/dedupe.js @@ -0,0 +1,82 @@ + +const path = require('path'); + +const async = require('async'); +const _ = require('underscore-plus'); +const yargs = require('yargs'); + +const config = require('./apm'); +const Command = require('./command'); +const fs = require('./fs'); + +module.exports = +class Dedupe extends Command { + static commandNames = [ "dedupe" ]; + + constructor() { + super(); + this.atomDirectory = config.getAtomDirectory(); + this.atomPackagesDirectory = path.join(this.atomDirectory, 'packages'); + this.atomNodeDirectory = path.join(this.atomDirectory, '.node-gyp'); + this.atomNpmPath = require.resolve('npm/bin/npm-cli'); + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm dedupe [...] + +Reduce duplication in the node_modules folder in the current directory. + +This command is experimental.\ +` + ); + return options.alias('h', 'help').describe('help', 'Print this usage message'); + } + + dedupeModules(options, callback) { + process.stdout.write('Deduping modules '); + + this.forkDedupeCommand(options, (...args) => { + this.logCommandResults(callback, ...args); + }); + } + + forkDedupeCommand(options, callback) { + const dedupeArgs = ['--globalconfig', config.getGlobalConfigPath(), '--userconfig', config.getUserConfigPath(), 'dedupe']; + dedupeArgs.push(...this.getNpmBuildFlags()); + if (options.argv.silent) { dedupeArgs.push('--silent'); } + if (options.argv.quiet) { dedupeArgs.push('--quiet'); } + + for (let packageName of Array.from(options.argv._)) { dedupeArgs.push(packageName); } + + fs.makeTreeSync(this.atomDirectory); + + const env = _.extend({}, process.env, {HOME: this.atomNodeDirectory, RUSTUP_HOME: config.getRustupHomeDirPath()}); + this.addBuildEnvVars(env); + + const dedupeOptions = {env}; + if (options.cwd) { dedupeOptions.cwd = options.cwd; } + + return this.fork(this.atomNpmPath, dedupeArgs, dedupeOptions, callback); + } + + createAtomDirectories() { + fs.makeTreeSync(this.atomDirectory); + return fs.makeTreeSync(this.atomNodeDirectory); + } + + run(options) { + const {callback, cwd} = options; + options = this.parseOptions(options.commandArgs); + options.cwd = cwd; + + this.createAtomDirectories(); + + const commands = []; + commands.push(callback => this.loadInstalledAtomMetadata(callback)); + commands.push(callback => this.dedupeModules(options, callback)); + return async.waterfall(commands, callback); + } +} diff --git a/src/deprecated-packages.coffee b/src/deprecated-packages.coffee deleted file mode 100644 index 3de2c3dc..00000000 --- a/src/deprecated-packages.coffee +++ /dev/null @@ -1,11 +0,0 @@ -semver = require 'semver' -deprecatedPackages = null - -exports.isDeprecatedPackage = (name, version) -> - deprecatedPackages ?= require('../deprecated-packages') ? {} - return false unless deprecatedPackages.hasOwnProperty(name) - - deprecatedVersionRange = deprecatedPackages[name].version - return true unless deprecatedVersionRange - - semver.valid(version) and semver.validRange(deprecatedVersionRange) and semver.satisfies(version, deprecatedVersionRange) diff --git a/src/deprecated-packages.js b/src/deprecated-packages.js new file mode 100644 index 00000000..3b6870ee --- /dev/null +++ b/src/deprecated-packages.js @@ -0,0 +1,13 @@ + +const semver = require('semver'); +let deprecatedPackages = null; + +exports.isDeprecatedPackage = function(name, version) { + deprecatedPackages ??= require("../deprecated-packages") ?? {}; + if (!deprecatedPackages.hasOwnProperty(name)) { return false; } + + const deprecatedVersionRange = deprecatedPackages[name].version; + if (!deprecatedVersionRange) { return true; } + + return semver.valid(version) && semver.validRange(deprecatedVersionRange) && semver.satisfies(version, deprecatedVersionRange); +}; diff --git a/src/develop.coffee b/src/develop.coffee deleted file mode 100644 index 0bca2ccd..00000000 --- a/src/develop.coffee +++ /dev/null @@ -1,108 +0,0 @@ -fs = require 'fs' -path = require 'path' - -_ = require 'underscore-plus' -async = require 'async' -yargs = require 'yargs' - -config = require './apm' -Command = require './command' -Install = require './install' -git = require './git' -Link = require './link' -request = require './request' - -module.exports = -class Develop extends Command - @commandNames: ['dev', 'develop'] - - constructor: -> - super() - @atomDirectory = config.getAtomDirectory() - @atomDevPackagesDirectory = path.join(@atomDirectory, 'dev', 'packages') - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - - options.usage """ - Usage: ppm develop [] - - Clone the given package's Git repository to the directory specified, - install its dependencies, and link it for development to - ~/.pulsar/dev/packages/. - - If no directory is specified then the repository is cloned to - ~/github/. The default folder to clone packages into can - be overridden using the ATOM_REPOS_HOME environment variable. - - Once this command completes you can open a dev window from atom using - cmd-shift-o to run the package out of the newly cloned repository. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - - getRepositoryUrl: (packageName, callback) -> - requestSettings = - url: "#{config.getAtomPackagesUrl()}/#{packageName}" - json: true - request.get requestSettings, (error, response, body={}) -> - if error? - callback("Request for package information failed: #{error.message}") - else if response.statusCode is 200 - if repositoryUrl = body.repository.url - callback(null, repositoryUrl) - else - callback("No repository URL found for package: #{packageName}") - else - message = request.getErrorMessage(response, body) - callback("Request for package information failed: #{message}") - - cloneRepository: (repoUrl, packageDirectory, options, callback = ->) -> - config.getSetting 'git', (command) => - command ?= 'git' - args = ['clone', '--recursive', repoUrl, packageDirectory] - process.stdout.write "Cloning #{repoUrl} " unless options.argv.json - git.addGitToEnv(process.env) - @spawn command, args, (args...) => - if options.argv.json - @logCommandResultsIfFail(callback, args...) - else - @logCommandResults(callback, args...) - - installDependencies: (packageDirectory, options, callback = ->) -> - process.chdir(packageDirectory) - installOptions = _.clone(options) - installOptions.callback = callback - - new Install().run(installOptions) - - linkPackage: (packageDirectory, options, callback) -> - linkOptions = _.clone(options) - if callback - linkOptions.callback = callback - linkOptions.commandArgs = [packageDirectory, '--dev'] - new Link().run(linkOptions) - - run: (options) -> - packageName = options.commandArgs.shift() - - unless packageName?.length > 0 - return options.callback("Missing required package name") - - packageDirectory = options.commandArgs.shift() ? path.join(config.getReposDirectory(), packageName) - packageDirectory = path.resolve(packageDirectory) - - if fs.existsSync(packageDirectory) - @linkPackage(packageDirectory, options) - else - @getRepositoryUrl packageName, (error, repoUrl) => - if error? - options.callback(error) - else - tasks = [] - tasks.push (callback) => @cloneRepository repoUrl, packageDirectory, options, callback - - tasks.push (callback) => @installDependencies packageDirectory, options, callback - - tasks.push (callback) => @linkPackage packageDirectory, options, callback - - async.waterfall tasks, options.callback diff --git a/src/develop.js b/src/develop.js new file mode 100644 index 00000000..0744362e --- /dev/null +++ b/src/develop.js @@ -0,0 +1,134 @@ + +const fs = require('fs'); +const path = require('path'); + +const _ = require('underscore-plus'); +const async = require('async'); +const yargs = require('yargs'); + +const config = require('./apm'); +const Command = require('./command'); +const Install = require('./install'); +const git = require('./git'); +const Link = require('./link'); +const request = require('./request'); + +module.exports = +class Develop extends Command { + static commandNames = [ "dev", "develop" ]; + + constructor() { + super(); + this.atomDirectory = config.getAtomDirectory(); + this.atomDevPackagesDirectory = path.join(this.atomDirectory, 'dev', 'packages'); + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + + options.usage(`\ +Usage: ppm develop [] + +Clone the given package's Git repository to the directory specified, +install its dependencies, and link it for development to +~/.pulsar/dev/packages/. + +If no directory is specified then the repository is cloned to +~/github/. The default folder to clone packages into can +be overridden using the ATOM_REPOS_HOME environment variable. + +Once this command completes you can open a dev window from atom using +cmd-shift-o to run the package out of the newly cloned repository.\ +` + ); + return options.alias('h', 'help').describe('help', 'Print this usage message'); + } + + getRepositoryUrl(packageName, callback) { + const requestSettings = { + url: `${config.getAtomPackagesUrl()}/${packageName}`, + json: true + }; + return request.get(requestSettings, function(error, response, body) { + if (body == null) { body = {}; } + if (error != null) { + return callback(`Request for package information failed: ${error.message}`); + } else if (response.statusCode === 200) { + let repositoryUrl; + if ((repositoryUrl = body.repository.url)) { + return callback(null, repositoryUrl); + } else { + return callback(`No repository URL found for package: ${packageName}`); + } + } else { + const message = request.getErrorMessage(response, body); + return callback(`Request for package information failed: ${message}`); + } + }); + } + + cloneRepository(repoUrl, packageDirectory, options, callback) { + if (callback == null) { callback = function() {}; } + return config.getSetting('git', command => { + if (command == null) { command = 'git'; } + const args = ['clone', '--recursive', repoUrl, packageDirectory]; + if (!options.argv.json) { process.stdout.write(`Cloning ${repoUrl} `); } + git.addGitToEnv(process.env); + return this.spawn(command, args, (...args) => { + if (options.argv.json) { + return this.logCommandResultsIfFail(callback, ...args); + } else { + return this.logCommandResults(callback, ...args); + } + }); + }); + } + + installDependencies(packageDirectory, options, callback) { + if (callback == null) { callback = function() {}; } + process.chdir(packageDirectory); + const installOptions = _.clone(options); + installOptions.callback = callback; + + return new Install().run(installOptions); + } + + linkPackage(packageDirectory, options, callback) { + const linkOptions = _.clone(options); + if (callback) { + linkOptions.callback = callback; + } + linkOptions.commandArgs = [packageDirectory, '--dev']; + return new Link().run(linkOptions); + } + + run(options) { + const packageName = options.commandArgs.shift(); + + if (!((packageName != null ? packageName.length : undefined) > 0)) { + return options.callback("Missing required package name"); + } + + let packageDirectory = options.commandArgs.shift() ?? path.join(config.getReposDirectory(), packageName); + packageDirectory = path.resolve(packageDirectory); + + if (fs.existsSync(packageDirectory)) { + return this.linkPackage(packageDirectory, options); + } else { + return this.getRepositoryUrl(packageName, (error, repoUrl) => { + if (error != null) { + return options.callback(error); + } else { + const tasks = []; + tasks.push(callback => this.cloneRepository(repoUrl, packageDirectory, options, callback)); + + tasks.push(callback => this.installDependencies(packageDirectory, options, callback)); + + tasks.push(callback => this.linkPackage(packageDirectory, options, callback)); + + return async.waterfall(tasks, options.callback); + } + }); + } + } +} diff --git a/src/disable.coffee b/src/disable.coffee deleted file mode 100644 index 02b56357..00000000 --- a/src/disable.coffee +++ /dev/null @@ -1,83 +0,0 @@ -_ = require 'underscore-plus' -path = require 'path' -CSON = require 'season' -yargs = require 'yargs' - -config = require './apm' -Command = require './command' -List = require './list' - -module.exports = -class Disable extends Command - @commandNames: ['disable'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm disable []... - - Disables the named package(s). - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - - getInstalledPackages: (callback) -> - options = - argv: - theme: false - bare: true - - lister = new List() - lister.listBundledPackages options, (error, core_packages) -> - lister.listDevPackages options, (error, dev_packages) -> - lister.listUserPackages options, (error, user_packages) -> - callback(null, core_packages.concat(dev_packages, user_packages)) - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - - packageNames = @packageNamesFromArgv(options.argv) - - configFilePath = CSON.resolve(path.join(config.getAtomDirectory(), 'config')) - unless configFilePath - callback("Could not find config.cson. Run Atom first?") - return - - try - settings = CSON.readFileSync(configFilePath) - catch error - callback "Failed to load `#{configFilePath}`: #{error.message}" - return - - @getInstalledPackages (error, installedPackages) => - return callback(error) if error - - installedPackageNames = (pkg.name for pkg in installedPackages) - - # uninstalledPackages = (name for name in packageNames when !installedPackageNames[name]) - uninstalledPackageNames = _.difference(packageNames, installedPackageNames) - if uninstalledPackageNames.length > 0 - console.log "Not Installed:\n #{uninstalledPackageNames.join('\n ')}" - - # only installed packages can be disabled - packageNames = _.difference(packageNames, uninstalledPackageNames) - - if packageNames.length is 0 - callback("Please specify a package to disable") - return - - keyPath = '*.core.disabledPackages' - disabledPackages = _.valueForKeyPath(settings, keyPath) ? [] - result = _.union(disabledPackages, packageNames) - _.setValueForKeyPath(settings, keyPath, result) - - try - CSON.writeFileSync(configFilePath, settings) - catch error - callback "Failed to save `#{configFilePath}`: #{error.message}" - return - - console.log "Disabled:\n #{packageNames.join('\n ')}" - @logSuccess() - callback() diff --git a/src/disable.js b/src/disable.js new file mode 100644 index 00000000..fc04341d --- /dev/null +++ b/src/disable.js @@ -0,0 +1,95 @@ + +const _ = require('underscore-plus'); +const path = require('path'); +const CSON = require('season'); +const yargs = require('yargs'); + +const config = require('./apm'); +const Command = require('./command'); +const List = require('./list'); + +module.exports = +class Disable extends Command { + static commandNames = [ "disable" ]; + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm disable []... + +Disables the named package(s).\ +` + ); + return options.alias('h', 'help').describe('help', 'Print this usage message'); + } + + getInstalledPackages(callback) { + const options = { + argv: { + theme: false, + bare: true + } + }; + + const lister = new List(); + return lister.listBundledPackages(options, (error, core_packages) => lister.listDevPackages(options, (error, dev_packages) => lister.listUserPackages(options, (error, user_packages) => callback(null, core_packages.concat(dev_packages, user_packages))))); + } + + run(options) { + let settings; + const {callback} = options; + options = this.parseOptions(options.commandArgs); + + let packageNames = this.packageNamesFromArgv(options.argv); + + const configFilePath = CSON.resolve(path.join(config.getAtomDirectory(), 'config')); + if (!configFilePath) { + callback("Could not find config.cson. Run Atom first?"); + return; + } + + try { + settings = CSON.readFileSync(configFilePath); + } catch (error) { + callback(`Failed to load \`${configFilePath}\`: ${error.message}`); + return; + } + + return this.getInstalledPackages((error, installedPackages) => { + if (error) { return callback(error); } + + const installedPackageNames = (Array.from(installedPackages).map((pkg) => pkg.name)); + + // uninstalledPackages = (name for name in packageNames when !installedPackageNames[name]) + const uninstalledPackageNames = _.difference(packageNames, installedPackageNames); + if (uninstalledPackageNames.length > 0) { + console.log(`Not Installed:\n ${uninstalledPackageNames.join('\n ')}`); + } + + // only installed packages can be disabled + packageNames = _.difference(packageNames, uninstalledPackageNames); + + if (packageNames.length === 0) { + callback("Please specify a package to disable"); + return; + } + + const keyPath = '*.core.disabledPackages'; + const disabledPackages = _.valueForKeyPath(settings, keyPath) ?? []; + const result = _.union(disabledPackages, packageNames); + _.setValueForKeyPath(settings, keyPath, result); + + try { + CSON.writeFileSync(configFilePath, settings); + } catch (error) { + callback(`Failed to save \`${configFilePath}\`: ${error.message}`); + return; + } + + console.log(`Disabled:\n ${packageNames.join('\n ')}`); + this.logSuccess(); + return callback(); + }); + } + } diff --git a/src/docs.coffee b/src/docs.coffee deleted file mode 100644 index c379cdb4..00000000 --- a/src/docs.coffee +++ /dev/null @@ -1,44 +0,0 @@ -yargs = require 'yargs' -open = require 'open' - -View = require './view' -config = require './apm' - -module.exports = -class Docs extends View - @commandNames: ['docs', 'home', 'open'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm docs [options] - - Open a package's homepage in the default browser. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - options.boolean('p').alias('p', 'print').describe('print', 'Print the URL instead of opening it') - - openRepositoryUrl: (repositoryUrl) -> - open(repositoryUrl) - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - [packageName] = options.argv._ - - unless packageName - callback("Missing required package name") - return - - @getPackage packageName, options, (error, pack) => - return callback(error) if error? - - if repository = @getRepository(pack) - if options.argv.print - console.log repository - else - @openRepositoryUrl(repository) - callback() - else - callback("Package \"#{packageName}\" does not contain a repository URL") diff --git a/src/docs.js b/src/docs.js new file mode 100644 index 00000000..3d42b900 --- /dev/null +++ b/src/docs.js @@ -0,0 +1,55 @@ + +const yargs = require('yargs'); +const open = require('open'); + +const View = require('./view'); +const config = require('./apm'); + +module.exports = +class Docs extends View { + static commandNames = [ "docs", "home", "open" ]; + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm docs [options] + +Open a package's homepage in the default browser.\ +` + ); + options.alias('h', 'help').describe('help', 'Print this usage message'); + return options.boolean('p').alias('p', 'print').describe('print', 'Print the URL instead of opening it'); + } + + openRepositoryUrl(repositoryUrl) { + return open(repositoryUrl); + } + + run(options) { + const {callback} = options; + options = this.parseOptions(options.commandArgs); + const [packageName] = options.argv._; + + if (!packageName) { + callback("Missing required package name"); + return; + } + + this.getPackage(packageName, options, (error, pack) => { + let repository; + if (error != null) { return callback(error); } + + if (repository = this.getRepository(pack)) { + if (options.argv.print) { + console.log(repository); + } else { + this.openRepositoryUrl(repository); + } + return callback(); + } else { + return callback(`Package \"${packageName}\" does not contain a repository URL`); + } + }); + } + } diff --git a/src/enable.coffee b/src/enable.coffee deleted file mode 100644 index 82aa0cea..00000000 --- a/src/enable.coffee +++ /dev/null @@ -1,64 +0,0 @@ -_ = require 'underscore-plus' -path = require 'path' -CSON = require 'season' -yargs = require 'yargs' - -config = require './apm' -Command = require './command' - -module.exports = -class Enable extends Command - @commandNames: ['enable'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm enable []... - - Enables the named package(s). - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - packageNames = @packageNamesFromArgv(options.argv) - - configFilePath = CSON.resolve(path.join(config.getAtomDirectory(), 'config')) - unless configFilePath - callback("Could not find config.cson. Run Atom first?") - return - - try - settings = CSON.readFileSync(configFilePath) - catch error - callback "Failed to load `#{configFilePath}`: #{error.message}" - return - - keyPath = '*.core.disabledPackages' - disabledPackages = _.valueForKeyPath(settings, keyPath) ? [] - - errorPackages = _.difference(packageNames, disabledPackages) - if errorPackages.length > 0 - console.log "Not Disabled:\n #{errorPackages.join('\n ')}" - - # can't enable a package that isn't disabled - packageNames = _.difference(packageNames, errorPackages) - - if packageNames.length is 0 - callback("Please specify a package to enable") - return - - result = _.difference(disabledPackages, packageNames) - _.setValueForKeyPath(settings, keyPath, result) - - try - CSON.writeFileSync(configFilePath, settings) - catch error - callback "Failed to save `#{configFilePath}`: #{error.message}" - return - - console.log "Enabled:\n #{packageNames.join('\n ')}" - @logSuccess() - callback() diff --git a/src/enable.js b/src/enable.js new file mode 100644 index 00000000..3471f991 --- /dev/null +++ b/src/enable.js @@ -0,0 +1,75 @@ + +const _ = require('underscore-plus'); +const path = require('path'); +const CSON = require('season'); +const yargs = require('yargs'); + +const config = require('./apm'); +const Command = require('./command'); + +module.exports = +class Enable extends Command { + static commandNames = [ "enable" ]; + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm enable []... + +Enables the named package(s).\ +` + ); + return options.alias('h', 'help').describe('help', 'Print this usage message'); + } + + run(options) { + let error, settings; + const {callback} = options; + options = this.parseOptions(options.commandArgs); + let packageNames = this.packageNamesFromArgv(options.argv); + + const configFilePath = CSON.resolve(path.join(config.getAtomDirectory(), 'config')); + if (!configFilePath) { + callback("Could not find config.cson. Run Atom first?"); + return; + } + + try { + settings = CSON.readFileSync(configFilePath); + } catch (error) { + callback(`Failed to load \`${configFilePath}\`: ${error.message}`); + return; + } + + const keyPath = '*.core.disabledPackages'; + const disabledPackages = _.valueForKeyPath(settings, keyPath) ?? []; + + const errorPackages = _.difference(packageNames, disabledPackages); + if (errorPackages.length > 0) { + console.log(`Not Disabled:\n ${errorPackages.join('\n ')}`); + } + + // can't enable a package that isn't disabled + packageNames = _.difference(packageNames, errorPackages); + + if (packageNames.length === 0) { + callback("Please specify a package to enable"); + return; + } + + const result = _.difference(disabledPackages, packageNames); + _.setValueForKeyPath(settings, keyPath, result); + + try { + CSON.writeFileSync(configFilePath, settings); + } catch (error) { + callback(`Failed to save \`${configFilePath}\`: ${error.message}`); + return; + } + + console.log(`Enabled:\n ${packageNames.join('\n ')}`); + this.logSuccess(); + return callback(); + } + } diff --git a/src/featured.coffee b/src/featured.coffee deleted file mode 100644 index 34605735..00000000 --- a/src/featured.coffee +++ /dev/null @@ -1,86 +0,0 @@ -_ = require 'underscore-plus' -yargs = require 'yargs' - -Command = require './command' -config = require './apm' -request = require './request' -tree = require './tree' - -module.exports = -class Featured extends Command - @commandNames: ['featured'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm featured - ppm featured --themes - ppm featured --compatible 0.49.0 - - List the Pulsar packages and themes that are currently featured. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - options.alias('t', 'themes').boolean('themes').describe('themes', 'Only list themes') - options.alias('c', 'compatible').string('compatible').describe('compatible', 'Only list packages/themes compatible with this Pulsar version') - options.boolean('json').describe('json', 'Output featured packages as JSON array') - - getFeaturedPackagesByType: (atomVersion, packageType, callback) -> - [callback, atomVersion] = [atomVersion, null] if _.isFunction(atomVersion) - - requestSettings = - url: "#{config.getAtomApiUrl()}/#{packageType}/featured" - json: true - requestSettings.qs = engine: atomVersion if atomVersion - - request.get requestSettings, (error, response, body=[]) -> - if error? - callback(error) - else if response.statusCode is 200 - packages = body.filter (pack) -> pack?.releases? - packages = packages.map ({readme, metadata, downloads, stargazers_count}) -> _.extend({}, metadata, {readme, downloads, stargazers_count}) - packages = _.sortBy(packages, 'name') - callback(null, packages) - else - message = request.getErrorMessage(response, body) - callback("Requesting packages failed: #{message}") - - getAllFeaturedPackages: (atomVersion, callback) -> - @getFeaturedPackagesByType atomVersion, 'packages', (error, packages) => - return callback(error) if error? - - @getFeaturedPackagesByType atomVersion, 'themes', (error, themes) -> - return callback(error) if error? - callback(null, packages.concat(themes)) - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - - listCallback = (error, packages) -> - return callback(error) if error? - - if options.argv.json - console.log(JSON.stringify(packages)) - else - if options.argv.themes - console.log "#{'Featured Pulsar Themes'.cyan} (#{packages.length})" - else - console.log "#{'Featured Pulsar Packages'.cyan} (#{packages.length})" - - tree packages, ({name, version, description, downloads, stargazers_count}) -> - label = name.yellow - label += " #{description.replace(/\s+/g, ' ')}" if description - label += " (#{_.pluralize(downloads, 'download')}, #{_.pluralize(stargazers_count, 'star')})".grey if downloads >= 0 and stargazers_count >= 0 - label - - console.log() - console.log "Use `ppm install` to install them or visit #{'https://web.pulsar-edit.dev/'.underline} to read more about them." - console.log() - - callback() - - if options.argv.themes - @getFeaturedPackagesByType(options.argv.compatible, 'themes', listCallback) - else - @getAllFeaturedPackages(options.argv.compatible, listCallback) diff --git a/src/featured.js b/src/featured.js new file mode 100644 index 00000000..8232ea49 --- /dev/null +++ b/src/featured.js @@ -0,0 +1,104 @@ + +const _ = require('underscore-plus'); +const yargs = require('yargs'); + +const Command = require('./command'); +const config = require('./apm'); +const request = require('./request'); +const tree = require('./tree'); + +module.exports = +class Featured extends Command { + static commandNames = [ "featured" ]; + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm featured + ppm featured --themes + ppm featured --compatible 0.49.0 + +List the Pulsar packages and themes that are currently featured.\ +` + ); + options.alias('h', 'help').describe('help', 'Print this usage message'); + options.alias('t', 'themes').boolean('themes').describe('themes', 'Only list themes'); + options.alias('c', 'compatible').string('compatible').describe('compatible', 'Only list packages/themes compatible with this Pulsar version'); + return options.boolean('json').describe('json', 'Output featured packages as JSON array'); + } + + getFeaturedPackagesByType(atomVersion, packageType, callback) { + if (_.isFunction(atomVersion)) { [callback, atomVersion] = [atomVersion, null]; } + + const requestSettings = { + url: `${config.getAtomApiUrl()}/${packageType}/featured`, + json: true + }; + if (atomVersion) { requestSettings.qs = {engine: atomVersion}; } + + return request.get(requestSettings, function(error, response, body) { + if (body == null) { body = []; } + if (error != null) { + return callback(error); + } else if (response.statusCode === 200) { + let packages = body.filter(pack => (pack != null ? pack.releases : undefined) != null); + packages = packages.map(({readme, metadata, downloads, stargazers_count}) => _.extend({}, metadata, {readme, downloads, stargazers_count})); + packages = _.sortBy(packages, 'name'); + return callback(null, packages); + } else { + const message = request.getErrorMessage(response, body); + return callback(`Requesting packages failed: ${message}`); + } + }); + } + + getAllFeaturedPackages(atomVersion, callback) { + this.getFeaturedPackagesByType(atomVersion, 'packages', (error, packages) => { + if (error != null) { return callback(error); } + + this.getFeaturedPackagesByType(atomVersion, 'themes', function(error, themes) { + if (error != null) { return callback(error); } + return callback(null, packages.concat(themes)); + }); + }); + } + + run(options) { + const {callback} = options; + options = this.parseOptions(options.commandArgs); + + const listCallback = function(error, packages) { + if (error != null) { return callback(error); } + + if (options.argv.json) { + console.log(JSON.stringify(packages)); + } else { + if (options.argv.themes) { + console.log(`${'Featured Pulsar Themes'.cyan} (${packages.length})`); + } else { + console.log(`${'Featured Pulsar Packages'.cyan} (${packages.length})`); + } + + tree(packages, function({name, version, description, downloads, stargazers_count}) { + let label = name.yellow; + if (description) { label += ` ${description.replace(/\s+/g, ' ')}`; } + if ((downloads >= 0) && (stargazers_count >= 0)) { label += ` (${_.pluralize(downloads, 'download')}, ${_.pluralize(stargazers_count, 'star')})`.grey; } + return label; + }); + + console.log(); + console.log(`Use \`ppm install\` to install them or visit ${'https://web.pulsar-edit.dev/'.underline} to read more about them.`); + console.log(); + } + + return callback(); + }; + + if (options.argv.themes) { + return this.getFeaturedPackagesByType(options.argv.compatible, 'themes', listCallback); + } else { + return this.getAllFeaturedPackages(options.argv.compatible, listCallback); + } + } + } diff --git a/src/fs.coffee b/src/fs.coffee deleted file mode 100644 index b5905e61..00000000 --- a/src/fs.coffee +++ /dev/null @@ -1,42 +0,0 @@ -_ = require 'underscore-plus' -fs = require 'fs-plus' -ncp = require 'ncp' -rm = require 'rimraf' -wrench = require 'wrench' -path = require 'path' - -fsAdditions = - list: (directoryPath) -> - if fs.isDirectorySync(directoryPath) - try - fs.readdirSync(directoryPath) - catch e - [] - else - [] - - listRecursive: (directoryPath) -> - wrench.readdirSyncRecursive(directoryPath) - - cp: (sourcePath, destinationPath, callback) -> - rm destinationPath, (error) -> - if error? - callback(error) - else - ncp(sourcePath, destinationPath, callback) - - mv: (sourcePath, destinationPath, callback) -> - rm destinationPath, (error) -> - if error? - callback(error) - else - wrench.mkdirSyncRecursive(path.dirname(destinationPath), 0o755) - fs.rename(sourcePath, destinationPath, callback) - -module.exports = new Proxy({}, { - get: (target, key) -> - fsAdditions[key] or fs[key] - - set: (target, key, value) -> - fsAdditions[key] = value -}) diff --git a/src/fs.js b/src/fs.js new file mode 100644 index 00000000..cacd5f34 --- /dev/null +++ b/src/fs.js @@ -0,0 +1,56 @@ + +const _ = require('underscore-plus'); +const fs = require('fs-plus'); +const ncp = require('ncp'); +const rm = require('rimraf'); +const wrench = require('wrench'); +const path = require('path'); + +const fsAdditions = { + list(directoryPath) { + if (fs.isDirectorySync(directoryPath)) { + try { + return fs.readdirSync(directoryPath); + } catch (e) { + return []; + } + } else { + return []; + } + }, + + listRecursive(directoryPath) { + return wrench.readdirSyncRecursive(directoryPath); + }, + + cp(sourcePath, destinationPath, callback) { + return rm(destinationPath, function(error) { + if (error != null) { + return callback(error); + } else { + return ncp(sourcePath, destinationPath, callback); + } + }); + }, + + mv(sourcePath, destinationPath, callback) { + return rm(destinationPath, function(error) { + if (error != null) { + return callback(error); + } else { + wrench.mkdirSyncRecursive(path.dirname(destinationPath), 0o755); + return fs.rename(sourcePath, destinationPath, callback); + } + }); + } +}; + +module.exports = new Proxy({}, { + get(target, key) { + return fsAdditions[key] || fs[key]; + }, + + set(target, key, value) { + return fsAdditions[key] = value; + } +}); diff --git a/src/git.coffee b/src/git.coffee deleted file mode 100644 index ca6b1e04..00000000 --- a/src/git.coffee +++ /dev/null @@ -1,68 +0,0 @@ -{spawn} = require 'child_process' -path = require 'path' -_ = require 'underscore-plus' -npm = require 'npm' -config = require './apm' -fs = require './fs' - -addPortableGitToEnv = (env) -> - localAppData = env.LOCALAPPDATA - return unless localAppData - - githubPath = path.join(localAppData, 'GitHub') - - try - children = fs.readdirSync(githubPath) - catch error - return - - for child in children when child.indexOf('PortableGit_') is 0 - cmdPath = path.join(githubPath, child, 'cmd') - binPath = path.join(githubPath, child, 'bin') - if env.Path - env.Path += "#{path.delimiter}#{cmdPath}#{path.delimiter}#{binPath}" - else - env.Path = "#{cmdPath}#{path.delimiter}#{binPath}" - break - - return - -addGitBashToEnv = (env) -> - if env.ProgramFiles - gitPath = path.join(env.ProgramFiles, 'Git') - - unless fs.isDirectorySync(gitPath) - if env['ProgramFiles(x86)'] - gitPath = path.join(env['ProgramFiles(x86)'], 'Git') - - return unless fs.isDirectorySync(gitPath) - - cmdPath = path.join(gitPath, 'cmd') - binPath = path.join(gitPath, 'bin') - if env.Path - env.Path += "#{path.delimiter}#{cmdPath}#{path.delimiter}#{binPath}" - else - env.Path = "#{cmdPath}#{path.delimiter}#{binPath}" - -exports.addGitToEnv = (env) -> - return if process.platform isnt 'win32' - addPortableGitToEnv(env) - addGitBashToEnv(env) - -exports.getGitVersion = (callback) -> - npmOptions = - userconfig: config.getUserConfigPath() - globalconfig: config.getGlobalConfigPath() - npm.load npmOptions, -> - git = npm.config.get('git') ? 'git' - exports.addGitToEnv(process.env) - spawned = spawn(git, ['--version']) - outputChunks = [] - spawned.stderr.on 'data', (chunk) -> outputChunks.push(chunk) - spawned.stdout.on 'data', (chunk) -> outputChunks.push(chunk) - spawned.on 'error', -> - spawned.on 'close', (code) -> - if code is 0 - [gitName, versionName, version] = Buffer.concat(outputChunks).toString().split(' ') - version = version?.trim() - callback(version) diff --git a/src/git.js b/src/git.js new file mode 100644 index 00000000..69023421 --- /dev/null +++ b/src/git.js @@ -0,0 +1,90 @@ + +const {spawn} = require('child_process'); +const path = require('path'); +const _ = require('underscore-plus'); +const npm = require('npm'); +const config = require('./apm'); +const fs = require('./fs'); + +const addPortableGitToEnv = function(env) { + let children; + const localAppData = env.LOCALAPPDATA; + if (!localAppData) { return; } + + const githubPath = path.join(localAppData, 'GitHub'); + + try { + children = fs.readdirSync(githubPath); + } catch (error) { + return; + } + + for (let child of children) { + if (child.indexOf('PortableGit_') === 0) { + const cmdPath = path.join(githubPath, child, 'cmd'); + const binPath = path.join(githubPath, child, 'bin'); + if (env.Path) { + env.Path += `${path.delimiter}${cmdPath}${path.delimiter}${binPath}`; + } else { + env.Path = `${cmdPath}${path.delimiter}${binPath}`; + } + break; + } + } + +}; + +const addGitBashToEnv = function(env) { + let gitPath; + if (env.ProgramFiles) { + gitPath = path.join(env.ProgramFiles, 'Git'); + } + + if (!fs.isDirectorySync(gitPath)) { + if (env['ProgramFiles(x86)']) { + gitPath = path.join(env['ProgramFiles(x86)'], 'Git'); + } + } + + if (!fs.isDirectorySync(gitPath)) { return; } + + const cmdPath = path.join(gitPath, 'cmd'); + const binPath = path.join(gitPath, 'bin'); + if (env.Path) { + return env.Path += `${path.delimiter}${cmdPath}${path.delimiter}${binPath}`; + } else { + return env.Path = `${cmdPath}${path.delimiter}${binPath}`; + } +}; + +exports.addGitToEnv = function(env) { + if (process.platform !== 'win32') { return; } + addPortableGitToEnv(env); + addGitBashToEnv(env); +}; + +exports.getGitVersion = function(callback) { + const npmOptions = { + userconfig: config.getUserConfigPath(), + globalconfig: config.getGlobalConfigPath() + }; + npm.load(npmOptions, function() { + let left; + const git = (left = npm.config.get('git')) != null ? left : 'git'; + exports.addGitToEnv(process.env); + const spawned = spawn(git, ['--version']); + const outputChunks = []; + spawned.stderr.on('data', chunk => outputChunks.push(chunk)); + spawned.stdout.on('data', chunk => outputChunks.push(chunk)); + spawned.on('error', function() {}); + return spawned.on('close', function(code) { + let version; + if (code === 0) { + let gitName, versionName; + [gitName, versionName, version] = Buffer.concat(outputChunks).toString().split(' '); + version = version != null ? version.trim() : undefined; + } + return callback(version); + }); + }); +}; diff --git a/src/init.coffee b/src/init.coffee deleted file mode 100644 index e7819c66..00000000 --- a/src/init.coffee +++ /dev/null @@ -1,182 +0,0 @@ -path = require 'path' - -yargs = require 'yargs' - -Command = require './command' -fs = require './fs' - -module.exports = -class Init extends Command - @commandNames: ['init'] - - supportedSyntaxes: ['coffeescript', 'javascript'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - - options.usage """ - Usage: - ppm init -p - ppm init -p --syntax - ppm init -p -c ~/Downloads/r.tmbundle - ppm init -p -c https://github.com/textmate/r.tmbundle - ppm init -p --template /path/to/your/package/template - - ppm init -t - ppm init -t -c ~/Downloads/Dawn.tmTheme - ppm init -t -c https://raw.github.com/chriskempson/tomorrow-theme/master/textmate/Tomorrow-Night-Eighties.tmTheme - ppm init -t --template /path/to/your/theme/template - - ppm init -l - - Generates code scaffolding for either a theme or package depending - on the option selected. - """ - options.alias('p', 'package').string('package').describe('package', 'Generates a basic package') - options.alias('s', 'syntax').string('syntax').describe('syntax', 'Sets package syntax to CoffeeScript or JavaScript') - options.alias('t', 'theme').string('theme').describe('theme', 'Generates a basic theme') - options.alias('l', 'language').string('language').describe('language', 'Generates a basic language package') - options.alias('c', 'convert').string('convert').describe('convert', 'Path or URL to TextMate bundle/theme to convert') - options.alias('h', 'help').describe('help', 'Print this usage message') - options.string('template').describe('template', 'Path to the package or theme template') - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - if options.argv.package?.length > 0 - if options.argv.convert - @convertPackage(options.argv.convert, options.argv.package, callback) - else - packagePath = path.resolve(options.argv.package) - syntax = options.argv.syntax or @supportedSyntaxes[0] - if syntax not in @supportedSyntaxes - return callback("You must specify one of #{@supportedSyntaxes.join(', ')} after the --syntax argument") - templatePath = @getTemplatePath(options.argv, "package-#{syntax}") - @generateFromTemplate(packagePath, templatePath) - callback() - else if options.argv.theme?.length > 0 - if options.argv.convert - @convertTheme(options.argv.convert, options.argv.theme, callback) - else - themePath = path.resolve(options.argv.theme) - templatePath = @getTemplatePath(options.argv, 'theme') - @generateFromTemplate(themePath, templatePath) - callback() - else if options.argv.language?.length > 0 - languagePath = path.resolve(options.argv.language) - languageName = path.basename(languagePath).replace(/^language-/, '') - languagePath = path.join(path.dirname(languagePath), "language-#{languageName}") - templatePath = @getTemplatePath(options.argv, 'language') - @generateFromTemplate(languagePath, templatePath, languageName) - callback() - else if options.argv.package? - callback('You must specify a path after the --package argument') - else if options.argv.theme? - callback('You must specify a path after the --theme argument') - else - callback('You must specify either --package, --theme or --language to `ppm init`') - - convertPackage: (sourcePath, destinationPath, callback) -> - unless destinationPath - callback("Specify directory to create package in using --package") - return - - PackageConverter = require './package-converter' - converter = new PackageConverter(sourcePath, destinationPath) - converter.convert (error) => - if error? - callback(error) - else - destinationPath = path.resolve(destinationPath) - templatePath = path.resolve(__dirname, '..', 'templates', 'bundle') - @generateFromTemplate(destinationPath, templatePath) - callback() - - convertTheme: (sourcePath, destinationPath, callback) -> - unless destinationPath - callback("Specify directory to create theme in using --theme") - return - - ThemeConverter = require './theme-converter' - converter = new ThemeConverter(sourcePath, destinationPath) - converter.convert (error) => - if error? - callback(error) - else - destinationPath = path.resolve(destinationPath) - templatePath = path.resolve(__dirname, '..', 'templates', 'theme') - @generateFromTemplate(destinationPath, templatePath) - fs.removeSync(path.join(destinationPath, 'styles', 'colors.less')) - fs.removeSync(path.join(destinationPath, 'LICENSE.md')) - callback() - - generateFromTemplate: (packagePath, templatePath, packageName) -> - packageName ?= path.basename(packagePath) - packageAuthor = process.env.GITHUB_USER or 'atom' - - fs.makeTreeSync(packagePath) - - for childPath in fs.listRecursive(templatePath) - templateChildPath = path.resolve(templatePath, childPath) - relativePath = templateChildPath.replace(templatePath, "") - relativePath = relativePath.replace(/^\//, '') - relativePath = relativePath.replace(/\.template$/, '') - relativePath = @replacePackageNamePlaceholders(relativePath, packageName) - - sourcePath = path.join(packagePath, relativePath) - continue if fs.existsSync(sourcePath) - if fs.isDirectorySync(templateChildPath) - fs.makeTreeSync(sourcePath) - else if fs.isFileSync(templateChildPath) - fs.makeTreeSync(path.dirname(sourcePath)) - contents = fs.readFileSync(templateChildPath).toString() - contents = @replacePackageNamePlaceholders(contents, packageName) - contents = @replacePackageAuthorPlaceholders(contents, packageAuthor) - contents = @replaceCurrentYearPlaceholders(contents) - fs.writeFileSync(sourcePath, contents) - - replacePackageAuthorPlaceholders: (string, packageAuthor) -> - string.replace(/__package-author__/g, packageAuthor) - - replacePackageNamePlaceholders: (string, packageName) -> - placeholderRegex = /__(?:(package-name)|([pP]ackageName)|(package_name))__/g - string = string.replace placeholderRegex, (match, dash, camel, underscore) => - if dash - @dasherize(packageName) - else if camel - if /[a-z]/.test(camel[0]) - packageName = packageName[0].toLowerCase() + packageName[1...] - else if /[A-Z]/.test(camel[0]) - packageName = packageName[0].toUpperCase() + packageName[1...] - @camelize(packageName) - - else if underscore - @underscore(packageName) - - replaceCurrentYearPlaceholders: (string) -> - string.replace '__current_year__', new Date().getFullYear() - - getTemplatePath: (argv, templateType) -> - if argv.template? - path.resolve(argv.template) - else - path.resolve(__dirname, '..', 'templates', templateType) - - dasherize: (string) -> - string = string[0].toLowerCase() + string[1..] - string.replace /([A-Z])|(_)/g, (m, letter, underscore) -> - if letter - "-" + letter.toLowerCase() - else - "-" - - camelize: (string) -> - string.replace /[_-]+(\w)/g, (m) -> m[1].toUpperCase() - - underscore: (string) -> - string = string[0].toLowerCase() + string[1..] - string.replace /([A-Z])|(-)/g, (m, letter, dash) -> - if letter - "_" + letter.toLowerCase() - else - "_" diff --git a/src/init.js b/src/init.js new file mode 100644 index 00000000..7f0d13bc --- /dev/null +++ b/src/init.js @@ -0,0 +1,227 @@ + +const path = require('path'); + +const yargs = require('yargs'); + +const Command = require('./command'); +const fs = require('./fs'); + +module.exports = +class Init extends Command { + static commandNames = [ "init" ]; + + constructor() { + super(); + this.supportedSyntaxes = [ "coffeescript", "javascript" ]; + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + + options.usage(`\ +Usage: + ppm init -p + ppm init -p --syntax + ppm init -p -c ~/Downloads/r.tmbundle + ppm init -p -c https://github.com/textmate/r.tmbundle + ppm init -p --template /path/to/your/package/template + + ppm init -t + ppm init -t -c ~/Downloads/Dawn.tmTheme + ppm init -t -c https://raw.github.com/chriskempson/tomorrow-theme/master/textmate/Tomorrow-Night-Eighties.tmTheme + ppm init -t --template /path/to/your/theme/template + + ppm init -l + +Generates code scaffolding for either a theme or package depending +on the option selected.\ +` + ); + options.alias('p', 'package').string('package').describe('package', 'Generates a basic package'); + options.alias('s', 'syntax').string('syntax').describe('syntax', 'Sets package syntax to CoffeeScript or JavaScript'); + options.alias('t', 'theme').string('theme').describe('theme', 'Generates a basic theme'); + options.alias('l', 'language').string('language').describe('language', 'Generates a basic language package'); + options.alias('c', 'convert').string('convert').describe('convert', 'Path or URL to TextMate bundle/theme to convert'); + options.alias('h', 'help').describe('help', 'Print this usage message'); + return options.string('template').describe('template', 'Path to the package or theme template'); + } + + run(options) { + let templatePath; + const {callback} = options; + options = this.parseOptions(options.commandArgs); + if ((options.argv.package != null ? options.argv.package.length : undefined) > 0) { + if (options.argv.convert) { + return this.convertPackage(options.argv.convert, options.argv.package, callback); + } else { + const packagePath = path.resolve(options.argv.package); + const syntax = options.argv.syntax || this.supportedSyntaxes[0]; + if (!Array.from(this.supportedSyntaxes).includes(syntax)) { + return callback(`You must specify one of ${this.supportedSyntaxes.join(', ')} after the --syntax argument`); + } + templatePath = this.getTemplatePath(options.argv, `package-${syntax}`); + this.generateFromTemplate(packagePath, templatePath); + return callback(); + } + } else if ((options.argv.theme != null ? options.argv.theme.length : undefined) > 0) { + if (options.argv.convert) { + return this.convertTheme(options.argv.convert, options.argv.theme, callback); + } else { + const themePath = path.resolve(options.argv.theme); + templatePath = this.getTemplatePath(options.argv, 'theme'); + this.generateFromTemplate(themePath, templatePath); + return callback(); + } + } else if ((options.argv.language != null ? options.argv.language.length : undefined) > 0) { + let languagePath = path.resolve(options.argv.language); + const languageName = path.basename(languagePath).replace(/^language-/, ''); + languagePath = path.join(path.dirname(languagePath), `language-${languageName}`); + templatePath = this.getTemplatePath(options.argv, 'language'); + this.generateFromTemplate(languagePath, templatePath, languageName); + return callback(); + } else if (options.argv.package != null) { + return callback('You must specify a path after the --package argument'); + } else if (options.argv.theme != null) { + return callback('You must specify a path after the --theme argument'); + } else { + return callback('You must specify either --package, --theme or --language to `ppm init`'); + } + } + + convertPackage(sourcePath, destinationPath, callback) { + if (!destinationPath) { + callback("Specify directory to create package in using --package"); + return; + } + + const PackageConverter = require('./package-converter'); + const converter = new PackageConverter(sourcePath, destinationPath); + return converter.convert(error => { + if (error != null) { + return callback(error); + } else { + destinationPath = path.resolve(destinationPath); + const templatePath = path.resolve(__dirname, '..', 'templates', 'bundle'); + this.generateFromTemplate(destinationPath, templatePath); + return callback(); + } + }); + } + + convertTheme(sourcePath, destinationPath, callback) { + if (!destinationPath) { + callback("Specify directory to create theme in using --theme"); + return; + } + + const ThemeConverter = require('./theme-converter'); + const converter = new ThemeConverter(sourcePath, destinationPath); + converter.convert(error => { + if (error != null) { + return callback(error); + } else { + destinationPath = path.resolve(destinationPath); + const templatePath = path.resolve(__dirname, '..', 'templates', 'theme'); + this.generateFromTemplate(destinationPath, templatePath); + fs.removeSync(path.join(destinationPath, 'styles', 'colors.less')); + fs.removeSync(path.join(destinationPath, 'LICENSE.md')); + return callback(); + } + }); + } + + generateFromTemplate(packagePath, templatePath, packageName) { + if (packageName == null) { packageName = path.basename(packagePath); } + const packageAuthor = process.env.GITHUB_USER || 'atom'; + + fs.makeTreeSync(packagePath); + + return (() => { + const result = []; + for (let childPath of Array.from(fs.listRecursive(templatePath))) { + const templateChildPath = path.resolve(templatePath, childPath); + let relativePath = templateChildPath.replace(templatePath, ""); + relativePath = relativePath.replace(/^\//, ''); + relativePath = relativePath.replace(/\.template$/, ''); + relativePath = this.replacePackageNamePlaceholders(relativePath, packageName); + + const sourcePath = path.join(packagePath, relativePath); + if (fs.existsSync(sourcePath)) { continue; } + if (fs.isDirectorySync(templateChildPath)) { + result.push(fs.makeTreeSync(sourcePath)); + } else if (fs.isFileSync(templateChildPath)) { + fs.makeTreeSync(path.dirname(sourcePath)); + let contents = fs.readFileSync(templateChildPath).toString(); + contents = this.replacePackageNamePlaceholders(contents, packageName); + contents = this.replacePackageAuthorPlaceholders(contents, packageAuthor); + contents = this.replaceCurrentYearPlaceholders(contents); + result.push(fs.writeFileSync(sourcePath, contents)); + } else { + result.push(undefined); + } + } + return result; + })(); + } + + replacePackageAuthorPlaceholders(string, packageAuthor) { + return string.replace(/__package-author__/g, packageAuthor); + } + + replacePackageNamePlaceholders(string, packageName) { + const placeholderRegex = /__(?:(package-name)|([pP]ackageName)|(package_name))__/g; + return string = string.replace(placeholderRegex, (match, dash, camel, underscore) => { + if (dash) { + return this.dasherize(packageName); + } else if (camel) { + if (/[a-z]/.test(camel[0])) { + packageName = packageName[0].toLowerCase() + packageName.slice(1); + } else if (/[A-Z]/.test(camel[0])) { + packageName = packageName[0].toUpperCase() + packageName.slice(1); + } + return this.camelize(packageName); + + } else if (underscore) { + return this.underscore(packageName); + } + }); + } + + replaceCurrentYearPlaceholders(string) { + return string.replace('__current_year__', new Date().getFullYear()); + } + + getTemplatePath(argv, templateType) { + if (argv.template != null) { + return path.resolve(argv.template); + } else { + return path.resolve(__dirname, '..', 'templates', templateType); + } + } + + dasherize(string) { + string = string[0].toLowerCase() + string.slice(1); + return string.replace(/([A-Z])|(_)/g, function(m, letter, underscore) { + if (letter) { + return "-" + letter.toLowerCase(); + } else { + return "-"; + } + }); + } + + camelize(string) { + return string.replace(/[_-]+(\w)/g, m => m[1].toUpperCase()); + } + + underscore(string) { + string = string[0].toLowerCase() + string.slice(1); + return string.replace(/([A-Z])|(-)/g, function(m, letter, dash) { + if (letter) { + return "_" + letter.toLowerCase(); + } else { + return "_"; + } + }); + } + } diff --git a/src/install.coffee b/src/install.coffee deleted file mode 100644 index 0f31f3b2..00000000 --- a/src/install.coffee +++ /dev/null @@ -1,626 +0,0 @@ -assert = require 'assert' -path = require 'path' - -_ = require 'underscore-plus' -async = require 'async' -CSON = require 'season' -yargs = require 'yargs' -Git = require 'git-utils' -semver = require 'semver' -temp = require 'temp' -hostedGitInfo = require 'hosted-git-info' - -config = require './apm' -Command = require './command' -fs = require './fs' -RebuildModuleCache = require './rebuild-module-cache' -request = require './request' -{isDeprecatedPackage} = require './deprecated-packages' - -module.exports = -class Install extends Command - @commandNames: ['install', 'i'] - - constructor: -> - super() - @atomDirectory = config.getAtomDirectory() - @atomPackagesDirectory = path.join(@atomDirectory, 'packages') - @atomNodeDirectory = path.join(@atomDirectory, '.node-gyp') - @atomNpmPath = require.resolve('npm/bin/npm-cli') - @repoLocalPackagePathRegex = /^file:(?!\/\/)(.*)/ - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm install [...] - ppm install @ - ppm install [-b ] - ppm install / [-b ] - ppm install --packages-file my-packages.txt - ppm i (with any of the previous argument usage) - - Install the given Pulsar package to ~/.pulsar/packages/. - - If no package name is given then all the dependencies in the package.json - file are installed to the node_modules folder in the current working - directory. - - A packages file can be specified that is a newline separated list of - package names to install with optional versions using the - `package-name@version` syntax. - """ - options.alias('c', 'compatible').string('compatible').describe('compatible', 'Only install packages/themes compatible with this Pulsar version') - options.alias('h', 'help').describe('help', 'Print this usage message') - options.alias('s', 'silent').boolean('silent').describe('silent', 'Set the npm log level to silent') - options.alias('b', 'branch').string('branch').describe('branch', 'Sets the tag or branch to install') - options.alias('t', 'tag').string('tag').describe('tag', 'Sets the tag or branch to install') - options.alias('q', 'quiet').boolean('quiet').describe('quiet', 'Set the npm log level to warn') - options.boolean('check').describe('check', 'Check that native build tools are installed') - options.boolean('verbose').default('verbose', false).describe('verbose', 'Show verbose debug information') - options.string('packages-file').describe('packages-file', 'A text file containing the packages to install') - options.boolean('production').describe('production', 'Do not install dev dependencies') - - installModule: (options, pack, moduleURI, callback) -> - installGlobally = options.installGlobally ? true - - installArgs = ['--globalconfig', config.getGlobalConfigPath(), '--userconfig', config.getUserConfigPath(), 'install'] - installArgs.push(moduleURI) - installArgs.push(@getNpmBuildFlags()...) - installArgs.push("--global-style") if installGlobally - installArgs.push('--silent') if options.argv.silent - installArgs.push('--quiet') if options.argv.quiet - installArgs.push('--production') if options.argv.production - installArgs.push('--verbose') if options.argv.verbose - - fs.makeTreeSync(@atomDirectory) - - env = _.extend({}, process.env, {HOME: @atomNodeDirectory, RUSTUP_HOME: config.getRustupHomeDirPath()}) - @addBuildEnvVars(env) - - installOptions = {env} - installOptions.streaming = true if @verbose - - if installGlobally - installDirectory = temp.mkdirSync('apm-install-dir-') - nodeModulesDirectory = path.join(installDirectory, 'node_modules') - fs.makeTreeSync(nodeModulesDirectory) - installOptions.cwd = installDirectory - - @fork @atomNpmPath, installArgs, installOptions, (code, stderr='', stdout='') => - if code is 0 - if installGlobally - commands = [] - children = fs.readdirSync(nodeModulesDirectory) - .filter (dir) -> dir isnt ".bin" - assert.equal(children.length, 1, "Expected there to only be one child in node_modules") - child = children[0] - source = path.join(nodeModulesDirectory, child) - destination = path.join(@atomPackagesDirectory, child) - commands.push (next) -> fs.cp(source, destination, next) - commands.push (next) => @buildModuleCache(pack.name, next) - commands.push (next) => @warmCompileCache(pack.name, next) - - async.waterfall commands, (error) => - if error? - @logFailure() - else - @logSuccess() unless options.argv.json - callback(error, {name: child, installPath: destination}) - else - callback(null, {name: child, installPath: destination}) - else - if installGlobally - fs.removeSync(installDirectory) - @logFailure() - - error = "#{stdout}\n#{stderr}" - error = @getGitErrorMessage(pack) if error.indexOf('code ENOGIT') isnt -1 - callback(error) - - getGitErrorMessage: (pack) -> - message = """ - Failed to install #{pack.name} because Git was not found. - - The #{pack.name} package has module dependencies that cannot be installed without Git. - - You need to install Git and add it to your path environment variable in order to install this package. - - """ - - switch process.platform - when 'win32' - message += """ - - You can install Git by downloading, installing, and launching GitHub for Windows: https://windows.github.com - - """ - when 'linux' - message += """ - - You can install Git from your OS package manager. - - """ - - message += """ - - Run ppm -v after installing Git to see what version has been detected. - """ - - message - - installModules: (options, callback) => - process.stdout.write 'Installing modules ' unless options.argv.json - - @forkInstallCommand options, (args...) => - if options.argv.json - @logCommandResultsIfFail(callback, args...) - else - @logCommandResults(callback, args...) - - forkInstallCommand: (options, callback) -> - installArgs = ['--globalconfig', config.getGlobalConfigPath(), '--userconfig', config.getUserConfigPath(), 'install'] - installArgs.push(@getNpmBuildFlags()...) - installArgs.push('--silent') if options.argv.silent - installArgs.push('--quiet') if options.argv.quiet - installArgs.push('--production') if options.argv.production - - fs.makeTreeSync(@atomDirectory) - - env = _.extend({}, process.env, {HOME: @atomNodeDirectory, RUSTUP_HOME: config.getRustupHomeDirPath()}) - @addBuildEnvVars(env) - - installOptions = {env} - installOptions.cwd = options.cwd if options.cwd - installOptions.streaming = true if @verbose - - @fork(@atomNpmPath, installArgs, installOptions, callback) - - # Request package information from the package API for a given package name. - # - # packageName - The string name of the package to request. - # callback - The function to invoke when the request completes with an error - # as the first argument and an object as the second. - requestPackage: (packageName, callback) -> - requestSettings = - url: "#{config.getAtomPackagesUrl()}/#{packageName}" - json: true - retries: 4 - request.get requestSettings, (error, response, body={}) -> - if error? - message = "Request for package information failed: #{error.message}" - message += " (#{error.code})" if error.code - callback(message) - else if response.statusCode isnt 200 - message = request.getErrorMessage(response, body) - callback("Request for package information failed: #{message}") - else - if body.releases.latest - callback(null, body) - else - callback("No releases available for #{packageName}") - - # Is the package at the specified version already installed? - # - # * packageName: The string name of the package. - # * packageVersion: The string version of the package. - isPackageInstalled: (packageName, packageVersion) -> - try - {version} = CSON.readFileSync(CSON.resolve(path.join('node_modules', packageName, 'package'))) ? {} - packageVersion is version - catch error - false - - # Install the package with the given name and optional version - # - # metadata - The package metadata object with at least a name key. A version - # key is also supported. The version defaults to the latest if - # unspecified. - # options - The installation options object. - # callback - The function to invoke when installation completes with an - # error as the first argument. - installRegisteredPackage: (metadata, options, callback) -> - packageName = metadata.name - packageVersion = metadata.version - - installGlobally = options.installGlobally ? true - unless installGlobally - if packageVersion and @isPackageInstalled(packageName, packageVersion) - callback(null, {}) - return - - label = packageName - label += "@#{packageVersion}" if packageVersion - unless options.argv.json - process.stdout.write "Installing #{label} " - if installGlobally - process.stdout.write "to #{@atomPackagesDirectory} " - - @requestPackage packageName, (error, pack) => - if error? - @logFailure() - callback(error) - else - packageVersion ?= @getLatestCompatibleVersion(pack) - unless packageVersion - @logFailure() - callback("No available version compatible with the installed Atom version: #{@installedAtomVersion}") - return - - {tarball} = pack.versions[packageVersion]?.dist ? {} - unless tarball - @logFailure() - callback("Package version: #{packageVersion} not found") - return - - commands = [] - commands.push (next) => @installModule(options, pack, tarball, next) - if installGlobally and (packageName.localeCompare(pack.name, 'en', {sensitivity: 'accent'}) isnt 0) - commands.push (newPack, next) => # package was renamed; delete old package folder - fs.removeSync(path.join(@atomPackagesDirectory, packageName)) - next(null, newPack) - commands.push ({installPath}, next) -> - if installPath? - metadata = JSON.parse(fs.readFileSync(path.join(installPath, 'package.json'), 'utf8')) - json = {installPath, metadata} - next(null, json) - else - next(null, {}) # installed locally, no install path data - - async.waterfall commands, (error, json) => - unless installGlobally - if error? - @logFailure() - else - @logSuccess() unless options.argv.json - callback(error, json) - - # Install the package with the given name and local path - # - # packageName - The name of the package - # packagePath - The local path of the package in the form "file:./packages/package-name" - # options - The installation options object. - # callback - The function to invoke when installation completes with an - # error as the first argument. - installLocalPackage: (packageName, packagePath, options, callback) -> - unless options.argv.json - process.stdout.write "Installing #{packageName} from #{packagePath.slice('file:'.length)} " - commands = [] - commands.push (next) => - @installModule(options, {name: packageName}, packagePath, next) - commands.push ({installPath}, next) -> - if installPath? - metadata = JSON.parse(fs.readFileSync(path.join(installPath, 'package.json'), 'utf8')) - json = {installPath, metadata} - next(null, json) - else - next(null, {}) # installed locally, no install path data - - async.waterfall commands, (error, json) => - if error? - @logFailure() - else - @logSuccess() unless options.argv.json - callback(error, json) - - # Install all the package dependencies found in the package.json file. - # - # options - The installation options - # callback - The callback function to invoke when done with an error as the - # first argument. - installPackageDependencies: (options, callback) -> - options = _.extend({}, options, installGlobally: false) - commands = [] - for name, version of @getPackageDependencies(options.cwd) - do (name, version) => - commands.push (next) => - if @repoLocalPackagePathRegex.test(version) - @installLocalPackage(name, version, options, next) - else - @installRegisteredPackage({name, version}, options, next) - - async.series(commands, callback) - - installDependencies: (options, callback) -> - options.installGlobally = false - commands = [] - commands.push (callback) => @installModules(options, callback) - commands.push (callback) => @installPackageDependencies(options, callback) - - async.waterfall commands, callback - - # Get all package dependency names and versions from the package.json file. - getPackageDependencies: (cloneDir) -> - try - fileName = path.join (cloneDir or '.'), 'package.json' - metadata = fs.readFileSync(fileName, 'utf8') - {packageDependencies, dependencies} = JSON.parse(metadata) ? {} - - return {} unless packageDependencies - return packageDependencies unless dependencies - - # This code filters out any `packageDependencies` that have an equivalent - # normalized repo-local package path entry in the `dependencies` section of - # `package.json`. Versioned `packageDependencies` are always returned. - filteredPackages = {} - for packageName, packageSpec of packageDependencies - dependencyPath = @getRepoLocalPackagePath(dependencies[packageName]) - packageDependencyPath = @getRepoLocalPackagePath(packageSpec) - unless packageDependencyPath and dependencyPath is packageDependencyPath - filteredPackages[packageName] = packageSpec - - filteredPackages - catch error - {} - - getRepoLocalPackagePath: (packageSpec) -> - return undefined if not packageSpec - repoLocalPackageMatch = packageSpec.match(@repoLocalPackagePathRegex) - if repoLocalPackageMatch - path.normalize(repoLocalPackageMatch[1]) - else - undefined - - createAtomDirectories: -> - fs.makeTreeSync(@atomDirectory) - fs.makeTreeSync(@atomPackagesDirectory) - fs.makeTreeSync(@atomNodeDirectory) - - # Compile a sample native module to see if a useable native build toolchain - # is instlalled and successfully detected. This will include both Python - # and a compiler. - checkNativeBuildTools: (callback) -> - process.stdout.write 'Checking for native build tools ' - - buildArgs = ['--globalconfig', config.getGlobalConfigPath(), '--userconfig', config.getUserConfigPath(), 'build'] - buildArgs.push(path.resolve(__dirname, '..', 'native-module')) - buildArgs.push(@getNpmBuildFlags()...) - - fs.makeTreeSync(@atomDirectory) - - env = _.extend({}, process.env, {HOME: @atomNodeDirectory, RUSTUP_HOME: config.getRustupHomeDirPath()}) - @addBuildEnvVars(env) - - buildOptions = {env} - buildOptions.streaming = true if @verbose - - fs.removeSync(path.resolve(__dirname, '..', 'native-module', 'build')) - - @fork @atomNpmPath, buildArgs, buildOptions, (args...) => - @logCommandResults(callback, args...) - - packageNamesFromPath: (filePath) -> - filePath = path.resolve(filePath) - - unless fs.isFileSync(filePath) - throw new Error("File '#{filePath}' does not exist") - - packages = fs.readFileSync(filePath, 'utf8') - @sanitizePackageNames(packages.split(/\s/)) - - buildModuleCache: (packageName, callback) -> - packageDirectory = path.join(@atomPackagesDirectory, packageName) - rebuildCacheCommand = new RebuildModuleCache() - rebuildCacheCommand.rebuild packageDirectory, -> - # Ignore cache errors and just finish the install - callback() - - warmCompileCache: (packageName, callback) -> - packageDirectory = path.join(@atomPackagesDirectory, packageName) - - @getResourcePath (resourcePath) => - try - CompileCache = require(path.join(resourcePath, 'src', 'compile-cache')) - - onDirectory = (directoryPath) -> - path.basename(directoryPath) isnt 'node_modules' - - onFile = (filePath) => - try - CompileCache.addPathToCache(filePath, @atomDirectory) - - fs.traverseTreeSync(packageDirectory, onFile, onDirectory) - callback(null) - - isBundledPackage: (packageName, callback) -> - @getResourcePath (resourcePath) -> - try - atomMetadata = JSON.parse(fs.readFileSync(path.join(resourcePath, 'package.json'))) - catch error - return callback(false) - - callback(atomMetadata?.packageDependencies?.hasOwnProperty(packageName)) - - getLatestCompatibleVersion: (pack) -> - unless @installedAtomVersion - if isDeprecatedPackage(pack.name, pack.releases.latest) - return null - else - return pack.releases.latest - - latestVersion = null - for version, metadata of pack.versions ? {} - continue unless semver.valid(version) - continue unless metadata - continue if isDeprecatedPackage(pack.name, version) - - engines = metadata.engines - engine = engines?.pulsar or engines?.atom or '*' - continue unless semver.validRange(engine) - continue unless semver.satisfies(@installedAtomVersion, engine) - - latestVersion ?= version - latestVersion = version if semver.gt(version, latestVersion) - - latestVersion - - getHostedGitInfo: (name) -> - hostedGitInfo.fromUrl(name) - - installGitPackage: (packageUrl, options, callback, version) -> - tasks = [] - - cloneDir = temp.mkdirSync("atom-git-package-clone-") - - tasks.push (data, next) => - urls = @getNormalizedGitUrls(packageUrl) - @cloneFirstValidGitUrl urls, cloneDir, options, (err) -> - next(err, data) - - tasks.push (data, next) => - if version - repo = Git.open(cloneDir) - data.sha = version - checked = repo.checkoutRef("refs/tags/#{version}", false) or - repo.checkoutReference(version, false) - error = "Can't find the branch, tag, or commit referenced by #{version}" unless checked - next(error, data) - else - @getRepositoryHeadSha cloneDir, (err, sha) -> - data.sha = sha - next(err, data) - - tasks.push (data, next) => - @installGitPackageDependencies cloneDir, options, (err) -> - next(err, data) - - tasks.push (data, next) -> - metadataFilePath = CSON.resolve(path.join(cloneDir, 'package')) - CSON.readFile metadataFilePath, (err, metadata) -> - data.metadataFilePath = metadataFilePath - data.metadata = metadata - next(err, data) - - tasks.push (data, next) -> - data.metadata.apmInstallSource = - type: "git" - source: packageUrl - sha: data.sha - CSON.writeFile data.metadataFilePath, data.metadata, (err) -> - next(err, data) - - tasks.push (data, next) => - {name} = data.metadata - targetDir = path.join(@atomPackagesDirectory, name) - process.stdout.write "Moving #{name} to #{targetDir} " unless options.argv.json - fs.cp cloneDir, targetDir, (err) => - if err - next(err) - else - @logSuccess() unless options.argv.json - json = {installPath: targetDir, metadata: data.metadata} - next(null, json) - - iteratee = (currentData, task, next) -> task(currentData, next) - async.reduce tasks, {}, iteratee, callback - - getNormalizedGitUrls: (packageUrl) -> - packageInfo = @getHostedGitInfo(packageUrl) - - if packageUrl.indexOf('file://') is 0 - [packageUrl] - else if packageInfo.default is 'sshurl' - [packageInfo.toString()] - else if packageInfo.default is 'https' - [packageInfo.https().replace(/^git\+https:/, "https:")] - else if packageInfo.default is 'shortcut' - [ - packageInfo.https().replace(/^git\+https:/, "https:"), - packageInfo.sshurl() - ] - - cloneFirstValidGitUrl: (urls, cloneDir, options, callback) -> - async.detectSeries(urls, (url, next) => - @cloneNormalizedUrl url, cloneDir, options, (error) -> - next(null, not error) - , (err, result) -> - if err or not result - invalidUrls = "Couldn't clone #{urls.join(' or ')}" - invalidUrlsError = new Error(invalidUrls) - callback(invalidUrlsError) - else - callback() - ) - - cloneNormalizedUrl: (url, cloneDir, options, callback) -> - # Require here to avoid circular dependency - Develop = require './develop' - develop = new Develop() - - develop.cloneRepository url, cloneDir, options, (err) -> - callback(err) - - installGitPackageDependencies: (directory, options, callback) => - options.cwd = directory - @installDependencies(options, callback) - - getRepositoryHeadSha: (repoDir, callback) -> - try - repo = Git.open(repoDir) - sha = repo.getReferenceTarget("HEAD") - callback(null, sha) - catch err - callback(err) - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - packagesFilePath = options.argv['packages-file'] - - @createAtomDirectories() - - if options.argv.check - config.loadNpm (error, @npm) => - @loadInstalledAtomMetadata => - @checkNativeBuildTools(callback) - return - - @verbose = options.argv.verbose - if @verbose - request.debug(true) - process.env.NODE_DEBUG = 'request' - - installPackage = (name, nextInstallStep) => - gitPackageInfo = @getHostedGitInfo(name) - - if gitPackageInfo or name.indexOf('file://') is 0 - @installGitPackage name, options, nextInstallStep, options.argv.branch or options.argv.tag - else if name is '.' - @installDependencies(options, nextInstallStep) - else # is registered package - atIndex = name.indexOf('@') - if atIndex > 0 - version = name.substring(atIndex + 1) - name = name.substring(0, atIndex) - - @isBundledPackage name, (isBundledPackage) => - if isBundledPackage - console.error """ - The #{name} package is bundled with Atom and should not be explicitly installed. - You can run `ppm uninstall #{name}` to uninstall it and then the version bundled - with Atom will be used. - """.yellow - @installRegisteredPackage({name, version}, options, nextInstallStep) - - if packagesFilePath - try - packageNames = @packageNamesFromPath(packagesFilePath) - catch error - return callback(error) - else - packageNames = @packageNamesFromArgv(options.argv) - packageNames.push('.') if packageNames.length is 0 - - commands = [] - commands.push (callback) => config.loadNpm (error, @npm) => callback(error) - commands.push (callback) => @loadInstalledAtomMetadata -> callback() - packageNames.forEach (packageName) -> - commands.push (callback) -> installPackage(packageName, callback) - iteratee = (item, next) -> item(next) - async.mapSeries commands, iteratee, (err, installedPackagesInfo) -> - return callback(err) if err - installedPackagesInfo = _.compact(installedPackagesInfo) - installedPackagesInfo = installedPackagesInfo.filter (item, idx) -> - packageNames[idx] isnt "." - console.log(JSON.stringify(installedPackagesInfo, null, " ")) if options.argv.json - callback(null) diff --git a/src/install.js b/src/install.js new file mode 100644 index 00000000..3b3d55d3 --- /dev/null +++ b/src/install.js @@ -0,0 +1,758 @@ + +const assert = require('assert'); +const path = require('path'); + +const _ = require('underscore-plus'); +const async = require('async'); +const CSON = require('season'); +const yargs = require('yargs'); +const Git = require('git-utils'); +const semver = require('semver'); +const temp = require('temp'); +const hostedGitInfo = require('hosted-git-info'); + +const config = require('./apm'); +const Command = require('./command'); +const fs = require('./fs'); +const RebuildModuleCache = require('./rebuild-module-cache'); +const request = require('./request'); +const {isDeprecatedPackage} = require('./deprecated-packages'); + +module.exports = +class Install extends Command { + static commandNames = [ "install", "i" ]; + + constructor() { + super(); + this.installModules = this.installModules.bind(this); + this.installGitPackageDependencies = this.installGitPackageDependencies.bind(this); + this.atomDirectory = config.getAtomDirectory(); + this.atomPackagesDirectory = path.join(this.atomDirectory, 'packages'); + this.atomNodeDirectory = path.join(this.atomDirectory, '.node-gyp'); + this.atomNpmPath = require.resolve('npm/bin/npm-cli'); + this.repoLocalPackagePathRegex = /^file:(?!\/\/)(.*)/; + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm install [...] + ppm install @ + ppm install [-b ] + ppm install / [-b ] + ppm install --packages-file my-packages.txt + ppm i (with any of the previous argument usage) + +Install the given Pulsar package to ~/.pulsar/packages/. + +If no package name is given then all the dependencies in the package.json +file are installed to the node_modules folder in the current working +directory. + +A packages file can be specified that is a newline separated list of +package names to install with optional versions using the +\`package-name@version\` syntax.\ +` + ); + options.alias('c', 'compatible').string('compatible').describe('compatible', 'Only install packages/themes compatible with this Pulsar version'); + options.alias('h', 'help').describe('help', 'Print this usage message'); + options.alias('s', 'silent').boolean('silent').describe('silent', 'Set the npm log level to silent'); + options.alias('b', 'branch').string('branch').describe('branch', 'Sets the tag or branch to install'); + options.alias('t', 'tag').string('tag').describe('tag', 'Sets the tag or branch to install'); + options.alias('q', 'quiet').boolean('quiet').describe('quiet', 'Set the npm log level to warn'); + options.boolean('check').describe('check', 'Check that native build tools are installed'); + options.boolean('verbose').default('verbose', false).describe('verbose', 'Show verbose debug information'); + options.string('packages-file').describe('packages-file', 'A text file containing the packages to install'); + return options.boolean('production').describe('production', 'Do not install dev dependencies'); + } + + installModule(options, pack, moduleURI, callback) { + let installDirectory, nodeModulesDirectory; + const installGlobally = options.installGlobally != null ? options.installGlobally : true; + + const installArgs = ['--globalconfig', config.getGlobalConfigPath(), '--userconfig', config.getUserConfigPath(), 'install']; + installArgs.push(moduleURI); + installArgs.push(...this.getNpmBuildFlags()); + if (installGlobally) { installArgs.push("--global-style"); } + if (options.argv.silent) { installArgs.push('--silent'); } + if (options.argv.quiet) { installArgs.push('--quiet'); } + if (options.argv.production) { installArgs.push('--production'); } + if (options.argv.verbose) { installArgs.push('--verbose'); } + + fs.makeTreeSync(this.atomDirectory); + + const env = _.extend({}, process.env, {HOME: this.atomNodeDirectory, RUSTUP_HOME: config.getRustupHomeDirPath()}); + this.addBuildEnvVars(env); + + const installOptions = {env}; + if (this.verbose) { installOptions.streaming = true; } + + if (installGlobally) { + installDirectory = temp.mkdirSync('apm-install-dir-'); + nodeModulesDirectory = path.join(installDirectory, 'node_modules'); + fs.makeTreeSync(nodeModulesDirectory); + installOptions.cwd = installDirectory; + } + + return this.fork(this.atomNpmPath, installArgs, installOptions, (code, stderr, stdout) => { + if (stderr == null) { stderr = ''; } + if (stdout == null) { stdout = ''; } + if (code === 0) { + let child, destination; + if (installGlobally) { + const commands = []; + const children = fs.readdirSync(nodeModulesDirectory) + .filter(dir => dir !== ".bin"); + assert.equal(children.length, 1, "Expected there to only be one child in node_modules"); + child = children[0]; + const source = path.join(nodeModulesDirectory, child); + destination = path.join(this.atomPackagesDirectory, child); + commands.push(next => fs.cp(source, destination, next)); + commands.push(next => this.buildModuleCache(pack.name, next)); + commands.push(next => this.warmCompileCache(pack.name, next)); + + return async.waterfall(commands, error => { + if (error != null) { + this.logFailure(); + } else { + if (!options.argv.json) { this.logSuccess(); } + } + return callback(error, {name: child, installPath: destination}); + }); + } else { + return callback(null, {name: child, installPath: destination}); + } + } else { + if (installGlobally) { + fs.removeSync(installDirectory); + this.logFailure(); + } + + let error = `${stdout}\n${stderr}`; + if (error.indexOf('code ENOGIT') !== -1) { error = this.getGitErrorMessage(pack); } + return callback(error); + } + }); + } + + getGitErrorMessage(pack) { + let message = `\ +Failed to install ${pack.name} because Git was not found. + +The ${pack.name} package has module dependencies that cannot be installed without Git. + +You need to install Git and add it to your path environment variable in order to install this package. +\ +`; + + switch (process.platform) { + case 'win32': + message += `\ + +You can install Git by downloading, installing, and launching GitHub for Windows: https://windows.github.com +\ +`; + break; + case 'linux': + message += `\ + +You can install Git from your OS package manager. +\ +`; + break; + } + + message += `\ + +Run ppm -v after installing Git to see what version has been detected.\ +`; + + return message; + } + + installModules(options, callback) { + if (!options.argv.json) { process.stdout.write('Installing modules '); } + + return this.forkInstallCommand(options, (...args) => { + if (options.argv.json) { + return this.logCommandResultsIfFail(callback, ...args); + } else { + return this.logCommandResults(callback, ...args); + } + }); + } + + forkInstallCommand(options, callback) { + const installArgs = ['--globalconfig', config.getGlobalConfigPath(), '--userconfig', config.getUserConfigPath(), 'install']; + installArgs.push(...this.getNpmBuildFlags()); + if (options.argv.silent) { installArgs.push('--silent'); } + if (options.argv.quiet) { installArgs.push('--quiet'); } + if (options.argv.production) { installArgs.push('--production'); } + + fs.makeTreeSync(this.atomDirectory); + + const env = _.extend({}, process.env, {HOME: this.atomNodeDirectory, RUSTUP_HOME: config.getRustupHomeDirPath()}); + this.addBuildEnvVars(env); + + const installOptions = {env}; + if (options.cwd) { installOptions.cwd = options.cwd; } + if (this.verbose) { installOptions.streaming = true; } + + return this.fork(this.atomNpmPath, installArgs, installOptions, callback); + } + + // Request package information from the package API for a given package name. + // + // packageName - The string name of the package to request. + // callback - The function to invoke when the request completes with an error + // as the first argument and an object as the second. + requestPackage(packageName, callback) { + const requestSettings = { + url: `${config.getAtomPackagesUrl()}/${packageName}`, + json: true, + retries: 4 + }; + return request.get(requestSettings, function(error, response, body) { + let message; + if (body == null) { body = {}; } + if (error != null) { + message = `Request for package information failed: ${error.message}`; + if (error.code) { message += ` (${error.code})`; } + return callback(message); + } else if (response.statusCode !== 200) { + message = request.getErrorMessage(response, body); + return callback(`Request for package information failed: ${message}`); + } else { + if (body.releases.latest) { + return callback(null, body); + } else { + return callback(`No releases available for ${packageName}`); + } + } + }); + } + + // Is the package at the specified version already installed? + // + // * packageName: The string name of the package. + // * packageVersion: The string version of the package. + isPackageInstalled(packageName, packageVersion) { + try { + let left; + const {version} = (left = CSON.readFileSync(CSON.resolve(path.join('node_modules', packageName, 'package')))) != null ? left : {}; + return packageVersion === version; + } catch (error) { + return false; + } + } + + // Install the package with the given name and optional version + // + // metadata - The package metadata object with at least a name key. A version + // key is also supported. The version defaults to the latest if + // unspecified. + // options - The installation options object. + // callback - The function to invoke when installation completes with an + // error as the first argument. + installRegisteredPackage(metadata, options, callback) { + const packageName = metadata.name; + let packageVersion = metadata.version; + + const installGlobally = options.installGlobally != null ? options.installGlobally : true; + if (!installGlobally) { + if (packageVersion && this.isPackageInstalled(packageName, packageVersion)) { + callback(null, {}); + return; + } + } + + let label = packageName; + if (packageVersion) { label += `@${packageVersion}`; } + if (!options.argv.json) { + process.stdout.write(`Installing ${label} `); + if (installGlobally) { + process.stdout.write(`to ${this.atomPackagesDirectory} `); + } + } + + return this.requestPackage(packageName, (error, pack) => { + if (error != null) { + this.logFailure(); + return callback(error); + } else { + if (packageVersion == null) { packageVersion = this.getLatestCompatibleVersion(pack); } + if (!packageVersion) { + this.logFailure(); + callback(`No available version compatible with the installed Atom version: ${this.installedAtomVersion}`); + return; + } + + const {tarball} = pack.versions[packageVersion]?.dist != null ? pack.versions[packageVersion]?.dist : {}; + if (!tarball) { + this.logFailure(); + callback(`Package version: ${packageVersion} not found`); + return; + } + + const commands = []; + commands.push(next => this.installModule(options, pack, tarball, next)); + if (installGlobally && (packageName.localeCompare(pack.name, 'en', {sensitivity: 'accent'}) !== 0)) { + commands.push((newPack, next) => { // package was renamed; delete old package folder + fs.removeSync(path.join(this.atomPackagesDirectory, packageName)); + return next(null, newPack); + }); + } + commands.push(function({installPath}, next) { + if (installPath != null) { + metadata = JSON.parse(fs.readFileSync(path.join(installPath, 'package.json'), 'utf8')); + const json = {installPath, metadata}; + return next(null, json); + } else { + return next(null, {}); + } + }); // installed locally, no install path data + + return async.waterfall(commands, (error, json) => { + if (!installGlobally) { + if (error != null) { + this.logFailure(); + } else { + if (!options.argv.json) { this.logSuccess(); } + } + } + return callback(error, json); + }); + } + }); + } + + // Install the package with the given name and local path + // + // packageName - The name of the package + // packagePath - The local path of the package in the form "file:./packages/package-name" + // options - The installation options object. + // callback - The function to invoke when installation completes with an + // error as the first argument. + installLocalPackage(packageName, packagePath, options, callback) { + if (!options.argv.json) { + process.stdout.write(`Installing ${packageName} from ${packagePath.slice('file:'.length)} `); + const commands = []; + commands.push(next => { + return this.installModule(options, {name: packageName}, packagePath, next); + }); + commands.push(function({installPath}, next) { + if (installPath != null) { + const metadata = JSON.parse(fs.readFileSync(path.join(installPath, 'package.json'), 'utf8')); + const json = {installPath, metadata}; + return next(null, json); + } else { + return next(null, {}); + } + }); // installed locally, no install path data + + return async.waterfall(commands, (error, json) => { + if (error != null) { + this.logFailure(); + } else { + if (!options.argv.json) { this.logSuccess(); } + } + return callback(error, json); + }); + } + } + + // Install all the package dependencies found in the package.json file. + // + // options - The installation options + // callback - The callback function to invoke when done with an error as the + // first argument. + installPackageDependencies(options, callback) { + options = _.extend({}, options, {installGlobally: false}); + const commands = []; + const object = this.getPackageDependencies(options.cwd); + for (let name in object) { + const version = object[name]; + ((name, version) => { + return commands.push(next => { + if (this.repoLocalPackagePathRegex.test(version)) { + return this.installLocalPackage(name, version, options, next); + } else { + return this.installRegisteredPackage({name, version}, options, next); + } + }); + })(name, version); + } + + return async.series(commands, callback); + } + + installDependencies(options, callback) { + options.installGlobally = false; + const commands = []; + commands.push(callback => this.installModules(options, callback)); + commands.push(callback => this.installPackageDependencies(options, callback)); + + return async.waterfall(commands, callback); + } + + // Get all package dependency names and versions from the package.json file. + getPackageDependencies(cloneDir) { + try { + let left; + const fileName = path.join((cloneDir || '.'), 'package.json'); + const metadata = fs.readFileSync(fileName, 'utf8'); + const {packageDependencies, dependencies} = (left = JSON.parse(metadata)) != null ? left : {}; + + if (!packageDependencies) { return {}; } + if (!dependencies) { return packageDependencies; } + + // This code filters out any `packageDependencies` that have an equivalent + // normalized repo-local package path entry in the `dependencies` section of + // `package.json`. Versioned `packageDependencies` are always returned. + const filteredPackages = {}; + for (let packageName in packageDependencies) { + const packageSpec = packageDependencies[packageName]; + const dependencyPath = this.getRepoLocalPackagePath(dependencies[packageName]); + const packageDependencyPath = this.getRepoLocalPackagePath(packageSpec); + if (!packageDependencyPath || (dependencyPath !== packageDependencyPath)) { + filteredPackages[packageName] = packageSpec; + } + } + + return filteredPackages; + } catch (error) { + return {}; + } + } + + getRepoLocalPackagePath(packageSpec) { + if (!packageSpec) { return undefined; } + const repoLocalPackageMatch = packageSpec.match(this.repoLocalPackagePathRegex); + if (repoLocalPackageMatch) { + return path.normalize(repoLocalPackageMatch[1]); + } else { + return undefined; + } + } + + createAtomDirectories() { + fs.makeTreeSync(this.atomDirectory); + fs.makeTreeSync(this.atomPackagesDirectory); + return fs.makeTreeSync(this.atomNodeDirectory); + } + + // Compile a sample native module to see if a useable native build toolchain + // is instlalled and successfully detected. This will include both Python + // and a compiler. + checkNativeBuildTools(callback) { + process.stdout.write('Checking for native build tools '); + + const buildArgs = ['--globalconfig', config.getGlobalConfigPath(), '--userconfig', config.getUserConfigPath(), 'build']; + buildArgs.push(path.resolve(__dirname, '..', 'native-module')); + buildArgs.push(...this.getNpmBuildFlags()); + + fs.makeTreeSync(this.atomDirectory); + + const env = _.extend({}, process.env, {HOME: this.atomNodeDirectory, RUSTUP_HOME: config.getRustupHomeDirPath()}); + this.addBuildEnvVars(env); + + const buildOptions = {env}; + if (this.verbose) { buildOptions.streaming = true; } + + fs.removeSync(path.resolve(__dirname, '..', 'native-module', 'build')); + + return this.fork(this.atomNpmPath, buildArgs, buildOptions, (...args) => { + return this.logCommandResults(callback, ...args); + }); + } + + packageNamesFromPath(filePath) { + filePath = path.resolve(filePath); + + if (!fs.isFileSync(filePath)) { + throw new Error(`File '${filePath}' does not exist`); + } + + const packages = fs.readFileSync(filePath, 'utf8'); + return this.sanitizePackageNames(packages.split(/\s/)); + } + + buildModuleCache(packageName, callback) { + const packageDirectory = path.join(this.atomPackagesDirectory, packageName); + const rebuildCacheCommand = new RebuildModuleCache(); + return rebuildCacheCommand.rebuild(packageDirectory, () => // Ignore cache errors and just finish the install + callback()); + } + + warmCompileCache(packageName, callback) { + const packageDirectory = path.join(this.atomPackagesDirectory, packageName); + + return this.getResourcePath(resourcePath => { + try { + const CompileCache = require(path.join(resourcePath, 'src', 'compile-cache')); + + const onDirectory = directoryPath => path.basename(directoryPath) !== 'node_modules'; + + const onFile = filePath => { + try { + return CompileCache.addPathToCache(filePath, this.atomDirectory); + } catch (error) {} + }; + + fs.traverseTreeSync(packageDirectory, onFile, onDirectory); + } catch (error) {} + return callback(null); + }); + } + + isBundledPackage(packageName, callback) { + return this.getResourcePath(function(resourcePath) { + let atomMetadata; + try { + atomMetadata = JSON.parse(fs.readFileSync(path.join(resourcePath, 'package.json'))); + } catch (error) { + return callback(false); + } + + return callback(atomMetadata?.packageDependencies?.hasOwnProperty(packageName)); + }); + } + + getLatestCompatibleVersion(pack) { + if (!this.installedAtomVersion) { + if (isDeprecatedPackage(pack.name, pack.releases.latest)) { + return null; + } else { + return pack.releases.latest; + } + } + + let latestVersion = null; + const object = pack.versions != null ? pack.versions : {}; + for (let version in object) { + const metadata = object[version]; + if (!semver.valid(version)) { continue; } + if (!metadata) { continue; } + if (isDeprecatedPackage(pack.name, version)) { continue; } + + const { + engines + } = metadata; + const engine = engines?.pulsar || engines?.atom || '*'; + if (!semver.validRange(engine)) { continue; } + if (!semver.satisfies(this.installedAtomVersion, engine)) { continue; } + + if (latestVersion == null) { latestVersion = version; } + if (semver.gt(version, latestVersion)) { latestVersion = version; } + } + + return latestVersion; + } + + getHostedGitInfo(name) { + return hostedGitInfo.fromUrl(name); + } + + installGitPackage(packageUrl, options, callback, version) { + const tasks = []; + + const cloneDir = temp.mkdirSync("atom-git-package-clone-"); + + tasks.push((data, next) => { + const urls = this.getNormalizedGitUrls(packageUrl); + return this.cloneFirstValidGitUrl(urls, cloneDir, options, err => next(err, data)); + }); + + tasks.push((data, next) => { + if (version) { + let error; + const repo = Git.open(cloneDir); + data.sha = version; + const checked = repo.checkoutRef(`refs/tags/${version}`, false) || + repo.checkoutReference(version, false); + if (!checked) { error = `Can't find the branch, tag, or commit referenced by ${version}`; } + return next(error, data); + } else { + return this.getRepositoryHeadSha(cloneDir, function(err, sha) { + data.sha = sha; + return next(err, data); + }); + } + }); + + tasks.push((data, next) => { + return this.installGitPackageDependencies(cloneDir, options, err => next(err, data)); + }); + + tasks.push(function(data, next) { + const metadataFilePath = CSON.resolve(path.join(cloneDir, 'package')); + return CSON.readFile(metadataFilePath, function(err, metadata) { + data.metadataFilePath = metadataFilePath; + data.metadata = metadata; + return next(err, data); + }); + }); + + tasks.push(function(data, next) { + data.metadata.apmInstallSource = { + type: "git", + source: packageUrl, + sha: data.sha + }; + return CSON.writeFile(data.metadataFilePath, data.metadata, err => next(err, data)); + }); + + tasks.push((data, next) => { + const {name} = data.metadata; + const targetDir = path.join(this.atomPackagesDirectory, name); + if (!options.argv.json) { process.stdout.write(`Moving ${name} to ${targetDir} `); } + return fs.cp(cloneDir, targetDir, err => { + if (err) { + return next(err); + } else { + if (!options.argv.json) { this.logSuccess(); } + const json = {installPath: targetDir, metadata: data.metadata}; + return next(null, json); + } + }); + }); + + const iteratee = (currentData, task, next) => task(currentData, next); + return async.reduce(tasks, {}, iteratee, callback); + } + + getNormalizedGitUrls(packageUrl) { + const packageInfo = this.getHostedGitInfo(packageUrl); + + if (packageUrl.indexOf('file://') === 0) { + return [packageUrl]; + } else if (packageInfo.default === 'sshurl') { + return [packageInfo.toString()]; + } else if (packageInfo.default === 'https') { + return [packageInfo.https().replace(/^git\+https:/, "https:")]; + } else if (packageInfo.default === 'shortcut') { + return [ + packageInfo.https().replace(/^git\+https:/, "https:"), + packageInfo.sshurl() + ]; + } + } + + cloneFirstValidGitUrl(urls, cloneDir, options, callback) { + return async.detectSeries(urls, (url, next) => { + return this.cloneNormalizedUrl(url, cloneDir, options, error => next(null, !error)); + } + , function(err, result) { + if (err || !result) { + const invalidUrls = `Couldn't clone ${urls.join(' or ')}`; + const invalidUrlsError = new Error(invalidUrls); + return callback(invalidUrlsError); + } else { + return callback(); + } + }); + } + + cloneNormalizedUrl(url, cloneDir, options, callback) { + // Require here to avoid circular dependency + const Develop = require('./develop'); + const develop = new Develop(); + + return develop.cloneRepository(url, cloneDir, options, err => callback(err)); + } + + installGitPackageDependencies(directory, options, callback) { + options.cwd = directory; + return this.installDependencies(options, callback); + } + + getRepositoryHeadSha(repoDir, callback) { + try { + const repo = Git.open(repoDir); + const sha = repo.getReferenceTarget("HEAD"); + return callback(null, sha); + } catch (err) { + return callback(err); + } + } + + run(options) { + let packageNames; + const {callback} = options; + options = this.parseOptions(options.commandArgs); + const packagesFilePath = options.argv['packages-file']; + + this.createAtomDirectories(); + + if (options.argv.check) { + config.loadNpm((error, npm) => { + this.npm = npm; + return this.loadInstalledAtomMetadata(() => { + return this.checkNativeBuildTools(callback); + }); + }); + return; + } + + this.verbose = options.argv.verbose; + if (this.verbose) { + request.debug(true); + process.env.NODE_DEBUG = 'request'; + } + + const installPackage = (name, nextInstallStep) => { + const gitPackageInfo = this.getHostedGitInfo(name); + + if (gitPackageInfo || (name.indexOf('file://') === 0)) { + return this.installGitPackage(name, options, nextInstallStep, options.argv.branch || options.argv.tag); + } else if (name === '.') { + return this.installDependencies(options, nextInstallStep); + } else { // is registered package + let version; + const atIndex = name.indexOf('@'); + if (atIndex > 0) { + version = name.substring(atIndex + 1); + name = name.substring(0, atIndex); + } + + return this.isBundledPackage(name, isBundledPackage => { + if (isBundledPackage) { + console.error(`\ +The ${name} package is bundled with Pulsar and should not be explicitly installed. +You can run \`ppm uninstall ${name}\` to uninstall it and then the version bundled +with Pulsar will be used.\ +`.yellow + ); + } + return this.installRegisteredPackage({name, version}, options, nextInstallStep); + }); + } + }; + + if (packagesFilePath) { + try { + packageNames = this.packageNamesFromPath(packagesFilePath); + } catch (error1) { + const error = error1; + return callback(error); + } + } else { + packageNames = this.packageNamesFromArgv(options.argv); + if (packageNames.length === 0) { packageNames.push('.'); } + } + + const commands = []; + commands.push(callback => { return config.loadNpm((error, npm) => { this.npm = npm; return callback(error); }); }); + commands.push(callback => this.loadInstalledAtomMetadata(() => callback())); + packageNames.forEach(packageName => commands.push(callback => installPackage(packageName, callback))); + const iteratee = (item, next) => item(next); + return async.mapSeries(commands, iteratee, function(err, installedPackagesInfo) { + if (err) { return callback(err); } + installedPackagesInfo = _.compact(installedPackagesInfo); + installedPackagesInfo = installedPackagesInfo.filter((item, idx) => packageNames[idx] !== "."); + if (options.argv.json) { console.log(JSON.stringify(installedPackagesInfo, null, " ")); } + return callback(null); + }); + } + } diff --git a/src/link.coffee b/src/link.coffee deleted file mode 100644 index 406d0741..00000000 --- a/src/link.coffee +++ /dev/null @@ -1,56 +0,0 @@ -path = require 'path' - -CSON = require 'season' -yargs = require 'yargs' - -Command = require './command' -config = require './apm' -fs = require './fs' - -module.exports = -class Link extends Command - @commandNames: ['link', 'ln'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm link [] [--name ] - - Create a symlink for the package in ~/.pulsar/packages. The package in the - current working directory is linked if no path is given. - - Run `ppm links` to view all the currently linked packages. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - options.alias('d', 'dev').boolean('dev').describe('dev', 'Link to ~/.pulsar/dev/packages') - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - - packagePath = options.argv._[0]?.toString() ? '.' - linkPath = path.resolve(process.cwd(), packagePath) - - packageName = options.argv.name - try - packageName = CSON.readFileSync(CSON.resolve(path.join(linkPath, 'package'))).name unless packageName - packageName = path.basename(linkPath) unless packageName - - if options.argv.dev - targetPath = path.join(config.getAtomDirectory(), 'dev', 'packages', packageName) - else - targetPath = path.join(config.getAtomDirectory(), 'packages', packageName) - - unless fs.existsSync(linkPath) - callback("Package directory does not exist: #{linkPath}") - return - - try - fs.unlinkSync(targetPath) if fs.isSymbolicLinkSync(targetPath) - fs.makeTreeSync path.dirname(targetPath) - fs.symlinkSync(linkPath, targetPath, 'junction') - console.log "#{targetPath} -> #{linkPath}" - callback() - catch error - callback("Linking #{targetPath} to #{linkPath} failed: #{error.message}") diff --git a/src/link.js b/src/link.js new file mode 100644 index 00000000..9a5f09a2 --- /dev/null +++ b/src/link.js @@ -0,0 +1,66 @@ + +const path = require('path'); + +const CSON = require('season'); +const yargs = require('yargs'); + +const Command = require('./command'); +const config = require('./apm'); +const fs = require('./fs'); + +module.exports = +class Link extends Command { + static commandNames = [ "link", "ln" ]; + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm link [] [--name ] + +Create a symlink for the package in ~/.pulsar/packages. The package in the +current working directory is linked if no path is given. + +Run \`ppm links\` to view all the currently linked packages.\ +` + ); + options.alias('h', 'help').describe('help', 'Print this usage message'); + return options.alias('d', 'dev').boolean('dev').describe('dev', 'Link to ~/.pulsar/dev/packages'); + } + + run(options) { + let targetPath; + const {callback} = options; + options = this.parseOptions(options.commandArgs); + + const packagePath = options.argv._[0]?.toString() ?? "."; + const linkPath = path.resolve(process.cwd(), packagePath); + + let packageName = options.argv.name; + try { + if (!packageName) { packageName = CSON.readFileSync(CSON.resolve(path.join(linkPath, 'package'))).name; } + } catch (error) {} + if (!packageName) { packageName = path.basename(linkPath); } + + if (options.argv.dev) { + targetPath = path.join(config.getAtomDirectory(), 'dev', 'packages', packageName); + } else { + targetPath = path.join(config.getAtomDirectory(), 'packages', packageName); + } + + if (!fs.existsSync(linkPath)) { + callback(`Package directory does not exist: ${linkPath}`); + return; + } + + try { + if (fs.isSymbolicLinkSync(targetPath)) { fs.unlinkSync(targetPath); } + fs.makeTreeSync(path.dirname(targetPath)); + fs.symlinkSync(linkPath, targetPath, 'junction'); + console.log(`${targetPath} -> ${linkPath}`); + return callback(); + } catch (error) { + return callback(`Linking ${targetPath} to ${linkPath} failed: ${error.message}`); + } + } + } diff --git a/src/links.coffee b/src/links.coffee deleted file mode 100644 index eb9f0ec0..00000000 --- a/src/links.coffee +++ /dev/null @@ -1,56 +0,0 @@ -path = require 'path' - -yargs = require 'yargs' - -Command = require './command' -config = require './apm' -fs = require './fs' -tree = require './tree' - -module.exports = -class Links extends Command - @commandNames: ['linked', 'links', 'lns'] - - constructor: -> - super() - @devPackagesPath = path.join(config.getAtomDirectory(), 'dev', 'packages') - @packagesPath = path.join(config.getAtomDirectory(), 'packages') - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm links - - List all of the symlinked atom packages in ~/.atom/packages and - ~/.pulsar/dev/packages. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - - getDevPackagePath: (packageName) -> path.join(@devPackagesPath, packageName) - - getPackagePath: (packageName) -> path.join(@packagesPath, packageName) - - getSymlinks: (directoryPath) -> - symlinks = [] - for directory in fs.list(directoryPath) - symlinkPath = path.join(directoryPath, directory) - symlinks.push(symlinkPath) if fs.isSymbolicLinkSync(symlinkPath) - symlinks - - logLinks: (directoryPath) -> - links = @getSymlinks(directoryPath) - console.log "#{directoryPath.cyan} (#{links.length})" - tree links, emptyMessage: '(no links)', (link) -> - try - realpath = fs.realpathSync(link) - catch error - realpath = '???'.red - "#{path.basename(link).yellow} -> #{realpath}" - - run: (options) -> - {callback} = options - - @logLinks(@devPackagesPath) - @logLinks(@packagesPath) - callback() diff --git a/src/links.js b/src/links.js new file mode 100644 index 00000000..e1d32094 --- /dev/null +++ b/src/links.js @@ -0,0 +1,68 @@ + +const path = require('path'); + +const yargs = require('yargs'); + +const Command = require('./command'); +const config = require('./apm'); +const fs = require('./fs'); +const tree = require('./tree'); + +module.exports = +class Links extends Command { + static commandNames = [ "linked", "links", "lns" ]; + + constructor() { + super(); + this.devPackagesPath = path.join(config.getAtomDirectory(), 'dev', 'packages'); + this.packagesPath = path.join(config.getAtomDirectory(), 'packages'); + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm links + +List all of the symlinked atom packages in ~/.atom/packages and +~/.pulsar/dev/packages.\ +` + ); + return options.alias('h', 'help').describe('help', 'Print this usage message'); + } + + getDevPackagePath(packageName) { return path.join(this.devPackagesPath, packageName); } + + getPackagePath(packageName) { return path.join(this.packagesPath, packageName); } + + getSymlinks(directoryPath) { + const symlinks = []; + for (let directory of fs.list(directoryPath)) { + const symlinkPath = path.join(directoryPath, directory); + if (fs.isSymbolicLinkSync(symlinkPath)) { symlinks.push(symlinkPath); } + } + return symlinks; + } + + logLinks(directoryPath) { + const links = this.getSymlinks(directoryPath); + console.log(`${directoryPath.cyan} (${links.length})`); + tree(links, {emptyMessage: '(no links)'}, function(link) { + let realpath; + try { + realpath = fs.realpathSync(link); + } catch (error) { + realpath = '???'.red; + } + return `${path.basename(link).yellow} -> ${realpath}`; + }); + } + + run(options) { + const {callback} = options; + + this.logLinks(this.devPackagesPath); + this.logLinks(this.packagesPath); + return callback(); + } + } diff --git a/src/list.coffee b/src/list.coffee deleted file mode 100644 index bbc6a517..00000000 --- a/src/list.coffee +++ /dev/null @@ -1,194 +0,0 @@ -path = require 'path' - -_ = require 'underscore-plus' -CSON = require 'season' -yargs = require 'yargs' - -Command = require './command' -fs = require './fs' -config = require './apm' -tree = require './tree' -{getRepository} = require "./packages" - -module.exports = -class List extends Command - @commandNames: ['list', 'ls'] - - constructor: -> - super() - @userPackagesDirectory = path.join(config.getAtomDirectory(), 'packages') - @devPackagesDirectory = path.join(config.getAtomDirectory(), 'dev', 'packages') - if configPath = CSON.resolve(path.join(config.getAtomDirectory(), 'config')) - try - @disabledPackages = CSON.readFileSync(configPath)?['*']?.core?.disabledPackages - @disabledPackages ?= [] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm list - ppm list --themes - ppm list --packages - ppm list --installed - ppm list --installed --enabled - ppm list --installed --bare > my-packages.txt - ppm list --json - - List all the installed packages and also the packages bundled with Atom. - """ - options.alias('b', 'bare').boolean('bare').describe('bare', 'Print packages one per line with no formatting') - options.alias('e', 'enabled').boolean('enabled').describe('enabled', 'Print only enabled packages') - options.alias('d', 'dev').boolean('dev').default('dev', true).describe('dev', 'Include dev packages') - options.boolean('disabled').describe('disabled', 'Print only disabled packages') - options.alias('h', 'help').describe('help', 'Print this usage message') - options.alias('i', 'installed').boolean('installed').describe('installed', 'Only list installed packages/themes') - options.alias('j', 'json').boolean('json').describe('json', 'Output all packages as a JSON object') - options.alias('l', 'links').boolean('links').default('links', true).describe('links', 'Include linked packages') - options.alias('t', 'themes').boolean('themes').describe('themes', 'Only list themes') - options.alias('p', 'packages').boolean('packages').describe('packages', 'Only list packages') - options.alias('v', 'versions').boolean('versions').default('versions', true).describe('versions', 'Include version of each package') - - isPackageDisabled: (name) -> - @disabledPackages.indexOf(name) isnt -1 - - logPackages: (packages, options) -> - if options.argv.bare - for pack in packages - packageLine = pack.name - packageLine += "@#{pack.version}" if pack.version? and options.argv.versions - console.log packageLine - else - tree packages, (pack) => - packageLine = pack.name - packageLine += "@#{pack.version}" if pack.version? and options.argv.versions - if pack.apmInstallSource?.type is 'git' - repo = getRepository(pack) - shaLine = "##{pack.apmInstallSource.sha.substr(0, 8)}" - shaLine = repo + shaLine if repo? - packageLine += " (#{shaLine})".grey - packageLine += ' (disabled)' if @isPackageDisabled(pack.name) and not options.argv.disabled - packageLine - console.log() - - checkExclusiveOptions: (options, positive_option, negative_option, value) -> - if options.argv[positive_option] - value - else if options.argv[negative_option] - not value - else - true - - isPackageVisible: (options, manifest) -> - @checkExclusiveOptions(options, 'themes', 'packages', manifest.theme) and - @checkExclusiveOptions(options, 'disabled', 'enabled', @isPackageDisabled(manifest.name)) - - listPackages: (directoryPath, options) -> - packages = [] - for child in fs.list(directoryPath) - continue unless fs.isDirectorySync(path.join(directoryPath, child)) - continue if child.match /^\./ - unless options.argv.links - continue if fs.isSymbolicLinkSync(path.join(directoryPath, child)) - - manifest = null - if manifestPath = CSON.resolve(path.join(directoryPath, child, 'package')) - try - manifest = CSON.readFileSync(manifestPath) - manifest ?= {} - manifest.name = child - - continue unless @isPackageVisible(options, manifest) - packages.push(manifest) - - packages - - listUserPackages: (options, callback) -> - userPackages = @listPackages(@userPackagesDirectory, options) - .filter (pack) -> not pack.apmInstallSource - unless options.argv.bare or options.argv.json - console.log "Community Packages (#{userPackages.length})".cyan, "#{@userPackagesDirectory}" - callback?(null, userPackages) - - listDevPackages: (options, callback) -> - return callback?(null, []) unless options.argv.dev - - devPackages = @listPackages(@devPackagesDirectory, options) - if devPackages.length > 0 - unless options.argv.bare or options.argv.json - console.log "Dev Packages (#{devPackages.length})".cyan, "#{@devPackagesDirectory}" - callback?(null, devPackages) - - listGitPackages: (options, callback) -> - gitPackages = @listPackages(@userPackagesDirectory, options) - .filter (pack) -> pack.apmInstallSource?.type is 'git' - if gitPackages.length > 0 - unless options.argv.bare or options.argv.json - console.log "Git Packages (#{gitPackages.length})".cyan, "#{@userPackagesDirectory}" - callback?(null, gitPackages) - - listBundledPackages: (options, callback) -> - config.getResourcePath (resourcePath) => - try - metadataPath = path.join(resourcePath, 'package.json') - {_atomPackages} = JSON.parse(fs.readFileSync(metadataPath)) - _atomPackages ?= {} - packages = (metadata for packageName, {metadata} of _atomPackages) - - packages = packages.filter (metadata) => - @isPackageVisible(options, metadata) - - unless options.argv.bare or options.argv.json - if options.argv.themes - console.log "#{'Built-in Atom Themes'.cyan} (#{packages.length})" - else - console.log "#{'Built-in Atom Packages'.cyan} (#{packages.length})" - - callback?(null, packages) - - listInstalledPackages: (options) -> - @listDevPackages options, (error, packages) => - @logPackages(packages, options) if packages.length > 0 - - @listUserPackages options, (error, packages) => - @logPackages(packages, options) - - @listGitPackages options, (error, packages) => - @logPackages(packages, options) if packages.length > 0 - - listPackagesAsJson: (options, callback = ->) -> - output = - core: [] - dev: [] - git: [] - user: [] - - @listBundledPackages options, (error, packages) => - return callback(error) if error - output.core = packages - @listDevPackages options, (error, packages) => - return callback(error) if error - output.dev = packages - @listUserPackages options, (error, packages) => - return callback(error) if error - output.user = packages - @listGitPackages options, (error, packages) -> - return callback(error) if error - output.git = packages - console.log JSON.stringify(output) - callback() - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - - if options.argv.json - @listPackagesAsJson(options, callback) - else if options.argv.installed - @listInstalledPackages(options) - callback() - else - @listBundledPackages options, (error, packages) => - @logPackages(packages, options) - @listInstalledPackages(options) - callback() diff --git a/src/list.js b/src/list.js new file mode 100644 index 00000000..d606ae29 --- /dev/null +++ b/src/list.js @@ -0,0 +1,258 @@ + +const path = require('path'); + +const _ = require('underscore-plus'); +const CSON = require('season'); +const yargs = require('yargs'); + +const Command = require('./command'); +const fs = require('./fs'); +const config = require('./apm'); +const tree = require('./tree'); +const {getRepository} = require("./packages"); + +module.exports = +class List extends Command { + static commandNames = [ "list", "ls" ]; + + constructor() { + let configPath; + super(); + this.userPackagesDirectory = path.join(config.getAtomDirectory(), 'packages'); + this.devPackagesDirectory = path.join(config.getAtomDirectory(), 'dev', 'packages'); + if (configPath = CSON.resolve(path.join(config.getAtomDirectory(), 'config'))) { + try { + this.disabledPackages = CSON.readFileSync(configPath)?.['*']?.core?.disabledPackages; + } catch (error) {} + } + if (this.disabledPackages == null) { this.disabledPackages = []; } + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm list + ppm list --themes + ppm list --packages + ppm list --installed + ppm list --installed --enabled + ppm list --installed --bare > my-packages.txt + ppm list --json + +List all the installed packages and also the packages bundled with Atom.\ +` + ); + options.alias('b', 'bare').boolean('bare').describe('bare', 'Print packages one per line with no formatting'); + options.alias('e', 'enabled').boolean('enabled').describe('enabled', 'Print only enabled packages'); + options.alias('d', 'dev').boolean('dev').default('dev', true).describe('dev', 'Include dev packages'); + options.boolean('disabled').describe('disabled', 'Print only disabled packages'); + options.alias('h', 'help').describe('help', 'Print this usage message'); + options.alias('i', 'installed').boolean('installed').describe('installed', 'Only list installed packages/themes'); + options.alias('j', 'json').boolean('json').describe('json', 'Output all packages as a JSON object'); + options.alias('l', 'links').boolean('links').default('links', true).describe('links', 'Include linked packages'); + options.alias('t', 'themes').boolean('themes').describe('themes', 'Only list themes'); + options.alias('p', 'packages').boolean('packages').describe('packages', 'Only list packages'); + return options.alias('v', 'versions').boolean('versions').default('versions', true).describe('versions', 'Include version of each package'); + } + + isPackageDisabled(name) { + return this.disabledPackages.indexOf(name) !== -1; + } + + logPackages(packages, options) { + if (options.argv.bare) { + return (() => { + const result = []; + for (let pack of Array.from(packages)) { + let packageLine = pack.name; + if ((pack.version != null) && options.argv.versions) { packageLine += `@${pack.version}`; } + result.push(console.log(packageLine)); + } + return result; + })(); + } else { + tree(packages, pack => { + let packageLine = pack.name; + if ((pack.version != null) && options.argv.versions) { packageLine += `@${pack.version}`; } + if (pack.apmInstallSource?.type === 'git') { + const repo = getRepository(pack); + let shaLine = `#${pack.apmInstallSource.sha.substr(0, 8)}`; + if (repo != null) { shaLine = repo + shaLine; } + packageLine += ` (${shaLine})`.grey; + } + if (this.isPackageDisabled(pack.name) && !options.argv.disabled) { packageLine += ' (disabled)'; } + return packageLine; + }); + return console.log(); + } + } + + checkExclusiveOptions(options, positive_option, negative_option, value) { + if (options.argv[positive_option]) { + return value; + } else if (options.argv[negative_option]) { + return !value; + } else { + return true; + } + } + + isPackageVisible(options, manifest) { + return this.checkExclusiveOptions(options, 'themes', 'packages', manifest.theme) && + this.checkExclusiveOptions(options, 'disabled', 'enabled', this.isPackageDisabled(manifest.name)); + } + + listPackages(directoryPath, options) { + const packages = []; + for (let child of Array.from(fs.list(directoryPath))) { + var manifestPath; + if (!fs.isDirectorySync(path.join(directoryPath, child))) { continue; } + if (child.match(/^\./)) { continue; } + if (!options.argv.links) { + if (fs.isSymbolicLinkSync(path.join(directoryPath, child))) { continue; } + } + + let manifest = null; + if (manifestPath = CSON.resolve(path.join(directoryPath, child, 'package'))) { + try { + manifest = CSON.readFileSync(manifestPath); + } catch (error) {} + } + if (manifest == null) { manifest = {}; } + manifest.name = child; + + if (!this.isPackageVisible(options, manifest)) { continue; } + packages.push(manifest); + } + + return packages; + } + + listUserPackages(options, callback) { + const userPackages = this.listPackages(this.userPackagesDirectory, options) + .filter(pack => !pack.apmInstallSource); + if (!options.argv.bare && !options.argv.json) { + console.log(`Community Packages (${userPackages.length})`.cyan, `${this.userPackagesDirectory}`); + } + return callback?.(null, userPackages); + } + + listDevPackages(options, callback) { + if (!options.argv.dev) { return callback?.(null, []); } + + const devPackages = this.listPackages(this.devPackagesDirectory, options); + if (devPackages.length > 0) { + if (!options.argv.bare && !options.argv.json) { + console.log(`Dev Packages (${devPackages.length})`.cyan, `${this.devPackagesDirectory}`); + } + } + return callback?.(null, devPackages); + } + + listGitPackages(options, callback) { + const gitPackages = this.listPackages(this.userPackagesDirectory, options) + .filter(pack => pack.apmInstallSource?.type === 'git'); + if (gitPackages.length > 0) { + if (!options.argv.bare && !options.argv.json) { + console.log(`Git Packages (${gitPackages.length})`.cyan, `${this.userPackagesDirectory}`); + } + } + return callback?.(null, gitPackages); + } + + listBundledPackages(options, callback) { + return config.getResourcePath(resourcePath => { + let _atomPackages; + let metadata; + try { + const metadataPath = path.join(resourcePath, 'package.json'); + ({_atomPackages} = JSON.parse(fs.readFileSync(metadataPath))); + } catch (error) {} + if (_atomPackages == null) { _atomPackages = {}; } + let packages = ((() => { + const result = []; + for (let packageName in _atomPackages) { + ({metadata} = _atomPackages[packageName]); + result.push(metadata); + } + return result; + })()); + + packages = packages.filter(metadata => { + return this.isPackageVisible(options, metadata); + }); + + if (!options.argv.bare && !options.argv.json) { + if (options.argv.themes) { + console.log(`${'Built-in Atom Themes'.cyan} (${packages.length})`); + } else { + console.log(`${'Built-in Atom Packages'.cyan} (${packages.length})`); + } + } + + return callback?.(null, packages); + }); + } + + listInstalledPackages(options) { + this.listDevPackages(options, (error, packages) => { + if (packages.length > 0) { this.logPackages(packages, options); } + + this.listUserPackages(options, (error, packages) => { + this.logPackages(packages, options); + + this.listGitPackages(options, (error, packages) => { + if (packages.length > 0) { return this.logPackages(packages, options); } + }); + }); + }); + } + + listPackagesAsJson(options, callback) { + if (callback == null) { callback = function() {}; } + const output = { + core: [], + dev: [], + git: [], + user: [] + }; + + this.listBundledPackages(options, (error, packages) => { + if (error) { return callback(error); } + output.core = packages; + this.listDevPackages(options, (error, packages) => { + if (error) { return callback(error); } + output.dev = packages; + this.listUserPackages(options, (error, packages) => { + if (error) { return callback(error); } + output.user = packages; + this.listGitPackages(options, function(error, packages) { + if (error) { return callback(error); } + output.git = packages; + console.log(JSON.stringify(output)); + return callback(); + }); + }); + }); + }); + } + + run(options) { + const {callback} = options; + options = this.parseOptions(options.commandArgs); + + if (options.argv.json) { + return this.listPackagesAsJson(options, callback); + } else if (options.argv.installed) { + this.listInstalledPackages(options); + return callback(); + } else { + this.listBundledPackages(options, (error, packages) => { + this.logPackages(packages, options); + this.listInstalledPackages(options); + return callback(); + }); + } + } + } diff --git a/src/login.coffee b/src/login.coffee deleted file mode 100644 index 80ee8c50..00000000 --- a/src/login.coffee +++ /dev/null @@ -1,83 +0,0 @@ -_ = require 'underscore-plus' -yargs = require 'yargs' -Q = require 'q' -read = require 'read' -open = require 'open' - -auth = require './auth' -Command = require './command' - -module.exports = -class Login extends Command - @getTokenOrLogin: (callback) -> - auth.getToken (error, token) -> - if error? - new Login().run({callback, commandArgs: []}) - else - callback(null, token) - - @commandNames: ['login'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - - options.usage """ - Usage: ppm login - - Enter your package API token and save it to the keychain. This token will - be used to identify you when publishing packages. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - options.string('token').describe('token', 'Package API token') - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - Q(token: options.argv.token) - .then(@welcomeMessage) - .then(@openURL) - .then(@getToken) - .then(@saveToken) - .then (token) -> callback(null, token) - .catch(callback) - - prompt: (options) -> - readPromise = Q.denodeify(read) - readPromise(options) - - welcomeMessage: (state) => - return Q(state) if state.token - - welcome = """ - Welcome to Pulsar! - - Before you can publish packages, you'll need an API token. - - Visit your account page on pulsar-edit.dev #{'https://web.pulsar-edit.dev/users'.underline}, - copy the token and paste it below when prompted. - - """ - console.log welcome - - @prompt({prompt: "Press [Enter] to open your account page."}) - - openURL: (state) -> - return Q(state) if state.token - - open('https://web.pulsar-edit.dev/users') - - getToken: (state) => - return Q(state) if state.token - - @prompt({prompt: 'Token>', edit: true}) - .spread (token) -> - state.token = token - Q(state) - - saveToken: ({token}) => - throw new Error("Token is required") unless token - - process.stdout.write('Saving token to Keychain ') - auth.saveToken(token) - @logSuccess() - Q(token) diff --git a/src/login.js b/src/login.js new file mode 100644 index 00000000..544dbe04 --- /dev/null +++ b/src/login.js @@ -0,0 +1,104 @@ + +const _ = require('underscore-plus'); +const yargs = require('yargs'); +const Q = require('q'); +const read = require('read'); +const open = require('open'); + +const auth = require('./auth'); +const Command = require('./command'); + +module.exports = +class Login extends Command { + static commandNames = [ "login" ]; + + constructor(...args) { + super(...args); + this.welcomeMessage = this.welcomeMessage.bind(this); + this.getToken = this.getToken.bind(this); + this.saveToken = this.saveToken.bind(this); + } + + static getTokenOrLogin(callback) { + return auth.getToken(function(error, token) { + if (error != null) { + return new Login().run({callback, commandArgs: []}); + } else { + return callback(null, token); + } + }); + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + + options.usage(`\ +Usage: ppm login + +Enter your package API token and save it to the keychain. This token will +be used to identify you when publishing packages.\ +` + ); + options.alias('h', 'help').describe('help', 'Print this usage message'); + return options.string('token').describe('token', 'Package API token'); + } + + run(options) { + const {callback} = options; + options = this.parseOptions(options.commandArgs); + return Q({token: options.argv.token}) + .then(this.welcomeMessage) + .then(this.openURL) + .then(this.getToken) + .then(this.saveToken) + .then(token => callback(null, token)) + .catch(callback); + } + + prompt(options) { + const readPromise = Q.denodeify(read); + return readPromise(options); + } + + welcomeMessage(state) { + if (state.token) { return Q(state); } + + const welcome = `\ +Welcome to Pulsar! + +Before you can publish packages, you'll need an API token. + +Visit your account page on pulsar-edit.dev ${'https://web.pulsar-edit.dev/users'.underline}, +copy the token and paste it below when prompted. +\ +`; + console.log(welcome); + + return this.prompt({prompt: "Press [Enter] to open your account page."}); + } + + openURL(state) { + if (state.token) { return Q(state); } + + return open('https://web.pulsar-edit.dev/users'); + } + + getToken(state) { + if (state.token) { return Q(state); } + + return this.prompt({prompt: 'Token>', edit: true}) + .spread(function(token) { + state.token = token; + return Q(state); + }); + } + + saveToken({token}) { + if (!token) { throw new Error("Token is required"); } + + process.stdout.write('Saving token to Keychain '); + auth.saveToken(token); + this.logSuccess(); + return Q(token); + } + } diff --git a/src/package-converter.coffee b/src/package-converter.coffee deleted file mode 100644 index 1eeec5ed..00000000 --- a/src/package-converter.coffee +++ /dev/null @@ -1,217 +0,0 @@ -path = require 'path' -url = require 'url' -zlib = require 'zlib' - -_ = require 'underscore-plus' -CSON = require 'season' -plist = require '@atom/plist' -{ScopeSelector} = require 'first-mate' -tar = require 'tar' -temp = require 'temp' - -fs = require './fs' -request = require './request' - -# Convert a TextMate bundle to an Atom package -module.exports = -class PackageConverter - constructor: (@sourcePath, destinationPath) -> - @destinationPath = path.resolve(destinationPath) - - @plistExtensions = [ - '.plist' - '.tmCommand' - '.tmLanguage' - '.tmMacro' - '.tmPreferences' - '.tmSnippet' - ] - - @directoryMappings = { - 'Preferences': 'settings' - 'Snippets': 'snippets' - 'Syntaxes': 'grammars' - } - - convert: (callback) -> - {protocol} = url.parse(@sourcePath) - if protocol is 'http:' or protocol is 'https:' - @downloadBundle(callback) - else - @copyDirectories(@sourcePath, callback) - - getDownloadUrl: -> - downloadUrl = @sourcePath - downloadUrl = downloadUrl.replace(/(\.git)?\/*$/, '') - downloadUrl += '/archive/master.tar.gz' - - downloadBundle: (callback) -> - tempPath = temp.mkdirSync('atom-bundle-') - requestOptions = url: @getDownloadUrl() - request.createReadStream requestOptions, (readStream) => - readStream.on 'response', ({headers, statusCode}) -> - if statusCode isnt 200 - callback("Download failed (#{headers.status})") - - readStream.pipe(zlib.createGunzip()).pipe(tar.extract(cwd: tempPath)) - .on 'error', (error) -> callback(error) - .on 'end', => - sourcePath = path.join(tempPath, fs.readdirSync(tempPath)[0]) - @copyDirectories(sourcePath, callback) - - copyDirectories: (sourcePath, callback) -> - sourcePath = path.resolve(sourcePath) - try - packageName = JSON.parse(fs.readFileSync(path.join(sourcePath, 'package.json')))?.packageName - packageName ?= path.basename(@destinationPath) - - @convertSnippets(packageName, sourcePath) - @convertPreferences(packageName, sourcePath) - @convertGrammars(sourcePath) - callback() - - filterObject: (object) -> - delete object.uuid - delete object.keyEquivalent - - convertSettings: (settings) -> - if settings.shellVariables - shellVariables = {} - for {name, value} in settings.shellVariables - shellVariables[name] = value - settings.shellVariables = shellVariables - - editorProperties = _.compactObject( - commentStart: _.valueForKeyPath(settings, 'shellVariables.TM_COMMENT_START') - commentEnd: _.valueForKeyPath(settings, 'shellVariables.TM_COMMENT_END') - increaseIndentPattern: settings.increaseIndentPattern - decreaseIndentPattern: settings.decreaseIndentPattern - foldEndPattern: settings.foldingStopMarker - completions: settings.completions - ) - {editor: editorProperties} unless _.isEmpty(editorProperties) - - readFileSync: (filePath) -> - if _.contains(@plistExtensions, path.extname(filePath)) - plist.parseFileSync(filePath) - else if _.contains(['.json', '.cson'], path.extname(filePath)) - CSON.readFileSync(filePath) - - writeFileSync: (filePath, object={}) -> - @filterObject(object) - if Object.keys(object).length > 0 - CSON.writeFileSync(filePath, object) - - convertFile: (sourcePath, destinationDir) -> - extension = path.extname(sourcePath) - destinationName = "#{path.basename(sourcePath, extension)}.cson" - destinationName = destinationName.toLowerCase() - destinationPath = path.join(destinationDir, destinationName) - - if _.contains(@plistExtensions, path.extname(sourcePath)) - contents = plist.parseFileSync(sourcePath) - else if _.contains(['.json', '.cson'], path.extname(sourcePath)) - contents = CSON.readFileSync(sourcePath) - - @writeFileSync(destinationPath, contents) - - normalizeFilenames: (directoryPath) -> - return unless fs.isDirectorySync(directoryPath) - - for child in fs.readdirSync(directoryPath) - childPath = path.join(directoryPath, child) - - # Invalid characters taken from http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx - convertedFileName = child.replace(/[|?*<>:"\\\/]+/g, '-') - continue if child is convertedFileName - - convertedFileName = convertedFileName.replace(/[\s-]+/g, '-') - convertedPath = path.join(directoryPath, convertedFileName) - suffix = 1 - while fs.existsSync(convertedPath) or fs.existsSync(convertedPath.toLowerCase()) - extension = path.extname(convertedFileName) - convertedFileName = "#{path.basename(convertedFileName, extension)}-#{suffix}#{extension}" - convertedPath = path.join(directoryPath, convertedFileName) - suffix++ - fs.renameSync(childPath, convertedPath) - - convertSnippets: (packageName, source) -> - sourceSnippets = path.join(source, 'snippets') - unless fs.isDirectorySync(sourceSnippets) - sourceSnippets = path.join(source, 'Snippets') - return unless fs.isDirectorySync(sourceSnippets) - - snippetsBySelector = {} - destination = path.join(@destinationPath, 'snippets') - for child in fs.readdirSync(sourceSnippets) - snippet = @readFileSync(path.join(sourceSnippets, child)) ? {} - {scope, name, content, tabTrigger} = snippet - continue unless tabTrigger and content - - # Replace things like '${TM_C_POINTER: *}' with ' *' - content = content.replace(/\$\{TM_[A-Z_]+:([^}]+)}/g, '$1') - - # Replace things like '${1:${TM_FILENAME/(\\w+)*/(?1:$1:NSObject)/}}' - # with '$1' - content = content.replace(/\$\{(\d)+:\s*\$\{TM_[^}]+\s*\}\s*\}/g, '$$1') - - # Unescape escaped dollar signs $ - content = content.replace(/\\\$/g, '$') - - unless name? - extension = path.extname(child) - name = path.basename(child, extension) - - try - selector = new ScopeSelector(scope).toCssSelector() if scope - catch e - e.message = "In file #{e.fileName} at #{JSON.stringify(scope)}: #{e.message}" - throw e - selector ?= '*' - - snippetsBySelector[selector] ?= {} - snippetsBySelector[selector][name] = {prefix: tabTrigger, body: content} - - @writeFileSync(path.join(destination, "#{packageName}.cson"), snippetsBySelector) - @normalizeFilenames(destination) - - convertPreferences: (packageName, source) -> - sourcePreferences = path.join(source, 'preferences') - unless fs.isDirectorySync(sourcePreferences) - sourcePreferences = path.join(source, 'Preferences') - return unless fs.isDirectorySync(sourcePreferences) - - preferencesBySelector = {} - destination = path.join(@destinationPath, 'settings') - for child in fs.readdirSync(sourcePreferences) - {scope, settings} = @readFileSync(path.join(sourcePreferences, child)) ? {} - continue unless scope and settings - - if properties = @convertSettings(settings) - try - selector = new ScopeSelector(scope).toCssSelector() - catch e - e.message = "In file #{e.fileName} at #{JSON.stringify(scope)}: #{e.message}" - throw e - for key, value of properties - preferencesBySelector[selector] ?= {} - if preferencesBySelector[selector][key]? - preferencesBySelector[selector][key] = _.extend(value, preferencesBySelector[selector][key]) - else - preferencesBySelector[selector][key] = value - - @writeFileSync(path.join(destination, "#{packageName}.cson"), preferencesBySelector) - @normalizeFilenames(destination) - - convertGrammars: (source) -> - sourceSyntaxes = path.join(source, 'syntaxes') - unless fs.isDirectorySync(sourceSyntaxes) - sourceSyntaxes = path.join(source, 'Syntaxes') - return unless fs.isDirectorySync(sourceSyntaxes) - - destination = path.join(@destinationPath, 'grammars') - for child in fs.readdirSync(sourceSyntaxes) - childPath = path.join(sourceSyntaxes, child) - @convertFile(childPath, destination) if fs.isFileSync(childPath) - - @normalizeFilenames(destination) diff --git a/src/package-converter.js b/src/package-converter.js new file mode 100644 index 00000000..b451f0ff --- /dev/null +++ b/src/package-converter.js @@ -0,0 +1,270 @@ + +const path = require('path'); +const url = require('url'); +const zlib = require('zlib'); + +const _ = require('underscore-plus'); +const CSON = require('season'); +const plist = require('@atom/plist'); +const {ScopeSelector} = require('first-mate'); +const tar = require('tar'); +const temp = require('temp'); + +const fs = require('./fs'); +const request = require('./request'); + +// Convert a TextMate bundle to an Atom package +module.exports = +class PackageConverter { + constructor(sourcePath, destinationPath) { + this.sourcePath = sourcePath; + this.destinationPath = path.resolve(destinationPath); + + this.plistExtensions = [ + '.plist', + '.tmCommand', + '.tmLanguage', + '.tmMacro', + '.tmPreferences', + '.tmSnippet' + ]; + + this.directoryMappings = { + 'Preferences': 'settings', + 'Snippets': 'snippets', + 'Syntaxes': 'grammars' + }; + } + + convert(callback) { + const {protocol} = url.parse(this.sourcePath); + if ((protocol === 'http:') || (protocol === 'https:')) { + return this.downloadBundle(callback); + } else { + return this.copyDirectories(this.sourcePath, callback); + } + } + + getDownloadUrl() { + let downloadUrl = this.sourcePath; + downloadUrl = downloadUrl.replace(/(\.git)?\/*$/, ''); + return downloadUrl += '/archive/master.tar.gz'; + } + + downloadBundle(callback) { + const tempPath = temp.mkdirSync('atom-bundle-'); + const requestOptions = {url: this.getDownloadUrl()}; + return request.createReadStream(requestOptions, readStream => { + readStream.on('response', function({headers, statusCode}) { + if (statusCode !== 200) { + return callback(`Download failed (${headers.status})`); + } + }); + + return readStream.pipe(zlib.createGunzip()).pipe(tar.extract({cwd: tempPath})) + .on('error', error => callback(error)) + .on('end', () => { + const sourcePath = path.join(tempPath, fs.readdirSync(tempPath)[0]); + return this.copyDirectories(sourcePath, callback); + }); + }); + } + + copyDirectories(sourcePath, callback) { + let packageName; + sourcePath = path.resolve(sourcePath); + try { + packageName = JSON.parse(fs.readFileSync(path.join(sourcePath, 'package.json')))?.packageName; + } catch (error) {} + if (packageName == null) { packageName = path.basename(this.destinationPath); } + + this.convertSnippets(packageName, sourcePath); + this.convertPreferences(packageName, sourcePath); + this.convertGrammars(sourcePath); + return callback(); + } + + filterObject(object) { + delete object.uuid; + return delete object.keyEquivalent; + } + + convertSettings(settings) { + if (settings.shellVariables) { + const shellVariables = {}; + for (let {name, value} of Array.from(settings.shellVariables)) { + shellVariables[name] = value; + } + settings.shellVariables = shellVariables; + } + + const editorProperties = _.compactObject({ + commentStart: _.valueForKeyPath(settings, 'shellVariables.TM_COMMENT_START'), + commentEnd: _.valueForKeyPath(settings, 'shellVariables.TM_COMMENT_END'), + increaseIndentPattern: settings.increaseIndentPattern, + decreaseIndentPattern: settings.decreaseIndentPattern, + foldEndPattern: settings.foldingStopMarker, + completions: settings.completions + }); + if (!_.isEmpty(editorProperties)) { return {editor: editorProperties}; } + } + + readFileSync(filePath) { + if (_.contains(this.plistExtensions, path.extname(filePath))) { + return plist.parseFileSync(filePath); + } else if (_.contains(['.json', '.cson'], path.extname(filePath))) { + return CSON.readFileSync(filePath); + } + } + + writeFileSync(filePath, object) { + if (object == null) { object = {}; } + this.filterObject(object); + if (Object.keys(object).length > 0) { + return CSON.writeFileSync(filePath, object); + } + } + + convertFile(sourcePath, destinationDir) { + let contents; + const extension = path.extname(sourcePath); + let destinationName = `${path.basename(sourcePath, extension)}.cson`; + destinationName = destinationName.toLowerCase(); + const destinationPath = path.join(destinationDir, destinationName); + + if (_.contains(this.plistExtensions, path.extname(sourcePath))) { + contents = plist.parseFileSync(sourcePath); + } else if (_.contains(['.json', '.cson'], path.extname(sourcePath))) { + contents = CSON.readFileSync(sourcePath); + } + + return this.writeFileSync(destinationPath, contents); + } + + normalizeFilenames(directoryPath) { + if (!fs.isDirectorySync(directoryPath)) { return; } + + return (() => { + const result = []; + for (let child of Array.from(fs.readdirSync(directoryPath))) { + const childPath = path.join(directoryPath, child); + + // Invalid characters taken from http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx + let convertedFileName = child.replace(/[|?*<>:"\\\/]+/g, '-'); + if (child === convertedFileName) { continue; } + + convertedFileName = convertedFileName.replace(/[\s-]+/g, '-'); + let convertedPath = path.join(directoryPath, convertedFileName); + let suffix = 1; + while (fs.existsSync(convertedPath) || fs.existsSync(convertedPath.toLowerCase())) { + const extension = path.extname(convertedFileName); + convertedFileName = `${path.basename(convertedFileName, extension)}-${suffix}${extension}`; + convertedPath = path.join(directoryPath, convertedFileName); + suffix++; + } + result.push(fs.renameSync(childPath, convertedPath)); + } + return result; + })(); + } + + convertSnippets(packageName, source) { + let sourceSnippets = path.join(source, 'snippets'); + if (!fs.isDirectorySync(sourceSnippets)) { + sourceSnippets = path.join(source, 'Snippets'); + } + if (!fs.isDirectorySync(sourceSnippets)) { return; } + + const snippetsBySelector = {}; + const destination = path.join(this.destinationPath, 'snippets'); + for (let child of Array.from(fs.readdirSync(sourceSnippets))) { + var left, selector; + const snippet = (left = this.readFileSync(path.join(sourceSnippets, child))) != null ? left : {}; + let {scope, name, content, tabTrigger} = snippet; + if (!tabTrigger || !content) { continue; } + + // Replace things like '${TM_C_POINTER: *}' with ' *' + content = content.replace(/\$\{TM_[A-Z_]+:([^}]+)}/g, '$1'); + + // Replace things like '${1:${TM_FILENAME/(\\w+)*/(?1:$1:NSObject)/}}' + // with '$1' + content = content.replace(/\$\{(\d)+:\s*\$\{TM_[^}]+\s*\}\s*\}/g, '$$1'); + + // Unescape escaped dollar signs $ + content = content.replace(/\\\$/g, '$'); + + if (name == null) { + const extension = path.extname(child); + name = path.basename(child, extension); + } + + try { + if (scope) { selector = new ScopeSelector(scope).toCssSelector(); } + } catch (e) { + e.message = `In file ${e.fileName} at ${JSON.stringify(scope)}: ${e.message}`; + throw e; + } + if (selector == null) { selector = '*'; } + + if (snippetsBySelector[selector] == null) { snippetsBySelector[selector] = {}; } + snippetsBySelector[selector][name] = {prefix: tabTrigger, body: content}; + } + + this.writeFileSync(path.join(destination, `${packageName}.cson`), snippetsBySelector); + return this.normalizeFilenames(destination); + } + + convertPreferences(packageName, source) { + let sourcePreferences = path.join(source, 'preferences'); + if (!fs.isDirectorySync(sourcePreferences)) { + sourcePreferences = path.join(source, 'Preferences'); + } + if (!fs.isDirectorySync(sourcePreferences)) { return; } + + const preferencesBySelector = {}; + const destination = path.join(this.destinationPath, 'settings'); + for (let child of Array.from(fs.readdirSync(sourcePreferences))) { + var left, properties; + const {scope, settings} = (left = this.readFileSync(path.join(sourcePreferences, child))) != null ? left : {}; + if (!scope || !settings) { continue; } + + if (properties = this.convertSettings(settings)) { + var selector; + try { + selector = new ScopeSelector(scope).toCssSelector(); + } catch (e) { + e.message = `In file ${e.fileName} at ${JSON.stringify(scope)}: ${e.message}`; + throw e; + } + for (let key in properties) { + const value = properties[key]; + if (preferencesBySelector[selector] == null) { preferencesBySelector[selector] = {}; } + if (preferencesBySelector[selector][key] != null) { + preferencesBySelector[selector][key] = _.extend(value, preferencesBySelector[selector][key]); + } else { + preferencesBySelector[selector][key] = value; + } + } + } + } + + this.writeFileSync(path.join(destination, `${packageName}.cson`), preferencesBySelector); + return this.normalizeFilenames(destination); + } + + convertGrammars(source) { + let sourceSyntaxes = path.join(source, 'syntaxes'); + if (!fs.isDirectorySync(sourceSyntaxes)) { + sourceSyntaxes = path.join(source, 'Syntaxes'); + } + if (!fs.isDirectorySync(sourceSyntaxes)) { return; } + + const destination = path.join(this.destinationPath, 'grammars'); + for (let child of Array.from(fs.readdirSync(sourceSyntaxes))) { + const childPath = path.join(sourceSyntaxes, child); + if (fs.isFileSync(childPath)) { this.convertFile(childPath, destination); } + } + + return this.normalizeFilenames(destination); + } +}; diff --git a/src/packages.coffee b/src/packages.coffee deleted file mode 100644 index 4dcadcce..00000000 --- a/src/packages.coffee +++ /dev/null @@ -1,22 +0,0 @@ -url = require 'url' - -# Package helpers -module.exports = - # Parse the repository in `name/owner` format from the package metadata. - # - # pack - The package metadata object. - # - # Returns a name/owner string or null if not parseable. - getRepository: (pack={}) -> - if repository = pack.repository?.url ? pack.repository - repoPath = url.parse(repository.replace(/\.git$/, '')).pathname - [name, owner] = repoPath.split('/')[-2..] - return "#{name}/#{owner}" if name and owner - null - - # Determine remote from package metadata. - # - # pack - The package metadata object. - # Returns a the remote or 'origin' if not parseable. - getRemote: (pack={}) -> - pack.repository?.url or pack.repository or 'origin' diff --git a/src/packages.js b/src/packages.js new file mode 100644 index 00000000..adbdc08b --- /dev/null +++ b/src/packages.js @@ -0,0 +1,30 @@ + +const url = require('url'); + +// Package helpers +module.exports = { + // Parse the repository in `name/owner` format from the package metadata. + // + // pack - The package metadata object. + // + // Returns a name/owner string or null if not parseable. + getRepository(pack) { + if (pack == null) { pack = {}; } + let repository = pack.repository?.url ?? pack.repository; + if (repository) { + const repoPath = url.parse(repository.replace(/\.git$/, '')).pathname; + const [name, owner] = repoPath.split('/').slice(-2); + if (name && owner) { return `${name}/${owner}`; } + } + return null; + }, + + // Determine remote from package metadata. + // + // pack - The package metadata object. + // Returns a the remote or 'origin' if not parseable. + getRemote(pack) { + if (pack == null) { pack = {}; } + return pack.repository?.url ?? pack.repository ?? "origin"; + } +}; diff --git a/src/publish.coffee b/src/publish.coffee deleted file mode 100644 index f3aacba1..00000000 --- a/src/publish.coffee +++ /dev/null @@ -1,382 +0,0 @@ -path = require 'path' -url = require 'url' - -yargs = require 'yargs' -Git = require 'git-utils' -semver = require 'semver' - -fs = require './fs' -config = require './apm' -Command = require './command' -Login = require './login' -Packages = require './packages' -request = require './request' - -module.exports = -class Publish extends Command - @commandNames: ['publish'] - - constructor: -> - super() - @userConfigPath = config.getUserConfigPath() - @atomNpmPath = require.resolve('npm/bin/npm-cli') - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm publish [ | major | minor | patch | build] - ppm publish --tag - ppm publish --rename - - Publish a new version of the package in the current working directory. - - If a new version or version increment is specified, then a new Git tag is - created and the package.json file is updated with that new version before - it is published to the ppm registry. The HEAD branch and the new tag are - pushed up to the remote repository automatically using this option. - - If a tag is provided via the --tag flag, it must have the form `vx.y.z`. - For example, `ppm publish -t v1.12.0`. - - If a new name is provided via the --rename flag, the package.json file is - updated with the new name and the package's name is updated. - - Run `ppm featured` to see all the featured packages or - `ppm view ` to see information about your package after you - have published it. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - options.alias('t', 'tag').string('tag').describe('tag', 'Specify a tag to publish. Must be of the form vx.y.z') - options.alias('r', 'rename').string('rename').describe('rename', 'Specify a new name for the package') - - # Create a new version and tag use the `npm version` command. - # - # version - The new version or version increment. - # callback - The callback function to invoke with an error as the first - # argument and a the generated tag string as the second argument. - versionPackage: (version, callback) -> - process.stdout.write 'Preparing and tagging a new version ' - versionArgs = ['version', version, '-m', 'Prepare v%s release'] - @fork @atomNpmPath, versionArgs, (code, stderr='', stdout='') => - if code is 0 - @logSuccess() - callback(null, stdout.trim()) - else - @logFailure() - callback("#{stdout}\n#{stderr}".trim()) - - # Push a tag to the remote repository. - # - # tag - The tag to push. - # pack - The package metadata. - # callback - The callback function to invoke with an error as the first - # argument. - pushVersion: (tag, pack, callback) -> - process.stdout.write "Pushing #{tag} tag " - pushArgs = ['push', Packages.getRemote(pack), 'HEAD', tag] - @spawn 'git', pushArgs, (args...) => - @logCommandResults(callback, args...) - - # Check for the tag being available from the GitHub API before notifying - # the package server about the new version. - # - # The tag is checked for 5 times at 1 second intervals. - # - # pack - The package metadata. - # tag - The tag that was pushed. - # callback - The callback function to invoke when either the tag is available - # or the maximum numbers of requests for the tag have been made. - # No arguments are passed to the callback when it is invoked. - waitForTagToBeAvailable: (pack, tag, callback) -> - retryCount = 5 - interval = 1000 - requestSettings = - url: "https://api.github.com/repos/#{Packages.getRepository(pack)}/tags" - json: true - - requestTags = -> - request.get requestSettings, (error, response, tags=[]) -> - if response?.statusCode is 200 - for {name}, index in tags when name is tag - return callback() - if --retryCount <= 0 - callback() - else - setTimeout(requestTags, interval) - requestTags() - - # Does the given package already exist in the registry? - # - # packageName - The string package name to check. - # callback - The callback function invoke with an error as the first - # argument and true/false as the second argument. - packageExists: (packageName, callback) -> - Login.getTokenOrLogin (error, token) -> - return callback(error) if error? - - requestSettings = - url: "#{config.getAtomPackagesUrl()}/#{packageName}" - json: true - headers: - authorization: token - request.get requestSettings, (error, response, body={}) -> - if error? - callback(error) - else - callback(null, response.statusCode is 200) - - # Register the current repository with the package registry. - # - # pack - The package metadata. - # callback - The callback function. - registerPackage: (pack, callback) -> - unless pack.name - callback('Required name field in package.json not found') - return - - @packageExists pack.name, (error, exists) => - return callback(error) if error? - return callback() if exists - - unless repository = Packages.getRepository(pack) - callback('Unable to parse repository name/owner from package.json repository field') - return - - process.stdout.write "Registering #{pack.name} " - Login.getTokenOrLogin (error, token) => - if error? - @logFailure() - callback(error) - return - - requestSettings = - url: config.getAtomPackagesUrl() - json: true - qs: - repository: repository - headers: - authorization: token - request.post requestSettings, (error, response, body={}) => - if error? - callback(error) - else if response.statusCode isnt 201 - message = request.getErrorMessage(response, body) - @logFailure() - callback("Registering package in #{repository} repository failed: #{message}") - else - @logSuccess() - callback(null, true) - - # Create a new package version at the given Git tag. - # - # packageName - The string name of the package. - # tag - The string Git tag of the new version. - # callback - The callback function to invoke with an error as the first - # argument. - createPackageVersion: (packageName, tag, options, callback) -> - Login.getTokenOrLogin (error, token) -> - if error? - callback(error) - return - - requestSettings = - url: "#{config.getAtomPackagesUrl()}/#{packageName}/versions" - json: true - qs: - tag: tag - rename: options.rename - headers: - authorization: token - request.post requestSettings, (error, response, body={}) -> - if error? - callback(error) - else if response.statusCode isnt 201 - message = request.getErrorMessage(response, body) - callback("Creating new version failed: #{message}") - else - callback() - - # Publish the version of the package associated with the given tag. - # - # pack - The package metadata. - # tag - The Git tag string of the package version to publish. - # options - An options Object (optional). - # callback - The callback function to invoke when done with an error as the - # first argument. - publishPackage: (pack, tag, remaining...) -> - options = remaining.shift() if remaining.length >= 2 - options ?= {} - callback = remaining.shift() - - process.stdout.write "Publishing #{options.rename or pack.name}@#{tag} " - @createPackageVersion pack.name, tag, options, (error) => - if error? - @logFailure() - callback(error) - else - @logSuccess() - callback() - - logFirstTimePublishMessage: (pack) -> - process.stdout.write 'Congrats on publishing a new package!'.rainbow - # :+1: :package: :tada: when available - if process.platform is 'darwin' - process.stdout.write ' \uD83D\uDC4D \uD83D\uDCE6 \uD83C\uDF89' - - process.stdout.write "\nCheck it out at https://web.pulsar-edit.dev/packages/#{pack.name}\n" - - loadMetadata: -> - metadataPath = path.resolve('package.json') - unless fs.isFileSync(metadataPath) - throw new Error("No package.json file found at #{process.cwd()}/package.json") - - try - pack = JSON.parse(fs.readFileSync(metadataPath)) - catch error - throw new Error("Error parsing package.json file: #{error.message}") - - saveMetadata: (pack, callback) -> - metadataPath = path.resolve('package.json') - metadataJson = JSON.stringify(pack, null, 2) - fs.writeFile(metadataPath, "#{metadataJson}\n", callback) - - loadRepository: -> - currentDirectory = process.cwd() - - repo = Git.open(currentDirectory) - unless repo?.isWorkingDirectory(currentDirectory) - throw new Error('Package must be in a Git repository before publishing: https://help.github.com/articles/create-a-repo') - - - if currentBranch = repo.getShortHead() - remoteName = repo.getConfigValue("branch.#{currentBranch}.remote") - remoteName ?= repo.getConfigValue('branch.master.remote') - - upstreamUrl = repo.getConfigValue("remote.#{remoteName}.url") if remoteName - upstreamUrl ?= repo.getConfigValue('remote.origin.url') - - unless upstreamUrl - throw new Error('Package must be pushed up to GitHub before publishing: https://help.github.com/articles/create-a-repo') - - # Rename package if necessary - renamePackage: (pack, name, callback) -> - if name?.length > 0 - return callback('The new package name must be different than the name in the package.json file') if pack.name is name - - message = "Renaming #{pack.name} to #{name} " - process.stdout.write(message) - @setPackageName pack, name, (error) => - if error? - @logFailure() - return callback(error) - - config.getSetting 'git', (gitCommand) => - gitCommand ?= 'git' - @spawn gitCommand, ['add', 'package.json'], (code, stderr='', stdout='') => - unless code is 0 - @logFailure() - addOutput = "#{stdout}\n#{stderr}".trim() - return callback("`git add package.json` failed: #{addOutput}") - - @spawn gitCommand, ['commit', '-m', message], (code, stderr='', stdout='') => - if code is 0 - @logSuccess() - callback() - else - @logFailure() - commitOutput = "#{stdout}\n#{stderr}".trim() - callback("Failed to commit package.json: #{commitOutput}") - else - # Just fall through if the name is empty - callback() - - setPackageName: (pack, name, callback) -> - pack.name = name - @saveMetadata(pack, callback) - - validateSemverRanges: (pack) -> - return unless pack - - isValidRange = (semverRange) -> - return true if semver.validRange(semverRange) - - try - return true if url.parse(semverRange).protocol.length > 0 - - semverRange is 'latest' - - range = pack.engines?.pulsar or pack.engines?.atom - if range? - unless semver.validRange(range) - throw new Error("The Pulsar or Atom engine range in the package.json file is invalid: #{range}") - - for packageName, semverRange of pack.dependencies - unless isValidRange(semverRange) - throw new Error("The #{packageName} dependency range in the package.json file is invalid: #{semverRange}") - - for packageName, semverRange of pack.devDependencies - unless isValidRange(semverRange) - throw new Error("The #{packageName} dev dependency range in the package.json file is invalid: #{semverRange}") - - return - - # Run the publish command with the given options - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - {tag, rename} = options.argv - [version] = options.argv._ - - try - pack = @loadMetadata() - catch error - return callback(error) - - try - @validateSemverRanges(pack) - catch error - return callback(error) - - try - @loadRepository() - catch error - return callback(error) - - if version?.length > 0 or rename?.length > 0 - version = 'patch' unless version?.length > 0 - originalName = pack.name if rename?.length > 0 - - @registerPackage pack, (error, firstTimePublishing) => - return callback(error) if error? - - @renamePackage pack, rename, (error) => - return callback(error) if error? - - @versionPackage version, (error, tag) => - return callback(error) if error? - - @pushVersion tag, pack, (error) => - return callback(error) if error? - - @waitForTagToBeAvailable pack, tag, => - - if originalName? - # If we're renaming a package, we have to hit the API with the - # current name, not the new one, or it will 404. - rename = pack.name - pack.name = originalName - @publishPackage pack, tag, {rename}, (error) => - if firstTimePublishing and not error? - @logFirstTimePublishMessage(pack) - callback(error) - else if tag?.length > 0 - @registerPackage pack, (error, firstTimePublishing) => - return callback(error) if error? - - @publishPackage pack, tag, (error) => - if firstTimePublishing and not error? - @logFirstTimePublishMessage(pack) - callback(error) - else - callback('A version, tag, or new package name is required') diff --git a/src/publish.js b/src/publish.js new file mode 100644 index 00000000..feb2825f --- /dev/null +++ b/src/publish.js @@ -0,0 +1,493 @@ + +const path = require('path'); +const url = require('url'); + +const yargs = require('yargs'); +const Git = require('git-utils'); +const semver = require('semver'); + +const fs = require('./fs'); +const config = require('./apm'); +const Command = require('./command'); +const Login = require('./login'); +const Packages = require('./packages'); +const request = require('./request'); + +module.exports = +class Publish extends Command { + static commandNames = [ "publish" ]; + + constructor() { + super(); + this.userConfigPath = config.getUserConfigPath(); + this.atomNpmPath = require.resolve('npm/bin/npm-cli'); + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm publish [ | major | minor | patch | build] + ppm publish --tag + ppm publish --rename + +Publish a new version of the package in the current working directory. + +If a new version or version increment is specified, then a new Git tag is +created and the package.json file is updated with that new version before +it is published to the ppm registry. The HEAD branch and the new tag are +pushed up to the remote repository automatically using this option. + +If a tag is provided via the --tag flag, it must have the form \`vx.y.z\`. +For example, \`ppm publish -t v1.12.0\`. + +If a new name is provided via the --rename flag, the package.json file is +updated with the new name and the package's name is updated. + +Run \`ppm featured\` to see all the featured packages or +\`ppm view \` to see information about your package after you +have published it.\ +` + ); + options.alias('h', 'help').describe('help', 'Print this usage message'); + options.alias('t', 'tag').string('tag').describe('tag', 'Specify a tag to publish. Must be of the form vx.y.z'); + return options.alias('r', 'rename').string('rename').describe('rename', 'Specify a new name for the package'); + } + + // Create a new version and tag use the `npm version` command. + // + // version - The new version or version increment. + // callback - The callback function to invoke with an error as the first + // argument and a the generated tag string as the second argument. + versionPackage(version, callback) { + process.stdout.write('Preparing and tagging a new version '); + const versionArgs = ['version', version, '-m', 'Prepare v%s release']; + return this.fork(this.atomNpmPath, versionArgs, (code, stderr, stdout) => { + if (stderr == null) { stderr = ''; } + if (stdout == null) { stdout = ''; } + if (code === 0) { + this.logSuccess(); + return callback(null, stdout.trim()); + } else { + this.logFailure(); + return callback(`${stdout}\n${stderr}`.trim()); + } + }); + } + + // Push a tag to the remote repository. + // + // tag - The tag to push. + // pack - The package metadata. + // callback - The callback function to invoke with an error as the first + // argument. + pushVersion(tag, pack, callback) { + process.stdout.write(`Pushing ${tag} tag `); + const pushArgs = ['push', Packages.getRemote(pack), 'HEAD', tag]; + return this.spawn('git', pushArgs, (...args) => { + return this.logCommandResults(callback, ...args); + }); + } + + // Check for the tag being available from the GitHub API before notifying + // the package server about the new version. + // + // The tag is checked for 5 times at 1 second intervals. + // + // pack - The package metadata. + // tag - The tag that was pushed. + // callback - The callback function to invoke when either the tag is available + // or the maximum numbers of requests for the tag have been made. + // No arguments are passed to the callback when it is invoked. + waitForTagToBeAvailable(pack, tag, callback) { + let retryCount = 5; + const interval = 1000; + const requestSettings = { + url: `https://api.github.com/repos/${Packages.getRepository(pack)}/tags`, + json: true + }; + + var requestTags = () => request.get(requestSettings, function(error, response, tags) { + if (tags == null) { tags = []; } + if ((response != null ? response.statusCode : undefined) === 200) { + for (let index = 0; index < tags.length; index++) { + const {name} = tags[index]; + if (name === tag) { + return callback(); + } + } + } + if (--retryCount <= 0) { + return callback(); + } else { + return setTimeout(requestTags, interval); + } + }); + return requestTags(); + } + + // Does the given package already exist in the registry? + // + // packageName - The string package name to check. + // callback - The callback function invoke with an error as the first + // argument and true/false as the second argument. + packageExists(packageName, callback) { + return Login.getTokenOrLogin(function(error, token) { + if (error != null) { return callback(error); } + + const requestSettings = { + url: `${config.getAtomPackagesUrl()}/${packageName}`, + json: true, + headers: { + authorization: token + } + }; + return request.get(requestSettings, function(error, response, body) { + if (body == null) { body = {}; } + if (error != null) { + return callback(error); + } else { + return callback(null, response.statusCode === 200); + } + }); + }); + } + + // Register the current repository with the package registry. + // + // pack - The package metadata. + // callback - The callback function. + registerPackage(pack, callback) { + if (!pack.name) { + callback('Required name field in package.json not found'); + return; + } + + this.packageExists(pack.name, (error, exists) => { + let repository; + if (error != null) { return callback(error); } + if (exists) { return callback(); } + + if (!(repository = Packages.getRepository(pack))) { + callback('Unable to parse repository name/owner from package.json repository field'); + return; + } + + process.stdout.write(`Registering ${pack.name} `); + return Login.getTokenOrLogin((error, token) => { + if (error != null) { + this.logFailure(); + callback(error); + return; + } + + const requestSettings = { + url: config.getAtomPackagesUrl(), + json: true, + qs: { + repository + }, + headers: { + authorization: token + } + }; + request.post(requestSettings, (error, response, body) => { + if (body == null) { body = {}; } + if (error != null) { + return callback(error); + } else if (response.statusCode !== 201) { + const message = request.getErrorMessage(response, body); + this.logFailure(); + return callback(`Registering package in ${repository} repository failed: ${message}`); + } else { + this.logSuccess(); + return callback(null, true); + } + }); + }); + }); + } + + // Create a new package version at the given Git tag. + // + // packageName - The string name of the package. + // tag - The string Git tag of the new version. + // callback - The callback function to invoke with an error as the first + // argument. + createPackageVersion(packageName, tag, options, callback) { + Login.getTokenOrLogin(function(error, token) { + if (error != null) { + callback(error); + return; + } + + const requestSettings = { + url: `${config.getAtomPackagesUrl()}/${packageName}/versions`, + json: true, + qs: { + tag, + rename: options.rename + }, + headers: { + authorization: token + } + }; + request.post(requestSettings, function(error, response, body) { + if (body == null) { body = {}; } + if (error != null) { + return callback(error); + } else if (response.statusCode !== 201) { + const message = request.getErrorMessage(response, body); + return callback(`Creating new version failed: ${message}`); + } else { + return callback(); + } + }); + }); + } + + // Publish the version of the package associated with the given tag. + // + // pack - The package metadata. + // tag - The Git tag string of the package version to publish. + // options - An options Object (optional). + // callback - The callback function to invoke when done with an error as the + // first argument. + publishPackage(pack, tag, ...remaining) { + let options; + if (remaining.length >= 2) { options = remaining.shift(); } + if (options == null) { options = {}; } + const callback = remaining.shift(); + + process.stdout.write(`Publishing ${options.rename || pack.name}@${tag} `); + this.createPackageVersion(pack.name, tag, options, error => { + if (error != null) { + this.logFailure(); + return callback(error); + } else { + this.logSuccess(); + return callback(); + } + }); + } + + logFirstTimePublishMessage(pack) { + process.stdout.write('Congrats on publishing a new package!'.rainbow); + // :+1: :package: :tada: when available + if (process.platform === 'darwin') { + process.stdout.write(' \uD83D\uDC4D \uD83D\uDCE6 \uD83C\uDF89'); + } + + return process.stdout.write(`\nCheck it out at https://web.pulsar-edit.dev/packages/${pack.name}\n`); + } + + loadMetadata() { + const metadataPath = path.resolve('package.json'); + if (!fs.isFileSync(metadataPath)) { + throw new Error(`No package.json file found at ${process.cwd()}/package.json`); + } + + try { + return JSON.parse(fs.readFileSync(metadataPath)); + } catch (error) { + throw new Error(`Error parsing package.json file: ${error.message}`); + } + } + + saveMetadata(pack, callback) { + const metadataPath = path.resolve('package.json'); + const metadataJson = JSON.stringify(pack, null, 2); + return fs.writeFile(metadataPath, `${metadataJson}\n`, callback); + } + + loadRepository() { + let currentBranch, remoteName, upstreamUrl; + const currentDirectory = process.cwd(); + + const repo = Git.open(currentDirectory); + if (!(repo != null ? repo.isWorkingDirectory(currentDirectory) : undefined)) { + throw new Error('Package must be in a Git repository before publishing: https://help.github.com/articles/create-a-repo'); + } + + + if (currentBranch = repo.getShortHead()) { + remoteName = repo.getConfigValue(`branch.${currentBranch}.remote`); + } + if (remoteName == null) { remoteName = repo.getConfigValue('branch.master.remote'); } + + if (remoteName) { upstreamUrl = repo.getConfigValue(`remote.${remoteName}.url`); } + if (upstreamUrl == null) { upstreamUrl = repo.getConfigValue('remote.origin.url'); } + + if (!upstreamUrl) { + throw new Error('Package must be pushed up to GitHub before publishing: https://help.github.com/articles/create-a-repo'); + } + } + + // Rename package if necessary + renamePackage(pack, name, callback) { + if ((name != null ? name.length : undefined) > 0) { + if (pack.name === name) { return callback('The new package name must be different than the name in the package.json file'); } + + const message = `Renaming ${pack.name} to ${name} `; + process.stdout.write(message); + return this.setPackageName(pack, name, error => { + if (error != null) { + this.logFailure(); + return callback(error); + } + + return config.getSetting('git', gitCommand => { + if (gitCommand == null) { gitCommand = 'git'; } + return this.spawn(gitCommand, ['add', 'package.json'], (code, stderr, stdout) => { + if (stderr == null) { stderr = ''; } + if (stdout == null) { stdout = ''; } + if (code !== 0) { + this.logFailure(); + const addOutput = `${stdout}\n${stderr}`.trim(); + return callback(`\`git add package.json\` failed: ${addOutput}`); + } + + return this.spawn(gitCommand, ['commit', '-m', message], (code, stderr, stdout) => { + if (stderr == null) { stderr = ''; } + if (stdout == null) { stdout = ''; } + if (code === 0) { + this.logSuccess(); + return callback(); + } else { + this.logFailure(); + const commitOutput = `${stdout}\n${stderr}`.trim(); + return callback(`Failed to commit package.json: ${commitOutput}`); + } + }); + }); + }); + }); + } else { + // Just fall through if the name is empty + return callback(); + } + } + + setPackageName(pack, name, callback) { + pack.name = name; + return this.saveMetadata(pack, callback); + } + + validateSemverRanges(pack) { + let packageName, semverRange; + if (!pack) { return; } + + const isValidRange = function(semverRange) { + if (semver.validRange(semverRange)) { return true; } + + try { + if (url.parse(semverRange).protocol.length > 0) { return true; } + } catch (error) {} + + return semverRange === 'latest'; + }; + + const range = pack.engines?.pulsar ?? pack.engines?.atom ?? undefined; + if (range != null) { + if (!semver.validRange(range)) { + throw new Error(`The Pulsar or Atom engine range in the package.json file is invalid: ${range}`); + } + } + + for (packageName in pack.dependencies) { + semverRange = pack.dependencies[packageName]; + if (!isValidRange(semverRange)) { + throw new Error(`The ${packageName} dependency range in the package.json file is invalid: ${semverRange}`); + } + } + + for (packageName in pack.devDependencies) { + semverRange = pack.devDependencies[packageName]; + if (!isValidRange(semverRange)) { + throw new Error(`The ${packageName} dev dependency range in the package.json file is invalid: ${semverRange}`); + } + } + + } + + // Run the publish command with the given options + run(options) { + let error, pack; + const {callback} = options; + options = this.parseOptions(options.commandArgs); + let {tag, rename} = options.argv; + let [version] = options.argv._; + + try { + pack = this.loadMetadata(); + } catch (error1) { + error = error1; + return callback(error); + } + + try { + this.validateSemverRanges(pack); + } catch (error2) { + error = error2; + return callback(error); + } + + try { + this.loadRepository(); + } catch (error3) { + error = error3; + return callback(error); + } + + if (((version != null ? version.length : undefined) > 0) || ((rename != null ? rename.length : undefined) > 0)) { + let originalName; + if (!((version != null ? version.length : undefined) > 0)) { version = 'patch'; } + if ((rename != null ? rename.length : undefined) > 0) { originalName = pack.name; } + + this.registerPackage(pack, (error, firstTimePublishing) => { + if (error != null) { return callback(error); } + + this.renamePackage(pack, rename, error => { + if (error != null) { return callback(error); } + + this.versionPackage(version, (error, tag) => { + if (error != null) { return callback(error); } + + this.pushVersion(tag, pack, error => { + if (error != null) { return callback(error); } + + this.waitForTagToBeAvailable(pack, tag, () => { + + if (originalName != null) { + // If we're renaming a package, we have to hit the API with the + // current name, not the new one, or it will 404. + rename = pack.name; + pack.name = originalName; + } + this.publishPackage(pack, tag, {rename}, error => { + if (firstTimePublishing && (error == null)) { + this.logFirstTimePublishMessage(pack); + } + return callback(error); + }); + }); + }); + }); + }); + }); + } else if ((tag != null ? tag.length : undefined) > 0) { + this.registerPackage(pack, (error, firstTimePublishing) => { + if (error != null) { return callback(error); } + + this.publishPackage(pack, tag, error => { + if (firstTimePublishing && (error == null)) { + this.logFirstTimePublishMessage(pack); + } + return callback(error); + }); + }); + } else { + return callback('A version, tag, or new package name is required'); + } + } + } diff --git a/src/rebuild-module-cache.coffee b/src/rebuild-module-cache.coffee deleted file mode 100644 index f2444e8d..00000000 --- a/src/rebuild-module-cache.coffee +++ /dev/null @@ -1,66 +0,0 @@ -path = require 'path' -async = require 'async' -yargs = require 'yargs' -Command = require './command' -config = require './apm' -fs = require './fs' - -module.exports = -class RebuildModuleCache extends Command - @commandNames: ['rebuild-module-cache'] - - constructor: -> - super() - @atomPackagesDirectory = path.join(config.getAtomDirectory(), 'packages') - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm rebuild-module-cache - - Rebuild the module cache for all the packages installed to - ~/.pulsar/packages - - You can see the state of the module cache for a package by looking - at the _atomModuleCache property in the package's package.json file. - - This command skips all linked packages. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - - getResourcePath: (callback) -> - if @resourcePath - process.nextTick => callback(@resourcePath) - else - config.getResourcePath (@resourcePath) => callback(@resourcePath) - - rebuild: (packageDirectory, callback) -> - @getResourcePath (resourcePath) => - try - @moduleCache ?= require(path.join(resourcePath, 'src', 'module-cache')) - @moduleCache.create(packageDirectory) - catch error - return callback(error) - - callback() - - run: (options) -> - {callback} = options - - commands = [] - fs.list(@atomPackagesDirectory).forEach (packageName) => - packageDirectory = path.join(@atomPackagesDirectory, packageName) - return if fs.isSymbolicLinkSync(packageDirectory) - return unless fs.isFileSync(path.join(packageDirectory, 'package.json')) - - commands.push (callback) => - process.stdout.write "Rebuilding #{packageName} module cache " - @rebuild packageDirectory, (error) => - if error? - @logFailure() - else - @logSuccess() - callback(error) - - async.waterfall(commands, callback) diff --git a/src/rebuild-module-cache.js b/src/rebuild-module-cache.js new file mode 100644 index 00000000..bb79d660 --- /dev/null +++ b/src/rebuild-module-cache.js @@ -0,0 +1,81 @@ + +const path = require('path'); +const async = require('async'); +const yargs = require('yargs'); +const Command = require('./command'); +const config = require('./apm'); +const fs = require('./fs'); + +module.exports = +class RebuildModuleCache extends Command { + static commandNames = [ "rebuild-module-cache" ]; + + constructor() { + super(); + this.atomPackagesDirectory = path.join(config.getAtomDirectory(), 'packages'); + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm rebuild-module-cache + +Rebuild the module cache for all the packages installed to +~/.pulsar/packages + +You can see the state of the module cache for a package by looking +at the _atomModuleCache property in the package's package.json file. + +This command skips all linked packages.\ +` + ); + return options.alias('h', 'help').describe('help', 'Print this usage message'); + } + + getResourcePath(callback) { + if (this.resourcePath) { + return process.nextTick(() => callback(this.resourcePath)); + } else { + return config.getResourcePath(resourcePath => { this.resourcePath = resourcePath; return callback(this.resourcePath); }); + } + } + + rebuild(packageDirectory, callback) { + return this.getResourcePath(resourcePath => { + try { + if (this.moduleCache == null) { this.moduleCache = require(path.join(resourcePath, 'src', 'module-cache')); } + this.moduleCache.create(packageDirectory); + } catch (error) { + return callback(error); + } + + return callback(); + }); + } + + run(options) { + const {callback} = options; + + const commands = []; + fs.list(this.atomPackagesDirectory).forEach(packageName => { + const packageDirectory = path.join(this.atomPackagesDirectory, packageName); + if (fs.isSymbolicLinkSync(packageDirectory)) { return; } + if (!fs.isFileSync(path.join(packageDirectory, 'package.json'))) { return; } + + return commands.push(callback => { + process.stdout.write(`Rebuilding ${packageName} module cache `); + return this.rebuild(packageDirectory, error => { + if (error != null) { + this.logFailure(); + } else { + this.logSuccess(); + } + return callback(error); + }); + }); + }); + + return async.waterfall(commands, callback); + } + } diff --git a/src/rebuild.coffee b/src/rebuild.coffee deleted file mode 100644 index 4a0603f8..00000000 --- a/src/rebuild.coffee +++ /dev/null @@ -1,60 +0,0 @@ -path = require 'path' - -_ = require 'underscore-plus' -yargs = require 'yargs' - -config = require './apm' -Command = require './command' -fs = require './fs' -Install = require './install' - -module.exports = -class Rebuild extends Command - @commandNames: ['rebuild'] - - constructor: -> - super() - @atomDirectory = config.getAtomDirectory() - @atomNodeDirectory = path.join(@atomDirectory, '.node-gyp') - @atomNpmPath = require.resolve('npm/bin/npm-cli') - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm rebuild [ [ ...]] - - Rebuild the given modules currently installed in the node_modules folder - in the current working directory. - - All the modules will be rebuilt if no module names are specified. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - - forkNpmRebuild: (options, callback) -> - process.stdout.write 'Rebuilding modules ' - - rebuildArgs = ['--globalconfig', config.getGlobalConfigPath(), '--userconfig', config.getUserConfigPath(), 'rebuild'] - rebuildArgs.push(@getNpmBuildFlags()...) - rebuildArgs.push(options.argv._...) - - fs.makeTreeSync(@atomDirectory) - - env = _.extend({}, process.env, {HOME: @atomNodeDirectory, RUSTUP_HOME: config.getRustupHomeDirPath()}) - @addBuildEnvVars(env) - - @fork(@atomNpmPath, rebuildArgs, {env}, callback) - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - - config.loadNpm (error, @npm) => - @loadInstalledAtomMetadata => - @forkNpmRebuild options, (code, stderr='') => - if code is 0 - @logSuccess() - callback() - else - @logFailure() - callback(stderr) diff --git a/src/rebuild.js b/src/rebuild.js new file mode 100644 index 00000000..f32f308a --- /dev/null +++ b/src/rebuild.js @@ -0,0 +1,73 @@ + +const path = require('path'); + +const _ = require('underscore-plus'); +const yargs = require('yargs'); + +const config = require('./apm'); +const Command = require('./command'); +const fs = require('./fs'); +const Install = require('./install'); + +module.exports = +class Rebuild extends Command { + static commandNames = [ "rebuild" ]; + + constructor() { + super(); + this.atomDirectory = config.getAtomDirectory(); + this.atomNodeDirectory = path.join(this.atomDirectory, '.node-gyp'); + this.atomNpmPath = require.resolve('npm/bin/npm-cli'); + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm rebuild [ [ ...]] + +Rebuild the given modules currently installed in the node_modules folder +in the current working directory. + +All the modules will be rebuilt if no module names are specified.\ +` + ); + return options.alias('h', 'help').describe('help', 'Print this usage message'); + } + + forkNpmRebuild(options, callback) { + process.stdout.write('Rebuilding modules '); + + const rebuildArgs = ['--globalconfig', config.getGlobalConfigPath(), '--userconfig', config.getUserConfigPath(), 'rebuild']; + rebuildArgs.push(...this.getNpmBuildFlags()); + rebuildArgs.push(...options.argv._); + + fs.makeTreeSync(this.atomDirectory); + + const env = _.extend({}, process.env, {HOME: this.atomNodeDirectory, RUSTUP_HOME: config.getRustupHomeDirPath()}); + this.addBuildEnvVars(env); + + return this.fork(this.atomNpmPath, rebuildArgs, {env}, callback); + } + + run(options) { + const {callback} = options; + options = this.parseOptions(options.commandArgs); + + config.loadNpm((error, npm) => { + this.npm = npm; + this.loadInstalledAtomMetadata(() => { + this.forkNpmRebuild(options, (code, stderr) => { + if (stderr == null) { stderr = ''; } + if (code === 0) { + this.logSuccess(); + return callback(); + } else { + this.logFailure(); + return callback(stderr); + } + }); + }); + }); + } + } diff --git a/src/request.coffee b/src/request.coffee deleted file mode 100644 index 8f435166..00000000 --- a/src/request.coffee +++ /dev/null @@ -1,59 +0,0 @@ -npm = require 'npm' -request = require 'request' - -config = require './apm' - -loadNpm = (callback) -> - npmOptions = - userconfig: config.getUserConfigPath() - globalconfig: config.getGlobalConfigPath() - npm.load(npmOptions, callback) - -configureRequest = (requestOptions, callback) -> - loadNpm -> - requestOptions.proxy ?= npm.config.get('https-proxy') or npm.config.get('proxy') or process.env.HTTPS_PROXY or process.env.HTTP_PROXY - requestOptions.strictSSL ?= npm.config.get('strict-ssl') - - userAgent = npm.config.get('user-agent') ? "AtomApm/#{require('../package.json').version}" - requestOptions.headers ?= {} - requestOptions.headers['User-Agent'] ?= userAgent - callback() - -module.exports = - get: (requestOptions, callback) -> - configureRequest requestOptions, -> - retryCount = requestOptions.retries ? 0 - requestsMade = 0 - tryRequest = -> - requestsMade++ - request.get requestOptions, (error, response, body) -> - if retryCount > 0 and error?.code in ['ETIMEDOUT', 'ECONNRESET'] - retryCount-- - tryRequest() - else - if error?.message and requestsMade > 1 - error.message += " (#{requestsMade} attempts)" - - callback(error, response, body) - tryRequest() - - del: (requestOptions, callback) -> - configureRequest requestOptions, -> - request.del(requestOptions, callback) - - post: (requestOptions, callback) -> - configureRequest requestOptions, -> - request.post(requestOptions, callback) - - createReadStream: (requestOptions, callback) -> - configureRequest requestOptions, -> - callback(request.get(requestOptions)) - - getErrorMessage: (response, body) -> - if response?.statusCode is 503 - "#{response.host} is temporarily unavailable, please try again later." - else - body?.message ? body?.error ? body - - debug: (debug) -> - request.debug = debug diff --git a/src/request.js b/src/request.js new file mode 100644 index 00000000..3a7a4d41 --- /dev/null +++ b/src/request.js @@ -0,0 +1,73 @@ + +const npm = require('npm'); +const request = require('request'); + +const config = require('./apm'); + +const loadNpm = function(callback) { + const npmOptions = { + userconfig: config.getUserConfigPath(), + globalconfig: config.getGlobalConfigPath() + }; + return npm.load(npmOptions, callback); +}; + +const configureRequest = (requestOptions, callback) => loadNpm(function() { + let left; + if (requestOptions.proxy == null) { requestOptions.proxy = npm.config.get('https-proxy') || npm.config.get('proxy') || process.env.HTTPS_PROXY || process.env.HTTP_PROXY; } + if (requestOptions.strictSSL == null) { requestOptions.strictSSL = npm.config.get('strict-ssl'); } + + const userAgent = (left = npm.config.get('user-agent')) != null ? left : `AtomApm/${require('../package.json').version}`; + if (requestOptions.headers == null) { requestOptions.headers = {}; } + if (requestOptions.headers['User-Agent'] == null) { requestOptions.headers['User-Agent'] = userAgent; } + return callback(); +}); + +module.exports = { + get(requestOptions, callback) { + return configureRequest(requestOptions, function() { + let retryCount = requestOptions.retries != null ? requestOptions.retries : 0; + let requestsMade = 0; + var tryRequest = function() { + requestsMade++; + return request.get(requestOptions, function(error, response, body) { + if ((retryCount > 0) && ['ETIMEDOUT', 'ECONNRESET'].includes(error != null ? error.code : undefined)) { + retryCount--; + return tryRequest(); + } else { + if ((error != null ? error.message : undefined) && (requestsMade > 1)) { + error.message += ` (${requestsMade} attempts)`; + } + + return callback(error, response, body); + } + }); + }; + return tryRequest(); + }); + }, + + del(requestOptions, callback) { + return configureRequest(requestOptions, () => request.del(requestOptions, callback)); + }, + + post(requestOptions, callback) { + return configureRequest(requestOptions, () => request.post(requestOptions, callback)); + }, + + createReadStream(requestOptions, callback) { + return configureRequest(requestOptions, () => callback(request.get(requestOptions))); + }, + + getErrorMessage(response, body) { + if ((response != null ? response.statusCode : undefined) === 503) { + return `${response.host} is temporarily unavailable, please try again later.`; + } else { + return body?.message ?? body?.error ?? body; + } + }, + + debug(debug) { + return request.debug = debug; + } +}; diff --git a/src/search.coffee b/src/search.coffee deleted file mode 100644 index 7a674f9c..00000000 --- a/src/search.coffee +++ /dev/null @@ -1,87 +0,0 @@ -_ = require 'underscore-plus' -yargs = require 'yargs' - -Command = require './command' -config = require './apm' -request = require './request' -tree = require './tree' -{isDeprecatedPackage} = require './deprecated-packages' - -module.exports = -class Search extends Command - @commandNames: ['search'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm search - - Search for packages/themes. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - options.boolean('json').describe('json', 'Output matching packages as JSON array') - options.boolean('packages').describe('packages', 'Search only non-theme packages').alias('p', 'packages') - options.boolean('themes').describe('themes', 'Search only themes').alias('t', 'themes') - - searchPackages: (query, opts, callback) -> - qs = - q: query - - if opts.packages - qs.filter = 'package' - else if opts.themes - qs.filter = 'theme' - - requestSettings = - url: "#{config.getAtomPackagesUrl()}/search" - qs: qs - json: true - - request.get requestSettings, (error, response, body={}) -> - if error? - callback(error) - else if response.statusCode is 200 - packages = body.filter (pack) -> pack.releases?.latest? - packages = packages.map ({readme, metadata, downloads, stargazers_count}) -> _.extend({}, metadata, {readme, downloads, stargazers_count}) - packages = packages.filter ({name, version}) -> not isDeprecatedPackage(name, version) - callback(null, packages) - else - message = request.getErrorMessage(response, body) - callback("Searching packages failed: #{message}") - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - [query] = options.argv._ - - unless query - callback("Missing required search query") - return - - searchOptions = - packages: options.argv.packages - themes: options.argv.themes - - @searchPackages query, searchOptions, (error, packages) -> - if error? - callback(error) - return - - if options.argv.json - console.log(JSON.stringify(packages)) - else - heading = "Search Results For '#{query}'".cyan - console.log "#{heading} (#{packages.length})" - - tree packages, ({name, version, description, downloads, stargazers_count}) -> - label = name.yellow - label += " #{description.replace(/\s+/g, ' ')}" if description - label += " (#{_.pluralize(downloads, 'download')}, #{_.pluralize(stargazers_count, 'star')})".grey if downloads >= 0 and stargazers_count >= 0 - label - - console.log() - console.log "Use `ppm install` to install them or visit #{'https://web.pulsar-edit.dev'.underline} to read more about them." - console.log() - - callback() diff --git a/src/search.js b/src/search.js new file mode 100644 index 00000000..9020665f --- /dev/null +++ b/src/search.js @@ -0,0 +1,105 @@ + +const _ = require('underscore-plus'); +const yargs = require('yargs'); + +const Command = require('./command'); +const config = require('./apm'); +const request = require('./request'); +const tree = require('./tree'); +const {isDeprecatedPackage} = require('./deprecated-packages'); + +module.exports = +class Search extends Command { + static commandNames = [ "search" ]; + + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm search + +Search for packages/themes.\ +` + ); + options.alias('h', 'help').describe('help', 'Print this usage message'); + options.boolean('json').describe('json', 'Output matching packages as JSON array'); + options.boolean('packages').describe('packages', 'Search only non-theme packages').alias('p', 'packages'); + return options.boolean('themes').describe('themes', 'Search only themes').alias('t', 'themes'); + } + + searchPackages(query, opts, callback) { + const qs = + {q: query}; + + if (opts.packages) { + qs.filter = 'package'; + } else if (opts.themes) { + qs.filter = 'theme'; + } + + const requestSettings = { + url: `${config.getAtomPackagesUrl()}/search`, + qs, + json: true + }; + + return request.get(requestSettings, function(error, response, body) { + if (body == null) { body = {}; } + if (error != null) { + return callback(error); + } else if (response.statusCode === 200) { + let packages = body.filter(pack => (pack.releases != null ? pack.releases.latest : undefined) != null); + packages = packages.map(({readme, metadata, downloads, stargazers_count}) => _.extend({}, metadata, {readme, downloads, stargazers_count})); + packages = packages.filter(({name, version}) => !isDeprecatedPackage(name, version)); + return callback(null, packages); + } else { + const message = request.getErrorMessage(response, body); + return callback(`Searching packages failed: ${message}`); + } + }); + } + + run(options) { + const {callback} = options; + options = this.parseOptions(options.commandArgs); + const [query] = options.argv._; + + if (!query) { + callback("Missing required search query"); + return; + } + + const searchOptions = { + packages: options.argv.packages, + themes: options.argv.themes + }; + + this.searchPackages(query, searchOptions, function(error, packages) { + if (error != null) { + callback(error); + return; + } + + if (options.argv.json) { + console.log(JSON.stringify(packages)); + } else { + const heading = `Search Results For '${query}'`.cyan; + console.log(`${heading} (${packages.length})`); + + tree(packages, function({name, version, description, downloads, stargazers_count}) { + let label = name.yellow; + if (description) { label += ` ${description.replace(/\s+/g, ' ')}`; } + if ((downloads >= 0) && (stargazers_count >= 0)) { label += ` (${_.pluralize(downloads, 'download')}, ${_.pluralize(stargazers_count, 'star')})`.grey; } + return label; + }); + + console.log(); + console.log(`Use \`ppm install\` to install them or visit ${'https://web.pulsar-edit.dev'.underline} to read more about them.`); + console.log(); + } + + return callback(); + }); + } + } diff --git a/src/star.coffee b/src/star.coffee deleted file mode 100644 index a77186a5..00000000 --- a/src/star.coffee +++ /dev/null @@ -1,93 +0,0 @@ -path = require 'path' - -_ = require 'underscore-plus' -async = require 'async' -CSON = require 'season' -yargs = require 'yargs' - -config = require './apm' -Command = require './command' -fs = require './fs' -Login = require './login' -Packages = require './packages' -request = require './request' - -module.exports = -class Star extends Command - @commandNames: ['star'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm star ... - - Star the given packages - - Run `ppm stars` to see all your starred packages. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - options.boolean('installed').describe('installed', 'Star all packages in ~/.pulsar/packages') - - starPackage: (packageName, {ignoreUnpublishedPackages, token}={}, callback) -> - process.stdout.write '\u2B50 ' if process.platform is 'darwin' - process.stdout.write "Starring #{packageName} " - requestSettings = - json: true - url: "#{config.getAtomPackagesUrl()}/#{packageName}/star" - headers: - authorization: token - request.post requestSettings, (error, response, body={}) => - if error? - @logFailure() - callback(error) - else if response.statusCode is 404 and ignoreUnpublishedPackages - process.stdout.write 'skipped (not published)\n'.yellow - callback() - else if response.statusCode isnt 200 - @logFailure() - message = request.getErrorMessage(response, body) - callback("Starring package failed: #{message}") - else - @logSuccess() - callback() - - getInstalledPackageNames: -> - installedPackages = [] - userPackagesDirectory = path.join(config.getAtomDirectory(), 'packages') - for child in fs.list(userPackagesDirectory) - continue unless fs.isDirectorySync(path.join(userPackagesDirectory, child)) - - if manifestPath = CSON.resolve(path.join(userPackagesDirectory, child, 'package')) - try - metadata = CSON.readFileSync(manifestPath) ? {} - if metadata.name and Packages.getRepository(metadata) - installedPackages.push metadata.name - - _.uniq(installedPackages) - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - - if options.argv.installed - packageNames = @getInstalledPackageNames() - if packageNames.length is 0 - callback() - return - else - packageNames = @packageNamesFromArgv(options.argv) - if packageNames.length is 0 - callback("Please specify a package name to star") - return - - Login.getTokenOrLogin (error, token) => - return callback(error) if error? - - starOptions = - ignoreUnpublishedPackages: options.argv.installed - token: token - - commands = packageNames.map (packageName) => - (callback) => @starPackage(packageName, starOptions, callback) - async.waterfall(commands, callback) diff --git a/src/star.js b/src/star.js new file mode 100644 index 00000000..bd7d8d47 --- /dev/null +++ b/src/star.js @@ -0,0 +1,119 @@ + +const path = require('path'); + +const _ = require('underscore-plus'); +const async = require('async'); +const CSON = require('season'); +const yargs = require('yargs'); + +const config = require('./apm'); +const Command = require('./command'); +const fs = require('./fs'); +const Login = require('./login'); +const Packages = require('./packages'); +const request = require('./request'); + +module.exports = +class Star extends Command { + static commandNames = [ "star" ]; + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm star ... + +Star the given packages + +Run \`ppm stars\` to see all your starred packages.\ +` + ); + options.alias('h', 'help').describe('help', 'Print this usage message'); + return options.boolean('installed').describe('installed', 'Star all packages in ~/.pulsar/packages'); + } + + starPackage(packageName, param, callback) { + if (param == null) { param = {}; } + const {ignoreUnpublishedPackages, token} = param; + if (process.platform === 'darwin') { process.stdout.write('\u2B50 '); } + process.stdout.write(`Starring ${packageName} `); + const requestSettings = { + json: true, + url: `${config.getAtomPackagesUrl()}/${packageName}/star`, + headers: { + authorization: token + } + }; + request.post(requestSettings, (error, response, body) => { + if (body == null) { body = {}; } + if (error != null) { + this.logFailure(); + return callback(error); + } else if ((response.statusCode === 404) && ignoreUnpublishedPackages) { + process.stdout.write('skipped (not published)\n'.yellow); + return callback(); + } else if (response.statusCode !== 200) { + this.logFailure(); + const message = request.getErrorMessage(response, body); + return callback(`Starring package failed: ${message}`); + } else { + this.logSuccess(); + return callback(); + } + }); + } + + getInstalledPackageNames() { + const installedPackages = []; + const userPackagesDirectory = path.join(config.getAtomDirectory(), 'packages'); + for (let child of fs.list(userPackagesDirectory)) { + if (!fs.isDirectorySync(path.join(userPackagesDirectory, child))) { continue; } + + let manifestPath = CSON.resolve(path.join(userPackagesDirectory, child, "package")); + if (manifestPath) { + try { + const metadata = CSON.readFileSync(manifestPath) ?? {}; + if (metadata.name && Packages.getRepository(metadata)) { + installedPackages.push(metadata.name); + } + } catch (error) {} + } + } + + return _.uniq(installedPackages); + } + + run(options) { + let packageNames; + const {callback} = options; + options = this.parseOptions(options.commandArgs); + + if (options.argv.installed) { + packageNames = this.getInstalledPackageNames(); + if (packageNames.length === 0) { + callback(); + return; + } + } else { + packageNames = this.packageNamesFromArgv(options.argv); + if (packageNames.length === 0) { + callback("Please specify a package name to star"); + return; + } + } + + Login.getTokenOrLogin((error, token) => { + if (error != null) { return callback(error); } + + const starOptions = { + ignoreUnpublishedPackages: options.argv.installed, + token + }; + + const commands = packageNames.map(packageName => { + return callback => this.starPackage(packageName, starOptions, callback); + }); + return async.waterfall(commands, callback); + }); + } + } diff --git a/src/stars.coffee b/src/stars.coffee deleted file mode 100644 index b1efcdb8..00000000 --- a/src/stars.coffee +++ /dev/null @@ -1,106 +0,0 @@ -_ = require 'underscore-plus' -yargs = require 'yargs' - -Command = require './command' -config = require './apm' -Install = require './install' -Login = require './login' -request = require './request' -tree = require './tree' - -module.exports = -class Stars extends Command - @commandNames: ['stars', 'starred'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm stars - ppm stars --install - ppm stars --user thedaniel - ppm stars --themes - - List or install starred Atom packages and themes. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - options.alias('i', 'install').boolean('install').describe('install', 'Install the starred packages') - options.alias('t', 'themes').boolean('themes').describe('themes', 'Only list themes') - options.alias('u', 'user').string('user').describe('user', 'GitHub username to show starred packages for') - options.boolean('json').describe('json', 'Output packages as a JSON array') - - getStarredPackages: (user, atomVersion, callback) -> - requestSettings = json: true - requestSettings.qs = engine: atomVersion if atomVersion - - if user - requestSettings.url = "#{config.getAtomApiUrl()}/users/#{user}/stars" - @requestStarredPackages(requestSettings, callback) - else - requestSettings.url = "#{config.getAtomApiUrl()}/stars" - Login.getTokenOrLogin (error, token) => - return callback(error) if error? - - requestSettings.headers = authorization: token - @requestStarredPackages(requestSettings, callback) - - requestStarredPackages: (requestSettings, callback) -> - request.get requestSettings, (error, response, body=[]) -> - if error? - callback(error) - else if response.statusCode is 200 - packages = body.filter (pack) -> pack?.releases?.latest? - packages = packages.map ({readme, metadata, downloads, stargazers_count}) -> _.extend({}, metadata, {readme, downloads, stargazers_count}) - packages = _.sortBy(packages, 'name') - callback(null, packages) - else - message = request.getErrorMessage(response, body) - callback("Requesting packages failed: #{message}") - - installPackages: (packages, callback) -> - return callback() if packages.length is 0 - - commandArgs = packages.map ({name}) -> name - new Install().run({commandArgs, callback}) - - logPackagesAsJson: (packages, callback) -> - console.log(JSON.stringify(packages)) - callback() - - logPackagesAsText: (user, packagesAreThemes, packages, callback) -> - userLabel = user ? 'you' - if packagesAreThemes - label = "Themes starred by #{userLabel}" - else - label = "Packages starred by #{userLabel}" - console.log "#{label.cyan} (#{packages.length})" - - tree packages, ({name, version, description, downloads, stargazers_count}) -> - label = name.yellow - label = "\u2B50 #{label}" if process.platform is 'darwin' - label += " #{description.replace(/\s+/g, ' ')}" if description - label += " (#{_.pluralize(downloads, 'download')}, #{_.pluralize(stargazers_count, 'star')})".grey if downloads >= 0 and stargazers_count >= 0 - label - - console.log() - console.log "Use `ppm stars --install` to install them all or visit #{'https://web.pulsar-edit.dev'.underline} to read more about them." - console.log() - callback() - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - user = options.argv.user?.toString().trim() - - @getStarredPackages user, options.argv.compatible, (error, packages) => - return callback(error) if error? - - if options.argv.themes - packages = packages.filter ({theme}) -> theme - - if options.argv.install - @installPackages(packages, callback) - else if options.argv.json - @logPackagesAsJson(packages, callback) - else - @logPackagesAsText(user, options.argv.themes, packages, callback) diff --git a/src/stars.js b/src/stars.js new file mode 100644 index 00000000..0384598b --- /dev/null +++ b/src/stars.js @@ -0,0 +1,127 @@ + +const _ = require('underscore-plus'); +const yargs = require('yargs'); + +const Command = require('./command'); +const config = require('./apm'); +const Install = require('./install'); +const Login = require('./login'); +const request = require('./request'); +const tree = require('./tree'); + +module.exports = +class Stars extends Command { + static commandNames = [ "stars", "starred" ]; + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm stars + ppm stars --install + ppm stars --user thedaniel + ppm stars --themes + +List or install starred Atom packages and themes.\ +` + ); + options.alias('h', 'help').describe('help', 'Print this usage message'); + options.alias('i', 'install').boolean('install').describe('install', 'Install the starred packages'); + options.alias('t', 'themes').boolean('themes').describe('themes', 'Only list themes'); + options.alias('u', 'user').string('user').describe('user', 'GitHub username to show starred packages for'); + return options.boolean('json').describe('json', 'Output packages as a JSON array'); + } + + getStarredPackages(user, atomVersion, callback) { + const requestSettings = {json: true}; + if (atomVersion) { requestSettings.qs = {engine: atomVersion}; } + + if (user) { + requestSettings.url = `${config.getAtomApiUrl()}/users/${user}/stars`; + return this.requestStarredPackages(requestSettings, callback); + } else { + requestSettings.url = `${config.getAtomApiUrl()}/stars`; + return Login.getTokenOrLogin((error, token) => { + if (error != null) { return callback(error); } + + requestSettings.headers = {authorization: token}; + return this.requestStarredPackages(requestSettings, callback); + }); + } + } + + requestStarredPackages(requestSettings, callback) { + return request.get(requestSettings, function(error, response, body) { + if (body == null) { body = []; } + if (error != null) { + return callback(error); + } else if (response.statusCode === 200) { + let packages = body.filter(pack => pack?.releases?.latest != null); + packages = packages.map(({readme, metadata, downloads, stargazers_count}) => _.extend({}, metadata, {readme, downloads, stargazers_count})); + packages = _.sortBy(packages, 'name'); + return callback(null, packages); + } else { + const message = request.getErrorMessage(response, body); + return callback(`Requesting packages failed: ${message}`); + } + }); + } + + installPackages(packages, callback) { + if (packages.length === 0) { return callback(); } + + const commandArgs = packages.map(({name}) => name); + return new Install().run({commandArgs, callback}); + } + + logPackagesAsJson(packages, callback) { + console.log(JSON.stringify(packages)); + return callback(); + } + + logPackagesAsText(user, packagesAreThemes, packages, callback) { + let label; + const userLabel = user != null ? user : 'you'; + if (packagesAreThemes) { + label = `Themes starred by ${userLabel}`; + } else { + label = `Packages starred by ${userLabel}`; + } + console.log(`${label.cyan} (${packages.length})`); + + tree(packages, function({name, version, description, downloads, stargazers_count}) { + label = name.yellow; + if (process.platform === 'darwin') { label = `\u2B50 ${label}`; } + if (description) { label += ` ${description.replace(/\s+/g, ' ')}`; } + if ((downloads >= 0) && (stargazers_count >= 0)) { label += ` (${_.pluralize(downloads, 'download')}, ${_.pluralize(stargazers_count, 'star')})`.grey; } + return label; + }); + + console.log(); + console.log(`Use \`ppm stars --install\` to install them all or visit ${'https://web.pulsar-edit.dev'.underline} to read more about them.`); + console.log(); + return callback(); + } + + run(options) { + const {callback} = options; + options = this.parseOptions(options.commandArgs); + const user = options.argv.user?.toString().trim(); + + return this.getStarredPackages(user, options.argv.compatible, (error, packages) => { + if (error != null) { return callback(error); } + + if (options.argv.themes) { + packages = packages.filter(({theme}) => theme); + } + + if (options.argv.install) { + return this.installPackages(packages, callback); + } else if (options.argv.json) { + return this.logPackagesAsJson(packages, callback); + } else { + return this.logPackagesAsText(user, options.argv.themes, packages, callback); + } + }); + } + } diff --git a/src/test.coffee b/src/test.coffee deleted file mode 100644 index 60808604..00000000 --- a/src/test.coffee +++ /dev/null @@ -1,65 +0,0 @@ -path = require 'path' - -yargs = require 'yargs' -temp = require 'temp' - -Command = require './command' -fs = require './fs' - -module.exports = -class Test extends Command - @commandNames: ['test'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - - options.usage """ - Usage: - ppm test - - Runs the package's tests contained within the spec directory (relative - to the current working directory). - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - options.alias('p', 'path').string('path').describe('path', 'Path to atom command') - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - {env} = process - - atomCommand = options.argv.path if options.argv.path - unless fs.existsSync(atomCommand) - atomCommand = 'atom' - atomCommand += '.cmd' if process.platform is 'win32' - - packagePath = process.cwd() - testArgs = ['--dev', '--test', path.join(packagePath, 'spec')] - - if process.platform is 'win32' - logFile = temp.openSync(suffix: '.log', prefix: "#{path.basename(packagePath)}-") - fs.closeSync(logFile.fd) - logFilePath = logFile.path - testArgs.push("--log-file=#{logFilePath}") - - @spawn atomCommand, testArgs, (code) -> - try - loggedOutput = fs.readFileSync(logFilePath, 'utf8') - process.stdout.write("#{loggedOutput}\n") if loggedOutput - - if code is 0 - process.stdout.write 'Tests passed\n'.green - callback() - else if code?.message - callback("Error spawning Atom: #{code.message}") - else - callback('Tests failed') - else - @spawn atomCommand, testArgs, {env, streaming: true}, (code) -> - if code is 0 - process.stdout.write 'Tests passed\n'.green - callback() - else if code?.message - callback("Error spawning #{atomCommand}: #{code.message}") - else - callback('Tests failed') diff --git a/src/test.js b/src/test.js new file mode 100644 index 00000000..8b180e22 --- /dev/null +++ b/src/test.js @@ -0,0 +1,78 @@ + +const path = require('path'); + +const yargs = require('yargs'); +const temp = require('temp'); + +const Command = require('./command'); +const fs = require('./fs'); + +module.exports = +class Test extends Command { + static commandNames = [ "test" ]; + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + + options.usage(`\ +Usage: + ppm test + +Runs the package's tests contained within the spec directory (relative +to the current working directory).\ +` + ); + options.alias('h', 'help').describe('help', 'Print this usage message'); + return options.alias('p', 'path').string('path').describe('path', 'Path to atom command'); + } + + run(options) { + let atomCommand; + const {callback} = options; + options = this.parseOptions(options.commandArgs); + const {env} = process; + + if (options.argv.path) { atomCommand = options.argv.path; } + if (!fs.existsSync(atomCommand)) { + atomCommand = 'atom'; + if (process.platform === 'win32') { atomCommand += '.cmd'; } + } + + const packagePath = process.cwd(); + const testArgs = ['--dev', '--test', path.join(packagePath, 'spec')]; + + if (process.platform === 'win32') { + const logFile = temp.openSync({suffix: '.log', prefix: `${path.basename(packagePath)}-`}); + fs.closeSync(logFile.fd); + const logFilePath = logFile.path; + testArgs.push(`--log-file=${logFilePath}`); + + this.spawn(atomCommand, testArgs, function(code) { + try { + const loggedOutput = fs.readFileSync(logFilePath, 'utf8'); + if (loggedOutput) { process.stdout.write(`${loggedOutput}\n`); } + } catch (error) {} + + if (code === 0) { + process.stdout.write('Tests passed\n'.green); + return callback(); + } else if ((code != null ? code.message : undefined)) { + return callback(`Error spawning Atom: ${code.message}`); + } else { + return callback('Tests failed'); + } + }); + } else { + this.spawn(atomCommand, testArgs, {env, streaming: true}, function(code) { + if (code === 0) { + process.stdout.write('Tests passed\n'.green); + return callback(); + } else if ((code != null ? code.message : undefined)) { + return callback(`Error spawning ${atomCommand}: ${code.message}`); + } else { + return callback('Tests failed'); + } + }); + } + } + } diff --git a/src/text-mate-theme.coffee b/src/text-mate-theme.coffee deleted file mode 100644 index 43e7ce63..00000000 --- a/src/text-mate-theme.coffee +++ /dev/null @@ -1,195 +0,0 @@ -_ = require 'underscore-plus' -plist = require '@atom/plist' -{ScopeSelector} = require 'first-mate' - -module.exports = -class TextMateTheme - constructor: (@contents) -> - @rulesets = [] - @buildRulesets() - - buildRulesets: -> - {settings} = plist.parseStringSync(@contents) ? {} - settings ?= [] - - for setting in settings - {scope, name} = setting.settings - continue if scope or name - - # Require all of these or invalid LESS will be generated if any required - # variable value is missing - {background, foreground, caret, selection, invisibles, lineHighlight} = setting.settings - if background and foreground and caret and selection and lineHighlight and invisibles - variableSettings = setting.settings - break - - unless variableSettings? - throw new Error """ - Could not find the required color settings in the theme. - - The theme being converted must contain a settings array with all of the following keys: - * background - * caret - * foreground - * invisibles - * lineHighlight - * selection - """ - - @buildSyntaxVariables(variableSettings) - @buildGlobalSettingsRulesets(variableSettings) - @buildScopeSelectorRulesets(settings) - - getStylesheet: -> - lines = [ - '@import "syntax-variables";' - '' - ] - for {selector, properties} in @getRulesets() - lines.push("#{selector} {") - lines.push " #{name}: #{value};" for name, value of properties - lines.push("}\n") - lines.join('\n') - - getRulesets: -> @rulesets - - getSyntaxVariables: -> @syntaxVariables - - buildSyntaxVariables: (settings) -> - @syntaxVariables = SyntaxVariablesTemplate - for key, value of settings - replaceRegex = new RegExp("\\{\\{#{key}\\}\\}", 'g') - @syntaxVariables = @syntaxVariables.replace(replaceRegex, @translateColor(value)) - @syntaxVariables - - buildGlobalSettingsRulesets: (settings) -> - @rulesets.push - selector: 'atom-text-editor' - properties: - 'background-color': '@syntax-background-color' - 'color': '@syntax-text-color' - - @rulesets.push - selector: 'atom-text-editor .gutter' - properties: - 'background-color': '@syntax-gutter-background-color' - 'color': '@syntax-gutter-text-color' - - @rulesets.push - selector: 'atom-text-editor .gutter .line-number.cursor-line' - properties: - 'background-color': '@syntax-gutter-background-color-selected' - 'color': '@syntax-gutter-text-color-selected' - - @rulesets.push - selector: 'atom-text-editor .gutter .line-number.cursor-line-no-selection' - properties: - 'color': '@syntax-gutter-text-color-selected' - - @rulesets.push - selector: 'atom-text-editor .wrap-guide' - properties: - 'color': '@syntax-wrap-guide-color' - - @rulesets.push - selector: 'atom-text-editor .indent-guide' - properties: - 'color': '@syntax-indent-guide-color' - - @rulesets.push - selector: 'atom-text-editor .invisible-character' - properties: - 'color': '@syntax-invisible-character-color' - - @rulesets.push - selector: 'atom-text-editor.is-focused .cursor' - properties: - 'border-color': '@syntax-cursor-color' - - @rulesets.push - selector: 'atom-text-editor.is-focused .selection .region' - properties: - 'background-color': '@syntax-selection-color' - - @rulesets.push - selector: 'atom-text-editor.is-focused .line-number.cursor-line-no-selection, - atom-text-editor.is-focused .line.cursor-line' - properties: - 'background-color': @translateColor(settings.lineHighlight) - - buildScopeSelectorRulesets: (scopeSelectorSettings) -> - for {name, scope, settings} in scopeSelectorSettings - continue unless scope - @rulesets.push - comment: name - selector: @translateScopeSelector(scope) - properties: @translateScopeSelectorSettings(settings) - - translateScopeSelector: (textmateScopeSelector) -> - new ScopeSelector(textmateScopeSelector).toCssSyntaxSelector() - - translateScopeSelectorSettings: ({foreground, background, fontStyle}) -> - properties = {} - - if fontStyle - fontStyles = fontStyle.split(/\s+/) - properties['font-weight'] = 'bold' if _.contains(fontStyles, 'bold') - properties['font-style'] = 'italic' if _.contains(fontStyles, 'italic') - properties['text-decoration'] = 'underline' if _.contains(fontStyles, 'underline') - - properties['color'] = @translateColor(foreground) if foreground - properties['background-color'] = @translateColor(background) if background - properties - - translateColor: (textmateColor) -> - textmateColor = "##{textmateColor.replace(/^#+/, '')}" - if textmateColor.length <= 7 - textmateColor - else - r = @parseHexColor(textmateColor[1..2]) - g = @parseHexColor(textmateColor[3..4]) - b = @parseHexColor(textmateColor[5..6]) - a = @parseHexColor(textmateColor[7..8]) - a = Math.round((a / 255.0) * 100) / 100 - - "rgba(#{r}, #{g}, #{b}, #{a})" - - parseHexColor: (color) -> - parsed = Math.min(255, Math.max(0, parseInt(color, 16))) - if isNaN(parsed) - 0 - else - parsed - -SyntaxVariablesTemplate = """ - // This defines all syntax variables that syntax themes must implement when they - // include a syntax-variables.less file. - - // General colors - @syntax-text-color: {{foreground}}; - @syntax-cursor-color: {{caret}}; - @syntax-selection-color: {{selection}}; - @syntax-background-color: {{background}}; - - // Guide colors - @syntax-wrap-guide-color: {{invisibles}}; - @syntax-indent-guide-color: {{invisibles}}; - @syntax-invisible-character-color: {{invisibles}}; - - // For find and replace markers - @syntax-result-marker-color: {{invisibles}}; - @syntax-result-marker-color-selected: {{foreground}}; - - // Gutter colors - @syntax-gutter-text-color: {{foreground}}; - @syntax-gutter-text-color-selected: {{foreground}}; - @syntax-gutter-background-color: {{background}}; - @syntax-gutter-background-color-selected: {{lineHighlight}}; - - // For git diff info. i.e. in the gutter - // These are static and were not extracted from your textmate theme - @syntax-color-renamed: #96CBFE; - @syntax-color-added: #A8FF60; - @syntax-color-modified: #E9C062; - @syntax-color-removed: #CC6666; -""" diff --git a/src/text-mate-theme.js b/src/text-mate-theme.js new file mode 100644 index 00000000..4f3bcbff --- /dev/null +++ b/src/text-mate-theme.js @@ -0,0 +1,245 @@ + +const _ = require('underscore-plus'); +const plist = require('@atom/plist'); +const {ScopeSelector} = require('first-mate'); + +module.exports = +class TextMateTheme { + constructor(contents) { + this.contents = contents; + this.rulesets = []; + this.buildRulesets(); + } + + buildRulesets() { + let variableSettings; + let { settings } = plist.parseStringSync(this.contents) ?? {}; + if (settings == null) { settings = []; } + + for (let setting of settings) { + const {scope, name} = setting.settings; + if (scope || name) { continue; } + + // Require all of these or invalid LESS will be generated if any required + // variable value is missing + const {background, foreground, caret, selection, invisibles, lineHighlight} = setting.settings; + if (background && foreground && caret && selection && lineHighlight && invisibles) { + variableSettings = setting.settings; + break; + } + } + + if (variableSettings == null) { + throw new Error(`\ +Could not find the required color settings in the theme. + +The theme being converted must contain a settings array with all of the following keys: + * background + * caret + * foreground + * invisibles + * lineHighlight + * selection\ +` + ); + } + + this.buildSyntaxVariables(variableSettings); + this.buildGlobalSettingsRulesets(variableSettings); + this.buildScopeSelectorRulesets(settings); + } + + getStylesheet() { + const lines = [ + '@import "syntax-variables";', + '' + ]; + for (let {selector, properties} of this.getRulesets()) { + lines.push(`${selector} {`); + for (let name in properties) { const value = properties[name]; lines.push(` ${name}: ${value};`); } + lines.push("}\n"); + } + return lines.join('\n'); + } + + getRulesets() { return this.rulesets; } + + getSyntaxVariables() { return this.syntaxVariables; } + + buildSyntaxVariables(settings) { + this.syntaxVariables = SyntaxVariablesTemplate; + for (let key in settings) { + const value = settings[key]; + const replaceRegex = new RegExp(`\\{\\{${key}\\}\\}`, 'g'); + this.syntaxVariables = this.syntaxVariables.replace(replaceRegex, this.translateColor(value)); + } + return this.syntaxVariables; + } + + buildGlobalSettingsRulesets(settings) { + this.rulesets.push({ + selector: 'atom-text-editor', + properties: { + 'background-color': '@syntax-background-color', + 'color': '@syntax-text-color' + } + }); + + this.rulesets.push({ + selector: 'atom-text-editor .gutter', + properties: { + 'background-color': '@syntax-gutter-background-color', + 'color': '@syntax-gutter-text-color' + } + }); + + this.rulesets.push({ + selector: 'atom-text-editor .gutter .line-number.cursor-line', + properties: { + 'background-color': '@syntax-gutter-background-color-selected', + 'color': '@syntax-gutter-text-color-selected' + } + }); + + this.rulesets.push({ + selector: 'atom-text-editor .gutter .line-number.cursor-line-no-selection', + properties: { + 'color': '@syntax-gutter-text-color-selected' + } + }); + + this.rulesets.push({ + selector: 'atom-text-editor .wrap-guide', + properties: { + 'color': '@syntax-wrap-guide-color' + } + }); + + this.rulesets.push({ + selector: 'atom-text-editor .indent-guide', + properties: { + 'color': '@syntax-indent-guide-color' + } + }); + + this.rulesets.push({ + selector: 'atom-text-editor .invisible-character', + properties: { + 'color': '@syntax-invisible-character-color' + } + }); + + this.rulesets.push({ + selector: 'atom-text-editor.is-focused .cursor', + properties: { + 'border-color': '@syntax-cursor-color' + } + }); + + this.rulesets.push({ + selector: 'atom-text-editor.is-focused .selection .region', + properties: { + 'background-color': '@syntax-selection-color' + } + }); + + return this.rulesets.push({ + selector: `atom-text-editor.is-focused .line-number.cursor-line-no-selection, \ +atom-text-editor.is-focused .line.cursor-line`, + properties: { + 'background-color': this.translateColor(settings.lineHighlight) + } + }); + } + + buildScopeSelectorRulesets(scopeSelectorSettings) { + return (() => { + const result = []; + for (let {name, scope, settings} of Array.from(scopeSelectorSettings)) { + if (!scope) { continue; } + result.push(this.rulesets.push({ + comment: name, + selector: this.translateScopeSelector(scope), + properties: this.translateScopeSelectorSettings(settings) + })); + } + return result; + })(); + } + + translateScopeSelector(textmateScopeSelector) { + return new ScopeSelector(textmateScopeSelector).toCssSyntaxSelector(); + } + + translateScopeSelectorSettings({foreground, background, fontStyle}) { + const properties = {}; + + if (fontStyle) { + const fontStyles = fontStyle.split(/\s+/); + if (_.contains(fontStyles, 'bold')) { properties['font-weight'] = 'bold'; } + if (_.contains(fontStyles, 'italic')) { properties['font-style'] = 'italic'; } + if (_.contains(fontStyles, 'underline')) { properties['text-decoration'] = 'underline'; } + } + + if (foreground) { properties['color'] = this.translateColor(foreground); } + if (background) { properties['background-color'] = this.translateColor(background); } + return properties; + } + + translateColor(textmateColor) { + textmateColor = `#${textmateColor.replace(/^#+/, '')}`; + if (textmateColor.length <= 7) { + return textmateColor; + } else { + const r = this.parseHexColor(textmateColor.slice(1, 3)); + const g = this.parseHexColor(textmateColor.slice(3, 5)); + const b = this.parseHexColor(textmateColor.slice(5, 7)); + let a = this.parseHexColor(textmateColor.slice(7, 9)); + a = Math.round((a / 255.0) * 100) / 100; + + return `rgba(${r}, ${g}, ${b}, ${a})`; + } + } + + parseHexColor(color) { + const parsed = Math.min(255, Math.max(0, parseInt(color, 16))); + if (isNaN(parsed)) { + return 0; + } else { + return parsed; + } + } +}; + +var SyntaxVariablesTemplate = `\ +// This defines all syntax variables that syntax themes must implement when they +// include a syntax-variables.less file. + +// General colors +@syntax-text-color: {{foreground}}; +@syntax-cursor-color: {{caret}}; +@syntax-selection-color: {{selection}}; +@syntax-background-color: {{background}}; + +// Guide colors +@syntax-wrap-guide-color: {{invisibles}}; +@syntax-indent-guide-color: {{invisibles}}; +@syntax-invisible-character-color: {{invisibles}}; + +// For find and replace markers +@syntax-result-marker-color: {{invisibles}}; +@syntax-result-marker-color-selected: {{foreground}}; + +// Gutter colors +@syntax-gutter-text-color: {{foreground}}; +@syntax-gutter-text-color-selected: {{foreground}}; +@syntax-gutter-background-color: {{background}}; +@syntax-gutter-background-color-selected: {{lineHighlight}}; + +// For git diff info. i.e. in the gutter +// These are static and were not extracted from your textmate theme +@syntax-color-renamed: #96CBFE; +@syntax-color-added: #A8FF60; +@syntax-color-modified: #E9C062; +@syntax-color-removed: #CC6666;\ +`; diff --git a/src/theme-converter.coffee b/src/theme-converter.coffee deleted file mode 100644 index 265be819..00000000 --- a/src/theme-converter.coffee +++ /dev/null @@ -1,44 +0,0 @@ -path = require 'path' -url = require 'url' -fs = require './fs' -request = require './request' -TextMateTheme = require './text-mate-theme' - -# Convert a TextMate theme to an Atom theme -module.exports = -class ThemeConverter - constructor: (@sourcePath, destinationPath) -> - @destinationPath = path.resolve(destinationPath) - - readTheme: (callback) -> - {protocol} = url.parse(@sourcePath) - if protocol is 'http:' or protocol is 'https:' - requestOptions = url: @sourcePath - request.get requestOptions, (error, response, body) => - if error? - if error.code is 'ENOTFOUND' - error = "Could not resolve URL: #{@sourcePath}" - callback(error) - else if response.statusCode isnt 200 - callback("Request to #{@sourcePath} failed (#{response.headers.status})") - else - callback(null, body) - else - sourcePath = path.resolve(@sourcePath) - if fs.isFileSync(sourcePath) - callback(null, fs.readFileSync(sourcePath, 'utf8')) - else - callback("TextMate theme file not found: #{sourcePath}") - - convert: (callback) -> - @readTheme (error, themeContents) => - return callback(error) if error? - - try - theme = new TextMateTheme(themeContents) - catch error - return callback(error) - - fs.writeFileSync(path.join(@destinationPath, 'styles', 'base.less'), theme.getStylesheet()) - fs.writeFileSync(path.join(@destinationPath, 'styles', 'syntax-variables.less'), theme.getSyntaxVariables()) - callback() diff --git a/src/theme-converter.js b/src/theme-converter.js new file mode 100644 index 00000000..c1052b08 --- /dev/null +++ b/src/theme-converter.js @@ -0,0 +1,58 @@ + +const path = require('path'); +const url = require('url'); +const fs = require('./fs'); +const request = require('./request'); +const TextMateTheme = require('./text-mate-theme'); + +// Convert a TextMate theme to an Atom theme +module.exports = +class ThemeConverter { + constructor(sourcePath, destinationPath) { + this.sourcePath = sourcePath; + this.destinationPath = path.resolve(destinationPath); + } + + readTheme(callback) { + const {protocol} = url.parse(this.sourcePath); + if ((protocol === 'http:') || (protocol === 'https:')) { + const requestOptions = {url: this.sourcePath}; + request.get(requestOptions, (error, response, body) => { + if (error != null) { + if (error.code === 'ENOTFOUND') { + error = `Could not resolve URL: ${this.sourcePath}`; + } + return callback(error); + } else if (response.statusCode !== 200) { + return callback(`Request to ${this.sourcePath} failed (${response.headers.status})`); + } else { + return callback(null, body); + } + }); + } else { + const sourcePath = path.resolve(this.sourcePath); + if (fs.isFileSync(sourcePath)) { + return callback(null, fs.readFileSync(sourcePath, 'utf8')); + } else { + return callback(`TextMate theme file not found: ${sourcePath}`); + } + } + } + + convert(callback) { + this.readTheme((error, themeContents) => { + let theme; + if (error != null) { return callback(error); } + + try { + theme = new TextMateTheme(themeContents); + } catch (error) { + return callback(error); + } + + fs.writeFileSync(path.join(this.destinationPath, 'styles', 'base.less'), theme.getStylesheet()); + fs.writeFileSync(path.join(this.destinationPath, 'styles', 'syntax-variables.less'), theme.getSyntaxVariables()); + return callback(); + }); + } +}; diff --git a/src/tree.coffee b/src/tree.coffee deleted file mode 100644 index 26e585f4..00000000 --- a/src/tree.coffee +++ /dev/null @@ -1,18 +0,0 @@ -_ = require 'underscore-plus' - -module.exports = (items, options={}, callback) -> - if _.isFunction(options) - callback = options - options = {} - callback ?= (item) -> item - - if items.length is 0 - emptyMessage = options.emptyMessage ? '(empty)' - console.log "\u2514\u2500\u2500 #{emptyMessage}" - else - for item, index in items - if index is items.length - 1 - itemLine = '\u2514\u2500\u2500 ' - else - itemLine = '\u251C\u2500\u2500 ' - console.log "#{itemLine}#{callback(item)}" diff --git a/src/tree.js b/src/tree.js new file mode 100644 index 00000000..44ce7b23 --- /dev/null +++ b/src/tree.js @@ -0,0 +1,31 @@ + +const _ = require('underscore-plus'); + +module.exports = function(items, options, callback) { + if (options == null) { options = {}; } + if (_.isFunction(options)) { + callback = options; + options = {}; + } + if (callback == null) { callback = item => item; } + + if (items.length === 0) { + const emptyMessage = options.emptyMessage != null ? options.emptyMessage : '(empty)'; + console.log(`\u2514\u2500\u2500 ${emptyMessage}`); + } else { + return (() => { + const result = []; + for (let index = 0; index < items.length; index++) { + var itemLine; + const item = items[index]; + if (index === (items.length - 1)) { + itemLine = '\u2514\u2500\u2500 '; + } else { + itemLine = '\u251C\u2500\u2500 '; + } + result.push(console.log(`${itemLine}${callback(item)}`)); + } + return result; + })(); + } +}; diff --git a/src/uninstall.coffee b/src/uninstall.coffee deleted file mode 100644 index e9adab26..00000000 --- a/src/uninstall.coffee +++ /dev/null @@ -1,94 +0,0 @@ -path = require 'path' - -async = require 'async' -CSON = require 'season' -yargs = require 'yargs' - -auth = require './auth' -Command = require './command' -config = require './apm' -fs = require './fs' -request = require './request' - -module.exports = -class Uninstall extends Command - @commandNames: ['deinstall', 'delete', 'erase', 'remove', 'rm', 'uninstall'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm uninstall ... - - Delete the installed package(s) from the ~/.pulsar/packages directory. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - options.alias('d', 'dev').boolean('dev').describe('dev', 'Uninstall from ~/.pulsar/dev/packages') - options.boolean('hard').describe('hard', 'Uninstall from ~/.pulsar/packages and ~/.pulsar/dev/packages') - - getPackageVersion: (packageDirectory) -> - try - CSON.readFileSync(path.join(packageDirectory, 'package.json'))?.version - catch error - null - - registerUninstall: ({packageName, packageVersion}, callback) -> - return callback() unless packageVersion - - auth.getToken (error, token) -> - return callback() unless token - - requestOptions = - url: "#{config.getAtomPackagesUrl()}/#{packageName}/versions/#{packageVersion}/events/uninstall" - json: true - headers: - authorization: token - - request.post requestOptions, (error, response, body) -> callback() - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - packageNames = @packageNamesFromArgv(options.argv) - - if packageNames.length is 0 - callback("Please specify a package name to uninstall") - return - - packagesDirectory = path.join(config.getAtomDirectory(), 'packages') - devPackagesDirectory = path.join(config.getAtomDirectory(), 'dev', 'packages') - - uninstallsToRegister = [] - uninstallError = null - - for packageName in packageNames - if packageName is '.' - packageName = path.basename(process.cwd()) - process.stdout.write "Uninstalling #{packageName} " - try - unless options.argv.dev - packageDirectory = path.join(packagesDirectory, packageName) - packageManifestPath = path.join(packageDirectory, 'package.json') - if fs.existsSync(packageManifestPath) - packageVersion = @getPackageVersion(packageDirectory) - fs.removeSync(packageDirectory) - if packageVersion - uninstallsToRegister.push({packageName, packageVersion}) - else if not options.argv.hard - throw new Error("No package.json found at #{packageManifestPath}") - - if options.argv.hard or options.argv.dev - packageDirectory = path.join(devPackagesDirectory, packageName) - if fs.existsSync(packageDirectory) - fs.removeSync(packageDirectory) - else if not options.argv.hard - throw new Error("Does not exist") - - @logSuccess() - catch error - @logFailure() - uninstallError = new Error("Failed to delete #{packageName}: #{error.message}") - break - - async.eachSeries uninstallsToRegister, @registerUninstall.bind(this), -> - callback(uninstallError) diff --git a/src/uninstall.js b/src/uninstall.js new file mode 100644 index 00000000..d555c41f --- /dev/null +++ b/src/uninstall.js @@ -0,0 +1,114 @@ + +const path = require('path'); + +const async = require('async'); +const CSON = require('season'); +const yargs = require('yargs'); + +const auth = require('./auth'); +const Command = require('./command'); +const config = require('./apm'); +const fs = require('./fs'); +const request = require('./request'); + +module.exports = +class Uninstall extends Command { + static commandNames = [ "deinstall", "delete", "erase", "remove", "rm", "uninstall" ]; + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm uninstall ... + +Delete the installed package(s) from the ~/.pulsar/packages directory.\ +` + ); + options.alias('h', 'help').describe('help', 'Print this usage message'); + options.alias('d', 'dev').boolean('dev').describe('dev', 'Uninstall from ~/.pulsar/dev/packages'); + return options.boolean('hard').describe('hard', 'Uninstall from ~/.pulsar/packages and ~/.pulsar/dev/packages'); + } + + getPackageVersion(packageDirectory) { + try { + return CSON.readFileSync(path.join(packageDirectory, 'package.json'))?.version; + } catch (error) { + return null; + } + } + + registerUninstall({packageName, packageVersion}, callback) { + if (!packageVersion) { return callback(); } + + return auth.getToken(function(error, token) { + if (!token) { return callback(); } + + const requestOptions = { + url: `${config.getAtomPackagesUrl()}/${packageName}/versions/${packageVersion}/events/uninstall`, + json: true, + headers: { + authorization: token + } + }; + + return request.post(requestOptions, (error, response, body) => callback()); + }); + } + + run(options) { + const {callback} = options; + options = this.parseOptions(options.commandArgs); + const packageNames = this.packageNamesFromArgv(options.argv); + + if (packageNames.length === 0) { + callback("Please specify a package name to uninstall"); + return; + } + + const packagesDirectory = path.join(config.getAtomDirectory(), 'packages'); + const devPackagesDirectory = path.join(config.getAtomDirectory(), 'dev', 'packages'); + + const uninstallsToRegister = []; + let uninstallError = null; + + for (let packageName of Array.from(packageNames)) { + if (packageName === '.') { + packageName = path.basename(process.cwd()); + } + process.stdout.write(`Uninstalling ${packageName} `); + try { + let packageDirectory; + if (!options.argv.dev) { + packageDirectory = path.join(packagesDirectory, packageName); + const packageManifestPath = path.join(packageDirectory, 'package.json'); + if (fs.existsSync(packageManifestPath)) { + const packageVersion = this.getPackageVersion(packageDirectory); + fs.removeSync(packageDirectory); + if (packageVersion) { + uninstallsToRegister.push({packageName, packageVersion}); + } + } else if (!options.argv.hard) { + throw new Error(`No package.json found at ${packageManifestPath}`); + } + } + + if (options.argv.hard || options.argv.dev) { + packageDirectory = path.join(devPackagesDirectory, packageName); + if (fs.existsSync(packageDirectory)) { + fs.removeSync(packageDirectory); + } else if (!options.argv.hard) { + throw new Error("Does not exist"); + } + } + + this.logSuccess(); + } catch (error) { + this.logFailure(); + uninstallError = new Error(`Failed to delete ${packageName}: ${error.message}`); + break; + } + } + + return async.eachSeries(uninstallsToRegister, this.registerUninstall.bind(this), () => callback(uninstallError)); + } + } diff --git a/src/unlink.coffee b/src/unlink.coffee deleted file mode 100644 index d79b1eb1..00000000 --- a/src/unlink.coffee +++ /dev/null @@ -1,94 +0,0 @@ -path = require 'path' - -CSON = require 'season' -yargs = require 'yargs' - -Command = require './command' -config = require './apm' -fs = require './fs' - -module.exports = -class Unlink extends Command - @commandNames: ['unlink'] - - constructor: -> - super() - @devPackagesPath = path.join(config.getAtomDirectory(), 'dev', 'packages') - @packagesPath = path.join(config.getAtomDirectory(), 'packages') - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm unlink [] - - Delete the symlink in ~/.pulsar/packages for the package. The package in the - current working directory is unlinked if no path is given. - - Run `ppm links` to view all the currently linked packages. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - options.alias('d', 'dev').boolean('dev').describe('dev', 'Unlink package from ~/.pulsar/dev/packages') - options.boolean('hard').describe('hard', 'Unlink package from ~/.pulsar/packages and ~/.pulsar/dev/packages') - options.alias('a', 'all').boolean('all').describe('all', 'Unlink all packages in ~/.pulsar/packages and ~/.pulsar/dev/packages') - - getDevPackagePath: (packageName) -> path.join(@devPackagesPath, packageName) - - getPackagePath: (packageName) -> path.join(@packagesPath, packageName) - - unlinkPath: (pathToUnlink) -> - try - process.stdout.write "Unlinking #{pathToUnlink} " - fs.unlinkSync(pathToUnlink) - @logSuccess() - catch error - @logFailure() - throw error - - unlinkAll: (options, callback) -> - try - for child in fs.list(@devPackagesPath) - packagePath = path.join(@devPackagesPath, child) - @unlinkPath(packagePath) if fs.isSymbolicLinkSync(packagePath) - unless options.argv.dev - for child in fs.list(@packagesPath) - packagePath = path.join(@packagesPath, child) - @unlinkPath(packagePath) if fs.isSymbolicLinkSync(packagePath) - callback() - catch error - callback(error) - - unlinkPackage: (options, callback) -> - packagePath = options.argv._[0]?.toString() ? '.' - linkPath = path.resolve(process.cwd(), packagePath) - - try - packageName = CSON.readFileSync(CSON.resolve(path.join(linkPath, 'package'))).name - packageName = path.basename(linkPath) unless packageName - - if options.argv.hard - try - @unlinkPath(@getDevPackagePath(packageName)) - @unlinkPath(@getPackagePath(packageName)) - callback() - catch error - callback(error) - else - if options.argv.dev - targetPath = @getDevPackagePath(packageName) - else - targetPath = @getPackagePath(packageName) - try - @unlinkPath(targetPath) - callback() - catch error - callback(error) - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - - if options.argv.all - @unlinkAll(options, callback) - else - @unlinkPackage(options, callback) diff --git a/src/unlink.js b/src/unlink.js new file mode 100644 index 00000000..880618a3 --- /dev/null +++ b/src/unlink.js @@ -0,0 +1,117 @@ + +const path = require('path'); + +const CSON = require('season'); +const yargs = require('yargs'); + +const Command = require('./command'); +const config = require('./apm'); +const fs = require('./fs'); + +module.exports = +class Unlink extends Command { + static commandNames = [ "unlink" ]; + + constructor() { + super(); + this.devPackagesPath = path.join(config.getAtomDirectory(), 'dev', 'packages'); + this.packagesPath = path.join(config.getAtomDirectory(), 'packages'); + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm unlink [] + +Delete the symlink in ~/.pulsar/packages for the package. The package in the +current working directory is unlinked if no path is given. + +Run \`ppm links\` to view all the currently linked packages.\ +` + ); + options.alias('h', 'help').describe('help', 'Print this usage message'); + options.alias('d', 'dev').boolean('dev').describe('dev', 'Unlink package from ~/.pulsar/dev/packages'); + options.boolean('hard').describe('hard', 'Unlink package from ~/.pulsar/packages and ~/.pulsar/dev/packages'); + return options.alias('a', 'all').boolean('all').describe('all', 'Unlink all packages in ~/.pulsar/packages and ~/.pulsar/dev/packages'); + } + + getDevPackagePath(packageName) { return path.join(this.devPackagesPath, packageName); } + + getPackagePath(packageName) { return path.join(this.packagesPath, packageName); } + + unlinkPath(pathToUnlink) { + try { + process.stdout.write(`Unlinking ${pathToUnlink} `); + fs.unlinkSync(pathToUnlink); + return this.logSuccess(); + } catch (error) { + this.logFailure(); + throw error; + } + } + + unlinkAll(options, callback) { + try { + let child, packagePath; + for (child of fs.list(this.devPackagesPath)) { + packagePath = path.join(this.devPackagesPath, child); + if (fs.isSymbolicLinkSync(packagePath)) { this.unlinkPath(packagePath); } + } + if (!options.argv.dev) { + for (child of fs.list(this.packagesPath)) { + packagePath = path.join(this.packagesPath, child); + if (fs.isSymbolicLinkSync(packagePath)) { this.unlinkPath(packagePath); } + } + } + return callback(); + } catch (error) { + return callback(error); + } + } + + unlinkPackage(options, callback) { + let packageName; + const packagePath = options.argv._[0]?.toString() ?? "."; + const linkPath = path.resolve(process.cwd(), packagePath); + + try { + packageName = CSON.readFileSync(CSON.resolve(path.join(linkPath, 'package'))).name; + } catch (error) {} + if (!packageName) { packageName = path.basename(linkPath); } + + if (options.argv.hard) { + try { + this.unlinkPath(this.getDevPackagePath(packageName)); + this.unlinkPath(this.getPackagePath(packageName)); + return callback(); + } catch (error) { + return callback(error); + } + } else { + let targetPath; + if (options.argv.dev) { + targetPath = this.getDevPackagePath(packageName); + } else { + targetPath = this.getPackagePath(packageName); + } + try { + this.unlinkPath(targetPath); + return callback(); + } catch (error) { + return callback(error); + } + } + } + + run(options) { + const {callback} = options; + options = this.parseOptions(options.commandArgs); + + if (options.argv.all) { + return this.unlinkAll(options, callback); + } else { + return this.unlinkPackage(options, callback); + } + } + } diff --git a/src/unpublish.coffee b/src/unpublish.coffee deleted file mode 100644 index a787c2ef..00000000 --- a/src/unpublish.coffee +++ /dev/null @@ -1,109 +0,0 @@ -path = require 'path' -readline = require 'readline' - -yargs = require 'yargs' - -auth = require './auth' -Command = require './command' -config = require './apm' -fs = require './fs' -request = require './request' - -module.exports = -class Unpublish extends Command - @commandNames: ['unpublish'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - - options.usage """ - Usage: ppm unpublish [] - ppm unpublish @ - - Remove a published package or package version. - - The package in the current working directory will be used if no package - name is specified. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - options.alias('f', 'force').boolean('force').describe('force', 'Do not prompt for confirmation') - - unpublishPackage: (packageName, packageVersion, callback) -> - packageLabel = packageName - packageLabel += "@#{packageVersion}" if packageVersion - - process.stdout.write "Unpublishing #{packageLabel} " - - auth.getToken (error, token) => - if error? - @logFailure() - callback(error) - return - - options = - uri: "#{config.getAtomPackagesUrl()}/#{packageName}" - headers: - authorization: token - json: true - - options.uri += "/versions/#{packageVersion}" if packageVersion - - request.del options, (error, response, body={}) => - if error? - @logFailure() - callback(error) - else if response.statusCode isnt 204 - @logFailure() - message = body.message ? body.error ? body - callback("Unpublishing failed: #{message}") - else - @logSuccess() - callback() - - promptForConfirmation: (packageName, packageVersion, callback) -> - packageLabel = packageName - packageLabel += "@#{packageVersion}" if packageVersion - - if packageVersion - question = "Are you sure you want to unpublish '#{packageLabel}'? (no) " - else - question = "Are you sure you want to unpublish ALL VERSIONS of '#{packageLabel}'? " + - "This will remove it from the ppm registry, including " + - "download counts and stars, and this action is irreversible. (no)" - - @prompt question, (answer) => - answer = if answer then answer.trim().toLowerCase() else 'no' - if answer in ['y', 'yes'] - @unpublishPackage(packageName, packageVersion, callback) - else - callback("Cancelled unpublishing #{packageLabel}") - - prompt: (question, callback) -> - prompt = readline.createInterface(process.stdin, process.stdout) - - prompt.question question, (answer) -> - prompt.close() - callback(answer) - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - [name] = options.argv._ - - if name?.length > 0 - atIndex = name.indexOf('@') - if atIndex isnt -1 - version = name.substring(atIndex + 1) - name = name.substring(0, atIndex) - - unless name - try - name = JSON.parse(fs.readFileSync('package.json'))?.name - - unless name - name = path.basename(process.cwd()) - - if options.argv.force - @unpublishPackage(name, version, callback) - else - @promptForConfirmation(name, version, callback) diff --git a/src/unpublish.js b/src/unpublish.js new file mode 100644 index 00000000..d1a18738 --- /dev/null +++ b/src/unpublish.js @@ -0,0 +1,136 @@ + +const path = require('path'); +const readline = require('readline'); + +const yargs = require('yargs'); + +const auth = require('./auth'); +const Command = require('./command'); +const config = require('./apm'); +const fs = require('./fs'); +const request = require('./request'); + +module.exports = +class Unpublish extends Command { + static commandNames = [ "unpublish" ]; + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + + options.usage(`\ +Usage: ppm unpublish [] + ppm unpublish @ + +Remove a published package or package version. + +The package in the current working directory will be used if no package +name is specified.\ +` + ); + options.alias('h', 'help').describe('help', 'Print this usage message'); + return options.alias('f', 'force').boolean('force').describe('force', 'Do not prompt for confirmation'); + } + + unpublishPackage(packageName, packageVersion, callback) { + let packageLabel = packageName; + if (packageVersion) { packageLabel += `@${packageVersion}`; } + + process.stdout.write(`Unpublishing ${packageLabel} `); + + auth.getToken((error, token) => { + if (error != null) { + this.logFailure(); + callback(error); + return; + } + + const options = { + uri: `${config.getAtomPackagesUrl()}/${packageName}`, + headers: { + authorization: token + }, + json: true + }; + + if (packageVersion) { options.uri += `/versions/${packageVersion}`; } + + request.del(options, (error, response, body) => { + if (body == null) { body = {}; } + if (error != null) { + this.logFailure(); + return callback(error); + } else if (response.statusCode !== 204) { + this.logFailure(); + const message = body.message ?? body.error ?? body; + return callback(`Unpublishing failed: ${message}`); + } else { + this.logSuccess(); + return callback(); + } + }); + }); + } + + promptForConfirmation(packageName, packageVersion, callback) { + let question; + let packageLabel = packageName; + if (packageVersion) { packageLabel += `@${packageVersion}`; } + + if (packageVersion) { + question = `Are you sure you want to unpublish '${packageLabel}'? (no) `; + } else { + question = `Are you sure you want to unpublish ALL VERSIONS of '${packageLabel}'? ` + + "This will remove it from the ppm registry, including " + + "download counts and stars, and this action is irreversible. (no)"; + } + + return this.prompt(question, answer => { + answer = answer ? answer.trim().toLowerCase() : 'no'; + if (['y', 'yes'].includes(answer)) { + return this.unpublishPackage(packageName, packageVersion, callback); + } else { + return callback(`Cancelled unpublishing ${packageLabel}`); + } + }); + } + + prompt(question, callback) { + const prompt = readline.createInterface(process.stdin, process.stdout); + + prompt.question(question, function(answer) { + prompt.close(); + return callback(answer); + }); + } + + run(options) { + let version; + const {callback} = options; + options = this.parseOptions(options.commandArgs); + let [name] = options.argv._; + + if (name?.length > 0) { + const atIndex = name.indexOf('@'); + if (atIndex !== -1) { + version = name.substring(atIndex + 1); + name = name.substring(0, atIndex); + } + } + + if (!name) { + try { + name = JSON.parse(fs.readFileSync('package.json'))?.name; + } catch (error) {} + } + + if (!name) { + name = path.basename(process.cwd()); + } + + if (options.argv.force) { + return this.unpublishPackage(name, version, callback); + } else { + return this.promptForConfirmation(name, version, callback); + } + } + } diff --git a/src/unstar.coffee b/src/unstar.coffee deleted file mode 100644 index ab40cb06..00000000 --- a/src/unstar.coffee +++ /dev/null @@ -1,59 +0,0 @@ -async = require 'async' -yargs = require 'yargs' - -config = require './apm' -Command = require './command' -Login = require './login' -request = require './request' - -module.exports = -class Unstar extends Command - @commandNames: ['unstar'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm unstar ... - - Unstar the given packages - - Run `ppm stars` to see all your starred packages. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - - starPackage: (packageName, token, callback) -> - process.stdout.write '\uD83D\uDC5F \u2B50 ' if process.platform is 'darwin' - process.stdout.write "Unstarring #{packageName} " - requestSettings = - json: true - url: "#{config.getAtomPackagesUrl()}/#{packageName}/star" - headers: - authorization: token - request.del requestSettings, (error, response, body={}) => - if error? - @logFailure() - callback(error) - else if response.statusCode isnt 204 - @logFailure() - message = body.message ? body.error ? body - callback("Unstarring package failed: #{message}") - else - @logSuccess() - callback() - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - packageNames = @packageNamesFromArgv(options.argv) - - if packageNames.length is 0 - callback("Please specify a package name to unstar") - return - - Login.getTokenOrLogin (error, token) => - return callback(error) if error? - - commands = packageNames.map (packageName) => - (callback) => @starPackage(packageName, token, callback) - async.waterfall(commands, callback) diff --git a/src/unstar.js b/src/unstar.js new file mode 100644 index 00000000..5f0e09fe --- /dev/null +++ b/src/unstar.js @@ -0,0 +1,73 @@ + +const async = require('async'); +const yargs = require('yargs'); + +const config = require('./apm'); +const Command = require('./command'); +const Login = require('./login'); +const request = require('./request'); + +module.exports = +class Unstar extends Command { + static commandNames = [ "unstar" ]; + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm unstar ... + +Unstar the given packages + +Run \`ppm stars\` to see all your starred packages.\ +` + ); + return options.alias('h', 'help').describe('help', 'Print this usage message'); + } + + starPackage(packageName, token, callback) { + if (process.platform === 'darwin') { process.stdout.write('\uD83D\uDC5F \u2B50 '); } + process.stdout.write(`Unstarring ${packageName} `); + const requestSettings = { + json: true, + url: `${config.getAtomPackagesUrl()}/${packageName}/star`, + headers: { + authorization: token + } + }; + request.del(requestSettings, (error, response, body) => { + if (body == null) { body = {}; } + if (error != null) { + this.logFailure(); + return callback(error); + } else if (response.statusCode !== 204) { + this.logFailure(); + const message = body.message ?? body.error ?? body; + return callback(`Unstarring package failed: ${message}`); + } else { + this.logSuccess(); + return callback(); + } + }); + } + + run(options) { + const {callback} = options; + options = this.parseOptions(options.commandArgs); + const packageNames = this.packageNamesFromArgv(options.argv); + + if (packageNames.length === 0) { + callback("Please specify a package name to unstar"); + return; + } + + Login.getTokenOrLogin((error, token) => { + if (error != null) { return callback(error); } + + const commands = packageNames.map(packageName => { + return callback => this.starPackage(packageName, token, callback); + }); + return async.waterfall(commands, callback); + }); + } + } diff --git a/src/upgrade.coffee b/src/upgrade.coffee deleted file mode 100644 index 7551aea4..00000000 --- a/src/upgrade.coffee +++ /dev/null @@ -1,222 +0,0 @@ -path = require 'path' - -_ = require 'underscore-plus' -async = require 'async' -yargs = require 'yargs' -read = require 'read' -semver = require 'semver' -Git = require 'git-utils' - -Command = require './command' -config = require './apm' -fs = require './fs' -Install = require './install' -Packages = require './packages' -request = require './request' -tree = require './tree' -git = require './git' - -module.exports = -class Upgrade extends Command - @commandNames: ['upgrade', 'outdated', 'update'] - - constructor: -> - super() - @atomDirectory = config.getAtomDirectory() - @atomPackagesDirectory = path.join(@atomDirectory, 'packages') - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm upgrade - ppm upgrade --list - ppm upgrade [...] - - Upgrade out of date packages installed to ~/.pulsar/packages - - This command lists the out of date packages and then prompts to install - available updates. - """ - options.alias('c', 'confirm').boolean('confirm').default('confirm', true).describe('confirm', 'Confirm before installing updates') - options.alias('h', 'help').describe('help', 'Print this usage message') - options.alias('l', 'list').boolean('list').describe('list', 'List but don\'t install the outdated packages') - options.boolean('json').describe('json', 'Output outdated packages as a JSON array') - options.string('compatible').describe('compatible', 'Only list packages/themes compatible with this Atom version') - options.boolean('verbose').default('verbose', false).describe('verbose', 'Show verbose debug information') - - getInstalledPackages: (options) -> - packages = [] - for name in fs.list(@atomPackagesDirectory) - if pack = @getIntalledPackage(name) - packages.push(pack) - - packageNames = @packageNamesFromArgv(options.argv) - if packageNames.length > 0 - packages = packages.filter ({name}) -> packageNames.indexOf(name) isnt -1 - - packages - - getIntalledPackage: (name) -> - packageDirectory = path.join(@atomPackagesDirectory, name) - return if fs.isSymbolicLinkSync(packageDirectory) - try - metadata = JSON.parse(fs.readFileSync(path.join(packageDirectory, 'package.json'))) - return metadata if metadata?.name and metadata?.version - - loadInstalledAtomVersion: (options, callback) -> - if options.argv.compatible - process.nextTick => - version = @normalizeVersion(options.argv.compatible) - @installedAtomVersion = version if semver.valid(version) - callback() - else - @loadInstalledAtomMetadata(callback) - - folderIsRepo: (pack) -> - repoGitFolderPath = path.join(@atomPackagesDirectory, pack.name, '.git') - return fs.existsSync repoGitFolderPath - - getLatestVersion: (pack, callback) -> - requestSettings = - url: "#{config.getAtomPackagesUrl()}/#{pack.name}" - json: true - request.get requestSettings, (error, response, body={}) => - if error? - callback("Request for package information failed: #{error.message}") - else if response.statusCode is 404 - callback() - else if response.statusCode isnt 200 - message = body.message ? body.error ? body - callback("Request for package information failed: #{message}") - else - atomVersion = @installedAtomVersion - latestVersion = pack.version - for version, metadata of body.versions ? {} - continue unless semver.valid(version) - continue unless metadata - - engine = metadata.engines?.pulsar or metadata.engines?.atom or '*' - continue unless semver.validRange(engine) - continue unless semver.satisfies(atomVersion, engine) - - latestVersion = version if semver.gt(version, latestVersion) - - if latestVersion isnt pack.version and @hasRepo(pack) - callback(null, latestVersion) - else - callback() - - getLatestSha: (pack, callback) -> - repoPath = path.join(@atomPackagesDirectory, pack.name) - repo = Git.open(repoPath) - config.getSetting 'git', (command) => - command ?= 'git' - args = ['fetch', 'origin', repo.getShortHead()] - git.addGitToEnv(process.env) - @spawn command, args, {cwd: repoPath}, (code, stderr='', stdout='') -> - return callback(new Error('Exit code: ' + code + ' - ' + stderr)) unless code is 0 - sha = repo.getReferenceTarget(repo.getUpstreamBranch(repo.getHead())) - if sha isnt pack.apmInstallSource.sha - callback(null, sha) - else - callback() - - hasRepo: (pack) -> - Packages.getRepository(pack)? - - getAvailableUpdates: (packages, callback) -> - getLatestVersionOrSha = (pack, done) => - if @folderIsRepo(pack) and pack.apmInstallSource?.type is 'git' - @getLatestSha pack, (err, sha) -> - done(err, {pack, sha}) - else - @getLatestVersion pack, (err, latestVersion) -> - done(err, {pack, latestVersion}) - - async.mapLimit packages, 10, getLatestVersionOrSha, (error, updates) -> - return callback(error) if error? - - updates = _.filter updates, (update) -> update.latestVersion? or update.sha? - updates.sort (updateA, updateB) -> - updateA.pack.name.localeCompare(updateB.pack.name) - - callback(null, updates) - - promptForConfirmation: (callback) -> - read {prompt: 'Would you like to install these updates? (yes)', edit: true}, (error, answer) -> - answer = if answer then answer.trim().toLowerCase() else 'yes' - callback(error, answer is 'y' or answer is 'yes') - - installUpdates: (updates, callback) -> - installCommands = [] - verbose = @verbose - for {pack, latestVersion} in updates - do (pack, latestVersion) -> - installCommands.push (callback) -> - if pack.apmInstallSource?.type is 'git' - commandArgs = [pack.apmInstallSource.source] - else - commandArgs = ["#{pack.name}@#{latestVersion}"] - commandArgs.unshift('--verbose') if verbose - new Install().run({callback, commandArgs}) - - async.waterfall(installCommands, callback) - - run: (options) -> - {callback, command} = options - options = @parseOptions(options.commandArgs) - options.command = command - - @verbose = options.argv.verbose - if @verbose - request.debug(true) - process.env.NODE_DEBUG = 'request' - - @loadInstalledAtomVersion options, => - if @installedAtomVersion - @upgradePackages(options, callback) - else - callback('Could not determine current Atom version installed') - - upgradePackages: (options, callback) -> - packages = @getInstalledPackages(options) - @getAvailableUpdates packages, (error, updates) => - return callback(error) if error? - - if options.argv.json - packagesWithLatestVersionOrSha = updates.map ({pack, latestVersion, sha}) -> - pack.latestVersion = latestVersion if latestVersion - pack.latestSha = sha if sha - pack - console.log JSON.stringify(packagesWithLatestVersionOrSha) - else - console.log "Package Updates Available".cyan + " (#{updates.length})" - tree updates, ({pack, latestVersion, sha}) -> - {name, apmInstallSource, version} = pack - name = name.yellow - if sha? - version = apmInstallSource.sha.substr(0, 8).red - latestVersion = sha.substr(0, 8).green - else - version = version.red - latestVersion = latestVersion.green - latestVersion = latestVersion?.green or apmInstallSource?.sha?.green - "#{name} #{version} -> #{latestVersion}" - - return callback() if options.command is 'outdated' - return callback() if options.argv.list - return callback() if updates.length is 0 - - console.log() - if options.argv.confirm - @promptForConfirmation (error, confirmed) => - return callback(error) if error? - - if confirmed - console.log() - @installUpdates(updates, callback) - else - callback() - else - @installUpdates(updates, callback) diff --git a/src/upgrade.js b/src/upgrade.js new file mode 100644 index 00000000..afa05077 --- /dev/null +++ b/src/upgrade.js @@ -0,0 +1,277 @@ + +const path = require('path'); + +const _ = require('underscore-plus'); +const async = require('async'); +const yargs = require('yargs'); +const read = require('read'); +const semver = require('semver'); +const Git = require('git-utils'); + +const Command = require('./command'); +const config = require('./apm'); +const fs = require('./fs'); +const Install = require('./install'); +const Packages = require('./packages'); +const request = require('./request'); +const tree = require('./tree'); +const git = require('./git'); + +module.exports = +class Upgrade extends Command { + static commandNames = [ "upgrade", "outdated", "update" ]; + + constructor() { + super(); + this.atomDirectory = config.getAtomDirectory(); + this.atomPackagesDirectory = path.join(this.atomDirectory, 'packages'); + } + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm upgrade + ppm upgrade --list + ppm upgrade [...] + +Upgrade out of date packages installed to ~/.pulsar/packages + +This command lists the out of date packages and then prompts to install +available updates.\ +` + ); + options.alias('c', 'confirm').boolean('confirm').default('confirm', true).describe('confirm', 'Confirm before installing updates'); + options.alias('h', 'help').describe('help', 'Print this usage message'); + options.alias('l', 'list').boolean('list').describe('list', 'List but don\'t install the outdated packages'); + options.boolean('json').describe('json', 'Output outdated packages as a JSON array'); + options.string('compatible').describe('compatible', 'Only list packages/themes compatible with this Atom version'); + return options.boolean('verbose').default('verbose', false).describe('verbose', 'Show verbose debug information'); + } + + getInstalledPackages(options) { + let packages = []; + for (let name of fs.list(this.atomPackagesDirectory)) { + var pack; + if (pack = this.getIntalledPackage(name)) { + packages.push(pack); + } + } + + const packageNames = this.packageNamesFromArgv(options.argv); + if (packageNames.length > 0) { + packages = packages.filter(({name}) => packageNames.indexOf(name) !== -1); + } + + return packages; + } + + getIntalledPackage(name) { + const packageDirectory = path.join(this.atomPackagesDirectory, name); + if (fs.isSymbolicLinkSync(packageDirectory)) { return; } + try { + const metadata = JSON.parse(fs.readFileSync(path.join(packageDirectory, 'package.json'))); + if (metadata?.name && metadata?.version) { return metadata; } + } catch (error) {} + } + + loadInstalledAtomVersion(options, callback) { + if (options.argv.compatible) { + process.nextTick(() => { + const version = this.normalizeVersion(options.argv.compatible); + if (semver.valid(version)) { this.installedAtomVersion = version; } + return callback(); + }); + } else { + return this.loadInstalledAtomMetadata(callback); + } + } + + folderIsRepo(pack) { + const repoGitFolderPath = path.join(this.atomPackagesDirectory, pack.name, '.git'); + return fs.existsSync(repoGitFolderPath); + } + + getLatestVersion(pack, callback) { + const requestSettings = { + url: `${config.getAtomPackagesUrl()}/${pack.name}`, + json: true + }; + return request.get(requestSettings, (error, response, body) => { + if (body == null) { body = {}; } + if (error != null) { + return callback(`Request for package information failed: ${error.message}`); + } else if (response.statusCode === 404) { + return callback(); + } else if (response.statusCode !== 200) { + const message = body.message ?? body.error ?? body; + return callback(`Request for package information failed: ${message}`); + } else { + let version; + const atomVersion = this.installedAtomVersion; + let latestVersion = pack.version; + const object = body.versions != null ? body.versions : {}; + for (version in object) { + const metadata = object[version]; + if (!semver.valid(version)) { continue; } + if (!metadata) { continue; } + + const engine = metadata.engines?.pulsar || metadata.engines?.atom || '*'; + if (!semver.validRange(engine)) { continue; } + if (!semver.satisfies(atomVersion, engine)) { continue; } + + if (semver.gt(version, latestVersion)) { latestVersion = version; } + } + + if ((latestVersion !== pack.version) && this.hasRepo(pack)) { + return callback(null, latestVersion); + } else { + return callback(); + } + } + }); + } + + getLatestSha(pack, callback) { + const repoPath = path.join(this.atomPackagesDirectory, pack.name); + const repo = Git.open(repoPath); + return config.getSetting('git', command => { + if (command == null) { command = 'git'; } + const args = ['fetch', 'origin', repo.getShortHead()]; + git.addGitToEnv(process.env); + return this.spawn(command, args, {cwd: repoPath}, function(code, stderr, stdout) { + if (stderr == null) { stderr = ''; } + if (stdout == null) { stdout = ''; } + if (code !== 0) { return callback(new Error('Exit code: ' + code + ' - ' + stderr)); } + const sha = repo.getReferenceTarget(repo.getUpstreamBranch(repo.getHead())); + if (sha !== pack.apmInstallSource.sha) { + return callback(null, sha); + } else { + return callback(); + } + }); + }); + } + + hasRepo(pack) { + return (Packages.getRepository(pack) != null); + } + + getAvailableUpdates(packages, callback) { + const getLatestVersionOrSha = (pack, done) => { + if (this.folderIsRepo(pack) && (pack.apmInstallSource?.type === 'git')) { + return this.getLatestSha(pack, (err, sha) => done(err, {pack, sha})); + } else { + return this.getLatestVersion(pack, (err, latestVersion) => done(err, {pack, latestVersion})); + } + }; + + async.mapLimit(packages, 10, getLatestVersionOrSha, function(error, updates) { + if (error != null) { return callback(error); } + + updates = _.filter(updates, update => (update.latestVersion != null) || (update.sha != null)); + updates.sort((updateA, updateB) => updateA.pack.name.localeCompare(updateB.pack.name)); + + return callback(null, updates); + }); + } + + promptForConfirmation(callback) { + read({prompt: 'Would you like to install these updates? (yes)', edit: true}, function(error, answer) { + answer = answer ? answer.trim().toLowerCase() : 'yes'; + return callback(error, (answer === 'y') || (answer === 'yes')); + }); + } + + installUpdates(updates, callback) { + const installCommands = []; + const { + verbose + } = this; + for (let {pack, latestVersion} of Array.from(updates)) { + (((pack, latestVersion) => installCommands.push(function(callback) { + let commandArgs; + if (pack.apmInstallSource?.type === 'git') { + commandArgs = [pack.apmInstallSource.source]; + } else { + commandArgs = [`${pack.name}@${latestVersion}`]; + } + if (verbose) { commandArgs.unshift('--verbose'); } + return new Install().run({callback, commandArgs}); + })))(pack, latestVersion); + } + + return async.waterfall(installCommands, callback); + } + + run(options) { + const {callback, command} = options; + options = this.parseOptions(options.commandArgs); + options.command = command; + + this.verbose = options.argv.verbose; + if (this.verbose) { + request.debug(true); + process.env.NODE_DEBUG = 'request'; + } + + return this.loadInstalledAtomVersion(options, () => { + if (this.installedAtomVersion) { + return this.upgradePackages(options, callback); + } else { + return callback('Could not determine current Atom version installed'); + } + }); + } + + upgradePackages(options, callback) { + const packages = this.getInstalledPackages(options); + return this.getAvailableUpdates(packages, (error, updates) => { + if (error != null) { return callback(error); } + + if (options.argv.json) { + const packagesWithLatestVersionOrSha = updates.map(function({pack, latestVersion, sha}) { + if (latestVersion) { pack.latestVersion = latestVersion; } + if (sha) { pack.latestSha = sha; } + return pack; + }); + console.log(JSON.stringify(packagesWithLatestVersionOrSha)); + } else { + console.log("Package Updates Available".cyan + ` (${updates.length})`); + tree(updates, function({pack, latestVersion, sha}) { + let {name, apmInstallSource, version} = pack; + name = name.yellow; + if (sha != null) { + version = apmInstallSource.sha.substr(0, 8).red; + latestVersion = sha.substr(0, 8).green; + } else { + version = version.red; + latestVersion = latestVersion.green; + } + latestVersion = latestVersion?.green || apmInstallSource?.sha?.green; + return `${name} ${version} -> ${latestVersion}`; + }); + } + + if (options.command === 'outdated') { return callback(); } + if (options.argv.list) { return callback(); } + if (updates.length === 0) { return callback(); } + + console.log(); + if (options.argv.confirm) { + return this.promptForConfirmation((error, confirmed) => { + if (error != null) { return callback(error); } + + if (confirmed) { + console.log(); + return this.installUpdates(updates, callback); + } else { + return callback(); + } + }); + } else { + return this.installUpdates(updates, callback); + } + }); + } + } diff --git a/src/view.coffee b/src/view.coffee deleted file mode 100644 index a37129cb..00000000 --- a/src/view.coffee +++ /dev/null @@ -1,106 +0,0 @@ -_ = require 'underscore-plus' -yargs = require 'yargs' -semver = require 'semver' - -Command = require './command' -config = require './apm' -request = require './request' -tree = require './tree' - -module.exports = -class View extends Command - @commandNames: ['view', 'show'] - - parseOptions: (argv) -> - options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())) - options.usage """ - - Usage: ppm view - - View information about a package/theme. - """ - options.alias('h', 'help').describe('help', 'Print this usage message') - options.boolean('json').describe('json', 'Output featured packages as JSON array') - options.string('compatible').describe('compatible', 'Show the latest version compatible with this Atom version') - - loadInstalledAtomVersion: (options, callback) -> - process.nextTick => - if options.argv.compatible - version = @normalizeVersion(options.argv.compatible) - installedAtomVersion = version if semver.valid(version) - callback(installedAtomVersion) - - getLatestCompatibleVersion: (pack, options, callback) -> - @loadInstalledAtomVersion options, (installedAtomVersion) -> - return callback(pack.releases.latest) unless installedAtomVersion - - latestVersion = null - for version, metadata of pack.versions ? {} - continue unless semver.valid(version) - continue unless metadata - - engine = metadata.engines?.pulsar or metadata.engines?.atom or '*' - continue unless semver.validRange(engine) - continue unless semver.satisfies(installedAtomVersion, engine) - - latestVersion ?= version - latestVersion = version if semver.gt(version, latestVersion) - - callback(latestVersion) - - getRepository: (pack) -> - if repository = pack.repository?.url ? pack.repository - repository.replace(/\.git$/, '') - - getPackage: (packageName, options, callback) -> - requestSettings = - url: "#{config.getAtomPackagesUrl()}/#{packageName}" - json: true - request.get requestSettings, (error, response, body={}) => - if error? - callback(error) - else if response.statusCode is 200 - @getLatestCompatibleVersion body, options, (version) -> - {name, readme, downloads, stargazers_count} = body - metadata = body.versions?[version] ? {name} - pack = _.extend({}, metadata, {readme, downloads, stargazers_count}) - callback(null, pack) - else - message = body.message ? body.error ? body - callback("Requesting package failed: #{message}") - - run: (options) -> - {callback} = options - options = @parseOptions(options.commandArgs) - [packageName] = options.argv._ - - unless packageName - callback("Missing required package name") - return - - @getPackage packageName, options, (error, pack) => - if error? - callback(error) - return - - if options.argv.json - console.log(JSON.stringify(pack, null, 2)) - else - console.log "#{pack.name.cyan}" - items = [] - items.push(pack.version.yellow) if pack.version - if repository = @getRepository(pack) - items.push(repository.underline) - items.push(pack.description.replace(/\s+/g, ' ')) if pack.description - if pack.downloads >= 0 - items.push(_.pluralize(pack.downloads, 'download')) - if pack.stargazers_count >= 0 - items.push(_.pluralize(pack.stargazers_count, 'star')) - - tree(items) - - console.log() - console.log "Run `ppm install #{pack.name}` to install this package." - console.log() - - callback() diff --git a/src/view.js b/src/view.js new file mode 100644 index 00000000..32ef5846 --- /dev/null +++ b/src/view.js @@ -0,0 +1,137 @@ + +const _ = require('underscore-plus'); +const yargs = require('yargs'); +const semver = require('semver'); + +const Command = require('./command'); +const config = require('./apm'); +const request = require('./request'); +const tree = require('./tree'); + +module.exports = +class View extends Command { + static commandNames = [ "view", "show" ]; + + parseOptions(argv) { + const options = yargs(argv).wrap(Math.min(100, yargs.terminalWidth())); + options.usage(`\ + +Usage: ppm view + +View information about a package/theme.\ +` + ); + options.alias('h', 'help').describe('help', 'Print this usage message'); + options.boolean('json').describe('json', 'Output featured packages as JSON array'); + return options.string('compatible').describe('compatible', 'Show the latest version compatible with this Atom version'); + } + + loadInstalledAtomVersion(options, callback) { + return process.nextTick(() => { + let installedAtomVersion; + if (options.argv.compatible) { + const version = this.normalizeVersion(options.argv.compatible); + if (semver.valid(version)) { installedAtomVersion = version; } + } + return callback(installedAtomVersion); + }); + } + + getLatestCompatibleVersion(pack, options, callback) { + return this.loadInstalledAtomVersion(options, function(installedAtomVersion) { + if (!installedAtomVersion) { return callback(pack.releases.latest); } + + let latestVersion = null; + const object = pack.versions != null ? pack.versions : {}; + for (let version in object) { + const metadata = object[version]; + if (!semver.valid(version)) { continue; } + if (!metadata) { continue; } + + const engine = metadata.engines?.pulsar ?? metadata.engines?.atom ?? "*"; + if (!semver.validRange(engine)) { continue; } + if (!semver.satisfies(installedAtomVersion, engine)) { continue; } + + if (latestVersion == null) { latestVersion = version; } + if (semver.gt(version, latestVersion)) { latestVersion = version; } + } + + return callback(latestVersion); + }); + } + + getRepository(pack) { + let repository = pack.repository?.url ?? pack.repository;; + if (repository) { + return repository.replace(/\.git$/, ''); + } + } + + getPackage(packageName, options, callback) { + const requestSettings = { + url: `${config.getAtomPackagesUrl()}/${packageName}`, + json: true + }; + return request.get(requestSettings, (error, response, body) => { + if (body == null) { body = {}; } + if (error != null) { + return callback(error); + } else if (response.statusCode === 200) { + return this.getLatestCompatibleVersion(body, options, function(version) { + const {name, readme, downloads, stargazers_count} = body; + const metadata = (body.versions != null ? body.versions[version] : undefined) != null ? (body.versions != null ? body.versions[version] : undefined) : {name}; + const pack = _.extend({}, metadata, {readme, downloads, stargazers_count}); + return callback(null, pack); + }); + } else { + const message = body.message ?? body.error ?? body; + return callback(`Requesting package failed: ${message}`); + } + }); + } + + run(options) { + const {callback} = options; + options = this.parseOptions(options.commandArgs); + const [packageName] = options.argv._; + + if (!packageName) { + callback("Missing required package name"); + return; + } + + return this.getPackage(packageName, options, (error, pack) => { + if (error != null) { + callback(error); + return; + } + + if (options.argv.json) { + console.log(JSON.stringify(pack, null, 2)); + } else { + let repository; + console.log(`${pack.name.cyan}`); + const items = []; + if (pack.version) { items.push(pack.version.yellow); } + if (repository = this.getRepository(pack)) { + items.push(repository.underline); + } + if (pack.description) { items.push(pack.description.replace(/\s+/g, ' ')); } + if (pack.downloads >= 0) { + items.push(_.pluralize(pack.downloads, 'download')); + } + if (pack.stargazers_count >= 0) { + items.push(_.pluralize(pack.stargazers_count, 'star')); + } + + tree(items); + + console.log(); + console.log(`Run \`ppm install ${pack.name}\` to install this package.`); + console.log(); + } + + return callback(); + }); + } + }