@ilanalimanjs/open_pack 2.2.4 → 2.4.3

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 (2) hide show
  1. package/bin/index.js +658 -37
  2. package/package.json +4 -2
package/bin/index.js CHANGED
@@ -1,93 +1,714 @@
1
1
  #!/usr/bin/env node
2
+ import os from 'node:os';
3
+ import net from 'node:net';
2
4
  import path from 'node:path';
3
5
  import process from 'node:process';
6
+ import { createRequire } from 'node:module';
7
+ import { parseArgs } from 'node:util';
8
+ import { spawn } from 'node:child_process';
9
+ import { randomBytes } from 'node:crypto';
10
+ import { setTimeout as sleep } from 'node:timers/promises';
4
11
  import { downloadTemplate } from 'giget';
12
+ import * as prompts from '@clack/prompts';
13
+ import fse from 'fs-extra';
5
14
  import chalk from 'chalk';
6
15
  import ora from 'ora';
7
16
 
17
+ const { version: CLI_VERSION } = createRequire(import.meta.url)('../package.json');
18
+
8
19
  const TEMPLATE_SOURCE = 'github:DevIlannn/open_pack/Source';
20
+ const REGISTRY_URL = 'https://registry.npmjs.org/@ilanalimanjs%2Fopen_pack/latest';
9
21
  const PACKAGE_COMMAND = 'npx @ilanalimanjs/open_pack@latest';
10
22
  const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
