unbrowse 2.12.2 → 2.12.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -44
- package/dist/cli.js +514 -20723
- package/package.json +4 -10
- package/runtime-src/api/routes.ts +15 -801
- package/runtime-src/auth/index.ts +32 -142
- package/runtime-src/capture/index.ts +101 -436
- package/runtime-src/cli.ts +371 -956
- package/runtime-src/client/index.ts +29 -622
- package/runtime-src/execution/index.ts +85 -345
- package/runtime-src/graph/index.ts +10 -128
- package/runtime-src/intent-match.ts +27 -27
- package/runtime-src/kuri/client.ts +82 -543
- package/runtime-src/orchestrator/index.ts +462 -2246
- package/runtime-src/reverse-engineer/index.ts +22 -220
- package/runtime-src/runtime/local-server.ts +16 -149
- package/runtime-src/runtime/paths.ts +5 -9
- package/runtime-src/runtime/setup.ts +1 -52
- package/runtime-src/server.ts +11 -6
- package/runtime-src/transform/schema-hints.ts +358 -0
- package/runtime-src/types/skill.ts +2 -49
- package/runtime-src/verification/index.ts +0 -15
- package/runtime-src/version.ts +13 -13
- package/vendor/kuri/darwin-arm64/kuri +0 -0
- package/vendor/kuri/darwin-x64/kuri +0 -0
- package/vendor/kuri/linux-arm64/kuri +0 -0
- package/vendor/kuri/linux-x64/kuri +0 -0
- package/bin/unbrowse-wrapper.mjs +0 -39
- package/bin/unbrowse.js +0 -38
- package/runtime-src/analytics-session.ts +0 -33
- package/runtime-src/api/browse-index.ts +0 -254
- package/runtime-src/api/browse-session.ts +0 -179
- package/runtime-src/api/browse-submit.ts +0 -455
- package/runtime-src/auth/runtime.ts +0 -116
- package/runtime-src/browser/index.ts +0 -635
- package/runtime-src/browser/types.ts +0 -41
- package/runtime-src/capture/prefetch.ts +0 -122
- package/runtime-src/capture/rsc.ts +0 -45
- package/runtime-src/cli/shortcuts.ts +0 -273
- package/runtime-src/client/graph-client.ts +0 -99
- package/runtime-src/execution/robots.ts +0 -167
- package/runtime-src/execution/search-forms.ts +0 -188
- package/runtime-src/graph/planner.ts +0 -411
- package/runtime-src/graph/session.ts +0 -294
- package/runtime-src/graph/trace-store.ts +0 -136
- package/runtime-src/indexer/index.ts +0 -480
- package/runtime-src/orchestrator/browser-agent.ts +0 -374
- package/runtime-src/orchestrator/dag-advisor.ts +0 -59
- package/runtime-src/orchestrator/dag-feedback.ts +0 -256
- package/runtime-src/orchestrator/first-pass-action.ts +0 -362
- package/runtime-src/orchestrator/passive-publish.ts +0 -152
- package/runtime-src/orchestrator/timing-economics.ts +0 -80
- package/runtime-src/payments/cascade.ts +0 -137
- package/runtime-src/payments/index.ts +0 -268
- package/runtime-src/payments/wallet.ts +0 -33
- package/runtime-src/reverse-engineer/description-prompt.ts +0 -132
- package/runtime-src/router.ts +0 -17
- package/runtime-src/runtime/browser-access.ts +0 -11
- package/runtime-src/runtime/browser-host.ts +0 -48
- package/runtime-src/runtime/lifecycle.ts +0 -17
- package/runtime-src/runtime/supervisor.ts +0 -69
- package/runtime-src/single-binary.ts +0 -141
- package/runtime-src/telemetry.ts +0 -253
- package/runtime-src/verification/matrix.ts +0 -30
- package/scripts/postinstall.mjs +0 -81
|
@@ -6,8 +6,7 @@ import { nanoid } from "nanoid";
|
|
|
6
6
|
import { inferEndpointSemantic } from "../graph/index.js";
|
|
7
7
|
import { writeDebugTrace } from "../debug-trace.js";
|
|
8
8
|
import { buildQueryBindingMap } from "../template-params.js";
|
|
9
|
-
|
|
10
|
-
import { isRscPayload, extractRscDataEndpoints } from "../capture/rsc.js";
|
|
9
|
+
|
|
11
10
|
const SKIP_EXTENSIONS = /\.(js|mjs|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|map|webp|html|avif)([?#]|$)/i;
|
|
12
11
|
const SKIP_JS_BUNDLES = /\/(boq-|_\/mss\/|og\/_\/js\/|_\/scs\/)/i;
|
|
13
12
|
const SKIP_PATHS = /\/_next\/static\/|\/_next\/data\/|\/_next\/image|\/static\/chunks\/|\/static\/media\/|\/cdn-cgi\//i;
|
|
@@ -55,19 +54,6 @@ const STRIP_HEADER_PREFIXES = [
|
|
|
55
54
|
"x-firebase-", // Firebase auth headers
|
|
56
55
|
];
|
|
57
56
|
|
|
58
|
-
// Browser-captured headers that are not secrets themselves, but are still
|
|
59
|
-
// required to replay authenticated requests after publish-time header redaction.
|
|
60
|
-
const REPLAY_HEADER_PREFIXES = [
|
|
61
|
-
"x-li-",
|
|
62
|
-
];
|
|
63
|
-
const REPLAY_HEADER_EXACT = new Set([
|
|
64
|
-
"accept",
|
|
65
|
-
"csrf-token",
|
|
66
|
-
"origin",
|
|
67
|
-
"x-requested-with",
|
|
68
|
-
"x-restli-protocol-version",
|
|
69
|
-
]);
|
|
70
|
-
|
|
71
57
|
// Headers known to be safe (non-sensitive) — used by the catch-all filter below
|
|
72
58
|
const SAFE_HEADERS = new Set([
|
|
73
59
|
"accept", "accept-encoding", "accept-language", "cache-control",
|
|
@@ -178,36 +164,23 @@ function buildEndpointDescription(
|
|
|
178
164
|
sampleResponse: unknown,
|
|
179
165
|
): string {
|
|
180
166
|
const url = new URL(req.url);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
: url.pathname.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
method: req.method,
|
|
199
|
-
params,
|
|
200
|
-
sample_response_keys: responseKeys.length > 0 ? responseKeys : undefined,
|
|
201
|
-
domain: url.hostname,
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
// Build the grounding prompt (available for optional LLM polish in
|
|
205
|
-
// backend/services/descriptions.ts) and the deterministic description.
|
|
206
|
-
const _prompt = buildDescriptionPrompt(ctx);
|
|
207
|
-
|
|
208
|
-
// Use the grounded description builder from description-prompt.ts so
|
|
209
|
-
// every description references real params and response fields.
|
|
210
|
-
return groundedDescription(ctx);
|
|
167
|
+
const pathTail = url.pathname.split("/").filter(Boolean).slice(-2).join(" ");
|
|
168
|
+
const requestKeys = Object.keys(sampleRequest).slice(0, 4);
|
|
169
|
+
const response = summarizeResponseExample(sampleResponse);
|
|
170
|
+
const action = requestKeys.some((key) => /^(q|query|search|term)$/i.test(key)) || /search|find|lookup/.test(url.pathname)
|
|
171
|
+
? "Searches"
|
|
172
|
+
: /status|health|incident|maintenance/.test(url.pathname)
|
|
173
|
+
? "Returns status for"
|
|
174
|
+
: url.pathname.match(/\{[^}]+\}|\/[0-9A-Za-z_-]{4,}(\/|$)/)
|
|
175
|
+
? "Returns details for"
|
|
176
|
+
: "Returns";
|
|
177
|
+
const subjectSource = new Set(["response", "data", "result", "results", "item", "items"]).has(response.subject.toLowerCase())
|
|
178
|
+
? inferPathSubject(url.pathname)
|
|
179
|
+
: response.subject;
|
|
180
|
+
const subject = titleCase(subjectSource === "response" ? (pathTail || url.hostname) : subjectSource);
|
|
181
|
+
const fieldText = response.fields.length > 0 ? ` with ${response.fields.join(", ")}` : "";
|
|
182
|
+
const inputText = requestKeys.length > 0 ? ` using ${requestKeys.join(", ")}` : "";
|
|
183
|
+
return `${action} ${subject}${fieldText}${inputText}`;
|
|
211
184
|
}
|
|
212
185
|
|
|
213
186
|
function looksLikeAdResponse(body: string | undefined): boolean {
|
|
@@ -408,8 +381,8 @@ function inferCsrfPlan(req: RawRequest, parsedBody?: unknown): CsrfPlan | undefi
|
|
|
408
381
|
Object.entries(req.request_headers).map(([key, value]) => [key.toLowerCase(), value]),
|
|
409
382
|
);
|
|
410
383
|
const cookies = parseCookieHeader(headers["cookie"]);
|
|
411
|
-
const csrfCookieNames = Object.keys(cookies).filter((name) => /^(ct0|csrf_token|_csrf|csrftoken|xsrf-token|_xsrf
|
|
412
|
-
const headerName = ["x-csrf-token", "x-xsrf-token", "x-csrftoken"
|
|
384
|
+
const csrfCookieNames = Object.keys(cookies).filter((name) => /^(ct0|csrf_token|_csrf|csrftoken|xsrf-token|_xsrf)$/i.test(name));
|
|
385
|
+
const headerName = ["x-csrf-token", "x-xsrf-token", "x-csrftoken"].find((name) => typeof headers[name] === "string" && headers[name].length > 0);
|
|
413
386
|
if (headerName && csrfCookieNames.length > 0) {
|
|
414
387
|
return {
|
|
415
388
|
source: "cookie",
|
|
@@ -575,8 +548,6 @@ function scoreRequest(req: RawRequest): number {
|
|
|
575
548
|
// Penalise Next.js RSC navigation requests — framework wire format, not data
|
|
576
549
|
if (req.url.includes("_rsc=")) score -= 3;
|
|
577
550
|
if (ct.includes("text/x-component")) score -= 10; // RSC wire format
|
|
578
|
-
// #227: Structural RSC body detection — catches payloads without URL/content-type hints
|
|
579
|
-
if (isRscPayload(req.response_body ?? "")) score -= 15;
|
|
580
551
|
// Penalise on-domain noise (framework plumbing, recaptcha, consent, ad bids)
|
|
581
552
|
try { if (ON_DOMAIN_NOISE.test(new URL(req.url).pathname)) score -= 15; } catch {}
|
|
582
553
|
// Reward rich JSON responses (data endpoints have deep objects, noise has shallow)
|
|
@@ -629,46 +600,7 @@ export function extractEndpoints(requests: RawRequest[], wsMessages?: CapturedWs
|
|
|
629
600
|
continue;
|
|
630
601
|
}
|
|
631
602
|
if (!hasAdmissibleParsedBody(req.response_body)) {
|
|
632
|
-
|
|
633
|
-
// Admit them anyway if the URL pattern is clearly an API endpoint.
|
|
634
|
-
const urlPath = (() => { try { return new URL(req.url).pathname; } catch { return ""; } })();
|
|
635
|
-
const isApiUrl = /\/(api|graphql)\b/i.test(urlPath) || /\.(json)(\?|$)/.test(req.url);
|
|
636
|
-
|
|
637
|
-
// For GraphQL: extract operationName from request body or URL
|
|
638
|
-
let graphqlOpName: string | undefined;
|
|
639
|
-
if (/graphql/i.test(req.url)) {
|
|
640
|
-
if (req.request_body) {
|
|
641
|
-
try {
|
|
642
|
-
const body = JSON.parse(req.request_body);
|
|
643
|
-
graphqlOpName = body.operationName ?? body.query?.match(/(?:query|mutation)\s+(\w+)/)?.[1];
|
|
644
|
-
} catch { /* not JSON */ }
|
|
645
|
-
}
|
|
646
|
-
// Also try extracting from URL query (GET GraphQL endpoints encode operationName in URL)
|
|
647
|
-
if (!graphqlOpName) {
|
|
648
|
-
const urlMatch = req.url.match(/\/graphql\/\w+\/(\w+)/);
|
|
649
|
-
if (urlMatch) graphqlOpName = urlMatch[1];
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// For .json endpoints: use the last path segment as description
|
|
654
|
-
const jsonEndpointName = /\.(json)(\?|$)/.test(req.url) ? urlPath.split("/").pop()?.replace(".json", "") : undefined;
|
|
655
|
-
|
|
656
|
-
if (!isApiUrl) {
|
|
657
|
-
traceRows.push({ url: req.url, method: req.method, score, kept: false, reason: "body_not_json_or_html" });
|
|
658
|
-
continue;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// Inject a synthetic response body so downstream processing works
|
|
662
|
-
const syntheticName = graphqlOpName ?? jsonEndpointName ?? "api_endpoint";
|
|
663
|
-
req.response_body = JSON.stringify({ data: { __typename: syntheticName } });
|
|
664
|
-
req.response_headers = { ...req.response_headers, "content-type": "application/json" };
|
|
665
|
-
}
|
|
666
|
-
// #227: Reject React Server Components wire format payloads — they are framework
|
|
667
|
-
// rendering wire format, not data APIs. Use the proper RSC parser instead of
|
|
668
|
-
// relying solely on URL heuristics (_rsc=) or content-type (text/x-component).
|
|
669
|
-
if (isRscPayload(req.response_body ?? "")) {
|
|
670
|
-
const rscUrls = extractRscDataEndpoints(req.response_body ?? "");
|
|
671
|
-
traceRows.push({ url: req.url, method: req.method, score, kept: false, reason: "rsc_payload", rsc_embedded_urls: rscUrls.length > 0 ? rscUrls : undefined });
|
|
603
|
+
traceRows.push({ url: req.url, method: req.method, score, kept: false, reason: "body_not_json_or_html" });
|
|
672
604
|
continue;
|
|
673
605
|
}
|
|
674
606
|
if (affinityDomains.size > 0) {
|
|
@@ -689,16 +621,6 @@ export function extractEndpoints(requests: RawRequest[], wsMessages?: CapturedWs
|
|
|
689
621
|
}
|
|
690
622
|
scored.sort((a, b) => b.score - a.score);
|
|
691
623
|
|
|
692
|
-
// For passive captures (no context page URL), pre-compute path templates across
|
|
693
|
-
// all candidate paths so individual endpoints can be annotated without needing
|
|
694
|
-
// collapseEndpoints' sibling grouping.
|
|
695
|
-
const minedTemplateMap = !context?.pageUrl
|
|
696
|
-
? minePathTemplates(scored.map(({ req }) => {
|
|
697
|
-
try { return new URL(req.url).pathname; } catch { return ""; }
|
|
698
|
-
}).filter(Boolean))
|
|
699
|
-
: new Map<string, string>();
|
|
700
|
-
|
|
701
|
-
|
|
702
624
|
for (const { req } of scored) {
|
|
703
625
|
const normalized = normalizeUrl(req.url);
|
|
704
626
|
const key = `${req.method}:${normalized}`;
|
|
@@ -827,12 +749,6 @@ export function extractEndpoints(requests: RawRequest[], wsMessages?: CapturedWs
|
|
|
827
749
|
action_kind: endpoint.semantic?.action_kind,
|
|
828
750
|
resource_kind: endpoint.semantic?.resource_kind,
|
|
829
751
|
});
|
|
830
|
-
// Annotate with mined template when available (passive capture, no page context)
|
|
831
|
-
try {
|
|
832
|
-
const pathname = new URL(req.url).pathname;
|
|
833
|
-
const mined = minedTemplateMap.get(pathname);
|
|
834
|
-
if (mined) endpoint._minedTemplate = mined;
|
|
835
|
-
} catch { /* ignore bad URLs */ }
|
|
836
752
|
endpoints.push(endpoint);
|
|
837
753
|
}
|
|
838
754
|
|
|
@@ -981,15 +897,6 @@ function isSensitiveHeader(name: string): boolean {
|
|
|
981
897
|
return false;
|
|
982
898
|
}
|
|
983
899
|
|
|
984
|
-
function isReplayCriticalHeader(name: string, value: string): boolean {
|
|
985
|
-
const lower = name.toLowerCase();
|
|
986
|
-
if (REPLAY_HEADER_EXACT.has(lower)) {
|
|
987
|
-
if (lower !== "accept") return true;
|
|
988
|
-
return /application\/vnd\./i.test(value);
|
|
989
|
-
}
|
|
990
|
-
return REPLAY_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
|
991
|
-
}
|
|
992
|
-
|
|
993
900
|
function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
|
|
994
901
|
return Object.fromEntries(
|
|
995
902
|
Object.entries(headers ?? {}).filter(([k]) => {
|
|
@@ -1011,7 +918,7 @@ export function extractAuthHeaders(requests: RawRequest[]): Record<string, strin
|
|
|
1011
918
|
for (const [k, v] of Object.entries(req.request_headers)) {
|
|
1012
919
|
const lower = k.toLowerCase();
|
|
1013
920
|
if (lower === "cookie" || lower === "content-length" || lower === "host") continue;
|
|
1014
|
-
if (
|
|
921
|
+
if (isSensitiveHeader(k) && !authHeaders[lower]) {
|
|
1015
922
|
authHeaders[lower] = v;
|
|
1016
923
|
}
|
|
1017
924
|
}
|
|
@@ -1147,25 +1054,6 @@ function templatizePathSegments(
|
|
|
1147
1054
|
pathParams[paramName] = oSeg;
|
|
1148
1055
|
continue;
|
|
1149
1056
|
}
|
|
1150
|
-
|
|
1151
|
-
// Pattern 3: Context-diff — endpoint URL segment differs from page URL at same position.
|
|
1152
|
-
// If the page URL has a different value at this position, this segment is likely an entity
|
|
1153
|
-
// that should be parameterized. e.g. page=/r/singularity/ endpoint=/r/programming/.json
|
|
1154
|
-
if (context?.pageUrl) {
|
|
1155
|
-
try {
|
|
1156
|
-
const contextSegments = new URL(context.pageUrl).pathname.split("/");
|
|
1157
|
-
const contextSeg = contextSegments[i];
|
|
1158
|
-
if (contextSeg && contextSeg !== tSeg &&
|
|
1159
|
-
!contextSeg.includes(".") &&
|
|
1160
|
-
contextSeg.length >= 2 && contextSeg.length <= 40 &&
|
|
1161
|
-
!/^(api|v\d+|www|en|es|fr|de|latest|search|i)$/i.test(contextSeg)) {
|
|
1162
|
-
const paramName = inferParamName(tSegments, i, "slug", usedNames);
|
|
1163
|
-
tSegments[i] = `{${paramName}}`;
|
|
1164
|
-
pathParams[paramName] = contextSeg; // use context URL value as default
|
|
1165
|
-
continue;
|
|
1166
|
-
}
|
|
1167
|
-
} catch { /* skip */ }
|
|
1168
|
-
}
|
|
1169
1057
|
}
|
|
1170
1058
|
|
|
1171
1059
|
return { url: `${tOrigin}${tSegments.join("/")}`, pathParams };
|
|
@@ -1210,18 +1098,6 @@ function tryParseBody(body: string): Record<string, unknown> | undefined {
|
|
|
1210
1098
|
* Used by collapseEndpoints to avoid merging distinct API actions
|
|
1211
1099
|
* like /relationships/connectionsSummary + /relationships/invitationsSummary.
|
|
1212
1100
|
*/
|
|
1213
|
-
/** Compute Shannon entropy (bits per character) for a string. */
|
|
1214
|
-
function computeEntropy(s: string): number {
|
|
1215
|
-
const freq = new Map<string, number>();
|
|
1216
|
-
for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
|
1217
|
-
let h = 0;
|
|
1218
|
-
for (const count of freq.values()) {
|
|
1219
|
-
const p = count / s.length;
|
|
1220
|
-
h -= p * Math.log2(p);
|
|
1221
|
-
}
|
|
1222
|
-
return h;
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
1101
|
function looksLikeEntityId(segment: string): boolean {
|
|
1226
1102
|
if (segment.startsWith("{")) return true;
|
|
1227
1103
|
// UUID (with or without dashes)
|
|
@@ -1236,12 +1112,6 @@ function looksLikeEntityId(segment: string): boolean {
|
|
|
1236
1112
|
if (/^[A-Z]{1,5}(\.[A-Z])?$/.test(segment)) return true;
|
|
1237
1113
|
// Comma-separated lists
|
|
1238
1114
|
if (segment.includes(",")) return true;
|
|
1239
|
-
// Base64-encoded IDs: mixed case with = padding
|
|
1240
|
-
if (/^[A-Za-z0-9+/]{6,}={1,2}$/.test(segment)) return true;
|
|
1241
|
-
|
|
1242
|
-
// High-entropy strings are likely encoded IDs (tokens, hashes, opaque cursors, etc.)
|
|
1243
|
-
const entropy = computeEntropy(segment);
|
|
1244
|
-
if (entropy > 3.5 && segment.length > 5) return true;
|
|
1245
1115
|
|
|
1246
1116
|
// === NOT an entity ID — these are action/resource names ===
|
|
1247
1117
|
// camelCase: lowercase letter followed by uppercase (e.g., connectionsSummary)
|
|
@@ -1251,9 +1121,6 @@ function looksLikeEntityId(segment: string): boolean {
|
|
|
1251
1121
|
// Pure lowercase alphabetic word 3+ chars (REST resource: "connections", "settings")
|
|
1252
1122
|
if (/^[a-z]{3,}$/.test(segment)) return false;
|
|
1253
1123
|
|
|
1254
|
-
// Low-entropy strings are likely readable names, not IDs
|
|
1255
|
-
if (entropy < 2.5 && segment.length > 3) return false;
|
|
1256
|
-
|
|
1257
1124
|
// Ambiguous — allow collapsing (conservative)
|
|
1258
1125
|
return true;
|
|
1259
1126
|
}
|
|
@@ -1272,71 +1139,6 @@ function looksLikeEntityId(segment: string): boolean {
|
|
|
1272
1139
|
* Only collapses when the majority (>50%) of varying segments look like entity
|
|
1273
1140
|
* IDs, NOT distinct action/resource names (camelCase, REST words).
|
|
1274
1141
|
*/
|
|
1275
|
-
|
|
1276
|
-
/**
|
|
1277
|
-
* Mine path templates from a batch of URL paths that lack a context page URL.
|
|
1278
|
-
* Builds a prefix trie and identifies positions where enough distinct children
|
|
1279
|
-
* look like entity IDs, replacing them with `{id}` placeholders.
|
|
1280
|
-
*
|
|
1281
|
-
* @param paths - Array of URL pathnames (e.g. "/api/users/123/posts")
|
|
1282
|
-
* @param maxChildren - Minimum distinct values at a position to trigger wildcarding (default 4)
|
|
1283
|
-
* @returns Map from original path to templated path (only paths that changed are included)
|
|
1284
|
-
*/
|
|
1285
|
-
export function minePathTemplates(
|
|
1286
|
-
paths: string[],
|
|
1287
|
-
maxChildren = 4,
|
|
1288
|
-
): Map<string, string> {
|
|
1289
|
-
// Build a prefix trie: prefix → Map<segment, count>
|
|
1290
|
-
const trie = new Map<string, Map<string, number>>();
|
|
1291
|
-
|
|
1292
|
-
for (const path of paths) {
|
|
1293
|
-
const segments = path.split("/").filter(Boolean);
|
|
1294
|
-
for (let i = 0; i < segments.length; i++) {
|
|
1295
|
-
const prefix = "/" + segments.slice(0, i).join("/");
|
|
1296
|
-
const children = trie.get(prefix) ?? new Map<string, number>();
|
|
1297
|
-
const seg = segments[i];
|
|
1298
|
-
children.set(seg, (children.get(seg) ?? 0) + 1);
|
|
1299
|
-
trie.set(prefix, children);
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
// Identify wildcard prefixes: positions where distinct children >= maxChildren
|
|
1304
|
-
// AND more than 50% of those children look like entity IDs.
|
|
1305
|
-
const wildcardPrefixes = new Set<string>();
|
|
1306
|
-
for (const [prefix, children] of trie) {
|
|
1307
|
-
if (children.size < maxChildren) continue;
|
|
1308
|
-
const segs = Array.from(children.keys());
|
|
1309
|
-
const entityCount = segs.filter((s) => looksLikeEntityId(s)).length;
|
|
1310
|
-
if (entityCount / segs.length > 0.5) {
|
|
1311
|
-
wildcardPrefixes.add(prefix);
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
if (wildcardPrefixes.size === 0) return new Map();
|
|
1316
|
-
|
|
1317
|
-
// Build original → template map for paths that contain wildcarded positions.
|
|
1318
|
-
const result = new Map<string, string>();
|
|
1319
|
-
for (const path of paths) {
|
|
1320
|
-
const segments = path.split("/").filter(Boolean);
|
|
1321
|
-
const templated: string[] = [];
|
|
1322
|
-
let changed = false;
|
|
1323
|
-
for (let i = 0; i < segments.length; i++) {
|
|
1324
|
-
const prefix = "/" + segments.slice(0, i).join("/");
|
|
1325
|
-
if (wildcardPrefixes.has(prefix)) {
|
|
1326
|
-
templated.push("{id}");
|
|
1327
|
-
changed = true;
|
|
1328
|
-
} else {
|
|
1329
|
-
templated.push(segments[i]);
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
if (changed) {
|
|
1333
|
-
result.set(path, "/" + templated.join("/"));
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
return result;
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
1142
|
function collapseEndpoints(endpoints: EndpointDescriptor[]): EndpointDescriptor[] {
|
|
1341
1143
|
// Group by method + origin + all-but-last path segment
|
|
1342
1144
|
const groups = new Map<string, EndpointDescriptor[]>();
|
|
@@ -2,15 +2,12 @@ import { openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { ensureDir, getPackageRoot, getServerAutostartLogFile, getServerPidFile, resolveSiblingEntrypoint, runtimeArgsForEntrypoint } from "./paths.js";
|
|
5
|
-
import { LocalSupervisor } from "./supervisor.js";
|
|
6
5
|
|
|
7
6
|
type PidState = {
|
|
8
7
|
pid: number;
|
|
9
8
|
base_url: string;
|
|
10
9
|
started_at: string;
|
|
11
10
|
entrypoint: string;
|
|
12
|
-
version?: string;
|
|
13
|
-
restart_count?: number;
|
|
14
11
|
};
|
|
15
12
|
|
|
16
13
|
async function isServerHealthy(baseUrl: string, timeoutMs = 2_000): Promise<boolean> {
|
|
@@ -63,34 +60,21 @@ function deriveListenEnv(baseUrl: string): Record<string, string> {
|
|
|
63
60
|
return { HOST: host, PORT: port, UNBROWSE_URL: baseUrl };
|
|
64
61
|
}
|
|
65
62
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
try {
|
|
69
|
-
const root = getPackageRoot(metaUrl);
|
|
70
|
-
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf-8"));
|
|
71
|
-
return pkg.version ?? "unknown";
|
|
72
|
-
} catch {
|
|
73
|
-
return "unknown";
|
|
74
|
-
}
|
|
75
|
-
}
|
|
63
|
+
export async function ensureLocalServer(baseUrl: string, noAutoStart: boolean, metaUrl: string): Promise<void> {
|
|
64
|
+
if (await isServerHealthy(baseUrl)) return;
|
|
76
65
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
66
|
+
const pidFile = getServerPidFile(baseUrl);
|
|
67
|
+
const existing = readPidState(pidFile);
|
|
68
|
+
if (existing?.pid && isPidAlive(existing.pid)) {
|
|
69
|
+
if (await waitForHealthy(baseUrl, 15_000)) return;
|
|
70
|
+
} else if (existing) {
|
|
71
|
+
clearStalePidFile(pidFile);
|
|
72
|
+
}
|
|
80
73
|
|
|
81
|
-
|
|
82
|
-
|
|
74
|
+
if (noAutoStart) {
|
|
75
|
+
throw new Error("Server not running and auto-start disabled (--no-auto-start).");
|
|
76
|
+
}
|
|
83
77
|
|
|
84
|
-
/**
|
|
85
|
-
* Spawn the local server as a detached child process.
|
|
86
|
-
* Returns the PidState written to the pid file.
|
|
87
|
-
*/
|
|
88
|
-
function spawnServer(
|
|
89
|
-
baseUrl: string,
|
|
90
|
-
metaUrl: string,
|
|
91
|
-
pidFile: string,
|
|
92
|
-
restartCount = 0,
|
|
93
|
-
): PidState {
|
|
94
78
|
const entrypoint = resolveSiblingEntrypoint(metaUrl, "index");
|
|
95
79
|
const packageRoot = getPackageRoot(metaUrl);
|
|
96
80
|
const logFile = getServerAutostartLogFile();
|
|
@@ -110,130 +94,13 @@ function spawnServer(
|
|
|
110
94
|
});
|
|
111
95
|
child.unref();
|
|
112
96
|
|
|
113
|
-
|
|
97
|
+
writeFileSync(pidFile, JSON.stringify({
|
|
114
98
|
pid: child.pid!,
|
|
115
99
|
base_url: baseUrl,
|
|
116
100
|
started_at: new Date().toISOString(),
|
|
117
101
|
entrypoint,
|
|
118
|
-
|
|
119
|
-
restart_count: restartCount,
|
|
120
|
-
};
|
|
121
|
-
writeFileSync(pidFile, JSON.stringify(state, null, 2));
|
|
122
|
-
return state;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Shared supervisor instance used for health checking. */
|
|
126
|
-
const supervisor = new LocalSupervisor();
|
|
127
|
-
|
|
128
|
-
export { supervisor };
|
|
129
|
-
|
|
130
|
-
export async function ensureLocalServer(baseUrl: string, noAutoStart: boolean, metaUrl: string): Promise<void> {
|
|
131
|
-
if (await isServerHealthy(baseUrl)) {
|
|
132
|
-
// Server already healthy — ensure supervisor state reflects this
|
|
133
|
-
if (!supervisor.isRunning()) await supervisor.start();
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const pidFile = getServerPidFile(baseUrl);
|
|
138
|
-
const existing = readPidState(pidFile);
|
|
139
|
-
|
|
140
|
-
if (existing?.pid && isPidAlive(existing.pid)) {
|
|
141
|
-
// Process alive but not healthy — wait then try supervisor restart
|
|
142
|
-
if (await waitForHealthy(baseUrl, 15_000)) {
|
|
143
|
-
if (!supervisor.isRunning()) await supervisor.start();
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
// Still unhealthy after wait — kill stale process and restart
|
|
147
|
-
try { process.kill(existing.pid, "SIGTERM"); } catch { /* ignore */ }
|
|
148
|
-
await new Promise((r) => setTimeout(r, 1_000));
|
|
149
|
-
clearStalePidFile(pidFile);
|
|
150
|
-
if (supervisor.isRunning()) await supervisor.stop();
|
|
151
|
-
} else if (existing) {
|
|
152
|
-
clearStalePidFile(pidFile);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (noAutoStart) {
|
|
156
|
-
throw new Error("Server not running and auto-start disabled (--no-auto-start).");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Single-binary mode: start server inline instead of spawning a child
|
|
160
|
-
if (isCompiledBinary()) {
|
|
161
|
-
const { startUnbrowseServer, installServerExitCleanup } = await import("../server.js");
|
|
162
|
-
installServerExitCleanup(pidFile);
|
|
163
|
-
const server = await startUnbrowseServer({ pidFile, scheduleVerification: true });
|
|
164
|
-
console.log(`[unbrowse] server started inline on http://${server.host}:${server.port}`);
|
|
165
|
-
await supervisor.start();
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Spawn with supervisor retry
|
|
170
|
-
for (let attempt = 0; attempt <= MAX_RESTART_ATTEMPTS; attempt++) {
|
|
171
|
-
spawnServer(baseUrl, metaUrl, pidFile, attempt);
|
|
172
|
-
|
|
173
|
-
if (await waitForHealthy(baseUrl, 30_000)) {
|
|
174
|
-
await supervisor.start();
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Failed to start — clear and retry with backoff
|
|
179
|
-
const state = readPidState(pidFile);
|
|
180
|
-
if (state?.pid) {
|
|
181
|
-
try { process.kill(state.pid, "SIGTERM"); } catch { /* ignore */ }
|
|
182
|
-
}
|
|
183
|
-
clearStalePidFile(pidFile);
|
|
184
|
-
|
|
185
|
-
if (attempt < MAX_RESTART_ATTEMPTS) {
|
|
186
|
-
const backoff = RESTART_BACKOFF_MS * (attempt + 1);
|
|
187
|
-
await new Promise((r) => setTimeout(r, backoff));
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const logFile = getServerAutostartLogFile();
|
|
192
|
-
throw new Error(`Server failed to start after ${MAX_RESTART_ATTEMPTS + 1} attempts. Check ${logFile}`);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Check if the running server version matches the installed CLI version.
|
|
197
|
-
* Returns null if no server is running, or { running, installed, needs_restart }.
|
|
198
|
-
*/
|
|
199
|
-
export function checkServerVersion(baseUrl: string, metaUrl: string): { running: string; installed: string; needs_restart: boolean } | null {
|
|
200
|
-
const pidFile = getServerPidFile(baseUrl);
|
|
201
|
-
const state = readPidState(pidFile);
|
|
202
|
-
if (!state) return null;
|
|
203
|
-
const installed = getVersion(metaUrl);
|
|
204
|
-
return {
|
|
205
|
-
running: state.version ?? "unknown",
|
|
206
|
-
installed,
|
|
207
|
-
needs_restart: state.version !== installed,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Stop the running server gracefully.
|
|
213
|
-
* Returns true if a server was stopped, false if none was running.
|
|
214
|
-
*/
|
|
215
|
-
export function stopServer(baseUrl: string): boolean {
|
|
216
|
-
const pidFile = getServerPidFile(baseUrl);
|
|
217
|
-
const state = readPidState(pidFile);
|
|
218
|
-
if (!state?.pid) return false;
|
|
219
|
-
try {
|
|
220
|
-
process.kill(state.pid, "SIGTERM");
|
|
221
|
-
clearStalePidFile(pidFile);
|
|
222
|
-
// Synchronously mark supervisor as stopped (fire-and-forget the async stop)
|
|
223
|
-
if (supervisor.isRunning()) void supervisor.stop();
|
|
224
|
-
return true;
|
|
225
|
-
} catch {
|
|
226
|
-
clearStalePidFile(pidFile);
|
|
227
|
-
return false;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
102
|
+
}, null, 2));
|
|
230
103
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
* Used after CLI upgrades to pick up new code.
|
|
234
|
-
*/
|
|
235
|
-
export async function restartServer(baseUrl: string, metaUrl: string): Promise<void> {
|
|
236
|
-
stopServer(baseUrl);
|
|
237
|
-
await new Promise((r) => setTimeout(r, 1_000));
|
|
238
|
-
await ensureLocalServer(baseUrl, false, metaUrl);
|
|
104
|
+
if (await waitForHealthy(baseUrl, 30_000)) return;
|
|
105
|
+
throw new Error(`Server failed to start. Check ${logFile}`);
|
|
239
106
|
}
|
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, realpathSync } from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
-
import { fileURLToPath
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
7
|
export function getModuleDir(metaUrl: string): string {
|
|
8
8
|
return path.dirname(fileURLToPath(metaUrl));
|
|
@@ -10,13 +10,9 @@ export function getModuleDir(metaUrl: string): string {
|
|
|
10
10
|
|
|
11
11
|
export function getPackageRoot(metaUrl: string): string {
|
|
12
12
|
if (process.env.UNBROWSE_PACKAGE_ROOT) return process.env.UNBROWSE_PACKAGE_ROOT;
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
if (existsSync(path.join(dir, "package.json"))) return dir;
|
|
17
|
-
dir = path.dirname(dir);
|
|
18
|
-
}
|
|
19
|
-
return getModuleDir(metaUrl);
|
|
13
|
+
const dir = getModuleDir(metaUrl);
|
|
14
|
+
const base = path.basename(dir);
|
|
15
|
+
return base === "src" || base === "dist" ? path.dirname(dir) : dir;
|
|
20
16
|
}
|
|
21
17
|
|
|
22
18
|
export function resolveSiblingEntrypoint(metaUrl: string, basename: string): string {
|
|
@@ -32,7 +28,7 @@ export function runtimeArgsForEntrypoint(metaUrl: string, entrypoint: string): s
|
|
|
32
28
|
const req = createRequire(metaUrl);
|
|
33
29
|
const tsxPkg = req.resolve("tsx/package.json");
|
|
34
30
|
const tsxLoader = path.join(path.dirname(tsxPkg), "dist", "loader.mjs");
|
|
35
|
-
if (existsSync(tsxLoader)) return ["--import",
|
|
31
|
+
if (existsSync(tsxLoader)) return ["--import", tsxLoader, entrypoint];
|
|
36
32
|
} catch {
|
|
37
33
|
// fall through to bare specifier
|
|
38
34
|
}
|
|
@@ -4,9 +4,6 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { ensureDir } from "./paths.js";
|
|
6
6
|
import { findKuriBinary, getKuriSourceCandidates } from "../kuri/client.js";
|
|
7
|
-
import { detectHostEnvironment, type HostEnvironment } from "./browser-host.js";
|
|
8
|
-
import { log } from "../logger.js";
|
|
9
|
-
import { checkWalletConfigured, type WalletCheckResult } from "../payments/wallet.js";
|
|
10
7
|
|
|
11
8
|
export type SetupScope = "auto" | "global" | "project" | "off";
|
|
12
9
|
|
|
@@ -16,7 +13,6 @@ export type SetupReport = {
|
|
|
16
13
|
release: string;
|
|
17
14
|
arch: string;
|
|
18
15
|
};
|
|
19
|
-
host_environment: HostEnvironment;
|
|
20
16
|
package_managers: {
|
|
21
17
|
npm: boolean;
|
|
22
18
|
npx: boolean;
|
|
@@ -32,9 +28,7 @@ export type SetupReport = {
|
|
|
32
28
|
detected: boolean;
|
|
33
29
|
action: "installed" | "updated" | "skipped" | "not-detected";
|
|
34
30
|
scope: "global" | "project" | "off";
|
|
35
|
-
|
|
36
|
-
wallet: WalletCheckResult & {
|
|
37
|
-
message: string;
|
|
31
|
+
command_file?: string;
|
|
38
32
|
};
|
|
39
33
|
};
|
|
40
34
|
|
|
@@ -185,52 +179,9 @@ export async function runSetup(options?: {
|
|
|
185
179
|
installBrowser?: boolean;
|
|
186
180
|
}): Promise<SetupReport> {
|
|
187
181
|
const cwd = options?.cwd || process.cwd();
|
|
188
|
-
|
|
189
|
-
const hostEnv = detectHostEnvironment();
|
|
190
|
-
log("setup", `detected host environment: ${hostEnv}`);
|
|
191
|
-
|
|
192
182
|
const browser = options?.installBrowser === false
|
|
193
183
|
? { installed: false, action: "skipped" as const }
|
|
194
184
|
: await ensureBrowserEngineInstalled();
|
|
195
|
-
const walletCheck = checkWalletConfigured();
|
|
196
|
-
const skipWalletSetup = process.env.UNBROWSE_SKIP_WALLET_SETUP === "1";
|
|
197
|
-
const lobsterInstalled = hasBinary("lobstercash") ||
|
|
198
|
-
existsSync(path.join(os.homedir(), ".agents", "skills", "lobstercash", "SKILL.md"));
|
|
199
|
-
|
|
200
|
-
// Auto-setup lobster.cash wallet if skill is installed but wallet not configured
|
|
201
|
-
if (!skipWalletSetup && !walletCheck.configured && lobsterInstalled) {
|
|
202
|
-
console.log("[unbrowse] lobster.cash skill detected but wallet not configured — running wallet setup...");
|
|
203
|
-
try {
|
|
204
|
-
execFileSync("npx", ["@crossmint/lobster-cli", "setup"], {
|
|
205
|
-
stdio: "inherit",
|
|
206
|
-
timeout: 60_000,
|
|
207
|
-
});
|
|
208
|
-
// Re-check after setup
|
|
209
|
-
const recheck = checkWalletConfigured();
|
|
210
|
-
if (recheck.configured) {
|
|
211
|
-
console.log(`[unbrowse] wallet configured (${recheck.provider})`);
|
|
212
|
-
}
|
|
213
|
-
} catch {
|
|
214
|
-
console.warn("[unbrowse] lobster.cash wallet setup failed or was skipped — continuing without wallet");
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Re-check wallet state after potential setup
|
|
219
|
-
const finalWalletCheck = checkWalletConfigured();
|
|
220
|
-
const wallet = {
|
|
221
|
-
...finalWalletCheck,
|
|
222
|
-
lobster_installed: lobsterInstalled,
|
|
223
|
-
message: finalWalletCheck.configured
|
|
224
|
-
? `Wallet configured (${finalWalletCheck.provider}). This address is the contributor truth: it is synced onto your agent profile, used for contributor payouts when your routes earn, and used for paid-route spending.`
|
|
225
|
-
: lobsterInstalled
|
|
226
|
-
? "lobster.cash installed but wallet not paired. Pair it now so this wallet address becomes your contributor payout target and your paid-route spending wallet. Run: lobstercash setup"
|
|
227
|
-
: "No wallet configured. Install/pair a wallet so your contributor payouts have a destination address and premium-route spending can clear automatically. Without it you stay in free indexing mode only.",
|
|
228
|
-
install_hint: finalWalletCheck.configured
|
|
229
|
-
? undefined
|
|
230
|
-
: lobsterInstalled
|
|
231
|
-
? "lobstercash setup"
|
|
232
|
-
: "npx skills add https://github.com/Crossmint/lobstercash-cli-skills --global --yes",
|
|
233
|
-
};
|
|
234
185
|
|
|
235
186
|
return {
|
|
236
187
|
os: {
|
|
@@ -238,10 +189,8 @@ export async function runSetup(options?: {
|
|
|
238
189
|
release: os.release(),
|
|
239
190
|
arch: process.arch,
|
|
240
191
|
},
|
|
241
|
-
host_environment: hostEnv,
|
|
242
192
|
package_managers: detectPackageManagers(),
|
|
243
193
|
browser_engine: browser,
|
|
244
194
|
opencode: writeOpenCodeCommand(options?.opencode ?? "auto", cwd),
|
|
245
|
-
wallet,
|
|
246
195
|
};
|
|
247
196
|
}
|