This commit is contained in:
65
packages/launcher/package.json
Normal file
65
packages/launcher/package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@waggle-ai/waggle",
|
||||
"version": "1.0.0",
|
||||
"description": "Waggle — Your personal AI agent swarm. Start with `npx @waggle-ai/waggle`.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"waggle": "./dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"start": "tsx src/cli.ts",
|
||||
"test": "node ../../node_modules/vitest/vitest.mjs run --root ../.. --config vitest.config.ts packages/launcher/tests/cli.test.ts",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"agent",
|
||||
"swarm",
|
||||
"memory",
|
||||
"waggle",
|
||||
"llm",
|
||||
"personal-assistant"
|
||||
],
|
||||
"author": "Marko Markovic",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/fastify": "^3.0.0",
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@fastify/websocket": "^11.0.0",
|
||||
"@whiskeysockets/baileys": "^7.0.0-rc13",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"bullmq": "^5.0.0",
|
||||
"cron-parser": "^4.9.0",
|
||||
"docx": "^9.6.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"fastify": "^5.3.0",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"glob": "^13.0.6",
|
||||
"ioredis": "^5.4.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"postgres": "^3.4.0",
|
||||
"sqlite-vec": "^0.1.7-alpha.2",
|
||||
"stripe": "^21.0.1",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
}
|
||||
126
packages/launcher/src/cli-core.ts
Normal file
126
packages/launcher/src/cli-core.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
export interface LauncherArgs {
|
||||
port: number;
|
||||
skipLiteLLM: boolean;
|
||||
noBrowser: boolean;
|
||||
help: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StartupSuccessOptions {
|
||||
url: string;
|
||||
llmProvider: string;
|
||||
llmHealth: string;
|
||||
dataDir: string;
|
||||
noBrowser: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 3333;
|
||||
const MAX_PORT = 65535;
|
||||
|
||||
export function parseArgs(argv: string[]): LauncherArgs {
|
||||
const args = argv.slice(2);
|
||||
const result: LauncherArgs = {
|
||||
port: DEFAULT_PORT,
|
||||
skipLiteLLM: false,
|
||||
noBrowser: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === '--port' || arg === '-p') {
|
||||
const value = args[++i];
|
||||
if (value === undefined) {
|
||||
result.error = 'Missing value for --port. Use a number between 1 and 65535.';
|
||||
return result;
|
||||
}
|
||||
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 1 || port > MAX_PORT) {
|
||||
result.error = `Invalid port: ${value}. Use a number between 1 and 65535.`;
|
||||
return result;
|
||||
}
|
||||
|
||||
result.port = port;
|
||||
} else if (arg === '--skip-litellm') {
|
||||
result.skipLiteLLM = true;
|
||||
} else if (arg === '--no-open') {
|
||||
result.noBrowser = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
result.help = true;
|
||||
} else if (arg.startsWith('-')) {
|
||||
result.error = `Unknown option: ${arg}`;
|
||||
return result;
|
||||
} else {
|
||||
result.error = `Unknown argument: ${arg}`;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatHelp(): string {
|
||||
return `
|
||||
Waggle - Your personal AI agent swarm
|
||||
|
||||
Usage:
|
||||
npx waggle [options]
|
||||
|
||||
Options:
|
||||
--port, -p <number> Server port (default: 3333)
|
||||
--skip-litellm Use built-in Anthropic proxy instead of LiteLLM
|
||||
--no-open Do not open browser automatically
|
||||
--help, -h Show this help message
|
||||
|
||||
Data directory: ~/.waggle/
|
||||
Config: ~/.waggle/config.json
|
||||
`;
|
||||
}
|
||||
|
||||
export function formatStartupFailure(message: string, port: number): string {
|
||||
const firstLine = message.split(/\r?\n/)[0] || message;
|
||||
const lines = [
|
||||
'',
|
||||
` Failed to start Waggle: ${firstLine}`,
|
||||
'',
|
||||
];
|
||||
|
||||
if (/port\s+\d+\s+is already in use/i.test(firstLine)) {
|
||||
lines.push(
|
||||
` Another app is already using port ${port}.`,
|
||||
` Try: npx waggle --port ${port + 1}`,
|
||||
' Or close the other Waggle instance and run the command again.',
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
' Check the message above, fix the startup problem, and run the command again.',
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function formatStartupSuccess(options: StartupSuccessOptions): string {
|
||||
const lines = [
|
||||
'',
|
||||
` Server: ${options.url}`,
|
||||
` LLM: ${options.llmProvider} (${options.llmHealth})`,
|
||||
` Data: ${options.dataDir}`,
|
||||
];
|
||||
|
||||
if (options.noBrowser) {
|
||||
lines.push(
|
||||
' Browser: not opened (--no-open)',
|
||||
` Open manually: ${options.url}`,
|
||||
);
|
||||
} else {
|
||||
lines.push(' Browser: opening default browser');
|
||||
}
|
||||
|
||||
lines.push('', ' Press Ctrl+C to stop', '');
|
||||
return lines.join('\n');
|
||||
}
|
||||
117
packages/launcher/src/cli.ts
Normal file
117
packages/launcher/src/cli.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Waggle CLI Launcher - `npx waggle`
|
||||
*
|
||||
* Starts the Waggle server and opens the frontend in the default browser.
|
||||
*
|
||||
* Usage:
|
||||
* npx waggle # Start on default port 3333
|
||||
* npx waggle --port 4000 # Start on custom port
|
||||
* npx waggle --skip-litellm # Skip LiteLLM proxy
|
||||
* npx waggle --no-open # Do not open browser automatically
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
formatHelp,
|
||||
formatStartupFailure,
|
||||
formatStartupSuccess,
|
||||
parseArgs,
|
||||
} from './cli-core.js';
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
const platform = os.platform();
|
||||
const onOpenError = (err: Error | null): void => {
|
||||
if (err) console.log(` Open manually: ${url}`);
|
||||
};
|
||||
|
||||
if (platform === 'win32') {
|
||||
execFile('cmd', ['/c', 'start', '', url], onOpenError);
|
||||
} else if (platform === 'darwin') {
|
||||
execFile('open', [url], onOpenError);
|
||||
} else {
|
||||
execFile('xdg-open', [url], onOpenError);
|
||||
}
|
||||
}
|
||||
|
||||
function checkNodeVersion(): boolean {
|
||||
const [major] = process.versions.node.split('.').map(Number);
|
||||
if (major < 18) {
|
||||
console.error(`\n Waggle requires Node.js >= 18. You have ${process.versions.node}.`);
|
||||
console.error(' Install a newer version: https://nodejs.org\n');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { port, skipLiteLLM, noBrowser, help, error } = parseArgs(process.argv);
|
||||
|
||||
if (help) {
|
||||
console.log(formatHelp());
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error();
|
||||
console.error(` ${error}`);
|
||||
console.error(formatHelp());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!checkNodeVersion()) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(' Waggle - AI Agent Swarm');
|
||||
console.log(' ------------------------');
|
||||
|
||||
const { startService, isFirstRun } = await import('@waggle/server/local/service');
|
||||
const dataDir = process.env.WAGGLE_DATA_DIR || undefined;
|
||||
const displayDataDir = dataDir ?? `${os.homedir()}/.waggle`;
|
||||
const firstRun = isFirstRun(displayDataDir);
|
||||
|
||||
if (firstRun) {
|
||||
console.log(' Welcome! Setting up for the first time...');
|
||||
console.log();
|
||||
}
|
||||
|
||||
try {
|
||||
const { server } = await startService({
|
||||
port,
|
||||
skipLiteLLM,
|
||||
dataDir,
|
||||
onProgress: (event) => {
|
||||
const pct = Math.round(event.progress * 100);
|
||||
process.stdout.write(`\r [${pct.toString().padStart(3)}%] ${event.message}`);
|
||||
if (event.phase === 'ready') {
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const addr = server.server.address();
|
||||
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
|
||||
const url = `http://localhost:${actualPort}`;
|
||||
const llm = server.agentState.llmProvider;
|
||||
|
||||
console.log(formatStartupSuccess({
|
||||
url,
|
||||
llmProvider: llm.provider,
|
||||
llmHealth: llm.health,
|
||||
dataDir: displayDataDir,
|
||||
noBrowser,
|
||||
}));
|
||||
|
||||
if (!noBrowser) {
|
||||
openBrowser(url);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(formatStartupFailure(message, port));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
536
packages/launcher/tests/cli.test.ts
Normal file
536
packages/launcher/tests/cli.test.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* 9D-3: npx waggle CLI Launcher — tests.
|
||||
*
|
||||
* Tests argument parsing, version checking, launcher configuration, and the
|
||||
* packed/installed CLI startup path a user gets from npm.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatStartupFailure,
|
||||
formatStartupSuccess,
|
||||
parseArgs,
|
||||
} from '../src/cli-core.js';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..', '..', '..');
|
||||
const LAUNCHER_DIR = path.join(ROOT, 'packages', 'launcher');
|
||||
|
||||
interface CommandResult {
|
||||
status: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
function bin(name: string): string {
|
||||
return process.platform === 'win32' ? `${name}.cmd` : name;
|
||||
}
|
||||
|
||||
function makeHome(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-launcher-cli-'));
|
||||
}
|
||||
|
||||
function run(
|
||||
command: string,
|
||||
args: string[],
|
||||
home: string,
|
||||
extraEnv: NodeJS.ProcessEnv = {},
|
||||
): Promise<CommandResult> {
|
||||
return runInCwd(command, args, ROOT, home, extraEnv);
|
||||
}
|
||||
|
||||
function runInCwd(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
home: string,
|
||||
extraEnv: NodeJS.ProcessEnv = {},
|
||||
): Promise<CommandResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
...extraEnv,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32' && command.endsWith('.cmd'),
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
child.once('error', (error) => resolve({ status: null, stdout, stderr, error }));
|
||||
child.once('close', (status) => resolve({ status, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
function spawnInCwd(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
home: string,
|
||||
extraEnv: NodeJS.ProcessEnv = {},
|
||||
): ChildProcessWithoutNullStreams {
|
||||
return spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
...extraEnv,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32' && command.endsWith('.cmd'),
|
||||
});
|
||||
}
|
||||
|
||||
async function occupyPort(): Promise<{ server: net.Server; port: number }> {
|
||||
const server = net.createServer();
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
resolve(typeof addr === 'object' && addr ? addr.port : 0);
|
||||
});
|
||||
});
|
||||
return { server, port };
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
const { server, port } = await occupyPort();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
return port;
|
||||
}
|
||||
|
||||
async function extractTarball(file: string, cwd: string): Promise<void> {
|
||||
const tar = await import('tar');
|
||||
await tar.x({ file, cwd });
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
predicate: () => boolean | Promise<boolean>,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function waitForHealth(url: string, timeoutMs: number): Promise<Record<string, unknown>> {
|
||||
let lastError = '';
|
||||
await waitFor(async () => {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(750) });
|
||||
if (!res.ok) {
|
||||
lastError = `HTTP ${res.status}`;
|
||||
return false;
|
||||
}
|
||||
const body = await res.json() as Record<string, unknown>;
|
||||
if (body.status === 'ok' || body.status === 'degraded') return true;
|
||||
lastError = `unexpected status ${String(body.status)}`;
|
||||
return false;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
return false;
|
||||
}
|
||||
}, timeoutMs).catch((err) => {
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)} waiting for ${url}: ${lastError}`);
|
||||
});
|
||||
|
||||
const res = await fetch(url);
|
||||
return await res.json() as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcessWithoutNullStreams): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => child.once('exit', () => resolve())),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
|
||||
]);
|
||||
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
describe('Waggle CLI Launcher', () => {
|
||||
describe('argument parsing', () => {
|
||||
it('uses default port 3333 when no arguments', () => {
|
||||
const result = parseArgs(['node', 'waggle']);
|
||||
expect(result.port).toBe(3333);
|
||||
expect(result.skipLiteLLM).toBe(false);
|
||||
expect(result.noBrowser).toBe(false);
|
||||
expect(result.help).toBe(false);
|
||||
});
|
||||
|
||||
it('parses --port flag', () => {
|
||||
const result = parseArgs(['node', 'waggle', '--port', '4000']);
|
||||
expect(result.port).toBe(4000);
|
||||
});
|
||||
|
||||
it('parses -p shorthand', () => {
|
||||
const result = parseArgs(['node', 'waggle', '-p', '8080']);
|
||||
expect(result.port).toBe(8080);
|
||||
});
|
||||
|
||||
it('rejects invalid port values', () => {
|
||||
const result = parseArgs(['node', 'waggle', '--port', 'abc']);
|
||||
expect(result.port).toBe(3333); // default
|
||||
expect(result.error).toContain('Invalid port');
|
||||
});
|
||||
|
||||
it('rejects out-of-range ports', () => {
|
||||
const neg = parseArgs(['node', 'waggle', '--port', '-1']);
|
||||
expect(neg.port).toBe(3333);
|
||||
expect(neg.error).toContain('Invalid port');
|
||||
|
||||
const big = parseArgs(['node', 'waggle', '--port', '99999']);
|
||||
expect(big.port).toBe(3333);
|
||||
expect(big.error).toContain('Invalid port');
|
||||
});
|
||||
|
||||
it('rejects missing port values', () => {
|
||||
const result = parseArgs(['node', 'waggle', '--port']);
|
||||
expect(result.port).toBe(3333);
|
||||
expect(result.error).toContain('Missing value');
|
||||
});
|
||||
|
||||
it('rejects unknown options before startup', () => {
|
||||
const result = parseArgs(['node', 'waggle', '--wat']);
|
||||
expect(result.error).toContain('Unknown option');
|
||||
});
|
||||
|
||||
it('parses --skip-litellm flag', () => {
|
||||
const result = parseArgs(['node', 'waggle', '--skip-litellm']);
|
||||
expect(result.skipLiteLLM).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --no-open flag', () => {
|
||||
const result = parseArgs(['node', 'waggle', '--no-open']);
|
||||
expect(result.noBrowser).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --help flag', () => {
|
||||
const result = parseArgs(['node', 'waggle', '--help']);
|
||||
expect(result.help).toBe(true);
|
||||
});
|
||||
|
||||
it('parses -h shorthand', () => {
|
||||
const result = parseArgs(['node', 'waggle', '-h']);
|
||||
expect(result.help).toBe(true);
|
||||
});
|
||||
|
||||
it('handles multiple flags together', () => {
|
||||
const result = parseArgs(['node', 'waggle', '--port', '5000', '--skip-litellm', '--no-open']);
|
||||
expect(result.port).toBe(5000);
|
||||
expect(result.skipLiteLLM).toBe(true);
|
||||
expect(result.noBrowser).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Node.js version check', () => {
|
||||
it('current Node version meets minimum requirement (>=18)', () => {
|
||||
const [major] = process.versions.node.split('.').map(Number);
|
||||
expect(major).toBeGreaterThanOrEqual(18);
|
||||
});
|
||||
});
|
||||
|
||||
describe('package configuration', () => {
|
||||
it('package.json has correct bin entry', async () => {
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const { resolve } = await import('node:path');
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(resolve(import.meta.dirname, '..', 'package.json'), 'utf-8')
|
||||
);
|
||||
expect(pkg.name).toBe('@waggle-ai/waggle');
|
||||
expect(pkg.bin).toBeDefined();
|
||||
expect(pkg.bin.waggle).toContain('cli');
|
||||
});
|
||||
});
|
||||
|
||||
describe('startup copy', () => {
|
||||
it('turns port conflicts into actionable CLI guidance', () => {
|
||||
const message = formatStartupFailure(
|
||||
'Port 3333 is already in use. Another Waggle instance may be running.',
|
||||
3333,
|
||||
);
|
||||
|
||||
expect(message).toContain('Failed to start Waggle');
|
||||
expect(message).toContain('Port 3333 is already in use');
|
||||
expect(message).toContain('npx waggle --port 3334');
|
||||
});
|
||||
|
||||
it('explains --no-open success without implying a browser opened', () => {
|
||||
const message = formatStartupSuccess({
|
||||
url: 'http://localhost:3333',
|
||||
llmProvider: 'anthropic-proxy',
|
||||
llmHealth: 'degraded',
|
||||
dataDir: 'C:/tmp/waggle',
|
||||
noBrowser: true,
|
||||
});
|
||||
|
||||
expect(message).toContain('Server: http://localhost:3333');
|
||||
expect(message).toContain('Browser: not opened (--no-open)');
|
||||
expect(message).toContain('Open manually: http://localhost:3333');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime help', () => {
|
||||
it('runs built help without starting service setup', async () => {
|
||||
const home = makeHome();
|
||||
try {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle-ai/waggle'], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const result = await run(process.execPath, [path.join(LAUNCHER_DIR, 'dist', 'cli.js'), '--help'], home);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Usage:');
|
||||
expect(result.stdout).not.toContain('[waggle:service]');
|
||||
expect(result.stderr).toBe('');
|
||||
expect(fs.existsSync(path.join(home, '.waggle'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('runs built invalid-port validation before starting service setup', async () => {
|
||||
const home = makeHome();
|
||||
try {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle-ai/waggle'], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const result = await run(process.execPath, [path.join(LAUNCHER_DIR, 'dist', 'cli.js'), '--port', 'abc'], home);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('Invalid port: abc');
|
||||
expect(result.stderr).not.toContain('[waggle:service]');
|
||||
expect(fs.existsSync(path.join(home, '.waggle'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports occupied ports with a working --port recovery command', async () => {
|
||||
const home = makeHome();
|
||||
const { server: blocker, port } = await occupyPort();
|
||||
try {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle-ai/waggle'], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const result = await run(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(LAUNCHER_DIR, 'dist', 'cli.js'),
|
||||
'--port',
|
||||
String(port),
|
||||
'--skip-litellm',
|
||||
'--no-open',
|
||||
],
|
||||
home,
|
||||
{ WAGGLE_DATA_DIR: path.join(home, 'data') },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(`Port ${port} is already in use`);
|
||||
expect(result.stderr).toContain(`npx waggle --port ${port + 1}`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('packs a tarball with a runnable first command', async () => {
|
||||
const home = makeHome();
|
||||
try {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle-ai/waggle'], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const pack = await run(
|
||||
bin('npm'),
|
||||
['pack', '--workspace', '@waggle-ai/waggle', '--pack-destination', home, '--json'],
|
||||
home,
|
||||
);
|
||||
expect(pack.status).toBe(0);
|
||||
|
||||
const [packResult] = JSON.parse(pack.stdout) as Array<{ filename: string }>;
|
||||
const tarball = path.join(home, packResult.filename);
|
||||
const extractDir = path.join(home, 'packed');
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
await extractTarball(tarball, extractDir);
|
||||
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(extractDir, 'package', 'package.json'), 'utf8'),
|
||||
);
|
||||
const result = await run(
|
||||
process.execPath,
|
||||
[path.join(extractDir, 'package', 'dist', 'cli.js'), '--help'],
|
||||
home,
|
||||
);
|
||||
|
||||
expect(pkg.bin.waggle).toBe('./dist/cli.js');
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Usage:');
|
||||
expect(result.stdout).not.toContain('[waggle:service]');
|
||||
expect(result.stderr).toBe('');
|
||||
expect(fs.existsSync(path.join(home, '.waggle'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('installs the packed launcher and runs npx help plus startup recovery', async () => {
|
||||
const home = makeHome();
|
||||
const { server: blocker, port } = await occupyPort();
|
||||
try {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle-ai/waggle'], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const pack = await run(
|
||||
bin('npm'),
|
||||
['pack', '--workspace', '@waggle-ai/waggle', '--pack-destination', home, '--json'],
|
||||
home,
|
||||
);
|
||||
expect(pack.status).toBe(0);
|
||||
|
||||
const [packResult] = JSON.parse(pack.stdout) as Array<{ filename: string }>;
|
||||
const projectDir = path.join(home, 'project');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'package.json'),
|
||||
JSON.stringify({ private: true, type: 'module' }, null, 2),
|
||||
);
|
||||
|
||||
const install = await runInCwd(
|
||||
bin('npm'),
|
||||
[
|
||||
'install',
|
||||
path.join(home, packResult.filename),
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--prefer-offline',
|
||||
],
|
||||
projectDir,
|
||||
home,
|
||||
);
|
||||
expect(install.status).toBe(0);
|
||||
|
||||
const help = await runInCwd(bin('npx'), ['waggle', '--help'], projectDir, home);
|
||||
expect(help.status).toBe(0);
|
||||
expect(help.stdout).toContain('Usage:');
|
||||
expect(help.stdout).not.toContain('[waggle:service]');
|
||||
expect(help.stderr).toBe('');
|
||||
expect(fs.existsSync(path.join(home, '.waggle'))).toBe(false);
|
||||
|
||||
const startup = await runInCwd(
|
||||
bin('npx'),
|
||||
['waggle', '--port', String(port), '--skip-litellm', '--no-open'],
|
||||
projectDir,
|
||||
home,
|
||||
{ WAGGLE_DATA_DIR: path.join(home, 'data') },
|
||||
);
|
||||
|
||||
expect(startup.status).toBe(1);
|
||||
expect(startup.stderr).toContain(`Port ${port} is already in use`);
|
||||
expect(startup.stderr).toContain(`npx waggle --port ${port + 1}`);
|
||||
expect(fs.existsSync(path.join(home, '.waggle'))).toBe(false);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it('starts the installed launcher long enough to serve health', async () => {
|
||||
const home = makeHome();
|
||||
let child: ChildProcessWithoutNullStreams | undefined;
|
||||
try {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle-ai/waggle'], home);
|
||||
expect(build.status).toBe(0);
|
||||
|
||||
const pack = await run(
|
||||
bin('npm'),
|
||||
['pack', '--workspace', '@waggle-ai/waggle', '--pack-destination', home, '--json'],
|
||||
home,
|
||||
);
|
||||
expect(pack.status).toBe(0);
|
||||
|
||||
const [packResult] = JSON.parse(pack.stdout) as Array<{ filename: string }>;
|
||||
const projectDir = path.join(home, 'project');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, 'package.json'),
|
||||
JSON.stringify({ private: true, type: 'module' }, null, 2),
|
||||
);
|
||||
|
||||
const install = await runInCwd(
|
||||
bin('npm'),
|
||||
[
|
||||
'install',
|
||||
path.join(home, packResult.filename),
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--prefer-offline',
|
||||
],
|
||||
projectDir,
|
||||
home,
|
||||
);
|
||||
expect(install.status).toBe(0);
|
||||
|
||||
const port = await freePort();
|
||||
const dataDir = path.join(home, 'data');
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child = spawnInCwd(
|
||||
bin('npx'),
|
||||
['waggle', '--port', String(port), '--skip-litellm', '--no-open'],
|
||||
projectDir,
|
||||
home,
|
||||
{ WAGGLE_DATA_DIR: dataDir },
|
||||
);
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
|
||||
const health = await waitForHealth(`http://127.0.0.1:${port}/health`, 30_000);
|
||||
await waitFor(() => stdout.includes('Press Ctrl+C to stop'), 10_000);
|
||||
|
||||
expect(health.database).toMatchObject({ healthy: true });
|
||||
expect(stdout).toContain(`Server: http://localhost:${port}`);
|
||||
expect(stdout).toContain('Browser: not opened (--no-open)');
|
||||
expect(stdout).toContain(`Open manually: http://localhost:${port}`);
|
||||
expect(stderr).not.toContain('Failed to start Waggle');
|
||||
expect(fs.existsSync(path.join(dataDir, 'personal.mind'))).toBe(true);
|
||||
} finally {
|
||||
if (child) await stopProcess(child);
|
||||
// Windows can release SQLite WAL/SHM handles a few milliseconds after
|
||||
// taskkill reports success; retry the isolated temp-home cleanup.
|
||||
fs.rmSync(home, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
retryDelay: 100,
|
||||
});
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
});
|
||||
20
packages/launcher/tsup.config.ts
Normal file
20
packages/launcher/tsup.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/cli.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node18',
|
||||
clean: true,
|
||||
shims: true,
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
// Inline all @waggle/* workspace packages into the bundle.
|
||||
// Third-party npm packages stay external (installed as dependencies).
|
||||
noExternal: [/^@waggle\//],
|
||||
// Native/optional packages that cannot be bundled.
|
||||
external: [
|
||||
'playwright-core',
|
||||
'chromium-bidi',
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user