shopify-tunnel-dev 0.1.0 → 0.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.md CHANGED
@@ -1,118 +1,103 @@
1
1
  # shopify-tunnel-dev
2
2
 
3
- Chạy `shopify app dev` sau một Cloudflare named tunnel có **hostname cố định**. Cả team dùng chung
4
- một bộ credential, dev mới chỉ cần đặt `VITE_HOST` rồi chạy, không ai phải cấp tunnel hay cert.
3
+ Run `shopify app dev` behind a Cloudflare named tunnel with a **fixed hostname**. The team shares one
4
+ set of credentials; a new dev picks a hostname and runs. Nobody provisions tunnels or certificates.
5
5
 
6
- Cùng cơ chế và cùng tên biến với `@bss-sbc/shopify-dev`, nên repo đang dùng tool đó đổi sang được
7
- mà không phải sửa `.env` hay `account.json`. Không có dependency, cần Node >= 20.12.
6
+ Zero dependencies. Node >= 20.12.
8
7
 
9
- ## Cách chạy
8
+ ## How it works
10
9
 
11
- ```
12
- pnpm dev
13
- ├─ đọc web/.env hoặc .env, sau đó .cloudflared/account.json (account.json đè lên)
14
- ├─ tìm tunnel tên = phần đầu của VITE_HOST, chưa có thì tạo bằng secret chung
15
- ├─ trỏ DNS VITE_HOST về tunnel (không ghi đè record của tunnel khác)
16
- ├─ chạy connector về http://localhost:$VITE_PORT, chờ edge nhận kết nối
17
- └─ npx shopify app dev --tunnel-url https://$VITE_HOST:$VITE_PORT
18
- ```
10
+ 1. Reads `DEV_HOST` / `DEV_PORT` from `.env`, shared credentials from `.cloudflared/`.
11
+ 2. Finds the tunnel named after the first label of `DEV_HOST`, creates it on first run.
12
+ 3. Points DNS for `DEV_HOST` at the tunnel.
13
+ 4. Starts the connector to `http://localhost:$DEV_PORT`.
14
+ 5. Runs `shopify app dev --tunnel-url https://$DEV_HOST:$DEV_PORT`.
15
+
16
+ TLS terminates at Cloudflare, so no local certificate is needed. `cloudflared` is used from `PATH`,
17
+ or downloaded to `~/.cache/shopify-tunnel-dev/` on first run (override with `CLOUDFLARED_BIN`).
18
+
19
+ ## Team setup (once)
19
20
 
