Skip to content

feat(extensions): add the extension manager library - #520

Closed
scottlovegrove wants to merge 1 commit into
mainfrom
feat/extensions-manager
Closed

scottlovegrove wants to merge 1 commit into
mainfrom
feat/extensions-manager

Conversation

@scottlovegrove

Copy link
Copy Markdown
Collaborator

Summary

First half of phase 1 of the extensions spec: the library that discovers, installs, upgrades, removes and runs extensions. There is no user-facing command yet, so this changes nothing for users; the td extension group and the src/index.ts wiring follow in the next PR.

Everything lives in src/lib/extensions/ and takes the host CLI's name, directories, version, reserved command names and first-party source through one options object, with no imports from the rest of the repo. That is decision 1 in the spec, and it is what makes the eventual move to @doist/cli-core a file move rather than a rewrite.

What is here

  • Discovery is one readdir plus a couple of small file reads, with no subprocesses and no network, because it runs on every invocation of td including the common case of no extensions at all. Kind is inferred from the directory in the spec's order: symlink or path file means local, .git means git, .td-manifest.json means binary.
  • Install covers release binaries with checksum verification, git clones with npm dependency installation, and symlinks for local development. Everything is assembled in a staging directory and moved into place only once complete, so a failed install never leaves a half-written extension behind and never destroys the one already installed. The trust warning goes out before anything is downloaded, cloned or executed.
  • Upgrade fast-forwards clones, re-downloads a binary when the release tag moves, honours pins unless forced, leaves local installs alone, and bounds GitHub requests with a worker pool of four.
  • Remove deletes only the link for a local install, and refuses a clone with uncommitted work unless forced.
  • Dispatch passes arguments through verbatim, sets the documented environment contract, runs node-shebang scripts under the host's own Node, and returns the extension's exit code. The API token is deliberately absent from the child environment.

Testing

112 tests over nine files, using real files in temporary directories rather than a mocked filesystem, a stubbed GitHub API, and a fixture extension in src/test-support/ that reports the arguments and environment it was given. The spec's named cases are covered: a mismatched checksum refuses the install and leaves nothing behind, --pin resolves the tagged release rather than the latest one, a reserved name fails before any request is made, and the trust warning precedes the first fetch.

Not in this PR

The command group, root-command wiring, the command-token lookup change, --user scoping, doctor checks, and SKILL_CONTENT. Those are the user-facing half and land next.

First half of phase 1 of the extensions spec: the host-agnostic library
that discovers, installs, upgrades, removes and runs extensions. No
user-facing command is wired up yet, so nothing changes for users until
the command group lands.

Everything lives in src/lib/extensions/ and takes the host CLI's name,
directories, version, reserved command names and first-party source
through one options object, with no imports from the rest of the repo.
That is what makes the later move to @doist/cli-core a file move.

What it does:

- discovery: one readdir plus a couple of small file reads, no
  subprocesses and no network, since it runs on every invocation
- install: release binaries with checksum verification, git clones with
  npm dependency installation, and symlinks for local development, all
  assembled in a staging directory and moved into place only when
  complete
- upgrade: fast-forward for clones, re-download when a release tag moves,
  pins honoured unless forced, and a worker pool bounding GitHub requests
- remove: local installs lose only their link, clones with uncommitted
  work refuse without --force
- dispatch: arguments passed through verbatim, the documented environment
  contract, node-shebang scripts run under the host's own node, and the
  extension's exit code returned to the caller

The API token is deliberately absent from the child environment; an
extension that needs it runs `td auth token view`.

Tests cover all of it with real files in temporary directories, a
stubbed GitHub API and a fixture extension that reports how it was
called.
@scottlovegrove scottlovegrove self-assigned this Sep 10, 2026
@doistbot

doistbot commented Sep 10, 2026

Copy link
Copy Markdown
Member

⚠️ PR size is large: Review quality may be affected

👋 @scottlovegrove This PR is large enough that Doistbot's review may miss details.

Current diff: 3,711 review-load lines across 25 files (+3711 / -0). I will still run the review, but this would be easier for your colleagues to review as smaller PRs or a PR stack 😅

