mygensite 1.1.0 → 1.3.0

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.en.md CHANGED
@@ -154,6 +154,70 @@ await tunnel.updateAccess({ mode: 'ip_only', allowed_ips: ['1.2.3.0/24'] });
154
154
  await tunnel.extendTTL(3600);
155
155
  ```
156
156
 
157
+ ## Constraints
158
+
159
+ ### Slug (subdomain)
160
+
161
+ - 3–63 characters, lowercase letters (`a-z`), numbers (`0-9`), and hyphens (`-`) only
162
+ - Must start and end with a letter or number (not a hyphen)
163
+ - Reserved words cannot be used: `www`, `api`, `dashboard`, `admin`, `mail`, `ftp`, `static`, `docs`, `status`, `health`, `internal`, `tunnel`, `app`, `web`
164
+ - A slug used as a tunnel cannot be reused for static deployment (and vice versa). Delete the existing service first.
165
+
166
+ ```
167
+ OK: my-app, demo-v2, test-123
168
+ BAD: My-App, -dash, ab, a_b, my--app..com
169
+ ```
170
+
171
+ ### File Paths (static deploy)
172
+
173
+ - Allowed characters per segment: letters, numbers, hyphens (`-`), underscores (`_`), dots (`.`), spaces
174
+ - Forward slashes (`/`) for directory nesting
175
+ - Max total path length: 1024 characters. Max segment length: 255 characters.
176
+ - Path traversal (`..`, `.`) is rejected
177
+ - Hidden files (names starting with `.`) are rejected (e.g. `.env`, `.git`)
178
+ - No leading spaces, backslashes, or control characters
179
+ - Total upload size limit: **50 MB** per deployment
180
+
181
+ ```
182
+ OK: index.html, assets/style.css, img/logo 2.png, deep/nested/file.js
183
+ BAD: ../secret.txt, .env, file\name.html
184
+ ```
185
+
186
+ ### Static File Serving Behavior
187
+
188
+ - `/` serves `index.html`
189
+ - `/about/` serves `about/index.html`
190
+ - `/about` (no trailing slash) tries the literal file first, then falls back to `about/index.html`
191
+ - Content-Type is determined by file extension (e.g. `.css` → `text/css`, `.js` → `application/javascript`)
192
+ - Responses include `Cache-Control: public, max-age=60`
193
+
194
+ ### TTL
195
+
196
+ - Minimum: 60 seconds (1 minute)
197
+ - Maximum: 86,400 seconds (24 hours)
198
+ - Default: 3,600 seconds (1 hour)
199
+ - Extending TTL resets the timer (created_at becomes now)
200
+
201
+ ### Client-Side Validation
202
+
203
+ The library validates inputs before making API calls, throwing an error immediately if values are invalid:
204
+
205
+ ```js
206
+ // Throws at construction time — no API call made
207
+ const tunnel = await mygensite({ port: 3000, subdomain: 'INVALID' });
208
+ // Error: Slug must be lowercase alphanumeric and hyphens...
209
+
210
+ // Use validators directly for custom checks
211
+ const { validate } = require('mygensite');
212
+
213
+ validate.validateSlug('my-app'); // { valid: true }
214
+ validate.validateSlug('AB'); // { valid: false, error: 'Slug must be 3-63 characters' }
215
+ validate.validateFilePath('assets/x.js');// { valid: true, cleaned: 'assets/x.js' }
216
+ validate.validateFilePath('../etc'); // { valid: false, error: 'Path traversal...' }
217
+ validate.validateTTL(30); // { valid: false, error: 'TTL must be...' }
218
+ validate.validateAccessMode('public'); // { valid: true }
219
+ ```
220
+
157
221
  ## Error Codes
158
222
 
159
223
  ### Tunnel creation errors
@@ -237,11 +301,32 @@ const site = await mygensite.deploy({
237
301
  access: 'public',
238
302
  files: [
239
303
  { name: 'index.html', content: '<h1>Hello World</h1>' },
240
- { name: 'style.css', content: 'body { font-family: sans-serif; }' },
304
+ { name: 'assets/style.css', content: 'body { font-family: sans-serif; }' },
241
305
  ],
242
306
  });
243
307
  ```
244
308
 
309
+ #### Deploy with curl
310
+
311
+ Multipart `filename` strips directory paths (e.g. `assets/style.css` becomes `style.css`). Use the `filepaths` JSON field to preserve directory structure:
312
+
313
+ ```bash
314
+ # Flat files (no subdirectories) — filepaths not needed
315
+ curl -X POST https://mygen.site/api/deploy \
316
+ -F slug=demo -F access='{"mode":"public"}' \
317
+ -F files=@index.html -F files=@style.css
318
+
319
+ # With subdirectories — filepaths required
320
+ curl -X POST https://mygen.site/api/deploy \
321
+ -F slug=demo -F access='{"mode":"public"}' \
322
+ -F 'filepaths=["index.html","assets/style.css","assets/js/app.js"]' \
323
+ -F files=@index.html \
324
+ -F files=@assets/style.css \
325
+ -F files=@assets/js/app.js
326
+ ```
327
+
328
+ The `filepaths` field is a JSON array where each element corresponds to the `files` field in order. The server uses these paths instead of the multipart filename.
329
+
245
330
  ### Site instance
246
331
 
247
332
  The returned object contains the deployment result plus convenience methods:
@@ -265,6 +350,34 @@ The returned object contains the deployment result plus convenience methods:
265
350
  | `redeploy(directory)` | directory path (string) | Upload new files, replacing all existing files. Returns a Promise. |
266
351
  | `delete(purge?)` | purge (boolean) | Delete the site. `false` = soft delete (files kept), `true` = purge S3 files. Returns a Promise. |
267
352
 
353
+ ### mygensite.manage(options)
354
+
355
+ Create a management handle for an existing service when you already have the `slug` and `admin_token` (e.g. saved from a previous deploy). **Do not redeploy just to get a management object** — use this instead.
356
+
357
+ ```js
358
+ const mygensite = require('mygensite');
359
+
360
+ const site = mygensite.manage({
361
+ slug: 'demo',
362
+ admin_token: 'tok_xxx', // from the original deploy/tunnel response
363
+ host: 'https://mygen.site', // optional
364
+ });
365
+
366
+ // Same methods as deploy result
367
+ await site.updateAccess({ mode: 'public' });
368
+ await site.extendTTL(86400);
369
+ await site.redeploy('./dist-v2');
370
+ await site.delete();
371
+ ```
372
+
373
+ #### Options
374
+
375
+ | option | type | required | default | description |
376
+ | --- | --- | --- | --- | --- |
377
+ | `slug` | string | yes | — | The service slug |
378
+ | `admin_token` | string | yes | — | The admin token from the original deploy/tunnel response |
379
+ | `host` | string | | `https://mygen.site` | Server URL |
380
+
268
381
  ### Deploy management examples
269
382
 
270
383
  ```js
package/README.ko.md CHANGED
@@ -154,6 +154,70 @@ await tunnel.updateAccess({ mode: 'ip_only', allowed_ips: ['1.2.3.0/24'] });
154
154
  await tunnel.extendTTL(3600);
155
155
  ```
156
156
 
157
+ ## 제약사항
158
+
159
+ ### Slug (서브도메인)
160
+
161
+ - 3–63자, 소문자 영문(`a-z`), 숫자(`0-9`), 하이픈(`-`)만 허용
162
+ - 영문자 또는 숫자로 시작/끝나야 함 (하이픈으로 시작/끝 불가)
163
+ - 예약어 사용 불가: `www`, `api`, `dashboard`, `admin`, `mail`, `ftp`, `static`, `docs`, `status`, `health`, `internal`, `tunnel`, `app`, `web`
164
+ - 터널로 사용 중인 slug에 정적 배포 불가 (반대도 동일). 기존 서비스를 먼저 삭제해야 함.
165
+
166
+ ```
167
+ OK: my-app, demo-v2, test-123
168
+ BAD: My-App, -dash, ab, a_b, my--app..com
169
+ ```
170
+
171
+ ### 파일 경로 (정적 배포)
172
+
173
+ - 세그먼트별 허용 문자: 영문, 숫자, 하이픈(`-`), 밑줄(`_`), 점(`.`), 공백
174
+ - 슬래시(`/`)로 디렉토리 구조 표현
175
+ - 최대 경로 길이: 1024자, 세그먼트 최대: 255자
176
+ - 경로 탈출(`..`, `.`) 거부
177
+ - 숨김 파일(`.`으로 시작하는 이름) 거부 (예: `.env`, `.git`)
178
+ - 선행 공백, 백슬래시, 제어 문자 불가
179
+ - 전체 업로드 크기 제한: **50 MB**
180
+
181
+ ```
182
+ OK: index.html, assets/style.css, img/logo 2.png, deep/nested/file.js
183
+ BAD: ../secret.txt, .env, file\name.html
184
+ ```
185
+
186
+ ### 정적 파일 서빙 동작
187
+
188
+ - `/` → `index.html` 제공
189
+ - `/about/` → `about/index.html` 제공
190
+ - `/about` (슬래시 없이) → 해당 파일 먼저 시도, 없으면 `about/index.html`로 폴백
191
+ - Content-Type은 확장자로 결정 (`.css` → `text/css`, `.js` → `application/javascript`)
192
+ - 응답에 `Cache-Control: public, max-age=60` 포함
193
+
194
+ ### TTL
195
+
196
+ - 최소: 60초 (1분)
197
+ - 최대: 86,400초 (24시간)
198
+ - 기본: 3,600초 (1시간)
199
+ - TTL 연장 시 타이머 리셋 (created_at이 현재 시각으로 변경)
200
+
201
+ ### 클라이언트 사전 검증
202
+
203
+ 라이브러리가 API 호출 전에 입력값을 검증하여 잘못된 값은 즉시 에러를 발생시킵니다:
204
+
205
+ ```js
206
+ // 생성 시점에 즉시 에러 — API 호출 없음
207
+ const tunnel = await mygensite({ port: 3000, subdomain: 'INVALID' });
208
+ // Error: Slug must be lowercase alphanumeric and hyphens...
209
+
210
+ // 검증 함수 직접 사용
211
+ const { validate } = require('mygensite');
212
+
213
+ validate.validateSlug('my-app'); // { valid: true }
214
+ validate.validateSlug('AB'); // { valid: false, error: 'Slug must be 3-63 characters' }
215
+ validate.validateFilePath('assets/x.js');// { valid: true, cleaned: 'assets/x.js' }
216
+ validate.validateFilePath('../etc'); // { valid: false, error: '경로 탈출 불가...' }
217
+ validate.validateTTL(30); // { valid: false, error: 'TTL 범위 초과...' }
218
+ validate.validateAccessMode('public'); // { valid: true }
219
+ ```
220
+
157
221
  ## 에러 코드
