Skip to content

Commit

Permalink
Allow installing old versions not listed in the package info
Browse files Browse the repository at this point in the history
Fixes atom#733

Rather than relying only on the JSON returned for the package, we now fall back
to the `GET /api/packages/:package_name/versions/:version_name` endpoint if
necessary, and use the tarball URL from there if we find one.

Since I changed the code to use `version` to mean a version JSON object, I
renamed `packageVersion` to `versionName` in a few places to be more clear. I
also rely on `version.version` containing the version name, which required
updating some test fixtures.

This also adds tests for installing old versions in general and producing the
expected error message if a version isn't found.
  • Loading branch information
alangpierce committed Feb 10, 2018
1 parent 478f7be commit 1b3a97c
Show file tree
Hide file tree
Showing 14 changed files with 164 additions and 57 deletions.
3 changes: 2 additions & 1 deletion spec/fixtures/atom-2048.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"1.2.3": {
"dist": {
"tarball": "http://localhost:3000/tarball/test-module-1.0.0.tgz"
}
},
"version": "1.2.3"
}
}
}
3 changes: 2 additions & 1 deletion spec/fixtures/install-multi-version.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
},
"dist": {
"tarball": "http://localhost:3000/tarball/test-module-1.0.0.tgz"
}
},
"version": "0.3.0"
},
"0.2.0": {
"engines": {
Expand Down
6 changes: 6 additions & 0 deletions spec/fixtures/install-test-module-version-0.2.0.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"dist": {
"tarball": "http://localhost:3000/tarball/test-module-0.2.0.tgz"
},
"version": "0.2.0"
}
3 changes: 2 additions & 1 deletion spec/fixtures/install-test-module-with-bin.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"2.0.0": {
"dist": {
"tarball": "http://localhost:3000/tarball/test-module-with-bin-2.0.0.tgz"
}
},
"version": "2.0.0"
}
}
}
3 changes: 2 additions & 1 deletion spec/fixtures/install-test-module-with-symlink.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"5.0.0": {
"dist": {
"tarball": "http://localhost:3000/tarball/test-module-with-symlink-5.0.0.tgz"
}
},
"version": "5.0.0"
}
}
}
11 changes: 9 additions & 2 deletions spec/fixtures/install-test-module.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@
"versions": {
"0.4.0": {
"dist": {
"tarball": "http://localhost:3000/tarball/test-module-1.0.0.tgz"
}
"tarball": "http://localhost:3000/tarball/test-module-0.4.0.tgz"
},
"version": "0.4.0"
},
"0.3.0": {
"dist": {
"tarball": "http://localhost:3000/tarball/test-module-0.3.0.tgz"
},
"version": "0.3.0"
}
}
}
3 changes: 2 additions & 1 deletion spec/fixtures/install-test-module2.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"2.0.0": {
"dist": {
"tarball": "http://localhost:3000/tarball/test-module2-2.0.0.tgz"
}
},
"version": "2.0.0"
}
}
}
3 changes: 2 additions & 1 deletion spec/fixtures/native-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"1.0.0": {
"dist": {
"tarball": "http://localhost:3000/tarball/native-package-1.0.0.tgz"
}
},
"version": "1.0.0"
}
}
}
Binary file added spec/fixtures/test-module-0.2.0.tgz
Binary file not shown.
Binary file added spec/fixtures/test-module-0.3.0.tgz
Binary file not shown.
Binary file added spec/fixtures/test-module-0.4.0.tgz
Binary file not shown.
45 changes: 45 additions & 0 deletions spec/install-spec.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,20 @@ describe 'apm install', ->
response.sendfile path.join(__dirname, 'fixtures', 'node_x64.lib')
app.get '/node/v0.10.3/SHASUMS256.txt', (request, response) ->
response.sendfile path.join(__dirname, 'fixtures', 'SHASUMS256.txt')
app.get '/tarball/test-module-0.2.0.tgz', (request, response) ->
response.sendfile path.join(__dirname, 'fixtures', 'test-module-0.2.0.tgz')
app.get '/tarball/test-module-0.3.0.tgz', (request, response) ->
response.sendfile path.join(__dirname, 'fixtures', 'test-module-0.3.0.tgz')
app.get '/tarball/test-module-0.4.0.tgz', (request, response) ->
response.sendfile path.join(__dirname, 'fixtures', 'test-module-0.4.0.tgz')
app.get '/tarball/test-module-1.0.0.tgz', (request, response) ->
response.sendfile path.join(__dirname, 'fixtures', 'test-module-1.0.0.tgz')
app.get '/tarball/test-module2-2.0.0.tgz', (request, response) ->
response.sendfile path.join(__dirname, 'fixtures', 'test-module2-2.0.0.tgz')
app.get '/packages/test-module', (request, response) ->
response.sendfile path.join(__dirname, 'fixtures', 'install-test-module.json')
app.get '/packages/test-module/versions/0.2.0', (request, response) ->
response.sendfile path.join(__dirname, 'fixtures', 'install-test-module-version-0.2.0.json')
app.get '/packages/test-module2', (request, response) ->
response.sendfile path.join(__dirname, 'fixtures', 'install-test-module2.json')
app.get '/packages/test-rename', (request, response) ->
Expand Down Expand Up @@ -201,6 +209,43 @@ describe 'apm install', ->
expect(fs.existsSync(path.join(testModuleDirectory, 'package.json'))).toBeTruthy()
expect(callback.mostRecentCall.args[0]).toBeNull()

