diff --git a/ci.ts b/ci.ts new file mode 100644 index 0000000..af50f0f --- /dev/null +++ b/ci.ts @@ -0,0 +1,26 @@ +import { getPackageInfo } from "@workshop/shared/packages" +import { test, expect } from "bun:test" +import { basename } from "node:path" +import { ensure } from "@workshop/shared/utils" + +test("verify all package.json", async () => { + const packageInfo = await getPackageInfo() + const mainPackageFile = await Bun.file("package.json") + const mainPackageJson = await mainPackageFile.json() + const catalog = mainPackageJson.workshop?.catalog ?? {} + + for (const { json, path } of packageInfo) { + const dirname = basename(path) + ensure(json, `package.json in ${dirname} must exist`) + + const unscopedName = json.name.replace("@workshop/", "") + expect(unscopedName, `Name in package.json must match the dir name`).toBe(dirname) + + const dependencies = { ...json.dependencies, ...json.devDependencies } + Object.entries(dependencies).forEach(([dep, version]) => { + if (catalog[dep]) { + expect(version, `Dependency "${dep}" in "${json.name}" must be "catalog:"`).toBe("catalog:") // The dep is in the catalog because having multiple versions causes hard to diagnose bugs + } + }) + } +}) diff --git a/main.ts b/main.ts index 1cceeaf..b778a2e 100644 --- a/main.ts +++ b/main.ts @@ -6,11 +6,8 @@ console.log(`Bun Version: ${Bun.version}`) console.log("----------------------------------\n\n") const run = async (cmd: string[]) => { - const proc = spawn(cmd.filter(Boolean), { - stdout: "inherit", - stderr: "inherit", - }) - + // Filter gets rid of empty strings because it FREAKS Bun out + const proc = spawn(cmd.filter(Boolean), { stdout: "inherit", stderr: "inherit" }) const status = await proc.exited const commandText = cmd.join(" ") @@ -29,7 +26,7 @@ try { await Promise.all([ run(["bun", "run", noElide, "--filter=@workshop/http", "start"]), - run(["bun", "run", noElide, "bot:discord"]), + run(["bun", "run", noElide, "--filter=@workshop/spike", "bot:discord"]), ]) console.log("โœ… All processes completed successfully") } catch (error) { diff --git a/package.json b/package.json index af2d70c..aa86bbb 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,8 @@ "semi": false }, "scripts": { - "http": "bun run --filter=@workshop/http start", - "bot:cli": "bun run --filter=@workshop/spike bot:cli", - "bot:discord": "bun run --filter=@workshop/spike bot:discord", - "start": "bun run main.ts" + "start": "bun run main.ts", + "super-clean": "rm -rf **/node_modules && rm -rf **/bun.lockb && rm -rf **/.nano-remix", + "ci": "bun test ./ci.ts" } } \ No newline at end of file diff --git a/packages/http/src/orchestrator.ts b/packages/http/src/orchestrator.ts index e1cd6db..ac0ca30 100644 --- a/packages/http/src/orchestrator.ts +++ b/packages/http/src/orchestrator.ts @@ -1,24 +1,24 @@ -import { readdir } from "node:fs/promises" -import { basename, join } from "node:path" +import { basename } from "node:path" +import { getPackageInfo } from "@workshop/shared/packages" export const startSubdomainServers = async () => { const portMap: Record = {} - const packageInfo = await subdomainPackageInfo() + const packageInfo = await subdomainInfo() let currentPort = 3001 const isDevMode = process.env.NODE_ENV !== "production" const processes = packageInfo.map((info) => { const port = currentPort++ - const dirname = basename(info.packagePath) + const dirname = basename(info.path) portMap[dirname] = port // Use subdomain:dev if available and in dev mode, otherwise use subdomain:start const script = isDevMode && info.hasDevScript ? "subdomain:dev" : "subdomain:start" return run(["bun", "run", script], { - cwd: info.packagePath, + cwd: info.path, env: { PORT: port.toString() }, }) }) @@ -31,30 +31,20 @@ export const startSubdomainServers = async () => { return portMap } -export const subdomainPackageInfo = async () => { - const packagesDir = join(import.meta.dir, "../../") - const packages = await readdir(packagesDir) - const packagePaths: { packageName: string; packagePath: string; hasDevScript: boolean }[] = [] +export const subdomainInfo = async () => { + const packageInfo = await getPackageInfo() + const subdomainInfo = [] + for (const info of packageInfo) { + if (!info.json) continue + if (!info.json.scripts?.["subdomain:start"]) continue - for (const pkg of packages) { - const packagePath = join(packagesDir, pkg) - const packageJsonPath = join(packagePath, "package.json") - const hasPackageJson = await Bun.file(packageJsonPath).exists() - if (!hasPackageJson) continue - - const packageJson = await Bun.file(packageJsonPath).json() - - if (packageJson.scripts?.["subdomain:start"]) { - const hasDevScript = !!packageJson.scripts?.["subdomain:dev"] - packagePaths.push({ - packageName: basename(packagePath), - packagePath: packagePath, - hasDevScript, - }) - } + subdomainInfo.push({ + path: info.path, + hasDevScript: Boolean(info.json.scripts?.["subdomain:dev"]), + }) } - return packagePaths + return subdomainInfo } const run = async (cmd: string[], options: { cwd: string; env?: Record }) => { diff --git a/packages/shared/package.json b/packages/shared/package.json index 08d2b13..5e31f31 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -5,7 +5,8 @@ "./kv": "./src/kv.ts", "./utils": "./src/utils.ts", "./log": "./src/log.ts", - "./errors": "./src/errors.ts" + "./errors": "./src/errors.ts", + "./packages": "./src/packages.ts" }, "devDependencies": { "@types/bun": "latest" diff --git a/packages/shared/src/packages.ts b/packages/shared/src/packages.ts new file mode 100644 index 0000000..6138ee5 --- /dev/null +++ b/packages/shared/src/packages.ts @@ -0,0 +1,41 @@ +import { readdir } from "node:fs/promises" +import { join } from "node:path" + +type PackageInfo = { + path: string + json?: { + name: string + version: string + scripts?: Record + dependencies?: Record + devDependencies?: Record + prettier?: Record + } +} + +export const getPackageInfo = async (): Promise => { + const packagesDir = join(import.meta.dir, "../../") + const packageNames = await readdir(packagesDir) + + const packageInfo: PackageInfo[] = [] + for (const packageName of packageNames) { + const path = join(packagesDir, packageName) + try { + const dir = await Bun.file(path) + const stats = await dir.stat() + if (!stats.isDirectory()) { + continue + } + + const jsonPath = join(path, "package.json") + const json = await Bun.file(jsonPath).json() + + packageInfo.push({ path, json }) + } catch (error) { + console.error(`Error processing package ${packageName}:`, error) + packageInfo.push({ path }) + } + } + + return packageInfo +} diff --git a/packages/spike/src/discord/commands.ts b/packages/spike/src/discord/commands.ts index a5d6324..405a8f9 100644 --- a/packages/spike/src/discord/commands.ts +++ b/packages/spike/src/discord/commands.ts @@ -49,7 +49,7 @@ const commands: { command: SlashCommandOptionsOnlyBuilder; execute: ExecuteComma .addChoices( { name: "App", value: "app" }, { name: "Build", value: "build" }, - { name: "Deploy", value: "deploy" } + { name: "Request", value: "request" } ) ) .addNumberOption((option) => diff --git a/packages/spike/src/discord/cron.ts b/packages/spike/src/discord/cron.ts deleted file mode 100644 index 5a53292..0000000 --- a/packages/spike/src/discord/cron.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Client } from "discord.js" - -const channelId = process.env.CHANNEL_ID ?? "1382121375619223594" - -export const runCronJobs = async (client: Client) => { - const minuteBetweenCronJobs = 1 - const timeout = 1000 * 60 * minuteBetweenCronJobs - - setInterval(async () => { - try { - } catch (error) { - console.error("Error running cron job:", error) - } - }, timeout) -} diff --git a/packages/spike/src/discord/index.ts b/packages/spike/src/discord/index.ts index cf24dfd..f2efdd0 100644 --- a/packages/spike/src/discord/index.ts +++ b/packages/spike/src/discord/index.ts @@ -1,8 +1,7 @@ import { Client, GatewayIntentBits, Partials } from "discord.js" import { listenForEvents } from "./events" -import { runCronJobs } from "./cron" import { alertAboutCrashLog, logCrash } from "./crash" -import { keepThreadsAlive } from "./threadKeepAlive" +import { keepThreadsAlive } from "./keepThreadsAlive" import { registerCommands } from "./commands" const client = new Client({ @@ -33,6 +32,5 @@ process.on("uncaughtException", async (error) => { await logCrash(error) }) -runCronJobs(client) await alertAboutCrashLog(client) // startAuthServer() this is handy if you make a new bot diff --git a/packages/spike/src/discord/keepThreadsAlive.ts b/packages/spike/src/discord/keepThreadsAlive.ts new file mode 100644 index 0000000..5c9070f --- /dev/null +++ b/packages/spike/src/discord/keepThreadsAlive.ts @@ -0,0 +1,37 @@ +import { Client } from "discord.js" +import { timeBomb } from "@workshop/shared/utils" +import { DateTime } from "luxon" + +timeBomb("7-15-2025", "Get rid of all these console messages Corey!") + +// const timeout = 1000 * 60 * 30 // 30 minutes +// const timeout = 1000 * 60 // 1 minute +const timeout = 3000 // 1 minute +export const keepThreadsAlive = async (client: Client) => { + try { + if (!client.isReady() || client.guilds.cache.size === 0) return + + for (const guild of client.guilds.cache.values()) { + const fetchedThreads = await guild.channels.fetchActiveThreads() + + for (const [threadId, thread] of fetchedThreads.threads) { + const archiveAt = DateTime.fromMillis(thread.archiveTimestamp || 0) + const result = await thread.setArchived( + false, + "Revived by Spike - keeping important discussion alive" + ) + + // send message to the thread if it was archived + if (thread.archived) { + const message = `๐Ÿ” Revived: ${thread.name} -- ${archiveAt.toFormat("F")}` + await thread.send(message) + } + console.log(`๐Ÿ” Revive: ${thread.name}: ${archiveAt.toFormat("F")} -- ${result.archived}`) + } + } + } catch (error) { + console.error("Error scanning and reviving threads:", error) + } finally { + setTimeout(() => keepThreadsAlive(client), timeout) + } +} diff --git a/packages/spike/src/discord/threadKeepAlive.ts b/packages/spike/src/discord/threadKeepAlive.ts deleted file mode 100644 index f5c1249..0000000 --- a/packages/spike/src/discord/threadKeepAlive.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Client, type PrivateThreadChannel, type PublicThreadChannel } from "discord.js" -import KV, { type TrackedThread } from "@workshop/shared/kv" -import { getErrorMessage } from "@workshop/shared/errors" -import { timeBomb } from "@workshop/shared/utils" - -timeBomb("7-15-2025", "Get rid of all these console messages Corey!") - -const timeout = 1000 * 60 * 30 // 30 minutes -export const keepThreadsAlive = async (client: Client) => { - try { - if (!client.isReady() || client.guilds.cache.size === 0) { - return - } - - const trackedThreads = await KV.get("threads", {}) - const activeThreadIds = new Set() - - // Scan all active threads and update our tracking - for (const guild of client.guilds.cache.values()) { - const fetchedThreads = await guild.channels.fetchActiveThreads() - - for (const [threadId, thread] of fetchedThreads.threads) { - const threadData: TrackedThread = { threadId, threadName: thread.name } - trackedThreads[threadId] = threadData - activeThreadIds.add(threadId) - } - - if (fetchedThreads.threads.size > 0) { - console.log(`๐Ÿงต Found ${fetchedThreads.threads.size} active threads in ${guild.name}`) - } - } - - // Find threads to revive (any thread we're tracking that's not currently active) - for (const [threadId, threadData] of Object.entries(trackedThreads)) { - if (!activeThreadIds.has(threadId)) { - await reviveThread(client, threadData) - } - } - - await KV.set("threads", trackedThreads) - } catch (error) { - console.error("Error scanning and reviving threads:", error) - } finally { - setTimeout(() => keepThreadsAlive(client), timeout) - } -} - -const reviveThread = async (client: Client, threadData: TrackedThread) => { - try { - const thread = await client.channels.fetch(threadData.threadId) - - if (!thread?.isThread()) { - console.error(`โŒ Thread "${threadData.threadName}" no longer exists, removing from tracking`) - await KV.update("threads", {}, (threads) => { - delete threads[threadData.threadId] - return threads - }) - - return - } - - if (thread.archived || aboutToBeArchived(thread)) { - await thread.setArchived(false, "Revived by Spike - keeping important discussion alive") - console.log(`โœ… Revived thread: "${threadData.threadName}"`) - } else { - console.log(`๐ŸŸข Thread "${threadData.threadName}" is already active, no action needed`) - } - } catch (error) { - console.error( - `๐Ÿšซ Failed to revive "${threadData.threadName}:${threadData.threadId}".`, - getErrorMessage(error) - ) - await KV.update("threads", {}, (threads) => { - delete threads[threadData.threadId] - return threads - }) - } -} - -const aboutToBeArchived = (thread: PublicThreadChannel | PrivateThreadChannel) => { - const archiveTimestamp = thread.archiveTimestamp - if (!archiveTimestamp) return true // I'm not sure when this happens - - const timeUntilArchive = archiveTimestamp - Date.now() - return timeUntilArchive <= timeout -}