Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,18 @@
"mkdirp": "0.5.1",
"mz": "2.6.0",
"node-firefox-connect": "1.2.0",
"open": "0.0.5",
"node-notifier": "5.1.2",
"open": "0.0.5",
"parse-json": "2.2.0",
"regenerator-runtime": "0.10.5",
"require-uncached": "1.0.3",
"rimraf": "2.6.1",
"sign-addon": "0.2.1",
"source-map-support": "0.4.15",
"stream-to-promise": "2.2.0",
"tmp": "0.0.30",
"watchpack": "1.3.0",
"update-notifier": "2.1.0",
"watchpack": "1.3.0",
"yargs": "6.6.0",
"zip-dir": "1.0.2"
},
Expand Down Expand Up @@ -115,6 +116,7 @@
"load-grunt-tasks": "3.5.2",
"mocha": "3.4.2",
"mocha-multi": "0.11.0",
"mock-stdin": "0.3.1",
"nsp": "2.6.3",
"object.entries": "1.0.4",
"object.values": "1.0.4",
Expand Down
133 changes: 133 additions & 0 deletions src/cmd/create.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/* @flow */
import path from 'path';
import readline from 'readline';
import tty from 'tty';

import {fs} from 'mz';
import mkdirp from 'mkdirp';
import promisify from 'es6-promisify';

import {createLogger} from '../util/logger';
import {UsageError, isErrorWithCode} from '../errors';

const log = createLogger(__filename);
const defaultAsyncMkdirp = promisify(mkdirp);

export type CreateParams = {|
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should split these properties into two parameters and flow types like we do for the other commands:

  • the first one should be named SomethingParams (e.g. CreateCmdParams) and it should only contains what is supposed to be in the regular parameters of the command (e.g. dirPath in this case)
  • the second one should be named SomethingOptions (e.g. CreateCmdOptions) and it should only contains the optional injected dependencies (stdin? in this case), which are used mostly to override the dependencies in the unit tests

dirPath: string,
stdin?: stream$Readable,
|};

export default async function create(
{
dirPath,
stdin = process.stdin,
}: CreateParams
): Promise<void> {
const targetPath = path.join(process.cwd(), dirPath);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking that instead of using process.cwd() directly here, it would be better to turn it into a parameter (which defaults to process.cwd() if missing) and/or into an injected dependency (e.g. something like function getBaseDir(baseDir) { return baseDir || process.cwd(); }).

This way, the command can provide much more control to the caller if needed, which should be helpful both in the unit tests and while using web-ext as a library.

const name = path.basename(dirPath);
log.info(name, targetPath);

let userAbort = true;

try {
const stats = await fs.stat(targetPath);
if (stats.isDirectory()) {
if (stdin.isTTY && (stdin instanceof tty.ReadStream)) {
stdin.setRawMode(true);
readline.emitKeypressEvents(stdin);
let userConfirmation = false;

while (!userConfirmation) {
log.info(`The ${targetPath} already exists. Are you sure you want ` +
'to use this directory and overwrite existing files? Y/N');

const pressed = await new Promise((resolve) => {
stdin.once('keypress', (str, key) => resolve(key));
});

if (pressed.name === 'n' || (pressed.ctrl && pressed.name === 'c')) {
userConfirmation = true;
break;
} else if (pressed.name === 'y') {
userConfirmation = true;
userAbort = false;
break;
}
}
} else {
throw new UsageError('Target dir already exist, overwrite is not ' +
'allowed without user confirmation.');
}
}

if (userAbort) {
log.info('User aborted the command.');
stdin.pause();
return;
}

return await createFiles(name, targetPath).then(() => {
stdin.pause();
});
} catch (statErr) {
if (!isErrorWithCode('ENOENT', statErr)) {
throw statErr;
} else {
try {
await defaultAsyncMkdirp(targetPath);
await createFiles(name, targetPath);
} catch (mkdirErr) {
throw mkdirErr;
}
}
}
}

async function createFiles(name, targetPath): Promise<void> {
log.info('Creating manifest file');
const generatedManifest = await generateManifest(name);
const json = JSON.stringify(generatedManifest, null, 2);
try {
log.info('Writing files');
await fs.writeFile(path.join(targetPath, 'manifest.json'), json, 'utf8');
await fs.open(path.join(targetPath, 'background.js'), 'w');
await fs.open(path.join(targetPath, 'content.js'), 'w');
} catch (error) {
throw error;
}
return;
}

async function generateManifest(title) {
return {
manifest_version: 2,
name: `${title} (name)`,
description: `${title} (description)`,
version: 0.1,
default_locale: 'en',
icons: {
'48': 'icon.png',
'96': 'icon@2x.png',
},
browser_action: {
default_title: `${title} (browserAction)`,
default_icon: {
'19': 'button/button-19.png',
'38': 'button/button-38.png',
},
},
background: {
scripts: ['background.js'],
page: '',
},
content_scripts: [
{
exclude_matches: [],
matches: [],
js: ['content.js'],
},
],
permissions: [],
};
}
4 changes: 2 additions & 2 deletions src/cmd/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import build from './build';
import lint from './lint';
import run from './run';
import sign from './sign';
import create from './create';
import docs from './docs';

