propr-cli 0.8.5 → 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.
package/README.md CHANGED
@@ -60,6 +60,42 @@ and exits nonzero when required local stack prerequisites are missing. Scripts
60
60
  or shell integrations that invoked bare `propr` to print help text should call
61
61
  `propr --help` instead.
62
62
 
63
+ ## Hosted UI Tunnel
64
+
65
+ ProPR Connect can provision a managed Cloudflare Tunnel so the hosted UI at
66
+ `https://app.propr.dev` can reach your local stack's API. Run the one-time setup
67
+ command shown in ProPR Connect from your initialized stack directory:
68
+
69
+ ```bash
70
+ npm install -g propr-cli@latest
71
+ propr tunnel setup --token <connector-token> --url https://t-<id>.propr.dev --start
72
+ ```
73
+
74
+ Connect-managed tunnels require `propr-cli` 0.8.6 or newer. The setup command
75
+ writes the tunnel token, instance id, hosted UI origin, public API URL, and
76
+ OAuth callback URL to the stack `.env`, records tunnel mode as enabled, and with
77
+ `--start` starts or recreates the stack so the API picks up the hosted URLs
78
+ immediately. If you have only the instance id, `--instance-id <id>` derives the
79
+ same public URL.
80
+
81
+ The public tunnel origin is always a bare `https://t-<id>.propr.dev` URL; ProPR
82
+ routes only `/api/*` and `/socket.io/*` through it, and the root URL
83
+ intentionally returns 404. The hosted UI itself stays on `https://app.propr.dev`
84
+ and calls the API through the per-instance tunnel host.
85
+
86
+ Useful follow-up commands:
87
+
88
+ ```bash
89
+ propr tunnel verify # check cloudflared + /api/status, /, /socket.io/
90
+ propr tunnel off # stop only the sidecar; token/env values stay in .env
91
+ propr tunnel on # restart the sidecar later
92
+ ```
93
+
94
+ `propr tunnel off` intentionally leaves the Connect-written `.env` values in
95
+ place. If you are switching the same stack back to a local or custom self-hosted
96
+ UI, remove or replace `PROPR_UI_PUBLIC_API_URL`, `API_PUBLIC_URL`,
97
+ `FRONTEND_URL`, and `GH_OAUTH_CALLBACK_URL` before restarting.
98
+
63
99
  ## Repository Setup
64
100
 
65
101
  Use `propr init` from a repository root to scaffold `.propr` setup files used by agent execution containers.
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { Command } from "commander";
19
19
  import { join } from "node:path";
20
- import { DEFAULT_PROPR_UI_ORIGIN, proprTunnelEndpoints, isProprProxyUrl, PROPR_UI_PROXY_SUFFIX, } from "../vendor/shared/index.js";
20
+ import { DEFAULT_PROPR_UI_ORIGIN, proprInstanceProxyUrl, proprTunnelEndpoints, isProprProxyUrl, PROPR_UI_PROXY_SUFFIX, PROPR_UI_PROXY_LABEL_PREFIX, } from "../vendor/shared/index.js";
21
21
  import { createConfigManager } from "../config/index.js";
22
22
  import { getHostConfig, resolveStackRoot } from "../orchestrator/index.js";
23
23
  import { parseOnOffState, ParseStateError } from "../utils/index.js";
