@lamalibre/create-portlama 1.0.2 → 1.0.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/create-portlama",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "One-command setup for secure reverse tunnels with a management dashboard",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.js CHANGED
@@ -11,6 +11,7 @@ import { nodeTasks } from './tasks/node.js';
11
11
  import { mtlsTasks } from './tasks/mtls.js';
12
12
  import { nginxTasks } from './tasks/nginx.js';
13
13
  import { panelTasks } from './tasks/panel.js';
14
+ import { redeployTasks } from './tasks/redeploy.js';
14
15
 
15
16
  /**
16
17
  * Parse minimal CLI flags from process.argv.
@@ -24,6 +25,7 @@ function parseFlags() {
24
25
  dev: args.includes('--dev'),
25
26
  help: args.includes('--help') || args.includes('-h'),
26
27
  yes: args.includes('--yes') || args.includes('-y'),
28
+ forceFull: args.includes('--force-full'),
27
29
  };
28
30
  }
29
31
 
@@ -92,6 +94,7 @@ ${b('FLAGS')}
92
94
  ${c('--yes')}, ${c('-y')} Skip the confirmation prompt
93
95
  ${c('--skip-harden')} Skip OS hardening (swap, UFW, fail2ban, SSH)
94
96
  ${c('--dev')} Allow private/non-routable IP addresses
97
+ ${c('--force-full')} Run full installation even on existing installs
95
98
  ${c('--uninstall')} Show manual removal guide for Portlama
96
99
  `);
97
100
  process.exit(0);
@@ -266,10 +269,33 @@ async function detectExistingState() {
266
269
  /**
267
270
  * Print a confirmation banner and optionally wait for user input.
268
271
  * @param {{ yes: boolean }} flags - Parsed CLI flags.
272
+ * @param {boolean} isRedeploy - Whether we are in redeploy mode.
269
273
  * @param {{ portlamaExists: boolean, onboardingStatus: string | null, existingNginxSites: string[], port3100InUse: boolean, ufwActive: boolean, ufwRuleCount: number }} existingState - Detection results.
270
274
  */
