@o-a/cms-agent 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/create-site/cli.js +0 -0
  2. package/dist/create-site/generate-site.js +3 -0
  3. package/dist/create-site/mint-token-cli.js +0 -0
  4. package/dist/create-site/template/AGENTS.md +7 -5
  5. package/dist/create-site/template/content/menus/footerCompany.json +1 -1
  6. package/dist/create-site/template/content/menus/footerProduct.json +1 -1
  7. package/dist/create-site/template/content/menus/footerResources.json +1 -1
  8. package/dist/create-site/template/content/menus/main.json +1 -1
  9. package/dist/create-site/template/content/pages/404.json +1 -1
  10. package/dist/create-site/template/content/pages/about/careers.json +1 -1
  11. package/dist/create-site/template/content/pages/about/team.json +1 -1
  12. package/dist/create-site/template/content/pages/about.json +1 -1
  13. package/dist/create-site/template/content/pages/docs/deployment.json +1 -1
  14. package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +1 -1
  15. package/dist/create-site/template/content/pages/docs/getting-started.json +1 -1
  16. package/dist/create-site/template/content/pages/docs.json +1 -1
  17. package/dist/create-site/template/content/pages/index.json +1 -1
  18. package/dist/create-site/template/vhost/docker-entrypoint.sh +33 -7
  19. package/dist/media/seed-media-cli.js +0 -0
  20. package/dist/migrations/index.d.ts +1 -1
  21. package/dist/migrations/index.js +12 -1
  22. package/dist/renderer/render-page.js +13 -3
  23. package/dist/routes/menus.js +72 -1
  24. package/dist/schemas/menu.schema.json +1 -0
  25. package/dist/search/query-index.d.ts +5 -0
  26. package/dist/search/query-index.js +21 -0
  27. package/dist/server.js +13 -0
  28. package/dist/services/manage-menus.d.ts +6 -1
  29. package/dist/services/manage-menus.js +55 -1
  30. package/dist/services/menu-references.d.ts +3 -0
  31. package/dist/services/menu-references.js +38 -0
  32. package/dist/services/menus.d.ts +1 -0
  33. package/dist/services/menus.js +2 -1
  34. package/dist/services/pid-file.d.ts +13 -0
  35. package/dist/services/pid-file.js +54 -0
  36. package/dist/services/post-urls.d.ts +3 -0
  37. package/dist/services/post-urls.js +27 -0
  38. package/dist/services/resolve-blog-url.d.ts +11 -0
  39. package/dist/services/resolve-blog-url.js +31 -0
  40. package/dist/site-check/cli.js +0 -0
  41. package/dist/stop-site/cli.d.ts +2 -0
  42. package/dist/stop-site/cli.js +28 -0
  43. package/dist/stop-site/stop-site.d.ts +26 -0
  44. package/dist/stop-site/stop-site.js +56 -0
  45. package/package.json +3 -2
File without changes
@@ -135,6 +135,9 @@ export function scaffoldSite(targetDir) {
135
135
  // instead of media/. Run it from here as
136
136
  // `npm run seed-media -- .. <file>`.
137
137
  'seed-media': 'seed-media',
138
+ // Stops the site from any terminal, not only the one it was
139
+ // started in (stop-site, via vhost/data/server.pid).
140
+ stop: 'stop-site',
138
141
  },