@@ -34,7 +34,7 @@ export class TunnelTokenMissingError extends Error {
34
34
  /**
35
35
  * Thrown by applyTunnelToggle when `tunnel on` is requested but no public proxy
36
36
  * URL can be derived. Hosted UI tunnel mode fundamentally needs an advertised
37
- * endpoint (`PROPR_INSTANCE_ID` → https://<id>.proxy.propr.dev, or an explicit
37
+ * endpoint (`PROPR_INSTANCE_ID` → https://t-<id>.propr.dev, or an explicit
38
38
  * `PROPR_UI_PUBLIC_API_URL`); without one the sidecar would start and the desired
39
39
  * state persist while the hosted UI has no usable endpoint, surfacing only as
40
40
  * later status/verify failures. So we refuse up front instead.
@@ -42,7 +42,7 @@ export class TunnelTokenMissingError extends Error {
42
42
  export class TunnelPublicUrlMissingError extends Error {
43
43
  constructor() {
44
44
  super("cannot start the tunnel — no public proxy URL can be derived.\n" +
45
- " The hosted UI reaches this stack at https://<id>.proxy.propr.dev, so set\n" +
45
+ " The hosted UI reaches this stack at https://t-<id>.propr.dev, so set\n" +
46
46
  " PROPR_INSTANCE_ID (preferred) or an explicit PROPR_UI_PUBLIC_API_URL in\n" +
47
47
  " your stack .env, then run 'propr tunnel on' again.");
48
48
  this.name = "TunnelPublicUrlMissingError";
@@ -50,7 +50,7 @@ export class TunnelPublicUrlMissingError extends Error {
50
50
  }
51
51
  /**
52
52
  * Thrown by applyTunnelToggle when `tunnel on` is requested with a public proxy
53
- * URL that is not a hosted `https://<id>.proxy.propr.dev` URL. propr-routing only
53
+ * URL that is not a hosted `https://t-<id>.propr.dev` URL. propr-routing only
54
54
  * forwards `/api/*` and `/socket.io/*` on those hosts, so an explicit
55
55
  * `PROPR_UI_PUBLIC_API_URL` pointing anywhere else (e.g. https://custom.example.com)
56
56
  * would start a sidecar the hosted UI cannot route to. The launcher's
@@ -61,10 +61,10 @@ export class TunnelPublicUrlMissingError extends Error {
61
61
  export class TunnelPublicUrlInvalidError extends Error {
62
62
  constructor(url) {
63
63
  super(`cannot start the tunnel — PROPR_UI_PUBLIC_API_URL ("${url}") is not a\n` +
64
- ` hosted proxy URL (https://<id>.${PROPR_UI_PROXY_SUFFIX}). The tunnel only\n` +
64
+ ` hosted proxy URL (https://${PROPR_UI_PROXY_LABEL_PREFIX}<id>.${PROPR_UI_PROXY_SUFFIX}). The tunnel only\n` +
65
65
  ` routes /api/* and /socket.io/* on ${PROPR_UI_PROXY_SUFFIX} hosts, so the\n` +
66
66
  " hosted UI could not reach this stack. Set PROPR_INSTANCE_ID (preferred) or\n" +
67
- ` a https://<id>.${PROPR_UI_PROXY_SUFFIX} URL, then run 'propr tunnel on' again.`);
67
+ ` a https://${PROPR_UI_PROXY_LABEL_PREFIX}<id>.${PROPR_UI_PROXY_SUFFIX} URL, then run 'propr tunnel on' again.`);
68
68
  this.name = "TunnelPublicUrlInvalidError";
69
69
  }
70
70
  }
@@ -165,7 +165,7 @@ export async function applyTunnelToggle({ enable, cfg, orch, configManager, forc
165
165
  }
166
166
  // A derived public URL is always a well-formed proxy URL, but an explicit
167
167
  // PROPR_UI_PUBLIC_API_URL can be anything. propr-routing only forwards /api/*
168
- // and /socket.io/* on https://<id>.proxy.propr.dev hosts, so a non-proxy URL
168
+ // and /socket.io/* on https://t-<id>.propr.dev hosts, so a non-proxy URL
169
169
  // would start a sidecar the hosted UI cannot route to. validateEnv() rejects
170
170
  // this for `propr start`/`propr check`; mirror it here so `propr tunnel on`
171
171
  // doesn't persist/start an unroutable configuration those commands would refuse.
@@ -283,8 +283,12 @@ function instanceIdFromProxyUrl(url) {
283
283
  return undefined;
284
284
  }
285
285
  const suffix = `.${PROPR_UI_PROXY_SUFFIX}`;
286
- return parsed.protocol === "https:" && parsed.hostname.endsWith(suffix)
287
- ? parsed.hostname.slice(0, -suffix.length)
286
+ if (parsed.protocol !== "https:" || !parsed.hostname.endsWith(suffix)) {
287
+ return undefined;
288
+ }
289
+ const label = parsed.hostname.slice(0, -suffix.length);
290
+ return label.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)
291
+ ? label.slice(PROPR_UI_PROXY_LABEL_PREFIX.length)
288
292
  : undefined;
289
293
  }
290
294
  export function buildTunnelSetupEnv(input) {
@@ -294,11 +298,14 @@ export function buildTunnelSetupEnv(input) {
294
298
  const explicitUrl = input.url?.trim().replace(/\/+$/, "");
295
299
  const explicitInstanceId = input.instanceId?.trim();
296
300
  if (!explicitUrl && !explicitInstanceId) {
297
- throw new Error("provide --url https://<id>.proxy.propr.dev or --instance-id <id>");
301
+ throw new Error("provide --url https://t-<id>.propr.dev or --instance-id <id>");
302
+ }
303
+ const candidateUrl = explicitUrl ?? proprInstanceProxyUrl(explicitInstanceId);
304
+ if (!candidateUrl) {
305
+ throw new Error(`could not derive a hosted proxy URL from --instance-id (${explicitInstanceId})`);
298
306
  }
299
- const candidateUrl = explicitUrl ?? `https://${explicitInstanceId}.${PROPR_UI_PROXY_SUFFIX}`;
300
307
  if (!isProprProxyUrl(candidateUrl)) {
301
- throw new Error(`tunnel URL must be a bare hosted proxy URL such as https://<id>.${PROPR_UI_PROXY_SUFFIX} (no path/query/fragment)`);
308
+ throw new Error(`tunnel URL must be a bare hosted proxy URL such as https://${PROPR_UI_PROXY_LABEL_PREFIX}<id>.${PROPR_UI_PROXY_SUFFIX} (no path/query/fragment)`);
302
309
  }
303
310
  // Canonicalize: URL parsing already lowercases the host, and `.origin` drops
304
311
  // any (validated-absent) path so the persisted value matches what the launcher
@@ -306,11 +313,16 @@ export function buildTunnelSetupEnv(input) {
306
313
  // mixed-case --instance-id would otherwise diverge from the launcher's value.
307
314
  const publicUrl = new URL(candidateUrl).origin;
308
315
  const derivedInstanceId = instanceIdFromProxyUrl(publicUrl);
309
- const instanceId = (explicitInstanceId ?? derivedInstanceId)?.toLowerCase();
316
+ const normalizedExplicitInstanceId = explicitInstanceId?.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)
317
+ ? explicitInstanceId.slice(PROPR_UI_PROXY_LABEL_PREFIX.length)
318
+ : explicitInstanceId;
319
+ const instanceId = (normalizedExplicitInstanceId ?? derivedInstanceId)?.toLowerCase();
310
320
  if (!instanceId) {
311
321
  throw new Error(`could not derive an instance id from ${publicUrl}`);
312
322
  }
313
- if (derivedInstanceId && explicitInstanceId && derivedInstanceId.toLowerCase() !== explicitInstanceId.toLowerCase()) {
323
+ if (derivedInstanceId &&
324
+ normalizedExplicitInstanceId &&
325
+ derivedInstanceId.toLowerCase() !== normalizedExplicitInstanceId.toLowerCase()) {
314
326
  throw new Error(`--instance-id (${explicitInstanceId}) does not match --url host (${derivedInstanceId})`);
315
327
  }
316
328
  return {
@@ -505,20 +517,20 @@ export function createTunnelCommand() {
505
517
  .option("--root <dir>", "Stack root directory")
506
518
  .option("--force", "With 'on', start the tunnel even if the core stack is not running")
507
519
  .option("--token <token>", "Connector token from ProPR Connect (setup only)")
508
- .option("--url <url>", "Public proxy URL from ProPR Connect, e.g. https://<id>.proxy.propr.dev (setup only)")
509
- .option("--instance-id <id>", "Instance id from ProPR Connect; derives https://<id>.proxy.propr.dev (setup only)")
520
+ .option("--url <url>", "Public proxy URL from ProPR Connect, e.g. https://t-<id>.propr.dev (setup only)")
521
+ .option("--instance-id <id>", "Instance id from ProPR Connect; derives https://t-<id>.propr.dev (setup only)")
510
522
  .option("--start", "After setup, start or restart the stack with hosted tunnel settings")
511
523
  .addHelpText("after", `
512
524
  Setup writes the tunnel settings to your stack .env for you:
513
525
 
514
- $ propr tunnel setup --token <connector-token> --url https://<id>.proxy.propr.dev --start
526
+ $ propr tunnel setup --token <connector-token> --url https://t-<id>.propr.dev --start
515
527
 
516
528
  Starting the tunnel requires a token AND a public proxy URL:
517
529
  PROPR_UI_TUNNEL_TOKEN Cloudflare Tunnel token (required to start). This is a
518
530
  live Cloudflare credential — do not commit, log, or share it
519
531
  PROPR_UI_TUNNEL_ENABLED Explicitly enables the managed tunnel sidecar
520
532
  PROPR_INSTANCE_ID Instance id; derives the public URL
521
- https://<id>.proxy.propr.dev (required unless
533
+ https://t-<id>.propr.dev (required unless
522
534
  PROPR_UI_PUBLIC_API_URL is set)
523
535
  PROPR_UI_PUBLIC_API_URL Explicit public API URL advertised through the tunnel
524
536
  (overrides the id-derived URL)
@@ -1,16 +1,16 @@
1
1
  {
2
- "version": "0.8.5",
3
- "git_sha": "45e9ed5a",
2
+ "version": "0.8.6",
3
+ "git_sha": "cdf74ac2",
4
4
  "registry": "propr",
5
5
  "images": {
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",
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
14
  "redis": "redis:7-alpine",
15
15
  "cloudflared": "cloudflare/cloudflared:2024.12.2"
16
16
  }
@@ -26,11 +26,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
26
26
 
27
27
  // Hosted UI tunnel naming. These mirror the shared TypeScript constants in
28
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;
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;
32
33
  // test/orchestratorProprUrlsDrift.test.ts guards against the copies diverging.
33
- export const PROPR_UI_PROXY_SUFFIX = 'proxy.propr.dev';
34
+ export const PROPR_UI_PROXY_SUFFIX = 'propr.dev';
35
+ export const PROPR_UI_PROXY_LABEL_PREFIX = 't-';
34
36
  // Fallback used only when the manifest has no `cloudflared` entry. Pin it to the
35
37
  // same tag the manifest ships (docker/launcher/manifest.json) so the effective
36
38
  // default is identical whether it comes from the manifest or this fallback —
@@ -39,28 +41,29 @@ export const DEFAULT_CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2024.12.2';
39
41
  export const DEFAULT_PROPR_UI_ORIGIN = 'https://app.propr.dev';
40
42
 
41
43
  // 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
44
+ // (t-<id>.propr.dev): 1–63 chars, ASCII letters/digits/hyphens only, no
43
45
  // leading/trailing hyphen. Mirrors isValidProprInstanceId() in the shared pkg.
44
46
  export function isValidProprInstanceId(instanceId) {
45
47
  const id = (instanceId ?? '').trim();
46
48
  return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(id);
47
49
  }
48
50
 
49
- // Derive the per-instance public API/UI URL (https://<instanceId>.proxy.propr.dev)
51
+ // Derive the per-instance public API/UI URL (https://t-<instanceId>.propr.dev)
50
52
  // 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
+ // 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).
53
56
  // Mirrors proprInstanceProxyUrl() in packages/shared/src/proprServiceUrls.ts.
54
57
  export function proprInstanceProxyUrl(instanceId) {
55
- const id = (instanceId ?? '').trim();
56
- return isValidProprInstanceId(id) ? `https://${id.toLowerCase()}.${PROPR_UI_PROXY_SUFFIX}` : undefined;
58
+ const id = normalizeProprInstanceId(instanceId);
59
+ return isValidProprInstanceId(id) ? `https://${PROPR_UI_PROXY_LABEL_PREFIX}${id.toLowerCase()}.${PROPR_UI_PROXY_SUFFIX}` : undefined;
57
60
  }
58
61
 
59
- // Whether a URL is a hosted per-instance proxy URL (https://<id>.proxy.propr.dev).
62
+ // Whether a URL is a hosted per-instance proxy URL (https://t-<id>.propr.dev).
60
63
  // 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
+ // 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
64
67
  // proprTunnelEndpoints does not double up the /api prefix). Mirrors
65
68
  // isProprProxyUrl() in the shared pkg.
66
69
  export function isProprProxyUrl(url) {
@@ -73,12 +76,23 @@ export function isProprProxyUrl(url) {
73
76
  if (/[^/]/.test(pathname) || search || hash) return false;
74
77
  const suffix = `.${PROPR_UI_PROXY_SUFFIX}`;
75
78
  if (!hostname.endsWith(suffix)) return false;
76
- return isValidProprInstanceId(hostname.slice(0, -suffix.length));
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));
77
84
  } catch {
78
85
  return false;
79
86
  }
80
87
  }
81
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
+
82
96
  // The concrete endpoints the hosted UI reaches through the tunnel base URL.
83
97
  // propr-routing only allows /api/* and /socket.io/*, so the base (root) URL
84
98
  // itself intentionally returns 404 — it is NOT a health target; probe apiStatus
@@ -1458,7 +1472,7 @@ export function validateEnv(cfg) {
1458
1472
  if (cfg.uiTunnelEnabled && !cfg.uiPublicApiUrl) {
1459
1473
  errors.push(
1460
1474
  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.`
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.`
1462
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.'
1463
1477
  );
1464
1478
  }
@@ -1477,7 +1491,7 @@ export function validateEnv(cfg) {
1477
1491
  // tunnel is ENABLED the value is advertised to the API/worker, probed by
1478
1492
  // getTunnelStatus()/verify, and must point at a hosted proxy host because
1479
1493
  // propr-routing only forwards /api/* and /socket.io/* on
1480
- // https://<id>.proxy.propr.dev — so a non-proxy URL would start an unroutable
1494
+ // https://t-<id>.propr.dev — so a non-proxy URL would start an unroutable
1481
1495
  // tunnel stack (matching the routing rule and the `propr tunnel on` guard). With
1482
1496
  // the tunnel off, any valid http(s) origin the UI should call is legitimate, so
1483
1497
  // only the well-formedness check applies.
@@ -1485,9 +1499,9 @@ export function validateEnv(cfg) {
1485
1499
  let parsed;
1486
1500
  try { parsed = new URL(cfg.uiPublicApiUrl); } catch { /* invalid below */ }
