Repository navigation
refactor(installer)!: port installer engine to TypeScript/bun - #3
Merged
Merged
Conversation
Replace the 20 Node .mjs installer scripts under .cursor-plugin/scripts/ with a TypeScript/bun port of the Claude Code installer, cloned then adapted rather than rewritten from scratch. Single entry point install-hooks.ts (bun) spawns the deployment engine (global-install.ts / project-install.ts) then chains configuration (runSetup). Prerequisite changes from Node.js to Bun; install.sh, install.ps1 and verify-project.sh are recabled accordingly. Preserved: --dry-run, --uninstall, --project <path>, --skip-env, transactional deploy with rollback, per-plugin hashed receipt.json, preflightOwned, install lock, anti-symlink guard. Cursor hooks: all 62 hook commands switched to relative path ./scripts/hook.sh (marketplace submission requirement), with 21 embedded wrappers that resolve the shared harness at runtime (vendored binary -> self-heal bun install -> npx). Events verified camelCase against Cursor CLI 3.18.25. Vendored harness manifest and wrappers pinned to the latest tag. Fixes: mcp-installer now writes ~/.cursor/mcp.json (was shelling out to Claude Code's `claude mcp add-json`); hook configuration now targets ~/.cursor/hooks.json (was writing to a settings.json Cursor never reads); global instructions now ship as ~/.cursor/rules/fuse-global.mdc instead of ~/.cursor/AGENTS.md (Cursor only recognises AGENTS.md at a workspace root); FUSE_HARNESS_REFS scan depth corrected; loader now copied with its full src/ tree; spawnSync reverted to a hardcoded "node" where process.execPath now resolves to bun. Adds .gitignore (node_modules ignored, bun.lock versioned) and missing frontmatter on 12 plugin rule files. Validated: bunx tsc --noEmit clean, bun test 182/182 pass, and a real install exercised in a sandboxed HOME (24 plugins deployed, 24-hash receipt + ruleHash, fuse-global.mdc deployed with alwaysApply: true, loader starts from its destination, 21 wrappers executable, --dry-run writes nothing).
main() only skipped runSetup() (the global configuration stage) for --dry-run/--uninstall, so --project fell through to it too. That stage scans ~/.cursor/plugins/local: on a missing directory BSD find exits 1 and Bun's $ turns that into an uncaught throw, failing the command even though the project deploy had already succeeded. - install-hooks.ts: --project now returns before runSetup - fs-helpers.ts: makeScriptsExecutable no longer treats a missing directory as an error (existsSync guard + .nothrow())
Three jobs cover what previously had no automated gate: typecheck + bun unit tests for the installer engine, the shell integration suites run against a sandboxed HOME, and manifest/hook validation across every plugin (JSON parses, hook commands stay relative, wrapper scripts are executable, shell entry points resolve to existing targets).
Bun's own bookkeeping (BUN_RUNTIME_TRANSPILER_CACHE_PATH, BUN_INSTALL_CACHE_DIR) writes under $HOME on first run, which was fighting the "install writes nothing to $HOME" checks in global-install.test.sh and project-install.test.sh. Redirect both caches out of the sandbox instead of narrowing the assertion to ~/.cursor, so it keeps covering the whole fake HOME and runs against a genuinely fresh $HOME (the bug fixed in eadef61). marketplace-sparse-install.test.sh now scans git-tracked paths via `git ls-files` instead of walking the working tree, so untracked, gitignored build artifacts (node_modules, .harness, .impeccable) can no longer be mistaken for missing or symlinked marketplace material. global-install.test.sh's uninstall check now asserts on the receipt-managed artifacts (receipt.json, .managed-by-fusengine) instead of the whole .fusengine-global directory, matching uninstallGlobal()'s documented behavior of preserving the vendored scripts/ tree when non-empty. Remaining references to the retired install.mjs entry point are updated to install-hooks.ts across all affected suites.
.claude/apex/docs/task-1-generic.md is a 5-line agent research note matching the .gitignore .claude/ rule, tracked by mistake in a public plugin. The plugin's own README already documents it as excluded from the source tree; removing it makes that documentation accurate. No code, test, or manifest references this file.
Captures why a test-side workaround for a missing ~/.cursor/plugins/local must never mask a production bug, why Bun's runtime caches need neutralizing rather than narrowing the $HOME assertion, and why an uncommitted git rm produces a false failure against a HEAD-relative suite.
…hiding it zsh/sh test.each cases spawned argv[0] directly, so ubuntu-latest (no zsh/fish) made the suite FAIL with ENOENT instead of skipping — and the fish case, which already carried a manual skip guard, had been silently skipping in CI all along with nothing to flag the lost coverage. - Replace the test.each block with a for loop + test.skipIf(!Bun.which(shell)) per case (bun:test has no per-row skip in test.each: oven-sh/bun#4469, open) - Install zsh and fish on the CI runner (fish ships in universe, enabled by default on ubuntu-latest) so all three loaders actually run - Fail the Unit tests step if bun test reports any skip: a skip in CI means a missing shell on the runner, never an acceptable outcome Verified: bunx tsc --noEmit clean; bun test 182 pass/0 fail/0 skip; negative control with zsh stripped from PATH gives 7 pass/1 skip/0 fail (vs 1 fail before the fix) and the CI grep guard matches bun's " 1 skip" line.
Two lessons from the shell-loader CI fix session:
- a skipIf(tool absent) guard protects a contributor's machine but hides lost
coverage on CI if the tool is never installed there
- a claimed write-scope ("only writes under ~/.cursor") must come from
grepping the module's write primitives, not from memory of the paths —
challenger caught rc-file appends the claim had missed
uninstallGlobal() only reversed what receipt.json tracked (plugin hashes and ruleHash), while the deployed hook loader and its src/ tree - written by setup-runner.ts, a module the deployment engine never imports - could never be reclaimed by any code path. The non-recursive rmdirSync then failed silently on ENOTEMPTY inside an empty catch, reporting success while leaving ~64 files (loader tree + the harness node_modules the wrapper self-installs there) behind for good. The control root belongs to the installer in full (readGlobalReceipt already refuses to act without the ownership marker, validatePathChain forbids symlinks), so uninstallGlobal now removes it recursively via fs.rmSync, guarded by an lstatSync TOCTOU check placed immediately before the destructive call. User plugins, a modified rule, ~/.cursor/.env and the hooks/MCP config are preserved as before. Test: the uninstall assertion is tightened from receipt.json alone to the strict `test ! -e .fusengine-global`, preceded by `test -d .../scripts` so the assertion has teeth (proves the directory existed before uninstall, not just that it's gone).
…tree - setup-runner.ts: remove the unused installClaudeMd import; the function stays exported for project-scope callers, it was just never referenced from this module. - fs-helpers.ts: copyLoaderTree now excludes .DS_Store from the files it copies into the deployed loader tree - it was being shipped to users' ~/.cursor/.fusengine-global/scripts alongside the real loader files.
…usion No version bump - suite version stays frozen at 0.1.1 per owner decision.
Two lessons from this fix: an assertion relaxed to entrench a leak instead of flagging it (test ! -e receipt.json standing in for the whole control root, justified by citing the code's own "preserved if non-empty" comment as if it were the contract), and the counter-intuitive root cause - an artifact written outside the receipt registry is orphaned by construction, not a non-recursive rmdir.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Résumé
L'installeur Cursor (20 scripts
.mjsNode) est remplacé par un portage TypeScript/bun de l'installeur Claude Code. Zéro.mjssous.cursor-plugin/scripts/.Entrée unique
install-hooks.ts: elle lance le moteur de déploiement (global-install.tsouproject-install.ts) puis la configuration.Breaking : le prérequis passe de Node.js à Bun. Les options CLI (
--dry-run,--uninstall,--project,--skip-env) et les garanties transactionnelles (rollback, hash par plugin dansreceipt.json, ownership preflight, garde symlink) sont inchangées.Défauts corrigés
mcp-installerappelaitclaude mcp add-jsonconfigureHooksvisaitsettings.json~/.cursor/AGENTS.mdAGENTS.mdn'est reconnu qu'à la racine d'un workspace — jamais luFUSE_HARNESS_REFSscannaitplugins/src/Cannot find module— hooks mortsspawnSync(process.execPath, …)bunaprès le portage, alors que le hook déployé lancenodePlus :
.gitignoreajouté, frontmatter.mdcmanquant sur 12 rules qui n'étaient donc jamais auto-appliquées.Marketplace public
Chaque plugin embarque
scripts/hook.sh, invoqué en chemin relatif comme l'exige la checklist de soumission Cursor. Le wrapper résout le harness lui-même (binaire vendoré → self-healbun install→npx) et suit le taglatest, donc le plugin fonctionne aussi installé depuis le marketplace, où aucun installeur ne tourne.Vérification
bunx tsc --noEmit: vertbun test: 182 pass / 0 failreceipt.jsonà 24 hashes +ruleHash,~/.cursor/rules/fuse-global.mdc(10 713 o,alwaysApply: true), loader déployé qui démarre (exit 0), 21 wrappers exécutables,--dry-runn'écrit rien sous~/.cursor${env:NAME}Version : 0.1.1