139
142
  dependencies: {
140
143
  // Pinned exact, never a ^range - at v0.x even a minor bump
File without changes
@@ -104,7 +104,8 @@ Write the description as one short sentence about what the section **is**, not w
104
104
  {{ page.author }} the page's author, if set
105
105
  {{ page.publishDate }} the page's publish date, if set
106
106
  {% for tag in page.tags %}...{% endfor %} the page's tags, if any
107
- {{ menus.<name>.items }} every menu in content/menus/, keyed by filename
107
+ {{ menus.<handle>.items }} every menu in content/menus/, keyed by handle (its filename)
108
+ {{ menus.<handle>.name }} that menu's optional display name (blank when unset)
108
109
  ```
109
110
 
110
111
  ### Snippets
@@ -275,7 +276,7 @@ Required fields, `additionalProperties: false`:
275
276
 
276
277
  | Field | Type | Notes |
277
278
  |---|---|---|
278
- | `schemaVersion` | integer | Always `6` for new content |
279
+ | `schemaVersion` | integer | Always `7` for new content |
279
280
  | `name` | string | Internal label (shown in the admin's page tree) |
280
281
  | `title` | string | Rendered as `{{ page.title }}` |
281
282
  | `type` | string | **The field that decides which listings a page appears in.** Free-form (e.g. `"page"`, `"project"`, `"article"`), lowercase by convention. It is indexed as `pageType` and is what `GET /search.json?pageType=...` filters on, so a project listing, a blog index and a team directory each depend on their pages carrying the right value here. Give every kind of page its own type; `"page"` is for ordinary one-off pages only |
@@ -289,7 +290,7 @@ Each entry in `sections` requires `id` (any non-empty string, unique within the
289
290
 
290
291
  ```json
291
292
  {
292
- "schemaVersion": 6,
293
+ "schemaVersion": 7,
293
294
  "name": "Home",
294
295
  "title": "Welcome",
295
296
  "type": "page",
@@ -308,10 +309,10 @@ Each entry in `sections` requires `id` (any non-empty string, unique within the
308
309
  }
309
310
  ```
310
311
 
311
- `content/menus/<name>.json` - referenced in layouts as `{{ menus.<name>.items }}`:
312
+ `content/menus/<handle>.json` - the filename is the menu's handle, referenced in layouts as `{{ menus.<handle>.items }}`. An optional `"name"` is its display name (editable in the admin, available as `{{ menus.<handle>.name }}`). Renaming the file changes the handle and empties every layout still using the old one, so change `"name"` to rename a menu:
312
313
 
313
314
  ```json
314
- { "schemaVersion": 6, "items": [{ "label": "Home", "url": "/" }, { "label": "About", "url": "/about" }] }
315
+ { "schemaVersion": 7, "items": [{ "label": "Home", "url": "/" }, { "label": "About", "url": "/about" }] }
315
316
  ```
316
317
 
317
318
  `content/redirects.json` - a single file, not a folder:
@@ -429,6 +430,7 @@ From `vhost/`:
429
430
  npm start # boots the site on the port set in vhost/site.config.json
430
431
  npm run tunnel # same, plus a public tunnel URL for sharing a preview
431
432
  npm run dev # same, plus auto-restart whenever a theme/ file changes - use this one while iterating
433
+ npm run stop # stops a site started by any of the above, from any terminal
432
434
  ```
433
435
 
434
436
  `npm run dev` only watches `theme/` - content changes (via the API) already show up on the next request with no restart needed, so there's nothing to gain watching `content/` too.
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 6,
2
+ "schemaVersion": 7,
3
3
  "items": [
4
4
  { "label": "About", "url": "/about" },
5
5
  { "label": "Careers", "url": "/careers" },
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 6,
2
+ "schemaVersion": 7,
3
3
  "items": [
4
4
  { "label": "Features", "url": "#features" },
5
5
  { "label": "Pricing", "url": "#pricing" },
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 6,
2
+ "schemaVersion": 7,
3
3
  "items": [
4
4
  { "label": "Documentation", "url": "/docs" },
5
5
  { "label": "Theme authoring guide", "url": "/docs/themes" },
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 6,
2
+ "schemaVersion": 7,
3
3
  "items": [
4
4
  { "label": "Features", "url": "#features" },
5
5
  { "label": "How it works", "url": "#how-it-works" },
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 6,
2
+ "schemaVersion": 7,
3
3
  "title": "Page not found — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 6,
2
+ "schemaVersion": 7,
3
3
  "title": "Careers — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 6,
2
+ "schemaVersion": 7,
3
3
  "title": "Team — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 6,
2
+ "schemaVersion": 7,
3
3
  "title": "About — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 6,
2
+ "schemaVersion": 7,
3
3
  "title": "Deployment — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 6,
2
+ "schemaVersion": 7,
3
3
  "title": "Quickstart — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 6,
2
+ "schemaVersion": 7,
3
3
  "title": "Getting Started — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 6,
2
+ "schemaVersion": 7,
3
3
  "title": "Docs — Granite CMS",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,5 +1,5 @@
1
1
  {
2
- "schemaVersion": 6,
2
+ "schemaVersion": 7,
3
3
  "title": "Granite CMS — Solid foundations for client sites",
4
4
  "type": "page",
5
5
  "layout": "theme",
@@ -1,6 +1,12 @@
1
1
  #!/bin/sh
2
2
  set -e
3
3
 
4
+ # /seed is the image-baked copy of the site; /site is where the
5
+ # persistent volume is mounted. Overridable only so the test suite can
6
+ # run this exact script against temporary directories.
7
+ SEED="${CMS_SEED_DIR:-/seed}"
8
+ SITE="${CMS_SITE_DIR:-/site}"
9
+
4
10
  # First boot against an empty mounted volume: seed it from the
5
11
  # image-baked copy at /seed (content, theme, the already-`npm
6
12
  # install`ed vhost/node_modules). A later boot finds /site/vhost
@@ -8,8 +14,28 @@ set -e
8
14
  # identically with a real persistent volume mounted at /site
9
15
  # (production) or with nothing mounted at all (a quick local trial,
10
16
  # where content just lives in the ephemeral container layer).
11
- if [ ! -d /site/vhost ]; then
12
- cp -r /seed/. /site/
17
+ if [ ! -d "$SITE/vhost" ]; then
18
+ cp -r "$SEED/." "$SITE/"
19
+ else
20
+ # Every later boot: refresh the agent itself from the image, and
21
+ # nothing else. Content, drafts, media and theme on the volume are
22
+ # the live site's own and are never touched - but the installed
23
+ # @o-a/cms-agent is code, not content, so without this a redeploy
24
+ # built from a newer agent would keep running the old one from the
25
+ # volume forever. Only the dependency manifest and its installed
26
+ # packages are copied; site.config.json (tokens) and server.js stay
27
+ # as they are on the volume.
28
+ #
29
+ # This leaves vhost/package.json (and package-lock.json) showing as
30
+ # modified in the volume's git working tree after an upgrade. That is
31
+ # deliberate: nothing here commits, since publishing is the only
32
+ # routine operation that creates a commit.
33
+ cp "$SEED/vhost/package.json" "$SITE/vhost/package.json"
34
+ if [ -f "$SEED/vhost/package-lock.json" ]; then
35
+ cp "$SEED/vhost/package-lock.json" "$SITE/vhost/package-lock.json"
36
+ fi
37
+ rm -rf "$SITE/vhost/node_modules"
38
+ cp -r "$SEED/vhost/node_modules" "$SITE/vhost/node_modules"
13
39
  fi
14
40
 
15
41
  # The site root must be a real git repository (services/startup-checks.ts) -
@@ -18,13 +44,13 @@ fi
18
44
  # git-archive-style upload of tracked file contents only, which never
19
45
  # includes .git at all. Recovered here rather than assumed away: if
20
46
  # it's missing, start a fresh repo over the already-seeded content.
21
- if [ ! -d /site/.git ]; then
22
- git -C /site init --quiet
23
- git -C /site add -A
47
+ if [ ! -d "$SITE/.git" ]; then
48
+ git -C "$SITE" init --quiet
49
+ git -C "$SITE" add -A
24
50
  GIT_AUTHOR_NAME="cms-agent" GIT_AUTHOR_EMAIL="cms-agent@localhost" \
25
51
  GIT_COMMITTER_NAME="cms-agent" GIT_COMMITTER_EMAIL="cms-agent@localhost" \
26
- git -C /site commit --quiet -m "chore: initial scaffold (recovered - .git was not part of the deploy upload)"
52
+ git -C "$SITE" commit --quiet -m "chore: initial scaffold (recovered - .git was not part of the deploy upload)"
27
53
  fi
28
54
 
29
- cd /site/vhost
55
+ cd "$SITE/vhost"
30
56
  exec node server.js
File without changes
@@ -1,3 +1,3 @@
1
1
  import type { MigrationMap } from '../services/migration-runner.ts';
2
- export declare const CURRENT_SCHEMA_VERSION = 6;
2
+ export declare const CURRENT_SCHEMA_VERSION = 7;
3
3
  export declare const migrations: MigrationMap;
@@ -1,7 +1,7 @@
1
1
  // The current content schema version. Bumping this and adding a new
2
2
  // migrations[N] entry is the only way a content shape may change
3
3
  // (constraint 4) - never a manual edit convention.
4
- export const CURRENT_SCHEMA_VERSION = 6;
4
+ export const CURRENT_SCHEMA_VERSION = 7;
5
5
  // A trivial identity migration, proving the mechanism (per the build
6
6
  // plan's Phase 1 scope): no shape change, only the version bump. Safe
7
7
  // against page.schema.json's schemaVersion: { minimum: 1 } (not an
@@ -63,10 +63,21 @@ function migrateV5ToV6(content) {
63
63
  const title = typeof content.title === 'string' ? content.title : '';
64
64
  return { ...content, schemaVersion: 6, name: title };
65
65
  }
66
+ // menu.schema.json gains an optional "name": the admin's own display
67
+ // label for a menu, editable without touching its filename (which is
68
+ // what themes reference it by, as menus.<filename>, so renaming the
69
+ // file would silently empty every nav that uses it). Optional, so no
70
+ // existing menu needs a value invented for it - the admin falls back
71
+ // to deriving one from the filename, exactly as it always has. No
72
+ // shape change for pages at all; only the version bump.
73
+ function migrateV6ToV7(content) {
74
+ return { ...content, schemaVersion: 7 };
75
+ }
66
76
  export const migrations = {
67
77
  1: migrateV1ToV2,
68
78
  2: migrateV2ToV3,
69
79
  3: migrateV3ToV4,
70
80
  4: migrateV4ToV5,
71
81
  5: migrateV5ToV6,
82
+ 6: migrateV6ToV7,
72
83
  };
@@ -168,11 +168,21 @@ export function getPageMtimeMs(config, relativePath) {
168
168
  // A menu edit affects every page's rendered nav, not just one page, so
169
169
  // the cache's freshness check needs one value covering all menus
170
170
  // together rather than per-page tracking. The max mtime across every
171
- // menu file serves that - any single menu changing bumps it. 0 (never
172
- // stale relative to anything) when there are no menus at all, matching
173
- // listFilesRecursively's own "missing directory returns []" behaviour.
171
+ // menu file covers an edit. It does not cover a menu being renamed (a
172
+ // handle change keeps the file's mtime) or deleted (removes one), so
173
+ // the menus directory's own mtime is included too: that changes
174
+ // whenever an entry is added, renamed or removed. Menus are flat, so
175
+ // the one directory is enough. 0 (never stale relative to anything)
176
+ // when there are no menus at all, matching listFilesRecursively's own
177
+ // "missing directory returns []" behaviour.
174
178
  export function getMenusMtimeMs(config) {
175
179
  let max = 0;
180
+ try {
181
+ max = statSync(config.menusRoot).mtimeMs;
182
+ }
183
+ catch {
184
+ // No content/menus/ at all - nothing to track.
185
+ }
176
186
  for (const relativePath of listFilesRecursively(config.menusRoot, config.menusRoot, '.json')) {
177
187
  try {
178
188
  const { mtimeMs } = statSync(sanitisePath(config.menusRoot, relativePath));
@@ -1,5 +1,6 @@
1
1
  import { isValidCommitAuthor } from "../services/git.js";
2
- import { ManageMenuError, saveMenu } from "../services/manage-menus.js";
2
+ import { ManageMenuError, renameMenu, saveMenu } from "../services/manage-menus.js";
3
+ import { findMenuReferences, isValidMenuHandle } from "../services/menu-references.js";
3
4
  import { PathSafetyError } from "../services/path-safety.js";
4
5
  import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
5
6
  import { requireScope } from "../services/token-auth.js";
@@ -67,6 +68,72 @@ async function handleSaveMenu(request, reply, config) {
67
68
  throw error;
68
69
  }
69
70
  }
71
+ function parseRenameMenuBody(body) {
72
+ if (typeof body !== 'object' || body === null) {
73
+ return null;
74
+ }
75
+ const { from, to, message, author } = body;
76
+ if (!isNonEmptyString(from) || !isNonEmptyString(to) || !isNonEmptyString(message) || !isValidCommitAuthor(author)) {
77
+ return null;
78
+ }
79
+ return { from, to, message, author };
80
+ }
81
+ // Takes handles (what a layout writes: menus.<handle>.items), not file
82
+ // paths - a rename can only ever move a menu within content/menus/.
83
+ // If-Match is the menu's current etag, as for a save.
84
+ async function handleRenameMenu(request, reply, config) {
85
+ const ifMatch = request.headers['if-match'];
86
+ if (typeof ifMatch !== 'string' || ifMatch.length === 0) {
87
+ reply.code(428).send({
88
+ statusCode: 428,
89
+ error: 'Precondition Required',
90
+ message: 'An If-Match header is required to rename a menu',
91
+ });
92
+ return;
93
+ }
94
+ const parsed = parseRenameMenuBody(request.body);
95
+ if (!parsed) {
96
+ reply.code(400).send({
97
+ statusCode: 400,
98
+ error: 'Bad Request',
99
+ message: 'Expected { from: string, to: string, message: string, author: { name, email } }',
100
+ });
101
+ return;
102
+ }
103
+ try {
104
+ const result = await renameMenu(config, parsed.from, parsed.to, ifMatch, parsed.message, parsed.author);
105
+ reply.header('etag', result.etag).send({ ok: true, staleThemeReferences: result.staleThemeReferences });
106
+ }
107
+ catch (error) {
108
+ if (error instanceof PathSafetyError) {
109
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: 'Not a valid menu handle' });
110
+ return;
111
+ }
112
+ if (error instanceof ManageMenuError && error.reason === 'not-found') {
113
+ reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
114
+ return;
115
+ }
116
+ if (error instanceof ManageMenuError && error.reason === 'conflict') {
117
+ reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
118
+ return;
119
+ }
120
+ if (error instanceof ManageMenuError && error.reason === 'validation-failed') {
121
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
122
+ return;
123
+ }
124
+ throw error;
125
+ }
126
+ }
127
+ // Read-only and advisory: which theme files mention a handle, so the
128
+ // admin can warn before a rename or delete empties a nav.
129
+ function handleMenuReferences(request, reply, config) {
130
+ const handle = request.query.handle;
131
+ if (typeof handle !== 'string' || !isValidMenuHandle(handle)) {
132
+ reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: 'Expected ?handle=<menu handle>' });
133
+ return;
134
+ }
135
+ reply.send({ handle, themeFiles: findMenuReferences(config, handle) });
136
+ }
70
137
  // GET/DELETE/move for menus/ paths deliberately stay on the existing
71
138
  // generic /v1/content routes (Group N) - only this write path is new.
72
139
  // Its own namespace (/v1/menus, not wedged into /v1/content), matching
@@ -74,4 +141,8 @@ async function handleSaveMenu(request, reply, config) {
74
141
  // with type-conditional write behaviour.
75
142
  export const menusRoutes = async (fastify, opts) => {
76
143
  fastify.put('/menus/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleSaveMenu(request, reply, opts.config));
144
+ // Static paths, distinct from PUT /menus/* by method (POST/GET), the
145
+ // same way POST /content/move sits beside DELETE /content/*.
146
+ fastify.post('/menus/rename', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => handleRenameMenu(request, reply, opts.config));
147
+ fastify.get('/menus/references', { preHandler: requireScope(opts.tokens, 'content') }, async (request, reply) => handleMenuReferences(request, reply, opts.config));
77
148
  };
@@ -7,6 +7,7 @@
7
7
  "required": ["schemaVersion", "items"],
8
8
  "properties": {
9
9
  "schemaVersion": { "type": "integer", "minimum": 1 },
10
+ "name": { "type": "string", "minLength": 1 },
10
11
  "items": {
11
12
  "type": "array",
12
13
  "items": {
@@ -0,0 +1,5 @@
1
+ export interface SearchResult {
2
+ url: string;
3
+ title: string;
4
+ }
5
+ export declare function queryIndex(searchIndexPath: string, term: string): SearchResult[];
@@ -0,0 +1,21 @@
1
+ import { openNodeSqliteDriver } from "./drivers/node-sqlite-driver.js";
2
+ // Never queued: an in-flight query holding an open handle during a
3
+ // concurrent rebuild's unlink just keeps reading the pre-rebuild inode
4
+ // (stale but consistent, never torn) - queuing a read against the same
5
+ // queue as writes would only add latency for no correctness benefit.
6
+ export function queryIndex(searchIndexPath, term) {
7
+ const driver = openNodeSqliteDriver(searchIndexPath);
8
+ try {
9
+ const rows = driver.prepare('SELECT url, title FROM pages_fts WHERE pages_fts MATCH ?').all(term);
10
+ // node:sqlite returns rows as [Object: null prototype] instances;
11
+ // rebuilt here as plain objects so callers (and assert.deepEqual)
12
+ // never have to know that's a driver implementation detail.
13
+ return rows.map((row) => {
14
+ const { url, title } = row;
15
+ return { url, title };
16
+ });
17
+ }
18
+ finally {
19
+ driver.close();
20
+ }
21
+ }
package/dist/server.js CHANGED
@@ -13,6 +13,7 @@ import { CHECKPOINT_AUTHOR, runCheckpoint } from "./services/checkpoint.js";
13
13
  import { startDevTunnel } from "./services/dev-tunnel.js";
14
14
  import { startIntervalJob } from "./services/interval-job.js";
15
15
  import { reindexOnBootIfMissing } from "./services/reindex-on-write.js";
16
+ import { isProcessAlive, readPidFile, removeOwnPidFile, writePidFile } from "./services/pid-file.js";
16
17
  // Never wrap v1Routes (or any route-group plugin it registers) with
17
18
  // fastify-plugin (fp()): plain app.register() gives each file its own
18
19
  // encapsulation scope by default, which Group B's auth preHandler
@@ -165,6 +166,7 @@ export async function startServer(siteRoot, options = {}) {
165
166
  scheduler.stop();
166
167
  process.removeListener('SIGTERM', shutdown);
167
168
  process.removeListener('SIGINT', shutdown);
169
+ removeOwnPidFile(booted.config);
168
170
  try {
169
171
  await doCheckpoint();
170
172
  }
@@ -185,6 +187,14 @@ export async function startServer(siteRoot, options = {}) {
185
187
  // them the two real ways to pick a different one instead of
186
188
  // rethrowing Node's own stack trace.
187
189
  if (error instanceof Error && 'code' in error && error.code === 'EADDRINUSE') {
190
+ // Most often it's this same site, started earlier in another
191
+ // terminal - say so, and how to stop it from this one.
192
+ const running = readPidFile(booted.config);
193
+ if (running && running.port === serverConfig.port && running.pid !== process.pid && isProcessAlive(running.pid)) {
194
+ console.error(`This site is already running on port ${serverConfig.port} (process ${running.pid}).`);
195
+ console.error('Stop it with "npm run stop" from vhost/, then start it again.');
196
+ process.exit(1);
197
+ }
188
198
  console.error(`Port ${serverConfig.port} is already in use.`);
189
199
  console.error(`Set a different port with the PORT environment variable (e.g. PORT=3001 node server.js), or "port" in vhost/site.config.json.`);
190
200
  process.exit(1);
@@ -200,6 +210,9 @@ export async function startServer(siteRoot, options = {}) {
200
210
  const address = app.server.address();
201
211
  if (address !== null && typeof address !== 'string') {
202
212
  console.log(`Site running at http://127.0.0.1:${address.port}`);
213
+ // Recorded only once the port is really bound, so a start that
214
+ // fails never leaves a record of a site that isn't running.
215
+ writePidFile(booted.config, address.port);
203
216
  }
204
217
  // A search index is never git-tracked (constraint 3), so a fresh
205
218
  // clone or first-ever boot has no index file - without this,
@@ -1,6 +1,6 @@
1
1
  import type { SiteConfig } from '../config.ts';
2
2
  import type { CommitAuthor } from './git.ts';
3
- export type ManageMenuReason = 'validation-failed' | 'conflict' | 'write-failed' | 'commit-failed' | 'rollback-failed';
3
+ export type ManageMenuReason = 'validation-failed' | 'not-found' | 'conflict' | 'write-failed' | 'commit-failed' | 'rollback-failed';
4
4
  export declare class ManageMenuError extends Error {
5
5
  readonly reason: ManageMenuReason;
6
6
  constructor(reason: ManageMenuReason, message: string, options?: {
@@ -8,3 +8,8 @@ export declare class ManageMenuError extends Error {
8
8
  });
9
9
  }
10
10
  export declare function saveMenu(config: SiteConfig, relativePath: string, content: unknown, expectedEtag: string, message: string, author: CommitAuthor): Promise<string>;
11
+ export interface RenameMenuResult {
12
+ etag: string;
13
+ staleThemeReferences: string[];
14
+ }
15
+ export declare function renameMenu(config: SiteConfig, from: string, to: string, expectedEtag: string, message: string, author: CommitAuthor): Promise<RenameMenuResult>;
@@ -1,8 +1,9 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
  import { ContentReadError, readContentFile } from "./content-read.js";
4
4
  import { computeEtag, etagsMatch } from "./etag.js";
5
5
  import { commitPaths } from "./git.js";
6
+ import { findMenuReferences, isValidMenuHandle } from "./menu-references.js";
6
7
  import { sanitisePath } from "./path-safety.js";
7
8
  import { validateMenu } from "./validation.js";
8
9
  import { enqueue } from "./write-queue.js";
@@ -82,3 +83,56 @@ async function saveMenuJob(config, relativePath, content, expectedEtag, message,
82
83
  export function saveMenu(config, relativePath, content, expectedEtag, message, author) {
83
84
  return enqueue(() => saveMenuJob(config, relativePath, content, expectedEtag, message, author));
84
85
  }
86
+ // Changes a menu's handle: moves content/menus/<from>.json to
87
+ // <to>.json in one commit. Contents are untouched, so the returned
88
+ // etag is the same one the menu already had. Refuses rather than
89
+ // overwriting when <to> is taken, and checks If-Match against <from>
90
+ // like every other menu write, so a rename made from a stale view is
91
+ // refused rather than applied.
92
+ async function renameMenuJob(config, from, to, expectedEtag, message, author) {
93
+ if (!isValidMenuHandle(from) || !isValidMenuHandle(to)) {
94
+ throw new ManageMenuError('validation-failed', 'A menu handle may only contain letters, numbers, hyphens and underscores');
95
+ }
96
+ if (from === to) {
97
+ throw new ManageMenuError('validation-failed', `The menu is already called "${to}"`);
98
+ }
99
+ mkdirSync(config.menusRoot, { recursive: true });
100
+ const fromPath = sanitisePath(config.menusRoot, `${from}.json`);
101
+ const toPath = sanitisePath(config.menusRoot, `${to}.json`);
102
+ const currentEtag = readCurrentEtag(config, `${from}.json`);
103
+ if (currentEtag === null) {
104
+ throw new ManageMenuError('not-found', `No menu with the handle "${from}"`);
105
+ }
106
+ if (!etagsMatch(currentEtag, expectedEtag)) {
107
+ throw new ManageMenuError('conflict', `If-Match "${expectedEtag}" does not match the current ETag for "${from}.json"`);
108
+ }
109
+ // existsSync is case-insensitive on a case-insensitive filesystem
110
+ // (macOS by default), so "main" -> "Main" is refused there rather
111
+ // than risking a rename onto itself. Harmless: pick another handle.
112
+ if (existsSync(toPath)) {
113
+ throw new ManageMenuError('conflict', `A menu with the handle "${to}" already exists`);
114
+ }
115
+ try {
116
+ renameSync(fromPath, toPath);
117
+ }
118
+ catch (error) {
119
+ throw new ManageMenuError('write-failed', `Could not rename "${from}" to "${to}"`, { cause: error });
120
+ }
121
+ try {
122
+ commitPaths(config.siteRoot, [fromPath, toPath], message, author);
123
+ }
124
+ catch (error) {
125
+ try {
126
+ renameSync(toPath, fromPath);
127
+ }
128
+ catch (rollbackError) {
129
+ throw new ManageMenuError('rollback-failed', 'Menu rename failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: rollbackError });
130
+ }
131
+ const detail = error instanceof Error ? error.message : String(error);
132
+ throw new ManageMenuError('commit-failed', `Menu rename failed: ${detail}`, { cause: error });
133
+ }
134
+ return { etag: currentEtag, staleThemeReferences: findMenuReferences(config, from) };
135
+ }
136
+ export function renameMenu(config, from, to, expectedEtag, message, author) {
137
+ return enqueue(() => renameMenuJob(config, from, to, expectedEtag, message, author));
138
+ }
@@ -0,0 +1,3 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export declare function isValidMenuHandle(handle: string): boolean;
3
+ export declare function findMenuReferences(config: SiteConfig, handle: string): string[];
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { listFilesRecursively } from "./fs-walk.js";
4
+ // A menu's handle is its filename without .json - what a layout writes
5
+ // as menus.<handle>.items. Menus are flat (no subfolders), and these
6
+ // are the only characters a Liquid dot-lookup reads as one name.
7
+ const MENU_HANDLE = /^[A-Za-z0-9_-]+$/;
8
+ export function isValidMenuHandle(handle) {
9
+ return MENU_HANDLE.test(handle);
10
+ }
11
+ function escapeRegExp(value) {
12
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+ }
14
+ // Which theme files mention a menu by handle, as site-relative paths
15
+ // (e.g. "theme/layouts/theme.liquid"). Read-only and advisory: it lets
16
+ // the admin warn before a rename or delete leaves a nav empty. Matches
17
+ // both menus.<handle> and menus['<handle>'] / menus["<handle>"], and
18
+ // the dot form only as a whole name, so "main" never matches
19
+ // menus.mainFooter. A handle built at runtime ({% assign m = ... %})
20
+ // can't be seen here, which is why this only ever informs a warning,
21
+ // never blocks anything.
22
+ export function findMenuReferences(config, handle) {
23
+ if (!isValidMenuHandle(handle)) {
24
+ return [];
25
+ }
26
+ const escaped = escapeRegExp(handle);
27
+ const pattern = new RegExp(`menus(?:\\.${escaped}(?![A-Za-z0-9_-])|\\[\\s*(['"])${escaped}\\1\\s*\\])`);
28
+ return listFilesRecursively(config.themeRoot, config.siteRoot, '.liquid')
29
+ .filter((relativePath) => {
30
+ try {
31
+ return pattern.test(readFileSync(join(config.siteRoot, relativePath), 'utf-8'));
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ })
37
+ .sort();
38
+ }
@@ -1,5 +1,6 @@
1
1
  import type { SiteConfig } from '../config.ts';
2
2
  export interface MenuContent {
3
+ name?: string;
3
4
  items: Array<{
4
5
  label: string;
5
6
  url: string;
@@ -7,7 +7,8 @@ function tryReadMenu(fullPath) {
7
7
  if (!Array.isArray(parsed.items)) {
8
8
  return null;
9
9
  }
10
- return { items: parsed.items };
10
+ const items = parsed.items;
11
+ return typeof parsed.name === 'string' ? { name: parsed.name, items } : { items };
11
12
  }
12
13
  catch {
13
14
  // One malformed menu file is skipped individually, not fatal to
@@ -0,0 +1,13 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export interface PidRecord {
3
+ pid: number;
4
+ serverPid: number;
5
+ port: number;
6
+ startedAt: string;
7
+ }
8
+ export declare function pidFilePath(config: SiteConfig): string;
9
+ export declare function writePidFile(config: SiteConfig, port: number): void;
10
+ export declare function readPidFile(config: SiteConfig): PidRecord | null;
11
+ export declare function removeOwnPidFile(config: SiteConfig): void;
12
+ export declare function removePidFile(config: SiteConfig): void;
13
+ export declare function isProcessAlive(pid: number): boolean;
@@ -0,0 +1,54 @@
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export function pidFilePath(config) {
4
+ return join(config.dataRoot, 'server.pid');
5
+ }
6
+ // Node sets WATCH_REPORT_DEPENDENCIES in the child it runs under
7
+ // --watch, whose parent is then the watcher itself.
8
+ function stopTarget() {
9
+ return process.env.WATCH_REPORT_DEPENDENCIES ? process.ppid : process.pid;
10
+ }
11
+ export function writePidFile(config, port) {
12
+ const record = { pid: stopTarget(), serverPid: process.pid, port, startedAt: new Date().toISOString() };
13
+ mkdirSync(config.dataRoot, { recursive: true });
14
+ writeFileSync(pidFilePath(config), `${JSON.stringify(record, null, 2)}\n`);
15
+ }
16
+ export function readPidFile(config) {
17
+ try {
18
+ const parsed = JSON.parse(readFileSync(pidFilePath(config), 'utf-8'));
19
+ const { pid, serverPid, port, startedAt } = parsed;
20
+ if (typeof pid !== 'number' ||
21
+ typeof serverPid !== 'number' ||
22
+ typeof port !== 'number' ||
23
+ typeof startedAt !== 'string') {
24
+ return null;
25
+ }
26
+ return { pid, serverPid, port, startedAt };
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ // Only removes the file if it is still this process's own record: under
33
+ // `npm run dev` the watcher starts a new process on a theme change, and
34
+ // the old one shutting down must not delete the record the new one has
35
+ // just written.
36
+ export function removeOwnPidFile(config) {
37
+ if (readPidFile(config)?.serverPid === process.pid) {
38
+ removePidFile(config);
39
+ }
40
+ }
41
+ export function removePidFile(config) {
42
+ rmSync(pidFilePath(config), { force: true });
43
+ }
44
+ // Whether a process with this id exists. EPERM means it does, but
45
+ // belongs to another user.
46
+ export function isProcessAlive(pid) {
47
+ try {
48
+ process.kill(pid, 0);
49
+ return true;
50
+ }
51
+ catch (error) {
52
+ return error instanceof Error && 'code' in error && error.code === 'EPERM';
53
+ }
54
+ }
@@ -0,0 +1,3 @@
1
+ export declare function isBlogUrl(url: string): boolean;
2
+ export declare function urlToPostPath(url: string): string | null;
3
+ export declare function postPathToUrl(relativePostPath: string): string;
@@ -0,0 +1,27 @@
1
+ // Pure, filesystem-free mapping between a /blog/<slug> URL and a
2
+ // post's path relative to postsRoot. Unlike pages' arbitrary nested
3
+ // paths (about.json beside a sibling about/ directory), posts are
4
+ // flat only - a URL with more than one segment after /blog/ is never
5
+ // a valid post URL, enforced here rather than left to sanitisePath.
6
+ const BLOG_PREFIX = '/blog/';
7
+ // /blog is a permanently reserved namespace: both "/blog" itself (no
8
+ // slug) and every "/blog/..." URL are recognised here, so a caller can
9
+ // route the whole namespace to post resolution before ever checking
10
+ // for a page, matching the confirmed reserved-namespace decision.
11
+ export function isBlogUrl(url) {
12
+ return url === '/blog' || url.startsWith(BLOG_PREFIX);
13
+ }
14
+ export function urlToPostPath(url) {
15
+ if (!url.startsWith(BLOG_PREFIX)) {
16
+ return null;
17
+ }
18
+ const slug = url.slice(BLOG_PREFIX.length);
19
+ if (slug === '' || slug.includes('/')) {
20
+ return null;
21
+ }
22
+ return `${slug}.json`;
23
+ }
24
+ export function postPathToUrl(relativePostPath) {
25
+ const withoutExtension = relativePostPath.replace(/\.json$/, '');
26
+ return `${BLOG_PREFIX}${withoutExtension}`;
27
+ }
@@ -0,0 +1,11 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export type ResolvedBlogUrl = {
3
+ kind: 'post';
4
+ relativePath: string;
5
+ } | {
6
+ kind: 'redirect';
7
+ to: string;
8
+ } | {
9
+ kind: 'not-found';
10
+ };
11
+ export declare function resolveBlogUrl(config: SiteConfig, url: string): ResolvedBlogUrl;
@@ -0,0 +1,31 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { sanitisePath } from "./path-safety.js";
3
+ import { buildRedirectLookup, loadRedirects } from "./redirects.js";
4
+ import { urlToPostPath } from "./post-urls.js";
5
+ // Mirrors resolve-url.ts's shape exactly (a live post always wins over
6
+ // a redirect at the same URL), kept as its own distinct result type
7
+ // rather than reusing ResolvedUrl - clearer branching at the call site
8
+ // and avoids touching Group C's already-tested resolve-url.ts.
9
+ //
10
+ // Unlike pagesRoot (always expected to exist on a real site),
11
+ // postsRoot is optional - a site that has never used blog posts has
12
+ // no content/posts/ directory at all. sanitisePath calls realpathSync
13
+ // directly on its root argument, which throws a raw, uncaught ENOENT
14
+ // if that root itself is missing - so postsRoot's existence is checked
15
+ // first, before ever calling sanitisePath, rather than letting a
16
+ // perfectly ordinary "no posts yet" site crash on its first /blog/ hit.
17
+ export function resolveBlogUrl(config, url) {
18
+ const relativePath = urlToPostPath(url);
19
+ if (relativePath !== null && existsSync(config.postsRoot)) {
20
+ const postFile = sanitisePath(config.postsRoot, relativePath);
21
+ if (existsSync(postFile)) {
22
+ return { kind: 'post', relativePath };
23
+ }
24
+ }
25
+ const lookup = buildRedirectLookup(loadRedirects(config).entries);
26
+ const to = lookup.get(url);
27
+ if (to !== undefined) {
28
+ return { kind: 'redirect', to };
29
+ }
30
+ return { kind: 'not-found' };
31
+ }
File without changes
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from 'node:path';
3
+ import { loadSiteConfig } from "../config.js";
4
+ import { stopSite } from "./stop-site.js";
5
+ // Run from vhost/ (the scaffold's own "stop" script), so the site root
6
+ // is one level up - the same relationship check-site relies on.
7
+ const config = loadSiteConfig(resolve(process.cwd(), '..'));
8
+ const result = await stopSite(config);
9
+ switch (result.outcome) {
10
+ case 'not-running':
11
+ console.log("The site isn't running.");
12
+ break;
13
+ case 'removed-stale':
14
+ console.log(`The site isn't running. Removed a leftover record of process ${result.pid}, which has already ended.`);
15
+ break;
16
+ case 'not-a-site':
17
+ console.error(`Process ${result.pid} is recorded as this site, but no site is answering on port ${result.port}, so nothing was stopped.`);
18
+ console.error('If you are sure the site is not running, delete vhost/data/server.pid.');
19
+ process.exit(1);
20
+ break;
21
+ case 'stopped':
22
+ console.log(`Stopped the site (was on port ${result.port}).`);
23
+ break;
24
+ case 'still-stopping':
25
+ console.error(`Asked the site to stop (process ${result.pid}), but it is still shutting down.`);
26
+ process.exit(1);
27
+ break;
28
+ }
@@ -0,0 +1,26 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export type StopSiteResult = {
3
+ outcome: 'not-running';
4
+ } | {
5
+ outcome: 'removed-stale';
6
+ pid: number;
7
+ } | {
8
+ outcome: 'not-a-site';
9
+ pid: number;
10
+ port: number;
11
+ } | {
12
+ outcome: 'stopped';
13
+ port: number;
14
+ } | {
15
+ outcome: 'still-stopping';
16
+ pid: number;
17
+ };
18
+ export interface StopSiteDeps {
19
+ isAlive: (pid: number) => boolean;
20
+ signal: (pid: number) => void;
21
+ isSiteListening: (port: number) => Promise<boolean>;
22
+ sleep: (ms: number) => Promise<void>;
23
+ timeoutMs: number;
24
+ }
25
+ export declare const defaultStopSiteDeps: StopSiteDeps;
26
+ export declare function stopSite(config: SiteConfig, deps?: StopSiteDeps): Promise<StopSiteResult>;
@@ -0,0 +1,56 @@
1
+ import { isProcessAlive, readPidFile, removePidFile } from "../services/pid-file.js";
2
+ // GET /v1/capabilities is unauthenticated and always answers with the
3
+ // agent's own version, so it's the check that something on the recorded
4
+ // port really is a site - not just any process that happens to have
5
+ // been given the recorded id after the site's own ended.
6
+ async function capabilitiesAnswer(port) {
7
+ try {
8
+ const response = await fetch(new URL('/v1/capabilities', `http://127.0.0.1:${port}`), {
9
+ signal: AbortSignal.timeout(2_000),
10
+ });
11
+ const body = (await response.json());
12
+ return response.ok && typeof body.agentVersion === 'string';
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ export const defaultStopSiteDeps = {
19
+ isAlive: isProcessAlive,
20
+ signal: (pid) => process.kill(pid, 'SIGTERM'),
21
+ isSiteListening: capabilitiesAnswer,
22
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
23
+ // Long enough for the final draft checkpoint a graceful shutdown runs.
24
+ timeoutMs: 15_000,
25
+ };
26
+ // Stops the site recorded in vhost/data/server.pid with SIGTERM - the
27
+ // same graceful shutdown as Ctrl+C (the server closes and runs its
28
+ // final draft checkpoint) - then waits for it to exit. It only ever
29
+ // signals a process that is both alive and answering as a site on the
30
+ // recorded port, so a leftover file from a crash never gets an
31
+ // unrelated process that has since been given the same id killed.
32
+ export async function stopSite(config, deps = defaultStopSiteDeps) {
33
+ const record = readPidFile(config);
34
+ if (!record) {
35
+ return { outcome: 'not-running' };
36
+ }
37
+ if (!deps.isAlive(record.pid)) {
38
+ removePidFile(config);
39
+ return { outcome: 'removed-stale', pid: record.pid };
40
+ }
41
+ if (!(await deps.isSiteListening(record.port))) {
42
+ return { outcome: 'not-a-site', pid: record.pid, port: record.port };
43
+ }
44
+ deps.signal(record.pid);
45
+ const pollMs = 200;
46
+ for (let waited = 0; waited < deps.timeoutMs; waited += pollMs) {
47
+ if (!deps.isAlive(record.pid) && !deps.isAlive(record.serverPid)) {
48
+ // The server removes its own record on a graceful shutdown; this
49
+ // covers one that exited without getting that far.
50
+ removePidFile(config);
51
+ return { outcome: 'stopped', port: record.port };
52
+ }
53
+ await deps.sleep(pollMs);
54
+ }
55
+ return { outcome: 'still-stopping', pid: record.pid };
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o-a/cms-agent",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -25,7 +25,8 @@
25
25
  "create-site": "dist/create-site/cli.js",
26
26
  "mint-token": "dist/create-site/mint-token-cli.js",
27
27
  "check-site": "dist/site-check/cli.js",
28
- "seed-media": "dist/media/seed-media-cli.js"
28
+ "seed-media": "dist/media/seed-media-cli.js",
29
+ "stop-site": "dist/stop-site/cli.js"
29
30
  },
30
31
  "files": [
31
32
  "dist"