1487
1501
  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.`);
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.`);
1489
1503
  } 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).`);
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).`);
1491
1505
  }
1492
1506
  }
1493
1507
 
@@ -1498,7 +1512,7 @@ export function validateEnv(cfg) {
1498
1512
  // hosted app.propr.dev CORS, cookies, and public links even though the
1499
1513
  // cloudflared sidecar itself starts successfully.
1500
1514
  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.`);
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.`);
1502
1516
  }
1503
1517
  if (cfg.uiTunnelEnabled && isLocalhostHttpUrl(cfg.frontendUrl)) {
1504
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}.`);
@@ -1510,7 +1524,7 @@ export function validateEnv(cfg) {
1510
1524
  // hosted UI cannot reach. Warn so the operator updates it (and the GitHub App
1511
1525
  // config) to the public proxy callback.
1512
1526
  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.`);
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.`);
1514
1528
  }
1515
1529
 
1516
1530
  const hasOpenCodeConfig = Boolean(cfg.hostOpencodeXdgDir);
@@ -10,7 +10,7 @@ export { validateRelayUrl } from './validateRelayUrl.js';
10
10
  // Export the hosted propr-routing service default URLs (one source of truth for
11
11
  // the webhook.propr.dev host shared by the CLI, the daemon dialer, and the
12
12
  // boot/check prerequisite validators)
13
- export { DEFAULT_PROPR_ROUTING_URL, DEFAULT_PROPR_GH_RELAY_URL, DEFAULT_PROPR_UI_ORIGIN, PROPR_UI_PROXY_SUFFIX, DEFAULT_CLOUDFLARED_IMAGE, proprInstanceProxyUrl, isValidProprInstanceId, isProprProxyUrl, proprTunnelEndpoints, } from './proprServiceUrls.js';
13
+ export { DEFAULT_PROPR_ROUTING_URL, DEFAULT_PROPR_GH_RELAY_URL, DEFAULT_PROPR_UI_ORIGIN, PROPR_UI_PROXY_SUFFIX, PROPR_UI_PROXY_LABEL_PREFIX, DEFAULT_CLOUDFLARED_IMAGE, proprInstanceProxyUrl, isValidProprInstanceId, isProprProxyUrl, proprTunnelEndpoints, } from './proprServiceUrls.js';
14
14
  // Export routing URL validation (shared by intake prerequisites and the daemon
15
15
  // routing service so the boot/CLI checks and the dialer agree on one policy)
16
16
  export { validateRoutingUrl } from './validateRoutingUrl.js';
@@ -5,7 +5,7 @@
5
5
  // updates packages/shared/package.json or docker/launcher/manifest.json but
6
6
  // forgets this constant is caught by the drift test in
7
7
  // test/orchestratorProprUrlsDrift.test.ts, which asserts all three agree.
8
- export const PROPR_VERSION = '0.8.5';
8
+ export const PROPR_VERSION = '0.8.6';
9
9
  // Bump this only when the API/UI contract changes in a way the hosted UI must
10
10
  // account for. Patch releases that do not change the browser-facing contract can
11
11
  // keep the same compatibility version.
@@ -27,15 +27,18 @@ export const DEFAULT_PROPR_GH_RELAY_URL = 'https://webhook.propr.dev/v1';
27
27
  /**
28
28
  * Origin of the hosted Propr UI (https://app.propr.dev). This is where the
29
29
  * managed control plane is served from; a local stack exposes its own UI on a
30
- * tunnel under {@link PROPR_UI_PROXY_SUFFIX} so the hosted UI can reach it.
30
+ * tunnel under a {@link PROPR_UI_PROXY_LABEL_PREFIX} host on
31
+ * {@link PROPR_UI_PROXY_SUFFIX} so the hosted UI can reach it.
31
32
  */
32
33
  export const DEFAULT_PROPR_UI_ORIGIN = 'https://app.propr.dev';
33
34
  /**
34
- * DNS suffix for per-instance UI/API tunnel hostnames. Each local stack with an
35
- * instance id is reachable at `https://<instanceId>.proxy.propr.dev`, so the
36
- * hosted UI at {@link DEFAULT_PROPR_UI_ORIGIN} can discover and address it.
35
+ * DNS suffix and label prefix for per-instance UI/API tunnel hostnames. Each
36
+ * local stack with an instance id is reachable at
37
+ * `https://t-<instanceId>.propr.dev`, so the hosted UI at
38
+ * {@link DEFAULT_PROPR_UI_ORIGIN} can discover and address it.
37
39
  */
