@within-7/jetr 0.0.3 → 0.1.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 CHANGED
@@ -1,51 +1,139 @@
1
- # jetr
1
+ # @within-7/jetr
2
2
 
3
- CLI tool for deploying static websites instantly.
3
+ Internal static site hosting CLI. Deploy static files to Cloudflare R2 and get a `{name}.jetr.within-7.com` subdomain.
4
4
 
5
- ## Installation
5
+ ## Install
6
6
 
7
7
  ```bash
8
- npm install -g @within-7/jetr
8
+ npm i -g @within-7/jetr
9
9
  ```
10
10
 
11
- ## Usage
11
+ Or use directly:
12
12
 
13
13
  ```bash
14
- # Deploy current directory with random project name
15
- jetr ./
14
+ npx @within-7/jetr deploy my-site ./dist
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```bash
20
+ # 1. Login with your JWT token (from auth-gateway)
21
+ jetr login <token>
22
+
23
+ # 2. Create a site
24
+ jetr create my-blog
25
+
26
+ # 3. Deploy
27
+ jetr deploy my-blog ./dist
28
+ # ✔ Deployed!
29
+ # URL: https://my-blog.jetr.within-7.com
30
+ # Uploaded: 12 files
31
+ # Size: 1.2 MB
32
+ ```
33
+
34
+ ## Commands
35
+
36
+ ### `jetr login <token>`
37
+
38
+ Save JWT token for authentication. Token is stored in `~/.jetr/config.json`.
39
+
40
+ ```bash
41
+ jetr login eyJhbGciOiJIUzI1NiJ9...
42
+ jetr login <token> --api-url https://custom-api.example.com
43
+ ```
44
+
45
+ ### `jetr create <name>`
46
+
47
+ Create a new site. Name becomes the subdomain: `{name}.jetr.within-7.com`.
48
+
49
+ ```bash
50
+ jetr create my-blog
51
+ jetr create preview-site --password secret123
52
+ jetr create temp-demo --expires 86400 # expires in 24h
53
+ ```
54
+
55
+ ### `jetr deploy <name> [directory]`
56
+
57
+ Deploy a directory to a site. Uses incremental upload — only changed files are uploaded.
58
+
59
+ ```bash
60
+ jetr deploy my-blog ./dist
61
+ jetr deploy my-blog . # deploy current directory
62
+ jetr deploy my-blog # defaults to current directory
63
+ ```
64
+
65
+ ### `jetr list`
16
66
 
17
- # Deploy current directory with specific project name
18
- jetr ./ my-site
67
+ List your sites.
19
68
 
20
- # Deploy a specific directory
21
- jetr ./dist
22
- jetr ./dist my-app
69
+ ```bash
70
+ jetr list
71
+ jetr ls
72
+ ```
73
+
74
+ ### `jetr info <name>`
23
75
 
24
- # Deploy a single file
25
- jetr ./index.html
26
- jetr ./demo.html my-demo
76
+ Show site details including files and share tokens.
27
77
 
28
- # Generate .jetrignore file
29
- jetr init
78
+ ```bash
79
+ jetr info my-blog
30
80
  ```
31
81
 
32
- After deployment, your site will be available at `http://<projectName>.statics.within-7.com`.
82
+ ### `jetr delete <name>`
33
83
 
34
- ## .jetrignore
84
+ Delete a site and all its files.
35
85
 
36
- Create a `.jetrignore` file in your project root to exclude files from deployment. It uses the same syntax as `.gitignore`.
86
+ ```bash
87
+ jetr delete my-blog
88
+ jetr rm my-blog
89
+ ```
37
90
 
38
- If no `.jetrignore` exists, one will be auto-created with common defaults (node_modules, .git, .env, etc.).
91
+ ### `jetr password <name> [password]`
39
92
 
40
- Run `jetr init` to manually generate a `.jetrignore` with default patterns.
93
+ Set or remove site password.
41
94
 
42
- ## Options
95
+ ```bash
96
+ jetr password my-blog secret123 # set password
97
+ jetr password my-blog # remove password
98
+ ```
99
+
100
+ ### `jetr token create <site>`
43
101
 
102
+ Create a share token for password-protected sites.
103
+
104
+ ```bash
105
+ jetr token create my-blog --note "For QA team"
106
+ jetr token create my-blog --expires 3600 # 1 hour
44
107
  ```
45
- -h, --help Show help message
46
- -v, --version Show version number
108
+
109
+ ### `jetr token revoke <site> <id>`
110
+
111
+ Revoke a share token.
112
+
113
+ ```bash
114
+ jetr token revoke my-blog KTEaMnomEOPHlZizIvDNL
47
115
  ```
48
116
 
49
- ## License
117
+ ### `jetr whoami`
118
+
119
+ Show current config (API URL and token).
120
+
121
+ ## How It Works
50
122
 
