@o-a/cms-agent 0.5.2 → 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.
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
@@ -430,6 +430,7 @@ From `vhost/`:
430
430
  npm start # boots the site on the port set in vhost/site.config.json
431
431
  npm run tunnel # same, plus a public tunnel URL for sharing a preview
432
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
433
434
  ```
434
435
 
435
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.
File without changes
@@ -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,
@@ -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.2",
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"