agim-cli 1.1.7 → 1.1.9
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/CHANGELOG.md +87 -0
- package/dist/cli.js +31 -0
- package/dist/cli.js.map +1 -1
- package/dist/core/message-sink.d.ts +14 -0
- package/dist/core/message-sink.d.ts.map +1 -1
- package/dist/core/message-sink.js +7 -5
- package/dist/core/message-sink.js.map +1 -1
- package/dist/core/tunnel.d.ts +23 -0
- package/dist/core/tunnel.d.ts.map +1 -0
- package/dist/core/tunnel.js +151 -0
- package/dist/core/tunnel.js.map +1 -0
- package/dist/core/viewer-config.d.ts +17 -0
- package/dist/core/viewer-config.d.ts.map +1 -1
- package/dist/core/viewer-config.js +24 -2
- package/dist/core/viewer-config.js.map +1 -1
- package/dist/web/public/settings.html +77 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +17 -0
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Tunnel — auto-launch cloudflared quick tunnel so operators without a
|
|
2
|
+
// public DNS / reverse proxy can still receive viewer links in IM.
|
|
3
|
+
//
|
|
4
|
+
// Trade-off: quick tunnels are ephemeral. The URL is a random
|
|
5
|
+
// `https://*.trycloudflare.com` reassigned per `cloudflared` process.
|
|
6
|
+
// On agim restart, IM links generated before the restart become dead.
|
|
7
|
+
// Operators who want stable URLs should configure
|
|
8
|
+
// `IMHUB_VIEWER_PUBLIC_BASE_URL` to a reverse proxy (caddy / tailscale /
|
|
9
|
+
// cloudflared named tunnel) instead — when that env is non-empty we
|
|
10
|
+
// skip the auto-tunnel.
|
|
11
|
+
//
|
|
12
|
+
// We deliberately do NOT bundle cloudflared in the npm package (binary is
|
|
13
|
+
// ~30 MB and architecture-specific). Operators install it themselves
|
|
14
|
+
// (`brew install cloudflared` / `apt install cloudflared` / download from
|
|
15
|
+
// https://github.com/cloudflare/cloudflared/releases). agim auto-detects
|
|
16
|
+
// the binary on `start` and emits a friendly install hint if missing.
|
|
17
|
+
import { spawn, execSync } from 'node:child_process';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { logger as rootLogger } from './logger.js';
|
|
20
|
+
const log = rootLogger.child({ component: 'tunnel' });
|
|
21
|
+
const URL_RE = /https?:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
22
|
+
let proc = null;
|
|
23
|
+
let currentUrl = null;
|
|
24
|
+
let lastError = null;
|
|
25
|
+
let lastStartAt = null;
|
|
26
|
+
let restartTimer = null;
|
|
27
|
+
/** Common locations where cloudflared may be installed. PATH is searched first. */
|
|
28
|
+
const CANDIDATE_PATHS = [
|
|
29
|
+
'/usr/local/bin/cloudflared',
|
|
30
|
+
'/usr/bin/cloudflared',
|
|
31
|
+
'/opt/homebrew/bin/cloudflared',
|
|
32
|
+
'/root/.agim/bin/cloudflared',
|
|
33
|
+
`${process.env.HOME || ''}/.agim/bin/cloudflared`,
|
|
34
|
+
];
|
|
35
|
+
export function findCloudflared() {
|
|
36
|
+
// PATH first (so users who installed via package manager / homebrew "just work").
|
|
37
|
+
const which = process.platform === 'win32' ? 'where' : 'which';
|
|
38
|
+
try {
|
|
39
|
+
const result = execSync(`${which} cloudflared`, {
|
|
40
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
}).trim();
|
|
43
|
+
if (result && existsSync(result.split('\n')[0])) {
|
|
44
|
+
return result.split('\n')[0];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// fall through
|
|
49
|
+
}
|
|
50
|
+
for (const p of CANDIDATE_PATHS) {
|
|
51
|
+
if (p && existsSync(p))
|
|
52
|
+
return p;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
export function getTunnelStatus() {
|
|
57
|
+
const bin = findCloudflared();
|
|
58
|
+
return {
|
|
59
|
+
running: proc !== null && !proc.killed,
|
|
60
|
+
url: currentUrl,
|
|
61
|
+
binaryFound: bin !== null,
|
|
62
|
+
binaryPath: bin,
|
|
63
|
+
lastError,
|
|
64
|
+
startedAt: lastStartAt,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Start a cloudflared quick tunnel pointing at the given local port. Idempotent
|
|
69
|
+
* — if a tunnel is already running, returns immediately. The function resolves
|
|
70
|
+
* as soon as the child is spawned; the URL is captured asynchronously from
|
|
71
|
+
* stderr and made available via getTunnelStatus().
|
|
72
|
+
*
|
|
73
|
+
* Throws if cloudflared isn't installed (so the caller can surface the install
|
|
74
|
+
* hint to the operator).
|
|
75
|
+
*/
|
|
76
|
+
export function startTunnel(localPort) {
|
|
77
|
+
if (proc && !proc.killed) {
|
|
78
|
+
log.debug({ event: 'tunnel.already_running', url: currentUrl }, 'tunnel already running');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const bin = findCloudflared();
|
|
82
|
+
if (!bin) {
|
|
83
|
+
lastError = 'cloudflared binary not found in PATH; install from https://github.com/cloudflare/cloudflared/releases or via your OS package manager';
|
|
84
|
+
log.warn({ event: 'tunnel.binary_missing', candidates: CANDIDATE_PATHS }, lastError);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const localUrl = `http://127.0.0.1:${localPort}`;
|
|
88
|
+
log.info({ event: 'tunnel.start', binary: bin, localUrl }, `starting cloudflared quick tunnel → ${localUrl}`);
|
|
89
|
+
proc = spawn(bin, [
|
|
90
|
+
'tunnel',
|
|
91
|
+
'--url', localUrl,
|
|
92
|
+
'--no-autoupdate',
|
|
93
|
+
// Quick tunnels: no login, no zone — gives us a random trycloudflare.com URL.
|
|
94
|
+
// Logging goes to stderr by default; we tap it to extract the URL.
|
|
95
|
+
], {
|
|
96
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
97
|
+
detached: false,
|
|
98
|
+
});
|
|
99
|
+
lastStartAt = Date.now();
|
|
100
|
+
lastError = null;
|
|
101
|
+
currentUrl = null;
|
|
102
|
+
const captureFromChunk = (chunk) => {
|
|
103
|
+
const text = chunk.toString('utf8');
|
|
104
|
+
const m = URL_RE.exec(text);
|
|
105
|
+
if (m && !currentUrl) {
|
|
106
|
+
currentUrl = m[0].replace(/\/$/, '');
|
|
107
|
+
log.info({ event: 'tunnel.url_acquired', url: currentUrl }, `tunnel URL: ${currentUrl}`);
|
|
108
|
+
}
|
|
109
|
+
// cloudflared echoes its own status to stderr; surface ERR lines to our log.
|
|
110
|
+
if (/error|failed|refused/i.test(text)) {
|
|
111
|
+
log.warn({ event: 'tunnel.stderr', text: text.trim().slice(0, 400) });
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
proc.stderr?.on('data', captureFromChunk);
|
|
115
|
+
proc.stdout?.on('data', captureFromChunk);
|
|
116
|
+
proc.on('exit', (code, signal) => {
|
|
117
|
+
log.warn({ event: 'tunnel.exit', code, signal, url: currentUrl }, `cloudflared exited (code=${code}, signal=${signal})`);
|
|
118
|
+
const wasUrl = currentUrl;
|
|
119
|
+
proc = null;
|
|
120
|
+
currentUrl = null;
|
|
121
|
+
lastError = `cloudflared exited (code=${code}${signal ? `, signal=${signal}` : ''})`;
|
|
122
|
+
// Auto-restart on non-clean exit so a brief network blip doesn't kill the
|
|
123
|
+
// tunnel for the rest of the agim process lifetime. 30s cooldown.
|
|
124
|
+
if (signal !== 'SIGTERM' && signal !== 'SIGINT') {
|
|
125
|
+
restartTimer = setTimeout(() => {
|
|
126
|
+
log.info({ event: 'tunnel.auto_restart', previousUrl: wasUrl }, 'auto-restarting tunnel');
|
|
127
|
+
startTunnel(localPort);
|
|
128
|
+
}, 30_000);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
proc.on('error', (err) => {
|
|
132
|
+
lastError = `spawn failed: ${err.message}`;
|
|
133
|
+
log.error({ event: 'tunnel.spawn_error', err: err.message });
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
export function stopTunnel() {
|
|
137
|
+
if (restartTimer) {
|
|
138
|
+
clearTimeout(restartTimer);
|
|
139
|
+
restartTimer = null;
|
|
140
|
+
}
|
|
141
|
+
if (proc && !proc.killed) {
|
|
142
|
+
log.info({ event: 'tunnel.stop' });
|
|
143
|
+
proc.kill('SIGTERM');
|
|
144
|
+
proc = null;
|
|
145
|
+
currentUrl = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export function getCurrentTunnelUrl() {
|
|
149
|
+
return currentUrl;
|
|
150
|
+
}
|
|
151
|
+
//# sourceMappingURL=tunnel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tunnel.js","sourceRoot":"","sources":["../../src/core/tunnel.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ViewerThresholds } from './render-router.js';
|
|
2
|
+
export type ViewerTunnelMode = 'off' | 'quick';
|
|
2
3
|
export interface ViewerConfig {
|
|
3
4
|
/** Master switch. When false, all replies are sent inline regardless of length. */
|
|
4
5
|
enabled: boolean;
|
|
@@ -9,10 +10,26 @@ export interface ViewerConfig {
|
|
|
9
10
|
* IM clients on phones will obviously not be able to open it.
|
|
10
11
|
*/
|
|
11
12
|
publicBaseUrl: string;
|
|
13
|
+
/**
|
|
14
|
+
* Auto-tunnel mode for operators without a public URL.
|
|
15
|
+
* - 'off' (default): use publicBaseUrl as-is.
|
|
16
|
+
* - 'quick': launch cloudflared quick tunnel on startup; URL is
|
|
17
|
+
* `https://*.trycloudflare.com` and changes per agim restart.
|
|
18
|
+
*/
|
|
19
|
+
tunnelMode: ViewerTunnelMode;
|
|
12
20
|
/** Char / line / code-block thresholds for routing to web. */
|
|
13
21
|
thresholds: ViewerThresholds;
|
|
14
22
|
}
|
|
15
23
|
export declare function getViewerConfig(): ViewerConfig;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the effective public base URL. Order of preference:
|
|
26
|
+
* 1. Explicit IMHUB_VIEWER_PUBLIC_BASE_URL (operator-managed reverse proxy)
|
|
27
|
+
* 2. Auto-tunnel URL if tunnel_mode=quick and cloudflared has acquired a URL
|
|
28
|
+
* 3. Caller-supplied fallback
|
|
29
|
+
*
|
|
30
|
+
* Returns empty string when nothing usable is available.
|
|
31
|
+
*/
|
|
32
|
+
export declare function getEffectivePublicBaseUrl(fallback?: string): string;
|
|
16
33
|
/**
|
|
17
34
|
* Build the URL placed in the IM reply for a saved paste. Returns null if
|
|
18
35
|
* no public base URL is configured AND no fallback was provided. Caller
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"viewer-config.d.ts","sourceRoot":"","sources":["../../src/core/viewer-config.ts"],"names":[],"mappings":"AAMA,OAAO,EAAsB,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;
|
|
1
|
+
{"version":3,"file":"viewer-config.d.ts","sourceRoot":"","sources":["../../src/core/viewer-config.ts"],"names":[],"mappings":"AAMA,OAAO,EAAsB,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAG9E,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,OAAO,CAAA;AAE9C,MAAM,WAAW,YAAY;IAC3B,mFAAmF;IACnF,OAAO,EAAE,OAAO,CAAA;IAChB;;;;;OAKG;IACH,aAAa,EAAE,MAAM,CAAA;IACrB;;;;;OAKG;IACH,UAAU,EAAE,gBAAgB,CAAA;IAC5B,8DAA8D;IAC9D,UAAU,EAAE,gBAAgB,CAAA;CAC7B;AAQD,wBAAgB,eAAe,IAAI,YAAY,CAgB9C;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAQnE;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIjF"}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// base URL + an enable flag), and the defaults handle everything else. No
|
|
5
5
|
// JSON-config schema dance, no on-disk state — change env, restart, done.
|
|
6
6
|
import { DEFAULT_THRESHOLDS } from './render-router.js';
|
|
7
|
+
import { getCurrentTunnelUrl } from './tunnel.js';
|
|
7
8
|
function parsePositiveInt(raw, fallback) {
|
|
8
9
|
if (!raw)
|
|
9
10
|
return fallback;
|
|
@@ -14,9 +15,12 @@ export function getViewerConfig() {
|
|
|
14
15
|
const enabledRaw = (process.env.IMHUB_VIEWER_ENABLED || '').toLowerCase();
|
|
15
16
|
const enabled = enabledRaw === '1' || enabledRaw === 'true' || enabledRaw === 'yes';
|
|
16
17
|
const publicBaseUrl = (process.env.IMHUB_VIEWER_PUBLIC_BASE_URL || '').replace(/\/$/, '');
|
|
18
|
+
const tunnelModeRaw = (process.env.IMHUB_VIEWER_TUNNEL_MODE || 'off').toLowerCase();
|
|
19
|
+
const tunnelMode = tunnelModeRaw === 'quick' ? 'quick' : 'off';
|
|
17
20
|
return {
|
|
18
21
|
enabled,
|
|
19
22
|
publicBaseUrl,
|
|
23
|
+
tunnelMode,
|
|
20
24
|
thresholds: {
|
|
21
25
|
chars: parsePositiveInt(process.env.IMHUB_VIEWER_CHARS, DEFAULT_THRESHOLDS.chars),
|
|
22
26
|
lines: parsePositiveInt(process.env.IMHUB_VIEWER_LINES, DEFAULT_THRESHOLDS.lines),
|
|
@@ -24,14 +28,32 @@ export function getViewerConfig() {
|
|
|
24
28
|
},
|
|
25
29
|
};
|
|
26
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the effective public base URL. Order of preference:
|
|
33
|
+
* 1. Explicit IMHUB_VIEWER_PUBLIC_BASE_URL (operator-managed reverse proxy)
|
|
34
|
+
* 2. Auto-tunnel URL if tunnel_mode=quick and cloudflared has acquired a URL
|
|
35
|
+
* 3. Caller-supplied fallback
|
|
36
|
+
*
|
|
37
|
+
* Returns empty string when nothing usable is available.
|
|
38
|
+
*/
|
|
39
|
+
export function getEffectivePublicBaseUrl(fallback) {
|
|
40
|
+
const cfg = getViewerConfig();
|
|
41
|
+
if (cfg.publicBaseUrl)
|
|
42
|
+
return cfg.publicBaseUrl;
|
|
43
|
+
if (cfg.tunnelMode === 'quick') {
|
|
44
|
+
const tu = getCurrentTunnelUrl();
|
|
45
|
+
if (tu)
|
|
46
|
+
return tu;
|
|
47
|
+
}
|
|
48
|
+
return (fallback || '').replace(/\/$/, '');
|
|
49
|
+
}
|
|
27
50
|
/**
|
|
28
51
|
* Build the URL placed in the IM reply for a saved paste. Returns null if
|
|
29
52
|
* no public base URL is configured AND no fallback was provided. Caller
|
|
30
53
|
* should treat null as "viewer can't produce a usable link — degrade".
|
|
31
54
|
*/
|
|
32
55
|
export function buildPasteUrl(id, fallbackBaseUrl) {
|
|
33
|
-
const
|
|
34
|
-
const base = cfg.publicBaseUrl || fallbackBaseUrl || '';
|
|
56
|
+
const base = getEffectivePublicBaseUrl(fallbackBaseUrl);
|
|
35
57
|
if (!base)
|
|
36
58
|
return null;
|
|
37
59
|
return `${base.replace(/\/$/, '')}/v/${id}`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"viewer-config.js","sourceRoot":"","sources":["../../src/core/viewer-config.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,EAAE;AACF,4EAA4E;AAC5E,0EAA0E;AAC1E,0EAA0E;AAE1E,OAAO,EAAE,kBAAkB,EAAyB,MAAM,oBAAoB,CAAA;
|
|
1
|
+
{"version":3,"file":"viewer-config.js","sourceRoot":"","sources":["../../src/core/viewer-config.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,EAAE;AACF,4EAA4E;AAC5E,0EAA0E;AAC1E,0EAA0E;AAE1E,OAAO,EAAE,kBAAkB,EAAyB,MAAM,oBAAoB,CAAA;AAC9E,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAA;AAyBjD,SAAS,gBAAgB,CAAC,GAAuB,EAAE,QAAgB;IACjE,IAAI,CAAC,GAAG;QAAE,OAAO,QAAQ,CAAA;IACzB,MAAM,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;IAC3B,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAA;AACnD,CAAC;AAED,MAAM,UAAU,eAAe;IAC7B,MAAM,UAAU,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,oBAAoB,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAA;IACzE,MAAM,OAAO,GAAG,UAAU,KAAK,GAAG,IAAI,UAAU,KAAK,MAAM,IAAI,UAAU,KAAK,KAAK,CAAA;IACnF,MAAM,aAAa,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,4BAA4B,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;IACzF,MAAM,aAAa,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,wBAAwB,IAAI,KAAK,CAAC,CAAC,WAAW,EAAE,CAAA;IACnF,MAAM,UAAU,GAAqB,aAAa,KAAK,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAA;IAChF,OAAO;QACL,OAAO;QACP,aAAa;QACb,UAAU;QACV,UAAU,EAAE;YACV,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,KAAK,CAAC;YACjF,KAAK,EAAE,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,KAAK,CAAC;YACjF,SAAS,EAAE,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,uBAAuB,EAAE,kBAAkB,CAAC,SAAS,CAAC;SAC/F;KACF,CAAA;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,yBAAyB,CAAC,QAAiB;IACzD,MAAM,GAAG,GAAG,eAAe,EAAE,CAAA;IAC7B,IAAI,GAAG,CAAC,aAAa;QAAE,OAAO,GAAG,CAAC,aAAa,CAAA;IAC/C,IAAI,GAAG,CAAC,UAAU,KAAK,OAAO,EAAE,CAAC;QAC/B,MAAM,EAAE,GAAG,mBAAmB,EAAE,CAAA;QAChC,IAAI,EAAE;YAAE,OAAO,EAAE,CAAA;IACnB,CAAC;IACD,OAAO,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;AAC5C,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,EAAU,EAAE,eAAwB;IAChE,MAAM,IAAI,GAAG,yBAAyB,CAAC,eAAe,CAAC,CAAA;IACvD,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAA;IACtB,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,EAAE,EAAE,CAAA;AAC7C,CAAC"}
|
|
@@ -168,6 +168,16 @@
|
|
|
168
168
|
viewerSaved: 'Saved — change takes effect on the next reply.',
|
|
169
169
|
viewerSaveFailed: 'Failed to save viewer settings',
|
|
170
170
|
viewerMissingUrl: '⚠️ Public URL is empty — Agim will fall back to inline text for long replies (no link can be built).',
|
|
171
|
+
viewerTunnelMode: 'Auto-tunnel (cloudflared)',
|
|
172
|
+
viewerTunnelHint: 'For users without a public domain. Agim launches a cloudflared quick tunnel on startup and auto-detects a temporary `*.trycloudflare.com` URL. Requires the `cloudflared` binary to be installed (`brew install cloudflared` / `apt install cloudflared`). URL changes per restart — old paste links die. Leave off when you have a static reverse proxy.',
|
|
173
|
+
viewerTunnelOff: 'Off (use Public URL above)',
|
|
174
|
+
viewerTunnelQuick: 'Quick tunnel (temporary URL, no domain needed)',
|
|
175
|
+
viewerTunnelStatus: 'Tunnel status',
|
|
176
|
+
viewerTunnelRunning: 'Running',
|
|
177
|
+
viewerTunnelNotRunning: 'Not running',
|
|
178
|
+
viewerTunnelBinaryMissing: 'cloudflared not installed',
|
|
179
|
+
viewerTunnelCurrentUrl: 'Current URL',
|
|
180
|
+
viewerTunnelRefresh: 'Refresh',
|
|
171
181
|
},
|
|
172
182
|
zh: {
|
|
173
183
|
title: 'Agim — 设置',
|
|
@@ -322,6 +332,16 @@
|
|
|
322
332
|
viewerSaved: '已保存 — 下一条回复生效。',
|
|
323
333
|
viewerSaveFailed: '保存 Viewer 配置失败',
|
|
324
334
|
viewerMissingUrl: '⚠️ 公网域名为空 — 长回复将退回到内联文本(无法构造跳转链接)。',
|
|
335
|
+
viewerTunnelMode: '自动 tunnel(cloudflared)',
|
|
336
|
+
viewerTunnelHint: '给没有公网域名的用户。Agim 启动时自动拉起 cloudflared quick tunnel,拿一个临时 `*.trycloudflare.com` URL。需要先装 `cloudflared`(`brew install cloudflared` / `apt install cloudflared`)。**重启后 URL 会变,旧 paste 链接打不开**。有反代域名的话保持关闭即可。',
|
|
337
|
+
viewerTunnelOff: '关闭(用上面的公网域名)',
|
|
338
|
+
viewerTunnelQuick: 'Quick tunnel(临时 URL,无需域名)',
|
|
339
|
+
viewerTunnelStatus: 'Tunnel 状态',
|
|
340
|
+
viewerTunnelRunning: '运行中',
|
|
341
|
+
viewerTunnelNotRunning: '未运行',
|
|
342
|
+
viewerTunnelBinaryMissing: '未安装 cloudflared',
|
|
343
|
+
viewerTunnelCurrentUrl: '当前 URL',
|
|
344
|
+
viewerTunnelRefresh: '刷新',
|
|
325
345
|
},
|
|
326
346
|
};
|
|
327
347
|
function t(key) { return T[window.__lang][key] || T.en[key] || key; }
|
|
@@ -1419,6 +1439,18 @@
|
|
|
1419
1439
|
<input type="text" id="viewerPublicUrl" placeholder="https://agim.example.com" style="max-width:480px" />
|
|
1420
1440
|
<p class="muted" style="margin-top:4px;font-size:12px">${t('viewerPublicUrlHint')}</p>
|
|
1421
1441
|
|
|
1442
|
+
<label style="margin-top:12px;display:block">${t('viewerTunnelMode')}</label>
|
|
1443
|
+
<select id="viewerTunnelMode" style="max-width:480px">
|
|
1444
|
+
<option value="off">${t('viewerTunnelOff')}</option>
|
|
1445
|
+
<option value="quick">${t('viewerTunnelQuick')}</option>
|
|
1446
|
+
</select>
|
|
1447
|
+
<p class="muted" style="margin-top:4px;font-size:12px">${t('viewerTunnelHint')}</p>
|
|
1448
|
+
<div id="viewerTunnelStatusBox" style="margin-top:6px;padding:8px 10px;border:1px solid var(--border, #d0d7de);border-radius:6px;font-size:12px;display:none">
|
|
1449
|
+
<div><strong>${t('viewerTunnelStatus')}:</strong> <span id="viewerTunnelState">—</span></div>
|
|
1450
|
+
<div style="margin-top:4px"><strong>${t('viewerTunnelCurrentUrl')}:</strong> <code id="viewerTunnelCurrentUrl" style="word-break:break-all">—</code></div>
|
|
1451
|
+
<button type="button" class="btn" id="viewerTunnelRefreshBtn" style="margin-top:6px;padding:4px 10px;font-size:12px">${t('viewerTunnelRefresh')}</button>
|
|
1452
|
+
</div>
|
|
1453
|
+
|
|
1422
1454
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-top:12px;max-width:720px">
|
|
1423
1455
|
<div>
|
|
1424
1456
|
<label>${t('viewerChars')}</label>
|
|
@@ -1462,6 +1494,32 @@
|
|
|
1462
1494
|
`;
|
|
1463
1495
|
}
|
|
1464
1496
|
|
|
1497
|
+
async function refreshViewerTunnelStatus() {
|
|
1498
|
+
const stateEl = document.getElementById('viewerTunnelState');
|
|
1499
|
+
const urlEl = document.getElementById('viewerTunnelCurrentUrl');
|
|
1500
|
+
if (!stateEl || !urlEl) return;
|
|
1501
|
+
stateEl.textContent = '…';
|
|
1502
|
+
urlEl.textContent = '—';
|
|
1503
|
+
try {
|
|
1504
|
+
const data = await authFetch('/api/viewer/tunnel').then(r => r.json());
|
|
1505
|
+
const tun = data && data.tunnel ? data.tunnel : {};
|
|
1506
|
+
if (!tun.binaryFound) {
|
|
1507
|
+
stateEl.textContent = t('viewerTunnelBinaryMissing');
|
|
1508
|
+
stateEl.style.color = '#c0392b';
|
|
1509
|
+
} else if (tun.running) {
|
|
1510
|
+
stateEl.textContent = '✓ ' + t('viewerTunnelRunning');
|
|
1511
|
+
stateEl.style.color = '#16a34a';
|
|
1512
|
+
} else {
|
|
1513
|
+
stateEl.textContent = t('viewerTunnelNotRunning');
|
|
1514
|
+
stateEl.style.color = '';
|
|
1515
|
+
}
|
|
1516
|
+
urlEl.textContent = tun.url || (data && data.effectivePublicUrl) || '—';
|
|
1517
|
+
} catch (err) {
|
|
1518
|
+
stateEl.textContent = 'error';
|
|
1519
|
+
urlEl.textContent = (err && err.message) || String(err);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1465
1523
|
async function loadEnvSection(reveal) {
|
|
1466
1524
|
try {
|
|
1467
1525
|
const url = reveal ? '/api/env?reveal=1' : '/api/env';
|
|
@@ -1490,6 +1548,16 @@
|
|
|
1490
1548
|
set('viewerLines', 'IMHUB_VIEWER_LINES');
|
|
1491
1549
|
set('viewerCodeLines', 'IMHUB_VIEWER_CODE_LINES');
|
|
1492
1550
|
set('viewerMaxPastes', 'IMHUB_VIEWER_MAX_PASTES');
|
|
1551
|
+
const vTunnelMode = document.getElementById('viewerTunnelMode');
|
|
1552
|
+
if (vTunnelMode) {
|
|
1553
|
+
vTunnelMode.value = (env['IMHUB_VIEWER_TUNNEL_MODE'] || 'off').toLowerCase() === 'quick' ? 'quick' : 'off';
|
|
1554
|
+
}
|
|
1555
|
+
// Show tunnel status panel only when tunnel mode = quick.
|
|
1556
|
+
const tunnelBox = document.getElementById('viewerTunnelStatusBox');
|
|
1557
|
+
if (tunnelBox) {
|
|
1558
|
+
tunnelBox.style.display = (vTunnelMode && vTunnelMode.value === 'quick') ? 'block' : 'none';
|
|
1559
|
+
if (vTunnelMode && vTunnelMode.value === 'quick') void refreshViewerTunnelStatus();
|
|
1560
|
+
}
|
|
1493
1561
|
const viewerStatus = document.getElementById('viewerStatus');
|
|
1494
1562
|
if (viewerStatus) {
|
|
1495
1563
|
if (!env['IMHUB_VIEWER_PUBLIC_BASE_URL']) {
|
|
@@ -1801,6 +1869,13 @@
|
|
|
1801
1869
|
});
|
|
1802
1870
|
document.getElementById('revealBaidu')?.addEventListener('click', () => loadEnvSection(true));
|
|
1803
1871
|
|
|
1872
|
+
// Tunnel mode dropdown — show/hide status panel on change.
|
|
1873
|
+
document.getElementById('viewerTunnelMode')?.addEventListener('change', (e) => {
|
|
1874
|
+
const box = document.getElementById('viewerTunnelStatusBox');
|
|
1875
|
+
if (box) box.style.display = e.target.value === 'quick' ? 'block' : 'none';
|
|
1876
|
+
});
|
|
1877
|
+
document.getElementById('viewerTunnelRefreshBtn')?.addEventListener('click', () => refreshViewerTunnelStatus());
|
|
1878
|
+
|
|
1804
1879
|
// Viewer card — save toggle + URL + thresholds in one round-trip.
|
|
1805
1880
|
document.getElementById('saveViewer')?.addEventListener('click', async () => {
|
|
1806
1881
|
const enabled = document.getElementById('viewerEnabled')?.checked ? '1' : '0';
|
|
@@ -1809,6 +1884,7 @@
|
|
|
1809
1884
|
const lines = (document.getElementById('viewerLines')?.value || '').trim();
|
|
1810
1885
|
const codeLines = (document.getElementById('viewerCodeLines')?.value || '').trim();
|
|
1811
1886
|
const maxPastes = (document.getElementById('viewerMaxPastes')?.value || '').trim();
|
|
1887
|
+
const tunnelMode = document.getElementById('viewerTunnelMode')?.value || 'off';
|
|
1812
1888
|
const status = document.getElementById('viewerStatus');
|
|
1813
1889
|
try {
|
|
1814
1890
|
await authFetch('/api/env', {
|
|
@@ -1822,6 +1898,7 @@
|
|
|
1822
1898
|
IMHUB_VIEWER_LINES: lines || null,
|
|
1823
1899
|
IMHUB_VIEWER_CODE_LINES: codeLines || null,
|
|
1824
1900
|
IMHUB_VIEWER_MAX_PASTES: maxPastes || null,
|
|
1901
|
+
IMHUB_VIEWER_TUNNEL_MODE: tunnelMode || 'off',
|
|
1825
1902
|
},
|
|
1826
1903
|
}),
|
|
1827
1904
|
});
|
package/dist/web/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/web/server.ts"],"names":[],"mappings":"AAsDA,wBAAgB,iBAAiB,IAAI,CAAC,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAOrE;AAED;;GAEG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,CAAA;CACrB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/web/server.ts"],"names":[],"mappings":"AAsDA,wBAAgB,iBAAiB,IAAI,CAAC,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAOrE;AAED;;GAEG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,CAAA;CACrB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAksB/C"}
|
package/dist/web/server.js
CHANGED
|
@@ -412,6 +412,9 @@ export async function startWebServer(options) {
|
|
|
412
412
|
if (url.pathname === '/api/viewer' && req.method === 'GET') {
|
|
413
413
|
return handleViewerList(req, res, url);
|
|
414
414
|
}
|
|
415
|
+
if (url.pathname === '/api/viewer/tunnel' && req.method === 'GET') {
|
|
416
|
+
return handleViewerTunnelStatus(req, res);
|
|
417
|
+
}
|
|
415
418
|
const viewerIdMatch = url.pathname.match(/^\/api\/viewer\/([0-9a-f-]{8,})$/i);
|
|
416
419
|
if (viewerIdMatch && req.method === 'GET') {
|
|
417
420
|
return handleViewerGet(req, res, viewerIdMatch[1]);
|
|
@@ -1640,6 +1643,7 @@ const ENV_EDITABLE_KEYS = [
|
|
|
1640
1643
|
'IMHUB_VIEWER_LINES',
|
|
1641
1644
|
'IMHUB_VIEWER_CODE_LINES',
|
|
1642
1645
|
'IMHUB_VIEWER_MAX_PASTES',
|
|
1646
|
+
'IMHUB_VIEWER_TUNNEL_MODE',
|
|
1643
1647
|
];
|
|
1644
1648
|
const SECRET_KEYS = new Set(['IMHUB_SMTP_PASS', 'IMHUB_BAIDU_MAP_AK']);
|
|
1645
1649
|
function maskSecret(v) {
|
|
@@ -2786,6 +2790,19 @@ async function handleViewerDelete(_req, res, id) {
|
|
|
2786
2790
|
}
|
|
2787
2791
|
sendJson(res, 200, { ok: true });
|
|
2788
2792
|
}
|
|
2793
|
+
async function handleViewerTunnelStatus(_req, res) {
|
|
2794
|
+
const { getTunnelStatus } = await import('../core/tunnel.js');
|
|
2795
|
+
const { getViewerConfig, getEffectivePublicBaseUrl } = await import('../core/viewer-config.js');
|
|
2796
|
+
const cfg = getViewerConfig();
|
|
2797
|
+
const tunnel = getTunnelStatus();
|
|
2798
|
+
sendJson(res, 200, {
|
|
2799
|
+
enabled: cfg.enabled,
|
|
2800
|
+
tunnelMode: cfg.tunnelMode,
|
|
2801
|
+
staticPublicUrl: cfg.publicBaseUrl,
|
|
2802
|
+
effectivePublicUrl: getEffectivePublicBaseUrl(),
|
|
2803
|
+
tunnel,
|
|
2804
|
+
});
|
|
2805
|
+
}
|
|
2789
2806
|
// ============================================
|
|
2790
2807
|
// WebSocket chat handlers
|
|
2791
2808
|
// ============================================
|