propr-cli 0.8.3 → 0.8.5
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 +4 -4
- package/dist/api/relay.js +10 -0
- package/dist/assets/env.example.txt +182 -59
- package/dist/auth/githubLogin.js +66 -0
- package/dist/commands/agentCommands.js +74 -0
- package/dist/commands/agentValidation.js +548 -0
- package/dist/commands/checkCommands.js +981 -76
- package/dist/commands/imageCommands.js +60 -0
- package/dist/commands/index.js +3 -0
- package/dist/commands/initStack.js +50 -1
- package/dist/commands/relayCommands.js +45 -12
- package/dist/commands/setup/agents.js +185 -0
- package/dist/commands/setup/engine.js +956 -0
- package/dist/commands/setup/github.js +181 -0
- package/dist/commands/setup/sequential.js +501 -0
- package/dist/commands/setup/state.js +242 -0
- package/dist/commands/setup/types.js +85 -0
- package/dist/commands/setupCommand.js +85 -0
- package/dist/commands/stackCommands.js +14 -2
- package/dist/commands/systemCommands.js +49 -2
- package/dist/commands/tunnelCommand.js +562 -0
- package/dist/config/ConfigManager.js +22 -0
- package/dist/config/types.js +1 -0
- package/dist/index.js +14 -45
- package/dist/orchestrator/format.js +46 -0
- package/dist/orchestrator/index.js +7 -2
- package/dist/orchestrator/manifest.json +12 -11
- package/dist/orchestrator/orchestrator.mjs +872 -73
- package/dist/tui/AgentTableApp.js +86 -0
- package/dist/tui/CheckApp.js +202 -0
- package/dist/tui/SetupApp.js +586 -0
- package/dist/tui/SetupApp.test.js +172 -0
- package/dist/tui/app.js +84 -0
- package/dist/tui/render.js +28 -2
- package/dist/utils/envFile.js +45 -0
- package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
- package/dist/vendor/shared/index.js +17 -0
- package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
- package/dist/vendor/shared/modelDefinitions.js +4 -4
- package/dist/vendor/shared/proprCompatibility.js +70 -0
- package/dist/vendor/shared/proprServiceUrls.js +124 -0
- package/dist/vendor/shared/statusKeys.js +14 -0
- package/dist/vendor/shared/validateRoutingUrl.js +46 -0
- package/package.json +3 -3
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service toggle command: `propr tunnel on|off`.
|
|
3
|
+
*
|
|
4
|
+
* The Cloudflare Tunnel is an optional managed sidecar (the official
|
|
5
|
+
* `cloudflared` image) that exposes this local stack's UI/API to the hosted
|
|
6
|
+
* control plane. Like `propr ui` and `propr docs`, toggling it just
|
|
7
|
+
* starts/stops the container, but with two differences:
|
|
8
|
+
*
|
|
9
|
+
* - Starting requires a configured token (PROPR_UI_TUNNEL_TOKEN); without one
|
|
10
|
+
* cloudflared cannot authenticate, so we fail clearly instead of launching a
|
|
11
|
+
* broken container.
|
|
12
|
+
* - Stopping only removes the tunnel container; it never touches the token or
|
|
13
|
+
* any other env value, so a later `propr tunnel on` works without rework.
|
|
14
|
+
*
|
|
15
|
+
* The desired state is persisted in the CLI config so `propr start` and restarts
|
|
16
|
+
* honor a previous toggle.
|
|
17
|
+
*/
|
|
18
|
+
import { Command } from "commander";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { DEFAULT_PROPR_UI_ORIGIN, proprTunnelEndpoints, isProprProxyUrl, PROPR_UI_PROXY_SUFFIX, } from "../vendor/shared/index.js";
|
|
21
|
+
import { createConfigManager } from "../config/index.js";
|
|
22
|
+
import { getHostConfig, resolveStackRoot } from "../orchestrator/index.js";
|
|
23
|
+
import { parseOnOffState, ParseStateError } from "../utils/index.js";
|
|
24
|
+
import { upsertEnvVars } from "../utils/envFile.js";
|
|
25
|
+
/** Thrown by applyTunnelToggle when `tunnel on` is requested without a token. */
|
|
26
|
+
export class TunnelTokenMissingError extends Error {
|
|
27
|
+
constructor() {
|
|
28
|
+
super("cannot start the tunnel — no token configured.\n" +
|
|
29
|
+
" Set PROPR_UI_TUNNEL_TOKEN in your stack .env (and optionally\n" +
|
|
30
|
+
" PROPR_UI_PUBLIC_API_URL), then run 'propr tunnel on' again.");
|
|
31
|
+
this.name = "TunnelTokenMissingError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Thrown by applyTunnelToggle when `tunnel on` is requested but no public proxy
|
|
36
|
+
* URL can be derived. Hosted UI tunnel mode fundamentally needs an advertised
|
|
37
|
+
* endpoint (`PROPR_INSTANCE_ID` → https://<id>.proxy.propr.dev, or an explicit
|
|
38
|
+
* `PROPR_UI_PUBLIC_API_URL`); without one the sidecar would start and the desired
|
|
39
|
+
* state persist while the hosted UI has no usable endpoint, surfacing only as
|
|
40
|
+
* later status/verify failures. So we refuse up front instead.
|
|
41
|
+
*/
|
|
42
|
+
export class TunnelPublicUrlMissingError extends Error {
|
|
43
|
+
constructor() {
|
|
44
|
+
super("cannot start the tunnel — no public proxy URL can be derived.\n" +
|
|
45
|
+
" The hosted UI reaches this stack at https://<id>.proxy.propr.dev, so set\n" +
|
|
46
|
+
" PROPR_INSTANCE_ID (preferred) or an explicit PROPR_UI_PUBLIC_API_URL in\n" +
|
|
47
|
+
" your stack .env, then run 'propr tunnel on' again.");
|
|
48
|
+
this.name = "TunnelPublicUrlMissingError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Thrown by applyTunnelToggle when `tunnel on` is requested with a public proxy
|
|
53
|
+
* URL that is not a hosted `https://<id>.proxy.propr.dev` URL. propr-routing only
|
|
54
|
+
* forwards `/api/*` and `/socket.io/*` on those hosts, so an explicit
|
|
55
|
+
* `PROPR_UI_PUBLIC_API_URL` pointing anywhere else (e.g. https://custom.example.com)
|
|
56
|
+
* would start a sidecar the hosted UI cannot route to. The launcher's
|
|
57
|
+
* `validateEnv()` already rejects this for `propr start`/`propr check`; mirroring
|
|
58
|
+
* it here keeps `propr tunnel on` from persisting and starting an unroutable
|
|
59
|
+
* configuration that those commands would refuse.
|
|
60
|
+
*/
|
|
61
|
+
export class TunnelPublicUrlInvalidError extends Error {
|
|
62
|
+
constructor(url) {
|
|
63
|
+
super(`cannot start the tunnel — PROPR_UI_PUBLIC_API_URL ("${url}") is not a\n` +
|
|
64
|
+
` hosted proxy URL (https://<id>.${PROPR_UI_PROXY_SUFFIX}). The tunnel only\n` +
|
|
65
|
+
` routes /api/* and /socket.io/* on ${PROPR_UI_PROXY_SUFFIX} hosts, so the\n` +
|
|
66
|
+
" hosted UI could not reach this stack. Set PROPR_INSTANCE_ID (preferred) or\n" +
|
|
67
|
+
` a https://<id>.${PROPR_UI_PROXY_SUFFIX} URL, then run 'propr tunnel on' again.`);
|
|
68
|
+
this.name = "TunnelPublicUrlInvalidError";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Thrown by applyTunnelToggle when `tunnel on` is requested while the core stack
|
|
73
|
+
* is down. Starting cloudflared then yields a healthy-looking sidecar pointing at
|
|
74
|
+
* an unavailable api:4000, so we refuse by default and tell the operator to bring
|
|
75
|
+
* the stack up first (or opt in with --force if that is intentional).
|
|
76
|
+
*/
|
|
77
|
+
export class TunnelCoreStackDownError extends Error {
|
|
78
|
+
constructor() {
|
|
79
|
+
super("cannot start the tunnel — the core stack does not appear to be running.\n" +
|
|
80
|
+
" cloudflared would route to an unavailable API (api:4000). Run\n" +
|
|
81
|
+
" 'propr start' first, or pass --force to start the tunnel anyway.");
|
|
82
|
+
this.name = "TunnelCoreStackDownError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Thrown when the setup `--start` env validation fails before any container is touched. */
|
|
86
|
+
export class TunnelSetupEnvInvalidError extends Error {
|
|
87
|
+
constructor(errors) {
|
|
88
|
+
super("cannot start the tunnel stack — the env written by setup is not valid:\n" +
|
|
89
|
+
errors.map((e) => ` ✗ ${e}`).join("\n") +
|
|
90
|
+
"\n Fix the values in your stack .env, then run 'propr start --restart'.");
|
|
91
|
+
this.name = "TunnelSetupEnvInvalidError";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export async function startOrRestartTunnelStack(orch, cfg, configManager, log = console.log, warn = console.warn) {
|
|
95
|
+
const tunnelCfg = { ...cfg, uiTunnelEnabled: true };
|
|
96
|
+
// Run the same env validation `propr start` runs before recreating the stack,
|
|
97
|
+
// so stale/invalid values written into .env surface as structured errors here
|
|
98
|
+
// rather than as opaque Docker/runtime failures after the stack is recreated.
|
|
99
|
+
// Validate before persisting enabled=true or touching any container so a
|
|
100
|
+
// refused start leaves no override behind.
|
|
101
|
+
const validation = orch.validateEnv(tunnelCfg);
|
|
102
|
+
for (const w of validation.warnings)
|
|
103
|
+
warn(`warning: ${w}`);
|
|
104
|
+
if (!validation.ok) {
|
|
105
|
+
throw new TunnelSetupEnvInvalidError(validation.errors);
|
|
106
|
+
}
|
|
107
|
+
// Unlike applyTunnelToggle (a pure on/off that rolls back on Docker failure),
|
|
108
|
+
// setup has already written the full tunnel .env (token, instance id, proxy
|
|
109
|
+
// URLs). The operator has committed to tunnel mode, so we persist enabled=true
|
|
110
|
+
// and deliberately do NOT roll it back if the stop/start below fails: a later
|
|
111
|
+
// `propr start` should still honor the configured tunnel rather than silently
|
|
112
|
+
// revert to non-tunnel mode after a transient Docker error.
|
|
113
|
+
await configManager.setTunnelEnabled(true);
|
|
114
|
+
// The override is now persisted. If the Docker stop/start below fails we keep
|
|
115
|
+
// it (see the comment above), but surface that explicitly to the operator so
|
|
116
|
+
// the leftover enabled state is not a silent surprise — `propr tunnel on`
|
|
117
|
+
// rolls back on a Docker failure, this path intentionally does not.
|
|
118
|
+
try {
|
|
119
|
+
const wasRunning = orch.isStackRunning(cfg);
|
|
120
|
+
if (wasRunning) {
|
|
121
|
+
log("Recreating the ProPR stack with hosted tunnel settings...");
|
|
122
|
+
const stopped = orch.stopStack(cfg, { remove: true, onLog: log });
|
|
123
|
+
if (stopped.failed.length > 0) {
|
|
124
|
+
throw new Error(`failed to stop ${stopped.failed.length} service${stopped.failed.length === 1 ? "" : "s"} before restart`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
log("Starting the ProPR stack with hosted tunnel settings...");
|
|
129
|
+
}
|
|
130
|
+
// Ensure the Docker network exists before starting, exactly as `propr start`
|
|
131
|
+
// does. For a fresh or previously-stopped stack the network may not exist yet,
|
|
132
|
+
// and startStack does not create it, so the generated Connect one-shot path
|
|
133
|
+
// would otherwise fail even though it is advertised as the setup path.
|
|
134
|
+
orch.ensureNetwork(tunnelCfg, log);
|
|
135
|
+
orch.startStack(tunnelCfg, { tunnel: true, onLog: log });
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
warn("Warning: the stack failed to start, but tunnel mode is still enabled in\n" +
|
|
139
|
+
" your CLI config (this path keeps the persisted override so a later\n" +
|
|
140
|
+
" 'propr start' honors tunnel mode). Fix the underlying Docker error and\n" +
|
|
141
|
+
" re-run 'propr start --restart', or run 'propr tunnel off' to disable it.");
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Core `propr tunnel on|off` behavior, decoupled from CLI wiring (config/orch
|
|
147
|
+
* loading, process.exit) so it can be unit-tested with injected fakes. Throws
|
|
148
|
+
* {@link TunnelTokenMissingError} when enabling without a token, and
|
|
149
|
+
* {@link TunnelCoreStackDownError} when enabling while the core stack is down
|
|
150
|
+
* (unless `force` is set).
|
|
151
|
+
*/
|
|
152
|
+
export async function applyTunnelToggle({ enable, cfg, orch, configManager, force = false, log = console.log, warn = console.warn, }) {
|
|
153
|
+
// Validate before touching anything: starting the tunnel needs a token, or
|
|
154
|
+
// cloudflared cannot authenticate.
|
|
155
|
+
if (enable && !cfg.uiTunnelToken) {
|
|
156
|
+
throw new TunnelTokenMissingError();
|
|
157
|
+
}
|
|
158
|
+
// It also needs a derivable public proxy URL — the endpoint the hosted UI uses
|
|
159
|
+
// to reach this stack. Without it the sidecar would run but advertise nothing,
|
|
160
|
+
// so refuse here (a config completeness requirement, not bypassed by --force)
|
|
161
|
+
// rather than letting it surface as a later status/verify failure. Checked
|
|
162
|
+
// before persisting so a refused start leaves no override behind.
|
|
163
|
+
if (enable && !cfg.uiPublicApiUrl) {
|
|
164
|
+
throw new TunnelPublicUrlMissingError();
|
|
165
|
+
}
|
|
166
|
+
// A derived public URL is always a well-formed proxy URL, but an explicit
|
|
167
|
+
// PROPR_UI_PUBLIC_API_URL can be anything. propr-routing only forwards /api/*
|
|
168
|
+
// and /socket.io/* on https://<id>.proxy.propr.dev hosts, so a non-proxy URL
|
|
169
|
+
// would start a sidecar the hosted UI cannot route to. validateEnv() rejects
|
|
170
|
+
// this for `propr start`/`propr check`; mirror it here so `propr tunnel on`
|
|
171
|
+
// doesn't persist/start an unroutable configuration those commands would refuse.
|
|
172
|
+
if (enable && cfg.uiPublicApiUrl && !isProprProxyUrl(cfg.uiPublicApiUrl)) {
|
|
173
|
+
throw new TunnelPublicUrlInvalidError(cfg.uiPublicApiUrl);
|
|
174
|
+
}
|
|
175
|
+
// The tunnel only routes to the core API (api:4000). Starting it while the core
|
|
176
|
+
// stack is down leaves a healthy-looking cloudflared sidecar pointing at an
|
|
177
|
+
// unavailable backend, so refuse unless the operator explicitly opts in with
|
|
178
|
+
// --force. Checked before persisting (like the token guard) so a refused start
|
|
179
|
+
// leaves no override behind.
|
|
180
|
+
const coreStackDown = enable && !orch.isStackRunning(cfg);
|
|
181
|
+
if (coreStackDown && !force) {
|
|
182
|
+
throw new TunnelCoreStackDownError();
|
|
183
|
+
}
|
|
184
|
+
// Persist the desired state up front (after validation, before Docker) so the
|
|
185
|
+
// recorded override and the actual container can't diverge if the start/stop
|
|
186
|
+
// throws partway through. Roll back to the previous value if the Docker op
|
|
187
|
+
// fails, so a failed toggle leaves the persisted state unchanged.
|
|
188
|
+
const previousEnabled = configManager.getTunnelEnabled();
|
|
189
|
+
await configManager.setTunnelEnabled(enable);
|
|
190
|
+
// The `cfg` passed in was resolved before this toggle persisted, so when we are
|
|
191
|
+
// turning the tunnel ON after a prior `propr tunnel off` it still carries
|
|
192
|
+
// uiTunnelEnabled=false. Reflect the just-persisted desired state in the config
|
|
193
|
+
// used for the start path so any tunnel-enabled-conditional behavior (and the
|
|
194
|
+
// endpoint summary below) sees a consistent, enabled config.
|
|
195
|
+
const effectiveCfg = { ...cfg, uiTunnelEnabled: enable };
|
|
196
|
+
try {
|
|
197
|
+
if (enable) {
|
|
198
|
+
// PROPR_UI_TUNNEL_TOKEN is a live Cloudflare Tunnel credential: anyone with
|
|
199
|
+
// it can route traffic through this tunnel. It is read from the stack .env,
|
|
200
|
+
// so remind the operator not to commit, log, or share that file.
|
|
201
|
+
warn("Warning: PROPR_UI_TUNNEL_TOKEN is a live Cloudflare credential. Keep it\n" +
|
|
202
|
+
" in your stack .env only — do not commit, log, or share it.");
|
|
203
|
+
// We only reach the start path with the core stack down when --force was
|
|
204
|
+
// given (otherwise applyTunnelToggle threw above). Remind the operator the
|
|
205
|
+
// sidecar will point at an unavailable API until they `propr start`.
|
|
206
|
+
if (coreStackDown) {
|
|
207
|
+
warn("Warning: the core stack does not appear to be running, so the tunnel\n" +
|
|
208
|
+
" will point at an unavailable API. Starting anyway because --force\n" +
|
|
209
|
+
" was given; run 'propr start' to bring the core services up.");
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
// The already-running API/worker containers were started with whatever
|
|
213
|
+
// API_PUBLIC_URL/FRONTEND_URL applied at the time. Turning the tunnel on
|
|
214
|
+
// now does not restart them, so their OAuth redirects, cookie security,
|
|
215
|
+
// and attachment links keep pointing at the pre-tunnel values until the
|
|
216
|
+
// stack is restarted.
|
|
217
|
+
warn("Warning: the core stack is already running, so its API_PUBLIC_URL/\n" +
|
|
218
|
+
" FRONTEND_URL were set before the tunnel was enabled. Run\n" +
|
|
219
|
+
" 'propr start --restart' to re-point OAuth redirects, cookies, and\n" +
|
|
220
|
+
" attachment links at the public proxy URL.");
|
|
221
|
+
}
|
|
222
|
+
log("Starting tunnel…");
|
|
223
|
+
orch.ensureNetwork(effectiveCfg, (l) => log(l));
|
|
224
|
+
orch.startService(effectiveCfg, "tunnel", { onLog: (l) => log(l) });
|
|
225
|
+
// Only the cloudflared sidecar was (re)started here. When the core stack was
|
|
226
|
+
// already running, its API/worker containers keep the pre-tunnel
|
|
227
|
+
// API_PUBLIC_URL/FRONTEND_URL until restarted, so say so explicitly rather
|
|
228
|
+
// than a bare "tunnel is up" that reads as fully cut over.
|
|
229
|
+
if (coreStackDown) {
|
|
230
|
+
log("tunnel sidecar is up.");
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
log("tunnel sidecar is up — run 'propr start --restart' to apply API_PUBLIC_URL/FRONTEND_URL.");
|
|
234
|
+
}
|
|
235
|
+
if (effectiveCfg.uiPublicApiUrl) {
|
|
236
|
+
// Show the concrete endpoints propr-routing forwards rather than the base
|
|
237
|
+
// URL itself: only /api/* and /socket.io/* are routed, so the root URL
|
|
238
|
+
// intentionally returns 404 (it is not the API health target).
|
|
239
|
+
const { apiStatus, socketIo } = proprTunnelEndpoints(effectiveCfg.uiPublicApiUrl);
|
|
240
|
+
log(` API: ${apiStatus}`);
|
|
241
|
+
log(` Realtime: ${socketIo}`);
|
|
242
|
+
log(" Root URL intentionally returns 404.");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
log("Stopping tunnel…");
|
|
247
|
+
orch.stopService(effectiveCfg, "tunnel", { remove: true, onLog: (l) => log(l) });
|
|
248
|
+
log("tunnel stopped. Token and env values are unchanged.");
|
|
249
|
+
// `tunnel off` only removes the cloudflared sidecar; it deliberately leaves
|
|
250
|
+
// the .env untouched. If `propr tunnel setup` wrote hosted proxy values
|
|
251
|
+
// (API_PUBLIC_URL/FRONTEND_URL/PROPR_UI_PUBLIC_API_URL), a later
|
|
252
|
+
// `propr start` still resolves them — so the stack can come back up in
|
|
253
|
+
// proxy-mode configuration with no tunnel running. Warn so the operator
|
|
254
|
+
// clears or changes those values for true local/self-hosted mode.
|
|
255
|
+
if (isProprProxyUrl(effectiveCfg.uiPublicApiUrl ?? "") ||
|
|
256
|
+
effectiveCfg.frontendUrl === DEFAULT_PROPR_UI_ORIGIN) {
|
|
257
|
+
warn("Warning: hosted proxy values from 'propr tunnel setup' remain in your\n" +
|
|
258
|
+
" .env (e.g. API_PUBLIC_URL/FRONTEND_URL/PROPR_UI_PUBLIC_API_URL). A\n" +
|
|
259
|
+
" later 'propr start' will still use them, so the stack may run in\n" +
|
|
260
|
+
" proxy-mode configuration without a tunnel. Remove or change those\n" +
|
|
261
|
+
" values to return to local/self-hosted mode.");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
// Revert to the exact prior value (including an unset "defer to env" state)
|
|
267
|
+
// so a failed toggle doesn't leave a stale persisted override behind.
|
|
268
|
+
await configManager.set("tunnelEnabled", previousEnabled);
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
export function validateTunnelCommandOptions(action, options) {
|
|
273
|
+
if (action === "setup" && options.force) {
|
|
274
|
+
throw new Error("--force is only supported with 'propr tunnel on'");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function instanceIdFromProxyUrl(url) {
|
|
278
|
+
let parsed;
|
|
279
|
+
try {
|
|
280
|
+
parsed = new URL(url);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
const suffix = `.${PROPR_UI_PROXY_SUFFIX}`;
|
|
286
|
+
return parsed.protocol === "https:" && parsed.hostname.endsWith(suffix)
|
|
287
|
+
? parsed.hostname.slice(0, -suffix.length)
|
|
288
|
+
: undefined;
|
|
289
|
+
}
|
|
290
|
+
export function buildTunnelSetupEnv(input) {
|
|
291
|
+
const token = input.token.trim();
|
|
292
|
+
if (!token)
|
|
293
|
+
throw new Error("--token is required");
|
|
294
|
+
const explicitUrl = input.url?.trim().replace(/\/+$/, "");
|
|
295
|
+
const explicitInstanceId = input.instanceId?.trim();
|
|
296
|
+
if (!explicitUrl && !explicitInstanceId) {
|
|
297
|
+
throw new Error("provide --url https://<id>.proxy.propr.dev or --instance-id <id>");
|
|
298
|
+
}
|
|
299
|
+
const candidateUrl = explicitUrl ?? `https://${explicitInstanceId}.${PROPR_UI_PROXY_SUFFIX}`;
|
|
300
|
+
if (!isProprProxyUrl(candidateUrl)) {
|
|
301
|
+
throw new Error(`tunnel URL must be a bare hosted proxy URL such as https://<id>.${PROPR_UI_PROXY_SUFFIX} (no path/query/fragment)`);
|
|
302
|
+
}
|
|
303
|
+
// Canonicalize: URL parsing already lowercases the host, and `.origin` drops
|
|
304
|
+
// any (validated-absent) path so the persisted value matches what the launcher
|
|
305
|
+
// resolves. DNS is case-insensitive, so the instance id is lowercased too — a
|
|
306
|
+
// mixed-case --instance-id would otherwise diverge from the launcher's value.
|
|
307
|
+
const publicUrl = new URL(candidateUrl).origin;
|
|
308
|
+
const derivedInstanceId = instanceIdFromProxyUrl(publicUrl);
|
|
309
|
+
const instanceId = (explicitInstanceId ?? derivedInstanceId)?.toLowerCase();
|
|
310
|
+
if (!instanceId) {
|
|
311
|
+
throw new Error(`could not derive an instance id from ${publicUrl}`);
|
|
312
|
+
}
|
|
313
|
+
if (derivedInstanceId && explicitInstanceId && derivedInstanceId.toLowerCase() !== explicitInstanceId.toLowerCase()) {
|
|
314
|
+
throw new Error(`--instance-id (${explicitInstanceId}) does not match --url host (${derivedInstanceId})`);
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
PROPR_UI_TUNNEL_TOKEN: token,
|
|
318
|
+
PROPR_UI_TUNNEL_ENABLED: "true",
|
|
319
|
+
PROPR_INSTANCE_ID: instanceId,
|
|
320
|
+
PROPR_UI_PUBLIC_API_URL: publicUrl,
|
|
321
|
+
API_PUBLIC_URL: publicUrl,
|
|
322
|
+
FRONTEND_URL: DEFAULT_PROPR_UI_ORIGIN,
|
|
323
|
+
GH_OAUTH_CALLBACK_URL: `${publicUrl}/api/auth/github/callback`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
// GET a URL behind a hard timeout. Resolves the HTTP status, or null on a
|
|
327
|
+
// network error / timeout (so the caller can distinguish "no response at all"
|
|
328
|
+
// from "responded with a status"). Never throws — verify reports, it does not gate.
|
|
329
|
+
async function probeStatus(url, fetchImpl, timeoutMs) {
|
|
330
|
+
const controller = new AbortController();
|
|
331
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
332
|
+
try {
|
|
333
|
+
const res = await fetchImpl(url, { signal: controller.signal, redirect: "manual" });
|
|
334
|
+
return res.status;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
clearTimeout(timer);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Core `propr tunnel verify` checks, decoupled from CLI wiring so they can be
|
|
345
|
+
* unit-tested with an injected fetch/orchestrator. Runs the simple liveness
|
|
346
|
+
* checks from the spec:
|
|
347
|
+
* - the cloudflared sidecar container is running;
|
|
348
|
+
* - GET <url>/api/status returns an OK/auth-expected response;
|
|
349
|
+
* - GET <url>/ returns 404 (the root is intentionally not routed);
|
|
350
|
+
* - GET <url>/socket.io/ is reachable (not blocked by Cloudflare ingress).
|
|
351
|
+
*/
|
|
352
|
+
export async function verifyTunnel({ cfg, orch, fetchImpl = fetch, timeoutMs = 5000, }) {
|
|
353
|
+
const checks = [];
|
|
354
|
+
// 1. cloudflared container running.
|
|
355
|
+
const state = orch.getServiceState(cfg, "tunnel");
|
|
356
|
+
checks.push({
|
|
357
|
+
name: "cloudflared container running",
|
|
358
|
+
ok: Boolean(state?.running),
|
|
359
|
+
detail: state?.running
|
|
360
|
+
? `${state.name} is up`
|
|
361
|
+
: "the cloudflared sidecar is not running — start it with 'propr tunnel on'",
|
|
362
|
+
});
|
|
363
|
+
const publicApiUrl = cfg.uiPublicApiUrl;
|
|
364
|
+
if (!publicApiUrl) {
|
|
365
|
+
// Without a public URL there is nothing to probe over HTTP. Record the
|
|
366
|
+
// remaining checks as failed with an actionable detail rather than skipping.
|
|
367
|
+
const detail = "no public proxy URL is known — set PROPR_INSTANCE_ID or PROPR_UI_PUBLIC_API_URL";
|
|
368
|
+
checks.push({ name: "GET /api/status", ok: false, detail });
|
|
369
|
+
checks.push({ name: "GET / returns 404", ok: false, detail });
|
|
370
|
+
checks.push({ name: "GET /socket.io/ reachable", ok: false, detail });
|
|
371
|
+
return { ok: false, checks };
|
|
372
|
+
}
|
|
373
|
+
const { apiStatus, socketIo, root } = proprTunnelEndpoints(publicApiUrl);
|
|
374
|
+
// 2. /api/status returns OK or an auth-expected response.
|
|
375
|
+
const apiCode = await probeStatus(apiStatus, fetchImpl, timeoutMs);
|
|
376
|
+
const apiOk = apiCode !== null && (apiCode < 400 || apiCode === 401 || apiCode === 403);
|
|
377
|
+
checks.push({
|
|
378
|
+
name: "GET /api/status",
|
|
379
|
+
ok: apiOk,
|
|
380
|
+
detail: apiCode === null
|
|
381
|
+
? `no response from ${apiStatus} (network error or timeout)`
|
|
382
|
+
: `${apiStatus} → ${apiCode}${apiCode === 401 || apiCode === 403 ? " (auth-expected)" : ""}`,
|
|
383
|
+
});
|
|
384
|
+
// 3. Root path intentionally returns 404 (only /api/* and /socket.io/* route).
|
|
385
|
+
const rootCode = await probeStatus(root, fetchImpl, timeoutMs);
|
|
386
|
+
checks.push({
|
|
387
|
+
name: "GET / returns 404",
|
|
388
|
+
ok: rootCode === 404,
|
|
389
|
+
detail: rootCode === null
|
|
390
|
+
? `no response from ${root} (network error or timeout)`
|
|
391
|
+
: `${root} → ${rootCode}${rootCode === 404 ? " (expected)" : " (expected 404)"}`,
|
|
392
|
+
});
|
|
393
|
+
// 4. Socket.IO path reachable — a Socket.IO server answers a bare GET with a
|
|
394
|
+
// 400 ("Transport unknown"), so a non-404, non-5xx HTTP response proves the
|
|
395
|
+
// path reaches the Socket.IO server through Cloudflare rather than being
|
|
396
|
+
// blocked at ingress. A 404 means the path is not routed; a 5xx means the
|
|
397
|
+
// request reached an edge/proxy that could not reach the backend (e.g. a
|
|
398
|
+
// Cloudflare 502/503 error page), which is not a usable Socket.IO endpoint —
|
|
399
|
+
// both are treated as failures rather than false-positive "routed".
|
|
400
|
+
const socketCode = await probeStatus(socketIo, fetchImpl, timeoutMs);
|
|
401
|
+
const socketOk = socketCode !== null && socketCode !== 404 && socketCode < 500;
|
|
402
|
+
checks.push({
|
|
403
|
+
name: "GET /socket.io/ reachable",
|
|
404
|
+
ok: socketOk,
|
|
405
|
+
detail: socketCode === null
|
|
406
|
+
? `no response from ${socketIo} (network error or timeout)`
|
|
407
|
+
: socketCode === 404
|
|
408
|
+
? `${socketIo} → 404 (path not routed / blocked at ingress)`
|
|
409
|
+
: socketCode >= 500
|
|
410
|
+
? `${socketIo} → ${socketCode} (proxy/server error, not reaching Socket.IO)`
|
|
411
|
+
: `${socketIo} → ${socketCode} (routed)`,
|
|
412
|
+
});
|
|
413
|
+
return { ok: checks.every((c) => c.ok), checks };
|
|
414
|
+
}
|
|
415
|
+
async function runTunnelVerify(root) {
|
|
416
|
+
const configManager = await createConfigManager();
|
|
417
|
+
const { orch, cfg } = await getHostConfig({ configManager, root });
|
|
418
|
+
if (!orch.dockerAvailable()) {
|
|
419
|
+
console.error("Error: cannot reach the Docker daemon. Run 'propr check'.");
|
|
420
|
+
process.exit(1);
|
|
421
|
+
}
|
|
422
|
+
console.log("Verifying tunnel…");
|
|
423
|
+
const { ok, checks } = await verifyTunnel({ cfg, orch });
|
|
424
|
+
for (const c of checks) {
|
|
425
|
+
console.log(` ${c.ok ? "✓" : "✗"} ${c.name} — ${c.detail}`);
|
|
426
|
+
}
|
|
427
|
+
console.log("");
|
|
428
|
+
if (ok) {
|
|
429
|
+
console.log("Tunnel verification passed.");
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
console.error("Tunnel verification failed.");
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async function toggleTunnel(stateArg, root, force) {
|
|
437
|
+
const enable = parseOnOffState(stateArg);
|
|
438
|
+
const configManager = await createConfigManager();
|
|
439
|
+
const { orch, cfg } = await getHostConfig({ configManager, root });
|
|
440
|
+
if (!orch.dockerAvailable()) {
|
|
441
|
+
console.error("Error: cannot reach the Docker daemon. Run 'propr check'.");
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
await applyTunnelToggle({ enable, cfg, orch, configManager, force });
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
if (error instanceof TunnelTokenMissingError ||
|
|
449
|
+
error instanceof TunnelPublicUrlMissingError ||
|
|
450
|
+
error instanceof TunnelPublicUrlInvalidError ||
|
|
451
|
+
error instanceof TunnelCoreStackDownError) {
|
|
452
|
+
console.error(`Error: ${error.message}`);
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
throw error;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
async function runTunnelSetup(options) {
|
|
459
|
+
const configManager = await createConfigManager();
|
|
460
|
+
const rootDir = resolveStackRoot(configManager, options.root);
|
|
461
|
+
const envPath = join(rootDir, ".env");
|
|
462
|
+
const vars = buildTunnelSetupEnv({
|
|
463
|
+
token: options.token ?? "",
|
|
464
|
+
url: options.url,
|
|
465
|
+
instanceId: options.instanceId,
|
|
466
|
+
});
|
|
467
|
+
upsertEnvVars(envPath, { ...vars });
|
|
468
|
+
// Persist the CLI tunnelEnabled override only when we are NOT going on to start
|
|
469
|
+
// the stack. With --start, defer the persist to startOrRestartTunnelStack, which
|
|
470
|
+
// runs env validation first and persists tunnelEnabled=true only AFTER it passes.
|
|
471
|
+
// Persisting here unconditionally would leave the override enabled even if the
|
|
472
|
+
// start path then rejected the just-written env, contradicting its
|
|
473
|
+
// validate-before-persist contract.
|
|
474
|
+
if (!options.start) {
|
|
475
|
+
await configManager.set("tunnelEnabled", true);
|
|
476
|
+
}
|
|
477
|
+
console.log("Tunnel configuration saved.");
|
|
478
|
+
console.log(` saved to: ${envPath}`);
|
|
479
|
+
console.log(` public API: ${vars.PROPR_UI_PUBLIC_API_URL}`);
|
|
480
|
+
console.log(` hosted UI: ${vars.FRONTEND_URL}`);
|
|
481
|
+
console.log(` OAuth callback: ${vars.GH_OAUTH_CALLBACK_URL}`);
|
|
482
|
+
console.log(" GitHub OAuth: register the callback URL above in your GitHub OAuth App");
|
|
483
|
+
console.log(` Hosted UI link: ${vars.FRONTEND_URL}?tunnel=${encodeURIComponent(vars.PROPR_UI_PUBLIC_API_URL)}`);
|
|
484
|
+
console.log("");
|
|
485
|
+
if (options.start) {
|
|
486
|
+
const { orch, cfg } = await getHostConfig({ configManager, root: rootDir });
|
|
487
|
+
if (!orch.dockerAvailable()) {
|
|
488
|
+
console.error("Error: cannot reach the Docker daemon. Run 'propr check'.");
|
|
489
|
+
process.exit(1);
|
|
490
|
+
}
|
|
491
|
+
await startOrRestartTunnelStack(orch, cfg, configManager);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
console.log("Next steps:");
|
|
495
|
+
console.log(" propr start --restart # apply the hosted UI/API URLs to the stack");
|
|
496
|
+
console.log(" update GitHub OAuth App # add the callback URL printed above");
|
|
497
|
+
console.log(" propr tunnel verify # confirm the public proxy can reach this stack");
|
|
498
|
+
console.log("");
|
|
499
|
+
console.log("Use 'propr tunnel setup --start ...' next time to save config and start or restart the stack in one step.");
|
|
500
|
+
}
|
|
501
|
+
export function createTunnelCommand() {
|
|
502
|
+
return new Command("tunnel")
|
|
503
|
+
.description("Configure, start, stop, or verify the Cloudflare Tunnel service")
|
|
504
|
+
.argument("<action>", "setup, on, off, or verify")
|
|
505
|
+
.option("--root <dir>", "Stack root directory")
|
|
506
|
+
.option("--force", "With 'on', start the tunnel even if the core stack is not running")
|
|
507
|
+
.option("--token <token>", "Connector token from ProPR Connect (setup only)")
|
|
508
|
+
.option("--url <url>", "Public proxy URL from ProPR Connect, e.g. https://<id>.proxy.propr.dev (setup only)")
|
|
509
|
+
.option("--instance-id <id>", "Instance id from ProPR Connect; derives https://<id>.proxy.propr.dev (setup only)")
|
|
510
|
+
.option("--start", "After setup, start or restart the stack with hosted tunnel settings")
|
|
511
|
+
.addHelpText("after", `
|
|
512
|
+
Setup writes the tunnel settings to your stack .env for you:
|
|
513
|
+
|
|
514
|
+
$ propr tunnel setup --token <connector-token> --url https://<id>.proxy.propr.dev --start
|
|
515
|
+
|
|
516
|
+
Starting the tunnel requires a token AND a public proxy URL:
|
|
517
|
+
PROPR_UI_TUNNEL_TOKEN Cloudflare Tunnel token (required to start). This is a
|
|
518
|
+
live Cloudflare credential — do not commit, log, or share it
|
|
519
|
+
PROPR_UI_TUNNEL_ENABLED Explicitly enables the managed tunnel sidecar
|
|
520
|
+
PROPR_INSTANCE_ID Instance id; derives the public URL
|
|
521
|
+
https://<id>.proxy.propr.dev (required unless
|
|
522
|
+
PROPR_UI_PUBLIC_API_URL is set)
|
|
523
|
+
PROPR_UI_PUBLIC_API_URL Explicit public API URL advertised through the tunnel
|
|
524
|
+
(overrides the id-derived URL)
|
|
525
|
+
API_PUBLIC_URL Same proxy URL, used by the API for public links/cookies
|
|
526
|
+
FRONTEND_URL Hosted UI origin allowed by CORS (${DEFAULT_PROPR_UI_ORIGIN})
|
|
527
|
+
GH_OAUTH_CALLBACK_URL Proxy-host OAuth callback URL to register in GitHub
|
|
528
|
+
|
|
529
|
+
Cloudflare forwards the tunnel to the Docker-internal API service at
|
|
530
|
+
http://api:4000 (NOT host port 4000), so the published host port is irrelevant
|
|
531
|
+
to tunnel routing and the two cannot conflict. Only /api/* and /socket.io/* are
|
|
532
|
+
routed; the root URL intentionally returns 404.
|
|
533
|
+
|
|
534
|
+
$ propr tunnel setup Save the token/proxy URL from ProPR Connect to .env
|
|
535
|
+
$ propr tunnel on Start the cloudflared sidecar (requires the core stack
|
|
536
|
+
to be running; pass --force to start it regardless)
|
|
537
|
+
$ propr tunnel off Stop the sidecar (token/env values are left untouched)
|
|
538
|
+
$ propr tunnel verify Check the sidecar + public /api/status, /, /socket.io/
|
|
539
|
+
`)
|
|
540
|
+
.action(async (action, options) => {
|
|
541
|
+
try {
|
|
542
|
+
validateTunnelCommandOptions(action, options);
|
|
543
|
+
if (action === "setup") {
|
|
544
|
+
await runTunnelSetup(options);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (action === "verify") {
|
|
548
|
+
await runTunnelVerify(options.root);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
await toggleTunnel(action, options.root, options.force);
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (error instanceof ParseStateError) {
|
|
555
|
+
console.error(`Error: ${error.message} (expected setup, on, off, or verify)`);
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
console.error(`Error running tunnel command: ${error.message}`);
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
}
|
|
@@ -139,6 +139,9 @@ export class ConfigManager {
|
|
|
139
139
|
if (typeof data.docsEnabled === "boolean") {
|
|
140
140
|
sanitized.docsEnabled = data.docsEnabled;
|
|
141
141
|
}
|
|
142
|
+
if (typeof data.tunnelEnabled === "boolean") {
|
|
143
|
+
sanitized.tunnelEnabled = data.tunnelEnabled;
|
|
144
|
+
}
|
|
142
145
|
return sanitized;
|
|
143
146
|
}
|
|
144
147
|
/**
|
|
@@ -277,6 +280,25 @@ export class ConfigManager {
|
|
|
277
280
|
async setDocsEnabled(enabled) {
|
|
278
281
|
await this.set("docsEnabled", enabled);
|
|
279
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Gets the desired Cloudflare Tunnel service state, or undefined when unset.
|
|
285
|
+
*
|
|
286
|
+
* Unlike the UI/docs toggles, the tunnel has no fixed CLI-side default: an
|
|
287
|
+
* unset value means "defer to the launcher's env-derived default" (a
|
|
288
|
+
* configured PROPR_UI_TUNNEL_TOKEN or PROPR_UI_TUNNEL_ENABLED=true). Callers
|
|
289
|
+
* forward this value as an explicit override only when the user has toggled
|
|
290
|
+
* it, so it must preserve the unset (undefined) state rather than collapsing
|
|
291
|
+
* it to false.
|
|
292
|
+
*/
|
|
293
|
+
getTunnelEnabled() {
|
|
294
|
+
return this.get("tunnelEnabled");
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Sets the desired Cloudflare Tunnel service state.
|
|
298
|
+
*/
|
|
299
|
+
async setTunnelEnabled(enabled) {
|
|
300
|
+
await this.set("tunnelEnabled", enabled);
|
|
301
|
+
}
|
|
280
302
|
/**
|
|
281
303
|
* Gets all configuration values.
|
|
282
304
|
*
|