271
- async function confirmInstallation(flags, existingState) {
272
- const banner = `
275
+ async function confirmInstallation(flags, isRedeploy, existingState) {
276
+ let banner;
277
+
278
+ if (isRedeploy) {
279
+ banner = `
280
+ ${chalk.cyan.bold('┌─────────────────────────────────────────────────────────────┐')}
281
+ ${chalk.cyan.bold('│')} ${chalk.white.bold('Portlama Panel Update')} ${chalk.cyan.bold('│')}
282
+ ${chalk.cyan.bold('├─────────────────────────────────────────────────────────────┤')}
283
+ ${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
284
+ ${chalk.cyan.bold('│')} Existing installation detected. Updating panel only. ${chalk.cyan.bold('│')}
285
+ ${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
286
+ ${chalk.cyan.bold('│')} The following changes will be made: ${chalk.cyan.bold('│')}
287
+ ${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
288
+ ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Stop panel service ${chalk.cyan.bold('│')}
289
+ ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Update panel-server and panel-client files ${chalk.cyan.bold('│')}
290
+ ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Install updated dependencies ${chalk.cyan.bold('│')}
291
+ ${chalk.cyan.bold('│')} ${chalk.yellow('•')} Restart panel service ${chalk.cyan.bold('│')}
292
+ ${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
293
+ ${chalk.cyan.bold('│')} ${chalk.dim('OS, nginx, mTLS certs, and firewall are untouched.')} ${chalk.cyan.bold('│')}
294
+ ${chalk.cyan.bold('│')} ${chalk.dim('Use --force-full to run the complete installer.')} ${chalk.cyan.bold('│')}
295
+ ${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
296
+ ${chalk.cyan.bold('└─────────────────────────────────────────────────────────────┘')}`;
297
+ } else {
298
+ banner = `
273
299
  ${chalk.cyan.bold('┌─────────────────────────────────────────────────────────────┐')}
274
300
  ${chalk.cyan.bold('│')} ${chalk.white.bold('Portlama Installer')} ${chalk.cyan.bold('│')}
275
301
  ${chalk.cyan.bold('├─────────────────────────────────────────────────────────────┤')}
@@ -288,39 +314,42 @@ ${chalk.cyan.bold('│')}
288
314
  ${chalk.cyan.bold('│')} ${chalk.dim('Designed for a fresh Ubuntu 24.04 droplet.')} ${chalk.cyan.bold('│')}
289
315
  ${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
290
316
  ${chalk.cyan.bold('└─────────────────────────────────────────────────────────────┘')}`;
317
+ }
291
318
 
292
319
  console.log(banner);
293
320
 
294
- // Display detection warnings below the banner
295
- const warnings = [];
321
+ // Display detection warnings below the banner (only for full install)
322
+ if (!isRedeploy) {
323
+ const warnings = [];
296
324
 
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
- }
325
+ if (existingState.portlamaExists) {
326
+ const status = existingState.onboardingStatus || 'UNKNOWN';
327
+ warnings.push(
328
+ `An existing Portlama installation was detected (onboarding: ${status}). Re-running will update the installation but preserve your configuration.`,
329
+ );
330
+ }
303
331
 
304
- if (existingState.existingNginxSites.length > 0) {
305
- warnings.push(
306
- `Existing nginx sites will be affected: ${existingState.existingNginxSites.join(', ')}`,
307
- );
308
- }
332
+ if (existingState.existingNginxSites.length > 0) {
333
+ warnings.push(
334
+ `Existing nginx sites will be affected: ${existingState.existingNginxSites.join(', ')}`,
335
+ );
336
+ }
309
337
 
310
- if (existingState.port3100InUse) {
311
- warnings.push('Port 3100 is currently in use. The panel may fail to start.');
312
- }
338
+ if (existingState.port3100InUse) {
339
+ warnings.push('Port 3100 is currently in use. The panel may fail to start.');
340
+ }
313
341
 
314
- if (existingState.ufwActive && existingState.ufwRuleCount > 0) {
315
- warnings.push(
316
- `Existing UFW firewall rules (${existingState.ufwRuleCount} rules) will be reset.`,
317
- );
318
- }
342
+ if (existingState.ufwActive && existingState.ufwRuleCount > 0) {
343
+ warnings.push(
344
+ `Existing UFW firewall rules (${existingState.ufwRuleCount} rules) will be reset.`,
345
+ );
346
+ }
319
347
 
320
- if (warnings.length > 0) {
321
- console.log('');
322
- for (const warning of warnings) {
323
- console.log(` ${chalk.yellow('!')} ${chalk.yellow(warning)}`);
348
+ if (warnings.length > 0) {
349
+ console.log('');
350
+ for (const warning of warnings) {
351
+ console.log(` ${chalk.yellow('!')} ${chalk.yellow(warning)}`);
352
+ }
324
353
  }
325
354
  }
326
355
 
@@ -461,11 +490,32 @@ export async function main() {
461
490
  // Detect existing system state for the confirmation banner
462
491
  const existingState = await detectExistingState();
463
492
 
464
- // Show confirmation banner and wait for user input
465
- await confirmInstallation(flags, existingState);
493
+ // Determine mode: redeploy (fast update) or full install
494
+ const isRedeploy = existingState.portlamaExists && !flags.forceFull;
466
495
 
467
- // Run installation tasks
468
- await installTasks.run();
496
+ // Show confirmation banner and wait for user input
497
+ await confirmInstallation(flags, isRedeploy, existingState);
498
+
499
+ if (isRedeploy) {
500
+ // Fast path: only update panel files and restart
501
+ const redeployTaskList = new Listr(
502
+ [
503
+ {
504
+ title: 'Redeploying Portlama panel',
505
+ task: (_ctx, task) => redeployTasks(ctx, task),
506
+ },
507
+ ],
508
+ {
509
+ renderer: 'default',
510
+ rendererOptions: { collapseSubtasks: false },
511
+ exitOnError: true,
512
+ },
513
+ );
514
+ await redeployTaskList.run();
515
+ } else {
516
+ // Full install path
517
+ await installTasks.run();
518
+ }
469
519
  } catch (error) {
470
520
  console.error('\n');
471
521
  console.error(' ┌─────────────────────────────────────────────┐');
@@ -1,21 +1,88 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import chalk from 'chalk';
3
3
 
4
+ // eslint-disable-next-line no-control-regex
5
+ const ansiPattern = new RegExp('\x1b\\[[0-9;]*m', 'g');
6
+ const stripAnsi = (str) => str.replace(ansiPattern, '');
7
+
8
+ /**
9
+ * Print an array of lines inside a bordered box with a title separator
10
+ * after the first 3 lines.
11
+ * @param {string[]} lines
12
+ */
13
+ function printBox(lines) {
14
+ const maxLineWidth = Math.max(...lines.map((l) => stripAnsi(l).length));
15
+ const boxInnerWidth = Math.max(maxLineWidth + 2, 62);
16
+
17
+ const border = chalk.cyan;
18
+ const topBorder = border(`╔${'═'.repeat(boxInnerWidth)}╗`);
19
+ const bottomBorder = border(`╚${'═'.repeat(boxInnerWidth)}╝`);
20
+ const midBorder = border(`╠${'═'.repeat(boxInnerWidth)}╣`);
21
+
22
+ const padLine = (line) => {
23
+ const visibleLength = stripAnsi(line).length;
24
+ const padding = boxInnerWidth - visibleLength;
25
+ return `${border('║')}${line}${' '.repeat(Math.max(0, padding))}${border('║')}`;
26
+ };
27
+
28
+ console.log('');
29
+ console.log(topBorder);
30
+
31
+ // Title section (first 3 lines: empty, title, empty)
32
+ for (let i = 0; i < 3; i++) {
33
+ console.log(padLine(lines[i]));
34
+ }
35
+
36
+ // Separator
37
+ console.log(midBorder);
38
+
39
+ // Remaining content lines
40
+ for (let i = 3; i < lines.length; i++) {
41
+ console.log(padLine(lines[i]));
42
+ }
43
+
44
+ console.log(bottomBorder);
45
+ console.log('');
46
+ }
47
+
4
48
  /**
5
49
  * Print the post-install summary box with all the information the user needs
6
50
  * to download their client certificate, import it, and access the panel.
7
51
  *
8
- * @param {{ ip: string, pkiDir: string }} ctx - Shared installer context.
52
+ * In redeploy mode, prints a shorter summary since the user already has
53
+ * their certificate and knows how to access the panel.
54
+ *
55
+ * @param {{ ip: string, pkiDir: string, installedVersion?: string, vendorVersion?: string }} ctx - Shared installer context.
9
56
  */
10
57
  export async function printSummary(ctx) {
58
+ const ip = ctx.ip;
59
+ const panelUrl = `https://${ip}:9292`;
60
+
61
+ // Redeploy mode: short summary
62
+ if (ctx.installedVersion !== undefined) {
63
+ const lines = [
64
+ '',
65
+ ` ${chalk.green.bold('Panel updated successfully!')}`,
66
+ '',
67
+ ` ${chalk.white.bold('Version:')} ${chalk.dim(ctx.installedVersion || 'unknown')} ${chalk.white('→')} ${chalk.cyan(ctx.vendorVersion || 'unknown')}`,
68
+ '',
69
+ ` ${chalk.white.bold('Panel:')} ${chalk.cyan.underline(panelUrl)}`,
70
+ '',
71
+ ` ${chalk.dim('Your certificates, nginx config, and OS settings are unchanged.')}`,
72
+ ` ${chalk.dim('Refresh your browser to see the updated panel.')}`,
73
+ '',
74
+ ];
75
+
76
+ printBox(lines);
77
+ return;
78
+ }
79
+
80
+ // Full install mode: complete summary with certificate instructions
11
81
  const p12PasswordFile = `${ctx.pkiDir}/.p12-password`;
12
82
  const p12Password = (await readFile(p12PasswordFile, 'utf8')).trim();
13
83
 
14
- const ip = ctx.ip;
15
84
  const scpCmd = `scp root@${ip}:${ctx.pkiDir}/client.p12 .`;
16
- const panelUrl = `https://${ip}:9292`;
17
85
 
18
- // Build content lines (without border)
19
86
  const lines = [
20
87
  '',
21
88
  ` ${chalk.green.bold('Portlama installed successfully!')}`,
@@ -55,47 +122,5 @@ export async function printSummary(ctx) {
55
122
  '',
56
123
  ];
57
124
 
58
- // Calculate the box width based on the longest visible-content line.
59
- // We strip ANSI codes to measure actual character width.
60
- // eslint-disable-next-line no-control-regex
61
- const ansiPattern = new RegExp('\x1b\\[[0-9;]*m', 'g');
62
- const stripAnsi = (str) => str.replace(ansiPattern, '');
63
- const maxLineWidth = Math.max(...lines.map((l) => stripAnsi(l).length));
64
- const boxInnerWidth = Math.max(maxLineWidth + 2, 62);
65
-
66
- const border = chalk.cyan;
67
- const topBorder = border(
68
- `╔${'═'.repeat(boxInnerWidth)}╗`,
69
- );
70
- const bottomBorder = border(
71
- `╚${'═'.repeat(boxInnerWidth)}╝`,
72
- );
73
- const midBorder = border(
74
- `╠${'═'.repeat(boxInnerWidth)}╣`,
75
- );
76
-
77
- const padLine = (line) => {
78
- const visibleLength = stripAnsi(line).length;
79
- const padding = boxInnerWidth - visibleLength;
80
- return `${border('║')}${line}${' '.repeat(Math.max(0, padding))}${border('║')}`;
81
- };
82
-
83
- console.log('');
84
- console.log(topBorder);
85
-
86
- // Title section (first 3 lines: empty, title, empty)
87
- for (let i = 0; i < 3; i++) {
88
- console.log(padLine(lines[i]));
89
- }
90
-
91
- // Separator
92
- console.log(midBorder);
93
-
94
- // Remaining content lines
95
- for (let i = 3; i < lines.length; i++) {
96
- console.log(padLine(lines[i]));
97
- }
98
-
99
- console.log(bottomBorder);
100
- console.log('');
125
+ printBox(lines);
101
126
  }
@@ -239,10 +239,11 @@ StandardError=journal
239
239
  SyslogIdentifier=portlama-panel
240
240
 
241
241
  # Security hardening
242
- NoNewPrivileges=true
243
- ProtectSystem=strict
242
+ # Note: NoNewPrivileges is intentionally omitted — the panel needs sudo
243
+ # for provisioning (Chisel, Authelia, certbot, nginx, systemctl).
244
+ # Access is restricted via fine-grained sudoers rules in /etc/sudoers.d/portlama.
244
245
  ProtectHome=true
245
- ReadWritePaths=${configDir}
246
+ ReadWritePaths=${configDir} /var/www/portlama
246
247
  PrivateTmp=true
247
248
 
248
249
  [Install]
@@ -0,0 +1,236 @@
1
+ import { execa } from 'execa';
2
+ import { writeFile, readFile, cp, rm } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dirname, join } from 'node:path';
7
+
8
+ /**
9
+ * Read the installed panel-server's package.json version, or null if not found.
10
+ * @param {string} installDir
11
+ * @returns {Promise<string | null>}
12
+ */
13
+ async function getInstalledVersion(installDir) {
14
+ try {
15
+ const pkgPath = join(installDir, 'panel-server', 'package.json');
16
+ const raw = await readFile(pkgPath, 'utf8');
17
+ return JSON.parse(raw).version || null;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Read the vendor (new) panel-server's package.json version.
25
+ * @param {string} vendorDir
26
+ * @returns {Promise<string | null>}
27
+ */
28
+ async function getVendorVersion(vendorDir) {
29
+ try {
30
+ const pkgPath = join(vendorDir, 'panel-server', 'package.json');
31
+ const raw = await readFile(pkgPath, 'utf8');
32
+ return JSON.parse(raw).version || null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Panel redeployment subtasks. Only updates panel-server and panel-client files,
40
+ * runs npm install, merges config, and restarts the service. Does not touch
41
+ * OS hardening, mTLS certs, nginx, or any other system configuration.
42
+ *
43
+ * @param {object} ctx Shared installer context.
44
+ * @param {object} task Parent Listr2 task reference.
45
+ * @returns {import('listr2').ListrTask[]}
46
+ */
47
+ export function redeployTasks(ctx, task) {
48
+ const installDir = ctx.installDir;
49
+ const configDir = ctx.configDir;
50
+
51
+ const thisFile = fileURLToPath(import.meta.url);
52
+ const thisDir = dirname(thisFile);
53
+ const packageRoot = join(thisDir, '..', '..');
54
+ const vendorDir = join(packageRoot, 'vendor');
55
+
56
+ return task.newListr([
57
+ {
58
+ title: 'Checking versions',
59
+ task: async (_ctx, subtask) => {
60
+ const installed = await getInstalledVersion(installDir);
61
+ const vendor = await getVendorVersion(vendorDir);
62
+ ctx.installedVersion = installed;
63
+ ctx.vendorVersion = vendor;
64
+ subtask.output = `Installed: ${installed || 'unknown'} → New: ${vendor || 'unknown'}`;
65
+ },
66
+ rendererOptions: { persistentOutput: true },
67
+ },
68
+ {
69
+ title: 'Stopping panel service',
70
+ task: async (_ctx, subtask) => {
71
+ try {
72
+ const { stdout: status } = await execa('systemctl', [
73
+ 'is-active',
74
+ 'portlama-panel',
75
+ ]);
76
+ if (status.trim() === 'active') {
77
+ await execa('systemctl', ['stop', 'portlama-panel']);
78
+ subtask.output = 'Service stopped';
79
+ } else {
80
+ subtask.output = `Service was not running (${status.trim()})`;
81
+ }
82
+ } catch {
83
+ subtask.output = 'Service was not running';
84
+ }
85
+ },
86
+ rendererOptions: { persistentOutput: true },
87
+ },
88
+ {
89
+ title: 'Updating panel-server',
90
+ task: async (_ctx, subtask) => {
91
+ const serverSrc = join(vendorDir, 'panel-server');
92
+ const serverDest = join(installDir, 'panel-server');
93
+
94
+ if (!existsSync(serverSrc)) {
95
+ throw new Error(
96
+ `Panel server source not found at ${serverSrc}. Ensure the package is intact.`,
97
+ );
98
+ }
99
+
100
+ subtask.output = 'Copying panel-server files...';
101
+ await cp(join(serverSrc, 'package.json'), join(serverDest, 'package.json'));
102
+ await cp(join(serverSrc, 'src'), join(serverDest, 'src'), {
103
+ recursive: true,
104
+ });
105
+
106
+ subtask.output = 'Installing production dependencies...';
107
+ try {
108
+ await execa('npm', ['install', '--production'], {
109
+ cwd: serverDest,
110
+ });
111
+ } catch (err) {
112
+ throw new Error(
113
+ `Failed to install panel-server dependencies.\n${err.stderr || err.message}`,
114
+ );
115
+ }
116
+
117
+ await execa('chown', ['-R', 'portlama:portlama', serverDest]);
118
+ subtask.output = 'Panel server updated';
119
+ },
120
+ rendererOptions: { persistentOutput: true },
121
+ },
122
+ {
123
+ title: 'Updating panel-client',
124
+ task: async (_ctx, subtask) => {
125
+ const clientSrc = join(vendorDir, 'panel-client');
126
+ const clientDest = join(installDir, 'panel-client');
127
+
128
+ if (!existsSync(clientSrc)) {
129
+ throw new Error(
130
+ `Panel client source not found at ${clientSrc}. Ensure the package is intact.`,
131
+ );
132
+ }
133
+
134
+ const prebuiltDist = join(clientSrc, 'dist');
135
+ if (!existsSync(join(prebuiltDist, 'index.html'))) {
136
+ throw new Error(
137
+ 'Pre-built panel-client dist not found. The package may be corrupted.',
138
+ );
139
+ }
140
+
141
+ subtask.output = 'Copying panel-client dist...';
142
+ const distDest = join(clientDest, 'dist');
143
+ await rm(distDest, { recursive: true, force: true });
144
+ await cp(prebuiltDist, distDest, { recursive: true });
145
+
146
+ await execa('chown', ['-R', 'portlama:portlama', clientDest]);
147
+ subtask.output = 'Panel client updated';
148
+ },
149
+ rendererOptions: { persistentOutput: true },
150
+ },
151
+ {
152
+ title: 'Updating panel configuration',
153
+ task: async (_ctx, subtask) => {
154
+ const configPath = join(configDir, 'panel.json');
155
+
156
+ if (!existsSync(configPath)) {
157
+ subtask.output = 'No existing config — skipping (full install needed)';
158
+ return;
159
+ }
160
+
161
+ subtask.output = 'Merging configuration...';
162
+ const existing = JSON.parse(await readFile(configPath, 'utf8'));
163
+
164
+ const config = {
165
+ ...existing,
166
+ ip: ctx.ip,
167
+ dataDir: configDir,
168
+ staticDir: join(installDir, 'panel-client', 'dist'),
169
+ };
170
+
171
+ await writeFile(
172
+ configPath,
173
+ JSON.stringify(config, null, 2) + '\n',
174
+ { mode: 0o640 },
175
+ );
176
+ await execa('chown', ['portlama:portlama', configPath]);
177
+
178
+ subtask.output = 'Configuration updated';
179
+ },
180
+ rendererOptions: { persistentOutput: true },
181
+ },
182
+ {
183
+ title: 'Reloading systemd and restarting panel',
184
+ task: async (_ctx, subtask) => {
185
+ subtask.output = 'Reloading systemd daemon...';
186
+ await execa('systemctl', ['daemon-reload']);
187
+
188
+ subtask.output = 'Starting portlama-panel...';
189
+ await execa('systemctl', ['start', 'portlama-panel']);
190
+
191
+ subtask.output = 'Waiting for service to start...';
192
+ await sleep(3000);
193
+
194
+ const { stdout: status } = await execa('systemctl', [
195
+ 'is-active',
196
+ 'portlama-panel',
197
+ ]);
198
+ if (status.trim() !== 'active') {
199
+ const { stdout: logs } = await execa('journalctl', [
200
+ '-u',
201
+ 'portlama-panel',
202
+ '--no-pager',
203
+ '-n',
204
+ '20',
205
+ ]);
206
+ throw new Error(
207
+ `Panel service failed to start. Status: ${status.trim()}\nRecent logs:\n${logs}`,
208
+ );
209
+ }
210
+
211
+ subtask.output = 'Running health check...';
212
+ try {
213
+ const { stdout: healthResponse } = await execa('curl', [
214
+ '-s',
215
+ '--max-time',
216
+ '5',
217
+ 'http://127.0.0.1:3100/api/health',
218
+ ]);
219
+ subtask.output = `Panel running. Health: ${healthResponse}`;
220
+ } catch (error) {
221
+ const { stdout: logs } = await execa('journalctl', [
222
+ '-u',
223
+ 'portlama-panel',
224
+ '--no-pager',
225
+ '-n',
226
+ '20',
227
+ ]);
228
+ throw new Error(
229
+ `Panel health check failed.\nRecent logs:\n${logs}\n${error.message}`,
230
+ );
231
+ }
232
+ },
233
+ rendererOptions: { persistentOutput: true },
234
+ },
235
+ ]);
236
+ }