@lamalibre/create-portlama 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/bin/create-portlama.js +15 -0
  2. package/package.json +50 -0
  3. package/scripts/bundle-vendor.js +60 -0
  4. package/src/index.js +484 -0
  5. package/src/lib/cert-help-page.js +160 -0
  6. package/src/lib/env.js +136 -0
  7. package/src/lib/secrets.js +19 -0
  8. package/src/lib/summary.js +101 -0
  9. package/src/tasks/harden.js +302 -0
  10. package/src/tasks/mtls.js +195 -0
  11. package/src/tasks/nginx.js +184 -0
  12. package/src/tasks/node.js +110 -0
  13. package/src/tasks/panel.js +434 -0
  14. package/vendor/panel-client/dist/assets/index-BDOylgUN.js +323 -0
  15. package/vendor/panel-client/dist/assets/index-BZTMcuQt.css +1 -0
  16. package/vendor/panel-client/dist/index.html +13 -0
  17. package/vendor/panel-server/package.json +31 -0
  18. package/vendor/panel-server/src/index.js +86 -0
  19. package/vendor/panel-server/src/lib/app-error.js +14 -0
  20. package/vendor/panel-server/src/lib/authelia.js +482 -0
  21. package/vendor/panel-server/src/lib/certbot.js +328 -0
  22. package/vendor/panel-server/src/lib/chisel.js +357 -0
  23. package/vendor/panel-server/src/lib/config.js +100 -0
  24. package/vendor/panel-server/src/lib/files.js +251 -0
  25. package/vendor/panel-server/src/lib/mtls.js +197 -0
  26. package/vendor/panel-server/src/lib/nginx.js +529 -0
  27. package/vendor/panel-server/src/lib/plist.js +65 -0
  28. package/vendor/panel-server/src/lib/services.js +128 -0
  29. package/vendor/panel-server/src/lib/state.js +95 -0
  30. package/vendor/panel-server/src/lib/system-stats.js +58 -0
  31. package/vendor/panel-server/src/middleware/errors.js +58 -0
  32. package/vendor/panel-server/src/middleware/mtls.js +30 -0
  33. package/vendor/panel-server/src/middleware/onboarding-guard.js +30 -0
  34. package/vendor/panel-server/src/routes/health.js +22 -0
  35. package/vendor/panel-server/src/routes/management/certs.js +225 -0
  36. package/vendor/panel-server/src/routes/management/logs.js +132 -0
  37. package/vendor/panel-server/src/routes/management/services.js +51 -0
  38. package/vendor/panel-server/src/routes/management/sites.js +448 -0
  39. package/vendor/panel-server/src/routes/management/system.js +12 -0
  40. package/vendor/panel-server/src/routes/management/tunnels.js +225 -0
  41. package/vendor/panel-server/src/routes/management/users.js +237 -0
  42. package/vendor/panel-server/src/routes/management.js +20 -0
  43. package/vendor/panel-server/src/routes/onboarding/dns.js +73 -0
  44. package/vendor/panel-server/src/routes/onboarding/domain.js +35 -0
  45. package/vendor/panel-server/src/routes/onboarding/index.js +18 -0
  46. package/vendor/panel-server/src/routes/onboarding/provision.js +291 -0
  47. package/vendor/panel-server/src/routes/onboarding/status.js +12 -0
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from '../src/index.js';
4
+
5
+ try {
6
+ await main();
7
+ } catch (error) {
8
+ console.error('\n');
9
+ console.error(' Portlama installation failed.');
10
+ console.error(` Error: ${error.message}`);
11
+ console.error('\n');
12
+ console.error(' You can safely re-run this installer to retry.');
13
+ console.error('\n');
14
+ process.exit(1);
15
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@lamalibre/create-portlama",
3
+ "version": "1.0.0",
4
+ "description": "One-command setup for secure reverse tunnels with a management dashboard",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Code Lama Software",
8
+ "bin": {
9
+ "create-portlama": "bin/create-portlama.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "src",
14
+ "scripts",
15
+ "vendor"
16
+ ],
17
+ "scripts": {
18
+ "lint": "eslint .",
19
+ "prepublishOnly": "npm run lint && node scripts/bundle-vendor.js"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/codelama/portlama.git",
24
+ "directory": "packages/create-portlama"
25
+ },
26
+ "homepage": "https://github.com/codelama/portlama#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/codelama/portlama/issues"
29
+ },
30
+ "keywords": [
31
+ "tunnel",
32
+ "reverse-tunnel",
33
+ "chisel",
34
+ "nginx",
35
+ "management",
36
+ "dashboard",
37
+ "vps",
38
+ "self-hosted",
39
+ "authelia",
40
+ "mtls"
41
+ ],
42
+ "dependencies": {
43
+ "chalk": "^5.3.0",
44
+ "execa": "^9.0.0",
45
+ "listr2": "^8.0.0"
46
+ },
47
+ "engines": {
48
+ "node": ">=20.0.0"
49
+ }
50
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Bundles sibling monorepo packages into vendor/ so that create-portlama
3
+ * works standalone when installed via npx (outside the monorepo).
4
+ *
5
+ * Run automatically via the "prepublishOnly" npm script.
6
+ */
7
+
8
+ import { cp, mkdir, rm } from 'node:fs/promises';
9
+ import { existsSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const thisDir = dirname(fileURLToPath(import.meta.url));
14
+ const packageRoot = join(thisDir, '..');
15
+ const monorepoRoot = join(packageRoot, '..', '..');
16
+ const vendorDir = join(packageRoot, 'vendor');
17
+
18
+ async function main() {
19
+ // Clean vendor/ if it exists, then recreate
20
+ await rm(vendorDir, { recursive: true, force: true });
21
+ await mkdir(vendorDir, { recursive: true });
22
+
23
+ // --- panel-server: package.json + src/ ---
24
+ const serverSrc = join(monorepoRoot, 'packages', 'panel-server');
25
+ if (!existsSync(serverSrc)) {
26
+ throw new Error(
27
+ `panel-server not found at ${serverSrc}. Run from the monorepo root.`,
28
+ );
29
+ }
30
+
31
+ const serverDest = join(vendorDir, 'panel-server');
32
+ await mkdir(serverDest, { recursive: true });
33
+ await cp(join(serverSrc, 'package.json'), join(serverDest, 'package.json'));
34
+ await cp(join(serverSrc, 'src'), join(serverDest, 'src'), {
35
+ recursive: true,
36
+ });
37
+
38
+ console.log('Bundled vendor/panel-server (package.json + src/)');
39
+
40
+ // --- panel-client: dist/ (pre-built assets) ---
41
+ const clientDist = join(monorepoRoot, 'packages', 'panel-client', 'dist');
42
+ if (!existsSync(clientDist)) {
43
+ throw new Error(
44
+ `panel-client/dist/ not found at ${clientDist}. Run "npm run build" first.`,
45
+ );
46
+ }
47
+
48
+ const clientDest = join(vendorDir, 'panel-client');
49
+ await mkdir(join(clientDest, 'dist'), { recursive: true });
50
+ await cp(clientDist, join(clientDest, 'dist'), { recursive: true });
51
+
52
+ console.log('Bundled vendor/panel-client (dist/)');
53
+
54
+ console.log('Vendor bundling complete.');
55
+ }
56
+
57
+ main().catch((err) => {
58
+ console.error(err.message);
59
+ process.exit(1);
60
+ });
package/src/index.js ADDED
@@ -0,0 +1,484 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { Listr } from 'listr2';
5
+ import chalk from 'chalk';
6
+ import { execa } from 'execa';
7
+ import { detectOS, detectIP, checkRoot } from './lib/env.js';
8
+ import { printSummary } from './lib/summary.js';
9
+ import { hardenTasks } from './tasks/harden.js';
10
+ import { nodeTasks } from './tasks/node.js';
11
+ import { mtlsTasks } from './tasks/mtls.js';
12
+ import { nginxTasks } from './tasks/nginx.js';
13
+ import { panelTasks } from './tasks/panel.js';
14
+
15
+ /**
16
+ * Parse minimal CLI flags from process.argv.
17
+ * @returns {{ skipHarden: boolean, uninstall: boolean, dev: boolean, help: boolean, yes: boolean }}
18
+ */
19
+ function parseFlags() {
20
+ const args = process.argv.slice(2);
21
+ return {
22
+ skipHarden: args.includes('--skip-harden'),
23
+ uninstall: args.includes('--uninstall'),
24
+ dev: args.includes('--dev'),
25
+ help: args.includes('--help') || args.includes('-h'),
26
+ yes: args.includes('--yes') || args.includes('-y'),
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Print help message describing Portlama and what the installer does,
32
+ * then exit with code 0.
33
+ */
34
+ function printHelp() {
35
+ const b = chalk.bold;
36
+ const c = chalk.cyan;
37
+ const y = chalk.yellow;
38
+ const d = chalk.dim;
39
+
40
+ console.log(`
41
+ ${b('Portlama')} — A self-hosted secure tunneling platform
42
+
43
+ ${b('DESCRIPTION')}
44
+
45
+ Portlama exposes web apps running behind a firewall through a VPS
46
+ via WebSocket-over-HTTPS tunnels. This installer provisions a fresh
47
+ Ubuntu 24.04 droplet with nginx, mTLS client certificates, and a
48
+ browser-based management panel — all in a single command.
49
+
50
+ ${b('USAGE')}
51
+
52
+ ${c('npx create-portlama')} ${d('[flags]')}
53
+
54
+ ${b('SYSTEM MODIFICATIONS')}
55
+
56
+ This installer makes the following changes to the machine:
57
+
58
+ ${y('Swap & Memory')}
59
+ • Creates a 1GB swap file
60
+
61
+ ${y('Firewall & Security')}
62
+ • Resets UFW firewall (allows only ports 22, 443, 9292)
63
+ • Installs fail2ban with SSH and nginx jails
64
+ • Hardens SSH (disables password authentication)
65
+
66
+ ${y('Packages')}
67
+ • Installs Node.js 20, nginx, certbot
68
+
69
+ ${y('Certificates')}
70
+ • Generates mTLS CA, server, and client certificates
71
+ • Creates a PKCS12 (.p12) bundle for browser-based access
72
+
73
+ ${y('Users & Services')}
74
+ • Creates ${c('portlama')} system user
75
+ • Creates systemd service ${c('portlama-panel')}
76
+ • Deploys panel server + client to ${c('/opt/portlama/')}
77
+
78
+ ${y('Directories')}
79
+ • ${c('/etc/portlama/')} — configuration and PKI certificates
80
+ • ${c('/opt/portlama/')} — panel server and client files
81
+ • ${c('/var/www/portlama/')} — static web assets
82
+
83
+ ${b('REQUIREMENTS')}
84
+
85
+ • Ubuntu 24.04
86
+ • Root access
87
+ • Public IP address (unless --dev is used)
88
+
89
+ ${b('FLAGS')}
90
+
91
+ ${c('--help')}, ${c('-h')} Show this help message and exit
92
+ ${c('--yes')}, ${c('-y')} Skip the confirmation prompt
93
+ ${c('--skip-harden')} Skip OS hardening (swap, UFW, fail2ban, SSH)
94
+ ${c('--dev')} Allow private/non-routable IP addresses
95
+ ${c('--uninstall')} Show manual removal guide for Portlama
96
+ `);
97
+ process.exit(0);
98
+ }
99
+
100
+ /**
101
+ * Print detailed uninstall guide listing all components installed by Portlama,
102
+ * then exit with code 0.
103
+ */
104
+ function printUninstallGuide() {
105
+ const b = chalk.bold;
106
+ const c = chalk.cyan;
107
+ const y = chalk.yellow;
108
+ const d = chalk.dim;
109
+
110
+ console.log(`
111
+ ${b('Portlama — Manual Removal Guide')}
112
+
113
+ ${y('⚠ Automated uninstall is not yet implemented.')}
114
+ ${y(' Follow the steps below to fully remove Portlama from this machine.')}
115
+
116
+ ${b('1. Stop and disable services')}
117
+
118
+ ${c('sudo systemctl stop portlama-panel')}
119
+ ${c('sudo systemctl disable portlama-panel')}
120
+ ${c('sudo rm /etc/systemd/system/portlama-panel.service')}
121
+ ${c('sudo systemctl daemon-reload')}
122
+
123
+ ${b('2. Remove nginx configuration')}
124
+
125
+ ${c('sudo rm /etc/nginx/sites-enabled/portlama-*')}
126
+ ${c('sudo rm /etc/nginx/sites-available/portlama-*')}
127
+ ${c('sudo rm /etc/nginx/snippets/portlama-mtls.conf')}
128
+ ${c('sudo nginx -t && sudo systemctl reload nginx')}
129
+
130
+ ${b('3. Remove Portlama directories')}
131
+
132
+ ${c('sudo rm -rf /etc/portlama/')} ${d('# Configuration, PKI certificates, state')}
133
+ ${c('sudo rm -rf /opt/portlama/')} ${d('# Panel server and client files')}
134
+ ${c('sudo rm -rf /var/www/portlama/')} ${d('# Static site files')}
135
+
136
+ ${b('4. Remove portlama user')}
137
+
138
+ ${c('sudo userdel -r portlama')}
139
+
140
+ ${b('5. Remove sudoers rules')}
141
+
142
+ ${c('sudo rm /etc/sudoers.d/portlama')}
143
+
144
+ ${b('6. Remove fail2ban config (optional)')}
145
+
146
+ ${c('sudo rm /etc/fail2ban/jail.d/portlama.conf')}
147
+ ${c('sudo systemctl restart fail2ban')}
148
+
149
+ ${b('7. Revert SSH hardening (optional)')}
150
+
151
+ ${d('If a backup was created during install:')}
152
+ ${c('sudo cp /etc/ssh/sshd_config.pre-portlama /etc/ssh/sshd_config')}
153
+ ${c('sudo sshd -t && sudo systemctl restart sshd')}
154
+
155
+ ${b('8. Revert firewall changes (optional)')}
156
+
157
+ ${d('Remove Portlama-specific UFW rules:')}
158
+ ${c('sudo ufw delete allow 9292/tcp')}
159
+
160
+ ${b('9. Remove swap file (optional)')}
161
+
162
+ ${d('Only if Portlama created it:')}
163
+ ${c('sudo swapoff /swapfile')}
164
+ ${c('sudo rm /swapfile')}
165
+ ${d('Remove the /swapfile line from /etc/fstab')}
166
+
167
+ ${b('10. Remove Let\'s Encrypt certificates (optional)')}
168
+
169
+ ${d('List Portlama-issued certs:')}
170
+ ${c('sudo certbot certificates')}
171
+ ${d('Delete specific ones:')}
172
+ ${c('sudo certbot delete --cert-name <domain>')}
173
+
174
+ ${d('Note: Steps 6-10 are optional. They revert OS hardening changes that')}
175
+ ${d('may be useful to keep even after removing Portlama.')}
176
+ `);
177
+ process.exit(0);
178
+ }
179
+
180
+ /**
181
+ * Detect existing system state to surface warnings before installation.
182
+ * All checks are wrapped in try/catch — detection failures never block the installer.
183
+ * @returns {Promise<{
184
+ * portlamaExists: boolean,
185
+ * onboardingStatus: string | null,
186
+ * existingNginxSites: string[],
187
+ * port3100InUse: boolean,
188
+ * ufwActive: boolean,
189
+ * ufwRuleCount: number,
190
+ * }>}
191
+ */
192
+ async function detectExistingState() {
193
+ const state = {
194
+ portlamaExists: false,
195
+ onboardingStatus: null,
196
+ existingNginxSites: [],
197
+ port3100InUse: false,
198
+ ufwActive: false,
199
+ ufwRuleCount: 0,
200
+ };
201
+
202
+ // 1. Check for existing Portlama installation
203
+ try {
204
+ if (existsSync('/etc/portlama/panel.json')) {
205
+ state.portlamaExists = true;
206
+ const raw = await readFile('/etc/portlama/panel.json', 'utf8');
207
+ const config = JSON.parse(raw);
208
+ state.onboardingStatus = config.onboardingStatus || 'FRESH';
209
+ }
210
+ } catch {
211
+ // If panel.json exists but is unreadable/invalid, still flag it
212
+ if (existsSync('/etc/portlama/panel.json')) {
213
+ state.portlamaExists = true;
214
+ state.onboardingStatus = 'UNKNOWN';
215
+ }
216
+ }
217
+
218
+ // 2. Check for existing nginx sites (non-portlama, non-default)
219
+ try {
220
+ const entries = await readdir('/etc/nginx/sites-enabled');
221
+ state.existingNginxSites = entries.filter(
222
+ (name) => !name.startsWith('portlama-') && name !== 'default',
223
+ );
224
+ } catch {
225
+ // nginx not installed or sites-enabled doesn't exist — nothing to report
226
+ }
227
+
228
+ // 3. Check if port 3100 is in use
229
+ try {
230
+ const { stdout } = await execa('ss', ['-tlnp', 'sport', '=', ':3100']);
231
+ // ss always prints a header line; if there are more lines, the port is in use
232
+ const lines = stdout.trim().split('\n');
233
+ state.port3100InUse = lines.length > 1;
234
+ } catch {
235
+ // ss not available or command failed — assume port is free
236
+ }
237
+
238
+ // 4. Check UFW status
239
+ try {
240
+ const { stdout } = await execa('ufw', ['status']);
241
+ state.ufwActive = stdout.includes('Status: active');
242
+ if (state.ufwActive) {
243
+ // Count rule lines: each rule line starts with a port number or an action keyword
244
+ // Skip the header lines (Status, blank lines, header dividers)
245
+ const lines = stdout.split('\n');
246
+ let ruleCount = 0;
247
+ let pastHeader = false;
248
+ for (const line of lines) {
249
+ if (line.startsWith('--')) {
250
+ pastHeader = true;
251
+ continue;
252
+ }
253
+ if (pastHeader && line.trim().length > 0) {
254
+ ruleCount++;
255
+ }
256
+ }
257
+ state.ufwRuleCount = ruleCount;
258
+ }
259
+ } catch {
260
+ // ufw not installed — nothing to report
261
+ }
262
+
263
+ return state;
264
+ }
265
+
266
+ /**
267
+ * Print a confirmation banner and optionally wait for user input.
268
+ * @param {{ yes: boolean }} flags - Parsed CLI flags.
269
+ * @param {{ portlamaExists: boolean, onboardingStatus: string | null, existingNginxSites: string[], port3100InUse: boolean, ufwActive: boolean, ufwRuleCount: number }} existingState - Detection results.
270
+ */
271
+ async function confirmInstallation(flags, existingState) {
272
+ const banner = `
273
+ ${chalk.cyan.bold('┌─────────────────────────────────────────────────────────────┐')}
274
+ ${chalk.cyan.bold('│')} ${chalk.white.bold('Portlama Installer')} ${chalk.cyan.bold('│')}
275
+ ${chalk.cyan.bold('├─────────────────────────────────────────────────────────────┤')}
276
+ ${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
277
+ ${chalk.cyan.bold('│')} This will install Portlama on this machine. ${chalk.cyan.bold('│')}
278
+ ${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
279
+ ${chalk.cyan.bold('│')} The following changes will be made: ${chalk.cyan.bold('│')}
280
+ ${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
281
+ ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Reset UFW firewall (allow ports 22, 443, 9292 only) ${chalk.cyan.bold('│')}
282
+ ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Harden SSH (disable password authentication) ${chalk.cyan.bold('│')}
283
+ ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Install fail2ban, Node.js 20, nginx, certbot ${chalk.cyan.bold('│')}
284
+ ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Generate mTLS certificates for browser access ${chalk.cyan.bold('│')}
285
+ ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Create portlama user and systemd service ${chalk.cyan.bold('│')}
286
+ ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Deploy panel to /opt/portlama/ ${chalk.cyan.bold('│')}
287
+ ${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
288
+ ${chalk.cyan.bold('│')} ${chalk.dim('Designed for a fresh Ubuntu 24.04 droplet.')} ${chalk.cyan.bold('│')}
289
+ ${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
290
+ ${chalk.cyan.bold('└─────────────────────────────────────────────────────────────┘')}`;
291
+
292
+ console.log(banner);
293
+
294
+ // Display detection warnings below the banner
295
+ const warnings = [];
296
+
297
+ if (existingState.portlamaExists) {
298
+ const status = existingState.onboardingStatus || 'UNKNOWN';
299
+ warnings.push(
300
+ `An existing Portlama installation was detected (onboarding: ${status}). Re-running will update the installation but preserve your configuration.`,
301
+ );
302
+ }
303
+
304
+ if (existingState.existingNginxSites.length > 0) {
305
+ warnings.push(
306
+ `Existing nginx sites will be affected: ${existingState.existingNginxSites.join(', ')}`,
307
+ );
308
+ }
309
+
310
+ if (existingState.port3100InUse) {
311
+ warnings.push('Port 3100 is currently in use. The panel may fail to start.');
312
+ }
313
+
314
+ if (existingState.ufwActive && existingState.ufwRuleCount > 0) {
315
+ warnings.push(
316
+ `Existing UFW firewall rules (${existingState.ufwRuleCount} rules) will be reset.`,
317
+ );
318
+ }
319
+
320
+ if (warnings.length > 0) {
321
+ console.log('');
322
+ for (const warning of warnings) {
323
+ console.log(` ${chalk.yellow('!')} ${chalk.yellow(warning)}`);
324
+ }
325
+ }
326
+
327
+ if (flags.yes) {
328
+ console.log(`\n ${chalk.dim('Skipping confirmation (--yes)')}\n`);
329
+ return;
330
+ }
331
+
332
+ const rl = createInterface({
333
+ input: process.stdin,
334
+ output: process.stdout,
335
+ });
336
+
337
+ await new Promise((resolve) => {
338
+ rl.question(
339
+ `\n ${chalk.white.bold('Press Enter to continue or Ctrl+C to abort...')} `,
340
+ () => {
341
+ rl.close();
342
+ resolve();
343
+ },
344
+ );
345
+ });
346
+ }
347
+
348
+ /**
349
+ * Main installer orchestrator. Creates a shared context, runs all installation
350
+ * tasks through Listr2, and prints a summary on completion.
351
+ */
352
+ export async function main() {
353
+ const flags = parseFlags();
354
+
355
+ if (flags.help) {
356
+ printHelp();
357
+ }
358
+
359
+ if (flags.uninstall) {
360
+ printUninstallGuide();
361
+ }
362
+
363
+ const ctx = {
364
+ ip: null,
365
+ osRelease: null,
366
+ skipHarden: flags.skipHarden,
367
+ nodeAlreadyInstalled: false,
368
+ nodeVersion: null,
369
+ npmVersion: null,
370
+ p12Password: null,
371
+ pkiDir: '/etc/portlama/pki',
372
+ configDir: '/etc/portlama',
373
+ installDir: '/opt/portlama',
374
+ };
375
+
376
+ // Phase 1: Environment checks
377
+ const envTasks = new Listr(
378
+ [
379
+ {
380
+ title: 'Checking environment',
381
+ task: async (_ctx, task) => {
382
+ return task.newListr([
383
+ {
384
+ title: 'Verifying root access',
385
+ task: async () => {
386
+ checkRoot();
387
+ },
388
+ },
389
+ {
390
+ title: 'Detecting operating system',
391
+ task: async (_ctx, subtask) => {
392
+ ctx.osRelease = await detectOS();
393
+ subtask.output = ctx.osRelease.prettyName;
394
+ },
395
+ rendererOptions: { persistentOutput: true },
396
+ },
397
+ {
398
+ title: 'Detecting IP address',
399
+ task: async (_ctx, subtask) => {
400
+ ctx.ip = await detectIP({ allowPrivate: flags.dev });
401
+ if (flags.dev) {
402
+ subtask.output = `${ctx.ip} (dev mode — private IP accepted)`;
403
+ } else {
404
+ subtask.output = ctx.ip;
405
+ }
406
+ },
407
+ rendererOptions: { persistentOutput: true },
408
+ },
409
+ ]);
410
+ },
411
+ },
412
+ ],
413
+ {
414
+ renderer: 'default',
415
+ rendererOptions: { collapseSubtasks: false },
416
+ exitOnError: true,
417
+ },
418
+ );
419
+
420
+ // Phase 2: Installation tasks
421
+ const installTasks = new Listr(
422
+ [
423
+ {
424
+ title: 'Hardening operating system',
425
+ task: (_ctx, task) => hardenTasks(ctx, task),
426
+ },
427
+ {
428
+ title: 'Installing Node.js 20',
429
+ task: (_ctx, task) => nodeTasks(ctx, task),
430
+ },
431
+ {
432
+ title: 'Generating mTLS certificates',
433
+ task: (_ctx, task) => mtlsTasks(ctx, task),
434
+ },
435
+ {
436
+ title: 'Configuring nginx',
437
+ task: (_ctx, task) => nginxTasks(ctx, task),
438
+ },
439
+ {
440
+ title: 'Deploying Portlama panel',
441
+ task: (_ctx, task) => panelTasks(ctx, task),
442
+ },
443
+ {
444
+ title: 'Installation complete',
445
+ task: async () => {
446
+ // Summary will be printed after Listr finishes
447
+ },
448
+ },
449
+ ],
450
+ {
451
+ renderer: 'default',
452
+ rendererOptions: { collapseSubtasks: false },
453
+ exitOnError: true,
454
+ },
455
+ );
456
+
457
+ try {
458
+ // Run environment checks first
459
+ await envTasks.run();
460
+
461
+ // Detect existing system state for the confirmation banner
462
+ const existingState = await detectExistingState();
463
+
464
+ // Show confirmation banner and wait for user input
465
+ await confirmInstallation(flags, existingState);
466
+
467
+ // Run installation tasks
468
+ await installTasks.run();
469
+ } catch (error) {
470
+ console.error('\n');
471
+ console.error(' ┌─────────────────────────────────────────────┐');
472
+ console.error(' │ Portlama installation failed. │');
473
+ console.error(` │ ${(error.message || 'Unknown error').slice(0, 43).padEnd(43)} │`);
474
+ console.error(' │ │');
475
+ console.error(' │ You can safely re-run this installer │');
476
+ console.error(' │ to retry. │');
477
+ console.error(' └─────────────────────────────────────────────┘');
478
+ console.error('\n');
479
+ process.exit(1);
480
+ }
481
+
482
+ // Print summary after Listr2 finishes rendering
483
+ await printSummary(ctx);
484
+ }