UNPKG

14.5 kBJavaScriptView Raw
1"use strict";
2
3const fs = require("fs-extra");
4const path = require("path");
5const os = require("os");
6const { URL } = require("whatwg-url");
7const { camelCase } = require("yargs-parser");
8const dedent = require("dedent");
9const initPackageJson = require("pify")(require("init-package-json"));
10const pacote = require("pacote");
11const npa = require("npm-package-arg");
12const pReduce = require("p-reduce");
13const slash = require("slash");
14
15const { Command } = require("@lerna/command");
16const childProcess = require("@lerna/child-process");
17const npmConf = require("@lerna/npm-conf");
18const { ValidationError } = require("@lerna/validation-error");
19const { builtinNpmrc } = require("./lib/builtin-npmrc");
20const { catFile } = require("./lib/cat-file");
21
22const LERNA_MODULE_DATA = require.resolve("./lib/lerna-module-data.js");
23const DEFAULT_DESCRIPTION = [
24 "Now I’m the model of a modern major general",
25 "The venerated Virginian veteran whose men are all",
26 "Lining up, to put me up on a pedestal",
27 "Writin’ letters to relatives",
28 "Embellishin’ my elegance and eloquence",
29 "But the elephant is in the room",
30 "The truth is in ya face when ya hear the British cannons go",
31 "BOOM",
32].join(" / ");
33
34module.exports = factory;
35
36function factory(argv) {
37 return new CreateCommand(argv);
38}
39
40class CreateCommand extends Command {
41 initialize() {
42 const {
43 bin,
44 description = DEFAULT_DESCRIPTION,
45 esModule,
46 keywords,
47 license,
48 loc: pkgLocation,
49 name: pkgName,
50 yes,
51 } = this.options;
52
53 // npm-package-arg handles all the edge-cases with scopes
54 const { name, scope } = npa(pkgName);
55
56 // optional scope is _not_ included in the directory name
57 this.dirName = scope ? name.split("/").pop() : name;
58 this.pkgName = name;
59 this.pkgsDir =
60 this.project.packageParentDirs.find((pd) => pd.indexOf(pkgLocation) > -1) ||
61 this.project.packageParentDirs[0];
62
63 this.camelName = camelCase(this.dirName);
64
65 // when transpiling, src => dist; otherwise everything in lib
66 this.outDir = esModule ? "dist" : "lib";
67 this.targetDir = path.resolve(this.pkgsDir, this.dirName);
68
69 this.binDir = path.join(this.targetDir, "bin");
70 this.binFileName = bin === true ? this.dirName : bin;
71
72 this.libDir = path.join(this.targetDir, esModule ? "src" : "lib");
73 this.libFileName = `${this.dirName}.js`;
74
75 this.testDir = path.join(this.targetDir, "__tests__");
76 this.testFileName = `${this.dirName}.test.js`;
77
78 this.conf = npmConf({
79 description,
80 esModule,
81 keywords,
82 scope,
83 yes,
84 });
85
86 // consume "builtin" npm config, if it exists (matches npm cli behaviour)
87 this.conf.addFile(builtinNpmrc(), "builtin");
88
89 // always set init-main, it's half of the whole point of this module
90 this.conf.set("init-main", `${this.outDir}/${this.libFileName}`);
91
92 if (esModule) {
93 this.conf.set("init-es-module", `${this.outDir}/${this.dirName}.module.js`);
94 }
95
96 // allow default init-version when independent versioning enabled
97 if (!this.project.isIndependent()) {
98 this.conf.set("init-version", this.project.version);
99 }
100
101 // default author metadata with git config
102 if (this.conf.get("init-author-name") === "") {
103 this.conf.set("init-author-name", this.gitConfig("user.name"));
104 }
105
106 if (this.conf.get("init-author-email") === "") {
107 this.conf.set("init-author-email", this.gitConfig("user.email"));
108 }
109
110 // override npm_config_init_license if --license provided
111 if (license) {
112 this.conf.set("init-license", license);
113 }
114
115 // pass --private into module data without aggravating eslint
116 if (this.options.private) {
117 this.conf.set("private", true);
118 }
119
120 // silence output if logging is silenced
121 // istanbul ignore else
122 if (this.options.loglevel === "silent") {
123 this.conf.set("silent", true);
124 }
125
126 // save read-package-json the trouble
127 if (this.binFileName) {
128 this.conf.set("bin", {
129 [this.binFileName]: `bin/${this.binFileName}`,
130 });
131 }
132
133 // setting _both_ pkg.bin and pkg.directories.bin is an error
134 // https://docs.npmjs.com/files/package.json#directoriesbin
135 this.conf.set("directories", {
136 lib: this.outDir,
137 test: "__tests__",
138 });
139
140 this.setFiles();
141 this.setHomepage();
142 this.setPublishConfig();
143 this.setRepository();
144
145 return Promise.resolve(this.setDependencies());
146 }
147
148 execute() {
149 let chain = Promise.resolve();
150
151 chain = chain.then(() => fs.mkdirp(this.libDir));
152 chain = chain.then(() => fs.mkdirp(this.testDir));
153 chain = chain.then(() => Promise.all([this.writeReadme(), this.writeLibFile(), this.writeTestFile()]));
154
155 if (this.binFileName) {
156 chain = chain.then(() => fs.mkdirp(this.binDir));
157 chain = chain.then(() => Promise.all([this.writeBinFile(), this.writeCliFile(), this.writeCliTest()]));
158 }
159
160 chain = chain.then(() => initPackageJson(this.targetDir, LERNA_MODULE_DATA, this.conf));
161
162 return chain.then((data) => {
163 if (this.options.esModule) {
164 this.logger.notice(
165 "✔",
166 dedent`
167 Ensure '${path.relative(".", this.pkgsDir)}/*/${this.outDir}' has been added to ./.gitignore
168 Ensure rollup or babel build scripts are in the root
169 `
170 );
171 }
172
173 this.logger.success(
174 "create",
175 `New package ${data.name} created at ./${path.relative(".", this.targetDir)}`
176 );
177 });
178 }
179
180 gitConfig(prop) {
181 return childProcess.execSync("git", ["config", "--get", prop], this.execOpts);
182 }
183
184 collectExternalVersions() {
185 // collect all current externalDependencies
186 const extVersions = new Map();
187
188 for (const { externalDependencies } of this.packageGraph.values()) {
189 for (const [name, resolved] of externalDependencies) {
190 extVersions.set(name, resolved.fetchSpec);
191 }
192 }
193
194 return extVersions;
195 }
196
197 hasLocalRelativeFileSpec() {
198 // if any local dependencies are specified as `file:../dir`,
199 // all new local dependencies should be created thusly
200 for (const { localDependencies } of this.packageGraph.values()) {
201 for (const spec of localDependencies.values()) {
202 if (spec.type === "directory") {
203 return true;
204 }
205 }
206 }
207 }
208
209 resolveRelative(depNode) {
210 // a relative file: specifier is _always_ posix
211 const relPath = path.relative(this.targetDir, depNode.location);
212 const spec = npa.resolve(depNode.name, relPath, this.targetDir);
213
214 return slash(spec.saveSpec);
215 }
216
217 setDependencies() {
218 const inputs = new Set((this.options.dependencies || []).sort());
219
220 // add yargs if a bin is required
221 if (this.options.bin) {
222 inputs.add("yargs");
223 }
224
225 if (!inputs.size) {
226 return;
227 }
228
229 const exts = this.collectExternalVersions();
230 const localRelative = this.hasLocalRelativeFileSpec();
231 const savePrefix = this.conf.get("save-exact") ? "" : this.conf.get("save-prefix");
232 const pacoteOpts = this.conf.snapshot;
233
234 const decideVersion = (spec) => {
235 if (this.packageGraph.has(spec.name)) {
236 // sibling dependency
237 const depNode = this.packageGraph.get(spec.name);
238
239 if (localRelative) {
240 // a local `file:../foo` specifier
241 return this.resolveRelative(depNode);
242 }
243
244 // yarn workspace or lerna packages config
245 return `${savePrefix}${depNode.version}`;
246 }
247
248 if (spec.type === "tag" && spec.fetchSpec === "latest") {
249 // resolve the latest version
250 if (exts.has(spec.name)) {
251 // from local external dependency
252 return exts.get(spec.name);
253 }
254
255 // from registry
256 return pacote.manifest(spec, pacoteOpts).then((pkg) => `${savePrefix}${pkg.version}`);
257 }
258
259 if (spec.type === "git") {
260 throw new ValidationError("EGIT", "Do not use git dependencies");
261 }
262
263 // TODO: resolve this if it's weird? (foo@1, bar@^2, etc)
264 return spec.rawSpec;
265 };
266
267 let chain = Promise.resolve();
268
269 chain = chain.then(() =>
270 pReduce(
271 inputs,
272 (obj, input) => {
273 const spec = npa(input);
274
275 return Promise.resolve(spec)
276 .then(decideVersion)
277 .then((version) => {
278 obj[spec.name] = version;
279
280 return obj;
281 });
282 },
283 {}
284 )
285 );
286
287 chain = chain.then((dependencies) => {
288 this.conf.set("dependencies", dependencies);
289 });
290
291 return chain;
292 }
293
294 setFiles() {
295 // no need to glob for files we already know
296 const files = [this.outDir];
297
298 if (this.options.bin) {
299 files.unshift("bin");
300 }
301
302 this.conf.set("files", files);
303 }
304
305 setHomepage() {
306 // allow --homepage override, but otherwise use root pkg.homepage, if it exists
307 let { homepage = this.project.manifest.get("homepage") } = this.options;
308
309 if (!homepage) {
310 // normalize-package-data will backfill from hosted-git-info, if possible
311 return;
312 }
313
314 // allow schemeless URLs (but don't blow up in URL constructor)
315 if (homepage.indexOf("http") !== 0) {
316 homepage = `http://${homepage}`;
317 }
318
319 const hurl = new URL(homepage);
320 const relativeTarget = path.relative(this.project.rootPath, this.targetDir);
321
322 if (hurl.hostname.match("github")) {
323 hurl.pathname = path.posix.join(hurl.pathname, "tree/main", relativeTarget);
324 // TODO: get actual upstream HEAD branch name
325 // current remote: git rev-parse --abbrev-ref --symbolic-full-name @{u}
326 // upstream HEAD: git symbolic-ref --short refs/remotes/origin/HEAD
327 hurl.hash = "readme";
328 } else if (!this.options.homepage) {
329 // don't mutate an explicit --homepage value
330 hurl.pathname = path.posix.join(hurl.pathname, relativeTarget);
331 }
332
333 this.conf.set("homepage", hurl.href);
334 }
335
336 setPublishConfig() {
337 const scope = this.conf.get("scope");
338 const registry = this.options.registry || this.conf.get(`${scope}:registry`) || this.conf.get("registry");
339 const isPublicRegistry = registry === this.conf.root.registry;
340 const publishConfig = {};
341
342 if (scope && isPublicRegistry) {
343 publishConfig.access = this.options.access || "public";
344 }
345
346 if (registry && !isPublicRegistry) {
347 publishConfig.registry = registry;
348 }
349
350 if (this.options.tag) {
351 publishConfig.tag = this.options.tag;
352 }
353
354 if (Object.keys(publishConfig).length) {
355 this.conf.set("publishConfig", publishConfig);
356 }
357 }
358
359 setRepository() {
360 try {
361 const url = childProcess.execSync("git", ["remote", "get-url", "origin"], this.execOpts);
362
363 this.conf.set("repository", url);
364 } catch (err) {
365 this.logger.warn("ENOREMOTE", "No git remote found, skipping repository property");
366 }
367 }
368
369 writeReadme() {
370 const readmeContent = dedent`
371 # \`${this.pkgName}\`
372
373 > ${this.options.description || "TODO: description"}
374
375 ## Usage
376
377 \`\`\`
378 ${
379 // eslint-disable-next-line no-nested-ternary
380 this.options.bin
381 ? dedent`
382 npm -g i ${this.pkgName}
383
384 ${this.binFileName} --help
385 `
386 : this.options.esModule
387 ? `import ${this.camelName} from '${this.pkgName}';`
388 : `const ${this.camelName} = require('${this.pkgName}');`
389 }
390
391 // TODO: DEMONSTRATE API
392 \`\`\`
393 `;
394
395 return catFile(this.targetDir, "README.md", readmeContent);
396 }
397
398 writeLibFile() {
399 const libContent = this.options.esModule
400 ? dedent`
401 export default function ${this.camelName}() {
402 // TODO
403 }
404 `
405 : dedent`
406 'use strict';
407
408 module.exports = ${this.camelName};
409
410 function ${this.camelName}() {
411 // TODO
412 }
413 `;
414
415 return catFile(this.libDir, this.libFileName, libContent);
416 }
417
418 writeTestFile() {
419 const testContent = this.options.esModule
420 ? dedent`
421 import ${this.camelName} from '../src/${this.dirName}';
422
423 describe('${this.pkgName}', () => {
424 it('needs tests');
425 });
426 `
427 : dedent`
428 'use strict';
429
430 const ${this.camelName} = require('..');
431
432 describe('${this.pkgName}', () => {
433 it('needs tests');
434 });
435 `;
436
437 return catFile(this.testDir, this.testFileName, testContent);
438 }
439
440 writeCliFile() {
441 const cliFileName = "cli.js";
442 const cliContent = [
443 this.options.esModule
444 ? dedent`
445 import factory from 'yargs/yargs';
446 import ${this.camelName} from './${this.dirName}';
447
448 export default cli;
449 `
450 : dedent`
451 'use strict';
452
453 const factory = require('yargs/yargs');
454 const ${this.camelName} = require('./${this.dirName}');
455
456 module.exports = cli;
457 `,
458 "", // blank line
459 dedent`
460 function cli(cwd) {
461 const parser = factory(null, cwd);
462
463 parser.alias('h', 'help');
464 parser.alias('v', 'version');
465
466 parser.usage(
467 "$0",
468 "TODO: description",
469 yargs => {
470 yargs.options({
471 // TODO: options
472 });
473 },
474 argv => ${this.camelName}(argv)
475 );
476
477 return parser;
478 }
479 `,
480 ].join(os.EOL);
481
482 return catFile(this.libDir, cliFileName, cliContent);
483 }
484
485 writeCliTest() {
486 const cliTestFileName = "cli.test.js";
487 const cliTestContent = [
488 this.options.esModule
489 ? dedent`
490 import cli from '../src/cli';
491 `
492 : dedent`
493 'use strict';
494
495 const cli = require('../lib/cli');
496 `,
497 "", // blank line
498 dedent`
499 describe('${this.pkgName} cli', () => {
500 // const argv = cli(cwd).parse(['args']);
501 it('needs tests');
502 });
503 `,
504 ].join(os.EOL);
505
506 return catFile(this.testDir, cliTestFileName, cliTestContent);
507 }
508
509 writeBinFile() {
510 const binContent = dedent`
511 #!/usr/bin/env node
512
513 'use strict';
514
515 // eslint-disable-next-line no-unused-expressions
516 require('../${this.outDir}/cli')${
517 this.options.esModule ? ".default" : ""
518 }().parse(process.argv.slice(2));`;
519
520 return catFile(this.binDir, this.binFileName, binContent, { mode: 0o755 });
521 }
522}
523
524module.exports.CreateCommand = CreateCommand;