propr-cli 0.8.4 → 0.8.6

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.6",
3
+ "git_sha": "cdf74ac2",
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.6",
7
+ "ui": "propr/ui:0.8.6",
8
+ "docs": "propr/docs:0.8.6",
9
+ "agent-claude": "propr/agent-claude:0.8.6",
10
+ "agent-codex": "propr/agent-codex:0.8.6",
11
+ "agent-antigravity": "propr/agent-antigravity:0.8.6",
12
+ "agent-opencode": "propr/agent-opencode:0.8.6",
13
+ "agent-vibe": "propr/agent-vibe:0.8.6",
14
+ "redis": "redis:7-alpine",
15
+ "cloudflared": "cloudflare/cloudflared:2024.12.2"
15
16
  }
16
17
  }
@@ -24,6 +24,96 @@ 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
+ // PROPR_UI_PROXY_LABEL_PREFIX, DEFAULT_CLOUDFLARED_IMAGE,
30
+ // DEFAULT_PROPR_UI_ORIGIN) — kept as plain literals here because this module is
31
+ // dependency-free .mjs (Node stdlib only) and cannot import the TS package.
32
+ // Change one, change the other;
33
+ // test/orchestratorProprUrlsDrift.test.ts guards against the copies diverging.
34
+ export const PROPR_UI_PROXY_SUFFIX = 'propr.dev';
35
+ export const PROPR_UI_PROXY_LABEL_PREFIX = 't-';
36
+ // Fallback used only when the manifest has no `cloudflared` entry. Pin it to the
37
+ // same tag the manifest ships (docker/launcher/manifest.json) so the effective
38
+ // default is identical whether it comes from the manifest or this fallback —
39
+ // operator docs can then describe a single, pinned default.
40
+ export const DEFAULT_CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2024.12.2';
41
+ export const DEFAULT_PROPR_UI_ORIGIN = 'https://app.propr.dev';
42
+
43
+ // Whether an instance id is a valid single DNS label for the proxy hostname
44
+ // (t-<id>.propr.dev): 1–63 chars, ASCII letters/digits/hyphens only, no
45
+ // leading/trailing hyphen. Mirrors isValidProprInstanceId() in the shared pkg.
46
+ export function isValidProprInstanceId(instanceId) {
47
+ const id = (instanceId ?? '').trim();
48
+ return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(id);
49
+ }
50
+
51
+ // Derive the per-instance public API/UI URL (https://t-<instanceId>.propr.dev)
52
+ // from an instance id; returns undefined for a missing/blank or invalid id (so a
53
+ // malformed hostname is never emitted). Accepts either the bare id or the public
54
+ // t-<id> DNS label. The id is lowercased so a mixed-case PROPR_INSTANCE_ID
55
+ // yields a canonical hostname (DNS is case-insensitive).
56
+ // Mirrors proprInstanceProxyUrl() in packages/shared/src/proprServiceUrls.ts.
57
+ export function proprInstanceProxyUrl(instanceId) {
58
+ const id = normalizeProprInstanceId(instanceId);
59
+ return isValidProprInstanceId(id) ? `https://${PROPR_UI_PROXY_LABEL_PREFIX}${id.toLowerCase()}.${PROPR_UI_PROXY_SUFFIX}` : undefined;
60
+ }
61
+
62
+ // Whether a URL is a hosted per-instance proxy URL (https://t-<id>.propr.dev).
63
+ // propr-routing only forwards /api/* and /socket.io/* on these hosts, so the
64
+ // tunnel base URL must be one of them. Requires exactly one t-<instance-id>
65
+ // label before the suffix (other propr.dev hosts and nested hosts are rejected)
66
+ // and a bare origin (a non-root path/query/fragment is rejected so
67
+ // proprTunnelEndpoints does not double up the /api prefix). Mirrors
68
+ // isProprProxyUrl() in the shared pkg.
69
+ export function isProprProxyUrl(url) {
70
+ if (!url) return false;
71
+ try {
72
+ const { protocol, hostname, pathname, search, hash } = new URL(url);
73
+ if (protocol !== 'https:') return false;
74
+ // Trailing slashes are tolerated; any real path segment/query/fragment
75
+ // is rejected so a base path can't double up the appended /api prefix.
76
+ if (/[^/]/.test(pathname) || search || hash) return false;
77
+ const suffix = `.${PROPR_UI_PROXY_SUFFIX}`;
78
+ if (!hostname.endsWith(suffix)) return false;
79
+ const label = hostname.slice(0, -suffix.length);
80
+ if (label.includes('.') || !label.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)) {
81
+ return false;
82
+ }
83
+ return isValidProprInstanceId(label.slice(PROPR_UI_PROXY_LABEL_PREFIX.length));
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ function normalizeProprInstanceId(instanceId) {
90
+ const id = (instanceId ?? '').trim();
91
+ return id.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)
92
+ ? id.slice(PROPR_UI_PROXY_LABEL_PREFIX.length)
93
+ : id;
94
+ }
95
+
96
+ // The concrete endpoints the hosted UI reaches through the tunnel base URL.
97
+ // propr-routing only allows /api/* and /socket.io/*, so the base (root) URL
98
+ // itself intentionally returns 404 — it is NOT a health target; probe apiStatus
99
+ // for liveness. Mirrors proprTunnelEndpoints() in packages/shared/src/proprServiceUrls.ts.
100
+ export function proprTunnelEndpoints(baseUrl) {
101
+ const base = baseUrl.replace(/\/+$/, '');
102
+ return {
103
+ apiStatus: `${base}/api/status`,
104
+ socketIo: `${base}/socket.io/`,
105
+ root: `${base}/`,
106
+ };
107
+ }
108
+
109
+ // Broad truthy parse for env flags, mirroring parseTruthyEnvValue() in
110
+ // packages/shared/src/demoMode.ts so `1`/`TRUE`/whitespace are accepted like
111
+ // elsewhere in the repo (kept local because this module imports no TS package).
112
+ function parseTruthyEnvValue(value) {
113
+ const normalized = value?.trim().toLowerCase();
114
+ return normalized === 'true' || normalized === '1';
115
+ }
116
+
27
117
  // True only for an existing regular file (guards against a path that exists but