38
- export const PROPR_UI_PROXY_SUFFIX = 'proxy.propr.dev';
40
+ export const PROPR_UI_PROXY_SUFFIX = 'propr.dev';
41
+ export const PROPR_UI_PROXY_LABEL_PREFIX = 't-';
39
42
  /**
40
43
  * Default Cloudflare Tunnel image used to expose the local stack's UI/API to
41
44
  * the hosted control plane when a UI tunnel is enabled. This is only a fallback:
@@ -46,7 +49,7 @@ export const PROPR_UI_PROXY_SUFFIX = 'proxy.propr.dev';
46
49
  export const DEFAULT_CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2024.12.2';
47
50
  /**
48
51
  * Whether an instance id is usable as a single DNS label in the per-instance
49
- * proxy hostname (`<id>.proxy.propr.dev`). Enforces the standard label rules:
52
+ * proxy hostname (`t-<id>.propr.dev`). Enforces the standard label rules:
50
53
  * 1–63 characters, ASCII letters/digits/hyphens only, and no leading or
51
54
  * trailing hyphen. This rejects spaces, slashes, dots, underscores, and other
52
55
  * characters that would produce an invalid or ambiguous hostname.
@@ -57,30 +60,31 @@ export function isValidProprInstanceId(instanceId) {
57
60
  }
58
61
  /**
59
62
  * Derive the public API/UI URL for a local stack from its instance id, using
60
- * the shared {@link PROPR_UI_PROXY_SUFFIX}. Returns `https://abc123.proxy.propr.dev`
61
- * for instance id `abc123`. Returns `undefined` for a missing/blank id or an
62
- * id that is not a valid DNS label (see {@link isValidProprInstanceId}) so
63
- * callers can fall back to an explicit URL or a local-development default
64
- * rather than emitting a malformed hostname. The id is lowercased so a
65
- * mixed-case instance id yields a canonical hostname (DNS is case-insensitive).
63
+ * the shared public tunnel host pattern. Returns `https://t-abc123.propr.dev`
64
+ * for instance id `abc123`. A caller may pass either the bare instance id or the
65
+ * public DNS label (`t-abc123`); the returned URL is canonicalized. Returns
66
+ * `undefined` for a missing/blank id — or an id that is not a valid DNS label
67
+ * (see {@link isValidProprInstanceId}) so callers can fall back to an explicit
68
+ * URL or a local-development default rather than emitting a malformed hostname.
69
+ * The id is lowercased so a mixed-case instance id yields a canonical hostname
70
+ * (DNS is case-insensitive).
66
71
  */
