@ainyc/canonry 1.48.0 → 1.48.4

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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # Canonry <img src="apps/web/public/favicon-32.png" alt="Canonry canary icon" width="24" />
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@ainyc/canonry)](https://www.npmjs.com/package/@ainyc/canonry) [![License: FSL-1.1-ALv2](https://img.shields.io/badge/License-FSL--1.1--ALv2-blue.svg)](https://fsl.software/) [![Node.js >= 20](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org)
3
+ [![npm version](https://img.shields.io/npm/v/@ainyc/canonry)](https://www.npmjs.com/package/@ainyc/canonry) [![License: FSL-1.1-ALv2](https://img.shields.io/badge/License-FSL--1.1--ALv2-blue.svg)](https://fsl.software/) [![Node.js >= 22.14](https://img.shields.io/badge/node-%3E%3D22.14-brightgreen)](https://nodejs.org)
4
4
 
5
- Canonry is an agent-first AEO platform powered by [OpenClaw](https://openclaw.ai). It tracks how ChatGPT, Gemini, Claude, and Perplexity cite your site, detects regressions, diagnoses causes, coordinates fixes, and reports results.
5
+ Canonry is an agent-first AEO platform CLI- and API-native, with a bundled AI agent. It tracks how ChatGPT, Gemini, Claude, and Perplexity cite your site, detects regressions, diagnoses causes, coordinates fixes, and reports results.
6
6
 
7
7
  AEO (Answer Engine Optimization) is about making sure your content shows up accurately in AI-generated answers. As search shifts from links to synthesized responses, you need something that can monitor, analyze, and act across these engines continuously.
8
8
 
@@ -15,7 +15,7 @@ npm install -g @ainyc/canonry
15
15
  canonry agent setup
16
16
  ```
17
17
 
18
- One command. It installs [OpenClaw](https://openclaw.ai), configures the agent's LLM, sets up monitoring providers, and seeds the workspace. Interactive prompts guide you through everything, or pass flags for fully automated setup:
18
+ One command. It installs the agent runtime, configures the agent's LLM, sets up monitoring providers, and seeds the workspace. Interactive prompts guide you through everything, or pass flags for fully automated setup:
19
19
 
20
20
  ```bash
21
21
  canonry agent setup --gemini-key <key> --agent-key <key> --format json
@@ -42,7 +42,7 @@ canonry serve
42
42
 
43
43
  ## What the Agent Does
44
44
 
45
- The Canonry agent ("Aero") is an [OpenClaw](https://openclaw.ai)-powered operator:
45
+ The Canonry agent ("Aero") is an autonomous operator:
46
46
 
47
47
  - **Monitors** visibility sweeps across providers on schedule, tracking citation changes over time
48
48
  - **Analyzes** regressions, emerging opportunities, and correlates visibility shifts with site changes
@@ -53,7 +53,7 @@ Every action the agent takes goes through the same CLI and API available to ever
53
53
 
54
54
  ## Features
55
55
 
56
- - **Agent-operated.** The OpenClaw agent monitors, analyzes, and acts autonomously. Humans supervise via the dashboard.
56
+ - **Agent-operated.** The bundled agent monitors, analyzes, and acts autonomously. Humans supervise via the dashboard.
57
57
  - **Multi-provider.** Query Gemini, OpenAI, Claude, Perplexity, and local LLMs from a single platform.
58
58
  - **Config-as-code.** Kubernetes-style YAML files. Version control your monitoring, let agents apply changes declaratively.
59
59
  - **Self-hosted.** Runs locally with SQLite. No cloud account required.
@@ -148,9 +148,9 @@ Integration setup guides: [Google Search Console](docs/google-search-console-set
148
148
 
149
149
  ## Skills
150
150
 
151
- The agent learns how to operate canonry through bundled [OpenClaw skills](https://clawhub.dev) that cover CLI commands, provider setup, analysis workflows, and troubleshooting. Skills are seeded into the agent workspace during `canonry agent setup`.
151
+ The agent learns how to operate canonry through bundled skills that cover CLI commands, provider setup, analysis workflows, and troubleshooting. Skills are seeded into the agent workspace during `canonry agent setup`.
152
152
 
153
- **Claude Code** also picks up the skill automatically from `.claude/skills/canonry-setup/` when you open this repo. **ClawHub** hosts the same skill at [clawhub.dev](https://clawhub.dev) for any MCP-equipped agent.
153
+ **Claude Code** also picks up the skill automatically from `.claude/skills/canonry-setup/` when you open this repo.
154
154
 
155
155
  ## Deployment
156
156
 
@@ -177,7 +177,7 @@ Create a Web Service with runtime Docker, attach a disk at `/data`. Health check
177
177
 
178
178
  ## Requirements
179
179
 
180
- - Node.js >= 20
180
+ - Node.js >= 22.14.0
181
181
  - At least one provider API key (configurable after startup)
182
182
 
183
183
  If `npm install` fails with `node-gyp` errors, install build tools for `better-sqlite3`: `xcode-select --install` (macOS), `apt-get install python3 make g++` (Debian), or see the [troubleshooting guide](https://github.com/WiseLibs/better-sqlite3/blob/master/docs/troubleshooting.md).
@@ -40,3 +40,4 @@ You coordinate across three tools to deliver comprehensive AEO monitoring:
40
40
  - [memory-patterns.md](references/memory-patterns.md) — What to persist per client
41
41
  - [regression-playbook.md](references/regression-playbook.md) — Detection through response
42
42
  - [reporting.md](references/reporting.md) — Report generation templates
43
+ - [wordpress-elementor-mcp.md](references/wordpress-elementor-mcp.md) — Elementor MCP tools for page management
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Per-Client State Template
4
4
 
5
- Store in OpenClaw agent memory after each significant event:
5
+ Store in agent memory after each significant event:
6
6
 
7
7
  ```
8
8
  Client: <business name>
@@ -0,0 +1,218 @@
1
+ # Elementor + WordPress MCP Development Guide
2
+
3
+ ## Overview
4
+
5
+ This guide covers programmatic management of WordPress + Elementor sites using the Elementor MCP plugin. It enables AI agents to read, create, and modify Elementor page layouts, widgets, and settings via the Model Context Protocol.
6
+
7
+ ## Prerequisites
8
+
9
+ ### WordPress Side
10
+ - WordPress >= 6.8
11
+ - Elementor >= 3.20 (container-based layouts)
12
+ - Elementor Pro (for custom CSS, forms, nav menus, etc.)
13
+ - **WordPress MCP Adapter** plugin ([GitHub](https://github.com/WordPress/mcp-adapter))
14
+ - **Elementor MCP** plugin ([GitHub](https://github.com/msrbuilds/elementor-mcp))
15
+ - Application Password created for API auth (Users > Profile > Application Passwords)
16
+ - Permalinks set to "Post name" (required for `/wp-json/` REST API routing)
17
+
18
+ ### Client Side
19
+ - `.mcp.json` in project root with HTTP MCP server config
20
+ - Base64-encoded credentials: `echo -n "username:app-password" | base64`
21
+
22
+ ## MCP Configuration
23
+
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "elementor-staging": {
28
+ "type": "http",
29
+ "url": "https://your-staging-site.com/wp-json/mcp/elementor-mcp-server",
30
+ "headers": {
31
+ "Authorization": "Basic BASE64_CREDENTIALS"
32
+ }
33
+ },
34
+ "elementor-production": {
35
+ "type": "http",
36
+ "url": "https://your-production-site.com/wp-json/mcp/elementor-mcp-server",
37
+ "headers": {
38
+ "Authorization": "Basic BASE64_CREDENTIALS"
39
+ }
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ ### Troubleshooting Connection Issues
46
+ - If `/wp-json/` returns 404: go to Settings > Permalinks, set to "Post name", click Save
47
+ - If auth fails (401): verify application password is correct and user has admin role
48
+ - If MCP endpoint 404 but `/wp-json/` works: re-activate both MCP plugins
49
+ - After staging re-clone: re-install plugins, re-create app password, re-save permalinks
50
+
51
+ ## Elementor Architecture
52
+
53
+ ### Element Hierarchy
54
+ ```
55
+ Page
56
+ └── Container (root section)
57
+ └── Container (hero/section)
58
+ └── Container (row/column)
59
+ └── Widget (heading, text-editor, button, etc.)
60
+ ```
61
+
62
+ ### Key Concepts
63
+ - **Containers** are flex/grid layouts that hold other containers or widgets
64
+ - **Widgets** are the actual content elements (headings, text, images, buttons, forms)
65
+ - **Element IDs** are 7-char hex strings, unique within a page
66
+ - **Settings** control all visual properties (typography, colors, spacing, responsive)
67
+ - **Responsive suffixes**: `_tablet` and `_mobile` variants (e.g., `typography_font_size_tablet`)
68
+
69
+ ### Common Widget Types
70
+ - `heading` — H1-H6 titles
71
+ - `text-editor` — Rich text content
72
+ - `button` — CTA buttons with links
73
+ - `image` / `image-box` — Images with optional text
74
+ - `form` — Contact/lead forms (Pro)
75
+ - `google_maps` — Embedded maps
76
+ - `html` — Custom HTML (used for JSON-LD schema injection)
77
+ - `reviews` — Testimonials
78
+ - `nested-tabs` / `nested-accordion` — Tabbed/accordion content
79
+
80
+ ## Core MCP Workflow
81
+
82
+ ### 1. Discovery
83
+
84
+ ```
85
+ list-pages → Get all Elementor pages with IDs
86
+ get-page-structure → See element tree (containers + widgets)
87
+ get-element-settings → Inspect any element's full settings
88
+ find-element → Search by text content, widget type, or setting
89
+ list-widgets → See all available widget types
90
+ get-widget-schema → Get available settings for a widget type
91
+ ```
92
+
93
+ ### 2. Content Updates
94
+
95
+ ```
96
+ update-widget → Change text, links, colors, typography (partial merge)
97
+ update-container → Change layout, spacing, alignment, background
98
+ add-heading → Add a heading widget
99
+ add-text-editor → Add a text block
100
+ add-button → Add a CTA button
101
+ add-html → Add custom HTML (JSON-LD schema, tracking scripts)
102
+ add-container → Add a layout container
103
+ ```
104
+
105
+ ### 3. Structure Changes
106
+
107
+ ```
108
+ move-element → Reorder or re-parent elements
109
+ remove-element → Delete an element
110
+ duplicate-element → Clone an element
111
+ reorder-elements → Change sibling order
112
+ ```
113
+
114
+ ### 4. Styling
115
+
116
+ ```
117
+ add-custom-css → Page-level or element-level CSS (use media queries for responsive)
118
+ update-global-colors → Site-wide color palette
119
+ update-global-typography → Site-wide font presets
120
+ ```
121
+
122
+ ### 5. Page Management
123
+
124
+ ```
125
+ create-page → New WordPress page with Elementor
126
+ build-page → Create complete page from declarative JSON
127
+ export-page → Get full page data
128
+ delete-page-content → Clear page content
129
+ ```
130
+
131
+ ## Best Practices
132
+
133
+ ### Staging-First Workflow
134
+ 1. Make all changes on staging via MCP
135
+ 2. Verify visually across viewports (use Chrome browser tools)
136
+ 3. Get human approval
137
+ 4. Replay changes on production via MCP
138
+ 5. Re-clone staging from production for next iteration
139
+
140
+ ### Responsive Design
141
+ - Always check 3 breakpoints: desktop (1440+), tablet (768-1024), mobile (375-390)
142
+ - Use `_tablet` and `_mobile` setting suffixes for responsive overrides
143
+ - For large desktop fixes (1800px+), use `add-custom-css` with media queries
144
+ - Elementor's breakpoints: desktop (default), tablet (1024px), mobile (767px)
145
+
146
+ ### Settings Merge Behavior
147
+ - `update-widget` and `update-container` do **partial merges** — only specified settings change
148
+ - Nested objects (like `margin`, `padding`, `typography_font_size`) must include all subkeys
149
+ - Example margin: `{"unit": "px", "top": "0", "right": "0", "bottom": "0", "left": "20", "isLinked": false}`
150
+
151
+ ### CSS Custom Overrides
152
+ - Use `add-custom-css` with `replace: true` to avoid CSS accumulation
153
+ - Use `selector` keyword for element-level CSS: `selector .heading { color: red; }`
154
+ - Wrap responsive fixes in `@media` queries to avoid affecting other breakpoints
155
+ - Always verify CSS doesn't break other viewports after applying
156
+
157
+ ### JSON-LD Schema Injection
158
+ - Use `add-html` widget with `<script type="application/ld+json">` content
159
+ - Place in root container (append position -1) — script tags produce no visible output
160
+ - Use distinct `@id` values that don't conflict with Yoast's auto-generated schema
161
+ - Yoast generates: WebPage, BreadcrumbList, WebSite, Organization
162
+ - Custom schema should use: Service, RoofingContractor, DefinedTerm, LocalBusiness, etc.
163
+
164
+ ### Common Gotchas
165
+ - **Elementor CSS cache**: changes may not appear until CSS is regenerated. Use Elementor > Tools > Regenerate Files & Data, or the cache flush endpoint
166
+ - **Shared templates**: headers/footers are Elementor Library templates (different post type). Find their post ID via browser inspection (`data-elementor-id` attribute)
167
+ - **Widget IDs shared across pages**: pages cloned from templates share element IDs. Changes to one page don't affect others
168
+ - **Yoast SEO meta**: not writable via REST API. Must be set manually in wp-admin
169
+ - **WP Staging re-clone**: wipes plugins, app passwords, and permalink settings. Must reconfigure after each clone
170
+ - **Background images**: `background-position` and `background-size: cover` interact differently across viewport sizes. Use browser tools to measure actual rendered positions
171
+
172
+ ## Quick Reference: Common Operations
173
+
174
+ ### Change heading text
175
+ ```
176
+ update-widget(post_id, element_id, {"title": "New Heading"})
177
+ ```
178
+
179
+ ### Change text block content
180
+ ```
181
+ update-widget(post_id, element_id, {"editor": "<p>New content</p>"})
182
+ ```
183
+
184
+ ### Change font size (responsive)
185
+ ```
186
+ update-widget(post_id, element_id, {
187
+ "typography_font_size": {"unit": "px", "size": 80, "sizes": []},
188
+ "typography_font_size_tablet": {"unit": "px", "size": 65, "sizes": []},
189
+ "typography_font_size_mobile": {"unit": "px", "size": 40, "sizes": []}
190
+ })
191
+ ```
192
+
193
+ ### Change container margin
194
+ ```
195
+ update-container(post_id, element_id, {
196
+ "margin": {"unit": "px", "top": "0", "right": "0", "bottom": "0", "left": "250", "isLinked": false}
197
+ })
198
+ ```
199
+
200
+ ### Add JSON-LD schema to a page
201
+ ```
202
+ add-html(post_id, parent_id, '<script type="application/ld+json">{"@context":"https://schema.org",...}</script>')
203
+ ```
204
+
205
+ ### Add responsive CSS fix
206
+ ```
207
+ add-custom-css(post_id, '@media (min-width: 1800px) { .elementor-element-XXXXX { margin-top: -200px !important; } }', replace=true)
208
+ ```
209
+
210
+ ### Find text on a page
211
+ ```
212
+ find-element(post_id, search_text="lorem ipsum")
213
+ ```
214
+
215
+ ### Update Google Maps widget
216
+ ```
217
+ update-widget(post_id, element_id, {"address": "Southeast Michigan, USA", "zoom": {"unit": "px", "size": 8, "sizes": []}})
218
+ ```
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: canonry
3
- description: "AEO (Answer Engine Optimization) monitoring and analysis using canonry CLI and aeo-audit tool. Use when: (1) running citation sweeps across AI providers (Gemini, ChatGPT, Claude, Perplexity); (2) auditing technical SEO with structured data validation; (3) implementing schema markup, sitemaps, llms.txt; (4) diagnosing indexing issues via Google Search Console and Bing Webmaster Tools; (5) optimizing content for AI readability and entity consistency. NOT for: general web development, content writing, PPC campaigns, or social media management."
3
+ description: "Agent-first AEO monitoring and operating platform."
4
4
  metadata:
5
5
  {
6
- "openclaw":
6
+ "agent":
7
7
  {
8
8
  "emoji": "📡",
9
9
  "requires": { "bins": ["canonry"] },
@@ -32,38 +32,25 @@ metadata:
32
32
 
33
33
  # Canonry
34
34
 
35
- Monitor and optimize site visibility across AI answer engines (Gemini, ChatGPT, Claude, Perplexity) and traditional search engines using the `canonry` CLI for AEO monitoring and `aeo-audit` for technical SEO analysis.
35
+ Open-source AEO (Answer Engine Optimization) monitoring platform. Track how AI answer engines cite your domain across Gemini, ChatGPT, Claude, and Perplexity.
36
36
 
37
- ## When to Use
37
+ **Website:** [ainyc.ai](https://ainyc.ai) | **Docs:** [github.com/AINYC/canonry](https://github.com/AINYC/canonry)
38
38
 
39
- **USE this skill when:**
39
+ ## When to Use
40
40
 
41
- - Tracking which keyphrases earn citations (or lose them) across AI providers
42
- - Running technical SEO audits with 14‑factor scoring
43
- - Implementing structured data (JSON‑LD: LocalBusiness, FAQPage, Service)
44
- - Diagnosing indexing gaps in Google Search Console / Bing Webmaster Tools
45
- - Optimizing `llms.txt`, `llms‑full.txt`, sitemaps, robots.txt for AI crawlers
46
- - Patching missing H1 tags, meta descriptions, image alt text
41
+ - Tracking keyphrase citations across AI providers
42
+ - Running technical SEO audits (14‑factor scoring)
43
+ - Implementing structured data (JSON‑LD)
44
+ - Diagnosing indexing gaps via Google Search Console / Bing Webmaster Tools
45
+ - Optimizing `llms.txt`, sitemaps, robots.txt for AI crawlers
47
46
  - Submitting URLs to Google Indexing API and Bing IndexNow
48
- - Analyzing competitor citation patterns in AI answers
49
-
50
- ## When NOT to Use
51
-
52
- ❌ **DON'T use this skill when:**
53
-
54
- - General WordPress development (use `wordpress` skill if available)
55
- - Content writing or copy creation (human‑led task)
56
- - Paid search/SEM campaigns (different specialty)
57
- - Social media management or outreach
58
- - Local business listing management (e.g., GBP, Yelp)
59
- - Backlink building or outreach campaigns
47
+ - Analyzing competitor citation patterns
60
48
 
61
49
  ## Core Philosophy
62
50
 
63
- - **AI models are black boxes** Measure citation outcomes, not assume causality
64
- - **Position, then wait** — Site changes take weeks/months to reflect in AI indexes; canonry tells us *when* it happens, not *if*
65
- - **Signalover‑noise** — Trim keyphrase lists to high‑intent queries; avoid granular targeting until base visibility exists
66
- - **CLI‑native, UI‑optional** — Prefer API‑driven changes over manual CMS clicks; faster, repeatable, auditable
51
+ - **Measure outcomes** — AI models are black boxes; track citations, don't assume causality
52
+ - **Signal over noise** — Focus on high‑intent queries; avoid granular targeting until base visibility exists
53
+ - **CLInative** — API‑driven changes over manual CMS clicks; faster, repeatable, auditable
67
54
 
68
55
  ## Toolchain
69
56
 
@@ -221,54 +208,7 @@ cat audit.json | jq -r '.factors[] | select(.score < 70) | "- \(.name): \(.score
221
208
  - **Client data stays private** — canonry repo is public; no real domains in issues
222
209
  - **Respect API rate limits** — batch operations, avoid tight loops
223
210
 
224
- ## Output Templates
225
-
226
- ### Audit Summary
227
- ```
228
- ## AEO/SEO Audit — https://client.com
229
-
230
- **Overall:** 66/100 (D)
231
-
232
- **Top strengths (A/A+):**
233
- - AI‑Readable Content (100) — llms.txt, llms‑full.txt present
234
- - FAQ Content (100) — FAQPage schema detected
235
- - AI Crawler Access (100) — robots.txt allows all bots
236
-
237
- **Critical gaps (F):**
238
- - Definition Blocks (0) — no "What is…" sections
239
- - E‑E‑A‑T Signals (45) — missing Person schema, author tags
240
- - Citations & Authority (44) — no external references to industry sources
241
-
242
- **Immediate actions:**
243
- 1. Add H1 tag to homepage (Technical SEO: 60/100)
244
- 2. Create "What is polyurea?" section on /services/ (Definition Blocks: 0/100)
245
- 3. Submit all 5 URLs to Bing IndexNow (indexing: 2/5)
246
- ```
247
-
248
- ### Citation Report
249
- ```
250
- ## canonry sweep — client-project
251
-
252
- **Run:** 2026‑04‑03T13:44Z (ID: 4a45ebfc...)
253
-
254
- **Keyphrase visibility (12 tracked):**
255
- ✅ polyurea roof coating — 3/3 providers
256
- ✅ commercial roof coating — 2/3 providers
257
- ❌ polyurea roof coating Michigan — 0/3 (geo gap)
258
- ❌ commercial roofing contractor Michigan — 0/3 (geo gap)
259
-
260
- **Changes since last sweep (2026‑03‑27):**
261
- - Lost `flat roof coating Michigan` on Gemini (−1)
262
- - Gained `industrial roof coating` on Claude (+1)
263
- - No change on ChatGPT (stable)
264
-
265
- **Next steps:**
266
- - Build Michigan location page (/michigan/)
267
- - Add county‑level references to llms.txt
268
- - Re‑sweep in 7 days
269
- ```
270
-
271
211
  ---
272
212
 
273
213
  **Tools:** canonry v1.37+, @ainyc/aeo‑audit v1.3+
274
- **Reference:** [AINYC AEO Methodology](https://ainyc.ai/aeo-methodology)
214
+ **Website:** [ainyc.ai](https://ainyc.ai) | **Reference:** [AINYC AEO Methodology](https://ainyc.ai/aeo-methodology)
@@ -296,10 +296,10 @@ canonry export <project> --include-results > project.yaml
296
296
  canonry sitemap inspect <project>
297
297
  ```
298
298
 
299
- ## Agent (OpenClaw Integration)
299
+ ## Agent
300
300
 
301
301
  `canonry agent setup` is the single entry point for configuring the agent. It handles everything:
302
- canonry initialization, OpenClaw installation, profile setup, LLM credential configuration,
302
+ canonry initialization, agent runtime installation, profile setup, LLM credential configuration,
303
303
  and workspace seeding. If canonry is not yet configured, it runs the interactive init flow first
304
304
  (prompting for monitoring provider keys and agent LLM credentials).
305
305
 
@@ -313,7 +313,7 @@ canonry agent setup --agent-provider openrouter --agent-key <key> --agent-model
313
313
  GEMINI_API_KEY=<key> canonry agent setup --agent-key <key> --format json
314
314
 
315
315
  # Lifecycle
316
- canonry agent start # start OpenClaw gateway as background process
316
+ canonry agent start # start agent gateway as background process
317
317
  canonry agent stop # stop the gateway process
318
318
  canonry agent status # check if gateway is running
319
319
  canonry agent status --format json # JSON output
@@ -336,9 +336,9 @@ canonry agent detach <project> # remove agent webhook from pro
336
336
  canonry agent detach <project> --format json # JSON output
337
337
  ```
338
338
 
339
- **Setup flow:** init canonry (if needed) → install OpenClaw (if needed) → configure profile → configure gateway → set agent LLM credentials → seed workspace with skills.
339
+ **Setup flow:** init canonry (if needed) → install agent runtime (if needed) → configure profile → configure gateway → set agent LLM credentials → seed workspace with skills.
340
340
 
341
- **Agent LLM credentials** are stored in `~/.openclaw-aero/.env` (e.g. `ANTHROPIC_API_KEY=...`) and loaded into the gateway process at start time. The model is set via `openclaw models set`.
341
+ **Agent LLM credentials** are stored in the agent env file (e.g. `ANTHROPIC_API_KEY=...`) and loaded into the gateway process at start time. The model is set via the agent CLI.
342
342
 
343
343
  **Re-running is safe:** setup is idempotent — it skips steps that are already configured.
344
344
 
@@ -55,3 +55,7 @@ canonry wordpress staging push mysite
55
55
  - If SEO meta is not writable through REST, canonry returns an actionable error instead of guessing
56
56
  - Duplicate slug matches are returned as explicit ambiguity errors with candidate page IDs/titles
57
57
  - Authentication is verified on connect by calling `/wp/v2/users/me` — if that fails, canonry returns an actionable error message
58
+
59
+ ## Related: Elementor MCP
60
+
61
+ For programmatic management of Elementor page layouts, widgets, and styling via MCP tools, see the aero skill reference: [`skills/aero/references/wordpress-elementor-mcp.md`](../../aero/references/wordpress-elementor-mcp.md).
@@ -6,6 +6,8 @@ import {
6
6
  bingUrlInspections,
7
7
  competitors,
8
8
  createLogger,
9
+ dropLegacyCredentialColumns,
10
+ extractLegacyCredentials,
9
11
  gaAiReferrals,
10
12
  gaSocialReferrals,
11
13
  gaTrafficSnapshots,
@@ -23,7 +25,7 @@ import {
23
25
  runs,
24
26
  schedules,
25
27
  usageCounters
26
- } from "./chunk-HO22LHTY.js";
28
+ } from "./chunk-ZZ57GRV6.js";
27
29
 
28
30
  // src/config.ts
29
31
  import fs from "fs";
@@ -338,7 +340,7 @@ import crypto23 from "crypto";
338
340
  import fs7 from "fs";
339
341
  import path8 from "path";
340
342
  import { fileURLToPath as fileURLToPath2 } from "url";
341
- import { eq as eq24, sql as sql6 } from "drizzle-orm";
343
+ import { eq as eq24 } from "drizzle-orm";
342
344
  import Fastify from "fastify";
343
345
 
344
346
  // ../contracts/src/config-schema.ts
@@ -6071,9 +6073,11 @@ var GSC_MAX_PAGES = 40;
6071
6073
 
6072
6074
  // ../integration-google/src/types.ts
6073
6075
  var GoogleAuthError = class extends Error {
6074
- constructor(message) {
6076
+ statusCode;
6077
+ constructor(message, statusCode) {
6075
6078
  super(message);
6076
6079
  this.name = "GoogleAuthError";
6080
+ this.statusCode = statusCode;
6077
6081
  }
6078
6082
  };
6079
6083
  var GoogleApiError = class extends Error {
@@ -6164,13 +6168,19 @@ async function exchangeCode(clientId, clientSecret, code, redirectUri) {
6164
6168
  }),
6165
6169
  signal: AbortSignal.timeout(GOOGLE_REQUEST_TIMEOUT_MS)
6166
6170
  });
6171
+ if (res.status === 429) {
6172
+ throw new GoogleAuthError("Google OAuth rate limit exceeded", 429);
6173
+ }
6167
6174
  if (!res.ok) {
6168
6175
  const body = await res.text();
6169
6176
  let detail = "";
6170
6177
  try {
6171
6178
  const parsed = JSON.parse(body);
6172
6179
  if (parsed.error) detail = parsed.error;
6173
- if (parsed.error_description) detail += detail ? `: ${parsed.error_description}` : parsed.error_description;
6180
+ if (parsed.error_description) {
6181
+ const sanitized = parsed.error_description.replace(new RegExp(escapeRegExp2(clientId), "g"), "***").replace(new RegExp(escapeRegExp2(clientSecret), "g"), "***").replace(new RegExp(escapeRegExp2(code), "g"), "***");
6182
+ detail += detail ? `: ${sanitized}` : sanitized;
6183
+ }
6174
6184
  } catch {
6175
6185
  detail = body.length <= 120 ? body : `${body.slice(0, 120)}...`;
6176
6186
  }
@@ -6178,6 +6188,9 @@ async function exchangeCode(clientId, clientSecret, code, redirectUri) {
6178
6188
  }
6179
6189
  return await res.json();
6180
6190
  }
6191
+ function escapeRegExp2(str) {
6192
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6193
+ }
6181
6194
  async function refreshAccessToken(clientId, clientSecret, currentRefreshToken) {
6182
6195
  validateClientId(clientId);
6183
6196
  validateClientSecret(clientSecret);
@@ -6193,13 +6206,19 @@ async function refreshAccessToken(clientId, clientSecret, currentRefreshToken) {
6193
6206
  }),
6194
6207
  signal: AbortSignal.timeout(GOOGLE_REQUEST_TIMEOUT_MS)
6195
6208
  });
6209
+ if (res.status === 429) {
6210
+ throw new GoogleAuthError("Google OAuth rate limit exceeded", 429);
6211
+ }
6196
6212
  if (!res.ok) {
6197
6213
  const body = await res.text();
6198
6214
  let detail = "";
6199
6215
  try {
6200
6216
  const parsed = JSON.parse(body);
6201
6217
  if (parsed.error) detail = parsed.error;
6202
- if (parsed.error_description) detail += detail ? `: ${parsed.error_description}` : parsed.error_description;
6218
+ if (parsed.error_description) {
6219
+ const sanitized = parsed.error_description.replace(new RegExp(escapeRegExp2(clientId), "g"), "***").replace(new RegExp(escapeRegExp2(clientSecret), "g"), "***").replace(new RegExp(escapeRegExp2(currentRefreshToken), "g"), "***");
6220
+ detail += detail ? `: ${sanitized}` : sanitized;
6221
+ }
6203
6222
  } catch {
6204
6223
  detail = body.length <= 120 ? body : `${body.slice(0, 120)}...`;
6205
6224
  }
@@ -6271,6 +6290,20 @@ function gscClientLog(level, action, ctx) {
6271
6290
  ...ctx
6272
6291
  };
6273
6292
  if (entry.accessToken) entry.accessToken = "***";
6293
+ if (typeof entry.siteUrl === "string") {
6294
+ try {
6295
+ const url = new URL(entry.siteUrl);
6296
+ if (url.search) {
6297
+ url.searchParams.forEach((_, key) => {
6298
+ if (key.toLowerCase().includes("token") || key.toLowerCase().includes("key") || key.toLowerCase().includes("auth")) {
6299
+ url.searchParams.set(key, "***");
6300
+ }
6301
+ });
6302
+ entry.siteUrl = url.toString();
6303
+ }
6304
+ } catch {
6305
+ }
6306
+ }
6274
6307
  const stream = level === "error" ? process.stderr : process.stdout;
6275
6308
  stream.write(JSON.stringify(entry) + "\n");
6276
6309
  }
@@ -6497,11 +6530,15 @@ async function getAccessToken(clientEmail, privateKey) {
6497
6530
  const body = await res.text().catch(() => "");
6498
6531
  ga4Log("error", "token.failed", { httpStatus: res.status });
6499
6532
  const detail = body.length <= 200 ? body : `${body.slice(0, 200)}... [truncated]`;
6500
- throw new GA4ApiError(`Failed to get access token: ${detail}`, res.status);
6533
+ const sanitizedDetail = detail.replace(new RegExp(escapeRegExp3(clientEmail), "g"), "***").replace(new RegExp(escapeRegExp3(privateKey.slice(0, 32)), "g"), "***");
6534
+ throw new GA4ApiError(`Failed to get access token: ${sanitizedDetail}`, res.status);
6501
6535
  }
6502
6536
  const data = await res.json();
6503
6537
  return data.access_token;
6504
6538
  }
6539
+ function escapeRegExp3(str) {
6540
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6541
+ }
6505
6542
  async function runReport(accessToken, propertyId, request) {
6506
6543
  const url = `${GA4_DATA_API_BASE}/properties/${propertyId}:runReport`;
6507
6544
  const res = await fetch(url, {
@@ -6619,6 +6656,7 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
6619
6656
  offset
6620
6657
  };
6621
6658
  const response = await runReport(accessToken, propertyId, request);
6659
+ if (!response) break;
6622
6660
  const pageRows = (response.rows ?? []).map((row) => ({
6623
6661
  date: row.dimensionValues[0].value,
6624
6662
  landingPage: row.dimensionValues[1].value,
@@ -6651,6 +6689,7 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
6651
6689
  offset: organicOffset
6652
6690
  };
6653
6691
  const organicResponse = await runReport(accessToken, propertyId, organicRequest);
6692
+ if (!organicResponse) break;
6654
6693
  for (const row of organicResponse.rows ?? []) {
6655
6694
  const key = `${row.dimensionValues[0].value}::${row.dimensionValues[1].value}`;
6656
6695
  organicMap.set(key, parseInt(row.metricValues[0].value, 10) || 0);
@@ -6778,6 +6817,7 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
6778
6817
  offset
6779
6818
  };
6780
6819
  const response = await runReport(accessToken, propertyId, request);
6820
+ if (!response) break;
6781
6821
  const pageRows = (response.rows ?? []).map((row) => ({
6782
6822
  date: row.dimensionValues[0].value,
6783
6823
  source: row.dimensionValues[1].value,
@@ -6854,6 +6894,7 @@ async function fetchSocialReferrals(accessToken, propertyId, days) {
6854
6894
  offset
6855
6895
  };
6856
6896
  const response = await runReport(accessToken, propertyId, request);
6897
+ if (!response) break;
6857
6898
  const pageRows = (response.rows ?? []).map((row) => ({
6858
6899
  date: row.dimensionValues[0].value,
6859
6900
  source: row.dimensionValues[1].value,
@@ -7663,6 +7704,9 @@ function bingClientLog(level, action, ctx) {
7663
7704
  const stream = level === "error" ? process.stderr : process.stdout;
7664
7705
  stream.write(JSON.stringify(entry) + "\n");
7665
7706
  }
7707
+ function escapeRegExp4(str) {
7708
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7709
+ }
7666
7710
  async function bingFetch(apiKey, endpoint, opts) {
7667
7711
  const method = opts?.method ?? "GET";
7668
7712
  const separator = endpoint.includes("?") ? "&" : "?";
@@ -7687,7 +7731,8 @@ async function bingFetch(apiKey, endpoint, opts) {
7687
7731
  if (!res.ok) {
7688
7732
  const body = await res.text();
7689
7733
  bingClientLog("error", "http.error", { endpoint, method, httpStatus: res.status });
7690
- const detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
7734
+ let detail = body.length <= 500 ? body : `${body.slice(0, 500)}... [truncated]`;
7735
+ detail = detail.replace(new RegExp(escapeRegExp4(apiKey), "g"), "***");
7691
7736
  throw new BingApiError(`Bing API error (${res.status}): ${detail}`, res.status);
7692
7737
  }
7693
7738
  const text = await res.text();
@@ -8183,7 +8228,7 @@ async function bingRoutes(app, opts) {
8183
8228
  query: s.Query,
8184
8229
  impressions: s.Impressions,
8185
8230
  clicks: s.Clicks,
8186
- ctr: Number.isFinite(s.Ctr) ? s.Ctr : 0,
8231
+ ctr: s.Impressions > 0 ? s.Clicks / s.Impressions : 0,
8187
8232
  averagePosition: s.AverageClickPosition ?? s.AverageImpressionPosition ?? 0
8188
8233
  }));
8189
8234
  });
@@ -9221,6 +9266,8 @@ function buildAuthErrorMessage(res, responseText) {
9221
9266
  return "WordPress credentials are invalid or lack permission for this action";
9222
9267
  }
9223
9268
  async function fetchJson(connection, siteUrl, path9, init) {
9269
+ if (siteUrl.startsWith("http:")) {
9270
+ }
9224
9271
  const res = await fetch(`${normalizeSiteUrl(siteUrl)}${path9}`, {
9225
9272
  ...init,
9226
9273
  headers: {
@@ -9235,6 +9282,9 @@ async function fetchJson(connection, siteUrl, path9, init) {
9235
9282
  const errorMessage = buildAuthErrorMessage(res, text);
9236
9283
  throw new WordpressApiError("AUTH_INVALID", errorMessage, res.status);
9237
9284
  }
9285
+ if (res.status === 429) {
9286
+ throw new WordpressApiError("UPSTREAM_ERROR", "WordPress API rate limit exceeded", 429);
9287
+ }
9238
9288
  if (res.status === 404) {
9239
9289
  throw new WordpressApiError("NOT_FOUND", "WordPress endpoint not found", 404);
9240
9290
  }
@@ -9733,12 +9783,12 @@ var CANONRY_SCHEMA_START = "<!-- canonry:schema:start -->";
9733
9783
  var CANONRY_SCHEMA_END = "<!-- canonry:schema:end -->";
9734
9784
  function stripCanonrySchema(content) {
9735
9785
  const regex = new RegExp(
9736
- `${escapeRegExp2(CANONRY_SCHEMA_START)}[\\s\\S]*?${escapeRegExp2(CANONRY_SCHEMA_END)}`,
9786
+ `${escapeRegExp5(CANONRY_SCHEMA_START)}[\\s\\S]*?${escapeRegExp5(CANONRY_SCHEMA_END)}`,
9737
9787
  "g"
9738
9788
  );
9739
9789
  return content.replace(regex, "").replace(/\n{3,}/g, "\n\n").trim();
9740
9790
  }
9741
- function escapeRegExp2(str) {
9791
+ function escapeRegExp5(str) {
9742
9792
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9743
9793
  }
9744
9794
  function injectCanonrySchema(content, schemas) {
@@ -9845,7 +9895,7 @@ async function getSchemaStatus(connection, env) {
9845
9895
  const thirdPartySchemas = [];
9846
9896
  if (hasCanonryMarker) {
9847
9897
  const markerRegex = new RegExp(
9848
- `${escapeRegExp2(CANONRY_SCHEMA_START)}([\\s\\S]*?)${escapeRegExp2(CANONRY_SCHEMA_END)}`
9898
+ `${escapeRegExp5(CANONRY_SCHEMA_START)}([\\s\\S]*?)${escapeRegExp5(CANONRY_SCHEMA_END)}`
9849
9899
  );
9850
9900
  const match = markerRegex.exec(rawContent);
9851
9901
  if (match?.[1]) {
@@ -10855,6 +10905,12 @@ function isRetryableError(err) {
10855
10905
  return status >= 500 || status === 429;
10856
10906
  }
10857
10907
  }
10908
+ if (err instanceof Error) {
10909
+ const msg = err.message.toLowerCase();
10910
+ if (msg.includes("fetch failed") || msg.includes("econnreset") || msg.includes("etimedout") || msg.includes("enotfound") || msg.includes("econnrefused") || msg.includes("network error")) {
10911
+ return true;
10912
+ }
10913
+ }
10858
10914
  return true;
10859
10915
  }
10860
10916
  async function withRetry(fn, options = {}) {
@@ -11074,7 +11130,10 @@ function extractCitedDomainsFromSources(groundingSources) {
11074
11130
  }
11075
11131
  if (source.title) {
11076
11132
  const titleDomain = extractDomainFromTitle(source.title);
11077
- if (titleDomain) domains.add(titleDomain);
11133
+ if (titleDomain) {
11134
+ domains.add(titleDomain);
11135
+ continue;
11136
+ }
11078
11137
  }
11079
11138
  }
11080
11139
  return [...domains];
@@ -11089,7 +11148,10 @@ function extractDomainFromTitle(title) {
11089
11148
  function extractDomainFromUri(uri) {
11090
11149
  try {
11091
11150
  const url = new URL(uri);
11092
- const hostname = url.hostname.replace(/^www\./, "");
11151
+ const hostname = url.hostname.replace(/^www\./, "").toLowerCase();
11152
+ if (hostname.includes("chatgpt.com") || hostname.includes("openai.com")) {
11153
+ return null;
11154
+ }
11093
11155
  if (hostname === "vertexaisearch.cloud.google.com") {
11094
11156
  const redirectPath = url.pathname.replace(/^\/grounding-api-redirect\//, "");
11095
11157
  if (redirectPath && redirectPath !== url.pathname) {
@@ -11239,6 +11301,12 @@ function isRetryableError2(err) {
11239
11301
  return status >= 500 || status === 429;
11240
11302
  }
11241
11303
  }
11304
+ if (err instanceof Error) {
11305
+ const msg = err.message.toLowerCase();
11306
+ if (msg.includes("fetch failed") || msg.includes("econnreset") || msg.includes("etimedout") || msg.includes("enotfound") || msg.includes("econnrefused") || msg.includes("network error")) {
11307
+ return true;
11308
+ }
11309
+ }
11242
11310
  return true;
11243
11311
  }
11244
11312
  async function withRetry2(fn, options = {}) {
@@ -11465,7 +11533,11 @@ function extractCitedDomainsFromSources2(groundingSources) {
11465
11533
  function extractDomainFromUri2(uri) {
11466
11534
  try {
11467
11535
  const url = new URL(uri);
11468
- return url.hostname.replace(/^www\./, "").toLowerCase();
11536
+ const hostname = url.hostname.replace(/^www\./, "").toLowerCase();
11537
+ if (hostname.includes("chatgpt.com") || hostname.includes("openai.com")) {
11538
+ return null;
11539
+ }
11540
+ return hostname;
11469
11541
  } catch {
11470
11542
  return null;
11471
11543
  }
@@ -11583,6 +11655,12 @@ function isRetryableError3(err) {
11583
11655
  return status >= 500 || status === 429;
11584
11656
  }
11585
11657
  }
11658
+ if (err instanceof Error) {
11659
+ const msg = err.message.toLowerCase();
11660
+ if (msg.includes("fetch failed") || msg.includes("econnreset") || msg.includes("etimedout") || msg.includes("enotfound") || msg.includes("econnrefused") || msg.includes("network error")) {
11661
+ return true;
11662
+ }
11663
+ }
11586
11664
  return true;
11587
11665
  }
11588
11666
  async function withRetry3(fn, options = {}) {
@@ -11834,7 +11912,11 @@ function extractCitedDomainsFromSources3(groundingSources) {
11834
11912
  function extractDomainFromUri3(uri) {
11835
11913
  try {
11836
11914
  const url = new URL(uri);
11837
- return url.hostname.replace(/^www\./, "").toLowerCase();
11915
+ const hostname = url.hostname.replace(/^www\./, "").toLowerCase();
11916
+ if (hostname.includes("chatgpt.com") || hostname.includes("openai.com")) {
11917
+ return null;
11918
+ }
11919
+ return hostname;
11838
11920
  } catch {
11839
11921
  return null;
11840
11922
  }
@@ -11950,6 +12032,12 @@ function isRetryableError4(err) {
11950
12032
  return status >= 500 || status === 429;
11951
12033
  }
11952
12034
  }
12035
+ if (err instanceof Error) {
12036
+ const msg = err.message.toLowerCase();
12037
+ if (msg.includes("fetch failed") || msg.includes("econnreset") || msg.includes("etimedout") || msg.includes("enotfound") || msg.includes("econnrefused") || msg.includes("network error")) {
12038
+ return true;
12039
+ }
12040
+ }
11953
12041
  return true;
11954
12042
  }
11955
12043
  async function withRetry4(fn, options = {}) {
@@ -12054,11 +12142,15 @@ async function executeTrackedQuery4(input) {
12054
12142
  function normalizeResult4(raw) {
12055
12143
  const answerText = extractAnswerText2(raw.rawResponse);
12056
12144
  const citedDomains = extractDomainMentions(answerText);
12145
+ const groundingSources = citedDomains.map((domain) => ({
12146
+ uri: `http://${domain}`,
12147
+ title: domain
12148
+ }));
12057
12149
  return {
12058
12150
  provider: "local",
12059
12151
  answerText,
12060
12152
  citedDomains,
12061
- groundingSources: raw.groundingSources,
12153
+ groundingSources,
12062
12154
  searchQueries: raw.searchQueries
12063
12155
  };
12064
12156
  }
@@ -12764,6 +12856,12 @@ function isRetryableError5(err) {
12764
12856
  return status >= 500 || status === 429;
12765
12857
  }
12766
12858
  }
12859
+ if (err instanceof Error) {
12860
+ const msg = err.message.toLowerCase();
12861
+ if (msg.includes("fetch failed") || msg.includes("econnreset") || msg.includes("etimedout") || msg.includes("enotfound") || msg.includes("econnrefused") || msg.includes("network error")) {
12862
+ return true;
12863
+ }
12864
+ }
12767
12865
  return true;
12768
12866
  }
12769
12867
  async function withRetry5(fn, options = {}) {
@@ -12981,7 +13079,11 @@ function extractCitedDomains2(groundingSources) {
12981
13079
  function extractDomainFromUri4(uri) {
12982
13080
  try {
12983
13081
  const url = new URL(uri);
12984
- return url.hostname.replace(/^www\./, "").toLowerCase();
13082
+ const hostname = url.hostname.replace(/^www\./, "").toLowerCase();
13083
+ if (hostname.includes("chatgpt.com") || hostname.includes("openai.com")) {
13084
+ return null;
13085
+ }
13086
+ return hostname;
12985
13087
  } catch {
12986
13088
  return null;
12987
13089
  }
@@ -13607,7 +13709,7 @@ var JobRunner = class {
13607
13709
  normalized.answerText,
13608
13710
  allDomains,
13609
13711
  normalized.citedDomains,
13610
- overlap
13712
+ competitorDomains
13611
13713
  );
13612
13714
  let screenshotRelPath = null;
13613
13715
  if (raw.screenshotPath && fs4.existsSync(raw.screenshotPath)) {
@@ -15481,7 +15583,8 @@ function isProcessAlive(pid) {
15481
15583
  try {
15482
15584
  process.kill(pid, 0);
15483
15585
  return true;
15484
- } catch {
15586
+ } catch (err) {
15587
+ if (err.code === "EPERM") return true;
15485
15588
  return false;
15486
15589
  }
15487
15590
  }
@@ -15509,6 +15612,9 @@ import os5 from "os";
15509
15612
  import path7 from "path";
15510
15613
  import { fileURLToPath } from "url";
15511
15614
  var CACHE_TTL_MS = 5 * 60 * 1e3;
15615
+ var OPENCLAW_VERSION = "2026.4.14";
15616
+ var OPENCLAW_PACKAGE_SPEC = `openclaw@${OPENCLAW_VERSION}`;
15617
+ var MIN_NODE_VERSION = "22.14.0";
15512
15618
  var cachedResult = null;
15513
15619
  var cachedAt = 0;
15514
15620
  function getAeroStateDir(profile = "aero") {
@@ -15572,8 +15678,15 @@ function findInPath() {
15572
15678
  }
15573
15679
  }
15574
15680
  async function installOpenClaw(opts) {
15681
+ const unsupportedNodeError = getUnsupportedNodeError(opts?.nodeVersion);
15682
+ if (unsupportedNodeError) {
15683
+ return {
15684
+ success: false,
15685
+ error: unsupportedNodeError
15686
+ };
15687
+ }
15575
15688
  try {
15576
- execSync("npm install -g openclaw", {
15689
+ execSync(`npm install -g ${OPENCLAW_PACKAGE_SPEC}`, {
15577
15690
  timeout: 12e4,
15578
15691
  stdio: opts?.silent ? "pipe" : "inherit"
15579
15692
  });
@@ -15588,11 +15701,57 @@ async function installOpenClaw(opts) {
15588
15701
  if (!detection.found) {
15589
15702
  return {
15590
15703
  success: false,
15591
- error: "npm install succeeded but openclaw binary was not found in PATH"
15704
+ error: `npm install succeeded but the ${OPENCLAW_PACKAGE_SPEC} binary was not found in PATH`
15592
15705
  };
15593
15706
  }
15707
+ if (detection.version) {
15708
+ const expectedVersion = parseVersionTuple(OPENCLAW_VERSION);
15709
+ const detectedVersion = parseVersionTuple(detection.version);
15710
+ if (expectedVersion && detectedVersion && compareVersionTuples(detectedVersion, expectedVersion) !== 0) {
15711
+ return {
15712
+ success: false,
15713
+ error: `Installed OpenClaw binary reports version ${detection.version}, but Canonry pinned ${OPENCLAW_VERSION}. A different openclaw binary may be shadowing the npm-installed package in PATH.`
15714
+ };
15715
+ }
15716
+ }
15594
15717
  return { success: true, detection };
15595
15718
  }
15719
+ function getUnsupportedNodeError(currentNodeVersionOverride) {
15720
+ const currentNodeVersion = normalizeVersion(currentNodeVersionOverride ?? process.versions.node);
15721
+ const minimumTuple = parseVersionTuple(MIN_NODE_VERSION);
15722
+ const currentTuple = parseVersionTuple(currentNodeVersion);
15723
+ if (!minimumTuple || !currentTuple || compareVersionTuples(currentTuple, minimumTuple) >= 0) {
15724
+ return null;
15725
+ }
15726
+ return `Canonry requires Node.js >=${MIN_NODE_VERSION} and installs pinned OpenClaw ${OPENCLAW_VERSION}, but the current runtime is ${currentNodeVersion}. Upgrade Node.js before running "canonry agent setup".`;
15727
+ }
15728
+ function normalizeVersion(version) {
15729
+ const tuple = parseVersionTuple(version);
15730
+ if (!tuple) {
15731
+ return version.trim().replace(/^v/i, "");
15732
+ }
15733
+ return tuple.join(".");
15734
+ }
15735
+ function parseVersionTuple(version) {
15736
+ const match = version.trim().replace(/^v/i, "").match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
15737
+ if (!match) {
15738
+ return null;
15739
+ }
15740
+ return [
15741
+ Number(match[1]),
15742
+ Number(match[2] ?? 0),
15743
+ Number(match[3] ?? 0)
15744
+ ];
15745
+ }
15746
+ function compareVersionTuples(left, right) {
15747
+ for (let index = 0; index < left.length; index++) {
15748
+ const delta = left[index] - right[index];
15749
+ if (delta !== 0) {
15750
+ return delta;
15751
+ }
15752
+ }
15753
+ return 0;
15754
+ }
15596
15755
  function seedWorkspace(stateDir) {
15597
15756
  const workspaceDir = path7.join(stateDir, "workspace");
15598
15757
  fs6.mkdirSync(workspaceDir, { recursive: true });
@@ -15802,68 +15961,46 @@ function serializeSessionCookie(opts) {
15802
15961
  }
15803
15962
  return parts.join("; ");
15804
15963
  }
15805
- function migrateDbCredentialsToConfig(db, config) {
15806
- try {
15807
- const googleColCheck = db.all(sql6.raw(
15808
- `SELECT COUNT(*) as c FROM pragma_table_info('google_connections') WHERE name = 'access_token'`
15809
- ));
15810
- if (googleColCheck[0]?.c) {
15811
- const rows = db.all(sql6.raw(
15812
- `SELECT domain, connection_type, property_id, sitemap_url, access_token, refresh_token, token_expires_at, scopes, created_at, updated_at FROM google_connections WHERE refresh_token IS NOT NULL AND refresh_token != ''`
15813
- ));
15814
- let migrated = 0;
15815
- for (const row of rows) {
15816
- const connType = row.connection_type;
15817
- const existing = getGoogleConnection(config, row.domain, connType);
15818
- if (existing?.refreshToken) continue;
15819
- upsertGoogleConnection(config, {
15820
- domain: row.domain,
15821
- connectionType: connType,
15822
- propertyId: row.property_id ?? null,
15823
- sitemapUrl: row.sitemap_url ?? null,
15824
- accessToken: row.access_token ?? void 0,
15825
- refreshToken: row.refresh_token ?? null,
15826
- tokenExpiresAt: row.token_expires_at ?? null,
15827
- scopes: parseJsonColumn(row.scopes, []),
15828
- createdAt: row.created_at,
15829
- updatedAt: row.updated_at
15830
- });
15831
- migrated++;
15832
- }
15833
- if (migrated > 0) {
15834
- saveConfigPatch({ google: config.google });
15835
- log9.info("credentials.migrated", { type: "google", count: migrated });
15836
- }
15837
- }
15838
- const gaColCheck = db.all(sql6.raw(
15839
- `SELECT COUNT(*) as c FROM pragma_table_info('ga_connections') WHERE name = 'private_key'`
15840
- ));
15841
- if (gaColCheck[0]?.c) {
15842
- const rows = db.all(sql6.raw(
15843
- `SELECT id, project_id, property_id, client_email, private_key, created_at, updated_at FROM ga_connections WHERE private_key IS NOT NULL AND private_key != ''`
15844
- ));
15845
- let migrated = 0;
15846
- for (const row of rows) {
15847
- const project = db.select({ name: projects.name }).from(projects).where(eq24(projects.id, row.project_id)).get();
15848
- if (!project) continue;
15849
- const existing = getGa4Connection(config, project.name);
15850
- if (existing?.privateKey) continue;
15851
- upsertGa4Connection(config, {
15852
- projectName: project.name,
15853
- propertyId: row.property_id,
15854
- clientEmail: row.client_email,
15855
- privateKey: row.private_key,
15856
- createdAt: row.created_at,
15857
- updatedAt: row.updated_at
15858
- });
15859
- migrated++;
15860
- }
15861
- if (migrated > 0) {
15862
- saveConfigPatch({ ga4: config.ga4 });
15863
- log9.info("credentials.migrated", { type: "ga4", count: migrated });
15864
- }
15865
- }
15866
- } catch {
15964
+ function applyLegacyCredentials(rows, config) {
15965
+ let migratedGoogle = 0;
15966
+ for (const row of rows.google) {
15967
+ const existing = getGoogleConnection(config, row.domain, row.connectionType);
15968
+ if (existing?.refreshToken) continue;
15969
+ upsertGoogleConnection(config, {
15970
+ domain: row.domain,
15971
+ connectionType: row.connectionType,
15972
+ propertyId: row.propertyId,
15973
+ sitemapUrl: row.sitemapUrl,
15974
+ accessToken: row.accessToken ?? void 0,
15975
+ refreshToken: row.refreshToken,
15976
+ tokenExpiresAt: row.tokenExpiresAt,
15977
+ scopes: row.scopes,
15978
+ createdAt: row.createdAt,
15979
+ updatedAt: row.updatedAt
15980
+ });
15981
+ migratedGoogle++;
15982
+ }
15983
+ if (migratedGoogle > 0) {
15984
+ saveConfigPatch({ google: config.google });
15985
+ log9.info("credentials.migrated", { type: "google", count: migratedGoogle });
15986
+ }
15987
+ let migratedGa4 = 0;
15988
+ for (const row of rows.ga4) {
15989
+ const existing = getGa4Connection(config, row.projectName);
15990
+ if (existing?.privateKey) continue;
15991
+ upsertGa4Connection(config, {
15992
+ projectName: row.projectName,
15993
+ propertyId: row.propertyId,
15994
+ clientEmail: row.clientEmail,
15995
+ privateKey: row.privateKey,
15996
+ createdAt: row.createdAt,
15997
+ updatedAt: row.updatedAt
15998
+ });
15999
+ migratedGa4++;
16000
+ }
16001
+ if (migratedGa4 > 0) {
16002
+ saveConfigPatch({ ga4: config.ga4 });
16003
+ log9.info("credentials.migrated", { type: "ga4", count: migratedGa4 });
15867
16004
  }
15868
16005
  }
15869
16006
  async function createServer(opts) {
@@ -15890,7 +16027,15 @@ async function createServer(opts) {
15890
16027
  quota: opts.config.geminiQuota
15891
16028
  };
15892
16029
  }
15893
- migrateDbCredentialsToConfig(opts.db, opts.config);
16030
+ try {
16031
+ const legacyRows = extractLegacyCredentials(opts.db);
16032
+ applyLegacyCredentials(legacyRows, opts.config);
16033
+ dropLegacyCredentialColumns(opts.db);
16034
+ } catch (err) {
16035
+ log9.warn("credentials.migration.failed", {
16036
+ error: err instanceof Error ? err.message : String(err)
16037
+ });
16038
+ }
15894
16039
  log9.info("providers.configured", { providers: Object.keys(providers).filter((k) => {
15895
16040
  const p = providers[k];
15896
16041
  return p?.apiKey || p?.baseUrl || p?.vertexProject;
@@ -16483,7 +16628,7 @@ async function createServer(opts) {
16483
16628
  await app.register(fastifyStatic.default, {
16484
16629
  root: assetsDir,
16485
16630
  prefix: basePath ?? "/",
16486
- wildcard: false,
16631
+ wildcard: true,
16487
16632
  // Don't serve index.html automatically — we handle it with config injection
16488
16633
  serve: true,
16489
16634
  index: false
@@ -115,7 +115,8 @@ var querySnapshots = sqliteTable("query_snapshots", {
115
115
  index("idx_snapshots_keyword").on(table.keywordId),
116
116
  index("idx_snapshots_citation_state").on(table.citationState),
117
117
  index("idx_snapshots_provider_model").on(table.provider, table.model),
118
- index("idx_snapshots_location").on(table.location)
118
+ index("idx_snapshots_location").on(table.location),
119
+ index("idx_snapshots_created_at").on(table.createdAt)
119
120
  ]);
120
121
  var auditLog = sqliteTable("audit_log", {
121
122
  id: text("id").primaryKey(),
@@ -848,7 +849,17 @@ var MIGRATIONS = [
848
849
  `CREATE INDEX IF NOT EXISTS idx_bing_coverage_snap_run ON bing_coverage_snapshots(sync_run_id)`,
849
850
  // v34: Rename unique index for bing_coverage_snapshots to follow convention
850
851
  `DROP INDEX IF EXISTS idx_bing_coverage_snap_project_date`,
851
- `CREATE UNIQUE INDEX IF NOT EXISTS idx_bing_coverage_snap_project_date_unique ON bing_coverage_snapshots(project_id, date)`
852
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_bing_coverage_snap_project_date_unique ON bing_coverage_snapshots(project_id, date)`,
853
+ // v35: Add missing index for query_snapshots createdAt for time-series filtering
854
+ `CREATE INDEX IF NOT EXISTS idx_snapshots_created_at ON query_snapshots(created_at)`
855
+ // v36: Transaction handling and SQL injection review: verified all strings use SQLite ? binding via Drizzle.
856
+ // No changes required for parameterization.
857
+ // v37: The legacy credential columns (private_key on ga_connections; access_token,
858
+ // refresh_token, token_expires_at on google_connections) are removed by the
859
+ // extractLegacyCredentials / dropLegacyCredentialColumns pair below. Callers
860
+ // read the rows, persist them to config.yaml, and only then drop the columns
861
+ // so a failed config write doesn't permanently lose credentials. Keeping the
862
+ // DROPs as raw SQL here would race with that read.
852
863
  ];
853
864
  function isDuplicateColumnError(err) {
854
865
  if (!(err instanceof Error)) return false;
@@ -856,6 +867,83 @@ function isDuplicateColumnError(err) {
856
867
  if (err.cause instanceof Error && err.cause.message.includes("duplicate column name")) return true;
857
868
  return false;
858
869
  }
870
+ function columnExists(db, table, column) {
871
+ const rows = db.all(sql.raw(
872
+ `SELECT COUNT(*) as c FROM pragma_table_info('${table}') WHERE name = '${column}'`
873
+ ));
874
+ return (rows[0]?.c ?? 0) > 0;
875
+ }
876
+ function dropColumnIfExists(db, table, column) {
877
+ try {
878
+ db.run(sql.raw(`ALTER TABLE ${table} DROP COLUMN ${column}`));
879
+ } catch (err) {
880
+ if (!(err instanceof Error)) throw err;
881
+ const msg = err.message;
882
+ const causeMsg = err.cause instanceof Error ? err.cause.message : "";
883
+ const expected = `no such column: "${column}"`;
884
+ const expectedAlt = `no such column: ${column}`;
885
+ if (msg.includes(expected) || msg.includes(expectedAlt)) return;
886
+ if (causeMsg.includes(expected) || causeMsg.includes(expectedAlt)) return;
887
+ throw err;
888
+ }
889
+ }
890
+ function extractLegacyCredentials(db) {
891
+ const out = { google: [], ga4: [] };
892
+ if (columnExists(db, "google_connections", "access_token")) {
893
+ const rows = db.all(sql.raw(
894
+ `SELECT domain, connection_type, property_id, sitemap_url, access_token, refresh_token, token_expires_at, scopes, created_at, updated_at
895
+ FROM google_connections
896
+ WHERE refresh_token IS NOT NULL AND refresh_token != ''`
897
+ ));
898
+ for (const row of rows) {
899
+ out.google.push({
900
+ domain: row.domain,
901
+ connectionType: row.connection_type,
902
+ propertyId: row.property_id,
903
+ sitemapUrl: row.sitemap_url,
904
+ accessToken: row.access_token,
905
+ refreshToken: row.refresh_token,
906
+ tokenExpiresAt: row.token_expires_at,
907
+ scopes: parseJsonColumn(row.scopes, []),
908
+ createdAt: row.created_at,
909
+ updatedAt: row.updated_at
910
+ });
911
+ }
912
+ }
913
+ if (columnExists(db, "ga_connections", "private_key")) {
914
+ const rows = db.all(sql.raw(
915
+ `SELECT p.name AS project_name, ga.property_id, ga.client_email, ga.private_key, ga.created_at, ga.updated_at
916
+ FROM ga_connections ga
917
+ INNER JOIN projects p ON p.id = ga.project_id
918
+ WHERE ga.private_key IS NOT NULL AND ga.private_key != ''`
919
+ ));
920
+ for (const row of rows) {
921
+ out.ga4.push({
922
+ projectName: row.project_name,
923
+ propertyId: row.property_id,
924
+ clientEmail: row.client_email,
925
+ privateKey: row.private_key,
926
+ createdAt: row.created_at,
927
+ updatedAt: row.updated_at
928
+ });
929
+ }
930
+ }
931
+ return out;
932
+ }
933
+ function dropLegacyCredentialColumns(db) {
934
+ if (columnExists(db, "google_connections", "access_token")) {
935
+ dropColumnIfExists(db, "google_connections", "access_token");
936
+ }
937
+ if (columnExists(db, "google_connections", "refresh_token")) {
938
+ dropColumnIfExists(db, "google_connections", "refresh_token");
939
+ }
940
+ if (columnExists(db, "google_connections", "token_expires_at")) {
941
+ dropColumnIfExists(db, "google_connections", "token_expires_at");
942
+ }
943
+ if (columnExists(db, "ga_connections", "private_key")) {
944
+ dropColumnIfExists(db, "ga_connections", "private_key");
945
+ }
946
+ }
859
947
  function migrate(db) {
860
948
  const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
861
949
  for (const statement of statements) {
@@ -1303,6 +1391,8 @@ export {
1303
1391
  healthSnapshots,
1304
1392
  createClient,
1305
1393
  parseJsonColumn,
1394
+ extractLegacyCredentials,
1395
+ dropLegacyCredentialColumns,
1306
1396
  migrate,
1307
1397
  createLogger,
1308
1398
  IntelligenceService
package/dist/cli.js CHANGED
@@ -47,7 +47,7 @@ import {
47
47
  trackEvent,
48
48
  usageError,
49
49
  writeAgentEnv
50
- } from "./chunk-25QLMK4F.js";
50
+ } from "./chunk-IPOVH342.js";
51
51
  import {
52
52
  apiKeys,
53
53
  competitors,
@@ -57,7 +57,7 @@ import {
57
57
  projects,
58
58
  querySnapshots,
59
59
  runs
60
- } from "./chunk-HO22LHTY.js";
60
+ } from "./chunk-ZZ57GRV6.js";
61
61
 
62
62
  // src/cli.ts
63
63
  import { pathToFileURL } from "url";
@@ -304,7 +304,7 @@ async function backfillAnswerVisibilityCommand(opts) {
304
304
  console.log(` Errors: ${providerErrors}`);
305
305
  }
306
306
  async function backfillInsightsCommand(project, opts) {
307
- const { IntelligenceService } = await import("./intelligence-service-ZISLIU4S.js");
307
+ const { IntelligenceService } = await import("./intelligence-service-MZ7SXEGE.js");
308
308
  const config = loadConfig();
309
309
  const db = createClient(config.database);
310
310
  migrate(db);
@@ -7920,7 +7920,7 @@ async function agentDetach(opts) {
7920
7920
  }
7921
7921
  async function autoInstallOrFail(format) {
7922
7922
  if (format !== "json") {
7923
- console.log("OpenClaw not found, installing via npm...");
7923
+ console.log("OpenClaw not found, installing pinned openclaw@2026.4.14 via npm...");
7924
7924
  }
7925
7925
  const install = await installOpenClaw({ silent: format === "json" });
7926
7926
  if (!install.success) {
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  createServer,
3
3
  loadConfig
4
- } from "./chunk-25QLMK4F.js";
5
- import "./chunk-HO22LHTY.js";
4
+ } from "./chunk-IPOVH342.js";
5
+ import "./chunk-ZZ57GRV6.js";
6
6
  export {
7
7
  createServer,
8
8
  loadConfig
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  IntelligenceService
3
- } from "./chunk-HO22LHTY.js";
3
+ } from "./chunk-ZZ57GRV6.js";
4
4
  export {
5
5
  IntelligenceService
6
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ainyc/canonry",
3
- "version": "1.48.0",
3
+ "version": "1.48.4",
4
4
  "type": "module",
5
5
  "description": "The ultimate open-source AEO monitoring tool - track how answer engines cite your domain",
6
6
  "license": "FSL-1.1-ALv2",
@@ -31,7 +31,7 @@
31
31
  "README.md"
32
32
  ],
33
33
  "engines": {
34
- "node": ">=20"
34
+ "node": ">=22.14.0"
35
35
  },
36
36
  "dependencies": {
37
37
  "@ainyc/aeo-audit": "1.3.2",
@@ -54,20 +54,20 @@
54
54
  "@types/node-cron": "^3.0.11",
55
55
  "tsup": "^8.5.1",
56
56
  "tsx": "^4.19.0",
57
- "@ainyc/canonry-config": "0.0.0",
58
- "@ainyc/canonry-contracts": "0.0.0",
59
57
  "@ainyc/canonry-api-routes": "0.0.0",
58
+ "@ainyc/canonry-contracts": "0.0.0",
59
+ "@ainyc/canonry-config": "0.0.0",
60
60
  "@ainyc/canonry-db": "0.0.0",
61
61
  "@ainyc/canonry-integration-bing": "0.0.0",
62
- "@ainyc/canonry-integration-google": "0.0.0",
63
- "@ainyc/canonry-intelligence": "0.0.0",
64
- "@ainyc/canonry-provider-cdp": "0.0.0",
65
62
  "@ainyc/canonry-integration-wordpress": "0.0.0",
63
+ "@ainyc/canonry-provider-cdp": "0.0.0",
64
+ "@ainyc/canonry-intelligence": "0.0.0",
66
65
  "@ainyc/canonry-provider-claude": "0.0.0",
67
- "@ainyc/canonry-provider-gemini": "0.0.0",
66
+ "@ainyc/canonry-provider-local": "0.0.0",
68
67
  "@ainyc/canonry-provider-openai": "0.0.0",
68
+ "@ainyc/canonry-integration-google": "0.0.0",
69
69
  "@ainyc/canonry-provider-perplexity": "0.0.0",
70
- "@ainyc/canonry-provider-local": "0.0.0"
70
+ "@ainyc/canonry-provider-gemini": "0.0.0"
71
71
  },
72
72
  "scripts": {
73
73
  "build": "tsx scripts/copy-agent-assets.ts && tsup && tsx build-web.ts",