Skip to content

feat: connector prerequisites system + Xiaohongshu connector#76

Merged
graydawnc merged 12 commits intomainfrom
feat/connector-prerequisites-xhs
Apr 14, 2026
Merged

feat: connector prerequisites system + Xiaohongshu connector#76
graydawnc merged 12 commits intomainfrom
feat/connector-prerequisites-xhs

Conversation

@graydawnc
Copy link
Copy Markdown
Collaborator

@graydawnc graydawnc commented Apr 14, 2026

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 needs
  • setup (runtime, dynamic): current per-step status

Three prerequisite kinds, schema open for future ones:

kind detection install action
cli exec --version + optional minVersion (semver) supervised install via user's login shell, 120s + SIGKILL escalation, cancel; copy-to-clipboard fallback
browser-extension exec doctor + matchStdout regex Chrome Web Store URL, or manual-install modal with Download + Copy URL buttons
site-session exec doctor + connectivity regex open login URL in default browser

Dependency ordering via requires: ["otherId"] — downstream marked pending if upstream isn't ok, skipping its detect call.

Detection in main process (PrerequisiteChecker) using the existing exec capability. 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 helper checkAuthViaPrerequisites(caps) that connectors can call when their auth is fully covered by prerequisites. XHS uses this — zero custom checkAuth logic 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.ephemeral without updating package.json and 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 uses defineProperty to 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 on gh api calls (TCP read reset) for users behind a proxy. The exec capability now wraps spawn in the user's shell so rc files get sourced:

  • zsh: -ilc (sources .zprofile + .zshrc)
  • bash: -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 --limit
  • xiaohongshu-notifications — opencli returns only [{rank: N}] placeholders without real content fields

Notes uses opencli's creator-notes subcommand (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:

  1. Connector-level: nextCursor: null always for opencli-backed connectors (no pagination supported upstream)
  2. Sync-engine maxPages option: new optional field on SyncOptions (default 100). Both ephemeral and persistent sync loops check the cap and exit with stopReason: 'max_pages'. Defense in depth for any connector

Dev-mode conveniences

Three non-production-affecting paths added for local testing:

  • Local registry: in !app.isPackaged, connector:fetch-registry reads packages/landing/public/registry.json from the workspace. SPOOL_REGISTRY_URL env var overrides explicitly. Production still fetches from raw.githubusercontent.com
  • Workspace install: in dev, the Install button symlinks the package from packages/connectors/<dir> if present; otherwise falls through to npm. Lets us test unpublished connectors via the real Install UX
  • XHS is NOT bundled: registered as a regular installable package. Tested via the dev-mode symlink path until published

Tests

  • packages/core/src/connectors/prerequisites.test.ts — PrerequisiteChecker detect paths and validatePrerequisites manifest validator
  • packages/core/src/connectors/registry.test.ts — multi-connector package merge in ConnectorRegistry
  • packages/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 group
  • packages/connectors/xiaohongshu/src/index.test.ts — single-shot fetch, never passes unsupported flags, treats opencli "no X found" exit as empty result, surfaces real errors

Total: 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), checkAuthViaPrerequisites helper
  • packages/core/src/connectors/prerequisites.tsPrerequisiteChecker (semver, in-flight dedup, dependency-aware ordering)
  • packages/core/src/connectors/loader.ts — manifest as single source of truth via applyManifestMetadata
  • packages/core/src/connectors/capabilities/exec-impl.ts — login-shell wrap with shell-specific flag (zsh -ilc, bash -lc), detached process group for clean kill
  • packages/core/src/connectors/registry.tsregisterPackage merges on same id (for multi-connector packages)
  • packages/core/src/connectors/sync-engine.tsmaxPages safety cap in both loops
  • packages/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 completes
  • packages/app/src/renderer/components/PackageSetupCard.tsx — status icons, per-kind primary actions, inline install progress, hide-when-all-ok by default, alwaysShow mode for connector detail (collapsed pill that expands on click), ghost-style accent install button
  • packages/app/src/renderer/components/ManualInstallModal.tsx — data-driven step list, Download / Copy URL / Check now actions
  • packages/connectors/xiaohongshu/ — package with xiaohongshu-notes sub-connector + prereqs

Test plan

  • Core: 140 tests passing across PrerequisiteChecker, validatePrerequisites, registry, loader (manifest truth), exec-impl (login shell), sync-engine
  • XHS connector: 5 tests covering single-shot fetch, unsupported-flag avoidance, empty-state handling, error propagation
  • App tsc --noEmit clean
  • CI green (unit + e2e mac + e2e linux)
  • Manual: fresh install → prereq card visible immediately with pending rows → check completes within ~1s and rows flip to ✓/✗ → Install OpenCLI triggers supervised shell → extension manual-install modal → login flow → card collapses to pill on detail view when all ok, expandable on click → focus event triggers recheck → removing extension flips card back to ✗
  • Manual: GitHub connector sync works through user's .zshrc proxy via the new login-shell wrap (verified: no more read tcp reset)
  • Regression: HN, Twitter Bookmarks, Typeless render and sync unchanged (no prerequisites declared)

Known caveats

  • XHS package not yet published to npm. Registry URL points to a package that cannot be npm installed outside this workspace. Before merging, either publish the package or gate the registry entry behind a flag
  • XHS feed and notifications sub-connectors are intentionally absent — see the refactor(connector-xiaohongshu): scope to notes-only commit for rationale and re-add path

Follow-ups

  • Prod-style smoke test before releases (npm pack → install into fresh app) — dev/prod install paths diverge enough that pure dev testing has missed regressions before
  • Re-add xiaohongshu-feed once opencli supports scroll-before-snapshot, and xiaohongshu-notifications once its JSON output includes real content fields

🤖 Generated with Claude Code

graydawnc and others added 5 commits April 14, 2026 18:14
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>
@graydawnc graydawnc force-pushed the feat/connector-prerequisites-xhs branch 2 times, most recently from bae888a to 8a43de5 Compare April 14, 2026 10:22
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>
@graydawnc graydawnc force-pushed the feat/connector-prerequisites-xhs branch from b75dbf7 to c4d1eb2 Compare April 14, 2026 11:58
graydawnc and others added 5 commits April 14, 2026 23:06
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>
@graydawnc graydawnc force-pushed the feat/connector-prerequisites-xhs branch from 7ff9960 to 2026624 Compare April 14, 2026 15:08
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>
@graydawnc graydawnc marked this pull request as ready for review April 14, 2026 15:45
@graydawnc graydawnc merged commit 8db0eb1 into main Apr 14, 2026
3 checks passed
@graydawnc graydawnc deleted the feat/connector-prerequisites-xhs branch April 14, 2026 15:48
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