67
72
  export function proprInstanceProxyUrl(instanceId) {
68
- const id = (instanceId ?? '').trim();
73
+ const id = normalizeProprInstanceId(instanceId);
69
74
  if (!isValidProprInstanceId(id))
70
75
  return undefined;
71
- return `https://${id.toLowerCase()}.${PROPR_UI_PROXY_SUFFIX}`;
76
+ return `https://${PROPR_UI_PROXY_LABEL_PREFIX}${id.toLowerCase()}.${PROPR_UI_PROXY_SUFFIX}`;
72
77
  }
73
78
  /**
74
- * Whether a URL is a hosted per-instance proxy URL (`https://<id>.proxy.propr.dev`).
79
+ * Whether a URL is a hosted per-instance proxy URL (`https://t-<id>.propr.dev`).
75
80
  * propr-routing only forwards `/api/*` and `/socket.io/*` on these hosts, so the
76
81
  * tunnel base URL must be one of them. Requires https and *exactly one* valid
77
- * instance-id label in front of the shared {@link PROPR_UI_PROXY_SUFFIX} — a
78
- * multi-label host like `foo.bar.proxy.propr.dev` is rejected because routing
79
- * only addresses a single instance label. It must also be a bare origin: a
80
- * non-root path, query, or fragment (e.g. `https://abc.proxy.propr.dev/api`) is
81
- * rejected because {@link proprTunnelEndpoints} appends `/api/...` itself and a
82
- * base path would double it up (`.../api/api/status`). Returns false for a
83
- * malformed URL.
82
+ * `t-<instance-id>` label in front of the shared {@link PROPR_UI_PROXY_SUFFIX}.
83
+ * Other propr.dev hosts like `app.propr.dev` and nested hosts are rejected. It
84
+ * must also be a bare origin: a non-root path, query, or fragment (e.g.
85
+ * `https://t-abc.propr.dev/api`) is rejected because
86
+ * {@link proprTunnelEndpoints} appends `/api/...` itself and a base path would
87
+ * double it up (`.../api/api/status`). Returns false for a malformed URL.
84
88
  */