describe 'when an explicit version is specified', ->
it 'installs the version', ->
testModuleDirectory = path.join(atomHome, 'packages', 'test-module')

callback = jasmine.createSpy('callback')
apm.run(['install', "[email protected]"], callback)

waitsFor 'waiting for install to complete', 600000, ->
callback.callCount is 1

runs ->
expect(callback.mostRecentCall.args[0]).toBeNull()
expect(JSON.parse(fs.readFileSync(path.join(testModuleDirectory, 'package.json'))).version).toBe "0.3.0"

it 'allows installing versions not in the package JSON', ->
testModuleDirectory = path.join(atomHome, 'packages', 'test-module')

callback = jasmine.createSpy('callback')
apm.run(['install', "[email protected]"], callback)

waitsFor 'waiting for install to complete', 600000, ->
callback.callCount is 1

runs ->
expect(callback.mostRecentCall.args[0]).toBeNull()
expect(JSON.parse(fs.readFileSync(path.join(testModuleDirectory, 'package.json'))).version).toBe "0.2.0"

it 'gives an error when installing a nonexistent version', ->
callback = jasmine.createSpy('callback')
apm.run(['install', "[email protected]"], callback)

waitsFor 'waiting for install to complete', 600000, ->
callback.callCount is 1

runs ->
expect(callback.mostRecentCall.args[0]).toBe 'Package version: 0.1.0 not found'

describe 'when multiple package names are specified', ->
it 'installs all packages', ->
testModuleDirectory = path.join(atomHome, 'packages', 'test-module')
Expand Down
4 changes: 2 additions & 2 deletions spec/stars-spec.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ describe 'apm stars', ->
response.sendfile path.join(__dirname, 'fixtures', 'node_x64.lib')
app.get '/node/v0.10.3/SHASUMS256.txt', (request, response) ->
response.sendfile path.join(__dirname, 'fixtures', 'SHASUMS256.txt')
app.get '/tarball/test-module-1.0.0.tgz', (request, response) ->
response.sendfile path.join(__dirname, 'fixtures', 'test-module-1.0.0.tgz')
app.get '/tarball/test-module-0.4.0.tgz', (request, response) ->
response.sendfile path.join(__dirname, 'fixtures', 'test-module-0.4.0.tgz')
app.get '/tarball/test-module2-2.0.0.tgz', (request, response) ->
response.sendfile path.join(__dirname, 'fixtures', 'test-module2-2.0.0.tgz')
app.get '/packages/test-module', (request, response) ->
Expand Down
137 changes: 90 additions & 47 deletions src/install.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,30 @@ class Install extends Command
else
callback("No releases available for #{packageName}")

# Request package version information from the atom.io API.
#
# packageName - The string name of the package to request.
# versionName - The string version 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.
requestPackageVersion: (packageName, versionName, callback) ->
requestSettings =
url: "#{config.getAtomPackagesUrl()}/#{packageName}/versions/#{versionName}"
json: true
retries: 4
request.get requestSettings, (error, response, body={}) ->
if error?
message = "Request for package version information failed: #{error.message}"
message += " (#{error.code})" if error.code
callback(message)
else if response.statusCode is 404
callback("Package version: #{versionName} not found")
else if response.statusCode isnt 200
message = request.getErrorMessage(response, body)
callback("Request for package version information failed: #{message}")
else
callback(null, body)

