propr-cli 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +549 -0
  2. package/dist/api/agentTank.js +27 -0
  3. package/dist/api/agents.js +201 -0
  4. package/dist/api/client.js +284 -0
  5. package/dist/api/errors.js +145 -0
  6. package/dist/api/implement.js +147 -0
  7. package/dist/api/index.js +26 -0
  8. package/dist/api/logs.js +59 -0
  9. package/dist/api/plans.js +160 -0
  10. package/dist/api/relay.js +73 -0
  11. package/dist/api/repos.js +243 -0
  12. package/dist/api/settings.js +219 -0
  13. package/dist/api/system.js +53 -0
  14. package/dist/api/tasks.js +140 -0
  15. package/dist/api/todos.js +77 -0
  16. package/dist/api/types.js +6 -0
  17. package/dist/assets/.env.example +183 -0
  18. package/dist/assets/env.example.txt +198 -0
  19. package/dist/commands/agentCommands.js +405 -0
  20. package/dist/commands/checkCommands.js +384 -0
  21. package/dist/commands/implementCommands.js +178 -0
  22. package/dist/commands/index.js +22 -0
  23. package/dist/commands/initCommands.js +167 -0
  24. package/dist/commands/initStack.js +193 -0
  25. package/dist/commands/logCommands.js +170 -0
  26. package/dist/commands/planCommands.js +552 -0
  27. package/dist/commands/relayCommands.js +149 -0
  28. package/dist/commands/repoCommands.js +526 -0
  29. package/dist/commands/settingCommands.js +237 -0
  30. package/dist/commands/stackCommands.js +86 -0
  31. package/dist/commands/startCommand.js +36 -0
  32. package/dist/commands/systemCommands.js +221 -0
  33. package/dist/commands/tankCommands.js +55 -0
  34. package/dist/commands/taskCommands.js +554 -0
  35. package/dist/commands/todoCommands.js +620 -0
  36. package/dist/commands/uiDocsCommands.js +69 -0
  37. package/dist/config/ConfigManager.js +360 -0
  38. package/dist/config/index.js +8 -0
  39. package/dist/config/types.js +16 -0
  40. package/dist/index.js +276 -0
  41. package/dist/orchestrator/format.js +31 -0
  42. package/dist/orchestrator/index.js +102 -0
  43. package/dist/orchestrator/manifest.json +16 -0
  44. package/dist/orchestrator/orchestrator.mjs +798 -0
  45. package/dist/orchestrator/types.js +10 -0
  46. package/dist/tui/StartApp.js +175 -0
  47. package/dist/tui/app.js +9 -0
  48. package/dist/tui/render.js +87 -0
  49. package/dist/utils/envFile.js +65 -0
  50. package/dist/utils/index.js +8 -0
  51. package/dist/utils/io.js +186 -0
  52. package/dist/utils/parseState.js +14 -0
  53. package/dist/utils/resolveProject.js +50 -0
  54. package/dist/vendor/shared/demoMode.js +6 -0
  55. package/dist/vendor/shared/events.js +30 -0
  56. package/dist/vendor/shared/githubAuthMode.js +35 -0
  57. package/dist/vendor/shared/index.js +15 -0
  58. package/dist/vendor/shared/labelUtils.js +32 -0
  59. package/dist/vendor/shared/modelDefinitions.js +146 -0
  60. package/dist/vendor/shared/reviewPrompt.js +18 -0
  61. package/dist/vendor/shared/usageTypes.js +13 -0
  62. package/dist/vendor/shared/userWhitelist.js +30 -0
  63. package/dist/vendor/shared/validateRelayUrl.js +21 -0
  64. package/package.json +31 -0
