propr-cli 0.8.3 → 0.8.4

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 (38) hide show
  1. package/README.md +4 -4
  2. package/dist/api/relay.js +10 -0
  3. package/dist/assets/env.example.txt +93 -57
  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 +2 -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/systemCommands.js +49 -2
  20. package/dist/index.js +13 -45
  21. package/dist/orchestrator/manifest.json +10 -10
  22. package/dist/orchestrator/orchestrator.mjs +513 -61
  23. package/dist/tui/AgentTableApp.js +86 -0
  24. package/dist/tui/CheckApp.js +202 -0
  25. package/dist/tui/SetupApp.js +586 -0
  26. package/dist/tui/SetupApp.test.js +172 -0
  27. package/dist/tui/app.js +84 -0
  28. package/dist/tui/render.js +11 -0
  29. package/dist/utils/envFile.js +45 -0
  30. package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
  31. package/dist/vendor/shared/index.js +16 -0
  32. package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
  33. package/dist/vendor/shared/modelDefinitions.js +4 -4
  34. package/dist/vendor/shared/proprServiceUrls.js +27 -0
  35. package/dist/vendor/shared/statusKeys.js +14 -0
  36. package/dist/vendor/shared/validateRoutingUrl.js +46 -0
  37. package/package.json +2 -2
  38. package/dist/assets/.env.example +0 -183
@@ -42,6 +42,10 @@ function isDirectory(path) {
42
42
  }
43
43
  }
44
44
 
45
+ function defaultHostVibePromptCacheDir() {
46
+ return `/tmp/propr-vibe-prompts-${typeof process.getuid === 'function' ? process.getuid() : 'user'}`;
47
+ }
48
+
45
49
  // ---------------------------------------------------------------------------
46
50
  // .env file parsing (parameterized by the file path so it works for both the
47
51
  // launcher's local env file and the host's <root>/.env)
@@ -90,6 +94,14 @@ function unescapeDoubleQuotedEnv(value) {
90
94
  });
91
95
  }
92
96
 
97
+ // Wrap a value in single quotes for safe copy-paste into a POSIX shell, so a
98
+ // path containing spaces or shell metacharacters in a suggested recovery command
99
+ // stays a single literal argument. Embedded single quotes are closed, escaped,
100
+ // and reopened ('\'').
101
+ function shellQuote(value) {
102
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
103
+ }
104
+
93
105
  // Reads a single value from an env file. Re-reads the file per call (matches the
94
106
  // original launcher behavior; call sites are few and startup-only).
