node-gtk
Version:
GNOME Gtk+ bindings for NodeJS
247 lines (214 loc) • 10.3 kB
JavaScript
/*
* create-app.js — create a new GTK/Adwaita application that uses node-gtk.
*
* Driven by the CLI: `node-gtk create <directory> [options]`.
* Copies the template tree in tools/templates/app/, substitutes a few tokens
* (app name, app id, package name, node-gtk version), and — unless --no-install
* is passed — runs `npm install` in the new directory (which in turn generates
* the TypeScript types via the project's postinstall script).
*/
const fs = require('fs')
const path = require('path')
const child_process = require('child_process')
const TEMPLATE_DIR = path.join(__dirname, 'templates', 'app')
// template file -> destination path (relative to the new project root).
// Templates carry a `.tmpl` suffix so npm never rewrites `.gitignore` to
// `.npmignore` and never treats a nested `package.json` as a real manifest.
const FILES = [
['package.json.tmpl', 'package.json'],
['tsconfig.json.tmpl', 'tsconfig.json'],
['gitignore.tmpl', '.gitignore'],
['README.md.tmpl', 'README.md'],
['style.css.tmpl', 'style.css'],
['src/main.ts.tmpl', path.join('src', 'main.ts')],
['src/welcome.ts.tmpl', path.join('src', 'welcome.ts')],
]
// ---------------------------------------------------------------------------
// name derivation
// ---------------------------------------------------------------------------
// A human-facing title: "my-cool-app" / "my_cool_app" -> "My Cool App".
function toAppName(base) {
return base
.replace(/[-_.\s]+/g, ' ')
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase()) || 'My App'
}
// A valid npm package name: lowercase, url-safe.
function toPkgName(base) {
const name = base
.toLowerCase()
.replace(/[^a-z0-9-_.]+/g, '-')
.replace(/^[-_.]+|[-_.]+$/g, '')
return name || 'gtk-app'
}
// A reverse-DNS application id: "My Cool App" -> "com.example.MyCoolApp".
function toAppId(appName) {
const suffix = appName
.replace(/[^A-Za-z0-9 ]+/g, '')
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0].toUpperCase() + w.slice(1))
.join('') || 'App'
return `com.example.${suffix}`
}
// GApplication ids must look like reverse-DNS: 2+ dot-separated segments, each
// starting with a letter, containing only [A-Za-z0-9_-] (and no trailing dot).
function isValidAppId(id) {
return /^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z][A-Za-z0-9_-]*)+$/.test(id)
}
// The `node-gtk` dependency to write into the generated package.json.
//
// The created app uses the `gi:` import scheme and `node-gtk/register`, which
// exist only in this node-gtk. When run from a normal install we depend on the
// matching published version (`^x.y.z`). But when run from a *source checkout*
// (a contributor testing `node bin/node-gtk.js create`), `^x.y.z` would resolve
// to the published release — which may predate these features — so we instead
// point the app at the local checkout via `file:`, so it uses the exact node-gtk
// it was created with.
function nodeGtkDependency() {
const repoRoot = path.resolve(__dirname, '..')
const version = require('../package.json').version
const isInstalled = repoRoot.split(path.sep).includes('node_modules')
return isInstalled ? `^${version}` : `file:${repoRoot}`
}
// ---------------------------------------------------------------------------
// project creation (pure: writes files, never installs, never exits the process)
// ---------------------------------------------------------------------------
function createProject(opts) {
const { dir, appName, appId, pkgName, force = false } = opts
if (fs.existsSync(dir) && fs.readdirSync(dir).length > 0 && !force)
throw new Error(`target directory is not empty: ${dir}\nUse --force to write into it anyway.`)
const nodeGtkVersion = opts.nodeGtkVersion || nodeGtkDependency()
const tokens = {
__APP_NAME__: appName,
__APP_ID__: appId,
__PKG_NAME__: pkgName,
__NODE_GTK_VERSION__: nodeGtkVersion,
}
const substitute = (s) =>
s.replace(/__APP_NAME__|__APP_ID__|__PKG_NAME__|__NODE_GTK_VERSION__/g, (m) => tokens[m])
const written = []
for (const [src, dest] of FILES) {
const content = substitute(fs.readFileSync(path.join(TEMPLATE_DIR, src), 'utf8'))
const destPath = path.join(dir, dest)
fs.mkdirSync(path.dirname(destPath), { recursive: true })
fs.writeFileSync(destPath, content)
written.push(dest)
}
return written
}
// ---------------------------------------------------------------------------
// CLI entry
// ---------------------------------------------------------------------------
const HELP = `node-gtk create — create a GTK/Adwaita app that uses node-gtk
Usage:
node-gtk create <directory> [options]
Options:
--name <name> Human-facing app name (default: derived from <directory>)
--app-id <id> Reverse-DNS application id (default: com.example.<Name>)
--no-install Don't run \`npm install\` after creating the project
--force Create into <directory> even if it exists and is non-empty
-h, --help Show this help
Example:
node-gtk create my-app --name "My App" --app-id org.example.MyApp
`
function parseArgs(argv) {
const opts = { install: true, force: false }
const positional = []
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
switch (arg) {
case '-h': case '--help': opts.help = true; break
case '--no-install': opts.install = false; break
case '--force': opts.force = true; break
case '--name': opts.name = argv[++i]; break
case '--app-id': opts.appId = argv[++i]; break
default:
if (arg.startsWith('--name=')) opts.name = arg.slice('--name='.length)
else if (arg.startsWith('--app-id=')) opts.appId = arg.slice('--app-id='.length)
else if (arg.startsWith('-')) { opts.unknown = arg }
else positional.push(arg)
}
}
opts.dir = positional[0]
return opts
}
// Minimal ANSI styling (chalk is only a devDependency, so we can't use it at
// runtime). No-ops when stdout isn't a TTY or NO_COLOR is set.
const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
const ansi = (open, close) => (s) => useColor ? `\x1b[${open}m${s}\x1b[${close}m` : s
const bold = ansi(1, 22)
const dim = ansi(2, 22)
const cyan = ansi(36, 39)
const green = ansi(32, 39)
const yellow = ansi(33, 39)
function run(argv) {
const opts = parseArgs(argv)
if (opts.help) { process.stdout.write(HELP); return }
if (opts.unknown) { process.stderr.write(`node-gtk create: unknown option '${opts.unknown}'\n\n${HELP}`); process.exit(1) }
if (!opts.dir) { process.stderr.write(`node-gtk create: missing <directory>\n\n${HELP}`); process.exit(1) }
const dir = path.resolve(opts.dir)
const base = path.basename(dir)
const appName = opts.name || toAppName(base)
const pkgName = toPkgName(base)
const appId = opts.appId || toAppId(appName)
if (!isValidAppId(appId)) {
process.stderr.write(`node-gtk create: invalid --app-id '${appId}'.\n` +
`It must be reverse-DNS, e.g. com.example.MyApp (2+ segments, each starting with a letter).\n`)
process.exit(1)
}
let written
try {
written = createProject({ dir, appName, appId, pkgName, force: opts.force })
} catch (err) {
process.stderr.write(`node-gtk create: ${err.message}\n`)
process.exit(1)
}
// Shortest copy-pasteable path to the new project: the relative path unless it
// escapes the cwd (e.g. `../../tmp/app`), in which case the absolute path reads
// better.
const relPath = path.relative(process.cwd(), dir)
const rel = (!relPath || relPath.startsWith('..')) ? dir : relPath
process.stdout.write(`\n${green('✓')} ${bold(`Created ${appName}`)} ${dim(`(${written.length} files) in ${dir}`)}\n`)
if (opts.install) {
process.stdout.write(`${dim('…')} ${bold('Installing dependencies')}${dim(' (npm install)')}\n`)
// Capture output and surface it only on failure — keep the happy path quiet.
const res = child_process.spawnSync('npm', ['install'], { cwd: dir, encoding: 'utf8' })
if (res.status !== 0) {
const output = `${res.stdout || ''}${res.stderr || ''}`
// The one expected, recoverable failure: node-gtk itself installed fine, but
// the postinstall type generation couldn't find the GTK/GI typelibs because
// the native libraries aren't installed yet. generate-types reports this as
// "Typelib file for namespace … not found" — treat it as a warning (the
// project is created and runnable) and hand back the command to finish up.
if (/Typelib file for namespace/i.test(output)) {
const finish = `cd ${rel} && npm run generate-types && npm run dev`
process.stdout.write(
`\n${yellow(bold('⚠ Project created, but TypeScript types could not be generated.'))}\n` +
` The native ${bold('GTK 4 / libadwaita')} libraries don't seem to be installed.\n` +
` ${dim('They power type generation and the app itself.')}\n\n` +
` ${bold('1.')} Install them for your platform:\n` +
` ${cyan('https://github.com/romgrk/node-gtk#installing')}\n\n` +
` ${bold('2.')} Then finish setup:\n` +
` ${cyan(finish)}\n\n`)
return
}
// Any other failure is unexpected: surface the raw output, the manual
// recovery steps, and a link to report it.
if (res.stdout) process.stderr.write(res.stdout)
if (res.stderr) process.stderr.write(res.stderr)
process.stderr.write(
`\nnpm install did not complete cleanly. Your project is created — ` +
`finish setup manually:\n\n cd ${rel}\n npm install\n npm run dev\n\n` +
`If this keeps failing, please report it (include the output above):\n` +
` https://github.com/romgrk/node-gtk/issues\n`)
process.exit(res.status || 1)
}
}
process.stdout.write(`${green('✓')} ${bold('Done!')}\n`)
const steps = [`cd ${rel}`].concat(opts.install ? [] : ['npm install']).concat(['npm run dev'])
process.stdout.write(`\n${bold('Next steps:')}\n\n`)
for (const s of steps) process.stdout.write(` ${cyan(s)}\n`)
process.stdout.write('\n')
}
module.exports = { run, createProject, nodeGtkDependency, toAppName, toPkgName, toAppId, isValidAppId }