23
+ const DATABASE_URL_PATTERN = /^postgres(ql)?:\/\/.+/;
24
+ const ANSI_PATTERN = /\u001B\[[0-9;]*m/g;
25
+ const ENV_EXAMPLE_FILE = '.env.examples';
26
+ const ENV_FILE = '.env';
27
+ const DEFAULT_PORT = 3000;
28
+ const DEFAULT_DATABASE_PORT = 5432;
29
+ const MAX_PORT = 65535;
30
+ const PORT_SEARCH_RANGE = 50;
31
+ const MIN_NODE_VERSION = { major: 20, minor: 11 };
32
+ const BANNER_DELAY_MS = 70;
33
+ const STEP_MIN_DURATION_MS = 450;
34
+ const DETECT_TIMEOUT_MS = 4000;
35
+ const DATABASE_TIMEOUT_MS = 2500;
36
+ const UPDATE_TIMEOUT_MS = 2500;
37
+ const EXIT_INTERRUPTED = 130;
38
+ const GRADIENT = ['#22d3ee', '#38bdf8', '#60a5fa', '#818cf8', '#a78bfa'];
39
+
40
+ const GLYPHS = [
41
+ [String.raw` ___ `, String.raw` / _ \ `, String.raw`| | | |`, String.raw`| |_| |`, String.raw` \___/ `],
42
+ [String.raw` ____ `, String.raw`| _ \ `, String.raw`| |_) |`, String.raw`| __/ `, String.raw`|_| `],
43
+ [String.raw` _____ `, String.raw`| ____|`, String.raw`| _| `, String.raw`| |___ `, String.raw`|_____|`],
44
+ [String.raw` _ _ `, String.raw`| \ | |`, String.raw`| \| |`, String.raw`| |\ |`, String.raw`|_| \_|`],
45
+ [String.raw` ____ `, String.raw`| _ \ `, String.raw`| |_) |`, String.raw`| __/ `, String.raw`|_| `],
46
+ [String.raw` _ `, String.raw` / \ `, String.raw` / _ \ `, String.raw` / ___ \ `, String.raw`/_/ \_\ `],
47
+ [String.raw` ____ `, String.raw` / ___|`, String.raw`| | `, String.raw`| |___ `, String.raw` \____|`],
48
+ [String.raw` _ __ `, String.raw`| |/ / `, String.raw`| ' / `, String.raw`| . \ `, String.raw`|_|\_\ `]
49
+ ];
50
+
51
+ const BANNER_LINES = GLYPHS[0].map((_, row) => GLYPHS.map((glyph) => glyph[row]).join(''));
52
+
53
+ const PACKAGE_MANAGERS = {
54
+ npm: { label: 'npm', install: ['install'], dev: ['run', 'dev'] },
55
+ pnpm: { label: 'pnpm', install: ['install'], dev: ['run', 'dev'] },
56
+ yarn: { label: 'yarn', install: ['install'], dev: ['run', 'dev'] },
57
+ bun: { label: 'bun', install: ['install'], dev: ['run', 'dev'] }
58
+ };
59
+
60
+ const PLATFORM_LABELS = {
61
+ darwin: 'macOS',
62
+ win32: 'Windows',
63
+ linux: 'Linux'
64
+ };
65
+
66
+ const MODES = {
67
+ run: 'run',
68
+ install: 'install',
69
+ none: 'none'
70
+ };
11
71
 
12
- const [projectName] = process.argv.slice(2);
72
+ const CLI_OPTIONS = {
73
+ yes: { type: 'boolean', short: 'y' },
74
+ pm: { type: 'string' },
75
+ 'database-url': { type: 'string' },
76
+ port: { type: 'string' },
77
+ 'no-install': { type: 'boolean' },
78
+ 'no-run': { type: 'boolean' },
79
+ 'no-db-check': { type: 'boolean' },
80
+ 'no-update-check': { type: 'boolean' },
81
+ help: { type: 'boolean', short: 'h' },
82
+ version: { type: 'boolean', short: 'v' }
83
+ };
84
+
85
+ const HELP_OPTIONS = [
86
+ ['-y, --yes', 'Skip prompts and use defaults'],
87
+ ['--pm <name>', 'Package manager: npm, pnpm, yarn, bun'],
88
+ ['--database-url <url>', 'PostgreSQL connection string'],
89
+ ['--port <number>', `Development server port (default ${DEFAULT_PORT})`],
90
+ ['--no-install', 'Create the project without installing dependencies'],
91
+ ['--no-run', 'Install dependencies without starting the server'],
92
+ ['--no-db-check', 'Skip the database reachability check'],
93
+ ['--no-update-check', 'Skip the CLI update check'],
94
+ ['-h, --help', 'Show this help'],
95
+ ['-v, --version', 'Show the CLI version']
96
+ ];
13
97
 
14
- const accent = chalk.cyan;
98
+ const accent = chalk.hex('#38bdf8');
15
99
  const muted = chalk.gray;
100
+ const isWindows = process.platform === 'win32';
101
+ const isTTY = Boolean(process.stdout.isTTY && process.stdin.isTTY);
102
+
103
+ const STATE_ICONS = {
104
+ done: chalk.green('✔'),
105
+ warning: chalk.yellow('!'),
106
+ failed: chalk.red('✖'),
107
+ pending: muted('○')
108
+ };
109
+
110
+ class UsageError extends Error {}
111
+
112
+ const pause = (ms) => (isTTY ? sleep(ms) : Promise.resolve());
113
+
114
+ const visibleLength = (value) => value.replace(ANSI_PATTERN, '').length;
115
+
116
+ const renderCard = (title, rows) => {
117
+ const labelWidth = Math.max(...rows.map((row) => visibleLength(row.label)));
118
+ const lines = rows.map((row) => `${row.label}${' '.repeat(labelWidth - visibleLength(row.label))} ${row.value}`);
119
+ const width = Math.max(visibleLength(title) + 1, ...lines.map(visibleLength));
120
+ const inner = width + 2;
121
+ const border = chalk.hex('#475569');
122
+ const top = border('╭─ ') + chalk.bold(title) + border(` ${'─'.repeat(inner - visibleLength(title) - 3)}╮`);
123
+ const body = lines.map((line) => `${border('│')} ${line}${' '.repeat(width - visibleLength(line))} ${border('│')}`);
124
+ const bottom = border(`╰${'─'.repeat(inner)}╯`);
125
+ return [top, ...body, bottom].map((line) => ` ${line}`).join('\n');
126
+ };
127
+
128
+ const compareVersions = (left, right) => {
129
+ const leftParts = left.split('.').map(Number);
130
+ const rightParts = right.split('.').map(Number);
131
+ for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
132
+ const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
133
+ if (difference !== 0) return difference;
134
+ }
135
+ return 0;
136
+ };
137
+
138
+ const validateProjectName = (value) => {
139
+ if (!value) return 'Project name is required';
140
+ if (!NAME_PATTERN.test(value)) return 'Use letters, numbers, dot, dash, or underscore';
141
+ return undefined;
142
+ };
143
+
144
+ const validateDatabaseUrl = (value) =>
145
+ DATABASE_URL_PATTERN.test(value ?? '') ? undefined : 'Use the format postgresql://user:password@host:port/db';
146
+
147
+ const validatePort = (value) => {
148
+ const port = Number(value);
149
+ return /^\d+$/.test(String(value ?? '')) && port >= 1 && port <= MAX_PORT
150
+ ? undefined
151
+ : `Use a port between 1 and ${MAX_PORT}`;
152
+ };
153
+
154
+ const validateFlags = ({ values, positionals }) => {
155
+ const checks = [
156
+ positionals[0] === undefined ? undefined : validateProjectName(positionals[0]),
157
+ values.pm === undefined || PACKAGE_MANAGERS[values.pm] ? undefined : '--pm must be one of npm, pnpm, yarn, bun',
158
+ values.port === undefined ? undefined : validatePort(values.port),
159
+ values['database-url'] === undefined ? undefined : validateDatabaseUrl(values['database-url'])
160
+ ];
161
+ return checks.find(Boolean);
162
+ };
163
+
164
+ const resolveFixedMode = (values) => {
165
+ if (values['no-install']) return MODES.none;
166
+ if (values['no-run']) return MODES.install;
167
+ return undefined;
168
+ };
169
+
170
+ const readFixedOptions = ({ values, positionals }) => ({
171
+ name: positionals[0],
172
+ packageManager: values.pm,
173
+ databaseUrl: values['database-url'],
174
+ port: values.port === undefined ? undefined : Number(values.port),
175
+ mode: resolveFixedMode(values)
176
+ });
177
+
178
+ const captureCommand = (command, args) =>
179
+ new Promise((resolve) => {
180
+ const child = spawn(command, args, {
181
+ shell: isWindows,
182
+ stdio: ['ignore', 'pipe', 'ignore'],
183
+ timeout: DETECT_TIMEOUT_MS
184
+ });
185
+ let output = '';
186
+ child.stdout.on('data', (chunk) => {
187
+ output += chunk.toString();
188
+ });
189
+ child.on('error', () => resolve(null));
190
+ child.on('close', (code) => resolve(code === 0 ? (output.trim().split('\n')[0] ?? null) : null));
191
+ });
192
+
193
+ const isPortFree = (port) =>
194
+ new Promise((resolve) => {
195
+ const server = net.createServer();
196
+ server.once('error', () => resolve(false));
197
+ server.once('listening', () => server.close(() => resolve(true)));
198
+ server.listen(port);
199
+ });
200
+
201
+ const findFreePort = async (start) => {
202
+ for (let offset = 0; offset < PORT_SEARCH_RANGE && start + offset <= MAX_PORT; offset += 1) {
203
+ if (await isPortFree(start + offset)) return start + offset;
204
+ }
205
+ throw new Error(`No free port found from ${start}`);
206
+ };
207
+
208
+ const isTcpReachable = (host, port) =>
209
+ new Promise((resolve) => {
210
+ const socket = net.connect({ host, port, timeout: DATABASE_TIMEOUT_MS });
211
+ socket.once('connect', () => {
212
+ socket.destroy();
213
+ resolve(true);
214
+ });
215
+ socket.once('timeout', () => {
216
+ socket.destroy();
217
+ resolve(false);
218
+ });
219
+ socket.once('error', () => resolve(false));
220
+ });
221
+
222
+ const fetchLatestVersion = async () => {
223
+ try {
224
+ const response = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS) });
225
+ if (!response.ok) return null;
226
+ const { version } = await response.json();
227
+ return typeof version === 'string' ? version : null;
228
+ } catch {
229
+ return null;
230
+ }
231
+ };
232
+
233
+ const parseLauncher = () => {
234
+ const [agent] = (process.env.npm_config_user_agent ?? '').split(' ');
235
+ const [name, version] = agent.split('/');
236
+ return PACKAGE_MANAGERS[name] ? { name, version } : null;
237
+ };
238
+
239
+ const isNodeSupported = () => {
240
+ const [major, minor] = process.versions.node.split('.').map(Number);
241
+ return major > MIN_NODE_VERSION.major || (major === MIN_NODE_VERSION.major && minor >= MIN_NODE_VERSION.minor);
242
+ };
16
243
 