ℹ️ To make it easier to review, the recommended diff size is < 750 review-load lines and < 25 files changed

To be mindful of their time I would suggest you split this PR

🪄 Suggested slicing plan 👇

Split the 3,711-line extension manager library into a 7-part stacked sequence ordered strictly by architectural dependency: foundational types/manifest/state primitives, disk discovery with test fixture support, process dispatch/execution, external tool integrations and uninstallation, atomic installation lifecycle, upgrade logic with concurrency control, and the top-level ExtensionManager facade with version range checking. Every slice stays below the 750 review-load line limit and keeps implementations together with their unit tests.

PR order

  1. slice-1-feat-extensions-core-types-naming-rules- → base main
  2. slice-2-feat-extensions-disk-discovery-and-test- → base slice-1-feat-extensions-core-types-naming-rules-
  3. slice-3-feat-extensions-process-dispatch-and-env → base slice-2-feat-extensions-disk-discovery-and-test-
  4. slice-4-feat-extensions-external-tool-helpers-an → base slice-3-feat-extensions-process-dispatch-and-env
  5. slice-5-feat-extensions-extension-installation-l → base slice-4-feat-extensions-external-tool-helpers-an
  6. slice-6-feat-extensions-extension-upgrade-and-co → base slice-5-feat-extensions-extension-installation-l
  7. slice-7-feat-extensions-version-range-checking-a → base slice-6-feat-extensions-extension-upgrade-and-co

PR 1 feat(extensions): core types, naming rules, manifest parsing, and state storage

Establish the shared data types, command/directory naming conventions, authored and installed manifest serialization, and per-extension state persistence that all subsequent operations depend upon.

Files (6):

  • src/lib/extensions/types.ts
  • src/lib/extensions/source.ts
  • src/lib/extensions/source.test.ts
  • src/lib/extensions/manifest.ts
  • src/lib/extensions/manifest.test.ts
  • src/lib/extensions/state.ts

PR 2 feat(extensions): disk discovery and test fixture support

Introduce lightweight, subprocess-free extension discovery across installed directories alongside test support helpers for building fake extensions in temporary directories.

Files (3):

  • src/test-support/extension-fixture.ts
  • src/lib/extensions/discover.ts
  • src/lib/extensions/discover.test.ts

PR 3 feat(extensions): process dispatch and environment isolation

Add the execution layer that spawns extension executables with the documented environment contract, shebang resolution, and token isolation.

Files (2):

  • src/lib/extensions/dispatch.ts
  • src/lib/extensions/dispatch.test.ts

PR 4 feat(extensions): external tool helpers and extension removal

Provide subprocess wrappers for git and npm, the GitHub API client for release asset fetching and checksum validation, and safe extension removal.

Files (6):

  • src/lib/extensions/run.ts
  • src/lib/extensions/git.ts
  • src/lib/extensions/npm.ts
  • src/lib/extensions/github.ts
  • src/lib/extensions/remove.ts
  • src/lib/extensions/remove.test.ts

PR 5 feat(extensions): extension installation lifecycle

Implement atomic staging and installation across GitHub release binaries, git clones, and local symlinks.

Files (2):

  • src/lib/extensions/install.ts
  • src/lib/extensions/install.test.ts

PR 6 feat(extensions): extension upgrade and concurrency pool

Implement upgrade logic for git clones and binary releases, dry-run comparisons, pin enforcement, and bounded parallel GitHub API queries.

Files (2):

  • src/lib/extensions/upgrade.ts
  • src/lib/extensions/upgrade.test.ts

PR 7 feat(extensions): version range checking and unified ExtensionManager facade

Add semver range evaluation for extension host requirements and provide the unified `createExtensionManager` facade that orchestrates all extension lifecycle operations.

Files (4):

  • src/lib/extensions/version-range.ts
  • src/lib/extensions/version-range.test.ts
  • src/lib/extensions/manager.ts
  • src/lib/extensions/manager.test.ts