@@ -0,0 +1,798 @@
1
+ // Propr stack orchestrator — shared, dependency-free core.
2
+ //
3
+ // This module contains all the logic for running the Propr stack as sibling
4
+ // containers via raw `docker run` against a docker daemon. It is consumed by
5
+ // TWO callers:
6
+ //
7
+ // 1. docker/launcher/entrypoint.mjs — runs INSIDE the propr/launcher
8
+ // container, talking to the host daemon over a mounted socket. Paths come
9
+ // in as bind-mounted host paths (PROPR_*_DIR), and the launcher reads the
10
+ // .env from a separate local path (PROPR_LAUNCHER_ENV_FILE / /app/.env).
11
+ //
12
+ // 2. packages/cli — runs natively ON THE HOST. Here the "local" path and the
13
+ // "host" path for the env file collapse to the same thing, and data/logs/
14
+ // repos live under a single root dir. resolveHostConfig() captures that.
15
+ //
16
+ // Pure Node stdlib only (child_process, fs, path, url) so the launcher image
17
+ // needs no npm install and the CLI can import it without a transpile step.
18
+ // The CLI imports this .mjs dynamically and types it via src/orchestrator/types.ts.
19
+
20
+ import { spawn, spawnSync } from 'node:child_process';
21
+ import { readFileSync, existsSync, statSync, accessSync, constants as fsConstants } from 'node:fs';
22
+ import { resolve, dirname, isAbsolute, join } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+
27
+ // True only for an existing regular file (guards against a path that exists but
28
+ // is a directory, which would make readFileSync throw EISDIR).
29
+ function isReadableFile(path) {
30
+ try {
31
+ return statSync(path).isFile();
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ function isDirectory(path) {
38
+ try {
39
+ return statSync(path).isDirectory();
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // .env file parsing (parameterized by the file path so it works for both the
47
+ // launcher's local env file and the host's <root>/.env)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export function parseEnvAssignment(rawLine) {
51
+ const line = rawLine.trim();
52
+ if (!line || line.startsWith('#')) return null;
53
+ const assignment = line.startsWith('export ') ? line.slice(7).trimStart() : line;
54
+ const equalsIndex = assignment.indexOf('=');
55
+ if (equalsIndex <= 0) return null;
56
+
57
+ const name = assignment.slice(0, equalsIndex).trim();
58
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return null;
59
+
60
+ const valueSource = assignment.slice(equalsIndex + 1).trimStart();
61
+ return { name, value: parseEnvValue(valueSource) };
62
+ }
63
+
64
+ export function parseEnvValue(valueSource) {
65
+ if (!valueSource) return '';
66
+ const quote = valueSource[0];
67
+ if (quote === '"' || quote === "'") {
68
+ let value = '';
69
+ for (let index = 1; index < valueSource.length; index += 1) {
70
+ const char = valueSource[index];
71
+ if (char === quote) return quote === '"' ? unescapeDoubleQuotedEnv(value) : value;
72
+ if (quote === '"' && char === '\\' && index + 1 < valueSource.length) {
73
+ value += char + valueSource[index + 1];
74
+ index += 1;
75
+ } else {
76
+ value += char;
77
+ }
78
+ }
79
+ return quote === '"' ? unescapeDoubleQuotedEnv(value) : value;
80
+ }
81
+ return valueSource.replace(/\s+#.*$/, '').trimEnd();
82
+ }
83
+
84
+ function unescapeDoubleQuotedEnv(value) {
85
+ return value.replace(/\\([\\nrt"$`])/g, (_match, escaped) => {
86
+ if (escaped === 'n') return '\n';
87
+ if (escaped === 'r') return '\r';
88
+ if (escaped === 't') return '\t';
89
+ return escaped;
90
+ });
91
+ }
92
+
93
+ // Reads a single value from an env file. Re-reads the file per call (matches the
94
+ // original launcher behavior; call sites are few and startup-only).
95
+ function envFileValueFrom(envFileLocal, name) {
96
+ if (!envFileLocal || !isReadableFile(envFileLocal)) return undefined;
97
+ for (const rawLine of readFileSync(envFileLocal, 'utf8').split(/\r?\n/)) {
98
+ const parsed = parseEnvAssignment(rawLine);
99
+ if (parsed?.name === name) {
100
+ const value = parsed.value || undefined;
101
+ if (value && /\$\{[A-Za-z_]/.test(value)) {
102
+ console.warn(`WARNING: ${name} in .env contains a variable reference ("${value}") that will not be expanded. Use an absolute path instead.`);
103
+ }
104
+ return value;
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /**
111
+ * Parse every assignment from an env file into a plain object. Used by the CLI
112
+ * `check`/`init` commands to inspect HOST_*_DIR settings without re-reading.
113
+ */
114
+ export function readEnvFile(envFilePath) {
115
+ const out = {};
116
+ if (!envFilePath || !isReadableFile(envFilePath)) return out;
117
+ for (const rawLine of readFileSync(envFilePath, 'utf8').split(/\r?\n/)) {
118
+ const parsed = parseEnvAssignment(rawLine);
119
+ if (parsed) out[parsed.name] = parsed.value;
120
+ }
121
+ return out;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Config resolution
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Resolve a stack config from an environment + overrides. Works for both the
130
+ * containerized launcher (paths are bind-mounted host paths) and the host CLI
131
+ * (paths are real local dirs). `overrides` lets the CLI inject host-derived
132
+ * paths without needing env vars.
133
+ */
134
+ export function resolveConfig(env = process.env, overrides = {}) {
135
+ const stack = overrides.stack ?? env.PROPR_STACK ?? 'propr';
136
+ const network = overrides.network ?? env.PROPR_NETWORK ?? `${stack}-net`;
137
+ const envFileLocal = overrides.envFileLocal ?? env.PROPR_LAUNCHER_ENV_FILE ?? '/app/.env';
138
+ const envFileHost = overrides.envFileHost ?? env.PROPR_ENV_FILE;
139
+
140
+ // value precedence: explicit override → process env → .env file
141
+ const get = (name) => env[name] !== undefined ? env[name] : envFileValueFrom(envFileLocal, name) || undefined;
142
+
143
+ const hostData = overrides.hostData ?? env.PROPR_DATA_DIR;
144
+ const hostLogs = overrides.hostLogs ?? env.PROPR_LOGS_DIR;
145
+ const hostRepos = overrides.hostRepos ?? env.PROPR_REPOS_DIR;
146
+
147
+ const apiPort = overrides.apiPort ?? get('API_PORT') ?? '4000';
148
+ const uiPort = overrides.uiPort ?? get('UI_PORT') ?? '5173';
149
+ const docsPort = overrides.docsPort ?? get('DOCS_PORT') ?? '8080';
150
+ const redisExternalPort = overrides.redisExternalPort ?? get('REDIS_EXTERNAL_PORT') ?? '';
151
+ const docsEnabled = overrides.docsEnabled ?? (get('DOCS_ENABLED') === 'true');
152
+
153
+ // Agent credential host dirs (HOST:HOST mounts so spawned agent containers
154
+ // resolve the same path end-to-end). HOST_OPENCODE_DIR is a back-compat alias.
155
+ const hostClaudeDir = get('HOST_CLAUDE_DIR');
156
+ const hostCodexDir = get('HOST_CODEX_DIR');
157
+ const hostAntigravityDir = get('HOST_ANTIGRAVITY_DIR');
158
+ const hostOpencodeLegacyDir = get('HOST_OPENCODE_LEGACY_DIR');
159
+ const hostOpencodeXdgDir = env.HOST_OPENCODE_XDG_DIR !== undefined ? env.HOST_OPENCODE_XDG_DIR
160
+ : env.HOST_OPENCODE_DIR !== undefined ? env.HOST_OPENCODE_DIR
161
+ : envFileValueFrom(envFileLocal, 'HOST_OPENCODE_XDG_DIR')
162
+ || envFileValueFrom(envFileLocal, 'HOST_OPENCODE_DIR') || undefined;
163
+ const hostOpencodeDataDir = get('HOST_OPENCODE_DATA_DIR');
164
+ const hostVibeDir = get('HOST_VIBE_DIR');
165
+
166
+ const vibePromptCacheDir = get('VIBE_PROMPT_CACHE_DIR') || '/tmp/propr-vibe-prompts';
167
+ const hostVibePromptCacheDir = get('HOST_VIBE_PROMPT_CACHE_DIR');
168
+
169
+ // Host path to the GitHub App private key (.pem). When set, the key is
170
+ // bind-mounted into the app containers (HOST:HOST, read-only) and
171
+ // GH_PRIVATE_KEY_PATH is overridden to that path so the daemon/worker can
172
+ // read it without the user having to stage it under data/.
173
+ const hostGhPrivateKey = get('HOST_GH_PRIVATE_KEY');
174
+
175
+ const manifestPath = overrides.manifestPath ?? resolve(__dirname, 'manifest.json');
176
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
177
+
178
+ return Object.freeze({
179
+ stack, network, envFileLocal, envFileHost,
180
+ validateHostPaths: overrides.validateHostPaths === true,
181
+ hostData, hostLogs, hostRepos,
182
+ apiPort, uiPort, docsPort, redisExternalPort, docsEnabled,
183
+ hostClaudeDir, hostCodexDir, hostAntigravityDir,
184
+ hostOpencodeLegacyDir, hostOpencodeXdgDir, hostOpencodeDataDir,
185
+ hostVibeDir, vibePromptCacheDir, hostVibePromptCacheDir,
186
+ hostGhPrivateKey,
187
+ // misc -e overrides the launcher computed from ports/env
188
+ apiPublicUrl: get('API_PUBLIC_URL') || `http://localhost:${apiPort}`,
189
+ frontendUrl: get('FRONTEND_URL') || `http://localhost:${uiPort}`,
190
+ ghOauthCallbackUrl: get('GH_OAUTH_CALLBACK_URL') || `http://localhost:${apiPort}/api/auth/github/callback`,
191
+ githubBotUsername: get('GITHUB_BOT_USERNAME') || 'propr.dev[bot]',
192
+ indexingScanInterval: get('INDEXING_SCAN_INTERVAL_MS') || '300000',
193
+ indexingReindexInterval: get('INDEXING_REINDEX_INTERVAL_MS') || '86400000',
194
+ mistralApiKey: get('MISTRAL_API_KEY'),
195
+ vibeConfigPath: get('VIBE_CONFIG_PATH'),
196
+ manifest, images: manifest.images, manifestPath,
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Host CLI convenience: env file, data, logs and repos all live under a single
202
+ * root dir; the local path IS the host path (no container indirection).
203
+ * `cliOverrides` lets the CLI pass in persisted config (e.g. docsEnabled from
204
+ * ConfigManager) that should take precedence over env/defaults.
205
+ */
206
+ export function resolveHostConfig({ rootDir = process.cwd(), env = process.env, manifestPath, cliOverrides = {} } = {}) {
207
+ return resolveConfig(env, {
208
+ envFileLocal: join(rootDir, '.env'),
209
+ envFileHost: join(rootDir, '.env'),
210
+ hostData: join(rootDir, 'data'),
211
+ hostLogs: join(rootDir, 'logs'),
212
+ hostRepos: join(rootDir, 'repos'),
213
+ validateHostPaths: true,
214
+ manifestPath,
215
+ ...cliOverrides,
216
+ });
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // docker arg builders
221
+ // ---------------------------------------------------------------------------
222
+
223
+ // Mount host credentials at the same path on both sides (HOST:HOST) and set the
224
+ // *_CONFIG_PATH env vars to that path, so the worker/api can re-mount them into
225
+ // agent containers without any path translation.
226
+ function agentCredentialArgs(cfg, { opencodeDataReadWrite = false } = {}) {
227
+ const args = [];
228
+ if (cfg.hostClaudeDir) {
229
+ args.push('-v', `${cfg.hostClaudeDir}:${cfg.hostClaudeDir}`);
230
+ args.push('-e', `CLAUDE_CONFIG_PATH=${cfg.hostClaudeDir}`);
231
+ }
232
+ if (cfg.hostCodexDir) {
233
+ args.push('-v', `${cfg.hostCodexDir}:${cfg.hostCodexDir}`);
234
+ args.push('-e', `CODEX_CONFIG_PATH=${cfg.hostCodexDir}`);
235
+ }
236
+ if (cfg.hostAntigravityDir) {
237
+ args.push('-v', `${cfg.hostAntigravityDir}:${cfg.hostAntigravityDir}`);
238
+ args.push('-e', `ANTIGRAVITY_CONFIG_PATH=${cfg.hostAntigravityDir}`);
239
+ }
240
+ if (cfg.hostOpencodeLegacyDir) {
241
+ args.push('-v', `${cfg.hostOpencodeLegacyDir}:${cfg.hostOpencodeLegacyDir}`);
242
+ args.push('-e', `OPENCODE_LEGACY_CONFIG_PATH=${cfg.hostOpencodeLegacyDir}`);
243
+ }
244
+ if (cfg.hostOpencodeXdgDir) {
245
+ args.push('-v', `${cfg.hostOpencodeXdgDir}:${cfg.hostOpencodeXdgDir}`);
246
+ args.push('-e', `OPENCODE_CONFIG_PATH=${cfg.hostOpencodeXdgDir}`);
247
+ } else if (cfg.hostOpencodeLegacyDir) {
248
+ args.push('-e', `OPENCODE_CONFIG_PATH=${cfg.hostOpencodeLegacyDir}`);
249
+ }
250
+ if (cfg.hostOpencodeDataDir) {
251
+ const dataMode = opencodeDataReadWrite ? 'rw' : 'ro';
252
+ args.push('-v', `${cfg.hostOpencodeDataDir}:${cfg.hostOpencodeDataDir}:${dataMode}`);
253
+ args.push('-e', `HOST_OPENCODE_DATA_DIR=${cfg.hostOpencodeDataDir}`);
254
+ }
255
+ if (cfg.hostVibeDir) {
256
+ args.push('-v', `${cfg.hostVibeDir}:${cfg.hostVibeDir}`);
257
+ args.push('-e', `VIBE_CONFIG_PATH=${cfg.hostVibeDir}`);
258
+ }
259
+ return args;
260
+ }
261
+
262
+ // Bind-mount the GitHub App private key into app containers (read-only) and
263
+ // point GH_PRIVATE_KEY_PATH at the mounted path. Mounting HOST:HOST keeps the
264
+ // path identical inside and out so GH_PRIVATE_KEY_PATH is a real, resolvable
265
+ // path for the daemon/worker.
266
+ function githubKeyArgs(cfg) {
267
+ if (!cfg.hostGhPrivateKey) return [];
268
+ return [
269
+ '-v', `${cfg.hostGhPrivateKey}:${cfg.hostGhPrivateKey}:ro`,
270
+ '-e', `GH_PRIVATE_KEY_PATH=${cfg.hostGhPrivateKey}`,
271
+ ];
272
+ }
273
+
274
+ function vibePromptCacheArgs(cfg) {
275
+ if (!cfg.hostVibePromptCacheDir) return [];
276
+ return [
277
+ '-v', `${cfg.hostVibePromptCacheDir}:${cfg.vibePromptCacheDir}`,
278
+ '-e', `VIBE_PROMPT_CACHE_DIR=${cfg.vibePromptCacheDir}`,
279
+ '-e', `HOST_VIBE_PROMPT_CACHE_DIR=${cfg.hostVibePromptCacheDir}`,
280
+ '-e', 'VIBE_PROMPT_CACHE_HOST_MOUNTED=1',
281
+ ];
282
+ }
283
+
284
+ // Validates host bind-mount paths for Linux deployments. ':' rejection prevents
285
+ // malformed -v HOST:CONTAINER args; Windows drive paths (C:\...) are unsupported.
286
+ export function validateDockerBindPath(name, value, { containerPath = false } = {}) {
287
+ if (!value || !isAbsolute(value) || value.includes('~') || /[\0\r\n]/.test(value)) {
288
+ return `${name} must be an absolute path without '~' or control characters (requires Linux host paths)`;
289
+ }
290
+ if (!containerPath && value.includes(':')) {
291
+ return `${name} cannot contain ':' because it is used in a Docker bind mount (requires Linux — Windows-style paths like C:\\... are not supported)`;
292
+ }
293
+ return null;
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // docker exec helpers
298
+ // ---------------------------------------------------------------------------
299
+
300
+ export function docker(args, { capture = false } = {}) {
301
+ const res = spawnSync('docker', args, {
302
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
303
+ encoding: 'utf8',
304
+ });
305
+ if (res.status !== 0 && !capture) {
306
+ throw new Error(`docker ${args.join(' ')} failed with code ${res.status}`);
307
+ }
308
+ return res;
309
+ }
310
+
311
+ /** Returns true if the docker daemon is reachable. */
312
+ export function dockerAvailable() {
313
+ const res = spawnSync('docker', ['info'], { stdio: ['ignore', 'ignore', 'ignore'] });
314
+ return res.status === 0;
315
+ }
316
+
317
+ function dockerRunDetached(cfg, name, service, args) {
318
+ const full = [
319
+ 'run', '-d', '--init', '--name', name,
320
+ '--network', cfg.network, '--restart', 'unless-stopped',
321
+ '--label', `propr.stack=${cfg.stack}`,
322
+ '--label', `propr.service=${service}`,
323
+ ...args,
324
+ ];
325
+ const res = docker(full, { capture: true });
326
+ if (res.status !== 0) {
327
+ throw new Error(`Failed to start ${name}: ${res.stderr}`);
328
+ }
329
+ }
330
+
331
+ function latestTagFor(imageTag) {
332
+ const slashIndex = imageTag.lastIndexOf('/');
333
+ const tagIndex = imageTag.lastIndexOf(':');
334
+ return tagIndex > slashIndex ? `${imageTag.slice(0, tagIndex)}:latest` : null;
335
+ }
336
+
337
+ function tagAgentLatest(key, imageTag) {
338
+ if (!key.startsWith('agent-')) return;
339
+ const latestTag = latestTagFor(imageTag);
340
+ if (!latestTag || latestTag === imageTag) return;
341
+ const res = docker(['tag', imageTag, latestTag], { capture: true });
342
+ if (res.status !== 0) {
343
+ throw new Error(`Failed to tag ${imageTag} as ${latestTag}: ${res.stderr}`);
344
+ }
345
+ }
346
+
347
+ export function containerExists(cfg, name) {
348
+ const res = docker(['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}'], { capture: true });
349
+ return res.stdout.trim() === name;
350
+ }
351
+
352
+ function removeIfExists(cfg, name, onLog) {
353
+ if (containerExists(cfg, name)) {
354
+ onLog?.(` · removing stale ${name}`);
355
+ docker(['rm', '-f', name], { capture: true });
356
+ }
357
+ }
358
+
359
+ export function ensureNetwork(cfg, onLog) {
360
+ const res = docker(['network', 'inspect', cfg.network], { capture: true });
361
+ if (res.status !== 0) {
362
+ onLog?.(`creating network ${cfg.network}`);
363
+ docker(['network', 'create', cfg.network], { capture: true });
364
+ }
365
+ }
366
+
367
+ function imagePresentLocally(tag) {
368
+ const res = docker(['images', '-q', tag], { capture: true });
369
+ return res.stdout.trim().length > 0;
370
+ }
371
+
372
+ /** Pull a single non-agent service image if it is not already present locally. */
373
+ export function ensureServiceImage(cfg, service, onLog) {
374
+ const tag = imageTagForService(cfg, service);
375
+ if (!tag) return;
376
+ if (imagePresentLocally(tag)) return;
377
+ onLog?.(` · pulling ${tag}`);
378
+ const res = docker(['pull', tag], { capture: true });
379
+ if (res.status !== 0) {
380
+ throw new Error(`Failed to pull ${tag}: ${(res.stderr || '').trim()}`);
381
+ }
382
+ }
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // service registry
386
+ // ---------------------------------------------------------------------------
387
+
388
+ export const CORE_SERVICES = ['redis', 'daemon', 'worker', 'analysis-worker', 'indexing-worker', 'api'];
389
+ export const TOGGLE_SERVICES = ['ui', 'docs'];
390
+ export const SERVICES = [...CORE_SERVICES, ...TOGGLE_SERVICES];
391
+
392
+ function imageTagForService(cfg, service) {
393
+ if (service === 'redis') return cfg.images.redis;
394
+ if (service === 'ui') return cfg.images.ui;
395
+ if (service === 'docs') return cfg.images.docs;
396
+ // daemon/worker/analysis-worker/indexing-worker/api all run the app image
397
+ return cfg.images.app;
398
+ }
399
+
400
+ function appBaseArgs(cfg) {
401
+ return [
402
+ // --env-file is resolved by the docker CLI (inside the launcher / on host).
403
+ '--env-file', cfg.envFileLocal,
404
+ '-v', `${cfg.hostLogs}:/usr/src/app/logs`,
405
+ '-v', `${cfg.hostData}:/usr/src/app/data`,
406
+ '-v', '/var/run/docker.sock:/var/run/docker.sock',
407
+ '-v', '/tmp/git-processor:/tmp/git-processor',
408
+ '--add-host', 'host.docker.internal:host-gateway',
409
+ '-e', `REDIS_HOST=${cfg.stack}-redis`,
410
+ // Every app container imports @propr/core's githubAuth, which needs the
411
+ // GitHub App private key — so mount it for all of them when provided.
412
+ ...githubKeyArgs(cfg),
413
+ ];
414
+ }
415
+
416
+ function appSpec(cfg, command, extraArgs = []) {
417
+ return { image: cfg.images.app, args: [...appBaseArgs(cfg), ...extraArgs], command: ['node', ...command] };
418
+ }
419
+
420
+ // Returns { image, args, command? } for a canonical service name.
421
+ function buildServiceSpec(cfg, service) {
422
+ switch (service) {
423
+ case 'redis': {
424
+ const args = ['-v', `${cfg.stack}-redis-data:/data`];
425
+ if (cfg.redisExternalPort && cfg.redisExternalPort !== '0' && cfg.redisExternalPort !== 'none') {
426
+ args.unshift('-p', `${cfg.redisExternalPort}:6379`);
427
+ }
428
+ return { image: cfg.images.redis, args };
429
+ }
430
+ case 'daemon':
431
+ return appSpec(cfg, ['dist/src/daemon.js'], [
432
+ '-v', `${cfg.envFileHost}:/usr/src/app/.env:ro`,
433
+ '-v', '/tmp/pr-worktrees:/tmp/pr-worktrees',
434
+ '-e', `GITHUB_BOT_USERNAME=${cfg.githubBotUsername}`,
435
+ '-e', 'STAGING_ENV_FILE=/usr/src/app/.env',
436
+ ]);
437
+ case 'worker':
438
+ return appSpec(cfg, ['dist/src/worker.js'], [
439
+ '-v', `${cfg.hostRepos}:/usr/src/app/repos`,
440
+ '-v', '/tmp/claude-logs:/tmp/claude-logs',
441
+ '--ulimit', 'nofile=65536:65536',
442
+ // The worker validates the attachment base URL at startup
443
+ // (validateAttachmentBaseUrlConfig); inject the computed value so a
444
+ // .env without API_PUBLIC_URL/FRONTEND_URL doesn't crashloop it.
445
+ '-e', `API_PUBLIC_URL=${cfg.apiPublicUrl}`,
446
+ ...vibePromptCacheArgs(cfg),
447
+ ...agentCredentialArgs(cfg, { opencodeDataReadWrite: true }),
448
+ ]);
449
+ case 'analysis-worker':
450
+ return appSpec(cfg, ['dist/src/analysis_worker.js'], [
451
+ ...vibePromptCacheArgs(cfg),
452
+ ...agentCredentialArgs(cfg),
453
+ ]);
454
+ case 'indexing-worker':
455
+ return appSpec(cfg, ['dist/src/indexing_worker.js'], [
456
+ '-v', '/tmp/claude-logs:/tmp/claude-logs',
457
+ '-e', `INDEXING_SCAN_INTERVAL_MS=${cfg.indexingScanInterval}`,
458
+ '-e', `INDEXING_REINDEX_INTERVAL_MS=${cfg.indexingReindexInterval}`,
459
+ ...agentCredentialArgs(cfg),
460
+ ]);
461
+ case 'api':
462
+ return appSpec(cfg, ['dist/packages/api/server.js'], [
463
+ '-p', `${cfg.apiPort}:4000`,
464
+ '-v', `${cfg.envFileHost}:/usr/src/app/.env:ro`,
465
+ '-v', '/tmp/pr-worktrees:/tmp/pr-worktrees',
466
+ '--ulimit', 'nofile=65536:65536',
467
+ ...vibePromptCacheArgs(cfg),
468
+ ...agentCredentialArgs(cfg),
469
+ '-e', `API_PUBLIC_URL=${cfg.apiPublicUrl}`,
470
+ '-e', `FRONTEND_URL=${cfg.frontendUrl}`,
471
+ '-e', `GH_OAUTH_CALLBACK_URL=${cfg.ghOauthCallbackUrl}`,
472
+ '-e', `SESSION_REDIS_HOST=${cfg.stack}-redis`,
473
+ '-e', 'CONFIG_REPO_PATH=/tmp/config_repo',
474
+ ]);
475
+ case 'ui':
476
+ return { image: cfg.images.ui, args: ['-p', `${cfg.uiPort}:5173`] };
477
+ case 'docs':
478
+ return { image: cfg.images.docs, args: ['-p', `${cfg.docsPort}:3000`] };
479
+ default:
480
+ throw new Error(`unknown service: ${service}`);
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Start a single service container (removing any stale instance first). Pulls
486
+ * the service image if it is missing so toggles (`propr docs on`) work even when
487
+ * the image was skipped at startup.
488
+ */
489
+ export function startService(cfg, service, { onLog, pull = true } = {}) {
490
+ const name = `${cfg.stack}-${service}`;
491
+ if (pull) ensureServiceImage(cfg, service, onLog);
492
+ const spec = buildServiceSpec(cfg, service);
493
+ removeIfExists(cfg, name, onLog);
494
+ const runArgs = [...spec.args, spec.image, ...(spec.command || [])];
495
+ dockerRunDetached(cfg, name, service, runArgs);
496
+ onLog?.(` [ok] started ${name}`);
497
+ return getServiceState(cfg, service);
498
+ }
499
+
500
+ /** Stop (and by default remove) a single service container. Throws if the stop fails. */
501
+ export function stopService(cfg, service, { remove = true, onLog } = {}) {
502
+ const name = `${cfg.stack}-${service}`;
503
+ if (!containerExists(cfg, name)) return;
504
+ const stopped = docker(['stop', '-t', '10', name], { capture: true });
505
+ if (stopped.status !== 0) {
506
+ throw new Error(`Failed to stop ${name}: ${(stopped.stderr || '').trim()}`);
507
+ }
508
+ if (remove) {
509
+ const removed = docker(['rm', name], { capture: true });
510
+ if (removed.status !== 0) {
511
+ throw new Error(`Stopped ${name} but failed to remove it: ${(removed.stderr || '').trim()}`);
512
+ }
513
+ }
514
+ onLog?.(` [ok] stopped ${name}`);
515
+ }
516
+
517
+ /**
518
+ * Check if any core service container in the stack is currently running.
519
+ * Useful for callers that want to detect an already-running stack and
520
+ * prompt before restarting (e.g. `propr start`).
521
+ */
522
+ export function isStackRunning(cfg) {
523
+ const status = getStackStatus(cfg);
524
+ return status.services.some((s) => CORE_SERVICES.includes(s.service) && s.running);
525
+ }
526
+
527
+ /**
528
+ * Start the full stack in dependency order. If a service fails to start, the
529
+ * services started so far are stopped (best effort) before the error is
530
+ * rethrown, so a failed startup doesn't leave a half-running stack behind.
531
+ */
532
+ export function startStack(cfg, { ui = true, docs = cfg.docsEnabled, onLog } = {}) {
533
+ const toStart = [...CORE_SERVICES, ...(ui ? ['ui'] : []), ...(docs ? ['docs'] : [])];
534
+ const started = [];
535
+ try {
536
+ for (const service of toStart) {
537
+ startService(cfg, service, { onLog });
538
+ started.push(service);
539
+ }
540
+ } catch (err) {
541
+ onLog?.(` ! startup failed (${err.message}) — rolling back already-started services`);
542
+ for (const service of started.reverse()) {
543
+ try {
544
+ stopService(cfg, service, { onLog });
545
+ } catch (stopErr) {
546
+ onLog?.(` ! rollback: ${stopErr.message}`);
547
+ }
548
+ }
549
+ throw err;
550
+ }
551
+ return getStackStatus(cfg);
552
+ }
553
+
554
+ /**
555
+ * Stop every container belonging to this stack (discovered by label + legacy
556
+ * name pattern). Returns `{ failed }` listing containers that could not be
557
+ * stopped/removed so callers can surface partial failures.
558
+ */
559
+ export function stopStack(cfg, { remove = true, removeNetwork = false, onLog } = {}) {
560
+ const res = docker(['ps', '-a', '--filter', `label=propr.stack=${cfg.stack}`, '--format', '{{.Names}}'], { capture: true });
561
+ const names = new Set(res.stdout.split('\n').map((s) => s.trim()).filter(Boolean));
562
+
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
+ const failed = [];
573
+ for (const name of names) {
574
+ // docker() with capture never throws — check the exit status explicitly so
575
+ // a failed stop is reported instead of being logged as "[ok] stopped".
576
+ const stopped = docker(['stop', '-t', '10', name], { capture: true });
577
+ if (stopped.status !== 0) {
578
+ failed.push(name);
579
+ onLog?.(` ! failed to stop ${name}: ${(stopped.stderr || '').trim()}`);
580
+ continue;
581
+ }
582
+ if (remove) {
583
+ const removed = docker(['rm', name], { capture: true });
584
+ if (removed.status !== 0) {
585
+ failed.push(name);
586
+ onLog?.(` ! stopped ${name} but failed to remove it: ${(removed.stderr || '').trim()}`);
587
+ continue;
588
+ }
589
+ }
590
+ onLog?.(` [ok] stopped ${name}`);
591
+ }
592
+
593
+ if (removeNetwork) {
594
+ const removedNet = docker(['network', 'rm', cfg.network], { capture: true });
595
+ // Non-zero is not fatal — the network may not exist or may still be in use.
596
+ if (removedNet.status === 0) {
597
+ onLog?.(` [ok] removed network ${cfg.network}`);
598
+ }
599
+ }
600
+
601
+ return { failed };
602
+ }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // status
606
+ // ---------------------------------------------------------------------------
607
+
608
+ /** Per-service state for the whole stack, discovered by canonical/legacy container name. */
609
+ export function getStackStatus(cfg) {
610
+ 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
+ const byName = new Map();
617
+ for (const line of res.stdout.split('\n').filter(Boolean)) {
618
+ const [name, state, status, ports] = line.split('\t');
619
+ if (expectedNames.has(name)) byName.set(name, { state, status, ports: ports || '' });
620
+ }
621
+
622
+ const services = SERVICES.map((service) => {
623
+ const name = `${cfg.stack}-${service}`;
624
+ const found = byName.get(name);
625
+ return {
626
+ name,
627
+ service,
628
+ exists: Boolean(found),
629
+ running: found ? found.state === 'running' : false,
630
+ state: found ? found.state : 'absent',
631
+ status: found ? found.status : 'not created',
632
+ ports: found ? found.ports : '',
633
+ };
634
+ });
635
+
636
+ const anyRunning = services.some((s) => s.running);
637
+ return { stack: cfg.stack, network: cfg.network, running: anyRunning, services };
638
+ }
639
+
640
+ export function getServiceState(cfg, service) {
641
+ return getStackStatus(cfg).services.find((s) => s.service === service);
642
+ }
643
+
644
+ /** Spawn `docker logs` for a service. Returns the ChildProcess. */
645
+ export function getServiceLogs(cfg, service, { follow = false, tail = 'all', stdio = 'inherit' } = {}) {
646
+ const args = ['logs'];
647
+ if (follow) args.push('-f');
648
+ args.push('--tail', String(tail), `${cfg.stack}-${service}`);
649
+ return spawn('docker', args, { stdio });
650
+ }
651
+
652
+ // ---------------------------------------------------------------------------
653
+ // validation + image pull (startup)
654
+ // ---------------------------------------------------------------------------
655
+
656
+ /**
657
+ * Validate that required host paths and vibe settings are coherent. Returns a
658
+ * result object (the caller decides whether to abort) — no process.exit here.
659
+ */
660
+ export function validateEnv(cfg) {
661
+ const errors = [];
662
+ const warnings = [];
663
+
664
+ // Docker name constraint — the stack name is embedded in container, volume
665
+ // and network names, so reject it early instead of failing mid-startup.
666
+ const dockerNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
667
+ if (!dockerNamePattern.test(cfg.stack)) {
668
+ errors.push(`PROPR_STACK ("${cfg.stack}") is not a valid Docker name — use letters, digits, '_', '.' or '-', starting with a letter or digit.`);
669
+ }
670
+ if (!dockerNamePattern.test(cfg.network)) {
671
+ errors.push(`PROPR_NETWORK ("${cfg.network}") is not a valid Docker network name — use letters, digits, '_', '.' or '-', starting with a letter or digit.`);
672
+ }
673
+
674
+ if (!cfg.envFileHost) errors.push('env file path is not set (PROPR_ENV_FILE / <root>/.env)');
675
+ if (!cfg.hostData) errors.push('data dir is not set (PROPR_DATA_DIR)');
676
+ if (!cfg.hostLogs) errors.push('logs dir is not set (PROPR_LOGS_DIR)');
677
+ if (!cfg.hostRepos) errors.push('repos dir is not set (PROPR_REPOS_DIR)');
678
+ if (cfg.validateHostPaths) {
679
+ for (const [name, path] of [
680
+ ['PROPR_DATA_DIR', cfg.hostData],
681
+ ['PROPR_LOGS_DIR', cfg.hostLogs],
682
+ ['PROPR_REPOS_DIR', cfg.hostRepos],
683
+ ]) {
684
+ if (path && !isDirectory(path)) {
685
+ errors.push(`${name} (${path}) is not an existing directory. Run \`propr init stack\` to create the stack directories.`);
686
+ }
687
+ }
688
+ }
689
+ if (cfg.envFileLocal && !isReadableFile(cfg.envFileLocal)) {
690
+ errors.push(`cannot read the env file at ${cfg.envFileLocal}`);
691
+ }
692
+
693
+ if (cfg.vibeConfigPath && !cfg.hostVibeDir) {
694
+ errors.push(
695
+ 'VIBE_CONFIG_PATH is set but HOST_VIBE_DIR is not. Set HOST_VIBE_DIR to the host path of your .vibe directory.'
696
+ );
697
+ }
698
+ 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
+ if (vibeEnabled || cfg.hostVibePromptCacheDir) {
707
+ const invalid = validateDockerBindPath('HOST_VIBE_PROMPT_CACHE_DIR', cfg.hostVibePromptCacheDir)
708
+ || validateDockerBindPath('VIBE_PROMPT_CACHE_DIR', cfg.vibePromptCacheDir, { containerPath: true });
709
+ if (invalid) {
710
+ errors.push(invalid);
711
+ } else if (cfg.hostVibePromptCacheDir && cfg.validateHostPaths) {
712
+ if (!existsSync(cfg.hostVibePromptCacheDir)) {
713
+ errors.push(`HOST_VIBE_PROMPT_CACHE_DIR (${cfg.hostVibePromptCacheDir}) does not exist. Create it: mkdir -p ${cfg.hostVibePromptCacheDir}`);
714
+ } else {
715
+ try {
716
+ accessSync(cfg.hostVibePromptCacheDir, fsConstants.W_OK);
717
+ } catch {
718
+ errors.push(`HOST_VIBE_PROMPT_CACHE_DIR (${cfg.hostVibePromptCacheDir}) is not writable.`);
719
+ }
720
+ }
721
+ }
722
+ }
723
+
724
+ const credentialDirs = [
725
+ ['HOST_CLAUDE_DIR', cfg.hostClaudeDir],
726
+ ['HOST_CODEX_DIR', cfg.hostCodexDir],
727
+ ['HOST_ANTIGRAVITY_DIR', cfg.hostAntigravityDir],
728
+ ['HOST_OPENCODE_LEGACY_DIR', cfg.hostOpencodeLegacyDir],
729
+ ['HOST_OPENCODE_XDG_DIR', cfg.hostOpencodeXdgDir],
730
+ ['HOST_OPENCODE_DATA_DIR', cfg.hostOpencodeDataDir],
731
+ ['HOST_VIBE_DIR', cfg.hostVibeDir],
732
+ ];
733
+ const invalidCredential = credentialDirs
734
+ .map(([name, value]) => (value ? validateDockerBindPath(name, value) : null))
735
+ .find(Boolean);
736
+ if (invalidCredential) errors.push(invalidCredential);
737
+
738
+ if (cfg.hostGhPrivateKey) {
739
+ const invalidKeyPath = validateDockerBindPath('HOST_GH_PRIVATE_KEY', cfg.hostGhPrivateKey);
740
+ if (invalidKeyPath) {
741
+ errors.push(invalidKeyPath);
742
+ } else if (cfg.validateHostPaths && !isReadableFile(cfg.hostGhPrivateKey)) {
743
+ errors.push(`HOST_GH_PRIVATE_KEY (${cfg.hostGhPrivateKey}) is not a readable file.`);
744
+ }
745
+ }
746
+
747
+ const hasOpenCodeConfig = Boolean(cfg.hostOpencodeXdgDir || cfg.hostOpencodeLegacyDir);
748
+ if (hasOpenCodeConfig && !cfg.hostOpencodeDataDir) {
749
+ warnings.push(
750
+ 'OpenCode config is mounted but HOST_OPENCODE_DATA_DIR is not set. ' +
751
+ 'Set it to ~/.local/share/opencode if authenticated runs cannot see credentials.'
752
+ );
753
+ }
754
+
755
+ return { ok: errors.length === 0, errors, warnings };
756
+ }
757
+
758
+ /**
759
+ * Pull every image from the manifest that is not already present locally.
760
+ * Mirrors the launcher's agent-image leniency (skip/strict via env flags).
761
+ */
762
+ export function pullImages(cfg, { onLog = () => {}, env = process.env } = {}) {
763
+ const skipAgentPull = env.PROPR_SKIP_AGENT_PULL === 'true' || env.PROPR_SKIP_AGENT_PULL === '1';
764
+ const strictAgentPull = env.PROPR_STRICT_AGENT_PULL !== 'false' && env.PROPR_STRICT_AGENT_PULL !== '0';
765
+ onLog('pulling images…');
766
+ const failedAgentImages = [];
767
+
768
+ for (const [key, tag] of Object.entries(cfg.images)) {
769
+ if (key === 'docs' && !cfg.docsEnabled) continue;
770
+
771
+ if (key.startsWith('agent-') && skipAgentPull) {
772
+ if (imagePresentLocally(tag)) {
773
+ onLog(` · ${tag} (local, pull skipped via PROPR_SKIP_AGENT_PULL)`);
774
+ tagAgentLatest(key, tag);
775
+ } else {
776
+ onLog(` · ${tag} (not found locally, pull skipped via PROPR_SKIP_AGENT_PULL)`);
777
+ }
778
+ continue;
779
+ }
780
+
781
+ if (imagePresentLocally(tag)) {
782
+ onLog(` · ${tag} (local)`);
783
+ tagAgentLatest(key, tag);
784
+ continue;
785
+ }
786
+
787
+ onLog(` · ${tag}`);
788
+ const pulled = docker(['pull', tag], { capture: key.startsWith('agent-') });
789
+ if (key.startsWith('agent-') && pulled.status !== 0) {
790
+ failedAgentImages.push(tag);
791
+ onLog(` · ${tag} (pull failed — jobs using this agent will fail until the image is available)`);
792
+ continue;
793
+ }
794
+ tagAgentLatest(key, tag);
795
+ }
796
+
797
+ return { failedAgentImages, strictAgentPull };
798
+ }