95
107
  function envFileValueFrom(envFileLocal, name) {
@@ -151,20 +163,21 @@ export function resolveConfig(env = process.env, overrides = {}) {
151
163
  const docsEnabled = overrides.docsEnabled ?? (get('DOCS_ENABLED') === 'true');
152
164
 
153
165
  // 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.
166
+ // resolve the same path end-to-end).
155
167
  const hostClaudeDir = get('HOST_CLAUDE_DIR');
156
168
  const hostCodexDir = get('HOST_CODEX_DIR');
157
169
  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;
170
+ const hostOpencodeXdgDir = get('HOST_OPENCODE_XDG_DIR');
163
171
  const hostOpencodeDataDir = get('HOST_OPENCODE_DATA_DIR');
164
172
  const hostVibeDir = get('HOST_VIBE_DIR');
173
+ const mistralApiKey = get('MISTRAL_API_KEY');
165
174
 
166
175
  const vibePromptCacheDir = get('VIBE_PROMPT_CACHE_DIR') || '/tmp/propr-vibe-prompts';
167
- const hostVibePromptCacheDir = get('HOST_VIBE_PROMPT_CACHE_DIR');
176
+ // The host bind path defaults to a per-user private /tmp location when Vibe
177
+ // is enabled, so prompt files are not exposed through a shared 0777 cache.
178
+ // An explicit HOST_VIBE_PROMPT_CACHE_DIR is still honored and validated.
179
+ const vibeEnabled = Boolean(hostVibeDir || mistralApiKey);
180
+ const hostVibePromptCacheDir = get('HOST_VIBE_PROMPT_CACHE_DIR') || (vibeEnabled ? defaultHostVibePromptCacheDir() : undefined);
168
181
 
169
182
  // Host path to the GitHub App private key (.pem). When set, the key is
170
183
  // bind-mounted into the app containers (HOST:HOST, read-only) and
@@ -181,7 +194,7 @@ export function resolveConfig(env = process.env, overrides = {}) {
181
194
  hostData, hostLogs, hostRepos,
182
195
  apiPort, uiPort, docsPort, redisExternalPort, docsEnabled,
183
196
  hostClaudeDir, hostCodexDir, hostAntigravityDir,
184
- hostOpencodeLegacyDir, hostOpencodeXdgDir, hostOpencodeDataDir,
197
+ hostOpencodeXdgDir, hostOpencodeDataDir,
185
198
  hostVibeDir, vibePromptCacheDir, hostVibePromptCacheDir,
186
199
  hostGhPrivateKey,
187
200
  // misc -e overrides the launcher computed from ports/env
@@ -191,7 +204,7 @@ export function resolveConfig(env = process.env, overrides = {}) {
191
204
  githubBotUsername: get('GITHUB_BOT_USERNAME') || 'propr.dev[bot]',
192
205
  indexingScanInterval: get('INDEXING_SCAN_INTERVAL_MS') || '300000',
193
206
  indexingReindexInterval: get('INDEXING_REINDEX_INTERVAL_MS') || '86400000',
194
- mistralApiKey: get('MISTRAL_API_KEY'),
207
+ mistralApiKey,
195
208
  vibeConfigPath: get('VIBE_CONFIG_PATH'),
196
209
  manifest, images: manifest.images, manifestPath,
197
210
  });
@@ -223,7 +236,7 @@ export function resolveHostConfig({ rootDir = process.cwd(), env = process.env,
223
236
  // Mount host credentials at the same path on both sides (HOST:HOST) and set the
224
237
  // *_CONFIG_PATH env vars to that path, so the worker/api can re-mount them into
225
238
  // agent containers without any path translation.
226
- function agentCredentialArgs(cfg, { opencodeDataReadWrite = false } = {}) {
239
+ export function agentCredentialArgs(cfg, { opencodeDataReadWrite = false } = {}) {
227
240
  const args = [];
228
241
  if (cfg.hostClaudeDir) {
229
242
  args.push('-v', `${cfg.hostClaudeDir}:${cfg.hostClaudeDir}`);
@@ -237,15 +250,9 @@ function agentCredentialArgs(cfg, { opencodeDataReadWrite = false } = {}) {
237
250
  args.push('-v', `${cfg.hostAntigravityDir}:${cfg.hostAntigravityDir}`);
238
251
  args.push('-e', `ANTIGRAVITY_CONFIG_PATH=${cfg.hostAntigravityDir}`);
239
252
  }
240
- if (cfg.hostOpencodeLegacyDir) {
241
- args.push('-v', `${cfg.hostOpencodeLegacyDir}:${cfg.hostOpencodeLegacyDir}`);
242
- args.push('-e', `OPENCODE_LEGACY_CONFIG_PATH=${cfg.hostOpencodeLegacyDir}`);
243
- }
244
253
  if (cfg.hostOpencodeXdgDir) {
245
254
  args.push('-v', `${cfg.hostOpencodeXdgDir}:${cfg.hostOpencodeXdgDir}`);
246
255
  args.push('-e', `OPENCODE_CONFIG_PATH=${cfg.hostOpencodeXdgDir}`);
247
- } else if (cfg.hostOpencodeLegacyDir) {
248
- args.push('-e', `OPENCODE_CONFIG_PATH=${cfg.hostOpencodeLegacyDir}`);
249
256
  }
250
257
  if (cfg.hostOpencodeDataDir) {
251
258
  const dataMode = opencodeDataReadWrite ? 'rw' : 'ro';
@@ -297,17 +304,54 @@ export function validateDockerBindPath(name, value, { containerPath = false } =
297
304
  // docker exec helpers
298
305
  // ---------------------------------------------------------------------------
299
306
 
300
- export function docker(args, { capture = false } = {}) {
307
+ const REMOTE_IMAGE_CHECK_TIMEOUT_MS = 5000;
308
+
309
+ export function docker(args, { capture = false, timeout } = {}) {
301
310
  const res = spawnSync('docker', args, {
302
311
  stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
303
312
  encoding: 'utf8',
313
+ timeout,
304
314
  });
305
315
  if (res.status !== 0 && !capture) {
306
- throw new Error(`docker ${args.join(' ')} failed with code ${res.status}`);
316
+ const detail = res.error?.message || (res.signal ? `signal ${res.signal}` : `code ${res.status}`);
317
+ throw new Error(`docker ${args.join(' ')} failed with ${detail}`);
307
318
  }
308
319
  return res;
309
320
  }
310
321
 
322
+ /**
323
+ * Async, captured docker exec. Mirrors `docker(..., { capture: true })`'s result
324
+ * shape ({ status, stdout, stderr, error }) but keeps the event loop free, so
325
+ * callers can run several probes concurrently and animate UI while they wait.
326
+ * On timeout it kills the child and reports an ETIMEDOUT error, matching the
327
+ * spawnSync timeout contract that `dockerError` inspects.
328
+ */
329
+ export function dockerAsync(args, { timeout } = {}) {
330
+ return new Promise((resolveResult) => {
331
+ const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
332
+ let stdout = '';
333
+ let stderr = '';
334
+ let settled = false;
335
+ let timeoutError = null;
336
+ const finish = (res) => {
337
+ if (settled) return;
338
+ settled = true;
339
+ if (timer) clearTimeout(timer);
340
+ resolveResult(res);
341
+ };
342
+ const timer = timeout
343
+ ? setTimeout(() => {
344
+ timeoutError = Object.assign(new Error('docker command timed out'), { code: 'ETIMEDOUT' });
345
+ child.kill('SIGKILL');
346
+ }, timeout)
347
+ : null;
348
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
349
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
350
+ child.on('error', (error) => finish({ status: null, stdout, stderr, error }));
351
+ child.on('close', (code, signal) => finish({ status: code, stdout, stderr, signal, error: timeoutError || undefined }));
352
+ });
353
+ }
354
+
311
355
  /** Returns true if the docker daemon is reachable. */
312
356
  export function dockerAvailable() {
313
357
  const res = spawnSync('docker', ['info'], { stdio: ['ignore', 'ignore', 'ignore'] });
@@ -334,7 +378,7 @@ function latestTagFor(imageTag) {
334
378
  return tagIndex > slashIndex ? `${imageTag.slice(0, tagIndex)}:latest` : null;
335
379
  }
336
380
 
337
- function tagAgentLatest(key, imageTag) {
381
+ export function tagAgentLatest(key, imageTag) {
338
382
  if (!key.startsWith('agent-')) return;
339
383
  const latestTag = latestTagFor(imageTag);
340
384
  if (!latestTag || latestTag === imageTag) return;
@@ -369,12 +413,236 @@ function imagePresentLocally(tag) {
369
413
  return res.stdout.trim().length > 0;
370
414
  }
371
415
 
372
- /** Pull a single non-agent service image if it is not already present locally. */
373
- export function ensureServiceImage(cfg, service, onLog) {
416
+ function firstLine(value) {
417
+ return (value || '').trim().split('\n')[0] || '';
418
+ }
419
+
420
+ export function normalizeDigest(value) {
421
+ const digest = firstLine(value);
422
+ if (!digest) return null;
423
+ const atIndex = digest.lastIndexOf('@');
424
+ return atIndex >= 0 ? digest.slice(atIndex + 1) : digest;
425
+ }
426
+
427
+ function localRepoDigests(tag) {
428
+ const res = docker(['image', 'inspect', '--format', '{{json .RepoDigests}}', tag], { capture: true });
429
+ if (res.status !== 0) return null;
430
+ try {
431
+ const parsed = JSON.parse(res.stdout.trim() || '[]');
432
+ return Array.isArray(parsed) ? parsed.map(normalizeDigest).filter(Boolean) : [];
433
+ } catch {
434
+ return [];
435
+ }
436
+ }
437
+
438
+ export function remoteDigestFromManifestInspectOutput(output) {
439
+ return remoteDigestsFromManifestInspectOutput(output)[0] ?? null;
440
+ }
441
+
442
+ export function remoteDigestsFromManifestInspectOutput(output) {
443
+ const parsed = JSON.parse(output);
444
+ const digests = new Set();
445
+ if (Array.isArray(parsed)) {
446
+ for (const entry of parsed) {
447
+ const refDigest = normalizeDigest(entry?.Ref);
448
+ if (refDigest) digests.add(refDigest);
449
+ const descriptor = entry?.Descriptor;
450
+ const descriptorDigest = normalizeDigest(descriptor?.digest || descriptor?.Digest || entry?.digest || entry?.Digest);
451
+ if (descriptorDigest) digests.add(descriptorDigest);
452
+ }
453
+ return [...digests];
454
+ }
455
+ const descriptor = parsed?.Descriptor;
456
+ const digest = normalizeDigest(descriptor?.digest || descriptor?.Digest || parsed?.digest || parsed?.Digest);
457
+ return digest ? [digest] : [];
458
+ }
459
+
460
+ export function remoteDigestFromImagetoolsInspectOutput(output) {
461
+ const match = output.match(/^\s*Digest:\s*([^\s]+)\s*$/im);
462
+ return match ? match[1] : null;
463
+ }
464
+
465
+ function appendDigest(digests, digest) {
466
+ const normalized = normalizeDigest(digest);
467
+ return normalized && !digests.includes(normalized) ? [...digests, normalized] : digests;
468
+ }
469
+
470
+ function dockerError(res, fallback) {
471
+ if (res.error?.code === 'ETIMEDOUT') {
472
+ 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`;
473
+ }
474
+ return firstLine(res.stderr || res.stdout || fallback);
475
+ }
476
+
477
+ function remoteManifestDigest(tag) {
478
+ // Older Docker CLIs may require experimental manifest support. Treat those
479
+ // failures like any other registry issue so callers can warn or skip.
480
+ const res = docker(['manifest', 'inspect', '--verbose', tag], { capture: true, timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS });
481
+ if (res.status !== 0) {
482
+ return { ok: false, error: dockerError(res, 'docker manifest inspect failed') };
483
+ }
484
+
485
+ try {
486
+ const digests = remoteDigestsFromManifestInspectOutput(res.stdout);
487
+ if (digests.length > 0) {
488
+ let allDigests = digests;
489
+ if (res.stdout.trim().startsWith('[')) {
490
+ const buildx = docker(['buildx', 'imagetools', 'inspect', tag], { capture: true, timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS });
491
+ if (buildx.status === 0) allDigests = appendDigest(allDigests, remoteDigestFromImagetoolsInspectOutput(buildx.stdout));
492
+ }
493
+ return { ok: true, digests: allDigests, digest: allDigests[0] };
494
+ }
495
+
496
+ // Older Docker manifest output may omit digest fields. buildx can still
497
+ // expose the tag's index digest, which is useful when the local daemon
498
+ // recorded that digest in RepoDigests.
499
+ const buildx = docker(['buildx', 'imagetools', 'inspect', tag], { capture: true, timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS });
500
+ if (buildx.status !== 0) {
501
+ return { ok: false, error: dockerError(buildx, 'docker buildx imagetools inspect failed') };
502
+ }
503
+ const buildxDigest = remoteDigestFromImagetoolsInspectOutput(buildx.stdout);
504
+ if (buildxDigest) return { ok: true, digests: [buildxDigest], digest: buildxDigest };
505
+
506
+ return { ok: false, error: 'remote manifest digest was not available from docker manifest inspect or docker buildx imagetools inspect' };
507
+ } catch {
508
+ return { ok: false, error: 'could not parse docker manifest inspect output' };
509
+ }
510
+ }
511
+
512
+ function classifyImageFreshness(tag, localDigests, remote) {
513
+ if (!remote.ok) {
514
+ return { status: 'unknown', tag, localDigests, error: remote.error };
515
+ }
516
+
517
+ const remoteDigests = (remote.digests ?? [remote.digest]).map(normalizeDigest).filter(Boolean);
518
+ if (remoteDigests.length === 0) {
519
+ return { status: 'unknown', tag, localDigests, error: 'remote manifest digest was empty' };
520
+ }
521
+
522
+ const digestFields = remoteDigests.length > 1
523
+ ? { remoteDigest: remoteDigests[0], remoteDigests }
524
+ : { remoteDigest: remoteDigests[0] };
525
+ return localDigests.some((digest) => remoteDigests.includes(digest))
526
+ ? { status: 'current', tag, localDigests, ...digestFields }
527
+ : { status: 'stale', tag, localDigests, ...digestFields };
528
+ }
529
+
530
+ function skipRemoteImageCheck(env = process.env) {
531
+ return env.PROPR_SKIP_REMOTE_IMAGE_CHECK === 'true' || env.PROPR_SKIP_REMOTE_IMAGE_CHECK === '1';
532
+ }
533
+
534
+ function isProprPublishedImage(cfg, tag) {
535
+ const registry = typeof cfg.manifest?.registry === 'string' ? cfg.manifest.registry : 'propr';
536
+ return tag.startsWith(`${registry}/`);
537
+ }
538
+
539
+ /**
540
+ * Inspect whether a local image tag is current with the remote registry tag.
541
+ * Registry and metadata errors are reported as "unknown" so callers can warn
542
+ * without treating offline/air-gapped environments as hard failures.
543
+ */
544
+ export function inspectImageFreshness(tag, { skipRemoteCheck = false } = {}) {
545
+ if (!imagePresentLocally(tag)) {
546
+ return { status: 'missing', tag };
547
+ }
548
+
549
+ const localDigests = localRepoDigests(tag);
550
+ if (!localDigests) {
551
+ return { status: 'unknown', tag, error: 'local image metadata could not be inspected' };
552
+ }
553
+
554
+ if (skipRemoteCheck) {
555
+ return { status: 'unknown', tag, localDigests, skipped: true, error: 'remote image check skipped' };
556
+ }
557
+
558
+ if (localDigests.length === 0) {
559
+ return { status: 'unknown', tag, localDigests, localOnly: true, error: 'local image has no registry digest; pull the tag to verify freshness' };
560
+ }
561
+
562
+ return classifyImageFreshness(tag, localDigests, remoteManifestDigest(tag));
563
+ }
564
+
565
+ /** Async mirror of remoteManifestDigest using non-blocking docker exec. */
566
+ async function remoteManifestDigestAsync(tag) {
567
+ const res = await dockerAsync(['manifest', 'inspect', '--verbose', tag], { timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS });
568
+ if (res.status !== 0) {
569
+ return { ok: false, error: dockerError(res, 'docker manifest inspect failed') };
570
+ }
571
+ try {
572
+ const digests = remoteDigestsFromManifestInspectOutput(res.stdout);
573
+ if (digests.length > 0) {
574
+ let allDigests = digests;
575
+ if (res.stdout.trim().startsWith('[')) {
576
+ const buildx = await dockerAsync(['buildx', 'imagetools', 'inspect', tag], { timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS });
577
+ if (buildx.status === 0) allDigests = appendDigest(allDigests, remoteDigestFromImagetoolsInspectOutput(buildx.stdout));
578
+ }
579
+ return { ok: true, digests: allDigests, digest: allDigests[0] };
580
+ }
581
+
582
+ const buildx = await dockerAsync(['buildx', 'imagetools', 'inspect', tag], { timeout: REMOTE_IMAGE_CHECK_TIMEOUT_MS });
583
+ if (buildx.status !== 0) {
584
+ return { ok: false, error: dockerError(buildx, 'docker buildx imagetools inspect failed') };
585
+ }
586
+ const buildxDigest = remoteDigestFromImagetoolsInspectOutput(buildx.stdout);
587
+ if (buildxDigest) return { ok: true, digests: [buildxDigest], digest: buildxDigest };
588
+
589
+ return { ok: false, error: 'remote manifest digest was not available from docker manifest inspect or docker buildx imagetools inspect' };
590
+ } catch {
591
+ return { ok: false, error: 'could not parse docker manifest inspect output' };
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Async mirror of inspectImageFreshness. The local (fast) docker calls stay
597
+ * synchronous; only the remote registry probe is awaited, so many tags can be
598
+ * checked concurrently without blocking the event loop.
599
+ */
600
+ export async function inspectImageFreshnessAsync(tag, { skipRemoteCheck = false } = {}) {
601
+ if (!imagePresentLocally(tag)) {
602
+ return { status: 'missing', tag };
603
+ }
604
+
605
+ const localDigests = localRepoDigests(tag);
606
+ if (!localDigests) {
607
+ return { status: 'unknown', tag, error: 'local image metadata could not be inspected' };
608
+ }
609
+
610
+ if (skipRemoteCheck) {
611
+ return { status: 'unknown', tag, localDigests, skipped: true, error: 'remote image check skipped' };
612
+ }
613
+
614
+ if (localDigests.length === 0) {
615
+ return { status: 'unknown', tag, localDigests, localOnly: true, error: 'local image has no registry digest; pull the tag to verify freshness' };
616
+ }
617
+
618
+ return classifyImageFreshness(tag, localDigests, await remoteManifestDigestAsync(tag));
619
+ }
620
+
621
+ function cachedImageFreshness(cache, tag, opts) {
622
+ if (!cache) return inspectImageFreshness(tag, opts);
623
+ const key = `${opts.skipRemoteCheck ? 'skip' : 'remote'}\0${tag}`;
624
+ if (!cache.has(key)) cache.set(key, inspectImageFreshness(tag, opts));
625
+ return cache.get(key);
626
+ }
627
+
628
+ /** Pull a single non-agent service image if it is not already present locally or is stale. */
629
+ export function ensureServiceImage(cfg, service, onLog, { freshnessCache } = {}) {
374
630
  const tag = imageTagForService(cfg, service);
375
631
  if (!tag) return;
376
- if (imagePresentLocally(tag)) return;
377
- onLog?.(` · pulling ${tag}`);
632
+ const skipFreshness = skipRemoteImageCheck() || !isProprPublishedImage(cfg, tag);
633
+ const freshness = cachedImageFreshness(freshnessCache, tag, { skipRemoteCheck: skipFreshness });
634
+ if (freshness.status === 'current') return;
635
+ if (freshness.status === 'unknown') {
636
+ if (freshness.skipped) return;
637
+ if (freshness.localOnly) {
638
+ onLog?.(` · ${tag} (local-only, pulling)`);
639
+ } else {
640
+ onLog?.(` · ${tag} (local, freshness not verified: ${freshness.error})`);
641
+ return;
642
+ }
643
+ } else {
644
+ onLog?.(` · pulling ${tag}`);
645
+ }
378
646
  const res = docker(['pull', tag], { capture: true });
379
647
  if (res.status !== 0) {
380
648
  throw new Error(`Failed to pull ${tag}: ${(res.stderr || '').trim()}`);
@@ -486,9 +754,9 @@ function buildServiceSpec(cfg, service) {
486
754
  * the service image if it is missing so toggles (`propr docs on`) work even when
487
755
  * the image was skipped at startup.
488
756
  */
489
- export function startService(cfg, service, { onLog, pull = true } = {}) {
757
+ export function startService(cfg, service, { onLog, pull = true, freshnessCache } = {}) {
490
758
  const name = `${cfg.stack}-${service}`;
491
- if (pull) ensureServiceImage(cfg, service, onLog);
759
+ if (pull) ensureServiceImage(cfg, service, onLog, { freshnessCache });
492
760
  const spec = buildServiceSpec(cfg, service);
493
761
  removeIfExists(cfg, name, onLog);
494
762
  const runArgs = [...spec.args, spec.image, ...(spec.command || [])];
@@ -532,9 +800,10 @@ export function isStackRunning(cfg) {
532
800
  export function startStack(cfg, { ui = true, docs = cfg.docsEnabled, onLog } = {}) {
533
801
  const toStart = [...CORE_SERVICES, ...(ui ? ['ui'] : []), ...(docs ? ['docs'] : [])];
534
802
  const started = [];
803
+ const freshnessCache = new Map();
535
804
  try {
536
805
  for (const service of toStart) {
537
- startService(cfg, service, { onLog });
806
+ startService(cfg, service, { onLog, freshnessCache });
538
807
  started.push(service);
539
808
  }
540
809
  } catch (err) {
@@ -551,24 +820,170 @@ export function startStack(cfg, { ui = true, docs = cfg.docsEnabled, onLog } = {
551
820
  return getStackStatus(cfg);
552
821
  }
553
822
 
823
+ // ---------------------------------------------------------------------------
824
+ // async start path
825
+ //
826
+ // The synchronous startStack/startService/ensureNetwork above drive `propr
827
+ // start`, where all the work finishes before any live UI is rendered. The
828
+ // interactive `propr setup` wizard is different: an Ink TUI is on screen while
829
+ // the stack comes up, so a blocking spawnSync would freeze the spinner and
830
+ // swallow keystrokes for the many seconds a cold start can take. These async
831
+ // mirrors do the identical work through dockerAsync(), keeping the event loop
832
+ // free so the wizard keeps animating and streaming progress. Their logic is
833
+ // intentionally kept in lockstep with the synchronous versions above — change
834
+ // one, change the other.
835
+ // ---------------------------------------------------------------------------
836
+
837
+ async function containerExistsAsync(cfg, name) {
838
+ const res = await dockerAsync(['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}']);
839
+ return res.stdout.trim() === name;
840
+ }
841
+
842
+ async function removeIfExistsAsync(cfg, name, onLog) {
843
+ if (await containerExistsAsync(cfg, name)) {
844
+ onLog?.(` · removing stale ${name}`);
845
+ await dockerAsync(['rm', '-f', name]);
846
+ }
847
+ }
848
+
849
+ async function dockerRunDetachedAsync(cfg, name, service, args) {
850
+ const full = [
851
+ 'run', '-d', '--init', '--name', name,
852
+ '--network', cfg.network, '--restart', 'unless-stopped',
853
+ '--label', `propr.stack=${cfg.stack}`,
854
+ '--label', `propr.service=${service}`,
855
+ ...args,
856
+ ];
857
+ const res = await dockerAsync(full);
858
+ if (res.status !== 0) {
859
+ throw new Error(`Failed to start ${name}: ${res.stderr}`);
860
+ }
861
+ }
862
+
863
+ /** Async mirror of ensureNetwork. */
864
+ export async function ensureNetworkAsync(cfg, onLog) {
865
+ const res = await dockerAsync(['network', 'inspect', cfg.network]);
866
+ if (res.status !== 0) {
867
+ onLog?.(`creating network ${cfg.network}`);
868
+ await dockerAsync(['network', 'create', cfg.network]);
869
+ }
870
+ }
871
+
872
+ /** Async, memoized image-freshness lookup mirroring cachedImageFreshness. */
873
+ async function cachedImageFreshnessAsync(cache, tag, opts) {
874
+ if (!cache) return inspectImageFreshnessAsync(tag, opts);
875
+ const key = `${opts.skipRemoteCheck ? 'skip' : 'remote'}\0${tag}`;
876
+ if (!cache.has(key)) cache.set(key, await inspectImageFreshnessAsync(tag, opts));
877
+ return cache.get(key);
878
+ }
879
+
880
+ /** Async mirror of ensureServiceImage — pulls a missing/stale image, awaited. */
881
+ async function ensureServiceImageAsync(cfg, service, onLog, { freshnessCache } = {}) {
882
+ const tag = imageTagForService(cfg, service);
883
+ if (!tag) return;
884
+ const skipFreshness = skipRemoteImageCheck() || !isProprPublishedImage(cfg, tag);
885
+ const freshness = await cachedImageFreshnessAsync(freshnessCache, tag, { skipRemoteCheck: skipFreshness });
886
+ if (freshness.status === 'current') return;
887
+ if (freshness.status === 'unknown') {
888
+ if (freshness.skipped) return;
889
+ if (freshness.localOnly) {
890
+ onLog?.(` · ${tag} (local-only, pulling)`);
891
+ } else {
892
+ onLog?.(` · ${tag} (local, freshness not verified: ${freshness.error})`);
893
+ return;
894
+ }
895
+ } else {
896
+ onLog?.(` · pulling ${tag}`);
897
+ }
898
+ const res = await dockerAsync(['pull', tag]);
899
+ if (res.status !== 0) {
900
+ throw new Error(`Failed to pull ${tag}: ${(res.stderr || '').trim()}`);
901
+ }
902
+ }
903
+
904
+ /** Async mirror of startService. */
905
+ export async function startServiceAsync(cfg, service, { onLog, pull = true, freshnessCache } = {}) {
906
+ const name = `${cfg.stack}-${service}`;
907
+ if (pull) await ensureServiceImageAsync(cfg, service, onLog, { freshnessCache });
908
+ const spec = buildServiceSpec(cfg, service);
909
+ await removeIfExistsAsync(cfg, name, onLog);
910
+ const runArgs = [...spec.args, spec.image, ...(spec.command || [])];
911
+ await dockerRunDetachedAsync(cfg, name, service, runArgs);
912
+ onLog?.(` [ok] started ${name}`);
913
+ return getServiceStateAsync(cfg, service);
914
+ }
915
+
916
+ /** Async mirror of stopService (used by startStackAsync's rollback). */
917
+ async function stopServiceAsync(cfg, service, { remove = true, onLog } = {}) {
918
+ const name = `${cfg.stack}-${service}`;
919
+ if (!(await containerExistsAsync(cfg, name))) return;
920
+ const stopped = await dockerAsync(['stop', '-t', '10', name]);
921
+ if (stopped.status !== 0) {
922
+ throw new Error(`Failed to stop ${name}: ${(stopped.stderr || '').trim()}`);
923
+ }
924
+ if (remove) {
925
+ const removed = await dockerAsync(['rm', name]);
926
+ if (removed.status !== 0) {
927
+ throw new Error(`Stopped ${name} but failed to remove it: ${(removed.stderr || '').trim()}`);
928
+ }
929
+ }
930
+ onLog?.(` [ok] stopped ${name}`);
931
+ }
932
+
554
933
  /**
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.
934
+ * Async mirror of startStack starts the full stack in dependency order
935
+ * without blocking the event loop, rolling back already-started services on a
936
+ * mid-startup failure (best effort) before rethrowing.
937
+ */
938
+ export async function startStackAsync(cfg, { ui = true, docs = cfg.docsEnabled, onLog } = {}) {
939
+ const toStart = [...CORE_SERVICES, ...(ui ? ['ui'] : []), ...(docs ? ['docs'] : [])];
940
+ const started = [];
941
+ const freshnessCache = new Map();
942
+ try {
943
+ for (const service of toStart) {
944
+ await startServiceAsync(cfg, service, { onLog, freshnessCache });
945
+ started.push(service);
946
+ }
947
+ } catch (err) {
948
+ onLog?.(` ! startup failed (${err.message}) — rolling back already-started services`);
949
+ for (const service of started.reverse()) {
950
+ try {
951
+ await stopServiceAsync(cfg, service, { onLog });
952
+ } catch (stopErr) {
953
+ onLog?.(` ! rollback: ${stopErr.message}`);
954
+ }
955
+ }
956
+ throw err;
957
+ }
958
+ return getStackStatusAsync(cfg);
959
+ }
960
+
961
+ /** Async mirror of getStackStatus. */
962
+ export async function getStackStatusAsync(cfg) {
963
+ const res = await dockerAsync(STACK_STATUS_PS_ARGS);
964
+ return parseStackStatus(cfg, res.stdout);
965
+ }
966
+
967
+ /** Async mirror of getServiceState. */
968
+ async function getServiceStateAsync(cfg, service) {
969
+ return (await getStackStatusAsync(cfg)).services.find((s) => s.service === service);
970
+ }
971
+
972
+ /** Async mirror of isStackRunning. */
973
+ export async function isStackRunningAsync(cfg) {
974
+ const status = await getStackStatusAsync(cfg);
975
+ return status.services.some((s) => CORE_SERVICES.includes(s.service) && s.running);
976
+ }
977
+
978
+ /**
979
+ * Stop every container belonging to this stack, discovered by the stack label.
980
+ * Returns `{ failed }` listing containers that could not be stopped/removed so
981
+ * callers can surface partial failures.
558
982
  */
559
983
  export function stopStack(cfg, { remove = true, removeNetwork = false, onLog } = {}) {
560
984
  const res = docker(['ps', '-a', '--filter', `label=propr.stack=${cfg.stack}`, '--format', '{{.Names}}'], { capture: true });
561
985
  const names = new Set(res.stdout.split('\n').map((s) => s.trim()).filter(Boolean));
562
986
 
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
987
  const failed = [];
573
988
  for (const name of names) {
574
989
  // docker() with capture never throws — check the exit status explicitly so
@@ -605,16 +1020,11 @@ export function stopStack(cfg, { remove = true, removeNetwork = false, onLog } =
605
1020
  // status
606
1021
  // ---------------------------------------------------------------------------
607
1022
 
608
- /** Per-service state for the whole stack, discovered by canonical/legacy container name. */
609
- export function getStackStatus(cfg) {
1023
+ /** Parse the `docker ps` table into per-service stack status (shared by sync/async). */
1024
+ function parseStackStatus(cfg, stdout) {
610
1025
  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
1026
  const byName = new Map();
617
- for (const line of res.stdout.split('\n').filter(Boolean)) {
1027
+ for (const line of stdout.split('\n').filter(Boolean)) {
618
1028
  const [name, state, status, ports] = line.split('\t');
619
1029
  if (expectedNames.has(name)) byName.set(name, { state, status, ports: ports || '' });
620
1030
  }
@@ -637,6 +1047,14 @@ export function getStackStatus(cfg) {
637
1047
  return { stack: cfg.stack, network: cfg.network, running: anyRunning, services };
638
1048
  }
639
1049
 
1050
+ const STACK_STATUS_PS_ARGS = ['ps', '-a', '--format', '{{.Names}}\t{{.State}}\t{{.Status}}\t{{.Ports}}'];
1051
+
1052
+ /** Per-service state for the whole stack, discovered by canonical container name. */
1053
+ export function getStackStatus(cfg) {
1054
+ const res = docker(STACK_STATUS_PS_ARGS, { capture: true });
1055
+ return parseStackStatus(cfg, res.stdout);
1056
+ }
1057
+
640
1058
  export function getServiceState(cfg, service) {
641
1059
  return getStackStatus(cfg).services.find((s) => s.service === service);
642
1060
  }
@@ -696,26 +1114,36 @@ export function validateEnv(cfg) {
696
1114
  );
697
1115
  }
698
1116
  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
1117
  if (vibeEnabled || cfg.hostVibePromptCacheDir) {
707
- const invalid = validateDockerBindPath('HOST_VIBE_PROMPT_CACHE_DIR', cfg.hostVibePromptCacheDir)
1118
+ // Only validate the host path when it is actually set — a missing value is
1119
+ // already reported above, so this avoids a misleading second "must be an
1120
+ // absolute path" error for the same root cause.
1121
+ const invalid = (cfg.hostVibePromptCacheDir
1122
+ ? validateDockerBindPath('HOST_VIBE_PROMPT_CACHE_DIR', cfg.hostVibePromptCacheDir)
1123
+ : null)
708
1124
  || validateDockerBindPath('VIBE_PROMPT_CACHE_DIR', cfg.vibePromptCacheDir, { containerPath: true });
709
1125
  if (invalid) {
710
1126
  errors.push(invalid);
711
1127
  } else if (cfg.hostVibePromptCacheDir && cfg.validateHostPaths) {
712
1128
  if (!existsSync(cfg.hostVibePromptCacheDir)) {
713
- errors.push(`HOST_VIBE_PROMPT_CACHE_DIR (${cfg.hostVibePromptCacheDir}) does not exist. Create it: mkdir -p ${cfg.hostVibePromptCacheDir}`);
1129
+ // A missing prompt cache is trivially recoverable `propr init
1130
+ // stack`, `propr start`, or Docker's bind-mount setup will create
1131
+ // it — so only fail when its parent location is not writable and
1132
+ // it therefore cannot be created.
1133
+ const parent = dirname(cfg.hostVibePromptCacheDir);
1134
+ let creatable = false;
1135
+ try { accessSync(parent, fsConstants.W_OK); creatable = true; } catch { /* parent not writable */ }
1136
+ if (!creatable) {
1137
+ 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)}`);
1138
+ }
714
1139
  } else {
715
1140
  try {
716
1141
  accessSync(cfg.hostVibePromptCacheDir, fsConstants.W_OK);
717
1142
  } catch {
718
- errors.push(`HOST_VIBE_PROMPT_CACHE_DIR (${cfg.hostVibePromptCacheDir}) is not writable.`);
1143
+ // Usually means a previous run let Docker auto-create the dir
1144
+ // as root on first bind-mount. Reclaim ownership or remove it
1145
+ // (it is a regenerable cache) so the user can write to it again.
1146
+ 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
1147
  }
720
1148
  }
721
1149
  }
@@ -725,7 +1153,6 @@ export function validateEnv(cfg) {
725
1153
  ['HOST_CLAUDE_DIR', cfg.hostClaudeDir],
726
1154
  ['HOST_CODEX_DIR', cfg.hostCodexDir],
727
1155
  ['HOST_ANTIGRAVITY_DIR', cfg.hostAntigravityDir],
728
- ['HOST_OPENCODE_LEGACY_DIR', cfg.hostOpencodeLegacyDir],
729
1156
  ['HOST_OPENCODE_XDG_DIR', cfg.hostOpencodeXdgDir],
730
1157
  ['HOST_OPENCODE_DATA_DIR', cfg.hostOpencodeDataDir],
731
1158
  ['HOST_VIBE_DIR', cfg.hostVibeDir],
@@ -744,7 +1171,7 @@ export function validateEnv(cfg) {
744
1171
  }
745
1172
  }
746
1173
 
747
- const hasOpenCodeConfig = Boolean(cfg.hostOpencodeXdgDir || cfg.hostOpencodeLegacyDir);
1174
+ const hasOpenCodeConfig = Boolean(cfg.hostOpencodeXdgDir);
748
1175
  if (hasOpenCodeConfig && !cfg.hostOpencodeDataDir) {
749
1176
  warnings.push(
750
1177
  'OpenCode config is mounted but HOST_OPENCODE_DATA_DIR is not set. ' +
@@ -762,6 +1189,8 @@ export function validateEnv(cfg) {
762
1189
  export function pullImages(cfg, { onLog = () => {}, env = process.env } = {}) {
763
1190
  const skipAgentPull = env.PROPR_SKIP_AGENT_PULL === 'true' || env.PROPR_SKIP_AGENT_PULL === '1';
764
1191
  const strictAgentPull = env.PROPR_STRICT_AGENT_PULL !== 'false' && env.PROPR_STRICT_AGENT_PULL !== '0';
1192
+ const skipFreshnessCheck = skipRemoteImageCheck(env);
1193
+ const freshnessCache = new Map();
765
1194
  onLog('pulling images…');
766
1195
  const failedAgentImages = [];
767
1196
 
@@ -778,13 +1207,36 @@ export function pullImages(cfg, { onLog = () => {}, env = process.env } = {}) {
778
1207
  continue;
779
1208
  }
780
1209
 
781
- if (imagePresentLocally(tag)) {
782
- onLog(` · ${tag} (local)`);
1210
+ const skipFreshnessForImage = skipFreshnessCheck || !isProprPublishedImage(cfg, tag);
1211
+ const freshness = cachedImageFreshness(freshnessCache, tag, { skipRemoteCheck: skipFreshnessForImage });
1212
+
1213
+ if (freshness.status === 'current') {
1214
+ onLog(` · ${tag} (local, current)`);
783
1215
  tagAgentLatest(key, tag);
784
1216
  continue;
785
1217
  }
786
1218
 
787
- onLog(` · ${tag}`);
1219
+ if (freshness.status === 'unknown') {
1220
+ if (freshness.localOnly) {
1221
+ onLog(` · ${tag} (local-only, pulling)`);
1222
+ // fall through and pull once; do not print the generic line too.
1223
+ } else if (freshness.skipped) {
1224
+ const reason = skipFreshnessCheck ? 'remote check skipped via PROPR_SKIP_REMOTE_IMAGE_CHECK' : 'third-party image';
1225
+ onLog(` · ${tag} (local, ${reason})`);
1226
+ tagAgentLatest(key, tag);
1227
+ continue;
1228
+ } else {
1229
+ onLog(` · ${tag} (local, freshness not verified: ${freshness.error})`);
1230
+ tagAgentLatest(key, tag);
1231
+ continue;
1232
+ }
1233
+ }
1234
+
1235
+ if (freshness.status === 'stale') {
1236
+ onLog(` · ${tag} (stale, pulling)`);
1237
+ } else if (!(freshness.status === 'unknown' && freshness.localOnly)) {
1238
+ onLog(` · ${tag}`);
1239
+ }
788
1240
  const pulled = docker(['pull', tag], { capture: key.startsWith('agent-') });
789
1241
  if (key.startsWith('agent-') && pulled.status !== 0) {
790
1242
  failedAgentImages.push(tag);