This plan is based on the current PR head. Keep each slice buildable and move tests with the behavior they cover.

You can use your agent of choice (Codex/Claude etc) to help you split this PR 😊 Just copy the link to this comment and ask them Can you please create a PR stack based on the suggestions in this comment

@doistbot doistbot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds the self-contained extension manager library under src/lib/extensions/ — discovery, staged installs, upgrades, removal, and dispatch — with no repo imports, ahead of the user-facing td extension commands.

Few things worth tightening:

  • Secrets leak across the process boundary: buildExtensionEnv passes TODOIST_API_TOKEN/GH_TOKEN through to extensions despite the documented contract, and npm lifecycle scripts during dependency installs inherit the full host environment — build from a minimal allowlist and add a test asserting the token is absent.
  • Upgrade flow gaps: upgrades never print the trust warning the spec requires, a forced git upgrade resets the clone but leaves the stale pin in state so later plain upgrades still skip it (worth asserting in the test), and upgradeGit itself has no coverage.
  • Removal can destroy uncommitted work: isDirty treats git errors as "clean" and the no-lockfile npm fallback generates a package-lock.json that dirties the clone, so remove wrongly refuses or wrongly deletes — distinguish the error case and avoid writing the lockfile.
  • Smaller items: Windows spawning of npm.cmd without a shell fails with EINVAL under Node's 2024 security fix, child output capture is unbounded, non-directory local install targets are accepted but can never dispatch, and ^0.0.x ranges are treated as minor-bounded rather than patch-only.

I also included a few optional follow-up notes in the details below.