# Download a package tarball.
#
# packageUrl - The string tarball URL to request
Expand Down Expand Up @@ -271,13 +295,13 @@ class Install extends Command
# Get the path to the package from the local cache.
#
# packageName - The string name of the package.
# packageVersion - The string version of the package.
# versionName - The string version of the package.
# callback - The function to call with error and cachePath arguments.
#
# Returns a path to the cached tarball or undefined when not in the cache.
getPackageCachePath: (packageName, packageVersion, callback) ->
getPackageCachePath: (packageName, versionName, callback) ->
cacheDir = config.getCacheDirectory()
cachePath = path.join(cacheDir, packageName, packageVersion, 'package.tgz')
cachePath = path.join(cacheDir, packageName, versionName, 'package.tgz')
if fs.isFileSync(cachePath)
tempPath = path.join(temp.mkdirSync(), path.basename(cachePath))
fs.cp cachePath, tempPath, (error) ->
Expand All @@ -287,16 +311,16 @@ class Install extends Command
callback(null, tempPath)
else
process.nextTick ->
callback(new Error("#{packageName}@#{packageVersion} is not in the cache"))
callback(new Error("#{packageName}@#{versionName} is not in the cache"))

# 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) ->
# * versionName: The string version of the package.
isPackageInstalled: (packageName, versionName) ->
try
{version} = CSON.readFileSync(CSON.resolve(path.join('node_modules', packageName, 'package'))) ? {}
packageVersion is version
versionName is version
catch error
false

Expand All @@ -310,16 +334,16 @@ class Install extends Command
# error as the first argument.
installRegisteredPackage: (metadata, options, callback) ->
packageName = metadata.name
packageVersion = metadata.version
versionName = metadata.version

installGlobally = options.installGlobally ? true
unless installGlobally
if packageVersion and @isPackageInstalled(packageName, packageVersion)
if versionName and @isPackageInstalled(packageName, versionName)
callback(null, {})
return

label = packageName
label += "@#{packageVersion}" if packageVersion
label += "@#{versionName}" if versionName
unless options.argv.json
process.stdout.write "Installing #{label} "
if installGlobally
Expand All @@ -330,50 +354,69 @@ class Install extends Command
@logFailure()
callback(error)
else
packageVersion ?= @getLatestCompatibleVersion(pack)
unless packageVersion
versionName ?= @getLatestCompatibleVersion(pack)
unless versionName
@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) =>
@getPackageCachePath packageName, packageVersion, (error, packagePath) =>
if packagePath
next(null, packagePath)
else
@downloadPackage(tarball, installGlobally, next)
installNode = options.installNode ? true
if installNode
commands.push (packagePath, next) =>
@installNode (error) -> next(error, packagePath)
commands.push (packagePath, next) =>
@installModule(options, pack, packagePath, 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
version = pack.versions[versionName]
unless version
# The package information only has recent versions, so do another API
# request in case this is an older version.
@requestPackageVersion packageName, versionName, (error, version) =>
if error?
@logFailure()
callback(error)
else
@logSuccess() unless options.argv.json
callback(error, json)
@installPackageVersion(packageName, pack, version, options, callback)
return
@installPackageVersion(packageName, pack, version, options, callback)

# Install the package with the given name and optional version
#
# packageName - The originally-requested package name. This might differ from
# pack.name if the package was renamed.
# pack - The package object returned by the API.
# version - The version object returned by the API.
# options - The installation options object.
# callback - The function to invoke when installation completes with an
# error as the first argument.
installPackageVersion: (packageName, pack, version, options, callback) ->
installGlobally = options.installGlobally ? true
tarball = version.dist.tarball
commands = []
commands.push (next) =>
@getPackageCachePath packageName, version.version, (error, packagePath) =>
if packagePath
next(null, packagePath)
else
@downloadPackage(tarball, installGlobally, next)
installNode = options.installNode ? true
if installNode
commands.push (packagePath, next) =>
@installNode (error) -> next(error, packagePath)
commands.push (packagePath, next) =>
@installModule(options, pack, packagePath, 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 all the package dependencies found in the package.json file.
#
Expand Down

0 comments on commit 1b3a97c

Please sign in to comment.