mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
80eef46b4e
* refactor: move adapters from src/clis/ to root clis/ for monorepo separation Separates CLI adapters from the core runtime to prepare for independent adapter distribution via postinstall fetch. Key changes: - Move src/clis/ → clis/ (adapters at repo root) - Change tsconfig rootDir from "src" to "." so tsc compiles both - Create root-level shim files (registry.ts, errors.ts, etc.) so adapter relative imports (../../registry.js) resolve correctly - Update build-manifest.ts, main.ts paths for new dist/src/ structure - Expand ensureUserCliCompatShims() to cover all adapter import targets (types, utils, logger, launcher, browser/*, download/*, pipeline/*) - Add scripts/fetch-adapters.js postinstall for ~/.opencli/clis/ sync - Update vitest.config.ts adapter test paths - Add package.json files field to exclude adapters from npm package Official adapter files are unconditionally overwritten on update; user-created files not in the manifest are preserved. * fix: add dist/clis/ and cli-manifest.json to npm files, harden fetch-adapters - Add dist/clis/ and dist/cli-manifest.json to package.json files field so built-in adapters and manifest ship with the npm package - Replace execSync with execFileSync to prevent command injection - Add version check to skip redundant adapter fetches - Track tmpRoot explicitly for reliable cleanup * fix: address review blockers — manifest-based updates, global-only fetch, first-run fallback 1. Manifest-based update strategy: - Read old manifest to identify previously-official files - Clean up files removed upstream (in old manifest but not new) - User-created files (never in any manifest) remain untouched 2. Only run fetch-adapters on global install (npm_config_global=true) or explicit OPENCLI_FETCH=1, preventing heavy side effects for local/dev installs 3. First-run fallback in discovery.ts: - ensureUserAdapters() checks for adapter-manifest.json - If missing and ~/.opencli/clis/ is empty, spawns fetch-adapters.js - Guarantees adapters are available even with --ignore-scripts * fix: remove OPENCLI_FETCH env var, use internal _OPENCLI_FIRST_RUN instead * feat: also support OPENCLI_FETCH=1 for explicit adapter fetch trigger * simplify: replace git clone with local copy from dist/clis/ Adapters already ship in the npm package (dist/clis/), so there's no need to clone from GitHub. Copy directly from the installed package: - Eliminates git, curl, tar dependencies - No network calls in postinstall - No timeout/offline issues - Version always matches the installed CLI - ~65 lines of clone/download code replaced by one cpSync loop
135 lines
5.8 KiB
TypeScript
135 lines
5.8 KiB
TypeScript
import { AuthRequiredError, EmptyResultError, SelectorError } from '../../errors.js';
|
|
import { cli, Strategy } from '../../registry.js';
|
|
|
|
/**
|
|
* band mentions — Show Band notifications where you were @mentioned.
|
|
*
|
|
* Band.us signs every API request with a per-request HMAC (`md` header) generated
|
|
* by its own JavaScript, so we cannot replicate it externally. Instead we use
|
|
* Strategy.INTERCEPT: install an XHR interceptor, open the notification panel by
|
|
* clicking the bell to trigger the get_news XHR call, then apply client-side
|
|
* filtering to extract notifications matching the requested filter/unread options.
|
|
*/
|
|
cli({
|
|
site: 'band',
|
|
name: 'mentions',
|
|
description: 'Show Band notifications where you are @mentioned',
|
|
domain: 'www.band.us',
|
|
strategy: Strategy.INTERCEPT,
|
|
browser: true,
|
|
args: [
|
|
{
|
|
name: 'filter',
|
|
default: 'mentioned',
|
|
choices: ['mentioned', 'all', 'post', 'comment'],
|
|
help: 'Filter: mentioned (default) | all | post | comment',
|
|
},
|
|
{ name: 'limit', type: 'int', default: 20, help: 'Max results' },
|
|
{ name: 'unread', type: 'bool', default: false, help: 'Show only unread notifications' },
|
|
],
|
|
columns: ['time', 'band', 'type', 'from', 'text', 'url'],
|
|
|
|
func: async (page, kwargs) => {
|
|
const filter = kwargs.filter as string;
|
|
const limit = kwargs.limit as number;
|
|
const unreadOnly = kwargs.unread as boolean;
|
|
|
|
// Navigate with a timestamp param to force a fresh page load each run.
|
|
// Without this, same-URL navigation may skip the reload (preserving the JS context
|
|
// and leaving the notification panel open from a previous run).
|
|
await page.goto(`https://www.band.us/?_=${Date.now()}`);
|
|
|
|
const cookies = await page.getCookies({ domain: 'band.us' });
|
|
const isLoggedIn = cookies.some(c => c.name === 'band_session');
|
|
if (!isLoggedIn) throw new AuthRequiredError('band.us', 'Not logged in to Band');
|
|
|
|
// Install XHR interceptor before any clicks so all get_news responses are captured.
|
|
await page.installInterceptor('get_news');
|
|
|
|
// Wait for the bell button to appear (React hydration) instead of a fixed sleep.
|
|
let bellReady = false;
|
|
for (let i = 0; i < 20; i++) {
|
|
const exists = await page.evaluate(`() => !!document.querySelector('button._btnWidgetIcon')`);
|
|
if (exists) { bellReady = true; break; }
|
|
await page.wait(0.5);
|
|
}
|
|
if (!bellReady) {
|
|
throw new SelectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.');
|
|
}
|
|
|
|
// Poll until a capture containing result_data.news arrives, up to maxSecs seconds.
|
|
// getInterceptedRequests() clears the array on each call, so captures are accumulated
|
|
// locally. The interceptor pattern 'get_news' also matches 'get_news_count' responses
|
|
// which don't have result_data.news — keep polling until the real news response arrives.
|
|
const waitForOneCapture = async (maxSecs = 8): Promise<any[]> => {
|
|
const captures: any[] = [];
|
|
for (let i = 0; i < maxSecs * 2; i++) {
|
|
await page.wait(0.5); // 0.5 seconds per iteration (page.wait takes seconds)
|
|
const reqs = await page.getInterceptedRequests();
|
|
if (reqs.length > 0) {
|
|
captures.push(...reqs);
|
|
if (captures.some((r: any) => Array.isArray(r?.result_data?.news))) return captures;
|
|
}
|
|
}
|
|
return captures;
|
|
};
|
|
|
|
// Click the bell. Guard against the element disappearing between the readiness
|
|
// check and the click (e.g. due to a React re-render) to surface a clear error.
|
|
const bellClicked = await page.evaluate(`() => {
|
|
const el = document.querySelector('button._btnWidgetIcon');
|
|
if (!el) return false;
|
|
el.click();
|
|
return true;
|
|
}`);
|
|
if (!bellClicked) {
|
|
throw new SelectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.');
|
|
}
|
|
|
|
const requests = await waitForOneCapture();
|
|
|
|
// Find the get_news response (has result_data.news); get_news_count responses do not.
|
|
const newsReq = requests.find((r: any) => Array.isArray(r?.result_data?.news)) as any;
|
|
if (!newsReq) {
|
|
throw new EmptyResultError('band mentions', 'Failed to capture get_news response from Band.us. Try running the command again.');
|
|
}
|
|
let items: any[] = newsReq.result_data.news ?? [];
|
|
|
|
if (items.length === 0) {
|
|
throw new EmptyResultError('band mentions', 'No notifications found');
|
|
}
|
|
|
|
// Apply filters client-side from the full notification list.
|
|
if (unreadOnly) {
|
|
items = items.filter((n: any) => n.is_new === true);
|
|
}
|
|
if (filter === 'mentioned') {
|
|
// 'filters' is Band's server-side tag array; 'referred' means you were @mentioned.
|
|
items = items.filter((n: any) => n.filters?.includes('referred'));
|
|
} else if (filter === 'post') {
|
|
items = items.filter((n: any) => n.category === 'post');
|
|
} else if (filter === 'comment') {
|
|
items = items.filter((n: any) => n.category === 'comment');
|
|
}
|
|
|
|
// Band markup tags (<band:mention uid="...">, <band:sticker>, etc.) appear in
|
|
// notification text; strip them to get plain readable content.
|
|
const stripBandTags = (s: string) => s.replace(/<\/?band:[^>]+>/g, '');
|
|
|
|
return items.slice(0, limit).map((n: any) => {
|
|
const ts = n.created_at ? new Date(n.created_at) : null;
|
|
return {
|
|
time: ts
|
|
? ts.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
: '',
|
|
band: n.band?.name ?? '',
|
|
// 'filters' is Band's server-side tag array; 'referred' means you were @mentioned.
|
|
type: n.filters?.includes('referred') ? '@mention' : n.category ?? '',
|
|
from: n.actor?.name ?? '',
|
|
text: stripBandTags(n.subtext ?? '').slice(0, 100),
|
|
url: n.action?.pc ?? '',
|
|
};
|
|
});
|
|
},
|
|
});
|