85
89
  export function isProprProxyUrl(url) {
86
90
  if (!url)
@@ -98,14 +102,22 @@ export function isProprProxyUrl(url) {
98
102
  const suffix = `.${PROPR_UI_PROXY_SUFFIX}`;
99
103
  if (!hostname.endsWith(suffix))
100
104
  return false;
101
- // The portion before the suffix must be a single valid DNS label (no dots),
102
- // so isValidProprInstanceId rejects nested hosts like `a.b.proxy.propr.dev`.
103
- return isValidProprInstanceId(hostname.slice(0, -suffix.length));
105
+ const label = hostname.slice(0, -suffix.length);
106
+ if (label.includes('.') || !label.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)) {
107
+ return false;
108
+ }
109
+ return isValidProprInstanceId(label.slice(PROPR_UI_PROXY_LABEL_PREFIX.length));
104
110
  }
105
111
  catch {
106
112
  return false;
107
113
  }
108
114
  }
115
+ function normalizeProprInstanceId(instanceId) {
116
+ const id = (instanceId ?? '').trim();
117
+ return id.startsWith(PROPR_UI_PROXY_LABEL_PREFIX)
118
+ ? id.slice(PROPR_UI_PROXY_LABEL_PREFIX.length)
119
+ : id;
120
+ }
109
121
  /**
110
122
  * The concrete endpoints the hosted UI reaches through the tunnel base URL.
111
123
  * propr-routing only allows `/api/*` and `/socket.io/*`, so the base (root) URL
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "propr-cli",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
4
4
  "description": "CLI for interacting with the ProPR backend",
5
5
  "type": "module",
6
6
  "bin": {