canopycms 0.0.64 → 0.0.66-int.81
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/README.md +1 -3
- package/dist/api/assets.js +9 -0
- package/dist/auth/file-based-auth-cache.js +14 -2
- package/dist/branch-health.d.ts +20 -0
- package/dist/branch-health.js +6 -1
- package/dist/cli/cli.d.ts +17 -0
- package/dist/cli/cli.js +89 -4
- package/dist/cli/generate-ai-content.js +48 -0
- package/dist/cli/init.js +31 -4
- package/dist/cli/template-files/Dockerfile.cms.template +8 -2
- package/dist/cli/template-files/cms-stack.ts.template +33 -6
- package/dist/cli/template-files/deploy-cms.yml.template +21 -8
- package/dist/content-reader.d.ts +25 -1
- package/dist/content-reader.js +15 -0
- package/dist/content-store.d.ts +39 -0
- package/dist/content-store.js +62 -3
- package/dist/context.d.ts +14 -4
- package/dist/context.js +15 -4
- package/dist/dev-content-watcher.d.ts +10 -0
- package/dist/dev-content-watcher.js +120 -29
- package/dist/task-queue/task-queue.js +10 -1
- package/dist/url-exclusivity-fixtures.d.ts +76 -0
- package/dist/url-exclusivity-fixtures.js +119 -0
- package/dist/url-path-resolver.js +8 -4
- package/dist/utils/git.d.ts +17 -0
- package/dist/utils/git.js +61 -0
- package/dist/worker/cms-worker.d.ts +22 -0
- package/dist/worker/cms-worker.js +262 -11
- package/package.json +2 -1
package/dist/content-store.d.ts
CHANGED
|
@@ -289,6 +289,45 @@ export declare class ContentStore {
|
|
|
289
289
|
*/
|
|
290
290
|
getSchemaItems(): IterableIterator<FlatSchemaItem>;
|
|
291
291
|
private assertSchemaItem;
|
|
292
|
+
/**
|
|
293
|
+
* Is this path a COLLECTION schema item?
|
|
294
|
+
*
|
|
295
|
+
* The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
|
|
296
|
+
* point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
|
|
297
|
+
* Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
|
|
298
|
+
* both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
|
|
299
|
+
* first-wins and would not.
|
|
300
|
+
*
|
|
301
|
+
* Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
|
|
302
|
+
* with subcollections and no entries of its own is legal, and mirroring that stricter test here
|
|
303
|
+
* would reject something `buildPaths` accepts.
|
|
304
|
+
*
|
|
305
|
+
* Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
|
|
306
|
+
* `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
|
|
307
|
+
*/
|
|
308
|
+
isCollectionPath(collectionPath: LogicalPath): boolean;
|
|
309
|
+
/**
|
|
310
|
+
* Does this collection declare `entryTypeName` in its `entries` config?
|
|
311
|
+
*
|
|
312
|
+
* Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
|
|
313
|
+
* whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
|
|
314
|
+
* scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
|
|
315
|
+
* was renamed out of the schema stays findable and therefore still editable, renameable and
|
|
316
|
+
* deletable. Only URL resolution consults this, so what enumeration hides is not served.
|
|
317
|
+
*
|
|
318
|
+
* Returns FALSE when the collection declares no `entries` at all, which is stricter than
|
|
319
|
+
* `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
|
|
320
|
+
* surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
|
|
321
|
+
* outright for a collection with no `entries`. A collections-only container therefore publishes
|
|
322
|
+
* nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
|
|
323
|
+
* advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
|
|
324
|
+
* been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
|
|
325
|
+
* or by retrofit, and it is invisible to the sitemap and to static params either way.
|
|
326
|
+
*
|
|
327
|
+
* Returns true for a path that is not a collection at all, because that is rule 1's question,
|
|
328
|
+
* not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
|
|
329
|
+
*/
|
|
330
|
+
declaresEntryType(collectionPath: LogicalPath, entryTypeName: string): boolean;
|
|
292
331
|
private assertCollection;
|
|
293
332
|
/**
|
|
294
333
|
* Lock key for an existing entry, addressed by its permanent content ID
|
package/dist/content-store.js
CHANGED
|
@@ -391,6 +391,54 @@ export class ContentStore {
|
|
|
391
391
|
}
|
|
392
392
|
return item;
|
|
393
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* Is this path a COLLECTION schema item?
|
|
396
|
+
*
|
|
397
|
+
* The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
|
|
398
|
+
* point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
|
|
399
|
+
* Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
|
|
400
|
+
* both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
|
|
401
|
+
* first-wins and would not.
|
|
402
|
+
*
|
|
403
|
+
* Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
|
|
404
|
+
* with subcollections and no entries of its own is legal, and mirroring that stricter test here
|
|
405
|
+
* would reject something `buildPaths` accepts.
|
|
406
|
+
*
|
|
407
|
+
* Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
|
|
408
|
+
* `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
|
|
409
|
+
*/
|
|
410
|
+
isCollectionPath(collectionPath) {
|
|
411
|
+
return this.schemaIndex.get(normalizeFilesystemPath(collectionPath))?.type === 'collection';
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Does this collection declare `entryTypeName` in its `entries` config?
|
|
415
|
+
*
|
|
416
|
+
* Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
|
|
417
|
+
* whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
|
|
418
|
+
* scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
|
|
419
|
+
* was renamed out of the schema stays findable and therefore still editable, renameable and
|
|
420
|
+
* deletable. Only URL resolution consults this, so what enumeration hides is not served.
|
|
421
|
+
*
|
|
422
|
+
* Returns FALSE when the collection declares no `entries` at all, which is stricter than
|
|
423
|
+
* `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
|
|
424
|
+
* surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
|
|
425
|
+
* outright for a collection with no `entries`. A collections-only container therefore publishes
|
|
426
|
+
* nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
|
|
427
|
+
* advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
|
|
428
|
+
* been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
|
|
429
|
+
* or by retrofit, and it is invisible to the sitemap and to static params either way.
|
|
430
|
+
*
|
|
431
|
+
* Returns true for a path that is not a collection at all, because that is rule 1's question,
|
|
432
|
+
* not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
|
|
433
|
+
*/
|
|
434
|
+
declaresEntryType(collectionPath, entryTypeName) {
|
|
435
|
+
const item = this.schemaIndex.get(normalizeFilesystemPath(collectionPath));
|
|
436
|
+
if (item?.type !== 'collection')
|
|
437
|
+
return true;
|
|
438
|
+
if (!item.entries)
|
|
439
|
+
return false;
|
|
440
|
+
return item.entries.some((e) => e.name === entryTypeName);
|
|
441
|
+
}
|
|
394
442
|
assertCollection(collectionPath) {
|
|
395
443
|
const item = this.assertSchemaItem(collectionPath);
|
|
396
444
|
if (item.type !== 'collection') {
|
|
@@ -499,9 +547,20 @@ export class ContentStore {
|
|
|
499
547
|
const rootWithSep = this.root.endsWith(path.sep) ? this.root : `${this.root}${path.sep}`;
|
|
500
548
|
// Entry-type items: delegate to their parent collection.
|
|
501
549
|
// Uses the same {type}.{slug}.{id}.{ext} pattern as all entries.
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
550
|
+
//
|
|
551
|
+
// This branch is for DIRECT ContentStore usage -- store.read('content/home', ''), and the
|
|
552
|
+
// read({ entryPath: 'content/home' }) API built on it, where the slug defaults to the entry
|
|
553
|
+
// type's own name. The API layer resolves paths via resolvePath(), which returns the parent
|
|
554
|
+
// collection directly and so never lands here.
|
|
555
|
+
//
|
|
556
|
+
// That used to be an observation, and it was WRONG: readByUrlPath reached this branch too,
|
|
557
|
+
// because `resolveUrlPathCandidates` happily produces `content/<typeName>` for the URL
|
|
558
|
+
// `/<typeName>` and the delegation below then answered it with the parent collection's index
|
|
559
|
+
// entry -- a URL no forward surface publishes. It is now enforced rather than assumed:
|
|
560
|
+
// readByUrlPath requires every candidate's entryPath to be a collection (see
|
|
561
|
+
// `isCollectionPath` and `ReadContentInput.urlAddressableOnly`). Narrowing the delegation
|
|
562
|
+
// ITSELF was the wrong fix -- write()/renameEntry()/delete() resolve through here as well,
|
|
563
|
+
// and the by-URL rule has no business constraining them.
|
|
505
564
|
if (schemaItem.type === 'entry-type') {
|
|
506
565
|
const parentPath = schemaItem.parentPath || '';
|
|
507
566
|
const parentCollection = this.schemaIndex.get(parentPath);
|
package/dist/context.d.ts
CHANGED
|
@@ -76,12 +76,22 @@ export interface CanopyBuildContext {
|
|
|
76
76
|
* Read content by URL path, resolving the collection/entry split automatically.
|
|
77
77
|
*
|
|
78
78
|
* Tries direct entry match first (last segment = slug, rest = collection path),
|
|
79
|
-
* then falls back to index entry (full path = collection, slug = 'index').
|
|
79
|
+
* then falls back to index entry (full path = collection, slug = 'index'). Both attempts
|
|
80
|
+
* require the collection part to be a real collection — see below.
|
|
80
81
|
* Root path '/' resolves to the content root's index entry.
|
|
81
82
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
83
|
+
* Resolves ONLY what `listEntries` publishes — `readByUrlPath(item.urlPath)` reaches the entry,
|
|
84
|
+
* and no other spelling does. Three shapes that used to resolve are therefore null now:
|
|
85
|
+
* `/x/index` (the literal spelling of an index entry's collapsed URL, in any case),
|
|
86
|
+
* `/<collection>/<entryTypeName>` and `/<collection>/<entryTypeName>/<slug>` (an entry-type
|
|
87
|
+
* path is not a collection, so it is not part of any published URL), and an entry whose
|
|
88
|
+
* on-disk type token its collection does not declare (`listEntries` skips those too). A
|
|
89
|
+
* collection literally named `index` is unaffected — the index fallback resolves it.
|
|
90
|
+
*
|
|
91
|
+
* `read({ entryPath: 'content/home' })` is deliberately NOT narrowed: addressing an entry
|
|
92
|
+
* structurally, by its schema path, is a different question from addressing it by its
|
|
93
|
+
* published URL, and it is the only way to reach a singleton without knowing its slug.
|
|
94
|
+
*
|
|
85
95
|
* Returns null if no content matches the path — including collection URLs that have no
|
|
86
96
|
* index entry (use buildContentTree for those) and non-entry/invalid paths such as
|
|
87
97
|
* `/favicon.ico` or Next internals (the slug validator rejects them, treated as a miss).
|
package/dist/context.js
CHANGED
|
@@ -60,8 +60,12 @@ export function createCanopyContext(options) {
|
|
|
60
60
|
const user = await getUser();
|
|
61
61
|
// Create base content reader
|
|
62
62
|
const baseReader = createContentReader({ services });
|
|
63
|
-
// Wrap reader to inject user automatically, validating strings → branded types at this
|
|
64
|
-
|
|
63
|
+
// Wrap reader to inject user automatically, validating strings → branded types at this
|
|
64
|
+
// boundary. `extra` carries options that are NOT part of the public `read` surface -- today
|
|
65
|
+
// just readByUrlPath's URL-addressability gate. Kept as a separate inner function rather than
|
|
66
|
+
// an optional second parameter on the `CanopyContext['read']`-typed closure so the two call
|
|
67
|
+
// sites stay visible and nobody widens the public API by accident.
|
|
68
|
+
const readWithOptions = async (input, extra) => {
|
|
65
69
|
const entryPath = createLogicalPath(input.entryPath);
|
|
66
70
|
let slug;
|
|
67
71
|
if (input.slug) {
|
|
@@ -77,9 +81,11 @@ export function createCanopyContext(options) {
|
|
|
77
81
|
branch: input.branch,
|
|
78
82
|
user,
|
|
79
83
|
resolveReferences: input.resolveReferences ?? true,
|
|
84
|
+
...extra,
|
|
80
85
|
};
|
|
81
86
|
return baseReader.read(readInput);
|
|
82
87
|
};
|
|
88
|
+
const read = (input) => readWithOptions(input);
|
|
83
89
|
const readByUrlPath = async (urlPath, options) => {
|
|
84
90
|
const contentRoot = services.config.contentRoot || 'content';
|
|
85
91
|
const candidates = resolveUrlPathCandidates(urlPath, contentRoot);
|
|
@@ -93,12 +99,17 @@ export function createCanopyContext(options) {
|
|
|
93
99
|
if (!parseSlug(candidate.slug).ok)
|
|
94
100
|
continue;
|
|
95
101
|
try {
|
|
96
|
-
return await
|
|
102
|
+
return await readWithOptions({
|
|
97
103
|
entryPath: candidate.entryPath,
|
|
98
104
|
slug: candidate.slug,
|
|
99
105
|
branch,
|
|
100
106
|
resolveReferences,
|
|
101
|
-
}
|
|
107
|
+
},
|
|
108
|
+
// This is a read BY PUBLISHED URL, so it must accept only what listEntries publishes.
|
|
109
|
+
// See ReadContentInput.urlAddressableOnly for the two rules and why they live in the
|
|
110
|
+
// reader (which holds the branch-correct schema) rather than in the candidate builder
|
|
111
|
+
// (which is pure and schema-free by design).
|
|
112
|
+
{ urlAddressableOnly: true });
|
|
102
113
|
}
|
|
103
114
|
catch (err) {
|
|
104
115
|
// Swallow "not found" errors from trying candidate paths, and FORBIDDEN (a denied
|
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
*
|
|
16
16
|
* All logic lives here in the core package; framework adapters just call startDevContentWatcher() once
|
|
17
17
|
* at dev startup. See packages/canopycms-next/src/context-wrapper.ts for the Next wiring.
|
|
18
|
+
*
|
|
19
|
+
* ## Divergence is a CONDITION, not an event
|
|
20
|
+
*
|
|
21
|
+
* It stays true until someone runs `sync push`, so the two things this module has to get right are
|
|
22
|
+
* (a) not re-printing the same condition into a scrolling request log, and (b) making the one printing
|
|
23
|
+
* of it hard to scroll past. Both are handled below by `reportOnce` + the gutter formatting; see
|
|
24
|
+
* `.claude/future-tasks/dev-divergence-in-app-surface.md` for the in-app surface that would replace
|
|
25
|
+
* the terminal as the primary home for this.
|
|
18
26
|
*/
|
|
19
27
|
import type { CanopyServices } from './services.js';
|
|
20
28
|
import type { DevContentSyncMode } from './config/types.js';
|
|
@@ -28,6 +36,8 @@ export interface StartDevContentWatcherOptions {
|
|
|
28
36
|
/** Warning sink. Defaults to console.warn. */
|
|
29
37
|
warn?: (message: string) => void;
|
|
30
38
|
}
|
|
39
|
+
/** Test-only: dispose every armed watcher and drop all cross-module dedupe state. */
|
|
40
|
+
export declare function resetDevContentWatchersForTests(): void;
|
|
31
41
|
/**
|
|
32
42
|
* Start watching the working-tree content directory for divergence from the served dev branch clone.
|
|
33
43
|
* Returns a disposer that stops the watcher. A no-op (returns immediately) when mode is 'off', when
|
|
@@ -15,35 +15,98 @@
|
|
|
15
15
|
*
|
|
16
16
|
* All logic lives here in the core package; framework adapters just call startDevContentWatcher() once
|
|
17
17
|
* at dev startup. See packages/canopycms-next/src/context-wrapper.ts for the Next wiring.
|
|
18
|
+
*
|
|
19
|
+
* ## Divergence is a CONDITION, not an event
|
|
20
|
+
*
|
|
21
|
+
* It stays true until someone runs `sync push`, so the two things this module has to get right are
|
|
22
|
+
* (a) not re-printing the same condition into a scrolling request log, and (b) making the one printing
|
|
23
|
+
* of it hard to scroll past. Both are handled below by `reportOnce` + the gutter formatting; see
|
|
24
|
+
* `.claude/future-tasks/dev-divergence-in-app-surface.md` for the in-app surface that would replace
|
|
25
|
+
* the terminal as the primary home for this.
|
|
18
26
|
*/
|
|
19
27
|
import fsSync from 'node:fs';
|
|
20
28
|
import path from 'node:path';
|
|
21
29
|
import chokidar from 'chokidar';
|
|
30
|
+
import pc from 'picocolors';
|
|
22
31
|
import { operatingStrategy } from './operating-mode/index.js';
|
|
23
32
|
import { getErrorMessage } from './utils/error.js';
|
|
24
33
|
import { diffContentTrees, isContentTreeDiffEmpty } from './sync-core.js';
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Per-category cap on listed files. Deliberately small: the block is a prompt to run `sync push`,
|
|
36
|
+
* not a diff viewer, and a 30-line wall of paths is exactly as easy to scroll past as a 1-line
|
|
37
|
+
* warning. Each list carries its true count, so nothing is hidden -- only elided.
|
|
38
|
+
*/
|
|
39
|
+
const MAX_LISTED_FILES = 5;
|
|
40
|
+
/**
|
|
41
|
+
* The registry lives on `globalThis`, NOT in module scope.
|
|
42
|
+
*
|
|
43
|
+
* Next's dev server compiles the server graph per route bundle and evaluates each copy in its own
|
|
44
|
+
* module scope, so a module-level `Map` is re-created empty per bundle -- every copy then believes it
|
|
45
|
+
* is the first watcher, arms its own, and re-prints the startup warning. (The duplicated
|
|
46
|
+
* "CanopyCMS dev-auth: Auto-configured ..." line in a dev log is the same effect on a different
|
|
47
|
+
* module-level latch.) A `globalThis` property is the one thing shared across those evaluations.
|
|
48
|
+
*/
|
|
49
|
+
const REGISTRY_KEY = '__canopycmsDevContentWatchers__';
|
|
50
|
+
function watcherRegistry() {
|
|
51
|
+
const host = globalThis;
|
|
52
|
+
const existing = host[REGISTRY_KEY];
|
|
53
|
+
if (existing)
|
|
54
|
+
return existing;
|
|
55
|
+
const created = new Map();
|
|
56
|
+
host[REGISTRY_KEY] = created;
|
|
57
|
+
return created;
|
|
58
|
+
}
|
|
59
|
+
/** Test-only: dispose every armed watcher and drop all cross-module dedupe state. */
|
|
60
|
+
export function resetDevContentWatchersForTests() {
|
|
61
|
+
const registry = watcherRegistry();
|
|
62
|
+
for (const state of registry.values())
|
|
63
|
+
state.dispose?.();
|
|
64
|
+
registry.clear();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* One body line of the notice block. The left gutter (rather than a full box) is deliberate: content
|
|
68
|
+
* paths are long and unpredictable, and a right border would force either wrapping math or truncation
|
|
69
|
+
* of the very filenames the block exists to name.
|
|
70
|
+
*/
|
|
71
|
+
function body(line) {
|
|
72
|
+
const gutter = pc.yellow('|');
|
|
73
|
+
return line ? `${gutter} ${line}` : gutter;
|
|
74
|
+
}
|
|
30
75
|
function formatList(label, files) {
|
|
31
76
|
if (files.length === 0)
|
|
32
|
-
return
|
|
33
|
-
const shown = files.slice(0, MAX_LISTED_FILES)
|
|
34
|
-
const
|
|
35
|
-
|
|
77
|
+
return [];
|
|
78
|
+
const shown = files.slice(0, MAX_LISTED_FILES);
|
|
79
|
+
const lines = [body(pc.bold(`${label} (${files.length})`)), ...shown.map((f) => body(` ${f}`))];
|
|
80
|
+
if (files.length > shown.length) {
|
|
81
|
+
lines.push(body(pc.dim(` +${files.length - shown.length} more`)));
|
|
82
|
+
}
|
|
83
|
+
return lines;
|
|
36
84
|
}
|
|
37
85
|
function formatDivergenceWarning(branch, diff) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
86
|
+
const total = diff.changed.length + diff.added.length + diff.removed.length;
|
|
87
|
+
// Blank lines top and bottom: the block's job is to be findable in a scrolling request log, and
|
|
88
|
+
// whitespace separation does more for that than any amount of in-block decoration.
|
|
89
|
+
return [
|
|
90
|
+
'',
|
|
91
|
+
`${pc.bgYellow(pc.black(' CanopyCMS '))} ${pc.bold(pc.yellow('working-tree content has diverged'))}`,
|
|
92
|
+
body(`The dev server is serving ${pc.bold('stale content')}: ${total} file(s) differ from the`),
|
|
93
|
+
body(`dev branch clone "${branch}".`),
|
|
94
|
+
body(''),
|
|
95
|
+
...formatList('changed', diff.changed),
|
|
96
|
+
...formatList('only in working tree', diff.added),
|
|
97
|
+
...formatList('only in branch clone', diff.removed),
|
|
98
|
+
body(''),
|
|
99
|
+
body(`Fix: ${pc.bold('npx canopycms sync push')}`),
|
|
100
|
+
body(pc.dim("Silence: set dev.contentSync: 'off' in your Canopy config")),
|
|
101
|
+
'',
|
|
102
|
+
].join('\n');
|
|
103
|
+
}
|
|
104
|
+
function formatResolvedNotice(branch) {
|
|
105
|
+
return [
|
|
106
|
+
'',
|
|
107
|
+
`${pc.bgGreen(pc.black(' CanopyCMS '))} ${pc.green(`working-tree content is back in sync with "${branch}"`)}`,
|
|
108
|
+
'',
|
|
109
|
+
].join('\n');
|
|
47
110
|
}
|
|
48
111
|
/**
|
|
49
112
|
* Start watching the working-tree content directory for divergence from the served dev branch clone.
|
|
@@ -73,6 +136,27 @@ export function startDevContentWatcher(services, options = {}) {
|
|
|
73
136
|
services.config.defaultActiveBranch ??
|
|
74
137
|
services.config.defaultBaseBranch ??
|
|
75
138
|
'main';
|
|
139
|
+
const registry = watcherRegistry();
|
|
140
|
+
let state = registry.get(workingTreeContentDir);
|
|
141
|
+
if (!state) {
|
|
142
|
+
state = { dispose: null, lastMessage: null, reportedDivergence: false };
|
|
143
|
+
registry.set(workingTreeContentDir, state);
|
|
144
|
+
}
|
|
145
|
+
const watcherState = state;
|
|
146
|
+
// Dispose any prior watcher for this dir (HMR re-start) before creating a new one. The registry
|
|
147
|
+
// ENTRY deliberately survives, carrying lastMessage/reportedDivergence into the new watcher.
|
|
148
|
+
watcherState.dispose?.();
|
|
149
|
+
/**
|
|
150
|
+
* Emit `message` unless it is verbatim what was emitted last for this directory. Covers the
|
|
151
|
+
* divergence block, the resolved notice and the error paths alike: in every case a repeat means
|
|
152
|
+
* "the condition is unchanged", which the reader already knows.
|
|
153
|
+
*/
|
|
154
|
+
const reportOnce = (message) => {
|
|
155
|
+
if (watcherState.lastMessage === message)
|
|
156
|
+
return;
|
|
157
|
+
watcherState.lastMessage = message;
|
|
158
|
+
warn(message);
|
|
159
|
+
};
|
|
76
160
|
let running = false;
|
|
77
161
|
let pending = false;
|
|
78
162
|
const check = async () => {
|
|
@@ -84,16 +168,26 @@ export function startDevContentWatcher(services, options = {}) {
|
|
|
84
168
|
try {
|
|
85
169
|
const branch = resolveBranch();
|
|
86
170
|
const branchContentDir = path.join(strategy.getContentBranchRoot(branch, sourceRoot), contentRoot);
|
|
87
|
-
// No branch clone yet (e.g. before the first editor/dev request created it)
|
|
171
|
+
// No branch clone yet (e.g. before the first editor/dev request created it) -> nothing to
|
|
172
|
+
// compare. Deliberately leaves reportedDivergence alone: "cannot tell" is not "resolved".
|
|
88
173
|
if (!fsSync.existsSync(branchContentDir))
|
|
89
174
|
return;
|
|
90
175
|
const diff = await diffContentTrees(workingTreeContentDir, branchContentDir);
|
|
91
|
-
if (isContentTreeDiffEmpty(diff))
|
|
176
|
+
if (isContentTreeDiffEmpty(diff)) {
|
|
177
|
+
// Close the loop: a condition that was announced needs its retraction announced too, or the
|
|
178
|
+
// reader is left believing the dev server is still serving stale content.
|
|
179
|
+
if (watcherState.reportedDivergence) {
|
|
180
|
+
watcherState.reportedDivergence = false;
|
|
181
|
+
reportOnce(formatResolvedNotice(branch));
|
|
182
|
+
}
|
|
92
183
|
return;
|
|
93
|
-
|
|
184
|
+
}
|
|
185
|
+
// Set BEFORE reporting so a deduped repeat still leaves the condition marked as outstanding.
|
|
186
|
+
watcherState.reportedDivergence = true;
|
|
187
|
+
reportOnce(formatDivergenceWarning(branch, diff));
|
|
94
188
|
}
|
|
95
189
|
catch (err) {
|
|
96
|
-
|
|
190
|
+
reportOnce(`CanopyCMS: dev content-sync check failed: ${getErrorMessage(err)}`);
|
|
97
191
|
}
|
|
98
192
|
finally {
|
|
99
193
|
running = false;
|
|
@@ -103,22 +197,19 @@ export function startDevContentWatcher(services, options = {}) {
|
|
|
103
197
|
}
|
|
104
198
|
}
|
|
105
199
|
};
|
|
106
|
-
// Dispose any prior watcher for this dir (HMR re-start) before creating a new one.
|
|
107
|
-
activeWatchers.get(workingTreeContentDir)?.();
|
|
108
200
|
const watcher = chokidar.watch(workingTreeContentDir, { ignoreInitial: true });
|
|
109
201
|
watcher.on('add', () => void check());
|
|
110
202
|
watcher.on('change', () => void check());
|
|
111
203
|
watcher.on('unlink', () => void check());
|
|
112
204
|
// Handle watcher errors (e.g. inotify ENOSPC/EMFILE) so an unhandled 'error' event can't crash dev.
|
|
113
|
-
watcher.on('error', (err) =>
|
|
205
|
+
watcher.on('error', (err) => reportOnce(`CanopyCMS: dev content-sync watcher error: ${getErrorMessage(err)}`));
|
|
114
206
|
// Initial divergence check at startup.
|
|
115
207
|
void check();
|
|
116
208
|
const dispose = () => {
|
|
117
209
|
void watcher.close();
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
}
|
|
210
|
+
if (watcherState.dispose === dispose)
|
|
211
|
+
watcherState.dispose = null;
|
|
121
212
|
};
|
|
122
|
-
|
|
213
|
+
watcherState.dispose = dispose;
|
|
123
214
|
return dispose;
|
|
124
215
|
}
|
|
@@ -339,7 +339,16 @@ export async function recoverOrphanedTasks(taskDir, maxAgeMs = 5 * 60_000, logge
|
|
|
339
339
|
export async function cleanupOldTasks(taskDir, maxAgeMs = 30 * 24 * 60 * 60_000, logger = nullLogger) {
|
|
340
340
|
const now = Date.now();
|
|
341
341
|
let cleaned = 0;
|
|
342
|
-
|
|
342
|
+
// `corrupt` included: unparseable task files are quarantined there by
|
|
343
|
+
// dequeue and orphan recovery and surfaced in admin listing, but the
|
|
344
|
+
// retention sweep never covered the directory, so it grew forever with
|
|
345
|
+
// deletion available only as a manual per-file admin action. Any recurring
|
|
346
|
+
// producer of malformed task JSON -- a partial write surviving a crash, a
|
|
347
|
+
// bad deploy writing schema-drifted tasks for a week -- accumulated files no
|
|
348
|
+
// automated path removed. Same stamp-based retention as the other two: a
|
|
349
|
+
// quarantined file older than the window has long since been triaged or
|
|
350
|
+
// forgotten.
|
|
351
|
+
for (const subdir of ['completed', 'failed', 'corrupt']) {
|
|
343
352
|
const dir = path.join(taskDir, subdir);
|
|
344
353
|
let files;
|
|
345
354
|
try {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-URL-per-entry invariant, as a reusable probe + report.
|
|
3
|
+
*
|
|
4
|
+
* `listEntries` assigns every entry exactly one `urlPath` and documents the round trip through
|
|
5
|
+
* `readByUrlPath` as safe. The reverse direction has repeatedly been the looser of the two: it
|
|
6
|
+
* has answered at URLs no forward surface emits, and those extra URLs were found one at a time,
|
|
7
|
+
* a release apart, by adopters (see
|
|
8
|
+
* `.claude/future-tasks/resolved/url-resolver-index-entry-extra-url.md`, then
|
|
9
|
+
* `resolved/readbyurlpath-entry-type-candidate-phantom-url.md`). This module exists so the whole
|
|
10
|
+
* invariant is asserted at once instead: enumerate, then probe every ADJACENT URL the resolver
|
|
11
|
+
* would try and require it to be a miss.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately free of `vitest` -- this is a plain `src` module in the shape of
|
|
14
|
+
* `operating-mode/deployment-name-fixtures.ts`, so more than one test file can import it without
|
|
15
|
+
* dragging that file's `vi.mock` calls and `describe` blocks along. It therefore ASSERTS NOTHING:
|
|
16
|
+
* it returns a report and the caller does the expecting, which also puts the offending URLs in
|
|
17
|
+
* the assertion message rather than behind a boolean.
|
|
18
|
+
*
|
|
19
|
+
* Scope of the invariant it checks: entries written in the `{type}.{slug}.{id}.{ext}` grammar
|
|
20
|
+
* with a type their collection declares -- i.e. exactly the set `listEntries` can see. A legacy
|
|
21
|
+
* untyped file (`overview.json`) is invisible to enumeration and is deliberately NOT probed here;
|
|
22
|
+
* see `.claude/future-tasks/legacy-untyped-files-url-addressable.md`.
|
|
23
|
+
*/
|
|
24
|
+
import { type RootCollectionConfig } from './config/index.js';
|
|
25
|
+
import type { ListEntriesItem } from './content-listing.js';
|
|
26
|
+
import type { CanopyContext } from './context.js';
|
|
27
|
+
import { type DuplicateUrlPath } from './static/index.js';
|
|
28
|
+
/**
|
|
29
|
+
* Every URL adjacent to the published set that the resolver would actually attempt.
|
|
30
|
+
*
|
|
31
|
+
* Three families, each one a shape that HAS resolved at some point in this package's history:
|
|
32
|
+
*
|
|
33
|
+
* 1. `/<collection>/<entryTypeName>` -- `resolveUrlPathCandidates`' index-fallback candidate lands
|
|
34
|
+
* on a registered entry-TYPE schema item, which `ContentStore.buildPaths` delegates to the
|
|
35
|
+
* parent collection with slug `index`, resolving that collection's index entry.
|
|
36
|
+
* 2. `/<collection>/<entryTypeName>/<slug>` -- the same delegation reached through the
|
|
37
|
+
* DIRECT-entry candidate instead, resolving the collection's entry `<slug>`. Wider than family
|
|
38
|
+
* 1: it needs no index entry at all, so it applies to every listed entry.
|
|
39
|
+
* 3. `/<publishedUrl>/index`, and its case variants -- an index entry answering at the literal
|
|
40
|
+
* spelling its collapsed URL replaced.
|
|
41
|
+
*
|
|
42
|
+
* Entry-type names are appended VERBATIM, after `computeEntryUrl` has lowercased the collection
|
|
43
|
+
* part. That asymmetry is real, not sloppiness: `flattenSchema` puts `entryType.name` into the
|
|
44
|
+
* logical path unchanged and `normalizeFilesystemPath` does not lowercase, so only the declared
|
|
45
|
+
* spelling can reach the entry-type schema item at all. A lowercased probe would miss the schema
|
|
46
|
+
* item, return null for the wrong reason, and pass vacuously.
|
|
47
|
+
*/
|
|
48
|
+
export declare const buildProbeUrls: (schema: RootCollectionConfig, items: readonly ListEntriesItem[], contentRoot?: string) => string[];
|
|
49
|
+
export interface UrlExclusivityReport {
|
|
50
|
+
/** Every `urlPath` the listing published, sorted. */
|
|
51
|
+
published: string[];
|
|
52
|
+
/** URLs claimed by more than one entry. A precondition failure, not a resolver finding. */
|
|
53
|
+
duplicates: DuplicateUrlPath[];
|
|
54
|
+
/** Published URLs `readByUrlPath` did NOT resolve -- the round trip broken going forward. */
|
|
55
|
+
unresolved: string[];
|
|
56
|
+
/** Published URLs that resolved to some OTHER entry than the one that published them. */
|
|
57
|
+
mismatched: Array<{
|
|
58
|
+
urlPath: string;
|
|
59
|
+
expectedEntryId?: string;
|
|
60
|
+
actualEntryId?: string;
|
|
61
|
+
}>;
|
|
62
|
+
/** Every adjacent URL probed, sorted. */
|
|
63
|
+
probes: string[];
|
|
64
|
+
/** Probes that resolved despite not being published -- the phantom URLs. Must be empty. */
|
|
65
|
+
phantoms: string[];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Enumerate, round-trip, then probe. See `UrlExclusivityReport` for what each field means and
|
|
69
|
+
* `buildProbeUrls` for which URLs are probed.
|
|
70
|
+
*
|
|
71
|
+
* A probe whose LOWERCASED form is published is skipped rather than asserted on: a collection
|
|
72
|
+
* literally named `index` publishes `/docs/index`, which family 3 also generates, and whether
|
|
73
|
+
* `/docs/Index` should resolve is a separate, pre-existing question about collection-path case
|
|
74
|
+
* sensitivity that this invariant does not speak to.
|
|
75
|
+
*/
|
|
76
|
+
export declare const collectUrlExclusivityReport: (ctx: Pick<CanopyContext, "listEntries" | "readByUrlPath">, schema: RootCollectionConfig, contentRoot?: string) => Promise<UrlExclusivityReport>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-URL-per-entry invariant, as a reusable probe + report.
|
|
3
|
+
*
|
|
4
|
+
* `listEntries` assigns every entry exactly one `urlPath` and documents the round trip through
|
|
5
|
+
* `readByUrlPath` as safe. The reverse direction has repeatedly been the looser of the two: it
|
|
6
|
+
* has answered at URLs no forward surface emits, and those extra URLs were found one at a time,
|
|
7
|
+
* a release apart, by adopters (see
|
|
8
|
+
* `.claude/future-tasks/resolved/url-resolver-index-entry-extra-url.md`, then
|
|
9
|
+
* `resolved/readbyurlpath-entry-type-candidate-phantom-url.md`). This module exists so the whole
|
|
10
|
+
* invariant is asserted at once instead: enumerate, then probe every ADJACENT URL the resolver
|
|
11
|
+
* would try and require it to be a miss.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately free of `vitest` -- this is a plain `src` module in the shape of
|
|
14
|
+
* `operating-mode/deployment-name-fixtures.ts`, so more than one test file can import it without
|
|
15
|
+
* dragging that file's `vi.mock` calls and `describe` blocks along. It therefore ASSERTS NOTHING:
|
|
16
|
+
* it returns a report and the caller does the expecting, which also puts the offending URLs in
|
|
17
|
+
* the assertion message rather than behind a boolean.
|
|
18
|
+
*
|
|
19
|
+
* Scope of the invariant it checks: entries written in the `{type}.{slug}.{id}.{ext}` grammar
|
|
20
|
+
* with a type their collection declares -- i.e. exactly the set `listEntries` can see. A legacy
|
|
21
|
+
* untyped file (`overview.json`) is invisible to enumeration and is deliberately NOT probed here;
|
|
22
|
+
* see `.claude/future-tasks/legacy-untyped-files-url-addressable.md`.
|
|
23
|
+
*/
|
|
24
|
+
import { flattenSchema } from './config/index.js';
|
|
25
|
+
import { findDuplicateUrlPaths } from './static/index.js';
|
|
26
|
+
import { computeEntryUrl } from './utils/entry-url.js';
|
|
27
|
+
/** Append raw segments to a URL base, without normalizing their case (see buildProbeUrls). */
|
|
28
|
+
const joinUrl = (base, ...segments) => (base === '/' ? '' : base) + segments.map((s) => `/${s}`).join('');
|
|
29
|
+
/**
|
|
30
|
+
* Every URL adjacent to the published set that the resolver would actually attempt.
|
|
31
|
+
*
|
|
32
|
+
* Three families, each one a shape that HAS resolved at some point in this package's history:
|
|
33
|
+
*
|
|
34
|
+
* 1. `/<collection>/<entryTypeName>` -- `resolveUrlPathCandidates`' index-fallback candidate lands
|
|
35
|
+
* on a registered entry-TYPE schema item, which `ContentStore.buildPaths` delegates to the
|
|
36
|
+
* parent collection with slug `index`, resolving that collection's index entry.
|
|
37
|
+
* 2. `/<collection>/<entryTypeName>/<slug>` -- the same delegation reached through the
|
|
38
|
+
* DIRECT-entry candidate instead, resolving the collection's entry `<slug>`. Wider than family
|
|
39
|
+
* 1: it needs no index entry at all, so it applies to every listed entry.
|
|
40
|
+
* 3. `/<publishedUrl>/index`, and its case variants -- an index entry answering at the literal
|
|
41
|
+
* spelling its collapsed URL replaced.
|
|
42
|
+
*
|
|
43
|
+
* Entry-type names are appended VERBATIM, after `computeEntryUrl` has lowercased the collection
|
|
44
|
+
* part. That asymmetry is real, not sloppiness: `flattenSchema` puts `entryType.name` into the
|
|
45
|
+
* logical path unchanged and `normalizeFilesystemPath` does not lowercase, so only the declared
|
|
46
|
+
* spelling can reach the entry-type schema item at all. A lowercased probe would miss the schema
|
|
47
|
+
* item, return null for the wrong reason, and pass vacuously.
|
|
48
|
+
*/
|
|
49
|
+
export const buildProbeUrls = (schema, items, contentRoot = 'content') => {
|
|
50
|
+
const collections = flattenSchema(schema, contentRoot).filter((i) => i.type === 'collection');
|
|
51
|
+
const probes = new Set();
|
|
52
|
+
for (const collection of collections) {
|
|
53
|
+
const base = computeEntryUrl(collection.logicalPath, '', contentRoot);
|
|
54
|
+
for (const entryType of collection.entries ?? []) {
|
|
55
|
+
// Family 1.
|
|
56
|
+
probes.add(joinUrl(base, entryType.name));
|
|
57
|
+
// Family 2 -- every listed entry of this collection, under every type name the collection
|
|
58
|
+
// declares (not just the entry's OWN type: the directory scan `buildPaths` runs matches on
|
|
59
|
+
// slug alone, so any declared name reaches any entry).
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
if (String(item.collectionPath) !== String(collection.logicalPath))
|
|
62
|
+
continue;
|
|
63
|
+
probes.add(joinUrl(base, entryType.name, item.slug));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Family 3.
|
|
68
|
+
for (const item of items) {
|
|
69
|
+
for (const spelling of ['index', 'Index', 'INDEX']) {
|
|
70
|
+
probes.add(joinUrl(item.urlPath, spelling));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...probes];
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Enumerate, round-trip, then probe. See `UrlExclusivityReport` for what each field means and
|
|
77
|
+
* `buildProbeUrls` for which URLs are probed.
|
|
78
|
+
*
|
|
79
|
+
* A probe whose LOWERCASED form is published is skipped rather than asserted on: a collection
|
|
80
|
+
* literally named `index` publishes `/docs/index`, which family 3 also generates, and whether
|
|
81
|
+
* `/docs/Index` should resolve is a separate, pre-existing question about collection-path case
|
|
82
|
+
* sensitivity that this invariant does not speak to.
|
|
83
|
+
*/
|
|
84
|
+
export const collectUrlExclusivityReport = async (ctx, schema, contentRoot = 'content') => {
|
|
85
|
+
const items = await ctx.listEntries();
|
|
86
|
+
const unresolved = [];
|
|
87
|
+
const mismatched = [];
|
|
88
|
+
for (const item of items) {
|
|
89
|
+
const result = await ctx.readByUrlPath(item.urlPath);
|
|
90
|
+
if (!result) {
|
|
91
|
+
unresolved.push(item.urlPath);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (result.meta.entryId !== item.entryId) {
|
|
95
|
+
mismatched.push({
|
|
96
|
+
urlPath: item.urlPath,
|
|
97
|
+
expectedEntryId: item.entryId,
|
|
98
|
+
actualEntryId: result.meta.entryId,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const publishedLower = new Set(items.map((i) => i.urlPath.toLowerCase()));
|
|
103
|
+
const probes = buildProbeUrls(schema, items, contentRoot);
|
|
104
|
+
const phantoms = [];
|
|
105
|
+
for (const probe of probes) {
|
|
106
|
+
if (publishedLower.has(probe.toLowerCase()))
|
|
107
|
+
continue;
|
|
108
|
+
if (await ctx.readByUrlPath(probe))
|
|
109
|
+
phantoms.push(probe);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
published: items.map((i) => i.urlPath).sort(),
|
|
113
|
+
duplicates: findDuplicateUrlPaths(items),
|
|
114
|
+
unresolved: unresolved.sort(),
|
|
115
|
+
mismatched,
|
|
116
|
+
probes: [...probes].sort(),
|
|
117
|
+
phantoms: phantoms.sort(),
|
|
118
|
+
};
|
|
119
|
+
};
|