51
- MIT
123
+ 1. **`jetr deploy`** scans the directory and computes SHA-256 hashes for each file
124
+ 2. Sends the file manifest to the API, which diffs against the current state
125
+ 3. Only uploads files that changed (incremental deploy)
126
+ 4. Finalizes the deploy — old files are cleaned up, new files go live
127
+
128
+ This means re-deploying a large site with one changed file only uploads that one file.
129
+
130
+ ## Config
131
+
132
+ Config is stored at `~/.jetr/config.json`:
133
+
134
+ ```json
135
+ {
136
+ "apiUrl": "https://jetr-api.lixilei.workers.dev",
137
+ "token": "eyJhbG..."
138
+ }
139
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+ import chalk from "chalk";
6
+ import ora from "ora";
7
+
8
+ // src/config.ts
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
10
+ import { join } from "path";
11
+ import { homedir } from "os";
12
+ var CONFIG_DIR = join(homedir(), ".jetr");
13
+ var CONFIG_FILE = join(CONFIG_DIR, "config.json");
14
+ var DEFAULT_CONFIG = {
15
+ apiUrl: "https://jetr-api.lixilei.workers.dev"
16
+ };
17
+ function loadConfig() {
18
+ if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
19
+ try {
20
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
21
+ return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
22
+ } catch {
23
+ return { ...DEFAULT_CONFIG };
24
+ }
25
+ }
26
+ function saveConfig(config) {
27
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
28
+ const current = loadConfig();
29
+ const merged = { ...current, ...config };
30
+ writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + "\n");
31
+ }
32
+ function getToken() {
33
+ const config = loadConfig();
34
+ if (!config.token) {
35
+ console.error("Not logged in. Run: jetr login");
36
+ process.exit(1);
37
+ }
38
+ return config.token;
39
+ }
40
+
41
+ // src/api.ts
42
+ async function request(path, options = {}) {
43
+ const config = loadConfig();
44
+ const url = `${config.apiUrl}${path}`;
45
+ const headers = {
46
+ Authorization: `Bearer ${getToken()}`,
47
+ ...options.headers || {}
48
+ };
49
+ const res = await fetch(url, { ...options, headers });
50
+ return res;
51
+ }
52
+ async function json(path, options = {}) {
53
+ const res = await request(path, options);
54
+ const body = await res.json();
55
+ if (!res.ok) {
56
+ throw new Error(body.error || `API error: ${res.status}`);
57
+ }
58
+ return body;
59
+ }
60
+ var api = {
61
+ async createSite(name, opts) {
62
+ return json("/sites", {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({ name, ...opts })
66
+ });
67
+ },
68
+ async listSites() {
69
+ return json("/sites");
70
+ },
71
+ async getSite(name) {
72
+ return json(`/sites/${name}`);
73
+ },
74
+ async updateSite(name, opts) {
75
+ return json(`/sites/${name}`, {
76
+ method: "PATCH",
77
+ headers: { "Content-Type": "application/json" },
78
+ body: JSON.stringify(opts)
79
+ });
80
+ },
81
+ async deleteSite(name) {
82
+ const res = await request(`/sites/${name}`, { method: "DELETE" });
83
+ if (!res.ok) {
84
+ const body = await res.json();
85
+ throw new Error(body.error || `Delete failed: ${res.status}`);
86
+ }
87
+ },
88
+ async deployDiff(name, files) {
89
+ return json(`/sites/${name}/deploy`, {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({ files })
93
+ });
94
+ },
95
+ async uploadFile(name, filePath, body, deployId, hash) {
96
+ const res = await request(`/sites/${name}/files/${filePath}`, {
97
+ method: "PUT",
98
+ headers: {
99
+ "Content-Type": "application/octet-stream",
100
+ "X-Deploy-Id": deployId,
101
+ "X-File-Hash": hash
102
+ },
103
+ body
104
+ });
105
+ if (!res.ok) {
106
+ const data = await res.json();
107
+ throw new Error(`Upload ${filePath}: ${data.error}`);
108
+ }
109
+ },
110
+ async finalize(name, deployId) {
111
+ return json(`/sites/${name}/deploy/${deployId}/finalize`, { method: "POST" });
112
+ },
113
+ async createToken(name, opts) {
114
+ return json(`/sites/${name}/tokens`, {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify(opts || {})
118
+ });
119
+ },
120
+ async revokeToken(name, tokenId) {
121
+ const res = await request(`/sites/${name}/tokens/${tokenId}`, { method: "DELETE" });
122
+ if (!res.ok) {
123
+ const body = await res.json();
124
+ throw new Error(body.error);
125
+ }
126
+ }
127
+ };
128
+
129
+ // src/deploy.ts
130
+ import { createHash } from "crypto";
131
+ import { readFileSync as readFileSync2, statSync } from "fs";
132
+ import { resolve, join as join2, posix } from "path";
133
+ import { glob } from "glob";
134
+ function hashFile(filePath) {
135
+ const content = readFileSync2(filePath);
136
+ const hash = createHash("sha256").update(content).digest("hex");
137
+ return `sha256:${hash}`;
138
+ }
139
+ async function deploy(siteName, directory, onProgress) {
140
+ const absDir = resolve(directory);
141
+ onProgress?.("Scanning files...");
142
+ const filePaths = await glob("**/*", {
143
+ cwd: absDir,
144
+ nodir: true,
145
+ dot: false
146
+ });
147
+ if (filePaths.length === 0) {
148
+ throw new Error(`No files found in ${directory}`);
149
+ }
150
+ const manifest = {};
151
+ for (const fp of filePaths) {
152
+ const absPath = join2(absDir, fp);
153
+ const hash = hashFile(absPath);
154
+ const size = statSync(absPath).size;
155
+ const normalizedPath = fp.split("/").join(posix.sep);
156
+ manifest[normalizedPath] = { hash, size };
157
+ }
158
+ onProgress?.(`Found ${filePaths.length} files`);
159
+ onProgress?.("Computing diff...");
160
+ const diff = await api.deployDiff(siteName, manifest);
161
+ onProgress?.(
162
+ `Upload: ${diff.upload.length}, Delete: ${diff.delete.length}, Unchanged: ${diff.unchanged.length}`
163
+ );
164
+ if (diff.upload.length > 0) {
165
+ for (let i = 0; i < diff.upload.length; i++) {
166
+ const fp = diff.upload[i];
167
+ const absPath = join2(absDir, fp);
168
+ const content = readFileSync2(absPath);
169
+ onProgress?.(`Uploading (${i + 1}/${diff.upload.length}) ${fp}`);
170
+ await api.uploadFile(siteName, fp, content, diff.deploy_id, manifest[fp].hash);
171
+ }
172
+ }
173
+ onProgress?.("Finalizing...");
174
+ const result = await api.finalize(siteName, diff.deploy_id);
175
+ return {
176
+ url: `https://${siteName}.jetr.within-7.com`,
177
+ filesUploaded: result.files_uploaded,
178
+ filesDeleted: result.files_deleted,
179
+ filesUnchanged: diff.unchanged.length,
180
+ totalSize: result.total_size
181
+ };
182
+ }
183
+
184
+ // src/cli.ts
185
+ var program = new Command();
186
+ program.name("jetr").description("CLI for Jetr static site hosting").version("0.1.0");
187
+ program.command("login").description("Save JWT token for authentication").argument("<token>", "JWT token from auth-gateway login").option("--api-url <url>", "API base URL").action((token, opts) => {
188
+ saveConfig({ token, ...opts.apiUrl ? { apiUrl: opts.apiUrl } : {} });
189
+ console.log(chalk.green("\u2713 Token saved to ~/.jetr/config.json"));
190
+ });
191
+ program.command("whoami").description("Show current config").action(() => {
192
+ const config = loadConfig();
193
+ console.log(`API: ${config.apiUrl}`);
194
+ console.log(`Token: ${config.token ? config.token.slice(0, 20) + "..." : chalk.red("not set")}`);
195
+ });
196
+ program.command("create").description("Create a new site").argument("<name>", "Site name (becomes {name}.jetr.within-7.com)").option("-p, --password <password>", "Set access password").option("-e, --expires <seconds>", "Expire after N seconds", parseInt).action(async (name, opts) => {
197
+ const spinner = ora("Creating site...").start();
198
+ try {
199
+ const site = await api.createSite(name, {
200
+ password: opts.password,
201
+ expires_in: opts.expires
202
+ });
203
+ spinner.succeed(`Created ${chalk.bold(site.name)}`);
204
+ console.log(` URL: ${chalk.cyan(site.url)}`);
205
+ if (site.password_protected) console.log(` Password: ${chalk.yellow("enabled")}`);
206
+ if (site.expires_at) console.log(` Expires: ${new Date(site.expires_at * 1e3).toISOString()}`);
207
+ } catch (e) {
208
+ spinner.fail(e.message);
209
+ process.exit(1);
210
+ }
211
+ });
212
+ program.command("deploy").description("Deploy a directory to a site").argument("<name>", "Site name").argument("[directory]", "Directory to deploy", ".").action(async (name, directory) => {
213
+ const spinner = ora("").start();
214
+ try {
215
+ const result = await deploy(name, directory, (msg) => {
216
+ spinner.text = msg;
217
+ });
218
+ spinner.succeed("Deployed!");
219
+ console.log(` URL: ${chalk.cyan(result.url)}`);
220
+ console.log(` Uploaded: ${result.filesUploaded} files`);
221
+ if (result.filesDeleted > 0) console.log(` Deleted: ${result.filesDeleted} files`);
222
+ if (result.filesUnchanged > 0) console.log(` Unchanged: ${result.filesUnchanged} files`);
223
+ console.log(` Size: ${formatBytes(result.totalSize)}`);
224
+ } catch (e) {
225
+ spinner.fail(e.message);
226
+ process.exit(1);
227
+ }
228
+ });
229
+ program.command("list").alias("ls").description("List your sites").action(async () => {
230
+ try {
231
+ const { sites } = await api.listSites();
232
+ if (sites.length === 0) {
233
+ console.log(chalk.dim("No sites yet. Create one: jetr create <name>"));
234
+ return;
235
+ }
236
+ for (const s of sites) {
237
+ const lock = s.password_protected ? chalk.yellow(" \u{1F512}") : "";
238
+ const expire = s.expires_at ? chalk.dim(` expires ${new Date(s.expires_at * 1e3).toLocaleDateString()}`) : "";
239
+ console.log(
240
+ ` ${chalk.bold(s.name)}${lock}${expire} ${chalk.dim(formatBytes(s.total_size))} ${chalk.cyan(s.url)}`
241
+ );
242
+ }
243
+ console.log(chalk.dim(`
244
+ ${sites.length} site(s)`));
245
+ } catch (e) {
246
+ console.error(chalk.red(e.message));
247
+ process.exit(1);
248
+ }
249
+ });
250
+ program.command("info").description("Show site details").argument("<name>", "Site name").action(async (name) => {
251
+ try {
252
+ const site = await api.getSite(name);
253
+ console.log(`${chalk.bold(site.name)}`);
254
+ console.log(` URL: ${chalk.cyan(site.url)}`);
255
+ console.log(` Password: ${site.password_protected ? chalk.yellow("yes") : "no"}`);
256
+ console.log(` Expires: ${site.expires_at ? new Date(site.expires_at * 1e3).toISOString() : "never"}`);
257
+ console.log(` Files: ${site.files.length}`);
258
+ console.log(` Created: ${new Date(site.created_at * 1e3).toISOString()}`);
259
+ console.log(` Updated: ${new Date(site.updated_at * 1e3).toISOString()}`);
260
+ if (site.files.length > 0) {
261
+ console.log(`
262
+ ${chalk.dim("Files:")}`);
263
+ for (const f of site.files) {
264
+ console.log(` ${f.path} ${chalk.dim(formatBytes(f.size))}`);
265
+ }
266
+ }
267
+ if (site.tokens.length > 0) {
268
+ console.log(`
269
+ ${chalk.dim("Tokens:")}`);
270
+ for (const t of site.tokens) {
271
+ const note = t.note ? ` (${t.note})` : "";
272
+ const exp = t.expires_at ? ` expires ${new Date(t.expires_at * 1e3).toLocaleDateString()}` : "";
273
+ console.log(` ${t.id}${note}${chalk.dim(exp)}`);
274
+ }
275
+ }
276
+ } catch (e) {
277
+ console.error(chalk.red(e.message));
278
+ process.exit(1);
279
+ }
280
+ });
281
+ program.command("delete").alias("rm").description("Delete a site and all its files").argument("<name>", "Site name").action(async (name) => {
282
+ const spinner = ora(`Deleting ${name}...`).start();
283
+ try {
284
+ await api.deleteSite(name);
285
+ spinner.succeed(`Deleted ${chalk.bold(name)}`);
286
+ } catch (e) {
287
+ spinner.fail(e.message);
288
+ process.exit(1);
289
+ }
290
+ });
291
+ program.command("password").description("Set or remove site password").argument("<name>", "Site name").argument("[password]", "New password (omit to remove)").action(async (name, password) => {
292
+ try {
293
+ const site = await api.updateSite(name, { password: password ?? null });
294
+ if (site.password_protected) {
295
+ console.log(chalk.green(`\u2713 Password set for ${chalk.bold(name)}`));
296
+ } else {
297
+ console.log(chalk.green(`\u2713 Password removed from ${chalk.bold(name)}`));
298
+ }
299
+ } catch (e) {
300
+ console.error(chalk.red(e.message));
301
+ process.exit(1);
302
+ }
303
+ });
304
+ var tokenCmd = program.command("token").description("Manage share tokens");
305
+ tokenCmd.command("create").description("Create a share token").argument("<site>", "Site name").option("-n, --note <note>", "Token note").option("-e, --expires <seconds>", "Expire after N seconds", parseInt).action(async (site, opts) => {
306
+ try {
307
+ const token = await api.createToken(site, {
308
+ note: opts.note,
309
+ expires_in: opts.expires
310
+ });
311
+ console.log(chalk.green("\u2713 Token created"));
312
+ console.log(` ID: ${token.id}`);
313
+ console.log(` URL: ${chalk.cyan(token.url)}`);
314
+ if (token.note) console.log(` Note: ${token.note}`);
315
+ if (token.expires_at) console.log(` Expires: ${new Date(token.expires_at * 1e3).toISOString()}`);
316
+ } catch (e) {
317
+ console.error(chalk.red(e.message));
318
+ process.exit(1);
319
+ }
320
+ });
321
+ tokenCmd.command("revoke").description("Revoke a share token").argument("<site>", "Site name").argument("<id>", "Token ID").action(async (site, id) => {
322
+ try {
323
+ await api.revokeToken(site, id);
324
+ console.log(chalk.green(`\u2713 Token ${id} revoked`));
325
+ } catch (e) {
326
+ console.error(chalk.red(e.message));
327
+ process.exit(1);
328
+ }
329
+ });
330
+ function formatBytes(bytes) {
331
+ if (bytes === 0) return "0 B";
332
+ const units = ["B", "KB", "MB", "GB"];
333
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
334
+ return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
335
+ }
336
+ program.parse();
package/package.json CHANGED
@@ -1,48 +1,34 @@
1
1
  {
2
2
  "name": "@within-7/jetr",
3
- "version": "0.0.3",
4
- "description": "CLI tool for deploying static websites instantly",
5
- "main": "src/index.js",
3
+ "version": "0.1.1",
4
+ "description": "CLI for Jetr static site hosting",
5
+ "type": "module",
6
6
  "bin": {
7
- "jetr": "./bin/jetr.js"
7
+ "jetr": "./dist/cli.js"
8
8
  },
9
- "files": [
10
- "bin/",
11
- "src/"
12
- ],
9
+ "files": ["dist", "README.md"],
13
10
  "scripts": {
14
- "test": "node bin/jetr.js --help",
15
- "link": "npm link --force",
16
- "unlink": "npm unlink -g @within-7/jetr",
17
- "publish:release": "node scripts/publish.cjs"
11
+ "build": "tsup src/cli.ts --format esm --target node20 --clean",
12
+ "dev": "tsx src/cli.ts",
13
+ "prepublishOnly": "pnpm build"
18
14
  },
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/Within-7/jetr.git"
22
- },
23
- "bugs": {
24
- "url": "https://github.com/Within-7/jetr/issues"
25
- },
26
- "homepage": "https://github.com/Within-7/jetr#readme",
27
- "keywords": [
28
- "static",
29
- "deploy",
30
- "hosting",
31
- "cli",
32
- "surge"
33
- ],
34
- "author": "",
35
- "license": "MIT",
36
15
  "dependencies": {
37
- "archiver": "^7.0.1",
38
- "chalk": "^5.3.0",
39
- "form-data": "^4.0.1",
40
- "ignore": "^6.0.2",
41
- "nanoid": "^5.0.9",
42
- "ora": "^8.1.1"
16
+ "commander": "^13",
17
+ "chalk": "^5",
18
+ "ora": "^8",
19
+ "glob": "^11"
20
+ },
21
+ "devDependencies": {
22
+ "tsup": "^8",
23
+ "tsx": "^4",
24
+ "typescript": "^5",
25
+ "@types/node": "^22"
43
26
  },
44
27
  "engines": {
45
- "node": ">=18.0.0"
28
+ "node": ">=20"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
46
32
  },
47
- "type": "module"
33
+ "license": "MIT"
48
34
  }
package/bin/jetr.js DELETED
@@ -1,5 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { run } from '../src/index.js';
4
-
5
- run();
package/src/index.js DELETED
@@ -1,565 +0,0 @@
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 API_URL = 'https://api-statics.within-7.com/api/deploy/s3';
17
- const CONFIG_FILE = '.jetrrc';
18
-
19
- const DEFAULT_JETRIGNORE = `# Dependencies
20
- node_modules/
21
- .pnp
22
- .pnp.js
23
-
24
- # Build outputs
25
- dist/
26
- build/
27
- out/
28
- .next/
29
- .nuxt/
30
- .output/
31
- .cache/
32
-
33
- # Logs
34
- *.log
35
- npm-debug.log*
36
- yarn-debug.log*
37
- yarn-error.log*
38
- pnpm-debug.log*
39
-
40
- # Environment
41
- .env
42
- .env.*
43
- .env.local
44
- .env.*.local
45
-
46
- # IDE & Editor
47
- .vscode/
48
- .idea/
49
- *.swp
50
- *.swo
51
- *~
52
- .project
53
- .classpath
54
- .settings/
55
-
56
- # OS files
57
- .DS_Store
58
- Thumbs.db
59
- desktop.ini
60
-
61
- # Git
62
- .git/
63
- .gitignore
64
-
65
- # Testing
66
- coverage/
67
- .nyc_output/
68
-
69
- # Package managers
70
- package-lock.json
71
- yarn.lock
72
- pnpm-lock.yaml
73
-
74
- # Misc
75
- *.zip
76
- *.tar.gz
77
- `;
78
-
79
- function initJetrignore(targetDir, silent = false) {
80
- const ignoreFile = path.join(targetDir, '.jetrignore');
81
-
82
- if (fs.existsSync(ignoreFile)) {
83
- if (!silent) {
84
- console.log(chalk.yellow('.jetrignore already exists in this directory'));
85
- }
86
- return false;
87
- }
88
-
89
- fs.writeFileSync(ignoreFile, DEFAULT_JETRIGNORE);
90
- if (!silent) {
91
- console.log(chalk.green('Created .jetrignore with default ignore patterns'));
92
- }
93
- return true;
94
- }
95
-
96
- function showHelp() {
97
- console.log(`
98
- ${chalk.bold('jetr')} - Deploy static websites instantly
99
-
100
- ${chalk.bold('Usage:')}
101
- jetr <directory> [projectName]
102
-
103
- ${chalk.bold('Examples:')}
104
- jetr ./ # Deploy current directory with random name
105
- jetr ./ my-site # Deploy current directory as my-site
106
- jetr ../app # Deploy ../app with random name
107
- jetr ./dist production # Deploy ./dist as production
108
-
109
- ${chalk.bold('Commands:')}
110
- init Create a .jetrignore file with default patterns
111
-
112
- ${chalk.bold('Options:')}
113
- -h, --help Show this help message
114
- -v, --version Show version number
115
-
116
- ${chalk.bold('.jetrignore:')}
117
- Create a .jetrignore file in your project root to exclude files.
118
- Uses the same syntax as .gitignore.
119
- Run "jetr init" to generate one with common defaults.
120
-
121
- ${chalk.bold('.jetrkeep:')}
122
- Create a .jetrkeep file to force-include files that match .jetrignore patterns.
123
- Uses the same syntax as .gitignore. Patterns in .jetrkeep override .jetrignore.
124
- `);
125
- }
126
-
127
- function showVersion() {
128
- const pkgPath = path.join(__dirname, '..', 'package.json');
129
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
130
- console.log(pkg.version);
131
- }
132
-
133
- function generateProjectName() {
134
- return nanoid(12).toLowerCase().replace(/[^a-z0-9]/g, '');
135
- }
136
-
137
- function sanitizeProjectName(name) {
138
- const sanitized = name
139
- .toLowerCase()
140
- .replace(/[^a-z0-9-]/g, '-')
141
- .replace(/^-+|-+$/g, '')
142
- .replace(/-+/g, '-');
143
-
144
- // If sanitization results in empty string, generate a random name
145
- if (!sanitized) {
146
- return generateProjectName();
147
- }
148
-
149
- return sanitized;
150
- }
151
-
152
- // ============ Config file functions ============
153
-
154
- function loadConfig(targetDir) {
155
- const configPath = path.join(targetDir, CONFIG_FILE);
156
- if (fs.existsSync(configPath)) {
157
- try {
158
- return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
159
- } catch {
160
- return { directory: null, files: {} };
161
- }
162
- }
163
- return { directory: null, files: {} };
164
- }
165
-
166
- function saveConfig(targetDir, config) {
167
- const configPath = path.join(targetDir, CONFIG_FILE);
168
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
169
- }
170
-
171
- function getConfigKey(singleFile) {
172
- // For directory mode, use 'directory'; for single file, use filename
173
- return singleFile ? path.basename(singleFile) : 'directory';
174
- }
175
-
176
- function getProjectFromConfig(config, key) {
177
- if (key === 'directory') {
178
- return config.directory;
179
- }
180
- return config.files?.[key] || null;
181
- }
182
-
183
- function updateConfig(config, key, projectName) {
184
- if (key === 'directory') {
185
- if (!config.directory) {
186
- config.directory = { default: projectName, history: [projectName] };
187
- } else {
188
- config.directory.default = projectName;
189
- if (!config.directory.history.includes(projectName)) {
190
- config.directory.history.push(projectName);
191
- }
192
- }
193
- } else {
194
- if (!config.files) config.files = {};
195
- if (!config.files[key]) {
196
- config.files[key] = { default: projectName, history: [projectName] };
197
- } else {
198
- config.files[key].default = projectName;
199
- if (!config.files[key].history.includes(projectName)) {
200
- config.files[key].history.push(projectName);
201
- }
202
- }
203
- }
204
- return config;
205
- }
206
-
207
- async function promptSelection(options, message) {
208
- const rl = readline.createInterface({
209
- input: process.stdin,
210
- output: process.stdout,
211
- });
212
-
213
- console.log();
214
- console.log(chalk.bold(message));
215
- options.forEach((opt, i) => {
216
- const marker = i === 0 ? chalk.green('(default)') : '';
217
- console.log(` ${chalk.cyan(i + 1)}) ${opt} ${marker}`);
218
- });
219
- console.log(` ${chalk.cyan(options.length + 1)}) ${chalk.gray('Create new project')}`);
220
- console.log();
221
-
222
- return new Promise((resolve) => {
223
- rl.question(chalk.bold('Select option [1]: '), (answer) => {
224
- rl.close();
225
- const num = parseInt(answer, 10);
226
- if (!answer || isNaN(num) || num < 1) {
227
- resolve({ type: 'existing', value: options[0] });
228
- } else if (num <= options.length) {
229
- resolve({ type: 'existing', value: options[num - 1] });
230
- } else {
231
- resolve({ type: 'new', value: null });
232
- }
233
- });
234
- });
235
- }
236
-
237
- async function promptNewName() {
238
- const rl = readline.createInterface({
239
- input: process.stdin,
240
- output: process.stdout,
241
- });
242
-
243
- return new Promise((resolve) => {
244
- rl.question(chalk.bold('Enter project name (leave empty for random): '), (answer) => {
245
- rl.close();
246
- resolve(answer.trim());
247
- });
248
- });
249
- }
250
-
251
- function loadKeepRules(targetDir) {
252
- const keepFile = path.join(targetDir, '.jetrkeep');
253
- if (!fs.existsSync(keepFile)) {
254
- return null;
255
- }
256
-
257
- const ig = ignore();
258
- const content = fs.readFileSync(keepFile, 'utf-8');
259
- ig.add(content);
260
- return ig;
261
- }
262
-
263
- function loadIgnoreRules(targetDir) {
264
- const ig = ignore();
265
- const ignoreFile = path.join(targetDir, '.jetrignore');
266
-
267
- if (fs.existsSync(ignoreFile)) {
268
- const content = fs.readFileSync(ignoreFile, 'utf-8');
269
- ig.add(content);
270
- }
271
-
272
- return ig;
273
- }
274
-
275
- async function createZip(targetDir, ig, keep, singleFile = null) {
276
- const tmpFile = path.join(targetDir, `.jetr-upload-${Date.now()}.zip`);
277
-
278
- return new Promise((resolve, reject) => {
279
- const output = fs.createWriteStream(tmpFile);
280
- const archive = archiver('zip', { zlib: { level: 9 } });
281
- let fileCount = 0;
282
-
283
- const cleanup = (err) => {
284
- if (fs.existsSync(tmpFile)) {
285
- try { fs.unlinkSync(tmpFile); } catch {}
286
- }
287
- reject(err);
288
- };
289
-
290
- output.on('close', () => {
291
- if (fileCount === 0) {
292
- cleanup(new Error('No files to upload (directory is empty or all files are ignored)'));
293
- } else {
294
- resolve({ zipPath: tmpFile, fileCount });
295
- }
296
- });
297
-
298
- output.on('error', cleanup);
299
- archive.on('error', cleanup);
300
- archive.pipe(output);
301
-
302
- if (singleFile) {
303
- // Single file mode: just add the one file
304
- const fileName = path.basename(singleFile);
305
- archive.file(singleFile, { name: fileName });
306
- fileCount = 1;
307
- } else {
308
- // Directory mode: add all files respecting .jetrignore
309
- const addFiles = (dir, base = '') => {
310
- let entries;
311
- try {
312
- entries = fs.readdirSync(dir, { withFileTypes: true });
313
- } catch (err) {
314
- return; // Skip directories we can't read
315
- }
316
-
317
- for (const entry of entries) {
318
- const relativePath = path.join(base, entry.name);
319
- const fullPath = path.join(dir, entry.name);
320
-
321
- // Skip temp zip files, .jetrignore and .jetrrc
322
- if (entry.name.startsWith('.jetr-upload-') && entry.name.endsWith('.zip')) {
323
- continue;
324
- }
325
- if (entry.name === '.jetrignore' || entry.name === '.jetrkeep' || entry.name === '.jetrrc') {
326
- continue;
327
- }
328
-
329
- // Skip ignored files/directories (unless kept by .jetrkeep)
330
- if (ig.ignores(relativePath) || ig.ignores(relativePath + '/')) {
331
- if (!keep || !keep.ignores(relativePath)) {
332
- continue;
333
- }
334
- }
335
-
336
- // Skip symbolic links to avoid potential loops
337
- if (entry.isSymbolicLink()) {
338
- continue;
339
- }
340
-
341
- if (entry.isDirectory()) {
342
- addFiles(fullPath, relativePath);
343
- } else if (entry.isFile()) {
344
- archive.file(fullPath, { name: relativePath });
345
- fileCount++;
346
- }
347
- }
348
- };
349
-
350
- addFiles(targetDir);
351
- }
352
-
353
- archive.finalize();
354
- });
355
- }
356
-
357
- async function uploadOnce(zipPath, projectName) {
358
- const form = new FormData();
359
- form.append('projectName', projectName);
360
- form.append('file', fs.createReadStream(zipPath), {
361
- filename: path.basename(zipPath),
362
- contentType: 'application/zip',
363
- });
364
-
365
- return new Promise((resolve, reject) => {
366
- const request = form.submit(API_URL, (err, res) => {
367
- if (err) {
368
- reject(err);
369
- return;
370
- }
371
-
372
- let data = '';
373
- res.on('data', (chunk) => {
374
- data += chunk;
375
- });
376
-
377
- res.on('end', () => {
378
- if (res.statusCode >= 200 && res.statusCode < 300) {
379
- try {
380
- resolve(JSON.parse(data));
381
- } catch (e) {
382
- reject(new Error(`Invalid JSON response: ${data}`));
383
- }
384
- } else {
385
- reject(new Error(`Upload failed: ${res.statusCode} ${res.statusMessage}\n${data}`));
386
- }
387
- });
388
-
389
- res.on('error', reject);
390
- });
391
-
392
- request.on('error', reject);
393
- });
394
- }
395
-
396
- async function upload(zipPath, projectName, maxRetries = 3) {
397
- let lastError;
398
-
399
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
400
- try {
401
- return await uploadOnce(zipPath, projectName);
402
- } catch (err) {
403
- lastError = err;
404
- const isRetryable = ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EPIPE', 'EAI_AGAIN'].includes(err.code);
405
-
406
- if (isRetryable && attempt < maxRetries) {
407
- const delay = attempt * 2000;
408
- console.log(chalk.yellow(`\nConnection error (${err.code}), retrying in ${delay / 1000}s... (${attempt}/${maxRetries})`));
409
- await new Promise(r => setTimeout(r, delay));
410
- } else {
411
- break;
412
- }
413
- }
414
- }
415
-
416
- throw lastError;
417
- }
418
-
419
- export async function run() {
420
- const args = process.argv.slice(2);
421
-
422
- if (args.includes('-h') || args.includes('--help')) {
423
- showHelp();
424
- process.exit(0);
425
- }
426
-
427
- if (args.includes('-v') || args.includes('--version')) {
428
- showVersion();
429
- process.exit(0);
430
- }
431
-
432
- if (args[0] === 'init') {
433
- const targetDir = path.resolve(args[1] || './');
434
- initJetrignore(targetDir);
435
- process.exit(0);
436
- }
437
-
438
- const inputPath = path.resolve(args[0] || './');
439
- let projectName = args[1] || '';
440
- let targetDir;
441
- let singleFile = null;
442
-
443
- if (!fs.existsSync(inputPath)) {
444
- console.error(chalk.red(`Error: Path does not exist: ${inputPath}`));
445
- process.exit(1);
446
- }
447
-
448
- const stats = fs.statSync(inputPath);
449
-
450
- if (stats.isFile()) {
451
- // Single file mode: treat the file as a standalone project
452
- singleFile = inputPath;
453
- targetDir = path.dirname(inputPath);
454
- console.log(chalk.gray(`Single file mode: ${path.basename(inputPath)}`));
455
- } else if (stats.isDirectory()) {
456
- targetDir = inputPath;
457
- } else {
458
- console.error(chalk.red(`Error: Invalid path: ${inputPath}`));
459
- process.exit(1);
460
- }
461
-
462
- // Auto-create .jetrignore if it doesn't exist (only for directory mode)
463
- if (!singleFile) {
464
- const created = initJetrignore(targetDir, true);
465
- if (created) {
466
- console.log(chalk.gray('Auto-created .jetrignore with default patterns'));
467
- }
468
- }
469
-
470
- // Load config
471
- const config = loadConfig(targetDir);
472
- const configKey = getConfigKey(singleFile);
473
- const savedProject = getProjectFromConfig(config, configKey);
474
-
475
- if (projectName) {
476
- // User specified a project name
477
- projectName = sanitizeProjectName(projectName);
478
- } else if (savedProject) {
479
- // Have saved config, let user choose
480
- const history = savedProject.history || [savedProject.default];
481
- // Put default first, then rest of history
482
- const options = [savedProject.default, ...history.filter(h => h !== savedProject.default)];
483
-
484
- if (options.length === 1) {
485
- // Only one option, use it directly
486
- projectName = options[0];
487
- console.log(chalk.gray(`Using saved project: ${projectName}`));
488
- } else {
489
- // Multiple options, prompt user
490
- const selection = await promptSelection(options, 'Previous deployments found:');
491
- if (selection.type === 'existing') {
492
- projectName = selection.value;
493
- } else {
494
- const newName = await promptNewName();
495
- projectName = newName ? sanitizeProjectName(newName) : generateProjectName();
496
- console.log(chalk.gray(`Using project name: ${projectName}`));
497
- }
498
- }
499
- } else {
500
- // No config, generate random name
501
- projectName = generateProjectName();
502
- console.log(chalk.gray(`Generated project name: ${projectName}`));
503
- }
504
-
505
- const ig = loadIgnoreRules(targetDir);
506
- const keep = loadKeepRules(targetDir);
507
-
508
- const spinner = ora('Packing files...').start();
509
- let zipPath;
510
- let fileCount;
511
-
512
- try {
513
- const result = await createZip(targetDir, ig, keep, singleFile);
514
- zipPath = result.zipPath;
515
- fileCount = result.fileCount;
516
- const stats = fs.statSync(zipPath);
517
- const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
518
- spinner.succeed(`Packed ${fileCount} file${fileCount > 1 ? 's' : ''} (${sizeMB} MB)`);
519
- } catch (err) {
520
- spinner.fail('Failed to pack files');
521
- console.error(chalk.red(err.message));
522
- process.exit(1);
523
- }
524
-
525
- const uploadSpinner = ora(`Uploading to ${projectName}...`).start();
526
-
527
- try {
528
- const result = await upload(zipPath, projectName);
529
-
530
- fs.unlinkSync(zipPath);
531
-
532
- if (result.success) {
533
- uploadSpinner.succeed('Deployed successfully!');
534
-
535
- // Save config
536
- const updatedConfig = updateConfig(config, configKey, projectName);
537
- saveConfig(targetDir, updatedConfig);
538
-
539
- console.log();
540
- console.log(chalk.bold('Project: ') + chalk.cyan(result.projectName));
541
- console.log(chalk.bold('URL: ') + chalk.green(result.defaultUrl || result.url));
542
- console.log();
543
- } else {
544
- uploadSpinner.fail('Deployment failed');
545
- console.error(chalk.red(JSON.stringify(result, null, 2)));
546
- process.exit(1);
547
- }
548
- } catch (err) {
549
- uploadSpinner.fail('Upload failed');
550
-
551
- if (zipPath && fs.existsSync(zipPath)) {
552
- fs.unlinkSync(zipPath);
553
- }
554
-
555
- console.error(chalk.red(err.message));
556
- if (err.code) {
557
- console.error(chalk.gray(`Error code: ${err.code}`));
558
- }
559
- if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT') {
560
- console.error(chalk.yellow('\nTip: This might be a network issue or the file is too large.'));
561
- console.error(chalk.yellow('Try again or check your internet connection.'));
562
- }
563
- process.exit(1);
564
- }
565
- }