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/README.md
CHANGED
|
@@ -316,9 +316,7 @@ This convention is applied consistently across the API:
|
|
|
316
316
|
| `readByUrlPath` | Automatically tries `slug: 'index'` as a fallback when the direct entry doesn't match. Works for all paths including `/`. A path whose last segment is an index slug (any case) skips the direct-entry attempt, so an index entry is reachable only at its collapsed path. |
|
|
317
317
|
| `buildContentTree` | Default `buildPath` collapses index entries so tree node paths match the URLs consumers would use. |
|
|
318
318
|
|
|
319
|
-
The round-trip property holds in both directions: for every item from `listEntries`, `readByUrlPath(item.urlPath)` resolves to the same entry, and for an index entry no `.../index` spelling does. (Ordinary entries keep case-insensitive matching on the final slug segment.)
|
|
320
|
-
|
|
321
|
-
One known exception, still open: an entry-type name is also resolvable as a URL segment, so an index entry additionally answers at `/<collection>/<entryTypeName>` -- e.g. a root index entry at both `/` and `/home`. Nothing advertises that URL (it is absent from `listEntries` and from static-param generation), so it only matters if you serve a catch-all route. See the `readbyurlpath-entry-type-candidate-phantom-url` task in the CanopyCMS repo.
|
|
319
|
+
The round-trip property holds in both directions: for every item from `listEntries`, `readByUrlPath(item.urlPath)` resolves to the same entry, and for an index entry no `.../index` spelling does, nor does an entry-type name -- `readByUrlPath` only accepts a candidate whose collection segment is an actual collection, so `/<collection>/<entryTypeName>` (e.g. a root index entry answering at both `/` and `/home`) is not a second URL for it. (Ordinary entries keep case-insensitive matching on the final slug segment.)
|
|
322
320
|
|
|
323
321
|
Two different entries can still compute the same `urlPath` — an entry whose slug matches a sibling collection that also has an `index` entry, or two slugs differing only by case. Only one of them can be served, so a production build fails and names them. `findDuplicateUrlPaths` (exported from `canopycms/server`) runs the same scan on demand.
|
|
324
322
|
|
package/dist/api/assets.js
CHANGED
|
@@ -256,6 +256,15 @@ async function serveLazyTransform(assetStore, key) {
|
|
|
256
256
|
if (meta.kind !== 'raster') {
|
|
257
257
|
return { ok: false, status: 400, error: 'Not a raster asset - svg/pdf are served statically' };
|
|
258
258
|
}
|
|
259
|
+
// The slug is decorative in the URL but load-bearing in the stored key, so
|
|
260
|
+
// it must equal the asset's real slug: `[a-z0-9-]+` is all the parser can
|
|
261
|
+
// enforce, and every other string that passes it aliases the same image into
|
|
262
|
+
// a new cache key and a new stored object. Canopy's own URLs always carry
|
|
263
|
+
// `meta.slug` (assets/asset-url.ts). Mirrors the prod transform Lambda's
|
|
264
|
+
// handler.ts check - the two paths must agree, or dev accepts URLs prod 404s.
|
|
265
|
+
if (parsed.slug !== meta.slug) {
|
|
266
|
+
return { ok: false, status: 404, error: 'Not found' };
|
|
267
|
+
}
|
|
259
268
|
// When the URL omits an explicit `f=` format, the transform preserves the
|
|
260
269
|
// source format, so the URL's `{ext}` must match the source's real ext
|
|
261
270
|
// exactly - the parser alone can't check this (it doesn't know the source
|
|
@@ -189,10 +189,22 @@ export async function writeAuthCacheSnapshot(cachePath, files) {
|
|
|
189
189
|
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
190
190
|
await fs.rename(tmpPath, finalPath);
|
|
191
191
|
}
|
|
192
|
-
// Atomic symlink swap: create temp symlink, rename over current
|
|
192
|
+
// Atomic symlink swap: create temp symlink, rename over current.
|
|
193
|
+
//
|
|
194
|
+
// The target MUST be relative (the bare `snapshot-<ts>` basename), because
|
|
195
|
+
// writer and reader do not always share a mount namespace. In prod the EC2
|
|
196
|
+
// worker mounts the EFS filesystem root and writes through
|
|
197
|
+
// CANOPYCMS_WORKSPACE_ROOT=/mnt/efs/workspace (cachePath
|
|
198
|
+
// /mnt/efs/workspace/.cache), while the CMS Lambda mounts the /workspace
|
|
199
|
+
// access point at /mnt/efs and reads the SAME directory as /mnt/efs/.cache.
|
|
200
|
+
// An absolute target recorded by one is a nonexistent path to the other -
|
|
201
|
+
// `resolveActiveCacheDir`'s escape guard then correctly rejects it and falls
|
|
202
|
+
// back to the flat layout, where the worker never writes, leaving the Lambda
|
|
203
|
+
// with a permanently empty cache. A relative target resolves against
|
|
204
|
+
// whichever cachePath the reader was given, so it is correct from both.
|
|
193
205
|
const currentLink = path.join(cachePath, 'current');
|
|
194
206
|
const tmpLink = path.join(cachePath, `current-${timestamp}`);
|
|
195
|
-
await fs.symlink(snapshotDir, tmpLink);
|
|
207
|
+
await fs.symlink(path.basename(snapshotDir), tmpLink);
|
|
196
208
|
await fs.rename(tmpLink, currentLink);
|
|
197
209
|
// Clean up old snapshots (keep the 2 most recent)
|
|
198
210
|
await cleanupOldSnapshots(cachePath, 2);
|
package/dist/branch-health.d.ts
CHANGED
|
@@ -34,6 +34,26 @@ export interface BranchHealthEntry {
|
|
|
34
34
|
* repair-content-duplicates admin action.
|
|
35
35
|
*/
|
|
36
36
|
duplicateContentIds?: DuplicateContentId[];
|
|
37
|
+
/**
|
|
38
|
+
* healthy only, and only when true: this clone has an interrupted rebase on
|
|
39
|
+
* disk (`.git/rebase-merge` / `.git/rebase-apply`).
|
|
40
|
+
*
|
|
41
|
+
* Deliberately an advisory flag on `healthy` rather than its own
|
|
42
|
+
* `BranchHealthKind`, for the same reason `duplicateContentIds` is: the
|
|
43
|
+
* branch's metadata is intact and the state is USUALLY transient -- the
|
|
44
|
+
* worker's sync loop aborts an interrupted rebase at the top of its next
|
|
45
|
+
* per-branch pass. What this flag buys is visibility in the window BEFORE
|
|
46
|
+
* that pass runs, where the branch otherwise scanned as unqualified
|
|
47
|
+
* `healthy` while being skipped as dirty every cycle.
|
|
48
|
+
*
|
|
49
|
+
* NOT self-recovering in every case, so a persisting value is the real
|
|
50
|
+
* signal and needs an operator. Two ways it sticks: the abort itself keeps
|
|
51
|
+
* failing, or the branch's status moved off `editing` after it wedged --
|
|
52
|
+
* the rebase loop filters by status BEFORE reaching the recovery step, so a
|
|
53
|
+
* clone that crashed mid-rebase and was then submitted or archived is never
|
|
54
|
+
* revisited, and this flag is the only thing that surfaces it.
|
|
55
|
+
*/
|
|
56
|
+
rebaseInProgress?: boolean;
|
|
37
57
|
/** corrupt-metadata only: message describing why the file failed to load. */
|
|
38
58
|
parseError?: string;
|
|
39
59
|
/** corrupt-metadata only: branch.json's mtime, ISO. Omitted if branch.json itself couldn't be stat'd. */
|
package/dist/branch-health.js
CHANGED
|
@@ -4,6 +4,7 @@ import { BranchMetadataFileManager, BranchMetadataCorruptError } from './branch-
|
|
|
4
4
|
import { ContentIdIndex } from './content-id-index.js';
|
|
5
5
|
import { sanitizeBranchName } from './paths/branch-name.js';
|
|
6
6
|
import { getErrorMessage, isNodeError, isNotFoundError } from './utils/error.js';
|
|
7
|
+
import { isRebaseInProgress } from './utils/git.js';
|
|
7
8
|
/**
|
|
8
9
|
* The on-disk path of the cross-process provisioning lock marker for a
|
|
9
10
|
* given branch directory, matching `branch-workspace.ts`'s
|
|
@@ -139,13 +140,17 @@ export async function scanBranchHealth(baseRoot, opts) {
|
|
|
139
140
|
continue;
|
|
140
141
|
}
|
|
141
142
|
if (meta) {
|
|
142
|
-
const duplicateContentIds = await
|
|
143
|
+
const [duplicateContentIds, rebaseInProgress] = await Promise.all([
|
|
144
|
+
scanDuplicateContentIds(branchRoot, contentRootName),
|
|
145
|
+
isRebaseInProgress(branchRoot),
|
|
146
|
+
]);
|
|
143
147
|
entries.push({
|
|
144
148
|
dirName,
|
|
145
149
|
kind: 'healthy',
|
|
146
150
|
...(isBaseBranch ? { isBaseBranch } : {}),
|
|
147
151
|
branch: meta.branch,
|
|
148
152
|
...(duplicateContentIds.length ? { duplicateContentIds } : {}),
|
|
153
|
+
...(rebaseInProgress ? { rebaseInProgress } : {}),
|
|
149
154
|
});
|
|
150
155
|
continue;
|
|
151
156
|
}
|
package/dist/cli/cli.d.ts
CHANGED
|
@@ -37,6 +37,23 @@ export declare function parseAuthFlag(value: string | boolean | undefined): Auth
|
|
|
37
37
|
export declare function parseDualBuildFlag(value: unknown): boolean | undefined;
|
|
38
38
|
declare const SYNC_SUBCOMMANDS: readonly ["push", "pull", "both", "abort"];
|
|
39
39
|
type SyncSubcommand = (typeof SYNC_SUBCOMMANDS)[number];
|
|
40
|
+
/** The auth modes `worker run-once` knows how to build a plugin for. */
|
|
41
|
+
export declare const KNOWN_AUTH_MODES: readonly ["clerk", "dev"];
|
|
42
|
+
export type KnownAuthMode = (typeof KNOWN_AUTH_MODES)[number];
|
|
43
|
+
/**
|
|
44
|
+
* Whether `CANOPY_AUTH_MODE` names a provider the CLI can actually construct.
|
|
45
|
+
*
|
|
46
|
+
* Pure and exported so it is testable: the dispatch below branches on 'clerk'
|
|
47
|
+
* and 'dev' only, and the catch around plugin loading fires solely on an
|
|
48
|
+
* IMPORT failure -- so before this guard existed, any other value (a typo,
|
|
49
|
+
* wrong casing like 'Clerk', a stale value from another system) selected no
|
|
50
|
+
* plugin, skipped the auth refresh entirely, and let the command run to "Done"
|
|
51
|
+
* with exit code 0. A cron'd `CANOPY_AUTH_MODE=clerk canopycms worker
|
|
52
|
+
* run-once` that became `Clerk` refreshed nothing for as long as the typo
|
|
53
|
+
* survived, while the cache aged and a user removed from the Clerk org kept
|
|
54
|
+
* editor access.
|
|
55
|
+
*/
|
|
56
|
+
export declare function isKnownAuthMode(value: string): value is KnownAuthMode;
|
|
40
57
|
/** Resolve sync subcommand from positional arg. Returns null if missing or invalid. Exported for testing. */
|
|
41
58
|
export declare function resolveSyncSubcommand(sub: string | undefined): SyncSubcommand | null;
|
|
42
59
|
export {};
|
package/dist/cli/cli.js
CHANGED
|
@@ -592,7 +592,7 @@ async function recoverOrphanedTasks(taskDir, maxAgeMs = 5 * 6e4, logger = nullLo
|
|
|
592
592
|
async function cleanupOldTasks(taskDir, maxAgeMs = 30 * 24 * 60 * 6e4, logger = nullLogger) {
|
|
593
593
|
const now = Date.now();
|
|
594
594
|
let cleaned = 0;
|
|
595
|
-
for (const subdir of ["completed", "failed"]) {
|
|
595
|
+
for (const subdir of ["completed", "failed", "corrupt"]) {
|
|
596
596
|
const dir = path6.join(taskDir, subdir);
|
|
597
597
|
let files;
|
|
598
598
|
try {
|
|
@@ -810,6 +810,13 @@ function configImportPath(appDir, subdirs) {
|
|
|
810
810
|
const totalDepth = appDepth + subdirs;
|
|
811
811
|
return "../".repeat(totalDepth) + "canopycms.config";
|
|
812
812
|
}
|
|
813
|
+
async function firstExistingPath(projectDir, candidates) {
|
|
814
|
+
for (const candidate of candidates) {
|
|
815
|
+
if (await filePathExists2(path7.join(projectDir, candidate)))
|
|
816
|
+
return candidate;
|
|
817
|
+
}
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
813
820
|
async function init(options) {
|
|
814
821
|
const { projectDir, mode, appDir, ai, force, nonInteractive } = options;
|
|
815
822
|
const writeOpts = { force, nonInteractive };
|
|
@@ -864,15 +871,35 @@ async function init(options) {
|
|
|
864
871
|
await writeFile(path7.join(projectDir, appDir, "ai/config.ts"), await aiConfig2(), writeOpts);
|
|
865
872
|
await writeFile(path7.join(projectDir, appDir, `ai/[...path]/${serverRouteExt}`), await aiRoute2({ configImport: configImportPath(appDir, 2) }), writeOpts);
|
|
866
873
|
}
|
|
867
|
-
|
|
868
|
-
|
|
874
|
+
const existingJsConfig = await firstExistingPath(projectDir, [
|
|
875
|
+
"next.config.js",
|
|
876
|
+
"next.config.mjs"
|
|
877
|
+
]);
|
|
878
|
+
if (existingJsConfig) {
|
|
879
|
+
p.note([
|
|
880
|
+
`Found ${existingJsConfig}, which Next loads in preference to next.config.ts.`,
|
|
881
|
+
"Left it untouched rather than writing a second config Next would ignore.",
|
|
882
|
+
"",
|
|
883
|
+
"Wrap your existing config by hand:",
|
|
884
|
+
"",
|
|
885
|
+
" import { withCanopy } from 'canopycms-next/config'",
|
|
886
|
+
" export default withCanopy(yourConfig)"
|
|
887
|
+
].join("\n"), "Manual step");
|
|
888
|
+
} else {
|
|
889
|
+
await writeFile(path7.join(projectDir, "next.config.ts"), await nextConfig2({ staticBuild }), writeOpts);
|
|
890
|
+
}
|
|
891
|
+
await writeFile(path7.join(projectDir, path7.dirname(appDir), "middleware.ts"), await middleware2({ authProvider }), writeOpts);
|
|
869
892
|
const gitignorePath = path7.join(projectDir, ".gitignore");
|
|
893
|
+
const CANOPY_GITIGNORE_BLOCK = "# CanopyCMS\n.canopy-dev/\n";
|
|
870
894
|
if (await filePathExists2(gitignorePath)) {
|
|
871
895
|
const content = await fs6.readFile(gitignorePath, "utf-8");
|
|
872
896
|
if (!content.includes(".canopy-dev")) {
|
|
873
|
-
await fs6.appendFile(gitignorePath,
|
|
897
|
+
await fs6.appendFile(gitignorePath, `
|
|
898
|
+
${CANOPY_GITIGNORE_BLOCK}`);
|
|
874
899
|
p.log.success("updated: .gitignore");
|
|
875
900
|
}
|
|
901
|
+
} else {
|
|
902
|
+
await writeFile(gitignorePath, CANOPY_GITIGNORE_BLOCK, writeOpts);
|
|
876
903
|
}
|
|
877
904
|
const packages = authProvider === "clerk" ? "canopycms canopycms-next canopycms-auth-clerk canopycms-auth-dev" : "canopycms canopycms-next canopycms-auth-dev";
|
|
878
905
|
p.note([
|
|
@@ -6237,6 +6264,54 @@ var init_generate_ai_content = __esm({
|
|
|
6237
6264
|
}
|
|
6238
6265
|
return item;
|
|
6239
6266
|
}
|
|
6267
|
+
/**
|
|
6268
|
+
* Is this path a COLLECTION schema item?
|
|
6269
|
+
*
|
|
6270
|
+
* The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
|
|
6271
|
+
* point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
|
|
6272
|
+
* Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
|
|
6273
|
+
* both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
|
|
6274
|
+
* first-wins and would not.
|
|
6275
|
+
*
|
|
6276
|
+
* Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
|
|
6277
|
+
* with subcollections and no entries of its own is legal, and mirroring that stricter test here
|
|
6278
|
+
* would reject something `buildPaths` accepts.
|
|
6279
|
+
*
|
|
6280
|
+
* Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
|
|
6281
|
+
* `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
|
|
6282
|
+
*/
|
|
6283
|
+
isCollectionPath(collectionPath) {
|
|
6284
|
+
return this.schemaIndex.get(normalizeFilesystemPath(collectionPath))?.type === "collection";
|
|
6285
|
+
}
|
|
6286
|
+
/**
|
|
6287
|
+
* Does this collection declare `entryTypeName` in its `entries` config?
|
|
6288
|
+
*
|
|
6289
|
+
* Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
|
|
6290
|
+
* whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
|
|
6291
|
+
* scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
|
|
6292
|
+
* was renamed out of the schema stays findable and therefore still editable, renameable and
|
|
6293
|
+
* deletable. Only URL resolution consults this, so what enumeration hides is not served.
|
|
6294
|
+
*
|
|
6295
|
+
* Returns FALSE when the collection declares no `entries` at all, which is stricter than
|
|
6296
|
+
* `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
|
|
6297
|
+
* surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
|
|
6298
|
+
* outright for a collection with no `entries`. A collections-only container therefore publishes
|
|
6299
|
+
* nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
|
|
6300
|
+
* advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
|
|
6301
|
+
* been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
|
|
6302
|
+
* or by retrofit, and it is invisible to the sitemap and to static params either way.
|
|
6303
|
+
*
|
|
6304
|
+
* Returns true for a path that is not a collection at all, because that is rule 1's question,
|
|
6305
|
+
* not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
|
|
6306
|
+
*/
|
|
6307
|
+
declaresEntryType(collectionPath, entryTypeName) {
|
|
6308
|
+
const item = this.schemaIndex.get(normalizeFilesystemPath(collectionPath));
|
|
6309
|
+
if (item?.type !== "collection")
|
|
6310
|
+
return true;
|
|
6311
|
+
if (!item.entries)
|
|
6312
|
+
return false;
|
|
6313
|
+
return item.entries.some((e) => e.name === entryTypeName);
|
|
6314
|
+
}
|
|
6240
6315
|
assertCollection(collectionPath) {
|
|
6241
6316
|
const item = this.assertSchemaItem(collectionPath);
|
|
6242
6317
|
if (item.type !== "collection") {
|
|
@@ -11196,6 +11271,10 @@ async function requireProjectRoot(command) {
|
|
|
11196
11271
|
}
|
|
11197
11272
|
return root;
|
|
11198
11273
|
}
|
|
11274
|
+
var KNOWN_AUTH_MODES = ["clerk", "dev"];
|
|
11275
|
+
function isKnownAuthMode(value) {
|
|
11276
|
+
return KNOWN_AUTH_MODES.includes(value);
|
|
11277
|
+
}
|
|
11199
11278
|
function resolveSyncSubcommand(sub) {
|
|
11200
11279
|
if (sub && SYNC_SUBCOMMANDS.includes(sub))
|
|
11201
11280
|
return sub;
|
|
@@ -11287,6 +11366,10 @@ async function main() {
|
|
|
11287
11366
|
process.exit(1);
|
|
11288
11367
|
}
|
|
11289
11368
|
const authMode = process.env.CANOPY_AUTH_MODE || "dev";
|
|
11369
|
+
if (!isKnownAuthMode(authMode)) {
|
|
11370
|
+
console.error(`Unknown CANOPY_AUTH_MODE "${authMode}" \u2014 expected one of: ${KNOWN_AUTH_MODES.join(", ")}. No auth plugin was loaded, so the auth cache will NOT be refreshed.`);
|
|
11371
|
+
process.exitCode = 1;
|
|
11372
|
+
}
|
|
11290
11373
|
let authPlugin;
|
|
11291
11374
|
try {
|
|
11292
11375
|
if (authMode === "clerk") {
|
|
@@ -11398,6 +11481,8 @@ if (isDirectRun) {
|
|
|
11398
11481
|
});
|
|
11399
11482
|
}
|
|
11400
11483
|
export {
|
|
11484
|
+
KNOWN_AUTH_MODES,
|
|
11485
|
+
isKnownAuthMode,
|
|
11401
11486
|
parseArgs,
|
|
11402
11487
|
parseAuthFlag,
|
|
11403
11488
|
parseDualBuildFlag,
|
|
@@ -2926,6 +2926,54 @@ var ContentStore = class {
|
|
|
2926
2926
|
}
|
|
2927
2927
|
return item;
|
|
2928
2928
|
}
|
|
2929
|
+
/**
|
|
2930
|
+
* Is this path a COLLECTION schema item?
|
|
2931
|
+
*
|
|
2932
|
+
* The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
|
|
2933
|
+
* point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
|
|
2934
|
+
* Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
|
|
2935
|
+
* both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
|
|
2936
|
+
* first-wins and would not.
|
|
2937
|
+
*
|
|
2938
|
+
* Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
|
|
2939
|
+
* with subcollections and no entries of its own is legal, and mirroring that stricter test here
|
|
2940
|
+
* would reject something `buildPaths` accepts.
|
|
2941
|
+
*
|
|
2942
|
+
* Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
|
|
2943
|
+
* `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
|
|
2944
|
+
*/
|
|
2945
|
+
isCollectionPath(collectionPath) {
|
|
2946
|
+
return this.schemaIndex.get(normalizeFilesystemPath(collectionPath))?.type === "collection";
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* Does this collection declare `entryTypeName` in its `entries` config?
|
|
2950
|
+
*
|
|
2951
|
+
* Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
|
|
2952
|
+
* whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
|
|
2953
|
+
* scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
|
|
2954
|
+
* was renamed out of the schema stays findable and therefore still editable, renameable and
|
|
2955
|
+
* deletable. Only URL resolution consults this, so what enumeration hides is not served.
|
|
2956
|
+
*
|
|
2957
|
+
* Returns FALSE when the collection declares no `entries` at all, which is stricter than
|
|
2958
|
+
* `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
|
|
2959
|
+
* surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
|
|
2960
|
+
* outright for a collection with no `entries`. A collections-only container therefore publishes
|
|
2961
|
+
* nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
|
|
2962
|
+
* advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
|
|
2963
|
+
* been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
|
|
2964
|
+
* or by retrofit, and it is invisible to the sitemap and to static params either way.
|
|
2965
|
+
*
|
|
2966
|
+
* Returns true for a path that is not a collection at all, because that is rule 1's question,
|
|
2967
|
+
* not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
|
|
2968
|
+
*/
|
|
2969
|
+
declaresEntryType(collectionPath, entryTypeName) {
|
|
2970
|
+
const item = this.schemaIndex.get(normalizeFilesystemPath(collectionPath));
|
|
2971
|
+
if (item?.type !== "collection")
|
|
2972
|
+
return true;
|
|
2973
|
+
if (!item.entries)
|
|
2974
|
+
return false;
|
|
2975
|
+
return item.entries.some((e) => e.name === entryTypeName);
|
|
2976
|
+
}
|
|
2929
2977
|
assertCollection(collectionPath) {
|
|
2930
2978
|
const item = this.assertSchemaItem(collectionPath);
|
|
2931
2979
|
if (item.type !== "collection") {
|
package/dist/cli/init.js
CHANGED
|
@@ -1117,7 +1117,7 @@ async function recoverOrphanedTasks(taskDir, maxAgeMs = 5 * 6e4, logger = nullLo
|
|
|
1117
1117
|
async function cleanupOldTasks(taskDir, maxAgeMs = 30 * 24 * 60 * 6e4, logger = nullLogger) {
|
|
1118
1118
|
const now = Date.now();
|
|
1119
1119
|
let cleaned = 0;
|
|
1120
|
-
for (const subdir of ["completed", "failed"]) {
|
|
1120
|
+
for (const subdir of ["completed", "failed", "corrupt"]) {
|
|
1121
1121
|
const dir = path6.join(taskDir, subdir);
|
|
1122
1122
|
let files;
|
|
1123
1123
|
try {
|
|
@@ -1586,6 +1586,13 @@ function configImportPath(appDir, subdirs) {
|
|
|
1586
1586
|
const totalDepth = appDepth + subdirs;
|
|
1587
1587
|
return "../".repeat(totalDepth) + "canopycms.config";
|
|
1588
1588
|
}
|
|
1589
|
+
async function firstExistingPath(projectDir, candidates) {
|
|
1590
|
+
for (const candidate of candidates) {
|
|
1591
|
+
if (await filePathExists(path7.join(projectDir, candidate)))
|
|
1592
|
+
return candidate;
|
|
1593
|
+
}
|
|
1594
|
+
return null;
|
|
1595
|
+
}
|
|
1589
1596
|
async function init(options) {
|
|
1590
1597
|
const { projectDir, mode, appDir, ai, force, nonInteractive } = options;
|
|
1591
1598
|
const writeOpts = { force, nonInteractive };
|
|
@@ -1640,15 +1647,35 @@ async function init(options) {
|
|
|
1640
1647
|
await writeFile(path7.join(projectDir, appDir, "ai/config.ts"), await aiConfig2(), writeOpts);
|
|
1641
1648
|
await writeFile(path7.join(projectDir, appDir, `ai/[...path]/${serverRouteExt}`), await aiRoute2({ configImport: configImportPath(appDir, 2) }), writeOpts);
|
|
1642
1649
|
}
|
|
1643
|
-
|
|
1644
|
-
|
|
1650
|
+
const existingJsConfig = await firstExistingPath(projectDir, [
|
|
1651
|
+
"next.config.js",
|
|
1652
|
+
"next.config.mjs"
|
|
1653
|
+
]);
|
|
1654
|
+
if (existingJsConfig) {
|
|
1655
|
+
p.note([
|
|
1656
|
+
`Found ${existingJsConfig}, which Next loads in preference to next.config.ts.`,
|
|
1657
|
+
"Left it untouched rather than writing a second config Next would ignore.",
|
|
1658
|
+
"",
|
|
1659
|
+
"Wrap your existing config by hand:",
|
|
1660
|
+
"",
|
|
1661
|
+
" import { withCanopy } from 'canopycms-next/config'",
|
|
1662
|
+
" export default withCanopy(yourConfig)"
|
|
1663
|
+
].join("\n"), "Manual step");
|
|
1664
|
+
} else {
|
|
1665
|
+
await writeFile(path7.join(projectDir, "next.config.ts"), await nextConfig2({ staticBuild }), writeOpts);
|
|
1666
|
+
}
|
|
1667
|
+
await writeFile(path7.join(projectDir, path7.dirname(appDir), "middleware.ts"), await middleware2({ authProvider }), writeOpts);
|
|
1645
1668
|
const gitignorePath = path7.join(projectDir, ".gitignore");
|
|
1669
|
+
const CANOPY_GITIGNORE_BLOCK = "# CanopyCMS\n.canopy-dev/\n";
|
|
1646
1670
|
if (await filePathExists(gitignorePath)) {
|
|
1647
1671
|
const content = await fs6.readFile(gitignorePath, "utf-8");
|
|
1648
1672
|
if (!content.includes(".canopy-dev")) {
|
|
1649
|
-
await fs6.appendFile(gitignorePath,
|
|
1673
|
+
await fs6.appendFile(gitignorePath, `
|
|
1674
|
+
${CANOPY_GITIGNORE_BLOCK}`);
|
|
1650
1675
|
p.log.success("updated: .gitignore");
|
|
1651
1676
|
}
|
|
1677
|
+
} else {
|
|
1678
|
+
await writeFile(gitignorePath, CANOPY_GITIGNORE_BLOCK, writeOpts);
|
|
1652
1679
|
}
|
|
1653
1680
|
const packages = authProvider === "clerk" ? "canopycms canopycms-next canopycms-auth-clerk canopycms-auth-dev" : "canopycms canopycms-next canopycms-auth-dev";
|
|
1654
1681
|
p.note([
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
# Node 22. Node 20 reached upstream EOL on 2026-04-30, so it receives no
|
|
2
|
+
# further security patches -- and the CMS Lambda runs this image.
|
|
3
|
+
#
|
|
4
|
+
# NOTE: the published canopycms packages declare `engines.node: ">=18"`,
|
|
5
|
+
# so this is not an installability floor -- it is the runtime CanopyCMS is
|
|
6
|
+
# actually built and tested on (.nvmrc is v22, as is the transform Lambda).
|
|
7
|
+
FROM public.ecr.aws/docker/library/node:22-slim AS builder
|
|
2
8
|
WORKDIR /app
|
|
3
9
|
{{DOCKER_COPY}}
|
|
4
10
|
# If your app uses `file:` dependencies (e.g. vendored tarballs under vendor/),
|
|
@@ -53,7 +59,7 @@ ARG NEXT_PUBLIC_CANOPY_MODE=dev
|
|
|
53
59
|
ENV NEXT_PUBLIC_CANOPY_MODE=$NEXT_PUBLIC_CANOPY_MODE
|
|
54
60
|
RUN {{DOCKER_BUILD}}
|
|
55
61
|
|
|
56
|
-
FROM public.ecr.aws/docker/library/node:
|
|
62
|
+
FROM public.ecr.aws/docker/library/node:22-slim AS runner
|
|
57
63
|
# Git is required by CanopyCMS for branch operations
|
|
58
64
|
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
|
59
65
|
# The runtime user's uid differs from the uid that owns EFS workspace files
|
|
@@ -107,13 +107,35 @@ export class CmsStack extends Stack {
|
|
|
107
107
|
reservedConcurrency: 10,
|
|
108
108
|
})
|
|
109
109
|
|
|
110
|
-
// Media support (uploads, on-demand image transforms).
|
|
111
|
-
// the deployed editor an asset backend, pass `assetBucket:
|
|
112
|
-
// assetSupport.bucket` to CanopyCmsService above, and wire
|
|
113
|
-
// `assetSupport.cloudFrontBehaviors` into the distribution below. See the
|
|
114
|
-
// assets section of docs/deploying-to-aws.md.
|
|
110
|
+
// Media support (uploads, on-demand image transforms). To enable it:
|
|
115
111
|
//
|
|
116
|
-
//
|
|
112
|
+
// 1. add `AssetSupport` to the `canopycms-cdk` import at the top;
|
|
113
|
+
// 2. uncomment the block below, moving it ABOVE `cmsService` (it has to
|
|
114
|
+
// exist before you can pass its bucket);
|
|
115
|
+
// 3. pass `assetBucket: assetSupport.bucket` to CanopyCmsService;
|
|
116
|
+
// 4. pass the behaviors to CanopyCmsDistribution, `/assets/t/*` FIRST
|
|
117
|
+
// (CloudFront matches in order, so the general pattern would
|
|
118
|
+
// otherwise swallow the transform one):
|
|
119
|
+
//
|
|
120
|
+
// additionalBehaviors: {
|
|
121
|
+
// '/assets/t/*': assetSupport.assetBehaviors().assetsTransform,
|
|
122
|
+
// '/assets/*': assetSupport.assetBehaviors().assets,
|
|
123
|
+
// },
|
|
124
|
+
//
|
|
125
|
+
// `editorOrigins` is REQUIRED: it is the S3 CORS allowlist for the
|
|
126
|
+
// editor's presigned uploads, so it must list the origin the editor is
|
|
127
|
+
// served from. Include http://localhost:3000 only if you upload from a
|
|
128
|
+
// local dev editor against this bucket.
|
|
129
|
+
//
|
|
130
|
+
// Run `pnpm --filter canopycms-cdk run build:lambda` before deploying --
|
|
131
|
+
// the transform Lambda needs its native sharp binary, and the construct
|
|
132
|
+
// refuses to synth without it.
|
|
133
|
+
//
|
|
134
|
+
// See the assets section of docs/deploying-to-aws.md.
|
|
135
|
+
//
|
|
136
|
+
// const assetSupport = new AssetSupport(this, 'Assets', {
|
|
137
|
+
// editorOrigins: [`https://${props.domainName}`],
|
|
138
|
+
// })
|
|
117
139
|
|
|
118
140
|
// CloudFront + Route53, only when a domain is configured. Skipping this
|
|
119
141
|
// leaves `cmsService.functionUrl` as the entry point, which is enough to
|
|
@@ -124,6 +146,11 @@ export class CmsStack extends Stack {
|
|
|
124
146
|
functionUrl: cmsService.functionUrl,
|
|
125
147
|
domainName: props.domainName,
|
|
126
148
|
hostedZoneDomain: props.hostedZoneDomain,
|
|
149
|
+
// Keep CloudFront's origin read timeout equal to the Lambda's own.
|
|
150
|
+
// Both default to 60s, so this line changes nothing today -- it is
|
|
151
|
+
// here so that raising `timeout` above cannot silently leave
|
|
152
|
+
// CloudFront answering 504 at the edge on requests that succeed.
|
|
153
|
+
originReadTimeout: cmsService.timeout,
|
|
127
154
|
})
|
|
128
155
|
}
|
|
129
156
|
}
|
|
@@ -39,10 +39,15 @@
|
|
|
39
39
|
# 3. Install the CDK dependencies the app entry point needs:
|
|
40
40
|
# `{{ADD_DEV}} canopycms canopycms-cdk aws-cdk-lib constructs tsx aws-cdk`
|
|
41
41
|
# 4. Set these repository secrets (Settings -> Secrets and variables ->
|
|
42
|
-
# Actions -> Secrets).
|
|
43
|
-
# infrastructure/bin/app.ts, so a missing value fails the deploy at
|
|
42
|
+
# Actions -> Secrets). All but AWS_DEPLOY_ROLE_ARN are read by `required()`
|
|
43
|
+
# in infrastructure/bin/app.ts, so a missing value fails the deploy at
|
|
44
|
+
# synth; a missing role ARN fails earlier, at the AWS credentials step:
|
|
44
45
|
# AWS_DEPLOY_ROLE_ARN OIDC role ARN from step 2
|
|
45
|
-
#
|
|
46
|
+
# CANOPY_GITHUB_TOKEN_SECRET_ARN FULL Secrets Manager ARN (with suffix)
|
|
47
|
+
# NOTE: the CANOPY_ prefix is required, not cosmetic. GitHub rejects any
|
|
48
|
+
# Actions secret whose name starts with GITHUB_ (a reserved prefix), so
|
|
49
|
+
# the otherwise-obvious GITHUB_TOKEN_SECRET_ARN cannot be created at all.
|
|
50
|
+
# The env var handed to CDK below keeps the unprefixed name.
|
|
46
51
|
# CLERK_SECRET_KEY_SECRET_ARN FULL Secrets Manager ARN (with suffix)
|
|
47
52
|
# CLERK_JWT_KEY Clerk's public JWKS PEM
|
|
48
53
|
# 5. Set these repository variables (same page -> Variables):
|
|
@@ -64,6 +69,14 @@ on:
|
|
|
64
69
|
- 'src/**'
|
|
65
70
|
- 'content/**'
|
|
66
71
|
- 'canopycms.config.ts'
|
|
72
|
+
# The Next config is what `init-deploy aws` itself tells you to edit for
|
|
73
|
+
# the dual build, and `public/**` is copied into the runner image (and is
|
|
74
|
+
# load-bearing for editor/public preview parity). Omitting them meant an
|
|
75
|
+
# edit shipped days later, piggybacked on an unrelated content change --
|
|
76
|
+
# so any breakage was attributed to the wrong commit.
|
|
77
|
+
- 'next.config.*'
|
|
78
|
+
- 'middleware.ts'
|
|
79
|
+
- 'public/**'
|
|
67
80
|
- 'Dockerfile.cms'
|
|
68
81
|
- 'infrastructure/**'
|
|
69
82
|
- 'cdk.json'
|
|
@@ -89,11 +102,11 @@ jobs:
|
|
|
89
102
|
contents: read
|
|
90
103
|
|
|
91
104
|
steps:
|
|
92
|
-
- uses: actions/checkout@v4
|
|
105
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
93
106
|
|
|
94
|
-
- uses: actions/setup-node@v4
|
|
107
|
+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
|
95
108
|
with:
|
|
96
|
-
node-version:
|
|
109
|
+
node-version: 22
|
|
97
110
|
|
|
98
111
|
- name: Install dependencies
|
|
99
112
|
run: {{CI_INSTALL}}
|
|
@@ -124,7 +137,7 @@ jobs:
|
|
|
124
137
|
fi
|
|
125
138
|
|
|
126
139
|
- name: Configure AWS credentials
|
|
127
|
-
uses: aws-actions/configure-aws-credentials@v4
|
|
140
|
+
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4
|
|
128
141
|
with:
|
|
129
142
|
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
|
130
143
|
aws-region: ${{ vars.AWS_REGION }}
|
|
@@ -138,7 +151,7 @@ jobs:
|
|
|
138
151
|
# in one place and not the other fails the deploy at synth.
|
|
139
152
|
- name: Deploy
|
|
140
153
|
env:
|
|
141
|
-
GITHUB_TOKEN_SECRET_ARN: ${{ secrets.
|
|
154
|
+
GITHUB_TOKEN_SECRET_ARN: ${{ secrets.CANOPY_GITHUB_TOKEN_SECRET_ARN }}
|
|
142
155
|
CLERK_SECRET_KEY_SECRET_ARN: ${{ secrets.CLERK_SECRET_KEY_SECRET_ARN }}
|
|
143
156
|
CLERK_JWT_KEY: ${{ secrets.CLERK_JWT_KEY }}
|
|
144
157
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ vars.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
|
package/dist/content-reader.d.ts
CHANGED
|
@@ -11,7 +11,11 @@ export interface ContentReaderOptions {
|
|
|
11
11
|
getBranchContext?: (branch: string) => Promise<BranchContext | null>;
|
|
12
12
|
}
|
|
13
13
|
export interface ReadContentInput {
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Resolved schema path (e.g., content/posts or content/home). An entry-TYPE path like
|
|
16
|
+
* `content/home` is valid and resolves that singleton -- except under `urlAddressableOnly`
|
|
17
|
+
* below, which rejects it.
|
|
18
|
+
*/
|
|
15
19
|
entryPath: LogicalPath;
|
|
16
20
|
slug?: Slug;
|
|
17
21
|
branch?: string;
|
|
@@ -21,6 +25,26 @@ export interface ReadContentInput {
|
|
|
21
25
|
resolveReferences?: boolean;
|
|
22
26
|
/** Whether to resolve entry:ID links in body/markdown fields. Defaults to true. */
|
|
23
27
|
resolveEntryLinks?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* This read is addressing an entry by its PUBLISHED URL, so accept only what enumeration
|
|
30
|
+
* publishes. Two rules, both off by default:
|
|
31
|
+
*
|
|
32
|
+
* 1. `entryPath` must be a COLLECTION. A published URL is `/<collectionSegments>/<slug>` (or
|
|
33
|
+
* the collapsed collection path, for an index entry), so its non-slug segments are always
|
|
34
|
+
* collection names. An `entryPath` that resolves to an entry-TYPE item instead would be
|
|
35
|
+
* delegated by `ContentStore.buildPaths` to the parent collection -- answering at
|
|
36
|
+
* `/<collection>/<typeName>` and `/<collection>/<typeName>/<slug>`, neither of which any
|
|
37
|
+
* forward surface emits.
|
|
38
|
+
* 2. The resolved entry's type must be one its collection declares, matching the
|
|
39
|
+
* `parseTypedFilename(filename, collection.entries)` check `listEntries` applies. A legacy
|
|
40
|
+
* untyped file has no type token to fail on -- see `declaresEntryType`.
|
|
41
|
+
*
|
|
42
|
+
* Set by `readByUrlPath` and nothing else. Note what it does NOT do: `read({ entryPath:
|
|
43
|
+
* 'content/home' })` and direct `ContentStore` use keep the entry-type delegation, which is a
|
|
44
|
+
* supported API and the only way to address a singleton structurally. Misusing this flag can
|
|
45
|
+
* only make a read stricter, never looser.
|
|
46
|
+
*/
|
|
47
|
+
urlAddressableOnly?: boolean;
|
|
24
48
|
}
|
|
25
49
|
/**
|
|
26
50
|
* Structural metadata surfaced alongside a resolved content read. Shared by
|
package/dist/content-reader.js
CHANGED
|
@@ -122,6 +122,13 @@ export const createContentReader = (options) => {
|
|
|
122
122
|
const readDocument = async (input) => {
|
|
123
123
|
const { entryPath, slug, branchName, user } = resolveTarget(input);
|
|
124
124
|
const { context, branchRoot, store } = await resolveStore(branchName);
|
|
125
|
+
// Rule 1 of urlAddressableOnly (see ReadContentInput): a published URL's non-slug segments
|
|
126
|
+
// are collection names, so a candidate landing on an entry-TYPE item can only ever produce a
|
|
127
|
+
// URL enumeration never emits. NO_SCHEMA_ITEM is what assertCollection raises for exactly
|
|
128
|
+
// this condition, and readByUrlPath treats it as a miss and tries the next candidate.
|
|
129
|
+
if (input.urlAddressableOnly && !store.isCollectionPath(entryPath)) {
|
|
130
|
+
throw new ContentStoreError(`Path is not a collection: ${entryPath}`, 'NO_SCHEMA_ITEM');
|
|
131
|
+
}
|
|
125
132
|
// Get the path WITHOUT reading the file
|
|
126
133
|
let relativePath;
|
|
127
134
|
// Absolute filesystem path to the entry file. Surfaced on read() / readByUrlPath()
|
|
@@ -151,6 +158,14 @@ export const createContentReader = (options) => {
|
|
|
151
158
|
const code = err instanceof ContentStoreError ? err.code : 'VALIDATION';
|
|
152
159
|
throw new ContentStoreError(message, code);
|
|
153
160
|
}
|
|
161
|
+
// Rule 2 of urlAddressableOnly (see ReadContentInput): buildPaths' directory scan matches on
|
|
162
|
+
// slug alone, so it happily returns a file whose type token the collection no longer (or
|
|
163
|
+
// never did) declare -- a file listEntries skips. Checked here rather than inside the scan so
|
|
164
|
+
// the write path can still find, edit and rename it; making it unfindable would make the
|
|
165
|
+
// mistake unrecoverable through the editor.
|
|
166
|
+
if (input.urlAddressableOnly && !store.declaresEntryType(entryPath, entryType)) {
|
|
167
|
+
throw new ContentStoreError(`Entry type '${entryType}' is not declared by ${entryPath}`, 'NO_SCHEMA_ITEM');
|
|
168
|
+
}
|
|
154
169
|
// Check permissions BEFORE reading the file (security)
|
|
155
170
|
const shouldCheckPermissions = !(isDeployedStatic(services.config) || isBuildMode());
|
|
156
171
|
if (shouldCheckPermissions) {
|