@aexol/spectral 0.9.42 → 0.9.44
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/dist/extensions/aexol-mcp.d.ts.map +1 -1
- package/dist/extensions/aexol-mcp.js +44 -9
- package/dist/extensions/browser/browser-service.d.ts +4 -1
- package/dist/extensions/browser/browser-service.d.ts.map +1 -1
- package/dist/extensions/browser/browser-service.js +34 -7
- package/dist/extensions/browser/config.d.ts +3 -0
- package/dist/extensions/browser/config.d.ts.map +1 -1
- package/dist/extensions/browser/config.js +1 -0
- package/dist/extensions/browser/index.d.ts.map +1 -1
- package/dist/extensions/browser/index.js +3 -0
- package/dist/extensions/browser/record/record-tool.d.ts.map +1 -1
- package/dist/extensions/browser/record/record-tool.js +27 -4
- package/dist/extensions/browser/scenario/markdown-parser.d.ts +44 -0
- package/dist/extensions/browser/scenario/markdown-parser.d.ts.map +1 -0
- package/dist/extensions/browser/scenario/markdown-parser.js +159 -0
- package/dist/extensions/browser/scenario/scenario-tool.d.ts +4 -0
- package/dist/extensions/browser/scenario/scenario-tool.d.ts.map +1 -0
- package/dist/extensions/browser/scenario/scenario-tool.js +381 -0
- package/dist/extensions/browser/tools/core.js +1 -1
- package/dist/extensions/browser/tools/screenshot.d.ts.map +1 -1
- package/dist/extensions/browser/tools/screenshot.js +2 -1
- package/dist/extensions/seo/analyzers/agent-ux.d.ts +5 -0
- package/dist/extensions/seo/analyzers/agent-ux.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/agent-ux.js +95 -0
- package/dist/extensions/seo/analyzers/content-optimize.d.ts +3 -0
- package/dist/extensions/seo/analyzers/content-optimize.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/content-optimize.js +190 -0
- package/dist/extensions/seo/analyzers/content.d.ts +16 -0
- package/dist/extensions/seo/analyzers/content.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/content.js +174 -0
- package/dist/extensions/seo/analyzers/crawler.d.ts +4 -0
- package/dist/extensions/seo/analyzers/crawler.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/crawler.js +288 -0
- package/dist/extensions/seo/analyzers/drift.d.ts +13 -0
- package/dist/extensions/seo/analyzers/drift.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/drift.js +204 -0
- package/dist/extensions/seo/analyzers/images.d.ts +10 -0
- package/dist/extensions/seo/analyzers/images.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/images.js +49 -0
- package/dist/extensions/seo/analyzers/keywords.d.ts +9 -0
- package/dist/extensions/seo/analyzers/keywords.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/keywords.js +183 -0
- package/dist/extensions/seo/analyzers/parasite.d.ts +18 -0
- package/dist/extensions/seo/analyzers/parasite.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/parasite.js +142 -0
- package/dist/extensions/seo/analyzers/performance.d.ts +6 -0
- package/dist/extensions/seo/analyzers/performance.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/performance.js +257 -0
- package/dist/extensions/seo/analyzers/preload.d.ts +12 -0
- package/dist/extensions/seo/analyzers/preload.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/preload.js +108 -0
- package/dist/extensions/seo/analyzers/readability.d.ts +4 -0
- package/dist/extensions/seo/analyzers/readability.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/readability.js +121 -0
- package/dist/extensions/seo/analyzers/schema.d.ts +21 -0
- package/dist/extensions/seo/analyzers/schema.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/schema.js +324 -0
- package/dist/extensions/seo/analyzers/sitemap.d.ts +20 -0
- package/dist/extensions/seo/analyzers/sitemap.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/sitemap.js +121 -0
- package/dist/extensions/seo/analyzers/technical.d.ts +11 -0
- package/dist/extensions/seo/analyzers/technical.d.ts.map +1 -0
- package/dist/extensions/seo/analyzers/technical.js +268 -0
- package/dist/extensions/seo/config.d.ts +24 -0
- package/dist/extensions/seo/config.d.ts.map +1 -0
- package/dist/extensions/seo/config.js +48 -0
- package/dist/extensions/seo/index.d.ts +16 -0
- package/dist/extensions/seo/index.d.ts.map +1 -0
- package/dist/extensions/seo/index.js +126 -0
- package/dist/extensions/seo/tools/agent-ux.d.ts +7 -0
- package/dist/extensions/seo/tools/agent-ux.d.ts.map +1 -0
- package/dist/extensions/seo/tools/agent-ux.js +63 -0
- package/dist/extensions/seo/tools/batch.d.ts +4 -0
- package/dist/extensions/seo/tools/batch.d.ts.map +1 -0
- package/dist/extensions/seo/tools/batch.js +193 -0
- package/dist/extensions/seo/tools/content.d.ts +7 -0
- package/dist/extensions/seo/tools/content.d.ts.map +1 -0
- package/dist/extensions/seo/tools/content.js +55 -0
- package/dist/extensions/seo/tools/crawl.d.ts +4 -0
- package/dist/extensions/seo/tools/crawl.d.ts.map +1 -0
- package/dist/extensions/seo/tools/crawl.js +166 -0
- package/dist/extensions/seo/tools/drift.d.ts +7 -0
- package/dist/extensions/seo/tools/drift.d.ts.map +1 -0
- package/dist/extensions/seo/tools/drift.js +104 -0
- package/dist/extensions/seo/tools/fetch.d.ts +7 -0
- package/dist/extensions/seo/tools/fetch.d.ts.map +1 -0
- package/dist/extensions/seo/tools/fetch.js +123 -0
- package/dist/extensions/seo/tools/full-audit.d.ts +11 -0
- package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -0
- package/dist/extensions/seo/tools/full-audit.js +191 -0
- package/dist/extensions/seo/tools/helpers.d.ts +13 -0
- package/dist/extensions/seo/tools/helpers.d.ts.map +1 -0
- package/dist/extensions/seo/tools/helpers.js +24 -0
- package/dist/extensions/seo/tools/images.d.ts +7 -0
- package/dist/extensions/seo/tools/images.d.ts.map +1 -0
- package/dist/extensions/seo/tools/images.js +46 -0
- package/dist/extensions/seo/tools/keywords.d.ts +4 -0
- package/dist/extensions/seo/tools/keywords.d.ts.map +1 -0
- package/dist/extensions/seo/tools/keywords.js +234 -0
- package/dist/extensions/seo/tools/parasite.d.ts +7 -0
- package/dist/extensions/seo/tools/parasite.d.ts.map +1 -0
- package/dist/extensions/seo/tools/parasite.js +53 -0
- package/dist/extensions/seo/tools/parse.d.ts +7 -0
- package/dist/extensions/seo/tools/parse.d.ts.map +1 -0
- package/dist/extensions/seo/tools/parse.js +66 -0
- package/dist/extensions/seo/tools/performance.d.ts +4 -0
- package/dist/extensions/seo/tools/performance.d.ts.map +1 -0
- package/dist/extensions/seo/tools/performance.js +201 -0
- package/dist/extensions/seo/tools/preload.d.ts +7 -0
- package/dist/extensions/seo/tools/preload.d.ts.map +1 -0
- package/dist/extensions/seo/tools/preload.js +52 -0
- package/dist/extensions/seo/tools/report.d.ts +4 -0
- package/dist/extensions/seo/tools/report.d.ts.map +1 -0
- package/dist/extensions/seo/tools/report.js +167 -0
- package/dist/extensions/seo/tools/schema.d.ts +7 -0
- package/dist/extensions/seo/tools/schema.d.ts.map +1 -0
- package/dist/extensions/seo/tools/schema.js +105 -0
- package/dist/extensions/seo/tools/sitemap.d.ts +7 -0
- package/dist/extensions/seo/tools/sitemap.d.ts.map +1 -0
- package/dist/extensions/seo/tools/sitemap.js +85 -0
- package/dist/extensions/seo/tools/technical.d.ts +7 -0
- package/dist/extensions/seo/tools/technical.d.ts.map +1 -0
- package/dist/extensions/seo/tools/technical.js +62 -0
- package/dist/extensions/seo/types.d.ts +445 -0
- package/dist/extensions/seo/types.d.ts.map +1 -0
- package/dist/extensions/seo/types.js +7 -0
- package/dist/extensions/seo/utils/fetcher.d.ts +28 -0
- package/dist/extensions/seo/utils/fetcher.d.ts.map +1 -0
- package/dist/extensions/seo/utils/fetcher.js +118 -0
- package/dist/extensions/seo/utils/parser.d.ts +8 -0
- package/dist/extensions/seo/utils/parser.d.ts.map +1 -0
- package/dist/extensions/seo/utils/parser.js +265 -0
- package/dist/extensions/seo/utils/renderer.d.ts +39 -0
- package/dist/extensions/seo/utils/renderer.d.ts.map +1 -0
- package/dist/extensions/seo/utils/renderer.js +190 -0
- package/dist/extensions/seo/utils/url-safety.d.ts +31 -0
- package/dist/extensions/seo/utils/url-safety.d.ts.map +1 -0
- package/dist/extensions/seo/utils/url-safety.js +202 -0
- package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/native-extensions.js +11 -0
- package/dist/server/handlers/paths-pick-directory.d.ts +0 -27
- package/dist/server/handlers/paths-pick-directory.d.ts.map +1 -1
- package/dist/server/handlers/paths-pick-directory.js +86 -92
- package/package.json +1 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL validation and SSRF protection.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the Python `url_safety.py` in claude-seo. Blocks requests to
|
|
5
|
+
* private/internal IP ranges to prevent server-side request forgery.
|
|
6
|
+
*/
|
|
7
|
+
import { lookup } from "node:dns/promises";
|
|
8
|
+
import { isIP } from "node:net";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
export class UrlSafetyError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "UrlSafetyError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Private/loopback IPv4 CIDR prefixes. */
|
|
17
|
+
const PRIVATE_IPV4_PREFIXES = [
|
|
18
|
+
"10.",
|
|
19
|
+
"172.16.", "172.17.", "172.18.", "172.19.", "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.",
|
|
20
|
+
"192.168.",
|
|
21
|
+
"169.254.",
|
|
22
|
+
"127.",
|
|
23
|
+
"0.",
|
|
24
|
+
"100.64.", "100.65.", "100.66.", "100.67.", "100.68.", "100.69.", "100.70.", "100.71.", "100.72.", "100.73.", "100.74.", "100.75.", "100.76.", "100.77.", "100.78.", "100.79.", "100.80.", "100.81.", "100.82.", "100.83.", "100.84.", "100.85.", "100.86.", "100.87.", "100.88.", "100.89.", "100.90.", "100.91.", "100.92.", "100.93.", "100.94.", "100.95.", "100.96.", "100.97.", "100.98.", "100.99.", "100.100.", "100.101.", "100.102.", "100.103.", "100.104.", "100.105.", "100.106.", "100.107.", "100.108.", "100.109.", "100.110.", "100.111.", "100.112.", "100.113.", "100.114.", "100.115.", "100.116.", "100.117.", "100.118.", "100.119.", "100.120.", "100.121.", "100.122.", "100.123.", "100.124.", "100.125.", "100.126.", "100.127.",
|
|
25
|
+
];
|
|
26
|
+
/** Blocked hostnames that resolve to metadata endpoints or local services. */
|
|
27
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
28
|
+
"localhost",
|
|
29
|
+
"metadata.google.internal",
|
|
30
|
+
"metadata",
|
|
31
|
+
"169.254.169.254",
|
|
32
|
+
"fd00.local",
|
|
33
|
+
"metadata.aws.internal",
|
|
34
|
+
]);
|
|
35
|
+
function isPrivateIPv4(ip) {
|
|
36
|
+
for (const prefix of PRIVATE_IPV4_PREFIXES) {
|
|
37
|
+
if (ip.startsWith(prefix))
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
function isPrivateIPv6(ip) {
|
|
43
|
+
const lower = ip.toLowerCase();
|
|
44
|
+
return (lower === "::1" ||
|
|
45
|
+
lower.startsWith("fe80:") ||
|
|
46
|
+
lower.startsWith("fc00:") ||
|
|
47
|
+
lower.startsWith("fd00:") ||
|
|
48
|
+
lower.startsWith("fd01:") ||
|
|
49
|
+
lower.startsWith("::ffff:") && isPrivateIPv4(lower.replace("::ffff:", "")));
|
|
50
|
+
}
|
|
51
|
+
function isPrivateIp(ip) {
|
|
52
|
+
const family = isIP(ip);
|
|
53
|
+
if (family === 4)
|
|
54
|
+
return isPrivateIPv4(ip);
|
|
55
|
+
if (family === 6)
|
|
56
|
+
return isPrivateIPv6(ip);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Validate a URL string with optional SSRF protection.
|
|
61
|
+
*
|
|
62
|
+
* Throws UrlSafetyError if the URL is invalid, uses a non-http(s) protocol,
|
|
63
|
+
* points to a blocked hostname, or (when ssrfProtection=true) resolves to a
|
|
64
|
+
* private IP address.
|
|
65
|
+
*/
|
|
66
|
+
export async function validateUrl(urlStr, opts = {}) {
|
|
67
|
+
const ssrfProtection = opts.ssrfProtection ?? true;
|
|
68
|
+
const resolveDns = opts.resolveDns ?? ssrfProtection;
|
|
69
|
+
if (!urlStr || typeof urlStr !== "string") {
|
|
70
|
+
throw new UrlSafetyError("URL is required.");
|
|
71
|
+
}
|
|
72
|
+
// Reject file:// and other local protocol schemes.
|
|
73
|
+
const trimmed = urlStr.trim();
|
|
74
|
+
if (/^(file|ftp|gopher|dict|ldap|data|blob):/i.test(trimmed)) {
|
|
75
|
+
throw new UrlSafetyError(`Protocol not allowed for SEO fetch: ${trimmed}`);
|
|
76
|
+
}
|
|
77
|
+
// Auto-prepend https:// if missing a protocol.
|
|
78
|
+
let normalized = trimmed;
|
|
79
|
+
if (!/^https?:\/\//i.test(normalized)) {
|
|
80
|
+
normalized = `https://${normalized}`;
|
|
81
|
+
}
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = new URL(normalized);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
throw new UrlSafetyError(`Invalid URL: ${urlStr}`);
|
|
88
|
+
}
|
|
89
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
90
|
+
throw new UrlSafetyError(`Only http and https protocols are allowed, got: ${parsed.protocol}`);
|
|
91
|
+
}
|
|
92
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
93
|
+
// Check for direct IP literals.
|
|
94
|
+
const ipFamily = isIP(hostname.replace(/^\[|\]$/g, ""));
|
|
95
|
+
if (ssrfProtection) {
|
|
96
|
+
if (BLOCKED_HOSTNAMES.has(hostname)) {
|
|
97
|
+
throw new UrlSafetyError(`Blocked hostname: ${hostname}`);
|
|
98
|
+
}
|
|
99
|
+
if (ipFamily && isPrivateIp(hostname.replace(/^\[|\]$/g, ""))) {
|
|
100
|
+
throw new UrlSafetyError(`URL points to a private IP address: ${hostname}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
let ips = [];
|
|
104
|
+
if (resolveDns && !ipFamily) {
|
|
105
|
+
try {
|
|
106
|
+
const records = await lookup(hostname, { all: true });
|
|
107
|
+
ips = records.map((r) => r.address);
|
|
108
|
+
if (ssrfProtection) {
|
|
109
|
+
for (const ip of ips) {
|
|
110
|
+
if (isPrivateIp(ip)) {
|
|
111
|
+
throw new UrlSafetyError(`Hostname ${hostname} resolves to private IP ${ip}. Blocked by SSRF protection.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (err instanceof UrlSafetyError)
|
|
118
|
+
throw err;
|
|
119
|
+
// DNS resolution failure is not necessarily an error for SSRF — the fetch
|
|
120
|
+
// will fail naturally. But we surface it if the caller wanted DNS.
|
|
121
|
+
if (resolveDns && !ssrfProtection) {
|
|
122
|
+
// non-fatal: continue without IPs
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
href: parsed.href,
|
|
128
|
+
protocol: parsed.protocol,
|
|
129
|
+
hostname: parsed.hostname,
|
|
130
|
+
port: parsed.port,
|
|
131
|
+
pathname: parsed.pathname,
|
|
132
|
+
search: parsed.search,
|
|
133
|
+
ips,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Quick synchronous URL validation without DNS resolution. */
|
|
137
|
+
export function validateUrlBasic(urlStr) {
|
|
138
|
+
if (!urlStr || typeof urlStr !== "string") {
|
|
139
|
+
throw new UrlSafetyError("URL is required.");
|
|
140
|
+
}
|
|
141
|
+
const trimmed = urlStr.trim();
|
|
142
|
+
if (/^(file|ftp|gopher|dict|ldap|data|blob):/i.test(trimmed)) {
|
|
143
|
+
throw new UrlSafetyError(`Protocol not allowed: ${trimmed}`);
|
|
144
|
+
}
|
|
145
|
+
let normalized = trimmed;
|
|
146
|
+
if (!/^https?:\/\//i.test(normalized)) {
|
|
147
|
+
normalized = `https://${normalized}`;
|
|
148
|
+
}
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
parsed = new URL(normalized);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw new UrlSafetyError(`Invalid URL: ${urlStr}`);
|
|
155
|
+
}
|
|
156
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
157
|
+
throw new UrlSafetyError(`Only http/https allowed: ${parsed.protocol}`);
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
href: parsed.href,
|
|
161
|
+
protocol: parsed.protocol,
|
|
162
|
+
hostname: parsed.hostname,
|
|
163
|
+
port: parsed.port,
|
|
164
|
+
pathname: parsed.pathname,
|
|
165
|
+
search: parsed.search,
|
|
166
|
+
ips: [],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** Normalize a URL for baseline matching (strip UTM, sort params, lowercase host). */
|
|
170
|
+
export function normalizeUrl(url) {
|
|
171
|
+
const UTM_PARAMS = new Set(["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"]);
|
|
172
|
+
let parsed;
|
|
173
|
+
try {
|
|
174
|
+
parsed = new URL(url);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return url;
|
|
178
|
+
}
|
|
179
|
+
parsed.hostname = parsed.hostname.toLowerCase();
|
|
180
|
+
// Strip default ports.
|
|
181
|
+
if ((parsed.protocol === "http:" && parsed.port === "80") || (parsed.protocol === "https:" && parsed.port === "443")) {
|
|
182
|
+
parsed.port = "";
|
|
183
|
+
}
|
|
184
|
+
// Remove UTM params.
|
|
185
|
+
const params = new URLSearchParams(parsed.search);
|
|
186
|
+
for (const key of [...params.keys()]) {
|
|
187
|
+
if (UTM_PARAMS.has(key.toLowerCase())) {
|
|
188
|
+
params.delete(key);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
params.sort();
|
|
192
|
+
parsed.search = params.toString();
|
|
193
|
+
// Strip trailing slash (except bare domain).
|
|
194
|
+
if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) {
|
|
195
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
|
196
|
+
}
|
|
197
|
+
return parsed.href;
|
|
198
|
+
}
|
|
199
|
+
/** Hash a normalized URL for baseline deduplication. */
|
|
200
|
+
export function urlHash(url) {
|
|
201
|
+
return createHash("sha256").update(normalizeUrl(url)).digest("hex").slice(0, 16);
|
|
202
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"native-extensions.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/extensions/native-extensions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,uBAAuB;IACtC,qEAAqE;IACrE,EAAE,EAAE,MAAM,CAAC;IACX,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,mDAAmD;IACnD,QAAQ,EAAE,MAAM,GAAG,YAAY,GAAG,aAAa,CAAC;IAChD;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,cAAc,EAAE,OAAO,CAAC;IACxB,mCAAmC;IACnC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,EAAE,uBAAuB,
|
|
1
|
+
{"version":3,"file":"native-extensions.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/extensions/native-extensions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,uBAAuB;IACtC,qEAAqE;IACrE,EAAE,EAAE,MAAM,CAAC;IACX,4DAA4D;IAC5D,KAAK,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,mDAAmD;IACnD,QAAQ,EAAE,MAAM,GAAG,YAAY,GAAG,aAAa,CAAC;IAChD;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,cAAc,EAAE,OAAO,CAAC;IACxB,mCAAmC;IACnC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,EAAE,uBAAuB,EA8EtD,CAAC;AAEF;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,MAAM,GAAG,uBAAuB,GAAG,SAAS,CAElF;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,CAIjF;AAED;;GAEG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,KAAK,CAAC;IACzE,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB,CAAC,CASD"}
|
|
@@ -77,6 +77,17 @@ export const NATIVE_EXTENSIONS = [
|
|
|
77
77
|
defaultEnabled: true,
|
|
78
78
|
tags: ["vision", "images"],
|
|
79
79
|
},
|
|
80
|
+
{
|
|
81
|
+
id: "seo",
|
|
82
|
+
label: "SEO Analysis",
|
|
83
|
+
description: "Native SEO audit tools: on-page parsing, technical checks, content quality, " +
|
|
84
|
+
"Schema.org detection/generation, sitemap, images, preload/bfcache, agent UX, " +
|
|
85
|
+
"parasite risk, drift monitoring, and full audit orchestration. No external API keys required.",
|
|
86
|
+
category: "automation",
|
|
87
|
+
entryPath: "src/extensions/seo/index.ts",
|
|
88
|
+
defaultEnabled: true,
|
|
89
|
+
tags: ["seo", "audit", "marketing"],
|
|
90
|
+
},
|
|
80
91
|
];
|
|
81
92
|
/**
|
|
82
93
|
* Look up a native extension by id. Returns undefined for unknown ids.
|
|
@@ -1,33 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Native OS folder picker dialog for project path selection.
|
|
3
|
-
*
|
|
4
|
-
* The "New Project" form in the browser has a "Browse..." button next to
|
|
5
|
-
* the typed-path autocomplete. When clicked, it sends
|
|
6
|
-
* `POST /api/paths/pick-directory` to this handler, which launches a
|
|
7
|
-
* native OS folder picker dialog on the user's machine and returns the
|
|
8
|
-
* absolute path of the selected directory (or `null` when cancelled).
|
|
9
|
-
*
|
|
10
|
-
* Platform strategy (tried in order):
|
|
11
|
-
* 1. macOS — osascript (built-in, zero deps, native look)
|
|
12
|
-
* 2. Linux — zenity (GTK), kdialog (KDE), then Python tkinter
|
|
13
|
-
* 3. Windows — PowerShell (COM object)
|
|
14
|
-
* 4. Fallback — Python tkinter (bundled with most Python installs)
|
|
15
|
-
*
|
|
16
|
-
* The first available tool wins; if none work, we return an error.
|
|
17
|
-
* All child processes are synchronous — the browser is waiting anyway.
|
|
18
|
-
*/
|
|
19
|
-
/** Result shape returned to the frontend. */
|
|
20
1
|
export interface PickDirectoryResult {
|
|
21
|
-
/** Absolute path of the selected directory, or null if cancelled. */
|
|
22
2
|
path: string | null;
|
|
23
|
-
/** Human-readable error when the picker couldn't be launched. */
|
|
24
3
|
error?: string;
|
|
25
4
|
}
|
|
26
|
-
/**
|
|
27
|
-
* Attempt to open a native folder picker dialog and return the selected path.
|
|
28
|
-
*
|
|
29
|
-
* Synchronous and blocking by design — the browser form is waiting for a
|
|
30
|
-
* response and the user is interacting with the dialog.
|
|
31
|
-
*/
|
|
32
5
|
export declare function handlePickDirectory(): PickDirectoryResult;
|
|
33
6
|
//# sourceMappingURL=paths-pick-directory.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"paths-pick-directory.d.ts","sourceRoot":"","sources":["../../../src/server/handlers/paths-pick-directory.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"paths-pick-directory.d.ts","sourceRoot":"","sources":["../../../src/server/handlers/paths-pick-directory.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA6JD,wBAAgB,mBAAmB,IAAI,mBAAmB,CAmBzD"}
|
|
@@ -1,30 +1,18 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Native OS folder picker dialog for project path selection.
|
|
3
|
-
*
|
|
4
|
-
* The "New Project" form in the browser has a "Browse..." button next to
|
|
5
|
-
* the typed-path autocomplete. When clicked, it sends
|
|
6
|
-
* `POST /api/paths/pick-directory` to this handler, which launches a
|
|
7
|
-
* native OS folder picker dialog on the user's machine and returns the
|
|
8
|
-
* absolute path of the selected directory (or `null` when cancelled).
|
|
9
|
-
*
|
|
10
|
-
* Platform strategy (tried in order):
|
|
11
|
-
* 1. macOS — osascript (built-in, zero deps, native look)
|
|
12
|
-
* 2. Linux — zenity (GTK), kdialog (KDE), then Python tkinter
|
|
13
|
-
* 3. Windows — PowerShell (COM object)
|
|
14
|
-
* 4. Fallback — Python tkinter (bundled with most Python installs)
|
|
15
|
-
*
|
|
16
|
-
* The first available tool wins; if none work, we return an error.
|
|
17
|
-
* All child processes are synchronous — the browser is waiting anyway.
|
|
18
|
-
*/
|
|
19
1
|
import { execSync } from "node:child_process";
|
|
20
2
|
import { platform } from "node:os";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
3
|
+
function execErrMsg(err) {
|
|
4
|
+
if (err && typeof err === "object" && "message" in err) {
|
|
5
|
+
return String(err.message);
|
|
6
|
+
}
|
|
7
|
+
return String(err);
|
|
8
|
+
}
|
|
9
|
+
function execErrStatus(err) {
|
|
10
|
+
if (err && typeof err === "object" && "status" in err) {
|
|
11
|
+
const s = err.status;
|
|
12
|
+
return typeof s === "number" ? s : undefined;
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
28
16
|
function pickMacOS() {
|
|
29
17
|
const script = `
|
|
30
18
|
set folderPath to POSIX path of (choose folder with prompt "Select project directory")
|
|
@@ -36,75 +24,95 @@ function pickMacOS() {
|
|
|
36
24
|
timeout: 180_000, // 3 min — user might take time to browse
|
|
37
25
|
});
|
|
38
26
|
const trimmed = result.trim();
|
|
39
|
-
|
|
27
|
+
if (!trimmed) {
|
|
28
|
+
return { path: null };
|
|
29
|
+
}
|
|
30
|
+
return { path: trimmed };
|
|
40
31
|
}
|
|
41
|
-
catch {
|
|
42
|
-
|
|
43
|
-
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (execErrStatus(err) === 1) {
|
|
34
|
+
return { path: null }; // user cancelled
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
path: null,
|
|
38
|
+
error: `Could not open folder picker (macOS): ${execErrMsg(err)}`,
|
|
39
|
+
};
|
|
44
40
|
}
|
|
45
41
|
}
|
|
46
|
-
/**
|
|
47
|
-
* Run a command that shows a folder picker dialog. Returns `null` on user
|
|
48
|
-
* cancellation, and falls through (re-throws) if the tool is not installed.
|
|
49
|
-
*/
|
|
50
42
|
function tryDialog(cmd) {
|
|
51
43
|
try {
|
|
52
44
|
const result = execSync(cmd, { encoding: "utf-8", timeout: 180_000 });
|
|
53
45
|
return result.trim() || null;
|
|
54
46
|
}
|
|
55
47
|
catch (err) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const status = err && typeof err === "object" && "status" in err
|
|
59
|
-
? err.status
|
|
60
|
-
: undefined;
|
|
61
|
-
if (status === 1)
|
|
62
|
-
return null;
|
|
48
|
+
if (execErrStatus(err) === 1)
|
|
49
|
+
return null; // user cancelled
|
|
63
50
|
throw err;
|
|
64
51
|
}
|
|
65
52
|
}
|
|
66
|
-
/**
|
|
67
|
-
* Linux: Try zenity (GTK), then kdialog (KDE), then Python tkinter.
|
|
68
|
-
*/
|
|
69
53
|
function pickLinux() {
|
|
70
54
|
try {
|
|
71
|
-
|
|
55
|
+
const p = tryDialog(`zenity --file-selection --directory --title="Select project directory"`);
|
|
56
|
+
return p ? { path: p } : { path: null };
|
|
72
57
|
}
|
|
73
58
|
catch {
|
|
74
|
-
// zenity not available
|
|
75
59
|
}
|
|
76
60
|
try {
|
|
77
|
-
|
|
61
|
+
const p = tryDialog(`kdialog --getexistingdirectory "${process.env.HOME ?? "/"}" --title "Select project directory"`);
|
|
62
|
+
return p ? { path: p } : { path: null };
|
|
78
63
|
}
|
|
79
64
|
catch {
|
|
80
|
-
// kdialog not available
|
|
81
65
|
}
|
|
82
|
-
|
|
83
|
-
|
|
66
|
+
const tkResult = pickTkinter();
|
|
67
|
+
if (tkResult.path === null && !tkResult.error) {
|
|
68
|
+
return {
|
|
69
|
+
path: null,
|
|
70
|
+
error: "No folder picker available. Install zenity, kdialog, or python3-tk.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return tkResult;
|
|
84
74
|
}
|
|
85
|
-
/**
|
|
86
|
-
* Windows: Use PowerShell to invoke the COM Shell.Application folder picker.
|
|
87
|
-
*/
|
|
88
75
|
function pickWindows() {
|
|
89
76
|
const script = `
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
77
|
+
try {
|
|
78
|
+
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
|
|
79
|
+
$f = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
80
|
+
$f.Description = "Select project directory"
|
|
81
|
+
$f.ShowNewFolderButton = $true
|
|
82
|
+
if ($f.ShowDialog() -eq "OK") { Write-Output $f.SelectedPath } else { Write-Output "__CANCELLED__" }
|
|
83
|
+
} catch {
|
|
84
|
+
Write-Error $_.Exception.Message
|
|
85
|
+
[Environment]::Exit(99)
|
|
86
|
+
}
|
|
95
87
|
`.trim();
|
|
88
|
+
// -EncodedCommand (UTF-16LE base64) avoids cmd.exe quote-escaping bugs
|
|
89
|
+
// that would corrupt -Command strings containing nested double-quotes.
|
|
90
|
+
const encoded = Buffer.from(script, "utf-16le").toString("base64");
|
|
96
91
|
try {
|
|
97
|
-
const result = execSync(`powershell -NoProfile -
|
|
98
|
-
|
|
92
|
+
const result = execSync(`powershell -STA -NoProfile -EncodedCommand ${encoded}`, { encoding: "utf-8", timeout: 180_000 });
|
|
93
|
+
const trimmed = result.trim();
|
|
94
|
+
if (!trimmed || trimmed === "__CANCELLED__") {
|
|
95
|
+
return { path: null };
|
|
96
|
+
}
|
|
97
|
+
return { path: trimmed };
|
|
99
98
|
}
|
|
100
|
-
catch {
|
|
101
|
-
|
|
99
|
+
catch (err) {
|
|
100
|
+
const status = execErrStatus(err);
|
|
101
|
+
if (status === 99) {
|
|
102
|
+
const stderr = err && typeof err === "object" && "stderr" in err
|
|
103
|
+
? String(err.stderr).trim()
|
|
104
|
+
: "";
|
|
105
|
+
return {
|
|
106
|
+
path: null,
|
|
107
|
+
error: stderr || "Folder picker could not be displayed.",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
path: null,
|
|
112
|
+
error: `Could not open folder picker (Windows): ${execErrMsg(err)}`,
|
|
113
|
+
};
|
|
102
114
|
}
|
|
103
115
|
}
|
|
104
|
-
/**
|
|
105
|
-
* Python tkinter fallback — works on any OS with Python + tkinter installed.
|
|
106
|
-
* tkinter is bundled with macOS Python from python.org and most Linux distros.
|
|
107
|
-
*/
|
|
108
116
|
function pickTkinter() {
|
|
109
117
|
const script = `import tkinter as tk
|
|
110
118
|
from tkinter import filedialog
|
|
@@ -120,61 +128,47 @@ print(d if d else "")
|
|
|
120
128
|
encoding: "utf-8",
|
|
121
129
|
timeout: 180_000,
|
|
122
130
|
});
|
|
123
|
-
|
|
131
|
+
const trimmed = result.trim();
|
|
132
|
+
return trimmed ? { path: trimmed } : { path: null };
|
|
124
133
|
}
|
|
125
134
|
catch {
|
|
126
|
-
// python3 not found — try 'python' (Windows)
|
|
127
135
|
try {
|
|
128
136
|
const result = execSync(`python -c "${script}"`, {
|
|
129
137
|
encoding: "utf-8",
|
|
130
138
|
timeout: 180_000,
|
|
131
139
|
});
|
|
132
|
-
|
|
140
|
+
const trimmed = result.trim();
|
|
141
|
+
return trimmed ? { path: trimmed } : { path: null };
|
|
133
142
|
}
|
|
134
143
|
catch {
|
|
135
|
-
return
|
|
144
|
+
return {
|
|
145
|
+
path: null,
|
|
146
|
+
error: "Python/tkinter not available for folder picker. Type the path manually.",
|
|
147
|
+
};
|
|
136
148
|
}
|
|
137
149
|
}
|
|
138
150
|
}
|
|
139
|
-
// ---------------------------------------------------------------------------
|
|
140
|
-
// Public handler
|
|
141
|
-
// ---------------------------------------------------------------------------
|
|
142
|
-
/**
|
|
143
|
-
* Attempt to open a native folder picker dialog and return the selected path.
|
|
144
|
-
*
|
|
145
|
-
* Synchronous and blocking by design — the browser form is waiting for a
|
|
146
|
-
* response and the user is interacting with the dialog.
|
|
147
|
-
*/
|
|
148
151
|
export function handlePickDirectory() {
|
|
149
152
|
const os = platform();
|
|
150
153
|
try {
|
|
151
|
-
let path = null;
|
|
152
154
|
if (os === "darwin") {
|
|
153
|
-
|
|
155
|
+
return pickMacOS();
|
|
154
156
|
}
|
|
155
157
|
else if (os === "linux") {
|
|
156
|
-
|
|
158
|
+
return pickLinux();
|
|
157
159
|
}
|
|
158
160
|
else if (os === "win32") {
|
|
159
|
-
|
|
161
|
+
return pickWindows();
|
|
160
162
|
}
|
|
161
163
|
else {
|
|
162
|
-
|
|
163
|
-
path = pickTkinter();
|
|
164
|
-
}
|
|
165
|
-
if (path === null) {
|
|
166
|
-
// User cancelled or no picker available. Distinguish:
|
|
167
|
-
// When no path AND no error in the platform pickers, it's a cancel.
|
|
168
|
-
return { path: null };
|
|
164
|
+
return pickTkinter();
|
|
169
165
|
}
|
|
170
|
-
return { path };
|
|
171
166
|
}
|
|
172
167
|
catch (err) {
|
|
173
|
-
// The platform picker failed entirely (not cancelled — process error).
|
|
174
168
|
const msg = err instanceof Error ? err.message : String(err);
|
|
175
169
|
return {
|
|
176
170
|
path: null,
|
|
177
|
-
error: `Could not open folder picker: ${msg}`,
|
|
171
|
+
error: `Could not open folder picker (${os}): ${msg}`,
|
|
178
172
|
};
|
|
179
173
|
}
|
|
180
174
|
}
|