158
222
 
159
223
  ### 터널 생성 에러
@@ -237,11 +301,32 @@ const site = await mygensite.deploy({
237
301
  access: 'public',
238
302
  files: [
239
303
  { name: 'index.html', content: '<h1>Hello World</h1>' },
240
- { name: 'style.css', content: 'body { font-family: sans-serif; }' },
304
+ { name: 'assets/style.css', content: 'body { font-family: sans-serif; }' },
241
305
  ],
242
306
  });
243
307
  ```
244
308
 
309
+ #### curl로 배포
310
+
311
+ Multipart의 `filename`은 디렉토리 경로를 제거합니다 (예: `assets/style.css` &rarr; `style.css`). 서브디렉토리가 있으면 `filepaths` JSON 필드를 사용하세요:
312
+
313
+ ```bash
314
+ # 플랫 파일 (서브디렉토리 없음) - filepaths 불필요
315
+ curl -X POST https://mygen.site/api/deploy \
316
+ -F slug=demo -F access='{"mode":"public"}' \
317
+ -F files=@index.html -F files=@style.css
318
+
319
+ # 서브디렉토리 있음 - filepaths 필수
320
+ curl -X POST https://mygen.site/api/deploy \
321
+ -F slug=demo -F access='{"mode":"public"}' \
322
+ -F 'filepaths=["index.html","assets/style.css","assets/js/app.js"]' \
323
+ -F files=@index.html \
324
+ -F files=@assets/style.css \
325
+ -F files=@assets/js/app.js
326
+ ```
327
+
328
+ `filepaths`는 JSON 배열로, 각 원소가 `files` 필드와 순서대로 대응됩니다. 서버는 multipart filename 대신 이 경로를 사용합니다.
329
+
245
330
  ### Site 인스턴스
246
331
 
247
332
  반환 객체에 배포 결과와 편의 메서드가 포함됩니다:
@@ -265,6 +350,35 @@ const site = await mygensite.deploy({
265
350
  | `redeploy(directory)` | 디렉토리 경로 (string) | 새 파일로 교체 업로드. Promise 반환. |
266
351
  | `delete(purge?)` | purge (boolean) | 사이트 삭제. `false` = 소프트 삭제 (파일 유지), `true` = S3 파일까지 삭제. Promise 반환. |
267
352
 
353
+ ### mygensite.manage(options)
354
+
355
+ 기존 서비스의 `slug`과 `admin_token`이 있을 때 관리 핸들을 생성합니다 (예: 이전 배포에서 저장해둔 값).
356
+ **관리 객체를 얻기 위해 재배포하지 마세요** — 이 함수를 사용하세요.
357
+
358
+ ```js
359
+ const mygensite = require('mygensite');
360
+
361
+ const site = mygensite.manage({
362
+ slug: 'demo',
363
+ admin_token: 'tok_xxx', // 원래 배포/터널 생성 시 반환된 값
364
+ host: 'https://mygen.site', // 선택
365
+ });
366
+
367
+ // deploy 결과와 동일한 메서드 사용 가능
368
+ await site.updateAccess({ mode: 'public' });
369
+ await site.extendTTL(86400);
370
+ await site.redeploy('./dist-v2');
371
+ await site.delete();
372
+ ```
373
+
374
+ #### 옵션
375
+
376
+ | 옵션 | 타입 | 필수 | 기본값 | 설명 |
377
+ | --- | --- | --- | --- | --- |
378
+ | `slug` | string | 예 | — | 서비스 slug |
379
+ | `admin_token` | string | 예 | — | 원래 배포/터널 생성 시 반환된 admin token |
380
+ | `host` | string | | `https://mygen.site` | 서버 URL |
381
+
268
382
  ### 배포 관리 예제
269
383
 
270
384
  ```js
package/README.md CHANGED
@@ -66,6 +66,27 @@ await site.delete(); // soft delete
66
66
  await site.delete(true); // purge (delete S3 files too)
67
67
  ```
68
68
 
69
+ ## Manage (existing service)
70
+
71
+ Use `manage()` when you already have the `slug` and `admin_token` from a previous deploy or tunnel.
72
+ **Do not redeploy just to get a management object** — use this instead.
73
+
74
+ ```js
75
+ const mygensite = require('mygensite');
76
+
77
+ const site = mygensite.manage({
78
+ slug: 'demo',
79
+ admin_token: 'tok_xxx', // from the original deploy response
80
+ host: 'https://mygen.site', // optional
81
+ });
82
+
83
+ // Same methods as deploy result
84
+ await site.updateAccess({ mode: 'public' });
85
+ await site.extendTTL(86400);
86
+ await site.redeploy('./dist-v2');
87
+ await site.delete();
88
+ ```
89
+
69
90
  ## Access Modes
70
91
 
71
92
  | mode | behavior |
@@ -75,6 +96,48 @@ await site.delete(true); // purge (delete S3 files too)
75
96
  | `ip_only` | allowed_ips only |
76
97
  | `both` | allowed_ips + password (default) |
77
98
 
