@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.
Files changed (144) hide show
  1. package/dist/extensions/aexol-mcp.d.ts.map +1 -1
  2. package/dist/extensions/aexol-mcp.js +44 -9
  3. package/dist/extensions/browser/browser-service.d.ts +4 -1
  4. package/dist/extensions/browser/browser-service.d.ts.map +1 -1
  5. package/dist/extensions/browser/browser-service.js +34 -7
  6. package/dist/extensions/browser/config.d.ts +3 -0
  7. package/dist/extensions/browser/config.d.ts.map +1 -1
  8. package/dist/extensions/browser/config.js +1 -0
  9. package/dist/extensions/browser/index.d.ts.map +1 -1
  10. package/dist/extensions/browser/index.js +3 -0
  11. package/dist/extensions/browser/record/record-tool.d.ts.map +1 -1
  12. package/dist/extensions/browser/record/record-tool.js +27 -4
  13. package/dist/extensions/browser/scenario/markdown-parser.d.ts +44 -0
  14. package/dist/extensions/browser/scenario/markdown-parser.d.ts.map +1 -0
  15. package/dist/extensions/browser/scenario/markdown-parser.js +159 -0
  16. package/dist/extensions/browser/scenario/scenario-tool.d.ts +4 -0
  17. package/dist/extensions/browser/scenario/scenario-tool.d.ts.map +1 -0
  18. package/dist/extensions/browser/scenario/scenario-tool.js +381 -0
  19. package/dist/extensions/browser/tools/core.js +1 -1
  20. package/dist/extensions/browser/tools/screenshot.d.ts.map +1 -1
  21. package/dist/extensions/browser/tools/screenshot.js +2 -1
  22. package/dist/extensions/seo/analyzers/agent-ux.d.ts +5 -0
  23. package/dist/extensions/seo/analyzers/agent-ux.d.ts.map +1 -0
  24. package/dist/extensions/seo/analyzers/agent-ux.js +95 -0
  25. package/dist/extensions/seo/analyzers/content-optimize.d.ts +3 -0
  26. package/dist/extensions/seo/analyzers/content-optimize.d.ts.map +1 -0
  27. package/dist/extensions/seo/analyzers/content-optimize.js +190 -0
  28. package/dist/extensions/seo/analyzers/content.d.ts +16 -0
  29. package/dist/extensions/seo/analyzers/content.d.ts.map +1 -0
  30. package/dist/extensions/seo/analyzers/content.js +174 -0
  31. package/dist/extensions/seo/analyzers/crawler.d.ts +4 -0
  32. package/dist/extensions/seo/analyzers/crawler.d.ts.map +1 -0
  33. package/dist/extensions/seo/analyzers/crawler.js +288 -0
  34. package/dist/extensions/seo/analyzers/drift.d.ts +13 -0
  35. package/dist/extensions/seo/analyzers/drift.d.ts.map +1 -0
  36. package/dist/extensions/seo/analyzers/drift.js +204 -0
  37. package/dist/extensions/seo/analyzers/images.d.ts +10 -0
  38. package/dist/extensions/seo/analyzers/images.d.ts.map +1 -0
  39. package/dist/extensions/seo/analyzers/images.js +49 -0
  40. package/dist/extensions/seo/analyzers/keywords.d.ts +9 -0
  41. package/dist/extensions/seo/analyzers/keywords.d.ts.map +1 -0
  42. package/dist/extensions/seo/analyzers/keywords.js +183 -0
  43. package/dist/extensions/seo/analyzers/parasite.d.ts +18 -0
  44. package/dist/extensions/seo/analyzers/parasite.d.ts.map +1 -0
  45. package/dist/extensions/seo/analyzers/parasite.js +142 -0
  46. package/dist/extensions/seo/analyzers/performance.d.ts +6 -0
  47. package/dist/extensions/seo/analyzers/performance.d.ts.map +1 -0
  48. package/dist/extensions/seo/analyzers/performance.js +257 -0
  49. package/dist/extensions/seo/analyzers/preload.d.ts +12 -0
  50. package/dist/extensions/seo/analyzers/preload.d.ts.map +1 -0
  51. package/dist/extensions/seo/analyzers/preload.js +108 -0
  52. package/dist/extensions/seo/analyzers/readability.d.ts +4 -0
  53. package/dist/extensions/seo/analyzers/readability.d.ts.map +1 -0
  54. package/dist/extensions/seo/analyzers/readability.js +121 -0
  55. package/dist/extensions/seo/analyzers/schema.d.ts +21 -0
  56. package/dist/extensions/seo/analyzers/schema.d.ts.map +1 -0
  57. package/dist/extensions/seo/analyzers/schema.js +324 -0
  58. package/dist/extensions/seo/analyzers/sitemap.d.ts +20 -0
  59. package/dist/extensions/seo/analyzers/sitemap.d.ts.map +1 -0
  60. package/dist/extensions/seo/analyzers/sitemap.js +121 -0
  61. package/dist/extensions/seo/analyzers/technical.d.ts +11 -0
  62. package/dist/extensions/seo/analyzers/technical.d.ts.map +1 -0
  63. package/dist/extensions/seo/analyzers/technical.js +268 -0
  64. package/dist/extensions/seo/config.d.ts +24 -0
  65. package/dist/extensions/seo/config.d.ts.map +1 -0
  66. package/dist/extensions/seo/config.js +48 -0
  67. package/dist/extensions/seo/index.d.ts +16 -0
  68. package/dist/extensions/seo/index.d.ts.map +1 -0
  69. package/dist/extensions/seo/index.js +126 -0
  70. package/dist/extensions/seo/tools/agent-ux.d.ts +7 -0
  71. package/dist/extensions/seo/tools/agent-ux.d.ts.map +1 -0
  72. package/dist/extensions/seo/tools/agent-ux.js +63 -0
  73. package/dist/extensions/seo/tools/batch.d.ts +4 -0
  74. package/dist/extensions/seo/tools/batch.d.ts.map +1 -0
  75. package/dist/extensions/seo/tools/batch.js +193 -0
  76. package/dist/extensions/seo/tools/content.d.ts +7 -0
  77. package/dist/extensions/seo/tools/content.d.ts.map +1 -0
  78. package/dist/extensions/seo/tools/content.js +55 -0
  79. package/dist/extensions/seo/tools/crawl.d.ts +4 -0
  80. package/dist/extensions/seo/tools/crawl.d.ts.map +1 -0
  81. package/dist/extensions/seo/tools/crawl.js +166 -0
  82. package/dist/extensions/seo/tools/drift.d.ts +7 -0
  83. package/dist/extensions/seo/tools/drift.d.ts.map +1 -0
  84. package/dist/extensions/seo/tools/drift.js +104 -0
  85. package/dist/extensions/seo/tools/fetch.d.ts +7 -0
  86. package/dist/extensions/seo/tools/fetch.d.ts.map +1 -0
  87. package/dist/extensions/seo/tools/fetch.js +123 -0
  88. package/dist/extensions/seo/tools/full-audit.d.ts +11 -0
  89. package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -0
  90. package/dist/extensions/seo/tools/full-audit.js +191 -0
  91. package/dist/extensions/seo/tools/helpers.d.ts +13 -0
  92. package/dist/extensions/seo/tools/helpers.d.ts.map +1 -0
  93. package/dist/extensions/seo/tools/helpers.js +24 -0
  94. package/dist/extensions/seo/tools/images.d.ts +7 -0
  95. package/dist/extensions/seo/tools/images.d.ts.map +1 -0
  96. package/dist/extensions/seo/tools/images.js +46 -0
  97. package/dist/extensions/seo/tools/keywords.d.ts +4 -0
  98. package/dist/extensions/seo/tools/keywords.d.ts.map +1 -0
  99. package/dist/extensions/seo/tools/keywords.js +234 -0
  100. package/dist/extensions/seo/tools/parasite.d.ts +7 -0
  101. package/dist/extensions/seo/tools/parasite.d.ts.map +1 -0
  102. package/dist/extensions/seo/tools/parasite.js +53 -0
  103. package/dist/extensions/seo/tools/parse.d.ts +7 -0
  104. package/dist/extensions/seo/tools/parse.d.ts.map +1 -0
  105. package/dist/extensions/seo/tools/parse.js +66 -0
  106. package/dist/extensions/seo/tools/performance.d.ts +4 -0
  107. package/dist/extensions/seo/tools/performance.d.ts.map +1 -0
  108. package/dist/extensions/seo/tools/performance.js +201 -0
  109. package/dist/extensions/seo/tools/preload.d.ts +7 -0
  110. package/dist/extensions/seo/tools/preload.d.ts.map +1 -0
  111. package/dist/extensions/seo/tools/preload.js +52 -0
  112. package/dist/extensions/seo/tools/report.d.ts +4 -0
  113. package/dist/extensions/seo/tools/report.d.ts.map +1 -0
  114. package/dist/extensions/seo/tools/report.js +167 -0
  115. package/dist/extensions/seo/tools/schema.d.ts +7 -0
  116. package/dist/extensions/seo/tools/schema.d.ts.map +1 -0
  117. package/dist/extensions/seo/tools/schema.js +105 -0
  118. package/dist/extensions/seo/tools/sitemap.d.ts +7 -0
  119. package/dist/extensions/seo/tools/sitemap.d.ts.map +1 -0
  120. package/dist/extensions/seo/tools/sitemap.js +85 -0
  121. package/dist/extensions/seo/tools/technical.d.ts +7 -0
  122. package/dist/extensions/seo/tools/technical.d.ts.map +1 -0
  123. package/dist/extensions/seo/tools/technical.js +62 -0
  124. package/dist/extensions/seo/types.d.ts +445 -0
  125. package/dist/extensions/seo/types.d.ts.map +1 -0
  126. package/dist/extensions/seo/types.js +7 -0
  127. package/dist/extensions/seo/utils/fetcher.d.ts +28 -0
  128. package/dist/extensions/seo/utils/fetcher.d.ts.map +1 -0
  129. package/dist/extensions/seo/utils/fetcher.js +118 -0
  130. package/dist/extensions/seo/utils/parser.d.ts +8 -0
  131. package/dist/extensions/seo/utils/parser.d.ts.map +1 -0
  132. package/dist/extensions/seo/utils/parser.js +265 -0
  133. package/dist/extensions/seo/utils/renderer.d.ts +39 -0
  134. package/dist/extensions/seo/utils/renderer.d.ts.map +1 -0
  135. package/dist/extensions/seo/utils/renderer.js +190 -0
  136. package/dist/extensions/seo/utils/url-safety.d.ts +31 -0
  137. package/dist/extensions/seo/utils/url-safety.d.ts.map +1 -0
  138. package/dist/extensions/seo/utils/url-safety.js +202 -0
  139. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  140. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +11 -0
  141. package/dist/server/handlers/paths-pick-directory.d.ts +0 -27
  142. package/dist/server/handlers/paths-pick-directory.d.ts.map +1 -1
  143. package/dist/server/handlers/paths-pick-directory.js +86 -92
  144. 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,EAkEtD,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"}
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":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAKH,6CAA6C;AAC7C,MAAM,WAAW,mBAAmB;IAClC,qEAAqE;IACrE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,iEAAiE;IACjE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAqID;;;;;GAKG;AACH,wBAAgB,mBAAmB,IAAI,mBAAmB,CAgCzD"}
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
- // Platform pickers
23
- // ---------------------------------------------------------------------------
24
- /**
25
- * macOS: Use `osascript` with AppleScript to show a native folder chooser.
26
- * Returns the POSIX path or empty string on cancel.
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
- return trimmed || null;
27
+ if (!trimmed) {
28
+ return { path: null };
29
+ }
30
+ return { path: trimmed };
40
31
  }
41
- catch {
42
- // osascript exits non-zero on user cancel (error -128) treat as silent cancel
43
- return null;
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
- // exit code 1 = user cancelled the dialog → stop here, return null
57
- // exit code 127 = command not found → re-throw so the next picker tries
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
- return tryDialog(`zenity --file-selection --directory --title="Select project directory"`);
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
- return tryDialog(`kdialog --getexistingdirectory "${process.env.HOME ?? "/"}" --title "Select project directory"`);
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
- // Try Python tkinter
83
- return pickTkinter();
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
- Add-Type -AssemblyName System.Windows.Forms
91
- $f = New-Object System.Windows.Forms.FolderBrowserDialog
92
- $f.Description = "Select project directory"
93
- $f.ShowNewFolderButton = $true
94
- if ($f.ShowDialog() -eq "OK") { $f.SelectedPath } else { "" }
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 -Command "${script.replace(/"/g, '\\"')}"`, { encoding: "utf-8", timeout: 180_000 });
98
- return result.trim() || null;
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
- return null;
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
- return result.trim() || null;
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
- return result.trim() || null;
140
+ const trimmed = result.trim();
141
+ return trimmed ? { path: trimmed } : { path: null };
133
142
  }
134
143
  catch {
135
- return null;
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
- path = pickMacOS();
155
+ return pickMacOS();
154
156
  }
155
157
  else if (os === "linux") {
156
- path = pickLinux();
158
+ return pickLinux();
157
159
  }
158
160
  else if (os === "win32") {
159
- path = pickWindows();
161
+ return pickWindows();
160
162
  }
161
163
  else {
162
- // Unknown platform — try Python tkinter as universal fallback
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.9.42",
3
+ "version": "0.9.44",
4
4
  "description": "AI coding agent for Aexol with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,