Skip to content

feat(extensions): the extension manager facade - #527

Merged
scottlovegrove merged 3 commits into
feat/extensions-6-upgradefrom
feat/extensions-7-manager
Sep 11, 2026
Merged

feat(extensions): the extension manager facade#527
scottlovegrove merged 3 commits into
feat/extensions-6-upgradefrom
feat/extensions-7-manager

Conversation

@scottlovegrove

@scottlovegrove scottlovegrove commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Slice 7 of 7, on top of slice 6. This completes the library half of phase 1.

Ties the slices together behind createExtensionManager, which is the only thing a host CLI needs to call.

Includes the version-range check behind an extension's requires field. A range the running CLI does not satisfy is a warning rather than a refusal: upgrading the CLI should not silently break an extension that still works.

Also records the subsystem in CODEBASE.md, including why nothing in the directory imports from the rest of the repo.

Next, in a separate PR: the td extension command group, the src/index.ts wiring, the command-token lookup change, --user scoping, doctor checks and SKILL_CONTENT.

Stack: #521 core · #522 discovery · #523 dispatch · #524 tools · #525 install · #526 upgrade · #527 manager

@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.

This PR completes the library half of phase 1 by tying the extension slices together behind createExtensionManager, adding the fail-open requires version-range check, and documenting the subsystem in CODEBASE.md. Few things worth tightening:

  • upgrade() passes selectors straight through without deduping after normalizeSelector, so aliased selectors (e.g. ['goals', 'td-goals']) can trigger two concurrent upgrades against the same directory — dedupe by normalized name first.
  • The range parser splits comparators on whitespace, so a valid range like >= 4.0.0 misreads 4.0.0 as an exact-match comparator; parse operators with optional trailing whitespace and add a test for this form.
  • find() discovers and describes every installed extension before picking one, making dispatch, remove, and targeted upgrade needlessly inspect all extensions; add a targeted lookup/describe path.

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

