Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions npm/packages/ruvbot/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
dist
coverage
*.tgz
.env
.env.*
!.env.example
5 changes: 3 additions & 2 deletions npm/packages/ruvbot/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ WORKDIR /app
COPY package.json package-lock.json* ./

# Install production dependencies only
RUN npm ci --only=production --ignore-scripts && \
RUN npm ci --omit=dev --ignore-scripts --legacy-peer-deps && \
npm cache clean --force

# -----------------------------------------------------------------------------
Expand All @@ -33,10 +33,11 @@ WORKDIR /app
COPY package.json package-lock.json* tsconfig*.json ./

# Install all dependencies (including dev)
RUN npm ci --ignore-scripts
RUN npm ci --ignore-scripts --legacy-peer-deps

# Copy source code
COPY src/ ./src/
COPY scripts/fix-esm.js ./scripts/fix-esm.js

# Build TypeScript
RUN npm run build
Expand Down
4 changes: 2 additions & 2 deletions npm/packages/ruvbot/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ruvbot",
"version": "0.3.5",
"version": "0.3.6",
"description": "Enterprise-grade self-learning AI assistant with military-strength security, 150x faster vector search, and 12+ LLM models",
"main": "dist/index.js",
"module": "dist/esm/index.js",
Expand Down Expand Up @@ -39,7 +39,7 @@
"scripts": {
"build": "npm run build:cjs && npm run build:esm",
"build:cjs": "tsc -p tsconfig.json",
"build:esm": "tsc -p tsconfig.esm.json",
"build:esm": "tsc -p tsconfig.esm.json && node scripts/fix-esm.js",
"build:rvf": "node scripts/build-rvf.js",
"run:rvf": "node scripts/run-rvf.js",
"inspect:rvf": "node scripts/run-rvf.js --inspect",
Expand Down
65 changes: 65 additions & 0 deletions npm/packages/ruvbot/scripts/fix-esm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env node
/**
* Post-build step for the ESM output (dist/esm).
*
* The package root is CommonJS (no "type" field), so Node treats every .js
* file as CJS unless a nearer package.json says otherwise. tsc does not emit
* one, which made `import('ruvbot')` fail. This script:
*
* 1. writes dist/esm/package.json with {"type":"module"}
* 2. verifies that every relative specifier in dist/esm resolves to a real
* file (ESM forbids extensionless and directory imports) and fails the
* build if one does not, so the bug cannot silently come back.
*/
'use strict';

const fs = require('fs');
const path = require('path');

const esmDir = path.resolve(__dirname, '..', 'dist', 'esm');

if (!fs.existsSync(esmDir)) {
console.error(`fix-esm: ${esmDir} does not exist; run build:esm first`);
process.exit(1);
}

fs.writeFileSync(
path.join(esmDir, 'package.json'),
JSON.stringify({ type: 'module' }, null, 2) + '\n'
);

const SPECIFIER_RE =
/(?:\bfrom\s*|\bimport\s*\(\s*|^\s*import\s+)(['"])(\.{1,2}\/[^'"]*)\1/gm;

function walk(dir, out) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (entry.name.endsWith('.js')) out.push(full);
}
return out;
}

const problems = [];
for (const file of walk(esmDir, [])) {
const src = fs.readFileSync(file, 'utf8');
for (const match of src.matchAll(SPECIFIER_RE)) {
const spec = match[2];
const target = path.resolve(path.dirname(file), spec);
let ok = false;
try {
ok = fs.statSync(target).isFile();
} catch {
ok = false;
}
if (!ok) problems.push(`${path.relative(esmDir, file)}: '${spec}'`);
}
}

if (problems.length > 0) {
console.error('fix-esm: unresolvable relative ESM specifiers (use explicit ".js" / "/index.js"):');
for (const p of problems) console.error(` ${p}`);
process.exit(1);
}

console.log('fix-esm: wrote dist/esm/package.json; all relative ESM specifiers resolve');
103 changes: 100 additions & 3 deletions npm/packages/ruvbot/src/api/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,98 @@ <h1>Welcome to RuvBot</h1>
<a href="/health" target="_blank">Health</a>
</footer>

<script id="ruvbot-api-client">
// API client: attaches "Authorization: Bearer <key>" to /api/* requests.
// When the server sets RUVBOT_API_KEY it answers 401 without the header;
// the user is then asked for the key once, and it is kept in sessionStorage
// (this tab only). With no key configured server-side nothing changes:
// requests succeed and no prompt is shown. The key never goes in a URL.
function createRuvBotApiClient(deps) {
const doFetch = deps.fetch;
const storage = deps.storage;
const ask = deps.prompt;
const STORAGE_KEY = 'ruvbot.apiKey';
let memoryKey = null; // fallback when sessionStorage is unavailable
let pendingPrompt = null; // share one prompt between concurrent 401s

function getKey() {
try {
const v = storage && storage.getItem(STORAGE_KEY);
if (v) return v;
} catch (e) { /* storage blocked */ }
return memoryKey;
}

function setKey(key) {
memoryKey = key;
try { if (storage) storage.setItem(STORAGE_KEY, key); } catch (e) { /* storage blocked */ }
}

function clearKey() {
memoryKey = null;
try { if (storage) storage.removeItem(STORAGE_KEY); } catch (e) { /* storage blocked */ }
}

function isApiUrl(url) {
const s = typeof url === 'string' ? url : (url && url.url) || '';
return s.startsWith('/api/') || s === '/api';
}

function withAuth(init, key) {
const next = Object.assign({}, init || {});
const headers = new Headers(next.headers || {});
headers.set('Authorization', 'Bearer ' + key);
next.headers = headers;
return next;
}

function promptForKey(rejected) {
if (!pendingPrompt) {
pendingPrompt = Promise.resolve().then(() => {
const msg = rejected
? 'The RuvBot API key was rejected. Enter the API key (RUVBOT_API_KEY):'
: 'This RuvBot server requires an API key. Enter the API key (RUVBOT_API_KEY):';
const answer = ask(msg);
const key = typeof answer === 'string' ? answer.trim() : '';
if (key) setKey(key);
return key || null;
}).finally(() => { pendingPrompt = null; });
}
return pendingPrompt;
}

async function apiFetch(url, init) {
if (!isApiUrl(url)) return doFetch(url, init);

const sentKey = getKey();
const res = await doFetch(url, sentKey ? withAuth(init, sentKey) : init);
if (res.status !== 401) return res;

// Another request may already have obtained a fresh key.
const current = getKey();
if (current && current !== sentKey) {
return doFetch(url, withAuth(init, current));
}
if (sentKey) clearKey();

const key = await promptForKey(!!sentKey);
if (!key) return res; // user cancelled: surface the 401
const retry = await doFetch(url, withAuth(init, key));
if (retry.status === 401) clearKey();
return retry;
}

return { apiFetch, getKey, setKey, clearKey };
}

const ruvbotApi = createRuvBotApiClient({
fetch: (url, init) => window.fetch(url, init),
storage: (() => { try { return window.sessionStorage; } catch (e) { return null; } })(),
prompt: (msg) => window.prompt(msg),
});
const apiFetch = ruvbotApi.apiFetch;
</script>

<script>
// State
let sessionId = null;
Expand Down Expand Up @@ -729,12 +821,17 @@ <h1>Welcome to RuvBot</h1>
async function createSession() {
console.log('[RuvBot] Creating new session...');
try {
const res = await fetch('/api/sessions', {
const res = await apiFetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentId: 'default-agent' })
});
const data = await res.json();
if (!res.ok) {
throw new Error(res.status === 401
? 'Unauthorized: a valid API key is required'
: (data.message || data.error || 'Failed to create session'));
}
console.log('[RuvBot] Session created:', data);
sessionId = data.id || data.sessionId;
return sessionId;
Expand Down Expand Up @@ -773,7 +870,7 @@ <h1>Welcome to RuvBot</h1>
console.log('[RuvBot] Sending message:', { sessionId, message, model: modelSelect.value });
const startTime = performance.now();

const res = await fetch(`/api/sessions/${sessionId}/chat`, {
const res = await apiFetch(`/api/sessions/${sessionId}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand Down Expand Up @@ -917,7 +1014,7 @@ <h1>Welcome to RuvBot</h1>
try {
const [healthRes, statusRes] = await Promise.all([
fetch('/health'),
fetch('/api/status')
apiFetch('/api/status')
]);
const health = await healthRes.json();
const status = await statusRes.json();
Expand Down
6 changes: 3 additions & 3 deletions npm/packages/ruvbot/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
* ChatEnhancer provides the ultimate chatbot experience with skills and memory.
*/

export * from './agent';
export * from './session';
export * from './skill';
export * from './agent/index.js';
export * from './session/index.js';
export * from './skill/index.js';

// ChatEnhancer - The ultimate chatbot integration layer
export {
Expand Down
6 changes: 3 additions & 3 deletions npm/packages/ruvbot/src/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
* External system connectivity and protocol adapters.
*/

export * from './slack';
export * from './webhooks';
export * from './providers';
export * from './slack/index.js';
export * from './webhooks/index.js';
export * from './providers/index.js';
114 changes: 114 additions & 0 deletions npm/packages/ruvbot/tests/integration/package/pack-exports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Packaging test: build, `npm pack`, install the tarball into a scratch
* project, and load every public entry point through both module systems.
*
* Guards the 0.3.4/0.3.5 regression where `require('ruvbot')` worked but
* `await import('ruvbot')` threw (dist/esm had no {"type":"module"} marker and
* used directory imports that ESM cannot resolve).
*
* Set RUVBOT_PACK_TEST_SKIP_BUILD=1 to reuse an existing dist/.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync, rmSync, writeFileSync, readdirSync } from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';

const pkgDir = resolve(__dirname, '../../..');
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';

const SUBPATHS = [
'ruvbot',
'ruvbot/core',
'ruvbot/learning',
'ruvbot/integrations/slack',
'ruvbot/integrations/webhooks',
];

let scratch: string;

function run(cmd: string, args: string[], cwd: string): string {
return execFileSync(cmd, args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, npm_config_loglevel: 'error' },
});
}

/** Run a script in the scratch project and return its parsed JSON output. */
function probe(file: string, body: string): Record<string, string> {
writeFileSync(join(scratch, file), body);
const out = run(process.execPath, [file], scratch);
return JSON.parse(out.trim().split('\n').pop() as string);
}

describe('packed tarball entry points', () => {
beforeAll(() => {
if (!process.env.RUVBOT_PACK_TEST_SKIP_BUILD) run(npm, ['run', 'build'], pkgDir);

scratch = mkdtempSync(join(tmpdir(), 'ruvbot-pack-'));
run(npm, ['pack', '--ignore-scripts', '--pack-destination', scratch], pkgDir);
const tarball = readdirSync(scratch).find((f) => f.endsWith('.tgz'));
if (!tarball) throw new Error('npm pack produced no tarball');

writeFileSync(join(scratch, 'package.json'), JSON.stringify({ name: 'scratch', private: true }));
run(
npm,
[
'install',
`./${tarball}`,
'--no-save',
'--no-workspaces',
'--ignore-scripts',
'--omit=optional',
'--legacy-peer-deps',
'--no-audit',
'--no-fund',
],
scratch
);
}, 600_000);

afterAll(() => {
if (scratch) rmSync(scratch, { recursive: true, force: true });
});

it('loads every entry point with require()', () => {
const result = probe(
'probe.cjs',
`const out = {};
for (const p of ${JSON.stringify(SUBPATHS)}) {
try { require(p); out[p] = 'ok'; } catch (e) { out[p] = String(e && e.message); }
}
console.log(JSON.stringify(out));`
);
for (const p of SUBPATHS) expect(result[p], p).toBe('ok');
});

it('loads every entry point with import()', () => {
const result = probe(
'probe.mjs',
`const out = {};
for (const p of ${JSON.stringify(SUBPATHS)}) {
try { await import(p); out[p] = 'ok'; } catch (e) { out[p] = String(e && e.message); }
}
console.log(JSON.stringify(out));`
);
for (const p of SUBPATHS) expect(result[p], p).toBe('ok');
});

it('exposes the same runtime exports from ESM and CJS roots', () => {
const result = probe(
'probe-keys.mjs',
`import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const esm = Object.keys(await import('ruvbot')).filter((k) => k !== 'default' && k !== 'module.exports').sort();
const cjs = Object.keys(require('ruvbot')).sort();
console.log(JSON.stringify({ esm: esm.join(','), cjs: cjs.join(','), hasChatEnhancer: String(esm.includes('ChatEnhancer')) }));`
);
expect(result.hasChatEnhancer).toBe('true');
expect(result.esm).toBe(result.cjs);
});
});
Loading
Loading