From 32aee466ff348ce490f53c101051578ecd5bd69e Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 8 Oct 2026 17:56:07 +0000 Subject: [PATCH 01/13] build: format with Biome, keeping Prettier only for Vue/HTML/SCSS/Less Biome already ran in every repo through the biome-check hook, but with its formatter disabled it only sorted imports. Enable the formatter (2 spaces, 120 columns, otherwise Biome defaults) and narrow the Prettier hook to the file types Biome cannot format yet (SCSS, Less) or only formats behind an experimental flag (Vue, HTML). Biome skips .vue files through an override so the two formatters never rewrite the same file. Biome also covers .js/.mjs/.cjs, .jsonc and tsconfig.json, which the Prettier hook's file pattern never matched; the files it reformats here are included. CSS is no longer excluded now that the Tailwind directive parser is enabled. Bump the biomejs/pre-commit hook to v2.5.15, swap the VS Code formatter mappings to the Biome extension, and install the same Biome version globally in the devcontainer so the extension can find it. --- .claude/hooks/caveman-config.js | 51 +++++---- .claude/hooks/caveman-mode-tracker.js | 101 ++++++++++-------- .claude/settings/hooks.jsonc | 22 ++-- .claude/settings/models.jsonc | 20 ++-- .../permissions/additional-dirs.jsonc | 4 +- .claude/settings/permissions/bash.jsonc | 10 +- .claude/settings/permissions/edit.jsonc | 4 +- .claude/settings/permissions/read.jsonc | 4 +- .devcontainer/devcontainer.json | 9 +- .../on-create-command-boilerplate.sh | 3 + .pre-commit-config.yaml | 19 +--- biome.jsonc | 34 ++++-- .../devcontainer.json.jinja-base | 7 +- .../istanbul-ignore-if-must-throw.mjs | 3 +- 14 files changed, 165 insertions(+), 126 deletions(-) diff --git a/.claude/hooks/caveman-config.js b/.claude/hooks/caveman-config.js index a06fda4bb..0670daf38 100644 --- a/.claude/hooks/caveman-config.js +++ b/.claude/hooks/caveman-config.js @@ -9,31 +9,36 @@ // - %APPDATA%\caveman\config.json (Windows fallback) // 3. 'full' -const fs = require('fs'); -const path = require('path'); -const os = require('os'); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); const VALID_MODES = [ - 'off', 'lite', 'full', 'ultra', - 'wenyan-lite', 'wenyan', 'wenyan-full', 'wenyan-ultra', - 'commit', 'review', 'compress' + "off", + "lite", + "full", + "ultra", + "wenyan-lite", + "wenyan", + "wenyan-full", + "wenyan-ultra", + "commit", + "review", + "compress", ]; function getConfigDir() { if (process.env.XDG_CONFIG_HOME) { - return path.join(process.env.XDG_CONFIG_HOME, 'caveman'); + return path.join(process.env.XDG_CONFIG_HOME, "caveman"); } - if (process.platform === 'win32') { - return path.join( - process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), - 'caveman' - ); + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "caveman"); } - return path.join(os.homedir(), '.config', 'caveman'); + return path.join(os.homedir(), ".config", "caveman"); } function getConfigPath() { - return path.join(getConfigDir(), 'config.json'); + return path.join(getConfigDir(), "config.json"); } function getDefaultMode() { @@ -46,7 +51,7 @@ function getDefaultMode() { // 2. Config file try { const configPath = getConfigPath(); - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); if (config.defaultMode && VALID_MODES.includes(config.defaultMode.toLowerCase())) { return config.defaultMode.toLowerCase(); } @@ -55,7 +60,7 @@ function getDefaultMode() { } // 3. Default - return 'full'; + return "full"; } // Symlink-safe flag file write. @@ -86,17 +91,21 @@ function safeWriteFlag(flagPath, content) { try { if (fs.lstatSync(flagPath).isSymbolicLink()) return; } catch (e) { - if (e.code !== 'ENOENT') return; + if (e.code !== "ENOENT") return; } const tempPath = path.join(flagDir, `.caveman-active.${process.pid}.${Date.now()}`); - const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW; let fd; try { fd = fs.openSync(tempPath, flags, 0o600); fs.writeSync(fd, String(content)); - try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ } + try { + fs.fchmodSync(fd, 0o600); + } catch (e) { + /* best-effort on Windows */ + } } finally { if (fd !== undefined) fs.closeSync(fd); } @@ -130,7 +139,7 @@ function readFlag(flagPath) { if (st.isSymbolicLink() || !st.isFile()) return null; if (st.size > MAX_FLAG_BYTES) return null; - const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; + const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0; const flags = fs.constants.O_RDONLY | O_NOFOLLOW; let fd; let out; @@ -138,7 +147,7 @@ function readFlag(flagPath) { fd = fs.openSync(flagPath, flags); const buf = Buffer.alloc(MAX_FLAG_BYTES); const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0); - out = buf.slice(0, n).toString('utf8'); + out = buf.slice(0, n).toString("utf8"); } finally { if (fd !== undefined) fs.closeSync(fd); } diff --git a/.claude/hooks/caveman-mode-tracker.js b/.claude/hooks/caveman-mode-tracker.js index 1cc2e35b9..660d49f0f 100644 --- a/.claude/hooks/caveman-mode-tracker.js +++ b/.claude/hooks/caveman-mode-tracker.js @@ -2,69 +2,79 @@ // caveman — UserPromptSubmit hook to track which caveman mode is active // Inspects user input for /caveman commands and writes mode to flag file -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { getDefaultMode, safeWriteFlag, readFlag } = require('./caveman-config'); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { getDefaultMode, safeWriteFlag, readFlag } = require("./caveman-config"); -const claudeDir = path.join(process.env.PROJECT_ROOT_DIR || os.homedir(), '.claude'); -const flagPath = path.join(claudeDir, '.caveman-active'); +const claudeDir = path.join(process.env.PROJECT_ROOT_DIR || os.homedir(), ".claude"); +const flagPath = path.join(claudeDir, ".caveman-active"); -let input = ''; -process.stdin.on('data', chunk => { input += chunk; }); -process.stdin.on('end', () => { +let input = ""; +process.stdin.on("data", (chunk) => { + input += chunk; +}); +process.stdin.on("end", () => { try { const data = JSON.parse(input); - const prompt = (data.prompt || '').trim().toLowerCase(); + const prompt = (data.prompt || "").trim().toLowerCase(); // Natural language activation (e.g. "activate caveman", "turn on caveman mode", // "talk like caveman"). README tells users they can say these, but the hook // only matched /caveman commands — flag file and statusline stayed out of sync. - if (/\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) || - /\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt)) { + if ( + /\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) || + /\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt) + ) { if (!/\b(stop|disable|turn off|deactivate)\b/i.test(prompt)) { const mode = getDefaultMode(); - if (mode !== 'off') { + if (mode !== "off") { safeWriteFlag(flagPath, mode); } } } // Match /caveman commands - if (prompt.startsWith('/caveman')) { + if (prompt.startsWith("/caveman")) { const parts = prompt.split(/\s+/); const cmd = parts[0]; // /caveman, /caveman-commit, /caveman-review, etc. - const arg = parts[1] || ''; + const arg = parts[1] || ""; let mode = null; - if (cmd === '/caveman-commit') { - mode = 'commit'; - } else if (cmd === '/caveman-review') { - mode = 'review'; - } else if (cmd === '/caveman-compress' || cmd === '/caveman:caveman-compress') { - mode = 'compress'; - } else if (cmd === '/caveman' || cmd === '/caveman:caveman') { - if (arg === 'lite') mode = 'lite'; - else if (arg === 'ultra') mode = 'ultra'; - else if (arg === 'wenyan-lite') mode = 'wenyan-lite'; - else if (arg === 'wenyan' || arg === 'wenyan-full') mode = 'wenyan'; - else if (arg === 'wenyan-ultra') mode = 'wenyan-ultra'; + if (cmd === "/caveman-commit") { + mode = "commit"; + } else if (cmd === "/caveman-review") { + mode = "review"; + } else if (cmd === "/caveman-compress" || cmd === "/caveman:caveman-compress") { + mode = "compress"; + } else if (cmd === "/caveman" || cmd === "/caveman:caveman") { + if (arg === "lite") mode = "lite"; + else if (arg === "ultra") mode = "ultra"; + else if (arg === "wenyan-lite") mode = "wenyan-lite"; + else if (arg === "wenyan" || arg === "wenyan-full") mode = "wenyan"; + else if (arg === "wenyan-ultra") mode = "wenyan-ultra"; else mode = getDefaultMode(); } - if (mode && mode !== 'off') { + if (mode && mode !== "off") { safeWriteFlag(flagPath, mode); - } else if (mode === 'off') { - try { fs.unlinkSync(flagPath); } catch (e) {} + } else if (mode === "off") { + try { + fs.unlinkSync(flagPath); + } catch (e) {} } } // Detect deactivation — natural language and slash commands - if (/\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) || - /\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) || - /\bnormal mode\b/i.test(prompt)) { - try { fs.unlinkSync(flagPath); } catch (e) {} + if ( + /\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) || + /\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) || + /\bnormal mode\b/i.test(prompt) + ) { + try { + fs.unlinkSync(flagPath); + } catch (e) {} } // Per-turn reinforcement: emit a structured reminder when caveman is active. @@ -78,17 +88,22 @@ process.stdin.on('end', () => { // If the flag is missing, corrupted, oversized, or a symlink pointing at // something like ~/.ssh/id_rsa, readFlag returns null and we emit nothing // — never inject untrusted bytes into model context. - const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']); + const INDEPENDENT_MODES = new Set(["commit", "review", "compress"]); const activeMode = readFlag(flagPath); if (activeMode && !INDEPENDENT_MODES.has(activeMode)) { - process.stdout.write(JSON.stringify({ - hookSpecificOutput: { - hookEventName: "UserPromptSubmit", - additionalContext: "CAVEMAN MODE ACTIVE (" + activeMode + "). " + - "Drop articles/filler/pleasantries/hedging. Fragments OK. " + - "Code/commits/security: write normal." - } - })); + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: + "CAVEMAN MODE ACTIVE (" + + activeMode + + "). " + + "Drop articles/filler/pleasantries/hedging. Fragments OK. " + + "Code/commits/security: write normal.", + }, + }), + ); } } catch (e) { // Silent fail diff --git a/.claude/settings/hooks.jsonc b/.claude/settings/hooks.jsonc index 9b94acdb7..98b2c67a2 100644 --- a/.claude/settings/hooks.jsonc +++ b/.claude/settings/hooks.jsonc @@ -8,16 +8,16 @@ "type": "command", "command": "sh -c 'SESSION=$(jq -r \".session_id\" 2>/dev/null); DIR=\"/tmp/claude-$(id -u)\"; mkdir -p \"$DIR\"; printf \"%s\" \"$(git rev-parse --show-toplevel)\" > \"$DIR/.claude_project_root_${SESSION}\"'", "timeout": 5, - "statusMessage": "Caching project root...", + "statusMessage": "Caching project root..." }, { "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/caveman-activate.js\"", "timeout": 5, - "statusMessage": "Loading caveman mode...", - }, - ], - }, + "statusMessage": "Loading caveman mode..." + } + ] + } ], "UserPromptSubmit": [ { @@ -26,10 +26,10 @@ "type": "command", "command": "sh -c 'INPUT=$(cat); SESSION=$(printf \"%s\" \"$INPUT\" | jq -r \".session_id\" 2>/dev/null); ROOT=$(cat \"/tmp/claude-$(id -u)/.claude_project_root_${SESSION}\"); printf \"%s\" \"$INPUT\" | PROJECT_ROOT_DIR=\"$ROOT\" node \"$ROOT/.claude/hooks/caveman-mode-tracker.js\"'", "timeout": 5, - "statusMessage": "Tracking caveman mode...", - }, - ], - }, - ], - }, + "statusMessage": "Tracking caveman mode..." + } + ] + } + ] + } } diff --git a/.claude/settings/models.jsonc b/.claude/settings/models.jsonc index 09ff5dbf6..8a5408811 100644 --- a/.claude/settings/models.jsonc +++ b/.claude/settings/models.jsonc @@ -1,12 +1,12 @@ { - "availableModels": [ - "fable", - "sonnet", - "opus", - "haiku", - "claude-opus-5", - "claude-opus-4-8", - "claude-sonnet-5", - "claude-sonnet-4-6" - ] + "availableModels": [ + "fable", + "sonnet", + "opus", + "haiku", + "claude-opus-5", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-sonnet-4-6" + ] } diff --git a/.claude/settings/permissions/additional-dirs.jsonc b/.claude/settings/permissions/additional-dirs.jsonc index ee3026bfa..c4cd7b022 100644 --- a/.claude/settings/permissions/additional-dirs.jsonc +++ b/.claude/settings/permissions/additional-dirs.jsonc @@ -2,6 +2,6 @@ // Additional directories that Claude Code can access "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { - "additionalDirectories": ["/tmp", "/workspaces"], - }, + "additionalDirectories": ["/tmp", "/workspaces"] + } } diff --git a/.claude/settings/permissions/bash.jsonc b/.claude/settings/permissions/bash.jsonc index ccf90bcb6..a226956f7 100644 --- a/.claude/settings/permissions/bash.jsonc +++ b/.claude/settings/permissions/bash.jsonc @@ -121,7 +121,7 @@ "Bash(sort *)", "Bash(tail *)", // Search - "Bash(rg *)", + "Bash(rg *)" // Research ], "ask": [ @@ -135,7 +135,7 @@ "Bash(gh pr close *)", "Bash(gh pr comment *)", "Bash(gh pr create *)", - "Bash(gh pr edit *)", + "Bash(gh pr edit *)" ], "deny": [ // Exceptions to generally allowed AI tooling @@ -197,7 +197,7 @@ "Bash(usermod *)", // System Control "Bash(reboot *)", - "Bash(shutdown *)", - ], - }, + "Bash(shutdown *)" + ] + } } diff --git a/.claude/settings/permissions/edit.jsonc b/.claude/settings/permissions/edit.jsonc index a2906ad5c..e2051fadd 100644 --- a/.claude/settings/permissions/edit.jsonc +++ b/.claude/settings/permissions/edit.jsonc @@ -6,6 +6,6 @@ // A single leading slash anchors at the settings source, so absolute paths need the `//` prefix "allow": ["Edit(//tmp/**)", "Edit(//workspaces/**/tmp/**)", "Edit(tmp/**)"], // Agents are allowed to run any task, but this provides some basic protection against editing a task to do something nefarious that's normally forbidden. - "ask": ["Edit(Taskfile.yaml)", "Edit(.config/taskfiles/**)"], - }, + "ask": ["Edit(Taskfile.yaml)", "Edit(.config/taskfiles/**)"] + } } diff --git a/.claude/settings/permissions/read.jsonc b/.claude/settings/permissions/read.jsonc index 7916e1e87..c9d6b16dc 100644 --- a/.claude/settings/permissions/read.jsonc +++ b/.claude/settings/permissions/read.jsonc @@ -4,6 +4,6 @@ "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { "allow": ["Read(~/.zshrc)"], - "deny": ["Read(~/Drive/Secrets/**)", "Read(~/.ssh/**)", "Read(**/.env*)", "Read(~/.aws/**)"], - }, + "deny": ["Read(~/Drive/Secrets/**)", "Read(~/.ssh/**)", "Read(**/.env*)", "Read(~/.aws/**)"] + } } diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9d12583a1..bf5bf12e9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -43,7 +43,7 @@ "samuelcolvin.jinjahtml@0.20.0", "tamasfe.even-better-toml@0.19.2", "emilast.LogFileHighlighter@3.3.3", - "esbenp.prettier-vscode@12.4.0" + "biomejs.biome@2026.8.190716" ], "settings": { "editor.accessibilitySupport": "off", // turn off sounds @@ -60,9 +60,8 @@ // TODO: see if there's a way to specify different configurations for different folders "ruff.configuration": "/workspaces/copier-base-template/.config/ruff-non-src.toml", // use the non-src configuration since it's less restrictive and won't show false positives and underline things "python.pyrefly.configPath": "/workspaces/copier-base-template/.config/pyrefly.toml", - "prettier.configPath": "/workspaces/copier-base-template/.config/.prettierrc", - "[jsonc][json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", + "[jsonc][json][javascript][typescript]": { + "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true } } @@ -71,5 +70,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): e3cf4594 # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 13212494 # spellchecker:disable-line } diff --git a/.devcontainer/on-create-command-boilerplate.sh b/.devcontainer/on-create-command-boilerplate.sh index 31e708845..5e664c6d6 100644 --- a/.devcontainer/on-create-command-boilerplate.sh +++ b/.devcontainer/on-create-command-boilerplate.sh @@ -3,6 +3,9 @@ set -ex python .devcontainer/install-ci-tooling.py +# The Biome VS Code extension finds this global install; keep the version matching the biomejs/pre-commit rev in .pre-commit-config.yaml +npm install -g @biomejs/biome@2.5.15 + git config --global --add --bool push.autoSetupRemote true git config --local core.symlinks true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6e66ab286..ee59c6d26 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -79,7 +79,7 @@ repos: )$ - repo: https://github.com/biomejs/pre-commit - rev: a450e9c04887b1305951c298db5999cd30155f74 # frozen: v2.1.1 + rev: d1d67cb1a73ea0917eb553c4f025e212c8cd0465 # frozen: v2.5.15 hooks: - id: biome-check # exclude generated files from codegen and strawberry @@ -95,28 +95,15 @@ repos: hooks: - id: prettier args: [--config, .config/.prettierrc] + # Biome formats everything else. These are the types it cannot format yet (SCSS, Less) or only formats experimentally (Vue, HTML) # TODO: get template YAML and MD files more in line with prettier expectations so we can start using prettier on those too exclude: | (?x)^( - .devcontainer/devcontainer.json| - .devcontainer/devcontainer-lock.json| - .*devcontainer.json| - .*tsconfig.json| .*/__snapshots__/.*| - .*.min.js| - .*.min.css| - .*.umd.js| - .*.yaml| - .*.yml| - .*.md| - .*.jsonc| .*/vendor_files/.*| - .*/schema.graphql| - .*/generated/open[-_]api/.*| - .*generated/graphql.ts| template/.*| )$ - files: (.*.json)|(.*.ts)|(.*.jsx)|(.*.tsx)|(.*.yaml)|(.*.yml)|(.*.md)|(.*.html)|(.*.css)|(.*.scss)|(.*.less)|(.*.vue)|(.*.graphql)|(.*.gql) + files: \.(vue|html|scss|less)$ - repo: https://github.com/pamoller/xmlformatter diff --git a/biome.jsonc b/biome.jsonc index e0ea5856c..8184f183a 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,16 +1,38 @@ { "formatter": { - "enabled": false, + "indentStyle": "space", + "lineWidth": 120 }, "linter": { - "enabled": false, + "enabled": false + }, + "css": { + "parser": { + "tailwindDirectives": true + } }, "files": { "includes": [ "**", - "!template/template/biome.jsonc", - "!template/biome.jsonc", - "!**/*.css", // still too many false positives, so we exclude CSS files - ], + // force-ignored (!!) so the scanner does not reject the generated repos' own root configs as nested root configs + "!!template/template/biome.jsonc", + "!!template/biome.jsonc", + "!**/__snapshots__", + "!**/vendor_files", + "!**/*.min.js", + "!**/*.min.css", + "!**/*.umd.js", + "!**/devcontainer.json", // rendered from the copier template, then stamped by the devcontainer context hash hook + "!**/devcontainer-lock.json" // written by the devcontainer CLI + ] }, + "overrides": [ + { + // Prettier formats Vue files until Biome's Vue support (html.experimentalFullSupportEnabled) is stable + "includes": ["**/*.vue"], + "formatter": { + "enabled": false + } + } + ] } diff --git a/template/.devcontainer/devcontainer.json.jinja-base b/template/.devcontainer/devcontainer.json.jinja-base index 209ff26b3..ddee575d6 100644 --- a/template/.devcontainer/devcontainer.json.jinja-base +++ b/template/.devcontainer/devcontainer.json.jinja-base @@ -58,6 +58,7 @@ "samuelcolvin.jinjahtml@0.20.0", "tamasfe.even-better-toml@0.19.2", "emilast.LogFileHighlighter@3.3.3", + "biomejs.biome@2026.8.190716", "esbenp.prettier-vscode@12.4.0" ], "settings": { @@ -76,7 +77,11 @@ "ruff.configuration": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/ruff-non-src.toml", // use the non-src configuration since it's less restrictive and won't show false positives and underline things "python.pyrefly.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/pyrefly.toml",{% endraw %}{% endif %}{% raw %} "prettier.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/.prettierrc", - "[jsonc][json][javascript][typescript][graphql][css][scss][html][vue]": { + "[jsonc][json][javascript][typescript][graphql][css]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true + }, + "[vue][html][scss][less]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true } diff --git a/template/copier_template_resources/{% if template_uses_typescript %}eslint{% endif %}/istanbul-ignore-if-must-throw.mjs b/template/copier_template_resources/{% if template_uses_typescript %}eslint{% endif %}/istanbul-ignore-if-must-throw.mjs index e9ca58ff1..d103dfd93 100644 --- a/template/copier_template_resources/{% if template_uses_typescript %}eslint{% endif %}/istanbul-ignore-if-must-throw.mjs +++ b/template/copier_template_resources/{% if template_uses_typescript %}eslint{% endif %}/istanbul-ignore-if-must-throw.mjs @@ -46,8 +46,7 @@ export default { meta: { type: "problem", docs: { - description: - "Require `istanbul ignore if` branches to throw a defensive assertion naming the violated invariant", + description: "Require `istanbul ignore if` branches to throw a defensive assertion naming the violated invariant", }, schema: [], messages: { From 1ad3d0fb63e2ddbcd458b5f4e2995db9aab16a0c Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 8 Oct 2026 18:35:57 +0000 Subject: [PATCH 02/13] build: note the global Biome install next to the Biome hook so version bumps update both --- .devcontainer/devcontainer.json | 2 +- .pre-commit-config.yaml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index bf5bf12e9..63fba3d9d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -70,5 +70,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 13212494 # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): b2a95836 # spellchecker:disable-line } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ee59c6d26..007781d3f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -78,6 +78,7 @@ repos: .*/vendor_files/.*| )$ + # Keep the global @biomejs/biome install in .devcontainer/on-create-command-boilerplate.sh on the same version (the VS Code extension uses it) - repo: https://github.com/biomejs/pre-commit rev: d1d67cb1a73ea0917eb553c4f025e212c8cd0465 # frozen: v2.5.15 hooks: From 21795ab14806d7998048018a5ff53aebb86ba2c1 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 8 Oct 2026 19:23:49 +0000 Subject: [PATCH 03/13] build: keep Biome away from generated/ folders so codegen output stays as generated --- biome.jsonc | 1 + 1 file changed, 1 insertion(+) diff --git a/biome.jsonc b/biome.jsonc index 8184f183a..f11fe94ee 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -19,6 +19,7 @@ "!!template/biome.jsonc", "!**/__snapshots__", "!**/vendor_files", + "!**/generated", // codegen output stays exactly as the generator wrote it, so regenerating never shows formatting diffs "!**/*.min.js", "!**/*.min.css", "!**/*.umd.js", From 1442b4791e8a97b626fee5d5642f284bce5fc905 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 8 Oct 2026 19:26:02 +0000 Subject: [PATCH 04/13] build: make biome.jsonc the single place that decides what Biome skips The biome-check hook's exclude patterns are now covered by biome.jsonc (generated/ folders and the Strawberry schema export), which also keeps VS Code's format-on-save away from those files. Keep VALID_MODES in the caveman config on one line with a biome-ignore format suppression instead of one mode per line. --- .claude/hooks/caveman-config.js | 15 ++------------- .devcontainer/devcontainer.json | 2 +- .pre-commit-config.yaml | 7 ------- biome.jsonc | 1 + 4 files changed, 4 insertions(+), 21 deletions(-) diff --git a/.claude/hooks/caveman-config.js b/.claude/hooks/caveman-config.js index 0670daf38..716396731 100644 --- a/.claude/hooks/caveman-config.js +++ b/.claude/hooks/caveman-config.js @@ -13,19 +13,8 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); -const VALID_MODES = [ - "off", - "lite", - "full", - "ultra", - "wenyan-lite", - "wenyan", - "wenyan-full", - "wenyan-ultra", - "commit", - "review", - "compress", -]; +// biome-ignore format: the whole mode list reads best on one line +const VALID_MODES = ["off", "lite", "full", "ultra", "wenyan-lite", "wenyan", "wenyan-full", "wenyan-ultra", "commit", "review", "compress"]; function getConfigDir() { if (process.env.XDG_CONFIG_HOME) { diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 63fba3d9d..abb8082a8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -70,5 +70,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): b2a95836 # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 2af01bf6 # spellchecker:disable-line } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 007781d3f..f847088c0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -83,13 +83,6 @@ repos: rev: d1d67cb1a73ea0917eb553c4f025e212c8cd0465 # frozen: v2.5.15 hooks: - id: biome-check - # exclude generated files from codegen and strawberry - exclude: | - (?x)^( - .*generated/graphql.ts| - .*/generated/open[-_]api/.*| - .*/schema.graphql| - )$ - repo: https://github.com/rbubley/mirrors-prettier rev: 0ee178619d696787ca73d210cc191d720868c631 # frozen: v3.9.6 diff --git a/biome.jsonc b/biome.jsonc index f11fe94ee..720aba3d8 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -20,6 +20,7 @@ "!**/__snapshots__", "!**/vendor_files", "!**/generated", // codegen output stays exactly as the generator wrote it, so regenerating never shows formatting diffs + "!**/schema.graphql", // exported by Strawberry from the Python schema "!**/*.min.js", "!**/*.min.css", "!**/*.umd.js", From 8ed599203b2b70025273e20eab1024cf83258428 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 8 Oct 2026 19:34:43 +0000 Subject: [PATCH 05/13] build: render devcontainer.json without trailing commas so Biome can format it Optional entries in devcontainer.json.jinja-base now carry their separating comma inside their own conditional, the same way the CI templates put an optional line's newline inside its conditional, and the codespaces repositories loop only writes a comma between entries. Every rendering is now free of trailing commas (checked across 52 answer combinations at both template levels), so biome.jsonc no longer needs to exclude devcontainer.json. --- biome.jsonc | 3 +-- .../.devcontainer/devcontainer.json.jinja-base | 16 ++++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/biome.jsonc b/biome.jsonc index 720aba3d8..1ee3bfe68 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -24,8 +24,7 @@ "!**/*.min.js", "!**/*.min.css", "!**/*.umd.js", - "!**/devcontainer.json", // rendered from the copier template, then stamped by the devcontainer context hash hook - "!**/devcontainer-lock.json" // written by the devcontainer CLI + "!**/devcontainer-lock.json" // written by the devcontainer CLI, including in Dependabot's devcontainers updates ] }, "overrides": [ diff --git a/template/.devcontainer/devcontainer.json.jinja-base b/template/.devcontainer/devcontainer.json.jinja-base index ddee575d6..669880401 100644 --- a/template/.devcontainer/devcontainer.json.jinja-base +++ b/template/.devcontainer/devcontainer.json.jinja-base @@ -12,20 +12,20 @@ // and it breaks creation of the devcontainer since pre-commit hooks are already in place. Probably a better way to fix this but until we use git-lfs we can leave it like this. // https://github.com/devcontainers/features/blob/main/src/git-lfs/README.md "autoPull": false - },{% endraw %}{% if is_child_of_copier_base_template is not defined %}{% raw %} + }{% endraw %}{% if is_child_of_copier_base_template is not defined %}{% raw %}, "ghcr.io/devcontainers/features/aws-cli:1.1.4": { // https://github.com/devcontainers/features/blob/main/src/aws-cli/devcontainer-feature.json // view latest version https://raw.githubusercontent.com/aws/aws-cli/v2/CHANGELOG.rst "version": "2.36.49" - },{% endraw %}{% endif %}{% raw %}{% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_typescript is defined and template_uses_typescript is sameas(true) %}{% raw %} + }{% endraw %}{% endif %}{% raw %}{% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_typescript is defined and template_uses_typescript is sameas(true) %}{% raw %}, "ghcr.io/devcontainers/features/node:2.1.0": { // https://github.com/devcontainers/features/blob/main/src/node/devcontainer-feature.json "version": "{% endraw %}{{ node_version }}{% raw %}", "pnpmVersion": "{% endraw %}{{ pnpm_version }}{% raw %}", - "nvmVersion": "{% endraw %}{{ nvm_version }}{% raw %}", - },{% endraw %}{% endif %}{% raw %}{% endraw %}{% if install_claude_cli %}{% raw %} + "nvmVersion": "{% endraw %}{{ nvm_version }}{% raw %}" + }{% endraw %}{% endif %}{% raw %}{% endraw %}{% if install_claude_cli %}{% raw %}, // https://github.com/anthropics/devcontainer-features/blob/main/src/claude-code/devcontainer-feature.json - "ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5": {},{% endraw %}{% endif %}{% raw %} + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5": {}{% endraw %}{% endif %}{% raw %} }, "customizations": { "vscode": { @@ -86,14 +86,14 @@ "editor.formatOnSave": true } } - },{% endraw %}{% if repo_names_for_codespaces_read_permissions is defined and repo_names_for_codespaces_read_permissions.strip() %}{% set _cs_repos = repo_names_for_codespaces_read_permissions.split(',') | map('trim') | reject('equalto', '') | list %}{% if _cs_repos %} + }{% endraw %}{% if repo_names_for_codespaces_read_permissions is defined and repo_names_for_codespaces_read_permissions.strip() %}{% set _cs_repos = repo_names_for_codespaces_read_permissions.split(',') | map('trim') | reject('equalto', '') | list %}{% if _cs_repos %}, "codespaces": { "repositories": { {% for cs_repo_item in _cs_repos %} {{ (repo_org_name ~ "/" ~ cs_repo_item) | tojson }}: { "permissions": { "contents": "read" } - }, + }{% if not loop.last %},{% endif %} {% endfor %} } - },{% endif %}{% endif %}{% raw %} + }{% endif %}{% endif %}{% raw %} }, "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", From 08c401059e6d4e290897cf6b01500f1cfc1f7167 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 8 Oct 2026 19:51:39 +0000 Subject: [PATCH 06/13] docs: keep private repository names out of public posts while still reporting the work done there --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index ef7fe86da..8f7fab62b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,10 @@ This project is a Copier template used to generate other copier templates. It is - Before saving any memory or adding any rule, explicitly ask the user whether the concept should be: (1) added to AGENTS.md as a general rule applicable across all projects, (2) added to AGENTS.md as a rule specific to this project, or (3) stored as a temporary local memory only relevant to the current active work. The devcontainer environment is ephemeral, so local memory files are rarely the right choice. +## Public Repositories + +- Before posting to a repository (PR or issue text, comments, commit messages, file contents), check its visibility (`gh repo view / --json visibility`). In a public repository, never name, link to, or quote a private repository. Still say when relevant work happened in one, such as testing in a private repo that depends on this one, but anonymize it ("tested in a private repo") and give only the details needed to make the point. + ## Tooling - Before hand-assembling a multi-step workflow, run `task --list` — it is probably already a task. Definitions live in `.config/taskfiles/`; the root `Taskfile.yaml` only includes them. From 17bff4072c353b680a142de9a79ba71b67117c72 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Thu, 8 Oct 2026 20:49:56 +0000 Subject: [PATCH 07/13] build: close permission gaps around the GitHub API, repo changes and localhost curl - Deny gh api, gh auth token and gh auth status -t/--show-token: raw API access or the token itself gets around every other gh rule. Scripts that need the API, like the address-pr-comments skill, call it themselves. - Deny the gh repo subcommands that create or change repositories so they take a deliberate run with `!`. Fork still prompts, and view is allowed because AGENTS.md has Claude check repository visibility with it. - Remove the localhost curl allow rules. Their trailing wildcard also matched other hosts, extra URLs, uploads and file writes, and allowed commands skip auto mode's classifier, which now reviews every curl. --- .claude/settings/permissions/bash.jsonc | 58 ++++++++----------------- 1 file changed, 19 insertions(+), 39 deletions(-) diff --git a/.claude/settings/permissions/bash.jsonc b/.claude/settings/permissions/bash.jsonc index a226956f7..433408af2 100644 --- a/.claude/settings/permissions/bash.jsonc +++ b/.claude/settings/permissions/bash.jsonc @@ -54,44 +54,7 @@ "Bash(gh search *)", "Bash(gh pr view *)", "Bash(gh pr diff *)", - // Local app interaction - // Claude currently has terrible support for this: https://github.com/anthropics/claude-code/issues/338 using `:*` as a pattern is not allowed (doesn't error, just doesn't really work), so have to specify _something_ between the colon and asterisk. - "Bash(curl http://localhost:1*)", - "Bash(curl http://localhost:2*)", - "Bash(curl http://localhost:3*)", - "Bash(curl http://localhost:4*)", - "Bash(curl http://localhost:5*)", - "Bash(curl http://localhost:6*)", - "Bash(curl http://localhost:7*)", - "Bash(curl http://localhost:8*)", - "Bash(curl http://localhost:9*)", - "Bash(curl -s http://localhost:1*)", - "Bash(curl -s http://localhost:2*)", - "Bash(curl -s http://localhost:3*)", - "Bash(curl -s http://localhost:4*)", - "Bash(curl -s http://localhost:5*)", - "Bash(curl -s http://localhost:6*)", - "Bash(curl -s http://localhost:7*)", - "Bash(curl -s http://localhost:8*)", - "Bash(curl -s http://localhost:9*)", - "Bash(curl \"http://localhost:1*)", - "Bash(curl \"http://localhost:2*)", - "Bash(curl \"http://localhost:3*)", - "Bash(curl \"http://localhost:4*)", - "Bash(curl \"http://localhost:5*)", - "Bash(curl \"http://localhost:6*)", - "Bash(curl \"http://localhost:7*)", - "Bash(curl \"http://localhost:8*)", - "Bash(curl \"http://localhost:9*)", - "Bash(curl -s \"http://localhost:1*)", - "Bash(curl -s \"http://localhost:2*)", - "Bash(curl -s \"http://localhost:3*)", - "Bash(curl -s \"http://localhost:4*)", - "Bash(curl -s \"http://localhost:5*)", - "Bash(curl -s \"http://localhost:6*)", - "Bash(curl -s \"http://localhost:7*)", - "Bash(curl -s \"http://localhost:8*)", - "Bash(curl -s \"http://localhost:9*)", + "Bash(gh repo view *)", // AGENTS.md has Claude check repo visibility with it before posting // Misc "Bash(amp *)", "Bash(atuin *)", @@ -126,7 +89,7 @@ ], "ask": [ // let's hold off before we let it use the github CLI in any free running allow mode...I don't want it somehow approving PRs with the user's credentials - "Bash(gh repo *)", + "Bash(gh repo fork *)", "Bash(gh release *)", "Bash(gh secret *)", "Bash(gh ruleset *)", @@ -146,6 +109,23 @@ "Bash(gh pr ready *)", "Bash(gh pr review *)", "Bash(gh pr update-branch *)", + // Raw API access and the token itself would let Claude get around every gh rule in this file. Scripts that need the API (like the address-pr-comments skill) are reviewed and run gh api themselves. + "Bash(gh api *)", + "Bash(gh auth token*)", + "Bash(gh auth status *-t*)", // -t / --show-token prints the token + // Creating or changing repositories should be a deliberate human step, so run these yourself with `!` + "Bash(gh repo archive *)", + "Bash(gh repo autolink create *)", + "Bash(gh repo autolink new *)", + "Bash(gh repo autolink delete *)", + "Bash(gh repo create *)", + "Bash(gh repo new *)", + "Bash(gh repo delete *)", + "Bash(gh repo deploy-key *)", + "Bash(gh repo edit *)", + "Bash(gh repo rename *)", + "Bash(gh repo sync *)", + "Bash(gh repo unarchive *)", // Task must run the repo's own taskfiles, which are the reviewed ones. These flags are the only ways to point it elsewhere (there is no environment variable equivalent -- https://github.com/go-task/task/issues/1721 was closed as a duplicate with nothing implemented), and `/tmp` is writable without a prompt, so without these denials `Bash(task *)` would be an allow-anything entry. "Bash(task -t *)", "Bash(task --taskfile *)", From d7432698e3ade643b9f205036dc7d9cbccfb7bc9 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Tue, 13 Oct 2026 00:22:45 +0000 Subject: [PATCH 08/13] build: move the Biome config into .config/ Biome only discovers biome.json(c) in the working directory or its parents, so each place that runs it now points at .config/biome.jsonc: the biome-check hook passes --config-path (which also covers CI), and the VS Code extension gets biome.configurationPath. With the config passed explicitly, its globs still resolve from the repo root. Biome treats any biome.jsonc it is handed as a config, so this file and the generated repos' copies of it are force-ignored by one glob, and a separate hook formats the config by running Biome from inside .config/, where the file is its own root config. CodeRabbit's Biome is disabled: CI already runs Biome through pre-commit, and CodeRabbit would not find the moved config and fall back to its own defaults. --- .coderabbit.yaml | 2 ++ biome.jsonc => .config/biome.jsonc | 5 ++--- .devcontainer/devcontainer.json | 3 ++- .pre-commit-config.yaml | 8 ++++++++ template/.config/biome.jsonc | 1 + template/.devcontainer/devcontainer.json.jinja-base | 1 + template/biome.jsonc | 1 - template/template/.config/biome.jsonc | 1 + template/template/biome.jsonc | 1 - 9 files changed, 17 insertions(+), 6 deletions(-) rename biome.jsonc => .config/biome.jsonc (80%) create mode 120000 template/.config/biome.jsonc delete mode 120000 template/biome.jsonc create mode 120000 template/template/.config/biome.jsonc delete mode 120000 template/template/biome.jsonc diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 82f1fdfdc..85dc0bb40 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -25,6 +25,8 @@ reviews: enabled: false pylint: # when the code contains python, pylint will be run in the CI pipeline, and coderabbit often generates false positives enabled: false + biome: # biome runs in the CI pipeline through pre-commit, and coderabbit would not find the config in .config/ and fall back to its own defaults + enabled: false flake8: # we use ruff instead (when we use Python) enabled: false poem: false diff --git a/biome.jsonc b/.config/biome.jsonc similarity index 80% rename from biome.jsonc rename to .config/biome.jsonc index 1ee3bfe68..350980ae3 100644 --- a/biome.jsonc +++ b/.config/biome.jsonc @@ -14,9 +14,8 @@ "files": { "includes": [ "**", - // force-ignored (!!) so the scanner does not reject the generated repos' own root configs as nested root configs - "!!template/template/biome.jsonc", - "!!template/biome.jsonc", + // force-ignored (!!) because Biome treats any biome.jsonc it is handed as a config, so this file and the generated repos' copies of it would otherwise be rejected as a second root config + "!!**/.config/biome.jsonc", "!**/__snapshots__", "!**/vendor_files", "!**/generated", // codegen output stays exactly as the generator wrote it, so regenerating never shows formatting diffs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index abb8082a8..5254e9aad 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -60,6 +60,7 @@ // TODO: see if there's a way to specify different configurations for different folders "ruff.configuration": "/workspaces/copier-base-template/.config/ruff-non-src.toml", // use the non-src configuration since it's less restrictive and won't show false positives and underline things "python.pyrefly.configPath": "/workspaces/copier-base-template/.config/pyrefly.toml", + "biome.configurationPath": "/workspaces/copier-base-template/.config/biome.jsonc", "[jsonc][json][javascript][typescript]": { "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true @@ -70,5 +71,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 2af01bf6 # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 788c9e3d # spellchecker:disable-line } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f847088c0..1122fca27 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -83,6 +83,14 @@ repos: rev: d1d67cb1a73ea0917eb553c4f025e212c8cd0465 # frozen: v2.5.15 hooks: - id: biome-check + args: [--config-path, .config/biome.jsonc] + - id: biome-format + alias: biome-format-config + name: format the biome config itself + # run from .config/ so Biome treats the file as its own root config instead of rejecting it as a second one + entry: bash -c 'cd .config && biome format --write biome.jsonc' + files: ^\.config/biome\.jsonc$ + pass_filenames: false - repo: https://github.com/rbubley/mirrors-prettier rev: 0ee178619d696787ca73d210cc191d720868c631 # frozen: v3.9.6 diff --git a/template/.config/biome.jsonc b/template/.config/biome.jsonc new file mode 120000 index 000000000..d72573687 --- /dev/null +++ b/template/.config/biome.jsonc @@ -0,0 +1 @@ +../../.config/biome.jsonc \ No newline at end of file diff --git a/template/.devcontainer/devcontainer.json.jinja-base b/template/.devcontainer/devcontainer.json.jinja-base index 669880401..b18e69213 100644 --- a/template/.devcontainer/devcontainer.json.jinja-base +++ b/template/.devcontainer/devcontainer.json.jinja-base @@ -77,6 +77,7 @@ "ruff.configuration": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/ruff-non-src.toml", // use the non-src configuration since it's less restrictive and won't show false positives and underline things "python.pyrefly.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/pyrefly.toml",{% endraw %}{% endif %}{% raw %} "prettier.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/.prettierrc", + "biome.configurationPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/biome.jsonc", "[jsonc][json][javascript][typescript][graphql][css]": { "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true diff --git a/template/biome.jsonc b/template/biome.jsonc deleted file mode 120000 index f6311a587..000000000 --- a/template/biome.jsonc +++ /dev/null @@ -1 +0,0 @@ -../biome.jsonc \ No newline at end of file diff --git a/template/template/.config/biome.jsonc b/template/template/.config/biome.jsonc new file mode 120000 index 000000000..d72573687 --- /dev/null +++ b/template/template/.config/biome.jsonc @@ -0,0 +1 @@ +../../.config/biome.jsonc \ No newline at end of file diff --git a/template/template/biome.jsonc b/template/template/biome.jsonc deleted file mode 120000 index 90d995a87..000000000 --- a/template/template/biome.jsonc +++ /dev/null @@ -1 +0,0 @@ -../../biome.jsonc \ No newline at end of file From 145006a2d5c806e3babbc65dcab7acc456e39b00 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Tue, 13 Oct 2026 10:08:09 +0000 Subject: [PATCH 09/13] build: install Biome once, as a standalone binary, only where it is used The Biome pre-commit hooks become local `language: system` hooks that run the standalone Biome binary install-ci-tooling.py puts in ~/.local/bin, which the VS Code extension also finds on PATH. That replaces the biomejs/pre-commit hook and the separate npm global install, so the hooks and the editor share one install pinned by one version (biome_version). install-ci-tooling.py gains --install-pre-commit-tooling (pre-commit, identify, prek and Biome) and --install-copier, so CI jobs that never run pre-commit or copier stop installing them. Local development, where the CI variable is unset, still gets everything. The install_deps action exposes both as inputs, and only the pre-commit and lint-matrix jobs turn them on. --- .devcontainer/devcontainer.json | 2 +- .devcontainer/install-ci-tooling.py | 138 ++++++++++++---- .../on-create-command-boilerplate.sh | 3 - .github/workflows/ci.yaml | 4 +- .pre-commit-config.yaml | 17 +- extensions/context.py | 1 + .../install-ci-tooling.py.jinja-base | 149 ++++++++++++++---- .../install_deps/action.yml.jinja-base | 10 +- template/.github/workflows/ci.yaml.jinja-base | 2 + .../workflows/pre-commit.yaml.jinja-base | 1 + template/extensions/context.py.jinja-base | 1 + 11 files changed, 247 insertions(+), 81 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5254e9aad..00ff1dd19 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -71,5 +71,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 788c9e3d # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 1840753e # spellchecker:disable-line } diff --git a/.devcontainer/install-ci-tooling.py b/.devcontainer/install-ci-tooling.py index 30e47f60b..37f0d2b04 100644 --- a/.devcontainer/install-ci-tooling.py +++ b/.devcontainer/install-ci-tooling.py @@ -1,5 +1,6 @@ import argparse import os +import platform import shutil import signal import subprocess @@ -17,7 +18,11 @@ IDENTIFY_VERSION = "==2.6.20" PREK_VERSION = "==0.5.5" TASK_VERSION = "==3.53.1" +BIOME_VERSION = "2.5.15" DOWNLOAD_TIMEOUT_SECONDS = 90 +# The Biome binary is about 60 MB, too big for the 60-second limit in CURL_DOWNLOAD_ARGS on a slow connection +BIOME_CURL_MAX_TIME_SECONDS = 300 +BIOME_DOWNLOAD_TIMEOUT_SECONDS = 330 # CI runners regularly see transient network failures (connection resets, DNS blips, registry 5xx), so every step that # touches the network is retried with exponential backoff before the job is failed. NETWORK_ATTEMPTS = 4 @@ -50,6 +55,27 @@ type=str, help="What version to install.", ) +_ = parser.add_argument( + "--install-pre-commit-tooling", + default=False, + action="store_true", + help="Install pre-commit, identify, prek and Biome. Always installed outside CI.", +) +_ = parser.add_argument( + "--install-copier", + default=False, + action="store_true", + help="Install copier. Always installed outside CI.", +) + + +def should_install(*, requested: bool) -> bool: + if requested: + return True + # GitHub Actions always sets CI, so its absence means a developer's machine or devcontainer, which gets every tool + if "CI" not in os.environ: + return True + return False def run_with_retries( @@ -159,6 +185,45 @@ def install_task(*, uv_path: str, uv_env: dict[str, str]) -> None: _ = github_path_file.write(f"{LOCAL_BIN_DIR}\n") +def install_biome() -> None: + """Install the pinned Biome release into `LOCAL_BIN_DIR` as Biome's standalone executable. + + The Biome pre-commit hooks run this binary (they are `language: system` hooks), and the VS Code extension finds it on + PATH, so the hooks and the editor always use the same version. The standalone executable needs no node, and an npm + global install would end up off PATH in CI (see `install_task`). + + The download lands in a temporary directory inside `LOCAL_BIN_DIR` so the final rename stays on one filesystem: an + atomic rename replaces a binary the editor is already running, where copying over it fails with "Text file busy". + """ + machine = platform.machine() + if machine == "x86_64": + architecture = "x64" + elif machine == "aarch64": + architecture = "arm64" + else: + raise NotImplementedError(f"No Biome binary is published for the {machine} machine type") + LOCAL_BIN_DIR.mkdir(parents=True, exist_ok=True) + biome_path = LOCAL_BIN_DIR / "biome" + with tempfile.TemporaryDirectory(dir=LOCAL_BIN_DIR) as tmp_dir: + download_path = Path(tmp_dir) / "biome" + run_with_retries( + [ + "curl", + *CURL_DOWNLOAD_ARGS, + "--max-time", + str(BIOME_CURL_MAX_TIME_SECONDS), + "--output", + str(download_path), + f"https://github.com/biomejs/biome/releases/download/@biomejs/biome@{BIOME_VERSION}/biome-linux-{architecture}", + ], + description="Downloading Biome", + timeout=BIOME_DOWNLOAD_TIMEOUT_SECONDS, + ) + download_path.chmod(0o755) + _ = download_path.replace(biome_path) + _ = subprocess.run([str(biome_path), "--version"], check=True) # noqa: S603 # this is all our own input + + def main(): args = parser.parse_args(sys.argv[1:]) uv_env = dict(os.environ) @@ -175,41 +240,46 @@ def main(): run_with_retries([f"npm install -g pnpm@{PNPM_VERSION}"], description="Installing pnpm", shell=True) # noqa: S604 # we need shell=True for npm commands, and this is all our own input _ = subprocess.run(["pnpm -v"], shell=True, check=True) # noqa: S602,S607 # we need shell=True for pnpm commands, and this is all our own input install_uv(uv_path=uv_path, uv_env=uv_env) + install_pre_commit_tooling = should_install(requested=args.install_pre_commit_tooling) if not args.no_python: - run_with_retries( - [ - uv_path, - "tool", - "install", - f"copier{COPIER_VERSION}", - "--with", - f"copier-template-extensions{COPIER_TEMPLATE_EXTENSIONS_VERSION}", - ], - description="Installing copier", - env=uv_env, - ) - run_with_retries( - [ - uv_path, - "tool", - "install", - f"pre-commit{PRE_COMMIT_VERSION}", - "--with", - f"identify{IDENTIFY_VERSION}", - ], - description="Installing pre-commit", - env=uv_env, - ) - run_with_retries( - [ - uv_path, - "tool", - "install", - f"prek{PREK_VERSION}", - ], - description="Installing prek", - env=uv_env, - ) + if should_install(requested=args.install_copier): + run_with_retries( + [ + uv_path, + "tool", + "install", + f"copier{COPIER_VERSION}", + "--with", + f"copier-template-extensions{COPIER_TEMPLATE_EXTENSIONS_VERSION}", + ], + description="Installing copier", + env=uv_env, + ) + if install_pre_commit_tooling: + run_with_retries( + [ + uv_path, + "tool", + "install", + f"pre-commit{PRE_COMMIT_VERSION}", + "--with", + f"identify{IDENTIFY_VERSION}", + ], + description="Installing pre-commit", + env=uv_env, + ) + run_with_retries( + [ + uv_path, + "tool", + "install", + f"prek{PREK_VERSION}", + ], + description="Installing prek", + env=uv_env, + ) + if install_pre_commit_tooling: + install_biome() install_task(uv_path=uv_path, uv_env=uv_env) _ = subprocess.run( # noqa: S603 # this is all our own input [ diff --git a/.devcontainer/on-create-command-boilerplate.sh b/.devcontainer/on-create-command-boilerplate.sh index 5e664c6d6..31e708845 100644 --- a/.devcontainer/on-create-command-boilerplate.sh +++ b/.devcontainer/on-create-command-boilerplate.sh @@ -3,9 +3,6 @@ set -ex python .devcontainer/install-ci-tooling.py -# The Biome VS Code extension finds this global install; keep the version matching the biomejs/pre-commit rev in .pre-commit-config.yaml -npm install -g @biomejs/biome@2.5.15 - git config --global --add --bool push.autoSetupRemote true git config --local core.symlinks true diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d68f7c148..ada1a75d7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,7 +35,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install Tooling - run: python .devcontainer/install-ci-tooling.py + run: python .devcontainer/install-ci-tooling.py --install-pre-commit-tooling - name: Install Dependencies run: | @@ -157,7 +157,7 @@ jobs: node-version: 24.11.1 - name: Install Tooling - run: python .devcontainer/install-ci-tooling.py + run: python .devcontainer/install-ci-tooling.py --install-copier --install-pre-commit-tooling - name: Instantiate copier template run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1122fca27..92092f740 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -78,17 +78,22 @@ repos: .*/vendor_files/.*| )$ - # Keep the global @biomejs/biome install in .devcontainer/on-create-command-boilerplate.sh on the same version (the VS Code extension uses it) - - repo: https://github.com/biomejs/pre-commit - rev: d1d67cb1a73ea0917eb553c4f025e212c8cd0465 # frozen: v2.5.15 + # `language: system` so the hooks run the one Biome that .devcontainer/install-ci-tooling.py installs, which the VS Code + # extension uses too, instead of a second copy whose version has to be kept in sync (https://biomejs.dev/recipes/git-hooks/#pre-commit) + - repo: local hooks: - id: biome-check - args: [--config-path, .config/biome.jsonc] - - id: biome-format - alias: biome-format-config + name: biome check + entry: biome check --write --files-ignore-unknown=true --no-errors-on-unmatched --config-path .config/biome.jsonc + language: system + types: [text] + files: '\.(jsx?|tsx?|c(js|ts)|m(js|ts)|d\.(ts|cts|mts)|jsonc?|css|svelte|vue|astro|graphql|gql)$' + require_serial: true + - id: biome-format-config name: format the biome config itself # run from .config/ so Biome treats the file as its own root config instead of rejecting it as a second one entry: bash -c 'cd .config && biome format --write biome.jsonc' + language: system files: ^\.config/biome\.jsonc$ pass_filenames: false diff --git a/extensions/context.py b/extensions/context.py index 75edad1dd..3cb7f1cd3 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -19,6 +19,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["prek_version"] = "==0.5.5" context["pnpm_version"] = "12.9.1" context["task_version"] = "==3.53.1" + context["biome_version"] = "2.5.15" # These also in pyproject.toml and the install-ci-tooling.py script in this repository context["copier_version"] = "==9.18.2" context["copier_template_extensions_version"] = "==0.3.3" diff --git a/template/.devcontainer/install-ci-tooling.py.jinja-base b/template/.devcontainer/install-ci-tooling.py.jinja-base index 544c4f899..7752f5219 100644 --- a/template/.devcontainer/install-ci-tooling.py.jinja-base +++ b/template/.devcontainer/install-ci-tooling.py.jinja-base @@ -18,7 +18,11 @@ PRE_COMMIT_VERSION = "{% endraw %}{{ pre_commit_version }}{% raw %}" IDENTIFY_VERSION = "{% endraw %}{{ identify_version }}{% raw %}" PREK_VERSION = "{% endraw %}{{ prek_version }}{% raw %}" TASK_VERSION = "{% endraw %}{{ task_version }}{% raw %}" +BIOME_VERSION = "{% endraw %}{{ biome_version }}{% raw %}" DOWNLOAD_TIMEOUT_SECONDS = 90 +# The Biome binary is about 60 MB, too big for the 60-second limit in CURL_DOWNLOAD_ARGS on a slow connection +BIOME_CURL_MAX_TIME_SECONDS = 300 +BIOME_DOWNLOAD_TIMEOUT_SECONDS = 330 # CI runners regularly see transient network failures (connection resets, DNS blips, registry 5xx), so every step that # touches the network is retried with exponential backoff before the job is failed. NETWORK_ATTEMPTS = 4 @@ -72,6 +76,27 @@ _ = parser.add_argument( default=False, help="Skip installing the SSM plugin for AWS CLI", ) +_ = parser.add_argument( + "--install-pre-commit-tooling", + action="store_true", + default=False, + help="Install pre-commit, identify, prek and Biome. Always installed outside CI.", +) +_ = parser.add_argument( + "--install-copier", + action="store_true", + default=False, + help="Install copier. Always installed outside CI.", +) + + +def should_install(*, requested: bool) -> bool: + if requested: + return True + # GitHub Actions always sets CI, so its absence means a developer's machine or devcontainer, which gets every tool + if "CI" not in os.environ: + return True + return False def pwsh_cmd(cmd: str) -> list[str]: @@ -206,6 +231,57 @@ def install_task(*, uv_path: str, uv_env: dict[str, str], is_windows: bool) -> N _ = github_path_file.write(f"{LOCAL_BIN_DIR}\n") +def install_biome(*, is_windows: bool) -> None: + """Install the pinned Biome release into `LOCAL_BIN_DIR` as Biome's standalone executable. + + The Biome pre-commit hooks run this binary (they are `language: system` hooks), and the VS Code extension finds it on + PATH, so the hooks and the editor always use the same version. The standalone executable needs no node, and an npm + global install would end up off PATH in CI (see `install_task`). + + The download lands in a temporary directory inside `LOCAL_BIN_DIR` so the final rename stays on one filesystem: an + atomic rename replaces a binary the editor is already running, where copying over it fails with "Text file busy". + """ + machine = platform.machine().lower() + if machine == "x86_64": + architecture = "x64" + elif machine == "amd64": + architecture = "x64" + elif machine == "aarch64": + architecture = "arm64" + elif machine == "arm64": + architecture = "arm64" + else: + raise NotImplementedError(f"No Biome binary is published for the {machine} machine type") + if is_windows: + asset_name = f"biome-win32-{architecture}.exe" + biome_path = LOCAL_BIN_DIR / "biome.exe" + elif sys.platform == "darwin": + asset_name = f"biome-darwin-{architecture}" + biome_path = LOCAL_BIN_DIR / "biome" + else: + asset_name = f"biome-linux-{architecture}" + biome_path = LOCAL_BIN_DIR / "biome" + LOCAL_BIN_DIR.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=LOCAL_BIN_DIR) as tmp_dir: + download_path = Path(tmp_dir) / biome_path.name + run_with_retries( + [ + "curl", + *CURL_DOWNLOAD_ARGS, + "--max-time", + str(BIOME_CURL_MAX_TIME_SECONDS), + "--output", + str(download_path), + f"https://github.com/biomejs/biome/releases/download/@biomejs/biome@{BIOME_VERSION}/{asset_name}", + ], + description="Downloading Biome", + timeout=BIOME_DOWNLOAD_TIMEOUT_SECONDS, + ) + download_path.chmod(0o755) + _ = download_path.replace(biome_path) + _ = subprocess.run([str(biome_path), "--version"], check=True) # noqa: S603 # this is all our own input + + def node_cmd(cmd: str, *, is_windows: bool) -> list[str]: if is_windows: return pwsh_cmd(cmd) @@ -351,41 +427,46 @@ def main(): else: uv_path = "uv" install_uv(uv_path=uv_path, uv_env=uv_env, is_windows=is_windows) + install_pre_commit_tooling = should_install(requested=args.install_pre_commit_tooling) if not args.no_python: - run_with_retries( - [ - uv_path, - "tool", - "install", - f"copier{COPIER_VERSION}", - "--with", - f"copier-template-extensions{COPIER_TEMPLATE_EXTENSIONS_VERSION}", - ], - description="Installing copier", - env=uv_env, - ) - run_with_retries( - [ - uv_path, - "tool", - "install", - f"pre-commit{PRE_COMMIT_VERSION}", - "--with", - f"identify{IDENTIFY_VERSION}", - ], - description="Installing pre-commit", - env=uv_env, - ) - run_with_retries( - [ - uv_path, - "tool", - "install", - f"prek{PREK_VERSION}", - ], - description="Installing prek", - env=uv_env, - ) + if should_install(requested=args.install_copier): + run_with_retries( + [ + uv_path, + "tool", + "install", + f"copier{COPIER_VERSION}", + "--with", + f"copier-template-extensions{COPIER_TEMPLATE_EXTENSIONS_VERSION}", + ], + description="Installing copier", + env=uv_env, + ) + if install_pre_commit_tooling: + run_with_retries( + [ + uv_path, + "tool", + "install", + f"pre-commit{PRE_COMMIT_VERSION}", + "--with", + f"identify{IDENTIFY_VERSION}", + ], + description="Installing pre-commit", + env=uv_env, + ) + run_with_retries( + [ + uv_path, + "tool", + "install", + f"prek{PREK_VERSION}", + ], + description="Installing prek", + env=uv_env, + ) + if install_pre_commit_tooling: + install_biome(is_windows=is_windows) if not args.no_node: install_pnpm(is_windows=is_windows) # Task is installed outside the --no-node branch because CI always passes --no-node (pnpm/setup handles pnpm there), and every job still needs the task runner diff --git a/template/.github/actions/install_deps/action.yml.jinja-base b/template/.github/actions/install_deps/action.yml.jinja-base index 7529c662c..d3f915c2b 100644 --- a/template/.github/actions/install_deps/action.yml.jinja-base +++ b/template/.github/actions/install_deps/action.yml.jinja-base @@ -21,6 +21,14 @@ inputs: required: false default: false description: Whether to explicitly skip installing the SSM Plugin manager when setting up basic CI tooling + install-pre-commit-tooling: + required: false + default: false + description: Whether to install the tooling the pre-commit hooks need (pre-commit, identify, prek and Biome), for jobs that run pre-commit + install-copier: + required: false + default: false + description: Whether to install copier, for jobs that instantiate a copier template project-dir: description: What's the relative path to the project? required: false @@ -95,7 +103,7 @@ runs: - name: Install tooling # the funky syntax is github action ternary # --no-node is always passed because the only node tooling this script installs is pnpm, which pnpm/setup above already installed - run: python .devcontainer/install-ci-tooling.py ${{ inputs.python-version == 'notUsing' && '--no-python' || '' }} --no-node ${{ inputs.skip-installing-ssm-plugin-manager == 'true' && '--skip-installing-ssm-plugin' || '' }} + run: python .devcontainer/install-ci-tooling.py ${{ inputs.python-version == 'notUsing' && '--no-python' || '' }} --no-node ${{ inputs.skip-installing-ssm-plugin-manager == 'true' && '--skip-installing-ssm-plugin' || '' }} ${{ inputs.install-pre-commit-tooling == 'true' && '--install-pre-commit-tooling' || '' }} ${{ inputs.install-copier == 'true' && '--install-copier' || '' }} shell: pwsh - name: OIDC Auth for CodeArtifact diff --git a/template/.github/workflows/ci.yaml.jinja-base b/template/.github/workflows/ci.yaml.jinja-base index b58e7357d..a9c0b2f4e 100644 --- a/template/.github/workflows/ci.yaml.jinja-base +++ b/template/.github/workflows/ci.yaml.jinja-base @@ -124,6 +124,8 @@ jobs: python-version: ${{ matrix.python-version }}{% endraw %}{% if template_uses_typescript is defined and template_uses_typescript is sameas(true) %}{% raw %} node-version: {% endraw %}{{ default_node_version }}{% raw %}{% endraw %}{% endif %}{% raw %} install-deps: false + install-copier: true + install-pre-commit-tooling: true - name: Instantiate copier template run: | diff --git a/template/.github/workflows/pre-commit.yaml.jinja-base b/template/.github/workflows/pre-commit.yaml.jinja-base index 3283cc7e1..26e3450cc 100644 --- a/template/.github/workflows/pre-commit.yaml.jinja-base +++ b/template/.github/workflows/pre-commit.yaml.jinja-base @@ -51,6 +51,7 @@ jobs: node-version: ${{ inputs.node-version }} skip-installing-ssm-plugin-manager: true skip-installing-pulumi-cli: true + install-pre-commit-tooling: true - name: Compute Pre-commit cache key # must run after checkout, since hashFiles needs the file on disk run: | diff --git a/template/extensions/context.py.jinja-base b/template/extensions/context.py.jinja-base index 3eec6073b..e6b188fd0 100644 --- a/template/extensions/context.py.jinja-base +++ b/template/extensions/context.py.jinja-base @@ -20,6 +20,7 @@ class ContextUpdater(ContextHook): context["identify_version"] = "{{ identify_version }}" context["prek_version"] = "{{ prek_version }}" context["task_version"] = "{{ task_version }}" + context["biome_version"] = "{{ biome_version }}" context["pytest_version"] = "{{ pytest_version }}" context["pytest_randomly_version"] = "{{ pytest_randomly_version }}" context["pytest_cov_version"] = "{{ pytest_cov_version }}" From a5306dfbfcf25099127f6c8d43c80bde0b6c2529 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Tue, 13 Oct 2026 10:17:57 +0000 Subject: [PATCH 10/13] clone --- .claude/settings/permissions/bash.jsonc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings/permissions/bash.jsonc b/.claude/settings/permissions/bash.jsonc index 433408af2..efcbbe1c0 100644 --- a/.claude/settings/permissions/bash.jsonc +++ b/.claude/settings/permissions/bash.jsonc @@ -54,7 +54,8 @@ "Bash(gh search *)", "Bash(gh pr view *)", "Bash(gh pr diff *)", - "Bash(gh repo view *)", // AGENTS.md has Claude check repo visibility with it before posting + "Bash(gh repo view *)", + "Bash(gh repo clone *)", // Misc "Bash(amp *)", "Bash(atuin *)", From 994f9d565f1ee84a8a7e58a1fff799597cc50b06 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Tue, 13 Oct 2026 10:19:09 +0000 Subject: [PATCH 11/13] build: install the Prettier VS Code extension only in repos that use Vue In the hybrid setup Prettier formats only .vue, .html, .scss and .less files, and only Vue repos have any. Everywhere else the extension was a 15 MB download per container build that still activates in every window. It now installs with Volar, under the same template_uses_vuejs condition, along with its config path and the editor settings that route those file types to it. --- template/.devcontainer/devcontainer.json.jinja-base | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/template/.devcontainer/devcontainer.json.jinja-base b/template/.devcontainer/devcontainer.json.jinja-base index b18e69213..a13007d11 100644 --- a/template/.devcontainer/devcontainer.json.jinja-base +++ b/template/.devcontainer/devcontainer.json.jinja-base @@ -49,6 +49,7 @@ // VueJS "vue.volar@3.2.5", "vitest.explorer@1.36.0", + "esbenp.prettier-vscode@12.4.0", {% endraw %}{% endif %}{% raw %}{% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_typescript is defined and template_uses_typescript is sameas(true) %}{% raw %} // All javascript "dbaeumer.vscode-eslint@3.0.24", @@ -58,8 +59,7 @@ "samuelcolvin.jinjahtml@0.20.0", "tamasfe.even-better-toml@0.19.2", "emilast.LogFileHighlighter@3.3.3", - "biomejs.biome@2026.8.190716", - "esbenp.prettier-vscode@12.4.0" + "biomejs.biome@2026.8.190716" ], "settings": { "editor.accessibilitySupport": "off", // turn off sounds @@ -76,16 +76,16 @@ // TODO: see if there's a way to specify different configurations for different folders "ruff.configuration": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/ruff-non-src.toml", // use the non-src configuration since it's less restrictive and won't show false positives and underline things "python.pyrefly.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/pyrefly.toml",{% endraw %}{% endif %}{% raw %} - "prettier.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/.prettierrc", "biome.configurationPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/biome.jsonc", "[jsonc][json][javascript][typescript][graphql][css]": { "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true - }, + }{% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_vuejs is defined and template_uses_vuejs is sameas(true) %}{% raw %}, + "prettier.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/.prettierrc", "[vue][html][scss][less]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true - } + }{% endraw %}{% endif %}{% raw %} } }{% endraw %}{% if repo_names_for_codespaces_read_permissions is defined and repo_names_for_codespaces_read_permissions.strip() %}{% set _cs_repos = repo_names_for_codespaces_read_permissions.split(',') | map('trim') | reject('equalto', '') | list %}{% if _cs_repos %}, "codespaces": { From 90936d1300f71ee192eadbbdc1eae7473c64cc00 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Tue, 13 Oct 2026 13:05:59 +0000 Subject: [PATCH 12/13] build: install Biome through npm, putting npm's global bin directory on PATH in CI --- .devcontainer/devcontainer.json | 2 +- .devcontainer/install-ci-tooling.py | 49 +++---------- .../install-ci-tooling.py.jinja-base | 68 ++++++------------- 3 files changed, 33 insertions(+), 86 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 589a7e830..faf67e2fe 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -71,5 +71,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 9bad8415 # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 3d00a211 # spellchecker:disable-line } diff --git a/.devcontainer/install-ci-tooling.py b/.devcontainer/install-ci-tooling.py index 0db907a28..6c592d1d1 100644 --- a/.devcontainer/install-ci-tooling.py +++ b/.devcontainer/install-ci-tooling.py @@ -1,6 +1,5 @@ import argparse import os -import platform import shutil import signal import subprocess @@ -20,9 +19,6 @@ TASK_VERSION = "==3.53.1" BIOME_VERSION = "2.5.15" DOWNLOAD_TIMEOUT_SECONDS = 90 -# The Biome binary is about 60 MB, too big for the 60-second limit in CURL_DOWNLOAD_ARGS on a slow connection -BIOME_CURL_MAX_TIME_SECONDS = 300 -BIOME_DOWNLOAD_TIMEOUT_SECONDS = 330 # CI runners regularly see transient network failures (connection resets, DNS blips, registry 5xx), so every step that # touches the network is retried with exponential backoff before the job is failed. NETWORK_ATTEMPTS = 4 @@ -186,42 +182,19 @@ def install_task(*, uv_path: str, uv_env: dict[str, str]) -> None: def install_biome() -> None: - """Install the pinned Biome release into `LOCAL_BIN_DIR` as Biome's standalone executable. + """Install the pinned Biome globally through npm. - The Biome pre-commit hooks run this binary (they are `language: system` hooks), and the VS Code extension finds it on - PATH, so the hooks and the editor always use the same version. The standalone executable needs no node, and an npm - global install would end up off PATH in CI (see `install_task`). - - The download lands in a temporary directory inside `LOCAL_BIN_DIR` so the final rename stays on one filesystem: an - atomic rename replaces a binary the editor is already running, where copying over it fails with "Text file busy". + The Biome pre-commit hooks are `language: system` hooks that run this install, and the VS Code extension finds it in + the global npm root, so the hooks and the editor always use the same version. When node comes from `pnpm/setup` in + CI, npm's global bin directory is not on PATH (see `install_task`), so it is appended to `GITHUB_PATH`. """ - machine = platform.machine() - if machine == "x86_64": - architecture = "x64" - elif machine == "aarch64": - architecture = "arm64" - else: - raise NotImplementedError(f"No Biome binary is published for the {machine} machine type") - LOCAL_BIN_DIR.mkdir(parents=True, exist_ok=True) - biome_path = LOCAL_BIN_DIR / "biome" - with tempfile.TemporaryDirectory(dir=LOCAL_BIN_DIR) as tmp_dir: - download_path = Path(tmp_dir) / "biome" - run_with_retries( - [ - "curl", - *CURL_DOWNLOAD_ARGS, - "--max-time", - str(BIOME_CURL_MAX_TIME_SECONDS), - "--output", - str(download_path), - f"https://github.com/biomejs/biome/releases/download/@biomejs/biome@{BIOME_VERSION}/biome-linux-{architecture}", - ], - description="Downloading Biome", - timeout=BIOME_DOWNLOAD_TIMEOUT_SECONDS, - ) - download_path.chmod(0o755) - _ = download_path.replace(biome_path) - _ = subprocess.run([str(biome_path), "--version"], check=True) # noqa: S603 # this is all our own input + run_with_retries([f"npm install -g @biomejs/biome@{BIOME_VERSION}"], description="Installing Biome", shell=True) # noqa: S604 # we need shell=True for npm commands, and this is all our own input + npm_prefix = subprocess.run(["npm prefix -g"], shell=True, check=True, capture_output=True, text=True).stdout # noqa: S602,S607 # we need shell=True for npm commands, and this is all our own input + global_bin_dir = Path(npm_prefix.strip()) / "bin" + _ = subprocess.run([str(global_bin_dir / "biome"), "--version"], check=True) # noqa: S603 # this is all our own input + if "GITHUB_PATH" in os.environ: + with Path(os.environ["GITHUB_PATH"]).open("a", encoding="utf-8") as github_path_file: + _ = github_path_file.write(f"{global_bin_dir}\n") def main(): diff --git a/template/.devcontainer/install-ci-tooling.py.jinja-base b/template/.devcontainer/install-ci-tooling.py.jinja-base index feb296de6..02fdcb608 100644 --- a/template/.devcontainer/install-ci-tooling.py.jinja-base +++ b/template/.devcontainer/install-ci-tooling.py.jinja-base @@ -20,9 +20,6 @@ PREK_VERSION = "{% endraw %}{{ prek_version }}{% raw %}" TASK_VERSION = "{% endraw %}{{ task_version }}{% raw %}" BIOME_VERSION = "{% endraw %}{{ biome_version }}{% raw %}" DOWNLOAD_TIMEOUT_SECONDS = 90 -# The Biome binary is about 60 MB, too big for the 60-second limit in CURL_DOWNLOAD_ARGS on a slow connection -BIOME_CURL_MAX_TIME_SECONDS = 300 -BIOME_DOWNLOAD_TIMEOUT_SECONDS = 330 # CI runners regularly see transient network failures (connection resets, DNS blips, registry 5xx), so every step that # touches the network is retried with exponential backoff before the job is failed. NETWORK_ATTEMPTS = 4 @@ -232,54 +229,31 @@ def install_task(*, uv_path: str, uv_env: dict[str, str], is_windows: bool) -> N def install_biome(*, is_windows: bool) -> None: - """Install the pinned Biome release into `LOCAL_BIN_DIR` as Biome's standalone executable. + """Install the pinned Biome globally through npm. - The Biome pre-commit hooks run this binary (they are `language: system` hooks), and the VS Code extension finds it on - PATH, so the hooks and the editor always use the same version. The standalone executable needs no node, and an npm - global install would end up off PATH in CI (see `install_task`). - - The download lands in a temporary directory inside `LOCAL_BIN_DIR` so the final rename stays on one filesystem: an - atomic rename replaces a binary the editor is already running, where copying over it fails with "Text file busy". + The Biome pre-commit hooks are `language: system` hooks that run this install, and the VS Code extension finds it in + the global npm root, so the hooks and the editor always use the same version. When node comes from `pnpm/setup` in + CI, npm's global bin directory is not on PATH (see `install_task`), so it is appended to `GITHUB_PATH`. """ - machine = platform.machine().lower() - if machine == "x86_64": - architecture = "x64" - elif machine == "amd64": - architecture = "x64" - elif machine == "aarch64": - architecture = "arm64" - elif machine == "arm64": - architecture = "arm64" - else: - raise NotImplementedError(f"No Biome binary is published for the {machine} machine type") + run_with_retries( # noqa: S604 # we need shell=True for npm commands, and this is all our own input + node_cmd(f"npm install -g @biomejs/biome@{BIOME_VERSION}", is_windows=is_windows), + description="Installing Biome", + shell=True, + ) + npm_prefix = subprocess.run( # noqa: S602 # we need shell=True for npm commands, and this is all our own input + node_cmd("npm prefix -g", is_windows=is_windows), shell=True, check=True, capture_output=True, text=True + ).stdout.strip() + # npm links global executables straight into its prefix on Windows, and into the prefix's bin directory elsewhere if is_windows: - asset_name = f"biome-win32-{architecture}.exe" - biome_path = LOCAL_BIN_DIR / "biome.exe" - elif sys.platform == "darwin": - asset_name = f"biome-darwin-{architecture}" - biome_path = LOCAL_BIN_DIR / "biome" + global_bin_dir = Path(npm_prefix) else: - asset_name = f"biome-linux-{architecture}" - biome_path = LOCAL_BIN_DIR / "biome" - LOCAL_BIN_DIR.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(dir=LOCAL_BIN_DIR) as tmp_dir: - download_path = Path(tmp_dir) / biome_path.name - run_with_retries( - [ - "curl", - *CURL_DOWNLOAD_ARGS, - "--max-time", - str(BIOME_CURL_MAX_TIME_SECONDS), - "--output", - str(download_path), - f"https://github.com/biomejs/biome/releases/download/@biomejs/biome@{BIOME_VERSION}/{asset_name}", - ], - description="Downloading Biome", - timeout=BIOME_DOWNLOAD_TIMEOUT_SECONDS, - ) - download_path.chmod(0o755) - _ = download_path.replace(biome_path) - _ = subprocess.run([str(biome_path), "--version"], check=True) # noqa: S603 # this is all our own input + global_bin_dir = Path(npm_prefix) / "bin" + biome_env = dict(os.environ) + biome_env["PATH"] = f"{global_bin_dir}{os.pathsep}{biome_env['PATH']}" + _ = subprocess.run(node_cmd("biome --version", is_windows=is_windows), shell=True, check=True, env=biome_env) # noqa: S602 # we need shell=True for npm-installed commands, and this is all our own input + if "GITHUB_PATH" in os.environ: + with Path(os.environ["GITHUB_PATH"]).open("a", encoding="utf-8") as github_path_file: + _ = github_path_file.write(f"{global_bin_dir}\n") def node_cmd(cmd: str, *, is_windows: bool) -> list[str]: From 55ce5daac311d3899738abb793ef3b71602085e5 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Tue, 13 Oct 2026 14:08:56 +0000 Subject: [PATCH 13/13] build: leave SCSS and Less out of the Prettier hook and editor settings, since no repo uses them --- .devcontainer/devcontainer.json | 2 +- .pre-commit-config.yaml | 4 ++-- template/.devcontainer/devcontainer.json.jinja-base | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index faf67e2fe..e93fd88f2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -71,5 +71,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 3d00a211 # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 85e48e15 # spellchecker:disable-line } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 92092f740..120c5df5e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -102,7 +102,7 @@ repos: hooks: - id: prettier args: [--config, .config/.prettierrc] - # Biome formats everything else. These are the types it cannot format yet (SCSS, Less) or only formats experimentally (Vue, HTML) + # Biome formats everything else. Its Vue and HTML formatting is still experimental # TODO: get template YAML and MD files more in line with prettier expectations so we can start using prettier on those too exclude: | (?x)^( @@ -110,7 +110,7 @@ repos: .*/vendor_files/.*| template/.*| )$ - files: \.(vue|html|scss|less)$ + files: \.(vue|html)$ - repo: https://github.com/pamoller/xmlformatter diff --git a/template/.devcontainer/devcontainer.json.jinja-base b/template/.devcontainer/devcontainer.json.jinja-base index a13007d11..b92ef7b1b 100644 --- a/template/.devcontainer/devcontainer.json.jinja-base +++ b/template/.devcontainer/devcontainer.json.jinja-base @@ -82,7 +82,7 @@ "editor.formatOnSave": true }{% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_vuejs is defined and template_uses_vuejs is sameas(true) %}{% raw %}, "prettier.configPath": "/workspaces/{% endraw %}{{ repo_name }}{% raw %}/.config/.prettierrc", - "[vue][html][scss][less]": { + "[vue][html]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true }{% endraw %}{% endif %}{% raw %}