@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.
- package/bin/index.js +669 -38
- 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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
24
|
-
console.log(
|
|
25
|
-
|
|
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
|
|
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('
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
642
|
+
const cli = parseCli();
|
|
63
643
|
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 (!
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
printNextSteps(projectName, startedAt);
|
|
693
|
+
await runSteps(steps, results);
|
|
694
|
+
rollback.disarm();
|
|
83
695
|
} catch (error) {
|
|
84
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
}
|