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 +58 -73
- package/bin/shopify-tunnel-dev.js +22 -3
- package/package.json +1 -1
- package/src/load-config.js +10 -11
- package/src/scaffold-project.js +44 -0
- package/src/start-tunnel.js +3 -3
package/README.md
CHANGED
|
@@ -1,118 +1,103 @@
|
|
|
1
1
|
# shopify-tunnel-dev
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
8
|
+
## How it works
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
+
```bash
|
|
24
|
+
cloudflared tunnel login # pick your zone, writes ~/.cloudflared/cert.pem
|
|
25
|
+
openssl rand -base64 32 # tunnel secret
|
|
26
|
+
```
|
|
25
27
|
|
|
26
|
-
|
|
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
|
|
35
|
-
"CF_HOST_PATTERN": "^dev-
|
|
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`
|
|
38
|
+
`CF_HOST_PATTERN` is optional; use it to give each app its own prefix.
|
|
40
39
|
|
|
41
|
-
##
|
|
40
|
+
## App repo setup
|
|
42
41
|
|
|
43
42
|
```bash
|
|
44
|
-
pnpm add -D shopify-tunnel-dev
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
```
|
|
54
|
-
.cloudflared/*
|
|
55
|
-
!.cloudflared/*.example
|
|
50
|
+
```bash
|
|
51
|
+
npx shopify-tunnel-dev init
|
|
56
52
|
```
|
|
57
53
|
|
|
58
|
-
`
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
58
|
+
Give each dev their own app in the Partner Dashboard; the app URL lives on Shopify, not in the tunnel.
|
|
63
59
|
|
|
64
|
-
|
|
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
|
-
`.
|
|
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
|
-
|
|
75
|
-
|
|
66
|
+
DEV_HOST=dev-myapp-alice.example.com
|
|
67
|
+
DEV_PORT=9000
|
|
76
68
|
```
|
|
77
69
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
## Tuỳ chọn
|
|
70
|
+
Run `pnpm dev`.
|
|
81
71
|
|
|
82
|
-
|
|
|
72
|
+
| Variable | Meaning |
|
|
83
73
|
|---|---|
|
|
84
|
-
|
|
|
85
|
-
|
|
|
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
|
-
##
|
|
77
|
+
## Options
|
|
89
78
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
85
|
+
## Hostname collisions
|
|
97
86
|
|
|
98
|
-
|
|
87
|
+
The tool stops with an error instead of hijacking a hostname when:
|
|
99
88
|
|
|
100
|
-
-
|
|
101
|
-
-
|
|
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
|
-
|
|
92
|
+
If your own process just died and left a stale connection, wait a few seconds or use `--force`.
|
|
106
93
|
|
|
107
|
-
|
|
108
|
-
- Ai có `CF_SECRET_KEY` thì chạy được connector cho mọi tunnel mà tool đã tạo.
|
|
94
|
+
## Security
|
|
109
95
|
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
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
|
|
19
|
-
const
|
|
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
package/src/load-config.js
CHANGED
|
@@ -4,9 +4,8 @@ import { parseEnv } from 'node:util';
|
|
|
4
4
|
|
|
5
5
|
export class ConfigError extends Error {}
|
|
6
6
|
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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.
|
|
19
|
+
const port = Number(vars.DEV_PORT);
|
|
21
20
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
22
|
-
throw new ConfigError(`
|
|
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 = ['
|
|
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
|
|
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.
|
|
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(`
|
|
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
|
|
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(`
|
|
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
|
+
}
|
package/src/start-tunnel.js
CHANGED
|
@@ -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
|
|
41
|
-
'Pick another
|
|
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
|
|
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
|
}
|