418 lines
11 KiB
JavaScript
Executable File
418 lines
11 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import fs from 'node:fs'
|
|
import url from 'node:url'
|
|
import path from 'node:path'
|
|
import { execSync } from 'node:child_process'
|
|
|
|
import * as ejs from 'ejs'
|
|
import chalk from 'chalk'
|
|
import * as Diff from 'diff'
|
|
import * as readline from 'node:readline'
|
|
|
|
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
|
|
|
// Generate Dockerfile class
|
|
export class GDF {
|
|
static templates = path.join(__dirname, 'templates')
|
|
|
|
// Where the app is. Used both for scanning and is updated with new files.
|
|
#appdir
|
|
|
|
// Parsed package.json file contents.
|
|
#pj
|
|
|
|
// which packager is used (npm, pnpm, yarn)
|
|
#packager
|
|
|
|
// previous answer to conflict prompt
|
|
#answer = ''
|
|
|
|
// Does this application use remix.run?
|
|
get remix() {
|
|
return !!(this.#pj.dependencies?.remix ||
|
|
this.#pj.dependencies?.['@remix-run/node'])
|
|
}
|
|
|
|
// Does this application use prisma?
|
|
get prisma() {
|
|
return !!(this.#pj.dependencies?.['@prisma/client'] ||
|
|
this.#pj.devDependencies?.prisma)
|
|
}
|
|
|
|
// Does this application use next.js?
|
|
get nextjs() {
|
|
return !!this.#pj.dependencies?.next
|
|
}
|
|
|
|
// Does this application use nuxt.js?
|
|
get nuxtjs() {
|
|
return !!this.#pj.dependencies?.nuxt
|
|
}
|
|
|
|
// Does this application use gatsby?
|
|
get gatsby() {
|
|
return !!this.#pj.dependencies?.gatsby
|
|
}
|
|
|
|
// Does this application use nest?
|
|
get nestjs() {
|
|
return !!this.#pj.dependencies?.['@nestjs/core']
|
|
}
|
|
|
|
// what node version should be used?
|
|
get nodeVersion() {
|
|
const ltsVersion = '18.16.0'
|
|
|
|
try {
|
|
return execSync('node -v', { encoding: 'utf8' })
|
|
.match(/\d+\.\d+\.\d+/)?.[0] || ltsVersion
|
|
} catch {
|
|
return ltsVersion
|
|
}
|
|
}
|
|
|
|
// classic version of yarn (installed by default)
|
|
yarnClassic = '1.22.19'
|
|
|
|
// What yarn version should be used?
|
|
get yarnVersion() {
|
|
const version = this.#pj.packageManager?.match(/(\d+\.\d+\.\d+)/)?.[0] // Should return something like "1.22.10"
|
|
|
|
if (version !== undefined) {
|
|
return version
|
|
} else {
|
|
try {
|
|
return execSync('yarn --version', { encoding: 'utf8' })
|
|
.match(/\d+\.\d+\.\d+/)?.[0] || this.yarnClassic
|
|
} catch {
|
|
return this.yarnClassic
|
|
}
|
|
}
|
|
}
|
|
|
|
// What pnpm version should be used?
|
|
get pnpmVersion() {
|
|
try {
|
|
return execSync('pnpm --version', { encoding: 'utf8' })
|
|
.match(/\d+\.\d+\.\d+/)?.[0] || 'latest'
|
|
} catch {
|
|
return 'latest'
|
|
}
|
|
}
|
|
|
|
// List of package files needed to install
|
|
get packageFiles() {
|
|
const result = ['package.json']
|
|
|
|
for (const file of ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']) {
|
|
if (fs.statSync(path.join(this.#appdir, file), { throwIfNoEntry: false })) {
|
|
result.push(file)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Which packager should be used?
|
|
get packager() {
|
|
if (this.#packager !== undefined) return this.#packager
|
|
|
|
const packageFiles = this.packageFiles
|
|
|
|
if (packageFiles.includes('yarn.lock')) {
|
|
this.#packager = 'yarn'
|
|
} else if (packageFiles.includes('pnpm-lock.yaml')) {
|
|
this.#packager = 'pnpm'
|
|
} else {
|
|
this.#packager = 'npm'
|
|
}
|
|
|
|
return this.#packager
|
|
}
|
|
|
|
// install all dependencies in package.json
|
|
get packagerInstall() {
|
|
let install = `${this.packager} install`
|
|
|
|
const packageFiles = this.packageFiles
|
|
|
|
// clean install
|
|
if (this.packager === 'npm' && packageFiles.includes('package-lock.json')) {
|
|
install = 'npm ci'
|
|
} else if (packageFiles.includes('yarn.lock')) {
|
|
if (this.yarnVersion.startsWith('1.')) {
|
|
install += ' --frozen-lockfile'
|
|
} else if (this.yarnVersion.startsWith('2.')) {
|
|
install += ' --immutable --immutable-cache --check-cache'
|
|
} else {
|
|
// yarn 3+
|
|
install += ' --immutable'
|
|
}
|
|
} else if (packageFiles.includes('pnpm-lock.yaml')) {
|
|
install += ' --frozen-lockfile'
|
|
}
|
|
|
|
// optionally include dev dependencies
|
|
if (this.devDependencies && !this.pnpm) {
|
|
if (this.yarn) {
|
|
install += ' --production=false'
|
|
} else {
|
|
install += ' --include=dev'
|
|
}
|
|
}
|
|
|
|
// optionally include legacy peer dependencies
|
|
if (this.options.legacyPeerDeps) {
|
|
if (this.npm) {
|
|
install += ' --legacy-peer-deps'
|
|
} else if (this.yarn && !this.yarnVersion.startsWith('1.')) {
|
|
install += ' --legacy-peer-deps'
|
|
}
|
|
}
|
|
|
|
// optionally include scripts
|
|
if (this.options.ignoreScripts) {
|
|
install += ' --ignore-scripts'
|
|
}
|
|
|
|
return install
|
|
}
|
|
|
|
// Prune development dependencies
|
|
get packagerPrune() {
|
|
let prune
|
|
|
|
if (this.yarn) {
|
|
prune = 'yarn install --production=true'
|
|
|
|
if (this.options.legacyPeerDeps && !this.yarnVersion.startsWith('1.')) {
|
|
prune += ' --legacy-peer-deps'
|
|
}
|
|
} else if (this.pnpm) {
|
|
prune = 'pnpm prune --prod'
|
|
} else {
|
|
prune = 'npm prune --omit=dev'
|
|
|
|
if (this.options.legacyPeerDeps) prune += ' --legacy-peer-deps'
|
|
}
|
|
|
|
return prune
|
|
}
|
|
|
|
// Is the packager yarn?
|
|
get yarn() {
|
|
return this.packager === 'yarn'
|
|
}
|
|
|
|
// Is the packager npm?
|
|
get npm() {
|
|
return this.packager === 'npm'
|
|
}
|
|
|
|
// Is the packager pnpm?
|
|
get pnpm() {
|
|
return this.packager === 'pnpm'
|
|
}
|
|
|
|
// How to install python (switched from buster to bullseye)
|
|
get python() {
|
|
return parseInt(this.nodeVersion.split('.')[0]) > 16 ? 'python-is-python3' : 'python'
|
|
}
|
|
|
|
// Are there any development dependencies?
|
|
get devDependencies() {
|
|
return !!this.#pj.devDependencies
|
|
}
|
|
|
|
// Is there a build script?
|
|
get build() {
|
|
return !!this.#pj.scripts?.build
|
|
}
|
|
|
|
// Descriptive form of detected runtime
|
|
get runtime() {
|
|
let runtime = 'Node.js'
|
|
|
|
if (this.remix) runtime = 'Remix'
|
|
if (this.nextjs) runtime = 'Next.js'
|
|
if (this.nuxtjs) runtime = 'Nuxt.js'
|
|
if (this.nestjs) runtime = 'NestJS'
|
|
if (this.gatsby) runtime = 'Gatsby'
|
|
|
|
if (this.prisma) runtime += '/Prisma'
|
|
|
|
return runtime
|
|
}
|
|
|
|
get user() {
|
|
return this.runtime.split('/')[0].replaceAll('.', '').toLowerCase()
|
|
}
|
|
|
|
// command to start the web server
|
|
get startCommand() {
|
|
if (this.gatsby) {
|
|
return ['npx', 'gatsby', 'serve', '-H', '0.0.0.0']
|
|
} else if (this.runtime === 'Node.js' && this.#pj.scripts?.start?.includes('fastify')) {
|
|
let start = this.#pj.scripts.start
|
|
if (!start.includes('-a') && !start.includes('--address')) {
|
|
start = start.replace('start', 'start --address 0.0.0.0')
|
|
}
|
|
|
|
start = start.split(' ')
|
|
start.unshift('npx')
|
|
return start
|
|
} else {
|
|
return [this.packager, 'run', 'start']
|
|
}
|
|
}
|
|
|
|
// Does this Dockerfile need an entrypoint script?
|
|
get entrypoint() {
|
|
return this.prisma || this.options.swap
|
|
}
|
|
|
|
// determine if the entrypoint needs to be adjusted to run on Linux
|
|
// generally only needed when developing on windows
|
|
get entrypointFixups() {
|
|
const fixups = []
|
|
|
|
const entrypoint = path.join(this.#appdir, 'docker-entrypoint')
|
|
|
|
const stat = fs.statSync(entrypoint, { throwIfNoEntry: false })
|
|
if (!stat) return fixups
|
|
|
|
if (this.options.windows || !(stat.mode & fs.constants.S_IXUSR)) {
|
|
fixups.push('chmod +x ./docker-entrypoint')
|
|
}
|
|
|
|
if (this.options.windows || fs.readFileSync(entrypoint, 'utf-8').includes('\r')) {
|
|
fixups.push('sed -i "s/\\r$//g" ./docker-entrypoint')
|
|
}
|
|
|
|
return fixups
|
|
}
|
|
|
|
// Port to be used
|
|
get port() {
|
|
let port = 3000
|
|
|
|
if (this.gatsby) port = 8080
|
|
if (this.remix) port = 8080
|
|
|
|
return port
|
|
}
|
|
|
|
// render each template and write to the destination dir
|
|
async run(appdir, options = {}) {
|
|
this.options = options
|
|
this.#appdir = appdir
|
|
this.#pj = JSON.parse(fs.readFileSync(path.join(appdir, 'package.json'), 'utf-8'))
|
|
|
|
if (options.force) this.#answer = 'a'
|
|
|
|
// select and render templates
|
|
const templates = ['Dockerfile.ejs']
|
|
if (this.entrypoint) templates.unshift('docker-entrypoint.ejs')
|
|
|
|
for (const template of templates) {
|
|
const dest = await this.#writeTemplateFile(template)
|
|
|
|
if (template === 'docker-entrypoint.ejs') fs.chmodSync(dest, 0o755)
|
|
}
|
|
|
|
// ensure that there is a dockerignore file
|
|
try {
|
|
fs.statSync(path.join(appdir, '.dockerignore'))
|
|
} catch {
|
|
try {
|
|
fs.copyFileSync(
|
|
path.join(appdir, '.gitignore'),
|
|
path.join(appdir, '.dockerignore')
|
|
)
|
|
} catch {
|
|
await this.#writeTemplateFile('.dockerignore.ejs')
|
|
}
|
|
}
|
|
}
|
|
|
|
// write template file, prompting when there is a conflict
|
|
async #writeTemplateFile(template) {
|
|
const proposed = await ejs.renderFile(path.join(GDF.templates, template), this)
|
|
const name = template.replace(/\.ejs$/m, '')
|
|
const dest = path.join(this.#appdir, name)
|
|
|
|
if (fs.statSync(dest, { throwIfNoEntry: false })) {
|
|
const current = fs.readFileSync(dest, 'utf-8')
|
|
|
|
if (current === proposed) {
|
|
console.log(` ${chalk.bold.blue('identical')} ${name}`)
|
|
return dest
|
|
}
|
|
|
|
let prompt
|
|
let question
|
|
|
|
try {
|
|
if (this.#answer !== 'a') {
|
|
console.log(`${chalk.bold.red('conflict'.padStart(11))} ${name}`)
|
|
|
|
prompt = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout
|
|
})
|
|
|
|
// support node 16 which doesn't have a promisfied readline interface
|
|
question = query => {
|
|
return new Promise(resolve => {
|
|
prompt.question(query, resolve)
|
|
})
|
|
}
|
|
}
|
|
|
|
while (true) {
|
|
if (question) {
|
|
this.#answer = await question(`Overwrite ${dest}? (enter "h" for help) [Ynaqdh] `)
|
|
}
|
|
|
|
switch (this.#answer.toLocaleLowerCase()) {
|
|
case '':
|
|
case 'y':
|
|
case 'a':
|
|
console.log(`${chalk.bold.yellow('force'.padStart(11, ' '))} ${name}`)
|
|
fs.writeFileSync(dest, proposed)
|
|
return dest
|
|
|
|
case 'n':
|
|
console.log(`${chalk.bold.yellow('skip'.padStart(11, ' '))} ${name}`)
|
|
return dest
|
|
|
|
case 'q':
|
|
process.exit(0)
|
|
break
|
|
|
|
case 'd':
|
|
console.log(Diff.createPatch(name, current, proposed, 'current', 'proposed').trimEnd() + '\n')
|
|
break
|
|
|
|
default:
|
|
console.log(' Y - yes, overwrite')
|
|
console.log(' n - no, do not overwrite')
|
|
console.log(' a - all, overwrite this and all others')
|
|
console.log(' q - quit, abort')
|
|
console.log(' d - diff, show the differences between the old and the new')
|
|
console.log(' h - help, show this help')
|
|
}
|
|
}
|
|
} finally {
|
|
if (prompt) prompt.close()
|
|
}
|
|
} else {
|
|
console.log(`${chalk.bold.green('create'.padStart(11, ' '))} ${name}`)
|
|
fs.writeFileSync(dest, proposed)
|
|
}
|
|
|
|
return dest
|
|
}
|
|
}
|