propr-cli 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +4 -4
  2. package/dist/api/relay.js +10 -0
  3. package/dist/assets/env.example.txt +182 -59
  4. package/dist/auth/githubLogin.js +66 -0
  5. package/dist/commands/agentCommands.js +74 -0
  6. package/dist/commands/agentValidation.js +548 -0
  7. package/dist/commands/checkCommands.js +981 -76
  8. package/dist/commands/imageCommands.js +60 -0
  9. package/dist/commands/index.js +3 -0
  10. package/dist/commands/initStack.js +50 -1
  11. package/dist/commands/relayCommands.js +45 -12
  12. package/dist/commands/setup/agents.js +185 -0
  13. package/dist/commands/setup/engine.js +956 -0
  14. package/dist/commands/setup/github.js +181 -0
  15. package/dist/commands/setup/sequential.js +501 -0
  16. package/dist/commands/setup/state.js +242 -0
  17. package/dist/commands/setup/types.js +85 -0
  18. package/dist/commands/setupCommand.js +85 -0
  19. package/dist/commands/stackCommands.js +14 -2
  20. package/dist/commands/systemCommands.js +49 -2
  21. package/dist/commands/tunnelCommand.js +562 -0
  22. package/dist/config/ConfigManager.js +22 -0
  23. package/dist/config/types.js +1 -0
  24. package/dist/index.js +14 -45
  25. package/dist/orchestrator/format.js +46 -0
  26. package/dist/orchestrator/index.js +7 -2
  27. package/dist/orchestrator/manifest.json +12 -11
  28. package/dist/orchestrator/orchestrator.mjs +872 -73
  29. package/dist/tui/AgentTableApp.js +86 -0
  30. package/dist/tui/CheckApp.js +202 -0
  31. package/dist/tui/SetupApp.js +586 -0
  32. package/dist/tui/SetupApp.test.js +172 -0
  33. package/dist/tui/app.js +84 -0
  34. package/dist/tui/render.js +28 -2
  35. package/dist/utils/envFile.js +45 -0
  36. package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
  37. package/dist/vendor/shared/index.js +17 -0
  38. package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
  39. package/dist/vendor/shared/modelDefinitions.js +4 -4
  40. package/dist/vendor/shared/proprCompatibility.js +70 -0
  41. package/dist/vendor/shared/proprServiceUrls.js +124 -0
  42. package/dist/vendor/shared/statusKeys.js +14 -0
  43. package/dist/vendor/shared/validateRoutingUrl.js +46 -0
  44. package/package.json +3 -3
@@ -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) {
@@ -42,6 +118,10 @@ function isDirectory(path) {
42
118
  }
43
119
  }
44
120
 
121
+ function defaultHostVibePromptCacheDir() {
122
+ return `/tmp/propr-vibe-prompts-${typeof process.getuid === 'function' ? process.getuid() : 'user'}`;
123
+ }
124
+
45
125
  // ---------------------------------------------------------------------------
46
126
  // .env file parsing (parameterized by the file path so it works for both the
47
127
  // launcher's local env file and the host's <root>/.env)
@@ -90,6 +170,14 @@ function unescapeDoubleQuotedEnv(value) {
90
170
  });
91
171
  }
92
172
 
173
+ // Wrap a value in single quotes for safe copy-paste into a POSIX shell, so a
174
+ // path containing spaces or shell metacharacters in a suggested recovery command
175
+ // stays a single literal argument. Embedded single quotes are closed, escaped,
176
+ // and reopened ('\'').
177
+ function shellQuote(value) {
178
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
179
+ }
180
+
93
181
  // Reads a single value from an env file. Re-reads the file per call (matches the
94
182
  // original launcher behavior; call sites are few and startup-only).
