Active player list

This commit is contained in:
Emmaline Autumn 2023-10-04 04:09:01 -06:00
parent 4a4563ba85
commit b63db82a99
27 changed files with 544 additions and 136 deletions

3
.gitignore vendored
View File

@ -16,4 +16,5 @@ vanilla/
server/
# Config file for MCGRIZZ
mcgrizz.json
mcgrizz.json
players.cache.json

View File

@ -18,8 +18,13 @@ const colors = {
text: "text-white",
},
fire: {
bg: "bg-grape",
border: "border-grape-800",
bg: "bg-fire",
border: "border-fire-800",
text: "text-white",
},
wasabi: {
bg: "bg-wasabi-600",
border: "border-wasabi-800",
text: "text-white",
},
};
@ -38,6 +43,7 @@ export function Button(
${colors[color].text}
hover:bg-smoke-500
transition-colors
disabled:opacity-30
${props.class || ""}`;
return (
<button

View File

@ -2,7 +2,7 @@ import { VNode } from "preact";
export function Content(props: { children?: VNode | VNode[] }) {
return (
<div class="mt-16 bg-smoke-200 dark:bg-smoke-900 border-4 border-licorice-800 p-8 rounded-3xl">
<div class="bg-smoke-200 dark:bg-smoke-900 border-4 border-licorice-800 p-8 rounded-3xl">
{props.children}
</div>
);

28
components/properties.tsx Normal file
View File

@ -0,0 +1,28 @@
import { Button } from "./Button.tsx";
import { Content } from "./Content.tsx";
export function MCProperties(
{ properties }: { properties: Map<string, string> },
) {
return (
<div className="container p-8">
<Content>
<form method="POST" action="/properties">
<div class="grid grid-cols-3 gap-y-4 gap-x-16">
{Array.from(properties.entries()).sort(([a],[b]) => a > b ? 1 : -1).map(([k, v]) => (
<div>
<label class="block font-pixel text-lg" htmlFor={k}>{k}</label>
<input class="w-full" type="text" name={k} value={v} autocomplete="off" />
</div>
))}
</div>
<div class="mt-8">
<Button type="submit">
Save
</Button>
</div>
</form>
</Content>
</div>
);
}

View File

@ -5,30 +5,40 @@
import * as $0 from "./routes/_404.tsx";
import * as $1 from "./routes/_app.tsx";
import * as $2 from "./routes/api/fabric/index.ts";
import * as $3 from "./routes/api/terminal.ts";
import * as $4 from "./routes/index.tsx";
import * as $5 from "./routes/setup/eula.tsx";
import * as $6 from "./routes/setup/fabric.tsx";
import * as $7 from "./routes/setup/index.tsx";
import * as $8 from "./routes/setup/start.tsx";
import * as $3 from "./routes/api/manage.ts";
import * as $4 from "./routes/api/players.ts";
import * as $5 from "./routes/index.tsx";
import * as $6 from "./routes/players.tsx";
import * as $7 from "./routes/properties.tsx";
import * as $8 from "./routes/setup/eula.tsx";
import * as $9 from "./routes/setup/fabric.tsx";
import * as $10 from "./routes/setup/index.tsx";
import * as $11 from "./routes/terminal.tsx";
import * as $$0 from "./islands/fabricVersions.tsx";
import * as $$1 from "./islands/terminal.tsx";
import * as $$1 from "./islands/players.tsx";
import * as $$2 from "./islands/statusManager.tsx";
import * as $$3 from "./islands/terminal.tsx";
const manifest = {
routes: {
"./routes/_404.tsx": $0,
"./routes/_app.tsx": $1,
"./routes/api/fabric/index.ts": $2,
"./routes/api/terminal.ts": $3,
"./routes/index.tsx": $4,
"./routes/setup/eula.tsx": $5,
"./routes/setup/fabric.tsx": $6,
"./routes/setup/index.tsx": $7,
"./routes/setup/start.tsx": $8,
"./routes/api/manage.ts": $3,
"./routes/api/players.ts": $4,
"./routes/index.tsx": $5,
"./routes/players.tsx": $6,
"./routes/properties.tsx": $7,
"./routes/setup/eula.tsx": $8,
"./routes/setup/fabric.tsx": $9,
"./routes/setup/index.tsx": $10,
"./routes/terminal.tsx": $11,
},
islands: {
"./islands/fabricVersions.tsx": $$0,
"./islands/terminal.tsx": $$1,
"./islands/players.tsx": $$1,
"./islands/statusManager.tsx": $$2,
"./islands/terminal.tsx": $$3,
},
baseUrl: import.meta.url,
};

55
islands/players.tsx Normal file
View File

@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { StatusManager } from "./statusManager.tsx";
import { IS_BROWSER } from "$fresh/runtime.ts";
import { PlayerData } from "../util/players.ts";
import { Button } from "../components/Button.tsx";
export function ActivePlayerList() {
const [players, setPlayers] = useState<PlayerData[]>([]);
useEffect(() => {
if (!IS_BROWSER) return;
configureEventSource();
}, []);
const eventSource = useRef<EventSource>();
const configureEventSource = () => {
if (eventSource.current?.OPEN) eventSource.current.close();
eventSource.current = new EventSource("/api/players");
eventSource.current.addEventListener("players", (e) => {
setPlayers(JSON.parse(e.data));
});
};
const followStatusManager = (action: string) => {
if (action === "started" || action === "restarted") {
configureEventSource();
}
};
return (
<div>
<StatusManager onAction={followStatusManager} />
<div class="grid grid-cols-2 p-8">
<h2 class="col-span-2 font-pixel text-xl">Active Players</h2>
{players.map((p) => <PlayerCard player={p} />)}
</div>
</div>
);
}
function PlayerCard({ player }: { player: PlayerData }) {
return (
<div class="flex gap-4">
<img class="w-16" src={player.avatar} alt={`${player.username}'s avatar`} />
<div>
<h3>{player.username}</h3>
<small class="opacity-50">UUID: {player.id}</small>
<div>
Did you know that bats are the only mammal that can fly?
</div>
</div>
</div>
);
}

