Add basic ci

This commit is contained in:
Corey Johnson 2025-07-22 16:24:17 -07:00
parent c89a1849a7
commit 99645e3993
11 changed files with 130 additions and 142 deletions

26
ci.ts Normal file
View File

@ -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
}
})
}
})

View File

@ -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) {

View File

@ -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"
}
}

View File

@ -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<string, number> = {}
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<string, string> }) => {

View File

@ -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"

View File

@ -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<string, string>
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
prettier?: Record<string, string>
}
}
export const getPackageInfo = async (): Promise<PackageInfo[]> => {
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
}

View File

@ -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) =>

View File

@ -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)
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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<boolean> | PrivateThreadChannel) => {
const archiveTimestamp = thread.archiveTimestamp
if (!archiveTimestamp) return true // I'm not sure when this happens
const timeUntilArchive = archiveTimestamp - Date.now()
return timeUntilArchive <= timeout
}