Compare commits
1 Commits
claude/ama
...
claude/ele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2797eaf6ca |
@ -1,11 +1,42 @@
|
|||||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import type * as db from "@rakazo/db";
|
||||||
|
import { SpaceLimitError } from "@rakazo/db";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
|
||||||
|
|
||||||
const owner = { userId: "user-owner", isDeploymentOwner: true };
|
const createSpaceForMember = vi.hoisted(() => vi.fn());
|
||||||
const member = { userId: "user-member", isDeploymentOwner: false };
|
vi.mock("@rakazo/db", async (original) => ({
|
||||||
|
...(await original<typeof db>()),
|
||||||
|
createSpaceForMember,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
|
||||||
|
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
|
||||||
|
|
||||||
|
type SpaceUpdate = { where: { id: string }; data: { workspacePath: string } };
|
||||||
|
|
||||||
|
/** The spaces the scan reads and links; new ones come from `createSpaceForMember`. */
|
||||||
|
function fakeDatabase({ spaceLimit = Number.POSITIVE_INFINITY } = {}) {
|
||||||
|
const spaces: Array<{ id: string; name: string; workspacePath: string | null }> = [];
|
||||||
|
createSpaceForMember.mockImplementation(async (_prisma: unknown, input: { name: string }) => {
|
||||||
|
if (spaces.length >= spaceLimit) throw new SpaceLimitError();
|
||||||
|
const space = { id: `space-${spaces.length + 1}`, name: input.name, workspacePath: null };
|
||||||
|
spaces.push(space);
|
||||||
|
return { id: space.id, name: space.name };
|
||||||
|
});
|
||||||
|
const prisma = {
|
||||||
|
space: {
|
||||||
|
findMany: async () => spaces.filter((space) => space.workspacePath),
|
||||||
|
update: async ({ where, data }: SpaceUpdate) => {
|
||||||
|
const space = spaces.find((candidate) => candidate.id === where.id);
|
||||||
|
if (space) space.workspacePath = data.workspacePath;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as db.PrismaClient;
|
||||||
|
return { prisma, spaces };
|
||||||
|
}
|
||||||
|
|
||||||
async function scanHomeWithOneFile(): Promise<string> {
|
async function scanHomeWithOneFile(): Promise<string> {
|
||||||
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
|
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
|
||||||
@ -14,6 +45,12 @@ async function scanHomeWithOneFile(): Promise<string> {
|
|||||||
return home;
|
return home;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function scanHomeWithRepos(...repos: string[]): Promise<string> {
|
||||||
|
const home = await scanHomeWithOneFile();
|
||||||
|
for (const repo of repos) await mkdir(join(home, repo, ".git"), { recursive: true });
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
|
||||||
async function completed(scanId: string, userId: string) {
|
async function completed(scanId: string, userId: string) {
|
||||||
return await vi.waitFor(
|
return await vi.waitFor(
|
||||||
async () => {
|
async () => {
|
||||||
@ -25,13 +62,21 @@ async function completed(scanId: string, userId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The emulated runtime that the shared test setup pins keeps the scan simulated. */
|
||||||
|
function allowHostScan() {
|
||||||
|
vi.stubEnv("AGENT_RUNTIME", "");
|
||||||
|
vi.stubEnv("SANDBOX_PROVIDER", "");
|
||||||
|
}
|
||||||
|
|
||||||
describe("onboarding scan", () => {
|
describe("onboarding scan", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
|
createSpaceForMember.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides a scan from every account except the one that started it", async () => {
|
it("hides a scan from every account except the one that started it", async () => {
|
||||||
const { scanId } = await startOnboardingScan({ actor: member }, {});
|
const { prisma } = fakeDatabase();
|
||||||
|
const { scanId } = await startOnboardingScan({ actor: member }, prisma);
|
||||||
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
|
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
|
||||||
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
|
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
|
||||||
status: expect.any(String),
|
status: expect.any(String),
|
||||||
@ -40,25 +85,77 @@ describe("onboarding scan", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("never reads the host without a configured scan home", async () => {
|
it("never reads the host without a configured scan home", async () => {
|
||||||
const { scanId } = await startOnboardingScan({ actor: owner }, {});
|
const { prisma } = fakeDatabase();
|
||||||
|
const { scanId } = await startOnboardingScan({ actor: owner }, prisma);
|
||||||
const status = await completed(scanId, owner.userId);
|
const status = await completed(scanId, owner.userId);
|
||||||
expect(status.status).toBe("complete");
|
expect(status.status).toBe("complete");
|
||||||
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
|
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets only the deployment owner discover the configured scan home", async () => {
|
it("lets only the deployment owner discover the configured scan home", async () => {
|
||||||
// The shared test setup pins the emulated runtime, which also keeps the scan simulated.
|
allowHostScan();
|
||||||
vi.stubEnv("AGENT_RUNTIME", "");
|
const scanHome = await scanHomeWithRepos("Projects/app");
|
||||||
vi.stubEnv("SANDBOX_PROVIDER", "");
|
const { prisma } = fakeDatabase();
|
||||||
const scanHome = await scanHomeWithOneFile();
|
|
||||||
|
|
||||||
const asMember = await startOnboardingScan({ actor: member, scanHome }, {});
|
const asMember = await startOnboardingScan({ actor: member, scanHome }, prisma);
|
||||||
const memberStatus = await completed(asMember.scanId, member.userId);
|
const memberStatus = await completed(asMember.scanId, member.userId);
|
||||||
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
|
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
|
||||||
|
expect(createSpaceForMember).not.toHaveBeenCalled();
|
||||||
|
|
||||||
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {});
|
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||||
const ownerStatus = await completed(asOwner.scanId, owner.userId);
|
const ownerStatus = await completed(asOwner.scanId, owner.userId);
|
||||||
expect(ownerStatus.status).toBe("complete");
|
expect(ownerStatus.status).toBe("complete");
|
||||||
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
|
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("links each repository of the deployment owner to one project of its own", async () => {
|
||||||
|
allowHostScan();
|
||||||
|
const scanHome = await scanHomeWithRepos("Projects/app", ".config/nvim");
|
||||||
|
const app = await realpath(join(scanHome, "Projects", "app"));
|
||||||
|
const { prisma, spaces } = fakeDatabase();
|
||||||
|
|
||||||
|
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||||
|
const firstStatus = await completed(first.scanId, owner.userId);
|
||||||
|
expect(firstStatus.status).toBe("complete");
|
||||||
|
expect(firstStatus.result).toMatchObject({
|
||||||
|
localRepos: [{ name: "app" }],
|
||||||
|
createdProjects: [{ spaceId: "space-1", name: "app", path: app }],
|
||||||
|
});
|
||||||
|
expect(createSpaceForMember).toHaveBeenCalledWith(prisma, {
|
||||||
|
currentSpaceId: owner.spaceId,
|
||||||
|
userId: owner.userId,
|
||||||
|
name: "app",
|
||||||
|
});
|
||||||
|
expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]);
|
||||||
|
|
||||||
|
// Scanning again finds the linked project instead of creating a second one.
|
||||||
|
const again = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||||
|
const againStatus = await completed(again.scanId, owner.userId);
|
||||||
|
expect(againStatus.result).toMatchObject({ createdProjects: [] });
|
||||||
|
expect(createSpaceForMember).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins a running scan instead of linking its repositories twice", async () => {
|
||||||
|
allowHostScan();
|
||||||
|
const scanHome = await scanHomeWithRepos("Projects/app");
|
||||||
|
const { prisma, spaces } = fakeDatabase();
|
||||||
|
|
||||||
|
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||||
|
const second = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||||
|
expect(second.scanId).toBe(first.scanId);
|
||||||
|
await completed(first.scanId, owner.userId);
|
||||||
|
expect(spaces).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops creating projects at the space limit and still completes", async () => {
|
||||||
|
allowHostScan();
|
||||||
|
const scanHome = await scanHomeWithRepos("Projects/a", "Projects/b", "Projects/c");
|
||||||
|
const { prisma } = fakeDatabase({ spaceLimit: 1 });
|
||||||
|
|
||||||
|
const { scanId } = await startOnboardingScan({ actor: owner, scanHome }, prisma);
|
||||||
|
const status = await completed(scanId, owner.userId);
|
||||||
|
expect(status.status).toBe("complete");
|
||||||
|
expect(status.result).toMatchObject({ createdProjects: [{ spaceId: "space-1" }] });
|
||||||
|
expect(createSpaceForMember).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,9 +1,16 @@
|
|||||||
// Onboarding scan — real filesystem scan, Git analysis, and profile compilation.
|
// Onboarding scan — real filesystem scan, Git analysis, and profile compilation.
|
||||||
// Runs synchronously on the host machine (no sandbox needed).
|
// Runs synchronously on the host machine (no sandbox needed).
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { access, readdir, stat } from "node:fs/promises";
|
import { access, readdir, realpath, stat } from "node:fs/promises";
|
||||||
import { dirname, join } from "node:path";
|
import { basename, dirname, join } from "node:path";
|
||||||
import type { Actor } from "@rakazo/contracts";
|
import type { Actor } from "@rakazo/contracts";
|
||||||
|
import type { PrismaClient } from "@rakazo/db";
|
||||||
|
import {
|
||||||
|
createSpaceForMember,
|
||||||
|
InvalidSpaceNameError,
|
||||||
|
SpaceLimitError,
|
||||||
|
wireProjectWorkspace,
|
||||||
|
} from "@rakazo/db";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@ -54,10 +61,9 @@ interface ProfileSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CreatedProject {
|
interface CreatedProject {
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
description: string;
|
name: string;
|
||||||
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScanRecord {
|
interface ScanRecord {
|
||||||
@ -115,8 +121,10 @@ async function runSimulatedScan(record: ScanRecord) {
|
|||||||
record.completedAt = new Date();
|
record.completedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScanActor = Pick<Actor, "userId" | "spaceId" | "isDeploymentOwner">;
|
||||||
|
|
||||||
export interface OnboardingScanRequest {
|
export interface OnboardingScanRequest {
|
||||||
actor: Pick<Actor, "userId" | "isDeploymentOwner">;
|
actor: ScanActor;
|
||||||
botId?: string;
|
botId?: string;
|
||||||
/** Home-style directory to discover. Unset keeps the scan off the host filesystem. */
|
/** Home-style directory to discover. Unset keeps the scan off the host filesystem. */
|
||||||
scanHome?: string;
|
scanHome?: string;
|
||||||
@ -124,8 +132,12 @@ export interface OnboardingScanRequest {
|
|||||||
|
|
||||||
export async function startOnboardingScan(
|
export async function startOnboardingScan(
|
||||||
{ actor, botId, scanHome }: OnboardingScanRequest,
|
{ actor, botId, scanHome }: OnboardingScanRequest,
|
||||||
prisma: any,
|
prisma: PrismaClient,
|
||||||
): Promise<{ scanId: string }> {
|
): Promise<{ scanId: string }> {
|
||||||
|
// A repeated start (a remount, a retry) joins the running scan, so no repository is linked twice.
|
||||||
|
for (const running of scanRegistry.values()) {
|
||||||
|
if (running.ownerUserId === actor.userId && !running.completedAt) return { scanId: running.id };
|
||||||
|
}
|
||||||
const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const record: ScanRecord = {
|
const record: ScanRecord = {
|
||||||
id: scanId,
|
id: scanId,
|
||||||
@ -154,7 +166,7 @@ export async function startOnboardingScan(
|
|||||||
await runSimulatedScan(record);
|
await runSimulatedScan(record);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await executeFullScan(scanHome, botId ?? "", prisma);
|
const result = await executeFullScan(scanHome, actor, prisma);
|
||||||
record.status = "complete";
|
record.status = "complete";
|
||||||
record.progress = 100;
|
record.progress = 100;
|
||||||
record.message = "Scan complete — profile ready";
|
record.message = "Scan complete — profile ready";
|
||||||
@ -189,7 +201,11 @@ export async function getScanStatus(
|
|||||||
// Full scan pipeline
|
// Full scan pipeline
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function executeFullScan(homeDir: string, _botId: string, prisma: any): Promise<ScanResult> {
|
async function executeFullScan(
|
||||||
|
homeDir: string,
|
||||||
|
actor: ScanActor,
|
||||||
|
prisma: PrismaClient,
|
||||||
|
): Promise<ScanResult> {
|
||||||
// Phase 1: Local file scan
|
// Phase 1: Local file scan
|
||||||
const [fileSummary, projectFolders] = await scanFiles(homeDir);
|
const [fileSummary, projectFolders] = await scanFiles(homeDir);
|
||||||
|
|
||||||
@ -200,8 +216,8 @@ async function executeFullScan(homeDir: string, _botId: string, prisma: any): Pr
|
|||||||
// Phase 4: Profile compilation from findings
|
// Phase 4: Profile compilation from findings
|
||||||
const profileSummary = compileProfile(localRepos, fileSummary, projectFolders);
|
const profileSummary = compileProfile(localRepos, fileSummary, projectFolders);
|
||||||
|
|
||||||
// Phase 6: Project folder creation
|
// Phase 6: Projects for discovered repositories
|
||||||
const createdProjects = await createProjectFolders(localRepos, prisma);
|
const createdProjects = await createProjects(localRepos, actor, prisma);
|
||||||
|
|
||||||
// Phase 7: Sub-agent role creation
|
// Phase 7: Sub-agent role creation
|
||||||
const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos);
|
const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos);
|
||||||
@ -413,9 +429,9 @@ async function findGitDirectories(dir: string, depth: number = 0): Promise<strin
|
|||||||
if (entry.name === ".git") {
|
if (entry.name === ".git") {
|
||||||
results.push(dirname(fullPath));
|
results.push(dirname(fullPath));
|
||||||
} else if (
|
} else if (
|
||||||
|
// Dot folders hold tools and caches (shell and editor plugins), not the person's projects.
|
||||||
|
!entry.name.startsWith(".") &&
|
||||||
entry.name !== "node_modules" &&
|
entry.name !== "node_modules" &&
|
||||||
entry.name !== ".git" &&
|
|
||||||
entry.name !== ".venv" &&
|
|
||||||
entry.name !== "dist"
|
entry.name !== "dist"
|
||||||
) {
|
) {
|
||||||
results.push(...(await findGitDirectories(fullPath, depth + 1)));
|
results.push(...(await findGitDirectories(fullPath, depth + 1)));
|
||||||
@ -622,38 +638,43 @@ function generateBriefing(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Phase 5: Project folder creation
|
// Phase 5: Projects for discovered repositories
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function createProjectFolders(repos: RepoInfo[], prisma: any): Promise<CreatedProject[]> {
|
/** Give each discovered repository a project linked to its folder, unless one already links it. */
|
||||||
|
async function createProjects(
|
||||||
|
repos: RepoInfo[],
|
||||||
|
actor: ScanActor,
|
||||||
|
prisma: PrismaClient,
|
||||||
|
): Promise<CreatedProject[]> {
|
||||||
|
const linked = await prisma.space.findMany({
|
||||||
|
where: { workspacePath: { not: null }, memberships: { some: { userId: actor.userId } } },
|
||||||
|
select: { workspacePath: true },
|
||||||
|
});
|
||||||
|
const linkedFolders = new Set(linked.map((space) => space.workspacePath));
|
||||||
const created: CreatedProject[] = [];
|
const created: CreatedProject[] = [];
|
||||||
|
|
||||||
for (const repo of repos) {
|
for (const repo of repos) {
|
||||||
|
// Compare and link the real folder, so the next scan recognizes the projects of this one.
|
||||||
|
const folder = await realpath(repo.path).catch(() => null);
|
||||||
|
if (!folder || linkedFolders.has(folder)) continue;
|
||||||
|
let space: { id: string; name: string };
|
||||||
try {
|
try {
|
||||||
// Check if space already exists
|
space = await createSpaceForMember(prisma, {
|
||||||
const existing = await prisma.space.findFirst({ where: { title: repo.name } });
|
currentSpaceId: actor.spaceId,
|
||||||
if (existing) {
|
userId: actor.userId,
|
||||||
created.push({
|
name: basename(folder),
|
||||||
id: existing.id,
|
|
||||||
name: repo.name,
|
|
||||||
spaceId: existing.id,
|
|
||||||
description: repo.remote || repo.path,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Create new space
|
|
||||||
const space = await prisma.space.create({
|
|
||||||
data: { title: repo.name, path: repo.path },
|
|
||||||
});
|
});
|
||||||
created.push({
|
} catch (error) {
|
||||||
id: space.id,
|
if (error instanceof SpaceLimitError) break;
|
||||||
name: repo.name,
|
if (error instanceof InvalidSpaceNameError) continue;
|
||||||
spaceId: space.id,
|
throw error;
|
||||||
description: repo.remote || repo.path,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Space might already exist or DB unavailable — skip
|
|
||||||
}
|
}
|
||||||
|
const workspace = await wireProjectWorkspace(prisma, {
|
||||||
|
spaceId: space.id,
|
||||||
|
workspacePath: folder,
|
||||||
|
});
|
||||||
|
created.push({ spaceId: space.id, name: space.name, path: workspace.path });
|
||||||
}
|
}
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
|
|||||||
@ -1,19 +1,10 @@
|
|||||||
|
// Server-only. Kept out of the package index so browser bundles never load `node:child_process`.
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
import { access } from "node:fs/promises";
|
import { access } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js";
|
import { gitStatusArgv, gitUntrackedFilesArgv, parseGitNameOnly } from "./compose-update.js";
|
||||||
import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-update.js";
|
import { DEFAULT_UPDATE_REMOTE, parseGitStatusPorcelain } from "./self-update.js";
|
||||||
|
|
||||||
// Lazy import to avoid bundling `node:child_process` in browser bundles.
|
|
||||||
// `defaultGitRunner` is server-only; lazy-loading ensures tree-shaking works.
|
|
||||||
type SpawnFn = typeof import("node:child_process").spawn;
|
|
||||||
let _spawn: SpawnFn | undefined;
|
|
||||||
const getSpawn = (): SpawnFn => {
|
|
||||||
if (_spawn === undefined) {
|
|
||||||
_spawn = require("node:child_process").spawn as SpawnFn;
|
|
||||||
}
|
|
||||||
return _spawn;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface GitCheckoutStatus {
|
export interface GitCheckoutStatus {
|
||||||
present: boolean;
|
present: boolean;
|
||||||
remoteUrl: string | null;
|
remoteUrl: string | null;
|
||||||
@ -32,7 +23,6 @@ export type GitRunner = (args: string[], cwd: string) => Promise<GitRunnerResult
|
|||||||
|
|
||||||
export async function defaultGitRunner(args: string[], cwd: string): Promise<GitRunnerResult> {
|
export async function defaultGitRunner(args: string[], cwd: string): Promise<GitRunnerResult> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const spawn = getSpawn();
|
|
||||||
const child = spawn("git", args, { cwd, env: process.env });
|
const child = spawn("git", args, { cwd, env: process.env });
|
||||||
let output = "";
|
let output = "";
|
||||||
child.stdout.on("data", (chunk: Buffer | string) => {
|
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user