propr-cli 0.8.4 → 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/dist/assets/.env.example +183 -0
- package/dist/assets/env.example.txt +90 -3
- package/dist/commands/index.js +1 -0
- package/dist/commands/stackCommands.js +14 -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 +3 -2
- 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 +362 -15
- package/dist/tui/render.js +17 -2
- package/dist/vendor/shared/index.js +2 -1
- package/dist/vendor/shared/proprCompatibility.js +70 -0
- package/dist/vendor/shared/proprServiceUrls.js +97 -0
- package/package.json +2 -2
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Shared rendering for stack status — used by `propr status` (one-shot) and the
|
|
3
3
|
* `propr start` TUI's non-TTY fallback.
|
|
4
4
|
*/
|
|
5
|
+
import { proprTunnelEndpoints } from "../vendor/shared/index.js";
|
|
5
6
|
export function stateGlyph(s) {
|
|
6
7
|
if (!s.exists)
|
|
7
8
|
return "·";
|
|
@@ -29,3 +30,48 @@ export function renderStatusTable(status) {
|
|
|
29
30
|
}
|
|
30
31
|
return lines.join("\n");
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* A short "where the hosted UI reaches this stack" summary for startup output.
|
|
35
|
+
* Lists the concrete routed endpoints rather than the base URL, and notes the
|
|
36
|
+
* root 404, so nothing implies the root URL is an API/health target. Returns an
|
|
37
|
+
* empty array when there is no public URL to advertise.
|
|
38
|
+
*/
|
|
39
|
+
export function renderTunnelEndpointSummary(publicApiUrl) {
|
|
40
|
+
if (!publicApiUrl)
|
|
41
|
+
return [];
|
|
42
|
+
const { apiStatus, socketIo } = proprTunnelEndpoints(publicApiUrl);
|
|
43
|
+
return [
|
|
44
|
+
"Tunnel is up — the hosted UI reaches this stack at:",
|
|
45
|
+
` API: ${apiStatus}`,
|
|
46
|
+
` Socket.IO: ${socketIo}`,
|
|
47
|
+
" Root URL intentionally returns 404.",
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
/** A yes/no/unknown glyph+label for a tri-state tunnel field. */
|
|
51
|
+
function tunnelFlag(value) {
|
|
52
|
+
if (value === null)
|
|
53
|
+
return "· unknown";
|
|
54
|
+
return value ? "● yes" : "○ no";
|
|
55
|
+
}
|
|
56
|
+
/** Tunnel diagnostics section as a string (see TunnelStatus). */
|
|
57
|
+
export function renderTunnelSection(t) {
|
|
58
|
+
const lines = [];
|
|
59
|
+
lines.push("Tunnel");
|
|
60
|
+
lines.push("─".repeat(60));
|
|
61
|
+
lines.push(` enabled ${tunnelFlag(t.enabled)}`);
|
|
62
|
+
lines.push(` configured ${tunnelFlag(t.configured)}`);
|
|
63
|
+
lines.push(` running ${tunnelFlag(t.running)}`);
|
|
64
|
+
if (t.publicApiUrl) {
|
|
65
|
+
// Show the concrete endpoints propr-routing forwards, not the base URL as if
|
|
66
|
+
// it were a health target — the root path intentionally 404s through the
|
|
67
|
+
// tunnel. `reachable` reflects the /api/status probe.
|
|
68
|
+
const { apiStatus, socketIo } = proprTunnelEndpoints(t.publicApiUrl);
|
|
69
|
+
lines.push(` API ${apiStatus}`);
|
|
70
|
+
lines.push(` Socket.IO ${socketIo}`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
lines.push(` public URL —`);
|
|
74
|
+
}
|
|
75
|
+
lines.push(` reachable ${tunnelFlag(t.reachable)} (probes /api/status)`);
|
|
76
|
+
return lines.join("\n");
|
|
77
|
+
}
|
|
@@ -77,8 +77,9 @@ export function resolveStackRoot(configManager, flagRoot) {
|
|
|
77
77
|
/**
|
|
78
78
|
* Convenience: load the orchestrator and resolve a host config for the given
|
|
79
79
|
* (or resolved) stack root. When a ConfigManager is provided, persisted CLI
|
|
80
|
-
* settings (docsEnabled) are forwarded as overrides so `propr
|
|
81
|
-
* `propr docs on`. Note: uiEnabled is read
|
|
80
|
+
* settings (docsEnabled, tunnelEnabled) are forwarded as overrides so `propr
|
|
81
|
+
* start` honors `propr docs on` and `propr tunnel on`. Note: uiEnabled is read
|
|
82
|
+
* directly from ConfigManager at
|
|
82
83
|
* call sites (e.g. render.ts) and passed to startStack(); it is not part of
|
|
83
84
|
* the resolved config because resolveConfig does not consume it.
|
|
84
85
|
*/
|
|
@@ -96,6 +97,10 @@ export async function getHostConfig(opts) {
|
|
|
96
97
|
if (docsExplicit !== undefined) {
|
|
97
98
|
cliOverrides.docsEnabled = docsExplicit;
|
|
98
99
|
}
|
|
100
|
+
const tunnelExplicit = opts.configManager.get("tunnelEnabled");
|
|
101
|
+
if (tunnelExplicit !== undefined) {
|
|
102
|
+
cliOverrides.uiTunnelEnabled = tunnelExplicit;
|
|
103
|
+
}
|
|
99
104
|
}
|
|
100
105
|
const cfg = orch.resolveHostConfig({ rootDir, env: process.env, manifestPath, cliOverrides });
|
|
101
106
|
return { orch, cfg, rootDir };
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.8.
|
|
3
|
-
"git_sha": "
|
|
2
|
+
"version": "0.8.5",
|
|
3
|
+
"git_sha": "45e9ed5a",
|
|
4
4
|
"registry": "propr",
|
|
5
5
|
"images": {
|
|
6
|
-
"app": "propr/app:0.8.
|
|
7
|
-
"ui": "propr/ui:0.8.
|
|
8
|
-
"docs": "propr/docs:0.8.
|
|
9
|
-
"agent-claude": "propr/agent-claude:0.8.
|
|
10
|
-
"agent-codex": "propr/agent-codex:0.8.
|
|
11
|
-
"agent-antigravity": "propr/agent-antigravity:0.8.
|
|
12
|
-
"agent-opencode": "propr/agent-opencode:0.8.
|
|
13
|
-
"agent-vibe": "propr/agent-vibe:0.8.
|
|
14
|
-
"redis": "redis:7-alpine"
|
|
6
|
+
"app": "propr/app:0.8.5",
|
|
7
|
+
"ui": "propr/ui:0.8.5",
|
|
8
|
+
"docs": "propr/docs:0.8.5",
|
|
9
|
+
"agent-claude": "propr/agent-claude:0.8.5",
|
|
10
|
+
"agent-codex": "propr/agent-codex:0.8.5",
|
|
11
|
+
"agent-antigravity": "propr/agent-antigravity:0.8.5",
|
|
12
|
+
"agent-opencode": "propr/agent-opencode:0.8.5",
|
|
13
|
+
"agent-vibe": "propr/agent-vibe:0.8.5",
|
|
14
|
+
"redis": "redis:7-alpine",
|
|
15
|
+
"cloudflared": "cloudflare/cloudflared:2024.12.2"
|
|
15
16
|
}
|
|
16
17
|
}
|
|
@@ -24,6 +24,82 @@ import { fileURLToPath } from 'node:url';
|
|
|
24
24
|
|
|
25
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
26
|
|
|
27
|
+
// Hosted UI tunnel naming. These mirror the shared TypeScript constants in
|
|
28
|
+
// packages/shared/src/proprServiceUrls.ts (PROPR_UI_PROXY_SUFFIX,
|
|
29
|
+
// DEFAULT_CLOUDFLARED_IMAGE, DEFAULT_PROPR_UI_ORIGIN) — kept as plain literals
|
|
30
|
+
// here because this module is dependency-free .mjs (Node stdlib only) and cannot
|
|
31
|
+
// import the TS package. Change one, change the other;
|
|
32
|
+
// test/orchestratorProprUrlsDrift.test.ts guards against the copies diverging.
|
|
33
|
+
export const PROPR_UI_PROXY_SUFFIX = 'proxy.propr.dev';
|
|
34
|
+
// Fallback used only when the manifest has no `cloudflared` entry. Pin it to the
|
|
35
|
+
// same tag the manifest ships (docker/launcher/manifest.json) so the effective
|
|
36
|
+
// default is identical whether it comes from the manifest or this fallback —
|
|
37
|
+
// operator docs can then describe a single, pinned default.
|
|
38
|
+
export const DEFAULT_CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2024.12.2';
|
|
39
|
+
export const DEFAULT_PROPR_UI_ORIGIN = 'https://app.propr.dev';
|
|
40
|
+
|
|
41
|
+
// Whether an instance id is a valid single DNS label for the proxy hostname
|
|
42
|
+
// (<id>.proxy.propr.dev): 1–63 chars, ASCII letters/digits/hyphens only, no
|
|
43
|
+
// leading/trailing hyphen. Mirrors isValidProprInstanceId() in the shared pkg.
|
|
44
|
+
export function isValidProprInstanceId(instanceId) {
|
|
45
|
+
const id = (instanceId ?? '').trim();
|
|
46
|
+
return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Derive the per-instance public API/UI URL (https://<instanceId>.proxy.propr.dev)
|
|
50
|
+
// from an instance id; returns undefined for a missing/blank or invalid id (so a
|
|
51
|
+
// malformed hostname is never emitted). The id is lowercased so a mixed-case
|
|
52
|
+
// PROPR_INSTANCE_ID yields a canonical hostname (DNS is case-insensitive).
|
|
53
|
+
// Mirrors proprInstanceProxyUrl() in packages/shared/src/proprServiceUrls.ts.
|
|
54
|
+
export function proprInstanceProxyUrl(instanceId) {
|
|
55
|
+
const id = (instanceId ?? '').trim();
|
|
56
|
+
return isValidProprInstanceId(id) ? `https://${id.toLowerCase()}.${PROPR_UI_PROXY_SUFFIX}` : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Whether a URL is a hosted per-instance proxy URL (https://<id>.proxy.propr.dev).
|
|
60
|
+
// propr-routing only forwards /api/* and /socket.io/* on these hosts, so the
|
|
61
|
+
// tunnel base URL must be one of them. Requires exactly one valid instance-id
|
|
62
|
+
// label before the suffix (a nested host like foo.bar.proxy.propr.dev is
|
|
63
|
+
// rejected) and a bare origin (a non-root path/query/fragment is rejected so
|
|
64
|
+
// proprTunnelEndpoints does not double up the /api prefix). Mirrors
|
|
65
|
+
// isProprProxyUrl() in the shared pkg.
|
|
66
|
+
export function isProprProxyUrl(url) {
|
|
67
|
+
if (!url) return false;
|
|
68
|
+
try {
|
|
69
|
+
const { protocol, hostname, pathname, search, hash } = new URL(url);
|
|
70
|
+
if (protocol !== 'https:') return false;
|
|
71
|
+
// Trailing slashes are tolerated; any real path segment/query/fragment
|
|
72
|
+
// is rejected so a base path can't double up the appended /api prefix.
|
|
73
|
+
if (/[^/]/.test(pathname) || search || hash) return false;
|
|
74
|
+
const suffix = `.${PROPR_UI_PROXY_SUFFIX}`;
|
|
75
|
+
if (!hostname.endsWith(suffix)) return false;
|
|
76
|
+
return isValidProprInstanceId(hostname.slice(0, -suffix.length));
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The concrete endpoints the hosted UI reaches through the tunnel base URL.
|
|
83
|
+
// propr-routing only allows /api/* and /socket.io/*, so the base (root) URL
|
|
84
|
+
// itself intentionally returns 404 — it is NOT a health target; probe apiStatus
|
|
85
|
+
// for liveness. Mirrors proprTunnelEndpoints() in packages/shared/src/proprServiceUrls.ts.
|
|
86
|
+
export function proprTunnelEndpoints(baseUrl) {
|
|
87
|
+
const base = baseUrl.replace(/\/+$/, '');
|
|
88
|
+
return {
|
|
89
|
+
apiStatus: `${base}/api/status`,
|
|
90
|
+
socketIo: `${base}/socket.io/`,
|
|
91
|
+
root: `${base}/`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Broad truthy parse for env flags, mirroring parseTruthyEnvValue() in
|
|
96
|
+
// packages/shared/src/demoMode.ts so `1`/`TRUE`/whitespace are accepted like
|
|
97
|
+
// elsewhere in the repo (kept local because this module imports no TS package).
|
|
98
|
+
function parseTruthyEnvValue(value) {
|
|
99
|
+
const normalized = value?.trim().toLowerCase();
|
|
100
|
+
return normalized === 'true' || normalized === '1';
|
|
101
|
+
}
|
|
102
|
+
|
|
27
103
|
// True only for an existing regular file (guards against a path that exists but
|
|
28
104
|
// is a directory, which would make readFileSync throw EISDIR).
|
|
29
105
|
function isReadableFile(path) {
|
|
@@ -188,6 +264,27 @@ export function resolveConfig(env = process.env, overrides = {}) {
|
|
|
188
264
|
const manifestPath = overrides.manifestPath ?? resolve(__dirname, 'manifest.json');
|
|
189
265
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
190
266
|
|
|
267
|
+
// Hosted UI tunnel: expose this local stack's UI/API to the hosted control
|
|
268
|
+
// plane (https://app.propr.dev) via a Cloudflare Tunnel. A token alone is
|
|
269
|
+
// enough to enable it; PROPR_UI_TUNNEL_ENABLED=true also turns it on.
|
|
270
|
+
const uiTunnelToken = get('PROPR_UI_TUNNEL_TOKEN') || undefined;
|
|
271
|
+
// A persisted CLI toggle (`propr tunnel on|off`) wins over the env-derived
|
|
272
|
+
// default so `propr start` honors the user's last explicit choice.
|
|
273
|
+
const uiTunnelEnabled = overrides.uiTunnelEnabled ?? (Boolean(uiTunnelToken) || parseTruthyEnvValue(get('PROPR_UI_TUNNEL_ENABLED')));
|
|
274
|
+
const proprInstanceId = get('PROPR_INSTANCE_ID') || undefined;
|
|
275
|
+
// Cloudflared image for the optional tunnel sidecar: an explicit env override
|
|
276
|
+
// wins, then the manifest's pinned tag, with DEFAULT_CLOUDFLARED_IMAGE as a
|
|
277
|
+
// final fallback for manifests without a cloudflared entry.
|
|
278
|
+
const cloudflaredImage = get('PROPR_CLOUDFLARED_IMAGE') || manifest.images.cloudflared || DEFAULT_CLOUDFLARED_IMAGE;
|
|
279
|
+
// Explicit URL wins; otherwise derive from the instance id's proxy hostname.
|
|
280
|
+
// Falls back to undefined for local development (no instance id), where
|
|
281
|
+
// API_PUBLIC_URL / FRONTEND_URL keep their localhost defaults below. Trailing
|
|
282
|
+
// slashes are stripped once here so every consumer (API/worker/UI env, status
|
|
283
|
+
// output, endpoint rendering) sees one canonical form — the derived URL never
|
|
284
|
+
// has one, but an explicit PROPR_UI_PUBLIC_API_URL might.
|
|
285
|
+
const uiPublicApiUrl =
|
|
286
|
+
(get('PROPR_UI_PUBLIC_API_URL') || proprInstanceProxyUrl(proprInstanceId))?.replace(/\/+$/, '') || undefined;
|
|
287
|
+
|
|
191
288
|
return Object.freeze({
|
|
192
289
|
stack, network, envFileLocal, envFileHost,
|
|
193
290
|
validateHostPaths: overrides.validateHostPaths === true,
|
|
@@ -197,10 +294,18 @@ export function resolveConfig(env = process.env, overrides = {}) {
|
|
|
197
294
|
hostOpencodeXdgDir, hostOpencodeDataDir,
|
|
198
295
|
hostVibeDir, vibePromptCacheDir, hostVibePromptCacheDir,
|
|
199
296
|
hostGhPrivateKey,
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
297
|
+
// Hosted UI tunnel settings (see resolution above). Defaults keep local
|
|
298
|
+
// development unaffected: no instance id ⇒ no derived public URL.
|
|
299
|
+
uiTunnelEnabled, uiTunnelToken, proprInstanceId, uiPublicApiUrl, cloudflaredImage,
|
|
300
|
+
// misc -e overrides the launcher computed from ports/env. When the UI
|
|
301
|
+
// tunnel is enabled the API/worker must advertise the public proxy URL
|
|
302
|
+
// (OAuth/session redirects, attachment links, browser-visible API refs)
|
|
303
|
+
// and the frontend must point at the hosted UI origin. An explicit
|
|
304
|
+
// API_PUBLIC_URL / FRONTEND_URL still wins; otherwise tunnel mode derives
|
|
305
|
+
// them, falling back to the localhost defaults for local development.
|
|
306
|
+
apiPublicUrl: get('API_PUBLIC_URL') || (uiTunnelEnabled && uiPublicApiUrl ? uiPublicApiUrl : `http://localhost:${apiPort}`),
|
|
307
|
+
frontendUrl: get('FRONTEND_URL') || (uiTunnelEnabled ? DEFAULT_PROPR_UI_ORIGIN : undefined) || `http://localhost:${uiPort}`,
|
|
308
|
+
ghOauthCallbackUrl: get('GH_OAUTH_CALLBACK_URL') || (uiTunnelEnabled && uiPublicApiUrl ? `${uiPublicApiUrl}/api/auth/github/callback` : `http://localhost:${apiPort}/api/auth/github/callback`),
|
|
204
309
|
githubBotUsername: get('GITHUB_BOT_USERNAME') || 'propr.dev[bot]',
|
|
205
310
|
indexingScanInterval: get('INDEXING_SCAN_INTERVAL_MS') || '300000',
|
|
206
311
|
indexingReindexInterval: get('INDEXING_REINDEX_INTERVAL_MS') || '86400000',
|
|
@@ -288,6 +393,21 @@ function vibePromptCacheArgs(cfg) {
|
|
|
288
393
|
];
|
|
289
394
|
}
|
|
290
395
|
|
|
396
|
+
// Tunnel-related env propagated into the API container for status/debugging and
|
|
397
|
+
// future Connect support. PROPR_UI_TUNNEL_TOKEN is deliberately NOT among these
|
|
398
|
+
// — only the cloudflared sidecar receives the token. The instance id and public
|
|
399
|
+
// API URL are injected only when set, so local-development containers (no tunnel)
|
|
400
|
+
// stay free of empty PROPR_* vars while still always reporting the enabled flag.
|
|
401
|
+
function tunnelApiEnvArgs(cfg) {
|
|
402
|
+
const args = ['-e', `PROPR_UI_TUNNEL_ENABLED=${cfg.uiTunnelEnabled ? 'true' : 'false'}`];
|
|
403
|
+
// Inject the instance id lowercased so it matches the derived public URL
|
|
404
|
+
// (proprInstanceProxyUrl lowercases the host), keeping the id and the
|
|
405
|
+
// PROPR_UI_PUBLIC_API_URL host consistent for any consumer that compares them.
|
|
406
|
+
if (isValidProprInstanceId(cfg.proprInstanceId)) args.push('-e', `PROPR_INSTANCE_ID=${cfg.proprInstanceId.trim().toLowerCase()}`);
|
|
407
|
+
if (cfg.uiPublicApiUrl) args.push('-e', `PROPR_UI_PUBLIC_API_URL=${cfg.uiPublicApiUrl}`);
|
|
408
|
+
return args;
|
|
409
|
+
}
|
|
410
|
+
|
|
291
411
|
// Validates host bind-mount paths for Linux deployments. ':' rejection prevents
|
|
292
412
|
// malformed -v HOST:CONTAINER args; Windows drive paths (C:\...) are unsupported.
|
|
293
413
|
export function validateDockerBindPath(name, value, { containerPath = false } = {}) {
|
|
@@ -654,13 +774,14 @@ export function ensureServiceImage(cfg, service, onLog, { freshnessCache } = {})
|
|
|
654
774
|
// ---------------------------------------------------------------------------
|
|
655
775
|
|
|
656
776
|
export const CORE_SERVICES = ['redis', 'daemon', 'worker', 'analysis-worker', 'indexing-worker', 'api'];
|
|
657
|
-
export const TOGGLE_SERVICES = ['ui', 'docs'];
|
|
777
|
+
export const TOGGLE_SERVICES = ['ui', 'docs', 'tunnel'];
|
|
658
778
|
export const SERVICES = [...CORE_SERVICES, ...TOGGLE_SERVICES];
|
|
659
779
|
|
|
660
780
|
function imageTagForService(cfg, service) {
|
|
661
781
|
if (service === 'redis') return cfg.images.redis;
|
|
662
782
|
if (service === 'ui') return cfg.images.ui;
|
|
663
783
|
if (service === 'docs') return cfg.images.docs;
|
|
784
|
+
if (service === 'tunnel') return cfg.cloudflaredImage;
|
|
664
785
|
// daemon/worker/analysis-worker/indexing-worker/api all run the app image
|
|
665
786
|
return cfg.images.app;
|
|
666
787
|
}
|
|
@@ -686,7 +807,7 @@ function appSpec(cfg, command, extraArgs = []) {
|
|
|
686
807
|
}
|
|
687
808
|
|
|
688
809
|
// Returns { image, args, command? } for a canonical service name.
|
|
689
|
-
function buildServiceSpec(cfg, service) {
|
|
810
|
+
export function buildServiceSpec(cfg, service) {
|
|
690
811
|
switch (service) {
|
|
691
812
|
case 'redis': {
|
|
692
813
|
const args = ['-v', `${cfg.stack}-redis-data:/data`];
|
|
@@ -728,6 +849,17 @@ function buildServiceSpec(cfg, service) {
|
|
|
728
849
|
]);
|
|
729
850
|
case 'api':
|
|
730
851
|
return appSpec(cfg, ['dist/packages/api/server.js'], [
|
|
852
|
+
// Stable in-network DNS alias so the cloudflared sidecar (and the
|
|
853
|
+
// Cloudflare Tunnel ingress config) can target a fixed
|
|
854
|
+
// `http://api:4000` regardless of the stack prefix. Without it the
|
|
855
|
+
// container is only reachable as `${stack}-api` (e.g. propr-api),
|
|
856
|
+
// which would force a per-stack tunnel ingress config and break the
|
|
857
|
+
// documented `http://api:4000` target. The alias is added
|
|
858
|
+
// unconditionally (not only in tunnel mode): it is harmless for
|
|
859
|
+
// tunnel-disabled local dev — each stack has its own network, so the
|
|
860
|
+
// alias is scoped to that network and never collides — and keeping it
|
|
861
|
+
// always-on avoids restarting the API just to enable the tunnel later.
|
|
862
|
+
'--network-alias', 'api',
|
|
731
863
|
'-p', `${cfg.apiPort}:4000`,
|
|
732
864
|
'-v', `${cfg.envFileHost}:/usr/src/app/.env:ro`,
|
|
733
865
|
'-v', '/tmp/pr-worktrees:/tmp/pr-worktrees',
|
|
@@ -739,11 +871,53 @@ function buildServiceSpec(cfg, service) {
|
|
|
739
871
|
'-e', `GH_OAUTH_CALLBACK_URL=${cfg.ghOauthCallbackUrl}`,
|
|
740
872
|
'-e', `SESSION_REDIS_HOST=${cfg.stack}-redis`,
|
|
741
873
|
'-e', 'CONFIG_REPO_PATH=/tmp/config_repo',
|
|
874
|
+
...tunnelApiEnvArgs(cfg),
|
|
742
875
|
]);
|
|
743
|
-
case 'ui':
|
|
744
|
-
|
|
876
|
+
case 'ui': {
|
|
877
|
+
// The UI image's docker-entrypoint.sh rewrites public/config.js from
|
|
878
|
+
// PROPR_UI_PUBLIC_API_URL so one prebuilt bundle can point at any
|
|
879
|
+
// per-instance proxy. Pass the tunnel base URL through unchanged — the
|
|
880
|
+
// UI appends /api/... to it for REST and uses /socket.io/ for Socket.IO,
|
|
881
|
+
// so the value must be the bare proxy origin (no /api suffix). Only set
|
|
882
|
+
// it when known; an unset value keeps the same-origin local default.
|
|
883
|
+
const uiArgs = ['-p', `${cfg.uiPort}:5173`];
|
|
884
|
+
if (cfg.uiPublicApiUrl) uiArgs.push('-e', `PROPR_UI_PUBLIC_API_URL=${cfg.uiPublicApiUrl}`);
|
|
885
|
+
return { image: cfg.images.ui, args: uiArgs };
|
|
886
|
+
}
|
|
745
887
|
case 'docs':
|
|
746
888
|
return { image: cfg.images.docs, args: ['-p', `${cfg.docsPort}:3000`] };
|
|
889
|
+
case 'tunnel':
|
|
890
|
+
// The tunnel sidecar cannot authenticate without a token. Callers via
|
|
891
|
+
// the CLI validate this up front (validateEnv / `propr tunnel on`), but
|
|
892
|
+
// make the invariant local so a direct buildServiceSpec/startService
|
|
893
|
+
// call fails clearly instead of emitting a malformed `docker run`.
|
|
894
|
+
if (!cfg.uiTunnelToken) {
|
|
895
|
+
throw new Error('cannot build the tunnel service spec: PROPR_UI_TUNNEL_TOKEN is not set (the cloudflared sidecar needs a token to authenticate).');
|
|
896
|
+
}
|
|
897
|
+
// Optional Cloudflare Tunnel sidecar running the official cloudflared
|
|
898
|
+
// image (its entrypoint is `cloudflared`). It dials out to Cloudflare's
|
|
899
|
+
// edge, so no local ports are published.
|
|
900
|
+
//
|
|
901
|
+
// The spec's `tunnel --no-autoupdate run --token $PROPR_UI_TUNNEL_TOKEN`
|
|
902
|
+
// contract is satisfied via the env var rather than the literal flag:
|
|
903
|
+
// in cloudflared the `run` command's `--token` flag is bound to the
|
|
904
|
+
// TUNNEL_TOKEN env var (urfave/cli `EnvVars: ["TUNNEL_TOKEN"]`), so
|
|
905
|
+
// `tunnel run` reads TUNNEL_TOKEN natively and treats it exactly as if
|
|
906
|
+
// `--token <value>` had been passed. This binding is present in the
|
|
907
|
+
// pinned image (cloudflare/cloudflared:2024.12.2, see manifest.json) and
|
|
908
|
+
// has been stable across cloudflared releases, so the sidecar starts
|
|
909
|
+
// authenticated without the literal token ever appearing on argv.
|
|
910
|
+
// We prefer the env var precisely to keep the token off the process argv
|
|
911
|
+
// (otherwise visible to anyone via host `ps`/`docker top`, and to
|
|
912
|
+
// unprivileged in-container tooling). It is still present in the
|
|
913
|
+
// container's env, so a `docker inspect` by someone with Docker-daemon
|
|
914
|
+
// access can read it — Docker access is already privileged. The token
|
|
915
|
+
// is injected only here — no other container receives it.
|
|
916
|
+
return {
|
|
917
|
+
image: cfg.cloudflaredImage,
|
|
918
|
+
args: ['-e', `TUNNEL_TOKEN=${cfg.uiTunnelToken}`],
|
|
919
|
+
command: ['tunnel', '--no-autoupdate', 'run'],
|
|
920
|
+
};
|
|
747
921
|
default:
|
|
748
922
|
throw new Error(`unknown service: ${service}`);
|
|
749
923
|
}
|
|
@@ -797,8 +971,8 @@ export function isStackRunning(cfg) {
|
|
|
797
971
|
* services started so far are stopped (best effort) before the error is
|
|
798
972
|
* rethrown, so a failed startup doesn't leave a half-running stack behind.
|
|
799
973
|
*/
|
|
800
|
-
export function startStack(cfg, { ui = true, docs = cfg.docsEnabled, onLog } = {}) {
|
|
801
|
-
const toStart = [...CORE_SERVICES, ...(ui ? ['ui'] : []), ...(docs ? ['docs'] : [])];
|
|
974
|
+
export function startStack(cfg, { ui = true, docs = cfg.docsEnabled, tunnel = cfg.uiTunnelEnabled, onLog } = {}) {
|
|
975
|
+
const toStart = [...CORE_SERVICES, ...(ui ? ['ui'] : []), ...(docs ? ['docs'] : []), ...(tunnel ? ['tunnel'] : [])];
|
|
802
976
|
const started = [];
|
|
803
977
|
const freshnessCache = new Map();
|
|
804
978
|
try {
|
|
@@ -935,8 +1109,8 @@ async function stopServiceAsync(cfg, service, { remove = true, onLog } = {}) {
|
|
|
935
1109
|
* without blocking the event loop, rolling back already-started services on a
|
|
936
1110
|
* mid-startup failure (best effort) before rethrowing.
|
|
937
1111
|
*/
|
|
938
|
-
export async function startStackAsync(cfg, { ui = true, docs = cfg.docsEnabled, onLog } = {}) {
|
|
939
|
-
const toStart = [...CORE_SERVICES, ...(ui ? ['ui'] : []), ...(docs ? ['docs'] : [])];
|
|
1112
|
+
export async function startStackAsync(cfg, { ui = true, docs = cfg.docsEnabled, tunnel = cfg.uiTunnelEnabled, onLog } = {}) {
|
|
1113
|
+
const toStart = [...CORE_SERVICES, ...(ui ? ['ui'] : []), ...(docs ? ['docs'] : []), ...(tunnel ? ['tunnel'] : [])];
|
|
940
1114
|
const started = [];
|
|
941
1115
|
const freshnessCache = new Map();
|
|
942
1116
|
try {
|
|
@@ -1021,7 +1195,7 @@ export function stopStack(cfg, { remove = true, removeNetwork = false, onLog } =
|
|
|
1021
1195
|
// ---------------------------------------------------------------------------
|
|
1022
1196
|
|
|
1023
1197
|
/** Parse the `docker ps` table into per-service stack status (shared by sync/async). */
|
|
1024
|
-
function parseStackStatus(cfg, stdout) {
|
|
1198
|
+
export function parseStackStatus(cfg, stdout) {
|
|
1025
1199
|
const expectedNames = new Set(SERVICES.map((service) => `${cfg.stack}-${service}`));
|
|
1026
1200
|
const byName = new Map();
|
|
1027
1201
|
for (const line of stdout.split('\n').filter(Boolean)) {
|
|
@@ -1043,7 +1217,11 @@ function parseStackStatus(cfg, stdout) {
|
|
|
1043
1217
|
};
|
|
1044
1218
|
});
|
|
1045
1219
|
|
|
1046
|
-
|
|
1220
|
+
// The stack is "running" only when a core service is up. A lone optional
|
|
1221
|
+
// sidecar (e.g. an orphaned propr-tunnel left over after the core stack
|
|
1222
|
+
// stopped) must not mask the unusable state — otherwise `propr status`
|
|
1223
|
+
// would skip "Stack is not running" while the API is actually down.
|
|
1224
|
+
const anyRunning = services.some((s) => CORE_SERVICES.includes(s.service) && s.running);
|
|
1047
1225
|
return { stack: cfg.stack, network: cfg.network, running: anyRunning, services };
|
|
1048
1226
|
}
|
|
1049
1227
|
|
|
@@ -1059,6 +1237,92 @@ export function getServiceState(cfg, service) {
|
|
|
1059
1237
|
return getStackStatus(cfg).services.find((s) => s.service === service);
|
|
1060
1238
|
}
|
|
1061
1239
|
|
|
1240
|
+
// Best-effort GET <publicApiUrl>/api/status behind a hard timeout. propr-routing
|
|
1241
|
+
// only forwards /api/* and /socket.io/* on the proxy host, so the old root
|
|
1242
|
+
// /health path is no longer reachable through the tunnel — /api/status is the
|
|
1243
|
+
// public liveness endpoint. Resolves true when the API answers: a 2xx (status
|
|
1244
|
+
// payload) or an auth-expected 401/403 both prove the proxy reaches the API.
|
|
1245
|
+
// Resolves false on any other status / network error / timeout. Never throws:
|
|
1246
|
+
// tunnel reachability is a diagnostic, not a gate, so a slow or down proxy must
|
|
1247
|
+
// not fail `propr status`.
|
|
1248
|
+
// True for a well-formed http(s) URL. Used to skip probing/advertising a
|
|
1249
|
+
// malformed PROPR_UI_PUBLIC_API_URL (validateEnv flags it, but a programmatic
|
|
1250
|
+
// caller may have skipped validation).
|
|
1251
|
+
function isValidHttpUrl(value) {
|
|
1252
|
+
try {
|
|
1253
|
+
const { protocol } = new URL(value);
|
|
1254
|
+
return protocol === 'http:' || protocol === 'https:';
|
|
1255
|
+
} catch {
|
|
1256
|
+
return false;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function isLocalhostHttpUrl(value) {
|
|
1261
|
+
try {
|
|
1262
|
+
const { protocol, hostname } = new URL(value);
|
|
1263
|
+
return (
|
|
1264
|
+
(protocol === 'http:' || protocol === 'https:')
|
|
1265
|
+
&& (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]')
|
|
1266
|
+
);
|
|
1267
|
+
} catch {
|
|
1268
|
+
return false;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
async function probeTunnelReachable(publicApiUrl, timeoutMs = 3000) {
|
|
1273
|
+
const { apiStatus } = proprTunnelEndpoints(publicApiUrl);
|
|
1274
|
+
const controller = new AbortController();
|
|
1275
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1276
|
+
try {
|
|
1277
|
+
// `redirect: 'manual'` so a redirect is treated as the proxy's own
|
|
1278
|
+
// response rather than transparently followed off-host — matching the
|
|
1279
|
+
// probe in `propr tunnel verify` (tunnelCommand.ts) so the two agree.
|
|
1280
|
+
const res = await fetch(apiStatus, { signal: controller.signal, redirect: 'manual' });
|
|
1281
|
+
// 2xx means the API answered; 401/403 means it answered but wants auth —
|
|
1282
|
+
// either way the tunnel forwarded the request to the API behind it.
|
|
1283
|
+
return res.ok || res.status === 401 || res.status === 403;
|
|
1284
|
+
} catch {
|
|
1285
|
+
return false;
|
|
1286
|
+
} finally {
|
|
1287
|
+
clearTimeout(timer);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* Tunnel diagnostics for `propr status`. The Cloudflare tunnel is a local
|
|
1293
|
+
* managed service, so its health belongs in local status:
|
|
1294
|
+
* - enabled: tunnel turned on by resolved config (token present or the
|
|
1295
|
+
* explicit PROPR_UI_TUNNEL_ENABLED flag)
|
|
1296
|
+
* - configured: a tunnel token is present
|
|
1297
|
+
* - running: the cloudflared sidecar container is running
|
|
1298
|
+
* - publicApiUrl: the expected public proxy URL (null when not derivable)
|
|
1299
|
+
* - reachable: best-effort <publicApiUrl>/api/status probe — true/false when
|
|
1300
|
+
* a URL is known, null when there is nothing to probe
|
|
1301
|
+
*
|
|
1302
|
+
* Pass a precomputed stack status to reuse a single `docker ps`.
|
|
1303
|
+
*/
|
|
1304
|
+
export async function getTunnelStatus(cfg, stackStatus) {
|
|
1305
|
+
const status = stackStatus ?? await getStackStatusAsync(cfg);
|
|
1306
|
+
const tunnel = status.services.find((s) => s.service === 'tunnel');
|
|
1307
|
+
const tunnelRunning = Boolean(tunnel && tunnel.running);
|
|
1308
|
+
const publicApiUrl = cfg.uiPublicApiUrl ?? null;
|
|
1309
|
+
// Only spend up to ~3s on the external probe when the tunnel is enabled, the
|
|
1310
|
+
// cloudflared sidecar is actually running, and the public URL is a well-formed
|
|
1311
|
+
// http(s) URL. Probing a configured-but-stopped tunnel can only ever fail (the
|
|
1312
|
+
// sidecar that routes the request is down), so skipping it avoids adding the
|
|
1313
|
+
// timeout to every `propr status` in the common "enabled but stopped" case.
|
|
1314
|
+
const reachable = (cfg.uiTunnelEnabled && tunnelRunning && publicApiUrl && isValidHttpUrl(publicApiUrl))
|
|
1315
|
+
? await probeTunnelReachable(publicApiUrl)
|
|
1316
|
+
: null;
|
|
1317
|
+
return {
|
|
1318
|
+
enabled: Boolean(cfg.uiTunnelEnabled),
|
|
1319
|
+
configured: Boolean(cfg.uiTunnelToken),
|
|
1320
|
+
running: tunnelRunning,
|
|
1321
|
+
publicApiUrl,
|
|
1322
|
+
reachable,
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1062
1326
|
/** Spawn `docker logs` for a service. Returns the ChildProcess. */
|
|
1063
1327
|
export function getServiceLogs(cfg, service, { follow = false, tail = 'all', stdio = 'inherit' } = {}) {
|
|
1064
1328
|
const args = ['logs'];
|
|
@@ -1107,6 +1371,9 @@ export function validateEnv(cfg) {
|
|
|
1107
1371
|
if (cfg.envFileLocal && !isReadableFile(cfg.envFileLocal)) {
|
|
1108
1372
|
errors.push(`cannot read the env file at ${cfg.envFileLocal}`);
|
|
1109
1373
|
}
|
|
1374
|
+
if (cfg.proprInstanceId && !isValidProprInstanceId(cfg.proprInstanceId)) {
|
|
1375
|
+
errors.push(`PROPR_INSTANCE_ID ("${cfg.proprInstanceId}") is not a valid DNS label. Use 1–63 letters/digits/hyphens with no leading or trailing hyphen, or unset PROPR_INSTANCE_ID and use a valid PROPR_UI_PUBLIC_API_URL.`);
|
|
1376
|
+
}
|
|
1110
1377
|
|
|
1111
1378
|
if (cfg.vibeConfigPath && !cfg.hostVibeDir) {
|
|
1112
1379
|
errors.push(
|
|
@@ -1171,6 +1438,81 @@ export function validateEnv(cfg) {
|
|
|
1171
1438
|
}
|
|
1172
1439
|
}
|
|
1173
1440
|
|
|
1441
|
+
// The tunnel sidecar cannot authenticate without a token. uiTunnelEnabled is
|
|
1442
|
+
// true whenever a token is present, so this only trips when the tunnel was
|
|
1443
|
+
// turned on without a token — either via PROPR_UI_TUNNEL_ENABLED=true or a
|
|
1444
|
+
// persisted `propr tunnel on` override.
|
|
1445
|
+
if (cfg.uiTunnelEnabled && !cfg.uiTunnelToken) {
|
|
1446
|
+
errors.push('The UI tunnel is enabled (via PROPR_UI_TUNNEL_ENABLED=true or `propr tunnel on`) but PROPR_UI_TUNNEL_TOKEN is not set. Set PROPR_UI_TUNNEL_TOKEN to your Cloudflare Tunnel token, or disable the tunnel with `propr tunnel off` (or by unsetting PROPR_UI_TUNNEL_ENABLED).');
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Tunnel enabled but no public URL is known (cfg.uiPublicApiUrl is the
|
|
1450
|
+
// explicit PROPR_UI_PUBLIC_API_URL or the id-derived one, so this only trips
|
|
1451
|
+
// when neither yields a value — a missing or non-DNS-label instance id with no
|
|
1452
|
+
// explicit override). The stack would then be inconsistent: frontendUrl=
|
|
1453
|
+
// https://app.propr.dev but apiPublicUrl falls back to localhost, so cloudflared
|
|
1454
|
+
// starts while the hosted UI has no endpoint to reach. `propr start` enables the
|
|
1455
|
+
// tunnel from PROPR_UI_TUNNEL_TOKEN alone, bypassing the stricter `propr tunnel
|
|
1456
|
+
// on` guard (TunnelPublicUrlMissingError), so this is a hard error here — fail
|
|
1457
|
+
// startup rather than bring up a broken tunnel-mode stack.
|
|
1458
|
+
if (cfg.uiTunnelEnabled && !cfg.uiPublicApiUrl) {
|
|
1459
|
+
errors.push(
|
|
1460
|
+
cfg.proprInstanceId
|
|
1461
|
+
? `PROPR_INSTANCE_ID ("${cfg.proprInstanceId}") is not a valid DNS label, so no https://<id>.proxy.propr.dev URL can be derived. The tunnel would start while the API advertises its localhost URL and the frontend points at the hosted UI, leaving the hosted UI with no endpoint to reach. Set a valid instance id (1–63 letters/digits/hyphens, no leading/trailing hyphen) or an explicit PROPR_UI_PUBLIC_API_URL.`
|
|
1462
|
+
: 'The UI tunnel is enabled but neither PROPR_INSTANCE_ID nor PROPR_UI_PUBLIC_API_URL is set, so no public proxy URL can be derived. The tunnel would start while the API advertises its localhost URL, leaving the hosted UI with no endpoint to reach. Set PROPR_INSTANCE_ID (preferred) or an explicit PROPR_UI_PUBLIC_API_URL.'
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// Validate an explicit PROPR_UI_PUBLIC_API_URL. A derived public URL is always
|
|
1467
|
+
// well-formed, so a bad value here can only come from an explicit override.
|
|
1468
|
+
//
|
|
1469
|
+
// A malformed value is ALWAYS a hard error, regardless of tunnel state: the
|
|
1470
|
+
// launcher injects PROPR_UI_PUBLIC_API_URL into the UI container whenever it is
|
|
1471
|
+
// set (buildServiceSpec('ui')), and docker-entrypoint.sh writes it into
|
|
1472
|
+
// config.js as the browser's API base URL. So even with the tunnel disabled a
|
|
1473
|
+
// bad value is NOT inert — it breaks the UI's API calls in local/self-hosted
|
|
1474
|
+
// mode. Validate it consistently wherever it will be injected.
|
|
1475
|
+
//
|
|
1476
|
+
// The hosted-proxy-host requirement is narrower and stays tunnel-only: when the
|
|
1477
|
+
// tunnel is ENABLED the value is advertised to the API/worker, probed by
|
|
1478
|
+
// getTunnelStatus()/verify, and must point at a hosted proxy host because
|
|
1479
|
+
// propr-routing only forwards /api/* and /socket.io/* on
|
|
1480
|
+
// https://<id>.proxy.propr.dev — so a non-proxy URL would start an unroutable
|
|
1481
|
+
// tunnel stack (matching the routing rule and the `propr tunnel on` guard). With
|
|
1482
|
+
// the tunnel off, any valid http(s) origin the UI should call is legitimate, so
|
|
1483
|
+
// only the well-formedness check applies.
|
|
1484
|
+
if (cfg.uiPublicApiUrl) {
|
|
1485
|
+
let parsed;
|
|
1486
|
+
try { parsed = new URL(cfg.uiPublicApiUrl); } catch { /* invalid below */ }
|
|
1487
|
+
if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
|
|
1488
|
+
errors.push(`PROPR_UI_PUBLIC_API_URL ("${cfg.uiPublicApiUrl}") is not a valid http(s) URL. It is injected into the UI container (config.js) as the browser's API base URL even when the tunnel is off, so a malformed value breaks the UI. Use a full URL such as https://abc123.proxy.propr.dev.`);
|
|
1489
|
+
} else if (cfg.uiTunnelEnabled && !isProprProxyUrl(cfg.uiPublicApiUrl)) {
|
|
1490
|
+
errors.push(`PROPR_UI_PUBLIC_API_URL ("${cfg.uiPublicApiUrl}") is not a hosted proxy URL (https://<id>.${PROPR_UI_PROXY_SUFFIX}). The tunnel only routes /api/* and /socket.io/* on ${PROPR_UI_PROXY_SUFFIX} hosts, so the hosted UI would be unable to reach this stack. Set PROPR_INSTANCE_ID or a bare https://<id>.${PROPR_UI_PROXY_SUFFIX} origin (no path/query/fragment — the /api and /socket.io paths are appended automatically).`);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// Existing local stacks commonly have explicit localhost API/UI URLs in
|
|
1495
|
+
// their .env. Explicit values win during resolution, so without this guard
|
|
1496
|
+
// enabling a tunnel can still launch a stack that advertises localhost to the
|
|
1497
|
+
// API/worker or permits only localhost as the frontend origin. That breaks
|
|
1498
|
+
// hosted app.propr.dev CORS, cookies, and public links even though the
|
|
1499
|
+
// cloudflared sidecar itself starts successfully.
|
|
1500
|
+
if (cfg.uiTunnelEnabled && isLocalhostHttpUrl(cfg.apiPublicUrl)) {
|
|
1501
|
+
errors.push(`API_PUBLIC_URL ("${cfg.apiPublicUrl}") still points at localhost while the UI tunnel is enabled. In tunnel mode the API must advertise the hosted proxy URL (for example https://<id>.${PROPR_UI_PROXY_SUFFIX}) so app.propr.dev can reach this stack. Remove the explicit API_PUBLIC_URL or set it to the hosted proxy URL.`);
|
|
1502
|
+
}
|
|
1503
|
+
if (cfg.uiTunnelEnabled && isLocalhostHttpUrl(cfg.frontendUrl)) {
|
|
1504
|
+
errors.push(`FRONTEND_URL ("${cfg.frontendUrl}") still points at localhost while the UI tunnel is enabled. In tunnel mode FRONTEND_URL must be ${DEFAULT_PROPR_UI_ORIGIN} so CORS and redirects allow the hosted UI. Remove the explicit FRONTEND_URL or set it to ${DEFAULT_PROPR_UI_ORIGIN}.`);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// In tunnel mode GH_OAUTH_CALLBACK_URL is derived from the public proxy URL
|
|
1508
|
+
// when unset. An explicit localhost callback still wins, but it is a common
|
|
1509
|
+
// broken-OAuth setup: GitHub redirects the browser to a localhost URL the
|
|
1510
|
+
// hosted UI cannot reach. Warn so the operator updates it (and the GitHub App
|
|
1511
|
+
// config) to the public proxy callback.
|
|
1512
|
+
if (cfg.uiTunnelEnabled && /^https?:\/\/(localhost|127\.0\.0\.1)\b/i.test(cfg.ghOauthCallbackUrl)) {
|
|
1513
|
+
warnings.push(`GH_OAUTH_CALLBACK_URL ("${cfg.ghOauthCallbackUrl}") still points at localhost while the UI tunnel is enabled. GitHub OAuth will redirect the browser to a localhost URL the hosted UI cannot reach. Set GH_OAUTH_CALLBACK_URL to your public proxy callback (e.g. https://<id>.${PROPR_UI_PROXY_SUFFIX}/api/auth/github/callback) and register it in the GitHub App.`);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1174
1516
|
const hasOpenCodeConfig = Boolean(cfg.hostOpencodeXdgDir);
|
|
1175
1517
|
if (hasOpenCodeConfig && !cfg.hostOpencodeDataDir) {
|
|
1176
1518
|
warnings.push(
|
|
@@ -1194,8 +1536,13 @@ export function pullImages(cfg, { onLog = () => {}, env = process.env } = {}) {
|
|
|
1194
1536
|
onLog('pulling images…');
|
|
1195
1537
|
const failedAgentImages = [];
|
|
1196
1538
|
|
|
1197
|
-
for (const [key,
|
|
1539
|
+
for (const [key, manifestTag] of Object.entries(cfg.images)) {
|
|
1198
1540
|
if (key === 'docs' && !cfg.docsEnabled) continue;
|
|
1541
|
+
if (key === 'cloudflared' && !cfg.uiTunnelEnabled) continue;
|
|
1542
|
+
// The tunnel sidecar actually runs cfg.cloudflaredImage, which honors a
|
|
1543
|
+
// PROPR_CLOUDFLARED_IMAGE override; pre-pull that image rather than the
|
|
1544
|
+
// bare manifest tag so an override isn't pulled twice (here + on demand).
|
|
1545
|
+
const tag = key === 'cloudflared' ? cfg.cloudflaredImage : manifestTag;
|
|
1199
1546
|
|
|
1200
1547
|
if (key.startsWith('agent-') && skipAgentPull) {
|
|
1201
1548
|
if (imagePresentLocally(tag)) {
|
package/dist/tui/render.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* In all cases the containers run detached, so the stack outlives this process.
|
|
7
7
|
*/
|
|
8
8
|
import { getHostConfig } from "../orchestrator/index.js";
|
|
9
|
-
import { renderStatusTable } from "../orchestrator/format.js";
|
|
9
|
+
import { renderStatusTable, renderTunnelEndpointSummary } from "../orchestrator/format.js";
|
|
10
10
|
import { ensureVibePromptCacheDir } from "../commands/initStack.js";
|
|
11
11
|
import { createInterface } from "node:readline/promises";
|
|
12
12
|
async function confirmRestart() {
|
|
@@ -71,8 +71,23 @@ export async function runStart(configManager, options) {
|
|
|
71
71
|
}
|
|
72
72
|
const ui = configManager.getUiEnabled();
|
|
73
73
|
const docs = cfg.docsEnabled;
|
|
74
|
+
// cfg.uiTunnelEnabled already reflects a persisted `propr tunnel on|off`
|
|
75
|
+
// toggle (forwarded as an override in getHostConfig), falling back to the
|
|
76
|
+
// env-derived default when the toggle has never been set.
|
|
77
|
+
const tunnel = cfg.uiTunnelEnabled;
|
|
74
78
|
orch.ensureNetwork(cfg, (l) => console.log(l));
|
|
75
|
-
const status = orch.startStack(cfg, { ui, docs, onLog: (l) => console.log(l) });
|
|
79
|
+
const status = orch.startStack(cfg, { ui, docs, tunnel, onLog: (l) => console.log(l) });
|
|
80
|
+
// When the tunnel is on, surface the concrete routed endpoints (not the base
|
|
81
|
+
// URL as a health target) so the operator can see where the hosted UI reaches
|
|
82
|
+
// this stack.
|
|
83
|
+
if (tunnel) {
|
|
84
|
+
const summary = renderTunnelEndpointSummary(cfg.uiPublicApiUrl);
|
|
85
|
+
if (summary.length > 0) {
|
|
86
|
+
console.log("");
|
|
87
|
+
for (const line of summary)
|
|
88
|
+
console.log(line);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
76
91
|
const interactive = options.tui !== false && Boolean(process.stdout.isTTY);
|
|
77
92
|
if (!interactive) {
|
|
78
93
|
console.log("");
|