@openchambery/web 1.17.1-beta.7 → 1.17.1-beta.8
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 +3 -39
- package/bin/cli-output.js +0 -16
- package/bin/cli.js +3 -66
- package/bin/cli.test.js +4 -81
- package/bin/lib/DOCUMENTATION.md +5 -19
- package/bin/lib/cli-args.js +3 -327
- package/bin/lib/cli-http.js +0 -16
- package/bin/lib/cli-lifecycle.js +2 -111
- package/bin/lib/cli-paths.js +0 -57
- package/dist/assets/{AgentAvatar-htFrIvQx.js → AgentAvatar-C6Hxu5_o.js} +1 -1
- package/dist/assets/{AssistantView-BZiJsNuI.js → AssistantView-ZlXc0xFT.js} +1 -1
- package/dist/assets/{ChatContainer-CdmcMnL3.js → ChatContainer-BXFy3Uu2.js} +5 -5
- package/dist/assets/{DiagramView-B7sq74V-.js → DiagramView-CgotrQyK.js} +1 -1
- package/dist/assets/{FadeInOnReveal-8fY7XL23.js → FadeInOnReveal-CvNA_aGD.js} +1 -1
- package/dist/assets/{Icon-B7eLbnyj.js → Icon-BPJ_quEe.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-DICju9ve.js → MarkdownRendererImpl-Cj4y0P0S.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-CuxWU1yf.js → MobileDetailNavigation-5tAL5d_M.js} +1 -1
- package/dist/assets/{MobileShareBridge-CPg9Eyro.js → MobileShareBridge-BL7Ehxhd.js} +1 -1
- package/dist/assets/{MobileSurface-D7sDH_tx.js → MobileSurface-ByMnQ5rD.js} +1 -1
- package/dist/assets/{MultiRunWindow-MSO0Equt.js → MultiRunWindow-Che7L3TX.js} +1 -1
- package/dist/assets/{OnboardingScreen-C87u_MLg.js → OnboardingScreen-CdvaEBtt.js} +1 -1
- package/dist/assets/{OverlayScrollbar-Kmmybw-z.js → OverlayScrollbar-C2pEDFq-.js} +1 -1
- package/dist/assets/SettingsView-D0p-e8Px.js +25 -0
- package/dist/assets/{SettingsWindow-Bl25cfXX.js → SettingsWindow-KYhsFUrb.js} +1 -1
- package/dist/assets/{TerminalView-jR5jP1b0.js → TerminalView-Da5aLqHR.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DCEZb2DZ.js → ToolOutputDialog-DZI4uxx3.js} +1 -1
- package/dist/assets/{appThemeRegistry-aiRF-s3c.js → appThemeRegistry-DAgBeFsZ.js} +2 -2
- package/dist/assets/{button-bYWkDtDI.js → button-BmNJBT13.js} +1 -1
- package/dist/assets/{clipboard-CzpzN4Um.js → clipboard-CXWbHCAk.js} +1 -1
- package/dist/assets/{collapsible-A1PFvMRp.js → collapsible-68IqWBM1.js} +1 -1
- package/dist/assets/{context-menu-CkpHn-Bm.js → context-menu-BXH0AcOc.js} +1 -1
- package/dist/assets/desktopHosts-BpjUSDz4.js +1 -0
- package/dist/assets/{desktopRelayRestore-C5vYnSTd.js → desktopRelayRestore-BzUEk1i7.js} +1 -1
- package/dist/assets/{diagram-BkwKWaTO.js → diagram-YN0GZ1Q0.js} +1 -1
- package/dist/assets/{dialog-DW7-_iqo.js → dialog-BV1ENlLf.js} +1 -1
- package/dist/assets/es-EXZUX1sB.js +15 -0
- package/dist/assets/{fileQueries-P9SX4YwC.js → fileQueries-i4oziMnC.js} +1 -1
- package/dist/assets/fr-jWNPO91J.js +13 -0
- package/dist/assets/{gitApi-CMsZ2v08.js → gitApi-BW5AFgD1.js} +1 -1
- package/dist/assets/{gitApiHttp-DvIIcDAO.js → gitApiHttp-C1DaEMKR.js} +1 -1
- package/dist/assets/{input-B9aNttqV.js → input-BLVhPvhN.js} +1 -1
- package/dist/assets/{insertionBoundaries-Di3rCXgN.js → insertionBoundaries-D4KbXFFj.js} +1 -1
- package/dist/assets/ja-Dcb0VtDE.js +15 -0
- package/dist/assets/ko-CX71xNDX.js +15 -0
- package/dist/assets/{main-DY27kMV5.js → main-CcyyPAib.js} +2 -2
- package/dist/assets/{main-Chtf300b.js → main-DzxdsF_k.js} +5 -5
- package/dist/assets/{markdown-worker-BoB1_DPo.js → markdown-worker-Cq3Twp1A.js} +1 -1
- package/dist/assets/{markdownTheme-om5XcPqq.js → markdownTheme-CCnvI6L0.js} +1 -1
- package/dist/assets/{miniChat-BWbpl5SN.js → miniChat-BsNf9y48.js} +2 -2
- package/dist/assets/mobile-CsJg2IAq.js +2 -0
- package/dist/assets/{multirun-BfPcVktg.js → multirun-qs5hZkwM.js} +1 -1
- package/dist/assets/persistence-C_KCiMcY.js +3 -0
- package/dist/assets/{pl-BSjLn4dm.js → pl-mR_3Sqr8.js} +2 -2
- package/dist/assets/{projectMeta-CiAR7MRG.js → projectMeta-1SSTNg8w.js} +1 -1
- package/dist/assets/pt-BR-D1RpVOvC.js +15 -0
- package/dist/assets/radio-DBJG9Dd1.js +1 -0
- package/dist/assets/{renderElectronMiniChatApp-CiEXAVSt.js → renderElectronMiniChatApp-DOWE0jSA.js} +2 -2
- package/dist/assets/{renderMobileApp-BHvgSVPk.js → renderMobileApp-bklJJSZi.js} +3 -3
- package/dist/assets/{runtimeConfig-49YbZaqI.js → runtimeConfig-JSaYFA5x.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-CdhtjuPe.js → runtimeEndpointReset-ix1NxqYw.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-XeYica3j.js → sortable-tabs-strip-DncWjNx4.js} +1 -1
- package/dist/assets/src-B8mzksNL.css +2 -0
- package/dist/assets/store-oojlrF4d.js +15 -0
- package/dist/assets/{terminalApi-DAu7xKli.js → terminalApi-BYTA045F.js} +1 -1
- package/dist/assets/{tooltip-BSlxaNrR.js → tooltip-BYGBBTc2.js} +1 -1
- package/dist/assets/uk-UvnVqWv-.js +15 -0
- package/dist/assets/{url-DjkTDrad.js → url-CGHw6D-E.js} +1 -1
- package/dist/assets/useAppFontEffects-B1EhZNex.js +7 -0
- package/dist/assets/{useDesktopWindowControlsLayout-BwYnhrEy.js → useDesktopWindowControlsLayout-Dc69l-pm.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-CEmsrrEs.js → useEffectiveDirectory-dCGvvYRh.js} +1 -1
- package/dist/assets/{useFontPreferences-BxMT_kty.js → useFontPreferences-Bu4ME5hk.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-DryW-QdG.js → useMobileNavigationStore-DhkIZBJw.js} +4 -4
- package/dist/assets/useSessionAutoCleanup-D-r9oDye.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-CFW2AoPL.js → useWorkerHighlightedLines-05dLFgTI.js} +1 -1
- package/dist/assets/{utils-RUACCsOs.js → utils-DyUyiLje.js} +1 -1
- package/dist/assets/zh-CN-9v-RC966.js +15 -0
- package/dist/assets/zh-TW-BZ_wZdC-.js +15 -0
- package/dist/index.html +8 -8
- package/dist/mini-chat.html +8 -8
- package/dist/mobile.html +8 -8
- package/package.json +1 -1
- package/server/index.d.ts +0 -6
- package/server/index.js +5 -153
- package/server/lib/opencode/DOCUMENTATION.md +6 -10
- package/server/lib/opencode/cli-entry-runtime.js +0 -10
- package/server/lib/opencode/cli-options.js +0 -65
- package/server/lib/opencode/core-routes.js +2 -4
- package/server/lib/opencode/routes.test.js +0 -1
- package/server/lib/opencode/server-startup-runtime.js +0 -55
- package/server/lib/opencode/settings-helpers.js +0 -55
- package/server/lib/opencode/settings-helpers.test.js +0 -20
- package/server/lib/opencode/settings-normalization-runtime.js +0 -87
- package/server/lib/opencode/settings-normalization-runtime.test.js +0 -3
- package/server/lib/opencode/settings-runtime.js +9 -102
- package/server/lib/opencode/settings-runtime.test.js +0 -10
- package/server/lib/opencode/shutdown-runtime.js +1 -9
- package/server/lib/opencode/shutdown-runtime.test.js +0 -2
- package/server/lib/opencode/startup-pipeline-runtime.js +0 -20
- package/server/lib/opencode/startup-pipeline-runtime.test.js +2 -3
- package/server/lib/opencode/tunnel-wiring-runtime.js +2 -80
- package/server/lib/relay/DOCUMENTATION.md +1 -1
- package/bin/lib/cli-tunnel-capabilities.js +0 -9
- package/bin/lib/cli-tunnel-profiles.js +0 -366
- package/bin/lib/cli-tunnel-utils.js +0 -300
- package/bin/lib/commands-tunnel.js +0 -1761
- package/dist/assets/SettingsView-5YNu54KI.js +0 -25
- package/dist/assets/desktopHosts-D5m_ggY1.js +0 -1
- package/dist/assets/es-Cei_75Jt.js +0 -15
- package/dist/assets/fr-Dn7WSHh2.js +0 -13
- package/dist/assets/ja-DFARVqVj.js +0 -15
- package/dist/assets/ko-pyLnIyDY.js +0 -15
- package/dist/assets/mobile-DEQeGskq.js +0 -2
- package/dist/assets/persistence-CHA81Od7.js +0 -3
- package/dist/assets/pt-BR-DaWvr_0A.js +0 -15
- package/dist/assets/radio-BnF4XhCC.js +0 -1
- package/dist/assets/src-BtkWqMFu.css +0 -2
- package/dist/assets/store-DIpqFVjs.js +0 -15
- package/dist/assets/uk-CHCRqRKf.js +0 -15
- package/dist/assets/useAppFontEffects-BRNICZS2.js +0 -7
- package/dist/assets/useSessionAutoCleanup-DMJ1LiW_.js +0 -1
- package/dist/assets/zh-CN-BWEQZKXk.js +0 -15
- package/dist/assets/zh-TW-BbO-gJq3.js +0 -15
- package/server/lib/cloudflare-tunnel.js +0 -618
- package/server/lib/ngrok-tunnel.js +0 -343
- package/server/lib/ngrok-tunnel.test.js +0 -50
- package/server/lib/tunnels/DOCUMENTATION.md +0 -21
- package/server/lib/tunnels/executable-search.js +0 -140
- package/server/lib/tunnels/executable-search.test.js +0 -96
- package/server/lib/tunnels/index.js +0 -179
- package/server/lib/tunnels/index.test.js +0 -93
- package/server/lib/tunnels/install-help.js +0 -56
- package/server/lib/tunnels/install-help.test.js +0 -31
- package/server/lib/tunnels/managed-config.js +0 -201
- package/server/lib/tunnels/providers/cloudflare.js +0 -266
- package/server/lib/tunnels/providers/ngrok.js +0 -123
- package/server/lib/tunnels/registry.js +0 -51
- package/server/lib/tunnels/routes.js +0 -610
- package/server/lib/tunnels/types.js +0 -245
- package/server/lib/tunnels/types.test.js +0 -34
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
import { spawn, spawnSync } from 'child_process';
|
|
2
|
-
import {
|
|
3
|
-
createExecutableSearchEnv,
|
|
4
|
-
resolveExecutableLaunchTarget,
|
|
5
|
-
} from './tunnels/executable-search.js';
|
|
6
|
-
import { getTunnelDependencyInstallInfo } from './tunnels/install-help.js';
|
|
7
|
-
import { TUNNEL_PROVIDER_NGROK } from './tunnels/types.js';
|
|
8
|
-
|
|
9
|
-
const DEFAULT_STARTUP_TIMEOUT_MS = 30000;
|
|
10
|
-
const NGROK_API_URL = 'http://127.0.0.1:4040/api/tunnels';
|
|
11
|
-
const NGROK_PUBLIC_URL_REGEX = /https:\/\/[^\s"']+/i;
|
|
12
|
-
const NGROK_AUTHTOKEN_HELP = 'Run: ngrok config add-authtoken <your-ngrok-token>';
|
|
13
|
-
const getNgrokInstallInfo = () => getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_NGROK);
|
|
14
|
-
|
|
15
|
-
export async function checkNgrokAvailable() {
|
|
16
|
-
const target = resolveExecutableLaunchTarget('ngrok');
|
|
17
|
-
if (target) {
|
|
18
|
-
try {
|
|
19
|
-
const result = spawnSync(target.command, ['version'], {
|
|
20
|
-
encoding: 'utf8',
|
|
21
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
22
|
-
windowsHide: true,
|
|
23
|
-
env: target.env,
|
|
24
|
-
});
|
|
25
|
-
if (result.status === 0) {
|
|
26
|
-
return { available: true, path: target.command, version: result.stdout.trim() || result.stderr.trim() };
|
|
27
|
-
}
|
|
28
|
-
} catch {
|
|
29
|
-
// Ignore and report unavailable below.
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return { available: false, path: null, version: null };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function checkNgrokAuthtokenConfigured(ngrokPath = null) {
|
|
36
|
-
if (typeof process.env.NGROK_AUTHTOKEN === 'string' && process.env.NGROK_AUTHTOKEN.trim().length > 0) {
|
|
37
|
-
return { configured: true, detail: 'NGROK_AUTHTOKEN is set.' };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const target = ngrokPath
|
|
41
|
-
? { command: ngrokPath, env: createExecutableSearchEnv() }
|
|
42
|
-
: resolveExecutableLaunchTarget('ngrok');
|
|
43
|
-
if (!target) {
|
|
44
|
-
return { configured: false, detail: getNgrokInstallInfo().message };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
const result = spawnSync(target.command, ['config', 'check'], {
|
|
49
|
-
encoding: 'utf8',
|
|
50
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
51
|
-
windowsHide: true,
|
|
52
|
-
env: target.env,
|
|
53
|
-
});
|
|
54
|
-
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
55
|
-
if (result.status === 0) {
|
|
56
|
-
return { configured: true, detail: output || 'ngrok config is valid.' };
|
|
57
|
-
}
|
|
58
|
-
return { configured: false, detail: output || NGROK_AUTHTOKEN_HELP };
|
|
59
|
-
} catch (error) {
|
|
60
|
-
return {
|
|
61
|
-
configured: false,
|
|
62
|
-
detail: error instanceof Error ? error.message : String(error),
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export async function checkNgrokApiReachability({ fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
|
|
68
|
-
if (typeof fetchImpl !== 'function') {
|
|
69
|
-
return { reachable: false, status: null, error: 'Fetch API is unavailable in this runtime.' };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const controller = new AbortController();
|
|
73
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
74
|
-
try {
|
|
75
|
-
const response = await fetchImpl('https://api.ngrok.com/', {
|
|
76
|
-
method: 'GET',
|
|
77
|
-
signal: controller.signal,
|
|
78
|
-
});
|
|
79
|
-
return { reachable: true, status: response.status, error: null };
|
|
80
|
-
} catch (error) {
|
|
81
|
-
return {
|
|
82
|
-
reachable: false,
|
|
83
|
-
status: null,
|
|
84
|
-
error: error instanceof Error ? error.message : String(error),
|
|
85
|
-
};
|
|
86
|
-
} finally {
|
|
87
|
-
clearTimeout(timeout);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const spawnNgrok = (args, resolvedBinaryPath = 'ngrok') => spawn(resolvedBinaryPath, args, {
|
|
92
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
93
|
-
windowsHide: true,
|
|
94
|
-
env: createExecutableSearchEnv(),
|
|
95
|
-
killSignal: 'SIGINT',
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
const normalizeNgrokPublicUrl = (value) => {
|
|
99
|
-
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
const trimmed = value.trim();
|
|
103
|
-
try {
|
|
104
|
-
const parsed = new URL(trimmed);
|
|
105
|
-
if (parsed.protocol === 'https:' && parsed.hostname.includes('ngrok')) {
|
|
106
|
-
return parsed.toString().replace(/\/$/, '');
|
|
107
|
-
}
|
|
108
|
-
} catch {
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
return null;
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
export function extractNgrokPublicUrlFromText(text) {
|
|
115
|
-
if (typeof text !== 'string' || text.trim().length === 0) {
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
120
|
-
for (const line of lines) {
|
|
121
|
-
try {
|
|
122
|
-
const parsed = JSON.parse(line);
|
|
123
|
-
const parsedUrl = normalizeNgrokPublicUrl(parsed?.url) || normalizeNgrokPublicUrl(parsed?.public_url);
|
|
124
|
-
if (parsedUrl) {
|
|
125
|
-
return parsedUrl;
|
|
126
|
-
}
|
|
127
|
-
} catch {
|
|
128
|
-
// ngrok may emit non-JSON diagnostics even when log-format=json.
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
const match = line.match(NGROK_PUBLIC_URL_REGEX);
|
|
132
|
-
const matchedUrl = normalizeNgrokPublicUrl(match?.[0]);
|
|
133
|
-
if (matchedUrl) {
|
|
134
|
-
return matchedUrl;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const normalizeNgrokDiagnosticText = (value) => {
|
|
142
|
-
if (typeof value !== 'string') {
|
|
143
|
-
return '';
|
|
144
|
-
}
|
|
145
|
-
return value
|
|
146
|
-
.replace(/\r/g, '')
|
|
147
|
-
.split('\n')
|
|
148
|
-
.map((line) => line.trim())
|
|
149
|
-
.filter(Boolean)
|
|
150
|
-
.join(' ')
|
|
151
|
-
.replace(/\s+/g, ' ')
|
|
152
|
-
.trim();
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
export const summarizeNgrokOutput = (lines) => {
|
|
156
|
-
const nonEmptyLines = Array.isArray(lines)
|
|
157
|
-
? lines.map((line) => String(line || '').trim()).filter(Boolean)
|
|
158
|
-
: [];
|
|
159
|
-
if (nonEmptyLines.length === 0) {
|
|
160
|
-
return '';
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
for (const line of [...nonEmptyLines].reverse()) {
|
|
164
|
-
try {
|
|
165
|
-
const parsed = JSON.parse(line);
|
|
166
|
-
const level = typeof parsed?.lvl === 'string' ? parsed.lvl.toLowerCase() : '';
|
|
167
|
-
if (level !== 'eror' && level !== 'error' && level !== 'crit') {
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
const err = normalizeNgrokDiagnosticText(parsed?.err);
|
|
171
|
-
if (err && err !== '<nil>') {
|
|
172
|
-
return err;
|
|
173
|
-
}
|
|
174
|
-
} catch {
|
|
175
|
-
// Not a JSON ngrok log line.
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
for (const line of [...nonEmptyLines].reverse()) {
|
|
180
|
-
try {
|
|
181
|
-
const parsed = JSON.parse(line);
|
|
182
|
-
const err = normalizeNgrokDiagnosticText(parsed?.err);
|
|
183
|
-
if (err && err !== '<nil>' && !/context canceled/i.test(err)) {
|
|
184
|
-
return err;
|
|
185
|
-
}
|
|
186
|
-
const msg = normalizeNgrokDiagnosticText(parsed?.msg);
|
|
187
|
-
if (msg && /failed|error|invalid|auth/i.test(msg)) {
|
|
188
|
-
return msg;
|
|
189
|
-
}
|
|
190
|
-
} catch {
|
|
191
|
-
// Not a JSON ngrok log line.
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const errorLines = nonEmptyLines
|
|
196
|
-
.filter((line) => /^ERROR:/i.test(line))
|
|
197
|
-
.map((line) => normalizeNgrokDiagnosticText(line.replace(/^ERROR:\s*/i, '')))
|
|
198
|
-
.filter(Boolean);
|
|
199
|
-
if (errorLines.length > 0) {
|
|
200
|
-
return errorLines.slice(0, 4).join(' ');
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const lastLine = [...nonEmptyLines].reverse().find((line) => line.trim().length > 0);
|
|
204
|
-
if (!lastLine) {
|
|
205
|
-
return '';
|
|
206
|
-
}
|
|
207
|
-
try {
|
|
208
|
-
const parsed = JSON.parse(lastLine);
|
|
209
|
-
if (typeof parsed?.err === 'string' && parsed.err.trim().length > 0) {
|
|
210
|
-
return normalizeNgrokDiagnosticText(parsed.err);
|
|
211
|
-
}
|
|
212
|
-
if (typeof parsed?.msg === 'string' && parsed.msg.trim().length > 0) {
|
|
213
|
-
return normalizeNgrokDiagnosticText(parsed.msg);
|
|
214
|
-
}
|
|
215
|
-
} catch {
|
|
216
|
-
// Fall through to plain text output.
|
|
217
|
-
}
|
|
218
|
-
return normalizeNgrokDiagnosticText(lastLine);
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
const appendNgrokOutputSummary = (message, lines) => {
|
|
222
|
-
const summary = summarizeNgrokOutput(lines);
|
|
223
|
-
return summary ? `${message}: ${summary}` : message;
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
async function fetchNgrokPublicUrl(fetchImpl = globalThis.fetch) {
|
|
227
|
-
if (typeof fetchImpl !== 'function') {
|
|
228
|
-
return null;
|
|
229
|
-
}
|
|
230
|
-
try {
|
|
231
|
-
const response = await fetchImpl(NGROK_API_URL, { method: 'GET' });
|
|
232
|
-
if (!response.ok) {
|
|
233
|
-
return null;
|
|
234
|
-
}
|
|
235
|
-
const payload = await response.json();
|
|
236
|
-
const tunnels = Array.isArray(payload?.tunnels) ? payload.tunnels : [];
|
|
237
|
-
const httpsTunnel = tunnels.find((entry) => entry?.proto === 'https' && normalizeNgrokPublicUrl(entry?.public_url));
|
|
238
|
-
const fallbackTunnel = tunnels.find((entry) => normalizeNgrokPublicUrl(entry?.public_url));
|
|
239
|
-
return normalizeNgrokPublicUrl(httpsTunnel?.public_url) || normalizeNgrokPublicUrl(fallbackTunnel?.public_url);
|
|
240
|
-
} catch {
|
|
241
|
-
return null;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export async function startNgrokQuickTunnel({ port }) {
|
|
246
|
-
const ngrokCheck = await checkNgrokAvailable();
|
|
247
|
-
if (!ngrokCheck.available) {
|
|
248
|
-
throw new Error(getNgrokInstallInfo().message);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const authtokenCheck = await checkNgrokAuthtokenConfigured(ngrokCheck.path);
|
|
252
|
-
if (!authtokenCheck.configured) {
|
|
253
|
-
throw new Error(`ngrok authtoken is not configured. ${authtokenCheck.detail || NGROK_AUTHTOKEN_HELP}`);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
if (!Number.isFinite(port)) {
|
|
257
|
-
throw new Error('A local port is required to start an ngrok tunnel');
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const child = spawnNgrok(['http', '--log=stdout', '--log-format=json', `127.0.0.1:${port}`], ngrokCheck.path);
|
|
261
|
-
let publicUrl = null;
|
|
262
|
-
const recentOutput = [];
|
|
263
|
-
|
|
264
|
-
const captureOutput = (chunk) => {
|
|
265
|
-
const text = chunk.toString('utf8');
|
|
266
|
-
const parsedUrl = extractNgrokPublicUrlFromText(text);
|
|
267
|
-
if (parsedUrl) {
|
|
268
|
-
publicUrl = parsedUrl;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
for (const line of text.split(/\r?\n/)) {
|
|
272
|
-
const trimmed = line.trim();
|
|
273
|
-
if (!trimmed) {
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
recentOutput.push(trimmed);
|
|
277
|
-
if (recentOutput.length > 200) {
|
|
278
|
-
recentOutput.shift();
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
return text;
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
child.stdout.on('data', (chunk) => {
|
|
285
|
-
captureOutput(chunk);
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
child.stderr.on('data', (chunk) => {
|
|
289
|
-
const text = captureOutput(chunk);
|
|
290
|
-
process.stderr.write(text);
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
await new Promise((resolve, reject) => {
|
|
294
|
-
let settled = false;
|
|
295
|
-
const finish = (handler, value) => {
|
|
296
|
-
if (settled) {
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
settled = true;
|
|
300
|
-
clearTimeout(timeout);
|
|
301
|
-
clearInterval(checkReady);
|
|
302
|
-
child.off('error', onError);
|
|
303
|
-
child.off('exit', onExit);
|
|
304
|
-
handler(value);
|
|
305
|
-
};
|
|
306
|
-
|
|
307
|
-
const timeout = setTimeout(() => {
|
|
308
|
-
try { child.kill('SIGINT'); } catch { /* ignore */ }
|
|
309
|
-
finish(reject, new Error(appendNgrokOutputSummary('Ngrok tunnel URL not received within 30 seconds', recentOutput)));
|
|
310
|
-
}, DEFAULT_STARTUP_TIMEOUT_MS);
|
|
311
|
-
|
|
312
|
-
const checkReady = setInterval(async () => {
|
|
313
|
-
publicUrl = publicUrl || await fetchNgrokPublicUrl();
|
|
314
|
-
if (publicUrl) {
|
|
315
|
-
finish(resolve, null);
|
|
316
|
-
}
|
|
317
|
-
}, 250);
|
|
318
|
-
|
|
319
|
-
const onError = (error) => {
|
|
320
|
-
finish(reject, new Error(`Ngrok failed to start: ${error.message}`));
|
|
321
|
-
};
|
|
322
|
-
|
|
323
|
-
const onExit = (code) => {
|
|
324
|
-
finish(reject, new Error(appendNgrokOutputSummary(`Ngrok exited while starting (code ${code ?? 'unknown'})`, recentOutput)));
|
|
325
|
-
};
|
|
326
|
-
|
|
327
|
-
child.once('error', onError);
|
|
328
|
-
child.once('exit', onExit);
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
return {
|
|
332
|
-
mode: 'quick',
|
|
333
|
-
stop: () => {
|
|
334
|
-
try {
|
|
335
|
-
child.kill('SIGINT');
|
|
336
|
-
} catch {
|
|
337
|
-
// Ignore.
|
|
338
|
-
}
|
|
339
|
-
},
|
|
340
|
-
process: child,
|
|
341
|
-
getPublicUrl: () => publicUrl,
|
|
342
|
-
};
|
|
343
|
-
}
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'bun:test';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
extractNgrokPublicUrlFromText,
|
|
5
|
-
summarizeNgrokOutput,
|
|
6
|
-
} from './ngrok-tunnel.js';
|
|
7
|
-
|
|
8
|
-
describe('extractNgrokPublicUrlFromText', () => {
|
|
9
|
-
it('extracts public URL from ngrok JSON logs', () => {
|
|
10
|
-
const url = extractNgrokPublicUrlFromText('{"lvl":"info","msg":"started tunnel","url":"https://demo.ngrok-free.app"}\n');
|
|
11
|
-
|
|
12
|
-
expect(url).toBe('https://demo.ngrok-free.app');
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('extracts public URL from ngrok text output', () => {
|
|
16
|
-
const url = extractNgrokPublicUrlFromText('Forwarding https://demo.ngrok-free.app -> http://127.0.0.1:3000');
|
|
17
|
-
|
|
18
|
-
expect(url).toBe('https://demo.ngrok-free.app');
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('ignores non-ngrok URLs', () => {
|
|
22
|
-
const url = extractNgrokPublicUrlFromText('{"url":"https://example.com"}\n');
|
|
23
|
-
|
|
24
|
-
expect(url).toBeNull();
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe('summarizeNgrokOutput', () => {
|
|
29
|
-
it('prefers actionable JSON error details over trailing help text', () => {
|
|
30
|
-
const summary = summarizeNgrokOutput([
|
|
31
|
-
'{"err":"authentication failed: Your ngrok-agent version \\"3.3.1\\" is too old. The minimum supported agent version for your account is \\"3.20.0\\".\\r\\n\\r\\nERR_NGROK_121\\r\\n","lvl":"crit","msg":"command failed"}',
|
|
32
|
-
'NAME:',
|
|
33
|
-
' http - start an HTTP tunnel',
|
|
34
|
-
]);
|
|
35
|
-
|
|
36
|
-
expect(summary).toContain('Your ngrok-agent version "3.3.1" is too old');
|
|
37
|
-
expect(summary).toContain('ERR_NGROK_121');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('skips bare ERROR lines and keeps the useful error text', () => {
|
|
41
|
-
const summary = summarizeNgrokOutput([
|
|
42
|
-
'ERROR:',
|
|
43
|
-
'ERROR: authentication failed',
|
|
44
|
-
'ERROR:',
|
|
45
|
-
'ERROR: ERR_NGROK_121',
|
|
46
|
-
]);
|
|
47
|
-
|
|
48
|
-
expect(summary).toBe('authentication failed ERR_NGROK_121');
|
|
49
|
-
});
|
|
50
|
-
});
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# Tunnels Module Documentation
|
|
2
|
-
|
|
3
|
-
## Purpose
|
|
4
|
-
This module contains tunnel provider orchestration for OpenChamber, including provider registry/service wiring, managed remote token config lifecycle, and tunnel HTTP route registration.
|
|
5
|
-
|
|
6
|
-
## Entrypoints and structure
|
|
7
|
-
- `packages/web/server/lib/tunnels/index.js`: tunnel service orchestration.
|
|
8
|
-
- `packages/web/server/lib/tunnels/executable-search.js`: cross-platform executable discovery, including Windows Store app aliases.
|
|
9
|
-
- `packages/web/server/lib/tunnels/registry.js`: provider registry.
|
|
10
|
-
- `packages/web/server/lib/tunnels/managed-config.js`: managed remote tunnel token/preset persistence runtime.
|
|
11
|
-
- `packages/web/server/lib/tunnels/install-help.js`: provider/platform install command metadata for missing tunnel dependencies.
|
|
12
|
-
- `packages/web/server/lib/tunnels/routes.js`: tunnel API route registration and request orchestration runtime.
|
|
13
|
-
- `packages/web/server/lib/tunnels/types.js`: tunnel constants, normalization, and shared type helpers.
|
|
14
|
-
- `packages/web/server/lib/tunnels/providers/cloudflare.js`: Cloudflare tunnel provider implementation.
|
|
15
|
-
- `packages/web/server/lib/tunnels/providers/ngrok.js`: Ngrok quick tunnel provider implementation.
|
|
16
|
-
|
|
17
|
-
## Public exports (routes.js)
|
|
18
|
-
- `createTunnelRoutesRuntime(dependencies)`: creates tunnel routes runtime and helpers.
|
|
19
|
-
- Returned API:
|
|
20
|
-
- `registerRoutes(app)`
|
|
21
|
-
- `startTunnelWithNormalizedRequest(request)`
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import os from 'os';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
|
|
5
|
-
const getEnvValue = (env, keys) => {
|
|
6
|
-
for (const key of keys) {
|
|
7
|
-
const value = env?.[key];
|
|
8
|
-
if (typeof value === 'string' && value.trim().length > 0) {
|
|
9
|
-
return value;
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
return '';
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
const normalizeSearchDirectoryKey = (directory, platform) => {
|
|
16
|
-
const trimmed = typeof directory === 'string' ? directory.trim() : '';
|
|
17
|
-
return platform === 'win32' ? trimmed.toLowerCase() : trimmed;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const getWindowsAppsDirectory = (env) => {
|
|
21
|
-
const localAppData = getEnvValue(env, ['LOCALAPPDATA', 'LocalAppData', 'localappdata']);
|
|
22
|
-
if (localAppData) {
|
|
23
|
-
return path.win32.join(localAppData, 'Microsoft', 'WindowsApps');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const userProfile = getEnvValue(env, ['USERPROFILE', 'UserProfile', 'userprofile']);
|
|
27
|
-
if (userProfile) {
|
|
28
|
-
return path.win32.join(userProfile, 'AppData', 'Local', 'Microsoft', 'WindowsApps');
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return path.win32.join(os.homedir(), 'AppData', 'Local', 'Microsoft', 'WindowsApps');
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export function getExecutableSearchDirectories({ env = process.env, platform = process.platform } = {}) {
|
|
35
|
-
const delimiter = platform === 'win32' ? ';' : ':';
|
|
36
|
-
const pathValue = getEnvValue(env, ['PATH', 'Path', 'path']);
|
|
37
|
-
const directories = pathValue.split(delimiter).map((entry) => entry.trim()).filter(Boolean);
|
|
38
|
-
|
|
39
|
-
if (platform === 'win32') {
|
|
40
|
-
directories.push(getWindowsAppsDirectory(env));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const seen = new Set();
|
|
44
|
-
const unique = [];
|
|
45
|
-
for (const directory of directories) {
|
|
46
|
-
const key = normalizeSearchDirectoryKey(directory, platform);
|
|
47
|
-
if (!key || seen.has(key)) {
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
seen.add(key);
|
|
51
|
-
unique.push(directory);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return unique;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function createExecutableSearchEnv({ env = process.env, platform = process.platform } = {}) {
|
|
58
|
-
const delimiter = platform === 'win32' ? ';' : ':';
|
|
59
|
-
const pathValue = getExecutableSearchDirectories({ env, platform }).join(delimiter);
|
|
60
|
-
const nextEnv = { ...env };
|
|
61
|
-
|
|
62
|
-
if (platform === 'win32') {
|
|
63
|
-
nextEnv.PATH = pathValue;
|
|
64
|
-
nextEnv.Path = pathValue;
|
|
65
|
-
nextEnv.path = pathValue;
|
|
66
|
-
} else {
|
|
67
|
-
nextEnv.PATH = pathValue;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return nextEnv;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const getExecutableExtensions = ({ env = process.env, platform = process.platform } = {}) => {
|
|
74
|
-
if (platform !== 'win32') {
|
|
75
|
-
return [''];
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return (env.PATHEXT || env.PathExt || env.pathext || '.EXE;.CMD;.BAT;.COM')
|
|
79
|
-
.split(';')
|
|
80
|
-
.map((ext) => ext.trim().toLowerCase())
|
|
81
|
-
.filter(Boolean)
|
|
82
|
-
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`));
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
export function findExecutableOnPath(command, {
|
|
86
|
-
env = process.env,
|
|
87
|
-
platform = process.platform,
|
|
88
|
-
fsLike = fs,
|
|
89
|
-
} = {}) {
|
|
90
|
-
if (typeof command !== 'string' || command.trim().length === 0) {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const pathApi = platform === 'win32' ? path.win32 : path;
|
|
95
|
-
const directories = getExecutableSearchDirectories({ env, platform });
|
|
96
|
-
const extensions = getExecutableExtensions({ env, platform });
|
|
97
|
-
const commandName = command.trim();
|
|
98
|
-
|
|
99
|
-
for (const directory of directories) {
|
|
100
|
-
for (const extension of extensions) {
|
|
101
|
-
const fileName = platform === 'win32' ? `${commandName}${extension}` : commandName;
|
|
102
|
-
const candidate = pathApi.join(directory, fileName);
|
|
103
|
-
try {
|
|
104
|
-
const stats = fsLike.statSync(candidate);
|
|
105
|
-
if (!stats.isFile()) {
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
if (platform !== 'win32') {
|
|
109
|
-
try {
|
|
110
|
-
fsLike.accessSync(candidate, fs.constants.X_OK);
|
|
111
|
-
} catch {
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return candidate;
|
|
116
|
-
} catch {
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export function resolveExecutableLaunchTarget(command, options = {}) {
|
|
126
|
-
const platform = options.platform || process.platform;
|
|
127
|
-
const resolvedPath = findExecutableOnPath(command, { ...options, platform });
|
|
128
|
-
const env = createExecutableSearchEnv({ env: options.env || process.env, platform });
|
|
129
|
-
if (resolvedPath) {
|
|
130
|
-
return { command: resolvedPath, env };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Windows Store app execution aliases are launchable through CreateProcess
|
|
134
|
-
// but can reject fs.stat/fs.access with EACCES. Let the version probe decide.
|
|
135
|
-
if (platform === 'win32' && typeof command === 'string' && command.trim().length > 0) {
|
|
136
|
-
return { command: command.trim(), env };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return null;
|
|
140
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'bun:test';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
createExecutableSearchEnv,
|
|
5
|
-
findExecutableOnPath,
|
|
6
|
-
getExecutableSearchDirectories,
|
|
7
|
-
resolveExecutableLaunchTarget,
|
|
8
|
-
} from './executable-search.js';
|
|
9
|
-
|
|
10
|
-
describe('getExecutableSearchDirectories', () => {
|
|
11
|
-
it('adds the WindowsApps app-alias directory on Windows', () => {
|
|
12
|
-
const directories = getExecutableSearchDirectories({
|
|
13
|
-
platform: 'win32',
|
|
14
|
-
env: {
|
|
15
|
-
PATH: 'C:\\Tools',
|
|
16
|
-
LOCALAPPDATA: 'C:\\Users\\Ada\\AppData\\Local',
|
|
17
|
-
},
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
expect(directories).toContain('C:\\Users\\Ada\\AppData\\Local\\Microsoft\\WindowsApps');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('reads Windows Path casing when PATH is not present', () => {
|
|
24
|
-
const directories = getExecutableSearchDirectories({
|
|
25
|
-
platform: 'win32',
|
|
26
|
-
env: {
|
|
27
|
-
Path: 'C:\\Tools;C:\\MoreTools',
|
|
28
|
-
LOCALAPPDATA: 'C:\\Users\\Ada\\AppData\\Local',
|
|
29
|
-
},
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
expect(directories[0]).toBe('C:\\Tools');
|
|
33
|
-
expect(directories[1]).toBe('C:\\MoreTools');
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
describe('findExecutableOnPath', () => {
|
|
38
|
-
it('finds Windows Store app execution aliases even when PATH omits WindowsApps', () => {
|
|
39
|
-
const aliasPath = 'C:\\Users\\Ada\\AppData\\Local\\Microsoft\\WindowsApps\\ngrok.exe';
|
|
40
|
-
const fsLike = {
|
|
41
|
-
statSync: (candidate) => {
|
|
42
|
-
if (candidate === aliasPath) {
|
|
43
|
-
return { isFile: () => true };
|
|
44
|
-
}
|
|
45
|
-
throw new Error('not found');
|
|
46
|
-
},
|
|
47
|
-
accessSync: () => {},
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const resolved = findExecutableOnPath('ngrok', {
|
|
51
|
-
platform: 'win32',
|
|
52
|
-
env: {
|
|
53
|
-
PATH: 'C:\\Tools',
|
|
54
|
-
LOCALAPPDATA: 'C:\\Users\\Ada\\AppData\\Local',
|
|
55
|
-
PATHEXT: '.EXE;.CMD',
|
|
56
|
-
},
|
|
57
|
-
fsLike,
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
expect(resolved).toBe(aliasPath);
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe('resolveExecutableLaunchTarget', () => {
|
|
65
|
-
it('returns a Windows launch target with WindowsApps on PATH when stat lookup fails', () => {
|
|
66
|
-
const target = resolveExecutableLaunchTarget('ngrok', {
|
|
67
|
-
platform: 'win32',
|
|
68
|
-
env: {
|
|
69
|
-
PATH: 'C:\\Windows\\System32',
|
|
70
|
-
LOCALAPPDATA: 'C:\\Users\\Ada\\AppData\\Local',
|
|
71
|
-
},
|
|
72
|
-
fsLike: {
|
|
73
|
-
statSync: () => { throw new Error('EACCES'); },
|
|
74
|
-
accessSync: () => {},
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
expect(target?.command).toBe('ngrok');
|
|
79
|
-
expect(target?.env.Path).toContain('C:\\Users\\Ada\\AppData\\Local\\Microsoft\\WindowsApps');
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
describe('createExecutableSearchEnv', () => {
|
|
84
|
-
it('keeps Windows PATH variants in sync', () => {
|
|
85
|
-
const env = createExecutableSearchEnv({
|
|
86
|
-
platform: 'win32',
|
|
87
|
-
env: {
|
|
88
|
-
PATH: 'C:\\Windows\\System32',
|
|
89
|
-
LOCALAPPDATA: 'C:\\Users\\Ada\\AppData\\Local',
|
|
90
|
-
},
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
expect(env.PATH).toBe(env.Path);
|
|
94
|
-
expect(env.path).toBe(env.Path);
|
|
95
|
-
});
|
|
96
|
-
});
|