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.
- package/README.md +4 -4
- package/dist/api/relay.js +10 -0
- package/dist/assets/env.example.txt +182 -59
- 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 +3 -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/stackCommands.js +14 -2
- package/dist/commands/systemCommands.js +49 -2
- package/dist/commands/tunnelCommand.js +562 -0
- package/dist/config/ConfigManager.js +22 -0
- package/dist/config/types.js +1 -0
- package/dist/index.js +14 -45
- package/dist/orchestrator/format.js +46 -0
- package/dist/orchestrator/index.js +7 -2
- package/dist/orchestrator/manifest.json +12 -11
- package/dist/orchestrator/orchestrator.mjs +872 -73
- 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 +28 -2
- package/dist/utils/envFile.js +45 -0
- package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
- package/dist/vendor/shared/index.js +17 -0
- package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
- package/dist/vendor/shared/modelDefinitions.js +4 -4
- package/dist/vendor/shared/proprCompatibility.js +70 -0
- package/dist/vendor/shared/proprServiceUrls.js +124 -0
- package/dist/vendor/shared/statusKeys.js +14 -0
- package/dist/vendor/shared/validateRoutingUrl.js +46 -0
- 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).
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
294
|
+
hostOpencodeXdgDir, hostOpencodeDataDir,
|
|
185
295
|
hostVibeDir, vibePromptCacheDir, hostVibePromptCacheDir,
|
|
186
296
|
hostGhPrivateKey,
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
|
556
|
-
*
|
|
557
|
-
*
|
|
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
|
-
/**
|
|
609
|
-
export function
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
782
|
-
|
|
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
|
-
|
|
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);
|