chore: add buildOutput RTK filter, drop legacy cloud sync, internal cleanup
- feat(rtk): buildOutput filter + autodetect for npm/yarn/cargo logs - chore: remove unused cloud sync module and related routes - ui: hide deprecated providers (qwen, iflow, antigravity) - chore: minor tray/cli/internal adjustments
This commit is contained in:
parent
21ea744c72
commit
3cca2252a6
26 changed files with 871 additions and 535 deletions
33
cli/cli.js
33
cli/cli.js
|
|
@ -219,8 +219,12 @@ function killAllAppProcesses(appPort) {
|
|||
});
|
||||
const lines = output.split("\n").slice(1).filter(l => l.trim());
|
||||
lines.forEach(line => {
|
||||
const isAppProcess = line.toLowerCase().includes("9router") ||
|
||||
line.toLowerCase().includes("next-server");
|
||||
// Whitelist: real node process running 9router/cli.js, or next-server.
|
||||
// Avoids killing editors/grep/strace/cursor that just have "9router" in cmdline.
|
||||
const cmd = line.toLowerCase();
|
||||
const isAppProcess =
|
||||
(cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("\\9router") || cmd.includes("/9router")))
|
||||
|| cmd.includes("next-server");
|
||||
if (isAppProcess) {
|
||||
const match = line.match(/^"(\d+)"/);
|
||||
if (match && match[1] && match[1] !== process.pid.toString()) {
|
||||
|
|
@ -241,7 +245,12 @@ function killAllAppProcesses(appPort) {
|
|||
const lines = output.split('\n');
|
||||
|
||||
lines.forEach(line => {
|
||||
const isAppProcess = line.includes("9router") || line.includes("next-server");
|
||||
// Whitelist: real node process running 9router/cli.js, or next-server.
|
||||
// Avoids killing grep/strace/editors/cursor that incidentally match "9router".
|
||||
const cmd = line.toLowerCase();
|
||||
const isAppProcess =
|
||||
(cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("/9router")))
|
||||
|| cmd.includes("next-server");
|
||||
if (isAppProcess) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const pid = parts[1];
|
||||
|
|
@ -685,10 +694,10 @@ function startServer(latestVersion) {
|
|||
const { clearScreen } = require("./src/cli/utils/display");
|
||||
clearScreen();
|
||||
|
||||
// Kill current tray FIRST so the new bgProcess can register a fresh
|
||||
// NSStatusItem on macOS without conflicting with the orphan binary
|
||||
try { require("./src/cli/tray/tray").killTray(); } catch (e) { }
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
// Kill current tray and AWAIT Go binary fully exit. macOS needs the
|
||||
// old NSStatusItem released before a new tray process can register;
|
||||
// otherwise the bgProcess tray silently fails ("works sometimes").
|
||||
try { await require("./src/cli/tray/tray").killTray(); } catch (e) { }
|
||||
|
||||
// Enable auto startup on OS boot
|
||||
try {
|
||||
|
|
@ -699,10 +708,18 @@ function startServer(latestVersion) {
|
|||
}
|
||||
} catch (e) { }
|
||||
|
||||
// Log bgProcess stderr to file so silent tray failures are debuggable.
|
||||
// Previously stdio:"ignore" swallowed every error from systray2 init.
|
||||
const logDir = path.join(getAppDataDir(), "logs");
|
||||
try { fs.mkdirSync(logDir, { recursive: true }); } catch (e) { }
|
||||
const bgLogPath = path.join(logDir, "tray-bg.log");
|
||||
let bgLogFd = "ignore";
|
||||
try { bgLogFd = fs.openSync(bgLogPath, "a"); } catch (e) { }
|
||||
|
||||
// Spawn new detached process with --tray flag
|
||||
const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
stdio: ["ignore", bgLogFd, bgLogFd],
|
||||
windowsHide: true,
|
||||
env: { ...process.env }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ function initUnixTray(options) {
|
|||
items
|
||||
};
|
||||
|
||||
trayInstance = new SysTray({ menu, debug: false, copyDir: false });
|
||||
trayInstance = new SysTray({ menu, debug: false, copyDir: true });
|
||||
isWinTray = false;
|
||||
|
||||
trayInstance.onClick((action) => {
|
||||
|
|
@ -245,31 +245,49 @@ function initUnixTray(options) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Kill/close system tray gracefully
|
||||
* Kill tray, wait Go binary fully exit (returns Promise).
|
||||
* Critical for hide-to-tray: macOS must release NSStatusItem before bgProcess
|
||||
* spawns a new tray, otherwise the new icon silently fails to register.
|
||||
*/
|
||||
function killTray() {
|
||||
const instance = trayInstance;
|
||||
const wasWin = isWinTray;
|
||||
trayInstance = null;
|
||||
if (!instance) return Promise.resolve();
|
||||
|
||||
if (instance) {
|
||||
if (wasWin) {
|
||||
try { instance.kill(); } catch (e) {}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Unix: get the Go tray child process handle, SIGKILL it, await "exit"
|
||||
let proc = null;
|
||||
try {
|
||||
if (wasWin) instance.kill();
|
||||
else {
|
||||
// systray2.kill(false) closes IPC but leaves the Go tray binary
|
||||
// subprocess running, which keeps an orphan NSStatusItem on macOS
|
||||
// and blocks a freshly spawned tray (e.g. hide-to-tray bgProcess)
|
||||
// from registering. Kill the child PID directly first.
|
||||
try {
|
||||
const proc = instance._process || (typeof instance.process === "function" ? instance.process() : null);
|
||||
if (proc && proc.pid) {
|
||||
process.kill(proc.pid, "SIGKILL");
|
||||
}
|
||||
} catch (e) {}
|
||||
instance.kill(false);
|
||||
}
|
||||
proc = instance._process || (typeof instance.process === "function" ? instance.process() : null);
|
||||
} catch (e) {}
|
||||
|
||||
// Always close IPC (best-effort, may throw if pipe already broken)
|
||||
const closeIpc = () => { try { instance.kill(false); } catch (e) {} };
|
||||
|
||||
if (!proc || !proc.pid) {
|
||||
closeIpc();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = () => { if (done) return; done = true; closeIpc(); resolve(); };
|
||||
|
||||
proc.once("exit", finish);
|
||||
try { proc.kill("SIGKILL"); } catch (e) {}
|
||||
|
||||
// Fallback poll in case "exit" never fires (detached child, pipe closed)
|
||||
const deadline = Date.now() + 3000;
|
||||
const poll = setInterval(() => {
|
||||
try { process.kill(proc.pid, 0); } catch { clearInterval(poll); finish(); return; }
|
||||
if (Date.now() > deadline) { clearInterval(poll); finish(); }
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
// Port of auto_detect_filter (rtk/src/cmds/system/pipe_cmd.rs:132-188) + JS extras
|
||||
// Order: git-diff → git-status → grep → find → tree → ls → search-list
|
||||
// Order: git-diff → git-status → build-output → grep → find → tree → ls → search-list
|
||||
// → read-numbered → dedup-log → smart-truncate → null
|
||||
import { DETECT_WINDOW, READ_NUMBERED_MIN_HIT_RATIO, SMART_TRUNCATE_MIN_LINES } from "./constants.js";
|
||||
import { gitDiff } from "./filters/gitDiff.js";
|
||||
import { gitStatus } from "./filters/gitStatus.js";
|
||||
import { buildOutput } from "./filters/buildOutput.js";
|
||||
import { grep } from "./filters/grep.js";
|
||||
import { find } from "./filters/find.js";
|
||||
import { dedupLog } from "./filters/dedupLog.js";
|
||||
|
|
@ -17,6 +18,7 @@ const RE_GIT_DIFF = /^diff --git /m;
|
|||
const RE_GIT_DIFF_HUNK = /^@@ /m;
|
||||
const RE_GIT_STATUS = /^On branch |^nothing to commit|^Changes (not |to be )|^Untracked files:/m;
|
||||
const RE_PORCELAIN = /^[ MADRCU?!][ MADRCU?!] \S/m;
|
||||
const RE_BUILD_OUTPUT = /^(npm (warn|error|ERR!)|yarn (warn|error)|\s*Compiling\s+\S+|\s*Downloading\s+\S+|added \d+ package|\[ERROR\]|BUILD (SUCCESS|FAILED)|\s*Finished\s+|Successfully (installed|built)|ERROR:)/im;
|
||||
const RE_TREE_GLYPH = /[├└]──|│ /;
|
||||
const RE_LS_ROW = /^[-dlbcps][rwx-]{9}/m;
|
||||
const RE_LS_TOTAL = /^total \d+$/m;
|
||||
|
|
@ -26,7 +28,12 @@ export function autoDetectFilter(text) {
|
|||
const head = text.length > DETECT_WINDOW ? text.slice(0, DETECT_WINDOW) : text;
|
||||
|
||||
if (RE_GIT_DIFF.test(head) || RE_GIT_DIFF_HUNK.test(head)) return gitDiff;
|
||||
if (RE_GIT_STATUS.test(head) || isMostlyPorcelain(head)) return gitStatus;
|
||||
if (RE_GIT_STATUS.test(head)) return gitStatus;
|
||||
|
||||
// Build output BEFORE porcelain check: prevents cargo "Compiling" misdetection as git-status
|
||||
if (RE_BUILD_OUTPUT.test(head)) return buildOutput;
|
||||
|
||||
if (isMostlyPorcelain(head)) return gitStatus;
|
||||
|
||||
const lines = head.split("\n");
|
||||
const nonEmpty = lines.filter(l => l.trim().length > 0);
|
||||
|
|
|
|||
|
|
@ -50,5 +50,6 @@ export const FILTERS = {
|
|||
DEDUP_LOG: "dedup-log",
|
||||
SMART_TRUNCATE: "smart-truncate",
|
||||
READ_NUMBERED: "read-numbered",
|
||||
SEARCH_LIST: "search-list"
|
||||
SEARCH_LIST: "search-list",
|
||||
BUILD_OUTPUT: "build-output"
|
||||
};
|
||||
|
|
|
|||
127
open-sse/rtk/filters/buildOutput.js
Normal file
127
open-sse/rtk/filters/buildOutput.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// Compress build tool output (npm, cargo, pip, maven, gradle, etc.)
|
||||
// Keeps: errors, warnings, final summary
|
||||
// Strips: progress logs, verbose "Compiling X" lists, download logs
|
||||
|
||||
// Cargo/rustc error continuation: " --> file:line", " |", "N | code", " = note: ..."
|
||||
const RE_CARGO_ERR_CONT = /^\s*(-->|\||\d+\s*\||=)/;
|
||||
const DEPRECATION_KEEP = 3;
|
||||
|
||||
export function buildOutput(input) {
|
||||
const lines = input.split("\n");
|
||||
if (lines.length === 0) return input;
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const deprecations = [];
|
||||
let summary = null;
|
||||
let compilingCount = 0;
|
||||
let downloadingCount = 0;
|
||||
let inCargoError = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Continuation of cargo error block: keep verbatim while in block
|
||||
if (inCargoError) {
|
||||
if (!trimmed) { inCargoError = false; continue; }
|
||||
if (RE_CARGO_ERR_CONT.test(line)) { errors.push(line); continue; }
|
||||
inCargoError = false;
|
||||
}
|
||||
|
||||
if (!trimmed) continue;
|
||||
|
||||
if (/^npm (ERR!|error)/i.test(trimmed) || /^yarn error/i.test(trimmed)) {
|
||||
errors.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^npm warn deprecated/i.test(trimmed)) {
|
||||
deprecations.push(line);
|
||||
continue;
|
||||
}
|
||||
if (/^npm warn/i.test(trimmed) || /^yarn warn/i.test(trimmed)) {
|
||||
warnings.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^error(\[|:)/i.test(trimmed) || trimmed.startsWith("error -->")) {
|
||||
errors.push(line);
|
||||
inCargoError = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^warning(\[|:)/i.test(trimmed) || trimmed.startsWith("warning -->")) {
|
||||
warnings.push(line);
|
||||
inCargoError = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^ERROR:/i.test(trimmed)) {
|
||||
errors.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\[ERROR\]/i.test(trimmed) || /^BUILD FAILED/i.test(trimmed)) {
|
||||
errors.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\[WARNING\]/i.test(trimmed)) {
|
||||
warnings.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*Compiling\s+\S+/i.test(trimmed)) {
|
||||
compilingCount++;
|
||||
continue;
|
||||
}
|
||||
if (/^\s*Downloading\s+\S+/i.test(trimmed) || /^Fetching\s+/i.test(trimmed)) {
|
||||
downloadingCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
/^(added|removed|changed|audited|installed)\s+\d+\s+package/i.test(trimmed) ||
|
||||
/^\s*Finished\s+/i.test(trimmed) ||
|
||||
/^BUILD SUCCESS/i.test(trimmed) ||
|
||||
/^\d+\s+(vulnerabilities|packages?|warnings?|errors?)/i.test(trimmed) ||
|
||||
/^Successfully (installed|built)/i.test(trimmed) ||
|
||||
/^To address .* issues/i.test(trimmed) ||
|
||||
/^Run `npm (audit|fund)`/i.test(trimmed) ||
|
||||
/packages are looking for funding/i.test(trimmed)
|
||||
) {
|
||||
summary = summary ? `${summary}\n${line}` : line;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let out = "";
|
||||
|
||||
// Keep first N deprecations verbatim (package name + reason), count the rest
|
||||
const keepDep = deprecations.slice(0, DEPRECATION_KEEP);
|
||||
for (const d of keepDep) out += `${d}\n`;
|
||||
if (deprecations.length > DEPRECATION_KEEP) {
|
||||
out += `... +${deprecations.length - DEPRECATION_KEEP} more deprecated packages\n`;
|
||||
}
|
||||
|
||||
if (compilingCount > 0) {
|
||||
out += `Compiled ${compilingCount} packages\n`;
|
||||
}
|
||||
if (downloadingCount > 0) {
|
||||
out += `Downloaded ${downloadingCount} packages\n`;
|
||||
}
|
||||
|
||||
for (const e of errors) out += `${e}\n`;
|
||||
|
||||
const keepWarnings = warnings.slice(0, 5);
|
||||
for (const w of keepWarnings) out += `${w}\n`;
|
||||
if (warnings.length > 5) {
|
||||
out += `... +${warnings.length - 5} more warnings\n`;
|
||||
}
|
||||
|
||||
if (summary) out += `${summary}\n`;
|
||||
|
||||
return out.replace(/\n+$/, "") || input;
|
||||
}
|
||||
|
||||
buildOutput.filterName = "build-output";
|
||||
|
|
@ -273,19 +273,21 @@ export default function ProvidersPage() {
|
|||
}))
|
||||
.filter((p) => matchSearch(p.name));
|
||||
|
||||
const oauthEntries = Object.entries(OAUTH_PROVIDERS).filter(([, info]) =>
|
||||
matchSearch(info.name),
|
||||
const oauthEntries = Object.entries(OAUTH_PROVIDERS).filter(
|
||||
([, info]) => !info.hidden && matchSearch(info.name),
|
||||
);
|
||||
const freeEntries = Object.entries(FREE_PROVIDERS).filter(([, info]) =>
|
||||
matchSearch(info.name),
|
||||
const freeEntries = Object.entries(FREE_PROVIDERS).filter(
|
||||
([, info]) => !info.hidden && matchSearch(info.name),
|
||||
);
|
||||
const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS).filter(
|
||||
([, info]) => matchSearch(info.name),
|
||||
([, info]) => !info.hidden && matchSearch(info.name),
|
||||
);
|
||||
const apikeyEntries = sortByPriority(
|
||||
Object.entries(APIKEY_PROVIDERS).filter(
|
||||
([, info]) =>
|
||||
(info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name),
|
||||
!info.hidden &&
|
||||
(info.serviceKinds ?? ["llm"]).includes("llm") &&
|
||||
matchSearch(info.name),
|
||||
),
|
||||
"apikey",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { validateApiKey, getProviderConnections, getModelAliases } from "@/models";
|
||||
|
||||
// Verify API key and return provider credentials
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return NextResponse.json({ error: "Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
|
||||
// Validate API key
|
||||
const isValid = await validateApiKey(apiKey);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get active provider connections
|
||||
const connections = await getProviderConnections({ isActive: true });
|
||||
|
||||
// Map connections
|
||||
const mappedConnections = connections.map(conn => ({
|
||||
provider: conn.provider,
|
||||
authType: conn.authType,
|
||||
apiKey: conn.apiKey || null,
|
||||
accessToken: conn.accessToken || null,
|
||||
refreshToken: conn.refreshToken || null,
|
||||
projectId: conn.projectId || null,
|
||||
expiresAt: conn.expiresAt,
|
||||
priority: conn.priority,
|
||||
globalPriority: conn.globalPriority,
|
||||
defaultModel: conn.defaultModel,
|
||||
isActive: conn.isActive
|
||||
}));
|
||||
|
||||
// Get model aliases
|
||||
const modelAliases = await getModelAliases();
|
||||
|
||||
return NextResponse.json({
|
||||
connections: mappedConnections,
|
||||
modelAliases
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.log("Cloud auth error:", error);
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { validateApiKey, getProviderConnections, updateProviderConnection } from "@/models";
|
||||
|
||||
// Update provider credentials (for cloud token refresh)
|
||||
export async function PUT(request) {
|
||||
try {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return NextResponse.json({ error: "Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
const body = await request.json();
|
||||
const { provider, credentials } = body;
|
||||
|
||||
if (!provider || !credentials) {
|
||||
return NextResponse.json({ error: "Provider and credentials required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
const isValid = await validateApiKey(apiKey);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Find active connection for provider
|
||||
const connections = await getProviderConnections({ provider, isActive: true });
|
||||
const connection = connections[0];
|
||||
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: `No active connection found for provider: ${provider}` }, { status: 404 });
|
||||
}
|
||||
|
||||
// Update credentials
|
||||
const updateData = {};
|
||||
if (credentials.accessToken) {
|
||||
updateData.accessToken = credentials.accessToken;
|
||||
}
|
||||
if (credentials.refreshToken) {
|
||||
updateData.refreshToken = credentials.refreshToken;
|
||||
}
|
||||
if (credentials.expiresIn) {
|
||||
updateData.expiresAt = new Date(Date.now() + credentials.expiresIn * 1000).toISOString();
|
||||
}
|
||||
|
||||
await updateProviderConnection(connection.id, updateData);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Credentials updated for provider: ${provider}`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.log("Update credentials error:", error);
|
||||
return NextResponse.json({ error: "Failed to update credentials" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { validateApiKey, getModelAliases } from "@/models";
|
||||
|
||||
// Resolve model alias to provider/model
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return NextResponse.json({ error: "Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
|
||||
const body = await request.json();
|
||||
const { alias } = body;
|
||||
|
||||
if (!alias) {
|
||||
return NextResponse.json({ error: "Missing alias" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate API key
|
||||
const isValid = await validateApiKey(apiKey);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get model aliases
|
||||
const modelAliases = await getModelAliases();
|
||||
const resolved = modelAliases[alias];
|
||||
|
||||
if (resolved) {
|
||||
// Parse provider/model
|
||||
const firstSlash = resolved.indexOf("/");
|
||||
if (firstSlash > 0) {
|
||||
return NextResponse.json({
|
||||
alias,
|
||||
provider: resolved.slice(0, firstSlash),
|
||||
model: resolved.slice(firstSlash + 1)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Not found
|
||||
return NextResponse.json({ error: "Alias not found" }, { status: 404 });
|
||||
|
||||
} catch (error) {
|
||||
console.log("Model resolve error:", error);
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { validateApiKey, getModelAliases, setModelAlias } from "@/models";
|
||||
|
||||
// PUT /api/cloud/models/alias - Set model alias (for cloud/CLI)
|
||||
export async function PUT(request) {
|
||||
try {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
const apiKey = authHeader?.replace("Bearer ", "");
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const isValid = await validateApiKey(apiKey);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { model, alias } = body;
|
||||
|
||||
if (!model || !alias) {
|
||||
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if alias already exists for different model
|
||||
const aliases = await getModelAliases();
|
||||
const existingModel = aliases[alias];
|
||||
if (existingModel && existingModel !== model) {
|
||||
return NextResponse.json({
|
||||
error: `Alias '${alias}' already in use for model '${existingModel}'`
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Update alias
|
||||
await setModelAlias(alias, model);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
model,
|
||||
alias,
|
||||
message: `Alias '${alias}' set for model '${model}'`
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error updating alias:", error);
|
||||
return NextResponse.json({ error: "Failed to update alias" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/cloud/models/alias - Get all aliases
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
const apiKey = authHeader?.replace("Bearer ", "");
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const isValid = await validateApiKey(apiKey);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const aliases = await getModelAliases();
|
||||
|
||||
return NextResponse.json({ aliases });
|
||||
} catch (error) {
|
||||
console.log("Error fetching aliases:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch aliases" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,4 @@
|
|||
// Auto-initialize cloud sync when server starts
|
||||
import "@/lib/initCloudSync";
|
||||
|
||||
// This API route is called automatically to initialize sync
|
||||
// This API route is called automatically to initialize app
|
||||
export async function GET() {
|
||||
return new Response("Initialized", { status: 200 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getApiKeys } from "@/lib/localDb";
|
||||
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
|
||||
const CLI_TOKEN_SALT = "9r-cli-auth";
|
||||
|
||||
// POST /api/models/test - Ping a single model via internal completions or embeddings
|
||||
export async function POST(request) {
|
||||
|
|
@ -19,6 +22,8 @@ export async function POST(request) {
|
|||
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
// Bypass dashboardGuard for internal self-call via CLI token (machineId-based)
|
||||
headers["x-9r-cli-token"] = await getConsistentMachineId(CLI_TOKEN_SALT);
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { getProviderConnectionById, getApiKeys } from "@/lib/localDb";
|
|||
import { getProviderModels, PROVIDER_ID_TO_ALIAS } from "open-sse/config/providerModels.js";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
import { UPDATER_CONFIG } from "@/shared/constants/config";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
|
||||
const CLI_TOKEN_SALT = "9r-cli-auth";
|
||||
|
||||
/**
|
||||
* Get an active API key to pass through auth when requireApiKey is enabled.
|
||||
|
|
@ -16,11 +19,12 @@ async function getInternalApiKey() {
|
|||
* Ping a single model via internal completions endpoint (OpenAI format).
|
||||
* open-sse handles all provider translation automatically.
|
||||
*/
|
||||
async function pingModel(modelId, baseUrl, apiKey) {
|
||||
async function pingModel(modelId, baseUrl, apiKey, cliToken) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
if (cliToken) headers["x-9r-cli-token"] = cliToken;
|
||||
const res = await fetch(`${baseUrl}/api/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
|
|
@ -83,17 +87,19 @@ export async function POST(request, { params }) {
|
|||
}
|
||||
|
||||
const apiKey = await getInternalApiKey();
|
||||
// Bypass dashboardGuard for internal self-call via CLI token (machineId-based)
|
||||
const cliToken = await getConsistentMachineId(CLI_TOKEN_SALT);
|
||||
|
||||
// Warm up with first model to trigger token refresh (if needed) before parallel calls.
|
||||
// This prevents race condition where multiple requests concurrently refresh the same token.
|
||||
const [first, ...rest] = models;
|
||||
const firstResult = await pingModel(`${alias}/${first.id}`, baseUrl, apiKey);
|
||||
const firstResult = await pingModel(`${alias}/${first.id}`, baseUrl, apiKey, cliToken);
|
||||
const results = [{ modelId: first.id, name: first.name || first.id, ...firstResult }];
|
||||
|
||||
if (rest.length > 0) {
|
||||
const restResults = await Promise.all(
|
||||
rest.map(async (model) => {
|
||||
const result = await pingModel(`${alias}/${model.id}`, baseUrl, apiKey);
|
||||
const result = await pingModel(`${alias}/${model.id}`, baseUrl, apiKey, cliToken);
|
||||
return { modelId: model.id, name: model.name || model.id, ...result };
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,19 +2,51 @@ import { NextResponse } from "next/server";
|
|||
import { getProviderConnections } from "@/lib/localDb";
|
||||
import { backfillCodexEmails } from "@/lib/oauth/providers";
|
||||
|
||||
// GET /api/providers/client - List all connections for client (includes sensitive fields for sync)
|
||||
// Whitelist: only safe metadata fields exposed to UI
|
||||
const SAFE_FIELDS = [
|
||||
"id", "provider", "authType", "name", "email", "displayName",
|
||||
"priority", "globalPriority", "isActive", "defaultModel",
|
||||
"testStatus", "lastError", "lastErrorAt", "errorCode",
|
||||
"expiresAt", "lastUsedAt", "consecutiveUseCount",
|
||||
"createdAt", "updatedAt",
|
||||
];
|
||||
|
||||
// providerSpecificData fields safe to expose (non-secret config only)
|
||||
const SAFE_PSD_FIELDS = [
|
||||
"baseUrl", "azureEndpoint", "deployment", "apiVersion", "accountId",
|
||||
"region", "projectId", "resourceUrl", "proxyPoolId",
|
||||
"connectionProxyEnabled", "connectionProxyUrl", "connectionNoProxy",
|
||||
"githubLogin", "githubName", "githubEmail", "githubUserId",
|
||||
"username", "firstName", "lastName", "authMethod", "authKind",
|
||||
];
|
||||
|
||||
function maskName(name) {
|
||||
if (typeof name !== "string" || name.length <= 16) return name;
|
||||
// Names like "hahask-uDUOg90..." may embed API keys — mask if looks like key
|
||||
if (/[a-zA-Z0-9_-]{32,}/.test(name)) return `${name.slice(0, 8)}***`;
|
||||
return name;
|
||||
}
|
||||
|
||||
function sanitize(c) {
|
||||
const safe = {};
|
||||
for (const f of SAFE_FIELDS) if (c[f] !== undefined) safe[f] = c[f];
|
||||
if (safe.name) safe.name = maskName(safe.name);
|
||||
if (c.providerSpecificData) {
|
||||
const psd = {};
|
||||
for (const f of SAFE_PSD_FIELDS) {
|
||||
if (c.providerSpecificData[f] !== undefined) psd[f] = c.providerSpecificData[f];
|
||||
}
|
||||
safe.providerSpecificData = psd;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
// GET /api/providers/client - List connections for dashboard UI (whitelist only)
|
||||
export async function GET() {
|
||||
try {
|
||||
await backfillCodexEmails();
|
||||
const connections = await getProviderConnections();
|
||||
|
||||
// Include sensitive fields for sync to cloud (only accessible from same origin)
|
||||
const clientConnections = connections.map(c => ({
|
||||
...c,
|
||||
// Don't hide sensitive fields here since this is for internal sync
|
||||
}));
|
||||
|
||||
return NextResponse.json({ connections: clientConnections });
|
||||
return NextResponse.json({ connections: connections.map(sanitize) });
|
||||
} catch (error) {
|
||||
console.log("Error fetching providers for client:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { callCloudWithMachineId } from "@/shared/utils/cloud.js";
|
||||
import { handleChat } from "@/sse/handlers/chat.js";
|
||||
import { initTranslators } from "open-sse/translator/index.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Inter } from "next/font/google";
|
|||
import "material-symbols/outlined.css";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/shared/components/ThemeProvider";
|
||||
import "@/lib/initCloudSync"; // Auto-initialize cloud sync
|
||||
import "@/lib/network/initOutboundProxy"; // Auto-initialize outbound proxy env
|
||||
import { initConsoleLogCapture } from "@/lib/consoleLogBuffer";
|
||||
import { RuntimeI18nProvider } from "@/i18n/RuntimeI18nProvider";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
// Auto-initialize cloud sync when server starts
|
||||
import "@/lib/initCloudSync";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function InitPage() {
|
||||
|
|
|
|||
|
|
@ -68,8 +68,16 @@ const PROTECTED_API_PATHS = [
|
|||
// Routes that spawn child processes or read host secrets — restrict to localhost.
|
||||
const LOCAL_ONLY_PATHS = [
|
||||
"/api/cli-tools/cowork-settings",
|
||||
"/api/cli-tools/antigravity-mitm",
|
||||
"/api/mcp/",
|
||||
"/api/tunnel/tailscale-install",
|
||||
"/api/tunnel/tailscale-enable",
|
||||
"/api/tunnel/tailscale-disable",
|
||||
"/api/tunnel/tailscale-login",
|
||||
"/api/tunnel/tailscale-start-daemon",
|
||||
"/api/tunnel/tailscale-check",
|
||||
"/api/tunnel/enable",
|
||||
"/api/tunnel/disable",
|
||||
"/api/oauth/cursor/auto-import",
|
||||
"/api/oauth/kiro/auto-import",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import initializeApp from "@/shared/services/initializeApp";
|
||||
|
||||
// Survive Next.js HMR — module-level flag resets on reload, globalThis persists
|
||||
const g = globalThis.__cloudSyncInit ??= { initialized: false, inProgress: null };
|
||||
|
||||
export async function ensureAppInitialized() {
|
||||
if (g.initialized) return true;
|
||||
if (g.inProgress) return g.inProgress;
|
||||
g.inProgress = (async () => {
|
||||
try {
|
||||
await initializeApp();
|
||||
g.initialized = true;
|
||||
} catch (error) {
|
||||
console.error("[ServerInit] Error initializing app:", error);
|
||||
} finally {
|
||||
g.inProgress = null;
|
||||
}
|
||||
return g.initialized;
|
||||
})();
|
||||
return g.inProgress;
|
||||
}
|
||||
|
||||
// Auto-initialize at runtime only, not during next build.
|
||||
// Defer to next tick so HTTP server can accept connections before heavy init runs.
|
||||
if (process.env.NEXT_PHASE !== "phase-production-build") {
|
||||
setImmediate(() => {
|
||||
ensureAppInitialized().catch(console.log);
|
||||
});
|
||||
}
|
||||
|
||||
export default ensureAppInitialized;
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
// Provider definitions
|
||||
|
||||
const RISK_NOTICE = "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.";
|
||||
|
||||
// Free Providers (kiro first, iflow last)
|
||||
export const FREE_PROVIDERS = {
|
||||
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35", website: "https://kiro.dev", notice: { signupUrl: "https://kiro.dev" } },
|
||||
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981", mediaPriority: 999, deprecated: true, deprecationNotice: "Qwen OAuth free tier was discontinued by Alibaba on 2026-04-15. New connections will not work.", website: "https://chat.qwen.ai", notice: { signupUrl: "https://chat.qwen.ai" }, serviceKinds: ["llm", "tts"], ttsConfig: { baseUrl: "http://localhost:8000/v1/audio/speech", authType: "none", authHeader: "none", format: "openai", models: [{ id: "qwen3-tts", name: "Qwen3 TTS" }] } },
|
||||
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: "Gemini CLI is designed exclusively for Gemini CLI. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans.", website: "https://github.com/google-gemini/gemini-cli", notice: { signupUrl: "https://github.com/google-gemini/gemini-cli" } },
|
||||
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981", mediaPriority: 999, hidden: true, deprecated: true, deprecationNotice: "Qwen OAuth free tier was discontinued by Alibaba on 2026-04-15. New connections will not work.", website: "https://chat.qwen.ai", notice: { signupUrl: "https://chat.qwen.ai" }, serviceKinds: ["llm", "tts"], ttsConfig: { baseUrl: "http://localhost:8000/v1/audio/speech", authType: "none", authHeader: "none", format: "openai", models: [{ id: "qwen3-tts", name: "Qwen3 TTS" }] } },
|
||||
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: RISK_NOTICE, website: "https://github.com/google-gemini/gemini-cli", notice: { signupUrl: "https://github.com/google-gemini/gemini-cli" } },
|
||||
// gitlab: { id: "gitlab", alias: "gl", name: "GitLab Duo", icon: "code", color: "#FC6D26" },
|
||||
// codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" },
|
||||
// qoder: { id: "qoder", alias: "qd", name: "Qoder AI", icon: "water_drop", color: "#EC4899" },
|
||||
iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1", website: "https://iflow.cn", notice: { signupUrl: "https://iflow.cn" } },
|
||||
iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1", hidden: true, website: "https://iflow.cn", notice: { signupUrl: "https://iflow.cn" } },
|
||||
opencode: { id: "opencode", alias: "oc", name: "OpenCode Free", icon: "terminal", color: "#E87040", textIcon: "OC", noAuth: true, passthroughModels: true, modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" } },
|
||||
};
|
||||
|
||||
|
|
@ -53,10 +55,10 @@ const MINIMAX_TTS_MODELS = [
|
|||
|
||||
// OAuth Providers
|
||||
export const OAUTH_PROVIDERS = {
|
||||
claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757", website: "https://claude.ai", notice: { signupUrl: "https://claude.ai" } },
|
||||
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", deprecated: true, deprecationNotice: "AG is designed exclusively for Antigravity IDE. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans.", website: "https://antigravity.google", notice: { signupUrl: "https://antigravity.google" } },
|
||||
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", thinkingConfig: THINKING_CONFIG.effort, serviceKinds: ["llm", "image"], kindNotice: { image: "Requires a ChatGPT Plus (or higher) account. Free accounts are not supported for image generation." }, website: "https://chatgpt.com/codex", notice: { signupUrl: "https://chatgpt.com/codex" } },
|
||||
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333", serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://models.github.ai/inference/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }] }, website: "https://github.com/features/copilot", notice: { signupUrl: "https://github.com/features/copilot" } },
|
||||
claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757", deprecated: true, deprecationNotice: RISK_NOTICE, website: "https://claude.ai", notice: { signupUrl: "https://claude.ai" } },
|
||||
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", hidden: true, deprecated: true, deprecationNotice: "AG is designed exclusively for Antigravity IDE. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans.", website: "https://antigravity.google", notice: { signupUrl: "https://antigravity.google" } },
|
||||
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", deprecated: true, deprecationNotice: RISK_NOTICE, thinkingConfig: THINKING_CONFIG.effort, serviceKinds: ["llm", "image"], kindNotice: { image: "Requires a ChatGPT Plus (or higher) account. Free accounts are not supported for image generation." }, website: "https://chatgpt.com/codex", notice: { signupUrl: "https://chatgpt.com/codex" } },
|
||||
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333", deprecated: true, deprecationNotice: RISK_NOTICE, serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://models.github.ai/inference/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }] }, website: "https://github.com/features/copilot", notice: { signupUrl: "https://github.com/features/copilot" } },
|
||||
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA", website: "https://cursor.com", notice: { signupUrl: "https://cursor.com" } },
|
||||
// "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" },
|
||||
kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC", website: "https://kilocode.ai", notice: { signupUrl: "https://kilocode.ai" } },
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { isCloudEnabled } from "@/lib/localDb";
|
||||
|
||||
const INTERNAL_BASE_URL =
|
||||
process.env.BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
"http://localhost:20128";
|
||||
|
||||
/**
|
||||
* Cloud sync scheduler
|
||||
*/
|
||||
export class CloudSyncScheduler {
|
||||
constructor(machineId = null, intervalMinutes = 15) {
|
||||
this.machineId = machineId;
|
||||
this.intervalMinutes = intervalMinutes;
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize machine ID if not provided
|
||||
*/
|
||||
async initializeMachineId() {
|
||||
if (!this.machineId) {
|
||||
this.machineId = await getConsistentMachineId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic sync (delays first sync to allow server to be ready)
|
||||
*/
|
||||
async start() {
|
||||
if (this.intervalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.initializeMachineId();
|
||||
|
||||
// Delay first sync by 30 seconds to ensure server is ready
|
||||
setTimeout(() => {
|
||||
this.syncWithRetry().catch(() => {});
|
||||
}, 30000);
|
||||
|
||||
// Then sync periodically
|
||||
this.intervalId = setInterval(() => {
|
||||
this.syncWithRetry().catch(() => {});
|
||||
}, this.intervalMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic sync
|
||||
*/
|
||||
stop() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync with retry logic (exponential backoff)
|
||||
*/
|
||||
async syncWithRetry(maxRetries = 1) {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await this.sync();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (attempt === maxRetries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const delay = Math.min(1000 * Math.pow(2, attempt), 10000); // Max 10s
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform sync via internal API route (handles token update to db.json)
|
||||
*/
|
||||
async sync() {
|
||||
// Check if cloud is enabled
|
||||
const enabled = await isCloudEnabled();
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.initializeMachineId();
|
||||
|
||||
// Call internal API route which handles both sync and token update
|
||||
const response = await fetch(`${INTERNAL_BASE_URL}/api/sync/cloud`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ machineId: this.machineId, action: "sync" })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Sync failed");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if scheduler is running
|
||||
*/
|
||||
isRunning() {
|
||||
return this.intervalId !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance if needed
|
||||
let cloudSyncScheduler = null;
|
||||
|
||||
export async function getCloudSyncScheduler(machineId = null, intervalMinutes = 15) {
|
||||
if (!cloudSyncScheduler) {
|
||||
cloudSyncScheduler = new CloudSyncScheduler(machineId, intervalMinutes);
|
||||
}
|
||||
return cloudSyncScheduler;
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
/* ========== CLOUD SYNC — COMMENTED OUT (replaced by Tunnel) ==========
|
||||
import { getCloudSyncScheduler } from "@/shared/services/cloudSyncScheduler";
|
||||
========== END CLOUD SYNC ========== */
|
||||
import { cleanupProviderConnections } from "@/lib/localDb";
|
||||
|
||||
/**
|
||||
* Initialize cloud sync scheduler
|
||||
* This should be called when the application starts
|
||||
*/
|
||||
export async function initializeCloudSync() {
|
||||
try {
|
||||
// Cleanup null fields from existing data
|
||||
await cleanupProviderConnections();
|
||||
|
||||
/* ========== CLOUD SYNC — COMMENTED OUT (replaced by Tunnel) ==========
|
||||
// Create scheduler instance with default 15-minute interval
|
||||
const scheduler = await getCloudSyncScheduler(null, 15);
|
||||
|
||||
// Start the scheduler
|
||||
await scheduler.start();
|
||||
|
||||
return scheduler;
|
||||
========== END CLOUD SYNC ========== */
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[CloudSync] Error initializing scheduler:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// For development/testing purposes
|
||||
if (typeof require !== "undefined" && require.main === module) {
|
||||
initializeCloudSync().catch(console.log);
|
||||
}
|
||||
|
||||
export default initializeCloudSync;
|
||||
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { getMachineId } from "@/shared/utils/machine";
|
||||
|
||||
// Function to get cloud URL with machine ID
|
||||
export function getCloudUrl(machineId) {
|
||||
// Get from environment or default to localhost:8787
|
||||
const cloudUrl = process.env.NEXT_PUBLIC_CLOUD_URL || "http://localhost:8787";
|
||||
return `${cloudUrl}/${machineId}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
// Function to call cloud with machine ID
|
||||
export async function callCloudWithMachineId(request) {
|
||||
const machineId = await getMachineId();
|
||||
if (!machineId) {
|
||||
throw new Error("Could not get machine ID");
|
||||
}
|
||||
|
||||
const cloudUrl = getCloudUrl(machineId);
|
||||
|
||||
// Get the original request body and headers
|
||||
const body = await request.json();
|
||||
const headers = new Headers(request.headers);
|
||||
|
||||
// Remove authorization header since cloud won't need it (uses machineId instead)
|
||||
headers.delete("authorization");
|
||||
|
||||
// Call the cloud with machine ID
|
||||
const response = await fetch(cloudUrl, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Function to periodically sync provider data to cloud (now a no-op)
|
||||
export function startProviderSync(cloudUrl, intervalMs = 900000) { // Default 15 minutes
|
||||
console.log("Frontend sync is disabled. Use backend sync instead.");
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,18 +1,6 @@
|
|||
import { getConsistentMachineId } from './machineId';
|
||||
import { getConsistentMachineId } from "./machineId";
|
||||
|
||||
// Get machine ID using node-machine-id with salt
|
||||
export async function getMachineId() {
|
||||
return await getConsistentMachineId();
|
||||
}
|
||||
|
||||
// Keep sync functions for backward compatibility but make them no-ops
|
||||
// (Frontend sync is disabled - use backend sync instead)
|
||||
export async function syncProviderDataToCloud(cloudUrl) {
|
||||
console.log("Frontend sync is disabled. Use backend sync instead.");
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
export async function getProvidersNeedingRefresh() {
|
||||
console.log("Frontend sync is disabled. Use backend sync instead.");
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
|
|
|||
232
tests/unit/buildOutputFilter.test.js
Normal file
232
tests/unit/buildOutputFilter.test.js
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
// Tests for PR #1175: build output filter + porcelain regex fix
|
||||
// Covers edge cases: porcelain workdir-only, cargo misdetection, false positives, compression
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { autoDetectFilter } from "../../open-sse/rtk/autodetect.js";
|
||||
import { buildOutput } from "../../open-sse/rtk/filters/buildOutput.js";
|
||||
import { gitStatus } from "../../open-sse/rtk/filters/gitStatus.js";
|
||||
|
||||
describe("PR #1175 - buildOutput filter detection", () => {
|
||||
it("detects npm install output", () => {
|
||||
const input = [
|
||||
"npm warn deprecated har-validator@5.1.5: this library is no longer supported",
|
||||
"npm warn deprecated uuid@3.4.0: uuid@10 and below is no longer supported",
|
||||
"npm warn deprecated request@2.88.2: request has been deprecated",
|
||||
"added 47 packages, and audited 48 packages in 13s",
|
||||
"3 packages are looking for funding",
|
||||
" run `npm fund` for details",
|
||||
"4 vulnerabilities (2 moderate, 2 critical)",
|
||||
"Run `npm audit` for details."
|
||||
].join("\n");
|
||||
const filter = autoDetectFilter(input);
|
||||
expect(filter).toBe(buildOutput);
|
||||
});
|
||||
|
||||
it("detects cargo build output (no longer misdetected as git-status)", () => {
|
||||
const input = [
|
||||
" Compiling proc-macro2 v1.0.95",
|
||||
" Compiling unicode-ident v1.0.18",
|
||||
" Compiling quote v1.0.40",
|
||||
" Compiling syn v2.0.104",
|
||||
" Compiling my-project v0.1.0 (/home/user/my-project)",
|
||||
" Finished `dev` profile [unoptimized + debuginfo] target(s) in 12.34s"
|
||||
].join("\n");
|
||||
const filter = autoDetectFilter(input);
|
||||
expect(filter).toBe(buildOutput);
|
||||
expect(filter).not.toBe(gitStatus);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PR #1175 - buildOutput compression behavior", () => {
|
||||
it("compresses npm install with deprecations", () => {
|
||||
const input = [
|
||||
"npm warn deprecated har-validator@5.1.5: this library is no longer supported",
|
||||
"npm warn deprecated uuid@3.4.0: uuid@10 and below is no longer supported",
|
||||
"npm warn deprecated request@2.88.2: request has been deprecated",
|
||||
"added 47 packages, and audited 48 packages in 13s",
|
||||
"3 packages are looking for funding",
|
||||
" run `npm fund` for details",
|
||||
"4 vulnerabilities (2 moderate, 2 critical)",
|
||||
"Run `npm audit` for details."
|
||||
].join("\n");
|
||||
const out = buildOutput(input);
|
||||
// Minimal fix: keep first 3 deprecations verbatim (no truncation needed since count == 3)
|
||||
expect(out).toContain("har-validator@5.1.5");
|
||||
expect(out).toContain("uuid@3.4.0");
|
||||
expect(out).toContain("request@2.88.2");
|
||||
expect(out).toContain("added 47 packages");
|
||||
expect(out).toContain("4 vulnerabilities");
|
||||
// 3 deprecations kept verbatim → no size win on this small input
|
||||
expect(out.length).toBeLessThanOrEqual(input.length);
|
||||
});
|
||||
|
||||
it("compresses cargo build output", () => {
|
||||
const input = [
|
||||
" Compiling proc-macro2 v1.0.95",
|
||||
" Compiling unicode-ident v1.0.18",
|
||||
" Compiling quote v1.0.40",
|
||||
" Compiling syn v2.0.104",
|
||||
" Compiling serde v1.0.219",
|
||||
" Compiling serde_derive v1.0.219",
|
||||
" Compiling serde_json v1.0.140",
|
||||
" Compiling tokio v1.45.0",
|
||||
" Compiling hyper v1.6.0",
|
||||
" Compiling my-project v0.1.0 (/home/user/my-project)",
|
||||
" Finished `dev` profile [unoptimized + debuginfo] target(s) in 12.34s"
|
||||
].join("\n");
|
||||
const out = buildOutput(input);
|
||||
expect(out).toContain("Compiled 10 packages");
|
||||
expect(out).toContain("Finished");
|
||||
expect(out.length).toBeLessThan(input.length * 0.5);
|
||||
});
|
||||
|
||||
it("keeps cargo errors verbatim", () => {
|
||||
const input = [
|
||||
" Compiling foo v0.1.0",
|
||||
"error[E0308]: mismatched types",
|
||||
" --> src/main.rs:5:9",
|
||||
" |",
|
||||
"5 | let x: u32 = \"hello\";",
|
||||
" | --- ^^^^^^^ expected `u32`, found `&str`",
|
||||
"error: aborting due to previous error"
|
||||
].join("\n");
|
||||
const out = buildOutput(input);
|
||||
expect(out).toContain("error[E0308]");
|
||||
expect(out).toContain("error: aborting");
|
||||
});
|
||||
|
||||
it("keeps maven BUILD FAILED as error", () => {
|
||||
const input = [
|
||||
"[INFO] Scanning for projects...",
|
||||
"[ERROR] Failed to execute goal",
|
||||
"[ERROR] Could not resolve dependencies",
|
||||
"BUILD FAILED"
|
||||
].join("\n");
|
||||
const out = buildOutput(input);
|
||||
expect(out).toContain("[ERROR] Failed to execute goal");
|
||||
expect(out).toContain("BUILD FAILED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PR #1175 - porcelain regex fix edge cases", () => {
|
||||
it("git status --porcelain workdir-only (space first char) STILL detects as gitStatus (minimal fix preserved old regex)", () => {
|
||||
const input = [
|
||||
" M src/a.js",
|
||||
" M src/b.js",
|
||||
" D src/c.js",
|
||||
"?? new.js"
|
||||
].join("\n");
|
||||
const filter = autoDetectFilter(input);
|
||||
expect(filter).toBe(gitStatus);
|
||||
});
|
||||
|
||||
it("git status --porcelain with staged (status code first char) still detects", () => {
|
||||
const input = [
|
||||
"M src/a.js",
|
||||
"A src/new.js",
|
||||
"?? untracked.js",
|
||||
"M src/b.js"
|
||||
].join("\n");
|
||||
const filter = autoDetectFilter(input);
|
||||
expect(filter).toBe(gitStatus);
|
||||
});
|
||||
|
||||
it("cargo Compiling lines NOT detected as git-status (porcelain false positive fix)", () => {
|
||||
const input = [
|
||||
" Compiling proc-macro2 v1.0.95",
|
||||
" Compiling unicode-ident v1.0.18",
|
||||
" Compiling quote v1.0.40"
|
||||
].join("\n");
|
||||
const filter = autoDetectFilter(input);
|
||||
expect(filter).not.toBe(gitStatus);
|
||||
});
|
||||
|
||||
it("long-form git status with 'On branch' always detects", () => {
|
||||
const input = [
|
||||
"On branch main",
|
||||
"Your branch is up to date with 'origin/main'.",
|
||||
"",
|
||||
"Changes not staged for commit:",
|
||||
"\tmodified: src/a.js"
|
||||
].join("\n");
|
||||
const filter = autoDetectFilter(input);
|
||||
expect(filter).toBe(gitStatus);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PR #1175 - false positive risks", () => {
|
||||
it("generic app log with 'ERROR:' triggers buildOutput (potential false positive)", () => {
|
||||
const input = [
|
||||
"2026-05-16 10:00:00 INFO Server started on port 3000",
|
||||
"2026-05-16 10:00:05 INFO Request received: GET /api/users",
|
||||
"ERROR: Database connection timeout",
|
||||
"2026-05-16 10:00:10 INFO Retrying connection",
|
||||
"2026-05-16 10:00:15 INFO Connection restored"
|
||||
].join("\n");
|
||||
const filter = autoDetectFilter(input);
|
||||
// Document actual behavior — may detect as buildOutput
|
||||
console.log("[generic-error-log] detected:", filter?.filterName || "null");
|
||||
// Whatever the detection, the filter should NOT corrupt the data
|
||||
if (filter === buildOutput) {
|
||||
const out = buildOutput(input);
|
||||
expect(out).toContain("ERROR: Database connection timeout");
|
||||
}
|
||||
});
|
||||
|
||||
it("generic 'Compiling templates' (non-build context) triggers buildOutput", () => {
|
||||
const input = [
|
||||
"[INFO] Starting application",
|
||||
"[INFO] Compiling templates for view layer",
|
||||
"[INFO] Compiling assets for production",
|
||||
"[INFO] Application ready"
|
||||
].join("\n");
|
||||
const filter = autoDetectFilter(input);
|
||||
console.log("[compiling-templates] detected:", filter?.filterName || "null");
|
||||
if (filter === buildOutput) {
|
||||
const out = buildOutput(input);
|
||||
// Should at least preserve structure (count compiling)
|
||||
expect(out).toContain("Compiled");
|
||||
}
|
||||
});
|
||||
|
||||
it("plain text with no patterns falls through (no false positive)", () => {
|
||||
const input = [
|
||||
"Hello world",
|
||||
"This is a normal message",
|
||||
"Nothing special here"
|
||||
].join("\n");
|
||||
const filter = autoDetectFilter(input);
|
||||
expect(filter).not.toBe(buildOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PR #1175 - safety: no data corruption", () => {
|
||||
it("empty input returns input", () => {
|
||||
expect(buildOutput("")).toBe("");
|
||||
});
|
||||
|
||||
it("input with only errors preserves all errors", () => {
|
||||
const input = "npm ERR! code ENOENT\nnpm ERR! syscall open\nnpm ERR! path /tmp/foo";
|
||||
const out = buildOutput(input);
|
||||
expect(out).toContain("npm ERR! code ENOENT");
|
||||
expect(out).toContain("npm ERR! syscall open");
|
||||
expect(out).toContain("npm ERR! path /tmp/foo");
|
||||
});
|
||||
|
||||
it("input with no recognized patterns returns input (fallback)", () => {
|
||||
const input = "random text\nmore random\nstill random";
|
||||
const out = buildOutput(input);
|
||||
// When nothing matches, returns input via `|| input`
|
||||
expect(out).toBe(input);
|
||||
});
|
||||
|
||||
it("limits warnings to 5 + summary line", () => {
|
||||
const warnings = [];
|
||||
for (let i = 0; i < 10; i++) warnings.push(`npm warn config foo${i} something`);
|
||||
const input = warnings.join("\n");
|
||||
const out = buildOutput(input);
|
||||
expect(out).toContain("npm warn config foo0");
|
||||
expect(out).toContain("npm warn config foo4");
|
||||
expect(out).toContain("... +5 more warnings");
|
||||
expect(out).not.toContain("npm warn config foo9");
|
||||
});
|
||||
});
|
||||
357
tests/unit/buildOutputFilterAdversarial.test.js
Normal file
357
tests/unit/buildOutputFilterAdversarial.test.js
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
// Adversarial / edge-case tests for PR #1175
|
||||
// Goals: find corruption, boundary bugs, false positives, integration regressions
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { autoDetectFilter } from "../../open-sse/rtk/autodetect.js";
|
||||
import { buildOutput } from "../../open-sse/rtk/filters/buildOutput.js";
|
||||
import { gitDiff } from "../../open-sse/rtk/filters/gitDiff.js";
|
||||
import { gitStatus } from "../../open-sse/rtk/filters/gitStatus.js";
|
||||
import { safeApply } from "../../open-sse/rtk/applyFilter.js";
|
||||
import { compressMessages } from "../../open-sse/rtk/index.js";
|
||||
import { DETECT_WINDOW, MIN_COMPRESS_SIZE } from "../../open-sse/rtk/constants.js";
|
||||
|
||||
// ============================================================
|
||||
// 1. PRIORITY / OVERLAPPING PATTERNS
|
||||
// ============================================================
|
||||
describe("PR #1175 - priority with overlapping patterns", () => {
|
||||
it("git-diff wins over buildOutput when both present", () => {
|
||||
const input = [
|
||||
"diff --git a/Cargo.toml b/Cargo.toml",
|
||||
"index abc..def 100644",
|
||||
"--- a/Cargo.toml",
|
||||
"+++ b/Cargo.toml",
|
||||
"@@ -1,3 +1,3 @@",
|
||||
"-version = \"0.1.0\"",
|
||||
"+version = \"0.2.0\"",
|
||||
" Compiling foo v0.1.0"
|
||||
].join("\n");
|
||||
expect(autoDetectFilter(input)).toBe(gitDiff);
|
||||
});
|
||||
|
||||
it("git-status (long form) wins over buildOutput", () => {
|
||||
const input = [
|
||||
"On branch main",
|
||||
"Changes not staged for commit:",
|
||||
"\tmodified: Cargo.toml",
|
||||
" Compiling foo v0.1.0"
|
||||
].join("\n");
|
||||
expect(autoDetectFilter(input)).toBe(gitStatus);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 2. BOUNDARY: DETECT_WINDOW
|
||||
// ============================================================
|
||||
describe("PR #1175 - DETECT_WINDOW boundary", () => {
|
||||
it("build pattern beyond DETECT_WINDOW chars: NOT detected", () => {
|
||||
const padding = "x".repeat(DETECT_WINDOW + 100);
|
||||
const input = `${padding}\n Compiling foo v0.1.0\n Finished release in 1.2s`;
|
||||
const filter = autoDetectFilter(input);
|
||||
// Pattern lives past detection window — won't be seen
|
||||
expect(filter).not.toBe(buildOutput);
|
||||
});
|
||||
|
||||
it("build pattern at very start: detected", () => {
|
||||
const input = " Compiling foo v0.1.0\n" + "y".repeat(2000);
|
||||
expect(autoDetectFilter(input)).toBe(buildOutput);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 3. LINE ENDINGS / WHITESPACE QUIRKS
|
||||
// ============================================================
|
||||
describe("PR #1175 - line endings & whitespace", () => {
|
||||
it("CRLF line endings still detect", () => {
|
||||
const input = "npm warn deprecated foo@1.0.0\r\nadded 5 packages in 2s\r\n";
|
||||
expect(autoDetectFilter(input)).toBe(buildOutput);
|
||||
});
|
||||
|
||||
it("Tab-prefixed Compiling (real cargo output uses leading spaces, not tab)", () => {
|
||||
const input = "\tCompiling foo v0.1.0\n\tCompiling bar v0.2.0\n\tFinished dev in 1s";
|
||||
const filter = autoDetectFilter(input);
|
||||
// \s matches tab, so should detect
|
||||
expect(filter).toBe(buildOutput);
|
||||
});
|
||||
|
||||
it("Compiling without leading spaces", () => {
|
||||
const input = "Compiling foo v0.1.0\nCompiling bar v0.2.0\nFinished dev in 1s";
|
||||
expect(autoDetectFilter(input)).toBe(buildOutput);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 4. ADVERSARIAL: USER CODE / STRING LITERALS
|
||||
// ============================================================
|
||||
describe("PR #1175 - adversarial: user code containing build strings", () => {
|
||||
it("user JS code with console.log('npm warn ...') triggers buildOutput", () => {
|
||||
// This is a realistic case: LLM is reading a file with this code
|
||||
const input = [
|
||||
"function logWarning() {",
|
||||
" console.log('npm warn this is a warning');",
|
||||
" return true;",
|
||||
"}",
|
||||
"function logError() {",
|
||||
" console.log('npm error something bad');",
|
||||
"}"
|
||||
].join("\n");
|
||||
const filter = autoDetectFilter(input);
|
||||
// Regex uses `m` flag, so ^ matches line start — these are inside indented code
|
||||
// BUT: regex uses 'i' so case-insensitive, and `^npm warn` requires line to START with it
|
||||
console.log("[user-code-npm-warn] detected:", filter?.filterName || "null");
|
||||
// Expectation: should NOT detect (lines start with spaces)
|
||||
expect(filter).not.toBe(buildOutput);
|
||||
});
|
||||
|
||||
it("file content with 'BUILD SUCCESS' on its own line triggers buildOutput", () => {
|
||||
const input = [
|
||||
"Here is the deployment script:",
|
||||
"It outputs:",
|
||||
"BUILD SUCCESS",
|
||||
"when complete."
|
||||
].join("\n");
|
||||
const filter = autoDetectFilter(input);
|
||||
console.log("[file-content-build-success] detected:", filter?.filterName || "null");
|
||||
// Document behavior — buildOutput should preserve non-pattern lines as fallback
|
||||
if (filter === buildOutput) {
|
||||
const out = buildOutput(input);
|
||||
// BUILD SUCCESS preserved
|
||||
expect(out).toContain("BUILD SUCCESS");
|
||||
}
|
||||
});
|
||||
|
||||
it("real cargo error spanning multiple lines preserves context", () => {
|
||||
const input = [
|
||||
" Compiling my-app v0.1.0",
|
||||
"error[E0432]: unresolved import `foo::bar`",
|
||||
" --> src/main.rs:2:5",
|
||||
" |",
|
||||
"2 | use foo::bar;",
|
||||
" | ^^^^^^^^ no `bar` in `foo`",
|
||||
"",
|
||||
"error: aborting due to previous error",
|
||||
"",
|
||||
"For more information about this error, try `rustc --explain E0432`.",
|
||||
"error: could not compile `my-app` (bin \"my-app\") due to previous error"
|
||||
].join("\n");
|
||||
const out = buildOutput(input);
|
||||
expect(out).toContain("error[E0432]");
|
||||
expect(out).toContain("error: aborting");
|
||||
expect(out).toContain("error: could not compile");
|
||||
// Minimal fix: cargo error context lines now preserved
|
||||
expect(out).toContain("use foo::bar");
|
||||
expect(out).toContain("no `bar`");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 5. CORRUPTION / SAFETY: NO EMPTY OUTPUT
|
||||
// ============================================================
|
||||
describe("PR #1175 - corruption safety", () => {
|
||||
it("input with only progress lines (no errors/warnings/summary) returns input fallback", () => {
|
||||
const input = [
|
||||
" Compiling a v0.1.0",
|
||||
" Compiling b v0.1.0",
|
||||
" Compiling c v0.1.0"
|
||||
].join("\n");
|
||||
const out = buildOutput(input);
|
||||
// out = "Compiled 3 packages" (non-empty)
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
expect(out).toContain("Compiled 3 packages");
|
||||
});
|
||||
|
||||
it("input with only Downloading lines", () => {
|
||||
const input = [
|
||||
" Downloading foo v0.1.0",
|
||||
" Downloading bar v0.2.0",
|
||||
"Fetching baz from registry"
|
||||
].join("\n");
|
||||
const out = buildOutput(input);
|
||||
expect(out).toContain("Downloaded");
|
||||
});
|
||||
|
||||
it("input with ONLY a single ERROR: line", () => {
|
||||
const input = "ERROR: Something failed";
|
||||
const out = buildOutput(input);
|
||||
expect(out).toContain("ERROR: Something failed");
|
||||
});
|
||||
|
||||
it("unicode/emoji in deprecation warning preserved (minimal fix keeps first 3 verbatim)", () => {
|
||||
const input = [
|
||||
"npm warn deprecated 📦 foo@1.0.0: 🚫 deprecated reason",
|
||||
"added 1 package ✨",
|
||||
"Run `npm audit` for details."
|
||||
].join("\n");
|
||||
const out = buildOutput(input);
|
||||
expect(out).toContain("📦");
|
||||
expect(out).toContain("foo@1.0.0");
|
||||
expect(out).toContain("added 1 package ✨");
|
||||
});
|
||||
|
||||
it("more than 3 deprecations: keep first 3 verbatim + count rest", () => {
|
||||
const input = [
|
||||
"npm warn deprecated a@1.0.0: reason A",
|
||||
"npm warn deprecated b@1.0.0: reason B",
|
||||
"npm warn deprecated c@1.0.0: reason C",
|
||||
"npm warn deprecated d@1.0.0: reason D",
|
||||
"npm warn deprecated e@1.0.0: reason E",
|
||||
"added 5 packages"
|
||||
].join("\n");
|
||||
const out = buildOutput(input);
|
||||
expect(out).toContain("a@1.0.0");
|
||||
expect(out).toContain("b@1.0.0");
|
||||
expect(out).toContain("c@1.0.0");
|
||||
expect(out).not.toContain("d@1.0.0");
|
||||
expect(out).not.toContain("e@1.0.0");
|
||||
expect(out).toContain("... +2 more deprecated packages");
|
||||
});
|
||||
|
||||
it("safeApply wraps buildOutput against panics", () => {
|
||||
// Pass a non-string input via direct call — safeApply should catch
|
||||
const out = safeApply(buildOutput, "npm warn deprecated foo\nadded 1 package\n");
|
||||
expect(typeof out).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 6. INTEGRATION: compressMessages pipeline
|
||||
// ============================================================
|
||||
describe("PR #1175 - integration with compressMessages", () => {
|
||||
function buildBody(toolResultText) {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "id1", content: toolResultText }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
it("npm install output above MIN_COMPRESS_SIZE → compressed", () => {
|
||||
const padding = "npm warn deprecated foo@1.0.0: this is a deprecation warning\n".repeat(20);
|
||||
const text = padding + "added 47 packages, and audited 48 packages in 13s\n4 vulnerabilities (2 moderate, 2 critical)\nRun `npm audit` for details.";
|
||||
expect(text.length).toBeGreaterThan(MIN_COMPRESS_SIZE);
|
||||
const body = buildBody(text);
|
||||
const stats = compressMessages(body, true);
|
||||
expect(stats).toBeTruthy();
|
||||
expect(stats.hits.length).toBe(1);
|
||||
expect(stats.hits[0].filter).toBe("build-output");
|
||||
expect(stats.bytesAfter).toBeLessThan(stats.bytesBefore);
|
||||
const compressed = body.messages[0].content[0].content;
|
||||
expect(compressed).toContain("... +17 more deprecated packages");
|
||||
});
|
||||
|
||||
it("input below MIN_COMPRESS_SIZE → NOT compressed", () => {
|
||||
const text = "npm warn deprecated foo\nadded 1 package";
|
||||
expect(text.length).toBeLessThan(MIN_COMPRESS_SIZE);
|
||||
const body = buildBody(text);
|
||||
const stats = compressMessages(body, true);
|
||||
expect(stats.hits.length).toBe(0);
|
||||
expect(body.messages[0].content[0].content).toBe(text);
|
||||
});
|
||||
|
||||
it("compressed output never grows input (safety guard)", () => {
|
||||
// Pathological: every line is something buildOutput keeps verbatim
|
||||
const text = "npm ERR! error line 1\nnpm ERR! error line 2\nnpm ERR! error line 3\n".repeat(20);
|
||||
const body = buildBody(text);
|
||||
const stats = compressMessages(body, true);
|
||||
// either no hit (grew) or hit and shrunk
|
||||
const after = body.messages[0].content[0].content;
|
||||
expect(after.length).toBeLessThanOrEqual(text.length);
|
||||
});
|
||||
|
||||
it("tool_result with is_error:true is NOT compressed (preserve error traces)", () => {
|
||||
const text = "npm warn deprecated foo@1.0.0\n".repeat(30) + "added 5 packages in 2s";
|
||||
const body = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "id1", content: text, is_error: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
const stats = compressMessages(body, true);
|
||||
expect(stats.hits.length).toBe(0);
|
||||
expect(body.messages[0].content[0].content).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 7. PORCELAIN REGRESSION DEEPER TESTS
|
||||
// ============================================================
|
||||
describe("PR #1175 - porcelain regression deeper", () => {
|
||||
it("mixed staged + workdir + untracked porcelain → detected (has status code first char)", () => {
|
||||
const input = [
|
||||
"M src/staged.js", // staged modified
|
||||
" M src/workdir.js", // workdir modified (space first)
|
||||
"?? new.js",
|
||||
"A src/added.js"
|
||||
].join("\n");
|
||||
const filter = autoDetectFilter(input);
|
||||
// M and A and ?? lines have status code first → 4/4 lines hit? No — " M" has space first
|
||||
// isMostlyPorcelain requires >= 60% hit. With new regex, hits = M/?/A = 3, total = 4, 75% ≥ 60%
|
||||
expect(filter).toBe(gitStatus);
|
||||
});
|
||||
|
||||
it("100% workdir-only porcelain → STILL detects gitStatus (minimal fix preserved old regex)", () => {
|
||||
const input = [
|
||||
" M src/a.js",
|
||||
" M src/b.js",
|
||||
" M src/c.js",
|
||||
" D src/d.js"
|
||||
].join("\n");
|
||||
const filter = autoDetectFilter(input);
|
||||
expect(filter).toBe(gitStatus);
|
||||
});
|
||||
|
||||
it("manual gitStatus() call on workdir-only porcelain still parses correctly", () => {
|
||||
const input = [
|
||||
" M src/a.js",
|
||||
" M src/b.js",
|
||||
" D src/c.js"
|
||||
].join("\n");
|
||||
const out = gitStatus(input);
|
||||
expect(out).toContain("Modified: 3 files");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 8. PATHOLOGICAL INPUTS
|
||||
// ============================================================
|
||||
describe("PR #1175 - pathological", () => {
|
||||
it("very long single line (no newlines) with build pattern", () => {
|
||||
const input = "npm warn deprecated foo@1.0.0: " + "x".repeat(5000);
|
||||
const filter = autoDetectFilter(input);
|
||||
// Pattern at start (within DETECT_WINDOW)
|
||||
expect(filter).toBe(buildOutput);
|
||||
// Should NOT crash
|
||||
const out = buildOutput(input);
|
||||
expect(typeof out).toBe("string");
|
||||
});
|
||||
|
||||
it("10000 Compiling lines don't crash", () => {
|
||||
const lines = [];
|
||||
for (let i = 0; i < 10000; i++) lines.push(` Compiling pkg${i} v0.1.0`);
|
||||
lines.push(" Finished dev in 60s");
|
||||
const input = lines.join("\n");
|
||||
const out = buildOutput(input);
|
||||
expect(out).toContain("Compiled 10000 packages");
|
||||
expect(out).toContain("Finished");
|
||||
expect(out.length).toBeLessThan(input.length / 100);
|
||||
});
|
||||
|
||||
it("input with only newlines", () => {
|
||||
const input = "\n\n\n\n";
|
||||
const out = buildOutput(input);
|
||||
expect(typeof out).toBe("string");
|
||||
});
|
||||
|
||||
it("null/undefined safety via safeApply", () => {
|
||||
// buildOutput would throw on null.split() — safeApply must catch
|
||||
const out = safeApply(buildOutput, null);
|
||||
expect(out).toBe(null);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue