n-seo 0.3.3 → 0.4.1
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/CHANGELOG.md +413 -0
- package/README.md +25 -3
- package/bin/n-seo.mjs +130 -5
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/INSTANCE.md +5 -4
- package/docs/PRD.md +26 -8
- package/ingest/__pycache__/analyze_metadata.cpython-312.pyc +0 -0
- package/ingest/__pycache__/google_auth.cpython-312.pyc +0 -0
- package/ingest/__pycache__/http_util.cpython-312.pyc +0 -0
- package/ingest/__pycache__/seo_config.cpython-312.pyc +0 -0
- package/ingest/seo_config.py +7 -1
- package/n-seo.config.example.json +3 -0
- package/ops/__pycache__/daily.cpython-312.pyc +0 -0
- package/ops/__pycache__/daily_diff.cpython-312.pyc +0 -0
- package/ops/__pycache__/demo_data.cpython-312.pyc +0 -0
- package/ops/__pycache__/export_static.cpython-312.pyc +0 -0
- package/ops/__pycache__/llm.cpython-312.pyc +0 -0
- package/ops/__pycache__/publish.cpython-312.pyc +0 -0
- package/ops/__pycache__/update_check.cpython-312.pyc +0 -0
- package/ops/daily.py +3 -0
- package/ops/doctor.py +18 -0
- package/ops/update_check.py +113 -0
- package/package.json +2 -1
- package/probes/__pycache__/site_probe.cpython-312.pyc +0 -0
- package/public/styles.css +5 -0
- package/src/config.ts +3 -0
- package/src/data.ts +27 -1
- package/src/settings.tsx +16 -2
- package/src/views.tsx +22 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
|
|
5
|
+
uses [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
Nothing yet.
|
|
10
|
+
|
|
11
|
+
## [0.4.1] - 2026-09-13
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **Every page says which engine built it.** The footer now carries
|
|
15
|
+
`n-seo <version>`, and when a newer one is published it carries the upgrade
|
|
16
|
+
command and a link to the release notes beside it. This matters most on a
|
|
17
|
+
published mirror: `/settings` is deliberately not exported, because it shows
|
|
18
|
+
local paths and the service-account email, so before this a mirror named no
|
|
19
|
+
version anywhere. The notice is baked in at export time from the file the
|
|
20
|
+
daily run writes — polling the registry from the browser would put every
|
|
21
|
+
viewer of a shared mirror onto npmjs.com on every page view, to learn
|
|
22
|
+
something that changes once a day.
|
|
23
|
+
|
|
24
|
+
## [0.4.0] - 2026-09-12
|
|
25
|
+
|
|
26
|
+
Upgrading used to be something you found out about by accident. This release
|
|
27
|
+
makes it visible and makes it one command.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- **`n-seo upgrade` does the upgrade.** It works out how the engine was
|
|
31
|
+
installed — a git checkout, a global npm install, a global install under a
|
|
32
|
+
custom prefix, or a local one — and runs the right thing for that layout.
|
|
33
|
+
It prints the versions either side, the new release's changelog section,
|
|
34
|
+
the exact rollback command, and a reminder that the dashboard needs a
|
|
35
|
+
restart. `--check` reports what is available and changes nothing.
|
|
36
|
+
- **A daily update check, and a notice where you already look.** The new
|
|
37
|
+
`updateCheck` module asks the registry once per run whether a newer engine
|
|
38
|
+
shipped, and the Settings page and `n-seo doctor` show it. The dashboard
|
|
39
|
+
never makes that request itself — it reads `data/update-check.json` — so
|
|
40
|
+
every page still renders with the machine offline, and an unreachable
|
|
41
|
+
registry leaves the previous answer in place rather than failing the run.
|
|
42
|
+
- **This is the only module that ships enabled**, so the claim that nothing
|
|
43
|
+
leaves the host was no longer strictly true. The README, the site and the
|
|
44
|
+
PRD now say exactly what it sends (nothing about you) and how to switch it
|
|
45
|
+
off. `CHANGELOG.md` is published with the package so the upgrade can show
|
|
46
|
+
you what you just got.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
- **`n-seo upgrade` gave advice that silently did nothing.** For any npm
|
|
50
|
+
engine it printed `npm update n-seo`. That is correct for exactly one
|
|
51
|
+
layout — a consumer `package.json` with n-seo as a dependency — and wrong
|
|
52
|
+
for the global install the README recommends: run there it reports "up to
|
|
53
|
+
date", exits 0, upgrades nothing, and leaves a stray `package-lock.json` in
|
|
54
|
+
whatever directory you were standing in.
|
|
55
|
+
- The Settings notice compares against the engine running now rather than the
|
|
56
|
+
version recorded in the file, so upgrading clears it immediately instead of
|
|
57
|
+
at the next daily run.
|
|
58
|
+
|
|
59
|
+
## [0.3.3] - 2026-09-11
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
- **Every published mirror page claimed the daily run had not happened.**
|
|
63
|
+
`data/last-run.json` was written once, after all steps finished. But
|
|
64
|
+
`static-export` is itself a step, so when it stamped each page it read the
|
|
65
|
+
*previous* run's record — a mirror rebuilt at 07:44 carried a "daily run
|
|
66
|
+
OK" badge from the night before, which reads exactly like a run that never
|
|
67
|
+
fired. The record is now written after every step, so a later step sees the
|
|
68
|
+
run it is part of. It also means the dashboard's status advances during a
|
|
69
|
+
long run instead of sitting stale for its full duration.
|
|
70
|
+
|
|
71
|
+
## [0.3.2] - 2026-09-10
|
|
72
|
+
|
|
73
|
+
### Changed
|
|
74
|
+
- **The dashboard's top bar uses the display face too.** 0.3.1 put Montserrat
|
|
75
|
+
on headings only, which left the wordmark and the nav in the system UI font
|
|
76
|
+
and made the top bar look untouched next to a restyled page. Both now use
|
|
77
|
+
it, matching how the marketing site treats its own chrome. The face is a
|
|
78
|
+
`--display` token rather than three copies of a fallback stack. The
|
|
79
|
+
Sites and System dropdowns keep the mono face: they list hostnames, which
|
|
80
|
+
are easier to scan monospaced. Tables, chips and numbers are unchanged —
|
|
81
|
+
a dense data view is designed around the system font.
|
|
82
|
+
|
|
83
|
+
## [0.3.1] - 2026-09-10
|
|
84
|
+
|
|
85
|
+
### Fixed
|
|
86
|
+
- **`doctor` told a healthy npm install to run `npm install`.** The check
|
|
87
|
+
looked for a `node_modules` directory inside the engine. npm hoists
|
|
88
|
+
dependencies to the *consumer's* `node_modules`, so an `npm i n-seo` engine
|
|
89
|
+
correctly has none of its own, and every install reported a warning whose
|
|
90
|
+
suggested fix did nothing. It now asks node to resolve `tsx`, `hono` and
|
|
91
|
+
`@hono/node-server` from the engine — the same resolution the CLI does
|
|
92
|
+
before it spawns the dashboard — so it passes on both layouts and still
|
|
93
|
+
fails on an engine that genuinely cannot start. Found by pointing a real
|
|
94
|
+
instance at the published package instead of a git checkout.
|
|
95
|
+
|
|
96
|
+
## [0.3.0] - 2026-09-10
|
|
97
|
+
|
|
98
|
+
### Added
|
|
99
|
+
- **`n-seo init` refuses to scaffold inside a website.** An instance is a
|
|
100
|
+
standalone project: it writes a config, a queue, drafts and a `data/` tree
|
|
101
|
+
that the daily run rewrites every morning, and inside a site repo all of
|
|
102
|
+
that gets committed and usually deployed. `init` now stops when the target
|
|
103
|
+
directory (or the parent of a directory it is about to create) holds an
|
|
104
|
+
application marker such as `package.json`, or sits inside a git repository
|
|
105
|
+
that is not itself an instance. The error names the reason and shows the
|
|
106
|
+
right command. `--force` overrides; re-running `init` on an existing
|
|
107
|
+
instance is always allowed, and in-place mode in the engine checkout is
|
|
108
|
+
unaffected. The README, the marketing site, `docs/INSTANCE.md` and the FAQ
|
|
109
|
+
say the same thing in words.
|
|
110
|
+
- **The skills ship, and `init` installs them.** `.claude/skills/` was not in
|
|
111
|
+
the npm `files` list, so an `npm i n-seo` install had none of the operating
|
|
112
|
+
skills at all. The seven instance-facing ones are now packaged, and `init`
|
|
113
|
+
copies them into the new instance with this install's real engine and
|
|
114
|
+
instance paths substituted for the placeholders, alongside a `CLAUDE.md`
|
|
115
|
+
carrying the operating rules. Opening an instance in Claude Code is now
|
|
116
|
+
enough to run setup, triage, shipping and the weekly review by asking. The
|
|
117
|
+
contributor skills (`ndx-*`) stay in the engine checkout.
|
|
118
|
+
- **Command output points at the skills where it is useful.** `n-seo init`
|
|
119
|
+
ends with the prompts to try, `n-seo demo` names the one that connects real
|
|
120
|
+
sites, `n-seo start` prints the triage prompt, and `n-seo doctor` suggests
|
|
121
|
+
`/n-seo-setup` only when it found problems to fix. Each is gated on the
|
|
122
|
+
skills actually being present.
|
|
123
|
+
|
|
124
|
+
### Changed
|
|
125
|
+
- **The site shows the setup instead of describing it.** Someone opened their
|
|
126
|
+
own website's repo and ran `n-seo init` inside it, which is a fair reading
|
|
127
|
+
of a quickstart that never says where the command should be run. There is
|
|
128
|
+
now a "Where it goes" section on n-seo.dev with the wrong filesystem layout
|
|
129
|
+
beside the right one, a breakdown of what each command in the quickstart
|
|
130
|
+
actually does, and three cards naming what n-seo touches: your repos never,
|
|
131
|
+
Google's APIs read-only, its own directory for everything it writes. The
|
|
132
|
+
README and `docs/INSTANCE.md` carry the same trees as text.
|
|
133
|
+
- **Typography, self-hosted.** Montserrat and Merriweather replace DM Sans,
|
|
134
|
+
DM Mono and the Google Fonts CDN on the marketing site; the dashboard's
|
|
135
|
+
headings and brand mark move to Montserrat while its tables keep the system
|
|
136
|
+
UI font. Both families are shipped as latin-subset variable woff2 files
|
|
137
|
+
served from the same origin, so there is no third-party request, no
|
|
138
|
+
render-blocking stylesheet on another domain, and the dashboard renders
|
|
139
|
+
correctly on a host with no outbound internet. Licences are in
|
|
140
|
+
`www/fonts/OFL.txt` and `public/fonts/OFL.txt`. The static export copies the
|
|
141
|
+
font alongside `styles.css`.
|
|
142
|
+
- **The indexing page says what to do, per verdict.** Every coverage state now
|
|
143
|
+
carries a remediation line and a marker saying whether Request Indexing will
|
|
144
|
+
help. It helps for "URL is unknown to Google" and "Discovered - currently
|
|
145
|
+
not indexed", where Google has formed no judgement about the content. It
|
|
146
|
+
does not help for "Soft 404" or "Crawled - currently not indexed": Google
|
|
147
|
+
fetched the page, judged it, and would judge the same content the same way
|
|
148
|
+
again, so a re-request spends a slot of a roughly ten-a-day quota for
|
|
149
|
+
nothing. Redirects, canonicals, duplicates, robots blocks and noindex are
|
|
150
|
+
covered too. The FAQ answer on stale soft 404s was rewritten for the same
|
|
151
|
+
reason.
|
|
152
|
+
|
|
153
|
+
## [0.2.0] - 2026-09-09
|
|
154
|
+
|
|
155
|
+
### Changed
|
|
156
|
+
- **Says what it is.** n-seo is an agentic tool: your model turns findings
|
|
157
|
+
into proposals, verdicts and briefings, and your coding agent works the
|
|
158
|
+
queue over MCP. That was the seventh heading in the README and absent from
|
|
159
|
+
the npm description; it is now the second section on the site and near the
|
|
160
|
+
top of the README. "En Dash SEO" appears beside the wordmark, since `n-seo`
|
|
161
|
+
reads as noise to someone landing cold. The LLM module remains opt-in and
|
|
162
|
+
nothing posts, publishes or edits a site.
|
|
163
|
+
|
|
164
|
+
### Fixed
|
|
165
|
+
- The docs claimed 15 MCP tools where the server registers 16 (`engine_info`
|
|
166
|
+
was missing from the table), the README skills list omitted `n-seo-deploy`,
|
|
167
|
+
and `llms.txt` still carried the pre-npm clone quickstart.
|
|
168
|
+
- Two MCP error messages told the reader to run a Python script directly
|
|
169
|
+
rather than the CLI command that works on every platform.
|
|
170
|
+
|
|
171
|
+
### Added
|
|
172
|
+
- **Windows support.** CI runs the whole check job on `windows-latest` —
|
|
173
|
+
typecheck, both test suites, every dashboard route, the MCP stdio smoke and
|
|
174
|
+
a scaffolded instance — alongside Ubuntu on Node 20 and 22. Nothing is
|
|
175
|
+
gated off for Windows. `ops/templates/n-seo-daily-task.xml` is a Task
|
|
176
|
+
Scheduler definition for the daily run, with the catch-up behaviour launchd
|
|
177
|
+
gives on macOS.
|
|
178
|
+
- `ops/py.mjs` resolves the Python interpreter this machine actually has, and
|
|
179
|
+
the npm scripts and the CLI both go through it. `$PYTHON` still overrides.
|
|
180
|
+
- `tests/py/test_portability.py` enforces the two rules that make the above
|
|
181
|
+
hold: every text file call names `encoding="utf-8"`, and no shipped code
|
|
182
|
+
invokes `openssl`.
|
|
183
|
+
|
|
184
|
+
### Changed
|
|
185
|
+
- The service-account JWT is signed with node's crypto module instead of the
|
|
186
|
+
`openssl` binary. That removes an external dependency on every platform, and
|
|
187
|
+
removes the temporary private-key file the old path wrote to disk. The
|
|
188
|
+
Docker image no longer installs openssl.
|
|
189
|
+
- Dashboard copy names the CLI (`n-seo daily --only probe`) rather than
|
|
190
|
+
`python3 probes/site_probe.py`, which is not a command on Windows.
|
|
191
|
+
|
|
192
|
+
### Fixed
|
|
193
|
+
- Python file I/O and stdout used the platform's locale encoding, which is
|
|
194
|
+
cp1252 on a stock Windows install rather than UTF-8. Non-ASCII Search
|
|
195
|
+
Console queries would have raised `UnicodeEncodeError` and ended the daily
|
|
196
|
+
run there. All 85 call sites are explicit now, and `seo_config` forces UTF-8
|
|
197
|
+
stdio on import.
|
|
198
|
+
- A command configured with an unbalanced quote in Settings raised
|
|
199
|
+
`ValueError` out of `shlex` and took down the daily run on every platform.
|
|
200
|
+
It degrades to "no command", and `n-seo doctor` says the command could not
|
|
201
|
+
be parsed rather than reporting it as unconfigured.
|
|
202
|
+
- `shlex.split` treated backslashes in a configured LLM command as escapes,
|
|
203
|
+
turning `C:\tools\claude.exe` into `C:toolsclaude.exe`.
|
|
204
|
+
- The TypeScript test sandbox spawned `python3` to seed its dataset, so on
|
|
205
|
+
Windows every Python-seeded suite would have taken its skip branch and
|
|
206
|
+
reported a green run that tested none of them.
|
|
207
|
+
|
|
208
|
+
## [0.1.1] - 2026-09-09
|
|
209
|
+
|
|
210
|
+
### Changed
|
|
211
|
+
- Install instructions lead with `npm install -g n-seo` now that the package
|
|
212
|
+
is published; the clone path stays documented for people changing the
|
|
213
|
+
engine itself. README carries npm, CI and licence badges.
|
|
214
|
+
- The site moves to https://n-seo.dev, and `www/CNAME` ships inside the Pages
|
|
215
|
+
artifact so a redeploy cannot drop the custom domain.
|
|
216
|
+
|
|
217
|
+
### Fixed
|
|
218
|
+
- The release smoke script no longer parses `npm pack --json`, whose shape
|
|
219
|
+
differs between npm 10 and npm 12. The release job upgrades npm before
|
|
220
|
+
running it, so it saw a different shape than CI did and failed one step
|
|
221
|
+
before publishing.
|
|
222
|
+
|
|
223
|
+
### Security
|
|
224
|
+
- Releases are staged rather than published directly. CI submits with
|
|
225
|
+
`npm stage publish`; a maintainer approves with 2FA before anything is
|
|
226
|
+
live, so a compromised runner or a stray tag cannot put code on the
|
|
227
|
+
registry.
|
|
228
|
+
|
|
229
|
+
## [0.1.0] - 2026-09-09
|
|
230
|
+
|
|
231
|
+
First public release.
|
|
232
|
+
|
|
233
|
+
### Security
|
|
234
|
+
- Dashboard binds `127.0.0.1` by default (`SEO_HOST` overrides) and rejects
|
|
235
|
+
cross-origin `POST`s, since Settings can set the LLM command that the daily
|
|
236
|
+
run executes.
|
|
237
|
+
- The static export omits `/settings` (local paths and the service-account
|
|
238
|
+
email do not belong on a mirror).
|
|
239
|
+
|
|
240
|
+
### Added
|
|
241
|
+
- **Traffic sources over time.** `pull_timeseries.py` also pulls GA4
|
|
242
|
+
`date × source/medium` for 180 days into
|
|
243
|
+
`data/timeseries/ga4-sources-<host>.json`, and `classifySource` buckets it
|
|
244
|
+
into AI assistants, Search, Direct, Referral, Social and Other. Trends and
|
|
245
|
+
every site page get a stacked "sessions by source" chart, plus a dedicated
|
|
246
|
+
**AI assistants / day** chart on its own axis — inside the stack AI is a
|
|
247
|
+
percent or two, so the trend worth watching is invisible there.
|
|
248
|
+
- **Site pages show trends**, with the same window picker `/trends` has
|
|
249
|
+
(`/site/<host>/<days>`).
|
|
250
|
+
|
|
251
|
+
- **`modules.publish`**: getting `site/` to wherever people read it is now a
|
|
252
|
+
pipeline step rather than a hook you write yourself, so it is logged,
|
|
253
|
+
retried once and recorded in `last-run.json`. Targets: `gcs`
|
|
254
|
+
(`gcloud storage rsync`), `s3` (`aws s3 sync`), `rsync`, and `command` for
|
|
255
|
+
anything else. `delete` makes the mirror match the export; `dryRun` prints
|
|
256
|
+
the exact command without running it, which is how you rehearse a cutover
|
|
257
|
+
against a bucket that is already serving something; `env` is merged into
|
|
258
|
+
that command's environment only, with key names logged and values never.
|
|
259
|
+
It refuses clearly when the destination is empty, the tool is missing from
|
|
260
|
+
`PATH`, or there is no `site/` to publish.
|
|
261
|
+
- **`modules.staticExport.signOutUrl`** (with `signOutLabel`) adds a sign-out
|
|
262
|
+
link to every exported page, for a mirror behind IAP, oauth2-proxy,
|
|
263
|
+
Cloudflare Access or anything else with a sign-out URL. The live dashboard
|
|
264
|
+
renders nothing for it.
|
|
265
|
+
- **Engine / instance split**: `N_SEO_INSTANCE` points the engine at a separate
|
|
266
|
+
directory holding your config, queue, content and data, so upgrading the
|
|
267
|
+
engine is a `git pull` (or `npm update`) that never touches your files.
|
|
268
|
+
In-place mode (no env var) is unchanged.
|
|
269
|
+
- **`n-seo` CLI** (`bin/n-seo.mjs`): `init` scaffolds an instance;
|
|
270
|
+
`start`/`dev`/`daily`/`doctor`/`demo`/`mcp`/`check`/`export` run the engine
|
|
271
|
+
against it; `upgrade` pulls, reinstalls and re-checks the engine with a
|
|
272
|
+
printed rollback; `version` shows engine + instance.
|
|
273
|
+
- **Hooks** (`hooks.beforeRun` / `hooks.afterStep.<step>` / `hooks.afterRun`):
|
|
274
|
+
your own shell commands around the daily run, logged and recorded like steps.
|
|
275
|
+
- **`gscExtraProperties`**: extra Search Console properties pulled for their
|
|
276
|
+
data without appearing as sites.
|
|
277
|
+
- `engine_info` MCP tool; Engine card on Settings; `doctor` engine block.
|
|
278
|
+
- **One config file** (`n-seo.config.json`) read by the dashboard and every
|
|
279
|
+
Python script: sites, Google auth, watch pages, conversions, module switches.
|
|
280
|
+
Falls back to the example so a fresh checkout runs.
|
|
281
|
+
- **Google auth without gcloud**: service-account JSON key, JWT signed locally
|
|
282
|
+
with the `openssl` CLI. gcloud impersonation and user modes remain available.
|
|
283
|
+
- **Ingest**: Search Console (16-month and 90-day windows), GA4 (daily, sources,
|
|
284
|
+
landing pages, conversion events), per-page time series, URL Inspection
|
|
285
|
+
index-coverage sweep, live-site probes (robots, sitemap, llms.txt, soft 404s,
|
|
286
|
+
AI-crawler blocks, JS-shell detection), metadata audit, 84-day trend analysis.
|
|
287
|
+
- **Action engine**: metadata findings, CTR gaps, striking-distance queries,
|
|
288
|
+
probe hygiene, landing-page engagement mismatch, traffic-drop investigation;
|
|
289
|
+
ranked by impact per unit of effort and merged with a curated queue in
|
|
290
|
+
`config/backlog.json` (hot-reloaded). Shipped pages show as *watching*.
|
|
291
|
+
- **Dashboard** (Hono SSR, no bundler): Today board, action queue with
|
|
292
|
+
accept / mark-watching / retire, Insights, Trends (band charts + heatmap),
|
|
293
|
+
Content (drafts, campaigns, participation briefings), per-site pages,
|
|
294
|
+
Indexing, Probes, Logs, and a **Settings** page that toggles modules and
|
|
295
|
+
writes the config. Light and dark themes.
|
|
296
|
+
- **Opt-in modules**: LLM inference through any stdin CLI, Hacker News and
|
|
297
|
+
Reddit digests (briefings only, never comment text), IndexNow, static export,
|
|
298
|
+
git auto-commit, desktop notifications.
|
|
299
|
+
- **MCP server** (stdio and bearer-token HTTP, read-only) exposing the queue,
|
|
300
|
+
metrics, audit, ops status, proposals, campaigns and settings to agents.
|
|
301
|
+
- **Operations**: `ops/daily.py` orchestrator with network wait and one retry,
|
|
302
|
+
`ops/doctor.py` setup checker, `ops/demo_data.py` synthetic dataset,
|
|
303
|
+
launchd / cron / systemd templates and a launchd installer.
|
|
304
|
+
- **Tests and CI**: `node --test` suites for config, backlog writes, action
|
|
305
|
+
engine invariants and data helpers; `unittest` suites for config parity,
|
|
306
|
+
HTTP retry classification, JWT signing, daily-diff and demo-data conformance;
|
|
307
|
+
GitHub Actions running all of it plus a dashboard smoke test and a grep that
|
|
308
|
+
rejects private names.
|
|
309
|
+
- Docs: README, ARCHITECTURE, SETUP-GOOGLE, SCHEDULING, OPERATING-RULES,
|
|
310
|
+
PLAYBOOK, MCP, ADDING-A-SITE, FAQ, CONTRIBUTING, SECURITY.
|
|
311
|
+
|
|
312
|
+
- **Deployment off a laptop**: `google.auth: "metadata"` uses the GCE /
|
|
313
|
+
Cloud Run / GKE runtime service account, so a hosted install needs no key
|
|
314
|
+
file at all. The metadata token is `cloud-platform` scoped and Search
|
|
315
|
+
Console rejects that, so the account mints a correctly scoped token for
|
|
316
|
+
itself through IAM Credentials; a missing Token Creator binding is
|
|
317
|
+
reported with the exact `gcloud` command that fixes it.
|
|
318
|
+
- **LLM over HTTP**: `modules.llm.http` calls an Anthropic or
|
|
319
|
+
OpenAI-compatible endpoint instead of a local CLI, with the key read from
|
|
320
|
+
the environment or `.env`. Without it the opportunity scan and the digests
|
|
321
|
+
are silently inert on any machine you did not sign a CLI into. `http` wins
|
|
322
|
+
when its key resolves, so one config file works on both a laptop and a
|
|
323
|
+
server.
|
|
324
|
+
|
|
325
|
+
- **Deployment**: a container image (`Dockerfile`, 328 MB, Node + stdlib
|
|
326
|
+
Python + curl + openssl) whose entrypoint takes `serve`, `daily`, `doctor`,
|
|
327
|
+
`demo`, `init` or `cron`, with the instance as a volume at `/instance`.
|
|
328
|
+
`docker/compose.yml` runs the dashboard and a scheduler on one volume and
|
|
329
|
+
binds the dashboard to loopback, so a bare `docker compose up` is not an
|
|
330
|
+
open dashboard; `docker/compose.caddy.yml` adds TLS and basic auth and
|
|
331
|
+
refuses to start without credentials configured.
|
|
332
|
+
- **GCP scaffolding**: `deploy/gcp/setup.sh` (idempotent, `--dry-run`)
|
|
333
|
+
provisions a service account with the Token Creator self-binding, a private
|
|
334
|
+
mirror bucket, a data disk, a VM with no external IP reachable only over
|
|
335
|
+
IAP, and the Cloud Run mirror; `deploy/vm/startup.sh` brings the same stack
|
|
336
|
+
up on any Debian or Ubuntu host, so a VPS or a NAS works the same way.
|
|
337
|
+
`docs/DEPLOY.md` is the narrative and says plainly why the engine does not
|
|
338
|
+
belong on Cloud Run: the static export finishes with a directory rename,
|
|
339
|
+
which GCS FUSE cannot do atomically.
|
|
340
|
+
- **`n-seo-deploy` skill** walks an agent through choosing a host, running the
|
|
341
|
+
scaffolding, verifying with `doctor` and a probe-only run, and handing back
|
|
342
|
+
the console steps that cannot be scripted.
|
|
343
|
+
|
|
344
|
+
### Changed
|
|
345
|
+
- **The site page stops wasting the screen.** Query tables and the narrow
|
|
346
|
+
source lists sit in a 2:1 split instead of every table spanning the full
|
|
347
|
+
width, and "Do next" caps at six cards with a link to the full queue.
|
|
348
|
+
- **Seven nav items instead of ten.** Indexing, Probes, Logs and Settings
|
|
349
|
+
fold into one **System** menu.
|
|
350
|
+
|
|
351
|
+
- The Trends page opens with a portfolio source mix and an AI-assistants
|
|
352
|
+
chart for every site combined, so "where is the traffic coming from" is
|
|
353
|
+
answered without expanding a tile.
|
|
354
|
+
|
|
355
|
+
- `npm i n-seo` produced an install that could not serve a page. The CLI
|
|
356
|
+
refused to start because it looked for `node_modules` inside the engine,
|
|
357
|
+
which npm hoists to the consumer instead; and `tsconfig.json` was not in the
|
|
358
|
+
published files, so tsx fell back to the React JSX transform and every
|
|
359
|
+
server-rendered route answered 500. Both found by installing the packed
|
|
360
|
+
tarball and running it, which is now the release check.
|
|
361
|
+
|
|
362
|
+
### Fixed
|
|
363
|
+
- **Data loss**: a Search Console, GA4 or time-series pull that hit an API
|
|
364
|
+
error overwrote the previous snapshot with zero rows and still reported the
|
|
365
|
+
step as successful. The pulls now keep the last good file and fail the step.
|
|
366
|
+
- **Wrong advice**: the metadata audit ignored the HTTP status, so a page that
|
|
367
|
+
had started 404ing was audited against its error page and produced a
|
|
368
|
+
top-ranked "rewrite this title" card. It now reports the dead page instead,
|
|
369
|
+
counting both 200 and 206 as serving because a ranged request returns 206
|
|
370
|
+
from any server that honours the Range header.
|
|
371
|
+
Descriptions containing an apostrophe were truncated at it and then flagged
|
|
372
|
+
as too short, and HTML entities were blanked rather than decoded.
|
|
373
|
+
- **Inverted GEO signal**: the robots.txt check matched across `User-agent`
|
|
374
|
+
group boundaries, so a crawler the file explicitly allowed could be reported
|
|
375
|
+
as blocked, with a hygiene action to match.
|
|
376
|
+
- **Queue ordering**: one unrecognised `effort` in `config/backlog.json` made
|
|
377
|
+
the sort comparator return NaN, leaving the order of every other card
|
|
378
|
+
undefined. Values are coerced on load and scoring can no longer produce NaN.
|
|
379
|
+
- Two long titles could share a truncated id, so retiring one deleted both.
|
|
380
|
+
- The traffic-drop card measured 28-day sessions but was ranked and rendered
|
|
381
|
+
as monthly clicks, so it outranked everything else by roughly fifty times.
|
|
382
|
+
- The static export emptied `site/` before fetching, so a failed export
|
|
383
|
+
published an empty mirror through the `afterRun` rsync hook.
|
|
384
|
+
- `--no-network-wait` was ignored on the retry path, so an offline run waited
|
|
385
|
+
the full timeout for every failing step.
|
|
386
|
+
- GA4 time series and trend queries were unpaginated and truncated silently;
|
|
387
|
+
the URL Inspection budget was per host although the quota is per property.
|
|
388
|
+
- The JWT is backdated 60s so a slightly fast clock cannot fail
|
|
389
|
+
authentication, and `GOOGLE_APPLICATION_CREDENTIALS` is honoured when the
|
|
390
|
+
configured key path does not exist.
|
|
391
|
+
- The opportunity scan dropped genuine risers whose query appeared as a
|
|
392
|
+
substring anywhere in the queue's prose.
|
|
393
|
+
- `tsx` moved from devDependencies to dependencies. The engine runs
|
|
394
|
+
TypeScript directly with no build step, so `npm ci --omit=dev` — and
|
|
395
|
+
therefore any published `npm i n-seo` — produced an install whose dashboard
|
|
396
|
+
could not start.
|
|
397
|
+
- The test sandbox now seeds its own demo dataset and neutralizes
|
|
398
|
+
`N_SEO_INSTANCE` while importing. Previously the action-engine suite was
|
|
399
|
+
dropped from the run whenever the checkout had no `data/` — which is the
|
|
400
|
+
normal state of an engine in instance mode, and therefore of every
|
|
401
|
+
`n-seo upgrade` gate — without changing the reported test counts, and an
|
|
402
|
+
in-place install ran those assertions against the owner's live data.
|
|
403
|
+
|
|
404
|
+
[Unreleased]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.1...HEAD
|
|
405
|
+
[0.4.1]: https://github.com/en-dash-consulting/n-seo/compare/v0.4.0...v0.4.1
|
|
406
|
+
[0.4.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.3.3...v0.4.0
|
|
407
|
+
[0.3.3]: https://github.com/en-dash-consulting/n-seo/compare/v0.3.2...v0.3.3
|
|
408
|
+
[0.3.2]: https://github.com/en-dash-consulting/n-seo/compare/v0.3.1...v0.3.2
|
|
409
|
+
[0.3.1]: https://github.com/en-dash-consulting/n-seo/compare/v0.3.0...v0.3.1
|
|
410
|
+
[0.3.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.2.0...v0.3.0
|
|
411
|
+
[0.2.0]: https://github.com/en-dash-consulting/n-seo/compare/v0.1.1...v0.2.0
|
|
412
|
+
[0.1.1]: https://github.com/en-dash-consulting/n-seo/compare/v0.1.0...v0.1.1
|
|
413
|
+
[0.1.0]: https://github.com/en-dash-consulting/n-seo/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -31,7 +31,9 @@ to **a model you choose**, and everything that changes a site is handed to you.
|
|
|
31
31
|
- **It proposes; it never acts.** No module edits a site, sends an email or
|
|
32
32
|
posts a comment. Machine proposals wait in a holding area until you accept
|
|
33
33
|
them. Your data stays on your hardware: nothing leaves the host except the
|
|
34
|
-
Google APIs you authorize
|
|
34
|
+
Google APIs you authorize, the model provider you configured yourself, and
|
|
35
|
+
one daily version check against the npm registry that carries nothing about
|
|
36
|
+
you and turns off with `"updateCheck": {"enabled": false}`.
|
|
35
37
|
|
|
36
38
|

|
|
37
39
|
|
|
@@ -164,6 +166,25 @@ merge. See [docs/INSTANCE.md](docs/INSTANCE.md).
|
|
|
164
166
|
Adding another site later is one entry in the config —
|
|
165
167
|
[docs/ADDING-A-SITE.md](docs/ADDING-A-SITE.md).
|
|
166
168
|
|
|
169
|
+
## Upgrading
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
n-seo upgrade --check # what is available, changes nothing
|
|
173
|
+
n-seo upgrade # do it
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
One command, whichever way you installed it: a git checkout, a global npm
|
|
177
|
+
install, or a local one. It works out which, runs the right thing, prints
|
|
178
|
+
what changed and the exact command to roll back, then reminds you to restart
|
|
179
|
+
the dashboard. **Your config, queue, content and data are never touched** —
|
|
180
|
+
that is the whole point of keeping the instance separate from the engine.
|
|
181
|
+
|
|
182
|
+
You do not have to remember to check. Each daily run asks the registry once
|
|
183
|
+
whether a newer version shipped and notes it on the Settings page and in
|
|
184
|
+
`n-seo doctor`. The dashboard never makes that request itself, so pages still
|
|
185
|
+
render with the machine offline. Turn the whole thing off with
|
|
186
|
+
`"updateCheck": {"enabled": false}` in `n-seo.config.json`.
|
|
187
|
+
|
|
167
188
|
Running several people's sites, or want upgrades to be a `git pull`? Keep your
|
|
168
189
|
config in its own directory — see [docs/INSTANCE.md](docs/INSTANCE.md).
|
|
169
190
|
|
|
@@ -278,8 +299,8 @@ the data says whether it worked.
|
|
|
278
299
|
|
|
279
300
|
## Modules
|
|
280
301
|
|
|
281
|
-
Everything below is off by default except the three data steps
|
|
282
|
-
on the Settings page or in `n-seo.config.json`.
|
|
302
|
+
Everything below is off by default except the three data steps and
|
|
303
|
+
`updateCheck`. Toggle them on the Settings page or in `n-seo.config.json`.
|
|
283
304
|
|
|
284
305
|
| Module | What it does | Needs | Default |
|
|
285
306
|
|---|---|---|---|
|
|
@@ -293,6 +314,7 @@ on the Settings page or in `n-seo.config.json`.
|
|
|
293
314
|
| `staticExport` | Snapshots the dashboard into `site/` as static HTML | nothing | off |
|
|
294
315
|
| `gitAutoCommit` | Commits (and pushes) the daily log and export after each run | a git remote, if you want the push | off |
|
|
295
316
|
| `notifications` | macOS notification when a daily step fails | macOS | off |
|
|
317
|
+
| `updateCheck` | Once per run, asks the registry whether a newer engine shipped, and notes it on Settings and in `doctor` | nothing | **on** |
|
|
296
318
|
|
|
297
319
|
## Working with n-dx
|
|
298
320
|
|
package/bin/n-seo.mjs
CHANGED
|
@@ -39,7 +39,8 @@ usage: n-seo <command> [--instance <dir>] [args...]
|
|
|
39
39
|
mcp the read-only MCP server on stdio (for .mcp.json)
|
|
40
40
|
export static export of the dashboard into <instance>/site/
|
|
41
41
|
check engine self-test: typecheck + unit tests
|
|
42
|
-
upgrade update the engine
|
|
42
|
+
upgrade update the engine in place, whatever way it was installed;
|
|
43
|
+
--check reports what is available without changing anything
|
|
43
44
|
version engine version, commit, paths, mode
|
|
44
45
|
help this text
|
|
45
46
|
|
|
@@ -377,9 +378,133 @@ setup guide: ${path.join(ROOT, "docs", "SETUP-GOOGLE.md")}
|
|
|
377
378
|
|
|
378
379
|
const sha256 = (p) => (fs.existsSync(p) ? createHash("sha256").update(fs.readFileSync(p)).digest("hex") : "");
|
|
379
380
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
381
|
+
/** Where this engine came from, and therefore how to upgrade it.
|
|
382
|
+
*
|
|
383
|
+
* This used to print `npm update n-seo` for every npm install. That command
|
|
384
|
+
* is right for exactly one layout — a consumer package.json with n-seo as a
|
|
385
|
+
* dependency — and silently wrong for the global install the README
|
|
386
|
+
* recommends: it reports "up to date", exits 0, upgrades nothing, and drops
|
|
387
|
+
* a stray package-lock.json in whatever directory you ran it from. */
|
|
388
|
+
function installKind() {
|
|
389
|
+
if (isGitEngine()) return { kind: "git", label: "git checkout" };
|
|
390
|
+
|
|
391
|
+
const modules = path.dirname(ROOT); // .../node_modules
|
|
392
|
+
if (path.basename(modules) !== "node_modules") {
|
|
393
|
+
return { kind: "unknown", label: "unrecognised layout" };
|
|
394
|
+
}
|
|
395
|
+
let globalRoot = null;
|
|
396
|
+
try {
|
|
397
|
+
globalRoot = execFileSync(npmBin(), ["root", "-g"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
398
|
+
} catch { /* npm not on PATH; fall through to the path-shape checks */ }
|
|
399
|
+
|
|
400
|
+
if (globalRoot && path.resolve(globalRoot) === path.resolve(modules)) {
|
|
401
|
+
return { kind: "npm-global", label: "npm, global", args: ["install", "-g"] };
|
|
402
|
+
}
|
|
403
|
+
// A global install under a custom prefix lives in <prefix>/lib/node_modules,
|
|
404
|
+
// and its bin symlink is in <prefix>/bin — so it has to be upgraded with -g
|
|
405
|
+
// against that prefix, not as a plain local install.
|
|
406
|
+
if (path.basename(path.dirname(modules)) === "lib") {
|
|
407
|
+
const prefix = path.dirname(path.dirname(modules));
|
|
408
|
+
return { kind: "npm-global", label: `npm, global (prefix ${prefix})`, args: ["install", "-g", "--prefix", prefix] };
|
|
409
|
+
}
|
|
410
|
+
const dir = path.dirname(modules);
|
|
411
|
+
return { kind: "npm-local", label: `npm, in ${dir}`, args: ["install", "--prefix", dir] };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The version the registry serves, or null if it cannot be reached.
|
|
415
|
+
* `npm view` is used rather than a direct fetch so a private registry,
|
|
416
|
+
* proxy or .npmrc scope configuration is honoured. */
|
|
417
|
+
function registryVersion() {
|
|
418
|
+
try {
|
|
419
|
+
const out = execFileSync(npmBin(), ["view", `${PKG.name}@latest`, "version"],
|
|
420
|
+
{ stdio: ["ignore", "pipe", "ignore"], timeout: 20_000 }).toString().trim();
|
|
421
|
+
return /^\d+\.\d+\.\d+/.test(out) ? out : null;
|
|
422
|
+
} catch {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const RELEASES = "https://github.com/en-dash-consulting/n-seo/releases/tag/v";
|
|
428
|
+
|
|
429
|
+
/** One version's section of the engine's own CHANGELOG.md, if it ships one. */
|
|
430
|
+
function changelogSection(version) {
|
|
431
|
+
const file = path.join(ROOT, "CHANGELOG.md");
|
|
432
|
+
if (!fs.existsSync(file)) return null;
|
|
433
|
+
const lines = fs.readFileSync(file, "utf8").split("\n");
|
|
434
|
+
const start = lines.findIndex((l) => l.startsWith(`## [${version}]`));
|
|
435
|
+
if (start === -1) return null;
|
|
436
|
+
let end = lines.length;
|
|
437
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
438
|
+
if (lines[i].startsWith("## ")) { end = i; break; }
|
|
439
|
+
}
|
|
440
|
+
const body = lines.slice(start + 1, end)
|
|
441
|
+
.filter((l) => !/^\[[^\]]+\]:\s/.test(l))
|
|
442
|
+
.join("\n").trim();
|
|
443
|
+
return body || null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function upgradeNpm(install, check) {
|
|
447
|
+
const current = PKG.version;
|
|
448
|
+
console.log(`engine ${PKG.name} ${current} ${install.label}`);
|
|
449
|
+
const latest = registryVersion();
|
|
450
|
+
if (!latest) {
|
|
451
|
+
console.error("registry unreachable — check the network, or your registry configuration");
|
|
452
|
+
return 1;
|
|
453
|
+
}
|
|
454
|
+
const upToDate = latest === current;
|
|
455
|
+
console.log(`registry ${PKG.name} ${latest} ${upToDate ? "— up to date" : "— newer"}`);
|
|
456
|
+
|
|
457
|
+
if (upToDate) return 0;
|
|
458
|
+
if (check) {
|
|
459
|
+
console.log(`\nupgrade with: n-seo upgrade`);
|
|
460
|
+
console.log(`release notes: ${RELEASES}${latest}`);
|
|
461
|
+
return 0;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const args = [...install.args, `${PKG.name}@${latest}`];
|
|
465
|
+
console.log(`\nupgrading ${npmBin()} ${args.join(" ")}\n`);
|
|
466
|
+
const r = spawnSync(npmBin(), args, { stdio: "inherit" });
|
|
467
|
+
if (r.status !== 0) {
|
|
468
|
+
console.error(`\nupgrade failed. The engine is still ${current}; nothing in your instance was touched.`);
|
|
469
|
+
return r.status ?? 1;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
console.log(`\n${PKG.name} ${current} → ${latest}`);
|
|
473
|
+
// Read the changelog from the *new* install: this is "what you just got".
|
|
474
|
+
const notes = changelogSection(latest);
|
|
475
|
+
if (notes) {
|
|
476
|
+
console.log("\nwhat changed\n");
|
|
477
|
+
for (const line of notes.split("\n")) console.log(` ${line}`);
|
|
478
|
+
}
|
|
479
|
+
console.log(`\nfull notes ${RELEASES}${latest}`);
|
|
480
|
+
console.log(`\nrestart the dashboard so it picks this up:`);
|
|
481
|
+
console.log(` n-seo start${process.platform === "darwin" ? " (or, if it runs under launchd: launchctl kickstart -k gui/$(id -u)/<label>)" : ""}`);
|
|
482
|
+
console.log(`\nroll back with:\n ${npmBin()} ${[...install.args, `${PKG.name}@${current}`].join(" ")}`);
|
|
483
|
+
return 0;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function upgrade(instance, check = false) {
|
|
487
|
+
const install = installKind();
|
|
488
|
+
if (install.kind === "npm-global" || install.kind === "npm-local") {
|
|
489
|
+
return upgradeNpm(install, check);
|
|
490
|
+
}
|
|
491
|
+
if (install.kind === "unknown") {
|
|
492
|
+
console.error(`cannot tell how this engine was installed (${ROOT}).`);
|
|
493
|
+
console.error("upgrade it the way you installed it, then run: n-seo check");
|
|
494
|
+
return 1;
|
|
495
|
+
}
|
|
496
|
+
if (check) {
|
|
497
|
+
console.log(`engine ${PKG.name} ${PKG.version} git checkout at ${ROOT}`);
|
|
498
|
+
const r = spawnSync("git", ["-C", ROOT, "fetch", "--quiet"], { stdio: "inherit" });
|
|
499
|
+
if (r.status === 0) {
|
|
500
|
+
try {
|
|
501
|
+
const behind = execFileSync("git", ["-C", ROOT, "rev-list", "--count", "HEAD..@{u}"],
|
|
502
|
+
{ stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
503
|
+
console.log(behind === "0" ? "remote up to date" : `remote ${behind} commit(s) ahead — run: n-seo upgrade`);
|
|
504
|
+
} catch {
|
|
505
|
+
console.log("remote no upstream branch configured");
|
|
506
|
+
}
|
|
507
|
+
}
|
|
383
508
|
return 0;
|
|
384
509
|
}
|
|
385
510
|
const before = gitCommit();
|
|
@@ -457,7 +582,7 @@ switch (cmd) {
|
|
|
457
582
|
code = run(py, [path.join(ROOT, "ops", "export_static.py"), ...rest], instance);
|
|
458
583
|
break;
|
|
459
584
|
case "upgrade":
|
|
460
|
-
code = upgrade(instance);
|
|
585
|
+
code = upgrade(instance, rest.includes("--check"));
|
|
461
586
|
break;
|
|
462
587
|
case "version": {
|
|
463
588
|
const mode = instance === ROOT ? "in-place" : "instance";
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -200,7 +200,7 @@ the reports shows them.
|
|
|
200
200
|
|---|---|
|
|
201
201
|
| `n-seo init [dir]` | Scaffold an instance: config from the example, empty `config/`, `content/`, `.env`, `.gitignore`, `.mcp.json` pointing at this engine, README. Never overwrites |
|
|
202
202
|
| `n-seo start` · `dev` · `daily` · `doctor` · `demo` · `mcp` · `check` · `export` | Run the engine's command against the instance (`--instance <path>`, else `$N_SEO_INSTANCE`, else cwd); remaining args pass through |
|
|
203
|
-
| `n-seo upgrade` | Git engine: `git pull --ff-only`, `npm ci` if the lockfile changed, `npm run check`; prints the exact `git reset --hard <sha>` if the check fails. npm engine:
|
|
203
|
+
| `n-seo upgrade` | Detects the install shape and does the upgrade. Git engine: `git pull --ff-only`, `npm ci` if the lockfile changed, `npm run check`; prints the exact `git reset --hard <sha>` if the check fails. npm engine (global, custom prefix, or local): installs the published latest with the right flags for that layout, then prints the new CHANGELOG section and the rollback command. `--check` reports without changing anything |
|
|
204
204
|
| `n-seo version` | engine version, commit, engine path, instance path, mode |
|
|
205
205
|
|
|
206
206
|
## Operating rules the tooling encodes
|
package/docs/INSTANCE.md
CHANGED
|
@@ -232,16 +232,17 @@ npx n-seo init .
|
|
|
232
232
|
"daily": "n-seo daily",
|
|
233
233
|
"doctor": "n-seo doctor",
|
|
234
234
|
"export": "n-seo export",
|
|
235
|
-
"upgrade": "
|
|
235
|
+
"upgrade": "n-seo upgrade && n-seo check"
|
|
236
236
|
},
|
|
237
237
|
"dependencies": { "n-seo": "github:en-dash-consulting/n-seo" }
|
|
238
238
|
}
|
|
239
239
|
```
|
|
240
240
|
|
|
241
241
|
The engine lives in `node_modules/n-seo`; the instance is the current
|
|
242
|
-
directory. `n-seo upgrade` recognizes
|
|
243
|
-
|
|
244
|
-
(`github:en-dash-consulting/n-seo#<sha>`) when
|
|
242
|
+
directory. `n-seo upgrade` recognizes this layout and installs the published
|
|
243
|
+
latest into it; `n-seo upgrade --check` reports without changing anything.
|
|
244
|
+
Pin with a version or a commit (`github:en-dash-consulting/n-seo#<sha>`) when
|
|
245
|
+
you need to.
|
|
245
246
|
|
|
246
247
|
Python is still required on the machine — the engine's ingest scripts run
|
|
247
248
|
with `python3` from the package directory.
|
package/docs/PRD.md
CHANGED
|
@@ -29,8 +29,13 @@ with evidence attached, and measure yesterday's changes.
|
|
|
29
29
|
## Principles (non-negotiable)
|
|
30
30
|
|
|
31
31
|
1. **Local-first.** All data lives in files on the owner's machine. The only
|
|
32
|
-
outbound calls are to Google APIs the owner authorized
|
|
33
|
-
|
|
32
|
+
outbound calls are to Google APIs the owner authorized, the LLM provider
|
|
33
|
+
the owner configured, and — once per daily run, from the `updateCheck`
|
|
34
|
+
module — a public registry query for the engine's own latest version. That
|
|
35
|
+
last one is the only thing enabled by default that talks to a third party;
|
|
36
|
+
it sends nothing about the instance and is disabled in one line. The
|
|
37
|
+
dashboard itself never makes a network request, so every page renders with
|
|
38
|
+
the machine offline.
|
|
34
39
|
2. **It briefs; it never acts on the owner's behalf.** No module posts,
|
|
35
40
|
sends, publishes, or edits a site. Community modules produce briefings,
|
|
36
41
|
never comment text.
|
|
@@ -170,8 +175,12 @@ engine (e.g. `instance/src/extensions.ts`).
|
|
|
170
175
|
# Epic: Modules (opt-in) [shipped]
|
|
171
176
|
|
|
172
177
|
- Acceptance: each of indexStatus, metadataAudit, opportunityScan, llm,
|
|
173
|
-
hackerNews, reddit, indexNow, staticExport, gitAutoCommit, notifications
|
|
174
|
-
gated by `modules.<key>.enabled`; a disabled digest exits 0
|
|
178
|
+
hackerNews, reddit, indexNow, staticExport, gitAutoCommit, notifications,
|
|
179
|
+
updateCheck is gated by `modules.<key>.enabled`; a disabled digest exits 0
|
|
180
|
+
with a message.
|
|
181
|
+
- Acceptance: `updateCheck` is the only module enabled by default. It writes
|
|
182
|
+
`data/update-check.json`, exits 0 when the registry is unreachable without
|
|
183
|
+
disturbing the previous result, and makes no request at all when disabled.
|
|
175
184
|
- Acceptance: the LLM module runs any command that reads a prompt on stdin;
|
|
176
185
|
when unavailable, the scan records candidates only and digests skip
|
|
177
186
|
briefings.
|
|
@@ -229,10 +238,19 @@ instead of a CLI, with the key read from `.env`.
|
|
|
229
238
|
scaffolds an instance; the `files` list excludes tests and the marketing
|
|
230
239
|
site, and includes the instance-facing skills.
|
|
231
240
|
|
|
232
|
-
## Feature:
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
241
|
+
## Feature: Upgrading is visible and one command [shipped]
|
|
242
|
+
|
|
243
|
+
- Acceptance: `n-seo upgrade` detects how the engine was installed — git
|
|
244
|
+
checkout, npm global (including a custom prefix), npm local — and performs
|
|
245
|
+
the upgrade itself rather than printing advice. It prints the old and new
|
|
246
|
+
versions, the section of the new CHANGELOG, the rollback command, and a
|
|
247
|
+
reminder to restart the dashboard.
|
|
248
|
+
- Acceptance: `n-seo upgrade --check` reports what is available and changes
|
|
249
|
+
nothing.
|
|
250
|
+
- Acceptance: a newer published version is shown on the Settings page and in
|
|
251
|
+
`doctor`, both read from `data/update-check.json` rather than the network,
|
|
252
|
+
and both compare against the engine running now — so an upgrade stops the
|
|
253
|
+
notice immediately rather than at the next daily run.
|
|
236
254
|
|
|
237
255
|
# Epic: Quality [shipped]
|
|
238
256
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/ingest/seo_config.py
CHANGED
|
@@ -40,12 +40,18 @@ EXAMPLE_PATH = ROOT / "n-seo.config.example.json"
|
|
|
40
40
|
MODULE_KEYS = [
|
|
41
41
|
"indexStatus", "metadataAudit", "opportunityScan", "llm", "hackerNews",
|
|
42
42
|
"reddit", "indexNow", "staticExport", "publish", "gitAutoCommit",
|
|
43
|
-
"notifications",
|
|
43
|
+
"notifications", "updateCheck",
|
|
44
44
|
]
|
|
45
45
|
|
|
46
46
|
# Per-module defaults, so a half-written block cannot make a step guess.
|
|
47
47
|
# Mirrors MODULE_DEFAULTS in src/config.ts.
|
|
48
48
|
MODULE_DEFAULTS = {
|
|
49
|
+
# The one module that ships on. Everything else here is opt-in, but an
|
|
50
|
+
# engine that never mentions its own updates leaves people running old
|
|
51
|
+
# code without knowing it. One public metadata request a day, carrying
|
|
52
|
+
# nothing about this instance, and `"updateCheck": {"enabled": false}`
|
|
53
|
+
# stops it for good.
|
|
54
|
+
"updateCheck": {"enabled": True},
|
|
49
55
|
"staticExport": {"signOutUrl": "", "signOutLabel": "Sign out"},
|
|
50
56
|
"publish": {"target": "gcs", "destination": "", "command": "",
|
|
51
57
|
"delete": False, "dryRun": False, "env": {}},
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/ops/daily.py
CHANGED
|
@@ -39,6 +39,9 @@ LOG = seo_config.DATA / "daily-ops.log"
|
|
|
39
39
|
|
|
40
40
|
# (name, script, module gate or None)
|
|
41
41
|
STEPS = [
|
|
42
|
+
# First, and cheap: "a newer version exists" belongs at the top of the
|
|
43
|
+
# log, not buried under forty minutes of pulls.
|
|
44
|
+
("update-check", "ops/update_check.py", "updateCheck"),
|
|
42
45
|
("probe", "probes/site_probe.py", None),
|
|
43
46
|
("gsc", "ingest/pull_gsc.py", None),
|
|
44
47
|
("ga4", "ingest/pull_ga4.py", None),
|
package/ops/doctor.py
CHANGED
|
@@ -35,6 +35,24 @@ def check_engine():
|
|
|
35
35
|
else "config, queue and data live inside the engine checkout (see docs/INSTANCE.md to split them)")
|
|
36
36
|
report("OK", f"engine: {e['root']}")
|
|
37
37
|
report("OK", f"instance: {e['instance']}")
|
|
38
|
+
# From the file the daily run writes, never a fresh request: doctor is
|
|
39
|
+
# something people run when something is already wrong, often offline,
|
|
40
|
+
# and it should not hang on a registry.
|
|
41
|
+
try:
|
|
42
|
+
u = json.loads((seo_config.DATA / "update-check.json").read_text(encoding="utf-8"))
|
|
43
|
+
except (OSError, ValueError):
|
|
44
|
+
u = None
|
|
45
|
+
if u and u.get("latest"):
|
|
46
|
+
def parts(v):
|
|
47
|
+
bits = str(v).split(".")[:3]
|
|
48
|
+
return tuple(int(b) for b in bits) if len(bits) == 3 and all(b.isdigit() for b in bits) else None
|
|
49
|
+
cur, new_ = parts(e["version"]), parts(u["latest"])
|
|
50
|
+
if cur and new_ and new_ > cur:
|
|
51
|
+
report("WARN", f"n-seo {u['latest']} is available", "upgrade with: n-seo upgrade")
|
|
52
|
+
else:
|
|
53
|
+
report("OK", f"n-seo {e['version']} is current", f"checked {u.get('checked', '?')}")
|
|
54
|
+
elif seo_config.module("updateCheck").get("enabled"):
|
|
55
|
+
report("OK", "update check has not run yet", "it runs with the daily run")
|
|
38
56
|
|
|
39
57
|
|
|
40
58
|
def check_config():
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Ask the registry whether a newer engine has been published.
|
|
3
|
+
|
|
4
|
+
Nothing else in n-seo checks for updates, so before this existed the only way
|
|
5
|
+
to learn that a release had happened was to go and look. That is a poor deal
|
|
6
|
+
for a tool you schedule and then stop thinking about.
|
|
7
|
+
|
|
8
|
+
Two rules this obeys, because the promise on the tin is that your data stays
|
|
9
|
+
put:
|
|
10
|
+
|
|
11
|
+
1. **Only this step talks to the registry.** The dashboard never does — it
|
|
12
|
+
reads the file written here, so opening a page makes no outbound request
|
|
13
|
+
and works with the network unplugged.
|
|
14
|
+
2. **It is a module, and it can be switched off.** `modules.updateCheck`.
|
|
15
|
+
The request carries no instance data: it is the same public metadata
|
|
16
|
+
request `npm view n-seo version` makes from any machine.
|
|
17
|
+
|
|
18
|
+
Writes data/update-check.json. Pure stdlib; runs unattended from ops/daily.py.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ingest"))
|
|
30
|
+
import seo_config # noqa: E402
|
|
31
|
+
|
|
32
|
+
DATA = seo_config.DATA
|
|
33
|
+
OUT = DATA / "update-check.json"
|
|
34
|
+
PACKAGE = "n-seo"
|
|
35
|
+
RELEASES = "https://github.com/en-dash-consulting/n-seo/releases/tag/v"
|
|
36
|
+
|
|
37
|
+
SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parts(v: str):
|
|
41
|
+
"""(1, 2, 3) from '1.2.3', or None if it is not a plain release version."""
|
|
42
|
+
m = SEMVER.match(v or "")
|
|
43
|
+
return tuple(int(g) for g in m.groups()) if m else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def npm_bin() -> str:
|
|
47
|
+
return "npm.cmd" if sys.platform == "win32" else "npm"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def published_version() -> str | None:
|
|
51
|
+
"""What the registry serves.
|
|
52
|
+
|
|
53
|
+
`npm view` rather than a direct fetch, so a private registry, a proxy or
|
|
54
|
+
an .npmrc setting is honoured — someone running an internal mirror should
|
|
55
|
+
be told about *their* latest, not npmjs.com's.
|
|
56
|
+
"""
|
|
57
|
+
npm = shutil.which(npm_bin())
|
|
58
|
+
if not npm:
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
out = subprocess.run(
|
|
62
|
+
[npm, "view", f"{PACKAGE}@latest", "version"],
|
|
63
|
+
capture_output=True, text=True, timeout=45, encoding="utf-8",
|
|
64
|
+
)
|
|
65
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
66
|
+
return None
|
|
67
|
+
if out.returncode != 0:
|
|
68
|
+
return None
|
|
69
|
+
v = (out.stdout or "").strip()
|
|
70
|
+
return v if parts(v) else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main() -> int:
|
|
74
|
+
m = seo_config.module("updateCheck")
|
|
75
|
+
if not m.get("enabled"):
|
|
76
|
+
print("updateCheck module is off — skipping (no registry request made)")
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
engine = seo_config.engine_info()
|
|
80
|
+
current = engine.get("version", "0.0.0")
|
|
81
|
+
latest = published_version()
|
|
82
|
+
|
|
83
|
+
if latest is None:
|
|
84
|
+
# Never a failure: an offline morning must not fail the daily run over
|
|
85
|
+
# a version check, and a stale file is better than no file.
|
|
86
|
+
print("could not reach the registry — leaving the last result in place")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
cur_p, new_p = parts(current), parts(latest)
|
|
90
|
+
newer = bool(cur_p and new_p and new_p > cur_p)
|
|
91
|
+
ahead = bool(cur_p and new_p and cur_p > new_p)
|
|
92
|
+
|
|
93
|
+
DATA.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
OUT.write_text(json.dumps({
|
|
95
|
+
"checked": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%MZ"),
|
|
96
|
+
"current": current,
|
|
97
|
+
"latest": latest,
|
|
98
|
+
"newer": newer,
|
|
99
|
+
"notes": f"{RELEASES}{latest}" if newer else "",
|
|
100
|
+
}, indent=1) + "\n", encoding="utf-8")
|
|
101
|
+
|
|
102
|
+
if newer:
|
|
103
|
+
print(f"n-seo {latest} is available (running {current}) — upgrade with: n-seo upgrade")
|
|
104
|
+
print(f" release notes: {RELEASES}{latest}")
|
|
105
|
+
elif ahead:
|
|
106
|
+
print(f"running {current}, ahead of the published {latest} — a development build")
|
|
107
|
+
else:
|
|
108
|
+
print(f"n-seo {current} is the latest")
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
sys.exit(main())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "n-seo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Agentic, local-first SEO / AEO / GEO control plane: pulls Search Console + GA4, probes your sites, and ranks the next moves. Your LLM writes the proposals; your coding agent works the queue over MCP.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "En Dash Consulting (https://endash.us)",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"!.claude/skills/README.md",
|
|
58
58
|
"docs/",
|
|
59
59
|
"README.md",
|
|
60
|
+
"CHANGELOG.md",
|
|
60
61
|
"LICENSE"
|
|
61
62
|
],
|
|
62
63
|
"engines": {
|
|
Binary file
|
package/public/styles.css
CHANGED
|
@@ -151,6 +151,7 @@ a { color: var(--accent); }
|
|
|
151
151
|
.chip.ai, .chip.src { background: var(--accent-soft); color: var(--accent); }
|
|
152
152
|
.chip.good { background: var(--good-soft); color: var(--good); }
|
|
153
153
|
.chip.bad { background: var(--bad-soft); color: var(--bad); }
|
|
154
|
+
.chip.warn { background: var(--warn-soft); color: var(--warn); }
|
|
154
155
|
.chip.impact { background: var(--good-soft); color: var(--good); }
|
|
155
156
|
.chip.effort.e-S { background: var(--code-bg); color: var(--muted); }
|
|
156
157
|
.chip.effort.e-M { background: var(--warn-soft); color: var(--warn); }
|
|
@@ -190,6 +191,10 @@ td.trunc { max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-sp
|
|
|
190
191
|
.flash { background: var(--good-soft); color: var(--good); font-weight: 600; font-size: 0.85rem; padding: 8px 14px; border-radius: 8px; }
|
|
191
192
|
|
|
192
193
|
footer { max-width: 1480px; margin: 0 auto; padding: 16px 24px 40px; color: var(--muted); font-size: 0.75rem; border-top: 1px solid var(--line); }
|
|
194
|
+
footer .foot-ver { font-weight: 600; color: var(--ink); }
|
|
195
|
+
footer .foot-upd { background: var(--warn-soft); color: var(--warn); border-radius: 5px; padding: 1px 7px; font-weight: 600; }
|
|
196
|
+
footer .foot-upd code { background: rgba(0,0,0,0.06); color: inherit; }
|
|
197
|
+
footer .foot-upd a { color: inherit; text-decoration: underline; }
|
|
193
198
|
|
|
194
199
|
/* ---- buttons + inline forms ---- */
|
|
195
200
|
.btn {
|
package/src/config.ts
CHANGED
|
@@ -107,6 +107,8 @@ export interface Config {
|
|
|
107
107
|
/** Per-module defaults, so a half-written block cannot make a step guess.
|
|
108
108
|
* Mirrors MODULE_DEFAULTS in ingest/seo_config.py. */
|
|
109
109
|
export const MODULE_DEFAULTS: Record<string, Record<string, unknown>> = {
|
|
110
|
+
// The one module that ships on — see the note in ingest/seo_config.py.
|
|
111
|
+
updateCheck: { enabled: true },
|
|
110
112
|
staticExport: { signOutUrl: "", signOutLabel: "Sign out" },
|
|
111
113
|
publish: { target: "gcs", destination: "", command: "", delete: false, dryRun: false, env: {} },
|
|
112
114
|
};
|
|
@@ -123,6 +125,7 @@ export const MODULE_INFO: { key: string; title: string; blurb: string; needs?: s
|
|
|
123
125
|
{ key: "publish", title: "Publish the mirror", blurb: "Copy site/ to a bucket, an object store or a box over ssh after the export — a real pipeline step, so it is logged and retried like the rest. `dryRun` prints the command without running it, which is how you rehearse a cutover.", needs: "gcloud, aws or rsync on PATH, depending on the target" },
|
|
124
126
|
{ key: "gitAutoCommit", title: "Git auto-commit", blurb: "Commit the daily log and export after each run (and push if a remote is set)." },
|
|
125
127
|
{ key: "notifications", title: "Desktop notifications", blurb: "macOS notification when a daily step fails (osascript)." },
|
|
128
|
+
{ key: "updateCheck", title: "Update check", blurb: "Once a day, during the run, ask the registry whether a newer engine has been published and note it on this page. The only module that is on by default. The dashboard itself never makes the request — it reads the result — so pages still render with no network. Nothing about this instance is sent." },
|
|
126
129
|
];
|
|
127
130
|
|
|
128
131
|
function readJson<T>(p: string): T {
|
package/src/data.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* long-lived process (dashboard, MCP server) honest about what is on disk. */
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
|
-
import { ROOT, INSTANCE, config, gscDataSlug, type SiteCfg } from "./config.js";
|
|
7
|
+
import { ROOT, INSTANCE, ENGINE_VERSION, config, gscDataSlug, type SiteCfg } from "./config.js";
|
|
8
8
|
import { BACKLOG_PATH } from "./backlog.js";
|
|
9
9
|
|
|
10
10
|
const DATA = path.join(INSTANCE, "data");
|
|
@@ -736,6 +736,32 @@ export function opsLogTail(maxLines = 150): string {
|
|
|
736
736
|
|
|
737
737
|
export interface LastRun { ts: string; failures: string; steps?: { name: string; ok: boolean; seconds?: number }[] }
|
|
738
738
|
|
|
739
|
+
export interface UpdateCheck { checked: string; current: string; latest: string; newer: boolean; notes: string }
|
|
740
|
+
|
|
741
|
+
/** What ops/update_check.py last learned from the registry.
|
|
742
|
+
*
|
|
743
|
+
* Read-only, from disk. The server makes no network request of its own —
|
|
744
|
+
* a dashboard whose pages depend on reaching npmjs.com would be slower, and
|
|
745
|
+
* would break the promise that this thing works with the network unplugged. */
|
|
746
|
+
export function updateCheck(): UpdateCheck | null {
|
|
747
|
+
const r = readJson<Partial<UpdateCheck>>(path.join(DATA, "update-check.json"));
|
|
748
|
+
if (!r?.latest) return null;
|
|
749
|
+
// `newer` is recomputed against the engine running right now rather than
|
|
750
|
+
// trusted from the file. Upgrading does not rewrite it — only the next
|
|
751
|
+
// daily run does — so the stored flag would keep advertising an update you
|
|
752
|
+
// have already installed, for up to a day.
|
|
753
|
+
const parse = (v: string) => /^\d+\.\d+\.\d+/.test(v) ? v.split(".", 3).map(Number) : null;
|
|
754
|
+
const now = parse(ENGINE_VERSION), latest = parse(r.latest);
|
|
755
|
+
const newer = !!(now && latest && (latest[0] - now[0] || latest[1] - now[1] || latest[2] - now[2]) > 0);
|
|
756
|
+
return {
|
|
757
|
+
checked: r.checked ?? "",
|
|
758
|
+
current: ENGINE_VERSION,
|
|
759
|
+
latest: r.latest,
|
|
760
|
+
newer,
|
|
761
|
+
notes: r.notes ?? "",
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
739
765
|
export function lastRun(): LastRun | null {
|
|
740
766
|
const r = readJson<Partial<LastRun>>(path.join(DATA, "last-run.json"));
|
|
741
767
|
if (!r) return null;
|
package/src/settings.tsx
CHANGED
|
@@ -31,6 +31,7 @@ export const SettingsPage: FC<{ saved?: boolean; error?: string }> = ({ saved, e
|
|
|
31
31
|
const sa = serviceAccountEmail(cfg.google.serviceAccountKey);
|
|
32
32
|
const lr = data.lastRun();
|
|
33
33
|
const eng = engineInfo();
|
|
34
|
+
const upd = data.updateCheck();
|
|
34
35
|
const hookCount = cfg.hooks.beforeRun.length + cfg.hooks.afterRun.length + Object.values(cfg.hooks.afterStep).reduce((n, l) => n + l.length, 0);
|
|
35
36
|
const str = (v: unknown) => (v == null ? "" : String(v));
|
|
36
37
|
return (
|
|
@@ -51,7 +52,11 @@ export const SettingsPage: FC<{ saved?: boolean; error?: string }> = ({ saved, e
|
|
|
51
52
|
<h2>Engine <small>— what is running, and where</small></h2>
|
|
52
53
|
<div class="settings-grid">
|
|
53
54
|
<div class="s-card">
|
|
54
|
-
<div class="s-title">
|
|
55
|
+
<div class="s-title">
|
|
56
|
+
n-seo {eng.version}{eng.commit ? ` · ${eng.commit}` : ""}
|
|
57
|
+
{upd?.newer && <span class="chip warn" title={`checked ${upd.checked}`}> {upd.latest} available</span>}
|
|
58
|
+
{upd && !upd.newer && <span class="chip good" title={`checked ${upd.checked}`}> latest</span>}
|
|
59
|
+
</div>
|
|
55
60
|
<p class="sub"><span class={`chip ${eng.mode === "instance" ? "good" : ""}`}>{eng.mode}</span> {eng.mode === "instance"
|
|
56
61
|
? "the engine and this instance live in separate directories; upgrading the engine does not touch your config, queue or data."
|
|
57
62
|
: "config, queue and data live inside the engine checkout. Fine for one person; see docs/INSTANCE.md to split them."}</p>
|
|
@@ -59,7 +64,16 @@ export const SettingsPage: FC<{ saved?: boolean; error?: string }> = ({ saved, e
|
|
|
59
64
|
<div class="s-card">
|
|
60
65
|
<div class="s-title">Engine path</div>
|
|
61
66
|
<div class="mono">{eng.root}</div>
|
|
62
|
-
<p class="sub">
|
|
67
|
+
<p class="sub">
|
|
68
|
+
Upgrade with <code>n-seo upgrade</code>, whatever way it was installed.
|
|
69
|
+
{" "}<code>n-seo upgrade --check</code> reports without changing anything.
|
|
70
|
+
</p>
|
|
71
|
+
{upd?.newer && (
|
|
72
|
+
<p class="sub">
|
|
73
|
+
<b>{upd.latest} is available.</b> Your config, queue and content are untouched by an upgrade.
|
|
74
|
+
{upd.notes && <> <a href={upd.notes} target="_blank" rel="noopener noreferrer">Release notes →</a></>}
|
|
75
|
+
</p>
|
|
76
|
+
)}
|
|
63
77
|
</div>
|
|
64
78
|
<div class="s-card">
|
|
65
79
|
<div class="s-title">Instance path</div>
|
package/src/views.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Server-rendered views (hono/jsx). */
|
|
2
2
|
import type { FC, PropsWithChildren } from "hono/jsx";
|
|
3
|
-
import { SITES, config, loadConfig, USING_EXAMPLE_CONFIG, type SiteCfg } from "./config.js";
|
|
3
|
+
import { SITES, ENGINE_VERSION, config, loadConfig, USING_EXAMPLE_CONFIG, type SiteCfg } from "./config.js";
|
|
4
4
|
import * as data from "./data.js";
|
|
5
5
|
import { actionsFor, allActions, type Action } from "./actions.js";
|
|
6
6
|
import { insights as loadInsights } from "./insights.js";
|
|
@@ -35,6 +35,7 @@ export const Layout: FC<PropsWithChildren<{ title: string; active: string }>> =
|
|
|
35
35
|
loadConfig(); // refresh USING_EXAMPLE_CONFIG
|
|
36
36
|
const cfg = config();
|
|
37
37
|
const sites = cfg.sites;
|
|
38
|
+
const upd = data.updateCheck();
|
|
38
39
|
return (
|
|
39
40
|
<html lang="en">
|
|
40
41
|
<head>
|
|
@@ -87,7 +88,26 @@ export const Layout: FC<PropsWithChildren<{ title: string; active: string }>> =
|
|
|
87
88
|
)}
|
|
88
89
|
<main>{children}</main>
|
|
89
90
|
<footer>
|
|
90
|
-
|
|
91
|
+
{/* Which engine produced this page. On the static mirror that is
|
|
92
|
+
the only place it appears — /settings is not exported, because
|
|
93
|
+
it shows local paths and the service-account email — so without
|
|
94
|
+
this a published mirror named no version at all.
|
|
95
|
+
|
|
96
|
+
The update notice is baked in at export time from the file the
|
|
97
|
+
daily run writes. Polling the registry from the browser instead
|
|
98
|
+
would put every viewer of a shared mirror onto npmjs.com on
|
|
99
|
+
every page view, to learn something that changes once a day. */}
|
|
100
|
+
<span class="foot-ver">n-seo {ENGINE_VERSION}</span>
|
|
101
|
+
{upd?.newer && (
|
|
102
|
+
<>
|
|
103
|
+
{" "}
|
|
104
|
+
<span class="foot-upd">
|
|
105
|
+
{upd.latest} available — upgrade with <code>n-seo upgrade</code>
|
|
106
|
+
{upd.notes && <> · <a href={upd.notes} target="_blank" rel="noopener noreferrer">what changed</a></>}
|
|
107
|
+
</span>
|
|
108
|
+
</>
|
|
109
|
+
)}
|
|
110
|
+
{" · "}refreshed by <code>ops/daily.py</code> · queue in <code>config/backlog.json</code> · nothing here posts or publishes for you
|
|
91
111
|
</footer>
|
|
92
112
|
<script dangerouslySetInnerHTML={{ __html: `document.addEventListener('click',function(e){var d=e.target.closest('dialog');if(d&&e.target===d)d.close();});` }} />
|
|
93
113
|
</body>
|