51
islands/statusManager.tsx Normal file
View File

@ -0,0 +1,51 @@
import { JSX } from "preact";
import { Button } from "../components/Button.tsx";
import { ManageAction } from "../routes/api/manage.ts";
import { useEffect, useState } from "preact/hooks";
import { IS_BROWSER } from "$fresh/runtime.ts";
export function StatusManager(
props: JSX.HTMLAttributes<HTMLDivElement> & {
onAction?: (res: string) => void;
},
) {
const sendCommand = async (action: ManageAction) => {
console.log(action);
const res = await fetch("/api/manage", {
method: "POST",
body: action,
});
const body = await res.text();
props.onAction && props.onAction(body);
};
const [status, setStatus] = useState('');
const getStatus = async () => {
const res = await fetch('/api/manage');
const body = await res.text();
setStatus(body);
}
useEffect(() => {
if (IS_BROWSER) getStatus();
},[])
return (
<div {...props}>
{!!status && <small>Server is {status}</small>}
<div class="flex gap-4">
<Button color="wasabi" disabled={!status || status === 'running'} onClick={() => sendCommand(ManageAction.start)}>
Start
</Button>
<Button color="fire" disabled={!status || status === 'stopped'} onClick={() => sendCommand(ManageAction.stop)}>
Stop
</Button>
<Button color="sky" disabled={!status} onClick={() => sendCommand(ManageAction.restart)}>
Restart
</Button>
</div>
</div>
);
}

View File

