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