mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 06:56:20 +00:00
feat: cross-platform installer with JSON-mode integration
Adds install.bat / install.sh / install.mjs at the project root for a one-shot setup: prereqs check, renderer clone & link, dependency install, config copy, JSON parser mode selection, URL prompt with validation, and the production build. - install.bat / install.sh: thin OS-specific wrappers around install.mjs - install.mjs: 9-step installer with --help, --non-interactive, --skip-build/clone/link and per-URL override flags - new step 6 'Choose JSON parsing mode': prompts the operator (json5 recommended) or accepts --json-mode=json5|legacy|auto in CI; writes .nitro-build.json so the final 'yarn build' picks it up directly - summary now reports the selected JSON mode and its source - .gitattributes: force LF on install.sh / install.mjs so the shebang stays valid on Linux/macOS after a Windows checkout - install.sh marked executable in the index (100755) - README: new 'Quick install' section with interactive and CI usage, plus a complete --non-interactive example
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
install.sh text eol=lf
|
||||||
|
install.mjs text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
@@ -7,7 +7,75 @@
|
|||||||
- If using NodeJS < 18 remove `--openssl-legacy-provider` from the package.json scripts
|
- If using NodeJS < 18 remove `--openssl-legacy-provider` from the package.json scripts
|
||||||
- [Yarn](https://yarnpkg.com/) `npm i yarn -g`
|
- [Yarn](https://yarnpkg.com/) `npm i yarn -g`
|
||||||
|
|
||||||
## Installation
|
## Quick install (recommended)
|
||||||
|
|
||||||
|
The repository ships a cross-platform installer that performs the full setup
|
||||||
|
in one go: prerequisites check, renderer clone & link, dependency install,
|
||||||
|
config copy, JSON parsing mode selection, URL prompt with validation, and the
|
||||||
|
production build.
|
||||||
|
|
||||||
|
After cloning Nitro V3, from its root run:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Windows
|
||||||
|
install.bat
|
||||||
|
|
||||||
|
# Linux / macOS
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Both wrappers just exec `node install.mjs`, so you can also invoke it directly:
|
||||||
|
|
||||||
|
```
|
||||||
|
node install.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer walks through these steps:
|
||||||
|
|
||||||
|
```
|
||||||
|
[1/9] Check prerequisites (node >= 18, yarn, git)
|
||||||
|
[2/9] Clone Nitro_Render_V3
|
||||||
|
[3/9] Setup renderer (yarn install + yarn link)
|
||||||
|
[4/9] Setup client (yarn install + yarn link "@nitrots/nitro-renderer")
|
||||||
|
[5/9] Copy public/configuration/*.example -> *.json
|
||||||
|
[6/9] Choose JSON parsing mode (json5 recommended) -> writes .nitro-build.json
|
||||||
|
[7/9] Configure URLs (interactive, validated)
|
||||||
|
[8/9] Build (yarn build)
|
||||||
|
[9/9] Summary
|
||||||
|
```
|
||||||
|
|
||||||
|
### Headless / CI runs
|
||||||
|
|
||||||
|
Every step can be driven from flags so the installer can be used in pipelines:
|
||||||
|
|
||||||
|
```
|
||||||
|
node install.mjs --non-interactive \
|
||||||
|
--json-mode=json5 \
|
||||||
|
--socket-url=wss://example.com/ws \
|
||||||
|
--api-url=https://example.com \
|
||||||
|
--asset-url=https://example.com/nitro-assets/ \
|
||||||
|
--image-library-url=https://example.com/c_images \
|
||||||
|
--hof-furni-url=https://example.com/hof_furni \
|
||||||
|
--camera-url=https://example.com/camera \
|
||||||
|
--thumbnails-url=https://example.com/thumbnails \
|
||||||
|
--habbopages-url=/habbopages \
|
||||||
|
--api-base-url=https://example.com \
|
||||||
|
--plain-config-base-url=https://example.com/configuration \
|
||||||
|
--plain-gamedata-base-url=https://example.com/gamedata \
|
||||||
|
--skip-link
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful workflow flags:
|
||||||
|
|
||||||
|
- `--non-interactive` / `--skip-prompts` — keep example defaults unless a URL override is passed
|
||||||
|
- `--json-mode=<json5\|legacy\|auto>` — pick the parser without the JSON mode prompt
|
||||||
|
- `--skip-build`, `--skip-clone`, `--skip-link` — re-runs without redoing those steps
|
||||||
|
- `--help` — full flag reference and per-key URL flags
|
||||||
|
|
||||||
|
`install.mjs` is idempotent: re-running it keeps any `*.json` config files
|
||||||
|
that already exist and only patches the URL keys you pass on the CLI.
|
||||||
|
|
||||||
|
## Installation (manual)
|
||||||
|
|
||||||
- First you should open terminal and navigate to the folder where you want to clone Nitro and Nitro-Renderer
|
- First you should open terminal and navigate to the folder where you want to clone Nitro and Nitro-Renderer
|
||||||
- Clone Nitro (Expl. C:\Github\)
|
- Clone Nitro (Expl. C:\Github\)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@echo off
|
||||||
|
node "%~dp0install.mjs" %*
|
||||||
+556
@@ -0,0 +1,556 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { copyFile, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { platform } from 'node:os';
|
||||||
|
import * as readline from 'node:readline/promises';
|
||||||
|
import { stdin as input, stdout as output } from 'node:process';
|
||||||
|
|
||||||
|
const ROOT = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const RENDERER_REPO_URL = 'https://github.com/duckietm/Nitro_Render_V3.git';
|
||||||
|
const RENDERER_DIR = resolve(ROOT, '..', 'Nitro_Render_V3');
|
||||||
|
const CONFIG_DIR = join(ROOT, 'public', 'configuration');
|
||||||
|
const NITRO_BUILD_FILE = join(ROOT, '.nitro-build.json');
|
||||||
|
const IS_WINDOWS = platform() === 'win32';
|
||||||
|
const MIN_NODE_MAJOR = 18;
|
||||||
|
const VALID_JSON_MODES = ['json5', 'legacy', 'auto'];
|
||||||
|
const DEFAULT_JSON_MODE = 'json5';
|
||||||
|
|
||||||
|
const KEY_SPECS = {
|
||||||
|
'socket.url': { type: 'url', schemes: ['ws:', 'wss:'], flag: 'socket-url' },
|
||||||
|
'api.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'api-url' },
|
||||||
|
'asset.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'asset-url' },
|
||||||
|
'image.library.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'image-library-url' },
|
||||||
|
'hof.furni.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'hof-furni-url' },
|
||||||
|
'camera.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'camera-url' },
|
||||||
|
'thumbnails.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'thumbnails-url' },
|
||||||
|
'url.prefix': { type: 'pathOrUrl', schemes: ['http:', 'https:'], flag: 'url-prefix' },
|
||||||
|
'habbopages.url': { type: 'pathOrUrl', schemes: ['http:', 'https:'], flag: 'habbopages-url' },
|
||||||
|
'apiBaseUrl': { type: 'url', schemes: ['http:', 'https:'], flag: 'api-base-url' },
|
||||||
|
'plainConfigBaseUrl': { type: 'url', schemes: ['http:', 'https:'], flag: 'plain-config-base-url' },
|
||||||
|
'plainGamedataBaseUrl': { type: 'url', schemes: ['http:', 'https:'], flag: 'plain-gamedata-base-url' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const FLAG_TO_KEY = Object.fromEntries(
|
||||||
|
Object.entries(KEY_SPECS).map(([key, spec]) => [spec.flag, key])
|
||||||
|
);
|
||||||
|
|
||||||
|
const CONFIG_FILES = [
|
||||||
|
{
|
||||||
|
example: 'renderer-config.example',
|
||||||
|
target: 'renderer-config.json',
|
||||||
|
keys: ['socket.url', 'api.url', 'asset.url', 'image.library.url', 'hof.furni.url']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
example: 'ui-config.example',
|
||||||
|
target: 'ui-config.json',
|
||||||
|
keys: ['camera.url', 'thumbnails.url', 'url.prefix', 'habbopages.url']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
example: 'client-mode.example',
|
||||||
|
target: 'client-mode.json',
|
||||||
|
keys: ['apiBaseUrl', 'plainConfigBaseUrl', 'plainGamedataBaseUrl']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
'Check prerequisites',
|
||||||
|
'Clone Nitro_Render_V3',
|
||||||
|
'Setup renderer (yarn install + yarn link)',
|
||||||
|
'Setup client (yarn install + yarn link)',
|
||||||
|
'Copy config files',
|
||||||
|
'Choose JSON parsing mode',
|
||||||
|
'Configure URLs',
|
||||||
|
'Build (yarn build)',
|
||||||
|
'Summary'
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentStep = 0;
|
||||||
|
let activeReadline = null;
|
||||||
|
const summary = {
|
||||||
|
rendererCloned: false,
|
||||||
|
rendererSkipped: false,
|
||||||
|
configsCreated: [],
|
||||||
|
configsKept: [],
|
||||||
|
configsPatched: [],
|
||||||
|
jsonMode: null,
|
||||||
|
jsonModeSource: null,
|
||||||
|
buildRan: false,
|
||||||
|
buildSkipped: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
|
||||||
|
const c = {
|
||||||
|
reset: useColor ? '\x1b[0m' : '',
|
||||||
|
bold: useColor ? '\x1b[1m' : '',
|
||||||
|
dim: useColor ? '\x1b[2m' : '',
|
||||||
|
red: useColor ? '\x1b[31m' : '',
|
||||||
|
green: useColor ? '\x1b[32m' : '',
|
||||||
|
yellow: useColor ? '\x1b[33m' : '',
|
||||||
|
cyan: useColor ? '\x1b[36m' : ''
|
||||||
|
};
|
||||||
|
|
||||||
|
function info(msg) { console.log(c.cyan + '[i]' + c.reset + ' ' + msg); }
|
||||||
|
function ok(msg) { console.log(c.green + '[+]' + c.reset + ' ' + msg); }
|
||||||
|
function warn(msg) { console.log(c.yellow + '[!]' + c.reset + ' ' + msg); }
|
||||||
|
function err(msg) { console.error(c.red + '[x]' + c.reset + ' ' + msg); }
|
||||||
|
|
||||||
|
function step(label) {
|
||||||
|
currentStep += 1;
|
||||||
|
const sep = '----------------------------------------------------------------';
|
||||||
|
console.log('');
|
||||||
|
console.log(c.dim + sep + c.reset);
|
||||||
|
console.log(c.bold + '[' + currentStep + '/' + STEPS.length + '] ' + label + c.reset);
|
||||||
|
console.log(c.dim + sep + c.reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runShell(cmdString, cwd) {
|
||||||
|
return new Promise((resolveFn, rejectFn) => {
|
||||||
|
const child = spawn(cmdString, { shell: true, cwd, stdio: 'inherit' });
|
||||||
|
child.on('error', e => rejectFn(new Error("'" + cmdString + "' (cwd: " + cwd + ") failed to start: " + e.message)));
|
||||||
|
child.on('exit', code => {
|
||||||
|
if (code === 0) resolveFn();
|
||||||
|
else rejectFn(new Error("'" + cmdString + "' (cwd: " + cwd + ") exited with code " + code));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCapture(cmdString) {
|
||||||
|
return new Promise((resolveFn, rejectFn) => {
|
||||||
|
const child = spawn(cmdString, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', d => { stdout += d.toString(); });
|
||||||
|
child.stderr.on('data', d => { stderr += d.toString(); });
|
||||||
|
child.on('error', e => rejectFn(new Error("'" + cmdString + "' failed to start: " + e.message)));
|
||||||
|
child.on('exit', code => {
|
||||||
|
if (code === 0) resolveFn(stdout.trim());
|
||||||
|
else rejectFn(new Error("'" + cmdString + "' exited with code " + code + (stderr ? ': ' + stderr.trim() : '')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateValue(value, spec) {
|
||||||
|
if (value === '' || value === undefined || value === null) {
|
||||||
|
if (spec.type === 'pathOrUrl') return { valid: true };
|
||||||
|
return { valid: false, error: 'value cannot be empty' };
|
||||||
|
}
|
||||||
|
if (spec.type === 'pathOrUrl' && value.startsWith('/')) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(value);
|
||||||
|
} catch {
|
||||||
|
return { valid: false, error: 'not a valid URL' };
|
||||||
|
}
|
||||||
|
if (spec.schemes.length > 0 && !spec.schemes.includes(parsed.protocol)) {
|
||||||
|
const allowed = spec.schemes.map(s => s.replace(':', '')).join(', ');
|
||||||
|
return { valid: false, error: 'scheme must be one of: ' + allowed };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs() {
|
||||||
|
const opts = {
|
||||||
|
interactive: true,
|
||||||
|
skipBuild: false,
|
||||||
|
skipClone: false,
|
||||||
|
skipLink: false,
|
||||||
|
help: false,
|
||||||
|
jsonMode: null,
|
||||||
|
urlOverrides: {}
|
||||||
|
};
|
||||||
|
const builtinFlags = new Set([
|
||||||
|
'--non-interactive', '--skip-prompts',
|
||||||
|
'--skip-build', '--skip-clone', '--skip-link',
|
||||||
|
'--help', '-h'
|
||||||
|
]);
|
||||||
|
for (const arg of process.argv.slice(2)) {
|
||||||
|
if (builtinFlags.has(arg)) {
|
||||||
|
switch (arg) {
|
||||||
|
case '--non-interactive':
|
||||||
|
case '--skip-prompts': opts.interactive = false; break;
|
||||||
|
case '--skip-build': opts.skipBuild = true; break;
|
||||||
|
case '--skip-clone': opts.skipClone = true; break;
|
||||||
|
case '--skip-link': opts.skipLink = true; break;
|
||||||
|
case '--help':
|
||||||
|
case '-h': opts.help = true; break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const eq = arg.indexOf('=');
|
||||||
|
if (arg.startsWith('--') && eq > 2) {
|
||||||
|
const flagName = arg.slice(2, eq);
|
||||||
|
const value = arg.slice(eq + 1);
|
||||||
|
if (flagName === 'json-mode') {
|
||||||
|
if (!VALID_JSON_MODES.includes(value)) {
|
||||||
|
warn('Invalid --json-mode=' + value + ' (expected: ' + VALID_JSON_MODES.join(', ') + '), ignored');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
opts.jsonMode = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = FLAG_TO_KEY[flagName];
|
||||||
|
if (key) {
|
||||||
|
opts.urlOverrides[key] = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
warn('Unknown flag: ' + arg + ' (ignored)');
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
const flagList = Object.entries(KEY_SPECS)
|
||||||
|
.map(([key, spec]) => ' --' + spec.flag + '=<value>' + ' '.repeat(Math.max(1, 32 - spec.flag.length)) + 'Set ' + key)
|
||||||
|
.join('\n');
|
||||||
|
console.log([
|
||||||
|
'Nitro-V3 cross-platform installer',
|
||||||
|
'',
|
||||||
|
'Usage: node install.mjs [flags]',
|
||||||
|
'',
|
||||||
|
'Workflow flags:',
|
||||||
|
' --non-interactive, --skip-prompts Keep default URLs unless overridden by --<key>=<value>',
|
||||||
|
' --json-mode=<json5|legacy|auto> Choose the JSON parsing mode without prompting',
|
||||||
|
' --skip-build Skip the final yarn build',
|
||||||
|
' --skip-clone Skip cloning Nitro_Render_V3',
|
||||||
|
' --skip-link Skip yarn link calls (useful when re-running)',
|
||||||
|
' --help, -h Show this help and exit',
|
||||||
|
'',
|
||||||
|
'URL override flags (override interactive prompts; combine with --non-interactive for fully automated runs):',
|
||||||
|
flagList,
|
||||||
|
'',
|
||||||
|
'Steps performed:',
|
||||||
|
' 1. Check Node >= ' + MIN_NODE_MAJOR + ', yarn, git',
|
||||||
|
' 2. Clone Nitro_Render_V3 to ../Nitro_Render_V3',
|
||||||
|
' 3. yarn install + yarn link in the renderer',
|
||||||
|
' 4. yarn install + yarn link "@nitrots/nitro-renderer" in this project',
|
||||||
|
' 5. Copy public/configuration/*.example -> *.json (keeps existing files)',
|
||||||
|
' 6. Choose JSON parsing mode (json5 recommended) -> writes .nitro-build.json',
|
||||||
|
' 7. Prompt for URLs and patch the JSON config files',
|
||||||
|
' 8. yarn build (honours the JSON mode chosen at step 6)',
|
||||||
|
''
|
||||||
|
].join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPrereqs() {
|
||||||
|
const nodeVer = process.versions.node;
|
||||||
|
const major = parseInt(nodeVer.split('.')[0], 10);
|
||||||
|
if (Number.isNaN(major) || major < MIN_NODE_MAJOR) {
|
||||||
|
throw new Error('Node >= ' + MIN_NODE_MAJOR + ' required (you have v' + nodeVer + '). Install from https://nodejs.org/');
|
||||||
|
}
|
||||||
|
ok('Node v' + nodeVer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const v = await runCapture('yarn --version');
|
||||||
|
ok('yarn ' + v);
|
||||||
|
} catch {
|
||||||
|
const hint = IS_WINDOWS ? 'npm i -g yarn' : 'sudo npm i -g yarn';
|
||||||
|
throw new Error('yarn not found on PATH. Install with: ' + hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const v = await runCapture('git --version');
|
||||||
|
ok(v);
|
||||||
|
} catch {
|
||||||
|
const hint = IS_WINDOWS ? 'winget install Git.Git' : 'sudo apt-get install git (or your distro equivalent)';
|
||||||
|
throw new Error('git not found on PATH. Install with: ' + hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneRenderer(opts) {
|
||||||
|
if (opts.skipClone) { info('--skip-clone: not cloning Nitro_Render_V3'); summary.rendererSkipped = true; return; }
|
||||||
|
if (existsSync(RENDERER_DIR)) {
|
||||||
|
warn('Nitro_Render_V3 already exists at ' + RENDERER_DIR + ' - skipping clone (yarn install/link will still run).');
|
||||||
|
summary.rendererSkipped = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runShell('git clone ' + RENDERER_REPO_URL + ' "' + RENDERER_DIR + '"', dirname(RENDERER_DIR));
|
||||||
|
summary.rendererCloned = true;
|
||||||
|
ok('Cloned Nitro_Render_V3 to ' + RENDERER_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupRenderer(opts) {
|
||||||
|
if (!existsSync(RENDERER_DIR)) {
|
||||||
|
throw new Error('Renderer directory not found: ' + RENDERER_DIR + '. Re-run without --skip-clone or clone it manually.');
|
||||||
|
}
|
||||||
|
await runShell('yarn install', RENDERER_DIR);
|
||||||
|
if (opts.skipLink) { info('--skip-link: skipping yarn link in renderer'); return; }
|
||||||
|
try {
|
||||||
|
await runShell('yarn link', RENDERER_DIR);
|
||||||
|
} catch (e) {
|
||||||
|
warn('yarn link in renderer failed (likely already linked): ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupClient(opts) {
|
||||||
|
await runShell('yarn install', ROOT);
|
||||||
|
if (opts.skipLink) { info('--skip-link: skipping yarn link in client'); return; }
|
||||||
|
try {
|
||||||
|
await runShell('yarn link "@nitrots/nitro-renderer"', ROOT);
|
||||||
|
} catch (e) {
|
||||||
|
warn('yarn link "@nitrots/nitro-renderer" failed (likely already linked): ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonMode(mode) {
|
||||||
|
const payload = { jsonMode: mode, configuredAt: new Date().toISOString() };
|
||||||
|
await writeFile(NITRO_BUILD_FILE, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseJsonMode(opts) {
|
||||||
|
if (opts.jsonMode) {
|
||||||
|
await writeJsonMode(opts.jsonMode);
|
||||||
|
summary.jsonMode = opts.jsonMode;
|
||||||
|
summary.jsonModeSource = 'CLI (--json-mode)';
|
||||||
|
ok('JSON mode set to ' + opts.jsonMode + ' (from --json-mode)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing = null;
|
||||||
|
if (existsSync(NITRO_BUILD_FILE)) {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(NITRO_BUILD_FILE, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (VALID_JSON_MODES.includes(parsed?.jsonMode)) existing = parsed.jsonMode;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.interactive) {
|
||||||
|
const mode = existing || DEFAULT_JSON_MODE;
|
||||||
|
await writeJsonMode(mode);
|
||||||
|
summary.jsonMode = mode;
|
||||||
|
summary.jsonModeSource = existing ? 'existing .nitro-build.json' : 'default (non-interactive)';
|
||||||
|
info('--non-interactive: JSON mode = ' + mode + (existing ? ' (preserved)' : ' (default)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info('Pick how configuration files (renderer-config, ui-config, gamedata) are parsed.');
|
||||||
|
info(' 1) JSON5 (recommended - accepts comments, trailing commas, single quotes)');
|
||||||
|
info(' 2) JSON (legacy strict - only standard JSON valid)');
|
||||||
|
if (existing) info(' Current value in .nitro-build.json: ' + existing);
|
||||||
|
|
||||||
|
const rl = readline.createInterface({ input, output });
|
||||||
|
activeReadline = rl;
|
||||||
|
let chosen = null;
|
||||||
|
try {
|
||||||
|
while (chosen === null) {
|
||||||
|
const defaultLabel = existing || '1=JSON5';
|
||||||
|
const answer = (await rl.question(' Choice [' + defaultLabel + ']: ')).trim().toLowerCase();
|
||||||
|
if (answer.length === 0) {
|
||||||
|
chosen = existing || DEFAULT_JSON_MODE;
|
||||||
|
} else if (answer === '1' || answer === 'json5' || answer === 'y' || answer === 'yes') {
|
||||||
|
chosen = 'json5';
|
||||||
|
} else if (answer === '2' || answer === 'json' || answer === 'legacy' || answer === 'n' || answer === 'no') {
|
||||||
|
chosen = 'legacy';
|
||||||
|
} else if (answer === 'auto') {
|
||||||
|
chosen = 'auto';
|
||||||
|
} else {
|
||||||
|
warn('Invalid choice. Enter 1, 2, json5, json, legacy, or auto.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeReadline = null;
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeJsonMode(chosen);
|
||||||
|
summary.jsonMode = chosen;
|
||||||
|
summary.jsonModeSource = 'interactive prompt';
|
||||||
|
ok('JSON mode set to ' + chosen + ' -> wrote .nitro-build.json');
|
||||||
|
if (chosen === 'legacy') {
|
||||||
|
warn('Legacy mode is strict: config files must be valid standard JSON (no comments, no trailing commas).');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyConfigs() {
|
||||||
|
for (const entry of CONFIG_FILES) {
|
||||||
|
const src = join(CONFIG_DIR, entry.example);
|
||||||
|
const dst = join(CONFIG_DIR, entry.target);
|
||||||
|
if (!existsSync(src)) {
|
||||||
|
throw new Error('Missing example file: ' + src);
|
||||||
|
}
|
||||||
|
if (existsSync(dst)) {
|
||||||
|
warn(entry.target + ' already exists - keeping existing file (URL overrides will still patch it).');
|
||||||
|
summary.configsKept.push(entry.target);
|
||||||
|
} else {
|
||||||
|
await copyFile(src, dst);
|
||||||
|
ok('Created ' + entry.target);
|
||||||
|
summary.configsCreated.push(entry.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyOverridesNonInteractive(opts) {
|
||||||
|
for (const entry of CONFIG_FILES) {
|
||||||
|
const dst = join(CONFIG_DIR, entry.target);
|
||||||
|
const raw = await readFile(dst, 'utf8');
|
||||||
|
let obj;
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Could not parse ' + entry.target + ' as JSON: ' + e.message);
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
for (const key of entry.keys) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(opts.urlOverrides, key)) {
|
||||||
|
const value = opts.urlOverrides[key];
|
||||||
|
const result = validateValue(value, KEY_SPECS[key]);
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new Error('Invalid value for --' + KEY_SPECS[key].flag + '=' + JSON.stringify(value) + ': ' + result.error);
|
||||||
|
}
|
||||||
|
if (obj[key] !== value) {
|
||||||
|
obj[key] = value;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
await writeFile(dst, JSON.stringify(obj, null, 4) + '\n');
|
||||||
|
ok('Updated ' + entry.target + ' (from CLI flags)');
|
||||||
|
summary.configsPatched.push(entry.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptConfigs(opts) {
|
||||||
|
const overrideKeys = Object.keys(opts.urlOverrides);
|
||||||
|
if (!opts.interactive) {
|
||||||
|
if (overrideKeys.length > 0) {
|
||||||
|
info('--non-interactive with ' + overrideKeys.length + ' URL override(s); applying without prompts');
|
||||||
|
await applyOverridesNonInteractive(opts);
|
||||||
|
} else {
|
||||||
|
info('--non-interactive: keeping URL values from .example defaults');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info('Press Enter to keep the current value shown in [brackets]. URLs are validated.');
|
||||||
|
if (overrideKeys.length > 0) {
|
||||||
|
info('CLI overrides take precedence and skip prompts: ' + overrideKeys.map(k => '--' + KEY_SPECS[k].flag).join(', '));
|
||||||
|
}
|
||||||
|
const rl = readline.createInterface({ input, output });
|
||||||
|
activeReadline = rl;
|
||||||
|
try {
|
||||||
|
for (const entry of CONFIG_FILES) {
|
||||||
|
const dst = join(CONFIG_DIR, entry.target);
|
||||||
|
const raw = await readFile(dst, 'utf8');
|
||||||
|
let obj;
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Could not parse ' + entry.target + ' as JSON: ' + e.message);
|
||||||
|
}
|
||||||
|
console.log('\n ' + c.bold + entry.target + c.reset);
|
||||||
|
let changed = false;
|
||||||
|
for (const key of entry.keys) {
|
||||||
|
const spec = KEY_SPECS[key];
|
||||||
|
if (Object.prototype.hasOwnProperty.call(opts.urlOverrides, key)) {
|
||||||
|
const value = opts.urlOverrides[key];
|
||||||
|
const result = validateValue(value, spec);
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new Error('Invalid value for --' + spec.flag + '=' + JSON.stringify(value) + ': ' + result.error);
|
||||||
|
}
|
||||||
|
if (obj[key] !== value) { obj[key] = value; changed = true; }
|
||||||
|
console.log(' ' + c.dim + key + ' = ' + value + ' (from --' + spec.flag + ')' + c.reset);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const current = obj[key] === undefined ? '' : String(obj[key]);
|
||||||
|
while (true) {
|
||||||
|
const answer = await rl.question(' ' + key + ' [' + current + ']: ');
|
||||||
|
const trimmed = answer.trim();
|
||||||
|
if (trimmed.length === 0) break;
|
||||||
|
if (trimmed === current) break;
|
||||||
|
const result = validateValue(trimmed, spec);
|
||||||
|
if (!result.valid) {
|
||||||
|
warn('Invalid: ' + result.error + '. Try again or press Enter to keep current.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
obj[key] = trimmed;
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
await writeFile(dst, JSON.stringify(obj, null, 4) + '\n');
|
||||||
|
ok('Updated ' + entry.target);
|
||||||
|
summary.configsPatched.push(entry.target);
|
||||||
|
} else {
|
||||||
|
info('No changes to ' + entry.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeReadline = null;
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBuild(opts) {
|
||||||
|
if (opts.skipBuild) { info('--skip-build: skipping yarn build'); summary.buildSkipped = true; return; }
|
||||||
|
await runShell('yarn build', ROOT);
|
||||||
|
summary.buildRan = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSummary() {
|
||||||
|
const distPath = join(ROOT, 'dist');
|
||||||
|
console.log('');
|
||||||
|
console.log(c.bold + '================================================================' + c.reset);
|
||||||
|
console.log(c.bold + ' Installation summary' + c.reset);
|
||||||
|
console.log(c.bold + '================================================================' + c.reset);
|
||||||
|
console.log(' Renderer: ' + RENDERER_DIR + (summary.rendererCloned ? ' (cloned)' : ' (already present)'));
|
||||||
|
if (summary.configsCreated.length) console.log(' Created: ' + summary.configsCreated.join(', '));
|
||||||
|
if (summary.configsKept.length) console.log(' Kept: ' + summary.configsKept.join(', '));
|
||||||
|
if (summary.configsPatched.length) console.log(' Patched: ' + summary.configsPatched.join(', '));
|
||||||
|
if (summary.jsonMode) console.log(' JSON mode: ' + summary.jsonMode + (summary.jsonModeSource ? ' (' + summary.jsonModeSource + ')' : ''));
|
||||||
|
if (summary.buildRan) console.log(' Build: ' + c.green + 'OK' + c.reset + ' -> ' + distPath);
|
||||||
|
else if (summary.buildSkipped) console.log(' Build: skipped');
|
||||||
|
console.log('');
|
||||||
|
console.log(' Next steps:');
|
||||||
|
console.log(' - Development: yarn start');
|
||||||
|
if (summary.buildRan) {
|
||||||
|
console.log(' - Production: deploy the contents of ' + distPath + ' to your webserver');
|
||||||
|
} else {
|
||||||
|
console.log(' - Production: yarn build, then deploy ' + distPath);
|
||||||
|
}
|
||||||
|
console.log(c.bold + '================================================================' + c.reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const opts = parseArgs();
|
||||||
|
if (opts.help) { printUsage(); process.exit(0); }
|
||||||
|
|
||||||
|
console.log(c.bold + 'Nitro-V3 installer' + c.reset + ' (' + (IS_WINDOWS ? 'Windows' : platform()) + ')');
|
||||||
|
console.log('Project root: ' + ROOT);
|
||||||
|
|
||||||
|
step(STEPS[0]); await checkPrereqs();
|
||||||
|
step(STEPS[1]); await cloneRenderer(opts);
|
||||||
|
step(STEPS[2]); await setupRenderer(opts);
|
||||||
|
step(STEPS[3]); await setupClient(opts);
|
||||||
|
step(STEPS[4]); await copyConfigs();
|
||||||
|
step(STEPS[5]); await chooseJsonMode(opts);
|
||||||
|
step(STEPS[6]); await promptConfigs(opts);
|
||||||
|
step(STEPS[7]); await runBuild(opts);
|
||||||
|
step(STEPS[8]); printSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
if (activeReadline) {
|
||||||
|
try { activeReadline.close(); } catch {}
|
||||||
|
activeReadline = null;
|
||||||
|
}
|
||||||
|
const label = STEPS[currentStep - 1] || 'startup';
|
||||||
|
console.error('');
|
||||||
|
warn('Aborted at step ' + currentStep + ' (' + label + ')');
|
||||||
|
process.exit(130);
|
||||||
|
});
|
||||||
|
|
||||||
|
main().catch(e => {
|
||||||
|
const label = STEPS[currentStep - 1] || 'startup';
|
||||||
|
err('');
|
||||||
|
err('Step ' + currentStep + ' (' + label + ') failed:');
|
||||||
|
err(' ' + e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
exec node "$DIR/install.mjs" "$@"
|
||||||
Reference in New Issue
Block a user