Commit graph

14 commits

Author SHA1 Message Date
3816880801 Make favorites explicit: kill auto-save on Additional Groups picks
Auto-saving every Additional-Groups pick into favorites made the
list a running audit log of "every group I've ever touched" instead
of a curated set of go-to groups. Since most messages target a
different set of groups (one office one week, three campuses the
next), the auto-save polluted favorites for every user immediately.

Now:
- The Additional Groups picker is per-message only. Changing it no
  longer fires /user/groups/add.
- A new "Save selection to favorites" button under the Additional
  picker deliberately promotes whatever's currently selected there
  into the user's favorites. Explicit action, no surprises.
- On save, the just-promoted groups are moved from the Additional
  picker into the Favorites picker so this message's recipient set
  survives untouched, but the UI is visually consolidated to one
  place (avoids the "same group ticked in two pickers" confusion).
- The favorites VirtualSelect gets its options list rebuilt via
  setOptions() so the just-added groups are immediately pickable
  without a page reload; the existing selection is captured first
  because setOptions can reset it.
- Hint text updated to describe the new intent on both pickers.

CSS: added .fieldActions for a right-aligned button row under a
picker (sits between the picker and its hint).

Backend untouched — /user/groups/{add,remove,list} were already
per-id and the semantics still fit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 10:34:19 -04:00
90eb889523 Phase R3: split Express routes into routes/*.js
Every app.get/post/delete handler used to live directly in index.js,
which turned the file into the routing layer, the state store, and
the wiring root all at once. This peels the handlers out into five
per-concern modules and leaves index.js as a small composition root.

routes/info.js
- GET /status
- GET /CollabCentral/:app/info

routes/auth.js
- GET /CollabCentral/:app/authUrl
- GET /CollabCentral/:app/oauth (owns SESSION_COOKIE_OPTIONS now)

routes/user.js
- GET /CollabCentral/:app/user/:scope/:action (groups list / find)
- POST /CollabCentral/:app/user/groups/{add,remove}

routes/admin.js
- GET/POST /CollabCentral/:app/admin/users
- DELETE /CollabCentral/:app/admin/users/:id
- Owns requireAdmin + adminUserRow + adminUsersList (private to
  the module now that no other caller needs them).

routes/jobs.js
- POST /CollabCentral/:app/jobs/:action (edit / runNow / schedule /
  cancel)
- GET  /CollabCentral/:app/jobs/list/:scope
- GET  /CollabCentral/:app/jobs/detail/:jobId
- Owns buildJob (moved from index.js) since nothing outside the
  jobs routes ever called it.

Wiring pattern: each module exports registerXxxRoutes(app, deps)
and receives its dependencies through a dep-bag (isAuthorized,
isAdmin, webex, translator, jobsPipeline, saveConfig, helpers,
authorized, jobs, upload, sharp, uuid, fs, logger, ...). No route
module reaches into module-level state — that stays owned by
index.js.

index.js: 969 -> 457 lines (72% smaller than the original 1,642).
Now contains only imports, state loading (config, jobs, authorized,
tokens, botProfiles, groupsCache), the lib factory instantiations,
Express startup + cron schedules, the four register* calls, and a
handful of small helpers (saveConfig, cleanCompletedJobs,
isAuthorized, isAdmin, logger).

Tests still 53/53 green. Smoke-tested every route (auth matrix +
unknown bot + jobs detail 404 + signed-out 401 vs non-admin 403)
against a live process; every case matches pre-R3 behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 08:43:16 -04:00
8c3a32f47d Phase R2: extract lib/webex, lib/translation, lib/jobs from index.js
index.js had grown to 1,642 lines / ~50 functions. This peels the
three biggest self-contained concerns out into their own modules
and wires them back in through a dependency-bag factory so each
module stays free of module-level mutable state.

lib/webex.js — every webexapis.com round-trip (fetchWithRateLimit,
whoAmI, findWebexGroup, getGroupMembers, getPersonInfo,
findPersonByEmail, sendDirectMessage, sendMessageWithRetry,
sendDirectCard, deleteMessage, refreshToken). Factory closes over
token getters and the logger.

lib/translation.js — Google Translate fan-out (buildTranslations,
translateMessage). Reuses the webex 429 helper so the app has one
retry policy.

lib/jobs.js — cron-driven pipeline (checkScheduledJobs,
processRunningQueue, sendQueueJobMessages, buildJobCompletedCard)
plus the two recipient-resolution helpers (collectGroupMembers,
buildPeopleList). Factory takes jobs/queue/userPrefs/webex/etc so
mutable state stays owned by index.js.

index.js: instantiates webex/translator/jobsPipeline once, rewires
every call site to go through them, and drops ~715 lines of moved
code plus a dead msToTime wrapper. Down from 1,642 → 969 lines,
26 top-level functions instead of 50+.

Tests: 53/53 helpers still green. Smoke tested /info, /admin/users,
/user/groups/list, /user/groups/find, /jobs/list/all, and the
unauth 401 path — all match pre-R2 behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 08:33:42 -04:00
44568044e8 Phase R1: split per-user data into gitignored authorized.json
Every add/remove of a favorite group or authorized user was rewriting
config.json — the same file that carries structural bot metadata and
was committed to git. This split ends the git-noise and lets ops
deploy fresh installs without a pre-populated user list.

Split
- config.json (committed) stays structural: server, per-bot labels,
  integration + service-account ids, languages.
- config/authorized.json (gitignored) is the new mutable source of
  truth: { admins: [personId...], bot: { <appName>: { <personId>:
  { id, displayName, email, avatar, groups: [...] } } } }.
- Seeded authorized.json with the current admins list and all
  authorized users (3 on novi, 4 on techupdates) so this commit is
  a pure move — no data lost, no downtime.

Helpers (lib/helpers.js)
- New getAuthorizedEntry(authorized, app, id) as the single lookup
  point every consumer goes through, so nullability is uniform.
- isAuthorized() gains an authorized-doc arg (pure signature stays
  testable): fails closed when the doc is missing / partially
  loaded, so a broken deploy grants no access.
- isAdmin() now reads authorized.admins instead of config.admins.

Runtime (index.js)
- loadAuthorized() with an ENOENT fallback to { admins: [], bot: {} }
  so a fresh deploy can bootstrap via the admin page instead of
  requiring a hand-crafted authorized.json.
- All 8 previous config.webex.bot[app].authorized sites (favorites
  read/add/remove, admin list/add/delete, isAuthorized) now go
  through the authorized doc.
- Every mutation writes to config/authorized.json instead of
  config/config.json.

Latent-bug fixup (uncovered while smoke-testing this refactor)
- The /user/:scope/:action fallthroughs used res.status(4xx)
  without .send(...), so unknown scopes / unauthorized callers got
  a hung request instead of a response. Added ".send(...)" bodies
  so the response actually completes.

Docs + tests
- README updated: new "Authorized users" step in "Adding a new bot",
  updated file-layout section, docker mount list adds
  authorized.json.
- Test suite expanded from 48 → 53 with a new getAuthorizedEntry
  group and the existing isAuthorized/isAdmin cases reshaped for
  the new signatures.

Smoke tested the auth matrix end-to-end (admin + non-admin + signed-
out across /info, /admin/users, /user/groups/list): every path
returns the expected code and body.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 21:06:44 -04:00
bd6bfedb70 Persist authorized-user list changes from the admin UI
Three users added to techupdates.authorized via the admin page:
Samantha Matthews, Rachel Grattan, and Chloe Piccola. Committed so
the on-disk config stays in sync with origin after admin actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 20:52:26 -04:00
a793c9f39b Add admin UI for managing per-bot authorized users + retract cancelled preview DMs
Two features stitched together because they touch the same building-
job data path.

--- 1. Retract preview DM on cancel -----------------------------------

The /jobs/edit flow DMs a preview of the composed message to the
sender's own Webex space. Until now that DM lingered even if the
sender then hit Cancel or Discard.

- /jobs/edit now stores the returned message id as
  jobs.building[key].previewMessageId.
- /jobs/cancel captures that id before deleting the building entry,
  saves the cancel first, then fires a best-effort
  DELETE /v1/messages/<id> against Webex.
- New deleteWebexMessage(messageId, appName) helper wraps the DELETE.
  Uses the bot token (bots own their messages) and never throws — a
  Webex hiccup logs but doesn't fail the cancel that already
  succeeded on our side. Called fire-and-forget so the HTTP response
  isn't blocked on a slow Webex round trip.

--- 2. Admin: manage authorized users -------------------------------

Admin authority lives in a new top-level config.admins array of
personIds (seeded with Joe's id). Admin actions are cross-bot in
concept but the routes are :app-scoped because the resource being
edited is per-bot and it lets admin reuse the existing OAuth session
without a separate auth surface.

Helpers
- lib/helpers.js: new pure isAdmin(config, personId) that fails
  closed when admins is missing, not-an-array, or config is null.
- test/helpers.test.js: 5 new assertions covering the happy path,
  the "not in list" case, missing personId, non-array admins, and
  missing config. Total suite is now 48 assertions across 11 groups.

Server-side (index.js)
- New findPersonByEmail(email) helper hits Webex /v1/people?email=
  using the service account token, returns
  { id, displayName, email, avatar } or null.
- /info now returns isAdmin so the client can decide whether to
  render the admin dropdown.
- New requireAdmin(req, res) gate returns 401 for signed-out and
  403 for signed-in-but-not-admin (distinct codes so the frontend
  can render distinct panels).
- GET  /CollabCentral/:app/admin/users            → list users
- POST /CollabCentral/:app/admin/users            → lookup + add
- DELETE /CollabCentral/:app/admin/users/:id      → remove
- Shared adminUserRow / adminUsersList shape so every response is an
  authoritative snapshot the client can render without merging.
- DELETE of an unknown id is idempotent — returns 200 removed:false
  without rewriting config.json.

Frontend
- New html/admin.html + html/admin.js on the shared layout. Panels
  swap between not-signed-in / not-admin / admin. Add-user form
  takes an email; user list renders as rows with Webex avatar
  (fallback initials), name, email, favorite-group count, and a
  Remove button that confirm()s before firing DELETE.
- html/js/app.js: renderUserChip() replaces the plain-text top-right
  user label with a proper button + dropdown menu when the caller
  is an admin. Menu is keyboard-friendly (Escape to close), closes
  on outside-click, and currently exposes one item ("Admin" →
  admin.html). Non-admins get the plain-text label unchanged, so
  the existing pages are visually identical for them.
- html/css/app.css: new .appHeader__userBtn / .appHeader__userMenu
  dropdown, .inlineFieldRow for the email-plus-button pattern, and
  .userRow* rules for the admin user list.

Config
- Add config.admins array seeded with Joe McQueen's personId.
- config.json also picks up an in-app state change from the running
  instance (an "AV Team" favorite removed from Joe's techupdates
  authorized entry via the favorites UI). Rolling that into this
  commit so the file stops drifting from origin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 20:47:39 -04:00
224e853368 Add a cancel button to the compose page
Users can now abandon a draft they don't want to send. Two entry
points, one shared server action.

Backend
- New action on the existing POST /CollabCentral/:app/jobs/:action
  route: /jobs/cancel. Deletes jobs.building[<personId>::<appName>]
  if present, saves jobs.json, returns 200 with { cancelled: true }.
- Idempotent: if there was no building entry (client fires this on
  every cancel click regardless of state), the endpoint no-ops with
  { cancelled: false } and never touches jobs.json. That keeps disk
  writes tied to real state changes instead of every click.
- Wrapped saveConfig in try/catch so a disk-full or permission
  failure returns a clean 500 instead of crashing the request.

Frontend
- New "Cancel" button in the compose form's action row, sits next
  to "Review & send" as a secondary action.
- cancelMessage() checks whether the form has anything in it (text,
  image, CSV, groups, schedule) and only prompts window.confirm() if
  there's actually a draft to lose. Blank-form clicks skip the
  prompt so the button behaves as expected on first load.
- New "Discard" action on the review modal for "wait, actually no"
  decisions after clicking Review. Positioned on the far left of the
  modal footer with margin-right: auto so it's visually separated
  from the affirmative "Keep editing" / "Send" pair — a stray click
  on the way to Send lands on Keep editing, not Discard.
- Extended clearForm() to also clear both VirtualSelect group
  pickers via setValue([]) (previously only text/files/schedule got
  reset, leaving stale group selections after a cancel or send).
- New .btn-danger style: muted red text on a transparent background,
  filled-red hover state. Used for the modal Discard button and
  available for future destructive UI.

Not covered (follow-up if wanted): the preview DM the bot sends to
the sender during /jobs/edit isn't tracked in the building job, so
Cancel doesn't retract that Webex message. Adding server-side
tracking of the preview message id would let cancel call
DELETE /v1/messages/<id> to clean it up.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 20:32:50 -04:00
524b443dce Make favorite groups editable from the compose page
Selecting a group in "Additional groups" now auto-saves it to the
caller's favorites, and each favorite gets a × affordance for one-click
removal. Favorites still live where they always have —
config.webex.bot[app].authorized[personId].groups — so nothing changes
for existing installations.

Backend
- New POST /CollabCentral/:app/user/groups/add. Body { id }. Validates
  the caller is authorized for :app, validates :id resolves to a real
  Webex group by looking it up in the cached org-wide group list
  (blocks arbitrary strings from being stuffed into config.json),
  dedups against the existing favorites array, writes config.json via
  saveConfig, and returns the updated array.
- New POST /CollabCentral/:app/user/groups/remove. Body { id }.
  Filters that id out of the caller's favorites, writes only when
  something actually changed (a remove of an unknown id no-ops instead
  of rewriting config.json), and returns the updated array.
- Both endpoints are safe against concurrent writes: index.js is
  single-process and node is single-threaded, so read/mutate/write
  runs atomically per request.
- Both log a compact audit line (last-8 of personId + group name) so
  operators can see who is curating what.

Frontend (sendMessage.html + .js + app.css)
- New "Manage favorites" chip strip renders directly under the
  Favorite Groups picker. Each favorite becomes a pill (uses .alias
  when set, otherwise .name; long labels truncate with ellipsis).
  Clicking × on a pill removes that favorite server-side and updates
  the strip locally.
- Additional Groups picker wires a 'change' listener that diffs the
  current selection against the previous one and only fires
  /user/groups/add for newly-picked ids (never re-fires on the
  reselect side of a deselect+reselect, never spams the API with the
  full selection on every keystroke).
- Client keeps favoriteGroups mirrored to every API response so the
  "already a favorite?" dedup check is a pure in-memory lookup — no
  wasted round trips when the user re-picks a group they already
  favorited.
- Transient status message ("Added to favorites." / "Removed from
  favorites." / error variants) fades under the field label; sticks
  around ~4s.
- New shared styles: .fieldLabelRow (label + inline status),
  .fieldStatus (with --error variant), .chipStrip, .chip,
  .chip__label, .chip__close (with hover/focus states).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 20:25:15 -04:00
452673b9be Cache Webex group list in-process + fix log typo
The /user/groups/find endpoint used to hit Webex on every request,
paginating through ~10k groups 500 at a time (roughly 20 sequential
round trips per compose-page load). Now the whole list lives in a
process-local cache that refreshes on startup and once a day at 03:00
CRON_TIMEZONE.

- New groupsCache = { data, lastRefreshed, refreshing }. refreshing is
  a shared in-flight Promise so a startup refresh and the daily cron
  can't stampede if their timing overlaps.
- refreshGroupsCache() wraps findWebexGroup() with load timing +
  error logging.
- getGroupsFromCache() returns the cached list immediately; only the
  very first request after startup waits (and only if the startup
  refresh hasn't completed yet).
- Warm the cache in the background right after loadBotProfiles() in
  the app.listen callback.
- Third cron ('0 0 3 * * *') refreshes the cache daily, offset from
  the 01:10 jobs cleanup and per-minute token/scheduled-jobs tick so
  they don't fight for the event loop.

Bug fixes rolled in while I was in findWebexGroup:
- Return reject(...) on a non-ok Webex response instead of also
  continuing the while loop, which used to race resolve/reject.
- Return reject(error) from the try/catch so a Webex hiccup no longer
  hangs the paginator forever; previously it caught+logged and let
  the loop spin.
- Drop the stray `memberSize = 0` assignment (memberSize was never
  declared in scope).
- Drop the per-page console.log noise; failures now go through the
  timestamped logger under the findWebexGroup tag.

Route cleanup:
- /user/groups/find now serves getGroupsFromCache() and returns 502
  with a logged reason if the cache is empty AND the refresh failed.
- Dropped the stray saveConfig(response, "./testData.json") that
  used to persist the entire fetched group list to a scratch file on
  every request.

Also: fix "identify" -> "identity" in the whoAmI startup log line.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 20:13:58 -04:00
04fb4f6442 Fix OAuth infinite-redirect loop caused by httpOnly session cookies
The bug: SESSION_COOKIE_OPTIONS had httpOnly: true, so js/app.js could
not see the `id` cookie set by /oauth. Every page load thought the user
was signed out, redirected to Webex, minted a fresh token, set another
invisible cookie, redirected back, and looped -- until Webex's Common
Token Store hit its per-user limit and returned
error=tokenlimit_reached on the next callback.

Fix:
- httpOnly is now explicitly false with a comment explaining why: the
  app's design has always relied on the client reading the id and
  displayName cookies via document.cookie.
- sameSite tightened change from 'strict' to 'lax' so the cookie
  reliably survives the webex.com -> /oauth -> /sendMessage.html
  redirect chain across all browsers (some treat continuations of a
  cross-site navigation as cross-site for strict cookies).
- Stop setting access_token, refresh_token, avatar, email, orgId on
  the response. `req.cookies.*` grep confirms the server never reads
  any of them, and the client uses id/displayName only. Removing the
  token cookies also eliminates a would-be XSS foothold.

Existing broken sessions: setting a new cookie with the same name and
path replaces the old one regardless of httpOnly flag, so a single
completed OAuth after this deploy repairs the browser state.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 19:57:33 -04:00
2984e8e851 Phase 9: rebuild frontend on a shared layout + fix Monitor Jobs race
The nav link race:
- Every page's header used `<a id="jobLink" href=''>` and the real
  destination was only assigned later, after /info returned. An empty
  href resolves to the current document URL, so clicking "Monitor Jobs"
  on sendMessage before /info completed silently reloaded sendMessage.
- Fixed structurally: nav links now use static relative hrefs baked
  into the HTML ("./sendMessage.html", "./monitorJobs.html"), so the
  destination is correct the moment the DOM parses.

Shared UI:
- New html/css/app.css: design tokens (palette, radius, shadow, font),
  sticky compact top header (bot avatar + label on the left, nav pills
  in the middle, current user on the right, active-page highlight via
  aria-current), card containers, form styling with focus rings,
  DataTables theme overrides, status pills, modal, and responsive
  breakpoints.
- New html/js/app.js: shared browser bootstrap. Parses appName from
  the URL, redirects to OAuth if the id cookie is missing, fetches
  /info once, populates the header, applies aria-current to the
  active nav link, and invokes a per-page onReady callback with
  { info, appName }. Also exports getCookie, escapeHtml, and formatDate
  helpers so each page stops shipping its own copy.

Per-page rewrites:
- sendMessage.html/.js: form now lives in a card, image preview only
  shows when a file is attached, EasyMDE + VirtualSelect styled to
  match the theme, submit is a primary button, confirmation modal
  redesigned. All bootstrap code deleted (delegated to app.js).
- monitorJobs.html/.js: three cards (Running / Scheduled / Completed)
  with themed DataTables. Completed table sorts by start time desc,
  paginates, and searches; message column truncates HTML previews to
  ~120 chars. Empty-state text per table. `jobId` and running-row
  "view" links go to jobDetail via safe relative URLs.
- jobDetail.html/.js: same shared header + card layout; summary grid,
  message preview, and recipient table styled to match the new
  palette.

Sanity checks:
- All 43 helper tests still pass.
- Server boots cleanly on port 3001.
- Curl of sendMessage/monitorJobs/jobDetail all return 200 with the
  shared header markup.
- /CollabCentral/:app/css/app.css and /CollabCentral/:app/js/app.js
  both serve 200 (shared static mount is per-bot as expected).
- No local href in any page is empty; every nav target resolves at
  parse time.
- /info and requireBot 404 gate unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 19:36:36 -04:00
2b37c4b24f Phase 8: extract pure helpers into lib/ and cover with node:test
- Move buildingKey, jobsForApp, getBotToken, isBotEnabled, getBotConfig,
  isAuthorized, getOAuthRedirectUri, buildAuthUrl, cleanCompletedJobs,
  and msToTime into lib/helpers.js as state-free functions that accept
  config, botTokens, or env as parameters. COMPLETED_RETENTION_DAYS also
  lives there so callers and tests share the constant.
- Replace the bodies in index.js with thin wrappers that pass the module-
  level state into the pure helpers. Call sites and behavior are
  unchanged; index.js shrinks by ~60 lines.
- Move the cleanCompletedJobs logging into the cron caller so the pure
  helper returns a result object (jobs, removed, cutoff) that tests can
  assert on without capturing stdout.
- Add test/helpers.test.js with 43 assertions across 10 suites covering
  the enable/disable gating, per-bot draft isolation, authorization,
  OAuth URL construction, retention filter (including endTime -> startTime
  -> created fallback and the safety default for jobs missing a
  timestamp), and the duration formatter.
- Wire `npm test` to `node --test test/*.test.js` (no new deps, uses the
  built-in node:test runner) and document it in the README.

Smoke test confirms unchanged HTTP behavior for /info (known + unknown
bots), the requireBot 404 gate, and the 401 path on jobs/list/completed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:21:02 -04:00
b4e5ca3f33 Phase 7: polish for production readiness
- Add README with local setup, add-a-bot walkthrough, Docker run
  recipe, and a rundown of committed vs. runtime state.
- Wrap the cron schedules with an explicit timezone (default
  America/New_York, override via CRON_TIMEZONE) so cadence is
  independent of the host/container clock.
- Route saveServiceAccountToken through a try/catch so a disk hiccup
  no longer bubbles up as an unhandled exception during token refresh,
  and keep the in-memory copy usable even on write failure.
- Silence per-message queue.on('active') / on('completed') logs that
  also registered a new listener on every job invocation (accumulating
  on the shared queue over time).
- Fix logIt reference (undefined; would have thrown if
  checkScheduledJobs ever rejected) and a broken JSON.stringify.result
  debug log that always evaluated to undefined.
- Dockerfile: switch to node:20-slim, install from the lockfile via
  npm ci --omit=dev, set NODE_ENV=production, and document that env
  and volumes are supplied at runtime.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:00:59 -04:00
e604c7e9c9 Initial commit: multi-bot CollabCentral
Extends the single-Novi codebase into a multi-bot mass-messenger where
each bot has its own token, avatar, label, and per-user authorization.

- Secrets moved out of config.json: per-bot tokens in gitignored
  config/botTokens.json (with enabled flag), service-account OAuth in
  gitignored config/token.json (rewritten by refresh cron), integration
  and Google keys in .env.
- Single Webex integration handles OAuth for all bots via a per-app
  redirect URI derived from OAUTH_CALLBACK_URL_TEMPLATE.
- New requireBot middleware and getBotConfig helper reject requests for
  unknown or disabled bots at the /CollabCentral/:app boundary.
- New /info endpoint plus dynamic frontend loading (sendMessage,
  monitorJobs) so pages self-describe per bot, including bot avatar
  fetched from Webex /people/me at startup.
- Job draft state keyed by cookieId + appName so each bot has its own
  building queue; job list/detail endpoints filter by appName so users
  only see jobs from bots they are authorized on.
- New jobDetail page for a readable per-job view; completed jobs are
  retained for 30 days by the cleanup cron.
- Completion adaptive cards use per-bot avatar and label.
- Miscellaneous fixes: off-by-two in the send loop, removed three dead
  send/process variants, added defensive init for jobs.* on load,
  dropped the deprecated crypto npm shim, and cleaned up stray logger
  labels and typos.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 17:53:07 -04:00