28
118
  // is a directory, which would make readFileSync throw EISDIR).
29
119
  function isReadableFile(path) {
@@ -188,6 +278,27 @@ export function resolveConfig(env = process.env, overrides = {}) {
188
278
  const manifestPath = overrides.manifestPath ?? resolve(__dirname, 'manifest.json');
189
279
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
190
280
 
281
+ // Hosted UI tunnel: expose this local stack's UI/API to the hosted control
282
+ // plane (https://app.propr.dev) via a Cloudflare Tunnel. A token alone is
283
+ // enough to enable it; PROPR_UI_TUNNEL_ENABLED=true also turns it on.
284
+ const uiTunnelToken = get('PROPR_UI_TUNNEL_TOKEN') || undefined;
285
+ // A persisted CLI toggle (`propr tunnel on|off`) wins over the env-derived
286
+ // default so `propr start` honors the user's last explicit choice.
287
+ const uiTunnelEnabled = overrides.uiTunnelEnabled ?? (Boolean(uiTunnelToken) || parseTruthyEnvValue(get('PROPR_UI_TUNNEL_ENABLED')));
288
+ const proprInstanceId = get('PROPR_INSTANCE_ID') || undefined;
289
+ // Cloudflared image for the optional tunnel sidecar: an explicit env override
290
+ // wins, then the manifest's pinned tag, with DEFAULT_CLOUDFLARED_IMAGE as a
291
+ // final fallback for manifests without a cloudflared entry.
292
+ const cloudflaredImage = get('PROPR_CLOUDFLARED_IMAGE') || manifest.images.cloudflared || DEFAULT_CLOUDFLARED_IMAGE;
293
+ // Explicit URL wins; otherwise derive from the instance id's proxy hostname.
294
+ // Falls back to undefined for local development (no instance id), where
295
+ // API_PUBLIC_URL / FRONTEND_URL keep their localhost defaults below. Trailing
296
+ // slashes are stripped once here so every consumer (API/worker/UI env, status
297
+ // output, endpoint rendering) sees one canonical form — the derived URL never
298
+ // has one, but an explicit PROPR_UI_PUBLIC_API_URL might.
299
+ const uiPublicApiUrl =
300
+ (get('PROPR_UI_PUBLIC_API_URL') || proprInstanceProxyUrl(proprInstanceId))?.replace(/\/+$/, '') || undefined;
301
+
191
302
  return Object.freeze({
192
303
  stack, network, envFileLocal, envFileHost,
193
304
  validateHostPaths: overrides.validateHostPaths === true,
@@ -197,10 +308,18 @@ export function resolveConfig(env = process.env, overrides = {}) {
197
308
  hostOpencodeXdgDir, hostOpencodeDataDir,
198
309
  hostVibeDir, vibePromptCacheDir, hostVibePromptCacheDir,
199
310
  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`,
311
+ // Hosted UI tunnel settings (see resolution above). Defaults keep local
312
+ // development unaffected: no instance id ⇒ no derived public URL.
313
+ uiTunnelEnabled, uiTunnelToken, proprInstanceId, uiPublicApiUrl, cloudflaredImage,
314
+ // misc -e overrides the launcher computed from ports/env. When the UI
315
+ // tunnel is enabled the API/worker must advertise the public proxy URL
316
+ // (OAuth/session redirects, attachment links, browser-visible API refs)
317
+ // and the frontend must point at the hosted UI origin. An explicit
318
+ // API_PUBLIC_URL / FRONTEND_URL still wins; otherwise tunnel mode derives
319
+ // them, falling back to the localhost defaults for local development.
320
+ apiPublicUrl: get('API_PUBLIC_URL') || (uiTunnelEnabled && uiPublicApiUrl ? uiPublicApiUrl : `http://localhost:${apiPort}`),
321
+ frontendUrl: get('FRONTEND_URL') || (uiTunnelEnabled ? DEFAULT_PROPR_UI_ORIGIN : undefined) || `http://localhost:${uiPort}`,
322
+ ghOauthCallbackUrl: get('GH_OAUTH_CALLBACK_URL') || (uiTunnelEnabled && uiPublicApiUrl ? `${uiPublicApiUrl}/api/auth/github/callback` : `http://localhost:${apiPort}/api/auth/github/callback`),
204
323
  githubBotUsername: get('GITHUB_BOT_USERNAME') || 'propr.dev[bot]',
205
324
  indexingScanInterval: get('INDEXING_SCAN_INTERVAL_MS') || '300000',
206
325
  indexingReindexInterval: get('INDEXING_REINDEX_INTERVAL_MS') || '86400000',
@@ -288,6 +407,21 @@ function vibePromptCacheArgs(cfg) {
288
407
  ];
289
408
  }
290
409
 
410
+ // Tunnel-related env propagated into the API container for status/debugging and
411
+ // future Connect support. PROPR_UI_TUNNEL_TOKEN is deliberately NOT among these
412
+ // — only the cloudflared sidecar receives the token. The instance id and public
413
+ // API URL are injected only when set, so local-development containers (no tunnel)
414
+ // stay free of empty PROPR_* vars while still always reporting the enabled flag.
415
+ function tunnelApiEnvArgs(cfg) {
416
+ const args = ['-e', `PROPR_UI_TUNNEL_ENABLED=${cfg.uiTunnelEnabled ? 'true' : 'false'}`];
417
+ // Inject the instance id lowercased so it matches the derived public URL
418
+ // (proprInstanceProxyUrl lowercases the host), keeping the id and the
419
+ // PROPR_UI_PUBLIC_API_URL host consistent for any consumer that compares them.
420
+ if (isValidProprInstanceId(cfg.proprInstanceId)) args.push('-e', `PROPR_INSTANCE_ID=${cfg.proprInstanceId.trim().toLowerCase()}`);
421
+ if (cfg.uiPublicApiUrl) args.push('-e', `PROPR_UI_PUBLIC_API_URL=${cfg.uiPublicApiUrl}`);
422
+ return args;
423
+ }
424
+
291
425
  // Validates host bind-mount paths for Linux deployments. ':' rejection prevents
292
426
  // malformed -v HOST:CONTAINER args; Windows drive paths (C:\...) are unsupported.
293
427
  export function validateDockerBindPath(name, value, { containerPath = false } = {}) {
@@ -654,13 +788,14 @@ export function ensureServiceImage(cfg, service, onLog, { freshnessCache } = {})
654
788
  // ---------------------------------------------------------------------------
655
789
 
656
790
  export const CORE_SERVICES = ['redis', 'daemon', 'worker', 'analysis-worker', 'indexing-worker', 'api'];
657
- export const TOGGLE_SERVICES = ['ui', 'docs'];
791
+ export const TOGGLE_SERVICES = ['ui', 'docs', 'tunnel'];
658
792
  export const SERVICES = [...CORE_SERVICES, ...TOGGLE_SERVICES];
659
793
 
660
794
  function imageTagForService(cfg, service) {
661
795
  if (service === 'redis') return cfg.images.redis;
662
796
  if (service === 'ui') return cfg.images.ui;
663
797
  if (service === 'docs') return cfg.images.docs;
798
+ if (service === 'tunnel') return cfg.cloudflaredImage;
664
799
  // daemon/worker/analysis-worker/indexing-worker/api all run the app image
665
800
  return cfg.images.app;
666
801
  }
@@ -686,7 +821,7 @@ function appSpec(cfg, command, extraArgs = []) {
686
821
  }
687
822
 
688
823
  // Returns { image, args, command? } for a canonical service name.
689
- function buildServiceSpec(cfg, service) {
824
+ export function buildServiceSpec(cfg, service) {
690
825
  switch (service) {
691
826
  case 'redis': {
692
827
  const args = ['-v', `${cfg.stack}-redis-data:/data`];
@@ -728,6 +863,17 @@ function buildServiceSpec(cfg, service) {
728
863
  ]);
729
864
  case 'api':
730
865
  return appSpec(cfg, ['dist/packages/api/server.js'], [
866
+ // Stable in-network DNS alias so the cloudflared sidecar (and the
867
+ // Cloudflare Tunnel ingress config) can target a fixed
868
+ // `http://api:4000` regardless of the stack prefix. Without it the
869
+ // container is only reachable as `${stack}-api` (e.g. propr-api),
870
+ // which would force a per-stack tunnel ingress config and break the
871
+ // documented `http://api:4000` target. The alias is added
872
+ // unconditionally (not only in tunnel mode): it is harmless for
873
+ // tunnel-disabled local dev — each stack has its own network, so the
874
+ // alias is scoped to that network and never collides — and keeping it
875
+ // always-on avoids restarting the API just to enable the tunnel later.
876
+ '--network-alias', 'api',
731
877
  '-p', `${cfg.apiPort}:4000`,
732
878
  '-v', `${cfg.envFileHost}:/usr/src/app/.env:ro`,
733
879
  '-v', '/tmp/pr-worktrees:/tmp/pr-worktrees',
@@ -739,11 +885,53 @@ function buildServiceSpec(cfg, service) {
739
885
  '-e', `GH_OAUTH_CALLBACK_URL=${cfg.ghOauthCallbackUrl}`,
740
886
  '-e', `SESSION_REDIS_HOST=${cfg.stack}-redis`,
741
887
  '-e', 'CONFIG_REPO_PATH=/tmp/config_repo',
888
+ ...tunnelApiEnvArgs(cfg),
742
889
  ]);
743
- case 'ui':
744
- return { image: cfg.images.ui, args: ['-p', `${cfg.uiPort}:5173`] };
890
+ case 'ui': {
891
+ // The UI image's docker-entrypoint.sh rewrites public/config.js from
892
+ // PROPR_UI_PUBLIC_API_URL so one prebuilt bundle can point at any
893
+ // per-instance proxy. Pass the tunnel base URL through unchanged — the
894
+ // UI appends /api/... to it for REST and uses /socket.io/ for Socket.IO,
895
+ // so the value must be the bare proxy origin (no /api suffix). Only set
896
+ // it when known; an unset value keeps the same-origin local default.
897
+ const uiArgs = ['-p', `${cfg.uiPort}:5173`];
898
+ if (cfg.uiPublicApiUrl) uiArgs.push('-e', `PROPR_UI_PUBLIC_API_URL=${cfg.uiPublicApiUrl}`);
899
+ return { image: cfg.images.ui, args: uiArgs };
900
+ }
745
901
  case 'docs':
746
902
  return { image: cfg.images.docs, args: ['-p', `${cfg.docsPort}:3000`] };
903
+ case 'tunnel':
904
+ // The tunnel sidecar cannot authenticate without a token. Callers via
905
+ // the CLI validate this up front (validateEnv / `propr tunnel on`), but
906
+ // make the invariant local so a direct buildServiceSpec/startService
907
+ // call fails clearly instead of emitting a malformed `docker run`.
908
+ if (!cfg.uiTunnelToken) {
909
+ throw new Error('cannot build the tunnel service spec: PROPR_UI_TUNNEL_TOKEN is not set (the cloudflared sidecar needs a token to authenticate).');
910
+ }
911
+ // Optional Cloudflare Tunnel sidecar running the official cloudflared
912
+ // image (its entrypoint is `cloudflared`). It dials out to Cloudflare's
913
+ // edge, so no local ports are published.
914
+ //
915
+ // The spec's `tunnel --no-autoupdate run --token $PROPR_UI_TUNNEL_TOKEN`
916
+ // contract is satisfied via the env var rather than the literal flag:
917
+ // in cloudflared the `run` command's `--token` flag is bound to the
918
+ // TUNNEL_TOKEN env var (urfave/cli `EnvVars: ["TUNNEL_TOKEN"]`), so
919
+ // `tunnel run` reads TUNNEL_TOKEN natively and treats it exactly as if
920
+ // `--token <value>` had been passed. This binding is present in the
921
+ // pinned image (cloudflare/cloudflared:2024.12.2, see manifest.json) and
922
+ // has been stable across cloudflared releases, so the sidecar starts
923
+ // authenticated without the literal token ever appearing on argv.
924
+ // We prefer the env var precisely to keep the token off the process argv
925
+ // (otherwise visible to anyone via host `ps`/`docker top`, and to
926
+ // unprivileged in-container tooling). It is still present in the
927
+ // container's env, so a `docker inspect` by someone with Docker-daemon
928
+ // access can read it — Docker access is already privileged. The token
929
+ // is injected only here — no other container receives it.
930
+ return {
931
+ image: cfg.cloudflaredImage,
932
+ args: ['-e', `TUNNEL_TOKEN=${cfg.uiTunnelToken}`],
933
+ command: ['tunnel', '--no-autoupdate', 'run'],
934
+ };
747
935
  default:
748
936
  throw new Error(`unknown service: ${service}`);
749
937
  }
@@ -797,8 +985,8 @@ export function isStackRunning(cfg) {
797
985
  * services started so far are stopped (best effort) before the error is
798
986
  * rethrown, so a failed startup doesn't leave a half-running stack behind.
799
987
  */
800
- export function startStack(cfg, { ui = true, docs = cfg.docsEnabled, onLog } = {}) {
801
- const toStart = [...CORE_SERVICES, ...(ui ? ['ui'] : []), ...(docs ? ['docs'] : [])];
988
+ export function startStack(cfg, { ui = true, docs = cfg.docsEnabled, tunnel = cfg.uiTunnelEnabled, onLog } = {}) {
989
+ const toStart = [...CORE_SERVICES, ...(ui ? ['ui'] : []), ...(docs ? ['docs'] : []), ...(tunnel ? ['tunnel'] : [])];
802
990
  const started = [];
803
991
  const freshnessCache = new Map();
804
992
  try {
@@ -935,8 +1123,8 @@ async function stopServiceAsync(cfg, service, { remove = true, onLog } = {}) {
935
1123
  * without blocking the event loop, rolling back already-started services on a
936
1124
  * mid-startup failure (best effort) before rethrowing.
937
1125
  */
938
- export async function startStackAsync(cfg, { ui = true, docs = cfg.docsEnabled, onLog } = {}) {
939
- const toStart = [...CORE_SERVICES, ...(ui ? ['ui'] : []), ...(docs ? ['docs'] : [])];
1126
+ export async function startStackAsync(cfg, { ui = true, docs = cfg.docsEnabled, tunnel = cfg.uiTunnelEnabled, onLog } = {}) {
1127
+ const toStart = [...CORE_SERVICES, ...(ui ? ['ui'] : []), ...(docs ? ['docs'] : []), ...(tunnel ? ['tunnel'] : [])];
940
1128
  const started = [];
941
1129
  const freshnessCache = new Map();
942
1130
  try {
@@ -1021,7 +1209,7 @@ export function stopStack(cfg, { remove = true, removeNetwork = false, onLog } =
1021
1209
  // ---------------------------------------------------------------------------
1022
1210
 
1023
1211
  /** Parse the `docker ps` table into per-service stack status (shared by sync/async). */
1024
- function parseStackStatus(cfg, stdout) {
1212
+ export function parseStackStatus(cfg, stdout) {
1025
1213
  const expectedNames = new Set(SERVICES.map((service) => `${cfg.stack}-${service}`));
1026
1214
  const byName = new Map();
1027
1215
  for (const line of stdout.split('\n').filter(Boolean)) {
@@ -1043,7 +1231,11 @@ function parseStackStatus(cfg, stdout) {
1043
1231
  };
1044
1232
  });
1045
1233
 
1046
- const anyRunning = services.some((s) => s.running);
1234
+ // The stack is "running" only when a core service is up. A lone optional
1235
+ // sidecar (e.g. an orphaned propr-tunnel left over after the core stack
1236
+ // stopped) must not mask the unusable state — otherwise `propr status`
1237
+ // would skip "Stack is not running" while the API is actually down.
1238
+ const anyRunning = services.some((s) => CORE_SERVICES.includes(s.service) && s.running);
1047
1239
  return { stack: cfg.stack, network: cfg.network, running: anyRunning, services };
1048
1240
  }
1049
1241
 
@@ -1059,6 +1251,92 @@ export function getServiceState(cfg, service) {
1059
1251
  return getStackStatus(cfg).services.find((s) => s.service === service);
1060
1252
  }
1061
1253
 
1254
+ // Best-effort GET <publicApiUrl>/api/status behind a hard timeout. propr-routing
1255
+ // only forwards /api/* and /socket.io/* on the proxy host, so the old root
1256
+ // /health path is no longer reachable through the tunnel — /api/status is the
1257
+ // public liveness endpoint. Resolves true when the API answers: a 2xx (status
1258
+ // payload) or an auth-expected 401/403 both prove the proxy reaches the API.
1259
+ // Resolves false on any other status / network error / timeout. Never throws:
1260
+ // tunnel reachability is a diagnostic, not a gate, so a slow or down proxy must
1261
+ // not fail `propr status`.
1262
+ // True for a well-formed http(s) URL. Used to skip probing/advertising a
1263
+ // malformed PROPR_UI_PUBLIC_API_URL (validateEnv flags it, but a programmatic
1264
+ // caller may have skipped validation).
1265
+ function isValidHttpUrl(value) {
1266
+ try {
1267
+ const { protocol } = new URL(value);
1268
+ return protocol === 'http:' || protocol === 'https:';
1269
+ } catch {
1270
+ return false;
1271
+ }
1272
+ }
1273
+
1274
+ function isLocalhostHttpUrl(value) {
1275
+ try {
1276
+ const { protocol, hostname } = new URL(value);
1277
+ return (
1278
+ (protocol === 'http:' || protocol === 'https:')
1279
+ && (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]')
1280
+ );
1281
+ } catch {
1282
+ return false;
1283
+ }
1284
+ }
1285
+
1286
+ async function probeTunnelReachable(publicApiUrl, timeoutMs = 3000) {
1287
+ const { apiStatus } = proprTunnelEndpoints(publicApiUrl);
1288
+ const controller = new AbortController();
1289
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1290
+ try {
1291
+ // `redirect: 'manual'` so a redirect is treated as the proxy's own
1292
+ // response rather than transparently followed off-host — matching the
1293
+ // probe in `propr tunnel verify` (tunnelCommand.ts) so the two agree.
1294
+ const res = await fetch(apiStatus, { signal: controller.signal, redirect: 'manual' });
1295
+ // 2xx means the API answered; 401/403 means it answered but wants auth —
1296
+ // either way the tunnel forwarded the request to the API behind it.
1297
+ return res.ok || res.status === 401 || res.status === 403;
1298
+ } catch {
1299
+ return false;
1300
+ } finally {
1301
+ clearTimeout(timer);
1302
+ }
1303
+ }
1304
+
1305
+ /**
1306
+ * Tunnel diagnostics for `propr status`. The Cloudflare tunnel is a local
1307
+ * managed service, so its health belongs in local status:
1308
+ * - enabled: tunnel turned on by resolved config (token present or the
1309
+ * explicit PROPR_UI_TUNNEL_ENABLED flag)
1310
+ * - configured: a tunnel token is present
1311
+ * - running: the cloudflared sidecar container is running
1312
+ * - publicApiUrl: the expected public proxy URL (null when not derivable)
1313
+ * - reachable: best-effort <publicApiUrl>/api/status probe — true/false when
1314
+ * a URL is known, null when there is nothing to probe
1315
+ *
1316
+ * Pass a precomputed stack status to reuse a single `docker ps`.
1317
+ */
1318
+ export async function getTunnelStatus(cfg, stackStatus) {
1319
+ const status = stackStatus ?? await getStackStatusAsync(cfg);
1320
+ const tunnel = status.services.find((s) => s.service === 'tunnel');
1321
+ const tunnelRunning = Boolean(tunnel && tunnel.running);
1322
+ const publicApiUrl = cfg.uiPublicApiUrl ?? null;
1323
+ // Only spend up to ~3s on the external probe when the tunnel is enabled, the
1324
+ // cloudflared sidecar is actually running, and the public URL is a well-formed
1325
+ // http(s) URL. Probing a configured-but-stopped tunnel can only ever fail (the
1326
+ // sidecar that routes the request is down), so skipping it avoids adding the
1327
+ // timeout to every `propr status` in the common "enabled but stopped" case.
1328
+ const reachable = (cfg.uiTunnelEnabled && tunnelRunning && publicApiUrl && isValidHttpUrl(publicApiUrl))
1329
+ ? await probeTunnelReachable(publicApiUrl)
1330
+ : null;
1331
+ return {
1332
+ enabled: Boolean(cfg.uiTunnelEnabled),
1333
+ configured: Boolean(cfg.uiTunnelToken),
1334
+ running: tunnelRunning,
1335
+ publicApiUrl,
1336
+ reachable,
1337
+ };
1338
+ }
1339
+
1062
1340
  /** Spawn `docker logs` for a service. Returns the ChildProcess. */
1063
1341
  export function getServiceLogs(cfg, service, { follow = false, tail = 'all', stdio = 'inherit' } = {}) {
1064
1342
  const args = ['logs'];
@@ -1107,6 +1385,9 @@ export function validateEnv(cfg) {
1107
1385
  if (cfg.envFileLocal && !isReadableFile(cfg.envFileLocal)) {
1108
1386
  errors.push(`cannot read the env file at ${cfg.envFileLocal}`);
1109
1387
  }
1388
+ if (cfg.proprInstanceId && !isValidProprInstanceId(cfg.proprInstanceId)) {
1389
+ 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.`);
1390
+ }
1110
1391
 
1111
1392
  if (cfg.vibeConfigPath && !cfg.hostVibeDir) {
1112
1393
  errors.push(
@@ -1171,6 +1452,81 @@ export function validateEnv(cfg) {
1171
1452
  }
1172
1453
  }
1173
1454
 
1455
+ // The tunnel sidecar cannot authenticate without a token. uiTunnelEnabled is
1456
+ // true whenever a token is present, so this only trips when the tunnel was
1457
+ // turned on without a token — either via PROPR_UI_TUNNEL_ENABLED=true or a
1458
+ // persisted `propr tunnel on` override.
1459
+ if (cfg.uiTunnelEnabled && !cfg.uiTunnelToken) {
1460
+ 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).');
1461
+ }
1462
+
1463
+ // Tunnel enabled but no public URL is known (cfg.uiPublicApiUrl is the
1464
+ // explicit PROPR_UI_PUBLIC_API_URL or the id-derived one, so this only trips
1465
+ // when neither yields a value — a missing or non-DNS-label instance id with no
1466
+ // explicit override). The stack would then be inconsistent: frontendUrl=
1467
+ // https://app.propr.dev but apiPublicUrl falls back to localhost, so cloudflared
1468
+ // starts while the hosted UI has no endpoint to reach. `propr start` enables the
1469
+ // tunnel from PROPR_UI_TUNNEL_TOKEN alone, bypassing the stricter `propr tunnel
1470
+ // on` guard (TunnelPublicUrlMissingError), so this is a hard error here — fail
1471
+ // startup rather than bring up a broken tunnel-mode stack.
1472
+ if (cfg.uiTunnelEnabled && !cfg.uiPublicApiUrl) {
1473
+ errors.push(
1474
+ cfg.proprInstanceId
1475
+ ? `PROPR_INSTANCE_ID ("${cfg.proprInstanceId}") is not a valid DNS label, so no https://t-<id>.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.`
1476
+ : '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.'
1477
+ );
1478
+ }
1479
+
1480
+ // Validate an explicit PROPR_UI_PUBLIC_API_URL. A derived public URL is always
1481
+ // well-formed, so a bad value here can only come from an explicit override.
1482
+ //
1483
+ // A malformed value is ALWAYS a hard error, regardless of tunnel state: the
1484
+ // launcher injects PROPR_UI_PUBLIC_API_URL into the UI container whenever it is
1485
+ // set (buildServiceSpec('ui')), and docker-entrypoint.sh writes it into
1486
+ // config.js as the browser's API base URL. So even with the tunnel disabled a
1487
+ // bad value is NOT inert — it breaks the UI's API calls in local/self-hosted
1488
+ // mode. Validate it consistently wherever it will be injected.
1489
+ //
1490
+ // The hosted-proxy-host requirement is narrower and stays tunnel-only: when the
1491
+ // tunnel is ENABLED the value is advertised to the API/worker, probed by
1492
+ // getTunnelStatus()/verify, and must point at a hosted proxy host because
1493
+ // propr-routing only forwards /api/* and /socket.io/* on
1494
+ // https://t-<id>.propr.dev — so a non-proxy URL would start an unroutable
1495
+ // tunnel stack (matching the routing rule and the `propr tunnel on` guard). With
1496
+ // the tunnel off, any valid http(s) origin the UI should call is legitimate, so
1497
+ // only the well-formedness check applies.
1498
+ if (cfg.uiPublicApiUrl) {
1499
+ let parsed;
1500
+ try { parsed = new URL(cfg.uiPublicApiUrl); } catch { /* invalid below */ }
1501
+ if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
1502
+ 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://t-abc123.propr.dev.`);
1503
+ } else if (cfg.uiTunnelEnabled && !isProprProxyUrl(cfg.uiPublicApiUrl)) {
1504
+ errors.push(`PROPR_UI_PUBLIC_API_URL ("${cfg.uiPublicApiUrl}") is not a hosted proxy URL (https://${PROPR_UI_PROXY_LABEL_PREFIX}<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://${PROPR_UI_PROXY_LABEL_PREFIX}<id>.${PROPR_UI_PROXY_SUFFIX} origin (no path/query/fragment — the /api and /socket.io paths are appended automatically).`);
1505
+ }
1506
+ }
1507
+
1508
+ // Existing local stacks commonly have explicit localhost API/UI URLs in
1509
+ // their .env. Explicit values win during resolution, so without this guard
1510
+ // enabling a tunnel can still launch a stack that advertises localhost to the
1511
+ // API/worker or permits only localhost as the frontend origin. That breaks
1512
+ // hosted app.propr.dev CORS, cookies, and public links even though the
1513
+ // cloudflared sidecar itself starts successfully.
1514
+ if (cfg.uiTunnelEnabled && isLocalhostHttpUrl(cfg.apiPublicUrl)) {
1515
+ 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://${PROPR_UI_PROXY_LABEL_PREFIX}<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.`);
1516
+ }
1517
+ if (cfg.uiTunnelEnabled && isLocalhostHttpUrl(cfg.frontendUrl)) {
1518
+ 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}.`);
1519
+ }
1520
+
1521
+ // In tunnel mode GH_OAUTH_CALLBACK_URL is derived from the public proxy URL
1522
+ // when unset. An explicit localhost callback still wins, but it is a common
1523
+ // broken-OAuth setup: GitHub redirects the browser to a localhost URL the
1524
+ // hosted UI cannot reach. Warn so the operator updates it (and the GitHub App
1525
+ // config) to the public proxy callback.
1526
+ if (cfg.uiTunnelEnabled && /^https?:\/\/(localhost|127\.0\.0\.1)\b/i.test(cfg.ghOauthCallbackUrl)) {
1527
+ 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://${PROPR_UI_PROXY_LABEL_PREFIX}<id>.${PROPR_UI_PROXY_SUFFIX}/api/auth/github/callback) and register it in the GitHub App.`);
1528
+ }
1529
+
1174
1530
  const hasOpenCodeConfig = Boolean(cfg.hostOpencodeXdgDir);
1175
1531
  if (hasOpenCodeConfig && !cfg.hostOpencodeDataDir) {
1176
1532
  warnings.push(
@@ -1194,8 +1550,13 @@ export function pullImages(cfg, { onLog = () => {}, env = process.env } = {}) {
1194
1550
  onLog('pulling images…');
1195
1551
  const failedAgentImages = [];
1196
1552
 
1197
- for (const [key, tag] of Object.entries(cfg.images)) {
1553
+ for (const [key, manifestTag] of Object.entries(cfg.images)) {
1198
1554
  if (key === 'docs' && !cfg.docsEnabled) continue;
1555
+ if (key === 'cloudflared' && !cfg.uiTunnelEnabled) continue;
1556
+ // The tunnel sidecar actually runs cfg.cloudflaredImage, which honors a
1557
+ // PROPR_CLOUDFLARED_IMAGE override; pre-pull that image rather than the
1558
+ // bare manifest tag so an override isn't pulled twice (here + on demand).
1559
+ const tag = key === 'cloudflared' ? cfg.cloudflaredImage : manifestTag;
1199
1560
 
1200
1561
  if (key.startsWith('agent-') && skipAgentPull) {
1201
1562
  if (imagePresentLocally(tag)) {