UNPKG

25.8 kBJavaScriptView Raw
1/*
2 * Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved.
3 *
4 * Permission is hereby granted, free of charge, to any person obtaining a
5 * copy of this software and associated documentation files (the "Software"),
6 * to deal in the Software without restriction, including without limitation
7 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
8 * and/or sell copies of the Software, and to permit persons to whom the
9 * Software is furnished to do so, subject to the following conditions:
10 *
11 * The above copyright notice and this permission notice shall be included in
12 * all copies or substantial portions of the Software.
13 *
14 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
19 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20 * DEALINGS IN THE SOFTWARE.
21 *
22 */
23
24
25/*jslint vars: true, plusplus: true, devel: true, node: true, nomen: true,
26indent: 4, maxerr: 50 */
27
28"use strict";
29
30var supportDir;
31
32var semver = require("semver"),
33 path = require("path"),
34 http = require("http"),
35 request = require("request"),
36 os = require("os"),
37 fs = require("fs-extra"),
38 temp = require("temp"),
39 validate = require("./package-validator").validate;
40
41// Automatically clean up temp files on exit
42temp.track();
43
44var Errors = {
45 API_NOT_COMPATIBLE: "API_NOT_COMPATIBLE",
46 MISSING_REQUIRED_OPTIONS: "MISSING_REQUIRED_OPTIONS",
47 DOWNLOAD_ID_IN_USE: "DOWNLOAD_ID_IN_USE",
48 BAD_HTTP_STATUS: "BAD_HTTP_STATUS", // {0} is the HTTP status code
49 NO_SERVER_RESPONSE: "NO_SERVER_RESPONSE",
50 CANNOT_WRITE_TEMP: "CANNOT_WRITE_TEMP",
51 CANCELED: "CANCELED"
52};
53
54var Statuses = {
55 FAILED: "FAILED",
56 INSTALLED: "INSTALLED",
57 ALREADY_INSTALLED: "ALREADY_INSTALLED",
58 SAME_VERSION: "SAME_VERSION",
59 OLDER_VERSION: "OLDER_VERSION",
60 NEEDS_UPDATE: "NEEDS_UPDATE",
61 DISABLED: "DISABLED"
62};
63
64/**
65 * Maps unique download ID to info about the pending download. No entry if download no longer pending.
66 * outStream is only present if we've started receiving the body.
67 * @type {Object.<string, {request:!http.ClientRequest, callback:!function(string, string), localPath:string, outStream:?fs.WriteStream}>}
68 */
69var pendingDownloads = {};
70
71/**
72 * Private function to remove the installation directory if the installation fails.
73 * This does not call any callbacks. It's assumed that the callback has already been called
74 * and this cleanup routine will do its best to complete in the background. If there's
75 * a problem here, it is simply logged with console.error.
76 *
77 * @param {string} installDirectory Directory to remove
78 */
79function _removeFailedInstallation(installDirectory) {
80 fs.remove(installDirectory, function (err) {
81 if (err) {
82 console.error("Error while removing directory after failed installation", installDirectory, err);
83 }
84 });
85}
86
87/**
88 * Private function to unzip to the correct directory.
89 *
90 * @param {string} Absolute path to the package zip file
91 * @param {string} Absolute path to the destination directory for unzipping
92 * @param {Object} the return value with the useful information for the client
93 * @param {Function} callback function that is called at the end of the unzipping
94 */
95function _performInstall(packagePath, installDirectory, validationResult, callback) {
96 validationResult.installedTo = installDirectory;
97
98 var callbackCalled = false;
99
100 fs.mkdirs(installDirectory, function (err) {
101 if (err) {
102 callback(err);
103 return;
104 }
105 var sourceDir = path.join(validationResult.extractDir, validationResult.commonPrefix);
106
107 fs.copy(sourceDir, installDirectory, function (err) {
108 if (err) {
109 _removeFailedInstallation(installDirectory);
110 callback(err, null);
111 } else {
112 // The status may have already been set previously (as in the
113 // DISABLED case.
114 if (!validationResult.installationStatus) {
115 validationResult.installationStatus = Statuses.INSTALLED;
116 }
117 callback(null, validationResult);
118 }
119 });
120 });
121}
122
123/**
124 * Private function to remove the target directory and then install.
125 *
126 * @param {string} Absolute path to the package zip file
127 * @param {string} Absolute path to the destination directory for unzipping
128 * @param {Object} the return value with the useful information for the client
129 * @param {Function} callback function that is called at the end of the unzipping
130 */
131function _removeAndInstall(packagePath, installDirectory, validationResult, callback) {
132 // If this extension was previously installed but disabled, we will overwrite the
133 // previous installation in that directory.
134 fs.remove(installDirectory, function (err) {
135 if (err) {
136 callback(err);
137 return;
138 }
139 _performInstall(packagePath, installDirectory, validationResult, callback);
140 });
141}
142
143function _checkExistingInstallation(validationResult, installDirectory, systemInstallDirectory, callback) {
144 // If the extension being installed does not have a package.json, we can't
145 // do any kind of version comparison, so we just signal to the UI that
146 // it already appears to be installed.
147 if (!validationResult.metadata) {
148 validationResult.installationStatus = Statuses.ALREADY_INSTALLED;
149 callback(null, validationResult);
150 return;
151 }
152
153 fs.readJson(path.join(installDirectory, "package.json"), function (err, packageObj) {
154 // if the package.json is unreadable, we assume that the new package is an update
155 // that is the first to include a package.json.
156 if (err) {
157 validationResult.installationStatus = Statuses.NEEDS_UPDATE;
158 } else {
159 // Check to see if the version numbers signal an update.
160 if (semver.lt(packageObj.version, validationResult.metadata.version)) {
161 validationResult.installationStatus = Statuses.NEEDS_UPDATE;
162 } else if (semver.gt(packageObj.version, validationResult.metadata.version)) {
163 // Pass a message back to the UI that the new package appears to be an older version
164 // than what's installed.
165 validationResult.installationStatus = Statuses.OLDER_VERSION;
166 validationResult.installedVersion = packageObj.version;
167 } else {
168 // Signal to the UI that it looks like the user is re-installing the
169 // same version.
170 validationResult.installationStatus = Statuses.SAME_VERSION;
171 }
172 }
173 callback(null, validationResult);
174 });
175}
176
177/**
178 * A "legacy package" is an extension that was installed based on the GitHub name without
179 * a package.json file. Checking for the presence of these legacy extensions will help
180 * users upgrade if the extension developer puts a different name in package.json than
181 * the name of the GitHub project.
182 *
183 * @param {string} legacyDirectory directory to check for old-style extension.
184 */
185function legacyPackageCheck(legacyDirectory) {
186 return fs.existsSync(legacyDirectory) && !fs.existsSync(path.join(legacyDirectory, "package.json"));
187}
188
189/**
190 * Implements the "install" command in the "extensions" domain.
191 *
192 * There is no need to call validate independently. Validation is the first
193 * thing that is done here.
194 *
195 * After the extension is validated, it is installed in destinationDirectory
196 * unless the extension is already present there. If it is already present,
197 * a determination is made about whether the package being installed is
198 * an update. If it does appear to be an update, then result.installationStatus
199 * is set to NEEDS_UPDATE. If not, then it's set to ALREADY_INSTALLED.
200 *
201 * If the installation succeeds, then result.installationStatus is set to INSTALLED.
202 *
203 * The extension is unzipped into a directory in destinationDirectory with
204 * the name of the extension (the name is derived either from package.json
205 * or the name of the zip file).
206 *
207 * The destinationDirectory will be created if it does not exist.
208 *
209 * @param {string} Absolute path to the package zip file
210 * @param {string} the destination directory
211 * @param {{disabledDirectory: !string, apiVersion: !string, nameHint: ?string,
212 * systemExtensionDirectory: !string}} additional settings to control the installation
213 * @param {function} callback (err, result)
214 * @param {boolean} _doUpdate private argument to signal that an update should be performed
215 */
216function _cmdInstall(packagePath, destinationDirectory, options, callback, _doUpdate) {
217 if (!options || !options.disabledDirectory || !options.apiVersion || !options.systemExtensionDirectory) {
218 callback(new Error(Errors.MISSING_REQUIRED_OPTIONS), null);
219 return;
220 }
221
222 var validateCallback = function (err, validationResult) {
223 validationResult.localPath = packagePath;
224
225 if (destinationDirectory.indexOf("/support/") === 0) {
226 destinationDirectory = path.join(supportDir, destinationDirectory.substr(8));
227 }
228
229 // This is a wrapper for the callback that will delete the temporary
230 // directory to which the package was unzipped.
231 function deleteTempAndCallback(err) {
232 if (validationResult.extractDir) {
233 fs.remove(validationResult.extractDir);
234 delete validationResult.extractDir;
235 }
236 callback(err, validationResult);
237 }
238
239 // If there was trouble at the validation stage, we stop right away.
240 if (err || validationResult.errors.length > 0) {
241 validationResult.installationStatus = Statuses.FAILED;
242 deleteTempAndCallback(err, validationResult);
243 return;
244 }
245
246 // Prefers the package.json name field, but will take the zip
247 // file's name if that's all that's available.
248 var extensionName, guessedName;
249 if (options.nameHint) {
250 guessedName = path.basename(options.nameHint, ".zip");
251 } else {
252 guessedName = path.basename(packagePath, ".zip");
253 }
254 if (validationResult.metadata) {
255 extensionName = validationResult.metadata.name;
256 } else {
257 extensionName = guessedName;
258 }
259
260 validationResult.name = extensionName;
261 var installDirectory = path.join(destinationDirectory, extensionName),
262 legacyDirectory = path.join(destinationDirectory, guessedName),
263 systemInstallDirectory = path.join(options.systemExtensionDirectory, extensionName);
264
265 if (validationResult.metadata && validationResult.metadata.engines &&
266 validationResult.metadata.engines.brackets) {
267 var compatible = semver.satisfies(options.apiVersion,
268 validationResult.metadata.engines.brackets);
269 if (!compatible) {
270 installDirectory = path.join(options.disabledDirectory, extensionName);
271 validationResult.installationStatus = Statuses.DISABLED;
272 validationResult.disabledReason = Errors.API_NOT_COMPATIBLE;
273 _removeAndInstall(packagePath, installDirectory, validationResult, deleteTempAndCallback);
274 return;
275 }
276 }
277
278 // The "legacy" stuff should go away after all of the commonly used extensions
279 // have been upgraded with package.json files.
280 var hasLegacyPackage = validationResult.metadata && legacyPackageCheck(legacyDirectory);
281
282 // If the extension is already there, we signal to the front end that it's already installed
283 // unless the front end has signaled an intent to update.
284 if (hasLegacyPackage || fs.existsSync(installDirectory) || fs.existsSync(systemInstallDirectory)) {
285 if (_doUpdate) {
286 if (hasLegacyPackage) {
287 // When there's a legacy installed extension, remove it first,
288 // then also remove any new-style directory the user may have.
289 // This helps clean up if the user is in a state where they have
290 // both legacy and new extensions installed.
291 fs.remove(legacyDirectory, function (err) {
292 if (err) {
293 deleteTempAndCallback(err, validationResult);
294 return;
295 }
296 _removeAndInstall(packagePath, installDirectory, validationResult, deleteTempAndCallback);
297 });
298 } else {
299 _removeAndInstall(packagePath, installDirectory, validationResult, deleteTempAndCallback);
300 }
301 } else if (hasLegacyPackage) {
302 validationResult.installationStatus = Statuses.NEEDS_UPDATE;
303 validationResult.name = guessedName;
304 deleteTempAndCallback(null, validationResult);
305 } else {
306 _checkExistingInstallation(validationResult, installDirectory, systemInstallDirectory, deleteTempAndCallback);
307 }
308 } else {
309 // Regular installation with no conflicts.
310 validationResult.disabledReason = null;
311 _performInstall(packagePath, installDirectory, validationResult, deleteTempAndCallback);
312 }
313 };
314
315 validate(packagePath, {}, validateCallback);
316}
317
318/**
319 * Implements the "update" command in the "extensions" domain.
320 *
321 * Currently, this just wraps _cmdInstall, but will remove the existing directory
322 * first.
323 *
324 * There is no need to call validate independently. Validation is the first
325 * thing that is done here.
326 *
327 * After the extension is validated, it is installed in destinationDirectory
328 * unless the extension is already present there. If it is already present,
329 * a determination is made about whether the package being installed is
330 * an update. If it does appear to be an update, then result.installationStatus
331 * is set to NEEDS_UPDATE. If not, then it's set to ALREADY_INSTALLED.
332 *
333 * If the installation succeeds, then result.installationStatus is set to INSTALLED.
334 *
335 * The extension is unzipped into a directory in destinationDirectory with
336 * the name of the extension (the name is derived either from package.json
337 * or the name of the zip file).
338 *
339 * The destinationDirectory will be created if it does not exist.
340 *
341 * @param {string} Absolute path to the package zip file
342 * @param {string} the destination directory
343 * @param {{disabledDirectory: !string, apiVersion: !string, nameHint: ?string,
344 * systemExtensionDirectory: !string}} additional settings to control the installation
345 * @param {function} callback (err, result)
346 */
347function _cmdUpdate(packagePath, destinationDirectory, options, callback) {
348 _cmdInstall(packagePath, destinationDirectory, options, callback, true);
349}
350
351/**
352 * Wrap up after the given download has terminated (successfully or not). Closes connections, calls back the
353 * client's callback, and IF there was an error, delete any partially-downloaded file.
354 *
355 * @param {string} downloadId Unique id originally passed to _cmdDownloadFile()
356 * @param {?string} error If null, download was treated as successful
357 */
358function _endDownload(downloadId, error) {
359 var downloadInfo = pendingDownloads[downloadId];
360 delete pendingDownloads[downloadId];
361
362 if (error) {
363 // Abort the download if still pending
364 // Note that this will trigger response's "end" event
365 downloadInfo.request.abort();
366
367 // Clean up any partially-downloaded file
368 // (if no outStream, then we never got a response back yet and never created any file)
369 if (downloadInfo.outStream) {
370 downloadInfo.outStream.end(function () {
371 fs.unlink(downloadInfo.localPath);
372 });
373 }
374
375 downloadInfo.callback(error, null);
376
377 } else {
378 // Download completed successfully. Flush stream to disk and THEN signal completion
379 downloadInfo.outStream.end(function () {
380 downloadInfo.callback(null, downloadInfo.localPath);
381 });
382 }
383}
384
385/**
386 * Implements "downloadFile" command, asynchronously.
387 */
388function _cmdDownloadFile(downloadId, url, proxy, callback) {
389 // Backwards compatibility check, added in 0.37
390 if (typeof proxy === "function") {
391 callback = proxy;
392 proxy = undefined;
393 }
394
395 if (pendingDownloads[downloadId]) {
396 callback(Errors.DOWNLOAD_ID_IN_USE, null);
397 return;
398 }
399
400 var https = require("https");
401
402 var req = https.get(url, function(res) {
403 if (res.statusCode !== 200) {
404 _endDownload(downloadId, [Errors.BAD_HTTP_STATUS, res.statusCode]);
405 return;
406 }
407
408 var stream = temp.createWriteStream("brackets");
409 if (!stream) {
410 _endDownload(downloadId, Errors.CANNOT_WRITE_TEMP);
411 return;
412 }
413 pendingDownloads[downloadId].localPath = stream.path;
414 pendingDownloads[downloadId].outStream = stream;
415
416 res.on("data", function(d) {
417 stream.write(d);
418 });
419
420 res.on("end", function () {
421 _endDownload(downloadId);
422 });
423
424 }).on("error", function(e) {
425 console.error(e);
426 _endDownload(downloadId, e.message);
427 });
428
429 pendingDownloads[downloadId] = { request: req, callback: callback };
430}
431
432/**
433 * Implements "abortDownload" command, synchronously.
434 */
435function _cmdAbortDownload(downloadId) {
436 if (!pendingDownloads[downloadId]) {
437 // This may mean the download already completed
438 return false;
439 } else {
440 _endDownload(downloadId, Errors.CANCELED);
441 return true;
442 }
443}
444
445/**
446 * Implements the remove extension command.
447 */
448function _cmdRemove(extensionDir, callback) {
449 if (extensionDir.indexOf("/support/") === 0) {
450 extensionDir = path.join(supportDir, extensionDir.substr(8));
451 }
452
453 fs.remove(extensionDir, function (err) {
454 if (err) {
455 callback(err);
456 } else {
457 callback(null);
458 }
459 });
460}
461
462/**
463 * Initialize the "extensions" domain.
464 * The extensions domain handles downloading, unpacking/verifying, and installing extensions.
465 */
466function init(domainManager) {
467 supportDir = domainManager.supportDir;
468 if (!domainManager.hasDomain("extensionManager")) {
469 domainManager.registerDomain("extensionManager", {major: 0, minor: 1});
470 }
471 domainManager.registerCommand(
472 "extensionManager",
473 "validate",
474 validate,
475 true,
476 "Verifies that the contents of the given ZIP file are a valid Brackets extension package",
477 [{
478 name: "path",
479 type: "string",
480 description: "absolute filesystem path of the extension package"
481 }, {
482 name: "options",
483 type: "{requirePackageJSON: ?boolean}",
484 description: "options to control the behavior of the validator"
485 }],
486 [{
487 name: "errors",
488 type: "string|Array.<string>",
489 description: "download error, if any; first string is error code (one of Errors.*); subsequent strings are additional info"
490 }, {
491 name: "metadata",
492 type: "{name: string, version: string}",
493 description: "all package.json metadata (null if there's no package.json)"
494 }]
495 );
496 domainManager.registerCommand(
497 "extensionManager",
498 "install",
499 _cmdInstall,
500 true,
501 "Installs the given Brackets extension if it is valid (runs validation command automatically)",
502 [{
503 name: "path",
504 type: "string",
505 description: "absolute filesystem path of the extension package"
506 }, {
507 name: "destinationDirectory",
508 type: "string",
509 description: "absolute filesystem path where this extension should be installed"
510 }, {
511 name: "options",
512 type: "{disabledDirectory: !string, apiVersion: !string, nameHint: ?string, systemExtensionDirectory: !string}",
513 description: "installation options: disabledDirectory should be set so that extensions can be installed disabled."
514 }],
515 [{
516 name: "errors",
517 type: "string|Array.<string>",
518 description: "download error, if any; first string is error code (one of Errors.*); subsequent strings are additional info"
519 }, {
520 name: "metadata",
521 type: "{name: string, version: string}",
522 description: "all package.json metadata (null if there's no package.json)"
523 }, {
524 name: "disabledReason",
525 type: "string",
526 description: "reason this extension was installed disabled (one of Errors.*), none if it was enabled"
527 }, {
528 name: "installationStatus",
529 type: "string",
530 description: "Current status of the installation (an extension can be valid but not installed because it's an update"
531 }, {
532 name: "installedTo",
533 type: "string",
534 description: "absolute path where the extension was installed to"
535 }, {
536 name: "commonPrefix",
537 type: "string",
538 description: "top level directory in the package zip which contains all of the files"
539 }]
540 );
541 domainManager.registerCommand(
542 "extensionManager",
543 "update",
544 _cmdUpdate,
545 true,
546 "Updates the given Brackets extension (for which install was generally previously attemped). Brackets must be quit after this.",
547 [{
548 name: "path",
549 type: "string",
550 description: "absolute filesystem path of the extension package"
551 }, {
552 name: "destinationDirectory",
553 type: "string",
554 description: "absolute filesystem path where this extension should be installed"
555 }, {
556 name: "options",
557 type: "{disabledDirectory: !string, apiVersion: !string, nameHint: ?string, systemExtensionDirectory: !string}",
558 description: "installation options: disabledDirectory should be set so that extensions can be installed disabled."
559 }],
560 [{
561 name: "errors",
562 type: "string|Array.<string>",
563 description: "download error, if any; first string is error code (one of Errors.*); subsequent strings are additional info"
564 }, {
565 name: "metadata",
566 type: "{name: string, version: string}",
567 description: "all package.json metadata (null if there's no package.json)"
568 }, {
569 name: "disabledReason",
570 type: "string",
571 description: "reason this extension was installed disabled (one of Errors.*), none if it was enabled"
572 }, {
573 name: "installationStatus",
574 type: "string",
575 description: "Current status of the installation (an extension can be valid but not installed because it's an update"
576 }, {
577 name: "installedTo",
578 type: "string",
579 description: "absolute path where the extension was installed to"
580 }, {
581 name: "commonPrefix",
582 type: "string",
583 description: "top level directory in the package zip which contains all of the files"
584 }]
585 );
586 domainManager.registerCommand(
587 "extensionManager",
588 "remove",
589 _cmdRemove,
590 true,
591 "Removes the Brackets extension at the given path.",
592 [{
593 name: "path",
594 type: "string",
595 description: "absolute filesystem path of the installed extension folder"
596 }],
597 {}
598 );
599 domainManager.registerCommand(
600 "extensionManager",
601 "downloadFile",
602 _cmdDownloadFile,
603 true,
604 "Downloads the file at the given URL, saving it to a temp location. Callback receives path to the downloaded file.",
605 [{
606 name: "downloadId",
607 type: "string",
608 description: "Unique identifier for this download 'session'"
609 }, {
610 name: "url",
611 type: "string",
612 description: "URL to download from"
613 }, {
614 name: "proxy",
615 type: "string",
616 description: "optional proxy URL"
617 }],
618 {
619 type: "string",
620 description: "Local path to the downloaded file"
621 }
622 );
623 domainManager.registerCommand(
624 "extensionManager",
625 "abortDownload",
626 _cmdAbortDownload,
627 false,
628 "Aborts any pending download with the given id. Ignored if no download pending (may be already complete).",
629 [{
630 name: "downloadId",
631 type: "string",
632 description: "Unique identifier for this download 'session', previously pased to downloadFile"
633 }],
634 {
635 type: "boolean",
636 description: "True if the download was pending and able to be canceled; false otherwise"
637 }
638 );
639}
640
641// used in unit tests
642exports._cmdValidate = validate;
643exports._cmdInstall = _cmdInstall;
644exports._cmdRemove = _cmdRemove;
645exports._cmdUpdate = _cmdUpdate;
646
647// used to load the domain
648exports.init = init;