Modifying a VSIX with Node

2016-03-15

In a previous post I showed how to use PowerShell to edit a VSIX file. Because of the cross platform capabilities of the release and build system of Visual Studio Team Services, I ported the code to Node. This allows you to run the script on any of the supported platforms where the Node based agent runs such as Linux and Mac.

To create and test the script I first installed the Node.js tools for Visual Studio. This allows you to create  a Node.js application such as a Console App or a Web app.

Node.js tools for Visual Studio

To create the VSIX editing script, I created a Console Application. With Visual Studio you can then run and debug the script. The final extension has three important files.

package.json

Package.json is the configuration file for the Node.js package manager: NPM. In your package.json file you list the dependencies that are required for your application. You can also list scripts that are then available from the Task Runner (if you install the NPM Task Runner extension). The package.json file for this application looks like this:

{
 "devDependencies": {
 "adm-zip": "^0.4.7",
 "archiver": "^0.21.0",
 "copyfiles": "^0.2.1",
 "mkdirp": "^0.5.1",
 "q": "^1.4.1",
 "rimraf": "^2.5.1",
 "temp": "^0.8.3",
 "tfx-cli": "^0.3.12",
 "tsconfig-glob": "^0.4.0",
 "typescript": "^1.7.5",
 "typings": "^0.6.6",
 "vsts-task-lib": "^0.5.10",
 "x2js": "^2.0.0",
 "xmldom": "^0.1.22"
},
 "scripts": {
 "initdev:npm": "npm install",
 "initdev:typings": "typings install",
 "initdev": "npm run initdev:npm  && npm run initdev:typings",
 "compile": "tsc --project .\\\\"
},
 "name": "extensiondependencies",
 "private": true,
 "version": "0.0.0"
}

First, there is a list of development dependencies. All these dependencies are downloaded by NPM and put in the node_modules subfolder in your project. The scripts section lists a couple of scripts to initialize NPM and Typings and to compile the TypeScript files. These scripts can be run through the Task Runner Explorer or from the command line.

Run NPM Tasks in Visual Studio from the Task Runner

typings.json

The typings.json file describes the TypeScript definition files thar you use in your project:


{
 "dependencies": { },
 "devDependencies": { },
 "ambientDevDependencies": {
 "adm-zip": "github:DefinitelyTyped/DefinitelyTyped/adm-zip/adm-zip.d.ts",
 "jasmine": "github:DefinitelyTyped/DefinitelyTyped/jasmine/jasmine.d.ts",
 "node": "github:DefinitelyTyped/DefinitelyTyped/node/node.d.ts#1c56e368e17bb28ca57577250624ca5bd561aa81",
 "Q": "github:DefinitelyTyped/DefinitelyTyped/q/Q.d.ts#aae1368c8ee377f6e9c59c2d6faf1acb3ece7e05",
 "vsts-task-lib": "github:Microsoft/vso-agent-tasks/definitions/vsts-task-lib.d.ts#releases/m94",
 "temp": "github:DefinitelyTyped/DefinitelyTyped/temp/temp.d.ts",
 }
}

As you can see, a definition has a name and a location where the definition file can be found. A lot of definition files can be found in the GitHub repository DefinitelyTyped. The definition file for the vsts-task-lib is located in the Microsoft repository at GitHub. Running typings install from a command line downloads all the definition files and stores them in a typings folder in your project. A main.d.ts file is created which references all your definition files so that you only have to reference one single file from your code.

Solution Explorer in Visual Studio showing the Typing files

app.ts

The actual script is contained in the app.ts file. This is the startup file for your Node Console Application. Since it’s a TypeScript file, I created a class named vsixEditor that takes care of the actual work:


/// <reference path="typings/main.d.ts" />
import AdmZip = require("adm-zip")
import temp = require('temp');
import fs = require('fs');
import path = require('path');
import Q = require("q");

class VSIXEditor {
    private zip: AdmZip;
    private outputPath: string;
    private edit: boolean = false;

    private versionNumber: string = null;
    private id: string = null;
    private publisher: string = null;

    constructor(input: string, output: string) {
        this.outputPath = output;
        this.zip = new AdmZip(input);
    }

    public startEdit() {
        if (this.edit) throw "Edit is already started";
        this.edit = true;
    }

    public endEdit() {
        this.validateEditMode();

        if (this.hasEdits()) {
            temp.track();

            temp.mkdir("visxeditor", (err, dirPath) => {
                if (err) throw err;

                this.zip.extractAllTo(dirPath, true);

                this.EditVsixManifest(dirPath)
                    .then(() => {
                        this.EditVsoManifest(dirPath).then(() => {
                            var archiver = require('archiver');
                            var output = fs.createWriteStream(this.outputPath);
                            var archive = archiver('zip');

                            output.on('close', function () {
                                console.log(archive.pointer() + ' total bytes');
                                console.log('archiver has been finalized and the output file descriptor has closed.');
                            });

                            archive.on('error', function (err) {
                                throw err;
                        });

                        archive.pipe(output);

                        archive.bulk([{ expand: true, cwd: dirPath, src:['\*\*/\*'] ]);
                        archive.finalize();
                    });
                });
            });
        }
    }

    private EditVsoManifest(dirPath: string) {
        var deferred = Q.defer<boolean>();

        var vsoManifestPath = path.join(dirPath, 'extension.vsomanifest');
        fs.readFile(vsoManifestPath, 'utf8', (err, vsoManifestData) => {
            if (err) throw err;
            fs.writeFile(vsoManifestPath, vsoManifestData, () => {
                deferred.resolve(true)
            });
        });
        return deferred.promise;
    }

    private EditVsixManifest(dirPath: string) {
        var deferred = Q.defer<boolean>();
        var x2jsLib = require('x2js');
        var x2js = new x2jsLib();

        var vsixManifestPath = path.join(dirPath, 'extension.vsixmanifest');
        fs.readFile(vsixManifestPath, 'utf8', (err, vsixManifestData) => {
            if (err) throw err;

            var vsixmanifest = x2js.xml2js(vsixManifestData);
            var identity = vsixmanifest.PackageManifest.Metadata.Identity;
            if (this.versionNumber) identity._Version = this.versionNumber;
            if (this.id) identity._Id = this.id;
            if (this.publisher) identity._Publisher = this.publisher;

            vsixManifestData = x2js.js2xml(vsixmanifest);

            fs.writeFile(vsixManifestPath, vsixManifestData, () => {
                deferred.resolve(true)
            });
            deferred.resolve(true);
        });

        return deferred.promise;
    }

    private hasEdits(): boolean {
        return this.versionNumber != null
    }

    public EditVersion(version: string) {
        this.validateEditMode();
        this.versionNumber = version;
    }

    public EditId(id: string) {
       this.validateEditMode();
        this.id = id;
    }

    public EditPublisher(publisher: string) {
        this.validateEditMode();
        this.publisher = publisher;
    }

    private validateEditMode() {
        if (!this.edit) throw "Editing is not started";
    }
}

And this is how you would use the code:


var vsixEditor = new VSIXEditor("C:/temp/myvsix.vsix", "C:/temp/myvsixoutput.vsix");

vsixEditor.startEdit();
vsixEditor.EditVersion("1.0.0");
vsixEditor.EditId("xxIDxx");
vsixEditor.EditPublisher("xxPublisherxx");
vsixEditor.endEdit();