@nitsan-ai/ragsuite-test 0.1.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.
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { readConfig, CONFIG_DIR } = require('./config');
6
+
7
+ function looksLikeRepoRoot(dir) {
8
+ const start = path.join(dir, 'scripts', 'docker-start.sh');
9
+ const pkg = path.join(dir, 'package.json');
10
+ return fs.existsSync(start) && fs.existsSync(pkg);
11
+ }
12
+
13
+ function looksLikeFullBundleRoot(dir) {
14
+ if (!looksLikeRepoRoot(dir)) return false;
15
+ const compose = path.join(dir, 'docker-compose.yml');
16
+ return fs.existsSync(compose);
17
+ }
18
+
19
+ function walkUpForRepoRoot(startDir) {
20
+ let current = path.resolve(startDir);
21
+ for (;;) {
22
+ if (looksLikeRepoRoot(current)) {
23
+ return current;
24
+ }
25
+ const parent = path.dirname(current);
26
+ if (parent === current) {
27
+ return null;
28
+ }
29
+ current = parent;
30
+ }
31
+ }
32
+
33
+ function envRepoRoot(env = process.env) {
34
+ const candidates = [env.RAGSUITE_REPO_ROOT, env.RAGSUITE_INSTALL_DIR];
35
+ for (const raw of candidates) {
36
+ if (!raw || !String(raw).trim()) continue;
37
+ const root = path.resolve(String(raw).trim());
38
+ if (looksLikeRepoRoot(root)) {
39
+ return root;
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Resolve monorepo root:
47
+ * --repo-root → RAGSUITE_REPO_ROOT / RAGSUITE_INSTALL_DIR → walk-up / config.
48
+ */
49
+ function resolveRepoRoot({
50
+ cwd = process.cwd(),
51
+ repoRootFlag,
52
+ searchConfig = true,
53
+ env = process.env,
54
+ } = {}) {
55
+ if (repoRootFlag) {
56
+ const root = path.resolve(repoRootFlag);
57
+ if (!looksLikeRepoRoot(root)) {
58
+ const err = new Error(
59
+ `Not a RAGSuite repo root (missing scripts/docker-start.sh): ${root}`,
60
+ );
61
+ err.code = 'NOT_REPO_ROOT';
62
+ throw err;
63
+ }
64
+ return root;
65
+ }
66
+
67
+ const fromEnv = envRepoRoot(env);
68
+ if (fromEnv) {
69
+ return fromEnv;
70
+ }
71
+
72
+ if (searchConfig) {
73
+ const walked = walkUpForRepoRoot(cwd);
74
+ if (walked) {
75
+ const cfg = readConfig(walked);
76
+ if (cfg) {
77
+ for (const key of ['repoRoot', 'installDir']) {
78
+ const candidate = cfg[key];
79
+ if (candidate && looksLikeRepoRoot(candidate)) {
80
+ return path.resolve(candidate);
81
+ }
82
+ }
83
+ }
84
+ return walked;
85
+ }
86
+ }
87
+
88
+ const walked = walkUpForRepoRoot(cwd);
89
+ if (walked) {
90
+ return walked;
91
+ }
92
+
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>',
95
+ );
96
+ err.code = 'NOT_REPO_ROOT';
97
+ throw err;
98
+ }
99
+
100
+ function scriptPath(repoRoot, name) {
101
+ return path.join(repoRoot, 'scripts', name);
102
+ }
103
+
104
+ function ensureRagsuiteDir(repoRoot) {
105
+ const dir = path.join(repoRoot, CONFIG_DIR);
106
+ fs.mkdirSync(dir, { recursive: true });
107
+ return dir;
108
+ }
109
+
110
+ /** Native PID/logs stay under .ragsuite/native (Phase 4 scripts). */
111
+ function nativeLogDir(repoRoot) {
112
+ return path.join(repoRoot, '.ragsuite', 'native');
113
+ }
114
+
115
+ module.exports = {
116
+ looksLikeRepoRoot,
117
+ looksLikeFullBundleRoot,
118
+ walkUpForRepoRoot,
119
+ envRepoRoot,
120
+ resolveRepoRoot,
121
+ scriptPath,
122
+ ensureRagsuiteDir,
123
+ nativeLogDir,
124
+ };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ const net = require('net');
4
+
5
+ function apiPort(env = process.env) {
6
+ const n = Number(env.API_PORT || 9090);
7
+ return Number.isFinite(n) && n > 0 ? n : 9090;
8
+ }
9
+
10
+ /**
11
+ * Return true if something accepts TCP connections on host:port.
12
+ */
13
+ function isPortInUse(port, host = '127.0.0.1', timeoutMs = 500) {
14
+ return new Promise((resolve) => {
15
+ const socket = new net.Socket();
16
+ let settled = false;
17
+
18
+ const done = (inUse) => {
19
+ if (settled) return;
20
+ settled = true;
21
+ socket.destroy();
22
+ resolve(inUse);
23
+ };
24
+
25
+ socket.setTimeout(timeoutMs);
26
+ socket.once('connect', () => done(true));
27
+ socket.once('timeout', () => done(false));
28
+ socket.once('error', () => done(false));
29
+ socket.connect(port, host);
30
+ });
31
+ }
32
+
33
+ async function assertApiPortFree(env = process.env) {
34
+ const port = apiPort(env);
35
+ const busy = await isPortInUse(port);
36
+ if (busy) {
37
+ const err = new Error(
38
+ `API port ${port} is already in use. Stop the other process (e.g. npm run down or npm run stop:native) and retry. This CLI will not kill foreign processes.`,
39
+ );
40
+ err.code = 'PORT_IN_USE';
41
+ throw err;
42
+ }
43
+ return port;
44
+ }
45
+
46
+ const DEFAULT_INSTALL_PORTS = [
47
+ { port: 9090, label: 'API' },
48
+ { port: 9091, label: 'Web UI' },
49
+ { port: 5436, label: 'Postgres' },
50
+ { port: 6382, label: 'Redis' },
51
+ ];
52
+
53
+ async function assertPortsFree(ports = DEFAULT_INSTALL_PORTS) {
54
+ const busy = [];
55
+ for (const item of ports) {
56
+ const p = typeof item === 'number' ? item : item.port;
57
+ const label = typeof item === 'number' ? `port ${item}` : item.label;
58
+ if (await isPortInUse(p)) {
59
+ busy.push(`${label} :${p}`);
60
+ }
61
+ }
62
+ if (busy.length) {
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.`,
65
+ );
66
+ err.code = 'PORT_IN_USE';
67
+ throw err;
68
+ }
69
+ }
70
+
71
+ module.exports = {
72
+ apiPort,
73
+ isPortInUse,
74
+ assertApiPortFree,
75
+ assertPortsFree,
76
+ DEFAULT_INSTALL_PORTS,
77
+ };
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('child_process');
4
+ const { info, warn, error } = require('./log');
5
+
6
+ function commandExists(cmd) {
7
+ const r = spawnSync('which', [cmd], { encoding: 'utf8' });
8
+ return r.status === 0;
9
+ }
10
+
11
+ function dockerDaemonRunning() {
12
+ const r = spawnSync('docker', ['info'], {
13
+ encoding: 'utf8',
14
+ stdio: ['ignore', 'pipe', 'pipe'],
15
+ });
16
+ return r.status === 0;
17
+ }
18
+
19
+ function nodeMajorOk() {
20
+ const major = Number(String(process.versions.node).split('.')[0]);
21
+ return Number.isFinite(major) && major >= 18;
22
+ }
23
+
24
+ /**
25
+ * Check prerequisites for install mode. Returns { ok, issues[] }.
26
+ */
27
+ function checkPrereqs(mode = 'docker') {
28
+ const issues = [];
29
+ const notes = [];
30
+
31
+ if (!nodeMajorOk()) {
32
+ issues.push(`Node.js 18+ required (current: ${process.version})`);
33
+ } else {
34
+ notes.push(`Node.js ${process.version}`);
35
+ }
36
+
37
+ if (mode === 'docker') {
38
+ if (!commandExists('docker')) {
39
+ issues.push('Docker CLI not found (required for docker mode)');
40
+ } else if (!dockerDaemonRunning()) {
41
+ issues.push('Docker daemon not running — start Docker Desktop / dockerd');
42
+ } else {
43
+ notes.push('Docker CLI + daemon OK');
44
+ }
45
+ if (!commandExists('docker')) {
46
+ /* already noted */
47
+ } else {
48
+ const compose = spawnSync('docker', ['compose', 'version'], { encoding: 'utf8' });
49
+ if (compose.status !== 0) {
50
+ issues.push('docker compose plugin not available');
51
+ } else {
52
+ notes.push('docker compose OK');
53
+ }
54
+ }
55
+ } else {
56
+ if (!commandExists('python3')) {
57
+ issues.push('python3 not found (required for native mode)');
58
+ } else {
59
+ notes.push('python3 present');
60
+ }
61
+ if (!commandExists('yarn')) {
62
+ notes.push('yarn missing — native-start can still use npm/corepack in some setups');
63
+ } else {
64
+ notes.push('yarn present');
65
+ }
66
+ notes.push('Native mode also needs Postgres :5436 and Redis :6382 reachable');
67
+ }
68
+
69
+ if (process.platform === 'win32') {
70
+ notes.push('Windows: use WSL or Git Bash — start/stop spawn bash scripts');
71
+ }
72
+
73
+ return { ok: issues.length === 0, issues, notes };
74
+ }
75
+
76
+ function printPrereqs(mode) {
77
+ const { ok, issues, notes } = checkPrereqs(mode);
78
+ info(`Prerequisites (mode=${mode}):`);
79
+ for (const n of notes) info(` ✓ ${n}`);
80
+ for (const i of issues) error(` ✗ ${i}`);
81
+ return ok;
82
+ }
83
+
84
+ module.exports = {
85
+ commandExists,
86
+ dockerDaemonRunning,
87
+ nodeMajorOk,
88
+ checkPrereqs,
89
+ printPrereqs,
90
+ };
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ const readline = require('readline');
4
+
5
+ function createInterface() {
6
+ return readline.createInterface({
7
+ input: process.stdin,
8
+ output: process.stdout,
9
+ });
10
+ }
11
+
12
+ function ask(question, options = {}) {
13
+ const { defaultValue = '', yesMode = false } = options;
14
+ if (yesMode) {
15
+ return Promise.resolve(defaultValue);
16
+ }
17
+ const rl = createInterface();
18
+ const suffix = defaultValue !== '' && defaultValue != null ? ` [${defaultValue}]` : '';
19
+ return new Promise((resolve) => {
20
+ rl.question(`${question}${suffix}: `, (answer) => {
21
+ rl.close();
22
+ const trimmed = String(answer || '').trim();
23
+ resolve(trimmed === '' ? defaultValue : trimmed);
24
+ });
25
+ });
26
+ }
27
+
28
+ async function confirm(question, options = {}) {
29
+ const { defaultYes = false, yesMode = false } = options;
30
+ if (yesMode) return defaultYes;
31
+ const hint = defaultYes ? 'Y/n' : 'y/N';
32
+ const answer = await ask(`${question} (${hint})`, { yesMode: false });
33
+ if (!answer) return defaultYes;
34
+ return /^(y|yes)$/i.test(answer);
35
+ }
36
+
37
+ async function choose(question, choices, options = {}) {
38
+ const { defaultIndex = 0, yesMode = false } = options;
39
+ if (yesMode) {
40
+ return choices[defaultIndex];
41
+ }
42
+ console.log(question);
43
+ choices.forEach((c, i) => {
44
+ console.log(` ${i + 1}) ${c.label}`);
45
+ });
46
+ const answer = await ask('Choice', {
47
+ defaultValue: String(defaultIndex + 1),
48
+ yesMode: false,
49
+ });
50
+ const n = Number(answer);
51
+ if (!Number.isFinite(n) || n < 1 || n > choices.length) {
52
+ return choices[defaultIndex];
53
+ }
54
+ return choices[n - 1];
55
+ }
56
+
57
+ module.exports = { ask, confirm, choose };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('child_process');
4
+ const path = require('path');
5
+ const { info } = require('./log');
6
+
7
+ /**
8
+ * Run a repo script with inherited stdio. dry-run prints and returns 0.
9
+ * @returns {number} exit code
10
+ */
11
+ function runScript(repoRoot, relPath, args = [], options = {}) {
12
+ const { dryRun = false, env = process.env, shell = false } = options;
13
+ const abs = path.isAbsolute(relPath) ? relPath : path.join(repoRoot, relPath);
14
+ const display = [abs, ...args].join(' ');
15
+
16
+ if (dryRun) {
17
+ info(`[dry-run] ${display}`);
18
+ return 0;
19
+ }
20
+
21
+ const result = spawnSync('bash', [abs, ...args], {
22
+ cwd: repoRoot,
23
+ env: { ...env },
24
+ stdio: 'inherit',
25
+ shell,
26
+ });
27
+
28
+ if (result.error) {
29
+ throw result.error;
30
+ }
31
+ return typeof result.status === 'number' ? result.status : 1;
32
+ }
33
+
34
+ /**
35
+ * Run an arbitrary command (e.g. docker compose, git, tail).
36
+ * @returns {number} exit code
37
+ */
38
+ function runCommand(cmd, args, options = {}) {
39
+ const { cwd = process.cwd(), dryRun = false, env = process.env } = options;
40
+ const display = [cmd, ...args].join(' ');
41
+
42
+ if (dryRun) {
43
+ info(`[dry-run] ${display}`);
44
+ return 0;
45
+ }
46
+
47
+ const result = spawnSync(cmd, args, {
48
+ cwd,
49
+ env: { ...env },
50
+ stdio: 'inherit',
51
+ });
52
+
53
+ if (result.error) {
54
+ throw result.error;
55
+ }
56
+ return typeof result.status === 'number' ? result.status : 1;
57
+ }
58
+
59
+ /**
60
+ * Capture stdout/stderr (for git status, etc.).
61
+ */
62
+ function captureCommand(cmd, args, options = {}) {
63
+ const { cwd = process.cwd(), env = process.env } = options;
64
+ const result = spawnSync(cmd, args, {
65
+ cwd,
66
+ env: { ...env },
67
+ encoding: 'utf8',
68
+ });
69
+ return {
70
+ status: typeof result.status === 'number' ? result.status : 1,
71
+ stdout: result.stdout || '',
72
+ stderr: result.stderr || '',
73
+ error: result.error || null,
74
+ };
75
+ }
76
+
77
+ module.exports = { runScript, runCommand, captureCommand };
@@ -0,0 +1,213 @@
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 } = require('./paths');
8
+
9
+ function defaultInstallDir() {
10
+ return path.join(os.homedir(), 'ragsuite-test');
11
+ }
12
+
13
+ function requireUnzip() {
14
+ const which = spawnSync('which', ['unzip'], { encoding: 'utf8' });
15
+ if (which.status !== 0) {
16
+ const err = new Error('unzip is required for --from-zip (install unzip / use macOS or Linux)');
17
+ err.code = 'MISSING_UNZIP';
18
+ throw err;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * List zip entries; reject zip-slip (absolute paths, .. segments).
24
+ * @returns {string[]}
25
+ */
26
+ function listZipEntries(zipPath) {
27
+ requireUnzip();
28
+ const opts = { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 };
29
+ const result = spawnSync('unzip', ['-Z1', zipPath], opts);
30
+ if (result.status === 0) {
31
+ return String(result.stdout || '')
32
+ .split('\n')
33
+ .map((l) => l.trim())
34
+ .filter(Boolean);
35
+ }
36
+ const alt = spawnSync('unzip', ['-l', zipPath], opts);
37
+ if (alt.status !== 0) {
38
+ const err = new Error(
39
+ `Failed to list zip: ${zipPath} (${(result.stderr || alt.stderr || result.error || '').toString().trim() || 'unzip -Z1/-l failed'})`,
40
+ );
41
+ err.code = 'ZIP_LIST';
42
+ throw err;
43
+ }
44
+ return parseUnzipL(alt.stdout);
45
+ }
46
+
47
+ function parseUnzipL(stdout) {
48
+ const lines = String(stdout || '').split('\n');
49
+ const entries = [];
50
+ for (const line of lines) {
51
+ // typical: " Length Date Time Name" then data lines ending with name
52
+ const m = line.match(/^\s*\d+\s+\d{2}-\d{2}-\d{4}\s+\d{2}:\d{2}\s+(.+)$/);
53
+ if (m) entries.push(m[1].trim());
54
+ }
55
+ return entries.filter(Boolean);
56
+ }
57
+
58
+ function assertNoZipSlip(entries) {
59
+ for (const entry of entries) {
60
+ const normalized = entry.replace(/\\/g, '/');
61
+ if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) {
62
+ const err = new Error(`Refusing zip with absolute path entry: ${entry}`);
63
+ err.code = 'ZIP_SLIP';
64
+ throw err;
65
+ }
66
+ const parts = normalized.split('/');
67
+ if (parts.some((p) => p === '..')) {
68
+ const err = new Error(`Refusing zip with path traversal entry: ${entry}`);
69
+ err.code = 'ZIP_SLIP';
70
+ throw err;
71
+ }
72
+ }
73
+ }
74
+
75
+ function isDirEmptyOrMissing(dir) {
76
+ if (!fs.existsSync(dir)) return true;
77
+ const items = fs.readdirSync(dir).filter((n) => n !== '.DS_Store');
78
+ return items.length === 0;
79
+ }
80
+
81
+ function findBundleRoot(extractRoot) {
82
+ if (looksLikeFullBundleRoot(extractRoot)) {
83
+ return extractRoot;
84
+ }
85
+ const kids = fs
86
+ .readdirSync(extractRoot)
87
+ .map((n) => path.join(extractRoot, n))
88
+ .filter((p) => fs.statSync(p).isDirectory());
89
+ if (kids.length === 1 && looksLikeFullBundleRoot(kids[0])) {
90
+ return kids[0];
91
+ }
92
+ for (const kid of kids) {
93
+ if (looksLikeFullBundleRoot(kid)) return kid;
94
+ }
95
+ const err = new Error(
96
+ 'ZIP does not contain a RAGSuite monorepo root (need package.json, scripts/docker-start.sh, docker-compose.yml)',
97
+ );
98
+ err.code = 'ZIP_INVALID';
99
+ throw err;
100
+ }
101
+
102
+ function copyDirRecursive(src, dest) {
103
+ fs.mkdirSync(dest, { recursive: true });
104
+ for (const name of fs.readdirSync(src)) {
105
+ const from = path.join(src, name);
106
+ const to = path.join(dest, name);
107
+ const st = fs.statSync(from);
108
+ if (st.isDirectory()) {
109
+ copyDirRecursive(from, to);
110
+ } else {
111
+ fs.copyFileSync(from, to);
112
+ }
113
+ }
114
+ }
115
+
116
+ function rmRecursive(dir) {
117
+ fs.rmSync(dir, { recursive: true, force: true });
118
+ }
119
+
120
+ /**
121
+ * Extract zip safely into installDir.
122
+ * @returns {{ installDir: string, repoRoot: string }}
123
+ */
124
+ function extractZipToInstallDir(zipPath, options = {}) {
125
+ const absZip = path.resolve(zipPath);
126
+ if (!fs.existsSync(absZip)) {
127
+ const err = new Error(`ZIP not found: ${absZip}`);
128
+ err.code = 'ZIP_MISSING';
129
+ throw err;
130
+ }
131
+
132
+ const installDir = path.resolve(options.installDir || defaultInstallDir());
133
+ const force = Boolean(options.force);
134
+
135
+ if (!isDirEmptyOrMissing(installDir) && !force) {
136
+ const err = new Error(
137
+ `Install dir is not empty: ${installDir}. Pass --force to overwrite, or choose another --install-dir.`,
138
+ );
139
+ err.code = 'INSTALL_DIR_NONEMPTY';
140
+ throw err;
141
+ }
142
+
143
+ const entries = listZipEntries(absZip);
144
+ if (entries.length === 0) {
145
+ const err = new Error('ZIP is empty');
146
+ err.code = 'ZIP_EMPTY';
147
+ throw err;
148
+ }
149
+ assertNoZipSlip(entries);
150
+
151
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ragsuite-zip-'));
152
+ try {
153
+ requireUnzip();
154
+ const unzip = spawnSync('unzip', ['-q', '-o', absZip, '-d', tmp], {
155
+ encoding: 'utf8',
156
+ });
157
+ if (unzip.status !== 0) {
158
+ const err = new Error(`unzip failed: ${unzip.stderr || unzip.stdout || 'unknown'}`);
159
+ err.code = 'ZIP_EXTRACT';
160
+ throw err;
161
+ }
162
+
163
+ const bundleRoot = findBundleRoot(tmp);
164
+
165
+ if (force && fs.existsSync(installDir)) {
166
+ rmRecursive(installDir);
167
+ }
168
+ fs.mkdirSync(path.dirname(installDir), { recursive: true });
169
+ if (fs.existsSync(installDir) && isDirEmptyOrMissing(installDir)) {
170
+ rmRecursive(installDir);
171
+ }
172
+ // Move/copy bundle into installDir
173
+ try {
174
+ fs.renameSync(bundleRoot, installDir);
175
+ } catch {
176
+ copyDirRecursive(bundleRoot, installDir);
177
+ if (bundleRoot !== tmp) {
178
+ rmRecursive(bundleRoot);
179
+ }
180
+ }
181
+ } catch (err) {
182
+ try {
183
+ rmRecursive(tmp);
184
+ } catch {
185
+ /* ignore */
186
+ }
187
+ throw err;
188
+ }
189
+
190
+ // tmp may still have empty wrapper dirs
191
+ try {
192
+ if (fs.existsSync(tmp)) rmRecursive(tmp);
193
+ } catch {
194
+ /* ignore */
195
+ }
196
+
197
+ if (!looksLikeFullBundleRoot(installDir)) {
198
+ const err = new Error(`Extracted install is not a valid bundle: ${installDir}`);
199
+ err.code = 'ZIP_INVALID';
200
+ throw err;
201
+ }
202
+
203
+ return { installDir, repoRoot: installDir };
204
+ }
205
+
206
+ module.exports = {
207
+ defaultInstallDir,
208
+ listZipEntries,
209
+ assertNoZipSlip,
210
+ extractZipToInstallDir,
211
+ findBundleRoot,
212
+ isDirEmptyOrMissing,
213
+ };