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.
Files changed (60) hide show
  1. package/README.md +1 -3
  2. package/dist/ai/generate.d.ts +13 -0
  3. package/dist/ai/generate.js +9 -3
  4. package/dist/ai/to-plain-text.js +4 -0
  5. package/dist/ai/types.d.ts +20 -1
  6. package/dist/api/admin-branch-health.js +13 -12
  7. package/dist/api/branch.js +2 -2
  8. package/dist/branch-health.js +1 -1
  9. package/dist/branch-metadata-file.d.ts +58 -0
  10. package/dist/branch-metadata-file.js +65 -0
  11. package/dist/branch-metadata.d.ts +2 -26
  12. package/dist/branch-metadata.js +6 -33
  13. package/dist/branch-registry.js +4 -2
  14. package/dist/build/generate-ai-content.js +103 -0
  15. package/dist/cli/cli.js +432 -317
  16. package/dist/cli/generate-ai-content.js +264 -160
  17. package/dist/content-id-index.js +22 -0
  18. package/dist/content-listing.js +3 -0
  19. package/dist/content-reader.d.ts +25 -1
  20. package/dist/content-reader.js +15 -0
  21. package/dist/content-store.d.ts +50 -1
  22. package/dist/content-store.js +102 -4
  23. package/dist/context.d.ts +36 -4
  24. package/dist/context.js +15 -4
  25. package/dist/editor/admin/SystemHealthPanel.js +2 -2
  26. package/dist/entry-schema.d.ts +22 -0
  27. package/dist/git-manager.d.ts +9 -8
  28. package/dist/git-manager.js +35 -9
  29. package/dist/github-service.d.ts +1 -1
  30. package/dist/github-service.js +1 -1
  31. package/dist/paths/branch-name.d.ts +1 -1
  32. package/dist/paths/branch-name.js +1 -1
  33. package/dist/schema/index.d.ts +1 -1
  34. package/dist/schema/index.js +1 -1
  35. package/dist/schema/meta-loader.js +1 -1
  36. package/dist/services.d.ts +22 -0
  37. package/dist/types.d.ts +1 -1
  38. package/dist/url-exclusivity-fixtures.d.ts +76 -0
  39. package/dist/url-exclusivity-fixtures.js +119 -0
  40. package/dist/url-path-resolver.js +8 -4
  41. package/dist/utils/content-write-lock.d.ts +2 -1
  42. package/dist/utils/content-write-lock.js +2 -1
  43. package/dist/utils/error.d.ts +17 -0
  44. package/dist/utils/error.js +17 -0
  45. package/dist/utils/git.d.ts +1 -1
  46. package/dist/utils/git.js +1 -1
  47. package/dist/utils/occ-json-write.js +1 -1
  48. package/dist/worker/cms-worker.d.ts +22 -288
  49. package/dist/worker/cms-worker.js +118 -1995
  50. package/dist/worker/git-sync.d.ts +166 -0
  51. package/dist/worker/git-sync.js +554 -0
  52. package/dist/worker/history-rewrite.d.ts +129 -0
  53. package/dist/worker/history-rewrite.js +216 -0
  54. package/dist/worker/rebase.d.ts +171 -0
  55. package/dist/worker/rebase.js +859 -0
  56. package/dist/worker/task-runner.d.ts +93 -0
  57. package/dist/worker/task-runner.js +529 -0
  58. package/dist/worker/worker-context.d.ts +133 -0
  59. package/dist/worker/worker-context.js +1 -0
  60. 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
 
@@ -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;
@@ -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
- generated: new Date().toISOString(),
97
+ ...(buildId ? { buildId } : {}),
98
+ ...(generatedAt || !buildId ? { generated: generatedAt || new Date().toISOString() } : {}),
93
99
  entries: rootEntries,
94
100
  collections: manifestCollections,
95
101
  bundles: manifestBundles,
@@ -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);
@@ -215,7 +215,26 @@ export interface AIManifestBundle {
215
215
  }
216
216
  /** Top-level manifest for AI content */
217
217
  export interface AIManifest {
218
- generated: string;
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/cms-worker.ts).
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
- * - [H2] the purge itself runs under a zero-retry provisioning lock, so a
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
- // [H2] Zero-retry provisioning lock: fails fast (409) on genuine live
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
- * [MEDIUM-1] The provisioning lock is held across the ENTIRE archive+save
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
- // [MEDIUM-1] Zero-retry provisioning lock, mirroring purge's [H2]: fails
310
- // fast (409) on genuine live contention instead of hanging the request.
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
- // [MEDIUM-3] Prefer the clone's actual checked-out branch over the
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
- // [MEDIUM-1] in the docstring.
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
- * [MEDIUM-3] Best-effort read of the branch actually checked out in the
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`),
@@ -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/cms-worker.ts) treats any
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: cms-worker.ts's syncGit() fetches GitHub into it
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
@@ -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
- // [MEDIUM-2] parseError is served to the browser via the admin
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 interface BranchMetadataFile {
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.
@@ -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
- const BRANCH_META_DIR = '.canopy-meta';
9
- const BRANCH_META_FILE = 'branch.json';
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
- const resolvedRoot = path.resolve(branchRoot);
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.
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { BranchMetadataFileManager } from './branch-metadata.js';
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 BranchMetadataFileManager.loadOnly(branchRoot);
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;