Skip to content

refactor(installer)!: port installer engine to TypeScript/bun - #3

Merged
brunoazoulay merged 12 commits into
mainfrom
refactor/installer-bun-portage
Sep 1, 2026
Merged

brunoazoulay merged 12 commits into
mainfrom
refactor/installer-bun-portage

Conversation

@brunoazoulay

Copy link
Copy Markdown
Collaborator

Résumé

L'installeur Cursor (20 scripts .mjs Node) est remplacé par un portage TypeScript/bun de l'installeur Claude Code. Zéro .mjs sous .cursor-plugin/scripts/.

Entrée unique install-hooks.ts : elle lance le moteur de déploiement (global-install.ts ou project-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 dans receipt.json, ownership preflight, garde symlink) sont inchangées.

Défauts corrigés

Défaut Effet avant
mcp-installer appelait claude mcp add-json la CLI de Claude Code — aucun serveur MCP installé sous Cursor
configureHooks visait settings.json fichier que Cursor ne lit pas — hooks jamais enregistrés
Instructions globales en ~/.cursor/AGENTS.md AGENTS.md n'est reconnu qu'à la racine d'un workspace — jamais lu
FUSE_HARNESS_REFS scannait plugins/ un niveau trop haut — liste vide au lieu de 14 dossiers
Loader copié sans son src/ Cannot find module — hooks morts
spawnSync(process.execPath, …) valait bun après le portage, alors que le hook déployé lance node

Plus : .gitignore ajouté, frontmatter .mdc manquant 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-heal bun install → npx) et suit le tag latest, donc le plugin fonctionne aussi installé depuis le marketplace, où aucun installeur ne tourne.

Vérification

  • bunx tsc --noEmit : vert
  • bun test : 182 pass / 0 fail
  • Installation réelle en HOME sandbox : 24 plugins déployés, receipt.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-run n'écrit rien sous ~/.cursor
  • Événements de hooks validés contre le bundle Cursor 3.18.25
  • Scan secrets : aucun ; clés MCP toutes en ${env:NAME}

Version : 0.1.1

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.
@brunoazoulay
brunoazoulay merged commit 312a3a3 into main Sep 1, 2026
3 checks passed
@brunoazoulay
brunoazoulay deleted the refactor/installer-bun-portage branch September 1, 2026 21:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant