@nitsan-ai/ragsuite-test 0.1.0 → 0.1.2

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/src/utils/args.js CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  /**
4
4
  * Parse argv into global flags, command, and remaining args.
5
- * Globals: --help/-h, --repo-root, --dry-run, --mode,
6
- * --from-zip, --from-release, --install-dir, --force, --yes
7
5
  */
8
6
  function parseArgv(argv) {
9
7
  const tokens = argv.slice(2);
@@ -12,25 +10,33 @@ function parseArgv(argv) {
12
10
  repoRoot: null,
13
11
  dryRun: false,
14
12
  mode: null,
15
- fromZip: null,
13
+ fromGit: null,
16
14
  fromRelease: null,
17
15
  installDir: null,
18
16
  force: false,
19
17
  yes: false,
20
18
  llmApiKey: null,
19
+ smtpHost: null,
20
+ smtpUser: null,
21
+ smtpPassword: null,
22
+ emailFrom: null,
21
23
  };
22
24
  const rest = [];
23
25
  let command = null;
24
26
 
27
+ function takeValue(flag, t, i) {
28
+ const eq = `${flag}=`;
29
+ if (t.startsWith(eq)) return { value: t.slice(eq.length), nextI: i };
30
+ const value = tokens[i + 1];
31
+ return { value, nextI: i + 1 };
32
+ }
33
+
25
34
  for (let i = 0; i < tokens.length; i += 1) {
26
35
  const t = tokens[i];
27
36
 
28
37
  if (t === '--help' || t === '-h') {
29
- if (command) {
30
- rest.push(t);
31
- } else {
32
- globals.help = true;
33
- }
38
+ if (command) rest.push(t);
39
+ else globals.help = true;
34
40
  continue;
35
41
  }
36
42
 
@@ -50,9 +56,8 @@ function parseArgv(argv) {
50
56
  }
51
57
 
52
58
  if (t === '--repo-root' || t.startsWith('--repo-root=')) {
53
- const value = t.startsWith('--repo-root=')
54
- ? t.slice('--repo-root='.length)
55
- : tokens[++i];
59
+ const { value, nextI } = takeValue('--repo-root', t, i);
60
+ i = nextI;
56
61
  if (!value || value.startsWith('-')) {
57
62
  const err = new Error('--repo-root requires a path');
58
63
  err.code = 'USAGE';
@@ -63,7 +68,8 @@ function parseArgv(argv) {
63
68
  }
64
69
 
65
70
  if (t === '--mode' || t.startsWith('--mode=')) {
66
- const value = t.startsWith('--mode=') ? t.slice('--mode='.length) : tokens[++i];
71
+ const { value, nextI } = takeValue('--mode', t, i);
72
+ i = nextI;
67
73
  if (!value || String(value).startsWith('-')) {
68
74
  const err = new Error('--mode requires docker or native');
69
75
  err.code = 'USAGE';
@@ -73,23 +79,32 @@ function parseArgv(argv) {
73
79
  continue;
74
80
  }
75
81
 
76
- if (t === '--from-zip' || t.startsWith('--from-zip=')) {
77
- const value = t.startsWith('--from-zip=')
78
- ? t.slice('--from-zip='.length)
79
- : tokens[++i];
80
- if (!value || value.startsWith('-')) {
81
- const err = new Error('--from-zip requires a path to a .zip file');
82
- err.code = 'USAGE';
83
- throw err;
82
+ if (t === '--from-git' || t.startsWith('--from-git=')) {
83
+ if (t === '--from-git') {
84
+ const next = tokens[i + 1];
85
+ if (next && !next.startsWith('-')) {
86
+ globals.fromGit = next;
87
+ i += 1;
88
+ } else {
89
+ globals.fromGit = ''; // use default URL
90
+ }
91
+ } else {
92
+ globals.fromGit = t.slice('--from-git='.length);
84
93
  }
85
- globals.fromZip = value;
86
94
  continue;
87
95
  }
88
96
 
97
+ if (t === '--from-zip' || t.startsWith('--from-zip=')) {
98
+ const err = new Error(
99
+ '--from-zip was removed. Use --from-git (public deploy) or --repo-root <checkout>.',
100
+ );
101
+ err.code = 'USAGE';
102
+ throw err;
103
+ }
104
+
89
105
  if (t === '--from-release' || t.startsWith('--from-release=')) {
90
- const value = t.startsWith('--from-release=')
91
- ? t.slice('--from-release='.length)
92
- : tokens[++i];
106
+ const { value, nextI } = takeValue('--from-release', t, i);
107
+ i = nextI;
93
108
  if (!value || value.startsWith('-')) {
94
109
  const err = new Error('--from-release requires a tag or "latest"');
95
110
  err.code = 'USAGE';
@@ -100,9 +115,8 @@ function parseArgv(argv) {
100
115
  }
101
116
 
102
117
  if (t === '--install-dir' || t.startsWith('--install-dir=')) {
103
- const value = t.startsWith('--install-dir=')
104
- ? t.slice('--install-dir='.length)
105
- : tokens[++i];
118
+ const { value, nextI } = takeValue('--install-dir', t, i);
119
+ i = nextI;
106
120
  if (!value || value.startsWith('-')) {
107
121
  const err = new Error('--install-dir requires a path');
108
122
  err.code = 'USAGE';
@@ -113,9 +127,8 @@ function parseArgv(argv) {
113
127
  }
114
128
 
115
129
  if (t === '--llm-api-key' || t.startsWith('--llm-api-key=')) {
116
- const value = t.startsWith('--llm-api-key=')
117
- ? t.slice('--llm-api-key='.length)
118
- : tokens[++i];
130
+ const { value, nextI } = takeValue('--llm-api-key', t, i);
131
+ i = nextI;
119
132
  if (!value || value.startsWith('-')) {
120
133
  const err = new Error('--llm-api-key requires a value');
121
134
  err.code = 'USAGE';
@@ -125,6 +138,54 @@ function parseArgv(argv) {
125
138
  continue;
126
139
  }
127
140
 
141
+ if (t === '--smtp-host' || t.startsWith('--smtp-host=')) {
142
+ const { value, nextI } = takeValue('--smtp-host', t, i);
143
+ i = nextI;
144
+ if (!value || value.startsWith('-')) {
145
+ const err = new Error('--smtp-host requires a value');
146
+ err.code = 'USAGE';
147
+ throw err;
148
+ }
149
+ globals.smtpHost = value;
150
+ continue;
151
+ }
152
+
153
+ if (t === '--smtp-user' || t.startsWith('--smtp-user=')) {
154
+ const { value, nextI } = takeValue('--smtp-user', t, i);
155
+ i = nextI;
156
+ if (!value || value.startsWith('-')) {
157
+ const err = new Error('--smtp-user requires a value');
158
+ err.code = 'USAGE';
159
+ throw err;
160
+ }
161
+ globals.smtpUser = value;
162
+ continue;
163
+ }
164
+
165
+ if (t === '--smtp-password' || t.startsWith('--smtp-password=')) {
166
+ const { value, nextI } = takeValue('--smtp-password', t, i);
167
+ i = nextI;
168
+ if (value === undefined || value === null || String(value).startsWith('-')) {
169
+ const err = new Error('--smtp-password requires a value');
170
+ err.code = 'USAGE';
171
+ throw err;
172
+ }
173
+ globals.smtpPassword = value;
174
+ continue;
175
+ }
176
+
177
+ if (t === '--email-from' || t.startsWith('--email-from=')) {
178
+ const { value, nextI } = takeValue('--email-from', t, i);
179
+ i = nextI;
180
+ if (!value || value.startsWith('-')) {
181
+ const err = new Error('--email-from requires a value');
182
+ err.code = 'USAGE';
183
+ throw err;
184
+ }
185
+ globals.emailFrom = value;
186
+ continue;
187
+ }
188
+
128
189
  if (!command && !t.startsWith('-')) {
129
190
  command = t;
130
191
  continue;
@@ -3,36 +3,39 @@
3
3
  /**
4
4
  * Distribution modes for the testing CLI.
5
5
  *
6
- * - local-checkout: --repo-root / env / walk-up (path C)
7
- * - zip-bundle: init --from-zip (primary for private testing)
8
- * Registry/GHCR compose remains stubbed.
6
+ * - git-clone: default `ragsuite-test init` (public GitHub)
7
+ * - local-checkout: --repo-root (advanced / CI)
9
8
  */
10
9
 
11
10
  const MODE_LOCAL = 'local-checkout';
12
- const MODE_ZIP = 'zip-bundle';
11
+ const MODE_GIT = 'git-clone';
13
12
  const MODE_REGISTRY = 'registry-compose';
14
13
 
15
- function assertSupportedInstall() {
16
- // checkout + zip supported
17
- }
14
+ function assertSupportedInstall() {}
18
15
 
19
16
  function resolveComposeBundle() {
20
17
  const err = new Error(
21
- 'Registry/compose (GHCR) install is not available yet. Use: ragsuite-test init --from-zip <app.zip> or --repo-root <checkout>.',
18
+ 'Registry/compose (GHCR) install is not available yet. Run: ragsuite-test init',
22
19
  );
23
20
  err.code = 'DIST_NOT_READY';
24
21
  throw err;
25
22
  }
26
23
 
27
24
  function installHint() {
28
- return 'Supported: ragsuite-test init --from-zip <app.zip> [--install-dir ~/ragsuite-test] OR --repo-root <checkout>. GitHub Release download (--from-release) is not ready yet.';
25
+ return [
26
+ 'Simple deploy:',
27
+ ' npm install -g @nitsan-ai/ragsuite-test',
28
+ ' ragsuite-test init',
29
+ ' ragsuite-test start --repo-root ~/ragsuite-test --detach',
30
+ 'Docker is optional — init picks native automatically if Docker is not running.',
31
+ ].join('\n');
29
32
  }
30
33
 
31
34
  module.exports = {
32
35
  MODE_LOCAL,
33
- MODE_ZIP,
36
+ MODE_GIT,
34
37
  MODE_REGISTRY,
35
- mode: MODE_ZIP,
38
+ mode: MODE_GIT,
36
39
  assertSupportedInstall,
37
40
  resolveComposeBundle,
38
41
  installHint,
@@ -3,7 +3,7 @@
3
3
  const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
- const { warn } = require('./log');
6
+ const { info, warn } = require('./log');
7
7
 
8
8
  function generateJwtSecret() {
9
9
  return crypto.randomBytes(32).toString('hex');
@@ -15,7 +15,7 @@ function generateCiLlmKey() {
15
15
 
16
16
  function isPlaceholderSecret(value) {
17
17
  const v = String(value || '').trim();
18
- return !v || v.startsWith('change-me');
18
+ return !v || v.startsWith('change-me') || v.startsWith('your-smtp-');
19
19
  }
20
20
 
21
21
  /**
@@ -57,16 +57,84 @@ function buildEnvFromExample(examplePath, overrides = {}) {
57
57
  return `${out.join('\n').replace(/\n+$/, '')}\n`;
58
58
  }
59
59
 
60
+ function parseEnvFile(envPath) {
61
+ const out = {};
62
+ if (!fs.existsSync(envPath)) return out;
63
+ for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
64
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
65
+ if (m) out[m[1]] = m[2].trim();
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * Short “how to get / fix” hints when a config value is missing or still a placeholder.
72
+ */
73
+ function printConfigHints(issues) {
74
+ if (!issues.length) return;
75
+ warn('');
76
+ warn('Missing or placeholder configuration — how to fix:');
77
+ for (const issue of issues) {
78
+ warn(` ✗ ${issue.title}`);
79
+ for (const step of issue.steps) warn(` → ${step}`);
80
+ }
81
+ warn('');
82
+ }
83
+
84
+ function collectEnvConfigIssues(envMap) {
85
+ const issues = [];
86
+ if (isPlaceholderSecret(envMap.JWT_SECRET_KEY)) {
87
+ issues.push({
88
+ title: 'JWT_SECRET_KEY',
89
+ steps: [
90
+ 'Re-run: ragsuite-test init --force … (auto-generates JWT)',
91
+ 'Or set JWT_SECRET_KEY in .env (never commit .env)',
92
+ ],
93
+ });
94
+ }
95
+ if (isPlaceholderSecret(envMap.CUSTOM_LLM_INTERNAL_API_KEY)) {
96
+ issues.push({
97
+ title: 'CUSTOM_LLM_INTERNAL_API_KEY',
98
+ steps: [
99
+ 'Edit .env and set CUSTOM_LLM_INTERNAL_API_KEY=…',
100
+ 'Or: ragsuite-test init --force --llm-api-key YOUR_KEY',
101
+ ],
102
+ });
103
+ }
104
+ const smtpOk =
105
+ envMap.SMTP_HOST &&
106
+ envMap.SMTP_USER &&
107
+ envMap.SMTP_PASSWORD &&
108
+ !isPlaceholderSecret(envMap.SMTP_PASSWORD) &&
109
+ !isPlaceholderSecret(envMap.SMTP_USER) &&
110
+ (envMap.EMAIL_FROM || envMap.SMTP_USER);
111
+ if (!smtpOk) {
112
+ issues.push({
113
+ title: 'SMTP (real mail)',
114
+ steps: [
115
+ 'Put real values only in .env (gitignored) — never in .env.example',
116
+ 'Gmail: Google Account → Security → App passwords → Mail',
117
+ 'Set SMTP_USER, SMTP_PASSWORD, EMAIL_FROM in .env, then restart',
118
+ ],
119
+ });
120
+ }
121
+ return issues;
122
+ }
123
+
60
124
  /**
61
- * Write .env. Refuse overwrite unless force.
62
- * @returns {{ jwt: string, llmKey: string, smtpSkipped: boolean }}
125
+ * Write .env from .env.example. Never prompts.
126
+ * - Always regenerates JWT
127
+ * - Keeps LLM default from example (or generates if placeholder)
128
+ * - If SMTP in example is still a placeholder, writes a non-empty smoke SMTP
129
+ * so the backend can start; real mail requires editing local .env
130
+ * - Optional flag overrides only
63
131
  */
64
132
  function writeInstallEnv(repoRoot, options = {}) {
65
133
  const {
66
134
  force = false,
67
135
  jwtSecret = generateJwtSecret(),
68
136
  llmApiKey,
69
- smtpSkipped = true,
137
+ smtp,
70
138
  } = options;
71
139
 
72
140
  const envPath = path.join(repoRoot, '.env');
@@ -80,30 +148,62 @@ function writeInstallEnv(repoRoot, options = {}) {
80
148
  throw err;
81
149
  }
82
150
 
83
- if (isPlaceholderSecret(llmApiKey)) {
84
- const err = new Error('CUSTOM_LLM_INTERNAL_API_KEY must be set and must not be a change-me* placeholder');
85
- err.code = 'LLM_KEY_INVALID';
86
- throw err;
87
- }
88
-
151
+ const example = parseEnvFile(examplePath);
89
152
  const overrides = {
90
153
  JWT_SECRET_KEY: jwtSecret,
91
- CUSTOM_LLM_INTERNAL_API_KEY: llmApiKey,
92
154
  };
93
155
 
94
- if (smtpSkipped) {
95
- // Backend refuses to start when SMTP_PASSWORD is empty (main.py lifespan guard).
96
- // Use a non-empty smoke placeholder so Docker/native can boot; real email still fails.
97
- overrides.SMTP_PASSWORD = 'ci-smoke-smtp-not-for-production';
98
- warn('');
99
- warn('WARNING: SMTP skipped for smoke testing (placeholder SMTP_PASSWORD).');
100
- warn('Backend will start, but invites, password reset, and email 2FA will FAIL until real SMTP_* is set.');
101
- warn('');
156
+ if (llmApiKey && !isPlaceholderSecret(llmApiKey)) {
157
+ overrides.CUSTOM_LLM_INTERNAL_API_KEY = llmApiKey;
158
+ } else if (isPlaceholderSecret(example.CUSTOM_LLM_INTERNAL_API_KEY)) {
159
+ overrides.CUSTOM_LLM_INTERNAL_API_KEY = generateCiLlmKey();
160
+ }
161
+
162
+ if (smtp) {
163
+ if (smtp.host) overrides.SMTP_HOST = smtp.host;
164
+ if (smtp.user) overrides.SMTP_USER = smtp.user;
165
+ if (smtp.password) overrides.SMTP_PASSWORD = smtp.password;
166
+ if (smtp.emailFrom) overrides.EMAIL_FROM = smtp.emailFrom;
167
+ } else {
168
+ // No prompts: if template SMTP is placeholder, use smoke values so API can boot.
169
+ // Real Gmail credentials must live only in local .env (never commit).
170
+ const smtpUser = example.SMTP_USER || '';
171
+ const smtpPass = example.SMTP_PASSWORD || '';
172
+ if (isPlaceholderSecret(smtpPass) || isPlaceholderSecret(smtpUser)) {
173
+ overrides.SMTP_HOST = example.SMTP_HOST || 'smtp.gmail.com';
174
+ overrides.SMTP_PORT = example.SMTP_PORT || '587';
175
+ overrides.SMTP_USER = 'smoke-smtp@localhost';
176
+ overrides.SMTP_PASSWORD = 'ci-smoke-smtp-not-for-production';
177
+ overrides.SMTP_USE_TLS = example.SMTP_USE_TLS || 'true';
178
+ overrides.EMAIL_FROM = 'smoke-smtp@localhost';
179
+ warn('SMTP template was placeholder — wrote smoke SMTP into .env so the app can start.');
180
+ warn('For real email: edit .env (SMTP_USER / SMTP_PASSWORD / EMAIL_FROM). Never commit .env.');
181
+ }
102
182
  }
103
183
 
104
184
  const content = buildEnvFromExample(examplePath, overrides);
105
185
  fs.writeFileSync(envPath, content, 'utf8');
106
- return { jwt: jwtSecret, llmKey: llmApiKey, smtpSkipped: Boolean(smtpSkipped), envPath };
186
+
187
+ const written = parseEnvFile(envPath);
188
+ if (isPlaceholderSecret(written.JWT_SECRET_KEY) || isPlaceholderSecret(written.CUSTOM_LLM_INTERNAL_API_KEY)) {
189
+ printConfigHints(collectEnvConfigIssues(written));
190
+ const err = new Error('Install .env is incomplete (see hints above).');
191
+ err.code = 'ENV_INCOMPLETE';
192
+ throw err;
193
+ }
194
+
195
+ // Soft hint only for smoke SMTP (do not fail init — backend can start)
196
+ if (written.SMTP_PASSWORD === 'ci-smoke-smtp-not-for-production') {
197
+ info('SMTP: smoke defaults in .env (app starts). Edit .env for real Gmail/app-password mail.');
198
+ } else {
199
+ info('SMTP + LLM loaded into .env (no interactive prompts).');
200
+ }
201
+
202
+ return {
203
+ jwt: written.JWT_SECRET_KEY,
204
+ llmKey: written.CUSTOM_LLM_INTERNAL_API_KEY,
205
+ envPath,
206
+ };
107
207
  }
108
208
 
109
209
  function readEnvValue(envPath, key) {
@@ -119,6 +219,9 @@ module.exports = {
119
219
  generateCiLlmKey,
120
220
  isPlaceholderSecret,
121
221
  buildEnvFromExample,
222
+ parseEnvFile,
223
+ collectEnvConfigIssues,
224
+ printConfigHints,
122
225
  writeInstallEnv,
123
226
  readEnvValue,
124
227
  };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+ const { looksLikeFullBundleRoot, looksLikeRepoRoot } = require('./paths');
8
+
9
+ /** Default public monorepo (when the GitHub repo is public). */
10
+ const DEFAULT_GIT_URL = 'https://github.com/nitsan-ai/RAGSUITE.git';
11
+
12
+ function defaultInstallDir() {
13
+ return path.join(os.homedir(), 'ragsuite-test');
14
+ }
15
+
16
+ function requireGit() {
17
+ const which = spawnSync('which', ['git'], { encoding: 'utf8' });
18
+ if (which.status !== 0) {
19
+ const err = new Error(
20
+ 'git is required for --from-git. Install: macOS `xcode-select --install` or `brew install git`; Linux `sudo apt install git`',
21
+ );
22
+ err.code = 'MISSING_GIT';
23
+ throw err;
24
+ }
25
+ }
26
+
27
+ function isDirEmptyOrMissing(dir) {
28
+ if (!fs.existsSync(dir)) return true;
29
+ const items = fs.readdirSync(dir).filter((n) => n !== '.DS_Store');
30
+ return items.length === 0;
31
+ }
32
+
33
+ /**
34
+ * Shallow-clone a public (or authenticated) git repo into installDir.
35
+ * @returns {{ repoRoot: string, gitUrl: string }}
36
+ */
37
+ function cloneGitToInstallDir(gitUrl, options = {}) {
38
+ const { installDir, force = false, depth = 1 } = options;
39
+ requireGit();
40
+
41
+ const url = String(gitUrl || DEFAULT_GIT_URL).trim();
42
+ if (!url) {
43
+ const err = new Error('--from-git requires a repository URL');
44
+ err.code = 'GIT_URL_INVALID';
45
+ throw err;
46
+ }
47
+
48
+ const target = path.resolve(installDir);
49
+ if (!isDirEmptyOrMissing(target)) {
50
+ if (!force) {
51
+ const err = new Error(
52
+ `Install directory is not empty: ${target}. Pass --force to remove and re-clone, or choose another --install-dir.`,
53
+ );
54
+ err.code = 'INSTALL_DIR_NONEMPTY';
55
+ throw err;
56
+ }
57
+ fs.rmSync(target, { recursive: true, force: true });
58
+ }
59
+ fs.mkdirSync(path.dirname(target), { recursive: true });
60
+
61
+ const args = ['clone', `--depth=${depth}`, url, target];
62
+ const result = spawnSync('git', args, {
63
+ encoding: 'utf8',
64
+ stdio: ['ignore', 'pipe', 'pipe'],
65
+ });
66
+ if (result.status !== 0) {
67
+ const stderr = String(result.stderr || result.stdout || '').trim();
68
+ const err = new Error(
69
+ `git clone failed for ${url}: ${stderr || `exit ${result.status}`}. ` +
70
+ 'Repo must be public (or you must be authenticated). Or clone manually and use init --repo-root.',
71
+ );
72
+ err.code = 'GIT_CLONE_FAILED';
73
+ throw err;
74
+ }
75
+
76
+ const repoRoot = target;
77
+ if (!looksLikeRepoRoot(repoRoot) && !looksLikeFullBundleRoot(repoRoot)) {
78
+ const err = new Error(
79
+ `Cloned repo at ${repoRoot} does not look like a RAGSuite monorepo (missing scripts/docker-start.sh).`,
80
+ );
81
+ err.code = 'GIT_INVALID_REPO';
82
+ throw err;
83
+ }
84
+
85
+ return { repoRoot, gitUrl: url };
86
+ }
87
+
88
+ module.exports = {
89
+ DEFAULT_GIT_URL,
90
+ defaultInstallDir,
91
+ requireGit,
92
+ cloneGitToInstallDir,
93
+ };
@@ -91,7 +91,7 @@ function resolveRepoRoot({
91
91
  }
92
92
 
93
93
  const err = new Error(
94
- 'Could not find RAGSuite repo root. Pass --repo-root <path>, set RAGSUITE_REPO_ROOT, or run: ragsuite-test init --from-zip <app.zip>',
94
+ 'Could not find RAGSuite repo root. Pass --repo-root <path>, set RAGSUITE_REPO_ROOT, or run: ragsuite-test init --from-git',
95
95
  );
96
96
  err.code = 'NOT_REPO_ROOT';
97
97
  throw err;
package/src/utils/port.js CHANGED
@@ -61,7 +61,21 @@ async function assertPortsFree(ports = DEFAULT_INSTALL_PORTS) {
61
61
  }
62
62
  if (busy.length) {
63
63
  const err = new Error(
64
- `Port(s) already in use: ${busy.join(', ')}. Free them (ragsuite-test stop / npm run down) and retry. This CLI will not kill foreign processes.`,
64
+ [
65
+ `Port(s) already in use: ${busy.join(', ')}.`,
66
+ 'Free them, then retry. This CLI will not kill foreign processes.',
67
+ '',
68
+ 'If a previous RAGSuite install is running:',
69
+ ' ragsuite-test stop --repo-root ~/ragsuite-test',
70
+ ' # or from a monorepo checkout: cd <checkout> && npm run down',
71
+ '',
72
+ 'See what owns the ports (macOS/Linux):',
73
+ ' lsof -nP -iTCP:9090 -sTCP:LISTEN',
74
+ ' lsof -nP -iTCP:9091 -sTCP:LISTEN',
75
+ ' lsof -nP -iTCP:5436 -sTCP:LISTEN',
76
+ ' lsof -nP -iTCP:6382 -sTCP:LISTEN',
77
+ ' docker ps',
78
+ ].join('\n'),
65
79
  );
66
80
  err.code = 'PORT_IN_USE';
67
81
  throw err;