canopycms 0.0.65 → 0.0.66-int.82
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/ai/generate.d.ts +13 -0
- package/dist/ai/generate.js +9 -3
- package/dist/ai/to-plain-text.js +4 -0
- package/dist/ai/types.d.ts +20 -1
- package/dist/api/admin-branch-health.js +13 -12
- package/dist/api/branch.js +2 -2
- package/dist/branch-health.js +1 -1
- package/dist/branch-metadata-file.d.ts +58 -0
- package/dist/branch-metadata-file.js +65 -0
- package/dist/branch-metadata.d.ts +2 -26
- package/dist/branch-metadata.js +6 -33
- package/dist/branch-registry.js +4 -2
- package/dist/build/generate-ai-content.js +103 -0
- package/dist/cli/cli.js +432 -317
- package/dist/cli/generate-ai-content.js +264 -160
- package/dist/content-id-index.js +22 -0
- package/dist/content-listing.js +3 -0
- package/dist/content-reader.d.ts +25 -1
- package/dist/content-reader.js +15 -0
- package/dist/content-store.d.ts +50 -1
- package/dist/content-store.js +102 -4
- package/dist/context.d.ts +36 -4
- package/dist/context.js +15 -4
- package/dist/editor/admin/SystemHealthPanel.js +2 -2
- package/dist/entry-schema.d.ts +22 -0
- package/dist/git-manager.d.ts +9 -8
- package/dist/git-manager.js +35 -9
- package/dist/github-service.d.ts +1 -1
- package/dist/github-service.js +1 -1
- package/dist/paths/branch-name.d.ts +1 -1
- package/dist/paths/branch-name.js +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/schema/meta-loader.js +1 -1
- package/dist/services.d.ts +22 -0
- package/dist/types.d.ts +1 -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/content-write-lock.d.ts +2 -1
- package/dist/utils/content-write-lock.js +2 -1
- package/dist/utils/error.d.ts +17 -0
- package/dist/utils/error.js +17 -0
- package/dist/utils/git.d.ts +1 -1
- package/dist/utils/git.js +1 -1
- package/dist/utils/occ-json-write.js +1 -1
- package/dist/worker/cms-worker.d.ts +22 -288
- package/dist/worker/cms-worker.js +118 -1995
- package/dist/worker/git-sync.d.ts +166 -0
- package/dist/worker/git-sync.js +554 -0
- package/dist/worker/history-rewrite.d.ts +129 -0
- package/dist/worker/history-rewrite.js +216 -0
- package/dist/worker/rebase.d.ts +171 -0
- package/dist/worker/rebase.js +859 -0
- package/dist/worker/task-runner.d.ts +93 -0
- package/dist/worker/task-runner.js +529 -0
- package/dist/worker/worker-context.d.ts +133 -0
- package/dist/worker/worker-context.js +1 -0
- package/package.json +1 -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/ai/generate.d.ts
CHANGED
|
@@ -17,6 +17,19 @@ export interface GenerateOptions {
|
|
|
17
17
|
config?: AIContentConfig;
|
|
18
18
|
/** Custom URL resolver for entry links. */
|
|
19
19
|
entryLinkUrl?: EntryLinkUrlResolver;
|
|
20
|
+
/**
|
|
21
|
+
* ISO-8601 timestamp to record as the manifest's `generated`. Defaults to now.
|
|
22
|
+
*
|
|
23
|
+
* Passed in rather than read from the environment here so this stays a pure function of its
|
|
24
|
+
* arguments: the runtime route handler wants a live clock, the build path wants a pinned or
|
|
25
|
+
* omitted value, and only the caller knows which it is.
|
|
26
|
+
*/
|
|
27
|
+
generatedAt?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Artifact identifier to record as the manifest's `buildId`. Supplying this WITHOUT
|
|
30
|
+
* `generatedAt` omits `generated` entirely — see `AIManifest.generated`.
|
|
31
|
+
*/
|
|
32
|
+
buildId?: string;
|
|
20
33
|
}
|
|
21
34
|
export interface GenerateResult {
|
|
22
35
|
manifest: AIManifest;
|
package/dist/ai/generate.js
CHANGED
|
@@ -22,7 +22,7 @@ import { resolveEntryLinksInText } from '../entry-link-resolver.js';
|
|
|
22
22
|
* bundle files, and a manifest.
|
|
23
23
|
*/
|
|
24
24
|
export async function generateAIContent(options) {
|
|
25
|
-
const { store, flatSchema, contentRoot, config, entryLinkUrl } = options;
|
|
25
|
+
const { store, flatSchema, contentRoot, config, entryLinkUrl, generatedAt, buildId } = options;
|
|
26
26
|
const files = new Map();
|
|
27
27
|
// Build content ID index for entry link resolution
|
|
28
28
|
const idIndex = await store.idIndex();
|
|
@@ -87,9 +87,15 @@ export async function generateAIContent(options) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
-
// Build manifest
|
|
90
|
+
// Build manifest.
|
|
91
|
+
//
|
|
92
|
+
// `generated` is emitted unless the caller named an artifact but no timestamp: a build id says
|
|
93
|
+
// "this content is identified by an artifact, not by when a runner happened to build it", and a
|
|
94
|
+
// wall clock alongside it would be a claim the artifact cannot support months later. Key order
|
|
95
|
+
// is fixed (id before date) so the JSON is byte-stable across runs.
|
|
91
96
|
const manifest = {
|
|
92
|
-
|
|
97
|
+
...(buildId ? { buildId } : {}),
|
|
98
|
+
...(generatedAt || !buildId ? { generated: generatedAt || new Date().toISOString() } : {}),
|
|
93
99
|
entries: rootEntries,
|
|
94
100
|
collections: manifestCollections,
|
|
95
101
|
bundles: manifestBundles,
|
package/dist/ai/to-plain-text.js
CHANGED
|
@@ -287,6 +287,10 @@ function collapseWhitespace(text) {
|
|
|
287
287
|
* indexes genuinely diverge (see docs/adopter-migration.md).
|
|
288
288
|
*/
|
|
289
289
|
export function toPlainText(markdown) {
|
|
290
|
+
// Destructures `content` only: never touches `.data` (so the shared-instance
|
|
291
|
+
// hazard cannot apply) and never needs `.matter` (so the cache-hit hazard
|
|
292
|
+
// cannot apply either).
|
|
293
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
290
294
|
const { content } = matter(markdown);
|
|
291
295
|
const withoutImports = stripMdxImports(content);
|
|
292
296
|
const { masked, restore } = maskCode(withoutImports);
|
package/dist/ai/types.d.ts
CHANGED
|
@@ -215,7 +215,26 @@ export interface AIManifestBundle {
|
|
|
215
215
|
}
|
|
216
216
|
/** Top-level manifest for AI content */
|
|
217
217
|
export interface AIManifest {
|
|
218
|
-
|
|
218
|
+
/**
|
|
219
|
+
* When this content was generated, ISO-8601.
|
|
220
|
+
*
|
|
221
|
+
* OPTIONAL, and absent whenever the manifest carries a `buildId` and no usable
|
|
222
|
+
* `SOURCE_DATE_EPOCH` (unset, blank, or malformed — a rejected value does not fall back to a
|
|
223
|
+
* live clock, it leaves the field absent):
|
|
224
|
+
* under build-once-promote one artifact is built once and may be served months later, so a
|
|
225
|
+
* build clock describes the runner that produced it rather than the content, and anything
|
|
226
|
+
* reading it as "how fresh is this?" is misled by design. An adopter who has declared a build
|
|
227
|
+
* id has told us the date is not the identifying fact, so it is omitted rather than filled in
|
|
228
|
+
* with something arbitrary. Present unconditionally when neither env var is set.
|
|
229
|
+
*/
|
|
230
|
+
generated?: string;
|
|
231
|
+
/**
|
|
232
|
+
* Identifies the artifact this content was built into (`CANOPY_BUILD_ID`). Absent unless that
|
|
233
|
+
* variable is set to a usable value — 1-255 characters of `[A-Za-z0-9._-]`, not `.` or `..`,
|
|
234
|
+
* since it must also serve as a single path segment. This — not `generated` — is what a
|
|
235
|
+
* content-addressed deployment keys on.
|
|
236
|
+
*/
|
|
237
|
+
buildId?: string;
|
|
219
238
|
/** Root-level entries (outside any collection) */
|
|
220
239
|
entries: AIManifestEntry[];
|
|
221
240
|
collections: AIManifestCollection[];
|
|
@@ -14,6 +14,9 @@ import path from 'node:path';
|
|
|
14
14
|
import { z } from 'zod';
|
|
15
15
|
import { simpleGit } from 'simple-git';
|
|
16
16
|
import { BranchMetadataFileManager, getBranchMetadataFileManager } from '../branch-metadata.js';
|
|
17
|
+
// Same constants the reader uses, rather than a second copy of the two string
|
|
18
|
+
// literals -- they were declared independently here until 2026-08-23.
|
|
19
|
+
import { BRANCH_META_DIR, BRANCH_META_FILE } from '../branch-metadata-file.js';
|
|
17
20
|
import { scanBranchHealth } from '../branch-health.js';
|
|
18
21
|
import { ContentIdIndex } from '../content-id-index.js';
|
|
19
22
|
import { invalidateContentIndexesDurable } from '../content-index-generation.js';
|
|
@@ -30,8 +33,6 @@ import { defineEndpoint } from './route-builder.js';
|
|
|
30
33
|
const PROVISIONING_LOCK_FRESH_MS = 5 * 60_000;
|
|
31
34
|
/** An orphan dir younger than this may still be a clone in progress; corrupt dirs are exempt. */
|
|
32
35
|
const ORPHAN_YOUTH_THRESHOLD_MS = 15 * 60_000;
|
|
33
|
-
const BRANCH_META_DIR = '.canopy-meta';
|
|
34
|
-
const BRANCH_META_FILE = 'branch.json';
|
|
35
36
|
// ============================================================================
|
|
36
37
|
// Zod schemas
|
|
37
38
|
// ============================================================================
|
|
@@ -102,7 +103,7 @@ const getBranchHealthHandler = async (_gc, ctx, _req) => {
|
|
|
102
103
|
* Purge a corrupt-metadata or orphan branch directory by renaming it into a
|
|
103
104
|
* dot-prefixed trash name (reversible -- nothing is deleted here; the worker
|
|
104
105
|
* sweeps trash older than 30 days, see cleanupTrashedBranchDirs in
|
|
105
|
-
* worker/
|
|
106
|
+
* worker/rebase.ts).
|
|
106
107
|
*
|
|
107
108
|
* Safety rails (see the PR's design review for the finding IDs):
|
|
108
109
|
* - [always] the base branch directory can never be purged.
|
|
@@ -111,7 +112,7 @@ const getBranchHealthHandler = async (_gc, ctx, _req) => {
|
|
|
111
112
|
* - [H1] a fresh provisioning init-lock blocks purge; a stale one does not.
|
|
112
113
|
* - an orphan (not corrupt) dir younger than 15 minutes is presumed to be a
|
|
113
114
|
* clone in progress and is not purgeable yet.
|
|
114
|
-
* -
|
|
115
|
+
* - the purge itself runs under a zero-retry provisioning lock, so a
|
|
115
116
|
* real in-flight provisioner (whose lock is therefore fresh, and would
|
|
116
117
|
* already have 409'd above) can never race the rename; a same-instant
|
|
117
118
|
* contender simply 409s.
|
|
@@ -178,7 +179,7 @@ const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
|
|
|
178
179
|
};
|
|
179
180
|
}
|
|
180
181
|
}
|
|
181
|
-
//
|
|
182
|
+
// Zero-retry provisioning lock: fails fast (409) on genuine live
|
|
182
183
|
// contention instead of hanging the request for ~5 minutes.
|
|
183
184
|
let releaseProvisioningLock;
|
|
184
185
|
try {
|
|
@@ -248,7 +249,7 @@ const purgeBranchDirHandler = async (_gc, ctx, _req, params) => {
|
|
|
248
249
|
* (exiting the withOccFileLock callback) before save() runs -- calling
|
|
249
250
|
* save() while still holding the lock would deadlock against itself.
|
|
250
251
|
*
|
|
251
|
-
*
|
|
252
|
+
* The provisioning lock is held across the ENTIRE archive+save
|
|
252
253
|
* sequence (acquired before withOccFileLock, released only after save()
|
|
253
254
|
* completes), same lock order as purge (provisioning -> branch.json) so the
|
|
254
255
|
* two can never deadlock against each other. Without this, a concurrent
|
|
@@ -306,8 +307,8 @@ const repairBranchDirHandler = async (_gc, ctx, req, params) => {
|
|
|
306
307
|
? { ok: false, status: 409, error: 'Metadata is healthy' }
|
|
307
308
|
: { ok: false, status: 409, error: 'No metadata file -- use purge for orphans' };
|
|
308
309
|
}
|
|
309
|
-
//
|
|
310
|
-
//
|
|
310
|
+
// Zero-retry provisioning lock, mirroring purge's: fails fast (409) on
|
|
311
|
+
// genuine live contention instead of hanging the request.
|
|
311
312
|
// Held until the `finally` below, AFTER save() completes.
|
|
312
313
|
let releaseProvisioningLock;
|
|
313
314
|
try {
|
|
@@ -348,14 +349,14 @@ const repairBranchDirHandler = async (_gc, ctx, req, params) => {
|
|
|
348
349
|
error: `Could not lock branch metadata: ${getErrorMessage(err)}`,
|
|
349
350
|
};
|
|
350
351
|
}
|
|
351
|
-
//
|
|
352
|
+
// Prefer the clone's actual checked-out branch over the
|
|
352
353
|
// (possibly sanitized) directory name -- see resolveRepairedBranchName.
|
|
353
354
|
const branchName = await resolveRepairedBranchName(dirPath, params.dirName);
|
|
354
355
|
// Lock released above (we're outside the withOccFileLock callback now) --
|
|
355
356
|
// save() takes its own hold on the same lock internally. Its defaults
|
|
356
357
|
// path fabricates the rest of BranchMetadata and invalidates the
|
|
357
|
-
// registry. Still under the provisioning lock acquired above -- see
|
|
358
|
-
//
|
|
358
|
+
// registry. Still under the provisioning lock acquired above -- see the
|
|
359
|
+
// lock-ordering note in this handler's docstring.
|
|
359
360
|
const manager = getBranchMetadataFileManager(dirPath, baseRoot);
|
|
360
361
|
const saved = await manager.save({
|
|
361
362
|
branch: { name: branchName, status: 'editing', createdBy: req.user.userId },
|
|
@@ -385,7 +386,7 @@ const repairBranchDirHandler = async (_gc, ctx, req, params) => {
|
|
|
385
386
|
}
|
|
386
387
|
};
|
|
387
388
|
/**
|
|
388
|
-
*
|
|
389
|
+
* Best-effort read of the branch actually checked out in the
|
|
389
390
|
* clone at `dirPath`, falling back to `dirName` on any failure (no `.git`,
|
|
390
391
|
* detached HEAD, corrupt repo, etc.). Workspace directory names are
|
|
391
392
|
* sanitized (slashes stripped, see paths/branch.ts's `sanitizeBranchName`),
|
package/dist/api/branch.js
CHANGED
|
@@ -134,7 +134,7 @@ export const createBranchHandler = async (ctx, req, body) => {
|
|
|
134
134
|
// Reserve the WHOLE canopycms-settings- namespace, not just this
|
|
135
135
|
// deployment's own settings branch name. Two CanopyCMS deployments can
|
|
136
136
|
// share one GitHub repo, each with its own settings branch under this
|
|
137
|
-
// prefix; the worker (worker/
|
|
137
|
+
// prefix; the worker (worker/git-sync.ts) treats any
|
|
138
138
|
// `canopycms-settings-*` ref specially (orphan-branch reconcile/push
|
|
139
139
|
// logic), so another deployment's settings branch is a real name that
|
|
140
140
|
// must not be claimable as a content branch here either.
|
|
@@ -203,7 +203,7 @@ export const createBranchHandler = async (ctx, req, body) => {
|
|
|
203
203
|
// (PRIVATE_ISOLATED subnets, no NAT -- see AGENTS.md), so a synchronous
|
|
204
204
|
// GitHub API call at branch-create time is not possible. But remote.git
|
|
205
205
|
// is BOTH this deployment's local git origin AND a mirror of GitHub's
|
|
206
|
-
// view of the repo:
|
|
206
|
+
// view of the repo: worker/git-sync.ts's syncGit() fetches GitHub into it
|
|
207
207
|
// (see GITHUB_TRACKING_REF_PREFIX's doc comment in git-manager.ts) and
|
|
208
208
|
// then reconciles refs/heads/* non-destructively, so it carries both
|
|
209
209
|
// this deployment's local heads and GitHub's view -- readable offline by
|
package/dist/branch-health.js
CHANGED
|
@@ -112,7 +112,7 @@ export async function scanBranchHealth(baseRoot, opts) {
|
|
|
112
112
|
// branch.json, etc.) land here: all are "needs admin attention",
|
|
113
113
|
// and none may throw out of the scan.
|
|
114
114
|
//
|
|
115
|
-
// [
|
|
115
|
+
// [REDACT] parseError is served to the browser via the admin
|
|
116
116
|
// branch-health endpoint, so it must never leak the absolute
|
|
117
117
|
// workspace path. BranchMetadataCorruptError carries `parseCause`
|
|
118
118
|
// (the raw JSON.parse message, path-free) for exactly this --
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading `branch.json` — the file format, nothing else.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately a LEAF: it imports only node built-ins, a type, and the error
|
|
5
|
+
* helper. That is the whole point of the file.
|
|
6
|
+
*
|
|
7
|
+
* `branch-registry.ts` scans every branch directory and reads each one's
|
|
8
|
+
* `branch.json`; `branch-metadata.ts` owns writing it and, after a write,
|
|
9
|
+
* invalidates the registry cache. Both of those are correct, but together they
|
|
10
|
+
* were a runtime import cycle (`branch-metadata` -> `branch-registry` ->
|
|
11
|
+
* `branch-metadata`), with value imports on both edges — the only cycle in the
|
|
12
|
+
* package when `no-circular` was first turned on, 2026-08-23.
|
|
13
|
+
*
|
|
14
|
+
* Hoisting the READ here breaks it without changing either module's behavior:
|
|
15
|
+
* the registry gets its reader from a leaf, and the metadata manager keeps its
|
|
16
|
+
* invalidation edge. `BranchMetadataFileManager.loadOnly` stays as a thin
|
|
17
|
+
* delegate so its ~16 existing call sites are untouched.
|
|
18
|
+
*/
|
|
19
|
+
import type { BranchMetadata } from './types.js';
|
|
20
|
+
export declare const BRANCH_META_DIR = ".canopy-meta";
|
|
21
|
+
export declare const BRANCH_META_FILE = "branch.json";
|
|
22
|
+
export interface BranchMetadataFile {
|
|
23
|
+
schemaVersion: number;
|
|
24
|
+
version: number;
|
|
25
|
+
writeId?: string;
|
|
26
|
+
branch: BranchMetadata;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* branch.json exists but its content is not valid JSON. Distinguished from
|
|
30
|
+
* provisioning/IO failures so callers can degrade instead of failing hard:
|
|
31
|
+
* the registry scan quarantines the branch, and the request handler keeps
|
|
32
|
+
* serving (with empty internal groups) when the BASE branch is the corrupt
|
|
33
|
+
* one — otherwise the admin recovery surface would be unreachable exactly
|
|
34
|
+
* when it is needed.
|
|
35
|
+
*/
|
|
36
|
+
export declare class BranchMetadataCorruptError extends Error {
|
|
37
|
+
readonly branchRoot: string;
|
|
38
|
+
/**
|
|
39
|
+
* [REDACT] The raw JSON.parse failure message (e.g. "Unexpected token
|
|
40
|
+
* ..."), with no embedded path. `message` above deliberately keeps the
|
|
41
|
+
* full `branchRoot`-qualified text for server logs; `parseCause` is what
|
|
42
|
+
* callers should surface to clients (see branch-health.ts's `parseError`)
|
|
43
|
+
* so the admin branch-health scan never leaks the absolute workspace path.
|
|
44
|
+
*/
|
|
45
|
+
readonly parseCause: string;
|
|
46
|
+
constructor(branchRoot: string, cause: string);
|
|
47
|
+
}
|
|
48
|
+
/** Absolute path to a branch workspace's `branch.json`. */
|
|
49
|
+
export declare const branchMetadataFilePath: (branchRoot: string) => string;
|
|
50
|
+
/**
|
|
51
|
+
* Read and parse `branch.json`, with no locking, no OCC and no side effects.
|
|
52
|
+
*
|
|
53
|
+
* Returns `null` when the file does not exist (an un-provisioned or
|
|
54
|
+
* non-branch directory), throws `BranchMetadataCorruptError` on malformed
|
|
55
|
+
* JSON, and rethrows every other IO failure unchanged — callers distinguish
|
|
56
|
+
* all three.
|
|
57
|
+
*/
|
|
58
|
+
export declare function readBranchMetadataFile(branchRoot: string): Promise<BranchMetadataFile | null>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading `branch.json` — the file format, nothing else.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately a LEAF: it imports only node built-ins, a type, and the error
|
|
5
|
+
* helper. That is the whole point of the file.
|
|
6
|
+
*
|
|
7
|
+
* `branch-registry.ts` scans every branch directory and reads each one's
|
|
8
|
+
* `branch.json`; `branch-metadata.ts` owns writing it and, after a write,
|
|
9
|
+
* invalidates the registry cache. Both of those are correct, but together they
|
|
10
|
+
* were a runtime import cycle (`branch-metadata` -> `branch-registry` ->
|
|
11
|
+
* `branch-metadata`), with value imports on both edges — the only cycle in the
|
|
12
|
+
* package when `no-circular` was first turned on, 2026-08-23.
|
|
13
|
+
*
|
|
14
|
+
* Hoisting the READ here breaks it without changing either module's behavior:
|
|
15
|
+
* the registry gets its reader from a leaf, and the metadata manager keeps its
|
|
16
|
+
* invalidation edge. `BranchMetadataFileManager.loadOnly` stays as a thin
|
|
17
|
+
* delegate so its ~16 existing call sites are untouched.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs/promises';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { isNotFoundError } from './utils/error.js';
|
|
22
|
+
export const BRANCH_META_DIR = '.canopy-meta';
|
|
23
|
+
export const BRANCH_META_FILE = 'branch.json';
|
|
24
|
+
/**
|
|
25
|
+
* branch.json exists but its content is not valid JSON. Distinguished from
|
|
26
|
+
* provisioning/IO failures so callers can degrade instead of failing hard:
|
|
27
|
+
* the registry scan quarantines the branch, and the request handler keeps
|
|
28
|
+
* serving (with empty internal groups) when the BASE branch is the corrupt
|
|
29
|
+
* one — otherwise the admin recovery surface would be unreachable exactly
|
|
30
|
+
* when it is needed.
|
|
31
|
+
*/
|
|
32
|
+
export class BranchMetadataCorruptError extends Error {
|
|
33
|
+
constructor(branchRoot, cause) {
|
|
34
|
+
super(`Corrupt branch metadata in '${branchRoot}': ${cause}`);
|
|
35
|
+
this.name = 'BranchMetadataCorruptError';
|
|
36
|
+
this.branchRoot = branchRoot;
|
|
37
|
+
this.parseCause = cause;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Absolute path to a branch workspace's `branch.json`. */
|
|
41
|
+
export const branchMetadataFilePath = (branchRoot) => path.join(path.resolve(branchRoot), BRANCH_META_DIR, BRANCH_META_FILE);
|
|
42
|
+
/**
|
|
43
|
+
* Read and parse `branch.json`, with no locking, no OCC and no side effects.
|
|
44
|
+
*
|
|
45
|
+
* Returns `null` when the file does not exist (an un-provisioned or
|
|
46
|
+
* non-branch directory), throws `BranchMetadataCorruptError` on malformed
|
|
47
|
+
* JSON, and rethrows every other IO failure unchanged — callers distinguish
|
|
48
|
+
* all three.
|
|
49
|
+
*/
|
|
50
|
+
export async function readBranchMetadataFile(branchRoot) {
|
|
51
|
+
const resolvedRoot = path.resolve(branchRoot);
|
|
52
|
+
try {
|
|
53
|
+
const raw = await fs.readFile(branchMetadataFilePath(resolvedRoot), 'utf8');
|
|
54
|
+
return JSON.parse(raw);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (isNotFoundError(err)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (err instanceof SyntaxError) {
|
|
61
|
+
throw new BranchMetadataCorruptError(resolvedRoot, err.message);
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -1,34 +1,10 @@
|
|
|
1
1
|
import type { BranchContext, BranchMetadata } from './types.js';
|
|
2
|
+
import { BRANCH_META_DIR, BRANCH_META_FILE, BranchMetadataCorruptError, readBranchMetadataFile, type BranchMetadataFile } from './branch-metadata-file.js';
|
|
2
3
|
import { type OperatingMode } from './operating-mode/index.js';
|
|
3
|
-
export
|
|
4
|
-
schemaVersion: number;
|
|
5
|
-
version: number;
|
|
6
|
-
writeId?: string;
|
|
7
|
-
branch: BranchMetadata;
|
|
8
|
-
}
|
|
4
|
+
export { BRANCH_META_DIR, BRANCH_META_FILE, BranchMetadataCorruptError, readBranchMetadataFile, type BranchMetadataFile, };
|
|
9
5
|
export declare class BranchMetadataConflictError extends Error {
|
|
10
6
|
constructor(message?: string);
|
|
11
7
|
}
|
|
12
|
-
/**
|
|
13
|
-
* branch.json exists but its content is not valid JSON. Distinguished from
|
|
14
|
-
* provisioning/IO failures so callers can degrade instead of failing hard:
|
|
15
|
-
* the registry scan quarantines the branch, and the request handler keeps
|
|
16
|
-
* serving (with empty internal groups) when the BASE branch is the corrupt
|
|
17
|
-
* one — otherwise the admin recovery surface would be unreachable exactly
|
|
18
|
-
* when it is needed.
|
|
19
|
-
*/
|
|
20
|
-
export declare class BranchMetadataCorruptError extends Error {
|
|
21
|
-
readonly branchRoot: string;
|
|
22
|
-
/**
|
|
23
|
-
* [MEDIUM-2] The raw JSON.parse failure message (e.g. "Unexpected token
|
|
24
|
-
* ..."), with no embedded path. `message` above deliberately keeps the
|
|
25
|
-
* full `branchRoot`-qualified text for server logs; `parseCause` is what
|
|
26
|
-
* callers should surface to clients (see branch-health.ts's `parseError`)
|
|
27
|
-
* so the admin branch-health scan never leaks the absolute workspace path.
|
|
28
|
-
*/
|
|
29
|
-
readonly parseCause: string;
|
|
30
|
-
constructor(branchRoot: string, cause: string);
|
|
31
|
-
}
|
|
32
8
|
/**
|
|
33
9
|
* Manages branch.json — branch status and access ACLs, both security-adjacent
|
|
34
10
|
* state — under `.canopy-meta/` in a branch workspace.
|
package/dist/branch-metadata.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { BranchRegistry } from './branch-registry.js';
|
|
4
|
+
import { BRANCH_META_DIR, BRANCH_META_FILE, BranchMetadataCorruptError, readBranchMetadataFile, } from './branch-metadata-file.js';
|
|
4
5
|
import { resolveBranchPath } from './paths/index.js';
|
|
5
6
|
import { isNotFoundError } from './utils/error.js';
|
|
6
7
|
import { withLock } from './utils/async-mutex.js';
|
|
7
8
|
import { writeOccJsonFile, withOccRetry, withOccFileLock, OccWriteConflictError, } from './utils/occ-json-write.js';
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
// The file format itself lives in the leaf so branch-registry.ts can read
|
|
10
|
+
// branch.json without importing this module (which imports it back). Re-exported
|
|
11
|
+
// here so the existing importers of these names do not have to move.
|
|
12
|
+
export { BRANCH_META_DIR, BRANCH_META_FILE, BranchMetadataCorruptError, readBranchMetadataFile, };
|
|
10
13
|
const CURRENT_SCHEMA_VERSION = 1;
|
|
11
14
|
export class BranchMetadataConflictError extends Error {
|
|
12
15
|
constructor(message = 'Concurrent modification detected in branch metadata') {
|
|
@@ -14,22 +17,6 @@ export class BranchMetadataConflictError extends Error {
|
|
|
14
17
|
this.name = 'BranchMetadataConflictError';
|
|
15
18
|
}
|
|
16
19
|
}
|
|
17
|
-
/**
|
|
18
|
-
* branch.json exists but its content is not valid JSON. Distinguished from
|
|
19
|
-
* provisioning/IO failures so callers can degrade instead of failing hard:
|
|
20
|
-
* the registry scan quarantines the branch, and the request handler keeps
|
|
21
|
-
* serving (with empty internal groups) when the BASE branch is the corrupt
|
|
22
|
-
* one — otherwise the admin recovery surface would be unreachable exactly
|
|
23
|
-
* when it is needed.
|
|
24
|
-
*/
|
|
25
|
-
export class BranchMetadataCorruptError extends Error {
|
|
26
|
-
constructor(branchRoot, cause) {
|
|
27
|
-
super(`Corrupt branch metadata in '${branchRoot}': ${cause}`);
|
|
28
|
-
this.name = 'BranchMetadataCorruptError';
|
|
29
|
-
this.branchRoot = branchRoot;
|
|
30
|
-
this.parseCause = cause;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
20
|
/**
|
|
34
21
|
* Manages branch.json — branch status and access ACLs, both security-adjacent
|
|
35
22
|
* state — under `.canopy-meta/` in a branch workspace.
|
|
@@ -69,21 +56,7 @@ export class BranchMetadataFileManager {
|
|
|
69
56
|
* Use this for read-only access (e.g., in registry scanning or loadBranchContext).
|
|
70
57
|
*/
|
|
71
58
|
static async loadOnly(branchRoot) {
|
|
72
|
-
|
|
73
|
-
const filePath = path.join(resolvedRoot, BRANCH_META_DIR, BRANCH_META_FILE);
|
|
74
|
-
try {
|
|
75
|
-
const raw = await fs.readFile(filePath, 'utf8');
|
|
76
|
-
return JSON.parse(raw);
|
|
77
|
-
}
|
|
78
|
-
catch (err) {
|
|
79
|
-
if (isNotFoundError(err)) {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
if (err instanceof SyntaxError) {
|
|
83
|
-
throw new BranchMetadataCorruptError(resolvedRoot, err.message);
|
|
84
|
-
}
|
|
85
|
-
throw err;
|
|
86
|
-
}
|
|
59
|
+
return readBranchMetadataFile(branchRoot);
|
|
87
60
|
}
|
|
88
61
|
/**
|
|
89
62
|
* Get a BranchMetadataFileManager instance configured for registry invalidation.
|
package/dist/branch-registry.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
|
|
3
|
+
// The leaf, NOT './branch-metadata' — that module imports this one back, and
|
|
4
|
+
// the pair was the package's only runtime import cycle. See branch-metadata-file.ts.
|
|
5
|
+
import { readBranchMetadataFile } from './branch-metadata-file.js';
|
|
4
6
|
import { isNotFoundError, getErrorMessage } from './utils/error.js';
|
|
5
7
|
import { createDebugLogger } from './utils/debug.js';
|
|
6
8
|
// canopyLogWarn, not console.warn: registry regeneration is reached from every
|
|
@@ -243,7 +245,7 @@ export class BranchRegistry {
|
|
|
243
245
|
// branch-health surface to report and repair.
|
|
244
246
|
let meta;
|
|
245
247
|
try {
|
|
246
|
-
meta = await
|
|
248
|
+
meta = await readBranchMetadataFile(branchRoot);
|
|
247
249
|
}
|
|
248
250
|
catch (err) {
|
|
249
251
|
canopyLogWarn(`CanopyCMS: Skipping branch directory '${entry.name}' during registry scan: ${getErrorMessage(err)}`);
|
|
@@ -67,6 +67,108 @@ async function removeEmptyDirsUpward(startDir, stopAt) {
|
|
|
67
67
|
current = path.dirname(current);
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* A `CANOPY_BUILD_ID` usable as a build id: it becomes a single path segment under
|
|
72
|
+
* `_next/static/`, so a value like `heads/main` (what `git describe --all` returns) would nest
|
|
73
|
+
* that directory, and one containing `..` would climb out of it.
|
|
74
|
+
*
|
|
75
|
+
* Deliberately duplicated in `canopycms-next`'s `with-canopy.ts` rather than shared. That file is
|
|
76
|
+
* loaded by `next.config.ts` before any bundler runs, which is why it ships pre-built and imports
|
|
77
|
+
* nothing from this package; importing a constant from here would pull this module's graph —
|
|
78
|
+
* `node:fs` included — into a config file that must be plain executable JavaScript. The two
|
|
79
|
+
* copies must agree, and the tests on both sides assert the same set of values.
|
|
80
|
+
*/
|
|
81
|
+
const SAFE_BUILD_ID = /^[A-Za-z0-9._-]{1,255}$/;
|
|
82
|
+
/** The message every rejection uses, so the stated rule and the enforced rule cannot drift. */
|
|
83
|
+
const BUILD_ID_RULE = 'must be 1-255 characters of [A-Za-z0-9._-] and not "." or ".."';
|
|
84
|
+
function isUsableBuildId(value) {
|
|
85
|
+
// `.` and `..` clear the character class but are not names — as a path segment they resolve to
|
|
86
|
+
// the static directory itself or its parent. `a..b` is an ordinary filename and stays allowed.
|
|
87
|
+
// The 255 bound is the same rule: a longer segment fails at `mkdir` with ENAMETOOLONG.
|
|
88
|
+
return SAFE_BUILD_ID.test(value) && value !== '.' && value !== '..';
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* What an unusable `SOURCE_DATE_EPOCH` actually costs, which depends on whether a build id is set.
|
|
92
|
+
*
|
|
93
|
+
* Worth spelling out rather than saying "unpinned": with a build id, `generated` is not merely
|
|
94
|
+
* un-pinned but ABSENT, and a field silently vanishing from a published artifact is the outcome an
|
|
95
|
+
* operator most needs told.
|
|
96
|
+
*/
|
|
97
|
+
function unpinnedConsequence(buildId) {
|
|
98
|
+
return buildId
|
|
99
|
+
? 'omitting `generated` from the manifest entirely (a build id is set, so no build clock is recorded).'
|
|
100
|
+
: 'recording the current time in `generated` instead.';
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the manifest's build stamp from the environment.
|
|
104
|
+
*
|
|
105
|
+
* This lives at the BUILD boundary, not inside `generateAIContent`, because the same generator
|
|
106
|
+
* also serves the runtime route — where a live clock is the correct answer and a
|
|
107
|
+
* `SOURCE_DATE_EPOCH` that happens to be exported in a server environment must not freeze a
|
|
108
|
+
* response's timestamp.
|
|
109
|
+
*
|
|
110
|
+
* `SOURCE_DATE_EPOCH` is the Reproducible Builds convention (decimal seconds since the epoch),
|
|
111
|
+
* kept under its standard name so a harness that already exports it for tar/gzip/rpm gets this
|
|
112
|
+
* for free. `CANOPY_BUILD_ID` is ours: Next has no such variable of its own.
|
|
113
|
+
*
|
|
114
|
+
* That id is validated here against a rule justified by Next's `_next/static/<id>/` layout, even
|
|
115
|
+
* though this module is framework-agnostic. Deliberate: the variable's whole purpose is that the
|
|
116
|
+
* manifest's `buildId` and the framework's build id name the SAME artifact, so a value one reader
|
|
117
|
+
* would reject is not useful to the other. The cost is that a non-Next adopter cannot use, say, an
|
|
118
|
+
* ISO timestamp as a build id — revisit this rule, in both copies, when a second framework lands.
|
|
119
|
+
*
|
|
120
|
+
* Every rejection warns and is ignored rather than failing the build — same stance as
|
|
121
|
+
* `readGeneratedRecord` above. Both variables treat "set but unusable" as a broken pipeline
|
|
122
|
+
* (`SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)` with a failing command) and say so, while
|
|
123
|
+
* "unset" is a deliberate opt-out and says nothing.
|
|
124
|
+
*
|
|
125
|
+
* Note what ignoring a bad `SOURCE_DATE_EPOCH` means when a build id IS set: `generatedAt` stays
|
|
126
|
+
* undefined, so `generated` is omitted rather than falling back to a live clock. A bad value must
|
|
127
|
+
* not resurrect a field the adopter's configuration says is meaningless — which is also why that
|
|
128
|
+
* case warns rather than failing silently, since the field simply disappears.
|
|
129
|
+
*/
|
|
130
|
+
function resolveBuildStamp() {
|
|
131
|
+
// Trimmed and shape-checked with the same rule `withCanopy` applies, so that when an adopter
|
|
132
|
+
// pins BOTH halves of a build the manifest's `buildId` and Next's `_next/static/<id>/` agree.
|
|
133
|
+
// (They are independent readers of one variable: a host-supplied `generateBuildId`, or the CMS
|
|
134
|
+
// flavor of a dual build, can still leave Next on a different id — this only guarantees that
|
|
135
|
+
// the variable itself is read identically.)
|
|
136
|
+
const rawBuildId = process.env.CANOPY_BUILD_ID;
|
|
137
|
+
const trimmedBuildId = rawBuildId?.trim();
|
|
138
|
+
let buildId;
|
|
139
|
+
if (rawBuildId === undefined) {
|
|
140
|
+
buildId = undefined;
|
|
141
|
+
}
|
|
142
|
+
else if (!trimmedBuildId) {
|
|
143
|
+
console.warn('CanopyCMS: CANOPY_BUILD_ID is set but blank — recording no buildId in the manifest.');
|
|
144
|
+
}
|
|
145
|
+
else if (!isUsableBuildId(trimmedBuildId)) {
|
|
146
|
+
console.warn(`CanopyCMS: ignoring CANOPY_BUILD_ID="${trimmedBuildId}": it ${BUILD_ID_RULE}, so that ` +
|
|
147
|
+
"the manifest's buildId can match the build id Next stores for the same artifact.");
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
buildId = trimmedBuildId;
|
|
151
|
+
}
|
|
152
|
+
const rawEpoch = process.env.SOURCE_DATE_EPOCH;
|
|
153
|
+
const trimmedEpoch = rawEpoch?.trim();
|
|
154
|
+
if (rawEpoch !== undefined && !trimmedEpoch) {
|
|
155
|
+
console.warn(`CanopyCMS: SOURCE_DATE_EPOCH is set but blank — ${unpinnedConsequence(buildId)}`);
|
|
156
|
+
}
|
|
157
|
+
if (!trimmedEpoch)
|
|
158
|
+
return { buildId };
|
|
159
|
+
// Digits only, applied AFTER trimming: surrounding whitespace is a plausible accident in a
|
|
160
|
+
// shell-exported variable and the intent is unambiguous, but `Number('0x10')` also parses and
|
|
161
|
+
// `0x10` is not a SOURCE_DATE_EPOCH. The `Number.isNaN` check below also catches values large
|
|
162
|
+
// enough that `* 1000` overflows the Date range.
|
|
163
|
+
const seconds = /^\d+$/.test(trimmedEpoch) ? Number(trimmedEpoch) : Number.NaN;
|
|
164
|
+
const date = new Date(seconds * 1000);
|
|
165
|
+
if (Number.isNaN(date.getTime())) {
|
|
166
|
+
console.warn(`CanopyCMS: ignoring SOURCE_DATE_EPOCH="${trimmedEpoch}" (expected decimal seconds since ` +
|
|
167
|
+
`the Unix epoch) — ${unpinnedConsequence(buildId)}`);
|
|
168
|
+
return { buildId };
|
|
169
|
+
}
|
|
170
|
+
return { generatedAt: date.toISOString(), buildId };
|
|
171
|
+
}
|
|
70
172
|
/**
|
|
71
173
|
* Generate AI content files and write them to disk.
|
|
72
174
|
*
|
|
@@ -105,6 +207,7 @@ export async function generateAIContentFiles(options) {
|
|
|
105
207
|
contentRoot: contentRootName,
|
|
106
208
|
config: aiConfig,
|
|
107
209
|
entryLinkUrl: config.entryLinkUrl,
|
|
210
|
+
...resolveBuildStamp(),
|
|
108
211
|
});
|
|
109
212
|
// Write files to disk
|
|
110
213
|
const absoluteOutputDir = path.resolve(outputDir) + path.sep;
|