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.
- package/README.md +4 -4
- package/dist/api/relay.js +10 -0
- package/dist/assets/env.example.txt +93 -57
- package/dist/auth/githubLogin.js +66 -0
- package/dist/commands/agentCommands.js +74 -0
- package/dist/commands/agentValidation.js +548 -0
- package/dist/commands/checkCommands.js +981 -76
- package/dist/commands/imageCommands.js +60 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/initStack.js +50 -1
- package/dist/commands/relayCommands.js +45 -12
- package/dist/commands/setup/agents.js +185 -0
- package/dist/commands/setup/engine.js +956 -0
- package/dist/commands/setup/github.js +181 -0
- package/dist/commands/setup/sequential.js +501 -0
- package/dist/commands/setup/state.js +242 -0
- package/dist/commands/setup/types.js +85 -0
- package/dist/commands/setupCommand.js +85 -0
- package/dist/commands/systemCommands.js +49 -2
- package/dist/index.js +13 -45
- package/dist/orchestrator/manifest.json +10 -10
- package/dist/orchestrator/orchestrator.mjs +513 -61
- package/dist/tui/AgentTableApp.js +86 -0
- package/dist/tui/CheckApp.js +202 -0
- package/dist/tui/SetupApp.js +586 -0
- package/dist/tui/SetupApp.test.js +172 -0
- package/dist/tui/app.js +84 -0
- package/dist/tui/render.js +11 -0
- package/dist/utils/envFile.js +45 -0
- package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
- package/dist/vendor/shared/index.js +16 -0
- package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
- package/dist/vendor/shared/modelDefinitions.js +4 -4
- package/dist/vendor/shared/proprServiceUrls.js +27 -0
- package/dist/vendor/shared/statusKeys.js +14 -0
- package/dist/vendor/shared/validateRoutingUrl.js +46 -0
- package/package.json +2 -2
- 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).
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
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
|
-
/**
|
|
609
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
782
|
-
|
|
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
|
-
|
|
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);
|