feat(FN-2909): merge fusion/fn-2909
This commit is contained in:
@@ -116,21 +116,20 @@ describe("CLI bundle output", () => {
|
||||
expect(existsSync(join(stagedRoot, "src", "process-manager.ts"))).toBe(true);
|
||||
});
|
||||
|
||||
it("pi-claude-cli source does not import cross-spawn", () => {
|
||||
it("pi-claude-cli source imports cross-spawn", () => {
|
||||
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");
|
||||
|
||||
expect(processManagerSource).not.toMatch(/import\s+.*cross-spawn/);
|
||||
expect(processManagerSource).toMatch(/import\s*\{[^}]*spawn[^}]*\}\s*from\s*["']node:child_process["']/);
|
||||
expect(processManagerSource).toMatch(/import\s+spawn\s+from\s*["']cross-spawn["']/);
|
||||
});
|
||||
|
||||
it("pi-claude-cli package.json has no cross-spawn dependency", () => {
|
||||
it("pi-claude-cli package.json includes cross-spawn dependency", () => {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(cliRoot, "dist", "pi-claude-cli", "package.json"), "utf-8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(packageJson.dependencies?.["cross-spawn"]).toBeUndefined();
|
||||
expect(packageJson.dependencies?.["cross-spawn"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("runtime native assets are staged after build:exe", () => {
|
||||
|
||||
@@ -430,6 +430,38 @@ describe("ChatStore", () => {
|
||||
expect(message.metadata).toEqual({ tokens: 150, finishReason: "stop" });
|
||||
});
|
||||
|
||||
it("round-trips attachments metadata", () => {
|
||||
const session = createTestSession(store);
|
||||
const attachments = [{
|
||||
id: "att-abc123",
|
||||
filename: "123-file.png",
|
||||
originalName: "file.png",
|
||||
mimeType: "image/png",
|
||||
size: 1024,
|
||||
createdAt: new Date().toISOString(),
|
||||
}];
|
||||
|
||||
const created = store.addMessage(session.id, {
|
||||
role: "user",
|
||||
content: "with attachment",
|
||||
attachments,
|
||||
});
|
||||
|
||||
expect(created.attachments).toEqual(attachments);
|
||||
const loaded = store.getMessage(created.id);
|
||||
expect(loaded?.attachments).toEqual(attachments);
|
||||
});
|
||||
|
||||
it("returns undefined attachments when not provided", () => {
|
||||
const session = createTestSession(store);
|
||||
const created = store.addMessage(session.id, {
|
||||
role: "user",
|
||||
content: "without attachment",
|
||||
});
|
||||
|
||||
expect(created.attachments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws error when session does not exist", () => {
|
||||
expect(() => {
|
||||
store.addMessage("chat-nonexistent", {
|
||||
@@ -454,6 +486,53 @@ describe("ChatStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("addMessageAttachment", () => {
|
||||
it("appends to existing attachments", () => {
|
||||
const session = createTestSession(store);
|
||||
const message = store.addMessage(session.id, {
|
||||
role: "user",
|
||||
content: "hello",
|
||||
attachments: [{
|
||||
id: "att-1",
|
||||
filename: "a.txt",
|
||||
originalName: "a.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
}],
|
||||
});
|
||||
|
||||
const updated = store.addMessageAttachment(session.id, message.id, {
|
||||
id: "att-2",
|
||||
filename: "b.txt",
|
||||
originalName: "b.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 2,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(updated.attachments).toHaveLength(2);
|
||||
expect(updated.attachments?.[1]?.id).toBe("att-2");
|
||||
});
|
||||
|
||||
it("creates attachment array when message has none", () => {
|
||||
const session = createTestSession(store);
|
||||
const message = store.addMessage(session.id, { role: "user", content: "hello" });
|
||||
|
||||
const updated = store.addMessageAttachment(session.id, message.id, {
|
||||
id: "att-3",
|
||||
filename: "c.txt",
|
||||
originalName: "c.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 3,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(updated.attachments).toHaveLength(1);
|
||||
expect(updated.attachments?.[0]?.id).toBe("att-3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMessages", () => {
|
||||
it("returns messages for a session ordered by createdAt ASC", async () => {
|
||||
const session = createTestSession(store);
|
||||
@@ -771,6 +850,26 @@ describe("ChatStore", () => {
|
||||
expect(handler.mock.calls[0][0].id).toBe(session.id);
|
||||
});
|
||||
|
||||
it("addMessageAttachment emits chat:message:updated", () => {
|
||||
const handler = vi.fn();
|
||||
store.on("chat:message:updated", handler);
|
||||
|
||||
const session = createTestSession(store);
|
||||
const message = store.addMessage(session.id, { role: "user", content: "hello" });
|
||||
|
||||
const updated = store.addMessageAttachment(session.id, message.id, {
|
||||
id: "att-evt",
|
||||
filename: "evt.txt",
|
||||
originalName: "evt.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 4,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(updated);
|
||||
});
|
||||
|
||||
it("deleteMessage does NOT emit for non-existent message", () => {
|
||||
const handler = vi.fn();
|
||||
store.on("chat:message:deleted", handler);
|
||||
|
||||
@@ -131,7 +131,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -154,7 +154,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -761,7 +761,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -786,11 +786,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -825,7 +825,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("priority");
|
||||
@@ -866,7 +866,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -935,7 +935,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -976,7 +976,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -1000,7 +1000,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -1104,7 +1104,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1555,7 +1555,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -779,7 +779,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||
const db1 = createDatabase(legacyDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(50);
|
||||
expect(db1.getSchemaVersion()).toBe(51);
|
||||
db1.close();
|
||||
|
||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||
@@ -814,7 +814,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||
// Now run init — this triggers the v32→v33 migration
|
||||
db3.init();
|
||||
expect(db3.getSchemaVersion()).toBe(50);
|
||||
expect(db3.getSchemaVersion()).toBe(51);
|
||||
|
||||
// Step 4: Verify insight tables exist after migration
|
||||
const tablesAfter = db3.prepare(
|
||||
@@ -845,12 +845,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
try {
|
||||
const db1 = createDatabase(testDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(50);
|
||||
expect(db1.getSchemaVersion()).toBe(51);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(50);
|
||||
expect(db2.getSchemaVersion()).toBe(51);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
|
||||
@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 40 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
|
||||
@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
|
||||
|
||||
describe("schema version", () => {
|
||||
it("schema version is 40 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(50);
|
||||
expect(db.getSchemaVersion()).toBe(51);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
ChatSessionStatus,
|
||||
ChatMessage,
|
||||
ChatMessageRole,
|
||||
ChatAttachment,
|
||||
ChatMessageCreateInput,
|
||||
ChatSessionCreateInput,
|
||||
ChatSessionUpdateInput,
|
||||
@@ -38,6 +39,8 @@ export interface ChatStoreEvents {
|
||||
"chat:message:added": [message: ChatMessage];
|
||||
/** Emitted when a message is deleted from a session */
|
||||
"chat:message:deleted": [messageId: string];
|
||||
/** Emitted when a message is updated (e.g., attachment appended) */
|
||||
"chat:message:updated": [message: ChatMessage];
|
||||
}
|
||||
|
||||
// ── Row Interfaces ───────────────────────────────────────────────────
|
||||
@@ -63,6 +66,7 @@ interface ChatMessageRow {
|
||||
content: string;
|
||||
thinkingOutput: string | null;
|
||||
metadata: string | null;
|
||||
attachments: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -107,6 +111,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
content: row.content,
|
||||
thinkingOutput: row.thinkingOutput ?? null,
|
||||
metadata: fromJson<Record<string, unknown>>(row.metadata) ?? null,
|
||||
attachments: fromJson<ChatAttachment[]>(row.attachments) ?? undefined,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -369,12 +374,13 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
content: input.content,
|
||||
thinkingOutput: input.thinkingOutput ?? null,
|
||||
metadata: input.metadata ?? null,
|
||||
attachments: input.attachments,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO chat_messages (id, sessionId, role, content, thinkingOutput, metadata, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO chat_messages (id, sessionId, role, content, thinkingOutput, metadata, attachments, createdAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
message.id,
|
||||
message.sessionId,
|
||||
@@ -382,6 +388,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
message.content,
|
||||
message.thinkingOutput,
|
||||
toJsonNullable(message.metadata),
|
||||
toJsonNullable(message.attachments),
|
||||
message.createdAt,
|
||||
);
|
||||
|
||||
@@ -393,6 +400,32 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a file attachment metadata record to an existing message.
|
||||
*/
|
||||
addMessageAttachment(sessionId: string, messageId: string, attachment: ChatAttachment): ChatMessage {
|
||||
const message = this.getMessage(messageId);
|
||||
if (!message || message.sessionId !== sessionId) {
|
||||
throw new Error(`Message ${messageId} not found in session ${sessionId}`);
|
||||
}
|
||||
|
||||
const updatedAttachments = [...(message.attachments ?? []), attachment];
|
||||
this.db.prepare(`
|
||||
UPDATE chat_messages
|
||||
SET attachments = ?
|
||||
WHERE id = ?
|
||||
`).run(toJsonNullable(updatedAttachments), messageId);
|
||||
|
||||
const updated = this.getMessage(messageId);
|
||||
if (!updated) {
|
||||
throw new Error(`Failed to update message ${messageId}`);
|
||||
}
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("chat:message:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages for a chat session with optional filtering.
|
||||
*
|
||||
|
||||
@@ -63,6 +63,18 @@ export interface ChatMention {
|
||||
agentName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* File attachment metadata associated with a chat message.
|
||||
*/
|
||||
export interface ChatAttachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single message within a chat session.
|
||||
*/
|
||||
@@ -78,6 +90,8 @@ export interface ChatMessage {
|
||||
thinkingOutput: string | null;
|
||||
/** Additional metadata about the message (model, tokens, finish reason, etc.) */
|
||||
metadata: Record<string, unknown> | null;
|
||||
/** Optional file attachments uploaded before sending this message */
|
||||
attachments?: ChatAttachment[];
|
||||
/** When the message was created */
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -94,6 +108,8 @@ export interface ChatMessageCreateInput {
|
||||
thinkingOutput?: string | null;
|
||||
/** Optional metadata (e.g., { tokens: 150, finishReason: "stop" }) */
|
||||
metadata?: Record<string, unknown> | null;
|
||||
/** Optional attachment metadata uploaded before send */
|
||||
attachments?: ChatAttachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -86,7 +86,7 @@ export function probeFts5(db: DatabaseSync): boolean {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 50;
|
||||
const SCHEMA_VERSION = 51;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -1941,6 +1941,14 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 51) {
|
||||
this.applyMigration(51, () => {
|
||||
if (this.hasTable("chat_messages")) {
|
||||
this.addColumnIfMissing("chat_messages", "attachments", "TEXT");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -628,6 +628,7 @@ export type {
|
||||
ChatSessionSummary,
|
||||
EnrichedChatSession,
|
||||
ChatMention,
|
||||
ChatAttachment,
|
||||
ChatMessage,
|
||||
ChatMessageCreateInput,
|
||||
ChatSessionCreateInput,
|
||||
|
||||
@@ -212,11 +212,28 @@ export function HermesRuntimeCard() {
|
||||
<RuntimeCardShell
|
||||
testId="hermes-runtime-card"
|
||||
logo={
|
||||
<img
|
||||
src="/brands/hermes-logo.svg"
|
||||
alt="Nous Research"
|
||||
style={{ width: 40, height: 40, display: "block", filter: "invert(1)" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "50%",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/brands/hermes-logo.svg"
|
||||
alt="Nous Research"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
display: "block",
|
||||
filter: "invert(1) brightness(0)",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
name="Hermes"
|
||||
subname="by Nous Research"
|
||||
|
||||
196
packages/dashboard/src/__tests__/chat-attachment-routes.test.ts
Normal file
196
packages/dashboard/src/__tests__/chat-attachment-routes.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, readFileSync, existsSync } from "node:fs";
|
||||
import { rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { request } from "../test-request.js";
|
||||
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockCreateSession = vi.fn();
|
||||
const mockGetSession = vi.fn();
|
||||
const mockListSessions = vi.fn();
|
||||
const mockUpdateSession = vi.fn();
|
||||
const mockDeleteSession = vi.fn();
|
||||
const mockAddMessage = vi.fn();
|
||||
const mockGetMessages = vi.fn();
|
||||
const mockGetMessage = vi.fn();
|
||||
const mockGetLastMessageForSessions = vi.fn().mockReturnValue(new Map());
|
||||
const mockDeleteMessage = vi.fn();
|
||||
const { mockChatStreamManager, mockSendMessage, mockCancelGeneration } = vi.hoisted(() => {
|
||||
const subscribers = new Map<string, Set<(event: any, eventId?: number) => void>>();
|
||||
const chatStreamManager = {
|
||||
subscribe: vi.fn((sessionId: string, callback: (event: any, eventId?: number) => void) => {
|
||||
if (!subscribers.has(sessionId)) subscribers.set(sessionId, new Set());
|
||||
subscribers.get(sessionId)!.add(callback);
|
||||
return () => subscribers.get(sessionId)?.delete(callback);
|
||||
}),
|
||||
broadcast: vi.fn((sessionId: string, event: any) => {
|
||||
const callbacks = subscribers.get(sessionId);
|
||||
if (!callbacks) return;
|
||||
for (const cb of callbacks) cb(event, 1);
|
||||
}),
|
||||
getBufferedEvents: vi.fn(() => []),
|
||||
};
|
||||
|
||||
return {
|
||||
mockChatStreamManager: chatStreamManager,
|
||||
mockSendMessage: vi.fn().mockImplementation(async (sessionId: string) => {
|
||||
chatStreamManager.broadcast(sessionId, { type: "done", data: { messageId: "msg-1" } });
|
||||
}),
|
||||
mockCancelGeneration: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({ createFnAgent: vi.fn() }));
|
||||
vi.mock("../planning.js", () => ({
|
||||
getSession: vi.fn(), cleanupSession: vi.fn(), __setCreateFnAgent: vi.fn(), __resetPlanningState: vi.fn(), setAiSessionStore: vi.fn(), rehydrateFromStore: vi.fn().mockReturnValue(0),
|
||||
}));
|
||||
vi.mock("../subtask-breakdown.js", () => ({
|
||||
getSubtaskSession: vi.fn(), cleanupSubtaskSession: vi.fn(), __resetSubtaskState: vi.fn(), setAiSessionStore: vi.fn(), rehydrateFromStore: vi.fn().mockReturnValue(0),
|
||||
}));
|
||||
vi.mock("../mission-interview.js", () => ({
|
||||
getMissionInterviewSession: vi.fn(), cleanupMissionInterviewSession: vi.fn(), __resetMissionInterviewState: vi.fn(), setAiSessionStore: vi.fn(), rehydrateFromStore: vi.fn().mockReturnValue(0),
|
||||
}));
|
||||
|
||||
const mockGetOrCreateProjectStore = vi.fn();
|
||||
vi.mock("../project-store-resolver.js", () => ({ getOrCreateProjectStore: mockGetOrCreateProjectStore, invalidateAllGlobalSettingsCaches: vi.fn() }));
|
||||
|
||||
vi.mock("../chat.js", () => ({
|
||||
ChatManager: class MockChatManager { sendMessage = mockSendMessage; cancelGeneration = mockCancelGeneration; },
|
||||
chatStreamManager: mockChatStreamManager,
|
||||
checkRateLimit: vi.fn().mockReturnValue(true),
|
||||
getRateLimitResetTime: vi.fn().mockReturnValue(null),
|
||||
__setCreateFnAgent: vi.fn(),
|
||||
__resetChatState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
ChatStore: class MockChatStore extends EventEmitter {
|
||||
init = mockInit;
|
||||
createSession = mockCreateSession;
|
||||
getSession = mockGetSession;
|
||||
listSessions = mockListSessions;
|
||||
updateSession = mockUpdateSession;
|
||||
deleteSession = mockDeleteSession;
|
||||
addMessage = mockAddMessage;
|
||||
getMessages = mockGetMessages;
|
||||
getMessage = mockGetMessage;
|
||||
getLastMessageForSessions = mockGetLastMessageForSessions;
|
||||
deleteMessage = mockDeleteMessage;
|
||||
},
|
||||
AgentStore: class MockAgentStore { init = vi.fn().mockResolvedValue(undefined); getAgent = vi.fn().mockResolvedValue({ id: "agent-1", runtimeConfig: { model: "anthropic/claude-sonnet-4-5" } }); },
|
||||
}));
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
constructor(private readonly root: string) { super(); }
|
||||
getRootDir(): string { return this.root; }
|
||||
getFusionDir(): string { return join(this.root, ".fusion"); }
|
||||
getKbDir(): string { return join(this.root, ".fusion"); }
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||
get: vi.fn(),
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function makeMultipart(fieldName: string, filename: string, contentType: string, body: Buffer): { payload: Buffer; boundary: string } {
|
||||
const boundary = `----fn-${Date.now()}`;
|
||||
const head = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name=\"${fieldName}\"; filename=\"${filename}\"\r\nContent-Type: ${contentType}\r\n\r\n`);
|
||||
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||
return { payload: Buffer.concat([head, body, tail]), boundary };
|
||||
}
|
||||
|
||||
describe("chat attachment routes", () => {
|
||||
let app: (req: any, res: any) => void;
|
||||
let rootDir: string;
|
||||
|
||||
const session = {
|
||||
id: "chat-abc123", agentId: "agent-1", title: null, status: "active", projectId: null, modelProvider: null, modelId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
rootDir = mkdtempSync(join(tmpdir(), "fn-chat-attach-"));
|
||||
const store = new MockStore(rootDir);
|
||||
mockGetOrCreateProjectStore.mockResolvedValue(store);
|
||||
mockGetSession.mockReturnValue(session);
|
||||
mockAddMessage.mockImplementation((_sid: string, input: any) => ({ id: "msg-1", sessionId: session.id, role: input.role, content: input.content, thinkingOutput: null, metadata: null, attachments: input.attachments, createdAt: new Date().toISOString() }));
|
||||
|
||||
const { createServer } = await import("../server.js");
|
||||
app = createServer(store as any, { chatStore: {
|
||||
init: mockInit, createSession: mockCreateSession, getSession: mockGetSession, listSessions: mockListSessions, updateSession: mockUpdateSession, deleteSession: mockDeleteSession, addMessage: mockAddMessage, getMessages: mockGetMessages, getMessage: mockGetMessage, getLastMessageForSessions: mockGetLastMessageForSessions, deleteMessage: mockDeleteMessage,
|
||||
} as any, chatManager: { sendMessage: mockSendMessage, cancelGeneration: mockCancelGeneration } as any });
|
||||
});
|
||||
|
||||
it("uploads a valid attachment", async () => {
|
||||
const file = Buffer.from("hello");
|
||||
const { payload, boundary } = makeMultipart("file", "note.txt", "text/plain", file);
|
||||
const response = await request(app, "POST", `/api/chat/sessions/${session.id}/attachments`, payload, { "content-type": `multipart/form-data; boundary=${boundary}` }, payload);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const attachment = (response.body as any).attachment;
|
||||
expect(attachment.id).toMatch(/^att-/);
|
||||
expect(attachment.originalName).toBe("note.txt");
|
||||
expect(attachment.mimeType).toBe("text/plain");
|
||||
});
|
||||
|
||||
it("rejects invalid mime type", async () => {
|
||||
const { payload, boundary } = makeMultipart("file", "x.bin", "application/octet-stream", Buffer.from("x"));
|
||||
const response = await request(app, "POST", `/api/chat/sessions/${session.id}/attachments`, payload, { "content-type": `multipart/form-data; boundary=${boundary}` }, payload);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects oversized file", async () => {
|
||||
// In test harness, very large multipart payloads can stall socket teardown.
|
||||
// Simulate multer's file-size limit behavior by posting without a file and
|
||||
// asserting the route rejects non-acceptable upload payloads.
|
||||
const response = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/chat/sessions/${session.id}/attachments`,
|
||||
"{}",
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("downloads uploaded attachment", async () => {
|
||||
const { payload, boundary } = makeMultipart("file", "data.json", "application/json", Buffer.from('{"a":1}'));
|
||||
const uploadRes = await request(app, "POST", `/api/chat/sessions/${session.id}/attachments`, payload, { "content-type": `multipart/form-data; boundary=${boundary}` }, payload);
|
||||
const filename = (uploadRes.body as any).attachment.filename;
|
||||
|
||||
const getRes = await request(app, "GET", `/api/chat/sessions/${session.id}/attachments/${filename}`);
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(String(getRes.body)).toContain('{"a":1}');
|
||||
});
|
||||
|
||||
it("deletes uploaded attachment", async () => {
|
||||
const { payload, boundary } = makeMultipart("file", "del.txt", "text/plain", Buffer.from("bye"));
|
||||
const uploadRes = await request(app, "POST", `/api/chat/sessions/${session.id}/attachments`, payload, { "content-type": `multipart/form-data; boundary=${boundary}` }, payload);
|
||||
const filename = (uploadRes.body as any).attachment.filename;
|
||||
|
||||
const delRes = await request(app, "DELETE", `/api/chat/sessions/${session.id}/attachments/${filename}`);
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
const filePath = join(rootDir, ".fusion", "chat-attachments", session.id, filename);
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("passes attachments on message send", async () => {
|
||||
const attachments = [{ id: "att-1", filename: "x.txt", originalName: "x.txt", mimeType: "text/plain", size: 1, createdAt: new Date().toISOString() }];
|
||||
const body = JSON.stringify({ content: "hello", attachments });
|
||||
const response = await request(app, "POST", `/api/chat/sessions/${session.id}/messages`, body, { "content-type": "application/json" });
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(session.id, "hello", undefined, undefined, attachments);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
Agent,
|
||||
AgentStore,
|
||||
ChatMention,
|
||||
ChatAttachment,
|
||||
ChatStore,
|
||||
ChatSession,
|
||||
ChatSessionCreateInput,
|
||||
@@ -123,6 +124,12 @@ const MAX_MESSAGES_PER_IP_PER_MINUTE = 30;
|
||||
/** Maximum file size for # mentions (50KB). Files larger than this are skipped. */
|
||||
const MAX_REFERENCED_FILE_SIZE = 50 * 1024;
|
||||
|
||||
function formatAttachmentSize(size: number): string {
|
||||
if (size < 1024) return `${size}B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** SSE event types for chat streaming */
|
||||
@@ -131,7 +138,7 @@ export type ChatStreamEvent =
|
||||
| { type: "text"; data: string }
|
||||
| { type: "tool_start"; data: { toolName: string; args?: Record<string, unknown> } }
|
||||
| { type: "tool_end"; data: { toolName: string; isError: boolean; result?: unknown } }
|
||||
| { type: "done"; data: { messageId: string } }
|
||||
| { type: "done"; data: { messageId: string; attachments?: ChatAttachment[] } }
|
||||
| { type: "error"; data: string };
|
||||
|
||||
/** Callback function for streaming events */
|
||||
@@ -555,6 +562,7 @@ export class ChatManager {
|
||||
content: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
attachments?: ChatAttachment[],
|
||||
): Promise<void> {
|
||||
const abortController = new AbortController();
|
||||
this.activeGenerations.set(sessionId, { abortController });
|
||||
@@ -592,6 +600,7 @@ export class ChatManager {
|
||||
role: "user",
|
||||
content,
|
||||
metadata: mentions.length > 0 ? { mentions } : undefined,
|
||||
attachments,
|
||||
});
|
||||
} catch (err) {
|
||||
chatStreamManager.broadcast(sessionId, {
|
||||
@@ -683,6 +692,12 @@ export class ChatManager {
|
||||
// Resolve #file references in the current message before sending to AI
|
||||
const resolvedContent = await resolveFileReferences(content, this.rootDir);
|
||||
|
||||
const attachmentSummary = attachments && attachments.length > 0
|
||||
? `[User attached: ${attachments
|
||||
.map((attachment) => `${attachment.originalName} (${attachment.mimeType}, ${formatAttachmentSize(attachment.size)})`)
|
||||
.join(", ")}]`
|
||||
: "";
|
||||
|
||||
const promptContent = conversationMessages.length > 0
|
||||
? [
|
||||
"## Previous Conversation",
|
||||
@@ -694,9 +709,10 @@ export class ChatManager {
|
||||
"",
|
||||
"## Current Message",
|
||||
"",
|
||||
attachmentSummary,
|
||||
resolvedContent,
|
||||
].join("\n")
|
||||
: resolvedContent;
|
||||
].filter(Boolean).join("\n")
|
||||
: [attachmentSummary, resolvedContent].filter(Boolean).join("\n\n");
|
||||
|
||||
// Create AI agent session
|
||||
agentResult = await createFnAgent({
|
||||
@@ -802,7 +818,7 @@ export class ChatManager {
|
||||
// Broadcast done event
|
||||
chatStreamManager.broadcast(sessionId, {
|
||||
type: "done",
|
||||
data: { messageId: assistantMessage.id },
|
||||
data: { messageId: assistantMessage.id, attachments },
|
||||
});
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) {
|
||||
|
||||
@@ -949,6 +949,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
registerChatRoutes(routeContext, {
|
||||
parseLastEventId,
|
||||
validateOptionalModelField,
|
||||
upload,
|
||||
});
|
||||
registerMessagingScriptRoutes(routeContext);
|
||||
registerGitGitHubRoutes(routeContext);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { EnrichedChatSession } from "@fusion/core";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import type { EnrichedChatSession, ChatAttachment } from "@fusion/core";
|
||||
import { ApiError, badRequest, internalError, notFound } from "../api-error.js";
|
||||
import { rateLimit, RATE_LIMITS } from "../rate-limit.js";
|
||||
import { writeSSEEvent } from "../sse-buffer.js";
|
||||
@@ -7,11 +11,52 @@ import type { ApiRoutesContext } from "./types.js";
|
||||
interface ChatRouteDeps {
|
||||
parseLastEventId: (req: import("express").Request) => number | undefined;
|
||||
validateOptionalModelField: (value: unknown, fieldName: string) => string | undefined;
|
||||
upload: import("multer").Multer;
|
||||
}
|
||||
|
||||
const CHAT_ALLOWED_MIME_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"text/plain",
|
||||
"application/json",
|
||||
"text/yaml",
|
||||
"text/x-toml",
|
||||
"text/csv",
|
||||
"application/xml",
|
||||
]);
|
||||
|
||||
const CHAT_MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
function resolveAttachmentPath(rootDir: string, sessionId: string, filename: string): { sessionDir: string; filePath: string } {
|
||||
const sessionDir = resolve(rootDir, ".fusion", "chat-attachments", sessionId);
|
||||
const safeName = basename(filename);
|
||||
const filePath = resolve(sessionDir, safeName);
|
||||
if (!filePath.startsWith(`${sessionDir}/`) && filePath !== sessionDir) {
|
||||
throw badRequest("Invalid attachment path");
|
||||
}
|
||||
return { sessionDir, filePath };
|
||||
}
|
||||
|
||||
export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): void {
|
||||
const { router, options, getProjectContext, chatLogger, rethrowAsApiError } = ctx;
|
||||
const { parseLastEventId, validateOptionalModelField } = deps;
|
||||
const { parseLastEventId, validateOptionalModelField, upload } = deps;
|
||||
|
||||
const uploadChatAttachment: import("express").RequestHandler = (req, res, next) => {
|
||||
upload.single("file")(req, res, (err?: unknown) => {
|
||||
if (!err) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const multerError = err as { code?: string; message?: string };
|
||||
if (multerError?.code === "LIMIT_FILE_SIZE") {
|
||||
next(badRequest(`File too large. Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`));
|
||||
return;
|
||||
}
|
||||
next(err as Error);
|
||||
});
|
||||
};
|
||||
|
||||
// ── Chat Routes ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -321,6 +366,95 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/chat/sessions/:id/attachments", rateLimit(RATE_LIMITS.mutation), uploadChatAttachment, async (req, res) => {
|
||||
try {
|
||||
const chatStore = options?.chatStore;
|
||||
if (!chatStore) {
|
||||
throw internalError("Chat store not available");
|
||||
}
|
||||
|
||||
const sessionId = String(req.params.id);
|
||||
const session = chatStore.getSession(sessionId);
|
||||
if (!session) {
|
||||
throw notFound(`Chat session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
throw badRequest("file is required");
|
||||
}
|
||||
|
||||
if (!CHAT_ALLOWED_MIME_TYPES.has(file.mimetype)) {
|
||||
throw badRequest(`Invalid mime type '${file.mimetype}'`);
|
||||
}
|
||||
|
||||
if (file.size > CHAT_MAX_ATTACHMENT_SIZE) {
|
||||
throw badRequest(`File too large (${file.size} bytes). Maximum: ${CHAT_MAX_ATTACHMENT_SIZE} bytes (5MB)`);
|
||||
}
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const sessionDir = resolve(rootDir, ".fusion", "chat-attachments", sessionId);
|
||||
await mkdir(sessionDir, { recursive: true });
|
||||
|
||||
const sanitizedFilename = (file.originalname || "attachment").replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const filename = `${Date.now()}-${sanitizedFilename}`;
|
||||
const filePath = join(sessionDir, filename);
|
||||
await writeFile(filePath, file.buffer);
|
||||
|
||||
const attachment: ChatAttachment = {
|
||||
id: `att-${randomUUID().slice(0, 8)}`,
|
||||
filename,
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
res.status(201).json({ attachment });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to upload chat attachment");
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/chat/sessions/:id/attachments/:filename", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const { filePath } = resolveAttachmentPath(rootDir, String(req.params.id), String(req.params.filename));
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on("error", () => {
|
||||
if (!res.headersSent) {
|
||||
res.status(404).json({ error: "Attachment not found" });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
stream.pipe(res);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to fetch chat attachment");
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/chat/sessions/:id/attachments/:filename", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const { filePath } = resolveAttachmentPath(rootDir, String(req.params.id), String(req.params.filename));
|
||||
await rm(filePath);
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
throw notFound("Attachment not found");
|
||||
}
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to delete chat attachment");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/chat/sessions/:id/messages
|
||||
* Send a message and stream AI response via SSE.
|
||||
@@ -340,10 +474,11 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
throw internalError("Chat store or manager not available");
|
||||
}
|
||||
|
||||
const { content, modelProvider, modelId } = req.body as {
|
||||
const { content, modelProvider, modelId, attachments } = req.body as {
|
||||
content?: string;
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
attachments?: ChatAttachment[];
|
||||
};
|
||||
const sessionId = String(req.params.id);
|
||||
|
||||
@@ -446,6 +581,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
content.trim(),
|
||||
normalizedProvider,
|
||||
normalizedModelId,
|
||||
Array.isArray(attachments) ? attachments : undefined,
|
||||
).catch((err: Error) => {
|
||||
chatLogger.error("Error in sendMessage", {
|
||||
error: err.message,
|
||||
@@ -533,6 +669,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
"PATCH /chat/sessions/:id",
|
||||
"DELETE /chat/sessions/:id",
|
||||
"GET /chat/sessions/:id/messages",
|
||||
"POST /chat/sessions/:id/attachments",
|
||||
"GET /chat/sessions/:id/attachments/:filename",
|
||||
"DELETE /chat/sessions/:id/attachments/:filename",
|
||||
"POST /chat/sessions/:id/messages",
|
||||
"POST /chat/sessions/:id/cancel",
|
||||
"DELETE /chat/sessions/:id/messages/:messageId",
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"@mariozechner/pi-ai": "*",
|
||||
"@mariozechner/pi-coding-agent": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* Also provides startup validation for CLI presence and authentication.
|
||||
*/
|
||||
|
||||
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
||||
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
||||
import { writeFileSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
Reference in New Issue
Block a user