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