@paparats/cli 0.3.1 → 0.4.0

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 (46) hide show
  1. package/dist/commands/edit.d.ts +30 -0
  2. package/dist/commands/edit.d.ts.map +1 -0
  3. package/dist/commands/edit.js +131 -0
  4. package/dist/commands/edit.js.map +1 -0
  5. package/dist/commands/groups.d.ts.map +1 -1
  6. package/dist/commands/groups.js +1 -1
  7. package/dist/commands/groups.js.map +1 -1
  8. package/dist/commands/install.d.ts +64 -21
  9. package/dist/commands/install.d.ts.map +1 -1
  10. package/dist/commands/install.js +337 -294
  11. package/dist/commands/install.js.map +1 -1
  12. package/dist/commands/lifecycle.d.ts +17 -0
  13. package/dist/commands/lifecycle.d.ts.map +1 -0
  14. package/dist/commands/lifecycle.js +86 -0
  15. package/dist/commands/lifecycle.js.map +1 -0
  16. package/dist/commands/projects.d.ts +81 -0
  17. package/dist/commands/projects.d.ts.map +1 -0
  18. package/dist/commands/projects.js +321 -0
  19. package/dist/commands/projects.js.map +1 -0
  20. package/dist/docker-compose-generator.d.ts +30 -24
  21. package/dist/docker-compose-generator.d.ts.map +1 -1
  22. package/dist/docker-compose-generator.js +58 -81
  23. package/dist/docker-compose-generator.js.map +1 -1
  24. package/dist/index.js +19 -7
  25. package/dist/index.js.map +1 -1
  26. package/dist/projects-yml.d.ts +95 -0
  27. package/dist/projects-yml.d.ts.map +1 -0
  28. package/dist/projects-yml.js +157 -0
  29. package/dist/projects-yml.js.map +1 -0
  30. package/package.json +2 -2
  31. package/dist/commands/index-cmd.d.ts +0 -75
  32. package/dist/commands/index-cmd.d.ts.map +0 -1
  33. package/dist/commands/index-cmd.js +0 -240
  34. package/dist/commands/index-cmd.js.map +0 -1
  35. package/dist/commands/init.d.ts +0 -28
  36. package/dist/commands/init.d.ts.map +0 -1
  37. package/dist/commands/init.js +0 -316
  38. package/dist/commands/init.js.map +0 -1
  39. package/dist/commands/watch.d.ts +0 -33
  40. package/dist/commands/watch.d.ts.map +0 -1
  41. package/dist/commands/watch.js +0 -358
  42. package/dist/commands/watch.js.map +0 -1
  43. package/dist/lsp-installers.d.ts +0 -28
  44. package/dist/lsp-installers.d.ts.map +0 -1
  45. package/dist/lsp-installers.js +0 -125
  46. package/dist/lsp-installers.js.map +0 -1
@@ -5,10 +5,10 @@ import os from 'os';
5
5
  import { execSync, spawn } from 'child_process';
6
6
  import chalk from 'chalk';
7
7
  import ora from 'ora';
8
- import { confirm, input } from '@inquirer/prompts';
8
+ import { confirm, input, select } from '@inquirer/prompts';
9
9
  import { createTimeoutSignal } from '../abort.js';
10
- import { generateDockerCompose, generateServerCompose } from '../docker-compose-generator.js';
11
- const PAPARATS_HOME = path.join(os.homedir(), '.paparats');
10
+ import { generateCompose } from '../docker-compose-generator.js';
11
+ import { PAPARATS_HOME, COMPOSE_YML, PROJECTS_YML, migrateLegacyProjectsFile, readProjectsFile, writeProjectsFile, writeInstallState, localProjectsFor, } from '../projects-yml.js';
12
12
  const MODELS_DIR = path.join(PAPARATS_HOME, 'models');
13
13
  const OLLAMA_MODEL_NAME = 'jina-code-embeddings';
14
14
  const GGUF_URL = 'https://huggingface.co/jinaai/jina-code-embeddings-1.5b-GGUF/resolve/main/jina-code-embeddings-1.5b-Q8_0.gguf';
@@ -203,9 +203,145 @@ async function ensureLocalOllama(deps, cleanupTasks) {
203
203
  throw err;
204
204
  }
205
205
  }
206
- // ── Developer mode ──────────────────────────────────────────────────────────
207
- async function runDeveloperInstall(opts, deps) {
208
- const ollamaMode = opts.ollamaMode ?? 'local';
206
+ // ── Migration ───────────────────────────────────────────────────────────────
207
+ /**
208
+ * Detects a v1 install: legacy compose has `paparats-mcp` container without
209
+ * `paparats-indexer` service.
210
+ */
211
+ export function detectLegacyInstall(composeContent) {
212
+ if (!composeContent)
213
+ return null;
214
+ if (composeContent.includes('container_name: paparats-indexer'))
215
+ return null;
216
+ if (composeContent.includes('container_name: paparats-mcp') ||
217
+ composeContent.includes("container_name: 'paparats-mcp'")) {
218
+ return 'legacy compose has paparats-mcp without paparats-indexer';
219
+ }
220
+ return null;
221
+ }
222
+ /**
223
+ * Get the user's consent to migrate from a v1 install. Does NOT touch any
224
+ * file on disk — the actual tear-down happens later, after the replacement
225
+ * compose has been generated and written. This split prevents the
226
+ * "interrupt-mid-install" hole where we used to delete the legacy compose
227
+ * before knowing whether we could write a successor.
228
+ */
229
+ async function confirmMigration(resolvedDeps, opts) {
230
+ console.log(chalk.yellow.bold('\nLegacy install detected.\n') +
231
+ 'Paparats has switched to a single global install with one docker-compose.yml and a\n' +
232
+ 'project list at ~/.paparats/projects.yml. Per-project `paparats init` and the\n' +
233
+ '`developer` / `server` install modes are no longer used.\n\n' +
234
+ 'Existing data volumes (qdrant_data, paparats_data, indexer_repos) are\n' +
235
+ 'preserved — your indexed projects survive. The legacy compose and .env will be\n' +
236
+ 'backed up to *.legacy.bak and replaced once the new compose is ready.\n');
237
+ if (opts.force)
238
+ return true;
239
+ if (opts.nonInteractive) {
240
+ throw new Error('Migration prompt required but --non-interactive set; pass --force to proceed.');
241
+ }
242
+ const promptMigrate = resolvedDeps.promptMigrate ??
243
+ (() => confirm({ message: 'Continue migration?', default: false }));
244
+ const proceed = await promptMigrate();
245
+ if (!proceed) {
246
+ console.log(chalk.dim('Migration aborted, no changes made.'));
247
+ }
248
+ return proceed;
249
+ }
250
+ /**
251
+ * Tear down the legacy stack and back up its compose+env. Backups stay on
252
+ * disk as `<name>.legacy.bak` so the user has a recovery path if anything
253
+ * downstream goes wrong; we only clean them up after a fully successful
254
+ * install. Returns the backup paths that were actually created.
255
+ */
256
+ export function tearDownAndBackupLegacy(composePath, envPath, resolvedDeps, opts) {
257
+ // Best-effort: stop the legacy stack. A failure here doesn't block the
258
+ // upgrade — the user may already have stopped it manually, or the daemon
259
+ // may be unreachable; either way, the new compose can still be written.
260
+ try {
261
+ resolvedDeps.execSync(`${resolvedDeps.getDockerComposeCommand()} -f "${composePath}" down`, {
262
+ stdio: opts.verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
263
+ timeout: 60_000,
264
+ });
265
+ }
266
+ catch {
267
+ // user may have already stopped it; ignore
268
+ }
269
+ let composeBak = null;
270
+ let envBak = null;
271
+ if (resolvedDeps.existsSync(composePath)) {
272
+ composeBak = `${composePath}.legacy.bak`;
273
+ resolvedDeps.renameSync(composePath, composeBak);
274
+ }
275
+ if (resolvedDeps.existsSync(envPath)) {
276
+ envBak = `${envPath}.legacy.bak`;
277
+ resolvedDeps.renameSync(envPath, envBak);
278
+ }
279
+ return { composeBak, envBak };
280
+ }
281
+ export async function decideOllamaMode(opts, deps) {
282
+ if (opts.ollamaUrl) {
283
+ return { mode: 'external', ollamaUrl: opts.ollamaUrl, setupHostOllama: false };
284
+ }
285
+ if (opts.ollamaMode === 'docker') {
286
+ return { mode: 'docker', setupHostOllama: false };
287
+ }
288
+ if (opts.ollamaMode === 'native') {
289
+ return { mode: 'native', setupHostOllama: true };
290
+ }
291
+ const platform = deps.platform();
292
+ if (platform === 'darwin') {
293
+ if (deps.commandExists('ollama')) {
294
+ console.log(chalk.green('✓ Native Ollama detected on macOS\n'));
295
+ return { mode: 'native', setupHostOllama: true };
296
+ }
297
+ console.log(chalk.yellow('Ollama is not installed.\n') +
298
+ chalk.dim('Recommendation for macOS: install Ollama natively. Running Ollama in Docker on\n' +
299
+ 'macOS is significantly slower because the Docker VM cannot use Apple Silicon\n' +
300
+ 'GPU acceleration. Native Ollama uses Metal directly.\n'));
301
+ if (opts.nonInteractive) {
302
+ throw new Error('Ollama not found. Install with `brew install ollama`, or pass --ollama-mode docker / --ollama-url <url>.');
303
+ }
304
+ const choice = deps.promptOllamaChoiceMacOs
305
+ ? await deps.promptOllamaChoiceMacOs()
306
+ : await select({
307
+ message: 'How should Paparats reach Ollama?',
308
+ choices: [
309
+ { name: 'Install natively via brew install ollama (recommended)', value: 'brew' },
310
+ { name: 'Use a remote Ollama URL', value: 'remote' },
311
+ { name: 'Run Ollama in Docker (slower on macOS)', value: 'docker' },
312
+ ],
313
+ default: 'brew',
314
+ });
315
+ if (choice === 'brew') {
316
+ if (!deps.commandExists('brew')) {
317
+ throw new Error('Homebrew not found. Install brew first (https://brew.sh) or pass --ollama-mode docker / --ollama-url.');
318
+ }
319
+ const spinner = ora('Installing ollama via brew...').start();
320
+ try {
321
+ deps.execSync('brew install ollama', {
322
+ stdio: opts.verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
323
+ timeout: 180_000,
324
+ });
325
+ spinner.succeed('ollama installed');
326
+ }
327
+ catch (err) {
328
+ spinner.fail('brew install ollama failed');
329
+ throw err;
330
+ }
331
+ return { mode: 'native', setupHostOllama: true };
332
+ }
333
+ if (choice === 'remote') {
334
+ const url = deps.promptRemoteOllamaUrl
335
+ ? await deps.promptRemoteOllamaUrl()
336
+ : await input({ message: 'Remote Ollama URL:' });
337
+ return { mode: 'external', ollamaUrl: url, setupHostOllama: false };
338
+ }
339
+ return { mode: 'docker', setupHostOllama: false };
340
+ }
341
+ return { mode: 'docker', setupHostOllama: false };
342
+ }
343
+ // ── Unified install ─────────────────────────────────────────────────────────
344
+ async function runUnifiedInstall(opts, deps) {
209
345
  const cleanupTasks = [];
210
346
  if (deps.signal) {
211
347
  deps.signal.addEventListener('abort', () => {
@@ -221,216 +357,172 @@ async function runDeveloperInstall(opts, deps) {
221
357
  process.exit(130);
222
358
  });
223
359
  }
224
- // Check prerequisites
225
- const checks = [
226
- { cmd: 'docker', name: 'Docker', install: 'https://docker.com' },
227
- ];
228
- if (ollamaMode === 'local' && !opts.ollamaUrl) {
229
- checks.push({ cmd: 'ollama', name: 'Ollama', install: 'https://ollama.com' });
360
+ // 1. Prerequisites
361
+ if (!deps.commandExists('docker')) {
362
+ throw new Error('Docker not found. Install from https://docker.com');
230
363
  }
231
- for (const check of checks) {
232
- if (!deps.commandExists(check.cmd)) {
233
- throw new Error(`${check.name} not found. Install from ${check.install}`);
364
+ deps.getDockerComposeCommand();
365
+ console.log(chalk.green('✓ Docker + docker compose found\n'));
366
+ // 2. Migration check
367
+ deps.mkdirSync(PAPARATS_HOME);
368
+ const composePath = path.join(PAPARATS_HOME, COMPOSE_YML);
369
+ const envPath = path.join(PAPARATS_HOME, '.env');
370
+ const composeContent = deps.existsSync(composePath)
371
+ ? deps.readFileSync(composePath, 'utf8')
372
+ : null;
373
+ const legacyTrigger = detectLegacyInstall(composeContent);
374
+ let needsLegacyTeardown = false;
375
+ if (legacyTrigger) {
376
+ const proceeded = await confirmMigration(deps, opts);
377
+ if (!proceeded)
378
+ return;
379
+ // Defer the actual tear-down until we have the new compose generated
380
+ // and ready to write — see step 7c below.
381
+ needsLegacyTeardown = true;
382
+ }
383
+ // 3. Ollama decision
384
+ const ollamaDecision = await decideOllamaMode(opts, deps);
385
+ // 4. Qdrant decision
386
+ if (!opts.qdrantUrl && !opts.nonInteractive) {
387
+ const promptExternal = deps.promptUseExternalQdrant ??
388
+ (() => confirm({
389
+ message: 'Use an external Qdrant instance? (skip Qdrant Docker container)',
390
+ default: false,
391
+ }));
392
+ const useExternal = await promptExternal();
393
+ if (useExternal) {
394
+ const promptUrl = deps.promptQdrantUrl ??
395
+ (() => input({
396
+ message: 'Qdrant URL:',
397
+ default: 'http://localhost:6333',
398
+ validate: (value) => {
399
+ try {
400
+ new URL(value);
401
+ return true;
402
+ }
403
+ catch {
404
+ return 'Please enter a valid URL';
405
+ }
406
+ },
407
+ }));
408
+ opts.qdrantUrl = await promptUrl();
234
409
  }
235
410
  }
236
- const prereqNames = checks.map((c) => c.name).join(', ');
237
- console.log(chalk.green(`\u2713 Prerequisites found (${prereqNames})\n`));
238
- // Docker setup
239
- if (!opts.skipDocker) {
240
- const spinner = ora('Setting up Docker containers...').start();
241
- deps.mkdirSync(PAPARATS_HOME);
242
- const composeContent = deps.generateDockerCompose({
243
- ollamaMode,
244
- qdrantUrl: opts.qdrantUrl,
245
- qdrantApiKey: opts.qdrantApiKey,
246
- ollamaUrl: opts.ollamaUrl,
247
- });
248
- const composeDest = path.join(PAPARATS_HOME, 'docker-compose.yml');
249
- if (deps.existsSync(composeDest)) {
250
- const existing = deps.readFileSync(composeDest, 'utf8');
251
- if (existing !== composeContent) {
252
- const overwrite = process.stdin.isTTY
253
- ? await (async () => {
254
- spinner.stop();
255
- const result = await confirm({
256
- message: `${composeDest} already exists and differs. Overwrite?`,
257
- default: true,
258
- });
259
- spinner.start('Starting Docker containers...');
260
- return result;
261
- })()
262
- : true;
263
- if (!overwrite) {
264
- console.log(chalk.dim('Keeping existing docker-compose.yml'));
265
- }
266
- else {
267
- deps.writeFileSync(composeDest, composeContent);
268
- }
269
- }
270
- }
271
- else {
272
- deps.writeFileSync(composeDest, composeContent);
273
- }
274
- // Write .env for docker-compose variable substitution (API key, etc.)
275
- if (opts.qdrantApiKey) {
276
- const envPath = path.join(PAPARATS_HOME, '.env');
277
- const envLines = [];
278
- envLines.push(`QDRANT_API_KEY=${opts.qdrantApiKey}`);
279
- deps.writeFileSync(envPath, envLines.join('\n') + '\n');
280
- }
281
- spinner.text = 'Starting Docker containers...';
282
- const composeCmd = deps.getDockerComposeCommand();
283
- const fullCmd = `${composeCmd} -f "${composeDest}" up -d`;
284
- try {
285
- execSync(fullCmd, {
286
- stdio: opts.verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
287
- timeout: 120_000,
288
- });
289
- spinner.succeed('Docker containers started');
290
- }
291
- catch (err) {
292
- spinner.fail('Failed to start Docker containers');
293
- throw err;
294
- }
295
- const qdrantReady = await deps.waitForHealth(qdrantHealthUrl(opts.qdrantUrl), 'Qdrant');
296
- if (!qdrantReady) {
297
- console.log(chalk.yellow(' Qdrant is not responding yet. Continuing with remaining setup.\n' +
298
- ' Check Docker logs: docker compose -f ~/.paparats/docker-compose.yml logs qdrant'));
299
- }
300
- const mcpReady = await deps.waitForHealth('http://localhost:9876/health', 'MCP server');
301
- if (!mcpReady) {
302
- console.log(chalk.yellow(' MCP server is not responding yet. Continuing with remaining setup.\n' +
303
- ' Check Docker logs: docker compose -f ~/.paparats/docker-compose.yml logs paparats'));
411
+ if (opts.qdrantUrl && !opts.qdrantApiKey && !opts.nonInteractive) {
412
+ const promptApiKey = deps.promptQdrantApiKey ??
413
+ (() => input({ message: 'Qdrant API key (leave empty if none):', default: '' }));
414
+ const key = await promptApiKey();
415
+ if (key)
416
+ opts.qdrantApiKey = key;
417
+ }
418
+ // 5. Ensure projects.yml exists. Migrate the legacy paparats-indexer.yml
419
+ // in place if present so users coming from paparats < 0.4 don't lose
420
+ // their project list.
421
+ if (migrateLegacyProjectsFile(PAPARATS_HOME)) {
422
+ console.log(chalk.yellow(`Renamed legacy paparats-indexer.yml → ${PROJECTS_YML} (one-time migration).`));
423
+ }
424
+ const projectsYmlPath = path.join(PAPARATS_HOME, PROJECTS_YML);
425
+ if (!deps.existsSync(projectsYmlPath)) {
426
+ writeProjectsFile({ repos: [] }, PAPARATS_HOME);
427
+ console.log(chalk.dim(`Created empty ${projectsYmlPath}`));
428
+ }
429
+ // 6. Generate compose using current projects list
430
+ const projectsFile = readProjectsFile(PAPARATS_HOME);
431
+ const newComposeContent = deps.generateCompose({
432
+ ollamaMode: ollamaDecision.mode,
433
+ ...(ollamaDecision.ollamaUrl !== undefined ? { ollamaUrl: ollamaDecision.ollamaUrl } : {}),
434
+ ...(opts.qdrantUrl !== undefined ? { qdrantUrl: opts.qdrantUrl } : {}),
435
+ ...(opts.qdrantApiKey !== undefined ? { qdrantApiKey: opts.qdrantApiKey } : {}),
436
+ paparatsHome: PAPARATS_HOME,
437
+ localProjects: localProjectsFor(projectsFile),
438
+ });
439
+ // 7. Tear down the legacy stack and back up its compose+env now that we
440
+ // have a validated replacement in memory. Backups stay on disk under
441
+ // *.legacy.bak so the user has a recovery path; we keep them around
442
+ // even after success — `paparats install` is idempotent and a stray
443
+ // pair of bak files is cheaper than a missed rollback opportunity.
444
+ if (needsLegacyTeardown) {
445
+ const { composeBak, envBak } = tearDownAndBackupLegacy(composePath, envPath, deps, opts);
446
+ if (composeBak) {
447
+ console.log(chalk.dim(`Backed up legacy compose to ${composeBak}` + (envBak ? ` and .env to ${envBak}` : '')));
304
448
  }
305
449
  }
306
- // Ollama setup (local mode only, skip when external URL is provided)
307
- if (!opts.skipOllama && ollamaMode === 'local' && !opts.ollamaUrl) {
308
- await ensureLocalOllama(deps, cleanupTasks);
309
- }
310
- // Auto-configure Cursor MCP
311
- configureCursorMcp('http://localhost:9876/mcp', deps);
312
- console.log(chalk.bold.green('\n\u2713 Installation complete!\n'));
313
- console.log('Next steps:');
314
- console.log(chalk.dim(' 1. cd <your-project>'));
315
- console.log(chalk.dim(' 2. paparats init'));
316
- console.log(chalk.dim(' 3. paparats index'));
317
- console.log(chalk.dim(' 4. Connect your IDE (see README)'));
318
- console.log('');
319
- console.log(chalk.dim('To scope searches to specific projects, set PAPARATS_PROJECTS in your'));
320
- console.log(chalk.dim('MCP client config (e.g. "billing,tracking"). Project names are directory'));
321
- console.log(chalk.dim('basenames, not org/repo format.\n'));
322
- }
323
- // ── Server mode ─────────────────────────────────────────────────────────────
324
- async function runServerInstall(opts, deps) {
325
- const ollamaMode = opts.ollamaMode ?? 'docker';
326
- const needsLocalOllama = ollamaMode === 'local' && !opts.ollamaUrl;
327
- // Check prerequisites
328
- const checks = [
329
- { cmd: 'docker', name: 'Docker', install: 'https://docker.com' },
330
- ];
331
- if (needsLocalOllama) {
332
- checks.push({ cmd: 'ollama', name: 'Ollama', install: 'https://ollama.com' });
333
- }
334
- for (const check of checks) {
335
- if (!deps.commandExists(check.cmd)) {
336
- throw new Error(`${check.name} not found. Install from ${check.install}`);
450
+ // 7a. Compose write with overwrite confirmation (default N)
451
+ const existingCompose = deps.existsSync(composePath)
452
+ ? deps.readFileSync(composePath, 'utf8')
453
+ : null;
454
+ let writeCompose = true;
455
+ if (existingCompose !== null && existingCompose !== newComposeContent) {
456
+ if (opts.force) {
457
+ writeCompose = true;
337
458
  }
338
- }
339
- const prereqNames = checks.map((c) => c.name).join(', ');
340
- console.log(chalk.green(`\u2713 Prerequisites found (${prereqNames})\n`));
341
- deps.mkdirSync(PAPARATS_HOME);
342
- // Generate docker-compose with all services
343
- const composeContent = deps.generateServerCompose({
344
- ollamaMode,
345
- qdrantUrl: opts.qdrantUrl,
346
- qdrantApiKey: opts.qdrantApiKey,
347
- ollamaUrl: opts.ollamaUrl,
348
- repos: opts.repos,
349
- githubToken: opts.githubToken,
350
- cron: opts.cron,
351
- group: opts.group,
352
- });
353
- const composeDest = path.join(PAPARATS_HOME, 'docker-compose.yml');
354
- if (deps.existsSync(composeDest)) {
355
- const existing = deps.readFileSync(composeDest, 'utf8');
356
- if (existing !== composeContent) {
357
- const overwrite = process.stdin.isTTY
358
- ? await confirm({
359
- message: `${composeDest} already exists and differs. Overwrite?`,
360
- default: true,
361
- })
362
- : true;
363
- if (!overwrite) {
364
- console.log(chalk.dim('Keeping existing docker-compose.yml'));
365
- }
366
- else {
367
- deps.writeFileSync(composeDest, composeContent);
459
+ else if (opts.nonInteractive) {
460
+ throw new Error('docker-compose.yml differs; pass --force to overwrite or run without --non-interactive.');
461
+ }
462
+ else {
463
+ const promptOverwrite = deps.promptOverwriteCompose ??
464
+ (() => confirm({
465
+ message: `${composePath} has been hand-edited. Overwrite?`,
466
+ default: false,
467
+ }));
468
+ writeCompose = await promptOverwrite();
469
+ if (!writeCompose) {
470
+ console.log(chalk.dim('Existing compose preserved. Run `paparats install --force` to regenerate.\n'));
368
471
  }
369
472
  }
370
473
  }
371
- else {
372
- deps.writeFileSync(composeDest, composeContent);
373
- }
374
- // Create .env file for docker-compose variable substitution
375
- const envLines = [];
376
- if (opts.repos)
377
- envLines.push(`REPOS=${opts.repos}`);
378
- if (opts.githubToken)
379
- envLines.push(`GITHUB_TOKEN=${opts.githubToken}`);
380
- if (opts.cron)
381
- envLines.push(`CRON=${opts.cron}`);
382
- if (opts.group)
383
- envLines.push(`PAPARATS_GROUP=${opts.group}`);
384
- if (opts.qdrantApiKey)
385
- envLines.push(`QDRANT_API_KEY=${opts.qdrantApiKey}`);
386
- if (envLines.length > 0) {
387
- const envPath = path.join(PAPARATS_HOME, '.env');
388
- deps.writeFileSync(envPath, envLines.join('\n') + '\n');
389
- console.log(chalk.dim(`Created ${envPath}`));
390
- }
391
- // Start containers
392
- const spinner = ora('Starting Docker containers (this may take a while on first run)...').start();
474
+ if (writeCompose) {
475
+ deps.writeFileSync(composePath, newComposeContent);
476
+ }
477
+ // 7b. Persist install state so `paparats add | remove | edit projects` can
478
+ // regenerate compose later with the same flags (ollama mode, qdrant
479
+ // credentials, cron). Without this, those commands have no idea which
480
+ // services should be in the compose.
481
+ writeInstallState({
482
+ ollamaMode: ollamaDecision.mode,
483
+ ...(ollamaDecision.ollamaUrl !== undefined ? { ollamaUrl: ollamaDecision.ollamaUrl } : {}),
484
+ ...(opts.qdrantUrl !== undefined ? { qdrantUrl: opts.qdrantUrl } : {}),
485
+ ...(opts.qdrantApiKey !== undefined ? { qdrantApiKey: opts.qdrantApiKey } : {}),
486
+ }, PAPARATS_HOME);
487
+ // 8. .env file
488
+ if (opts.qdrantApiKey) {
489
+ deps.writeFileSync(envPath, `QDRANT_API_KEY=${opts.qdrantApiKey}\n`);
490
+ }
491
+ // 9. Bring up the stack
393
492
  const composeCmd = deps.getDockerComposeCommand();
394
- const fullCmd = `${composeCmd} -f "${composeDest}" up -d`;
493
+ const upSpinner = ora('Starting Docker containers...').start();
395
494
  try {
396
- execSync(fullCmd, {
495
+ execSync(`${composeCmd} -f "${composePath}" up -d`, {
397
496
  stdio: opts.verbose ? 'inherit' : ['pipe', 'pipe', 'pipe'],
398
- timeout: 300_000,
497
+ timeout: 180_000,
399
498
  });
400
- spinner.succeed('Docker containers started');
499
+ upSpinner.succeed('Docker containers started');
401
500
  }
402
501
  catch (err) {
403
- spinner.fail('Failed to start Docker containers');
502
+ upSpinner.fail('Failed to start Docker containers');
404
503
  throw err;
405
504
  }
406
- const qdrantReady = await deps.waitForHealth(qdrantHealthUrl(opts.qdrantUrl), 'Qdrant');
407
- if (!qdrantReady) {
408
- console.log(chalk.yellow(' Qdrant is not responding yet. Continuing with remaining setup.\n' +
409
- ' Check Docker logs: docker compose -f ~/.paparats/docker-compose.yml logs qdrant'));
410
- }
411
- const mcpReady = await deps.waitForHealth('http://localhost:9876/health', 'MCP server');
412
- if (!mcpReady) {
413
- console.log(chalk.yellow(' MCP server is not responding yet. Continuing with remaining setup.\n' +
414
- ' Check Docker logs: docker compose -f ~/.paparats/docker-compose.yml logs paparats'));
415
- }
416
- // Ollama setup (local mode only, skip when external URL or Docker Ollama)
417
- if (needsLocalOllama) {
418
- await ensureLocalOllama(deps, []);
419
- }
420
- console.log(chalk.bold.green('\n\u2713 Server installation complete!\n'));
421
- console.log('MCP endpoints:');
422
- console.log(chalk.dim(' Coding: http://localhost:9876/mcp'));
423
- console.log(chalk.dim(' Support: http://localhost:9876/support/mcp'));
424
- console.log(chalk.dim(' Health: http://localhost:9876/health'));
425
- if (opts.repos) {
426
- console.log(chalk.dim(`\nIndexer will process repos on schedule: ${opts.cron ?? '0 */6 * * *'}`));
427
- console.log(chalk.dim(' Trigger now: curl -X POST http://localhost:9877/trigger'));
428
- console.log(chalk.dim(' Status: curl http://localhost:9877/health'));
505
+ await deps.waitForHealth(qdrantHealthUrl(opts.qdrantUrl), 'Qdrant');
506
+ await deps.waitForHealth('http://localhost:9876/health', 'MCP server');
507
+ await deps.waitForHealth('http://localhost:9877/health', 'Indexer');
508
+ // 10. Ollama model registration on host (when native)
509
+ if (ollamaDecision.setupHostOllama) {
510
+ await ensureLocalOllama(deps, cleanupTasks);
429
511
  }
512
+ // 11. MCP-client wiring
513
+ configureCursorMcp('http://localhost:9876/mcp', deps);
514
+ // 12. Final summary
515
+ console.log(chalk.bold.green('\n✓ Installation complete!\n'));
516
+ console.log('Next steps:');
517
+ console.log(chalk.dim(` • Add a project: paparats add <path-or-repo>`));
518
+ console.log(chalk.dim(` • List projects: paparats list`));
519
+ console.log(chalk.dim(` • Edit project list: paparats edit projects`));
520
+ console.log(chalk.dim(` • Edit compose: paparats edit compose`));
521
+ console.log(chalk.dim(` • Stack lifecycle: paparats start | stop | restart\n`));
522
+ console.log(chalk.dim('MCP endpoints:'));
523
+ console.log(chalk.dim(' http://localhost:9876/mcp'));
524
+ console.log(chalk.dim(' http://localhost:9876/support/mcp'));
430
525
  console.log('');
431
- console.log(chalk.dim('To scope searches to specific projects, set PAPARATS_PROJECTS env var'));
432
- console.log(chalk.dim('on the MCP server (e.g. PAPARATS_PROJECTS=billing,tracking). Project'));
433
- console.log(chalk.dim('names are directory basenames, not org/repo format.\n'));
434
526
  }
435
527
  // ── Support mode ────────────────────────────────────────────────────────────
436
528
  async function runSupportInstall(opts, deps) {
@@ -497,114 +589,65 @@ function configureCursorMcp(mcpUrl, deps, serverName = 'paparats') {
497
589
  }
498
590
  // ── Main entry point ────────────────────────────────────────────────────────
499
591
  export async function runInstall(opts, deps) {
500
- const cmdExists = deps?.commandExists ?? commandExists;
501
- const getCompose = deps?.getDockerComposeCommand ?? getDockerComposeCommand;
502
- const modelExists = deps?.ollamaModelExists ?? ollamaModelExists;
503
- const ollamaRunning = deps?.isOllamaRunning ?? isOllamaRunning;
504
- const waitHealth = deps?.waitForHealth ?? waitForHealth;
505
- const download = deps?.downloadFile ?? downloadFile;
506
- const genCompose = deps?.generateDockerCompose ?? generateDockerCompose;
507
- const genServerCompose = deps?.generateServerCompose ?? generateServerCompose;
508
- const mkdir = deps?.mkdirSync ?? ((p) => fs.mkdirSync(p, { recursive: true }));
509
- const readFile = deps?.readFileSync ?? ((p) => fs.readFileSync(p, 'utf8'));
510
- const writeFile = deps?.writeFileSync ?? fs.writeFileSync.bind(fs);
511
- const exists = deps?.existsSync ?? fs.existsSync.bind(fs);
512
- const unlink = deps?.unlinkSync ?? fs.unlinkSync.bind(fs);
513
- const signal = deps?.signal;
514
- const mode = opts.mode ?? 'developer';
515
- console.log(chalk.bold(`\npaparats install --mode ${mode}\n`));
516
- // Interactive Qdrant prompt (developer & server modes, when not already provided via CLI flag)
517
- if ((mode === 'developer' || mode === 'server') && !opts.qdrantUrl && !opts.skipDocker) {
518
- const promptExternal = deps?.promptUseExternalQdrant ??
519
- (() => confirm({
520
- message: 'Use an external Qdrant instance? (skip Qdrant Docker container)',
521
- default: false,
522
- }));
523
- const useExternal = await promptExternal();
524
- if (useExternal) {
525
- const promptUrl = deps?.promptQdrantUrl ??
526
- (() => input({
527
- message: 'Qdrant URL:',
528
- default: 'http://localhost:6333',
529
- validate: (value) => {
530
- try {
531
- new URL(value);
532
- return true;
533
- }
534
- catch {
535
- return 'Please enter a valid URL (e.g. http://localhost:6333)';
536
- }
537
- },
538
- }));
539
- opts.qdrantUrl = await promptUrl();
540
- }
541
- }
542
- // Prompt for API key when using external Qdrant (via prompt or --qdrant-url flag)
543
- if (opts.qdrantUrl && !opts.qdrantApiKey) {
544
- const promptApiKey = deps?.promptQdrantApiKey ??
545
- (() => input({
546
- message: 'Qdrant API key (leave empty if none):',
547
- default: '',
548
- }));
549
- const apiKey = await promptApiKey();
550
- if (apiKey) {
551
- opts.qdrantApiKey = apiKey;
552
- }
553
- }
554
- const resolvedDeps = {
555
- commandExists: cmdExists,
556
- getDockerComposeCommand: getCompose,
557
- ollamaModelExists: modelExists,
558
- isOllamaRunning: ollamaRunning,
559
- waitForHealth: waitHealth,
560
- downloadFile: download,
561
- generateDockerCompose: genCompose,
562
- generateServerCompose: genServerCompose,
563
- mkdirSync: mkdir,
564
- readFileSync: readFile,
565
- writeFileSync: writeFile,
566
- existsSync: exists,
567
- unlinkSync: unlink,
568
- signal,
592
+ const resolved = {
593
+ commandExists: deps?.commandExists ?? commandExists,
594
+ getDockerComposeCommand: deps?.getDockerComposeCommand ?? getDockerComposeCommand,
595
+ ollamaModelExists: deps?.ollamaModelExists ?? ollamaModelExists,
596
+ isOllamaRunning: deps?.isOllamaRunning ?? isOllamaRunning,
597
+ waitForHealth: deps?.waitForHealth ?? waitForHealth,
598
+ downloadFile: deps?.downloadFile ?? downloadFile,
599
+ generateCompose: deps?.generateCompose ?? generateCompose,
600
+ mkdirSync: deps?.mkdirSync ?? ((p) => fs.mkdirSync(p, { recursive: true })),
601
+ readFileSync: deps?.readFileSync ?? ((p) => fs.readFileSync(p, 'utf8')),
602
+ writeFileSync: deps?.writeFileSync ?? fs.writeFileSync.bind(fs),
603
+ existsSync: deps?.existsSync ?? fs.existsSync.bind(fs),
604
+ unlinkSync: deps?.unlinkSync ?? fs.unlinkSync.bind(fs),
605
+ renameSync: deps?.renameSync ?? fs.renameSync.bind(fs),
606
+ platform: deps?.platform ?? (() => process.platform),
607
+ execSync: deps?.execSync ?? execSync,
608
+ ...(deps?.signal !== undefined ? { signal: deps.signal } : {}),
609
+ ...(deps?.promptUseExternalQdrant !== undefined
610
+ ? { promptUseExternalQdrant: deps.promptUseExternalQdrant }
611
+ : {}),
612
+ ...(deps?.promptQdrantUrl !== undefined ? { promptQdrantUrl: deps.promptQdrantUrl } : {}),
613
+ ...(deps?.promptQdrantApiKey !== undefined
614
+ ? { promptQdrantApiKey: deps.promptQdrantApiKey }
615
+ : {}),
616
+ ...(deps?.promptOllamaChoiceMacOs !== undefined
617
+ ? { promptOllamaChoiceMacOs: deps.promptOllamaChoiceMacOs }
618
+ : {}),
619
+ ...(deps?.promptRemoteOllamaUrl !== undefined
620
+ ? { promptRemoteOllamaUrl: deps.promptRemoteOllamaUrl }
621
+ : {}),
622
+ ...(deps?.promptOverwriteCompose !== undefined
623
+ ? { promptOverwriteCompose: deps.promptOverwriteCompose }
624
+ : {}),
625
+ ...(deps?.promptMigrate !== undefined ? { promptMigrate: deps.promptMigrate } : {}),
569
626
  };
570
- switch (mode) {
571
- case 'developer':
572
- await runDeveloperInstall(opts, resolvedDeps);
573
- break;
574
- case 'server':
575
- await runServerInstall(opts, resolvedDeps);
576
- break;
577
- case 'support':
578
- await runSupportInstall(opts, resolvedDeps);
579
- break;
580
- default:
581
- throw new Error(`Unknown install mode: ${mode}`);
627
+ const mode = opts.mode ?? 'developer';
628
+ console.log(chalk.bold(`\npaparats install${mode === 'support' ? ' --mode support' : ''}\n`));
629
+ if (mode === 'support') {
630
+ await runSupportInstall(opts, resolved);
631
+ return;
582
632
  }
633
+ await runUnifiedInstall(opts, resolved);
583
634
  }
584
635
  export const installCommand = new Command('install')
585
- .description('Set up Paparats — Docker containers, Ollama model, and MCP configuration')
586
- .option('--mode <mode>', 'Install mode: developer, server, or support', 'developer')
587
- .option('--ollama-mode <mode>', 'Ollama deployment: docker or local (developer/server mode)')
588
- .option('--ollama-url <url>', 'External Ollama URL (e.g. http://192.168.1.10:11434)')
589
- .option('--skip-docker', 'Skip Docker setup (developer mode)')
590
- .option('--skip-ollama', 'Skip Ollama model setup (developer mode)')
591
- .option('--qdrant-url <url>', 'External Qdrant URL (skip Qdrant Docker container)')
636
+ .description('Set up Paparats — Docker stack, Ollama, MCP wiring')
637
+ .option('--mode <mode>', 'support to wire up an MCP client only; otherwise the unified install runs', 'developer')
638
+ .option('--ollama-mode <mode>', 'Force Ollama mode: native | docker (default: native on macOS, docker elsewhere)')
639
+ .option('--ollama-url <url>', 'External Ollama URL (skips both native and docker Ollama)')
640
+ .option('--qdrant-url <url>', 'External Qdrant URL (skips Qdrant Docker container)')
592
641
  .option('--qdrant-api-key <key>', 'Qdrant API key for authenticated access')
593
- .option('--repos <repos>', 'Comma-separated repos to index (server mode)')
594
- .option('--github-token <token>', 'GitHub token for private repos (server mode)')
595
- .option('--cron <expression>', 'Cron schedule for indexing (server mode)')
596
- .option('--group <name>', 'Shared Qdrant group — all repos in one collection (server mode)')
597
642
  .option('--server <url>', 'Server URL to connect to (support mode)', 'http://localhost:9876')
643
+ .option('--force', 'Skip overwrite/migration prompts (always overwrite)')
644
+ .option('--non-interactive', 'Fail on any prompt instead of asking')
598
645
  .option('-v, --verbose', 'Show detailed output')
599
646
  .action(async (opts) => {
600
647
  const controller = new AbortController();
601
648
  process.on('SIGINT', () => controller.abort());
602
649
  try {
603
- await runInstall({
604
- ...opts,
605
- // --ollama-url implies local mode (no Docker Ollama)
606
- ollamaMode: opts.ollamaUrl ? 'local' : (opts.ollamaMode ?? 'local'),
607
- }, { signal: controller.signal });
650
+ await runInstall({ ...opts, ollamaMode: opts.ollamaMode }, { signal: controller.signal });
608
651
  }
609
652
  catch (err) {
610
653
  console.error(chalk.red(err.message));