@within-7/jetr 0.0.1

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/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # jetr
2
+
3
+ CLI tool for deploying static websites instantly.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g @within-7/jetr
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ # Deploy current directory with random project name
15
+ jetr ./
16
+
17
+ # Deploy current directory with specific project name
18
+ jetr ./ my-site
19
+
20
+ # Deploy a specific directory
21
+ jetr ./dist
22
+ jetr ./dist my-app
23
+
24
+ # Deploy a single file
25
+ jetr ./index.html
26
+ jetr ./demo.html my-demo
27
+
28
+ # Generate .jetrignore file
29
+ jetr init
30
+ ```
31
+
32
+ After deployment, your site will be available at `http://<projectName>.statics.within-7.com`.
33
+
34
+ ## .jetrignore
35
+
36
+ Create a `.jetrignore` file in your project root to exclude files from deployment. It uses the same syntax as `.gitignore`.
37
+
38
+ If no `.jetrignore` exists, one will be auto-created with common defaults (node_modules, .git, .env, etc.).
39
+
40
+ Run `jetr init` to manually generate a `.jetrignore` with default patterns.
41
+
42
+ ## Options
43
+
44
+ ```
45
+ -h, --help Show help message
46
+ -v, --version Show version number
47
+ ```
48
+
49
+ ## License
50
+
51
+ MIT
package/bin/jetr.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { run } from '../src/index.js';
4
+
5
+ run();
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@within-7/jetr",
3
+ "version": "0.0.1",
4
+ "description": "CLI tool for deploying static websites instantly",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "jetr": "./bin/jetr.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/"
12
+ ],
13
+ "scripts": {
14
+ "test": "node bin/jetr.js --help",
15
+ "publish:release": "node scripts/publish.cjs"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/Within-7/jetr.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/Within-7/jetr/issues"
23
+ },
24
+ "homepage": "https://github.com/Within-7/jetr#readme",
25
+ "keywords": [
26
+ "static",
27
+ "deploy",
28
+ "hosting",
29
+ "cli",
30
+ "surge"
31
+ ],
32
+ "author": "",
33
+ "license": "MIT",
34
+ "dependencies": {
35
+ "archiver": "^7.0.1",
36
+ "chalk": "^5.3.0",
37
+ "form-data": "^4.0.1",
38
+ "ignore": "^6.0.2",
39
+ "nanoid": "^5.0.9",
40
+ "ora": "^8.1.1"
41
+ },
42
+ "engines": {
43
+ "node": ">=18.0.0"
44
+ },
45
+ "type": "module"
46
+ }
package/src/index.js ADDED
@@ -0,0 +1,545 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import readline from 'readline';
4
+ import { fileURLToPath } from 'url';
5
+ import archiver from 'archiver';
6
+ import FormData from 'form-data';
7
+ import ignore from 'ignore';
8
+ import { nanoid } from 'nanoid';
9
+ import chalk from 'chalk';
10
+ import ora from 'ora';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+
15
+ const API_URL = 'https://api-statics.within-7.com/api/deploy';
16
+ const CONFIG_FILE = '.jetrrc';
17
+
18
+ const DEFAULT_JETRIGNORE = `# Dependencies
19
+ node_modules/
20
+ .pnp
21
+ .pnp.js
22
+
23
+ # Build outputs
24
+ dist/
25
+ build/
26
+ out/
27
+ .next/
28
+ .nuxt/
29
+ .output/
30
+ .cache/
31
+
32
+ # Logs
33
+ *.log
34
+ npm-debug.log*
35
+ yarn-debug.log*
36
+ yarn-error.log*
37
+ pnpm-debug.log*
38
+
39
+ # Environment
40
+ .env
41
+ .env.*
42
+ .env.local
43
+ .env.*.local
44
+
45
+ # IDE & Editor
46
+ .vscode/
47
+ .idea/
48
+ *.swp
49
+ *.swo
50
+ *~
51
+ .project
52
+ .classpath
53
+ .settings/
54
+
55
+ # OS files
56
+ .DS_Store
57
+ Thumbs.db
58
+ desktop.ini
59
+
60
+ # Git
61
+ .git/
62
+ .gitignore
63
+
64
+ # Testing
65
+ coverage/
66
+ .nyc_output/
67
+
68
+ # Package managers
69
+ package-lock.json
70
+ yarn.lock
71
+ pnpm-lock.yaml
72
+
73
+ # Misc
74
+ *.zip
75
+ *.tar.gz
76
+ `;
77
+
78
+ function initJetrignore(targetDir, silent = false) {
79
+ const ignoreFile = path.join(targetDir, '.jetrignore');
80
+
81
+ if (fs.existsSync(ignoreFile)) {
82
+ if (!silent) {
83
+ console.log(chalk.yellow('.jetrignore already exists in this directory'));
84
+ }
85
+ return false;
86
+ }
87
+
88
+ fs.writeFileSync(ignoreFile, DEFAULT_JETRIGNORE);
89
+ if (!silent) {
90
+ console.log(chalk.green('Created .jetrignore with default ignore patterns'));
91
+ }
92
+ return true;
93
+ }
94
+
95
+ function showHelp() {
96
+ console.log(`
97
+ ${chalk.bold('jetr')} - Deploy static websites instantly
98
+
99
+ ${chalk.bold('Usage:')}
100
+ jetr <directory> [projectName]
101
+
102
+ ${chalk.bold('Examples:')}
103
+ jetr ./ # Deploy current directory with random name
104
+ jetr ./ my-site # Deploy current directory as my-site
105
+ jetr ../app # Deploy ../app with random name
106
+ jetr ./dist production # Deploy ./dist as production
107
+
108
+ ${chalk.bold('Commands:')}
109
+ init Create a .jetrignore file with default patterns
110
+
111
+ ${chalk.bold('Options:')}
112
+ -h, --help Show this help message
113
+ -v, --version Show version number
114
+
115
+ ${chalk.bold('.jetrignore:')}
116
+ Create a .jetrignore file in your project root to exclude files.
117
+ Uses the same syntax as .gitignore.
118
+ Run "jetr init" to generate one with common defaults.
119
+ `);
120
+ }
121
+
122
+ function showVersion() {
123
+ const pkgPath = path.join(__dirname, '..', 'package.json');
124
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
125
+ console.log(pkg.version);
126
+ }
127
+
128
+ function generateProjectName() {
129
+ return nanoid(12).toLowerCase().replace(/[^a-z0-9]/g, '');
130
+ }
131
+
132
+ function sanitizeProjectName(name) {
133
+ const sanitized = name
134
+ .toLowerCase()
135
+ .replace(/[^a-z0-9-]/g, '-')
136
+ .replace(/^-+|-+$/g, '')
137
+ .replace(/-+/g, '-');
138
+
139
+ // If sanitization results in empty string, generate a random name
140
+ if (!sanitized) {
141
+ return generateProjectName();
142
+ }
143
+
144
+ return sanitized;
145
+ }
146
+
147
+ // ============ Config file functions ============
148
+
149
+ function loadConfig(targetDir) {
150
+ const configPath = path.join(targetDir, CONFIG_FILE);
151
+ if (fs.existsSync(configPath)) {
152
+ try {
153
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
154
+ } catch {
155
+ return { directory: null, files: {} };
156
+ }
157
+ }
158
+ return { directory: null, files: {} };
159
+ }
160
+
161
+ function saveConfig(targetDir, config) {
162
+ const configPath = path.join(targetDir, CONFIG_FILE);
163
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
164
+ }
165
+
166
+ function getConfigKey(singleFile) {
167
+ // For directory mode, use 'directory'; for single file, use filename
168
+ return singleFile ? path.basename(singleFile) : 'directory';
169
+ }
170
+
171
+ function getProjectFromConfig(config, key) {
172
+ if (key === 'directory') {
173
+ return config.directory;
174
+ }
175
+ return config.files?.[key] || null;
176
+ }
177
+
178
+ function updateConfig(config, key, projectName) {
179
+ if (key === 'directory') {
180
+ if (!config.directory) {
181
+ config.directory = { default: projectName, history: [projectName] };
182
+ } else {
183
+ config.directory.default = projectName;
184
+ if (!config.directory.history.includes(projectName)) {
185
+ config.directory.history.push(projectName);
186
+ }
187
+ }
188
+ } else {
189
+ if (!config.files) config.files = {};
190
+ if (!config.files[key]) {
191
+ config.files[key] = { default: projectName, history: [projectName] };
192
+ } else {
193
+ config.files[key].default = projectName;
194
+ if (!config.files[key].history.includes(projectName)) {
195
+ config.files[key].history.push(projectName);
196
+ }
197
+ }
198
+ }
199
+ return config;
200
+ }
201
+
202
+ async function promptSelection(options, message) {
203
+ const rl = readline.createInterface({
204
+ input: process.stdin,
205
+ output: process.stdout,
206
+ });
207
+
208
+ console.log();
209
+ console.log(chalk.bold(message));
210
+ options.forEach((opt, i) => {
211
+ const marker = i === 0 ? chalk.green('(default)') : '';
212
+ console.log(` ${chalk.cyan(i + 1)}) ${opt} ${marker}`);
213
+ });
214
+ console.log(` ${chalk.cyan(options.length + 1)}) ${chalk.gray('Create new project')}`);
215
+ console.log();
216
+
217
+ return new Promise((resolve) => {
218
+ rl.question(chalk.bold('Select option [1]: '), (answer) => {
219
+ rl.close();
220
+ const num = parseInt(answer, 10);
221
+ if (!answer || isNaN(num) || num < 1) {
222
+ resolve({ type: 'existing', value: options[0] });
223
+ } else if (num <= options.length) {
224
+ resolve({ type: 'existing', value: options[num - 1] });
225
+ } else {
226
+ resolve({ type: 'new', value: null });
227
+ }
228
+ });
229
+ });
230
+ }
231
+
232
+ async function promptNewName() {
233
+ const rl = readline.createInterface({
234
+ input: process.stdin,
235
+ output: process.stdout,
236
+ });
237
+
238
+ return new Promise((resolve) => {
239
+ rl.question(chalk.bold('Enter project name (leave empty for random): '), (answer) => {
240
+ rl.close();
241
+ resolve(answer.trim());
242
+ });
243
+ });
244
+ }
245
+
246
+ function loadIgnoreRules(targetDir) {
247
+ const ig = ignore();
248
+ const ignoreFile = path.join(targetDir, '.jetrignore');
249
+
250
+ if (fs.existsSync(ignoreFile)) {
251
+ const content = fs.readFileSync(ignoreFile, 'utf-8');
252
+ ig.add(content);
253
+ }
254
+
255
+ return ig;
256
+ }
257
+
258
+ async function createZip(targetDir, ig, singleFile = null) {
259
+ const tmpFile = path.join(targetDir, `.jetr-upload-${Date.now()}.zip`);
260
+
261
+ return new Promise((resolve, reject) => {
262
+ const output = fs.createWriteStream(tmpFile);
263
+ const archive = archiver('zip', { zlib: { level: 9 } });
264
+ let fileCount = 0;
265
+
266
+ const cleanup = (err) => {
267
+ if (fs.existsSync(tmpFile)) {
268
+ try { fs.unlinkSync(tmpFile); } catch {}
269
+ }
270
+ reject(err);
271
+ };
272
+
273
+ output.on('close', () => {
274
+ if (fileCount === 0) {
275
+ cleanup(new Error('No files to upload (directory is empty or all files are ignored)'));
276
+ } else {
277
+ resolve({ zipPath: tmpFile, fileCount });
278
+ }
279
+ });
280
+
281
+ output.on('error', cleanup);
282
+ archive.on('error', cleanup);
283
+ archive.pipe(output);
284
+
285
+ if (singleFile) {
286
+ // Single file mode: just add the one file
287
+ const fileName = path.basename(singleFile);
288
+ archive.file(singleFile, { name: fileName });
289
+ fileCount = 1;
290
+ } else {
291
+ // Directory mode: add all files respecting .jetrignore
292
+ const addFiles = (dir, base = '') => {
293
+ let entries;
294
+ try {
295
+ entries = fs.readdirSync(dir, { withFileTypes: true });
296
+ } catch (err) {
297
+ return; // Skip directories we can't read
298
+ }
299
+
300
+ for (const entry of entries) {
301
+ const relativePath = path.join(base, entry.name);
302
+ const fullPath = path.join(dir, entry.name);
303
+
304
+ // Skip temp zip files, .jetrignore and .jetrrc
305
+ if (entry.name.startsWith('.jetr-upload-') && entry.name.endsWith('.zip')) {
306
+ continue;
307
+ }
308
+ if (entry.name === '.jetrignore' || entry.name === '.jetrrc') {
309
+ continue;
310
+ }
311
+
312
+ // Skip ignored files/directories
313
+ if (ig.ignores(relativePath) || ig.ignores(relativePath + '/')) {
314
+ continue;
315
+ }
316
+
317
+ // Skip symbolic links to avoid potential loops
318
+ if (entry.isSymbolicLink()) {
319
+ continue;
320
+ }
321
+
322
+ if (entry.isDirectory()) {
323
+ addFiles(fullPath, relativePath);
324
+ } else if (entry.isFile()) {
325
+ archive.file(fullPath, { name: relativePath });
326
+ fileCount++;
327
+ }
328
+ }
329
+ };
330
+
331
+ addFiles(targetDir);
332
+ }
333
+
334
+ archive.finalize();
335
+ });
336
+ }
337
+
338
+ async function uploadOnce(zipPath, projectName) {
339
+ const form = new FormData();
340
+ form.append('projectName', projectName);
341
+ form.append('file', fs.createReadStream(zipPath), {
342
+ filename: path.basename(zipPath),
343
+ contentType: 'application/zip',
344
+ });
345
+
346
+ return new Promise((resolve, reject) => {
347
+ const request = form.submit(API_URL, (err, res) => {
348
+ if (err) {
349
+ reject(err);
350
+ return;
351
+ }
352
+
353
+ let data = '';
354
+ res.on('data', (chunk) => {
355
+ data += chunk;
356
+ });
357
+
358
+ res.on('end', () => {
359
+ if (res.statusCode >= 200 && res.statusCode < 300) {
360
+ try {
361
+ resolve(JSON.parse(data));
362
+ } catch (e) {
363
+ reject(new Error(`Invalid JSON response: ${data}`));
364
+ }
365
+ } else {
366
+ reject(new Error(`Upload failed: ${res.statusCode} ${res.statusMessage}\n${data}`));
367
+ }
368
+ });
369
+
370
+ res.on('error', reject);
371
+ });
372
+
373
+ request.on('error', reject);
374
+ });
375
+ }
376
+
377
+ async function upload(zipPath, projectName, maxRetries = 3) {
378
+ let lastError;
379
+
380
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
381
+ try {
382
+ return await uploadOnce(zipPath, projectName);
383
+ } catch (err) {
384
+ lastError = err;
385
+ const isRetryable = ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EPIPE', 'EAI_AGAIN'].includes(err.code);
386
+
387
+ if (isRetryable && attempt < maxRetries) {
388
+ const delay = attempt * 2000;
389
+ console.log(chalk.yellow(`\nConnection error (${err.code}), retrying in ${delay / 1000}s... (${attempt}/${maxRetries})`));
390
+ await new Promise(r => setTimeout(r, delay));
391
+ } else {
392
+ break;
393
+ }
394
+ }
395
+ }
396
+
397
+ throw lastError;
398
+ }
399
+
400
+ export async function run() {
401
+ const args = process.argv.slice(2);
402
+
403
+ if (args.includes('-h') || args.includes('--help')) {
404
+ showHelp();
405
+ process.exit(0);
406
+ }
407
+
408
+ if (args.includes('-v') || args.includes('--version')) {
409
+ showVersion();
410
+ process.exit(0);
411
+ }
412
+
413
+ if (args[0] === 'init') {
414
+ const targetDir = path.resolve(args[1] || './');
415
+ initJetrignore(targetDir);
416
+ process.exit(0);
417
+ }
418
+
419
+ const inputPath = path.resolve(args[0] || './');
420
+ let projectName = args[1] || '';
421
+ let targetDir;
422
+ let singleFile = null;
423
+
424
+ if (!fs.existsSync(inputPath)) {
425
+ console.error(chalk.red(`Error: Path does not exist: ${inputPath}`));
426
+ process.exit(1);
427
+ }
428
+
429
+ const stats = fs.statSync(inputPath);
430
+
431
+ if (stats.isFile()) {
432
+ // Single file mode: treat the file as a standalone project
433
+ singleFile = inputPath;
434
+ targetDir = path.dirname(inputPath);
435
+ console.log(chalk.gray(`Single file mode: ${path.basename(inputPath)}`));
436
+ } else if (stats.isDirectory()) {
437
+ targetDir = inputPath;
438
+ } else {
439
+ console.error(chalk.red(`Error: Invalid path: ${inputPath}`));
440
+ process.exit(1);
441
+ }
442
+
443
+ // Auto-create .jetrignore if it doesn't exist (only for directory mode)
444
+ if (!singleFile) {
445
+ const created = initJetrignore(targetDir, true);
446
+ if (created) {
447
+ console.log(chalk.gray('Auto-created .jetrignore with default patterns'));
448
+ }
449
+ }
450
+
451
+ // Load config
452
+ const config = loadConfig(targetDir);
453
+ const configKey = getConfigKey(singleFile);
454
+ const savedProject = getProjectFromConfig(config, configKey);
455
+
456
+ if (projectName) {
457
+ // User specified a project name
458
+ projectName = sanitizeProjectName(projectName);
459
+ } else if (savedProject) {
460
+ // Have saved config, let user choose
461
+ const history = savedProject.history || [savedProject.default];
462
+ // Put default first, then rest of history
463
+ const options = [savedProject.default, ...history.filter(h => h !== savedProject.default)];
464
+
465
+ if (options.length === 1) {
466
+ // Only one option, use it directly
467
+ projectName = options[0];
468
+ console.log(chalk.gray(`Using saved project: ${projectName}`));
469
+ } else {
470
+ // Multiple options, prompt user
471
+ const selection = await promptSelection(options, 'Previous deployments found:');
472
+ if (selection.type === 'existing') {
473
+ projectName = selection.value;
474
+ } else {
475
+ const newName = await promptNewName();
476
+ projectName = newName ? sanitizeProjectName(newName) : generateProjectName();
477
+ console.log(chalk.gray(`Using project name: ${projectName}`));
478
+ }
479
+ }
480
+ } else {
481
+ // No config, generate random name
482
+ projectName = generateProjectName();
483
+ console.log(chalk.gray(`Generated project name: ${projectName}`));
484
+ }
485
+
486
+ const ig = loadIgnoreRules(targetDir);
487
+
488
+ const spinner = ora('Packing files...').start();
489
+ let zipPath;
490
+ let fileCount;
491
+
492
+ try {
493
+ const result = await createZip(targetDir, ig, singleFile);
494
+ zipPath = result.zipPath;
495
+ fileCount = result.fileCount;
496
+ const stats = fs.statSync(zipPath);
497
+ const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
498
+ spinner.succeed(`Packed ${fileCount} file${fileCount > 1 ? 's' : ''} (${sizeMB} MB)`);
499
+ } catch (err) {
500
+ spinner.fail('Failed to pack files');
501
+ console.error(chalk.red(err.message));
502
+ process.exit(1);
503
+ }
504
+
505
+ const uploadSpinner = ora(`Uploading to ${projectName}...`).start();
506
+
507
+ try {
508
+ const result = await upload(zipPath, projectName);
509
+
510
+ fs.unlinkSync(zipPath);
511
+
512
+ if (result.success) {
513
+ uploadSpinner.succeed('Deployed successfully!');
514
+
515
+ // Save config
516
+ const updatedConfig = updateConfig(config, configKey, projectName);
517
+ saveConfig(targetDir, updatedConfig);
518
+
519
+ console.log();
520
+ console.log(chalk.bold('Project: ') + chalk.cyan(result.projectName));
521
+ console.log(chalk.bold('URL: ') + chalk.green(result.defaultUrl || result.url));
522
+ console.log();
523
+ } else {
524
+ uploadSpinner.fail('Deployment failed');
525
+ console.error(chalk.red(JSON.stringify(result, null, 2)));
526
+ process.exit(1);
527
+ }
528
+ } catch (err) {
529
+ uploadSpinner.fail('Upload failed');
530
+
531
+ if (zipPath && fs.existsSync(zipPath)) {
532
+ fs.unlinkSync(zipPath);
533
+ }
534
+
535
+ console.error(chalk.red(err.message));
536
+ if (err.code) {
537
+ console.error(chalk.gray(`Error code: ${err.code}`));
538
+ }
539
+ if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT') {
540
+ console.error(chalk.yellow('\nTip: This might be a network issue or the file is too large.'));
541
+ console.error(chalk.yellow('Try again or check your internet connection.'));
542
+ }
543
+ process.exit(1);
544
+ }
545
+ }