export default {build, lint, run, sign, docs};

export default {build, lint, run, sign, create, docs};
10 changes: 10 additions & 0 deletions src/program.js
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,16 @@ Example: $0 --help run.
default: false,
},
})
.command(
'create',
'Create basic structure of a new addon', commands.create, {
'dir-path': {
desc: 'Optional path to a directory',
type: 'string',
demand: true,
requiresArg: true,
},
})
.command('docs', 'Open the web-ext documentation in a browser',
commands.docs, {});

Expand Down
147 changes: 147 additions & 0 deletions tests/unit/test-cmd/test.create.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/* @flow */
import path from 'path';
import tty from 'tty';

import {fs} from 'mz';
import {describe, it, afterEach} from 'mocha';
import {assert} from 'chai';
import sinon from 'sinon';
import mockStdin from 'mock-stdin';
import rimraf from 'rimraf';

import create from '../../../src/cmd/create';
import {withTempDir} from '../../../src/util/temp-dir';
import {makeSureItFails} from '../helpers';
import {onlyInstancesOf, UsageError} from '../../../src/errors';

const homeDir = process.cwd();

describe('create', () => {

afterEach(() => {
process.chdir(homeDir);
});

async function cleanTmpDir(dir) {
rimraf(dir, (error) => {
if (error) {
throw error;
}
});
}

it('creates files including manifest with correct name', () => withTempDir(
(tmpDir) => {
process.chdir(tmpDir.path());
const targetDir = path.join(tmpDir.path(), 'target');
const manifest = path.join(targetDir, 'manifest.json');
return create({dirPath: 'target'})
.then(() => {
return fs.stat(path.join(targetDir, 'content.js'))
.then((contentstat) => {
assert.equal(contentstat.isDirectory(), false);
return fs.stat(path.join(targetDir, 'background.js'))
.then((bgstat) => {
assert.equal(bgstat.isDirectory(), false);
return fs.readFile(manifest, 'utf-8')
.then((data) => {
const parsed = JSON.parse(data);
assert.equal(parsed.name, 'target (name)');
})
.then(
() => cleanTmpDir(targetDir),
() => cleanTmpDir(targetDir)
);
});
});
});
}));

it('creates directory recursively when needed', () => withTempDir(
(tmpDir) => {
process.chdir(tmpDir.path());
const targetDir = path.join(tmpDir.path(), 'sub/target');
const manifest = path.join(targetDir, 'manifest.json');
return create({dirPath: 'sub/target'})
.then(() => {
return fs.stat(path.join(targetDir))
.then((contentstat) => {
assert.equal(contentstat.isDirectory(), true);
return fs.readFile(manifest, 'utf-8')
.then((data) => {
const parsed = JSON.parse(data);
assert.equal(parsed.name, 'target (name)');
})
.then(
() => cleanTmpDir(targetDir),
() => cleanTmpDir(targetDir)
);
});
});
}));

it('does not overwrite existing directory if user aborts', () => withTempDir(
(tmpDir) => {
process.chdir(tmpDir.path());
const targetDir = path.join(tmpDir.path(), 'target');
const fakeStdin = new tty.ReadStream();
fs.mkdir('target');
setTimeout(() => {
fakeStdin.emit('keypress', 'n', {name: 'n', ctrl: false});
}, 50);
return create({dirPath: 'target', stdin: fakeStdin})
.then(() => {
return fs.readFile(path.join(targetDir, 'manifest.json'), 'utf-8')
.then(makeSureItFails())
.catch((error) => {
assert.equal(error.code, 'ENOENT');
})
.then(
() => cleanTmpDir(targetDir),
() => cleanTmpDir(targetDir)
);
});
}));

it('overwrites existing directory if user allows', () => withTempDir(
(tmpDir) => {
process.chdir(tmpDir.path());
const targetDir = path.join(tmpDir.path(), 'target');
const fakeStdin = new tty.ReadStream();
sinon.spy(fakeStdin, 'pause');
fs.mkdir('target');
setTimeout(() => {
fakeStdin.emit('keypress', 'y', {name: 'y', ctrl: false});
}, 50);
return create({dirPath: 'target', stdin: fakeStdin})
.then(() => {
return fs.readFile(path.join(targetDir, 'manifest.json'), 'utf-8')
.then((data) => {
const manifest = JSON.parse(data);
assert.equal(manifest.name, 'target (name)');
assert.ok(fakeStdin.pause.called);
})
.then(
() => cleanTmpDir(targetDir),
() => cleanTmpDir(targetDir)
);
});
}));

it('throws error when user cannot confirm overwriting', () => withTempDir(
(tmpDir) => {
process.chdir(tmpDir.path());
mockStdin.isTTY = false;
fs.mkdir('target');
return create({dirPath: 'target', stdin: mockStdin})
.then(makeSureItFails())
.catch(onlyInstancesOf(UsageError, (error) => {
assert.match(error.message, /without user confirmation/);
}))
.then(
() => cleanTmpDir(path.join(tmpDir.path(), 'target')),
() => cleanTmpDir(path.join(tmpDir.path(), 'target'))
);
}));

});