Optional follow-up notes (7)
  • P3 src/lib/extensions/version-range.ts:73: An empty || alternative is treated as a satisfied clause (comparators.length === 0 returns true), so '>=9.0.0 ||' (or a leading/consecutive ||) makes the whole range pass and suppresses the warning even though the parseable part is unmet. Drop empty clauses before some, returning true only when no non-empty clause remains.
  • P3 src/lib/extensions/version-range.ts:72: A hyphen range like 4.0.0 - 5.0.0 is half-parsed rather than passed through: the split yields comparators {='4.0.0'}, undefined (for -), and {='5.0.0'}, so td 4.5.0 fails the range even though it sits inside it — producing exactly the spurious warning this module's header says false negatives should not cause, since an unparseable token makes the clause pass but parseable ones still enforce equality. Either reject clauses containing an unparseable comparator wholesale (treat the clause as satisfied), or explicitly document hyphen ranges as unsupported; the current middle ground silently misjudges them.
  • P3 src/lib/extensions/manager.ts:188: The EXTENSION_NOT_FOUND CliError is built twice in this file (here and in requireExtension above) with the same code, message, and hint. Extract one notFound(name) helper and call it from both paths so the user-facing message and hint cannot drift between the single-selector and batch paths.
  • P3 src/lib/extensions/version-range.test.ts:24: The caret test says it covers the 0.x special case, but it only exercises ^0.2.3 (the minor > 0 branch). The ^0.0.x branch in upperBound (return 0.0.${patch + 1}) is never hit, so a regression there would pass. Add satisfiesRange('0.0.3', '^0.0.3') as true and satisfiesRange('0.0.4', '^0.0.3') as false.
  • P3 src/lib/extensions/manager.test.ts:72: The test title promises the error points at the list command, but the assertion only checks code: 'EXTENSION_NOT_FOUND'. The hint (Run \td extension list`) is the user-visible behavior being named, and it can be dropped or mistyped without this test failing. Assert the message or hintscontainsextension list` too.
  • P3 src/lib/extensions/manager.test.ts:149: manager.upgrade has its own EXTENSION_NOT_FOUND branch for unknown selectors (it does not delegate to require), and nothing in this suite exercises it. A regression there would break the future td extension upgrade <name> error path with no failing test. Add a case asserting makeManager().upgrade(['ghost']) rejects with code: 'EXTENSION_NOT_FOUND' and a list-command hint.
  • P3 src/lib/extensions/manager.test.ts:46: Low-value test: this only asserts join(dataDir, 'extensions'), a constant. The isEmpty test below already verifies behaviorally that extensions are discovered from <dataDir>/extensions (it writes a fixture there and watches isEmpty() flip). I'd delete this test and let the behavioral one carry the contract.

Share FeedbackReview Logs

Comment thread src/lib/extensions/manager.ts Outdated
Comment thread src/lib/extensions/version-range.ts Outdated
Comment thread src/lib/extensions/manager.ts Outdated
@scottlovegrove
scottlovegrove added this pull request to stack #528 September 10, 2026 16:03
@scottlovegrove
scottlovegrove force-pushed the feat/extensions-7-manager branch 2 times, most recently from b54812f to e82597b Compare September 10, 2026 16:47
@scottlovegrove
scottlovegrove force-pushed the feat/extensions-7-manager branch from e82597b to 1f664d8 Compare September 10, 2026 16:52
@scottlovegrove
scottlovegrove force-pushed the feat/extensions-7-manager branch from 1f664d8 to f36f4d5 Compare September 10, 2026 16:57
@scottlovegrove
scottlovegrove removed this pull request from stack #528 September 10, 2026 16:57
@scottlovegrove
scottlovegrove added this pull request to stack #529 September 10, 2026 16:58
@scottlovegrove
scottlovegrove removed this pull request from stack #529 September 10, 2026 16:59
@scottlovegrove
scottlovegrove added this pull request to stack #530 September 10, 2026 16:59
@pawelgrimm

Copy link
Copy Markdown

@scottlovegrove Sorry, I ran out of time today. I'll get a review in tomorrow!

@scottlovegrove
scottlovegrove force-pushed the feat/extensions-7-manager branch from f36f4d5 to 7821d84 Compare September 11, 2026 09:43
@scottlovegrove
scottlovegrove force-pushed the feat/extensions-7-manager branch from 2bd106a to 7821d84 Compare September 11, 2026 09:48
@scottlovegrove scottlovegrove added the 👀 Show PR PR must be reviewed before or after merging label Sep 11, 2026
Ties the slices together behind createExtensionManager, which is the only
thing a host CLI needs to call.

Includes the version-range check behind an extension's `requires` field.
A range the running CLI does not satisfy is a warning rather than a
refusal: upgrading the CLI should not silently break an extension that
still works.

Also records the subsystem in CODEBASE.md, including why nothing in the
directory imports from the rest of the repo.
Review feedback on the manager slice:

- deduplicate selectors before upgrading, so naming one extension twice
  does not start two upgrades of the same directory at once
- look up a single extension through the targeted path, so dispatch,
  remove and a named upgrade stop inspecting every installed extension
- build the not-found error in one place, so the single and batch paths
  cannot drift
- read an operator written apart from its version, so ">= 4.0.0" is one
  comparator and does not warn about a version that satisfies it
- treat a clause this checker cannot read in full as satisfied rather
  than enforcing half of it, which is what made a hyphen range warn about
  a version inside it
- ignore empty alternatives, which were letting an unmet range pass

Adds coverage for those forms, for the ^0.0.x bound, and for the
not-found hint that names the list command.
Listing reports it rather than dispatch warning about it on every run: an
extension whose metadata is newer still works, and the listing is where
someone looks to find out why a description is missing.
@scottlovegrove
scottlovegrove force-pushed the feat/extensions-7-manager branch from 7821d84 to d3ee4d6 Compare September 11, 2026 10:00
@scottlovegrove
scottlovegrove merged commit bcd84b6 into next Sep 11, 2026
6 checks passed
@scottlovegrove
scottlovegrove deleted the feat/extensions-7-manager branch September 11, 2026 10:03
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
scottlovegrove added a commit that referenced this pull request Sep 11, 2026
Slice 2 of 7, on top of slice 1.

Discovery walks the extensions directory and works out what each entry
is: a symlink or path file is a local install, a `.git` directory is a
clone, anything else is a release binary. It runs on every invocation of
the CLI, including the common case of no extensions at all, so it costs
one `readdir` plus a couple of small file reads and never spawns a
process or touches the network. The git remote is read out of
`.git/config` rather than by asking git.

Also adds the test fixture helpers, which build real extension
directories in a temporary location so later slices can install and run
something genuine rather than a mock.

**Stack:** #521 core · #522 discovery · #523 dispatch · #524 tools ·
#525 install · #526 upgrade · #527 manager
scottlovegrove added a commit that referenced this pull request Sep 11, 2026
Slice 3 of 7, on top of slice 2.

The execution half of the contract: arguments reach the extension
exactly as the user typed them, the three standard streams are the
host's own, and the host exits with whatever the extension exited with.

Node-shebang scripts are launched with the host's own Node, which makes
them work on Windows and pins them to the Node version the CLI already
requires. Other scripts run directly on POSIX and through `sh` on
Windows.

The environment adds the documented `TD_*` contract. No credential is
ever injected: an extension that needs a token runs `td auth token
view`. A token the user exported themselves is inherited like any other
variable, which the spec records as a deliberate exception, and there is
now a test pinning that behaviour so it cannot change by accident.

**Stack:** #521 core · #522 discovery · #523 dispatch · #524 tools ·
#525 install · #526 upgrade · #527 manager
scottlovegrove added a commit that referenced this pull request Sep 11, 2026
Slice 4 of 7, on top of slice 3.

The outside world an install has to talk to, plus the one operation that
needs no installer.

- A subprocess wrapper that captures a bounded tail of output, since npm
runs extension lifecycle scripts that could otherwise print without end.
- Git operations, with working-tree state reported as clean, dirty or
unknown, so that "could not tell" is never mistaken for "safe to
delete".
- npm dependency installation, run without the CLI's own credentials,
invoked through the command interpreter on Windows because a `.cmd` shim
cannot be spawned directly on supported Node versions, and without
writing a lockfile into a clone that has none.
- The GitHub client: releases, asset downloads and checksum
verification.
- Removal, which deletes only the link for a local install and refuses a
clone with uncommitted or unreadable state unless forced.

**Stack:** #521 core · #522 discovery · #523 dispatch · #524 tools ·
#525 install · #526 upgrade · #527 manager
scottlovegrove added a commit that referenced this pull request Sep 11, 2026
Slice 5 of 7, on top of slice 4.

Installs from a release binary, a git clone, or a local directory.

Everything is assembled in a staging directory and moved into place only
once complete, with the previous install moved aside rather than
deleted, so a failure part-way through leaves the working extension
recoverable. The trust warning is printed before anything is downloaded,
cloned or executed.

Release assets are matched by platform and architecture, verified
against the release's checksums when it publishes any, and recorded in a
manifest that carries the author's description and version requirement.

**Stack:** #521 core · #522 discovery · #523 dispatch · #524 tools ·
#525 install · #526 upgrade · #527 manager
scottlovegrove added a commit that referenced this pull request Sep 11, 2026
Slice 6 of 7, on top of slice 5.

Clones fast-forward, release binaries are re-downloaded when the tag
moves, and local installs are left alone because the user's own
directory is already the source of truth.

Pins are honoured unless forced, and a forced upgrade clears the pin it
just moved past rather than leaving a stale one to skip every later
upgrade. Dependencies are reinstalled only when `package.json` or the
lockfile changed. Upgrading many extensions bounds how many talk to
GitHub at once, and warns about trust once for the whole run rather than
once per extension.

**Stack:** #521 core · #522 discovery · #523 dispatch · #524 tools ·
#525 install · #526 upgrade · #527 manager
@doist-release-bot

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 5.4.0-next.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

released on @next 👀 Show PR PR must be reviewed before or after merging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants