n-seo 0.3.3 → 0.4.0

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