xpm
Version:
The xPack project manager command line tool
529 lines (452 loc) • 15.7 kB
JavaScript
/*
* 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.
*/
// ----------------------------------------------------------------------------
/**
* 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`)
}
// --------------------------------------------------------------------------
}
// ----------------------------------------------------------------------------