feat: connector prerequisites system + Xiaohongshu connector#76
Merged
feat: connector prerequisites system + Xiaohongshu connector#76
Conversation
New types for declaring package-level external dependencies and surfacing
their runtime status:
- Prerequisite, PrerequisiteKind ('cli' | 'browser-extension' | 'site-session')
- Detect (exec-based detection with version regex, matchStdout, timeout)
- Install (discriminated union by kind: cli command by platform, browser-extension
with webstoreUrl or manual install steps, site-session openUrl)
- ManualInstall (downloadUrl + step list)
- SetupStep, SetupStatus ('ok' | 'missing' | 'outdated' | 'error' | 'pending')
- AuthStatus.setup field (optional, additive)
- PrerequisitesCapability with check() method
- checkAuthViaPrerequisites(caps) helper for connectors to delegate auth
entirely to the prerequisite system
All additions are optional; existing connectors compile and behave
unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PrerequisiteChecker (packages/core/src/connectors/prerequisites.ts): - Detects prerequisites via the existing exec capability - In-memory cache + in-flight deduplication (prevents redundant spawns on rapid focus events) - Dependency-aware ordering: downstream prerequisites marked 'pending' when upstream is non-ok, skipping detect - Version comparison uses the semver npm package (already in deps) - detectOne returns: ok — exit 0, matchStdout passes, or version >= minVersion outdated — version < minVersion missing — exec throws ENOENT or exit non-zero error — timeout, unparseable version, unknown detect type pending — upstream requires is non-ok Loader (loader.ts): - Parses spool.prerequisites into ConnectorPackage - validatePrerequisites exported for unit testing: checks required fields, install.kind matches kind, browser-extension has webstoreUrl or manual, minVersion requires versionRegex, requires references prior ids, duplicate id rejection Registry (registry.ts): - registerPackage merges connectors arrays when the same package id registers multiple times, dedupe by connector id (needed for multi- connector packages like Xiaohongshu where each sub-connector calls registerPackage) - getPackage(id), listPackages() accessors for main process Registry fetch (registry-fetch.ts): - Accepts optional url override (file://, absolute path, or HTTP URL) for dev-mode local registry Sync engine (sync-engine.ts): - SyncOptions.maxPages (default 100): defense-in-depth safety cap in both ephemeral and persistent sync loops. Prevents any connector from looping forever on broken pagination. Stops with stopReason='max_pages'. Tests: 16 new unit tests covering detect paths, validator rules, registry merge semantics. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…install
IPC handlers (packages/app/src/main/index.ts):
- connector:list extended with packageId and setup fields. Bundled and
user-installed connectors both resolve via the registry, not the
installed-packages directory scan.
- connector:recheck-prerequisites — force re-run detect for a package,
broadcast status-changed only when steps differ.
- connector:install-cli — supervised install via user login shell
($SHELL -lc / cmd /c), 120s wall-clock timeout with SIGKILL escalation
5s after SIGTERM, user-cancellable. Returns discriminated union:
{ ok: true, installId, exitCode } | { ok: false, reason: '...' }
Sudo-requiring or manually-flagged commands short-circuit to
{ ok: false, reason: 'requires-manual' } so the renderer can prompt
the user to copy the command instead.
- connector:install-cli-cancel — SIGTERM + SIGKILL escalation.
- connector:copy-install-command — platform-appropriate command to
clipboard, discriminated-union return shape.
- connector:open-external — wraps shell.openExternal.
resolveCliPrereq helper dedups the package + prereq + command lookup
across install-cli and copy-install-command.
Focus-driven recheck: BrowserWindow focus event iterates non-ok cached
packages only, runs checker.check, broadcasts status-changed only when
the step statuses diff against the prior cache.
PrerequisiteChecker and ExecCapability are singletons shared across
app.whenReady and reloadConnectors to avoid spawning parallel exec
instances.
connector:fetch-registry (dev):
- Reads workspace registry.json in dev mode (!app.isPackaged)
- SPOOL_REGISTRY_URL env var overrides explicitly
- Production still fetches from raw.githubusercontent.com/spool-lab/spool/main
installConnectorPackage (dev):
- installFromWorkspace() helper symlinks workspace connector packages
into ~/.spool/connectors/node_modules/ before falling through to npm.
- Lets unpublished connectors be tested via the real Install UI.
electron.vite.config.ts:
- @spool/core excluded from externalization so it's bundled (pure ESM,
can't be require()'d via externalizeDepsPlugin's runtime resolution).
Preload (packages/app/src/preload/index.ts):
- 6 new methods: recheckPrerequisites, installCli, cancelInstallCli,
copyInstallCommand, openExternal, onStatusChanged (returns unsubscribe).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ckages
New components:
- PackageSetupCard.tsx — renders a package's prerequisite checklist.
Status icons (Check/X/AlertTriangle/Circle) by SetupStatus. Kind icons
(Terminal/Puzzle/KeyRound) by PrerequisiteKind. Per-kind primary
action button:
cli missing/outdated: [Install] / [Upgrade] — supervised exec
with inline spinner 'Installing…' and Cancel. On failure shows
[Retry] and [Run manually] (copy command + toast). requires-manual
reason skips straight to manual path.
browser-extension with webstoreUrl: [Install from Chrome Store]
→ shell.openExternal(webstoreUrl)
browser-extension manual-only: [Install extension] → opens
ManualInstallModal
site-session: [Open site] → shell.openExternal(openUrl)
Auto-hides entirely when all steps are ok (DESIGN.md minimal
decoration; card reappears when anything regresses via focus recheck).
Re-check button while any step non-ok.
- ManualInstallModal.tsx — data-driven step list (no positional magic).
Download button (opens release URL), Copy chrome://extensions URL
button (clipboard), Check now button (triggers recheckPrerequisites
and closes on success — needed because focus event doesn't fire while
users stay in Spool walking through the steps).
SourcesPanel and SettingsPanel:
- Group connectors by packageId (populated from registry). Bundled
connectors don't appear in getInstalledConnectorPackages() so
packageName is empty; grouping falls back to packageId.
- Multi-connector packages render as ONE aggregated row with commonLabel
prefix extraction ('GitHub' from 'GitHub Stars / GitHub Notifications').
Group-level Connect and Sync buttons Promise.all across sub-connectors.
Single-connector groups preserve legacy per-connector rendering.
- SetupCard is rendered above the group/sub-connector cards. When any
step is non-ok, the sub-connector area is dimmed (opacity-50
pointer-events-none) so users complete setup first.
- Both panels subscribe to connector:status-changed and reload on
broadcast — needed so the Setup card appears within ~1s of first
connector:list without waiting for a window focus event.
- Amber error bar fallback preserved for connectors without setup
(HN, Twitter Bookmarks, Typeless).
commonLabel extracted to packages/app/src/renderer/lib/common-label.ts
and shared between both panels. Longest common word prefix, falls back
to the first label when no common prefix exists.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…gination safety
First real consumer of the prerequisites system. Three sub-connectors
sharing opencli CLI + OpenCLI browser bridge (manual-install Chrome
extension) + logged-in xiaohongshu.com session:
xiaohongshu-feed ephemeral — home feed snapshot
xiaohongshu-notes persistent — published notes history
xiaohongshu-notifications persistent — event log (comments/likes/@)
Notifications is persistent (not ephemeral) because notifications are
one-shot events with historical value — missing them means missing
them forever. Feed is ephemeral because content reappears naturally
on refresh.
fetchPage pagination:
- Cursor encoded as a decimal page counter ('1', '2', ...)
- opencli --cursor flag passed best-effort
- Per-subcommand MAX_PAGES (feed: 3, notes: 20, notifications: 20)
guarantees termination regardless of opencli's actual cursor support.
- Returns nextCursor: null when items.length < PAGE_LIMIT OR page >= max.
Combined with the SyncOptions.maxPages cap in sync-engine, this is
three layers of defense against infinite crawl.
checkAuth delegates entirely to checkAuthViaPrerequisites(caps) — no
custom auth logic in the connector code.
Tests (src/index.test.ts): 9 cases — first-call semantics, cursor
propagation to opencli, MAX_PAGES cap per sub-connector, infinite-
response loop termination, notifications persistence, opencli error
propagation.
Registry (packages/landing/public/registry.json): three entries added
for xiaohongshu-{feed,notes,notifications}, all pointing at
@spool-lab/connector-xiaohongshu. Not bundled — users install via the
Available Connectors UI, grouped by package name into one 'Xiaohongshu'
card with three sub-connectors listed.
App picks up @spool/connector-sdk as a workspace dep (was transitively
available via @spool/core; renderer components now import SetupStep and
ManualInstall types directly from @spool/core re-exports).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
bae888a to
8a43de5
Compare
Render /connectors/ as a 2-column grid of packages grouped by npm name, with icon + title + author header, clamped description, and a footer showing category and source count. "Copy CLI" button writes the install command to the clipboard with feedback, while multi-source packages expose the sub-connector list as a hover tooltip on the source count. Adds packageDescription in registry.json for the multi-connector packages so grouped cards can describe the whole package in one line. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
b75dbf7 to
c4d1eb2
Compare
opencli's xiaohongshu feed and notifications subcommands turned out to be
unsuitable for periodic sync: feed reads the page Pinia store snapshot so
item count fluctuates with whatever the store happens to hold (~20), and
notifications returns only `{rank: N}` placeholders without real fields.
Both will return when upstream behavior stabilizes.
Notes also moves to opencli's correct `creator-notes` subcommand (the old
`notes` was a typo) and stops attempting pagination since opencli has no
cursor/page/offset flag — single-shot --limit fetch with a stable nextCursor
of null. Empty results from opencli (which it reports as exit 1 with
"No notes found") are treated as a successful empty page rather than a
sync error, so connected-but-zero-items doesn't show as red. Default
--limit bumped to 100 to give XHS room when the store does have more.
Landing registry mirrors the scope reduction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…adata The loader used to require connector class fields (id, label, ephemeral, etc.) to exactly match the package manifest, throwing if they drifted. That caused a confusing failure mode: tweak class.ephemeral without updating package.json and the entire connector silently fails to load — for a multi-connector package, only the inconsistent sub-connector vanishes while its siblings still show, which looks like a UI bug rather than a metadata bug. Manifest now wins: after instantiating the class, the loader uses defineProperty to overwrite the metadata fields with the manifest values. A drift between class and manifest logs a warning so authors can clean up, but loading proceeds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…erit env GUI-launched apps on macOS don't inherit shell env, so connector subprocesses spawned via the exec capability had no proxy vars, no nvm-managed PATH, etc. This surfaced as the GitHub connector hanging on \`gh api\` calls (TCP read reset) for users behind a proxy: gh would direct-connect to api.github.com and time out because http_proxy/https_proxy weren't propagated. Wrap spawn in the user's shell so rc files get sourced, mirroring the install supervisor's approach. zsh uses \`-ilc\` to source both .zprofile and .zshrc (where most users keep proxy/PATH tweaks); bash uses plain \`-lc\` to avoid the "cannot set terminal process group" warnings that non-TTY interactive bash emits in CI, relying on the standard .bash_profile -> .bashrc chain. Args are POSIX single-quoted to avoid shell injection. Spawning detached so SIGTERM kills the whole process group on timeout (the inner command would otherwise be orphaned with stdio pipes open, blocking the close handler indefinitely). Windows keeps direct spawn. Side effect: missing-binary now exits 127 from the shell instead of throwing ENOENT at spawn time — the test suite is updated and connectors that need to distinguish missing-binary from other failures should check exitCode. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two changes that compound: the focus listener used to skip recheck for packages whose cache was all-ok, on the assumption that ok stays ok. But extensions can be removed, CLIs uninstalled, and login sessions expired without us knowing, so cached green status would persist indefinitely until the user manually clicked Re-check. Always re-check on focus instead — focus is naturally infrequent and the diff-before-broadcast logic prevents needless renderer churn. Second, the renderer's prereq card only showed once the in-memory cache had a result, which on a fresh app launch meant a brief moment of "card invisible" some users didn't realize would resolve. Pre-populate the setup field with status='pending' steps derived from the manifest when the cache is empty, so the UI structure is visible immediately and only the per-step status flips when the actual check completes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PackageSetupCard used to disappear once all prerequisites were satisfied,
which left no way to inspect prereq state for an installed connector
without uninstalling. Add an alwaysShow prop that keeps the card visible
in collapsed form ("Prerequisites — N of N ready") and expands to the
full step list on click. Wired into the connector detail view in
SettingsPanel; SourcesPanel keeps the existing auto-hide so the overview
stays quiet.
Also tidy each prereq row: the per-step install action used a solid
accent fill that out-weighed the row's icon content and made that row
taller than its siblings, breaking vertical rhythm. Switch to a
ghost-style accent button, align rows to center with min-height, and
wrap the action so long labels don't push it off-screen.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7ff9960 to
2026624
Compare
Match the sibling packages' pattern: tsconfig excludes .test.ts from the compiled dist, and package.json declares a files whitelist so the published tarball only contains dist/ and README. Previously the tarball would have shipped test files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.
Summary
Adds a package-level prerequisites declaration to the Spool connector plugin system, with the Xiaohongshu (XHS) connector as the first real consumer.
A connector package can now declare external dependencies (CLI tools, browser extensions, site logins) in its
package.json. The app detects them at runtime, renders a Setup checklist in the UI, offers one-click primary actions (supervised install, open Chrome Store, open login page), and re-checks automatically when the window regains focus or the user clicks Install.Status: draft. XHS package isn't on npm yet; tested locally via the dev-mode workspace symlink path.
Design
Two concepts, two names:
prerequisites(manifest, static): what the package needssetup(runtime, dynamic): current per-step statusThree prerequisite kinds, schema open for future ones:
cliexec --version+ optionalminVersion(semver)browser-extensionexec doctor+matchStdoutregexsite-sessionexec doctor+ connectivity regexDependency ordering via
requires: ["otherId"]— downstream markedpendingif upstream isn't ok, skipping its detect call.Detection in main process (
PrerequisiteChecker) using the existingexeccapability. In-memory cache, in-flight dedup prevents redundant spawns on rapid focus events. No DB migration — prerequisite state is purely runtime.Auto-recheck triggers: window focus (always, even when previously all-ok — extensions / CLIs / login sessions can degrade silently outside the app), post-install success, manual Re-check. Diff-before-broadcast prevents spurious renders when nothing changed.
Default
checkAuth: SDK helpercheckAuthViaPrerequisites(caps)that connectors can call when their auth is fully covered by prerequisites. XHS uses this — zero customcheckAuthlogic in the connector code.UI: one Setup card per package. SourcesPanel hides it when everything is ok (overview stays quiet); the connector detail view in SettingsPanel keeps it visible in collapsed form so users can inspect prereq state any time. The card pre-populates with
status='pending'rows from the manifest the moment the package is opened, then per-step status flips when the actual check completes — no flash of empty state.IPC error shapes are discriminated unions everywhere (
{ok: true, ...} | {ok: false, reason: '...'}), no string sentinels.Manifest is the single source of truth
The loader used to require the connector class fields (id, label, ephemeral, ...) to exactly match the package manifest, throwing if they drifted. That made multi-connector packages fragile: tweak
class.ephemeralwithout updatingpackage.jsonand the entire sub-connector silently fails to load while its siblings still appear, looking like a UI bug rather than a metadata bug. The loader now usesdefinePropertyto overwrite class fields with manifest values after instantiation — manifest wins, drift only logs a warning.Exec capability inherits user shell env
GUI-launched apps on macOS don't inherit shell env (no
http_proxy, no nvm-managed PATH, etc.), which surfaced as the GitHub connector hanging ongh apicalls (TCP read reset) for users behind a proxy. The exec capability now wraps spawn in the user's shell so rc files get sourced:-ilc(sources .zprofile + .zshrc)-lc(sources .bash_profile, which standardly chains to .bashrc; -i avoided because bash emits TTY warnings in CI)Subprocesses spawn detached so timeouts can SIGKILL the whole process group rather than orphaning the inner command.
XHS Connector
Currently ships one sub-connector:
xiaohongshu-notes(creator notes, persistent, single-shot fetch). The package keeps its multi-connector array structure so feed and notifications can return cleanly when their upstream issues resolve:xiaohongshu-feed— opencli reads the page Pinia store snapshot without scrolling, so item count fluctuates (~20) regardless of--limitxiaohongshu-notifications— opencli returns only[{rank: N}]placeholders without real content fieldsNotes uses opencli's
creator-notessubcommand (single-shot,--limit 100, no pagination since opencli has no cursor flag). Empty results (which opencli reports as exit 1 "No notes found") are treated as a successful empty page rather than a sync error.Safety against infinite crawl
Two layers:
nextCursor: nullalways for opencli-backed connectors (no pagination supported upstream)maxPagesoption: new optional field onSyncOptions(default 100). Both ephemeral and persistent sync loops check the cap and exit withstopReason: 'max_pages'. Defense in depth for any connectorDev-mode conveniences
Three non-production-affecting paths added for local testing:
!app.isPackaged,connector:fetch-registryreadspackages/landing/public/registry.jsonfrom the workspace.SPOOL_REGISTRY_URLenv var overrides explicitly. Production still fetches fromraw.githubusercontent.compackages/connectors/<dir>if present; otherwise falls through to npm. Lets us test unpublished connectors via the real Install UXTests
packages/core/src/connectors/prerequisites.test.ts— PrerequisiteChecker detect paths andvalidatePrerequisitesmanifest validatorpackages/core/src/connectors/registry.test.ts— multi-connector package merge in ConnectorRegistrypackages/core/src/connectors/loader.test.ts— manifest-as-truth (drift between class field and manifest is logged but doesn't fail loading)packages/core/src/connectors/capabilities/exec-impl.test.ts— login-shell semantics, arg quoting, missing-binary returns exit 127, timeout kills process grouppackages/connectors/xiaohongshu/src/index.test.ts— single-shot fetch, never passes unsupported flags, treats opencli "no X found" exit as empty result, surfaces real errorsTotal: 140 core tests + 5 XHS tests pass. CI green.
Files of note
packages/connector-sdk/src/connector.ts— SDK types (Prerequisite,Detect,Install,ManualInstall,SetupStep,AuthStatus.setup),checkAuthViaPrerequisiteshelperpackages/core/src/connectors/prerequisites.ts—PrerequisiteChecker(semver, in-flight dedup, dependency-aware ordering)packages/core/src/connectors/loader.ts— manifest as single source of truth viaapplyManifestMetadatapackages/core/src/connectors/capabilities/exec-impl.ts— login-shell wrap with shell-specific flag (zsh-ilc, bash-lc), detached process group for clean killpackages/core/src/connectors/registry.ts—registerPackagemerges on same id (for multi-connector packages)packages/core/src/connectors/sync-engine.ts—maxPagessafety cap in both loopspackages/app/src/main/index.ts— 6 IPC handlers, supervised install (SIGKILL escalation, login-shell, 120s timeout, cancel), focus-driven recheck (always, with diff-before-broadcast), pending-steps seeding so the prereq card renders before the first check completespackages/app/src/renderer/components/PackageSetupCard.tsx— status icons, per-kind primary actions, inline install progress, hide-when-all-ok by default,alwaysShowmode for connector detail (collapsed pill that expands on click), ghost-style accent install buttonpackages/app/src/renderer/components/ManualInstallModal.tsx— data-driven step list, Download / Copy URL / Check now actionspackages/connectors/xiaohongshu/— package withxiaohongshu-notessub-connector + prereqsTest plan
.zshrcproxy via the new login-shell wrap (verified: no moreread tcpreset)Known caveats
npm installed outside this workspace. Before merging, either publish the package or gate the registry entry behind a flagrefactor(connector-xiaohongshu): scope to notes-onlycommit for rationale and re-add pathFollow-ups
xiaohongshu-feedonce opencli supports scroll-before-snapshot, andxiaohongshu-notificationsonce its JSON output includes real content fields🤖 Generated with Claude Code