UNPKG

xpm

Version:

The xPack project manager command line tool

529 lines (452 loc) 15.7 kB
/* * This file is part of the xPack project (http://xpack.github.io). * Copyright (c) 2017-2026 Liviu Ionescu. All rights reserved. * * Permission to use, copy, modify, and/or distribute this software * for any purpose is hereby granted, under the terms of the MIT license. * * If a copy of the license was not distributed with this file, it can * be obtained from https://opensource.org/license/mit. */ 'use strict' // ---------------------------------------------------------------------------- /** * The `xpm init` command implementation. */ // ---------------------------------------------------------------------------- // https://nodejs.org/docs/latest/api/ import assert from 'assert' import fs from 'fs/promises' import os from 'os' import path from 'path' import util from 'util' // ---------------------------------------------------------------------------- // https://www.npmjs.com/package/del import { deleteAsync } from 'del' // https://www.npmjs.com/package/parse-git-config import parseGitConfig from 'parse-git-config' // ---------------------------------------------------------------------------- // ES6: `import { CliCommand, CliExitCodes, CliError } from 'cli-start-options' import cliStartOptionsCsj from '@ilg/cli-start-options' // https://www.npmjs.com/package/@xpack/xpm-lib import * as xpmLib from '@xpack/xpm-lib' // https://www.npmjs.com/package/liquidjs import { Liquid } from 'liquidjs' // ---------------------------------------------------------------------------- import { GlobalConfig } from '../classes/global-config.js' import { ManifestIds } from '../classes/manifest-ids.js' import { Spawn } from '../functions/spawn.js' import { convertXpmError } from '../functions/convert-xpm-errors.js' import { XpmDownloader } from '../classes/downloader.js' // ---------------------------------------------------------------------------- const { CliCommand, CliExitCodes, CliError } = cliStartOptionsCsj // ============================================================================ export class Init extends CliCommand { // -------------------------------------------------------------------------- /** * @summary Constructor, to set help definitions. * * @param {Object} context Reference to a context. */ constructor(context) { super(context) // Title displayed with the help message. this.title = 'xPack project manager - create an xpm package, ' + 'empty or from a template' this.optionGroups = [ { title: 'Init options', optionDefs: [ { options: ['-t', '--template'], param: 'xpack', msg: 'The xpm package implementing the template', init: ({ config }) => { config.template = null }, action: ({ config }, val) => { config.template = val }, isOptional: true, isMultiple: false, }, { options: ['-n', '--name'], init: ({ config }) => { config.projectName = null }, action: ({ config }, val) => { config.projectName = val }, msg: 'Project name', param: 'string', isOptional: true, }, { options: ['-p', '--property'], init: ({ config }) => { config.properties = {} }, action: ({ config }, val) => { const arr = val.split('=', 2) if (arr.length === 1) { arr[1] = 'true' // Mandatory a string, it is tested with '===' } config.properties[arr[0]] = arr[1] }, msg: 'Substitution variables', param: 'string', isOptional: true, isMultiple: true, }, { options: ['--ignore-errors'], init: ({ config }) => { config.isIgnoreErrors = false }, action: ({ config }) => { config.isIgnoreErrors = true }, msg: 'Ignore script errors', isOptional: true, }, ], }, ] } /** * @summary Execute the `install` command. * * @param {string[]} args Command line arguments. * @returns {number} Process exit code. * * @override */ async doRun(args) { const log = this.log const context = this.context const config = context.config log.trace(`${this.constructor.name}.doRun()`) log.verbose(this.title) // Extra options are not caught by CLI and must be checked/filtered here. args.forEach((element) => { if (element.startsWith('-')) { log.warn(`'${element}' ignored`) } }) log.info() if (config.projectName) { // Validate `--name` as project name. if ( !config.projectName.match(/^([@][a-zA-Z0-9-_]+[/])?[a-zA-Z0-9-_]+$/) ) { log.error( `project name '${config.projectName}' ` + 'may contain only letters, digits, hyphens and underscores' ) return CliExitCodes.ERROR.SYNTAX } } else { // Default to the current folder name. config.projectName = path.basename(process.cwd()).replace(/\.git/, '') } // Possibly replace non alphanumeric chars with dash ('-') config.projectName = config.projectName .replace(/[^@/a-zA-Z0-9-_]/g, '-') .replace(/--/g, '-') let code if (config.template) { code = await this.doInitWithTemplate() } else { code = await this.doInitSimple() } if (log.isVerbose) { this.outputDoneDuration() } return code } async doInitWithTemplate() { const log = this.log log.trace(`${this.constructor.name}.doInitWithTemplate()`) const context = this.context const config = context.config const xpmPackage = new xpmLib.Package({ log, packageFolderPath: config.cwd, }) this.xpmPackage = xpmPackage // Undefined for empty folders, existing package.json otherwise. await xpmPackage.readPackageDotJson() if (xpmPackage.isNpmPackage()) { log.error('the destination folder already has a package.json file') return CliExitCodes.ERROR.OUTPUT // Possible override. } context.globalConfig = new GlobalConfig() const xpmDownloader = new XpmDownloader({ log }) const cacheFolderPath = context.globalConfig.cacheFolderPath let manifest try { manifest = await xpmDownloader.pacoteCreateManifest({ specifier: config.template, cacheFolderPath, }) } catch (error) { log.trace(util.inspect(error)) log.error(error.message) return CliExitCodes.ERROR.INPUT } log.trace(util.inspect(manifest)) // No policies; getFolderName() will fail if used. const manifestIds = new ManifestIds({ manifest }) const globalPackagePath = path.join( context.globalConfig.globalFolderPath, manifestIds.getPath() ) const packFullName = manifestIds.getFullName() const globalXpmPackage = new xpmLib.Package({ log, packageFolderPath: globalPackagePath, }) let jsonGlobal = await globalXpmPackage.readPackageDotJson() if (!jsonGlobal) { log.info(`Installing ${packFullName}...`) await xpmDownloader.pacoteExtract({ specifier: config.template, destinationFolderPath: globalPackagePath, cacheFolderPath, }) jsonGlobal = await globalXpmPackage.readPackageDotJson() if (!globalXpmPackage.isXpmPackage()) { throw new CliError( 'not an xpm package template, ' + 'check for the "xpack" property in package.json', CliExitCodes.ERROR.APPLICATION ) } try { const minVersion = await globalXpmPackage.checkMinimumXpmRequired({ xpmRootFolderPath: context.rootPath, }) this.policies = new xpmLib.Policies({ log, minVersion }) } catch (error) { throw convertXpmError(error) } if (!jsonGlobal?.main) { throw new CliError( 'not an xpm package template, ' + 'check for the "main" property in package.json', CliExitCodes.ERROR.APPLICATION ) } // Keep bundled for compatibility. if ( jsonGlobal.bundleDependencies === undefined && jsonGlobal.bundledDependencies === undefined ) { // Old templates did not bundle dependencies and // required a full install. log.info('Installing npm dependencies...') const spawn = new Spawn() // const code = await spawn.executeShellPromise( let result try { result = await spawn.spawnShellPromise( 'npm install --production --color=false', { cwd: globalPackagePath, log, } ) } catch (error) { log.verbose(error) await deleteAsync(globalPackagePath, { force: true }) throw new CliError( 'install dependencies failed (npm returned error)', CliExitCodes.ERROR.APPLICATION ) } const code = result.code if (code !== 0) { await deleteAsync(globalPackagePath, { force: true }) throw new CliError( `install dependencies failed (npm returned ${code})`, CliExitCodes.ERROR.APPLICATION ) } } } else { try { const minVersion = await globalXpmPackage.checkMinimumXpmRequired({ xpmRootFolderPath: context.rootPath, }) this.policies = new xpmLib.Policies({ log, minVersion }) } catch (error) { throw convertXpmError(error) } log.info(`Using cached ${packFullName}...`) } log.info(`Processing ${packFullName}...`) const mainTemplatePath = path.join(globalPackagePath, jsonGlobal.main) // Use dynamic import. // On Windows, absolute paths start with a drive letter, and the // explicit `file://` is mandatory. const { XpmInitTemplate } = await import( `file://${mainTemplatePath.toString()}` ) if (!XpmInitTemplate) { log.error( 'not an xpm package template, ' + 'check for "XpmInitTemplate" in exports' ) return CliExitCodes.ERROR.APPLICATION } // To keep things in sync and simplify template dependencies, // forward several objects form cli-start-options via the context. context.CliError = CliError context.CliExitCodes = CliExitCodes let xpmInitTemplate assert(this.policies, 'this.policies must be set') if (this.policies.singleParameterXpmInitTemplate) { xpmInitTemplate = new XpmInitTemplate(context) } else { xpmInitTemplate = new XpmInitTemplate({ context, policies: this.policies, }) } try { const exitCode = await xpmInitTemplate.run() return exitCode } catch (error) { log.error(`template execution failed: ${error.message}`) return CliExitCodes.ERROR.APPLICATION } } async doInitSimple() { const log = this.log log.trace(`${this.constructor.name}.doInitSimple()`) const context = this.context const config = context.config const xpmPackage = new xpmLib.Package({ log, packageFolderPath: config.cwd, }) this.xpmPackage = xpmPackage // Undefined for empty folders, existing package.json otherwise. const jsonPackage = await xpmPackage.readPackageDotJson() const liquidMap = {} liquidMap.projectName = config.projectName.replace(/-xpack$/, '') // Return original if not a match. liquidMap.gitProjectName = config.projectName.replace( /^[@][a-zA-Z0-9-_]+[/]([a-zA-Z0-9-_]+)$/, '$1' ) const author = {} author.name = '<author-name>' author.email = '<author-email>' author.url = '<author-url>' let gitConfig = null try { gitConfig = await parseGitConfig.promise() } catch { // Nothing to do. } if (gitConfig === null) { try { gitConfig = await parseGitConfig.promise({ cwd: os.homedir(), path: '.gitconfig', }) } catch { // ENOENT: no such file or directory, stat '/github/home/.gitconfig' } } if (gitConfig) { if (gitConfig.user && gitConfig.user.name) { author.name = gitConfig.user.name } if (gitConfig.user && gitConfig.user.email) { author.email = gitConfig.user.email } } liquidMap.author = author const currentTime = new Date() liquidMap.year = currentTime.getFullYear().toString() liquidMap.xpm = {} liquidMap.xpm.version = context.package.version.split('-', 1)[0] const templatesPath = path.resolve(context.rootPath, 'assets', 'sources') log.debug(`from='${templatesPath}'`) this.engine = new Liquid({ root: templatesPath, cache: false, strict_filters: true, // default: false strict_variables: true, // default: false trim_right: false, // default: false trim_left: false, // default: false }) if (xpmPackage.isNpmPackage()) { // Existing package.json is filled in with xpack properties. if (xpmPackage.isXpmPackage()) { if (config.isIgnoreErrors) { log.warn('the destination folder is already an xpm package') return CliExitCodes.SUCCESS } else { log.error('the destination folder is already an xpm package') return CliExitCodes.ERROR.OUTPUT // Possible override. } } else { if (log.isVerbose) { log.verbose('Adding the "xpack" keyword to package.json...') } if (!Array.isArray(jsonPackage.keywords)) { jsonPackage.keywords = [] } if (!jsonPackage.keywords.includes('xpack')) { jsonPackage.keywords.push('xpack') } if (log.isVerbose) { log.verbose('Adding the "xpack" keyword to package.json...') } jsonPackage.xpack = { minimumXpmRequired: liquidMap.xpm.version, dependencies: {}, devDependencies: {}, properties: {}, actions: {}, buildConfigurations: {}, } await xpmPackage.rewritePackageDotJson(jsonPackage) if (!log.isVerbose) { log.info('File package.json updated') } } } else { log.info(`Creating project '${liquidMap.projectName}'...`) await this.render('package-liquid.json', 'package.json', liquidMap) } try { const readmePath = path.resolve(config.cwd, 'LICENSE') await fs.access(readmePath) log.info("File 'LICENSE' preserved, not overridden") } catch { // The LICENSE is not present. That's fine. await this.render('LICENSE-liquid', 'LICENSE', liquidMap) } return CliExitCodes.SUCCESS } async render(inputFileRelativePath, outputFileRelativePath, map) { const log = this.log const str = await this.engine.renderFile(inputFileRelativePath, map) // const headerPath = path.resolve(codePath, `${pnam}.h`) try { await fs.writeFile(outputFileRelativePath, str, 'utf8') } catch (error) { log.trace(util.inspect(error)) throw new CliError(error.message, CliExitCodes.ERROR.OUTPUT) } log.info(`File '${outputFileRelativePath}' generated`) } // -------------------------------------------------------------------------- } // ----------------------------------------------------------------------------