17
- const printHeader = () => {
244
+ const detectEnvironment = async ({ port, checkUpdate }) => {
245
+ const managerNames = Object.keys(PACKAGE_MANAGERS);
246
+ const [gitOutput, portFree, latestVersion, ...managerVersions] = await Promise.all([
247
+ captureCommand('git', ['--version']),
248
+ isPortFree(port),
249
+ checkUpdate ? fetchLatestVersion() : Promise.resolve(null),
250
+ ...managerNames.map((name) => captureCommand(name, ['--version']))
251
+ ]);
252
+ const managers = Object.fromEntries(
253
+ managerNames.map((name, index) => [name, managerVersions[index]]).filter(([, version]) => version)
254
+ );
255
+ const cpus = os.cpus();
256
+
257
+ return {
258
+ system: `${PLATFORM_LABELS[process.platform] ?? process.platform} ${os.release()} (${os.arch()})`,
259
+ device: `${(cpus[0]?.model ?? 'Unknown CPU').replace(/\s+/g, ' ').trim()} (${cpus.length} cores, ${Math.round(os.totalmem() / 1024 ** 3)} GB RAM)`,
260
+ node: process.versions.node,
261
+ nodeSupported: isNodeSupported(),
262
+ launcher: parseLauncher(),
263
+ managers,
264
+ git: gitOutput ? gitOutput.replace(/^git version\s*/, '') : null,
265
+ port,
266
+ portFree,
267
+ latestVersion
268
+ };
269
+ };
270
+
271
+ const printEnvironmentCard = (environment) => {
272
+ const managerList = Object.entries(environment.managers).map(([name, version]) => `${name} ${version}`);
273
+ const nodeStatus = environment.nodeSupported
274
+ ? chalk.green('✔')
275
+ : chalk.red(`✖ requires >=${MIN_NODE_VERSION.major}.${MIN_NODE_VERSION.minor}`);
276
+ const hasUpdate = environment.latestVersion && compareVersions(environment.latestVersion, CLI_VERSION) > 0;
277
+ const rows = [
278
+ { label: muted('System'), value: environment.system },
279
+ { label: muted('Device'), value: environment.device },
280
+ { label: muted('Node.js'), value: `v${environment.node} ${nodeStatus}` },
281
+ {
282
+ label: muted('Launched with'),
283
+ value: environment.launcher ? `${environment.launcher.name} ${environment.launcher.version}` : 'node (direct)'
284
+ },
285
+ {
286
+ label: muted('Package managers'),
287
+ value: managerList.length ? managerList.join(', ') : chalk.yellow('none detected')
288
+ },
289
+ {
290
+ label: muted('Git'),
291
+ value: environment.git ? `${environment.git} ${chalk.green('✔')}` : muted('not found')
292
+ },
293
+ {
294
+ label: muted('Port'),
295
+ value: `${environment.port} ${environment.portFree ? `${chalk.green('✔')} free` : chalk.yellow('in use')}`
296
+ },
297
+ {
298
+ label: muted('Openpack'),
299
+ value: hasUpdate
300
+ ? `v${CLI_VERSION} ${chalk.yellow(`update available: ${environment.latestVersion}`)}`
301
+ : `v${CLI_VERSION} ${chalk.green('✔')}`
302
+ }
303
+ ];
304
+ console.log(renderCard('Environment', rows));
18
305
  console.log();
19
- console.log(` ${chalk.bold('Open Pack')} ${muted('Fast starter for Node.js, Express, and PostgreSQL')}`);
306
+ };
307
+
308
+ const printStatusCard = (results) => {
309
+ const rows = results.map((result) => ({
310
+ label: `${STATE_ICONS[result.state]} ${result.label}`,
311
+ value: result.duration === null ? muted('pending') : muted(`${(result.duration / 1000).toFixed(1)}s`)
312
+ }));
20
313
  console.log();
314
+ console.log(renderCard('Build status', rows));
21
315
  };
22
316
 
23
- const printFailure = (title, detail) => {
24
- console.log(` ${chalk.red('✖')} ${chalk.bold(title)}`);
25
- if (detail) console.log(` ${muted(detail)}`);
317
+ const printBanner = async () => {
318
+ console.log();
319
+ for (const [index, line] of BANNER_LINES.entries()) {
320
+ console.log(` ${chalk.hex(GRADIENT[index % GRADIENT.length])(line)}`);
321
+ await pause(BANNER_DELAY_MS);
322
+ }
323
+ console.log();
324
+ console.log(` ${chalk.bold('Openpack')} ${muted('Fast starter for Node.js, Express, and PostgreSQL')}`);
26
325
  console.log();
27
- process.exitCode = 1;
28
326
  };
29
327
 
30
- const printUsage = () => {
328
+ const printHelp = () => {
329
+ const width = Math.max(...HELP_OPTIONS.map(([flag]) => flag.length));
330
+ console.log();
331
+ console.log(` ${chalk.bold('Openpack')} ${muted(`v${CLI_VERSION}`)}`);
332
+ console.log();
31
333
  console.log(` ${muted('Usage')}`);
32
- console.log(` ${PACKAGE_COMMAND} ${accent('<project-name>')}`);
334
+ console.log(` ${PACKAGE_COMMAND} ${accent('[project-name] [options]')}`);
335
+ console.log();
336
+ console.log(` ${muted('Options')}`);
337
+ HELP_OPTIONS.forEach(([flag, description]) => {
338
+ console.log(` ${accent(flag.padEnd(width))} ${description}`);
339
+ });
33
340
  console.log();
34
341
  console.log(` ${muted('Example')}`);
35
- console.log(` ${PACKAGE_COMMAND} ${accent('my-project')}`);
342
+ console.log(` ${PACKAGE_COMMAND} ${accent('my-project --yes --pm pnpm --database-url postgresql://user:pass@localhost:5432/db --no-run')}`);
36
343
  console.log();
37
344
  };
38
345
 
39
- const printNextSteps = (name, startedAt) => {
40
- const duration = ((performance.now() - startedAt) / 1000).toFixed(1);
346
+ const printFailure = (title, detail) => {
347
+ console.log();
348
+ console.log(` ${chalk.red('✖')} ${chalk.bold(title)}`);
349
+ if (detail) console.log(` ${muted(detail)}`);
350
+ console.log();
351
+ process.exitCode = 1;
352
+ };
353
+
354
+ const runCommand = (command, args, cwd) =>
355
+ new Promise((resolve, reject) => {
356
+ const child = spawn(command, args, { cwd, shell: isWindows, stdio: ['ignore', 'ignore', 'pipe'] });
357
+ const errors = [];
358
+ child.stderr.on('data', (chunk) => errors.push(chunk.toString()));
359
+ child.on('error', reject);
360
+ child.on('close', (code) => {
361
+ if (code === 0) return resolve();
362
+ const tail = errors.join('').trim().split('\n').slice(-5).join('\n');
363
+ reject(new Error(tail || `${command} exited with code ${code}`));
364
+ });
365
+ });
366
+
367
+ const runInteractive = (command, args, cwd) =>
368
+ new Promise((resolve, reject) => {
369
+ const child = spawn(command, args, { cwd, shell: isWindows, stdio: 'inherit' });
370
+ child.on('error', reject);
371
+ child.on('close', () => resolve());
372
+ });
373
+
374
+ const setEnvValue = (content, key, value) => {
375
+ const pattern = new RegExp(`^${key}=.*$`, 'm');
376
+ if (pattern.test(content)) return content.replace(pattern, () => `${key}=${value}`);
377
+ const separator = content.endsWith('\n') || content.length === 0 ? '' : '\n';
378
+ return `${content}${separator}${key}=${value}\n`;
379
+ };
380
+
381
+ const createRollback = (targetDirectory, originalCwd, enabled) => {
382
+ let armed = enabled;
383
+ return {
384
+ disarm: () => {
385
+ armed = false;
386
+ },
387
+ run: async () => {
388
+ if (!armed) return false;
389
+ armed = false;
390
+ process.chdir(originalCwd);
391
+ try {
392
+ await fse.remove(targetDirectory);
393
+ return true;
394
+ } catch {
395
+ return false;
396
+ }
397
+ }
398
+ };
399
+ };
400
+
401
+ const resolveManagerChoices = (environment) => {
402
+ const detected = Object.keys(environment.managers);
403
+ const names = detected.length ? detected : ['npm'];
404
+ const launcherName = environment.launcher?.name;
405
+ return { names, initial: names.includes(launcherName) ? launcherName : names[0] };
406
+ };
407
+
408
+ const needsPortChoice = async (port, mode) => mode === MODES.run && !(await isPortFree(port));
409
+
410
+ const askOptions = async (cli, environment) => {
411
+ const fixed = readFixedOptions(cli);
412
+ const managers = resolveManagerChoices(environment);
413
+
414
+ const answers = await prompts.group(
415
+ {
416
+ name: () =>
417
+ fixed.name !== undefined
418
+ ? Promise.resolve(fixed.name)
419
+ : prompts.text({
420
+ message: 'What is your project name?',
421
+ placeholder: 'my-project',
422
+ validate: validateProjectName
423
+ }),
424
+ packageManager: () =>
425
+ fixed.packageManager !== undefined
426
+ ? Promise.resolve(fixed.packageManager)
427
+ : prompts.select({
428
+ message: 'Choose a package manager',
429
+ initialValue: managers.initial,
430
+ options: managers.names.map((value) => ({
431
+ value,
432
+ label: PACKAGE_MANAGERS[value].label,
433
+ hint: environment.managers[value] ? `v${environment.managers[value]}` : undefined
434
+ }))
435
+ }),
436
+ databaseUrl: () =>
437
+ fixed.databaseUrl !== undefined
438
+ ? Promise.resolve(fixed.databaseUrl)
439
+ : prompts.text({
440
+ message: 'PostgreSQL DATABASE_URL',
441
+ placeholder: 'postgresql://user:password@localhost:5432/mydb',
442
+ validate: validateDatabaseUrl
443
+ }),
444
+ mode: () =>
445
+ fixed.mode !== undefined
446
+ ? Promise.resolve(fixed.mode)
447
+ : prompts.select({
448
+ message: 'Once the template is ready',
449
+ initialValue: MODES.run,
450
+ options: [
451
+ { value: MODES.run, label: 'Install dependencies and start the server', hint: 'recommended' },
452
+ { value: MODES.install, label: 'Install dependencies only' },
453
+ { value: MODES.none, label: 'Create the project only' }
454
+ ]
455
+ }),
456
+ port: async ({ results }) => {
457
+ const desired = fixed.port ?? DEFAULT_PORT;
458
+ if (!(await needsPortChoice(desired, results.mode))) return desired;
459
+ return prompts.text({
460
+ message: `Port ${desired} is in use. Choose another port`,
461
+ initialValue: String(await findFreePort(desired + 1)),
462
+ validate: validatePort
463
+ });
464
+ }
465
+ },
466
+ {
467
+ onCancel: () => {
468
+ prompts.cancel('Cancelled');
469
+ process.exit(0);
470
+ }
471
+ }
472
+ );
473
+
474
+ return { ...answers, port: Number(answers.port) };
475
+ };
476
+
477
+ const resolveDefaultOptions = async (cli, environment) => {
478
+ const fixed = readFixedOptions(cli);
479
+ if (fixed.name === undefined) throw new UsageError('Project name is required in non-interactive mode');
480
+ if (fixed.databaseUrl === undefined) throw new UsageError('--database-url is required in non-interactive mode');
481
+
482
+ const mode = fixed.mode ?? (process.stdout.isTTY ? MODES.run : MODES.install);
483
+ const desired = fixed.port ?? DEFAULT_PORT;
484
+ let port = desired;
485
+
486
+ if (await needsPortChoice(desired, mode)) {
487
+ if (fixed.port !== undefined) throw new UsageError(`Port ${desired} is already in use`);
488
+ port = await findFreePort(desired + 1);
489
+ }
490
+
491
+ return {
492
+ name: fixed.name,
493
+ packageManager: fixed.packageManager ?? resolveManagerChoices(environment).initial,
494
+ databaseUrl: fixed.databaseUrl,
495
+ mode,
496
+ port
497
+ };
498
+ };
499
+
500
+ const checkDatabase = async (databaseUrl) => {
501
+ const url = new URL(databaseUrl);
502
+ const host = url.hostname.replace(/^\[|\]$/g, '');
503
+ const port = Number(url.port) || DEFAULT_DATABASE_PORT;
504
+ const target = `${host}:${port}`;
505
+ return (await isTcpReachable(host, port))
506
+ ? { label: `Database reachable at ${target}` }
507
+ : { state: 'warning', label: `Database unreachable at ${target}` };
508
+ };
509
+
510
+ const buildSteps = ({ name, packageManager, databaseUrl, mode, port }, { dbCheck }) => {
511
+ const targetDirectory = path.resolve(process.cwd(), name);
512
+ const manager = PACKAGE_MANAGERS[packageManager];
513
+ const customPort = port !== DEFAULT_PORT;
41
514
  const steps = [
42
- `cd ${name}`,
43
- 'npm install',
44
- 'cp .env.examples .env',
45
- 'npm run dev',
515
+ {
516
+ active: 'Fetching template',
517
+ done: 'Template fetched',
518
+ task: () => downloadTemplate(TEMPLATE_SOURCE, { dir: targetDirectory })
519
+ },
520
+ {
521
+ active: 'Preparing environment',
522
+ done: `${ENV_FILE} is ready`,
523
+ task: async () => {
524
+ const source = path.join(targetDirectory, ENV_EXAMPLE_FILE);
525
+ if (!(await fse.pathExists(source))) throw new Error(`${ENV_EXAMPLE_FILE} not found in the template`);
526
+ await fse.copy(source, path.join(targetDirectory, ENV_FILE));
527
+ }
528
+ },
529
+ {
530
+ active: 'Injecting template configuration',
531
+ done: customPort ? 'DATABASE_URL, SESSION_SECRET, and PORT injected' : 'DATABASE_URL and SESSION_SECRET injected',
532
+ task: async () => {
533
+ const envPath = path.join(targetDirectory, ENV_FILE);
534
+ const current = await fse.readFile(envPath, 'utf8');
535
+ const withDatabase = setEnvValue(current, 'DATABASE_URL', databaseUrl);
536
+ const withSecret = setEnvValue(withDatabase, 'SESSION_SECRET', randomBytes(32).toString('hex'));
537
+ await fse.writeFile(envPath, customPort ? setEnvValue(withSecret, 'PORT', String(port)) : withSecret);
538
+ }
539
+ }
46
540
  ];
47
541
 
542
+ if (dbCheck) {
543
+ steps.push({
544
+ active: 'Checking database connection',
545
+ done: 'Database reachable',
546
+ task: () => checkDatabase(databaseUrl)
547
+ });
548
+ }
549
+
550
+ steps.push({
551
+ active: 'Entering project directory',
552
+ done: `Entered ${name}`,
553
+ task: async () => process.chdir(targetDirectory)
554
+ });
555
+
556
+ if (mode !== MODES.none) {
557
+ steps.push({
558
+ active: `Installing dependencies with ${manager.label}`,
559
+ done: 'Dependencies installed',
560
+ task: () => runCommand(packageManager, manager.install, targetDirectory),
561
+ minDuration: 0
562
+ });
563
+ }
564
+
565
+ if (mode === MODES.run) {
566
+ steps.push({
567
+ active: 'Starting template',
568
+ done: 'Template ready to run',
569
+ task: async () => undefined
570
+ });
571
+ }
572
+
573
+ return { steps, targetDirectory, manager };
574
+ };
575
+
576
+ const runSteps = async (steps, results) => {
577
+ const total = steps.length;
578
+ for (const [index, step] of steps.entries()) {
579
+ const prefix = muted(`[${index + 1}/${total}]`);
580
+ const spinner = ora({ text: `${prefix} ${step.active}`, indent: 2, spinner: 'dots12', color: 'cyan' }).start();
581
+ const startedAt = Date.now();
582
+ try {
583
+ const outcome = (await step.task()) ?? {};
584
+ const remaining = (step.minDuration ?? STEP_MIN_DURATION_MS) - (Date.now() - startedAt);
585
+ if (remaining > 0) await pause(remaining);
586
+ const state = outcome.state ?? 'done';
587
+ const label = outcome.label ?? step.done;
588
+ results[index] = { label, state, duration: Date.now() - startedAt };
589
+ spinner.stopAndPersist({ symbol: STATE_ICONS[state], text: `${prefix} ${label}` });
590
+ } catch (error) {
591
+ results[index] = { label: step.active, state: 'failed', duration: Date.now() - startedAt };
592
+ spinner.stopAndPersist({ symbol: STATE_ICONS.failed, text: `${prefix} ${step.active}` });
593
+ throw error;
594
+ }
595
+ }
596
+ };
597
+
598
+ const printSummary = ({ name, packageManager, mode, port, startedAt }) => {
599
+ const duration = ((performance.now() - startedAt) / 1000).toFixed(1);
600
+ const devUrl = `http://localhost:${port}`;
601
+ const devCommand = [packageManager, ...PACKAGE_MANAGERS[packageManager].dev].join(' ');
602
+ const pending = [`cd ${name}`];
603
+ if (mode === MODES.none) pending.push(`${packageManager} install`);
604
+ if (mode !== MODES.run) pending.push(devCommand);
605
+
48
606
  console.log();
49
607
  console.log(` ${chalk.green('✔')} ${chalk.bold(name)} is ready ${muted(`in ${duration}s`)}`);
50
608
  console.log();
609
+ if (mode === MODES.run) {
610
+ console.log(` ${muted('Server')} ${accent(devUrl)}`);
611
+ console.log(` ${muted('Health check')} ${accent(`${devUrl}/api/health`)}`);
612
+ console.log(` ${muted('Press Ctrl+C to stop')}`);
613
+ console.log();
614
+ return;
615
+ }
51
616
  console.log(` ${muted('Next steps')}`);
52
- steps.forEach((step, index) => {
53
- console.log(` ${muted(`${index + 1}`)} ${accent(step)}`);
54
- });
617
+ pending.forEach((step, index) => console.log(` ${muted(String(index + 1))} ${accent(step)}`));
55
618
  console.log();
56
- console.log(` ${muted('Fill in')} DATABASE_URL ${muted('and')} SESSION_SECRET ${muted('inside .env before running.')}`);
57
- console.log(` ${muted('Health check')} http://localhost:3000/api/health`);
619
+ console.log(` ${muted('Health check')} ${accent(`${devUrl}/api/health`)}`);
58
620
  console.log();
59
621
  };
60
622
 
623
+ const parseCli = () => {
624
+ try {
625
+ return parseArgs({ args: process.argv.slice(2), options: CLI_OPTIONS, allowPositionals: true });
626
+ } catch (error) {
627
+ throw new UsageError(error.message);
628
+ }
629
+ };
630
+
61
631
  async function main() {
62
- printHeader();
632
+ const cli = parseCli();
63
633
 
64
- if (!projectName) {
65
- printFailure('Project name is required');
66
- printUsage();
67
- return;
634
+ if (cli.values.help) return printHelp();
635
+ if (cli.values.version) return console.log(CLI_VERSION);
636
+
637
+ await printBanner();
638
+
639
+ const flagError = validateFlags(cli);
640
+ if (flagError) throw new UsageError(flagError);
641
+
642
+ const inspector = ora({ text: 'Inspecting your environment', indent: 2, spinner: 'dots12', color: 'cyan' }).start();
643
+ const environment = await detectEnvironment({
644
+ port: cli.values.port === undefined ? DEFAULT_PORT : Number(cli.values.port),
645
+ checkUpdate: !cli.values['no-update-check']
646
+ });
647
+ await pause(STEP_MIN_DURATION_MS);
648
+ inspector.stop();
649
+ printEnvironmentCard(environment);
650
+
651
+ if (!environment.nodeSupported) {
652
+ throw new UsageError(
653
+ `Openpack requires Node.js >=${MIN_NODE_VERSION.major}.${MIN_NODE_VERSION.minor}, found v${environment.node}`
654
+ );
68
655
  }
69
656
 
70
- if (!NAME_PATTERN.test(projectName)) {
71
- printFailure('Invalid project name', 'Use letters, numbers, dot, dash, or underscore.');
72
- return;
657
+ if (cli.values.pm && !environment.managers[cli.values.pm]) {
658
+ throw new UsageError(`${cli.values.pm} is not installed`);
73
659
  }
74
660
 
75
- const targetDirectory = path.resolve(process.cwd(), projectName);
661
+ const interactive = isTTY && !cli.values.yes;
662
+ if (!interactive) console.log(` ${muted('Non-interactive mode')}\n`);
663
+
664
+ const options = interactive ? await askOptions(cli, environment) : await resolveDefaultOptions(cli, environment);
665
+ const originalCwd = process.cwd();
666
+ const { steps, targetDirectory, manager } = buildSteps(options, { dbCheck: !cli.values['no-db-check'] });
667
+ const rollback = createRollback(targetDirectory, originalCwd, !(await fse.pathExists(targetDirectory)));
668
+ const results = steps.map((step) => ({ label: step.done, state: 'pending', duration: null }));
76
669
  const startedAt = performance.now();
77
- const spinner = ora({ text: `Creating ${chalk.bold(projectName)} from the Open Pack template`, indent: 2 }).start();
670
+
671
+ const handleInterrupt = async () => {
672
+ const removed = await rollback.run();
673
+ console.log();
674
+ console.log(` ${chalk.yellow('!')} Interrupted${removed ? ', partial project removed' : ''}`);
675
+ console.log();
676
+ process.exit(EXIT_INTERRUPTED);
677
+ };
678
+
679
+ console.log();
680
+ process.once('SIGINT', handleInterrupt);
78
681
 
79
682
  try {
80
- await downloadTemplate(TEMPLATE_SOURCE, { dir: targetDirectory });
81
- spinner.stop();
82
- printNextSteps(projectName, startedAt);
683
+ await runSteps(steps, results);
684
+ rollback.disarm();
83
685
  } catch (error) {
84
- spinner.stop();
686
+ const removed = await rollback.run();
687
+ printStatusCard(results);
688
+ if (removed) console.log(`\n ${muted('Partial project removed')}`);
85
689
  const alreadyExists = /already exists/i.test(error.message);
86
690
  printFailure(
87
691
  alreadyExists ? 'Directory already exists' : 'Failed to create project',
88
692
  alreadyExists ? `Choose another name or remove ${targetDirectory}` : error.message
89
693
  );
694
+ return;
695
+ } finally {
696
+ process.removeListener('SIGINT', handleInterrupt);
697
+ }
698
+
699
+ printStatusCard(results);
700
+ printSummary({ ...options, startedAt });
701
+
702
+ if (options.mode === MODES.run) {
703
+ await runInteractive(options.packageManager, manager.dev, targetDirectory);
90
704
  }
91
705
  }
92
706
 
93
- main();
707
+ main().catch((error) => {
708
+ if (error instanceof UsageError) {
709
+ printFailure('Invalid usage', error.message);
710
+ console.log(` ${muted('Run with --help to see all options')}\n`);
711
+ return;
712
+ }
713
+ printFailure('Unexpected error', error.message);
714
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilanalimanjs/open_pack",
3
- "version": "2.2.4",
3
+ "version": "2.4.3",
4
4
  "description": "Open Pack CLI: create a new project from the Open Pack source template",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,8 +20,10 @@
20
20
  "url": "git+https://github.com/DevIlannn/open_pack.git"
21
21
  },
22
22
  "dependencies": {
23
- "giget": "^2.0.0",
23
+ "@clack/prompts": "^0.11.0",
24
24
  "chalk": "^5.4.1",
25
+ "fs-extra": "^11.3.0",
26
+ "giget": "^2.0.0",
25
27
  "ora": "^8.1.1"
26
28
  }
27
29
  }