20
- HTTPS do Cloudflare xử lý, máy dev không cần cert TLS. Nếu không có `cloudflared` trên máy, lần
21
- chạy đầu tool tự tải bản mới nhất về `~/.cache/shopify-tunnel-dev/`. Muốn dùng binary khác thì đặt
22
- `CLOUDFLARED_BIN`.
21
+ Use a Cloudflare account dedicated to dev (see [Security](#security)).
23
22
 
24
- ## Setup một lần cho cả team
23
+ ```bash
24
+ cloudflared tunnel login # pick your zone, writes ~/.cloudflared/cert.pem
25
+ openssl rand -base64 32 # tunnel secret
26
+ ```
25
27
 
26
- 1. Dùng một Cloudflare account chỉ dành cho dev (lý do ở mục Bảo mật), thêm domain vào account đó.
27
- 2. Chạy `cloudflared tunnel login`, chọn zone. Lệnh này sinh `~/.cloudflared/cert.pem`.
28
- 3. Tạo secret: `openssl rand -base64 32`.
29
- 4. Lưu cả hai lên password manager (Passbolt...), kèm `account.json`:
28
+ Store `cert.pem` and this `account.json` in your password manager:
30
29
 
31
30
  ```json
32
31
  {
33
32
  "CF_ACCOUNT_TAG": "<account id>",
34
- "CF_SECRET_KEY": "<base64 32 byte>",
35
- "CF_HOST_PATTERN": "^dev-banner-[a-zA-Z0-9-]+\\.yourdomain\\.com$"
33
+ "CF_SECRET_KEY": "<base64 secret>",
34
+ "CF_HOST_PATTERN": "^dev-myapp-[a-zA-Z0-9-]+\\.example\\.com$"
36
35
  }
37
36
  ```
38
37
 
39
- `CF_HOST_PATTERN` là tuỳ chọn. Nên đặt để mỗi app có tiền tố riêng và không đè DNS của nhau.
38
+ `CF_HOST_PATTERN` is optional; use it to give each app its own prefix.
40
39
 
41
- ## Setup cho repo app
40
+ ## App repo setup
42
41
 
43
42
  ```bash
44
- pnpm add -D shopify-tunnel-dev # hoặc git URL / pnpm link khi chưa publish
43
+ pnpm add -D shopify-tunnel-dev
45
44
  ```
46
45
 
47
46
  ```json
48
47
  "scripts": { "dev": "shopify-tunnel-dev" }
49
48
  ```
50
49
 
51
- `.gitignore`:
52
-
53
- ```
54
- .cloudflared/*
55
- !.cloudflared/*.example
50
+ ```bash
51
+ npx shopify-tunnel-dev init
56
52
  ```
57
53
 
58
- `shopify.app.toml` của từng dev nên đặt `automatically_update_urls_on_dev = true` (mặc định), để
59
- Shopify CLI tự cập nhật URL app theo `VITE_HOST`. Mỗi dev nên có app dev riêng trên Partner, vì URL
60
- app nằm trên Shopify chứ không nằm ở tunnel.
54
+ `init` creates `.cloudflared/account.json` (empty template, mode 600), adds `.cloudflared/` to
55
+ `.gitignore`, and adds `DEV_HOST=` / `DEV_PORT=` to `.env`. It never overwrites anything, and
56
+ `pnpm dev` runs it automatically in a fresh clone.
61
57
 
62
- ## Dev mới
58
+ Give each dev their own app in the Partner Dashboard; the app URL lives on Shopify, not in the tunnel.
63
59
 
64
- ```bash
65
- # lấy từ password manager
66
- .cloudflared/cert.pem
67
- .cloudflared/account.json
68
- chmod 600 .cloudflared/*
69
- ```
60
+ ## Per dev
70
61
 
71
- `.env`:
62
+ Run `pnpm dev` once to scaffold, then fill `.cloudflared/account.json` and add `.cloudflared/cert.pem`
63
+ from the password manager (`chmod 600 .cloudflared/*`). Set in `.env`:
72
64
 
73
65
  ```
74
- VITE_HOST=dev-banner-huy.yourdomain.com
75
- VITE_PORT=9000
66
+ DEV_HOST=dev-myapp-alice.example.com
67
+ DEV_PORT=9000
76
68
  ```
77
69
 
78
- Chạy `pnpm dev`.
79
-
80
- ## Tuỳ chọn
70
+ Run `pnpm dev`.
81
71
 
82
- | Flag | Tác dụng |
72
+ | Variable | Meaning |
83
73
  |---|---|
84
- | `-q`, `--quick` | URL `*.trycloudflare.com` ngẫu nhiên, chỉ cần `VITE_PORT`, không cần credential |
85
- | `-f`, `--force` | Chiếm `VITE_HOST` dù DNS đang trỏ tunnel khác, hoặc tunnel đang có kết nối |
86
- | còn lại | Chuyển tiếp cho `shopify app dev` (`--reset`, `--store=...`) |
74
+ | `DEV_HOST` | Public hostname for your dev app. Its first label is the tunnel name (3-63 chars). |
75
+ | `DEV_PORT` | Local port Shopify CLI listens on; the tunnel forwards to it. |
87
76
 
88
- ## Chống trùng hostname
77
+ ## Options
89
78
 
90
- `@bss-sbc/shopify-dev` luôn ghi đè DNS và không kiểm tra connector đang chạy, nên hai người đặt
91
- cùng `VITE_HOST` sẽ bị chia request ngẫu nhiên cho nhau. Tool này dừng với lỗi khi:
92
-
93
- - tunnel của `VITE_HOST` đang có kết nối sống từ máy khác;
94
- - DNS của `VITE_HOST` đang trỏ về tunnel khác.
79
+ | Flag | Effect |
80
+ |---|---|
81
+ | `-q`, `--quick` | Random `*.trycloudflare.com` URL. Needs only `DEV_PORT`, no credentials. |
82
+ | `-f`, `--force` | Take over `DEV_HOST` even if it is in use. |
83
+ | anything else | Passed to `shopify app dev` (`--reset`, `--store=...`). |
95
84
 
96
- Nếu chính process của bạn vừa chết và tunnel còn giữ kết nối cũ, chờ vài giây hoặc chạy với `--force`.
85
+ ## Hostname collisions
97
86
 
98
- ## Khác với `@bss-sbc/shopify-dev`
87
+ The tool stops with an error instead of hijacking a hostname when:
99
88
 
100
- - Đọc `.env` bằng `util.parseEnv` của Node: dòng comment và giá trị có dấu `=` không còn làm hỏng.
101
- - `account.json` và `cert.pem` chỉ bắt buộc ở named mode, `--quick` không cần.
102
- - Credential của tunnel truyền qua env (`TUNNEL_CRED_CONTENTS`), không ghi file `<id>.json` ra đĩa.
103
- - Không có lệnh `start` (prod), vì prod không nên chạy qua tunnel dev.
89
+ - the tunnel already has a live connection from another machine, or
90
+ - the DNS record points at a different tunnel.
104
91
 
105
- ## Bảo mật
92
+ If your own process just died and left a stale connection, wait a few seconds or use `--force`.
106
93
 
107
- - `cert.pem` cho quyền tạo, xoá và trỏ DNS **mọi** tunnel trong account và zone đã chọn.
108
- - Ai có `CF_SECRET_KEY` thì chạy được connector cho mọi tunnel mà tool đã tạo.
94
+ ## Security
109
95
 
110
- Vì vậy dùng account Cloudflare riêng cho dev, và không commit hai giá trị này. Rotate: đổi secret
111
- trên password manager, xoá các tunnel cũ (`cloudflared tunnel delete`), lần `pnpm dev` sau sẽ tự
112
- tạo lại.
96
+ - `cert.pem` can create, delete and route **every** tunnel in the account and zone.
97
+ - `CF_SECRET_KEY` lets anyone run a connector for any tunnel this tool created.
113
98
 
114
- ## Test
99
+ Use a dedicated dev account and never commit either. Tunnel credentials are passed to `cloudflared`
100
+ via environment, never written to disk.
115
101
 
116
- ```bash
117
- node --test
118
- ```
102
+ To rotate: change the secret in the password manager, delete old tunnels
103
+ (`cloudflared tunnel delete <name>`); the next `pnpm dev` recreates them.
@@ -3,20 +3,30 @@ import { spawn } from 'node:child_process';
3
3
  import { resolveCloudflared } from '../src/cloudflared-binary.js';
4
4
  import { ConfigError, loadConfig } from '../src/load-config.js';
5
5
  import { stopCloudflared } from '../src/run-cloudflared.js';
6
+ import { scaffoldProject } from '../src/scaffold-project.js';
6
7
  import { startNamedTunnel, startQuickTunnel } from '../src/start-tunnel.js';
7
8
 
8
9
  const USAGE = `Usage: shopify-tunnel-dev [dev] [options] [-- shopify app dev args]
10
+ shopify-tunnel-dev init
11
+
12
+ Commands:
13
+ dev Start the tunnel and shopify app dev (default)
14
+ init Create .cloudflared/account.json, gitignore it, add DEV_HOST/DEV_PORT to .env
9
15
 
10
16
  Options:
11
17
  -q, --quick Random *.trycloudflare.com URL, no Cloudflare credentials needed
12
- -f, --force Take over VITE_HOST even if its DNS record or tunnel is in use
18
+ -f, --force Take over DEV_HOST even if its DNS record or tunnel is in use
13
19
  -h, --help Show this help
14
20
 
15
21
  Any other argument (e.g. --reset, --store=foo) is passed to \`shopify app dev\`.`;
16
22
 
23
+ const NEXT_STEPS =
24
+ 'set DEV_HOST and DEV_PORT in .env, fill .cloudflared/account.json and add .cloudflared/cert.pem';
25
+
17
26
  function parseArgs(argv) {
18
- const args = argv[0] === 'dev' ? argv.slice(1) : argv;
19
- const opts = { quick: false, force: false, help: false, passthrough: [] };
27
+ const init = argv[0] === 'init';
28
+ const args = argv[0] === 'dev' || init ? argv.slice(1) : argv;
29
+ const opts = { init, quick: false, force: false, help: false, passthrough: [] };
20
30
  for (const arg of args) {
21
31
  if (arg === '-q' || arg === '--quick') opts.quick = true;
22
32
  else if (arg === '-f' || arg === '--force') opts.force = true;
@@ -29,6 +39,15 @@ function parseArgs(argv) {
29
39
  async function main() {
30
40
  const opts = parseArgs(process.argv.slice(2));
31
41
  if (opts.help) return console.log(USAGE);
42
+ // `init` scaffolds explicitly; a named `dev` run in a fresh clone does the same before complaining,
43
+ // so the config error points at files that already exist.
44
+ if (opts.init || !opts.quick) {
45
+ const changes = scaffoldProject();
46
+ changes.forEach((change) => console.log(`[init] ${change}`));
47
+ if (changes.length) console.log(`[init] next: ${NEXT_STEPS}`);
48
+ else if (opts.init) console.log('[init] nothing to do');
49
+ if (opts.init) return;
50
+ }
32
51
 
33
52
  const cfg = loadConfig({ quick: opts.quick });
34
53
  const bin = await resolveCloudflared();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shopify-tunnel-dev",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Run `shopify app dev` behind a fixed-hostname Cloudflare named tunnel that the whole team shares one credential set for",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,9 +4,8 @@ import { parseEnv } from 'node:util';
4
4
 
5
5
  export class ConfigError extends Error {}
6
6
 
7
- // Same file layout and variable names as @bss-sbc/shopify-dev, so an existing repo can switch
8
- // without renaming anything: web/.env wins over ./.env, and .cloudflared/account.json overrides
9
- // keys from the env file.
7
+ // Per-dev values come from web/.env (if present) or ./.env; team-shared values come from
8
+ // .cloudflared/account.json, which overrides keys from the env file.
10
9
  export function loadConfig({ cwd = process.cwd(), quick = false } = {}) {
11
10
  const envPath = [path.join(cwd, 'web/.env'), path.join(cwd, '.env')].find((p) => existsSync(p));
12
11
  const cloudflaredDir = path.join(cwd, '.cloudflared');
@@ -17,28 +16,28 @@ export function loadConfig({ cwd = process.cwd(), quick = false } = {}) {
17
16
  const account = existsSync(accountPath) ? readJson(accountPath) : {};
18
17
  const vars = { ...env, ...account };
19
18
 
20
- const port = Number(vars.VITE_PORT);
19
+ const port = Number(vars.DEV_PORT);
21
20
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
22
- throw new ConfigError(`VITE_PORT must be a port number, got "${vars.VITE_PORT ?? ''}"`);
21
+ throw new ConfigError(`DEV_PORT must be a port number, got "${vars.DEV_PORT ?? ''}"`);
23
22
  }
24
23
  if (quick) return { quick, port };
25
24
 
26
- const missing = ['VITE_HOST', 'CF_ACCOUNT_TAG', 'CF_SECRET_KEY'].filter((key) => !vars[key]);
25
+ const missing = ['DEV_HOST', 'CF_ACCOUNT_TAG', 'CF_SECRET_KEY'].filter((key) => !vars[key]);
27
26
  if (missing.length) {
28
- throw new ConfigError(`Missing ${missing.join(', ')}. Put VITE_HOST in .env and CF_* in ${accountPath}`);
27
+ throw new ConfigError(`Missing ${missing.join(', ')}. Put DEV_HOST in .env and CF_* in ${accountPath}`);
29
28
  }
30
29
  if (!existsSync(certPath)) {
31
30
  throw new ConfigError(`Missing ${certPath} (origin cert from \`cloudflared tunnel login\`)`);
32
31
  }
33
32
 
34
- const host = vars.VITE_HOST.trim();
33
+ const host = vars.DEV_HOST.trim();
35
34
  if (vars.CF_HOST_PATTERN && !hostPattern(vars.CF_HOST_PATTERN).test(host)) {
36
- throw new ConfigError(`VITE_HOST "${host}" does not match CF_HOST_PATTERN ${vars.CF_HOST_PATTERN}`);
35
+ throw new ConfigError(`DEV_HOST "${host}" does not match CF_HOST_PATTERN ${vars.CF_HOST_PATTERN}`);
37
36
  }
38
- // Tunnel name = first DNS label, same rule as @bss-sbc/shopify-dev, so both tools reuse one tunnel.
37
+ // Tunnel name = first DNS label, so the same hostname always maps to the same tunnel on any machine.
39
38
  const tunnelName = host.split('.')[0];
40
39
  if (!host.includes('.') || !/^[a-zA-Z0-9-]{3,63}$/.test(tunnelName)) {
41
- throw new ConfigError(`VITE_HOST "${host}" must be a full hostname whose first label is 3-63 chars of [a-zA-Z0-9-]`);
40
+ throw new ConfigError(`DEV_HOST "${host}" must be a full hostname whose first label is 3-63 chars of [a-zA-Z0-9-]`);
42
41
  }
43
42
  // cloudflared rejects tunnel secrets that decode to fewer than 32 bytes.
44
43
  if (Buffer.from(vars.CF_SECRET_KEY, 'base64').length < 32) {
@@ -0,0 +1,44 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseEnv } from 'node:util';
4
+
5
+ const ACCOUNT_TEMPLATE = { CF_ACCOUNT_TAG: '', CF_SECRET_KEY: '', CF_HOST_PATTERN: '' };
6
+
7
+ // Idempotent: only creates what is missing, never overwrites. Returns the list of changes made.
8
+ export function scaffoldProject(cwd = process.cwd()) {
9
+ const changes = [];
10
+ const dir = path.join(cwd, '.cloudflared');
11
+ const accountPath = path.join(dir, 'account.json');
12
+
13
+ if (!existsSync(dir)) {
14
+ mkdirSync(dir, { mode: 0o700 });
15
+ changes.push('created .cloudflared/');
16
+ }
17
+ if (!existsSync(accountPath)) {
18
+ writeFileSync(accountPath, `${JSON.stringify(ACCOUNT_TEMPLATE, null, 2)}\n`, { mode: 0o600 });
19
+ changes.push('created .cloudflared/account.json (fill in from your password manager)');
20
+ }
21
+
22
+ // Keep the shared secrets out of git before anyone pastes them in.
23
+ const gitignorePath = path.join(cwd, '.gitignore');
24
+ const gitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
25
+ if (!/^\/?\.cloudflared\/?(\*)?\s*$/m.test(gitignore)) {
26
+ appendLine(gitignorePath, gitignore, '.cloudflared/');
27
+ changes.push('added .cloudflared/ to .gitignore');
28
+ }
29
+
30
+ const envPath = [path.join(cwd, 'web/.env'), path.join(cwd, '.env')].find((p) => existsSync(p)) ?? path.join(cwd, '.env');
31
+ const envText = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
32
+ const env = parseEnv(envText);
33
+ const missingKeys = ['DEV_HOST', 'DEV_PORT'].filter((key) => !(key in env));
34
+ if (missingKeys.length) {
35
+ appendLine(envPath, envText, missingKeys.map((key) => `${key}=`).join('\n'));
36
+ changes.push(`added ${missingKeys.join(', ')} to ${path.relative(cwd, envPath)}`);
37
+ }
38
+
39
+ return changes;
40
+ }
41
+
42
+ function appendLine(file, current, line) {
43
+ appendFileSync(file, `${current && !current.endsWith('\n') ? '\n' : ''}${line}\n`);
44
+ }
@@ -37,8 +37,8 @@ export async function startNamedTunnel(bin, cfg, { force = false } = {}) {
37
37
  // Two connectors on one tunnel = Cloudflare load-balances requests between two laptops.
38
38
  throw new Error(
39
39
  `Tunnel "${cfg.tunnelName}" already has ${tunnel.connections.length} live connection(s): ` +
40
- `someone else is probably running with VITE_HOST=${cfg.host}. ` +
41
- 'Pick another VITE_HOST, or pass --force if it is your own process that just died.',
40
+ `someone else is probably running with DEV_HOST=${cfg.host}. ` +
41
+ 'Pick another DEV_HOST, or pass --force if it is your own process that just died.',
42
42
  );
43
43
  }
44
44
 
@@ -84,7 +84,7 @@ async function routeDns(cf, tunnelId, host, force) {
84
84
  const detail = (err.stderr || err.message).trim();
85
85
  throw new Error(
86
86
  `Cannot point ${host} at tunnel ${tunnelId}: ${detail}\n` +
87
- 'If the record belongs to another tunnel, pick another VITE_HOST or pass --force to take it over.',
87
+ 'If the record belongs to another tunnel, pick another DEV_HOST or pass --force to take it over.',
88
88
  );
89
89
  }
90
90
  }