95
183
  function envFileValueFrom(envFileLocal, name) {
@@ -151,20 +239,21 @@ export function resolveConfig(env = process.env, overrides = {}) {
151
239
  const docsEnabled = overrides.docsEnabled ?? (get('DOCS_ENABLED') === 'true');
152
240
 
153
241
  // Agent credential host dirs (HOST:HOST mounts so spawned agent containers
154
- // resolve the same path end-to-end). HOST_OPENCODE_DIR is a back-compat alias.
242
+ // resolve the same path end-to-end).
155
243
  const hostClaudeDir = get('HOST_CLAUDE_DIR');
156
244
  const hostCodexDir = get('HOST_CODEX_DIR');
157
245
  const hostAntigravityDir = get('HOST_ANTIGRAVITY_DIR');
158
- const hostOpencodeLegacyDir = get('HOST_OPENCODE_LEGACY_DIR');
159
- const hostOpencodeXdgDir = env.HOST_OPENCODE_XDG_DIR !== undefined ? env.HOST_OPENCODE_XDG_DIR
160
- : env.HOST_OPENCODE_DIR !== undefined ? env.HOST_OPENCODE_DIR
161
- : envFileValueFrom(envFileLocal, 'HOST_OPENCODE_XDG_DIR')
162
- || envFileValueFrom(envFileLocal, 'HOST_OPENCODE_DIR') || undefined;
246
+ const hostOpencodeXdgDir = get('HOST_OPENCODE_XDG_DIR');
163
247
  const hostOpencodeDataDir = get('HOST_OPENCODE_DATA_DIR');
164
248
  const hostVibeDir = get('HOST_VIBE_DIR');
249
+ const mistralApiKey = get('MISTRAL_API_KEY');
165
250
 
166
251
  const vibePromptCacheDir = get('VIBE_PROMPT_CACHE_DIR') || '/tmp/propr-vibe-prompts';
167
- const hostVibePromptCacheDir = get('HOST_VIBE_PROMPT_CACHE_DIR');
252
+ // The host bind path defaults to a per-user private /tmp location when Vibe
253
+ // is enabled, so prompt files are not exposed through a shared 0777 cache.
254
+ // An explicit HOST_VIBE_PROMPT_CACHE_DIR is still honored and validated.
255
+ const vibeEnabled = Boolean(hostVibeDir || mistralApiKey);
256
+ const hostVibePromptCacheDir = get('HOST_VIBE_PROMPT_CACHE_DIR') || (vibeEnabled ? defaultHostVibePromptCacheDir() : undefined);
168
257
 
169
258
  // Host path to the GitHub App private key (.pem). When set, the key is
170
259
  // bind-mounted into the app containers (HOST:HOST, read-only) and
@@ -175,23 +264,52 @@ export function resolveConfig(env = process.env, overrides = {}) {
175
264
  const manifestPath = overrides.manifestPath ?? resolve(__dirname, 'manifest.json');
176
265
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
177
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
+
178
288
  return Object.freeze({
179
289
  stack, network, envFileLocal, envFileHost,
180
290
  validateHostPaths: overrides.validateHostPaths === true,
181
291
  hostData, hostLogs, hostRepos,
182
292
  apiPort, uiPort, docsPort, redisExternalPort, docsEnabled,
183
293
  hostClaudeDir, hostCodexDir, hostAntigravityDir,
184
- hostOpencodeLegacyDir, hostOpencodeXdgDir, hostOpencodeDataDir,
294
+ hostOpencodeXdgDir, hostOpencodeDataDir,
185
295
  hostVibeDir, vibePromptCacheDir, hostVibePromptCacheDir,
186
296
  hostGhPrivateKey,
187
- // misc -e overrides the launcher computed from ports/env
188
- apiPublicUrl: get('API_PUBLIC_URL') || `http://localhost:${apiPort}`,
189
- frontendUrl: get('FRONTEND_URL') || `http://localhost:${uiPort}`,
190
- 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`),
191
309
  githubBotUsername: get('GITHUB_BOT_USERNAME') || 'propr.dev[bot]',
192
310
  indexingScanInterval: get('INDEXING_SCAN_INTERVAL_MS') || '300000',
193
311
  indexingReindexInterval: get('INDEXING_REINDEX_INTERVAL_MS') || '86400000',
194
- mistralApiKey: get('MISTRAL_API_KEY'),
312
+ mistralApiKey,
195
313
  vibeConfigPath: get('VIBE_CONFIG_PATH'),
196
314
  manifest, images: manifest.images, manifestPath,
197
315
  });
@@ -223,7 +341,7 @@ export function resolveHostConfig({ rootDir = process.cwd(), env = process.env,
223
341
  // Mount host credentials at the same path on both sides (HOST:HOST) and set the
224
342
  // *_CONFIG_PATH env vars to that path, so the worker/api can re-mount them into
225
343
  // agent containers without any path translation.
226
- function agentCredentialArgs(cfg, { opencodeDataReadWrite = false } = {}) {
344
+ export function agentCredentialArgs(cfg, { opencodeDataReadWrite = false } = {}) {
227
345
  const args = [];
228
346
  if (cfg.hostClaudeDir) {
229
347
  args.push('-v', `${cfg.hostClaudeDir}:${cfg.hostClaudeDir}`);
@@ -237,15 +355,9 @@ function agentCredentialArgs(cfg, { opencodeDataReadWrite = false } = {}) {
237
355
  args.push('-v', `${cfg.hostAntigravityDir}:${cfg.hostAntigravityDir}`);
238
356
  args.push('-e', `ANTIGRAVITY_CONFIG_PATH=${cfg.hostAntigravityDir}`);
239
357
  }
240
- if (cfg.hostOpencodeLegacyDir) {
241
- args.push('-v', `${cfg.hostOpencodeLegacyDir}:${cfg.hostOpencodeLegacyDir}`);
242
- args.push('-e', `OPENCODE_LEGACY_CONFIG_PATH=${cfg.hostOpencodeLegacyDir}`);
243
- }
244
358
  if (cfg.hostOpencodeXdgDir) {
245
359
  args.push('-v', `${cfg.hostOpencodeXdgDir}:${cfg.hostOpencodeXdgDir}`);
246
360
  args.push('-e', `OPENCODE_CONFIG_PATH=${cfg.hostOpencodeXdgDir}`);
247
- } else if (cfg.hostOpencodeLegacyDir) {
248
- args.push('-e', `OPENCODE_CONFIG_PATH=${cfg.hostOpencodeLegacyDir}`);
249
361
  }
250
362
  if (cfg.hostOpencodeDataDir) {
251
363
  const dataMode = opencodeDataReadWrite ? 'rw' : 'ro';
@@ -281,6 +393,21 @@ function vibePromptCacheArgs(cfg) {
281
393
  ];
282
394
  }
283
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
+
284
411
  // Validates host bind-mount paths for Linux deployments. ':' rejection prevents
285
412
  // malformed -v HOST:CONTAINER args; Windows drive paths (C:\...) are unsupported.
286
413
  export function validateDockerBindPath(name, value, { containerPath = false } = {}) {
@@ -297,17 +424,54 @@ export function validateDockerBindPath(name, value, { containerPath = false } =
297
424
  // docker exec helpers
298
425
  // ---------------------------------------------------------------------------
299
426
 
300
- export function docker(args, { capture = false } = {}) {
427
+ const REMOTE_IMAGE_CHECK_TIMEOUT_MS = 5000;
428
+
429
+ export function docker(args, { capture = false, timeout } = {}) {
301
430
  const res = spawnSync('docker', args, {
302
431
  stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
303
432
  encoding: 'utf8',
433
+ timeout,
304
434
  });
305
435
  if (res.status !== 0 && !capture) {
306
- throw new Error(`docker ${args.join(' ')} failed with code ${res.status}`);
436
+ const detail = res.error?.message || (res.signal ? `signal ${res.signal}` : `code ${res.status}`);
437
+ throw new Error(`docker ${args.join(' ')} failed with ${detail}`);
307
438
  }
308
439
  return res;
309
440
  }
310
441
 
442
+ /**
443
+ * Async, captured docker exec. Mirrors `docker(..., { capture: true })`'s result
444
+ * shape ({ status, stdout, stderr, error }) but keeps the event loop free, so
445
+ * callers can run several probes concurrently and animate UI while they wait.
446
+ * On timeout it kills the child and reports an ETIMEDOUT error, matching the
447
+ * spawnSync timeout contract that `dockerError` inspects.
448
+ */
449
+ export function dockerAsync(args, { timeout } = {}) {
450
+ return new Promise((resolveResult) => {
451
+ const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
452
+ let stdout = '';
453
+ let stderr = '';
454
+ let settled = false;
455
+ let timeoutError = null;
456
+ const finish = (res) => {
457
+ if (settled) return;
458
+ settled = true;
459
+ if (timer) clearTimeout(timer);
460
+ resolveResult(res);
461
+ };
462
+ const timer = timeout
463
+ ? setTimeout(() => {
464
+ timeoutError = Object.assign(new Error('docker command timed out'), { code: 'ETIMEDOUT' });
465
+ child.kill('SIGKILL');
466
+ }, timeout)
467
+ : null;
468
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
469
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
470
+ child.on('error', (error) => finish({ status: null, stdout, stderr, error }));
471
+ child.on('close', (code, signal) => finish({ status: code, stdout, stderr, signal, error: timeoutError || undefined }));
472
+ });
473
+ }
474
+
311
475
  /** Returns true if the docker daemon is reachable. */
312
476
  export function dockerAvailable() {
313
477
  const res = spawnSync('docker', ['info'], { stdio: ['ignore', 'ignore', 'ignore'] });
@@ -334,7 +498,7 @@ function latestTagFor(imageTag) {
334
498
  return tagIndex > slashIndex ? `${imageTag.slice(0, tagIndex)}:latest` : null;
335
499
  }
336
500
 
337
- function tagAgentLatest(key, imageTag) {
501
+ export function tagAgentLatest(key, imageTag) {
338
502
  if (!key.startsWith('agent-')) return;
339
503
  const latestTag = latestTagFor(imageTag);
340
504
  if (!latestTag || latestTag === imageTag) return;
@@ -369,12 +533,236 @@ function imagePresentLocally(tag) {
369
533
  return res.stdout.trim().length > 0;
370
534
  }
371
535
 
372
- /** Pull a single non-agent service image if it is not already present locally. */
373
- export function ensureServiceImage(cfg, service, onLog) {
536
+ function firstLine(value) {
537
+ return (value || '').trim().split('\n')[0] || '';
538
+ }
539
+
540
+ export function normalizeDigest(value) {
541
+ const digest = firstLine(value);
542
+ if (!digest) return null;
543
+ const atIndex = digest.lastIndexOf('@');
544
+ return atIndex >= 0 ? digest.slice(atIndex + 1) : digest;
545
+ }
546
+
547
+ function localRepoDigests(tag) {
548
+ const res = docker(['image', 'inspect', '--format', '{{json .RepoDigests}}', tag], { capture: true });
549
+ if (res.status !== 0) return null;
550
+ try {
551
+ const parsed = JSON.parse(res.stdout.trim() || '[]');
552
+ return Array.isArray(parsed) ? parsed.map(normalizeDigest).filter(Boolean) : [];
553
+ } catch {
554
+ return [];
555
+ }
556
+ }
557
+
558
+ export function remoteDigestFromManifestInspectOutput(output) {
559
+ return remoteDigestsFromManifestInspectOutput(output)[0] ?? null;
560
+ }
561
+
562
+ export function remoteDigestsFromManifestInspectOutput(output) {
563
+ const parsed = JSON.parse(output);
564
+ const digests = new Set();
565
+ if (Array.isArray(parsed)) {
566
+ for (const entry of parsed) {
567
+ const refDigest = normalizeDigest(entry?.Ref);
568
+ if (refDigest) digests.add(refDigest);
569
+ const descriptor = entry?.Descriptor;
570
+ const descriptorDigest = normalizeDigest(descriptor?.digest || descriptor?.Digest || entry?.digest || entry?.Digest);
571
+ if (descriptorDigest) digests.add(descriptorDigest);
572
+ }
573
+ return [...digests];
574
+ }
575
+ const descriptor = parsed?.Descriptor;
576
+ const digest = normalizeDigest(descriptor?.digest || descriptor?.Digest || parsed?.digest || parsed?.Digest);
577
+ return digest ? [digest] : [];
578
+ }
579
+
580
+ export function remoteDigestFromImagetoolsInspectOutput(output) {
581
+ const match = output.match(/^\s*Digest:\s*([^\s]+)\s*$/im);
582
+ return match ? match[1] : null;
583
+ }
584
+
585
+ function appendDigest(digests, digest) {
586
+ const normalized = normalizeDigest(digest);
587
+ return normalized && !digests.includes(normalized) ? [...digests, normalized] : digests;
588
+ }
589
+
590
+ function dockerError(res, fallback) {
591
+ if (res.error?.code === 'ETIMEDOUT') {
592
+ return `remote image check timed out after ${REMOTE_IMAGE_CHECK_TIMEOUT_MS / 1000}s; set PROPR_SKIP_REMOTE_IMAGE_CHECK=1 to skip registry probes`;
593
+ }
594
+ return firstLine(res.stderr || res.stdout || fallback);
595
+ }
596
+
597
+ function remoteManifestDigest(tag) {
598
+ // Older Docker CLIs may require experimental manifest support. Treat those
599
+ // failures like any other registry issue so callers can warn or skip.
600
+ const res = docker(['manifest', 'inspect', '--verbose', tag], { capture: true, timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS });
601
+ if (res.status !== 0) {
602
+ return { ok: false, error: dockerError(res, 'docker manifest inspect failed') };
603
+ }
604
+
605
+ try {
606
+ const digests = remoteDigestsFromManifestInspectOutput(res.stdout);
607
+ if (digests.length > 0) {
608
+ let allDigests = digests;
609
+ if (res.stdout.trim().startsWith('[')) {
610
+ const buildx = docker(['buildx', 'imagetools', 'inspect', tag], { capture: true, timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS });
611
+ if (buildx.status === 0) allDigests = appendDigest(allDigests, remoteDigestFromImagetoolsInspectOutput(buildx.stdout));
612
+ }
613
+ return { ok: true, digests: allDigests, digest: allDigests[0] };
614
+ }
615
+
616
+ // Older Docker manifest output may omit digest fields. buildx can still
617
+ // expose the tag's index digest, which is useful when the local daemon
618
+ // recorded that digest in RepoDigests.
619
+ const buildx = docker(['buildx', 'imagetools', 'inspect', tag], { capture: true, timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS });
620
+ if (buildx.status !== 0) {
621
+ return { ok: false, error: dockerError(buildx, 'docker buildx imagetools inspect failed') };
622
+ }
623
+ const buildxDigest = remoteDigestFromImagetoolsInspectOutput(buildx.stdout);
624
+ if (buildxDigest) return { ok: true, digests: [buildxDigest], digest: buildxDigest };
625
+
626
+ return { ok: false, error: 'remote manifest digest was not available from docker manifest inspect or docker buildx imagetools inspect' };
627
+ } catch {
628
+ return { ok: false, error: 'could not parse docker manifest inspect output' };
629
+ }
630
+ }
631
+
632
+ function classifyImageFreshness(tag, localDigests, remote) {
633
+ if (!remote.ok) {
634
+ return { status: 'unknown', tag, localDigests, error: remote.error };
635
+ }
636
+
637
+ const remoteDigests = (remote.digests ?? [remote.digest]).map(normalizeDigest).filter(Boolean);
638
+ if (remoteDigests.length === 0) {
639
+ return { status: 'unknown', tag, localDigests, error: 'remote manifest digest was empty' };
640
+ }
641
+
642
+ const digestFields = remoteDigests.length > 1
643
+ ? { remoteDigest: remoteDigests[0], remoteDigests }
644
+ : { remoteDigest: remoteDigests[0] };
645
+ return localDigests.some((digest) => remoteDigests.includes(digest))
646
+ ? { status: 'current', tag, localDigests, ...digestFields }
647
+ : { status: 'stale', tag, localDigests, ...digestFields };
648
+ }
649
+
650
+ function skipRemoteImageCheck(env = process.env) {
651
+ return env.PROPR_SKIP_REMOTE_IMAGE_CHECK === 'true' || env.PROPR_SKIP_REMOTE_IMAGE_CHECK === '1';
652
+ }
653
+
654
+ function isProprPublishedImage(cfg, tag) {
655
+ const registry = typeof cfg.manifest?.registry === 'string' ? cfg.manifest.registry : 'propr';
656
+ return tag.startsWith(`${registry}/`);
657
+ }
658
+
659
+ /**
660
+ * Inspect whether a local image tag is current with the remote registry tag.
661
+ * Registry and metadata errors are reported as "unknown" so callers can warn
662
+ * without treating offline/air-gapped environments as hard failures.
663
+ */
664
+ export function inspectImageFreshness(tag, { skipRemoteCheck = false } = {}) {
665
+ if (!imagePresentLocally(tag)) {
666
+ return { status: 'missing', tag };
667
+ }
668
+
669
+ const localDigests = localRepoDigests(tag);
670
+ if (!localDigests) {
671
+ return { status: 'unknown', tag, error: 'local image metadata could not be inspected' };
672
+ }
673
+
674
+ if (skipRemoteCheck) {
675
+ return { status: 'unknown', tag, localDigests, skipped: true, error: 'remote image check skipped' };
676
+ }
677
+
678
+ if (localDigests.length === 0) {
679
+ return { status: 'unknown', tag, localDigests, localOnly: true, error: 'local image has no registry digest; pull the tag to verify freshness' };
680
+ }
681
+
682
+ return classifyImageFreshness(tag, localDigests, remoteManifestDigest(tag));
683
+ }
684
+
685
+ /** Async mirror of remoteManifestDigest using non-blocking docker exec. */
686
+ async function remoteManifestDigestAsync(tag) {
687
+ const res = await dockerAsync(['manifest', 'inspect', '--verbose', tag], { timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS });
688
+ if (res.status !== 0) {
689
+ return { ok: false, error: dockerError(res, 'docker manifest inspect failed') };
690
+ }
691
+ try {
692
+ const digests = remoteDigestsFromManifestInspectOutput(res.stdout);
693
+ if (digests.length > 0) {
694
+ let allDigests = digests;
695
+ if (res.stdout.trim().startsWith('[')) {
696
+ const buildx = await dockerAsync(['buildx', 'imagetools', 'inspect', tag], { timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS });
697
+ if (buildx.status === 0) allDigests = appendDigest(allDigests, remoteDigestFromImagetoolsInspectOutput(buildx.stdout));
698
+ }
699
+ return { ok: true, digests: allDigests, digest: allDigests[0] };
700
+ }
701
+
702
+ const buildx = await dockerAsync(['buildx', 'imagetools', 'inspect', tag], { timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS });
703
+ if (buildx.status !== 0) {
704
+ return { ok: false, error: dockerError(buildx, 'docker buildx imagetools inspect failed') };
705
+ }
706
+ const buildxDigest = remoteDigestFromImagetoolsInspectOutput(buildx.stdout);
707
+ if (buildxDigest) return { ok: true, digests: [buildxDigest], digest: buildxDigest };
708
+
709
+ return { ok: false, error: 'remote manifest digest was not available from docker manifest inspect or docker buildx imagetools inspect' };
710
+ } catch {
711
+ return { ok: false, error: 'could not parse docker manifest inspect output' };
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Async mirror of inspectImageFreshness. The local (fast) docker calls stay
717
+ * synchronous; only the remote registry probe is awaited, so many tags can be
718
+ * checked concurrently without blocking the event loop.
719
+ */
720
+ export async function inspectImageFreshnessAsync(tag, { skipRemoteCheck = false } = {}) {
721
+ if (!imagePresentLocally(tag)) {
722
+ return { status: 'missing', tag };
723
+ }
724
+
725
+ const localDigests = localRepoDigests(tag);
726
+ if (!localDigests) {
727
+ return { status: 'unknown', tag, error: 'local image metadata could not be inspected' };
728
+ }
729
+
730
+ if (skipRemoteCheck) {
731
+ return { status: 'unknown', tag, localDigests, skipped: true, error: 'remote image check skipped' };
732
+ }
733
+
734
+ if (localDigests.length === 0) {
735
+ return { status: 'unknown', tag, localDigests, localOnly: true, error: 'local image has no registry digest; pull the tag to verify freshness' };
736
+ }
737
+
738
+ return classifyImageFreshness(tag, localDigests, await remoteManifestDigestAsync(tag));
739
+ }
740
+
741
+ function cachedImageFreshness(cache, tag, opts) {
742
+ if (!cache) return inspectImageFreshness(tag, opts);
743
+ const key = `${opts.skipRemoteCheck ? 'skip' : 'remote'}\0${tag}`;
744
+ if (!cache.has(key)) cache.set(key, inspectImageFreshness(tag, opts));
745
+ return cache.get(key);
746
+ }
747
+
748
+ /** Pull a single non-agent service image if it is not already present locally or is stale. */
749
+ export function ensureServiceImage(cfg, service, onLog, { freshnessCache } = {}) {
374
750
  const tag = imageTagForService(cfg, service);
375
751
  if (!tag) return;
376
- if (imagePresentLocally(tag)) return;
377
- onLog?.(` · pulling ${tag}`);
752
+ const skipFreshness = skipRemoteImageCheck() || !isProprPublishedImage(cfg, tag);
753
+ const freshness = cachedImageFreshness(freshnessCache, tag, { skipRemoteCheck: skipFreshness });
754
+ if (freshness.status === 'current') return;
755
+ if (freshness.status === 'unknown') {
756
+ if (freshness.skipped) return;
757
+ if (freshness.localOnly) {
758
+ onLog?.(` · ${tag} (local-only, pulling)`);
759
+ } else {
760
+ onLog?.(` · ${tag} (local, freshness not verified: ${freshness.error})`);
761
+ return;
762
+ }
763
+ } else {
764
+ onLog?.(` · pulling ${tag}`);
765
+ }
378
766
  const res = docker(['pull', tag], { capture: true });
379
767
  if (res.status !== 0) {
380
768
  throw new Error(`Failed to pull ${tag}: ${(res.stderr || '').trim()}`);
@@ -386,13 +774,14 @@ export function ensureServiceImage(cfg, service, onLog) {
386
774
  // ---------------------------------------------------------------------------
387
775
 
388
776
  export const CORE_SERVICES = ['redis', 'daemon', 'worker', 'analysis-worker', 'indexing-worker', 'api'];
389
- export const TOGGLE_SERVICES = ['ui', 'docs'];
777
+ export const TOGGLE_SERVICES = ['ui', 'docs', 'tunnel'];
390
778
  export const SERVICES = [...CORE_SERVICES, ...TOGGLE_SERVICES];
391
779
 
392
780
  function imageTagForService(cfg, service) {
393
781
  if (service === 'redis') return cfg.images.redis;
394
782
  if (service === 'ui') return cfg.images.ui;
395
783
  if (service === 'docs') return cfg.images.docs;
784
+ if (service === 'tunnel') return cfg.cloudflaredImage;
396
785
  // daemon/worker/analysis-worker/indexing-worker/api all run the app image
397
786
  return cfg.images.app;
398
787
  }
@@ -418,7 +807,7 @@ function appSpec(cfg, command, extraArgs = []) {
418
807
  }
419
808
 
420
809
  // Returns { image, args, command? } for a canonical service name.
421
- function buildServiceSpec(cfg, service) {
810
+ export function buildServiceSpec(cfg, service) {
422
811
  switch (service) {
423
812
  case 'redis': {
424
813
  const args = ['-v', `${cfg.stack}-redis-data:/data`];
@@ -460,6 +849,17 @@ function buildServiceSpec(cfg, service) {
460
849
  ]);
461
850
  case 'api':
462
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',
463
863
  '-p', `${cfg.apiPort}:4000`,
464
864
  '-v', `${cfg.envFileHost}:/usr/src/app/.env:ro`,
465
865
  '-v', '/tmp/pr-worktrees:/tmp/pr-worktrees',
@@ -471,11 +871,53 @@ function buildServiceSpec(cfg, service) {
471
871
  '-e', `GH_OAUTH_CALLBACK_URL=${cfg.ghOauthCallbackUrl}`,
472
872
  '-e', `SESSION_REDIS_HOST=${cfg.stack}-redis`,
473
873
  '-e', 'CONFIG_REPO_PATH=/tmp/config_repo',
874
+ ...tunnelApiEnvArgs(cfg),
474
875
  ]);
475
- case 'ui':
476
- 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
+ }
477
887
  case 'docs':
478
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
+ };
479
921
  default:
480
922
  throw new Error(`unknown service: ${service}`);
481
923
  }
@@ -486,9 +928,9 @@ function buildServiceSpec(cfg, service) {
486
928
  * the service image if it is missing so toggles (`propr docs on`) work even when
487
929
  * the image was skipped at startup.
488
930
  */
489
- export function startService(cfg, service, { onLog, pull = true } = {}) {
931
+ export function startService(cfg, service, { onLog, pull = true, freshnessCache } = {}) {
490
932
  const name = `${cfg.stack}-${service}`;
491
- if (pull) ensureServiceImage(cfg, service, onLog);
933
+ if (pull) ensureServiceImage(cfg, service, onLog, { freshnessCache });
492
934
  const spec = buildServiceSpec(cfg, service);
493
935
  removeIfExists(cfg, name, onLog);
494
936
  const runArgs = [...spec.args, spec.image, ...(spec.command || [])];
@@ -529,12 +971,13 @@ export function isStackRunning(cfg) {
529
971
  * services started so far are stopped (best effort) before the error is
530
972
  * rethrown, so a failed startup doesn't leave a half-running stack behind.
531
973
  */
532
- export function startStack(cfg, { ui = true, docs = cfg.docsEnabled, onLog } = {}) {
533
- 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'] : [])];
534
976
  const started = [];
977
+ const freshnessCache = new Map();
535
978
  try {
536
979
  for (const service of toStart) {
537
- startService(cfg, service, { onLog });
980
+ startService(cfg, service, { onLog, freshnessCache });
538
981
  started.push(service);
539
982
  }
540
983
  } catch (err) {
@@ -551,24 +994,170 @@ export function startStack(cfg, { ui = true, docs = cfg.docsEnabled, onLog } = {
551
994
  return getStackStatus(cfg);
552
995
  }
553
996
 
997
+ // ---------------------------------------------------------------------------
998
+ // async start path
999
+ //
1000
+ // The synchronous startStack/startService/ensureNetwork above drive `propr
1001
+ // start`, where all the work finishes before any live UI is rendered. The
1002
+ // interactive `propr setup` wizard is different: an Ink TUI is on screen while
1003
+ // the stack comes up, so a blocking spawnSync would freeze the spinner and
1004
+ // swallow keystrokes for the many seconds a cold start can take. These async
1005
+ // mirrors do the identical work through dockerAsync(), keeping the event loop
1006
+ // free so the wizard keeps animating and streaming progress. Their logic is
1007
+ // intentionally kept in lockstep with the synchronous versions above — change
1008
+ // one, change the other.
1009
+ // ---------------------------------------------------------------------------
1010
+
1011
+ async function containerExistsAsync(cfg, name) {
1012
+ const res = await dockerAsync(['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}']);
1013
+ return res.stdout.trim() === name;
1014
+ }
1015
+
1016
+ async function removeIfExistsAsync(cfg, name, onLog) {
1017
+ if (await containerExistsAsync(cfg, name)) {
1018
+ onLog?.(` · removing stale ${name}`);
1019
+ await dockerAsync(['rm', '-f', name]);
1020
+ }
1021
+ }
1022
+
1023
+ async function dockerRunDetachedAsync(cfg, name, service, args) {
1024
+ const full = [
1025
+ 'run', '-d', '--init', '--name', name,
1026
+ '--network', cfg.network, '--restart', 'unless-stopped',
1027
+ '--label', `propr.stack=${cfg.stack}`,
1028
+ '--label', `propr.service=${service}`,
1029
+ ...args,
1030
+ ];
1031
+ const res = await dockerAsync(full);
1032
+ if (res.status !== 0) {
1033
+ throw new Error(`Failed to start ${name}: ${res.stderr}`);
1034
+ }
1035
+ }
1036
+
1037
+ /** Async mirror of ensureNetwork. */
1038
+ export async function ensureNetworkAsync(cfg, onLog) {
1039
+ const res = await dockerAsync(['network', 'inspect', cfg.network]);
1040
+ if (res.status !== 0) {
1041
+ onLog?.(`creating network ${cfg.network}`);
1042
+ await dockerAsync(['network', 'create', cfg.network]);
1043
+ }
1044
+ }
1045
+
1046
+ /** Async, memoized image-freshness lookup mirroring cachedImageFreshness. */
1047
+ async function cachedImageFreshnessAsync(cache, tag, opts) {
1048
+ if (!cache) return inspectImageFreshnessAsync(tag, opts);
1049
+ const key = `${opts.skipRemoteCheck ? 'skip' : 'remote'}\0${tag}`;
1050
+ if (!cache.has(key)) cache.set(key, await inspectImageFreshnessAsync(tag, opts));
1051
+ return cache.get(key);
1052
+ }
1053
+
1054
+ /** Async mirror of ensureServiceImage — pulls a missing/stale image, awaited. */
1055
+ async function ensureServiceImageAsync(cfg, service, onLog, { freshnessCache } = {}) {
1056
+ const tag = imageTagForService(cfg, service);
1057
+ if (!tag) return;
1058
+ const skipFreshness = skipRemoteImageCheck() || !isProprPublishedImage(cfg, tag);
1059
+ const freshness = await cachedImageFreshnessAsync(freshnessCache, tag, { skipRemoteCheck: skipFreshness });
1060
+ if (freshness.status === 'current') return;
1061
+ if (freshness.status === 'unknown') {
1062
+ if (freshness.skipped) return;
1063
+ if (freshness.localOnly) {
1064
+ onLog?.(` · ${tag} (local-only, pulling)`);
1065
+ } else {
1066
+ onLog?.(` · ${tag} (local, freshness not verified: ${freshness.error})`);
1067
+ return;
1068
+ }
1069
+ } else {
1070
+ onLog?.(` · pulling ${tag}`);
1071
+ }
1072
+ const res = await dockerAsync(['pull', tag]);
1073
+ if (res.status !== 0) {
1074
+ throw new Error(`Failed to pull ${tag}: ${(res.stderr || '').trim()}`);
1075
+ }
1076
+ }
1077
+
1078
+ /** Async mirror of startService. */
1079
+ export async function startServiceAsync(cfg, service, { onLog, pull = true, freshnessCache } = {}) {
1080
+ const name = `${cfg.stack}-${service}`;
1081
+ if (pull) await ensureServiceImageAsync(cfg, service, onLog, { freshnessCache });
1082
+ const spec = buildServiceSpec(cfg, service);
1083
+ await removeIfExistsAsync(cfg, name, onLog);
1084
+ const runArgs = [...spec.args, spec.image, ...(spec.command || [])];
1085
+ await dockerRunDetachedAsync(cfg, name, service, runArgs);
1086
+ onLog?.(` [ok] started ${name}`);
1087
+ return getServiceStateAsync(cfg, service);
1088
+ }
1089
+
1090
+ /** Async mirror of stopService (used by startStackAsync's rollback). */
1091
+ async function stopServiceAsync(cfg, service, { remove = true, onLog } = {}) {
1092
+ const name = `${cfg.stack}-${service}`;
1093
+ if (!(await containerExistsAsync(cfg, name))) return;
1094
+ const stopped = await dockerAsync(['stop', '-t', '10', name]);
1095
+ if (stopped.status !== 0) {
1096
+ throw new Error(`Failed to stop ${name}: ${(stopped.stderr || '').trim()}`);
1097
+ }
1098
+ if (remove) {
1099
+ const removed = await dockerAsync(['rm', name]);
1100
+ if (removed.status !== 0) {
1101
+ throw new Error(`Stopped ${name} but failed to remove it: ${(removed.stderr || '').trim()}`);
1102
+ }
1103
+ }
1104
+ onLog?.(` [ok] stopped ${name}`);
1105
+ }
1106
+
1107
+ /**
1108
+ * Async mirror of startStack — starts the full stack in dependency order
1109
+ * without blocking the event loop, rolling back already-started services on a
1110
+ * mid-startup failure (best effort) before rethrowing.
1111
+ */
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'] : [])];
1114
+ const started = [];
1115
+ const freshnessCache = new Map();
1116
+ try {
1117
+ for (const service of toStart) {
1118
+ await startServiceAsync(cfg, service, { onLog, freshnessCache });
1119
+ started.push(service);
1120
+ }
1121
+ } catch (err) {
1122
+ onLog?.(` ! startup failed (${err.message}) — rolling back already-started services`);
1123
+ for (const service of started.reverse()) {
1124
+ try {
1125
+ await stopServiceAsync(cfg, service, { onLog });
1126
+ } catch (stopErr) {
1127
+ onLog?.(` ! rollback: ${stopErr.message}`);
1128
+ }
1129
+ }
1130
+ throw err;
1131
+ }
1132
+ return getStackStatusAsync(cfg);
1133
+ }
1134
+
1135
+ /** Async mirror of getStackStatus. */
1136
+ export async function getStackStatusAsync(cfg) {
1137
+ const res = await dockerAsync(STACK_STATUS_PS_ARGS);
1138
+ return parseStackStatus(cfg, res.stdout);
1139
+ }
1140
+
1141
+ /** Async mirror of getServiceState. */
1142
+ async function getServiceStateAsync(cfg, service) {
1143
+ return (await getStackStatusAsync(cfg)).services.find((s) => s.service === service);
1144
+ }
1145
+
1146
+ /** Async mirror of isStackRunning. */
1147
+ export async function isStackRunningAsync(cfg) {
1148
+ const status = await getStackStatusAsync(cfg);
1149
+ return status.services.some((s) => CORE_SERVICES.includes(s.service) && s.running);
1150
+ }
1151
+
554
1152
  /**
555
- * Stop every container belonging to this stack (discovered by label + legacy
556
- * name pattern). Returns `{ failed }` listing containers that could not be
557
- * stopped/removed so callers can surface partial failures.
1153
+ * Stop every container belonging to this stack, discovered by the stack label.
1154
+ * Returns `{ failed }` listing containers that could not be stopped/removed so
1155
+ * callers can surface partial failures.
558
1156
  */
559
1157
  export function stopStack(cfg, { remove = true, removeNetwork = false, onLog } = {}) {
560
1158
  const res = docker(['ps', '-a', '--filter', `label=propr.stack=${cfg.stack}`, '--format', '{{.Names}}'], { capture: true });
561
1159
  const names = new Set(res.stdout.split('\n').map((s) => s.trim()).filter(Boolean));
562
1160
 
563
- // Also discover legacy containers that were created before labeling was added
564
- // (named <stack>-<service> but missing the propr.stack label).
565
- for (const service of SERVICES) {
566
- const legacyName = `${cfg.stack}-${service}`;
567
- if (!names.has(legacyName) && containerExists(cfg, legacyName)) {
568
- names.add(legacyName);
569
- }
570
- }
571
-
572
1161
  const failed = [];
573
1162
  for (const name of names) {
574
1163
  // docker() with capture never throws — check the exit status explicitly so
@@ -605,16 +1194,11 @@ export function stopStack(cfg, { remove = true, removeNetwork = false, onLog } =
605
1194
  // status
606
1195
  // ---------------------------------------------------------------------------
607
1196
 
608
- /** Per-service state for the whole stack, discovered by canonical/legacy container name. */
609
- export function getStackStatus(cfg) {
1197
+ /** Parse the `docker ps` table into per-service stack status (shared by sync/async). */
1198
+ export function parseStackStatus(cfg, stdout) {
610
1199
  const expectedNames = new Set(SERVICES.map((service) => `${cfg.stack}-${service}`));
611
- const res = docker([
612
- 'ps', '-a',
613
- '--format', '{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Ports}}',
614
- ], { capture: true });
615
-
616
1200
  const byName = new Map();
617
- for (const line of res.stdout.split('\n').filter(Boolean)) {
1201
+ for (const line of stdout.split('\n').filter(Boolean)) {
618
1202
  const [name, state, status, ports] = line.split('\t');
619
1203
  if (expectedNames.has(name)) byName.set(name, { state, status, ports: ports || '' });
620
1204
  }
@@ -633,14 +1217,112 @@ export function getStackStatus(cfg) {
633
1217
  };
634
1218
  });
635
1219
 
636
- 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);
637
1225
  return { stack: cfg.stack, network: cfg.network, running: anyRunning, services };
638
1226
  }
639
1227
 
1228
+ const STACK_STATUS_PS_ARGS = ['ps', '-a', '--format', '{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Ports}}'];
1229
+
1230
+ /** Per-service state for the whole stack, discovered by canonical container name. */
1231
+ export function getStackStatus(cfg) {
1232
+ const res = docker(STACK_STATUS_PS_ARGS, { capture: true });
1233
+ return parseStackStatus(cfg, res.stdout);
1234
+ }
1235
+
640
1236
  export function getServiceState(cfg, service) {
641
1237
  return getStackStatus(cfg).services.find((s) => s.service === service);
642
1238
  }
643
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
+
644
1326
  /** Spawn `docker logs` for a service. Returns the ChildProcess. */
645
1327
  export function getServiceLogs(cfg, service, { follow = false, tail = 'all', stdio = 'inherit' } = {}) {
646
1328
  const args = ['logs'];
@@ -689,6 +1371,9 @@ export function validateEnv(cfg) {
689
1371
  if (cfg.envFileLocal && !isReadableFile(cfg.envFileLocal)) {
690
1372
  errors.push(`cannot read the env file at ${cfg.envFileLocal}`);
691
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
+ }
692
1377
 
693
1378
  if (cfg.vibeConfigPath && !cfg.hostVibeDir) {
694
1379
  errors.push(
@@ -696,26 +1381,36 @@ export function validateEnv(cfg) {
696
1381
  );
697
1382
  }
698
1383
  const vibeEnabled = Boolean(cfg.hostVibeDir || cfg.mistralApiKey);
699
- if (vibeEnabled && !cfg.hostVibePromptCacheDir) {
700
- const vibeSource = cfg.hostVibeDir ? 'HOST_VIBE_DIR' : 'MISTRAL_API_KEY';
701
- errors.push(
702
- `Vibe support is enabled (via ${vibeSource}) but HOST_VIBE_PROMPT_CACHE_DIR is missing. ` +
703
- 'Set it to a host-visible directory path (e.g. /tmp/propr-vibe-prompts).'
704
- );
705
- }
706
1384
  if (vibeEnabled || cfg.hostVibePromptCacheDir) {
707
- const invalid = validateDockerBindPath('HOST_VIBE_PROMPT_CACHE_DIR', cfg.hostVibePromptCacheDir)
1385
+ // Only validate the host path when it is actually set — a missing value is
1386
+ // already reported above, so this avoids a misleading second "must be an
1387
+ // absolute path" error for the same root cause.
1388
+ const invalid = (cfg.hostVibePromptCacheDir
1389
+ ? validateDockerBindPath('HOST_VIBE_PROMPT_CACHE_DIR', cfg.hostVibePromptCacheDir)
1390
+ : null)
708
1391
  || validateDockerBindPath('VIBE_PROMPT_CACHE_DIR', cfg.vibePromptCacheDir, { containerPath: true });
709
1392
  if (invalid) {
710
1393
  errors.push(invalid);
711
1394
  } else if (cfg.hostVibePromptCacheDir && cfg.validateHostPaths) {
712
1395
  if (!existsSync(cfg.hostVibePromptCacheDir)) {
713
- errors.push(`HOST_VIBE_PROMPT_CACHE_DIR (${cfg.hostVibePromptCacheDir}) does not exist. Create it: mkdir -p ${cfg.hostVibePromptCacheDir}`);
1396
+ // A missing prompt cache is trivially recoverable `propr init
1397
+ // stack`, `propr start`, or Docker's bind-mount setup will create
1398
+ // it — so only fail when its parent location is not writable and
1399
+ // it therefore cannot be created.
1400
+ const parent = dirname(cfg.hostVibePromptCacheDir);
1401
+ let creatable = false;
1402
+ try { accessSync(parent, fsConstants.W_OK); creatable = true; } catch { /* parent not writable */ }
1403
+ if (!creatable) {
1404
+ errors.push(`HOST_VIBE_PROMPT_CACHE_DIR (${cfg.hostVibePromptCacheDir}) does not exist and ${parent} is not writable. Create it manually: mkdir -p ${shellQuote(cfg.hostVibePromptCacheDir)}`);
1405
+ }
714
1406
  } else {
715
1407
  try {
716
1408
  accessSync(cfg.hostVibePromptCacheDir, fsConstants.W_OK);
717
1409
  } catch {
718
- errors.push(`HOST_VIBE_PROMPT_CACHE_DIR (${cfg.hostVibePromptCacheDir}) is not writable.`);
1410
+ // Usually means a previous run let Docker auto-create the dir
1411
+ // as root on first bind-mount. Reclaim ownership or remove it
1412
+ // (it is a regenerable cache) so the user can write to it again.
1413
+ errors.push(`HOST_VIBE_PROMPT_CACHE_DIR (${cfg.hostVibePromptCacheDir}) is not writable. It is likely owned by root from a previous run; reclaim it with \`sudo chown -R $(id -u):$(id -g) ${shellQuote(cfg.hostVibePromptCacheDir)}\` or remove it (it is a regenerable cache) with \`sudo rm -rf ${shellQuote(cfg.hostVibePromptCacheDir)}\`.`);
719
1414
  }
720
1415
  }
721
1416
  }
@@ -725,7 +1420,6 @@ export function validateEnv(cfg) {
725
1420
  ['HOST_CLAUDE_DIR', cfg.hostClaudeDir],
726
1421
  ['HOST_CODEX_DIR', cfg.hostCodexDir],
727
1422
  ['HOST_ANTIGRAVITY_DIR', cfg.hostAntigravityDir],
728
- ['HOST_OPENCODE_LEGACY_DIR', cfg.hostOpencodeLegacyDir],
729
1423
  ['HOST_OPENCODE_XDG_DIR', cfg.hostOpencodeXdgDir],
730
1424
  ['HOST_OPENCODE_DATA_DIR', cfg.hostOpencodeDataDir],
731
1425
  ['HOST_VIBE_DIR', cfg.hostVibeDir],
@@ -744,7 +1438,82 @@ export function validateEnv(cfg) {
744
1438
  }
745
1439
  }
746
1440
 
747
- const hasOpenCodeConfig = Boolean(cfg.hostOpencodeXdgDir || cfg.hostOpencodeLegacyDir);
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
+
1516
+ const hasOpenCodeConfig = Boolean(cfg.hostOpencodeXdgDir);
748
1517
  if (hasOpenCodeConfig && !cfg.hostOpencodeDataDir) {
749
1518
  warnings.push(
750
1519
  'OpenCode config is mounted but HOST_OPENCODE_DATA_DIR is not set. ' +
@@ -762,11 +1531,18 @@ export function validateEnv(cfg) {
762
1531
  export function pullImages(cfg, { onLog = () => {}, env = process.env } = {}) {
763
1532
  const skipAgentPull = env.PROPR_SKIP_AGENT_PULL === 'true' || env.PROPR_SKIP_AGENT_PULL === '1';
764
1533
  const strictAgentPull = env.PROPR_STRICT_AGENT_PULL !== 'false' && env.PROPR_STRICT_AGENT_PULL !== '0';
1534
+ const skipFreshnessCheck = skipRemoteImageCheck(env);
1535
+ const freshnessCache = new Map();
765
1536
  onLog('pulling images…');
766
1537
  const failedAgentImages = [];
767
1538
 
768
- for (const [key, tag] of Object.entries(cfg.images)) {
1539
+ for (const [key, manifestTag] of Object.entries(cfg.images)) {
769
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;
770
1546
 
771
1547
  if (key.startsWith('agent-') && skipAgentPull) {
772
1548
  if (imagePresentLocally(tag)) {
@@ -778,13 +1554,36 @@ export function pullImages(cfg, { onLog = () => {}, env = process.env } = {}) {
778
1554
  continue;
779
1555
  }
780
1556
 
781
- if (imagePresentLocally(tag)) {
782
- onLog(` · ${tag} (local)`);
1557
+ const skipFreshnessForImage = skipFreshnessCheck || !isProprPublishedImage(cfg, tag);
1558
+ const freshness = cachedImageFreshness(freshnessCache, tag, { skipRemoteCheck: skipFreshnessForImage });
1559
+
1560
+ if (freshness.status === 'current') {
1561
+ onLog(` · ${tag} (local, current)`);
783
1562
  tagAgentLatest(key, tag);
784
1563
  continue;
785
1564
  }
786
1565
 
787
- onLog(` · ${tag}`);
1566
+ if (freshness.status === 'unknown') {
1567
+ if (freshness.localOnly) {
1568
+ onLog(` · ${tag} (local-only, pulling)`);
1569
+ // fall through and pull once; do not print the generic line too.
1570
+ } else if (freshness.skipped) {
1571
+ const reason = skipFreshnessCheck ? 'remote check skipped via PROPR_SKIP_REMOTE_IMAGE_CHECK' : 'third-party image';
1572
+ onLog(` · ${tag} (local, ${reason})`);
1573
+ tagAgentLatest(key, tag);
1574
+ continue;
1575
+ } else {
1576
+ onLog(` · ${tag} (local, freshness not verified: ${freshness.error})`);
1577
+ tagAgentLatest(key, tag);
1578
+ continue;
1579
+ }
1580
+ }
1581
+
1582
+ if (freshness.status === 'stale') {
1583
+ onLog(` · ${tag} (stale, pulling)`);
1584
+ } else if (!(freshness.status === 'unknown' && freshness.localOnly)) {
1585
+ onLog(` · ${tag}`);
1586
+ }
788
1587
  const pulled = docker(['pull', tag], { capture: key.startsWith('agent-') });
789
1588
  if (key.startsWith('agent-') && pulled.status !== 0) {
790
1589
  failedAgentImages.push(tag);