@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.
- package/dist/create-site/cli.js +0 -0
- package/dist/create-site/generate-site.js +3 -0
- package/dist/create-site/mint-token-cli.js +0 -0
- package/dist/create-site/template/AGENTS.md +7 -5
- package/dist/create-site/template/content/menus/footerCompany.json +1 -1
- package/dist/create-site/template/content/menus/footerProduct.json +1 -1
- package/dist/create-site/template/content/menus/footerResources.json +1 -1
- package/dist/create-site/template/content/menus/main.json +1 -1
- package/dist/create-site/template/content/pages/404.json +1 -1
- package/dist/create-site/template/content/pages/about/careers.json +1 -1
- package/dist/create-site/template/content/pages/about/team.json +1 -1
- package/dist/create-site/template/content/pages/about.json +1 -1
- package/dist/create-site/template/content/pages/docs/deployment.json +1 -1
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +1 -1
- package/dist/create-site/template/content/pages/docs/getting-started.json +1 -1
- package/dist/create-site/template/content/pages/docs.json +1 -1
- package/dist/create-site/template/content/pages/index.json +1 -1
- package/dist/create-site/template/vhost/docker-entrypoint.sh +33 -7
- package/dist/media/seed-media-cli.js +0 -0
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +12 -1
- package/dist/renderer/render-page.js +13 -3
- package/dist/routes/menus.js +72 -1
- package/dist/schemas/menu.schema.json +1 -0
- package/dist/search/query-index.d.ts +5 -0
- package/dist/search/query-index.js +21 -0
- package/dist/server.js +13 -0
- package/dist/services/manage-menus.d.ts +6 -1
- package/dist/services/manage-menus.js +55 -1
- package/dist/services/menu-references.d.ts +3 -0
- package/dist/services/menu-references.js +38 -0
- package/dist/services/menus.d.ts +1 -0
- package/dist/services/menus.js +2 -1
- package/dist/services/pid-file.d.ts +13 -0
- package/dist/services/pid-file.js +54 -0
- package/dist/services/post-urls.d.ts +3 -0
- package/dist/services/post-urls.js +27 -0
- package/dist/services/resolve-blog-url.d.ts +11 -0
- package/dist/services/resolve-blog-url.js +31 -0
- package/dist/site-check/cli.js +0 -0
- package/dist/stop-site/cli.d.ts +2 -0
- package/dist/stop-site/cli.js +28 -0
- package/dist/stop-site/stop-site.d.ts +26 -0
- package/dist/stop-site/stop-site.js +56 -0
- package/package.json +3 -2
package/dist/create-site/cli.js
CHANGED
|
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.<
|
|
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 `
|
|
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":
|
|
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/<
|
|
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":
|
|
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,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 /
|
|
12
|
-
cp -r
|
|
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
|
|
22
|
-
git -C
|
|
23
|
-
git -C
|
|
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
|
|
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 /
|
|
55
|
+
cd "$SITE/vhost"
|
|
30
56
|
exec node server.js
|
|
File without changes
|
package/dist/migrations/index.js
CHANGED
|
@@ -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 =
|
|
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
|
|
172
|
-
//
|
|
173
|
-
//
|
|
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));
|
package/dist/routes/menus.js
CHANGED
|
@@ -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
|
};
|
|
@@ -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,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
|
+
}
|
package/dist/services/menus.d.ts
CHANGED
package/dist/services/menus.js
CHANGED
|
@@ -7,7 +7,8 @@ function tryReadMenu(fullPath) {
|
|
|
7
7
|
if (!Array.isArray(parsed.items)) {
|
|
8
8
|
return null;
|
|
9
9
|
}
|
|
10
|
-
|
|
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,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
|
+
}
|
package/dist/site-check/cli.js
CHANGED
|
File without changes
|
|
@@ -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.
|
|
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"
|