99
+ ## Constraints
100
+
101
+ ### Slug (subdomain)
102
+
103
+ - 3–63 characters, lowercase letters, numbers, and hyphens only
104
+ - Must start and end with a letter or number (not a hyphen)
105
+ - Reserved: `www`, `api`, `dashboard`, `admin`, `docs`, `status`, `health`, etc.
106
+ - A slug used as a tunnel cannot be reused for static deploy (and vice versa)
107
+
108
+ ### File Paths (static deploy)
109
+
110
+ - Allowed: letters, numbers, `-`, `_`, `.`, spaces, `/` for directories
111
+ - Max path: 1024 chars, max segment: 255 chars
112
+ - No `..`, no hidden files (`.env`), no backslashes
113
+ - Total upload limit: **50 MB**
114
+
115
+ ### Static File Serving
116
+
117
+ - `/` → `index.html`
118
+ - `/about/` → `about/index.html`
119
+ - `/about` (no slash) → falls back to `about/index.html`
120
+ - Content-Type set by extension (`.css` → `text/css`, `.js` → `application/javascript`)
121
+
122
+ ### Client-Side Validation
123
+
124
+ The library validates slug, file paths, TTL, and access mode before making API calls:
125
+
126
+ ```js
127
+ const mygensite = require('mygensite');
128
+
129
+ // Throws immediately if slug is invalid
130
+ const tunnel = await mygensite({ port: 3000, subdomain: 'INVALID' });
131
+ // Error: Slug must be lowercase alphanumeric and hyphens...
132
+
133
+ // Use validators directly
134
+ const { validate } = mygensite;
135
+ validate.validateSlug('my-app'); // { valid: true }
136
+ validate.validateSlug('AB'); // { valid: false, error: '...' }
137
+ validate.validateFilePath('../etc'); // { valid: false, error: '...' }
138
+ validate.validateTTL(30); // { valid: false, error: '...' }
139
+ ```
140
+
78
141
  ## Error Codes
79
142
 
80
143
  | status | error | when | fix |
@@ -93,9 +156,69 @@ await site.delete(true); // purge (delete S3 files too)
93
156
 