@ -1,19 +1,40 @@
import { IS_BROWSER } from "$fresh/runtime.ts";
import { useEffect, useRef, useState } from "preact/hooks";
import { JSX } from "preact/jsx-runtime";
import { Sockpuppet } from "puppet/client";
export function Terminal(props: { channelId: string }) {
const puppet = useRef(new Sockpuppet("ws://sockpuppet.cyborggrizzly.com"));
const [lines, setLines] = useState<string[]>([]);
const divRef = useRef<HTMLDivElement>(null);
const storeKey = 'commandHistory';
const [commandHistory, setCommandHistory] = useState<string[]>(JSON.parse(localStorage.getItem(storeKey) || '[]'));
const [historyIndex, setHistoryIndex] = useState(commandHistory.length);
const [command, setCommand] = useState("");
const changeHistoryIndex = (by: number) => setHistoryIndex(i => i + by);
useEffect(() => {
if (!IS_BROWSER) return;
puppet.current.joinChannel(props.channelId, (line) => {
setLines((l) => [...l, line]);
});
document.addEventListener('keyup', (e) => {
console.log(e.key)
switch (e.key) {
case 'Up':
case 'ArrowUp':
changeHistoryIndex(-1);
break;
case 'Down':
case 'ArrowDown':
changeHistoryIndex(1);
break;
}
})
}, []);
useEffect(() => {
@ -24,22 +45,38 @@ export function Terminal(props: { channelId: string }) {
const sendCommand = (e: Event) => {
e.preventDefault();
puppet.current.getChannel(props.channelId)?.send(command);
puppet.current.getChannel(props.channelId)?.send(historyIndex === commandHistory.length ? command : commandHistory[historyIndex]);
setCommandHistory(c => [...c, command]);
setHistoryIndex(commandHistory.length + 1)
setCommand("");
};
const handleCommandUpdate = (e: JSX.TargetedEvent<HTMLInputElement, Event>) => {
const value = e.currentTarget.value;
if (historyIndex !== commandHistory.length) {
setHistoryIndex(commandHistory.length);
}
setCommand(value || '')
}
useEffect(() => {
localStorage.setItem(storeKey, JSON.stringify(commandHistory.slice(0,100)));
}, [commandHistory])
return (
<div>
<div ref={divRef} class="flex flex-col h-[600px] w-full overflow-auto">
{lines.map((l) => <div class="font-mono w-full">{l}</div>)}
{lines.map((l) => <pre class="font-mono w-full">{l}</pre>)}
</div>
<div>
<div class="mt-4">
<form onSubmit={sendCommand}>
<input
type="text"
class="w-full bg-smoke-600 text-white"
value={command}
onInput={(e) => setCommand((e.target as HTMLInputElement).value)}
value={commandHistory[historyIndex] || command}
onInput={handleCommandUpdate}
/>
</form>
</div>

31
routes/api/manage.ts Normal file
View File

@ -0,0 +1,31 @@
import { Handlers } from "$fresh/server.ts";
import { SERVER_STATE } from "../../state/serverState.ts";
export enum ManageAction {
start = "start",
stop = "stop",
restart = "restart",
}
export const handler: Handlers = {
async POST(req) {
const body: ManageAction & string = await req.text() as ManageAction;
switch (body) {
case ManageAction.start:
SERVER_STATE.startMCServer();
return new Response("started");
case ManageAction.stop:
SERVER_STATE.gracefullyStopMCServer();
return new Response("stopped");
case ManageAction.restart:
SERVER_STATE.restartMCServer();
return new Response("restarted");
default:
SERVER_STATE.sendStdIn(body);
return new Response("action done");
}
},
GET() {
return new Response(SERVER_STATE.status);
}
};

44
routes/api/players.ts Normal file
View File

@ -0,0 +1,44 @@
import { Handlers } from "$fresh/server.ts";
import { SERVER_STATE } from "../../state/serverState.ts";
import { getActivePlayers } from "../../util/players.ts";
export const handler: Handlers = {
GET(_req, _ctx) {
let listener: (e: CustomEvent) => void;
const body = new ReadableStream({
async start(controller){
console.log('did the thing')
if (SERVER_STATE.status !== 'running') return;
const players = await getActivePlayers();
const event = `event:players\ndata:${JSON.stringify(players)}\n\n`
controller.enqueue(event);
console.log('sent the thing')
listener = async (e: CustomEvent<string>) => {
console.log('message received')
if (e.detail.includes('joined the game') || e.detail.includes('lost connection')) {
console.log('connection change')
const players = await getActivePlayers();
const event = `event: players\ndata: ${JSON.stringify(players)}\n\n`
controller.enqueue(event);
}
}
globalThis.addEventListener('stdoutmsg' as any, listener);
console.log('listened the thing')
},
cancel() {
console.log('cancelled')
globalThis.removeEventListener('stdoutmsg' as any, listener);
}
})
return new Response(body.pipeThrough(new TextEncoderStream()), { headers: {
"Content-Type": "text/event-stream",
"cache-control": "no-cache"
}});
}
}

View File

@ -1 +0,0 @@
// const

12
routes/players.tsx Normal file
View File

@ -0,0 +1,12 @@
import { Content } from "../components/Content.tsx";
import { ActivePlayerList } from "../islands/players.tsx";
export default function PlayerManger() {
return (
<div className="container p-8">
<Content>
<ActivePlayerList />
</Content>
</div>
)
}

32
routes/properties.tsx Normal file
View File

@ -0,0 +1,32 @@
import { Handlers } from "$fresh/server.ts";
import { MCProperties } from "../components/properties.tsx";
import {
deserializeMCProperties,
serializeMCProperties,
} from "../util/mcProperties.ts";
export const handler: Handlers = {
async POST(req, ctx) {
const filePath = `./server/server.properties`;
const formData = await req.formData();
const text = await Deno.readTextFile(filePath);
const properties = deserializeMCProperties(text);
for (const [key, value] of formData.entries()) {
properties.set(key, value as string);
}
const serialized = serializeMCProperties(properties);
await Deno.writeTextFile(filePath, serialized);
return ctx.render();
},
};
export default async function Properties() {
const text = await Deno.readTextFile(`./server/server.properties`);
const properties = await deserializeMCProperties(text);
return <MCProperties properties={properties} />;
}

View File

@ -4,7 +4,7 @@ import { Content } from "../../components/Content.tsx";
import { SERVER_STATE } from "../../state/serverState.ts";
export const handler: Handlers = {
async POST(req, _ctx) {
POST(req, _ctx) {
const url = new URL(req.url);
SERVER_STATE.acceptEULA();
return Response.redirect(url.origin + '/setup/start');

View File

@ -1,27 +0,0 @@
import { Handlers } from "$fresh/server.ts";
import { Content } from "../../components/Content.tsx";
import { Terminal } from "../../islands/terminal.tsx";
import { SERVER_STATE } from "../../state/serverState.ts";
export const handler: Handlers = {
async GET(req,ctx) {
if (!SERVER_STATE.eulaAccepted) {
const url = new URL(req.url);
return Response.redirect(`${url.origin}/setup/eula`)
}
const res = await ctx.render();
return res;
}
}
export default function StartFabric() {
SERVER_STATE.startMCServer();
return (
<div class="container p-8">
<Content>
<Terminal channelId={SERVER_STATE.channelId} />
</Content>
</div>
);
}

18
routes/terminal.tsx Normal file
View File

@ -0,0 +1,18 @@
import { Content } from "../components/Content.tsx";
import { StatusManager } from "../islands/statusManager.tsx";
import { Terminal } from "../islands/terminal.tsx";
import { SERVER_STATE } from "../state/serverState.ts";
export default function TerminalPage() {
const chId = SERVER_STATE.channelId;
return (
<div class="container p-8">
<Content>
<div>
<StatusManager class="ml-auto" />
</div>
<Terminal channelId={chId} />
</Content>
</div>
);
}

View File

@ -1,49 +1,67 @@
import { Tail } from "npm:tail";
import { Sockpuppet } from "puppet/client";
import { acceptEULA, checkEULA } from "../util/EULA.ts";
import { Loader } from "../types/mcgrizzconf.ts";
import { updateConfFile } from "../util/confFile.ts";
import { IS_BROWSER } from "$fresh/runtime.ts";
type MCServerEvent = 'message';
type MCServerEventCallback = (msg: string) => void;
class ServerState {
private status: "running" | "stopped" = "stopped";
private _status: "running" | "stopped" = "stopped";
public get status() {
return this._status;
}
private command!: Deno.Command;
private process!: Deno.ChildProcess;
private tail: Tail;
private _eulaAccepted: boolean;
private sockpuppet!: Sockpuppet;
private _channelId = "blanaba";
public get channelId () {
public get channelId() {
return this._channelId;
}
public get eulaAccepted() {
return this._eulaAccepted;
}
private stdin!: WritableStreamDefaultWriter;
private _serverType: Loader = 'unset';
public get serverType(): Loader {
return this.serverType;
}
public set serverType(loader: Loader) {
updateConfFile({loader});
this._serverType = loader;
}
constructor() {
this._eulaAccepted = checkEULA();
this.sockpuppet = new Sockpuppet(
"ws://sockpuppet.cyborggrizzly.com",
() => {
this.sockpuppet.joinChannel(this.channelId, (msg) => {this.sendStdIn(msg)});
this.sockpuppet.joinChannel(this.channelId, (msg) => {
this.sendStdIn(msg);
});
},
);
}
public get stdin() {
return this.process.stdin;
}
public get channel() {
return this.sockpuppet.getChannel(this.channelId)
return this.sockpuppet.getChannel(this.channelId);
}
public sendStdIn(message: string) {
const msg = new TextEncoder().encode(message);
this.process.stdin.getWriter().write(msg);
public async sendStdIn(message: string) {
if (IS_BROWSER || !this.stdin) return;
const msg = new TextEncoder().encode(message + "\n");
await this.stdin.write(msg);
}
// "instance" should be moved to a private member once multi-instance support is implemented
public startMCServer(instance = "server") {
this.command = new Deno.Command("java", {
@ -60,6 +78,11 @@ class ServerState {
this.process = this.command.spawn();
const {readable, writable} = new TransformStream();
readable.pipeTo(this.process.stdin);
this.stdin = writable.getWriter();
this._status = "running";
this.startStream();
}
@ -68,14 +91,39 @@ class ServerState {
const decoder = new TextDecoder();
while (true) {
const {done, value} = await stream.read();
const { done, value } = await stream.read();
if (value) {
this.channel?.send(decoder.decode(value));
const line = decoder.decode(value);
this.channel?.send(line);
const stdoutMsg = new CustomEvent('stdoutmsg', {
detail: line
})
globalThis.dispatchEvent(stdoutMsg);
}
if (done) break;
}
}
public gracefullyStopMCServer() {
this._status = "stopped";
this.sendStdIn("stop");
}
public forceStopMCServer() {
this._status = "stopped";
this.process.kill();
}
public async restartMCServer() {
if (this.status === "running") {
await this.sendStdIn("stop");
// while (true) {
await this.process.status;
// }
}
this.startMCServer();
}
public acceptEULA() {
this._eulaAccepted = true;
acceptEULA();

File diff suppressed because one or more lines are too long

View File

@ -1,11 +1,13 @@
import { IS_BROWSER } from "$fresh/runtime.ts";
const eulaRegex = /(eula=false)/;
export const checkEULA = (instance = 'server') =>
!eulaRegex.test(Deno.readTextFileSync(`./${instance}/eula.txt`));
export const checkEULA = (instance = "server") =>
!IS_BROWSER && !eulaRegex.test(Deno.readTextFileSync(`./${instance}/eula.txt`));
// true;
export const acceptEULA = (instance = 'server') => {
export const acceptEULA = (instance = "server") => {
const eula = Deno.readTextFileSync(`./${instance}/eula.txt`);
const mod = eula.replace(eulaRegex, 'eula=true');
console.log(mod)
Deno.writeTextFileSync(`./${instance}/eula.txt`, mod);
}
const mod = eula.replace(eulaRegex, "eula=true");
console.log(mod);
!IS_BROWSER && Deno.writeTextFileSync(`./${instance}/eula.txt`, mod);
};

30
util/confFile.ts Normal file
View File

@ -0,0 +1,30 @@
import { MCGrizzConf } from "../types/mcgrizzconf.ts";
const defaultConf: MCGrizzConf = {
loader: 'unset',
}
const confPath = 'mcgrizz.json'
export function makeConfFile(): MCGrizzConf {
Deno.writeTextFileSync(confPath, JSON.stringify(defaultConf, null, 2), {create: true});
return defaultConf;
}
export function getConfFile(): MCGrizzConf {
const conf = JSON.parse(Deno.readTextFileSync(confPath));
if (!conf) {
return makeConfFile();
}
return conf;
}
export async function updateConfFile(newConf: Partial<MCGrizzConf>) {
const conf = {...getConfFile(), newConf};
await Deno.writeTextFile(confPath, JSON.stringify(conf, null, 2));
}

1
util/filters.ts Normal file
View File

@ -0,0 +1 @@
export const filterTruthy = (a: unknown) => !!a

View File

@ -1,6 +1,6 @@
import { MCGrizzConf } from "../types/mcgrizzconf.ts";
import { NavItem } from "../types/nav.ts";
import { makeConfFile } from "./makeConfFile.ts";
import { makeConfFile } from "./confFile.ts";
/**
* @description Determines the state of setup and returns the nav items for that state
@ -8,7 +8,7 @@ import { makeConfFile } from "./makeConfFile.ts";
export function getNavItems(): NavItem[] {
let conf: MCGrizzConf;
try {
conf = JSON.parse(Deno.readTextFileSync('mcgrizz.json'));
conf = JSON.parse(Deno.readTextFileSync("mcgrizz.json"));
} catch {
conf = makeConfFile();
}
@ -16,12 +16,25 @@ export function getNavItems(): NavItem[] {
switch (conf.loader) {
case "unset":
return [{
title: 'Setup',
href: '/',
}]
title: "Setup",
href: "/",
}];
case "forge":
case "fabric":
case "vanilla":
return [];
return [
{
title: "Server Terminal",
href: "/terminal",
},
{
title: "Players",
href: "/players",
},
{
title: "Server Properties",
href: "/properties",
},
];
}
}

View File

@ -1,11 +0,0 @@
import { MCGrizzConf } from "../types/mcgrizzconf.ts";
export function makeConfFile(): MCGrizzConf {
const conf: MCGrizzConf = {
loader: 'unset',
}
Deno.writeTextFileSync('mcgrizz.json', JSON.stringify(conf), {create: true});
return conf;
}

24
util/mcProperties.ts Normal file
View File

@ -0,0 +1,24 @@
import { filterTruthy } from "./filters.ts";
export const deserializeMCProperties = (serialized: string) => {
const propertiesMap = new Map<string,string>();
const commentRegex = /#.+\r?\n/g;
serialized.replace(commentRegex, '')
.split(/\r?\n/)
.filter(filterTruthy)
.forEach(prop => {
const [key, value] = prop.split('=');
propertiesMap.set(key, value ?? '');
});
return propertiesMap;
}
export const serializeMCProperties = (deserialized: Map<string,string>) => {
let text = '';
for (const [key,value] of deserialized.entries()) {
text += `${key}=${value}\n`;
}
return text;
}

46
util/players.ts Normal file
View File

@ -0,0 +1,46 @@
import { SERVER_STATE } from "../state/serverState.ts";
import { filterTruthy } from "./filters.ts";
const playerListRegex= /There are [0-9] of a max of [0-9]+ players online:/
export const getActivePlayers = (): Promise<PlayerData[]> => new Promise(res => {
const listener = async (e: CustomEvent<string>) => {
if (playerListRegex.test(e.detail)) {
const players: PlayerData[] = []
const [_, playerString] = e.detail.split(playerListRegex);
for (const playerName of playerString.split(', ')) {
players.push(await getPlayerData(playerName));
}
res(players.filter(filterTruthy));
globalThis.removeEventListener('stdoutmsg' as any, listener);
}
}
globalThis.addEventListener('stdoutmsg' as any, listener);
SERVER_STATE.sendStdIn('list');
})
export type PlayerData = {
username: string;
id: string;
avatar: string;
}
const getPlayerData = async (username: string) => {
username = username.trim();
if (!username) return;
const cacheFile = 'players.cache.json'
await Deno.create(cacheFile);
const cache = JSON.parse(await Deno.readTextFile(cacheFile) || '{}');
if (!cache[username]) {
const req = await fetch('https://playerdb.co/api/player/minecraft/' + username, {
headers: {
"User-Agent": "MCGRIZZ/0.1 emma@cyborggrizzly.com"
}
});
const json = await req.json();
cache[username] = json.data.player;
await Deno.writeTextFile(cacheFile, JSON.stringify(cache, null, 2), {create: true});
}
return cache[username]
}

View File

@ -1,17 +0,0 @@
import { startTail } from "./startTail.ts";
export async function startMCServer(instance = "server") {
const start = new Deno.Command("java", {
args: [
"-Xmx2G",
"-jar",
"./server.jar",
"nogui",
],
cwd: "./" + instance,
});
start.output();
return await startTail(instance);
}

View File

@ -1,25 +0,0 @@
import { Sockpuppet } from "puppet/client";
import { Tail } from "npm:tail";
export function startTail(instance = "server") {
return new Promise<[Tail, string]>((resolve) => {
const puppet = new Sockpuppet("ws://sockpuppet.cyborggrizzly.com", () => {
const channelId = crypto.randomUUID();
puppet.createChannel(channelId);
puppet.joinChannel(channelId, (line) => {
console.log(line);
});
setTimeout(() => {
const path = `./${instance}/logs/latest.log`;
const tail = new Tail(path, { follow: true });
tail.on("line", (line: string) => {
puppet.getChannel(channelId)?.send(line);
console.log(line);
});
resolve([tail, channelId]);
}, 1000);
});
});
}