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.
@@ -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 start` honors
81
- * `propr docs on`. Note: uiEnabled is read directly from ConfigManager at
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.4",
3
- "git_sha": "b31f23cb",
2
+ "version": "0.8.5",
3
+ "git_sha": "45e9ed5a",
4
4
  "registry": "propr",
5
5
  "images": {
6
- "app": "propr/app:0.8.4",
7
- "ui": "propr/ui:0.8.4",
8
- "docs": "propr/docs:0.8.4",
9
- "agent-claude": "propr/agent-claude:0.8.4",
10
- "agent-codex": "propr/agent-codex:0.8.4",
11
- "agent-antigravity": "propr/agent-antigravity:0.8.4",
12
- "agent-opencode": "propr/agent-opencode:0.8.4",
13
- "agent-vibe": "propr/agent-vibe:0.8.4",
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
- // misc -e overrides the launcher computed from ports/env
201
- apiPublicUrl: get('API_PUBLIC_URL') || `http://localhost:${apiPort}`,
202
- frontendUrl: get('FRONTEND_URL') || `http://localhost:${uiPort}`,
203
- ghOauthCallbackUrl: get('GH_OAUTH_CALLBACK_URL') || `http://localhost:${apiPort}/api/auth/github/callback`,
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
- return { image: cfg.images.ui, args: ['-p', `${cfg.uiPort}:5173`] };
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
- const anyRunning = services.some((s) => s.running);
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, tag] of Object.entries(cfg.images)) {
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)) {
@@ -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("");