From d07936e83e0f589f7d613f123f6e50285b3e5748 Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 6 Oct 2026 12:54:53 -0400 Subject: [PATCH 01/45] fix(build): remove stale dist output before building swc only writes new and changed files, so dist kept compiled files whose sources were deleted or renamed. The command loader imports every *.command.js under dist/commands, so an orphaned command either comes back as a ghost command or crashes startup with an ESM SyntaxError. - add --delete-dir-on-start to every swc build script - purge all dist folders before build and build:watch; turbo cache hits restore outputs on top of an existing dist without running the build script, so orphans survived branch switches --- apps/api/package.json | 2 +- apps/discord-bot/package.json | 2 +- apps/support-bot/package.json | 2 +- apps/verify-server/package.json | 2 +- package.json | 4 ++-- packages/api-client/package.json | 2 +- packages/assets/package.json | 2 +- packages/discord/package.json | 2 +- packages/logger/package.json | 2 +- packages/math/package.json | 2 +- packages/rendering/package.json | 2 +- packages/schemas/package.json | 2 +- packages/util/package.json | 2 +- 13 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 69f8986d9..a19f59950 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "start": "node --enable-source-maps .", - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index 9dc7db193..60746204c 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "start": "node --enable-source-maps .", - "build": "swc src --out-dir dist --strip-leading-paths", + "build": "swc src --out-dir dist --strip-leading-paths --delete-dir-on-start", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/apps/support-bot/package.json b/apps/support-bot/package.json index 19b914dd0..950b98bb3 100644 --- a/apps/support-bot/package.json +++ b/apps/support-bot/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "start": "node --enable-source-maps .", - "build": "swc src --out-dir dist --strip-leading-paths", + "build": "swc src --out-dir dist --strip-leading-paths --delete-dir-on-start", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/apps/verify-server/package.json b/apps/verify-server/package.json index bfb0482a0..cea90deed 100644 --- a/apps/verify-server/package.json +++ b/apps/verify-server/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "start": "node --enable-source-maps .", - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/package.json b/package.json index bfd5f8b67..8d6eec004 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "url": "git+https://github.com/Statsify/statsify.git" }, "scripts": { - "build": "turbo run build --filter=!site", - "build:watch": "turbo watch build --filter=!site", + "build": "pnpm scripts purge --dist && turbo run build --filter=!site", + "build:watch": "pnpm scripts purge --dist && turbo watch build --filter=!site", "typecheck": "turbo run typecheck", "test": "vitest", "test:ui": "vitest --ui", diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 28880a59f..9bffcd864 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -5,7 +5,7 @@ "types": "src/index.ts", "type": "module", "scripts": { - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/assets/package.json b/packages/assets/package.json index 93f7611d5..5c5d00329 100644 --- a/packages/assets/package.json +++ b/packages/assets/package.json @@ -5,7 +5,7 @@ "types": "src/index.ts", "type": "module", "scripts": { - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/discord/package.json b/packages/discord/package.json index e7a829451..a7b53e11c 100644 --- a/packages/discord/package.json +++ b/packages/discord/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "type": "module", "scripts": { - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/logger/package.json b/packages/logger/package.json index e5e46c44a..511327954 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -5,7 +5,7 @@ "types": "src/index.ts", "type": "module", "scripts": { - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/math/package.json b/packages/math/package.json index b33551203..7e1361a00 100644 --- a/packages/math/package.json +++ b/packages/math/package.json @@ -5,7 +5,7 @@ "types": "src/index.ts", "type": "module", "scripts": { - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/rendering/package.json b/packages/rendering/package.json index 5abf42564..d44df5ecc 100644 --- a/packages/rendering/package.json +++ b/packages/rendering/package.json @@ -14,7 +14,7 @@ } }, "scripts": { - "build": "swc src --out-dir dist --strip-leading-paths", + "build": "swc src --out-dir dist --strip-leading-paths --delete-dir-on-start", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 0aed6a9f8..c4ce1ea88 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "type": "module", "scripts": { - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --copy-files", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start --copy-files", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/util/package.json b/packages/util/package.json index 9c3cda222..1ea94dc07 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "types": "src/index.ts", "scripts": { - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github", From 6c2cd3f5130d7a60ef163c2beaf0823b1ec0f8ab Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 6 Oct 2026 12:54:53 -0400 Subject: [PATCH 02/45] fix(turbo): point globalDependencies at files that exist pnpm.lock and tsconfig.json do not exist, so lockfile and base tsconfig changes never invalidated the build cache. --- turbo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index cf52f50df..299cf8755 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,6 @@ { "$schema": "https://turborepo.org/schema.json", - "globalDependencies": ["pnpm.lock", "tsconfig.json", ".swcrc"], + "globalDependencies": ["pnpm-lock.yaml", "tsconfig.base.json", ".swcrc"], "ui": "tui", "tasks": { "build": { From 8cce0eeb81e16781cf2cf9b27f43c4400aa9fcdc Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 6 Oct 2026 12:54:53 -0400 Subject: [PATCH 03/45] fix(skin-renderer): cache native binary in turbo build outputs The napi build writes skin-renderer..node next to package.json, which was not in the build outputs. A cache hit restored pkg/ but not the binary, so the renderer failed to load at runtime. --- packages/skin-renderer/turbo.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 packages/skin-renderer/turbo.json diff --git a/packages/skin-renderer/turbo.json b/packages/skin-renderer/turbo.json new file mode 100644 index 000000000..a80250239 --- /dev/null +++ b/packages/skin-renderer/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turborepo.org/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["pkg/**", "*.node"] + } + } +} From 50a80b1d370485de74f9d6c239eabb25a8197cb3 Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 6 Oct 2026 12:58:50 -0400 Subject: [PATCH 04/45] fix(pnpm): exclude generated wasm package from the workspace wasm-pack writes packages/skin-renderer/pkg/package.json, which the packages/** glob picks up as a workspace project. After a build, the next pnpm install added an empty packages/skin-renderer/pkg importer to pnpm-lock.yaml even with --frozen-lockfile. --- pnpm-workspace.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 795b137c5..519ea70d0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - apps/** - packages/** + - "!packages/skin-renderer/pkg" - assets/** onlyBuiltDependencies: From df1e2a9c85197e6f930c29f744c4021cbaca15af Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 6 Oct 2026 12:59:07 -0400 Subject: [PATCH 05/45] feat(scripts): add setup-worktree script for new checkouts Prepares the main clone or a linked git worktree for development with pnpm scripts setup-worktree: - links config.json/config.js and the ignored texture pack from the main checkout - checks out asset submodules at the commit the branch pins, as git worktrees of the main checkout's submodule repos, so nothing is re-cloned - installs dependencies and builds, reusing turbo's shared worktree cache - restores blurred private backgrounds from a cache in the git common dir, keyed by the backgrounds tree and blur.mjs, instead of re-blurring (~30s) in every checkout --- apps/scripts/package.json | 1 + apps/scripts/src/setup-worktree.js | 235 +++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 apps/scripts/src/setup-worktree.js diff --git a/apps/scripts/package.json b/apps/scripts/package.json index 8315ef054..a5f3b72ef 100644 --- a/apps/scripts/package.json +++ b/apps/scripts/package.json @@ -10,6 +10,7 @@ "delete-sessions": "node src/delete-sessions.js", "limit-redis": "node src/limit-redis.js", "purge": "node src/purge.js", + "setup-worktree": "node src/setup-worktree.js", "rank-emojis": "node src/rank-emojis.js", "timestamp": "node --no-warnings src/timestamp.js", "validate-commands": "node --no-warnings src/validate-commands.js" diff --git a/apps/scripts/src/setup-worktree.js b/apps/scripts/src/setup-worktree.js new file mode 100644 index 000000000..8c974e67b --- /dev/null +++ b/apps/scripts/src/setup-worktree.js @@ -0,0 +1,235 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +/** + * Prepares a checkout (the main clone or a linked git worktree) for development. + * + * Only uses node builtins because it runs before `pnpm install`. + * + * 1. Links `config.json` / `config.js` from the main checkout + * 2. Checks out each asset submodule at the commit this branch pins, as a git + * worktree of the main checkout's submodule repo (no network, no re-clone) + * 3. Links the ignored minecraft texture pack from the main checkout + * 4. Installs dependencies (this also blurs the public backgrounds) + * 5. Restores the blurred private backgrounds from a cache, or generates them + * 6. Builds the monorepo + * + * Usage: pnpm scripts setup-worktree [--skip-build] + */ + +import { + constants, + cpSync, + existsSync, + mkdirSync, + readdirSync, + rmSync, + rmdirSync, + symlinkSync, +} from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { execFileSync } from "node:child_process"; + +const ROOT = git( + ["rev-parse", "--show-toplevel"], + resolve(import.meta.dirname, "../../.."), +); +const COMMON_DIR = resolve(ROOT, git(["rev-parse", "--git-common-dir"], ROOT)); +const MAIN_ROOT = dirname(COMMON_DIR); +const IS_LINKED_WORKTREE = MAIN_ROOT !== ROOT; +const CACHE_DIR = join(COMMON_DIR, "statsify-cache"); + +const TEXTURE_PACK = "assets/public/minecraft-textures/default"; +const PRIVATE_ASSETS = "assets/private"; + +/** + * @param {string[]} args + * @param {string} cwd + * @returns {string} + */ +function git(args, cwd = ROOT) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +/** + * @param {string} command + * @param {string[]} args + * @param {string} cwd + */ +function run(command, args, cwd = ROOT) { + execFileSync(command, args, { + cwd, + stdio: "inherit", + shell: process.platform === "win32", + }); +} + +/** + * @param {string} title + * @param {string} message + */ +function log(title, message) { + console.log(`\n! ${title} ${message}`); +} + +/** + * Symlinks a file or directory from the main checkout, copying files when symlinks are not permitted. + * @param {string} path path relative to the repo root + */ +function linkFromMain(path) { + const source = join(MAIN_ROOT, path); + const target = join(ROOT, path); + + if (existsSync(target) || !existsSync(source)) return false; + + mkdirSync(dirname(target), { recursive: true }); + + try { + symlinkSync(source, target, "junction"); + } catch { + cpSync(source, target, { recursive: true }); + } + + return true; +} + +function linkConfig() { + if (!IS_LINKED_WORKTREE) return; + + const linked = ["config.json", "config.js"].filter(linkFromMain); + + if (linked.length > 0) + log("Config", `linked ${linked.join(", ")} from ${MAIN_ROOT}`); + else if ( + !["config.json", "config.js"].some((file) => existsSync(join(ROOT, file))) + ) + log("Config", "missing, copy config.schema.js to config.js and fill it in"); +} + +/** + * @returns {string[]} + */ +function submodulePaths() { + let output; + + try { + output = git(["config", "--file", ".gitmodules", "--get-regexp", "path"]); + } catch { + return []; + } + + return output.split("\n").map((line) => line.split(" ")[1]); +} + +/** + * @param {string} path + */ +function setupSubmodule(path) { + const target = join(ROOT, path); + if (existsSync(join(target, ".git"))) return; + + const moduleDir = join(COMMON_DIR, "modules", path); + + if (!IS_LINKED_WORKTREE || !existsSync(moduleDir)) { + try { + run("git", ["submodule", "update", "--init", path]); + } catch { + log("Submodule", `could not clone ${path}, continuing without it`); + } + + return; + } + + const sha = git(["ls-tree", "HEAD", path]).split(/\s+/)[2]; + + try { + git(["--git-dir", moduleDir, "cat-file", "-e", `${sha}^{commit}`]); + } catch { + run("git", ["--git-dir", moduleDir, "fetch", "origin"]); + } + + // Worktrees that were deleted without `git worktree remove` leave stale entries behind + git(["--git-dir", moduleDir, "worktree", "prune"]); + + if (existsSync(target) && readdirSync(target).length === 0) rmdirSync(target); + git(["--git-dir", moduleDir, "worktree", "add", "--detach", target, sha]); + + log("Submodule", `checked out ${path} at ${sha.slice(0, 8)}`); +} + +function linkTexturePack() { + if (IS_LINKED_WORKTREE && linkFromMain(TEXTURE_PACK)) + log("Textures", `linked ${TEXTURE_PACK}`); + + if (!existsSync(join(ROOT, TEXTURE_PACK))) + log("Textures", `missing, add a 1.8.9 texture pack to ${TEXTURE_PACK}`); +} + +/** + * The blur output only depends on the backgrounds and the blur script, so it is cached + * by their git object ids. Uncommitted changes to either always regenerate. + */ +function blurPrivateBackgrounds() { + const assets = join(ROOT, PRIVATE_ASSETS); + if (!existsSync(join(assets, "package.json"))) return; + + const output = join(assets, "out", "backgrounds"); + const dirty = + git(["status", "--porcelain", "--", "backgrounds", "blur.mjs"], assets) !== + ""; + + const key = dirty + ? undefined + : `${git(["rev-parse", "HEAD:backgrounds"], assets)}-${git(["rev-parse", "HEAD:blur.mjs"], assets)}`; + + const cached = key && join(CACHE_DIR, "blurred-backgrounds", key); + + if (cached && existsSync(cached)) { + rmSync(output, { recursive: true, force: true }); + // Copy on write where the filesystem supports it, so restoring costs no extra disk space + cpSync(cached, output, { + recursive: true, + mode: constants.COPYFILE_FICLONE, + }); + log("Backgrounds", `restored from cache ${relative(MAIN_ROOT, cached)}`); + return; + } + + log("Backgrounds", "blurring private backgrounds, this takes a while"); + run("pnpm", ["blur"], assets); + + if (!cached) return; + + rmSync(join(CACHE_DIR, "blurred-backgrounds"), { + recursive: true, + force: true, + }); + cpSync(output, cached, { recursive: true, mode: constants.COPYFILE_FICLONE }); + log("Backgrounds", "cached for future checkouts"); +} + +log( + "Setup", + `${ROOT}${IS_LINKED_WORKTREE ? ` (worktree of ${MAIN_ROOT})` : ""}`, +); + +linkConfig(); +for (const path of submodulePaths()) setupSubmodule(path); +linkTexturePack(); + +run("pnpm", ["install", "--frozen-lockfile"]); + +blurPrivateBackgrounds(); + +if (!process.argv.includes("--skip-build")) run("pnpm", ["build"]); + +log("Setup", "done"); From 27d6ed535550f6e276393e3dd84087f08e35feb2 Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 6 Oct 2026 13:14:52 -0400 Subject: [PATCH 06/45] fix(build): only clean dist for the packages being built Purging every dist folder from the root build script meant a filtered build like pnpm build --filter=@statsify/util emptied the dist of every app it did not rebuild. Add a per-package clean task that build depends on, so turbo cleans exactly the packages it is about to build or restore from cache, including under turbo watch. --- apps/api/package.json | 1 + apps/discord-bot/package.json | 1 + apps/support-bot/package.json | 1 + apps/verify-server/package.json | 1 + package.json | 4 ++-- packages/api-client/package.json | 1 + packages/assets/package.json | 1 + packages/discord/package.json | 1 + packages/logger/package.json | 1 + packages/math/package.json | 1 + packages/rendering/package.json | 1 + packages/schemas/package.json | 1 + packages/util/package.json | 1 + turbo.json | 5 ++++- 14 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index a19f59950..6204849d2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,6 +7,7 @@ "scripts": { "start": "node --enable-source-maps .", "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index 60746204c..5affdfdba 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -7,6 +7,7 @@ "scripts": { "start": "node --enable-source-maps .", "build": "swc src --out-dir dist --strip-leading-paths --delete-dir-on-start", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/apps/support-bot/package.json b/apps/support-bot/package.json index 950b98bb3..edd880881 100644 --- a/apps/support-bot/package.json +++ b/apps/support-bot/package.json @@ -7,6 +7,7 @@ "scripts": { "start": "node --enable-source-maps .", "build": "swc src --out-dir dist --strip-leading-paths --delete-dir-on-start", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/apps/verify-server/package.json b/apps/verify-server/package.json index cea90deed..812957502 100644 --- a/apps/verify-server/package.json +++ b/apps/verify-server/package.json @@ -7,6 +7,7 @@ "scripts": { "start": "node --enable-source-maps .", "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/package.json b/package.json index 8d6eec004..bfd5f8b67 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "url": "git+https://github.com/Statsify/statsify.git" }, "scripts": { - "build": "pnpm scripts purge --dist && turbo run build --filter=!site", - "build:watch": "pnpm scripts purge --dist && turbo watch build --filter=!site", + "build": "turbo run build --filter=!site", + "build:watch": "turbo watch build --filter=!site", "typecheck": "turbo run typecheck", "test": "vitest", "test:ui": "vitest --ui", diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 9bffcd864..83d72c148 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/assets/package.json b/packages/assets/package.json index 5c5d00329..ac42db4d9 100644 --- a/packages/assets/package.json +++ b/packages/assets/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/discord/package.json b/packages/discord/package.json index a7b53e11c..8db1f8a7d 100644 --- a/packages/discord/package.json +++ b/packages/discord/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/logger/package.json b/packages/logger/package.json index 511327954..0eec02774 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/math/package.json b/packages/math/package.json index 7e1361a00..e9d889993 100644 --- a/packages/math/package.json +++ b/packages/math/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/rendering/package.json b/packages/rendering/package.json index d44df5ecc..3ceba0062 100644 --- a/packages/rendering/package.json +++ b/packages/rendering/package.json @@ -15,6 +15,7 @@ }, "scripts": { "build": "swc src --out-dir dist --strip-leading-paths --delete-dir-on-start", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/schemas/package.json b/packages/schemas/package.json index c4ce1ea88..00feb1a55 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start --copy-files", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/util/package.json b/packages/util/package.json index 1ea94dc07..42c73b8a4 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -6,6 +6,7 @@ "types": "src/index.ts", "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", + "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github", diff --git a/turbo.json b/turbo.json index 299cf8755..51b9db3eb 100644 --- a/turbo.json +++ b/turbo.json @@ -6,7 +6,10 @@ "build": { "env": ["API_URL", "API_KEY"], "outputs": ["dist/**", ".next/**", "pkg/**"], - "dependsOn": ["^build"] + "dependsOn": ["clean", "^build"] + }, + "clean": { + "cache": false }, "typecheck": { "outputs": [], From 988097f6a586c7fbd2bc12dfd1bf293ee6895c88 Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 6 Oct 2026 13:16:59 -0400 Subject: [PATCH 07/45] fix(scripts): keep submodules on the pinned commit in setup-worktree - move existing submodule checkouts to the commit the branch pins, so re-running setup after a submodule bump updates the assets; checkouts on a branch or with local changes are left alone with a warning - warn and continue when a pinned submodule commit cannot be fetched, matching the behaviour when a submodule cannot be cloned --- apps/scripts/src/setup-worktree.js | 78 +++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/apps/scripts/src/setup-worktree.js b/apps/scripts/src/setup-worktree.js index 8c974e67b..216505d35 100644 --- a/apps/scripts/src/setup-worktree.js +++ b/apps/scripts/src/setup-worktree.js @@ -14,6 +14,7 @@ * 1. Links `config.json` / `config.js` from the main checkout * 2. Checks out each asset submodule at the commit this branch pins, as a git * worktree of the main checkout's submodule repo (no network, no re-clone) + * and moves existing clean, detached checkouts to the pinned commit * 3. Links the ignored minecraft texture pack from the main checkout * 4. Installs dependencies (this also blurs the public backgrounds) * 5. Restores the blurred private backgrounds from a cache, or generates them @@ -130,12 +131,78 @@ function submodulePaths() { return output.split("\n").map((line) => line.split(" ")[1]); } +/** + * Checks that a submodule repo has a commit, fetching it when it is missing. + * @param {string[]} repo git arguments that select the submodule repo + * @param {string} sha + * @returns {boolean} whether the commit is available + */ +function hasCommit(repo, sha) { + const exists = () => { + try { + git([...repo, "cat-file", "-e", `${sha}^{commit}`]); + return true; + } catch { + return false; + } + }; + + if (exists()) return true; + + try { + run("git", [...repo, "fetch", "origin"]); + } catch { + return false; + } + + return exists(); +} + +/** + * Moves an existing submodule checkout to the commit this branch pins, unless it is + * on a branch or has local changes. + * @param {string} path + * @param {string} sha + */ +function updateSubmodule(path, sha) { + const target = join(ROOT, path); + const head = git(["rev-parse", "HEAD"], target); + if (head === sha) return; + + let onBranch = true; + + try { + git(["symbolic-ref", "-q", "HEAD"], target); + } catch { + onBranch = false; + } + + if (onBranch || git(["status", "--porcelain"], target) !== "") { + log( + "Submodule", + `${path} is at ${head.slice(0, 8)} but this branch pins ${sha.slice(0, 8)}, leaving it because it is on a branch or has local changes`, + ); + return; + } + + if (!hasCommit(["-C", target], sha)) { + log("Submodule", `could not fetch ${path} at ${sha.slice(0, 8)}`); + return; + } + + git(["checkout", "--detach", sha], target); + log("Submodule", `updated ${path} to ${sha.slice(0, 8)}`); +} + /** * @param {string} path */ function setupSubmodule(path) { const target = join(ROOT, path); - if (existsSync(join(target, ".git"))) return; + const sha = git(["ls-tree", "HEAD", path]).split(/\s+/)[2]; + if (!sha) return; + + if (existsSync(join(target, ".git"))) return updateSubmodule(path, sha); const moduleDir = join(COMMON_DIR, "modules", path); @@ -149,12 +216,9 @@ function setupSubmodule(path) { return; } - const sha = git(["ls-tree", "HEAD", path]).split(/\s+/)[2]; - - try { - git(["--git-dir", moduleDir, "cat-file", "-e", `${sha}^{commit}`]); - } catch { - run("git", ["--git-dir", moduleDir, "fetch", "origin"]); + if (!hasCommit(["--git-dir", moduleDir], sha)) { + log("Submodule", `could not fetch ${path}, continuing without it`); + return; } // Worktrees that were deleted without `git worktree remove` leave stale entries behind From c0a651a2584b7f9697e8f3248bc7b6f6a1b06549 Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 6 Oct 2026 13:31:58 -0400 Subject: [PATCH 08/45] fix(turbo): track root tool configs in task hashes Root config files outside a package are not task inputs by default, so editing them neither invalidated cached results nor marked packages as affected. - lint, lint:ci: include .oxlintrc.jsonc. A PR that only changed lint rules ran 0 lint tasks with --affected in CI, so new violations could merge unchecked - skin-renderer build: include the root Cargo.toml (workspace and release profile), so profile changes rebuild the native binary - fmt: disable caching. The formatter rewrites the files it reads, and a cache hit after a .oxfmtrc.jsonc change skipped formatting --- packages/skin-renderer/turbo.json | 1 + turbo.json | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/skin-renderer/turbo.json b/packages/skin-renderer/turbo.json index a80250239..ed2ea5fe8 100644 --- a/packages/skin-renderer/turbo.json +++ b/packages/skin-renderer/turbo.json @@ -3,6 +3,7 @@ "extends": ["//"], "tasks": { "build": { + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/Cargo.toml"], "outputs": ["pkg/**", "*.node"] } } diff --git a/turbo.json b/turbo.json index 51b9db3eb..40adce518 100644 --- a/turbo.json +++ b/turbo.json @@ -24,15 +24,17 @@ "dependsOn": ["build", "^test:coverage"] }, "lint": { + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.oxlintrc.jsonc"], "outputs": [], "dependsOn": [] }, "lint:ci": { + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.oxlintrc.jsonc"], "outputs": [], "dependsOn": [] }, "fmt": { - "outputs": [], + "cache": false, "dependsOn": [] } } From 31ed0294d83a1dba5eb6cdaec483709703888ffc Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 6 Oct 2026 13:41:44 -0400 Subject: [PATCH 09/45] fix(scripts): normalize repo root path in setup-worktree git rev-parse --show-toplevel prints forward slashes on Windows while the common dir goes through path.resolve, so the main checkout never matched its own root and was treated as a linked worktree. --- apps/scripts/src/setup-worktree.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/scripts/src/setup-worktree.js b/apps/scripts/src/setup-worktree.js index 216505d35..afc20d05c 100644 --- a/apps/scripts/src/setup-worktree.js +++ b/apps/scripts/src/setup-worktree.js @@ -36,9 +36,12 @@ import { import { dirname, join, relative, resolve } from "node:path"; import { execFileSync } from "node:child_process"; -const ROOT = git( - ["rev-parse", "--show-toplevel"], - resolve(import.meta.dirname, "../../.."), +// git prints forward slashes on Windows, resolve normalizes them to match COMMON_DIR +const ROOT = resolve( + git( + ["rev-parse", "--show-toplevel"], + resolve(import.meta.dirname, "../../.."), + ), ); const COMMON_DIR = resolve(ROOT, git(["rev-parse", "--git-common-dir"], ROOT)); const MAIN_ROOT = dirname(COMMON_DIR); From f5f0a3c0ce03cbea85ce36b36e82c271a0c1a8e4 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:40:21 -0400 Subject: [PATCH 10/45] chore(t3): add t3.json for parallel worktree threads - start threads in a worktree by default - skip T3's own submodule init (worktreeSubmodules: none), which would clone both asset repos over the network in every worktree; setup-worktree checks them out from the main checkout's submodule repos instead - run setup-worktree when a worktree is created, and make the agent wait for it so it never starts in a checkout without deps or builds; node is called directly because pnpm deps don't exist yet - add a Verify script: build, typecheck, lint, test --- t3.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 t3.json diff --git a/t3.json b/t3.json new file mode 100644 index 000000000..a93b1ef91 --- /dev/null +++ b/t3.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://t3.codes/schema/t3.json", + "defaultThreadEnvMode": "worktree", + "worktreeSubmodules": "none", + "scripts": [ + { + "name": "Setup worktree", + "command": "node apps/scripts/src/setup-worktree.js", + "icon": "configure", + "runOnWorktreeCreate": true, + "async": false + }, + { + "name": "Verify", + "command": "pnpm build && pnpm typecheck && pnpm lint && pnpm test --run", + "icon": "test" + } + ] +} From 527bb49a602e938771ca6fa56287b0c3c3c51273 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:42:19 -0400 Subject: [PATCH 11/45] fix(build): only remove orphaned dist files in the clean task dist was cleaned twice: each swc build ran --delete-dir-on-start, and the turbo clean task deleted the whole folder before build. Keep the clean task, since only it also runs before a cache restore, and drop --delete-dir-on-start. clean now deletes only files whose source in src no longer exists, instead of the whole folder. A full wipe had no dependencies, so under turbo watch every affected package's dist was emptied immediately and stayed empty until its dependency chain rebuilt: apps/api/dist/index.js was missing for ~8s after a one-line change in util. With pruning, no dist entry file goes missing during watch rebuilds, and orphans are still removed before both real builds and cache restores. --- apps/api/package.json | 4 +- apps/discord-bot/package.json | 4 +- apps/scripts/src/clean-dist.js | 75 ++++++++++++++++++++++++++++++++ apps/support-bot/package.json | 4 +- apps/verify-server/package.json | 4 +- packages/api-client/package.json | 4 +- packages/assets/package.json | 4 +- packages/discord/package.json | 4 +- packages/logger/package.json | 4 +- packages/math/package.json | 4 +- packages/rendering/package.json | 4 +- packages/schemas/package.json | 4 +- packages/util/package.json | 4 +- 13 files changed, 99 insertions(+), 24 deletions(-) create mode 100644 apps/scripts/src/clean-dist.js diff --git a/apps/api/package.json b/apps/api/package.json index 6204849d2..fc3ee3492 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,8 +6,8 @@ "type": "module", "scripts": { "start": "node --enable-source-maps .", - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", - "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "clean": "node ../../apps/scripts/src/clean-dist.js", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index 5affdfdba..4e1e09374 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -6,8 +6,8 @@ "type": "module", "scripts": { "start": "node --enable-source-maps .", - "build": "swc src --out-dir dist --strip-leading-paths --delete-dir-on-start", - "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", + "build": "swc src --out-dir dist --strip-leading-paths", + "clean": "node ../../apps/scripts/src/clean-dist.js", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/apps/scripts/src/clean-dist.js b/apps/scripts/src/clean-dist.js new file mode 100644 index 000000000..87f834dd6 --- /dev/null +++ b/apps/scripts/src/clean-dist.js @@ -0,0 +1,75 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +/** + * Removes files from `dist` whose source file in `src` no longer exists. + * + * Runs in a package directory as its turbo `clean` task, which `build` depends on, so it + * runs before both a real build and a cache restore. A cache restore writes outputs on + * top of `dist` without deleting anything, so this is what keeps deleted or renamed + * sources from leaving orphaned files behind. + * + * Only orphans are removed, so `dist` is never empty while `pnpm build:watch` rebuilds + * and running apps keep their files. + * + * Usage: node ../../apps/scripts/src/clean-dist.js + */ + +import { existsSync, readdirSync, rmSync, rmdirSync } from "node:fs"; +import { join } from "node:path"; + +const DIST = "dist"; +const SRC = "src"; + +// swc compiles each of these to a .js file with the same base name +const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx"]; + +/** + * @param {string} file path relative to dist + * @returns {boolean} + */ +function hasSource(file) { + const base = file.replace(/\.js(\.map)?$/, ""); + + // Files that swc copies as is with --copy-files + if (base === file) return existsSync(join(SRC, file)); + + return SOURCE_EXTENSIONS.some( + (extension) => + !base.endsWith(".d") && existsSync(join(SRC, `${base}${extension}`)), + ); +} + +/** + * @param {string} dir path relative to dist + * @returns {number} the number of orphaned files removed + */ +function prune(dir) { + let removed = 0; + + for (const entry of readdirSync(join(DIST, dir), { withFileTypes: true })) { + const path = join(dir, entry.name); + + if (entry.isDirectory()) { + removed += prune(path); + } else if (!hasSource(path)) { + rmSync(join(DIST, path), { force: true }); + removed++; + } + } + + if (dir && readdirSync(join(DIST, dir)).length === 0) + rmdirSync(join(DIST, dir)); + + return removed; +} + +if (existsSync(DIST)) { + const removed = prune(""); + if (removed > 0) console.log(`Removed ${removed} orphaned files from dist`); +} diff --git a/apps/support-bot/package.json b/apps/support-bot/package.json index edd880881..6ab9d2b24 100644 --- a/apps/support-bot/package.json +++ b/apps/support-bot/package.json @@ -6,8 +6,8 @@ "type": "module", "scripts": { "start": "node --enable-source-maps .", - "build": "swc src --out-dir dist --strip-leading-paths --delete-dir-on-start", - "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", + "build": "swc src --out-dir dist --strip-leading-paths", + "clean": "node ../../apps/scripts/src/clean-dist.js", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/apps/verify-server/package.json b/apps/verify-server/package.json index 812957502..b1c9044e7 100644 --- a/apps/verify-server/package.json +++ b/apps/verify-server/package.json @@ -6,8 +6,8 @@ "type": "module", "scripts": { "start": "node --enable-source-maps .", - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", - "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "clean": "node ../../apps/scripts/src/clean-dist.js", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 83d72c148..c91e9ac0b 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -5,8 +5,8 @@ "types": "src/index.ts", "type": "module", "scripts": { - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", - "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "clean": "node ../../apps/scripts/src/clean-dist.js", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/assets/package.json b/packages/assets/package.json index ac42db4d9..06664f090 100644 --- a/packages/assets/package.json +++ b/packages/assets/package.json @@ -5,8 +5,8 @@ "types": "src/index.ts", "type": "module", "scripts": { - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", - "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "clean": "node ../../apps/scripts/src/clean-dist.js", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/discord/package.json b/packages/discord/package.json index 8db1f8a7d..d4546a9bb 100644 --- a/packages/discord/package.json +++ b/packages/discord/package.json @@ -5,8 +5,8 @@ "main": "dist/index.js", "type": "module", "scripts": { - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", - "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "clean": "node ../../apps/scripts/src/clean-dist.js", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/logger/package.json b/packages/logger/package.json index 0eec02774..865f428db 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -5,8 +5,8 @@ "types": "src/index.ts", "type": "module", "scripts": { - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", - "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "clean": "node ../../apps/scripts/src/clean-dist.js", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/math/package.json b/packages/math/package.json index e9d889993..321b2e963 100644 --- a/packages/math/package.json +++ b/packages/math/package.json @@ -5,8 +5,8 @@ "types": "src/index.ts", "type": "module", "scripts": { - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", - "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "clean": "node ../../apps/scripts/src/clean-dist.js", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/rendering/package.json b/packages/rendering/package.json index 3ceba0062..6cc5c5e96 100644 --- a/packages/rendering/package.json +++ b/packages/rendering/package.json @@ -14,8 +14,8 @@ } }, "scripts": { - "build": "swc src --out-dir dist --strip-leading-paths --delete-dir-on-start", - "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", + "build": "swc src --out-dir dist --strip-leading-paths", + "clean": "node ../../apps/scripts/src/clean-dist.js", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 00feb1a55..274933d47 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -5,8 +5,8 @@ "main": "dist/index.js", "type": "module", "scripts": { - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start --copy-files", - "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --copy-files", + "clean": "node ../../apps/scripts/src/clean-dist.js", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/util/package.json b/packages/util/package.json index 42c73b8a4..6c0f6ab45 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -5,8 +5,8 @@ "main": "dist/index.js", "types": "src/index.ts", "scripts": { - "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --delete-dir-on-start", - "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", + "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", + "clean": "node ../../apps/scripts/src/clean-dist.js", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github", From ae1c55441c6671433b831f7c1e26982405ce3167 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:51:41 -0400 Subject: [PATCH 12/45] fix(scripts): make setup-worktree safe to run in many worktrees at once - blur cache: copy new entries to .tmp- and rename them into place instead of deleting the whole cache folder and copying into it. A setup whose key is already stored drops its copy. After storing, evict only the entries listed before the rename, skipping .tmp- folders, so concurrent setups with different keys can't delete each other's entries and the newest entry always survives - check a restore against the backgrounds folder and regenerate when it is incomplete, since an entry can be evicted while it is being copied - retry a submodule fetch once and recheck the commit, since concurrent fetches of the shared submodule repo can fail to lock refs - make worktree prune best-effort; git locks worktrees while adding them, so it can't remove one another setup is creating - export the cache functions and only run setup when executed directly - document the steps, concurrency, and node usage in the header --- apps/scripts/src/setup-worktree.js | 172 ++++++++++++++++++++++------- 1 file changed, 131 insertions(+), 41 deletions(-) diff --git a/apps/scripts/src/setup-worktree.js b/apps/scripts/src/setup-worktree.js index afc20d05c..8ee05c915 100644 --- a/apps/scripts/src/setup-worktree.js +++ b/apps/scripts/src/setup-worktree.js @@ -8,19 +8,26 @@ /** * Prepares a checkout (the main clone or a linked git worktree) for development. + * T3 Code runs it when it creates a worktree (see t3.json). * * Only uses node builtins because it runs before `pnpm install`. * * 1. Links `config.json` / `config.js` from the main checkout * 2. Checks out each asset submodule at the commit this branch pins, as a git - * worktree of the main checkout's submodule repo (no network, no re-clone) - * and moves existing clean, detached checkouts to the pinned commit + * worktree of the main checkout's submodule repo (no re-clone), and moves + * existing clean, detached checkouts to the pinned commit * 3. Links the ignored minecraft texture pack from the main checkout * 4. Installs dependencies (this also blurs the public backgrounds) - * 5. Restores the blurred private backgrounds from a cache, or generates them - * 6. Builds the monorepo + * 5. Restores the blurred private backgrounds from a cache shared by all + * checkouts, or generates and caches them + * 6. Builds the monorepo. turbo stores the cache of every linked worktree in the + * main checkout's .turbo/cache, so this is mostly cache hits * - * Usage: pnpm scripts setup-worktree [--skip-build] + * Safe to run in many new worktrees at once: cache entries are written to a + * temporary folder and renamed into place, restores are checked against the + * backgrounds in git, and fetches that lose a lock race are retried. + * + * Usage: node apps/scripts/src/setup-worktree.js [--skip-build] */ import { @@ -29,6 +36,7 @@ import { existsSync, mkdirSync, readdirSync, + renameSync, rmSync, rmdirSync, symlinkSync, @@ -46,7 +54,11 @@ const ROOT = resolve( const COMMON_DIR = resolve(ROOT, git(["rev-parse", "--git-common-dir"], ROOT)); const MAIN_ROOT = dirname(COMMON_DIR); const IS_LINKED_WORKTREE = MAIN_ROOT !== ROOT; -const CACHE_DIR = join(COMMON_DIR, "statsify-cache"); +const BLUR_CACHE_DIR = join( + COMMON_DIR, + "statsify-cache", + "blurred-backgrounds", +); const TEXTURE_PACK = "assets/public/minecraft-textures/default"; const PRIVATE_ASSETS = "assets/private"; @@ -152,13 +164,19 @@ function hasCommit(repo, sha) { if (exists()) return true; - try { - run("git", [...repo, "fetch", "origin"]); - } catch { - return false; + // Concurrent setups fetching the same submodule repo can fail to lock refs, + // so check whether the other fetch brought the commit in and retry once + for (let attempt = 0; attempt < 2; attempt++) { + try { + run("git", [...repo, "fetch", "origin"]); + } catch { + // Checked below + } + + if (exists()) return true; } - return exists(); + return false; } /** @@ -224,8 +242,14 @@ function setupSubmodule(path) { return; } - // Worktrees that were deleted without `git worktree remove` leave stale entries behind - git(["--git-dir", moduleDir, "worktree", "prune"]); + // Worktrees that were deleted without `git worktree remove` leave stale entries + // behind. git locks worktrees while it adds them, so this can't remove one that a + // concurrent setup is still creating + try { + git(["--git-dir", moduleDir, "worktree", "prune"]); + } catch { + // Only housekeeping + } if (existsSync(target) && readdirSync(target).length === 0) rmdirSync(target); git(["--git-dir", moduleDir, "worktree", "add", "--detach", target, sha]); @@ -241,6 +265,74 @@ function linkTexturePack() { log("Textures", `missing, add a 1.8.9 texture pack to ${TEXTURE_PACK}`); } +/** + * Copies a cache entry to `output` and checks that every background made it, since + * another setup can evict the entry while it is being copied. + * @param {string} entry + * @param {string} output + * @param {string[]} backgrounds file names in the backgrounds folder + * @returns {boolean} whether the restore is complete + */ +export function restoreBlurCache(entry, output, backgrounds) { + try { + rmSync(output, { recursive: true, force: true }); + // Copy on write where the filesystem supports it, so restoring costs no extra disk space + cpSync(entry, output, { + recursive: true, + mode: constants.COPYFILE_FICLONE, + }); + } catch { + return false; + } + + const restored = new Set(readdirSync(output)); + return backgrounds.every((background) => restored.has(background)); +} + +/** + * Stores `output` as the cache entry for `key` and evicts the entries that were there + * before it. The entry is copied to a temporary folder and renamed into place, so + * concurrent setups never see a partial entry. + * + * Only entries listed before the rename are evicted. Two setups can't evict each other, + * since each would have to list the cache after the other's rename, so the most recently + * stored entry always survives. A setup that finds its key already stored evicts nothing. + * @param {string} cacheDir + * @param {string} key + * @param {string} output + * @returns {boolean} whether this call stored the entry + */ +export function storeBlurCache(cacheDir, key, output) { + const entry = join(cacheDir, key); + const temp = `${entry}.tmp-${process.pid}`; + + mkdirSync(cacheDir, { recursive: true }); + cpSync(output, temp, { recursive: true, mode: constants.COPYFILE_FICLONE }); + + const previous = readdirSync(cacheDir); + + try { + renameSync(temp, entry); + } catch { + // Another setup already stored this key + rmSync(temp, { recursive: true, force: true }); + return false; + } + + for (const name of previous) { + // Temporary folders belong to setups that are still copying + if (name === key || name.includes(".tmp-")) continue; + + try { + rmSync(join(cacheDir, name), { recursive: true, force: true }); + } catch { + // Another setup is evicting it too + } + } + + return true; +} + /** * The blur output only depends on the backgrounds and the blur script, so it is cached * by their git object ids. Uncommitted changes to either always regenerate. @@ -258,45 +350,43 @@ function blurPrivateBackgrounds() { ? undefined : `${git(["rev-parse", "HEAD:backgrounds"], assets)}-${git(["rev-parse", "HEAD:blur.mjs"], assets)}`; - const cached = key && join(CACHE_DIR, "blurred-backgrounds", key); + const cached = key && join(BLUR_CACHE_DIR, key); if (cached && existsSync(cached)) { - rmSync(output, { recursive: true, force: true }); - // Copy on write where the filesystem supports it, so restoring costs no extra disk space - cpSync(cached, output, { - recursive: true, - mode: constants.COPYFILE_FICLONE, - }); - log("Backgrounds", `restored from cache ${relative(MAIN_ROOT, cached)}`); - return; + // blur.mjs writes one output per file in the backgrounds folder, with the same name + const backgrounds = readdirSync(join(assets, "backgrounds")); + + if (restoreBlurCache(cached, output, backgrounds)) { + log("Backgrounds", `restored from cache ${relative(MAIN_ROOT, cached)}`); + return; + } + + log("Backgrounds", "cache entry was evicted while restoring"); } log("Backgrounds", "blurring private backgrounds, this takes a while"); run("pnpm", ["blur"], assets); - if (!cached) return; - - rmSync(join(CACHE_DIR, "blurred-backgrounds"), { - recursive: true, - force: true, - }); - cpSync(output, cached, { recursive: true, mode: constants.COPYFILE_FICLONE }); - log("Backgrounds", "cached for future checkouts"); + if (key && storeBlurCache(BLUR_CACHE_DIR, key, output)) + log("Backgrounds", "cached for future checkouts"); } -log( - "Setup", - `${ROOT}${IS_LINKED_WORKTREE ? ` (worktree of ${MAIN_ROOT})` : ""}`, -); +// The cache functions are exported for testing, so only run setup when executed directly +if (import.meta.main) { + log( + "Setup", + `${ROOT}${IS_LINKED_WORKTREE ? ` (worktree of ${MAIN_ROOT})` : ""}`, + ); -linkConfig(); -for (const path of submodulePaths()) setupSubmodule(path); -linkTexturePack(); + linkConfig(); + for (const path of submodulePaths()) setupSubmodule(path); + linkTexturePack(); -run("pnpm", ["install", "--frozen-lockfile"]); + run("pnpm", ["install", "--frozen-lockfile"]); -blurPrivateBackgrounds(); + blurPrivateBackgrounds(); -if (!process.argv.includes("--skip-build")) run("pnpm", ["build"]); + if (!process.argv.includes("--skip-build")) run("pnpm", ["build"]); -log("Setup", "done"); + log("Setup", "done"); +} From 3037a7cd8988080d520aedf18fc9d0d61cb6d088 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:20:06 -0400 Subject: [PATCH 13/45] fix(scripts): evict blur cache temp folders left by crashed setups Temporary folders were always skipped during eviction, so one left behind by a crashed setup stayed forever. Evict .tmp- folders whose mtime is over an hour old. Copying keeps a live folder's mtime current, so fresh ones are still skipped. --- apps/scripts/src/setup-worktree.js | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/scripts/src/setup-worktree.js b/apps/scripts/src/setup-worktree.js index 8ee05c915..a832325b7 100644 --- a/apps/scripts/src/setup-worktree.js +++ b/apps/scripts/src/setup-worktree.js @@ -39,6 +39,7 @@ import { renameSync, rmSync, rmdirSync, + statSync, symlinkSync, } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; @@ -60,6 +61,8 @@ const BLUR_CACHE_DIR = join( "blurred-backgrounds", ); +const STALE_TEMP_MS = 60 * 60 * 1000; + const TEXTURE_PACK = "assets/public/minecraft-textures/default"; const PRIVATE_ASSETS = "assets/private"; @@ -297,6 +300,7 @@ export function restoreBlurCache(entry, output, backgrounds) { * Only entries listed before the rename are evicted. Two setups can't evict each other, * since each would have to list the cache after the other's rename, so the most recently * stored entry always survives. A setup that finds its key already stored evicts nothing. + * Temporary folders older than an hour are left over from crashed setups and evicted too. * @param {string} cacheDir * @param {string} key * @param {string} output @@ -320,13 +324,22 @@ export function storeBlurCache(cacheDir, key, output) { } for (const name of previous) { - // Temporary folders belong to setups that are still copying - if (name === key || name.includes(".tmp-")) continue; + if (name === key) continue; + + const path = join(cacheDir, name); try { - rmSync(join(cacheDir, name), { recursive: true, force: true }); + // A fresh temporary folder belongs to a setup that is still copying. Copying + // keeps updating its mtime, so an old one was left behind by a crashed setup + if ( + name.includes(".tmp-") && + Date.now() - statSync(path).mtimeMs < STALE_TEMP_MS + ) + continue; + + rmSync(path, { recursive: true, force: true }); } catch { - // Another setup is evicting it too + // Another setup is evicting it too, or it was this setup's temporary folder } } From 7bd98bbe6de2ca1f32a47362bf4a3dfbc85b5a3e Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:21:44 -0400 Subject: [PATCH 14/45] docs: add AGENTS.md and CLAUDE.md for parallel agent threads AGENTS.md covers repo conventions, worktree setup, how to verify changes, what agents must not do in worktrees, branch and thread rules for T3, and the main-checkout-only run steps. CLAUDE.md imports it so Claude Code reads the same file. --- AGENTS.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 94 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..acc8e8720 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# Statsify + +You are working in the Statsify monorepo. Make conservative, repo-native cleanup changes only in the files relevant to the current feature. + +## Working in this repo + +### Before editing + +- Read the existing command, schema, and nearby gamemode examples first. +- Prefer established Statsify patterns over new abstractions. +- Look at comparable commands/schemas such as BedWars and SkyWars (`apps/discord-bot/src/commands/bedwars`, `packages/schemas/src/player/gamemodes/bedwars`) when unsure and necessary. +- Keep changes tightly scoped to the requested command/schema. + +### Code style + +- Use existing Statsify utilities instead of local helpers when available. +- Use `@statsify/math` helpers such as `add`, `sub`, and `ratio` instead of hand-rolled reduce/sum/ratio logic. +- Use `@statsify/util` helpers such as `prettify`, `arrayGroup`, and other existing utilities instead of duplicating formatting/grouping logic. +- Prefer exported static registries/constants from schemas (e.g. `BEDWARS_MODES`) over duplicating lists in commands. +- Use typed constants like `const ITEMS = [...] as const` where they define a finite domain. +- Avoid extra files/abstractions unless they clearly reduce real complexity or match an existing local pattern. +- Keep logic close to the feature file when it is feature-specific. + +### Schema / Mongo storage + +- Do not store static or derivable data in Mongo. +- Store only player-specific values: counts, booleans, active selections, levels, weights, timestamps, etc. +- Do not store IDs, names, requirements, descriptions, colors, unlock text, or other non-unique metadata if they can be derived from static schema registries. +- Use schema registries to zip static metadata with compact stored arrays in the command/profile layer. +- If display names can be derived from IDs, use `prettify()` rather than maintaining ID-to-name maps. +- Keep explicit names only when `prettify()` would produce incorrect user-facing text. + +### Discord command/profile + +- Use the existing pagination patterns where comparable commands already do: `paginate()` and `scrollingPagination()` with the `Page` and `SubPage` types in `packages/discord/src/services/paginate.service.ts`, which provide the dropdowns and subpages. Gamemode commands get this through `BaseHypixelCommand`. +- Keep page definitions declarative when possible. +- Avoid duplicate switch logic; use maps for simple page labels/titles. +- Keep rendering helpers small and local unless shared across multiple commands. +- Use Minecraft color codes consistently and centrally when repeated. +- Do not move feature-specific display constants into generic shared files unless another feature actually needs them. + +### Cleanup priorities + +- Remove duplicated constants and lookup maps. +- Replace manual aggregation with shared helpers and static dimension arrays. +- Flatten hard-to-read boolean chains into named checks or switches. +- Preserve behavior unless the request explicitly asks to change it. +- Do not refactor unrelated code. + +## Setup + +T3 runs `node apps/scripts/src/setup-worktree.js` when it creates a worktree (see `t3.json`). It links `config.js`/`config.json` and the texture pack from the main checkout, checks out `assets/public` and `assets/private` at the commits the branch pins, installs dependencies, restores the blurred backgrounds from a shared cache, and builds. + +- turbo (2.9+) shares its build cache across worktrees on its own, so builds in a new worktree are mostly cache hits. +- If a worktree looks broken (missing deps, assets, config, or `dist`), rerun `node apps/scripts/src/setup-worktree.js`. It is safe to rerun and to run in many worktrees at once. +- T3 passes `--force` when an agent removes or settles a thread's worktree, so worktrees with submodules are removed. T3's background storage cleanup does not, so it skips them; remove those manually with `git worktree remove --force `. + +## Verifying changes + +- Run focused checks for each changed package: `pnpm --filter typecheck` and `pnpm --filter lint` (e.g. `api`, `discord-bot`, `@statsify/schemas`). +- Packages import each other through `dist`, so run `pnpm build` before any tests. turbo makes this fast. +- Tests never load `config.js` (`config()` returns defaults when `VITEST` is set), so `pnpm test --run` is safe. Coverage is thin (in-source tests plus `packages/rendering/tests`), so a clean build plus typecheck is the main signal. +- The T3 "Verify" script runs everything: `pnpm build && pnpm typecheck && pnpm lint && pnpm test --run`. +- Report exactly what changed, what passed, and what couldn't be verified. + +## Do not (in worktrees) + +- Start the API, Discord bot, support bot, verify server, or site. Every worktree shares one `config.js`, so they would share the Discord token, the Hypixel key, and ports, and the bot would re-post slash commands to the testing guild. +- Call the Hypixel API, or run anything in `apps/scripts` except `setup-worktree`. +- Run `docker compose`. +- Edit `config.js` or anything under `assets/`. +- Edit locales other than `locales/en-US`. +- Add or upgrade dependencies unless asked. + +## Branches and threads + +- Always pass `workspaceStrategy: { type: "worktree", baseRef: , branch: , startFromOrigin: false }` to `t3_thread_launch`. Without it, the thread runs in the main checkout. +- Commit on the integration branch before launching threads. New worktrees only contain committed work. +- Branch names: `feat/`, `fix/`, or `chore/` plus a short kebab-case description (e.g. `feat/posthog-events`). +- Split work by folder. Keep `apps/api/src/hypixel`, `apps/api/src/redis`, `apps/api/src/leaderboards`, and `packages/schemas/src/metadata` to a single thread. +- For conflicts in `locales/en-US/default.json`, keep both sides' keys. For `pnpm-lock.yaml` conflicts, rerun `pnpm install`. +- Run the full verify after merging thread branches, before settling threads. + +## Running locally (main checkout only) + +Done by the maintainer in the main checkout. Agents in worktrees must not do these steps. + +Need: `config.js` or `config.json` at the repo root, following `config.schema.js`. The loader checks `config.json` first. + +- Required: `database.mongoUri` and `database.redisUrl`, `hypixelApi.key`, `api.port` and `api.mediaRoot`, `discordBot.token`, `discordBot.applicationId` and `discordBot.publicKey`, `apiClient.key` and `apiClient.route` (the bot calls your local API), `supportBot.guild` and `supportBot.memberRole` (the bot's verify commands read them at startup), and `environment`. +- Optional: `hypixelApi.timeout` (defaults to 5000), `discordBot.testingGuild` (lets commands register instantly), `discordBot.port`, `api.ignoreAuth`, and Sentry. With a Sentry DSN set, `sentry.tracesSampleRate` is required too. + +Run it: start MongoDB and Redis (`compose.dev.yml`), then `pnpm build:watch`, then `pnpm api start` and `pnpm discord-bot start`. Apps resolve assets through `../../assets`, so always start them with `pnpm `. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 9e253952e6227b70b9223a242f9baadd01faa605 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:36:18 -0400 Subject: [PATCH 15/45] refactor(scripts): convert setup-worktree to TypeScript Node 24 strips types natively, so the script still runs with plain node before pnpm install and needs no build step. It only uses erasable syntax. JSDoc types become annotations, and t3.json, the scripts package and AGENTS.md point at the .ts file. clean-dist stays JavaScript: it runs in every build, including deploys on the self-hosted runner, whose node version isn't pinned. --- AGENTS.md | 4 +- apps/scripts/package.json | 2 +- .../{setup-worktree.js => setup-worktree.ts} | 75 +++++++------------ t3.json | 2 +- 4 files changed, 32 insertions(+), 51 deletions(-) rename apps/scripts/src/{setup-worktree.js => setup-worktree.ts} (88%) diff --git a/AGENTS.md b/AGENTS.md index acc8e8720..c6c630156 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,10 +49,10 @@ You are working in the Statsify monorepo. Make conservative, repo-native cleanup ## Setup -T3 runs `node apps/scripts/src/setup-worktree.js` when it creates a worktree (see `t3.json`). It links `config.js`/`config.json` and the texture pack from the main checkout, checks out `assets/public` and `assets/private` at the commits the branch pins, installs dependencies, restores the blurred backgrounds from a shared cache, and builds. +T3 runs `node apps/scripts/src/setup-worktree.ts` when it creates a worktree (see `t3.json`). It links `config.js`/`config.json` and the texture pack from the main checkout, checks out `assets/public` and `assets/private` at the commits the branch pins, installs dependencies, restores the blurred backgrounds from a shared cache, and builds. - turbo (2.9+) shares its build cache across worktrees on its own, so builds in a new worktree are mostly cache hits. -- If a worktree looks broken (missing deps, assets, config, or `dist`), rerun `node apps/scripts/src/setup-worktree.js`. It is safe to rerun and to run in many worktrees at once. +- If a worktree looks broken (missing deps, assets, config, or `dist`), rerun `node apps/scripts/src/setup-worktree.ts`. It is safe to rerun and to run in many worktrees at once. - T3 passes `--force` when an agent removes or settles a thread's worktree, so worktrees with submodules are removed. T3's background storage cleanup does not, so it skips them; remove those manually with `git worktree remove --force `. ## Verifying changes diff --git a/apps/scripts/package.json b/apps/scripts/package.json index a5f3b72ef..99fc5c83c 100644 --- a/apps/scripts/package.json +++ b/apps/scripts/package.json @@ -10,7 +10,7 @@ "delete-sessions": "node src/delete-sessions.js", "limit-redis": "node src/limit-redis.js", "purge": "node src/purge.js", - "setup-worktree": "node src/setup-worktree.js", + "setup-worktree": "node src/setup-worktree.ts", "rank-emojis": "node src/rank-emojis.js", "timestamp": "node --no-warnings src/timestamp.js", "validate-commands": "node --no-warnings src/validate-commands.js" diff --git a/apps/scripts/src/setup-worktree.js b/apps/scripts/src/setup-worktree.ts similarity index 88% rename from apps/scripts/src/setup-worktree.js rename to apps/scripts/src/setup-worktree.ts index a832325b7..d72f461a5 100644 --- a/apps/scripts/src/setup-worktree.js +++ b/apps/scripts/src/setup-worktree.ts @@ -10,7 +10,8 @@ * Prepares a checkout (the main clone or a linked git worktree) for development. * T3 Code runs it when it creates a worktree (see t3.json). * - * Only uses node builtins because it runs before `pnpm install`. + * Only uses node builtins because it runs before `pnpm install`. Node runs it as + * TypeScript by stripping the types, so it only uses erasable syntax. * * 1. Links `config.json` / `config.js` from the main checkout * 2. Checks out each asset submodule at the commit this branch pins, as a git @@ -27,7 +28,7 @@ * temporary folder and renamed into place, restores are checked against the * backgrounds in git, and fetches that lose a lock race are retried. * - * Usage: node apps/scripts/src/setup-worktree.js [--skip-build] + * Usage: node apps/scripts/src/setup-worktree.ts [--skip-build] */ import { @@ -66,12 +67,7 @@ const STALE_TEMP_MS = 60 * 60 * 1000; const TEXTURE_PACK = "assets/public/minecraft-textures/default"; const PRIVATE_ASSETS = "assets/private"; -/** - * @param {string[]} args - * @param {string} cwd - * @returns {string} - */ -function git(args, cwd = ROOT) { +function git(args: string[], cwd = ROOT): string { return execFileSync("git", args, { cwd, encoding: "utf8", @@ -79,12 +75,7 @@ function git(args, cwd = ROOT) { }).trim(); } -/** - * @param {string} command - * @param {string[]} args - * @param {string} cwd - */ -function run(command, args, cwd = ROOT) { +function run(command: string, args: string[], cwd = ROOT) { execFileSync(command, args, { cwd, stdio: "inherit", @@ -92,19 +83,15 @@ function run(command, args, cwd = ROOT) { }); } -/** - * @param {string} title - * @param {string} message - */ -function log(title, message) { +function log(title: string, message: string) { console.log(`\n! ${title} ${message}`); } /** * Symlinks a file or directory from the main checkout, copying files when symlinks are not permitted. - * @param {string} path path relative to the repo root + * @param path path relative to the repo root */ -function linkFromMain(path) { +function linkFromMain(path: string): boolean { const source = join(MAIN_ROOT, path); const target = join(ROOT, path); @@ -134,11 +121,8 @@ function linkConfig() { log("Config", "missing, copy config.schema.js to config.js and fill it in"); } -/** - * @returns {string[]} - */ -function submodulePaths() { - let output; +function submodulePaths(): string[] { + let output: string; try { output = git(["config", "--file", ".gitmodules", "--get-regexp", "path"]); @@ -151,11 +135,10 @@ function submodulePaths() { /** * Checks that a submodule repo has a commit, fetching it when it is missing. - * @param {string[]} repo git arguments that select the submodule repo - * @param {string} sha - * @returns {boolean} whether the commit is available + * @param repo git arguments that select the submodule repo + * @returns whether the commit is available */ -function hasCommit(repo, sha) { +function hasCommit(repo: string[], sha: string): boolean { const exists = () => { try { git([...repo, "cat-file", "-e", `${sha}^{commit}`]); @@ -185,10 +168,8 @@ function hasCommit(repo, sha) { /** * Moves an existing submodule checkout to the commit this branch pins, unless it is * on a branch or has local changes. - * @param {string} path - * @param {string} sha */ -function updateSubmodule(path, sha) { +function updateSubmodule(path: string, sha: string) { const target = join(ROOT, path); const head = git(["rev-parse", "HEAD"], target); if (head === sha) return; @@ -218,10 +199,7 @@ function updateSubmodule(path, sha) { log("Submodule", `updated ${path} to ${sha.slice(0, 8)}`); } -/** - * @param {string} path - */ -function setupSubmodule(path) { +function setupSubmodule(path: string) { const target = join(ROOT, path); const sha = git(["ls-tree", "HEAD", path]).split(/\s+/)[2]; if (!sha) return; @@ -271,12 +249,14 @@ function linkTexturePack() { /** * Copies a cache entry to `output` and checks that every background made it, since * another setup can evict the entry while it is being copied. - * @param {string} entry - * @param {string} output - * @param {string[]} backgrounds file names in the backgrounds folder - * @returns {boolean} whether the restore is complete + * @param backgrounds file names in the backgrounds folder + * @returns whether the restore is complete */ -export function restoreBlurCache(entry, output, backgrounds) { +export function restoreBlurCache( + entry: string, + output: string, + backgrounds: string[], +): boolean { try { rmSync(output, { recursive: true, force: true }); // Copy on write where the filesystem supports it, so restoring costs no extra disk space @@ -301,12 +281,13 @@ export function restoreBlurCache(entry, output, backgrounds) { * since each would have to list the cache after the other's rename, so the most recently * stored entry always survives. A setup that finds its key already stored evicts nothing. * Temporary folders older than an hour are left over from crashed setups and evicted too. - * @param {string} cacheDir - * @param {string} key - * @param {string} output - * @returns {boolean} whether this call stored the entry + * @returns whether this call stored the entry */ -export function storeBlurCache(cacheDir, key, output) { +export function storeBlurCache( + cacheDir: string, + key: string, + output: string, +): boolean { const entry = join(cacheDir, key); const temp = `${entry}.tmp-${process.pid}`; diff --git a/t3.json b/t3.json index a93b1ef91..f1a242d35 100644 --- a/t3.json +++ b/t3.json @@ -5,7 +5,7 @@ "scripts": [ { "name": "Setup worktree", - "command": "node apps/scripts/src/setup-worktree.js", + "command": "node apps/scripts/src/setup-worktree.ts", "icon": "configure", "runOnWorktreeCreate": true, "async": false From c130b71881aed40991d3d70babee4a9424313535 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:40:53 -0400 Subject: [PATCH 16/45] refactor(scripts): convert clean-dist to TypeScript The deploy server runs node 24.7.0, which strips types natively, so the clean task can run the .ts file directly like setup-worktree. All 12 swc packages point their clean script at clean-dist.ts. --- apps/api/package.json | 2 +- apps/discord-bot/package.json | 2 +- apps/scripts/src/{clean-dist.js => clean-dist.ts} | 13 ++++++------- apps/support-bot/package.json | 2 +- apps/verify-server/package.json | 2 +- packages/api-client/package.json | 2 +- packages/assets/package.json | 2 +- packages/discord/package.json | 2 +- packages/logger/package.json | 2 +- packages/math/package.json | 2 +- packages/rendering/package.json | 2 +- packages/schemas/package.json | 2 +- packages/util/package.json | 2 +- 13 files changed, 18 insertions(+), 19 deletions(-) rename apps/scripts/src/{clean-dist.js => clean-dist.ts} (87%) diff --git a/apps/api/package.json b/apps/api/package.json index fc3ee3492..44b9b5112 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "node --enable-source-maps .", "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", - "clean": "node ../../apps/scripts/src/clean-dist.js", + "clean": "node ../../apps/scripts/src/clean-dist.ts", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index 4e1e09374..cf20f84d3 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "node --enable-source-maps .", "build": "swc src --out-dir dist --strip-leading-paths", - "clean": "node ../../apps/scripts/src/clean-dist.js", + "clean": "node ../../apps/scripts/src/clean-dist.ts", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/apps/scripts/src/clean-dist.js b/apps/scripts/src/clean-dist.ts similarity index 87% rename from apps/scripts/src/clean-dist.js rename to apps/scripts/src/clean-dist.ts index 87f834dd6..dd36f5509 100644 --- a/apps/scripts/src/clean-dist.js +++ b/apps/scripts/src/clean-dist.ts @@ -17,7 +17,7 @@ * Only orphans are removed, so `dist` is never empty while `pnpm build:watch` rebuilds * and running apps keep their files. * - * Usage: node ../../apps/scripts/src/clean-dist.js + * Usage: node ../../apps/scripts/src/clean-dist.ts */ import { existsSync, readdirSync, rmSync, rmdirSync } from "node:fs"; @@ -30,10 +30,9 @@ const SRC = "src"; const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx"]; /** - * @param {string} file path relative to dist - * @returns {boolean} + * @param file path relative to dist */ -function hasSource(file) { +function hasSource(file: string): boolean { const base = file.replace(/\.js(\.map)?$/, ""); // Files that swc copies as is with --copy-files @@ -46,10 +45,10 @@ function hasSource(file) { } /** - * @param {string} dir path relative to dist - * @returns {number} the number of orphaned files removed + * @param dir path relative to dist + * @returns the number of orphaned files removed */ -function prune(dir) { +function prune(dir: string): number { let removed = 0; for (const entry of readdirSync(join(DIST, dir), { withFileTypes: true })) { diff --git a/apps/support-bot/package.json b/apps/support-bot/package.json index 6ab9d2b24..310ffe2d5 100644 --- a/apps/support-bot/package.json +++ b/apps/support-bot/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "node --enable-source-maps .", "build": "swc src --out-dir dist --strip-leading-paths", - "clean": "node ../../apps/scripts/src/clean-dist.js", + "clean": "node ../../apps/scripts/src/clean-dist.ts", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/apps/verify-server/package.json b/apps/verify-server/package.json index b1c9044e7..c0bf93f70 100644 --- a/apps/verify-server/package.json +++ b/apps/verify-server/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "node --enable-source-maps .", "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", - "clean": "node ../../apps/scripts/src/clean-dist.js", + "clean": "node ../../apps/scripts/src/clean-dist.ts", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/api-client/package.json b/packages/api-client/package.json index c91e9ac0b..1ad319c96 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", - "clean": "node ../../apps/scripts/src/clean-dist.js", + "clean": "node ../../apps/scripts/src/clean-dist.ts", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/assets/package.json b/packages/assets/package.json index 06664f090..89ade1479 100644 --- a/packages/assets/package.json +++ b/packages/assets/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", - "clean": "node ../../apps/scripts/src/clean-dist.js", + "clean": "node ../../apps/scripts/src/clean-dist.ts", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/discord/package.json b/packages/discord/package.json index d4546a9bb..6bf63d8e0 100644 --- a/packages/discord/package.json +++ b/packages/discord/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", - "clean": "node ../../apps/scripts/src/clean-dist.js", + "clean": "node ../../apps/scripts/src/clean-dist.ts", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/logger/package.json b/packages/logger/package.json index 865f428db..48767a5a2 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", - "clean": "node ../../apps/scripts/src/clean-dist.js", + "clean": "node ../../apps/scripts/src/clean-dist.ts", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/math/package.json b/packages/math/package.json index 321b2e963..31e39edb1 100644 --- a/packages/math/package.json +++ b/packages/math/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", - "clean": "node ../../apps/scripts/src/clean-dist.js", + "clean": "node ../../apps/scripts/src/clean-dist.ts", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/rendering/package.json b/packages/rendering/package.json index 6cc5c5e96..d3df8cf3a 100644 --- a/packages/rendering/package.json +++ b/packages/rendering/package.json @@ -15,7 +15,7 @@ }, "scripts": { "build": "swc src --out-dir dist --strip-leading-paths", - "clean": "node ../../apps/scripts/src/clean-dist.js", + "clean": "node ../../apps/scripts/src/clean-dist.ts", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 274933d47..1a7de4608 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths --copy-files", - "clean": "node ../../apps/scripts/src/clean-dist.js", + "clean": "node ../../apps/scripts/src/clean-dist.ts", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github" diff --git a/packages/util/package.json b/packages/util/package.json index 6c0f6ab45..e9e1983fc 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -6,7 +6,7 @@ "types": "src/index.ts", "scripts": { "build": "swc src --config-file ../../.swcrc --out-dir dist --strip-leading-paths", - "clean": "node ../../apps/scripts/src/clean-dist.js", + "clean": "node ../../apps/scripts/src/clean-dist.ts", "typecheck": "tsc --noEmit", "lint": "oxlint", "lint:ci": "oxlint --format=github", From 743f0dcffb2319de9e9a60a18eb04422e5258594 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:21:39 -0400 Subject: [PATCH 17/45] fix(scripts): replace incomplete blur cache entries An entry that was stored incomplete could never be replaced: every restore failed the completeness check, setup re-blurred, and storing failed because the key already existed. - delete an entry that restores incomplete, so it gets replaced - don't cache blur output that is missing backgrounds. blur.mjs exits 0 when a worker crashes, e.g. on an ignored .DS_Store in backgrounds, which left 304 of 380 backgrounds - check completeness against the tracked backgrounds (git ls-tree), the same files the cache key covers, so ignored files don't count --- apps/scripts/src/setup-worktree.ts | 39 ++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index d72f461a5..e6da6b05f 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -246,10 +246,21 @@ function linkTexturePack() { log("Textures", `missing, add a 1.8.9 texture pack to ${TEXTURE_PACK}`); } +/** + * @param backgrounds file names of the tracked backgrounds + * @returns whether `output` has a blurred file for every background + */ +function isComplete(output: string, backgrounds: string[]): boolean { + if (!existsSync(output)) return false; + + const blurred = new Set(readdirSync(output)); + return backgrounds.every((background) => blurred.has(background)); +} + /** * Copies a cache entry to `output` and checks that every background made it, since * another setup can evict the entry while it is being copied. - * @param backgrounds file names in the backgrounds folder + * @param backgrounds file names of the tracked backgrounds * @returns whether the restore is complete */ export function restoreBlurCache( @@ -268,8 +279,7 @@ export function restoreBlurCache( return false; } - const restored = new Set(readdirSync(output)); - return backgrounds.every((background) => restored.has(background)); + return isComplete(output, backgrounds); } /** @@ -346,22 +356,35 @@ function blurPrivateBackgrounds() { const cached = key && join(BLUR_CACHE_DIR, key); - if (cached && existsSync(cached)) { - // blur.mjs writes one output per file in the backgrounds folder, with the same name - const backgrounds = readdirSync(join(assets, "backgrounds")); + // blur.mjs writes one output per background, with the same name. Only tracked + // backgrounds are part of the key, so ignored files like .DS_Store don't count + const backgrounds = key + ? git(["ls-tree", "--name-only", "HEAD:backgrounds"], assets).split("\n") + : []; + if (cached && existsSync(cached)) { if (restoreBlurCache(cached, output, backgrounds)) { log("Backgrounds", `restored from cache ${relative(MAIN_ROOT, cached)}`); return; } - log("Backgrounds", "cache entry was evicted while restoring"); + // Either evicted while it was being copied, or stored incomplete. Delete it, or an + // incomplete entry could never be replaced + log("Backgrounds", "cache entry is incomplete, replacing it"); + rmSync(cached, { recursive: true, force: true }); } log("Backgrounds", "blurring private backgrounds, this takes a while"); run("pnpm", ["blur"], assets); - if (key && storeBlurCache(BLUR_CACHE_DIR, key, output)) + if (!key) return; + + if (!isComplete(output, backgrounds)) { + log("Backgrounds", "blur output is incomplete, not caching it"); + return; + } + + if (storeBlurCache(BLUR_CACHE_DIR, key, output)) log("Backgrounds", "cached for future checkouts"); } From e63de941698eaaebeb44cdc5855876dfc08a1bed Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:22:15 -0400 Subject: [PATCH 18/45] fix(scripts): keep setup running when a submodule can't be checked out When fetching assets/public failed, linking the texture pack still created assets/public/minecraft-textures. On the next run git worktree add refused the non-empty folder, and the uncaught error stopped setup before pnpm install, on every rerun. - only link the texture pack when assets/public is checked out - catch a failed worktree add, log how to recover, and continue like the other submodule failures --- apps/scripts/src/setup-worktree.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index e6da6b05f..a72570dd7 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -65,6 +65,7 @@ const BLUR_CACHE_DIR = join( const STALE_TEMP_MS = 60 * 60 * 1000; const TEXTURE_PACK = "assets/public/minecraft-textures/default"; +const PUBLIC_ASSETS = "assets/public"; const PRIVATE_ASSETS = "assets/private"; function git(args: string[], cwd = ROOT): string { @@ -233,13 +234,30 @@ function setupSubmodule(path: string) { } if (existsSync(target) && readdirSync(target).length === 0) rmdirSync(target); - git(["--git-dir", moduleDir, "worktree", "add", "--detach", target, sha]); + + try { + git(["--git-dir", moduleDir, "worktree", "add", "--detach", target, sha]); + } catch { + log( + "Submodule", + `could not check out ${path}, continuing without it. If ${path} has leftover files, delete it and rerun setup`, + ); + return; + } log("Submodule", `checked out ${path} at ${sha.slice(0, 8)}`); } function linkTexturePack() { - if (IS_LINKED_WORKTREE && linkFromMain(TEXTURE_PACK)) + // Linking into a public assets folder that isn't checked out would create it and + // block checking out the submodule there on the next run + const publicAssets = join(ROOT, PUBLIC_ASSETS, ".git"); + + if ( + IS_LINKED_WORKTREE && + existsSync(publicAssets) && + linkFromMain(TEXTURE_PACK) + ) log("Textures", `linked ${TEXTURE_PACK}`); if (!existsSync(join(ROOT, TEXTURE_PACK))) From 17af31056d9d62fa4b49370fce4f90d68f6bb2fb Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:22:29 -0400 Subject: [PATCH 19/45] fix(scripts): link config files as file symlinks on Windows linkFromMain created every link as a junction. Windows junctions only point at directories, so config.js or config.json became an unusable directory link, and because the call succeeded the copy fallback never ran. Use a junction for directories and a file symlink for files; when Windows refuses a file symlink without Developer Mode or admin rights, the copy fallback runs. --- apps/scripts/src/setup-worktree.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index a72570dd7..920c65bc6 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -89,7 +89,7 @@ function log(title: string, message: string) { } /** - * Symlinks a file or directory from the main checkout, copying files when symlinks are not permitted. + * Symlinks a file or directory from the main checkout, copying it when symlinks are not permitted. * @param path path relative to the repo root */ function linkFromMain(path: string): boolean { @@ -101,7 +101,13 @@ function linkFromMain(path: string): boolean { mkdirSync(dirname(target), { recursive: true }); try { - symlinkSync(source, target, "junction"); + // Windows junctions only work for directories, and need no extra permissions. + // A file symlink needs Developer Mode or admin rights, so it falls back to a copy + symlinkSync( + source, + target, + statSync(source).isDirectory() ? "junction" : "file", + ); } catch { cpSync(source, target, { recursive: true }); } From dd170d0e8a523a7b7a3bfd218c90eb6f48ff9a00 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:22:54 -0400 Subject: [PATCH 20/45] fix(scripts): find submodule repos by name in setup-worktree git stores a submodule's repo under .git/modules/, not its path. They're the same for both asset submodules today, but after a move (git mv keeps the name) setup would fall back to a full network clone, or pick the wrong repo. Read names and paths from .gitmodules. --- apps/scripts/src/setup-worktree.ts | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index 920c65bc6..81957f7eb 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -128,16 +128,32 @@ function linkConfig() { log("Config", "missing, copy config.schema.js to config.js and fill it in"); } -function submodulePaths(): string[] { +interface Submodule { + /** git stores the submodule's repo under .git/modules/ */ + name: string; + path: string; +} + +function submodules(): Submodule[] { let output: string; try { - output = git(["config", "--file", ".gitmodules", "--get-regexp", "path"]); + output = git([ + "config", + "--file", + ".gitmodules", + "--get-regexp", + String.raw`^submodule\..*\.path$`, + ]); } catch { return []; } - return output.split("\n").map((line) => line.split(" ")[1]); + // Lines look like `submodule..path `, and names can contain dots + return output.split("\n").map((line) => { + const [key, path] = line.split(" "); + return { name: key.slice("submodule.".length, -".path".length), path }; + }); } /** @@ -206,14 +222,14 @@ function updateSubmodule(path: string, sha: string) { log("Submodule", `updated ${path} to ${sha.slice(0, 8)}`); } -function setupSubmodule(path: string) { +function setupSubmodule({ name, path }: Submodule) { const target = join(ROOT, path); const sha = git(["ls-tree", "HEAD", path]).split(/\s+/)[2]; if (!sha) return; if (existsSync(join(target, ".git"))) return updateSubmodule(path, sha); - const moduleDir = join(COMMON_DIR, "modules", path); + const moduleDir = join(COMMON_DIR, "modules", name); if (!IS_LINKED_WORKTREE || !existsSync(moduleDir)) { try { @@ -420,7 +436,7 @@ if (import.meta.main) { ); linkConfig(); - for (const path of submodulePaths()) setupSubmodule(path); + for (const submodule of submodules()) setupSubmodule(submodule); linkTexturePack(); run("pnpm", ["install", "--frozen-lockfile"]); From de3c897e1735707ac45076c7f2544391a8d4df07 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:23:16 -0400 Subject: [PATCH 21/45] refactor(scripts): return early for .d.js outputs in clean-dist The .d check didn't depend on the extension but ran inside the SOURCE_EXTENSIONS loop. Return before the loop instead. Same behavior. --- apps/scripts/src/clean-dist.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/scripts/src/clean-dist.ts b/apps/scripts/src/clean-dist.ts index dd36f5509..372194c1d 100644 --- a/apps/scripts/src/clean-dist.ts +++ b/apps/scripts/src/clean-dist.ts @@ -38,9 +38,11 @@ function hasSource(file: string): boolean { // Files that swc copies as is with --copy-files if (base === file) return existsSync(join(SRC, file)); - return SOURCE_EXTENSIONS.some( - (extension) => - !base.endsWith(".d") && existsSync(join(SRC, `${base}${extension}`)), + // swc doesn't compile declaration files, so a .d.js output is always an orphan + if (base.endsWith(".d")) return false; + + return SOURCE_EXTENSIONS.some((extension) => + existsSync(join(SRC, `${base}${extension}`)), ); } From b97873b8987fe75c46fa7d29f5dff111d552803a Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:23:32 -0400 Subject: [PATCH 22/45] refactor(scripts): drop test-only exports from setup-worktree The blur cache functions were exported, and setup guarded behind import.meta.main, only so a throwaway race test could import them. No test uses them, and importing still ran git at module load. --- apps/scripts/src/setup-worktree.ts | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index 81957f7eb..bb4a9def2 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -303,7 +303,7 @@ function isComplete(output: string, backgrounds: string[]): boolean { * @param backgrounds file names of the tracked backgrounds * @returns whether the restore is complete */ -export function restoreBlurCache( +function restoreBlurCache( entry: string, output: string, backgrounds: string[], @@ -333,7 +333,7 @@ export function restoreBlurCache( * Temporary folders older than an hour are left over from crashed setups and evicted too. * @returns whether this call stored the entry */ -export function storeBlurCache( +function storeBlurCache( cacheDir: string, key: string, output: string, @@ -428,22 +428,19 @@ function blurPrivateBackgrounds() { log("Backgrounds", "cached for future checkouts"); } -// The cache functions are exported for testing, so only run setup when executed directly -if (import.meta.main) { - log( - "Setup", - `${ROOT}${IS_LINKED_WORKTREE ? ` (worktree of ${MAIN_ROOT})` : ""}`, - ); +log( + "Setup", + `${ROOT}${IS_LINKED_WORKTREE ? ` (worktree of ${MAIN_ROOT})` : ""}`, +); - linkConfig(); - for (const submodule of submodules()) setupSubmodule(submodule); - linkTexturePack(); +linkConfig(); +for (const submodule of submodules()) setupSubmodule(submodule); +linkTexturePack(); - run("pnpm", ["install", "--frozen-lockfile"]); +run("pnpm", ["install", "--frozen-lockfile"]); - blurPrivateBackgrounds(); +blurPrivateBackgrounds(); - if (!process.argv.includes("--skip-build")) run("pnpm", ["build"]); +if (!process.argv.includes("--skip-build")) run("pnpm", ["build"]); - log("Setup", "done"); -} +log("Setup", "done"); From d42ff79ff0c91bad00d9f25c614793505215e2e6 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:29:19 -0400 Subject: [PATCH 23/45] fix(scripts): include blur dependency versions in the blur cache key The key only covered the backgrounds and blur.mjs, but the output also depends on the installed skia-canvas and stackblur-canvas, which decode, blur, and encode the images. A bump that changed the output kept restoring old backgrounds in every checkout. Add their installed versions to the key, and skip the cache when they can't be read. --- apps/scripts/src/setup-worktree.ts | 40 ++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index bb4a9def2..3ca5e38bf 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -36,6 +36,7 @@ import { cpSync, existsSync, mkdirSync, + readFileSync, readdirSync, renameSync, rmSync, @@ -66,6 +67,8 @@ const STALE_TEMP_MS = 60 * 60 * 1000; const TEXTURE_PACK = "assets/public/minecraft-textures/default"; const PUBLIC_ASSETS = "assets/public"; +// Packages blur.mjs uses to decode, blur, and encode the backgrounds +const BLUR_DEPENDENCIES = ["skia-canvas", "stackblur-canvas"]; const PRIVATE_ASSETS = "assets/private"; function git(args: string[], cwd = ROOT): string { @@ -378,8 +381,26 @@ function storeBlurCache( } /** - * The blur output only depends on the backgrounds and the blur script, so it is cached - * by their git object ids. Uncommitted changes to either always regenerate. + * @returns `@` of a package installed in `dir`, or undefined when it + * isn't installed + */ +function installedVersion(dir: string, name: string): string | undefined { + try { + const manifest = JSON.parse( + readFileSync(join(dir, "node_modules", name, "package.json"), "utf8"), + ) as { version: string }; + + return `${name}@${manifest.version}`; + } catch { + return undefined; + } +} + +/** + * The blur output only depends on the backgrounds, the blur script, and the installed + * versions of the packages that decode, blur, and encode the images. It is cached by + * the git object ids of the first two and those versions. Uncommitted changes to the + * backgrounds or blur script always regenerate. */ function blurPrivateBackgrounds() { const assets = join(ROOT, PRIVATE_ASSETS); @@ -390,9 +411,18 @@ function blurPrivateBackgrounds() { git(["status", "--porcelain", "--", "backgrounds", "blur.mjs"], assets) !== ""; - const key = dirty - ? undefined - : `${git(["rev-parse", "HEAD:backgrounds"], assets)}-${git(["rev-parse", "HEAD:blur.mjs"], assets)}`; + const versions = BLUR_DEPENDENCIES.map((name) => + installedVersion(assets, name), + ); + + const key = + dirty || versions.includes(undefined) + ? undefined + : [ + git(["rev-parse", "HEAD:backgrounds"], assets), + git(["rev-parse", "HEAD:blur.mjs"], assets), + ...versions, + ].join("-"); const cached = key && join(BLUR_CACHE_DIR, key); From e89ae7c098a5eb77362d16b5a1199a88dbccc7b9 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:10:15 -0400 Subject: [PATCH 24/45] fix(scripts): keep setup running when a submodule update or cache write fails Two steps could still stop setup before install and build: - moving an existing submodule checkout to the pinned commit; a broken .git link failed every rerun at the same spot. Log how to recover and continue with the checkout as is - writing the optional blur cache; a full disk or unwritable .git stopped setup after a successful blur. Log it and continue --- apps/scripts/src/setup-worktree.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index 3ca5e38bf..1b2b6ff28 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -230,7 +230,18 @@ function setupSubmodule({ name, path }: Submodule) { const sha = git(["ls-tree", "HEAD", path]).split(/\s+/)[2]; if (!sha) return; - if (existsSync(join(target, ".git"))) return updateSubmodule(path, sha); + if (existsSync(join(target, ".git"))) { + try { + updateSubmodule(path, sha); + } catch { + log( + "Submodule", + `could not update ${path}, continuing with it as is. If it is broken in a worktree, delete ${path} and rerun setup`, + ); + } + + return; + } const moduleDir = join(COMMON_DIR, "modules", name); @@ -343,11 +354,19 @@ function storeBlurCache( ): boolean { const entry = join(cacheDir, key); const temp = `${entry}.tmp-${process.pid}`; + let previous: string[]; - mkdirSync(cacheDir, { recursive: true }); - cpSync(output, temp, { recursive: true, mode: constants.COPYFILE_FICLONE }); - - const previous = readdirSync(cacheDir); + // The cache is optional, so failing to write it (a full disk, an unwritable .git) + // must not stop setup + try { + mkdirSync(cacheDir, { recursive: true }); + cpSync(output, temp, { recursive: true, mode: constants.COPYFILE_FICLONE }); + previous = readdirSync(cacheDir); + } catch { + log("Backgrounds", "could not write the blur cache, continuing without it"); + rmSync(temp, { recursive: true, force: true }); + return false; + } try { renameSync(temp, entry); From 15f863a00f746c794d28e028022811c05cd81daf Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:10:32 -0400 Subject: [PATCH 25/45] fix(scripts): only link config.js into worktrees The config loader tries config.json first but loads it with a plain import(), which node 24 rejects for JSON (ERR_IMPORT_ATTRIBUTE_MISSING), so a config.json breaks every app. Stop linking it into worktrees, and make AGENTS.md say to use config.js. --- AGENTS.md | 4 ++-- apps/scripts/src/setup-worktree.ts | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c6c630156..eba3036f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ You are working in the Statsify monorepo. Make conservative, repo-native cleanup ## Setup -T3 runs `node apps/scripts/src/setup-worktree.ts` when it creates a worktree (see `t3.json`). It links `config.js`/`config.json` and the texture pack from the main checkout, checks out `assets/public` and `assets/private` at the commits the branch pins, installs dependencies, restores the blurred backgrounds from a shared cache, and builds. +T3 runs `node apps/scripts/src/setup-worktree.ts` when it creates a worktree (see `t3.json`). It links `config.js` and the texture pack from the main checkout, checks out `assets/public` and `assets/private` at the commits the branch pins, installs dependencies, restores the blurred backgrounds from a shared cache, and builds. - turbo (2.9+) shares its build cache across worktrees on its own, so builds in a new worktree are mostly cache hits. - If a worktree looks broken (missing deps, assets, config, or `dist`), rerun `node apps/scripts/src/setup-worktree.ts`. It is safe to rerun and to run in many worktrees at once. @@ -85,7 +85,7 @@ T3 runs `node apps/scripts/src/setup-worktree.ts` when it creates a worktree (se Done by the maintainer in the main checkout. Agents in worktrees must not do these steps. -Need: `config.js` or `config.json` at the repo root, following `config.schema.js`. The loader checks `config.json` first. +Need: `config.js` at the repo root, following `config.schema.js`. Use `config.js`, not `config.json`: the loader tries `config.json` first but node rejects importing it, so a `config.json` breaks every app. - Required: `database.mongoUri` and `database.redisUrl`, `hypixelApi.key`, `api.port` and `api.mediaRoot`, `discordBot.token`, `discordBot.applicationId` and `discordBot.publicKey`, `apiClient.key` and `apiClient.route` (the bot calls your local API), `supportBot.guild` and `supportBot.memberRole` (the bot's verify commands read them at startup), and `environment`. - Optional: `hypixelApi.timeout` (defaults to 5000), `discordBot.testingGuild` (lets commands register instantly), `discordBot.port`, `api.ignoreAuth`, and Sentry. With a Sentry DSN set, `sentry.tracesSampleRate` is required too. diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index 1b2b6ff28..331c7a22f 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -13,7 +13,7 @@ * Only uses node builtins because it runs before `pnpm install`. Node runs it as * TypeScript by stripping the types, so it only uses erasable syntax. * - * 1. Links `config.json` / `config.js` from the main checkout + * 1. Links `config.js` from the main checkout * 2. Checks out each asset submodule at the commit this branch pins, as a git * worktree of the main checkout's submodule repo (no re-clone), and moves * existing clean, detached checkouts to the pinned commit @@ -118,16 +118,16 @@ function linkFromMain(path: string): boolean { return true; } +/** + * Only config.js is linked. The loader tries config.json first, but loads it with a + * plain import(), which node rejects for JSON files. + */ function linkConfig() { if (!IS_LINKED_WORKTREE) return; - const linked = ["config.json", "config.js"].filter(linkFromMain); - - if (linked.length > 0) - log("Config", `linked ${linked.join(", ")} from ${MAIN_ROOT}`); - else if ( - !["config.json", "config.js"].some((file) => existsSync(join(ROOT, file))) - ) + if (linkFromMain("config.js")) + log("Config", `linked config.js from ${MAIN_ROOT}`); + else if (!existsSync(join(ROOT, "config.js"))) log("Config", "missing, copy config.schema.js to config.js and fill it in"); } From 2abd50530d5768355aa7f2ce49168c6edee33340 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:10:37 -0400 Subject: [PATCH 26/45] docs: clarify which apps/scripts scripts agents may run AGENTS.md said not to run anything in apps/scripts except setup-worktree, but every pnpm build runs apps/scripts/src/clean-dist.ts through the clean task. Say not to run them directly instead. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index eba3036f0..a44392f37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ T3 runs `node apps/scripts/src/setup-worktree.ts` when it creates a worktree (se ## Do not (in worktrees) - Start the API, Discord bot, support bot, verify server, or site. Every worktree shares one `config.js`, so they would share the Discord token, the Hypixel key, and ports, and the bot would re-post slash commands to the testing guild. -- Call the Hypixel API, or run anything in `apps/scripts` except `setup-worktree`. +- Call the Hypixel API, or run scripts in `apps/scripts` directly, other than `setup-worktree`. Builds run `clean-dist` on their own; that is fine. - Run `docker compose`. - Edit `config.js` or anything under `assets/`. - Edit locales other than `locales/en-US`. From e9ea6dd2d87d7a92cd7fe8a0f4ffc206c47e82d8 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:11:45 -0400 Subject: [PATCH 27/45] fix(scripts): don't clone submodules the main checkout lacks in worktrees When the main checkout had no repo for a submodule (e.g. no access to the private assets), setup ran git submodule update --init inside the worktree. That clones into the worktree's own git dir, so every worktree cloned it again, and a private repo could hang on a credential prompt. Skip it with a message saying how to set it up in the main checkout. --- apps/scripts/src/setup-worktree.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index 331c7a22f..8fc8590db 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -16,7 +16,8 @@ * 1. Links `config.js` from the main checkout * 2. Checks out each asset submodule at the commit this branch pins, as a git * worktree of the main checkout's submodule repo (no re-clone), and moves - * existing clean, detached checkouts to the pinned commit + * existing clean, detached checkouts to the pinned commit. In a worktree, a + * submodule the main checkout hasn't set up is skipped instead of cloned * 3. Links the ignored minecraft texture pack from the main checkout * 4. Installs dependencies (this also blurs the public backgrounds) * 5. Restores the blurred private backgrounds from a cache shared by all @@ -243,9 +244,7 @@ function setupSubmodule({ name, path }: Submodule) { return; } - const moduleDir = join(COMMON_DIR, "modules", name); - - if (!IS_LINKED_WORKTREE || !existsSync(moduleDir)) { + if (!IS_LINKED_WORKTREE) { try { run("git", ["submodule", "update", "--init", path]); } catch { @@ -255,6 +254,18 @@ function setupSubmodule({ name, path }: Submodule) { return; } + // Cloning here would put a full copy in this worktree's own git dir, so every + // worktree would clone it again, and a private repo can hang on a credential prompt + const moduleDir = join(COMMON_DIR, "modules", name); + + if (!existsSync(moduleDir)) { + log( + "Submodule", + `${path} isn't set up in the main checkout, continuing without it. Run \`git submodule update --init ${path}\` there to share it with worktrees`, + ); + return; + } + if (!hasCommit(["--git-dir", moduleDir], sha)) { log("Submodule", `could not fetch ${path}, continuing without it`); return; From 38108ed8a8581342120294d7f7dcf54325f85044 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:11:45 -0400 Subject: [PATCH 28/45] fix(scripts): read pinned submodule commits from the index Setup read the pinned commit from HEAD, so rerunning it in the main checkout during a staged but uncommitted submodule bump moved the submodule back to the old commit. Read it from the index, like git submodule update does. In a fresh worktree both are the same. --- apps/scripts/src/setup-worktree.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index 8fc8590db..f22be6856 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -228,7 +228,10 @@ function updateSubmodule(path: string, sha: string) { function setupSubmodule({ name, path }: Submodule) { const target = join(ROOT, path); - const sha = git(["ls-tree", "HEAD", path]).split(/\s+/)[2]; + // Read the pinned commit from the index like `git submodule update` does, so a + // staged but uncommitted submodule bump isn't undone. Lines look like + // `160000 0\t` + const sha = git(["ls-files", "--stage", "--", path]).split(/\s+/)[1]; if (!sha) return; if (existsSync(join(target, ".git"))) { From 73b3c01adfacc32bf5356196561e5ca085a97c23 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:36:02 -0400 Subject: [PATCH 29/45] fix(scripts): keep setup running when blurring backgrounds fails A failing pnpm blur (or reading the blur cache key) threw out of the script before pnpm build, leaving the worktree without dist. The backgrounds are only needed when an app renders, so log it and continue to the build like the other optional steps. --- apps/scripts/src/setup-worktree.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index f22be6856..87b5f161c 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -502,7 +502,16 @@ linkTexturePack(); run("pnpm", ["install", "--frozen-lockfile"]); -blurPrivateBackgrounds(); +// The backgrounds are only needed when an app renders, so a failed blur must not +// stop the build that every package import depends on +try { + blurPrivateBackgrounds(); +} catch { + log( + "Backgrounds", + "could not blur the private backgrounds, continuing without them. Rerun setup to retry", + ); +} if (!process.argv.includes("--skip-build")) run("pnpm", ["build"]); From b5a8921ec5c65f862b8aa5021b4c88911b8a3fe3 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:38:04 -0400 Subject: [PATCH 30/45] fix(scripts): fetch pinned submodule commits by sha as a fallback git fetch origin only downloads what remote branches reach, so a commit pinned from a deleted, squash-merged branch never arrived and the worktree went without that submodule. When fetching branches doesn't bring the commit in, fetch it by sha like git submodule update does. --- apps/scripts/src/setup-worktree.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index 87b5f161c..59b400122 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -189,7 +189,15 @@ function hasCommit(repo: string[], sha: string): boolean { if (exists()) return true; } - return false; + // A commit no remote branch reaches anymore (e.g. from a deleted, squash-merged + // branch) only arrives when fetched directly, like `git submodule update` does + try { + run("git", [...repo, "fetch", "origin", sha]); + } catch { + // Checked below + } + + return exists(); } /** From 7a6c41eae8aa770c6d4522314379a0ed507802e9 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:38:22 -0400 Subject: [PATCH 31/45] fix(scripts): read background names from git with -z MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without -z, git quotes and escapes file names with special characters (café.png came out as "caf\303\251.png"), so they never matched the files blur.mjs writes. The completeness check would always fail and the blur cache would never be used. --- apps/scripts/src/setup-worktree.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index 59b400122..3bcb4cee5 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -468,9 +468,12 @@ function blurPrivateBackgrounds() { const cached = key && join(BLUR_CACHE_DIR, key); // blur.mjs writes one output per background, with the same name. Only tracked - // backgrounds are part of the key, so ignored files like .DS_Store don't count + // backgrounds are part of the key, so ignored files like .DS_Store don't count. + // -z keeps git from quoting names with special characters, like café.png const backgrounds = key - ? git(["ls-tree", "--name-only", "HEAD:backgrounds"], assets).split("\n") + ? git(["ls-tree", "-z", "--name-only", "HEAD:backgrounds"], assets) + .split("\0") + .filter(Boolean) : []; if (cached && existsSync(cached)) { From 6cea92046c879b0755b9b55f6ea534fd8a6adbaf Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:39:07 -0400 Subject: [PATCH 32/45] build(turbo): scope .swcrc and tsconfig.base.json to the tasks that use them As globalDependencies they went into every task hash, so editing either one also rebuilt the Rust skin-renderer (napi + wasm-pack) in every worktree, although it uses neither. Make .swcrc an input of build and test (vitest.shared.ts reads it), and tsconfig.base.json an input of typecheck and lint. skin-renderer's own turbo.json replaces its build inputs, so its cache no longer depends on them. pnpm-lock.yaml stays global on purpose. --- turbo.json | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/turbo.json b/turbo.json index 40adce518..f280231e0 100644 --- a/turbo.json +++ b/turbo.json @@ -1,9 +1,10 @@ { "$schema": "https://turborepo.org/schema.json", - "globalDependencies": ["pnpm-lock.yaml", "tsconfig.base.json", ".swcrc"], + "globalDependencies": ["pnpm-lock.yaml"], "ui": "tui", "tasks": { "build": { + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.swcrc"], "env": ["API_URL", "API_KEY"], "outputs": ["dist/**", ".next/**", "pkg/**"], "dependsOn": ["clean", "^build"] @@ -12,24 +13,35 @@ "cache": false }, "typecheck": { + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/tsconfig.base.json"], "outputs": [], "dependsOn": ["^typecheck"] }, "test": { + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.swcrc"], "outputs": [], "dependsOn": ["build", "^test"] }, "test:coverage": { + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.swcrc"], "outputs": ["coverage"], "dependsOn": ["build", "^test:coverage"] }, "lint": { - "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.oxlintrc.jsonc"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/.oxlintrc.jsonc", + "$TURBO_ROOT$/tsconfig.base.json" + ], "outputs": [], "dependsOn": [] }, "lint:ci": { - "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.oxlintrc.jsonc"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/.oxlintrc.jsonc", + "$TURBO_ROOT$/tsconfig.base.json" + ], "outputs": [], "dependsOn": [] }, From 7c55304e6071d39dcb4965562e39cb2592f42b50 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:44:47 -0400 Subject: [PATCH 33/45] fix(scripts): never let git prompt for credentials during setup fetches Setup runs unattended and T3 makes the agent wait for it, so a fetch from the private assets repo on a machine without saved credentials would wait forever for a username. Run fetches with GIT_TERMINAL_PROMPT=0 so git fails right away and setup continues without that submodule. Saved credentials and credential helpers still work. --- apps/scripts/src/setup-worktree.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index 3bcb4cee5..e1dc8f22e 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -66,6 +66,10 @@ const BLUR_CACHE_DIR = join( const STALE_TEMP_MS = 60 * 60 * 1000; +// Setup runs unattended (T3 makes the agent wait for it), so a git credential prompt +// would hang forever. Make git fail instead; saved credentials still work +const FETCH_ENV = { GIT_TERMINAL_PROMPT: "0" }; + const TEXTURE_PACK = "assets/public/minecraft-textures/default"; const PUBLIC_ASSETS = "assets/public"; // Packages blur.mjs uses to decode, blur, and encode the backgrounds @@ -80,9 +84,15 @@ function git(args: string[], cwd = ROOT): string { }).trim(); } -function run(command: string, args: string[], cwd = ROOT) { +function run( + command: string, + args: string[], + cwd = ROOT, + env: Record = {}, +) { execFileSync(command, args, { cwd, + env: { ...process.env, ...env }, stdio: "inherit", shell: process.platform === "win32", }); @@ -181,7 +191,7 @@ function hasCommit(repo: string[], sha: string): boolean { // so check whether the other fetch brought the commit in and retry once for (let attempt = 0; attempt < 2; attempt++) { try { - run("git", [...repo, "fetch", "origin"]); + run("git", [...repo, "fetch", "origin"], ROOT, FETCH_ENV); } catch { // Checked below } @@ -192,7 +202,7 @@ function hasCommit(repo: string[], sha: string): boolean { // A commit no remote branch reaches anymore (e.g. from a deleted, squash-merged // branch) only arrives when fetched directly, like `git submodule update` does try { - run("git", [...repo, "fetch", "origin", sha]); + run("git", [...repo, "fetch", "origin", sha], ROOT, FETCH_ENV); } catch { // Checked below } From 36ff6244f19addbfb3631ae2b319254324968619 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:44:59 -0400 Subject: [PATCH 34/45] fix(scripts): use import attributes in validate-commands Import assertions (assert { type: "json" }) were removed in node 22, so the script failed to parse on node 24 with SyntaxError: Unexpected identifier 'assert'. Use the with { type: "json" } syntax instead. --- apps/scripts/src/validate-commands.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/scripts/src/validate-commands.js b/apps/scripts/src/validate-commands.js index 19d7c097c..a99e87b61 100644 --- a/apps/scripts/src/validate-commands.js +++ b/apps/scripts/src/validate-commands.js @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import commands from "discord-bot/commands.json" assert { type: "json" }; +import commands from "discord-bot/commands.json" with { type: "json" }; import z from "zod"; function toEnum(...args) { From 664176a64c363bdd18abfda144e31857d7c51cba Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:45:56 -0400 Subject: [PATCH 35/45] build(scripts): typecheck the TypeScript scripts in CI setup-worktree.ts and clean-dist.ts were never typechecked: apps/scripts had no typecheck script, so pnpm typecheck, CI, and Verify skipped them. - add a typecheck script, so CI's typecheck --affected covers them - enable erasableSyntaxOnly. node runs them by stripping types and throws ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX on syntax like enums, which would break every pnpm build since clean-dist runs in it - only include the .ts scripts. The .js scripts aren't typechecked, and including them pulled in workspace packages that use enums - load node's types explicitly; they had only arrived through the .js scripts' imports --- apps/scripts/package.json | 1 + apps/scripts/tsconfig.json | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/scripts/package.json b/apps/scripts/package.json index 99fc5c83c..c3558c389 100644 --- a/apps/scripts/package.json +++ b/apps/scripts/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "lint": "oxlint", + "typecheck": "tsc --noEmit", "lint:ci": "oxlint --format=github", "api-key": "node src/api-key.js", "delete-sessions": "node src/delete-sessions.js", diff --git a/apps/scripts/tsconfig.json b/apps/scripts/tsconfig.json index dd3fcc5f4..9cf09065a 100644 --- a/apps/scripts/tsconfig.json +++ b/apps/scripts/tsconfig.json @@ -1,4 +1,11 @@ { "extends": "../../tsconfig.base.json", - "include": ["src"] -} \ No newline at end of file + "compilerOptions": { + // node runs the .ts scripts by stripping types, which only works for erasable syntax + "erasableSyntaxOnly": true, + "types": ["node"] + }, + // The .js scripts aren't type checked (checkJs is off), and including them pulls in + // the workspace packages they import, which use non-erasable syntax like enums + "include": ["src/**/*.ts"] +} From fe75f21877b91bc6de2eff4b2a33474c14779967 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:02:30 -0400 Subject: [PATCH 36/45] fix(scripts): harden setup-worktree edge cases - find the main checkout from git worktree list, so it also works when its git dir isn't
/.git (e.g. --separate-git-dir) - on Windows, only run pnpm through a shell. cmd.exe split git's path arguments that contain spaces - also stop Git Credential Manager's GUI prompt during fetches - read .gitmodules with --null, so submodule paths can contain spaces - when restoring from the blur cache fails but the entry is complete (e.g. a full disk), keep the entry instead of evicting it from under other setups --- apps/scripts/src/setup-worktree.ts | 48 ++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index e1dc8f22e..dd9d1cc41 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -56,7 +56,13 @@ const ROOT = resolve( ), ); const COMMON_DIR = resolve(ROOT, git(["rev-parse", "--git-common-dir"], ROOT)); -const MAIN_ROOT = dirname(COMMON_DIR); +// The first entry is the main checkout. Unlike dirname(COMMON_DIR), this also holds +// when its git dir isn't
/.git (e.g. cloned with --separate-git-dir) +const MAIN_ROOT = resolve( + git(["worktree", "list", "--porcelain"], ROOT) + .split("\n")[0] + .slice("worktree ".length), +); const IS_LINKED_WORKTREE = MAIN_ROOT !== ROOT; const BLUR_CACHE_DIR = join( COMMON_DIR, @@ -67,8 +73,9 @@ const BLUR_CACHE_DIR = join( const STALE_TEMP_MS = 60 * 60 * 1000; // Setup runs unattended (T3 makes the agent wait for it), so a git credential prompt -// would hang forever. Make git fail instead; saved credentials still work -const FETCH_ENV = { GIT_TERMINAL_PROMPT: "0" }; +// would hang forever. Make git (and Git Credential Manager's GUI) fail instead; saved +// credentials still work +const FETCH_ENV = { GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" }; const TEXTURE_PACK = "assets/public/minecraft-textures/default"; const PUBLIC_ASSETS = "assets/public"; @@ -94,7 +101,9 @@ function run( cwd, env: { ...process.env, ...env }, stdio: "inherit", - shell: process.platform === "win32", + // pnpm is a .cmd shim on Windows, which needs a shell. Other commands (git) must not + // go through one, since cmd.exe would split path arguments that contain spaces + shell: process.platform === "win32" && command === "pnpm", }); } @@ -156,6 +165,7 @@ function submodules(): Submodule[] { "config", "--file", ".gitmodules", + "--null", "--get-regexp", String.raw`^submodule\..*\.path$`, ]); @@ -163,11 +173,17 @@ function submodules(): Submodule[] { return []; } - // Lines look like `submodule..path `, and names can contain dots - return output.split("\n").map((line) => { - const [key, path] = line.split(" "); - return { name: key.slice("submodule.".length, -".path".length), path }; - }); + // With --null, entries look like `submodule..path\n\0`, so names (which + // can contain dots) and paths can contain spaces + return output + .split("\0") + .filter(Boolean) + .map((entry) => { + const newline = entry.indexOf("\n"); + const key = entry.slice(0, newline); + const path = entry.slice(newline + 1); + return { name: key.slice("submodule.".length, -".path".length), path }; + }); } /** @@ -492,10 +508,16 @@ function blurPrivateBackgrounds() { return; } - // Either evicted while it was being copied, or stored incomplete. Delete it, or an - // incomplete entry could never be replaced - log("Backgrounds", "cache entry is incomplete, replacing it"); - rmSync(cached, { recursive: true, force: true }); + // Either evicted while it was being copied, stored incomplete, or the copy itself + // failed (e.g. a full disk). Only delete an entry that is itself incomplete, or an + // incomplete entry could never be replaced, but a local copy failure must not evict + // a good entry other setups are restoring from + if (isComplete(cached, backgrounds)) { + log("Backgrounds", "could not restore from cache"); + } else { + log("Backgrounds", "cache entry is incomplete, replacing it"); + rmSync(cached, { recursive: true, force: true }); + } } log("Backgrounds", "blurring private backgrounds, this takes a while"); From dbc97896a7493caeeb801a480ea6147a6f9e419f Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:02:30 -0400 Subject: [PATCH 37/45] fix(scripts): match swc's output extensions in clean-dist swc compiles .mts to .mjs, .cts to .cjs, and .mjs to .js, and copies .cjs as is with --copy-files. clean-dist assumed every .mjs and .cjs output came from a .ts-like source, so it would have deleted those outputs on every build. No package has such files yet. --- apps/scripts/src/clean-dist.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/scripts/src/clean-dist.ts b/apps/scripts/src/clean-dist.ts index 372194c1d..3feca7d4a 100644 --- a/apps/scripts/src/clean-dist.ts +++ b/apps/scripts/src/clean-dist.ts @@ -26,22 +26,29 @@ import { join } from "node:path"; const DIST = "dist"; const SRC = "src"; -// swc compiles each of these to a .js file with the same base name -const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx"]; +// The source extensions swc compiles to each output extension, with the same base name. +// .cjs isn't compiled, so --copy-files copies it as is +const SOURCE_EXTENSIONS: Record = { + ".js": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".es6", ".es"], + ".mjs": [".mts"], + ".cjs": [".cts", ".cjs"], +}; /** * @param file path relative to dist */ function hasSource(file: string): boolean { - const base = file.replace(/\.js(\.map)?$/, ""); + const match = /^(.+?)(\.[cm]?js)(\.map)?$/.exec(file); // Files that swc copies as is with --copy-files - if (base === file) return existsSync(join(SRC, file)); + if (!match) return existsSync(join(SRC, file)); + + const [, base, outputExtension] = match; // swc doesn't compile declaration files, so a .d.js output is always an orphan if (base.endsWith(".d")) return false; - return SOURCE_EXTENSIONS.some((extension) => + return SOURCE_EXTENSIONS[outputExtension].some((extension) => existsSync(join(SRC, `${base}${extension}`)), ); } From 11d3cd3d19b8e8b4c943dc18d7fbc5656e44899f Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:14:04 -0400 Subject: [PATCH 38/45] docs(agents): tighten AGENTS.md for parallel threads - replace the cleanup-only opening line, so feature threads don't hold back; cleanup rules now apply when the task is cleanup - move "Do not" to the top and "Running locally" to the end - threads must commit before ending their turn, and never commit to main, push, open PRs, launch threads, or create tags (v* deploys) - never print config.js, edit skin-renderer, rename schema fields (Mongo paths and leaderboard keys come from field names), or guess Hypixel API keys - document the license header, discord-bot import aliases, i18n, Discord command limits, commit message style, and when to run fmt - add packages/discord and base.hypixel-command.ts to the single-thread list - verify with the --affected checks CI runs, flag unchecked rendering, and end each thread with a standard report --- AGENTS.md | 77 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a44392f37..4196b8e38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,31 @@ # Statsify -You are working in the Statsify monorepo. Make conservative, repo-native cleanup changes only in the files relevant to the current feature. +You are working in the Statsify monorepo. Follow existing Statsify patterns and keep changes scoped to the task. + +## Do not (in worktrees) + +- Start the API, Discord bot, support bot, verify server, or site. Every worktree shares one `config.js`, so they would share the Discord token, the Hypixel key, and ports, and the bot would re-post slash commands to the testing guild. +- Open, print, or log `config.js`. It holds the bot token and the Hypixel key, and anything you print ends up in your transcript. Use `config.schema.js` to see its shape. +- Call the Hypixel API, or run scripts in `apps/scripts` directly, other than `setup-worktree`. Builds run `clean-dist` on their own; that is fine. +- Run `docker compose`. +- Edit `config.js` or anything under `assets/`. +- Edit `packages/skin-renderer` (Rust) unless asked. Rust changes force a slow recompile in every worktree, and its lint runs `cargo clippy --fix`, which rewrites files. +- Edit locales other than `locales/en-US`. +- Add or upgrade dependencies unless asked. +- Rename, move, or remove a schema field, or change its leaderboard config, unless asked. Mongo paths and Redis leaderboard keys (e.g. `player.stats.bedwars.wins`) come from field names, and nothing migrates existing data, so stored stats would silently stop matching. +- Create or push git tags. Pushing a `v*` tag deploys to production. + +## Branches and threads + +- Never commit, merge, or rebase onto `main`, and never push to it. All work goes on a `feat/`, `fix/`, or `chore/` branch, and changes reach `main` only through a reviewed PR. If you find yourself on `main`, create a branch before making any change. +- Commit all work on your branch before ending your turn. Uncommitted work is lost when the thread's worktree is removed. Don't push, open PRs, or launch your own threads unless asked. +- Commit messages are scoped conventional commits, like `fix(schemas): …`, `fix(bedwars): …`, or `feat(discord-bot): …`. +- Branch names: `feat/`, `fix/`, or `chore/` plus a short kebab-case description (e.g. `feat/posthog-events`). +- Always pass `workspaceStrategy: { type: "worktree", baseRef: , branch: , startFromOrigin: false }` to `t3_thread_launch`. Without it, the thread runs in the main checkout. +- Commit on the integration branch before launching threads. New worktrees only contain committed work. +- Split work by folder. Keep these to a single thread at a time, since most other code depends on them: `packages/discord` (the command framework), `apps/discord-bot/src/commands/base.hypixel-command.ts`, `apps/api/src/hypixel`, `apps/api/src/redis`, `apps/api/src/leaderboards`, and `packages/schemas/src/metadata`. +- For conflicts in `locales/en-US/default.json`, keep both sides' keys. For `pnpm-lock.yaml` conflicts, rerun `pnpm install`. +- Run the full verify after merging thread branches, before settling threads. ## Working in this repo @@ -13,13 +38,27 @@ You are working in the Statsify monorepo. Make conservative, repo-native cleanup ### Code style +- Every source file starts with the license header, or lint fails: + + ```ts + /** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + ``` + - Use existing Statsify utilities instead of local helpers when available. - Use `@statsify/math` helpers such as `add`, `sub`, and `ratio` instead of hand-rolled reduce/sum/ratio logic. - Use `@statsify/util` helpers such as `prettify`, `arrayGroup`, and other existing utilities instead of duplicating formatting/grouping logic. - Prefer exported static registries/constants from schemas (e.g. `BEDWARS_MODES`) over duplicating lists in commands. - Use typed constants like `const ITEMS = [...] as const` where they define a finite domain. +- In `apps/discord-bot`, import through the aliases `#commands/*`, `#components`, `#services`, `#lib/*`, `#constants`, and `#themes` rather than relative paths. - Avoid extra files/abstractions unless they clearly reduce real complexity or match an existing local pattern. - Keep logic close to the feature file when it is feature-specific. +- Most files aren't formatted with oxfmt yet, so don't run it on them; it would reformat whole files and cause conflicts between threads. Match the surrounding style instead. `packages/util` is the exception: run `pnpm fmt` if you change it. ### Schema / Mongo storage @@ -29,9 +68,12 @@ You are working in the Statsify monorepo. Make conservative, repo-native cleanup - Use schema registries to zip static metadata with compact stored arrays in the command/profile layer. - If display names can be derived from IDs, use `prettify()` rather than maintaining ID-to-name maps. - Keep explicit names only when `prettify()` would produce incorrect user-facing text. +- Only use Hypixel API keys you can point to in existing code or in a response the user provides. A wrong key fails silently: typecheck passes and the stat always reads 0. If you can't confirm a key, list it as unverified. ### Discord command/profile +- User-facing text goes through `t("…")`, with keys in `locales/en-US/default.json`. Never hardcode strings. +- Command and option names must be 3 to 32 characters, descriptions and choice names 1 to 100, and string choice values at most 32, in every translation. `validate-commands` can't run in worktrees, so check these by hand. - Use the existing pagination patterns where comparable commands already do: `paginate()` and `scrollingPagination()` with the `Page` and `SubPage` types in `packages/discord/src/services/paginate.service.ts`, which provide the dropdowns and subpages. Gamemode commands get this through `BaseHypixelCommand`. - Keep page definitions declarative when possible. - Avoid duplicate switch logic; use maps for simple page labels/titles. @@ -39,7 +81,9 @@ You are working in the Statsify monorepo. Make conservative, repo-native cleanup - Use Minecraft color codes consistently and centrally when repeated. - Do not move feature-specific display constants into generic shared files unless another feature actually needs them. -### Cleanup priorities +### Cleanup + +When the task is cleanup: - Remove duplicated constants and lookup maps. - Replace manual aggregation with shared helpers and static dimension arrays. @@ -57,29 +101,20 @@ T3 runs `node apps/scripts/src/setup-worktree.ts` when it creates a worktree (se ## Verifying changes -- Run focused checks for each changed package: `pnpm --filter typecheck` and `pnpm --filter lint` (e.g. `api`, `discord-bot`, `@statsify/schemas`). -- Packages import each other through `dist`, so run `pnpm build` before any tests. turbo makes this fast. +- Run the checks CI runs, limited to the packages your branch changes and the packages that depend on them: `pnpm build && pnpm typecheck --affected && pnpm lint --affected && pnpm test --run`. This is also the T3 "Verify" script. +- Packages import each other through `dist`, so `pnpm build` comes first. turbo makes it fast. - Tests never load `config.js` (`config()` returns defaults when `VITEST` is set), so `pnpm test --run` is safe. Coverage is thin (in-source tests plus `packages/rendering/tests`), so a clean build plus typecheck is the main signal. -- The T3 "Verify" script runs everything: `pnpm build && pnpm typecheck && pnpm lint && pnpm test --run`. -- Report exactly what changed, what passed, and what couldn't be verified. +- Rendered images (profiles, leaderboards, and other image output) can't be checked in a worktree. For any rendering change, say the image wasn't checked visually. -## Do not (in worktrees) +## Final report -- Start the API, Discord bot, support bot, verify server, or site. Every worktree shares one `config.js`, so they would share the Discord token, the Hypixel key, and ports, and the bot would re-post slash commands to the testing guild. -- Call the Hypixel API, or run scripts in `apps/scripts` directly, other than `setup-worktree`. Builds run `clean-dist` on their own; that is fine. -- Run `docker compose`. -- Edit `config.js` or anything under `assets/`. -- Edit locales other than `locales/en-US`. -- Add or upgrade dependencies unless asked. - -## Branches and threads +End every thread with: -- Always pass `workspaceStrategy: { type: "worktree", baseRef: , branch: , startFromOrigin: false }` to `t3_thread_launch`. Without it, the thread runs in the main checkout. -- Commit on the integration branch before launching threads. New worktrees only contain committed work. -- Branch names: `feat/`, `fix/`, or `chore/` plus a short kebab-case description (e.g. `feat/posthog-events`). -- Split work by folder. Keep `apps/api/src/hypixel`, `apps/api/src/redis`, `apps/api/src/leaderboards`, and `packages/schemas/src/metadata` to a single thread. -- For conflicts in `locales/en-US/default.json`, keep both sides' keys. For `pnpm-lock.yaml` conflicts, rerun `pnpm install`. -- Run the full verify after merging thread branches, before settling threads. +- **Branch:** name +- **Commits:** hash and message for each +- **Files:** each file touched +- **Checks:** each check run and its result +- **Not verified:** rendering not checked visually, unconfirmed Hypixel keys, and anything else you couldn't check ## Running locally (main checkout only) From c59eeb862949b3aa6bf6b3a96d2d49f12beae3f8 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:14:04 -0400 Subject: [PATCH 39/45] build(t3): run affected typecheck and lint in Verify Match CI, which runs typecheck and lint with --affected. This covers every package a branch changes plus its dependents, and only runs the skin-renderer lint, which rewrites files, when Rust code changed. --- t3.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t3.json b/t3.json index f1a242d35..e8dca39ce 100644 --- a/t3.json +++ b/t3.json @@ -12,7 +12,7 @@ }, { "name": "Verify", - "command": "pnpm build && pnpm typecheck && pnpm lint && pnpm test --run", + "command": "pnpm build && pnpm typecheck --affected && pnpm lint --affected && pnpm test --run", "icon": "test" } ] From 679015f36be83d518299911231b7fedefed6ca2e Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:17:48 -0400 Subject: [PATCH 40/45] feat(scripts): link local agent instruction files into worktrees Link AGENTS.md and CLAUDE.md from the main checkout when it has them, the same way as config.js, so each developer can keep their own agent instructions out of the repo and still get them in every worktree. --- apps/scripts/src/setup-worktree.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index dd9d1cc41..7510120b1 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -13,7 +13,8 @@ * Only uses node builtins because it runs before `pnpm install`. Node runs it as * TypeScript by stripping the types, so it only uses erasable syntax. * - * 1. Links `config.js` from the main checkout + * 1. Links `config.js`, and `AGENTS.md` and `CLAUDE.md` if present, from the main + * checkout * 2. Checks out each asset submodule at the commit this branch pins, as a git * worktree of the main checkout's submodule repo (no re-clone), and moves * existing clean, detached checkouts to the pinned commit. In a worktree, a @@ -151,6 +152,18 @@ function linkConfig() { log("Config", "missing, copy config.schema.js to config.js and fill it in"); } +/** + * Agent instruction files aren't committed, so a new worktree only gets them when the + * main checkout has them locally + */ +function linkAgentInstructions() { + if (!IS_LINKED_WORKTREE) return; + + for (const file of ["AGENTS.md", "CLAUDE.md"]) { + if (linkFromMain(file)) log("Agents", `linked ${file} from ${MAIN_ROOT}`); + } +} + interface Submodule { /** git stores the submodule's repo under .git/modules/ */ name: string; @@ -540,6 +553,7 @@ log( ); linkConfig(); +linkAgentInstructions(); for (const submodule of submodules()) setupSubmodule(submodule); linkTexturePack(); From bb4296d2dbb522c2e67b97bddd2cc6e4b082f493 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:17:48 -0400 Subject: [PATCH 41/45] chore: stop tracking AGENTS.md and CLAUDE.md Agent instructions are personal, so they stay local (in .git/info/exclude) and setup links them into worktrees. --- AGENTS.md | 128 ------------------------------------------------------ CLAUDE.md | 1 - 2 files changed, 129 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 4196b8e38..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,128 +0,0 @@ -# Statsify - -You are working in the Statsify monorepo. Follow existing Statsify patterns and keep changes scoped to the task. - -## Do not (in worktrees) - -- Start the API, Discord bot, support bot, verify server, or site. Every worktree shares one `config.js`, so they would share the Discord token, the Hypixel key, and ports, and the bot would re-post slash commands to the testing guild. -- Open, print, or log `config.js`. It holds the bot token and the Hypixel key, and anything you print ends up in your transcript. Use `config.schema.js` to see its shape. -- Call the Hypixel API, or run scripts in `apps/scripts` directly, other than `setup-worktree`. Builds run `clean-dist` on their own; that is fine. -- Run `docker compose`. -- Edit `config.js` or anything under `assets/`. -- Edit `packages/skin-renderer` (Rust) unless asked. Rust changes force a slow recompile in every worktree, and its lint runs `cargo clippy --fix`, which rewrites files. -- Edit locales other than `locales/en-US`. -- Add or upgrade dependencies unless asked. -- Rename, move, or remove a schema field, or change its leaderboard config, unless asked. Mongo paths and Redis leaderboard keys (e.g. `player.stats.bedwars.wins`) come from field names, and nothing migrates existing data, so stored stats would silently stop matching. -- Create or push git tags. Pushing a `v*` tag deploys to production. - -## Branches and threads - -- Never commit, merge, or rebase onto `main`, and never push to it. All work goes on a `feat/`, `fix/`, or `chore/` branch, and changes reach `main` only through a reviewed PR. If you find yourself on `main`, create a branch before making any change. -- Commit all work on your branch before ending your turn. Uncommitted work is lost when the thread's worktree is removed. Don't push, open PRs, or launch your own threads unless asked. -- Commit messages are scoped conventional commits, like `fix(schemas): …`, `fix(bedwars): …`, or `feat(discord-bot): …`. -- Branch names: `feat/`, `fix/`, or `chore/` plus a short kebab-case description (e.g. `feat/posthog-events`). -- Always pass `workspaceStrategy: { type: "worktree", baseRef: , branch: , startFromOrigin: false }` to `t3_thread_launch`. Without it, the thread runs in the main checkout. -- Commit on the integration branch before launching threads. New worktrees only contain committed work. -- Split work by folder. Keep these to a single thread at a time, since most other code depends on them: `packages/discord` (the command framework), `apps/discord-bot/src/commands/base.hypixel-command.ts`, `apps/api/src/hypixel`, `apps/api/src/redis`, `apps/api/src/leaderboards`, and `packages/schemas/src/metadata`. -- For conflicts in `locales/en-US/default.json`, keep both sides' keys. For `pnpm-lock.yaml` conflicts, rerun `pnpm install`. -- Run the full verify after merging thread branches, before settling threads. - -## Working in this repo - -### Before editing - -- Read the existing command, schema, and nearby gamemode examples first. -- Prefer established Statsify patterns over new abstractions. -- Look at comparable commands/schemas such as BedWars and SkyWars (`apps/discord-bot/src/commands/bedwars`, `packages/schemas/src/player/gamemodes/bedwars`) when unsure and necessary. -- Keep changes tightly scoped to the requested command/schema. - -### Code style - -- Every source file starts with the license header, or lint fails: - - ```ts - /** - * Copyright (c) Statsify - * - * This source code is licensed under the GNU GPL v3 license found in the - * LICENSE file in the root directory of this source tree. - * https://github.com/Statsify/statsify/blob/main/LICENSE - */ - ``` - -- Use existing Statsify utilities instead of local helpers when available. -- Use `@statsify/math` helpers such as `add`, `sub`, and `ratio` instead of hand-rolled reduce/sum/ratio logic. -- Use `@statsify/util` helpers such as `prettify`, `arrayGroup`, and other existing utilities instead of duplicating formatting/grouping logic. -- Prefer exported static registries/constants from schemas (e.g. `BEDWARS_MODES`) over duplicating lists in commands. -- Use typed constants like `const ITEMS = [...] as const` where they define a finite domain. -- In `apps/discord-bot`, import through the aliases `#commands/*`, `#components`, `#services`, `#lib/*`, `#constants`, and `#themes` rather than relative paths. -- Avoid extra files/abstractions unless they clearly reduce real complexity or match an existing local pattern. -- Keep logic close to the feature file when it is feature-specific. -- Most files aren't formatted with oxfmt yet, so don't run it on them; it would reformat whole files and cause conflicts between threads. Match the surrounding style instead. `packages/util` is the exception: run `pnpm fmt` if you change it. - -### Schema / Mongo storage - -- Do not store static or derivable data in Mongo. -- Store only player-specific values: counts, booleans, active selections, levels, weights, timestamps, etc. -- Do not store IDs, names, requirements, descriptions, colors, unlock text, or other non-unique metadata if they can be derived from static schema registries. -- Use schema registries to zip static metadata with compact stored arrays in the command/profile layer. -- If display names can be derived from IDs, use `prettify()` rather than maintaining ID-to-name maps. -- Keep explicit names only when `prettify()` would produce incorrect user-facing text. -- Only use Hypixel API keys you can point to in existing code or in a response the user provides. A wrong key fails silently: typecheck passes and the stat always reads 0. If you can't confirm a key, list it as unverified. - -### Discord command/profile - -- User-facing text goes through `t("…")`, with keys in `locales/en-US/default.json`. Never hardcode strings. -- Command and option names must be 3 to 32 characters, descriptions and choice names 1 to 100, and string choice values at most 32, in every translation. `validate-commands` can't run in worktrees, so check these by hand. -- Use the existing pagination patterns where comparable commands already do: `paginate()` and `scrollingPagination()` with the `Page` and `SubPage` types in `packages/discord/src/services/paginate.service.ts`, which provide the dropdowns and subpages. Gamemode commands get this through `BaseHypixelCommand`. -- Keep page definitions declarative when possible. -- Avoid duplicate switch logic; use maps for simple page labels/titles. -- Keep rendering helpers small and local unless shared across multiple commands. -- Use Minecraft color codes consistently and centrally when repeated. -- Do not move feature-specific display constants into generic shared files unless another feature actually needs them. - -### Cleanup - -When the task is cleanup: - -- Remove duplicated constants and lookup maps. -- Replace manual aggregation with shared helpers and static dimension arrays. -- Flatten hard-to-read boolean chains into named checks or switches. -- Preserve behavior unless the request explicitly asks to change it. -- Do not refactor unrelated code. - -## Setup - -T3 runs `node apps/scripts/src/setup-worktree.ts` when it creates a worktree (see `t3.json`). It links `config.js` and the texture pack from the main checkout, checks out `assets/public` and `assets/private` at the commits the branch pins, installs dependencies, restores the blurred backgrounds from a shared cache, and builds. - -- turbo (2.9+) shares its build cache across worktrees on its own, so builds in a new worktree are mostly cache hits. -- If a worktree looks broken (missing deps, assets, config, or `dist`), rerun `node apps/scripts/src/setup-worktree.ts`. It is safe to rerun and to run in many worktrees at once. -- T3 passes `--force` when an agent removes or settles a thread's worktree, so worktrees with submodules are removed. T3's background storage cleanup does not, so it skips them; remove those manually with `git worktree remove --force `. - -## Verifying changes - -- Run the checks CI runs, limited to the packages your branch changes and the packages that depend on them: `pnpm build && pnpm typecheck --affected && pnpm lint --affected && pnpm test --run`. This is also the T3 "Verify" script. -- Packages import each other through `dist`, so `pnpm build` comes first. turbo makes it fast. -- Tests never load `config.js` (`config()` returns defaults when `VITEST` is set), so `pnpm test --run` is safe. Coverage is thin (in-source tests plus `packages/rendering/tests`), so a clean build plus typecheck is the main signal. -- Rendered images (profiles, leaderboards, and other image output) can't be checked in a worktree. For any rendering change, say the image wasn't checked visually. - -## Final report - -End every thread with: - -- **Branch:** name -- **Commits:** hash and message for each -- **Files:** each file touched -- **Checks:** each check run and its result -- **Not verified:** rendering not checked visually, unconfirmed Hypixel keys, and anything else you couldn't check - -## Running locally (main checkout only) - -Done by the maintainer in the main checkout. Agents in worktrees must not do these steps. - -Need: `config.js` at the repo root, following `config.schema.js`. Use `config.js`, not `config.json`: the loader tries `config.json` first but node rejects importing it, so a `config.json` breaks every app. - -- Required: `database.mongoUri` and `database.redisUrl`, `hypixelApi.key`, `api.port` and `api.mediaRoot`, `discordBot.token`, `discordBot.applicationId` and `discordBot.publicKey`, `apiClient.key` and `apiClient.route` (the bot calls your local API), `supportBot.guild` and `supportBot.memberRole` (the bot's verify commands read them at startup), and `environment`. -- Optional: `hypixelApi.timeout` (defaults to 5000), `discordBot.testingGuild` (lets commands register instantly), `discordBot.port`, `api.ignoreAuth`, and Sentry. With a Sentry DSN set, `sentry.tracesSampleRate` is required too. - -Run it: start MongoDB and Redis (`compose.dev.yml`), then `pnpm build:watch`, then `pnpm api start` and `pnpm discord-bot start`. Apps resolve assets through `../../assets`, so always start them with `pnpm `. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 43c994c2d..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md From b250880b9e5964b1fa92551b8b89d47f572beeb8 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:39:03 -0400 Subject: [PATCH 42/45] chore: stop tracking t3.json T3 settings are personal, like the agent instruction files, so t3.json stays local. setup-worktree still works as a T3 worktree-creation script or when run by hand. --- apps/scripts/src/setup-worktree.ts | 2 +- t3.json | 19 ------------------- 2 files changed, 1 insertion(+), 20 deletions(-) delete mode 100644 t3.json diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index 7510120b1..6320b6ea7 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -8,7 +8,7 @@ /** * Prepares a checkout (the main clone or a linked git worktree) for development. - * T3 Code runs it when it creates a worktree (see t3.json). + * Run it in each new worktree, e.g. as a T3 Code script that runs on worktree creation. * * Only uses node builtins because it runs before `pnpm install`. Node runs it as * TypeScript by stripping the types, so it only uses erasable syntax. diff --git a/t3.json b/t3.json deleted file mode 100644 index e8dca39ce..000000000 --- a/t3.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://t3.codes/schema/t3.json", - "defaultThreadEnvMode": "worktree", - "worktreeSubmodules": "none", - "scripts": [ - { - "name": "Setup worktree", - "command": "node apps/scripts/src/setup-worktree.ts", - "icon": "configure", - "runOnWorktreeCreate": true, - "async": false - }, - { - "name": "Verify", - "command": "pnpm build && pnpm typecheck --affected && pnpm lint --affected && pnpm test --run", - "icon": "test" - } - ] -} From 1189e13cd3bd0d14d5c24da9e8bffb8c04e3bcb6 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:38:15 -0400 Subject: [PATCH 43/45] chore(scripts): trim the setup-worktree header comment --- apps/scripts/src/setup-worktree.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index 6320b6ea7..bc4c97301 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -13,19 +13,6 @@ * Only uses node builtins because it runs before `pnpm install`. Node runs it as * TypeScript by stripping the types, so it only uses erasable syntax. * - * 1. Links `config.js`, and `AGENTS.md` and `CLAUDE.md` if present, from the main - * checkout - * 2. Checks out each asset submodule at the commit this branch pins, as a git - * worktree of the main checkout's submodule repo (no re-clone), and moves - * existing clean, detached checkouts to the pinned commit. In a worktree, a - * submodule the main checkout hasn't set up is skipped instead of cloned - * 3. Links the ignored minecraft texture pack from the main checkout - * 4. Installs dependencies (this also blurs the public backgrounds) - * 5. Restores the blurred private backgrounds from a cache shared by all - * checkouts, or generates and caches them - * 6. Builds the monorepo. turbo stores the cache of every linked worktree in the - * main checkout's .turbo/cache, so this is mostly cache hits - * * Safe to run in many new worktrees at once: cache entries are written to a * temporary folder and renamed into place, restores are checked against the * backgrounds in git, and fetches that lose a lock race are retried. @@ -557,6 +544,7 @@ linkAgentInstructions(); for (const submodule of submodules()) setupSubmodule(submodule); linkTexturePack(); +// Also blurs the public backgrounds, through their postinstall run("pnpm", ["install", "--frozen-lockfile"]); // The backgrounds are only needed when an app renders, so a failed blur must not From 307099b738c011d14df9c74b6460f90cbc0e6f8c Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:32:59 -0400 Subject: [PATCH 44/45] fix(scripts): don't prompt for credentials when setup clones a submodule --- apps/scripts/src/setup-worktree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/scripts/src/setup-worktree.ts b/apps/scripts/src/setup-worktree.ts index bc4c97301..7c9798b07 100644 --- a/apps/scripts/src/setup-worktree.ts +++ b/apps/scripts/src/setup-worktree.ts @@ -283,7 +283,7 @@ function setupSubmodule({ name, path }: Submodule) { if (!IS_LINKED_WORKTREE) { try { - run("git", ["submodule", "update", "--init", path]); + run("git", ["submodule", "update", "--init", path], ROOT, FETCH_ENV); } catch { log("Submodule", `could not clone ${path}, continuing without it`); } From a620e5064b995710dfd5bb5d7c0484c7e66ecfc0 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:49:48 -0400 Subject: [PATCH 45/45] fix(scripts): make validate-commands work with zod 4 and node 24 --- apps/scripts/src/validate-commands.js | 38 +++++++++++++-------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/scripts/src/validate-commands.js b/apps/scripts/src/validate-commands.js index a99e87b61..2e431e9a9 100644 --- a/apps/scripts/src/validate-commands.js +++ b/apps/scripts/src/validate-commands.js @@ -7,17 +7,17 @@ */ import commands from "discord-bot/commands.json" with { type: "json" }; -import z from "zod"; +import { z } from "zod"; function toEnum(...args) { return z.union(args.map((arg) => z.literal(arg))); } const name = z.string().min(3).max(32); -const name_localizations = z.optional(z.record(name)); +const name_localizations = z.optional(z.record(z.string(), name)); const description = z.string().min(1).max(100); -const description_localizations = z.optional(z.record(description)); +const description_localizations = z.optional(z.record(z.string(), description)); const choicesSchema = z.object({ // this is intentional since choice names can go up to 100 characters @@ -52,27 +52,27 @@ const commandSchema = z.object({ type: z.optional(toEnum(1, 2, 3)), }); +let hasInvalidCommand = false; + for (const [commandName, command] of Object.entries(commands.commands)) { - try { - commandSchema.parse(command); - } catch (e) { - console.error(e); - console.log(command); - - for (const error of e.errors) { - console.error(error.message); - console.error(`${command.name}.${error.path.join(".")}`); - } - - console.error(`Command "${commandName}" is invalid.`); - process.exit(1); + const result = commandSchema.safeParse(command); + if (result.success) continue; + + for (const issue of result.error.issues) { + console.error(issue.message); + console.error(`${command.name}.${issue.path.join(".")}`); } + + console.error(`Command "${commandName}" is invalid.`); + hasInvalidCommand = true; } +if (hasInvalidCommand) process.exit(1); + const commandChars = {}; -const findLongestLocalizationLength = (localizations) => - Math.max(...Object.values(localizations).map((l) => l.length)); +const findLongestLocalizationLength = (localizations = {}) => + Math.max(0, ...Object.values(localizations).map((l) => l.length)); const findOptionsLength = (options = []) => options @@ -81,7 +81,7 @@ const findOptionsLength = (options = []) => option.name.length + findLongestLocalizationLength(option.description_localizations) + ( - option?.choices?.map((choice) => choice.value.length + choice.name.length) ?? [] + option?.choices?.map((choice) => `${choice.value}`.length + choice.name.length) ?? [] ).reduce((a, b) => a + b, 0) ) .reduce((a, b) => a + b, 0);