Optional follow-up notes (15)
  • P3 src/lib/extensions/install.ts:122: moveIntoPlace deletes the existing install (and the destination) before renaming the staged directory in. If the process dies between the rm and the rename, or the rename fails — e.g. on Windows a transient file lock/EPERM on the staged directory is realistic — the previously working extension is gone, contradicting the module doc's "never destroys the one already installed" guarantee. Consider renaming the old directory aside to a temp path first, then renaming staged into place, then deleting the old one, so a failure leaves the previous install recoverable.
  • P3 src/lib/extensions/types.ts:157: withSpinner is declared on ExtensionManagerOptions ("Wraps a slow operation in the host's spinner"), but createExtensionManager never reads it, so the option silently does nothing. Either wire it into install/upgrade/dispatch or drop it from the options until a caller uses it.
  • P3 src/lib/extensions/discover.ts:25: exists is defined identically in three new modules: here, install.ts (line 48), and npm.ts (line 14). Extract one shared helper (for example, a small fs utility beside run.ts) and import it everywhere so the stat/catch semantics have a single source of truth.
  • P3 src/lib/extensions/manager.ts:131: This isExecutable duplicates the private isExecutable in dispatch.ts (line 37) almost verbatim, differing only by a comment. Export that function from dispatch.ts and reuse it here instead of maintaining two copies of the same platform/executable-bit check.
  • P3 src/lib/extensions/discover.ts:54: parseRemote re-implements git-remote parsing that parseSource in source.ts also does: both use the same scp-like regex and .git-suffix stripping. Extract one shared host/owner/repo parser and call it from both, so the two parsing paths cannot drift.
  • P3 src/lib/extensions/dispatch.ts:144: _options is never referenced in dispatchExtension. Drop the parameter and the dispatchOptions argument forwarded at manager.ts:219 until the function actually consumes it.
  • P3 src/lib/extensions/upgrade.ts:44: before = await headSha(...) runs on every non-dry upgrade too, but that branch never reads before; pull and resetToRemote each compute the same starting SHA themselves. Move the call inside the dryRun branch to avoid an extra git rev-parse subprocess per git extension upgrade.
  • P3 src/lib/extensions/install.ts:328: This if block has no body — it only documents that a pinned tag with no release falls through to installGit. An empty conditional reads like missing code. Drop the if and keep the comment as a plain statement before the fall-through.
  • P3 src/lib/extensions/manager.ts:191: upgrade() re-runs full extension discovery for every selector. Each requireExtension calls find, which calls discover() — a readdir plus manifest/state reads for every installed extension — so upgrade(['a','b','c']) performs three complete scans in parallel. Discover once and resolve all selectors against that single snapshot instead.
  • P3 src/lib/extensions/upgrade.ts:149: readState runs for every extension, but its result is only used inside the extension.pinned && !options.force branch (for the detail message). Move the call into that branch so unpinned extensions skip the state-file read.
  • P3 src/lib/extensions/discover.ts:118: Regular entries pay for both stat and lstat here on every discovery. Call lstat first; it already identifies regular files/directories, and only follow it with stat when the entry is a symlink whose target must be inspected.
  • P3 src/lib/extensions/source.test.ts:36: 'td--x' appears twice in this reject matrix, so the second entry is a duplicate no-op case. Drop one.
  • P3 src/lib/extensions/dispatch.test.ts:89: These negative env assertions depend on the test process having no TD_* variables, but buildExtensionEnv starts from ...process.env. If TD_TOKEN, TD_API_TOKEN, TD_USER, or TD_ACCESSIBLE is set in the environment, the matching toBeUndefined() assertion fails spuriously; likewise expect(env.TD_EXTENSION).toBe(process.env.TD_EXTENSION) passes even when an ambient value is present. Clear the relevant TD_* keys in beforeEach (or use vi.stubEnv) so the contract is tested deterministically.
  • P3 src/lib/extensions/install.test.ts:319: if (!hasGit) return makes the test pass vacuously when git is missing, and the skip is invisible in the test report — a CI machine without git silently loses all clone-install coverage while everything looks green. remove.test.ts (lines 105, 116, 127) has the same pattern. Vitest reports skips properly with it.skipIf(!hasGit) / describe.skipIf(!hasGit), so use that instead of an early return.
  • P3 src/lib/extensions/manager.ts:1: AGENTS.md requires updating CODEBASE.md when a new broadly-reusable module is added under src/lib/ — a new 24-file subsystem directory qualifies. Add an entry to the src/lib/ catalog in CODEBASE.md (even a short one noting it's host-agnostic and headed to @doist/cli-core) so the map stays accurate.

Share FeedbackReview Logs

Comment thread src/lib/extensions/upgrade.ts
Comment thread src/lib/extensions/upgrade.ts
Comment thread src/lib/extensions/git.ts
Comment thread src/lib/extensions/npm.ts
Comment thread src/lib/extensions/npm.ts
Comment thread src/lib/extensions/version-range.ts
Comment thread src/lib/extensions/run.ts
Comment thread src/lib/extensions/upgrade.test.ts
Comment thread src/lib/extensions/upgrade.test.ts
Comment thread src/lib/extensions/npm.ts
@scottlovegrove

Copy link
Copy Markdown
Collaborator Author

Closing in favour of a seven-part stack, following the slicing plan suggested above. The content is the same work with this review's feedback applied; each thread above says which slice carries its fix.

Every slice type-checks and passes its own tests on its own branch, and the tip of the stack is byte-identical to what this branch would have been.

@scottlovegrove
scottlovegrove deleted the feat/extensions-manager branch September 10, 2026 14:18
scottlovegrove added a commit that referenced this pull request Sep 11, 2026
Slice 1 of 7. Replaces #520, which was one 3.7k-line PR; this is the
same work split along doistbot's suggested plan, with its review
feedback already applied.

The vocabulary every later slice builds on: the types describing an
installed extension, the naming rules derived from the host binary name
(`td` gives `td-<name>` directories, `td-extension.json` and
`.td-manifest.json`), one parser shared by install sources and by git
remotes read back off disk, manifest reading and writing including the
`package.json` fallback for Node extensions, and per-extension state
kept outside the extension directory so that deleting that directory by
hand is still a clean uninstall.

Nothing is wired up yet. See [the
spec](https://github.com/Doist/todoist-cli/blob/main/docs/specs/extensions.md)
for where this is going.

**Stack:** #521 core · #522 discovery · #523 dispatch · #524 tools ·
#525 install · #526 upgrade · #527 manager
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.

2 participants