94
157
  ```bash
95
158
  # Tunnel
96
- mygensite --port 3000 --subdomain my-app --access password --password secret --ttl 7200
159
+ mygensite --port 3000 --subdomain my-app --access password --password 'secret' --ttl 7200
97
160
 
98
- # Deploy (coming soon)
161
+ # Deploy
162
+ mygensite deploy --directory ./dist --subdomain demo --access public --ttl 86400
163
+
164
+ # Deploy with password
165
+ mygensite deploy -d ./dist -s private-demo --access password --password 'mypass'
166
+
167
+ # Redeploy (reuse admin_token)
168
+ mygensite deploy -d ./dist-v2 -s demo --admin-token tok_xxx
169
+ ```
170
+
171
+ ## curl Deploy
172
+
173
+ ```bash
174
+ # Simple (flat files)
175
+ curl -X POST https://mygen.site/api/deploy \
176
+ -F slug=demo -F access='{"mode":"public"}' \
177
+ -F files=@index.html -F files=@style.css
178
+
179
+ # With subdirectories — use filepaths to preserve directory structure
180
+ curl -X POST https://mygen.site/api/deploy \
181
+ -F slug=demo -F access='{"mode":"public"}' \
182
+ -F 'filepaths=["index.html","assets/style.css","assets/js/app.js"]' \
183
+ -F files=@index.html \
184
+ -F files=@assets/style.css \
185
+ -F files=@assets/js/app.js
186
+ ```
187
+
188
+ > **Note:** Multipart `filename` strips directory paths. The `filepaths` JSON field tells the server the correct path for each file, in order.
189
+
190
+ ## Passwords with Special Characters
191
+
192
+ When using the CLI or curl, passwords with special characters (like `!`, `$`, `&`, `\`) may be modified by your shell before reaching the program.
193
+
194
+ **Use single quotes** to prevent shell interpretation:
195
+
196
+ ```bash
197
+ # CORRECT — single quotes preserve special characters
198
+ mygensite --port 3000 --password 'my!p@ss$word'
199
+
200
+ # WRONG — double quotes: bash expands ! and $
201
+ mygensite --port 3000 --password "my!p@ss$word"
202
+
203
+ # WRONG — unquoted: shell interprets special chars
204
+ mygensite --port 3000 --password my!p@ss$word
205
+ ```
206
+
207
+ When using curl:
208
+
209
+ ```bash
210
+ # CORRECT
211
+ curl 'http://mygen.site/api/tunnels/my-app?password=my!pass'
212
+
213
+ # WRONG — double quotes let bash expand !
214
+ curl "http://mygen.site/api/tunnels/my-app?password=my!pass"
215
+ ```
216
+
217
+ When using the **Node.js API**, no escaping is needed — JavaScript string literals preserve all characters:
218
+
219
+ ```js
220
+ const tunnel = await mygensite({ port: 3000, password: 'my!p@ss$word' });
221
+ // password is stored and returned exactly as provided
99
222
  ```
100
223
 
101
224
  ## Documentation
package/bin/lt.js CHANGED
@@ -5,125 +5,184 @@ const openurl = require('openurl');
5
5
  const yargs = require('yargs');
6
6
 
7
7
  const localtunnel = require('../localtunnel');
8
+ const deploy = require('../lib/deploy');
8
9
  const { version } = require('../package');
9
10
 
10
- const { argv } = yargs
11
- .usage('Usage: mygensite --port [num] <options>')
11
+ yargs
12
+ .usage('Usage: mygensite <command> [options]')
12
13
  .env(true)
13
- .option('p', {
14
- alias: 'port',
15
- describe: 'Internal HTTP server port',
16
- })
17
- .option('h', {
18
- alias: 'host',
19
- describe: 'Upstream server providing forwarding',
20
- default: 'https://mygen.site',
21
- })
22
- .option('s', {
23
- alias: 'subdomain',
24
- describe: 'Request this subdomain',
25
- })
26
- .option('l', {
27
- alias: 'local-host',
28
- describe: 'Tunnel traffic to this host instead of localhost, override Host header to this host',
29
- })
30
- .option('local-https', {
31
- describe: 'Tunnel traffic to a local HTTPS server',
32
- })
33
- .option('local-cert', {
34
- describe: 'Path to certificate PEM file for local HTTPS server',
35
- })
36
- .option('local-key', {
37
- describe: 'Path to certificate key file for local HTTPS server',
38
- })
39
- .option('local-ca', {
40
- describe: 'Path to certificate authority file for self-signed certificates',
41
- })
42
- .option('allow-invalid-cert', {
43
- describe: 'Disable certificate checks for your local HTTPS server (ignore cert/key/ca options)',
44
- })
45
- .options('o', {
46
- alias: 'open',
47
- describe: 'Opens the tunnel URL in your browser',
48
- })
49
- .option('print-requests', {
50
- describe: 'Print basic request info',
51
- })
52
- .option('access', {
53
- describe: 'Access control mode: public, password, ip_only, both',
54
- })
55
- .option('password', {
56
- describe: 'Password for access control',
57
- })
58
- .option('owner-email', {
59
- describe: 'Owner email for dashboard management',
60
- })
61
- .option('ttl', {
62
- describe: 'Tunnel TTL in seconds (60-86400)',
63
- type: 'number',
64
- })
65
- .require('port')
66
- .boolean('local-https')
67
- .boolean('allow-invalid-cert')
68
- .boolean('print-requests')
69
- .help('help', 'Show this help and exit')
70
- .version(version);
14
+ .command('deploy', 'Deploy a static site', (y) => {
15
+ return y
16
+ .option('directory', {
17
+ alias: 'd',
18
+ describe: 'Directory to deploy',
19
+ type: 'string',
20
+ demandOption: true,
21
+ })
22
+ .option('host', {
23
+ alias: 'h',
24
+ describe: 'Server host',
25
+ default: 'https://mygen.site',
26
+ })
27
+ .option('subdomain', {
28
+ alias: 's',
29
+ describe: 'Subdomain (slug)',
30
+ })
31
+ .option('access', {
32
+ describe: 'Access mode: public, password, ip_only, both',
33
+ })
34
+ .option('password', {
35
+ describe: 'Password for access control',
36
+ })
37
+ .option('owner-email', {
38
+ describe: 'Owner email for dashboard',
39
+ })
40
+ .option('ttl', {
41
+ describe: 'TTL in seconds (60-86400)',
42
+ type: 'number',
43
+ })
44
+ .option('admin-token', {
45
+ describe: 'Admin token for redeployment',
46
+ });
47
+ }, async (argv) => {
48
+ try {
49
+ const result = await deploy({
50
+ host: argv.host,
51
+ subdomain: argv.subdomain,
52
+ directory: argv.directory,
53
+ access: argv.access,
54
+ password: argv.password,
55
+ owner_email: argv.ownerEmail,
56
+ ttl: argv.ttl,
57
+ admin_token: argv.adminToken,
58
+ });
71
59
 
72
- if (typeof argv.port !== 'number') {
73
- yargs.showHelp();
74
- console.error('\nInvalid argument: `port` must be a number');
75
- process.exit(1);
76
- }
60
+ console.log('your url is: %s', result.url);
61
+ console.log('your slug is: %s', result.slug);
62
+ if (result.admin_token) {
63
+ console.log('your admin_token is: %s', result.admin_token);
64
+ }
65
+ if (result.password) {
66
+ console.log('your password is: %s', result.password);
67
+ }
68
+ if (result.expires_at) {
69
+ console.log('expires at: %s', result.expires_at);
70
+ }
71
+ } catch (err) {
72
+ console.error('deploy failed: %s', err.message);
73
+ process.exit(1);
74
+ }
75
+ })
76
+ .command('$0', 'Create a tunnel (default)', (y) => {
77
+ return y
78
+ .option('p', {
79
+ alias: 'port',
80
+ describe: 'Internal HTTP server port',
81
+ })
82
+ .option('h', {
83
+ alias: 'host',
84
+ describe: 'Upstream server providing forwarding',
85
+ default: 'https://mygen.site',
86
+ })
87
+ .option('s', {
88
+ alias: 'subdomain',
89
+ describe: 'Request this subdomain',
90
+ })
91
+ .option('l', {
92
+ alias: 'local-host',
93
+ describe: 'Tunnel traffic to this host instead of localhost, override Host header to this host',
94
+ })
95
+ .option('local-https', {
96
+ describe: 'Tunnel traffic to a local HTTPS server',
97
+ })
98
+ .option('local-cert', {
99
+ describe: 'Path to certificate PEM file for local HTTPS server',
100
+ })
101
+ .option('local-key', {
102
+ describe: 'Path to certificate key file for local HTTPS server',
103
+ })
104
+ .option('local-ca', {
105
+ describe: 'Path to certificate authority file for self-signed certificates',
106
+ })
107
+ .option('allow-invalid-cert', {
108
+ describe: 'Disable certificate checks for your local HTTPS server (ignore cert/key/ca options)',
109
+ })
110
+ .options('o', {
111
+ alias: 'open',
112
+ describe: 'Opens the tunnel URL in your browser',
113
+ })
114
+ .option('print-requests', {
115
+ describe: 'Print basic request info',
116
+ })
117
+ .option('access', {
118
+ describe: 'Access control mode: public, password, ip_only, both',
119
+ })
120
+ .option('password', {
121
+ describe: 'Password for access control',
122
+ })
123
+ .option('owner-email', {
124
+ describe: 'Owner email for dashboard management',
125
+ })
126
+ .option('ttl', {
127
+ describe: 'Tunnel TTL in seconds (60-86400)',
128
+ type: 'number',
129
+ })
130
+ .boolean('local-https')
131
+ .boolean('allow-invalid-cert')
132
+ .boolean('print-requests');
133
+ }, async (argv) => {
134
+ if (typeof argv.port !== 'number') {
135
+ yargs.showHelp();
136
+ console.error('\nInvalid argument: `port` must be a number');
137
+ process.exit(1);
138
+ }
77
139
 
78
- (async () => {
79
- const tunnel = await localtunnel({
80
- port: argv.port,
81
- host: argv.host,
82
- subdomain: argv.subdomain,
83
- local_host: argv.localHost,
84
- local_https: argv.localHttps,
85
- local_cert: argv.localCert,
86
- local_key: argv.localKey,
87
- local_ca: argv.localCa,
88
- allow_invalid_cert: argv.allowInvalidCert,
89
- access: argv.access,
90
- password: argv.password,
91
- owner_email: argv.ownerEmail,
92
- ttl: argv.ttl,
93
- }).catch(err => {
94
- throw err;
95
- });
140
+ const tunnel = await localtunnel({
141
+ port: argv.port,
142
+ host: argv.host,
143
+ subdomain: argv.subdomain,
144
+ local_host: argv.localHost,
145
+ local_https: argv.localHttps,
146
+ local_cert: argv.localCert,
147
+ local_key: argv.localKey,
148
+ local_ca: argv.localCa,
149
+ allow_invalid_cert: argv.allowInvalidCert,
150
+ access: argv.access,
151
+ password: argv.password,
152
+ owner_email: argv.ownerEmail,
153
+ ttl: argv.ttl,
154
+ }).catch(err => {
155
+ throw err;
156
+ });
96
157
 
97
- tunnel.on('error', err => {
98
- throw err;
99
- });
158
+ tunnel.on('error', err => {
159
+ throw err;
160
+ });
100
161
 
101
- console.log('your url is: %s', tunnel.url);
162
+ console.log('your url is: %s', tunnel.url);
102
163
 
103
- if (tunnel.password) {
104
- console.log('your password is: %s', tunnel.password);
105
- }
164
+ if (tunnel.password) {
165
+ console.log('your password is: %s', tunnel.password);
166
+ }
106
167
 
107
- if (tunnel.admin_token) {
108
- console.log('your admin_token is: %s', tunnel.admin_token);
109
- }
168
+ if (tunnel.admin_token) {
169
+ console.log('your admin_token is: %s', tunnel.admin_token);
170
+ }
110
171
 
111
- /**
112
- * `cachedUrl` is set when using a proxy server that support resource caching.
113
- * This URL generally remains available after the tunnel itself has closed.
114
- * @see https://github.com/localtunnel/localtunnel/pull/319#discussion_r319846289
115
- */
116
- if (tunnel.cachedUrl) {
117
- console.log('your cachedUrl is: %s', tunnel.cachedUrl);
118
- }
172
+ if (tunnel.cachedUrl) {
173
+ console.log('your cachedUrl is: %s', tunnel.cachedUrl);
174
+ }
119
175
 
120
- if (argv.open) {
121
- openurl.open(tunnel.url);
122
- }
176
+ if (argv.open) {
177
+ openurl.open(tunnel.url);
178
+ }
123
179
 
124
- if (argv['print-requests']) {
125
- tunnel.on('request', info => {
126
- console.log(new Date().toString(), info.method, info.path);
127
- });
128
- }
129
- })();
180
+ if (argv['print-requests']) {
181
+ tunnel.on('request', info => {
182
+ console.log(new Date().toString(), info.method, info.path);
183
+ });
184
+ }
185
+ })
186
+ .help('help', 'Show this help and exit')
187
+ .version(version)
188
+ .parse();
package/lib/Tunnel.js CHANGED
@@ -6,6 +6,7 @@ const axios = require('axios');
6
6
  const debug = require('debug')('localtunnel:client');
7
7
 
8
8
  const TunnelCluster = require('./TunnelCluster');
9
+ const { validateSlug, validateTTL, validateAccessMode } = require('./validate');
9
10
 
10
11
  module.exports = class Tunnel extends EventEmitter {
11
12
  constructor(opts = {}) {
@@ -15,6 +16,26 @@ module.exports = class Tunnel extends EventEmitter {
15
16
  if (!this.opts.host) {
16
17
  this.opts.host = 'https://mygen.site';
17
18
  }
19
+
20
+ // Client-side validation — fail fast before API call
21
+ if (opts.subdomain) {
22
+ const slugCheck = validateSlug(opts.subdomain);
23
+ if (!slugCheck.valid) {
24
+ throw new Error(slugCheck.error);
25
+ }
26
+ }
27
+ if (opts.ttl != null) {
28
+ const ttlCheck = validateTTL(Number(opts.ttl));
29
+ if (!ttlCheck.valid) {
30
+ throw new Error(ttlCheck.error);
31
+ }
32
+ }
33
+ if (opts.access) {
34
+ const accessCheck = validateAccessMode(opts.access);
35
+ if (!accessCheck.valid) {
36
+ throw new Error(accessCheck.error);
37
+ }
38
+ }
18
39
  }
19
40
 
20
41
  _getInfo(body) {
package/lib/deploy.js CHANGED
@@ -3,6 +3,7 @@ const path = require('path');
3
3
  const axios = require('axios');
4
4
  const FormData = require('form-data');
5
5
  const debug = require('debug')('mygensite:deploy');
6
+ const { validateSlug, validateFilePath, validateTTL, validateAccessMode } = require('./validate');
6
7
 
7
8
  const DEFAULT_HOST = 'https://mygen.site';
8
9
 
@@ -94,6 +95,29 @@ async function deploy(options) {
94
95
  admin_token,
95
96
  } = options;
96
97
 
98
+ // Client-side validation — fail fast before API call
99
+ if (subdomain) {
100
+ const slugCheck = validateSlug(subdomain);
101
+ if (!slugCheck.valid) {
102
+ throw new Error(slugCheck.error);
103
+ }
104
+ }
105
+ if (ttl != null) {
106
+ const ttlCheck = validateTTL(Number(ttl));
107
+ if (!ttlCheck.valid) {
108
+ throw new Error(ttlCheck.error);
109
+ }
110
+ }
111
+ if (access) {
112
+ const mode = typeof access === 'string' ? access : access.mode;
113
+ if (mode) {
114
+ const accessCheck = validateAccessMode(mode);
115
+ if (!accessCheck.valid) {
116
+ throw new Error(accessCheck.error);
117
+ }
118
+ }
119
+ }
120
+
97
121
  // Build file list
98
122
  let fileList;
99
123
  if (directory) {
@@ -116,6 +140,15 @@ async function deploy(options) {
116
140
  throw new Error('No files found to deploy');
117
141
  }
118
142
 
143
+ // Validate all file paths before uploading
144
+ for (const file of fileList) {
145
+ const pathCheck = validateFilePath(file.name);
146
+ if (!pathCheck.valid) {
147
+ throw new Error(`Invalid file path "${file.name}": ${pathCheck.error}`);
148
+ }
149
+ file.name = pathCheck.cleaned;
150
+ }
151
+
119
152
  debug('deploying %d files to %s', fileList.length, host);
120
153
 
121
154
  // Build multipart form
@@ -137,9 +170,12 @@ async function deploy(options) {
137
170
  }
138
171
  form.append('access', JSON.stringify(accessObj));
139
172
 
173
+ // Send file paths as separate JSON field (busboy strips directories from filename)
174
+ form.append('filepaths', JSON.stringify(fileList.map(f => f.name)));
175
+
140
176
  for (const file of fileList) {
141
177
  form.append('files', file.content, {
142
- filename: file.name,
178
+ filepath: file.name,
143
179
  contentType: file.contentType,
144
180
  });
145
181
  }
@@ -169,4 +205,36 @@ async function deploy(options) {
169
205
  return data;
170
206
  }
171
207
 
208
+ /**
209
+ * Create a management handle for an existing service.
210
+ * Use this when you already have the slug and admin_token (e.g. from a previous deploy).
211
+ *
212
+ * @param {object} options
213
+ * @param {string} options.slug - The service slug
214
+ * @param {string} options.admin_token - The admin token from the original deploy
215
+ * @param {string} [options.host] - API host (default: https://mygen.site)
216
+ * @returns {object} Management object with updateAccess, extendTTL, redeploy, delete methods
217
+ */
218
+ function manage(options) {
219
+ const {
220
+ slug,
221
+ admin_token,
222
+ host = DEFAULT_HOST,
223
+ } = options;
224
+
225
+ if (!slug) throw new Error('slug is required');
226
+ if (!admin_token) throw new Error('admin_token is required');
227
+
228
+ return {
229
+ slug,
230
+ admin_token,
231
+ url: `https://${slug}.${host.replace(/^https?:\/\//, '')}`,
232
+ updateAccess: (access) => patchService(host, slug, admin_token, { access }),
233
+ extendTTL: (ttl) => patchService(host, slug, admin_token, { ttl }),
234
+ redeploy: (dir) => deploy({ host, subdomain: slug, directory: dir, admin_token }),
235
+ delete: (purge) => deleteService(host, slug, admin_token, purge),
236
+ };
237
+ }
238
+
172
239
  module.exports = deploy;
240
+ module.exports.manage = manage;
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Validation helpers for mygensite.
5
+ *
6
+ * These mirror the server-side rules so you can catch errors locally
7
+ * before making an API call.
8
+ */
9
+
10
+ // Must match: server/src/api/routes/tunnels.ts SLUG_REGEX
11
+ const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
12
+
13
+ const RESERVED_SLUGS = new Set([
14
+ 'www', 'api', 'dashboard', 'admin', 'mail',
15
+ 'ftp', 'static', 'docs', 'status', 'health',
16
+ 'internal', 'tunnel', 'app', 'web',
17
+ ]);
18
+
19
+ // Must match: server/src/api/routes/deploy.ts SAFE_PATH_SEGMENT
20
+ const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9_\-. ]+$/;
21
+
22
+ const VALID_ACCESS_MODES = ['public', 'password', 'ip_only', 'both'];
23
+
24
+ /**
25
+ * Validate a slug (subdomain) name.
26
+ * @param {string} slug
27
+ * @returns {{ valid: boolean, error?: string }}
28
+ */
29
+ function validateSlug(slug) {
30
+ if (!slug || typeof slug !== 'string') {
31
+ return { valid: false, error: 'Slug is required' };
32
+ }
33
+ if (slug.length < 3 || slug.length > 63) {
34
+ return { valid: false, error: 'Slug must be 3-63 characters' };
35
+ }
36
+ if (!SLUG_REGEX.test(slug)) {
37
+ return { valid: false, error: 'Slug must be lowercase alphanumeric and hyphens, starting and ending with alphanumeric' };
38
+ }
39
+ if (RESERVED_SLUGS.has(slug)) {
40
+ return { valid: false, error: `"${slug}" is a reserved slug` };
41
+ }
42
+ return { valid: true };
43
+ }
44
+
45
+ /**
46
+ * Validate a file path for static deployment.
47
+ * @param {string} name - File path (e.g. "index.html", "assets/style.css")
48
+ * @returns {{ valid: boolean, cleaned?: string, error?: string }}
49
+ */
50
+ function validateFilePath(name) {
51
+ if (!name || typeof name !== 'string') {
52
+ return { valid: false, error: 'File path is required' };
53
+ }
54
+ if (name.length > 1024) {
55
+ return { valid: false, error: 'File path must not exceed 1024 characters' };
56
+ }
57
+
58
+ const cleaned = name.replace(/^\/+/, '');
59
+ if (!cleaned) {
60
+ return { valid: false, error: 'File path is empty after cleaning' };
61
+ }
62
+
63
+ const segments = cleaned.split('/');
64
+ for (const seg of segments) {
65
+ if (!seg) {
66
+ return { valid: false, error: 'File path contains empty segments (double slash)' };
67
+ }
68
+ if (seg === '.' || seg === '..') {
69
+ return { valid: false, error: 'Path traversal ("." or "..") is not allowed' };
70
+ }
71
+ if (seg.length > 255) {
72
+ return { valid: false, error: `Segment "${seg.slice(0, 20)}..." exceeds 255 characters` };
73
+ }
74
+ if (seg.startsWith('.')) {
75
+ return { valid: false, error: `Hidden files (starting with ".") are not allowed: "${seg}"` };
76
+ }
77
+ if (seg.startsWith(' ')) {
78
+ return { valid: false, error: 'File names must not start with a space' };
79
+ }
80
+ if (!SAFE_PATH_SEGMENT.test(seg)) {
81
+ return { valid: false, error: `Invalid characters in "${seg}". Allowed: letters, numbers, hyphens, underscores, dots, spaces` };
82
+ }
83
+ }
84
+
85
+ return { valid: true, cleaned: segments.join('/') };
86
+ }
87
+
88
+ /**
89
+ * Validate TTL value.
90
+ * @param {number} ttl - TTL in seconds
91
+ * @returns {{ valid: boolean, error?: string }}
92
+ */
93
+ function validateTTL(ttl) {
94
+ if (typeof ttl !== 'number' || !Number.isFinite(ttl)) {
95
+ return { valid: false, error: 'TTL must be a number' };
96
+ }
97
+ if (ttl < 60 || ttl > 86400) {
98
+ return { valid: false, error: 'TTL must be between 60 and 86400 seconds (1 min to 24 hours)' };
99
+ }
100
+ return { valid: true };
101
+ }
102
+
103
+ /**
104
+ * Validate access mode.
105
+ * @param {string} mode
106
+ * @returns {{ valid: boolean, error?: string }}
107
+ */
108
+ function validateAccessMode(mode) {
109
+ if (!VALID_ACCESS_MODES.includes(mode)) {
110
+ return { valid: false, error: `Access mode must be one of: ${VALID_ACCESS_MODES.join(', ')}` };
111
+ }
112
+ return { valid: true };
113
+ }
114
+
115
+ module.exports = {
116
+ validateSlug,
117
+ validateFilePath,
118
+ validateTTL,
119
+ validateAccessMode,
120
+ SLUG_REGEX,
121
+ RESERVED_SLUGS,
122
+ VALID_ACCESS_MODES,
123
+ };
package/localtunnel.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const Tunnel = require('./lib/Tunnel');
2
2
  const deploy = require('./lib/deploy');
3
+ const validate = require('./lib/validate');
3
4
 
4
5
  function localtunnel(arg1, arg2, arg3) {
5
6
  const options = typeof arg1 === 'object' ? arg1 : { ...arg2, port: arg1 };
@@ -15,5 +16,7 @@ function localtunnel(arg1, arg2, arg3) {
15
16
  }
16
17
 
17
18
  localtunnel.deploy = deploy;
19
+ localtunnel.manage = deploy.manage;
20
+ localtunnel.validate = validate;
18
21
 
19
22
  module.exports = localtunnel;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mygensite",
3
3
  "description": "Expose your localhost to mygen.site with access control",
4
- "version": "1.1.0",
4
+ "version": "1.3.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",