#!/usr/bin/env node import yargs, { type Argv } from 'yargs'; import { fork } from 'child_process'; import { resolve } from 'node:path'; import { getAvailablePort, getImportmapAndRoutes, isPortAvailable, logFail, mergeImportmapAndRoutes, proxyImportmapAndRoutes, resolvePackages, runProject, trimEnd, } from './utils'; import type * as commands from './commands'; const runner = resolve(import.meta.dirname, `runner.js`); const root = resolve(import.meta.dirname, '..'); type Commands = typeof commands; type CommandNames = keyof Commands; function runCommand(type: T, args: Parameters[0]) { const ps = fork(runner, [], { cwd: type === 'runBuild' ? root : process.cwd() }); ps.send({ type, args, }); ps.on('exit', (code) => process.exit(code || 0)); } export function buildCli(y: Argv) { y.command( 'develop', 'Starts a new frontend module development session with the OpenMRS app shell.', (argv) => argv .option('port', { describe: 'The port where the dev server should run. Defaults to 8080, or the next available port.', type: 'number', }) .option('host', { default: 'localhost', describe: 'The host name or IP for the server to use.', type: 'string', }) .option('backend', { default: 'https://dev3.openmrs.org', describe: 'The backend to proxy API requests to.', type: 'string', coerce: (arg) => (arg.endsWith('/') ? arg.slice(0, -1) : arg), }) .option('add-cookie', { default: '', describe: 'Additional cookies to provide when proxying.', type: 'string', }) .option('spa-path', { default: '/openmrs/spa/', describe: 'The path of the application on the target server.', type: 'string', }) .option('api-url', { default: '/openmrs/', describe: 'The URL of the API. Can be a path if the API is on the same target server.', type: 'string', }) .option('open', { default: true, describe: 'Immediately opens the SPA page URL in the browser.', type: 'boolean', }) .option('config-url', { default: [], describe: 'The URL to a valid frontend configuration. Can be used multiple times.', type: 'array', string: true, }) .option('config-file', { default: [], describe: 'The path to a frontend configuration file. Can be used multiple times.', type: 'array', string: true, }) .option('sources', { describe: 'Runs the projects from the provided source directories. Can be used multiple times. Defaults to the current directory if neither --sources nor --packages is specified.', type: 'array', string: true, }) .option('packages', { default: [], describe: 'Runs the projects by package name, resolved from the current workspace. Can be used multiple times.', type: 'array', string: true, }) .option('shared-dependencies', { default: [], describe: 'The additional shared dependencies besides the ones from the app shell.', type: 'array', }) .option('importmap', { default: 'importmap.json', describe: 'The import map to use. Can be a path to a valid import map to be taken literally, an URL, or a fixed JSON object.', type: 'string', }) .option('routes', { default: 'routes.registry.json', describe: 'The routes.registry.json file to use. Can be a path to a valid routes registry to be taken literally, an URL, or a fixed JSON object.', type: 'string', }) .option('support-offline', { default: false, describe: 'Determines if a service worker should be installed for offline support.', type: 'boolean', }) .option('use-rspack', { default: undefined, describe: "Set this flag to force the CLI to attempt to use the Rspack Dev Server. Note that this will fail if the project you are using it in doesn't use Rspack.", type: 'boolean', }), async (args) => { let port: number; if (args.port === undefined) { port = await getAvailablePort(8080); } else { // handle case where user has specified a port to run on if (!(await isPortAvailable(args.port))) { console.error(`Error: Port ${args.port} is already in use. Please choose a different port.`); process.exit(1); } port = args.port; } let resolvedFromPackages: string[] = []; if (args.packages.length > 0) { try { resolvedFromPackages = await resolvePackages(args.packages); } catch (e) { logFail((e as Error).message); process.exit(1); } } const explicitSources = args.sources ?? []; const combined = [...explicitSources, ...resolvedFromPackages]; // De-duplicate by resolved absolute path const seen = new Set(); const deduplicated = combined.filter((s) => { const abs = resolve(s); if (seen.has(abs)) return false; seen.add(abs); return true; }); const sources = deduplicated.length > 0 ? deduplicated : ['.']; runCommand('runDevelop', { configUrls: args['config-url'], configFiles: args['config-file'], ...args, port, ...proxyImportmapAndRoutes( await mergeImportmapAndRoutes( await getImportmapAndRoutes(args.importmap, args.routes, port), await runProject(port, sources, args['use-rspack']), args.backend, args.spaPath, ), args.backend, args.spaPath, ), }); }, ); y.command( 'build', 'Builds a new app shell.', (argv) => argv .option('target', { default: 'dist', describe: 'The target directory where the build artifacts will be stored.', type: 'string', coerce: (arg) => resolve(process.cwd(), arg), }) .option('registry', { alias: 'reg', default: 'https://registry.npmjs.org/', describe: 'The NPM registry used for getting the packages.', type: 'string', }) .option('fresh', { default: false, describe: 'Whether to clear the output directory before running the build.', type: 'boolean', }) .option('support-offline', { default: false, describe: 'Determines if a service worker should be installed for offline support.', type: 'boolean', }) .option('build-config', { describe: 'Path to the SPA build config JSON', type: 'string', coerce: (arg) => resolve(process.cwd(), arg), }) .option('spa-path', { default: '/openmrs/spa/', describe: 'The path of the application on the target server.', type: 'string', }) .option('page-title', { default: 'OpenMRS', describe: 'The title of the web app usually displayed in the browser tab.', }) .option('api-url', { default: '/openmrs/', describe: 'The URL of the API. Can be a path if the API is on the same target server.', type: 'string', }) .option('config-url', { default: [], describe: 'The URL to a frontend configuration. Can be used multiple times. Resolved by the client during initialization.', type: 'array', string: true, }) .option('config-path', { default: [], describe: 'The path to a frontend configuration file. Can be used multiple times. The file is copied directly into the build directory.', type: 'array', coerce: (arg: Array) => arg.map((p) => resolve(process.cwd(), p)), }) .option('importmap', { default: undefined, describe: 'The import map to use. Can be a path to an import map to be taken literally, an URL, or a fixed JSON object.', type: 'string', }) .option('routes', { default: undefined, describe: 'The routes registry to use. Can be a path to an routes registry to be taken literally, an URL, or a fixed JSON object.', type: 'string', }) .option('default-locale', { default: '', describe: "The locale to use as a default for this build of the app shell. Should be a value that's valid to use in an HTML lang attribute, e.g., en or en_GB.", type: 'string', }) .option('env', { default: 'production', describe: 'The environment to build for. e.g., development, production.', type: 'string', }) .option('asset', { default: [], describe: "The path to CSS or JS assets to copy into 'assets/' in the build directory, and include as and