canopycms 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/cli.js CHANGED
@@ -7905,7 +7905,7 @@ async function resolveBranchRoot(config) {
7905
7905
  config: resolved,
7906
7906
  branchName: activeBranch,
7907
7907
  mode: config.mode,
7908
- createdBy: "canopycms-ai",
7908
+ createdBy: AI_USER_ID,
7909
7909
  remoteUrl: config.defaultRemoteUrl
7910
7910
  });
7911
7911
  return context.branchRoot;
@@ -8434,7 +8434,7 @@ async function generateAIContentCLI(options) {
8434
8434
  console.log(` Output: ${result.outputDir}
8435
8435
  `);
8436
8436
  }
8437
- var __defProp3, __getOwnPropNames3, __esm3, __export3, init_error3, init_atomic_write2, init_field_traversal, ITEM_KEY, ITEM_KEY_PATTERN, isItemKey, BASE58, isListField, init_item_keys, active2, init_logger2, SENTINEL_BASE2, DOT_SEGMENT2, init_sanitize_href2, init_normalize, RESERVED_SETTINGS_BRANCH_PREFIX2, init_branch_name, BASE58_PATTERN2, CONTENT_ID_PATTERN2, PHYSICAL_SEGMENT_PATTERN2, MAX_BRANCH_NAME_LENGTH2, init_validation, ROOT_COLLECTION_ID, init_types2, normalizePathValue, flattenSchema, init_flatten, scalarFieldTypes2, markdownFieldTypes2, primitiveFieldTypes2, fieldTypes2, init_types22, SAFE_HTML_TAGS2, COMPONENT_NAME2, MDX_BINDINGS2, FORBIDDEN_ATTRIBUTES2, init_mdx_allowlist2, fieldBaseSchema2, selectOptionSchema2, referenceOptionSchema2, primitiveFieldSchema2, mdxPropAllowSchema2, mdxAllowlistSchema2, markdownFieldSchema2, ASPECT_RATIO_RE2, imageFieldSchema2, selectFieldSchema2, referenceSortFieldSchema2, referenceFieldSchema2, fieldHolder2, blockSchema2, blockFieldSchema2, objectFieldSchema2, inlineGroupFieldSchema2, customFieldSchema2, knownFieldSchema2, fieldSchema2, init_field2, relativePathSchema2, entryTypeSchema2, collectionSchema2, init_collection2, uploadTargetUrlSchema2, previewPrefixSchema2, editorUrlSchema2, init_url2, mediaSchema2, init_media2, defaultBranchAccessSchema2, defaultPathAccessLevelSchema2, defaultPathAccessLevelsSchema2, defaultPathAccessSchema2, defaultBaseBranchSchema2, defaultRemoteNameSchema2, defaultRemoteUrlSchema2, gitBotAuthorNameSchema2, gitBotAuthorEmailSchema2, githubTokenEnvVarSchema2, operatingModeSchema2, deployedAsSchema2, contentRootSchema2, sourceRootSchema2, basePathSchema2, deploymentNameSchema2, devContentSyncSchema2, devConfigSchema2, editorConfigSchema2, seoFieldNameSchema2, seoSchema2, CanopyConfigSchema2, DEFAULT_PROD_WORKSPACE2, init_config3, SERVER_MODE_ENV_VAR, BROWSER_MODE_ENV_VAR, warned22, init_mode_env, forEachReferenceField, init_validation2, ProdClientSafeStrategy2, DevClientSafeStrategy2, clientStrategyCache, init_client_safe_strategy, warned3, VALID_DEPLOYMENT_NAME2, isValidDeploymentName2, init_deployment_name, warned4, DEPLOYMENT_PLACEHOLDER2, DEFAULT_BRANCH_NAMESPACE_TEMPLATE2, BRANCH_NAMESPACE_ENV2, init_branch_namespace, init_config22, ProdStrategy2, DevStrategy2, strategyCache2, init_client_unsafe_strategy, operating_mode_exports, init_operating_mode, task_queue_config_exports2, init_task_queue_config2, worker_status_exports, WORKER_STATUS_FILE, init_worker_status, BLOCK_DISCRIMINATOR_KEYS, BLOCK_STRUCTURAL_KEYS, envFlag2, LOG_LEVELS2, DebugLogger2, testLogger2, MARKDOWN_EXPORT_OPTIONS, BULLETS, DELIMITERS, EMPHASIS, STRONG, RULE, WORD_CHAR, GAP, Restyler, END, FRESH, LINE_WIDTH, MIN_LINE_WIDTH, SourceSplicer, log2, MAX_LCS_CELLS, Splicer, log22, EVIDENCE_MAX_DEPTH, log3, locks, PROVISIONING_LOCK_STALE_MS, sleep, META_DIR, CONTENT_WRITE_LOCK_NAME, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, ContentWriteLockBusyError, sleep2, CONTENT_WRITE_LOCK_STALE_MS, heldRoots, getFormatExtension, isDataOnlyFormat, asRecord, DEFAULT_SEO_FIELD_NAMES, asRecord2, asText, asFlag, asImageUrl, asOgType, asTwitterCard, RESTRICTED_REFERENCE_MARKER, SEO_GROUP_FIELDS, MAX_WARNED, warned5, SchemaUnavailableError, isValidId, parseTypedFilename, EMPTY_LOGICAL_PATH, ENTRY_EXTENSIONS, ContentIdIndex, CONTENT_EXTENSIONS, registry, finalization, log4, META_DIR2, entryTypeMetaSchema, collectionMetaSchema, rootCollectionMetaSchema, fingerprints, editDistance, closestName, forEachEntryType, collectEntryTypeNames, validateReferenceEntryTypes, isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER, storage, log5, BaseBranchUnresolvedError, NETWORK_SCHEME_PATTERN, TRANSPORT_HELPER_PATTERN, LEADING_OPTION_PATTERN, WINDOWS_DRIVE_PATTERN, COLON_BEFORE_SLASH_PATTERN, MISSING_REMOTE_REF_REASONS, CANOPY_META_DIR, SCHEMA_CACHE_VERSION, MTIME_CHECK_DEBOUNCE_MS, SNAPSHOT_MEMO_MAX_BYTES, SCHEMA_CACHE_FILE, SCHEMA_GENERATION_RESOURCE, reportedIssues, BranchSchemaCache, RESOURCE, BranchPathError, resolveContentBranchesRoot, ContentStoreError, ContentConflictError, EntryGoneError, BranchSyncingError, DuplicateContentIdError, UrlPathConflictError, isEmptyFieldValue, DEFAULT_INDEX_FRESHNESS_INTERVAL_MS, FORCED_REFRESH_MIN_INTERVAL_MS, STALE_LOOKUP, isMissingReference, createReferenceResolveCache, ContentStore, ATTR_CONTENT, BLOCK_PREFIX, BLOCK_SUFFIX, BLOCK_RESTORE_RE, INLINE_PREFIX, INLINE_SUFFIX, INLINE_RESTORE_RE, DEFAULT_REFERENCE_RENDERING, FLAT_CELL_TYPES, log6, BASE58_CHAR, ENTRY_LINK_PATTERN, INLINE_LINK_TARGET, BranchMetadataCorruptError, BRANCH_META_DIR, BRANCH_META_FILE, branchMetadataFilePath, UNKNOWN_TIME, enumOf, branchMetadataShape, branchMetadataSchema, branchMetadataFileSchema, log7, REGISTRY_FILE, REGISTRY_STALE_FILE, REGISTRY_TEMP_FILE, REGISTRY_VERSION, RESOURCE2, GET_MISS_REFRESH_MIN_INTERVAL_MS, snapshotSchema, reportedUnreadable, BranchRegistry, log8, OccWriteConflictError, CURRENT_SCHEMA_VERSION, BranchMetadataConflictError, BranchMetadataFileManager, loadBranchContext, GitProcessError, STDERR_CAP_BYTES, KILL_GRACE_MS, log9, GIT_ENV_PASSTHROUGH, FORCE_C_LOCALE, GIT_NETWORK_ENV_PASSTHROUGH, NO_AUTO_GC_CONFIG, HASH_INACTIVITY_TIMEOUT_MS, MAX_HASH_RUNS, LS_REMOTE_INACTIVITY_TIMEOUT_MS, symlinkBlobSha, REMOTE_GIT_CONFIG, REMOTE_GIT_MAX_LOOSE_OBJECTS, REMOTE_GIT_MAX_PACKS, PARALLEL_CHECKOUT_ARGS, remoteInitLocks, GITHUB_TRACKING_REF_PREFIX, BaseBranchMissingError, GitConflictError, GitRemoteRefMissingError, RemoteNotReadyError, SettingsRemoteUnreadableError, SettingsBranchDivergedError, SETTINGS_BRANCH_FILES, SettingsBranchHasContentHistoryError, GitManager, PUBLISH_LOCK_WAIT_MS, CREATE_RESIDUE_MIN_QUIET_MS, STAGING_MAX_AGE_MS, REPAIR_MAX_AGE_MS, DELETING_MAX_AGE_MS, SETTINGS_TRASH_MAX_AGE_MS, BranchProvisioningBusyError, BranchDirOccupiedError, STAGING_PREFIX, REPAIR_PREFIX, TRASH_PREFIX, stagingDirName, NO_GIT, RENAME_BLOCKED, SPARSE_CONE_RECORD, defaultSink, sink, ProvisionLog, testHooks, provisionsInFlight, BranchWorkspaceManager, log10, normalizePath, parseEntryParts, parseEntryData, readEntryData, createReferenceResolver, withoutUnavailableEntries, resolveCollectionItemReferences, looksLikeMalformedEntry, listCollectionEntries, MAX_INPUT_PIXELS, CROP_PRECISION, KNOWN_FIELD_TYPES, STRING_FIELD_TYPES, selectOptionValues, GENERATED_RECORD_FILENAME, SAFE_BUILD_ID, BUILD_ID_RULE, jiti;
8437
+ var __defProp3, __getOwnPropNames3, __esm3, __export3, init_error3, init_atomic_write2, init_field_traversal, ITEM_KEY, ITEM_KEY_PATTERN, isItemKey, BASE58, isListField, init_item_keys, active2, init_logger2, SENTINEL_BASE2, DOT_SEGMENT2, init_sanitize_href2, init_normalize, RESERVED_SETTINGS_BRANCH_PREFIX2, init_branch_name, BASE58_PATTERN2, CONTENT_ID_PATTERN2, PHYSICAL_SEGMENT_PATTERN2, MAX_BRANCH_NAME_LENGTH2, init_validation, ROOT_COLLECTION_ID, init_types2, normalizePathValue, flattenSchema, init_flatten, scalarFieldTypes2, markdownFieldTypes2, primitiveFieldTypes2, fieldTypes2, init_types22, SAFE_HTML_TAGS2, COMPONENT_NAME2, MDX_BINDINGS2, FORBIDDEN_ATTRIBUTES2, init_mdx_allowlist2, fieldBaseSchema2, selectOptionSchema2, referenceOptionSchema2, primitiveFieldSchema2, mdxPropAllowSchema2, mdxAllowlistSchema2, markdownFieldSchema2, ASPECT_RATIO_RE2, imageFieldSchema2, selectFieldSchema2, referenceSortFieldSchema2, referenceFieldSchema2, fieldHolder2, blockSchema2, blockFieldSchema2, objectFieldSchema2, inlineGroupFieldSchema2, customFieldSchema2, knownFieldSchema2, fieldSchema2, init_field2, relativePathSchema2, entryTypeSchema2, collectionSchema2, init_collection2, uploadTargetUrlSchema2, previewPrefixSchema2, editorUrlSchema2, init_url2, mediaSchema2, init_media2, defaultBranchAccessSchema2, defaultPathAccessLevelSchema2, defaultPathAccessLevelsSchema2, defaultPathAccessSchema2, defaultBaseBranchSchema2, defaultRemoteNameSchema2, defaultRemoteUrlSchema2, gitBotAuthorNameSchema2, gitBotAuthorEmailSchema2, githubTokenEnvVarSchema2, operatingModeSchema2, deployedAsSchema2, contentRootSchema2, sourceRootSchema2, basePathSchema2, deploymentNameSchema2, devContentSyncSchema2, devConfigSchema2, editorConfigSchema2, seoFieldNameSchema2, seoSchema2, CanopyConfigSchema2, DEFAULT_PROD_WORKSPACE2, init_config3, SERVER_MODE_ENV_VAR, BROWSER_MODE_ENV_VAR, warned22, init_mode_env, forEachReferenceField, init_validation2, ProdClientSafeStrategy2, DevClientSafeStrategy2, clientStrategyCache, init_client_safe_strategy, warned3, VALID_DEPLOYMENT_NAME2, isValidDeploymentName2, init_deployment_name, warned4, DEPLOYMENT_PLACEHOLDER2, DEFAULT_BRANCH_NAMESPACE_TEMPLATE2, BRANCH_NAMESPACE_ENV2, init_branch_namespace, init_config22, ProdStrategy2, DevStrategy2, strategyCache2, init_client_unsafe_strategy, operating_mode_exports, init_operating_mode, task_queue_config_exports2, init_task_queue_config2, worker_status_exports, WORKER_STATUS_FILE, init_worker_status, BLOCK_DISCRIMINATOR_KEYS, BLOCK_STRUCTURAL_KEYS, envFlag2, LOG_LEVELS2, DebugLogger2, testLogger2, MARKDOWN_EXPORT_OPTIONS, BULLETS, DELIMITERS, EMPHASIS, STRONG, RULE, WORD_CHAR, GAP, Restyler, END, FRESH, LINE_WIDTH, MIN_LINE_WIDTH, SourceSplicer, log2, MAX_LCS_CELLS, Splicer, log22, EVIDENCE_MAX_DEPTH, log3, locks, PROVISIONING_LOCK_STALE_MS, sleep, META_DIR, CONTENT_WRITE_LOCK_NAME, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, ContentWriteLockBusyError, sleep2, CONTENT_WRITE_LOCK_STALE_MS, heldRoots, getFormatExtension, isDataOnlyFormat, asRecord, DEFAULT_SEO_FIELD_NAMES, asRecord2, asText, asFlag, asImageUrl, asOgType, asTwitterCard, RESTRICTED_REFERENCE_MARKER, SEO_GROUP_FIELDS, MAX_WARNED, warned5, SchemaUnavailableError, isValidId, parseTypedFilename, EMPTY_LOGICAL_PATH, ENTRY_EXTENSIONS, ContentIdIndex, CONTENT_EXTENSIONS, registry, finalization, log4, META_DIR2, entryTypeMetaSchema, collectionMetaSchema, rootCollectionMetaSchema, fingerprints, editDistance, closestName, forEachEntryType, collectEntryTypeNames, validateReferenceEntryTypes, isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER, storage, log5, BaseBranchUnresolvedError, NETWORK_SCHEME_PATTERN, TRANSPORT_HELPER_PATTERN, LEADING_OPTION_PATTERN, WINDOWS_DRIVE_PATTERN, COLON_BEFORE_SLASH_PATTERN, MISSING_REMOTE_REF_REASONS, CANOPY_META_DIR, SCHEMA_CACHE_VERSION, MTIME_CHECK_DEBOUNCE_MS, SNAPSHOT_MEMO_MAX_BYTES, SCHEMA_CACHE_FILE, SCHEMA_GENERATION_RESOURCE, reportedIssues, BranchSchemaCache, RESOURCE, BranchPathError, resolveContentBranchesRoot, ContentStoreError, ContentConflictError, EntryGoneError, BranchSyncingError, DuplicateContentIdError, UrlPathConflictError, isEmptyFieldValue, DEFAULT_INDEX_FRESHNESS_INTERVAL_MS, FORCED_REFRESH_MIN_INTERVAL_MS, STALE_LOOKUP, isMissingReference, createReferenceResolveCache, ContentStore, ATTR_CONTENT, BLOCK_PREFIX, BLOCK_SUFFIX, BLOCK_RESTORE_RE, INLINE_PREFIX, INLINE_SUFFIX, INLINE_RESTORE_RE, DEFAULT_REFERENCE_RENDERING, FLAT_CELL_TYPES, log6, BASE58_CHAR, ENTRY_LINK_PATTERN, INLINE_LINK_TARGET, BranchMetadataCorruptError, BRANCH_META_DIR, BRANCH_META_FILE, branchMetadataFilePath, UNKNOWN_TIME, enumOf, branchMetadataShape, branchMetadataSchema, branchMetadataFileSchema, log7, REGISTRY_FILE, REGISTRY_STALE_FILE, REGISTRY_TEMP_FILE, REGISTRY_VERSION, RESOURCE2, GET_MISS_REFRESH_MIN_INTERVAL_MS, snapshotSchema, reportedUnreadable, BranchRegistry, log8, OccWriteConflictError, CURRENT_SCHEMA_VERSION, BranchMetadataConflictError, BranchMetadataFileManager, loadBranchContext, GitProcessError, STDERR_CAP_BYTES, KILL_GRACE_MS, log9, GIT_ENV_PASSTHROUGH, FORCE_C_LOCALE, GIT_NETWORK_ENV_PASSTHROUGH, NO_AUTO_GC_CONFIG, HASH_INACTIVITY_TIMEOUT_MS, MAX_HASH_RUNS, LS_REMOTE_INACTIVITY_TIMEOUT_MS, symlinkBlobSha, REMOTE_GIT_CONFIG, REMOTE_GIT_MAX_LOOSE_OBJECTS, REMOTE_GIT_MAX_PACKS, PARALLEL_CHECKOUT_ARGS, remoteInitLocks, GITHUB_TRACKING_REF_PREFIX, BaseBranchMissingError, GitConflictError, GitRemoteRefMissingError, RemoteNotReadyError, SettingsRemoteUnreadableError, SettingsBranchDivergedError, SETTINGS_BRANCH_FILES, SettingsBranchHasContentHistoryError, GitManager, PUBLISH_LOCK_WAIT_MS, CREATE_RESIDUE_MIN_QUIET_MS, STAGING_MAX_AGE_MS, REPAIR_MAX_AGE_MS, DELETING_MAX_AGE_MS, SETTINGS_TRASH_MAX_AGE_MS, BranchProvisioningBusyError, BranchDirOccupiedError, STAGING_PREFIX, REPAIR_PREFIX, TRASH_PREFIX, stagingDirName, NO_GIT, RENAME_BLOCKED, SPARSE_CONE_RECORD, defaultSink, sink, ProvisionLog, testHooks, provisionsInFlight, BranchWorkspaceManager, AI_USER_ID, log10, normalizePath, parseEntryParts, parseEntryData, readEntryData, createReferenceResolver, withoutUnavailableEntries, resolveCollectionItemReferences, looksLikeMalformedEntry, listCollectionEntries, MAX_INPUT_PIXELS, CROP_PRECISION, KNOWN_FIELD_TYPES, STRING_FIELD_TYPES, selectOptionValues, GENERATED_RECORD_FILENAME, SAFE_BUILD_ID, BUILD_ID_RULE, jiti;
8438
8438
  var init_generate_ai_content = __esm({
8439
8439
  "dist/cli/generate-ai-content.js"() {
8440
8440
  "use strict";
@@ -14593,6 +14593,7 @@ var init_generate_ai_content = __esm({
14593
14593
  return { kind: "exists", context: { branch: meta.branch, branchRoot, baseRoot } };
14594
14594
  }
14595
14595
  };
14596
+ AI_USER_ID = "canopycms-ai";
14596
14597
  init_error3();
14597
14598
  init_item_keys();
14598
14599
  log10 = createDebugLogger2({ prefix: "ContentListing" });
@@ -17045,6 +17046,10 @@ function sanitizeBranchName3(branchName) {
17045
17046
  const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
17046
17047
  return trimmedDots || "branch";
17047
17048
  }
17049
+ function isSettingsBranchName2(branchName, settingsBranchName) {
17050
+ const sanitized = sanitizeBranchName3(branchName);
17051
+ return sanitized.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX3) || settingsBranchName !== void 0 && sanitized === sanitizeBranchName3(settingsBranchName);
17052
+ }
17048
17053
  var RESERVED_SETTINGS_BRANCH_PREFIX3;
17049
17054
  var init_branch_name2 = __esm({
17050
17055
  "dist/paths/branch-name.js"() {
@@ -17057,6 +17062,18 @@ var init_branch_name2 = __esm({
17057
17062
  function isValidContentId2(id) {
17058
17063
  return CONTENT_ID_PATTERN3.test(id);
17059
17064
  }
17065
+ function isExactBranchForm(param) {
17066
+ return param.startsWith(EXACT_BRANCH_MARK);
17067
+ }
17068
+ function exactBranchSegment(fullName) {
17069
+ return `${EXACT_BRANCH_MARK}${fullName.replaceAll("/", EXACT_BRANCH_MARK)}`;
17070
+ }
17071
+ function exactBranchName(param) {
17072
+ if (!isExactBranchForm(param) || param.includes("/"))
17073
+ return null;
17074
+ const named = parseBranchRef3(param.slice(EXACT_BRANCH_MARK.length).replaceAll(EXACT_BRANCH_MARK, "/"));
17075
+ return named.ok ? named.name : null;
17076
+ }
17060
17077
  function parseBranchRef3(name) {
17061
17078
  if (!name || typeof name !== "string") {
17062
17079
  return { ok: false, error: "Branch name is required" };
@@ -17124,7 +17141,7 @@ function parseSlug2(slug) {
17124
17141
  }
17125
17142
  return { ok: true, slug: normalized };
17126
17143
  }
17127
- var BASE58_PATTERN3, CONTENT_ID_PATTERN3, PHYSICAL_SEGMENT_PATTERN3, MAX_BRANCH_NAME_LENGTH3;
17144
+ var BASE58_PATTERN3, CONTENT_ID_PATTERN3, PHYSICAL_SEGMENT_PATTERN3, MAX_BRANCH_NAME_LENGTH3, EXACT_BRANCH_MARK;
17128
17145
  var init_validation3 = __esm({
17129
17146
  "dist/paths/validation.js"() {
17130
17147
  "use strict";
@@ -17132,6 +17149,7 @@ var init_validation3 = __esm({
17132
17149
  CONTENT_ID_PATTERN3 = new RegExp(`^${BASE58_PATTERN3}{12}$`);
17133
17150
  PHYSICAL_SEGMENT_PATTERN3 = new RegExp(`\\.${BASE58_PATTERN3}{12}(?:\\.[a-z]+)?$`);
17134
17151
  MAX_BRANCH_NAME_LENGTH3 = 250;
17152
+ EXACT_BRANCH_MARK = "~";
17135
17153
  }
17136
17154
  });
17137
17155
 
@@ -17508,27 +17526,39 @@ var init_operating_mode2 = __esm({
17508
17526
  }
17509
17527
  });
17510
17528
 
17511
- // dist/cli/configured-asset-store.js
17512
- var configured_asset_store_exports = {};
17513
- __export(configured_asset_store_exports, {
17514
- loadConfiguredAssetStore: () => loadConfiguredAssetStore
17515
- });
17529
+ // dist/cli/site-config.js
17516
17530
  import path31 from "node:path";
17517
17531
  import { createJiti as createJiti3 } from "jiti";
17518
- import { z as z15 } from "zod";
17519
- async function loadConfiguredAssetStore(projectDir) {
17532
+ async function importSiteServerConfig(projectDir) {
17520
17533
  const configPath = path31.join(projectDir, "canopycms.config.ts");
17521
17534
  const jiti2 = createJiti3(import.meta.url);
17522
17535
  const mod = await jiti2.import(configPath);
17523
17536
  const configExport = mod.default ?? mod.config ?? mod;
17524
17537
  const server = typeof configExport === "object" && configExport !== null && "server" in configExport ? configExport.server : configExport;
17538
+ return { configPath, server };
17539
+ }
17540
+ var init_site_config = __esm({
17541
+ "dist/cli/site-config.js"() {
17542
+ "use strict";
17543
+ }
17544
+ });
17545
+
17546
+ // dist/cli/configured-asset-store.js
17547
+ var configured_asset_store_exports = {};
17548
+ __export(configured_asset_store_exports, {
17549
+ loadConfiguredAssetStore: () => loadConfiguredAssetStore
17550
+ });
17551
+ import path33 from "node:path";
17552
+ import { z as z15 } from "zod";
17553
+ async function loadConfiguredAssetStore(projectDir) {
17554
+ const { configPath, server } = await importSiteServerConfig(projectDir);
17525
17555
  const parsed = serverConfigSchema.safeParse(server);
17526
17556
  if (!parsed.success) {
17527
17557
  throw new Error(`Invalid CanopyCMS config at ${configPath}: ${parsed.error.message}`);
17528
17558
  }
17529
17559
  const { mode, media } = parsed.data;
17530
- const devAssetsDir = mode === "dev" ? path31.join(operatingStrategy3(mode).getWorkspaceRoot(projectDir), "assets") : void 0;
17531
- const resolvedMedia = media?.adapter === "local" && media.directory ? { ...media, directory: path31.resolve(projectDir, media.directory) } : media;
17560
+ const devAssetsDir = mode === "dev" ? path33.join(operatingStrategy3(mode).getWorkspaceRoot(projectDir), "assets") : void 0;
17561
+ const resolvedMedia = media?.adapter === "local" && media.directory ? { ...media, directory: path33.resolve(projectDir, media.directory) } : media;
17532
17562
  const store = createAssetStore(resolvedMedia, { devAssetsDir });
17533
17563
  if (!store) {
17534
17564
  throw new Error(`No asset store is configured in ${configPath}: set \`media\` (an s3 adapter in prod).`);
@@ -17542,6 +17572,7 @@ var init_configured_asset_store = __esm({
17542
17572
  init_factory();
17543
17573
  init_media3();
17544
17574
  init_operating_mode2();
17575
+ init_site_config();
17545
17576
  serverConfigSchema = z15.object({
17546
17577
  mode: z15.enum(["prod", "dev"]),
17547
17578
  media: mediaSchema3.optional()
@@ -17557,13 +17588,13 @@ __export(asset_refs_exports, {
17557
17588
  materializeAssetsCLI: () => materializeAssetsCLI
17558
17589
  });
17559
17590
  import fs27 from "node:fs/promises";
17560
- import path33 from "node:path";
17591
+ import path34 from "node:path";
17561
17592
  async function collectAssetRefsCLI(options) {
17562
17593
  if (!options.outDir) {
17563
17594
  console.error("Usage: canopycms collect-asset-refs <outDir>");
17564
17595
  return 1;
17565
17596
  }
17566
- const outDir = path33.resolve(options.outDir);
17597
+ const outDir = path34.resolve(options.outDir);
17567
17598
  try {
17568
17599
  if (!(await fs27.stat(outDir)).isDirectory()) {
17569
17600
  console.error(`Not a directory: ${outDir}`);
@@ -17658,19 +17689,19 @@ async function runMaterialize(options) {
17658
17689
  console.error("canopycms materialize-assets: --report needs a file");
17659
17690
  return MATERIALIZE_EXIT_CODES.error;
17660
17691
  }
17661
- if (options.reportPath && path33.resolve(options.reportPath) === path33.resolve(options.refsPath)) {
17692
+ if (options.reportPath && path34.resolve(options.reportPath) === path34.resolve(options.refsPath)) {
17662
17693
  console.error("canopycms materialize-assets: --report must not be the --refs file");
17663
17694
  return MATERIALIZE_EXIT_CODES.error;
17664
17695
  }
17665
17696
  if (options.reportPath)
17666
- await fs27.rm(path33.resolve(options.reportPath), { force: true });
17697
+ await fs27.rm(path34.resolve(options.reportPath), { force: true });
17667
17698
  const concurrency = parsePositiveInteger("--concurrency", options.concurrency);
17668
17699
  const transformConcurrency = parsePositiveInteger("--transform-concurrency", options.transformConcurrency);
17669
17700
  if (concurrency === null || transformConcurrency === null)
17670
17701
  return MATERIALIZE_EXIT_CODES.error;
17671
17702
  if (options.outputPrefix !== void 0)
17672
17703
  assertValidOutputPrefix(options.outputPrefix);
17673
- const refs = await readAssetRefsFile(path33.resolve(options.refsPath));
17704
+ const refs = await readAssetRefsFile(path34.resolve(options.refsPath));
17674
17705
  const store = await resolveStore(options);
17675
17706
  if (!store)
17676
17707
  return MATERIALIZE_EXIT_CODES.error;
@@ -17684,7 +17715,7 @@ async function runMaterialize(options) {
17684
17715
  });
17685
17716
  printReport(report);
17686
17717
  if (options.reportPath) {
17687
- await atomicWriteFile3(path33.resolve(options.reportPath), `${JSON.stringify(report, null, 2)}
17718
+ await atomicWriteFile3(path34.resolve(options.reportPath), `${JSON.stringify(report, null, 2)}
17688
17719
  `);
17689
17720
  }
17690
17721
  const { contentFailures, storeFailures } = report.summary;
@@ -17724,20 +17755,149 @@ var init_asset_refs2 = __esm({
17724
17755
  }
17725
17756
  });
17726
17757
 
17727
- // dist/utils/git.js
17728
- import { simpleGit as simpleGit4 } from "simple-git";
17729
- function isCanopyInternalPath(repoRelativePath) {
17730
- return repoRelativePath === CANOPY_META_DIR2 || repoRelativePath.startsWith(`${CANOPY_META_DIR2}/`);
17758
+ // dist/branch-metadata-error.js
17759
+ var BranchMetadataCorruptError2;
17760
+ var init_branch_metadata_error = __esm({
17761
+ "dist/branch-metadata-error.js"() {
17762
+ "use strict";
17763
+ BranchMetadataCorruptError2 = class extends Error {
17764
+ constructor(branchRoot, cause) {
17765
+ super(`Corrupt branch metadata in '${branchRoot}': ${cause}`);
17766
+ this.name = "BranchMetadataCorruptError";
17767
+ this.branchRoot = branchRoot;
17768
+ this.parseCause = cause;
17769
+ }
17770
+ };
17771
+ }
17772
+ });
17773
+
17774
+ // dist/branch-metadata-file.js
17775
+ import fs28 from "node:fs/promises";
17776
+ import path35 from "node:path";
17777
+ import { z as z16 } from "zod";
17778
+ function describeSchemaIssues2(issues) {
17779
+ const missing = /* @__PURE__ */ new Set();
17780
+ const invalid = /* @__PURE__ */ new Set();
17781
+ for (const issue of issues) {
17782
+ const field = issue.path.length > 0 ? issue.path.join(".") : "the file";
17783
+ const isMissing = issue.code === z16.ZodIssueCode.invalid_type && issue.received === z16.ZodParsedType.undefined;
17784
+ (isMissing ? missing : invalid).add(field);
17785
+ }
17786
+ const parts = [];
17787
+ if (missing.size > 0)
17788
+ parts.push(`Missing: ${[...missing].join(", ")}`);
17789
+ if (invalid.size > 0)
17790
+ parts.push(`Invalid: ${[...invalid].join(", ")}`);
17791
+ return `Not branch metadata. ${parts.join(". ")}`;
17731
17792
  }
17732
- async function stageAllExceptCanopyState2(git) {
17733
- await git.raw(["add", "-A", "--sparse"]);
17734
- await git.raw(["reset", "-q", "--", CANOPY_META_DIR2]);
17793
+ async function readBranchMetadataFile2(branchRoot) {
17794
+ const resolvedRoot = path35.resolve(branchRoot);
17795
+ let raw;
17796
+ try {
17797
+ raw = await fs28.readFile(branchMetadataFilePath2(resolvedRoot), "utf8");
17798
+ } catch (err2) {
17799
+ if (isNotFoundError(err2)) {
17800
+ return null;
17801
+ }
17802
+ throw err2;
17803
+ }
17804
+ return parseBranchMetadataFile2(resolvedRoot, raw);
17735
17805
  }
17736
- var CANOPY_META_DIR2;
17737
- var init_git = __esm({
17738
- "dist/utils/git.js"() {
17806
+ function parseBranchMetadataFile2(branchRoot, raw) {
17807
+ let json;
17808
+ try {
17809
+ json = JSON.parse(raw);
17810
+ } catch (err2) {
17811
+ throw new BranchMetadataCorruptError2(branchRoot, `Not valid JSON: ${getErrorMessage(err2)}`);
17812
+ }
17813
+ const result = branchMetadataFileSchema2.safeParse(json);
17814
+ if (!result.success) {
17815
+ throw new BranchMetadataCorruptError2(branchRoot, describeSchemaIssues2(result.error.issues));
17816
+ }
17817
+ const file = result.data;
17818
+ return file;
17819
+ }
17820
+ var BRANCH_META_DIR2, BRANCH_META_FILE2, branchMetadataFilePath2, UNKNOWN_TIME2, enumOf2, branchMetadataShape2, branchMetadataSchema2, branchMetadataFileSchema2;
17821
+ var init_branch_metadata_file = __esm({
17822
+ "dist/branch-metadata-file.js"() {
17739
17823
  "use strict";
17740
- CANOPY_META_DIR2 = ".canopy-meta";
17824
+ init_error();
17825
+ init_branch_metadata_error();
17826
+ BRANCH_META_DIR2 = ".canopy-meta";
17827
+ BRANCH_META_FILE2 = "branch.json";
17828
+ branchMetadataFilePath2 = (branchRoot) => path35.join(path35.resolve(branchRoot), BRANCH_META_DIR2, BRANCH_META_FILE2);
17829
+ UNKNOWN_TIME2 = (/* @__PURE__ */ new Date(0)).toISOString();
17830
+ enumOf2 = (values) => z16.nativeEnum(values);
17831
+ branchMetadataShape2 = {
17832
+ name: z16.string().min(1),
17833
+ title: z16.string().optional(),
17834
+ description: z16.string().optional(),
17835
+ status: enumOf2({
17836
+ editing: "editing",
17837
+ submitted: "submitted",
17838
+ approved: "approved",
17839
+ archived: "archived"
17840
+ }),
17841
+ access: z16.object({
17842
+ allowedUsers: z16.array(z16.string()).optional(),
17843
+ allowedGroups: z16.array(z16.string()).optional(),
17844
+ managerOrAdminAllowed: z16.boolean().optional()
17845
+ }).passthrough(),
17846
+ createdBy: z16.string().default("unknown"),
17847
+ createdAt: z16.string().default(UNKNOWN_TIME2),
17848
+ updatedAt: z16.string().default(UNKNOWN_TIME2),
17849
+ baseBranch: z16.string().optional(),
17850
+ pullRequestUrl: z16.string().optional(),
17851
+ pullRequestNumber: z16.number().int().optional(),
17852
+ submittedAt: z16.string().optional(),
17853
+ pushedToGitHubAt: z16.string().optional(),
17854
+ editorUrl: z16.string().optional(),
17855
+ syncStatus: enumOf2({
17856
+ synced: "synced",
17857
+ "pending-sync": "pending-sync",
17858
+ "sync-failed": "sync-failed"
17859
+ }).optional(),
17860
+ conflictStatus: enumOf2({
17861
+ clean: "clean",
17862
+ "conflicts-detected": "conflicts-detected"
17863
+ }).optional(),
17864
+ conflictFiles: z16.array(z16.custom((v) => typeof v === "string")).optional(),
17865
+ pullRequestState: enumOf2({
17866
+ open: "open",
17867
+ closed: "closed",
17868
+ merged: "merged"
17869
+ }).optional(),
17870
+ mergedAt: z16.string().optional(),
17871
+ rebaseFailure: z16.object({ message: z16.string(), firstAt: z16.string(), lastAt: z16.string() }).optional(),
17872
+ historyRewrittenFrom: z16.string().optional(),
17873
+ lastGitHubTip: z16.string().optional(),
17874
+ keptOverGitHub: z16.array(z16.string()).optional(),
17875
+ rewrittenOnGitHub: z16.string().optional(),
17876
+ submitUnfinished: z16.boolean().optional(),
17877
+ submitNotQueued: z16.boolean().optional(),
17878
+ unpublishedAtMerge: z16.boolean().optional(),
17879
+ syncFailureReason: z16.string().optional(),
17880
+ editors: z16.array(z16.string()).optional(),
17881
+ uncommittedEditors: z16.array(z16.string()).optional(),
17882
+ // Dropped when malformed: it only decorates the branch card, so it never fails a read.
17883
+ pullRequestHold: z16.discriminatedUnion("kind", [
17884
+ z16.object({ kind: z16.literal("conflicts") }),
17885
+ z16.object({ kind: z16.literal("retargeted"), base: z16.string() })
17886
+ ]).optional().catch(void 0),
17887
+ // Dropped when malformed: it only decorates the PR body, so it never fails a read.
17888
+ review: z16.object({
17889
+ state: z16.enum(["approved", "changes-requested"]),
17890
+ by: z16.object({ userId: z16.string(), name: z16.string().optional() }),
17891
+ at: z16.string()
17892
+ }).optional().catch(void 0)
17893
+ };
17894
+ branchMetadataSchema2 = z16.object(branchMetadataShape2).passthrough();
17895
+ branchMetadataFileSchema2 = z16.object({
17896
+ schemaVersion: z16.number().int().default(1),
17897
+ version: z16.number().int().nonnegative().default(0),
17898
+ writeId: z16.string().optional(),
17899
+ branch: branchMetadataSchema2
17900
+ }).passthrough();
17741
17901
  }
17742
17902
  });
17743
17903
 
@@ -17846,11 +18006,11 @@ var init_debug2 = __esm({
17846
18006
  });
17847
18007
 
17848
18008
  // dist/resource-generation.js
17849
- import fs28 from "node:fs/promises";
17850
- import path34 from "node:path";
18009
+ import fs29 from "node:fs/promises";
18010
+ import path36 from "node:path";
17851
18011
  import { randomUUID as randomUUID5 } from "node:crypto";
17852
18012
  function resourceGenerationPath2(root, resource) {
17853
- return path34.resolve(root, META_DIR3, `${resource}.generation`);
18013
+ return path36.resolve(root, META_DIR3, `${resource}.generation`);
17854
18014
  }
17855
18015
  async function bumpResourceGeneration2(root, resource, options) {
17856
18016
  const token = randomUUID5();
@@ -17866,7 +18026,7 @@ async function bumpResourceGeneration2(root, resource, options) {
17866
18026
  }
17867
18027
  async function readResourceGeneration2(root, resource) {
17868
18028
  try {
17869
- const token = await fs28.readFile(resourceGenerationPath2(root, resource), "utf-8");
18029
+ const token = await fs29.readFile(resourceGenerationPath2(root, resource), "utf-8");
17870
18030
  return { ok: true, token };
17871
18031
  } catch (err2) {
17872
18032
  if (isNodeError(err2) && err2.code === "ENOENT")
@@ -17893,270 +18053,314 @@ var init_resource_generation = __esm({
17893
18053
  }
17894
18054
  });
17895
18055
 
17896
- // dist/content-index-registry.js
17897
- import path35 from "node:path";
17898
- function invalidateContentIndexesForRoot2(root) {
17899
- const rootKey = path35.resolve(root);
17900
- const prefix = rootKey + path35.sep;
17901
- for (const [storeRoot, refs] of registry2) {
17902
- if (storeRoot !== rootKey && !storeRoot.startsWith(prefix))
17903
- continue;
17904
- for (const ref of refs) {
17905
- const target = ref.deref();
17906
- if (target) {
17907
- target.invalidateIndex();
17908
- } else {
17909
- refs.delete(ref);
17910
- }
17911
- }
17912
- if (refs.size === 0)
17913
- registry2.delete(storeRoot);
17914
- }
17915
- }
17916
- var registry2, finalization2;
17917
- var init_content_index_registry = __esm({
17918
- "dist/content-index-registry.js"() {
18056
+ // dist/branch-registry.js
18057
+ import fs30 from "node:fs/promises";
18058
+ import path37 from "node:path";
18059
+ import { z as z17 } from "zod";
18060
+ var log12, REGISTRY_FILE2, REGISTRY_STALE_FILE2, REGISTRY_TEMP_FILE2, REGISTRY_VERSION2, RESOURCE3, GET_MISS_REFRESH_MIN_INTERVAL_MS2, snapshotSchema2, reportedUnreadable2, BranchRegistry2;
18061
+ var init_branch_registry = __esm({
18062
+ "dist/branch-registry.js"() {
17919
18063
  "use strict";
17920
- registry2 = /* @__PURE__ */ new Map();
17921
- finalization2 = new FinalizationRegistry(({ rootKey, ref }) => {
17922
- const refs = registry2.get(rootKey);
17923
- if (!refs)
17924
- return;
17925
- refs.delete(ref);
17926
- if (refs.size === 0)
17927
- registry2.delete(rootKey);
18064
+ init_branch_metadata_file();
18065
+ init_error();
18066
+ init_debug2();
18067
+ init_logger3();
18068
+ init_resource_generation();
18069
+ log12 = createDebugLogger3({ prefix: "BranchRegistry" });
18070
+ REGISTRY_FILE2 = "branches.json";
18071
+ REGISTRY_STALE_FILE2 = "branches.stale.json";
18072
+ REGISTRY_TEMP_FILE2 = "branches.tmp.json";
18073
+ REGISTRY_VERSION2 = 2;
18074
+ RESOURCE3 = "branch-registry";
18075
+ GET_MISS_REFRESH_MIN_INTERVAL_MS2 = 5e3;
18076
+ snapshotSchema2 = z17.object({
18077
+ version: z17.literal(REGISTRY_VERSION2),
18078
+ branches: z17.array(z17.object({ branch: branchMetadataSchema2, branchRoot: z17.string(), baseRoot: z17.string() })),
18079
+ generation: z17.string().nullable()
17928
18080
  });
17929
- }
17930
- });
17931
-
17932
- // dist/id.js
17933
- import { generate as generate2 } from "short-uuid";
17934
- function generateId2() {
17935
- const full = generate2();
17936
- return full.substring(0, 12);
17937
- }
17938
- var isValidId2;
17939
- var init_id = __esm({
17940
- "dist/id.js"() {
17941
- "use strict";
17942
- init_validation3();
17943
- isValidId2 = isValidContentId2;
17944
- }
17945
- });
17946
-
17947
- // dist/utils/format.js
17948
- var getFormatExtension2;
17949
- var init_format = __esm({
17950
- "dist/utils/format.js"() {
17951
- "use strict";
17952
- getFormatExtension2 = (format) => {
17953
- if (format === "md")
17954
- return ".md";
17955
- if (format === "mdx")
17956
- return ".mdx";
17957
- if (format === "yaml")
17958
- return ".yaml";
17959
- return ".json";
17960
- };
17961
- }
17962
- });
17963
-
17964
- // dist/content-id-index.js
17965
- function extractIdFromFilename2(filename) {
17966
- if (filename.startsWith(".")) {
17967
- return null;
17968
- }
17969
- const parts = filename.split(".");
17970
- if (parts.length >= 3) {
17971
- const candidate = parts[parts.length - 2];
17972
- if (isValidId2(candidate))
17973
- return candidate;
17974
- }
17975
- if (parts.length === 2) {
17976
- const candidate = parts[parts.length - 1];
17977
- if (isValidId2(candidate))
17978
- return candidate;
17979
- }
17980
- return null;
17981
- }
17982
- function extractSlugFromFilename2(filename, entryTypeName) {
17983
- const parts = filename.split(".");
17984
- if (parts.length >= 3) {
17985
- const possibleId = parts[parts.length - 2];
17986
- if (isValidId2(possibleId)) {
17987
- let slugParts = parts.slice(0, parts.length - 2);
17988
- if (entryTypeName && slugParts.length > 1 && slugParts[0] === entryTypeName) {
17989
- slugParts = slugParts.slice(1);
17990
- } else if (parts.length >= 4 && slugParts.length > 1) {
17991
- slugParts = slugParts.slice(1);
18081
+ reportedUnreadable2 = /* @__PURE__ */ new Set();
18082
+ BranchRegistry2 = class {
18083
+ constructor(root) {
18084
+ this.regenInFlight = null;
18085
+ this.lastForcedRefreshMs = 0;
18086
+ this.root = path37.resolve(root);
18087
+ this.registryPath = path37.join(this.root, REGISTRY_FILE2);
18088
+ this.stalePath = path37.join(this.root, REGISTRY_STALE_FILE2);
18089
+ this.tempPath = path37.join(this.root, REGISTRY_TEMP_FILE2);
17992
18090
  }
17993
- return slugParts.join(".").toLowerCase();
17994
- }
17995
- }
17996
- if (parts.length === 2) {
17997
- const possibleId = parts[parts.length - 1];
17998
- if (isValidId2(possibleId)) {
17999
- return parts[0].toLowerCase();
18000
- }
18001
- }
18002
- if (parts.length > 1) {
18003
- return parts.slice(0, -1).join(".").toLowerCase();
18004
- }
18005
- return filename.toLowerCase();
18006
- }
18007
- var ENTRY_EXTENSIONS2;
18008
- var init_content_id_index = __esm({
18009
- "dist/content-id-index.js"() {
18010
- "use strict";
18011
- init_id();
18012
- init_format();
18013
- ENTRY_EXTENSIONS2 = new Set(Object.keys({ md: true, mdx: true, json: true, yaml: true }).map((format) => getFormatExtension2(format)));
18014
- }
18015
- });
18016
-
18017
- // dist/schema/meta-loader.js
18018
- import { promises as fs29 } from "fs";
18019
- import { join as join3 } from "pathe";
18020
- import { z as z16 } from "zod";
18021
- import chokidar2 from "chokidar";
18022
- function stripEmbeddedIdFromName2(name) {
18023
- return extractSlugFromFilename2(name);
18024
- }
18025
- async function scanForCollectionMeta2(baseDir, relativePath = "") {
18026
- const collections = [];
18027
- try {
18028
- const entries = await fs29.readdir(baseDir, { withFileTypes: true });
18029
- for (const entry of entries) {
18030
- if (!entry.isDirectory())
18031
- continue;
18032
- const folderName = entry.name;
18033
- const logicalName = stripEmbeddedIdFromName2(folderName);
18034
- const collectionContentId = extractIdFromFilename2(folderName) ?? void 0;
18035
- const folderPath = relativePath ? `${relativePath}/${logicalName}` : logicalName;
18036
- const absolutePath = join3(baseDir, folderName);
18037
- const metaPath = join3(absolutePath, ".collection.json");
18038
- try {
18039
- await fs29.access(metaPath);
18040
- const content = await fs29.readFile(metaPath, "utf-8");
18041
- const parsed = JSON.parse(content);
18042
- const meta = collectionMetaSchema2.parse(parsed);
18043
- collections.push({
18044
- ...meta,
18045
- path: folderPath,
18046
- contentId: collectionContentId
18091
+ /**
18092
+ * Returns all branches. Uses the cached snapshot if its embedded generation
18093
+ * token matches the live marker, regenerates otherwise.
18094
+ *
18095
+ * An unparseable or wrong-shaped snapshot is treated like an absent one: the
18096
+ * snapshot is a cache of the branch directories, so rebuilding it loses
18097
+ * nothing, while rethrowing would fail every editor's branch listing until
18098
+ * someone deleted the file. Other read failures (permissions, IO) propagate.
18099
+ */
18100
+ async list() {
18101
+ let raw;
18102
+ try {
18103
+ raw = await fs30.readFile(this.registryPath, "utf8");
18104
+ } catch (err2) {
18105
+ if (isNotFoundError(err2)) {
18106
+ return await this.regenerate();
18107
+ }
18108
+ throw err2;
18109
+ }
18110
+ const parsed = this.parseSnapshot(raw);
18111
+ if (!parsed)
18112
+ return await this.regenerate();
18113
+ const read = await readResourceGeneration2(this.root, RESOURCE3);
18114
+ if (isGenerationCurrent2(parsed.generation, read)) {
18115
+ return parsed.branches;
18116
+ }
18117
+ return await this.regenerate();
18118
+ }
18119
+ /** The snapshot in `raw`, or null when it must be rebuilt. */
18120
+ parseSnapshot(raw) {
18121
+ let json;
18122
+ try {
18123
+ json = JSON.parse(raw);
18124
+ } catch (err2) {
18125
+ this.reportUnreadable(getErrorMessage(err2));
18126
+ return null;
18127
+ }
18128
+ if (typeof json === "object" && json !== null && "version" in json && typeof json.version === "number" && json.version !== REGISTRY_VERSION2) {
18129
+ return null;
18130
+ }
18131
+ const result = snapshotSchema2.safeParse(json);
18132
+ if (!result.success) {
18133
+ this.reportUnreadable("it does not have the registry snapshot shape");
18134
+ return null;
18135
+ }
18136
+ reportedUnreadable2.delete(this.registryPath);
18137
+ const snapshot = result.data;
18138
+ return snapshot;
18139
+ }
18140
+ reportUnreadable(reason) {
18141
+ if (reportedUnreadable2.has(this.registryPath))
18142
+ return;
18143
+ reportedUnreadable2.add(this.registryPath);
18144
+ canopyLogWarn3(`CanopyCMS: Branch registry snapshot ${this.registryPath} is unreadable (${reason}); regenerating it from the branch directories`);
18145
+ }
18146
+ /**
18147
+ * Returns a single branch by name, from the cached snapshot when it is fresh.
18148
+ *
18149
+ * Suspicious-miss backstop: a `name` missing from the list is the signal that
18150
+ * the snapshot may predate a branch that now exists, so force one fresh
18151
+ * regeneration and re-search before giving up. Throttled per instance, so a
18152
+ * genuinely-absent branch costs at most one extra scan per window.
18153
+ */
18154
+ async get(name) {
18155
+ return this.find((b) => b.branch.name === name);
18156
+ }
18157
+ /**
18158
+ * Returns the branch whose workspace directory is `key` (`sanitizeBranchName` of its ref),
18159
+ * whatever its name, with {@link get}'s backstop.
18160
+ */
18161
+ async getByKey(key) {
18162
+ return this.find((b) => path37.basename(b.branchRoot) === key);
18163
+ }
18164
+ async find(matches) {
18165
+ const found = (await this.list()).find(matches);
18166
+ if (found)
18167
+ return found;
18168
+ if (!this.shouldForceRefresh())
18169
+ return void 0;
18170
+ const refreshed = await this.regenerate();
18171
+ return refreshed.find(matches);
18172
+ }
18173
+ shouldForceRefresh() {
18174
+ const now = Date.now();
18175
+ if (now - this.lastForcedRefreshMs < GET_MISS_REFRESH_MIN_INTERVAL_MS2)
18176
+ return false;
18177
+ this.lastForcedRefreshMs = now;
18178
+ return true;
18179
+ }
18180
+ /**
18181
+ * Marks the cache stale for every process sharing this root by bumping the
18182
+ * marker, then eager-regenerates on this host. The bump must succeed —
18183
+ * swallowing that failure leaves the registry stale indefinitely with no
18184
+ * backstop. A failed eager regen must NOT fail the caller's save/delete: the
18185
+ * bump alone already restored correctness for every future reader.
18186
+ */
18187
+ async invalidate() {
18188
+ await bumpResourceGeneration2(this.root, RESOURCE3, { mustSucceed: true });
18189
+ try {
18190
+ if (this.regenInFlight)
18191
+ await this.regenInFlight.catch(() => {
18192
+ });
18193
+ await this.regenerate();
18194
+ } catch (err2) {
18195
+ log12.warn("invalidate", "Eager regeneration after invalidate() failed", {
18196
+ error: getErrorMessage(err2)
18197
+ });
18198
+ }
18199
+ }
18200
+ /**
18201
+ * Scans branch directories and rebuilds the cache, deduping concurrent
18202
+ * callers on this instance to a single underlying scan. Never loops waiting
18203
+ * for the embedded token to match the marker — that livelocks under a bump
18204
+ * storm; a caller wanting the very latest state calls list() again.
18205
+ */
18206
+ async regenerate() {
18207
+ if (this.regenInFlight)
18208
+ return this.regenInFlight;
18209
+ const run = this.performRegenerate();
18210
+ this.regenInFlight = run;
18211
+ try {
18212
+ return await run;
18213
+ } finally {
18214
+ this.regenInFlight = null;
18215
+ }
18216
+ }
18217
+ async performRegenerate() {
18218
+ const read = await readResourceGeneration2(this.root, RESOURCE3);
18219
+ const branches = await this.scanBranchDirectories();
18220
+ await fs30.unlink(this.stalePath).catch(() => {
18047
18221
  });
18048
- const nestedCollections = await scanForCollectionMeta2(absolutePath, folderPath);
18049
- collections.push(...nestedCollections);
18050
- } catch (err2) {
18051
- if (err2.code !== "ENOENT") {
18052
- canopyLogError2(`Error loading ${metaPath}:`, err2);
18053
- throw new Error(`Invalid .collection.json in ${folderPath}: ${err2.message}`);
18222
+ if (!read.ok) {
18223
+ return branches;
18054
18224
  }
18055
- const nestedCollections = await scanForCollectionMeta2(absolutePath, folderPath);
18056
- collections.push(...nestedCollections);
18225
+ const snapshot = {
18226
+ version: REGISTRY_VERSION2,
18227
+ branches,
18228
+ generation: read.token
18229
+ };
18230
+ const uniqueTempPath = `${this.tempPath}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
18231
+ await fs30.mkdir(this.root, { recursive: true });
18232
+ await fs30.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
18233
+ try {
18234
+ await fs30.rename(uniqueTempPath, this.registryPath);
18235
+ } catch (err2) {
18236
+ await fs30.unlink(uniqueTempPath).catch(() => {
18237
+ });
18238
+ throw err2;
18239
+ }
18240
+ return branches;
18057
18241
  }
18058
- }
18059
- return collections;
18060
- } catch (err2) {
18061
- if (err2.code === "ENOENT") {
18062
- return [];
18063
- }
18064
- throw err2;
18242
+ /**
18243
+ * Scans the root directory for branch subdirectories with valid branch.json files.
18244
+ * Protected (rather than exported as a test hook) so tests can subclass and
18245
+ * override to simulate cross-process interleavings.
18246
+ */
18247
+ async scanBranchDirectories() {
18248
+ const branches = [];
18249
+ try {
18250
+ const entries = await fs30.readdir(this.root, { withFileTypes: true });
18251
+ for (const entry of entries) {
18252
+ if (!entry.isDirectory() || entry.name.startsWith(".")) {
18253
+ continue;
18254
+ }
18255
+ const branchRoot = path37.join(this.root, entry.name);
18256
+ let meta;
18257
+ try {
18258
+ meta = await readBranchMetadataFile2(branchRoot);
18259
+ } catch (err2) {
18260
+ canopyLogWarn3(`CanopyCMS: Skipping branch directory '${entry.name}' during registry scan: ${getErrorMessage(err2)}`);
18261
+ continue;
18262
+ }
18263
+ if (meta) {
18264
+ branches.push({
18265
+ branch: meta.branch,
18266
+ branchRoot,
18267
+ baseRoot: this.root
18268
+ });
18269
+ }
18270
+ }
18271
+ } catch (err2) {
18272
+ if (isNotFoundError(err2)) {
18273
+ return [];
18274
+ }
18275
+ throw err2;
18276
+ }
18277
+ return branches;
18278
+ }
18279
+ };
18065
18280
  }
18281
+ });
18282
+
18283
+ // dist/paths/branch.js
18284
+ import path38 from "node:path";
18285
+ function namesNoWorkspace(err2) {
18286
+ if (err2 instanceof BranchPathError2)
18287
+ return true;
18288
+ return isNodeError(err2) && (err2.code === "ENAMETOOLONG" || err2.code === "ENOTDIR");
18066
18289
  }
18067
- async function loadCollectionMetaFiles2(contentRoot) {
18068
- let root = null;
18069
- const rootMetaPath = join3(contentRoot, ".collection.json");
18070
- try {
18071
- await fs29.access(rootMetaPath);
18072
- } catch (err2) {
18073
- if (err2.code === "ENOENT") {
18074
- } else {
18075
- throw err2;
18076
- }
18290
+ function resolveBranchPath2(options) {
18291
+ if (options.branchName.includes("..")) {
18292
+ throw new BranchPathError2("Branch name cannot contain traversal segments");
18077
18293
  }
18078
- try {
18079
- const content = await fs29.readFile(rootMetaPath, "utf-8");
18080
- const parsed = JSON.parse(content);
18081
- root = rootCollectionMetaSchema2.parse(parsed);
18082
- } catch (err2) {
18083
- const errno = err2.code;
18084
- if (errno !== "ENOENT") {
18085
- throw new Error(`Invalid root .collection.json`);
18086
- }
18294
+ if (isSettingsBranchName2(options.branchName)) {
18295
+ throw new BranchPathError2("Settings branches are not content branches");
18087
18296
  }
18088
- const collections = await scanForCollectionMeta2(contentRoot);
18089
- return { root, collections };
18297
+ const safeBranch = sanitizeBranchName3(options.branchName);
18298
+ const strategy = operatingStrategy3(options.mode);
18299
+ const baseRoot = resolveContentBranchesRoot2(options.mode, options.basePathOverride);
18300
+ const normalizedBase = path38.resolve(baseRoot);
18301
+ const baseWithSep = normalizedBase.endsWith(path38.sep) ? normalizedBase : `${normalizedBase}${path38.sep}`;
18302
+ const branchRoot = strategy.getContentBranchRoot(safeBranch, options.basePathOverride);
18303
+ const withinBase = (target) => {
18304
+ const resolved = path38.resolve(target);
18305
+ return resolved === normalizedBase || resolved.startsWith(baseWithSep);
18306
+ };
18307
+ if (!withinBase(branchRoot)) {
18308
+ throw new BranchPathError2("Branch path resolves outside the base root");
18309
+ }
18310
+ return { branchRoot, baseRoot: normalizedBase, branchName: safeBranch };
18090
18311
  }
18091
- var entryTypeMetaSchema2, collectionMetaSchema2, rootCollectionMetaSchema2;
18092
- var init_meta_loader = __esm({
18093
- "dist/schema/meta-loader.js"() {
18312
+ var BranchPathError2, resolveContentBranchesRoot2;
18313
+ var init_branch = __esm({
18314
+ "dist/paths/branch.js"() {
18094
18315
  "use strict";
18095
- init_logger3();
18096
- init_content_id_index();
18097
- entryTypeMetaSchema2 = z16.object({
18098
- name: z16.string().min(1),
18099
- format: z16.enum(["md", "mdx", "json", "yaml"]),
18100
- schema: z16.string().min(1),
18101
- // Entry schema registry key (validated at resolution time)
18102
- label: z16.string().optional(),
18103
- description: z16.string().optional(),
18104
- default: z16.boolean().optional(),
18105
- maxItems: z16.number().int().positive().optional()
18106
- });
18107
- collectionMetaSchema2 = z16.object({
18108
- name: z16.string().min(1),
18109
- label: z16.string().optional(),
18110
- description: z16.string().optional(),
18111
- entries: z16.array(entryTypeMetaSchema2).optional(),
18112
- order: z16.array(z16.string()).default([])
18113
- // Embedded IDs for ordering; optional in the file, [] = alphabetical
18114
- }).refine((data) => data.entries && data.entries.length > 0, {
18115
- message: "Collection must have at least one entry type"
18116
- });
18117
- rootCollectionMetaSchema2 = z16.object({
18118
- label: z16.string().optional(),
18119
- entries: z16.array(entryTypeMetaSchema2).optional(),
18120
- order: z16.array(z16.string()).optional()
18121
- // Embedded IDs for ordering items
18122
- });
18316
+ init_operating_mode2();
18317
+ init_error();
18318
+ init_branch_name2();
18319
+ BranchPathError2 = class extends Error {
18320
+ };
18321
+ resolveContentBranchesRoot2 = (mode, override) => {
18322
+ return operatingStrategy3(mode).getContentBranchesRoot(override);
18323
+ };
18123
18324
  }
18124
18325
  });
18125
18326
 
18126
- // dist/branch-schema-cache.js
18127
- import { LRUCache as LRUCache2 } from "lru-cache";
18128
- var SNAPSHOT_MEMO_MAX_BYTES2, SCHEMA_GENERATION_RESOURCE2;
18129
- var init_branch_schema_cache = __esm({
18130
- "dist/branch-schema-cache.js"() {
18327
+ // dist/paths/index.js
18328
+ var init_paths = __esm({
18329
+ "dist/paths/index.js"() {
18131
18330
  "use strict";
18132
- SNAPSHOT_MEMO_MAX_BYTES2 = 64 * 1024 * 1024;
18133
- SCHEMA_GENERATION_RESOURCE2 = "schema";
18331
+ init_validation3();
18332
+ init_branch();
18134
18333
  }
18135
18334
  });
18136
18335
 
18137
- // dist/content-index-generation.js
18138
- async function bumpContentIndexGeneration2(root) {
18139
- return bumpResourceGeneration2(root, RESOURCE3);
18140
- }
18141
- async function invalidateBranchContentCaches2(root) {
18142
- await bumpContentIndexGeneration2(root);
18143
- await bumpResourceGeneration2(root, SCHEMA_GENERATION_RESOURCE2);
18144
- invalidateContentIndexesForRoot2(root);
18336
+ // dist/utils/async-mutex.js
18337
+ async function withLock2(key, fn) {
18338
+ const prev = locks2.get(key) ?? Promise.resolve();
18339
+ let resolve2;
18340
+ const next = new Promise((r) => {
18341
+ resolve2 = r;
18342
+ });
18343
+ locks2.set(key, next);
18344
+ await prev;
18345
+ try {
18346
+ return await fn();
18347
+ } finally {
18348
+ resolve2();
18349
+ if (locks2.get(key) === next)
18350
+ locks2.delete(key);
18351
+ }
18145
18352
  }
18146
- var RESOURCE3;
18147
- var init_content_index_generation = __esm({
18148
- "dist/content-index-generation.js"() {
18353
+ var locks2;
18354
+ var init_async_mutex = __esm({
18355
+ "dist/utils/async-mutex.js"() {
18149
18356
  "use strict";
18150
- init_resource_generation();
18151
- init_content_index_registry();
18152
- init_branch_schema_cache();
18153
- RESOURCE3 = "content-index";
18357
+ locks2 = /* @__PURE__ */ new Map();
18154
18358
  }
18155
18359
  });
18156
18360
 
18157
18361
  // dist/utils/provisioning-lock.js
18158
- import fs30 from "node:fs/promises";
18159
- import path36 from "node:path";
18362
+ import fs31 from "node:fs/promises";
18363
+ import path39 from "node:path";
18160
18364
  import lockfile3 from "proper-lockfile";
18161
18365
  function guardOnCompromised2(handler, lockPath) {
18162
18366
  return (err2) => {
@@ -18202,14 +18406,14 @@ function releaseIgnoringAlreadyReleased2(release, lockPath) {
18202
18406
  }
18203
18407
  async function tryAcquireProvisioningLock2(lockTargetDir3, lockName, onCompromised, staleMs = PROVISIONING_LOCK_STALE_MS2, createParents = true) {
18204
18408
  if (createParents) {
18205
- await fs30.mkdir(lockTargetDir3, { recursive: true });
18409
+ await fs31.mkdir(lockTargetDir3, { recursive: true });
18206
18410
  } else {
18207
- await fs30.mkdir(lockTargetDir3).catch((err2) => {
18411
+ await fs31.mkdir(lockTargetDir3).catch((err2) => {
18208
18412
  if (!isNodeError(err2) || err2.code !== "EEXIST")
18209
18413
  throw err2;
18210
18414
  });
18211
18415
  }
18212
- const lockPath = path36.join(lockTargetDir3, lockName);
18416
+ const lockPath = path39.join(lockTargetDir3, lockName);
18213
18417
  const release = await lockfile3.lock(lockPath, provisioningLockOptions2(lockPath, 0, onCompromised, staleMs));
18214
18418
  return releaseIgnoringAlreadyReleased2(release, lockPath);
18215
18419
  }
@@ -18223,1473 +18427,1556 @@ var init_provisioning_lock = __esm({
18223
18427
  }
18224
18428
  });
18225
18429
 
18226
- // dist/utils/content-write-lock.js
18227
- import { AsyncLocalStorage as AsyncLocalStorage3 } from "node:async_hooks";
18228
- import path37 from "node:path";
18229
- function lockTargetDir2(branchRoot) {
18230
- return path37.join(path37.resolve(branchRoot), META_DIR4);
18231
- }
18232
- function tryAcquireContentWriteLock2(branchRoot, onCompromised) {
18233
- return tryAcquireProvisioningLock2(lockTargetDir2(branchRoot), CONTENT_WRITE_LOCK_NAME2, onCompromised, CONTENT_WRITE_LOCK_STALE_MS2, false);
18234
- }
18235
- async function acquireContentWriteLock2(branchRoot, waitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2, onCompromised) {
18236
- const startedAt = Date.now();
18237
- for (let attempt = 0; ; attempt++) {
18430
+ // dist/utils/occ-json-write.js
18431
+ import fs33 from "node:fs/promises";
18432
+ import path40 from "node:path";
18433
+ import { randomUUID as randomUUID6 } from "node:crypto";
18434
+ import lockfile4 from "proper-lockfile";
18435
+ async function writeOccJsonFile2(filePath, payload, options) {
18436
+ const { expectedVersion, settleMs = 50, trailingNewline = false } = options;
18437
+ const newVersion = expectedVersion === null ? 1 : expectedVersion + 1;
18438
+ const writeId = randomUUID6();
18439
+ const data = { ...payload, version: newVersion, writeId };
18440
+ const content = JSON.stringify(data, null, 2) + (trailingNewline ? "\n" : "");
18441
+ const dir = path40.dirname(filePath);
18442
+ await fs33.mkdir(dir, { recursive: true });
18443
+ const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
18444
+ await fs33.writeFile(tempPath, content, "utf-8");
18445
+ if (expectedVersion === null) {
18238
18446
  try {
18239
- return await tryAcquireContentWriteLock2(branchRoot, onCompromised);
18447
+ await fs33.link(tempPath, filePath);
18240
18448
  } catch (err2) {
18241
- if (!isNodeError(err2) || err2.code !== "ELOCKED")
18242
- throw err2;
18243
- const remaining = waitMs - (Date.now() - startedAt);
18244
- if (remaining <= 0)
18245
- throw new ContentWriteLockBusyError2();
18246
- const base = Math.min(40 * 2 ** attempt, 250);
18247
- await sleep3(Math.min(base * (0.5 + Math.random()), remaining));
18248
- }
18249
- }
18250
- }
18251
- async function withContentWriteLock2(branchRoot, fn, waitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2) {
18252
- let compromised = false;
18253
- const release = await acquireContentWriteLock2(branchRoot, waitMs, (err2) => {
18254
- compromised = true;
18255
- canopyLogWarn3(`[canopy] Content-write lock compromised mid-hold for ${branchRoot}:`, getErrorMessage(err2));
18256
- });
18257
- let result;
18258
- try {
18259
- const held = new Set(heldRoots2.getStore());
18260
- held.add(path37.resolve(branchRoot));
18261
- result = await heldRoots2.run(held, fn);
18262
- } finally {
18263
- await release();
18264
- }
18265
- if (compromised) {
18266
- throw new ContentWriteLockBusyError2("We couldn't confirm your change was saved because the branch was syncing at the same time. Reload to check before saving again (your unsaved edits will be lost).", "unknown");
18267
- }
18268
- return result;
18269
- }
18270
- var META_DIR4, CONTENT_WRITE_LOCK_NAME2, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2, ContentWriteLockBusyError2, sleep3, CONTENT_WRITE_LOCK_STALE_MS2, heldRoots2;
18271
- var init_content_write_lock = __esm({
18272
- "dist/utils/content-write-lock.js"() {
18273
- "use strict";
18274
- init_error();
18275
- init_logger3();
18276
- init_provisioning_lock();
18277
- META_DIR4 = ".canopy-meta";
18278
- CONTENT_WRITE_LOCK_NAME2 = "content-write.lock";
18279
- DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2 = 2e3;
18280
- ContentWriteLockBusyError2 = class extends Error {
18281
- constructor(message = "This branch is busy syncing or saving another change, so your change was not saved. Try again in a moment.", outcome = "not-run") {
18282
- super(message);
18283
- this.outcome = outcome;
18284
- this.name = "ContentWriteLockBusyError";
18449
+ await fs33.unlink(tempPath).catch(() => {
18450
+ });
18451
+ if (isFileExistsError(err2)) {
18452
+ throw new OccWriteConflictError2();
18285
18453
  }
18286
- };
18287
- sleep3 = (ms) => new Promise((resolve2) => {
18288
- setTimeout(resolve2, ms);
18454
+ throw err2;
18455
+ }
18456
+ await fs33.unlink(tempPath).catch(() => {
18289
18457
  });
18290
- CONTENT_WRITE_LOCK_STALE_MS2 = 3e4;
18291
- heldRoots2 = new AsyncLocalStorage3();
18292
- }
18293
- });
18294
-
18295
- // dist/sync-core.js
18296
- import fs31 from "node:fs/promises";
18297
- import path38 from "node:path";
18298
- import { simpleGit as simpleGit5 } from "simple-git";
18299
- function assertWithinDir(resolved, parent, label) {
18300
- const normalizedResolved = path38.resolve(resolved);
18301
- const normalizedParent = path38.resolve(parent);
18302
- if (!normalizedResolved.startsWith(normalizedParent + path38.sep) && normalizedResolved !== normalizedParent) {
18303
- throw new Error(`${label} escapes the expected directory: ${resolved}`);
18304
- }
18305
- }
18306
- async function safeReplaceDir(oldDir, newDir) {
18307
- const backupDir = `${oldDir}.sync-backup-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
18308
- const oldExists = await filePathExists(oldDir);
18309
- if (oldExists) {
18310
- await fs31.rename(oldDir, backupDir);
18458
+ return { version: newVersion, writeId };
18311
18459
  }
18312
18460
  try {
18313
- await fs31.rename(newDir, oldDir);
18314
- } catch (err2) {
18315
- if (oldExists) {
18316
- await fs31.rename(backupDir, oldDir).catch(() => {
18317
- });
18461
+ let currentVersion = null;
18462
+ try {
18463
+ const current = JSON.parse(await fs33.readFile(filePath, "utf-8"));
18464
+ currentVersion = current.version ?? 0;
18465
+ } catch {
18466
+ currentVersion = null;
18318
18467
  }
18319
- throw err2;
18320
- }
18321
- if (oldExists) {
18322
- await fs31.rm(backupDir, { recursive: true, force: true }).catch(() => {
18468
+ if (currentVersion !== expectedVersion) {
18469
+ throw new OccWriteConflictError2();
18470
+ }
18471
+ await fs33.rename(tempPath, filePath);
18472
+ if (settleMs > 0) {
18473
+ await new Promise((resolve2) => setTimeout(resolve2, settleMs));
18474
+ }
18475
+ const afterWrite = JSON.parse(await fs33.readFile(filePath, "utf-8"));
18476
+ if (afterWrite.writeId !== writeId) {
18477
+ throw new OccWriteConflictError2();
18478
+ }
18479
+ } catch (err2) {
18480
+ await fs33.unlink(tempPath).catch(() => {
18323
18481
  });
18482
+ throw err2;
18324
18483
  }
18484
+ return { version: newVersion, writeId };
18325
18485
  }
18326
- async function listFilesRecursive(dir, prefix = "") {
18327
- const results = [];
18328
- let entries;
18329
- try {
18330
- entries = await fs31.readdir(dir, { withFileTypes: true });
18331
- } catch {
18332
- return results;
18333
- }
18334
- for (const entry of entries) {
18335
- if (entry.name === ".git")
18336
- continue;
18337
- if (entry.isSymbolicLink())
18338
- continue;
18339
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
18340
- if (entry.isDirectory()) {
18341
- results.push(...await listFilesRecursive(path38.join(dir, entry.name), rel));
18342
- } else {
18343
- results.push(rel);
18486
+ async function withOccRetry2(operation, options) {
18487
+ const maxAttempts = options?.maxAttempts ?? 10;
18488
+ const isRetryable = options?.isRetryable;
18489
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
18490
+ try {
18491
+ return await operation();
18492
+ } catch (err2) {
18493
+ const retryable = err2 instanceof OccWriteConflictError2 || (isRetryable?.(err2) ?? false);
18494
+ if (retryable && attempt < maxAttempts) {
18495
+ const baseDelay = Math.min(10 * Math.pow(2, attempt - 1), 100);
18496
+ const jitter = Math.random() * baseDelay;
18497
+ await new Promise((resolve2) => setTimeout(resolve2, baseDelay + jitter));
18498
+ continue;
18499
+ }
18500
+ throw err2;
18344
18501
  }
18345
18502
  }
18346
- return results;
18503
+ throw new Error("Unreachable");
18347
18504
  }
18348
- async function copyDir(src, dest) {
18349
- await fs31.mkdir(dest, { recursive: true });
18350
- const entries = await fs31.readdir(src, { withFileTypes: true });
18351
- for (const entry of entries) {
18352
- if (entry.name === ".git")
18353
- continue;
18354
- if (entry.isSymbolicLink())
18355
- continue;
18356
- const srcPath = path38.join(src, entry.name);
18357
- const destPath = path38.join(dest, entry.name);
18358
- if (entry.isDirectory()) {
18359
- await copyDir(srcPath, destPath);
18360
- } else {
18361
- await fs31.copyFile(srcPath, destPath);
18505
+ async function withOccFileLock2(filePath, fn) {
18506
+ try {
18507
+ await fs33.mkdir(path40.dirname(filePath));
18508
+ } catch (err2) {
18509
+ if (!isNodeError(err2) || err2.code !== "EEXIST") {
18510
+ throw new OccWriteConflictError2(`Could not acquire file lock: ${getErrorMessage(err2)}`);
18362
18511
  }
18363
18512
  }
18364
- }
18365
- async function pushContentToWorkspace(options) {
18366
- const { srcContentDir, branchPath, contentRoot } = options;
18367
- if (!await filePathExists(srcContentDir)) {
18368
- return { fileCount: 0, savedEditorState: false };
18369
- }
18370
- const wsContentDir = path38.join(branchPath, contentRoot);
18371
- assertWithinDir(wsContentDir, branchPath, "content-root");
18372
- return withContentWriteLock2(branchPath, () => replaceWorkspaceContent(options, wsContentDir));
18373
- }
18374
- async function replaceWorkspaceContent(options, wsContentDir) {
18375
- const { srcContentDir, branchPath, commitMessage, baseTag, saveEditorStateMessage } = options;
18376
- const wsGit = simpleGit5({ baseDir: branchPath });
18377
- let savedEditorState = false;
18378
- if (saveEditorStateMessage) {
18379
- const editorChanges = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
18380
- if (editorChanges.length > 0) {
18381
- await stageAllExceptCanopyState2(wsGit);
18382
- await wsGit.commit(saveEditorStateMessage);
18383
- savedEditorState = true;
18513
+ const MAX_ATTEMPTS = 14;
18514
+ let release = null;
18515
+ for (let attempt = 1; release === null; attempt++) {
18516
+ try {
18517
+ release = await lockfile4.lock(filePath, {
18518
+ lockfilePath: `${filePath}.lock`,
18519
+ realpath: false,
18520
+ stale: 1e4,
18521
+ retries: 0,
18522
+ // A compromised lock (the lock dir vanished or refresh failed mid-hold, e.g. the branch
18523
+ // directory containing it was deleted, or an EFS waiter misread a cached mtime and took
18524
+ // it over) must not crash the process, which is proper-lockfile's default. Our critical
18525
+ // sections are short and idempotent-on-conflict (OCC verify inside); log and let the
18526
+ // section finish.
18527
+ onCompromised: guardOnCompromised2((err2) => canopyLogWarn3(`[canopy] OCC file lock compromised mid-hold for ${filePath}:`, getErrorMessage(err2)), filePath)
18528
+ });
18529
+ } catch (err2) {
18530
+ const code = isNodeError(err2) ? err2.code : void 0;
18531
+ const contended = code === "ELOCKED";
18532
+ if (!contended || attempt >= MAX_ATTEMPTS) {
18533
+ throw new OccWriteConflictError2(`Could not acquire file lock: ${getErrorMessage(err2)}`);
18534
+ }
18535
+ const baseDelay = Math.min(50 * Math.pow(1.6, attempt - 1), 2e3);
18536
+ const jitter = Math.random() * baseDelay;
18537
+ await new Promise((resolve2) => setTimeout(resolve2, baseDelay + jitter));
18384
18538
  }
18385
18539
  }
18386
- const tmpDir = `${wsContentDir}.sync-tmp-${Date.now()}`;
18387
18540
  try {
18388
- await copyDir(srcContentDir, tmpDir);
18389
- await safeReplaceDir(wsContentDir, tmpDir);
18390
- } catch (err2) {
18391
- await fs31.rm(tmpDir, { recursive: true, force: true }).catch(() => {
18541
+ return await fn();
18542
+ } finally {
18543
+ await release().catch((err2) => {
18544
+ if (isNodeError(err2) && err2.code === "ENOENT")
18545
+ return;
18546
+ log13.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage(err2) });
18392
18547
  });
18393
- throw err2;
18394
18548
  }
18395
- try {
18396
- await stageAllExceptCanopyState2(wsGit);
18397
- const staged = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
18398
- if (staged.length === 0) {
18399
- if (baseTag)
18400
- await wsGit.tag(["-f", baseTag]);
18401
- return { fileCount: 0, savedEditorState };
18402
- }
18403
- await wsGit.commit(commitMessage ?? "sync: update content from working tree");
18404
- if (baseTag)
18405
- await wsGit.tag(["-f", baseTag]);
18406
- return { fileCount: staged.length, savedEditorState };
18407
- } finally {
18408
- await invalidateBranchContentCaches2(branchPath);
18549
+ }
18550
+ var log13, OccWriteConflictError2;
18551
+ var init_occ_json_write = __esm({
18552
+ "dist/utils/occ-json-write.js"() {
18553
+ "use strict";
18554
+ init_error();
18555
+ init_debug2();
18556
+ init_logger3();
18557
+ init_provisioning_lock();
18558
+ log13 = createDebugLogger3({ prefix: "OccJsonWrite" });
18559
+ OccWriteConflictError2 = class extends Error {
18560
+ constructor(message = "Concurrent modification detected") {
18561
+ super(message);
18562
+ this.name = "OccWriteConflictError";
18563
+ }
18564
+ };
18409
18565
  }
18566
+ });
18567
+
18568
+ // dist/branch-metadata.js
18569
+ import fs34 from "node:fs/promises";
18570
+ import path41 from "node:path";
18571
+ function isRecordedEditor2(branch, userId) {
18572
+ return (branch.editors?.includes(userId) ?? false) && (branch.uncommittedEditors?.includes(userId) ?? false);
18410
18573
  }
18411
- var SYNC_BASE_TAG;
18412
- var init_sync_core = __esm({
18413
- "dist/sync-core.js"() {
18574
+ function withMember2(list, id) {
18575
+ return list?.includes(id) ? [...list] : [...list ?? [], id];
18576
+ }
18577
+ function mergeBranchMetadata2(existing, version, incoming, now = (/* @__PURE__ */ new Date()).toISOString()) {
18578
+ const defaults = {
18579
+ name: "unknown",
18580
+ status: "editing",
18581
+ access: {},
18582
+ createdBy: "unknown",
18583
+ createdAt: now,
18584
+ updatedAt: now
18585
+ };
18586
+ return {
18587
+ // Envelope keys this code does not know survive, as unknown branch keys do below.
18588
+ ...existing,
18589
+ schemaVersion: CURRENT_SCHEMA_VERSION2,
18590
+ version: version ?? 0,
18591
+ branch: {
18592
+ ...defaults,
18593
+ ...existing?.branch,
18594
+ ...incoming.branch,
18595
+ access: {
18596
+ ...existing?.branch?.access,
18597
+ ...incoming.branch?.access
18598
+ },
18599
+ // Immutable after creation
18600
+ createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
18601
+ createdAt: existing?.branch.createdAt ?? defaults.createdAt,
18602
+ // Fork point is recorded once at creation; later saves must not move it
18603
+ baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch,
18604
+ // Always stamped fresh; the spreads above would otherwise let the creation-time value win
18605
+ // forever, freezing the timestamp the editor's Branches panel sorts and displays by
18606
+ updatedAt: now
18607
+ }
18608
+ };
18609
+ }
18610
+ var CURRENT_SCHEMA_VERSION2, BranchMetadataConflictError2, BranchMetadataFileManager2, loadBranchContext2;
18611
+ var init_branch_metadata = __esm({
18612
+ "dist/branch-metadata.js"() {
18414
18613
  "use strict";
18415
- init_content_index_generation();
18416
- init_content_write_lock();
18417
- init_fs();
18418
- init_git();
18419
- SYNC_BASE_TAG = "canopycms-sync-base";
18614
+ init_branch_registry();
18615
+ init_branch_metadata_file();
18616
+ init_paths();
18617
+ init_error();
18618
+ init_async_mutex();
18619
+ init_occ_json_write();
18620
+ CURRENT_SCHEMA_VERSION2 = 1;
18621
+ BranchMetadataConflictError2 = class extends Error {
18622
+ constructor(message = "Concurrent modification detected in branch metadata") {
18623
+ super(message);
18624
+ this.name = "BranchMetadataConflictError";
18625
+ }
18626
+ };
18627
+ BranchMetadataFileManager2 = class _BranchMetadataFileManager2 {
18628
+ constructor(branchRoot, baseRoot, options) {
18629
+ this.branchRoot = path41.resolve(branchRoot);
18630
+ this.filePath = path41.join(this.branchRoot, BRANCH_META_DIR2, BRANCH_META_FILE2);
18631
+ this.baseRoot = baseRoot;
18632
+ this.settleMs = options?.settleMs;
18633
+ }
18634
+ /** Read-only load, needing no baseRoot: registry scanning, loadBranchContext. */
18635
+ static async loadOnly(branchRoot) {
18636
+ return readBranchMetadataFile2(branchRoot);
18637
+ }
18638
+ /**
18639
+ * An instance wired for registry invalidation. API handlers use this, so the
18640
+ * registry cache is invalidated on update.
18641
+ */
18642
+ static get(branchRoot, baseRoot, options) {
18643
+ return new _BranchMetadataFileManager2(branchRoot, baseRoot, options);
18644
+ }
18645
+ /**
18646
+ * The file the save cycle merges over. A corrupt one throws
18647
+ * {@link BranchMetadataCorruptError} rather than reading as absent, so a save
18648
+ * never lays defaults (status 'editing', no ACL) over a branch whose real
18649
+ * state is unknown; repair-metadata archives the file before it saves.
18650
+ */
18651
+ async load() {
18652
+ const meta = await readBranchMetadataFile2(this.branchRoot);
18653
+ return { meta, version: meta?.version ?? null };
18654
+ }
18655
+ /**
18656
+ * Write branch.json via the shared OCC helper, applying the schemaVersion
18657
+ * default here since payload shaping is branch-metadata's concern.
18658
+ *
18659
+ * Throws the helper's raw {@link OccWriteConflictError}, which is what the
18660
+ * surrounding {@link withOccRetry} in save() recognizes and retries;
18661
+ * translating to the public `BranchMetadataConflictError` earlier than the
18662
+ * save() boundary would make withOccRetry's predicate miss it.
18663
+ *
18664
+ * branch.json is written WITH a trailing newline (`trailingNewline: true`),
18665
+ * unlike comments.json.
18666
+ */
18667
+ async write(meta, expectedVersion) {
18668
+ const payload = {
18669
+ ...meta,
18670
+ schemaVersion: meta.schemaVersion ?? CURRENT_SCHEMA_VERSION2
18671
+ };
18672
+ return writeOccJsonFile2(this.filePath, payload, {
18673
+ expectedVersion,
18674
+ settleMs: this.settleMs,
18675
+ trailingNewline: true
18676
+ });
18677
+ }
18678
+ /**
18679
+ * Run a save cycle under the full lock + OCC-retry stack (class doc). A
18680
+ * conflict surviving every retry surfaces as `BranchMetadataConflictError`.
18681
+ *
18682
+ * The stat guards a phantom-resurrection race with branch deletion: a
18683
+ * caller's branchContext can resolve BEFORE a concurrent deleteBranchHandler
18684
+ * removes the branch directory, while the save reaches here after it, and
18685
+ * write()'s `mkdir({recursive:true})` would then recreate `.canopy-meta/` and
18686
+ * branch.json from defaults inside a tree nothing else refers to — a registry
18687
+ * entry with no clone behind it. It runs BEFORE the lock stack so a doomed
18688
+ * save fails fast instead of paying for a lock.
18689
+ *
18690
+ * A save that passes the check just before a delete still cannot write: the
18691
+ * delete renames the tree away under the same lockfile, and that lockfile
18692
+ * creates only its own directory (`withOccFileLock`), so the save fails there.
18693
+ */
18694
+ async save(incoming) {
18695
+ const saved = await this.saveIf(incoming, () => true);
18696
+ if (saved === null)
18697
+ throw new Error("unreachable: an unconditional save was skipped");
18698
+ return saved;
18699
+ }
18700
+ /**
18701
+ * {@link save}, applied only when `onlyIf` accepts the metadata on disk; resolves null when it
18702
+ * does not, having written nothing. `onlyIf` runs inside the lock stack against the version
18703
+ * the write is checked against, so the decision and the write are one compare-and-set.
18704
+ */
18705
+ async saveIf(incoming, onlyIf) {
18706
+ return this.update((existing) => onlyIf(existing) ? incoming : null);
18707
+ }
18708
+ /**
18709
+ * A save whose update `compute` derives from the metadata on disk, inside the lock stack, so
18710
+ * an update built from the existing value (adding to a list) cannot lose a concurrent one.
18711
+ * `compute` returning null writes nothing and resolves null. `keepUpdatedAt` leaves
18712
+ * `updatedAt` as it was, for bookkeeping writes that are not a change to the branch.
18713
+ */
18714
+ async update(compute, options = {}) {
18715
+ try {
18716
+ await fs34.stat(this.branchRoot);
18717
+ } catch (err2) {
18718
+ if (isNotFoundError(err2)) {
18719
+ throw new BranchMetadataConflictError2("Branch no longer exists");
18720
+ }
18721
+ throw err2;
18722
+ }
18723
+ let saved;
18724
+ try {
18725
+ saved = await withLock2(this.filePath, () => withOccFileLock2(this.filePath, () => withOccRetry2(async () => {
18726
+ const { meta: existing, version } = await this.load();
18727
+ const incoming = compute(existing);
18728
+ if (incoming === null)
18729
+ return null;
18730
+ const merged = mergeBranchMetadata2(existing, version, incoming);
18731
+ if (options.keepUpdatedAt && existing)
18732
+ merged.branch.updatedAt = existing.branch.updatedAt;
18733
+ const written = await this.write(merged, version);
18734
+ merged.version = written.version;
18735
+ merged.writeId = written.writeId;
18736
+ return merged;
18737
+ })));
18738
+ } catch (err2) {
18739
+ if (err2 instanceof OccWriteConflictError2) {
18740
+ throw new BranchMetadataConflictError2();
18741
+ }
18742
+ throw err2;
18743
+ }
18744
+ if (saved !== null)
18745
+ await this.invalidateRegistry();
18746
+ return saved;
18747
+ }
18748
+ /**
18749
+ * Adds `userId` to `editors` and `uncommittedEditors`. A lock-free read skips the write when
18750
+ * both already hold it, so only a user's first save since the last submit commit pays for the lock.
18751
+ * Never creates branch.json.
18752
+ */
18753
+ async recordEditor(userId) {
18754
+ const current = await readBranchMetadataFile2(this.branchRoot);
18755
+ if (!current || isRecordedEditor2(current.branch, userId))
18756
+ return;
18757
+ await this.update((existing) => existing && !isRecordedEditor2(existing.branch, userId) ? {
18758
+ branch: {
18759
+ editors: withMember2(existing.branch.editors, userId),
18760
+ uncommittedEditors: withMember2(existing.branch.uncommittedEditors, userId)
18761
+ }
18762
+ } : null, { keepUpdatedAt: true });
18763
+ }
18764
+ /**
18765
+ * Removes `userIds` from `uncommittedEditors` once a submit commit's trailers name them. Ids
18766
+ * recorded after the commit read them stay, so the next commit names them.
18767
+ */
18768
+ async markEditorsCommitted(userIds) {
18769
+ const committed = new Set(userIds);
18770
+ if (committed.size === 0)
18771
+ return;
18772
+ await this.update((existing) => {
18773
+ const pending = existing?.branch.uncommittedEditors ?? [];
18774
+ const remaining = pending.filter((id) => !committed.has(id));
18775
+ if (remaining.length === pending.length)
18776
+ return null;
18777
+ return { branch: { uncommittedEditors: remaining.length > 0 ? remaining : void 0 } };
18778
+ }, { keepUpdatedAt: true });
18779
+ }
18780
+ /** Invalidate the registry cache so the next list() regenerates. */
18781
+ async invalidateRegistry() {
18782
+ const registry3 = new BranchRegistry2(this.baseRoot);
18783
+ await registry3.invalidate();
18784
+ }
18785
+ };
18786
+ loadBranchContext2 = async (options) => {
18787
+ const { branchRoot, baseRoot } = resolveBranchPath2({
18788
+ branchName: options.branchName,
18789
+ mode: options.mode,
18790
+ basePathOverride: options.basePathOverride
18791
+ });
18792
+ const meta = await BranchMetadataFileManager2.loadOnly(branchRoot);
18793
+ if (!meta) {
18794
+ return null;
18795
+ }
18796
+ return {
18797
+ branch: meta.branch,
18798
+ branchRoot,
18799
+ baseRoot
18800
+ };
18801
+ };
18420
18802
  }
18421
18803
  });
18422
18804
 
18423
- // dist/git-manager.js
18424
- import fs33 from "node:fs/promises";
18425
- import path39 from "node:path";
18426
- import { simpleGit as simpleGit6 } from "simple-git";
18427
- async function ensureGitExcludePattern2(repoPath, pattern) {
18428
- const excludePath = path39.join(repoPath, ".git", "info", "exclude");
18429
- await fs33.mkdir(path39.dirname(excludePath), { recursive: true });
18430
- let content = "";
18431
- try {
18432
- content = await fs33.readFile(excludePath, "utf-8");
18433
- } catch (err2) {
18434
- if (!isNotFoundError(err2))
18435
- throw err2;
18436
- }
18437
- const lines = content.split("\n");
18438
- if (lines.some((line) => line.trim() === pattern)) {
18439
- log12.debug("git", "Pattern already in .git/info/exclude", { pattern });
18440
- return;
18805
+ // dist/utils/base-branch.js
18806
+ function baseBranchOf2(config) {
18807
+ if (config.defaultBaseBranch)
18808
+ return config.defaultBaseBranch;
18809
+ if (config.mode === "prod") {
18810
+ throw new BaseBranchUnresolvedError2("the prod base branch has not been resolved.");
18441
18811
  }
18442
- const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
18443
- const newContent = content + (needsLeadingNewline ? "\n" : "") + pattern + "\n";
18444
- await fs33.writeFile(excludePath, newContent, "utf-8");
18445
- log12.debug("git", "Added pattern to .git/info/exclude", { pattern });
18812
+ return "main";
18446
18813
  }
18447
- var log12;
18448
- var init_git_manager = __esm({
18449
- "dist/git-manager.js"() {
18814
+ var BaseBranchUnresolvedError2;
18815
+ var init_base_branch = __esm({
18816
+ "dist/utils/base-branch.js"() {
18450
18817
  "use strict";
18451
- init_debug2();
18452
- init_error();
18453
- log12 = createDebugLogger3({ prefix: "GitManager" });
18818
+ BaseBranchUnresolvedError2 = class extends Error {
18819
+ constructor(detail) {
18820
+ super(`CanopyCMS: ${detail} Set defaultBaseBranch in your CanopyCMS config to the branch editing branches fork from.`);
18821
+ this.name = "BaseBranchUnresolvedError";
18822
+ }
18823
+ };
18454
18824
  }
18455
18825
  });
18456
18826
 
18457
- // dist/cli/sync.js
18458
- var sync_exports = {};
18459
- __export(sync_exports, {
18460
- sync: () => sync
18461
- });
18462
- import fs34 from "node:fs/promises";
18463
- import path40 from "node:path";
18464
- import { simpleGit as simpleGit7 } from "simple-git";
18465
- import * as p2 from "@clack/prompts";
18466
- function lockBusyAsSyncError(err2, branchName) {
18467
- if (!(err2 instanceof ContentWriteLockBusyError2))
18468
- return err2;
18469
- return new SyncError(err2.outcome === "not-run" ? `Branch workspace "${branchName}" is busy (the worker is syncing it, or an editor save is in flight); nothing was changed. Try again in a moment.` : `Branch workspace "${branchName}" was being synced during this run, so it may be partly updated. Check its state before running sync again.`);
18470
- }
18471
- async function withWorkspaceLock(branchPath, branchName, fn) {
18827
+ // dist/branch-param.js
18828
+ function cmsBranchNamespace2(config) {
18472
18829
  try {
18473
- return await withContentWriteLock2(branchPath, fn);
18474
- } catch (err2) {
18475
- throw lockBusyAsSyncError(err2, branchName);
18830
+ return operatingStrategy3(config.mode).getBranchNamespace(config);
18831
+ } catch {
18832
+ return null;
18476
18833
  }
18477
18834
  }
18478
- async function abortWorkspaceMerge(branchPath, branchName) {
18479
- return withWorkspaceLock(branchPath, branchName, async () => {
18480
- const wsGit = simpleGit7({ baseDir: branchPath });
18481
- if ((await wsGit.status()).conflicted.length === 0)
18835
+ function namesBaseOrActive2(param, config) {
18836
+ let base;
18837
+ try {
18838
+ base = baseBranchOf2(config);
18839
+ } catch (err2) {
18840
+ if (err2 instanceof BaseBranchUnresolvedError2)
18482
18841
  return false;
18483
- await wsGit.merge(["--abort"]);
18484
- await invalidateBranchContentCaches2(branchPath);
18485
- return true;
18486
- });
18487
- }
18488
- async function detectCurrentBranch(projectDir) {
18489
- const git = simpleGit7({ baseDir: projectDir });
18490
- const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
18491
- if (branch === "HEAD")
18492
- throw new Error("Detached HEAD \u2014 use --branch to specify a branch name");
18493
- return branch;
18494
- }
18495
- async function selectBranch(options, branchesDir) {
18496
- let branches = [];
18497
- if (await filePathExists(branchesDir)) {
18498
- const entries = await fs34.readdir(branchesDir, { withFileTypes: true });
18499
- branches = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
18500
- }
18501
- let branchName = options.branch;
18502
- if (!branchName) {
18503
- try {
18504
- branchName = await detectCurrentBranch(options.projectDir);
18505
- } catch {
18506
- }
18842
+ throw err2;
18507
18843
  }
18508
- if (!branchName) {
18509
- if (branches.length === 0) {
18510
- throw new SyncError("No branch workspaces found and could not detect current git branch. Use --branch to specify.");
18511
- }
18512
- if (branches.length === 1) {
18513
- branchName = branches[0];
18514
- } else {
18515
- const result = await p2.select({
18516
- message: "Which branch?",
18517
- options: branches.map((b) => ({ value: b, label: b }))
18518
- });
18519
- if (p2.isCancel(result)) {
18520
- p2.cancel("Sync cancelled.");
18844
+ return param === base || param === (config.defaultActiveBranch ?? base);
18845
+ }
18846
+ async function resolveBranchParam(param, config, options = {}) {
18847
+ const load = (branchName) => loadBranchContext2({ branchName, mode: config.mode, basePathOverride: options.basePathOverride });
18848
+ if (isExactBranchForm(param)) {
18849
+ const exact = exactBranchName(param);
18850
+ if (exact === null)
18851
+ return null;
18852
+ const named = await load(exact);
18853
+ return named?.branch.name === exact ? named : null;
18854
+ }
18855
+ if (namesBaseOrActive2(param, config))
18856
+ return load(param);
18857
+ const namespace = cmsBranchNamespace2(config);
18858
+ if (namespace !== null && !param.includes("/")) {
18859
+ const ref = `${namespace}${param}`;
18860
+ const inNamespace = await load(ref).catch((err2) => {
18861
+ if (namesNoWorkspace(err2))
18521
18862
  return null;
18522
- }
18523
- branchName = result;
18524
- }
18525
- }
18526
- const branchPath = path40.join(branchesDir, branchName);
18527
- assertWithinDir(branchPath, branchesDir, "--branch");
18528
- if (!branches.includes(branchName)) {
18529
- if (options.autoCreate) {
18530
- await fs34.mkdir(branchPath, { recursive: true });
18531
- const wsGit = simpleGit7({ baseDir: branchPath });
18532
- await wsGit.init();
18533
- await wsGit.checkoutLocalBranch(branchName);
18534
- await wsGit.raw(["commit", "--allow-empty", "-m", "init: workspace created by sync"]);
18535
- await ensureGitExcludePattern2(branchPath, operatingStrategy3("dev").getGitExcludePattern());
18536
- p2.log.info(`Created branch workspace: ${branchName}`);
18537
- } else {
18538
- const available = branches.length > 0 ? ` Available branches: ${branches.join(", ")}` : "";
18539
- throw new SyncError(`Branch workspace "${branchName}" not found.${available}`);
18540
- }
18863
+ throw err2;
18864
+ });
18865
+ if (inNamespace?.branch.name === ref)
18866
+ return inNamespace;
18541
18867
  }
18542
- return branchName;
18868
+ const outside = await load(param);
18869
+ return outside?.branch.name === param ? outside : null;
18543
18870
  }
18544
- async function syncPush(options) {
18545
- const { projectDir } = options;
18546
- const contentRoot = options.contentRoot || "content";
18547
- const branchesDir = path40.join(projectDir, ".canopy-dev", "content-branches");
18548
- const srcContentDir = path40.join(projectDir, contentRoot);
18549
- assertWithinDir(srcContentDir, projectDir, "--content-root");
18550
- if (!await filePathExists(srcContentDir)) {
18551
- throw new SyncError(`Content directory not found: ${contentRoot}/ (expected at ${srcContentDir})`);
18552
- }
18553
- const branchName = await selectBranch({ ...options, projectDir, autoCreate: true }, branchesDir);
18554
- if (!branchName)
18555
- return { fileCount: 0 };
18556
- const branchPath = path40.join(branchesDir, branchName);
18557
- assertWithinDir(branchPath, branchesDir, "--branch");
18558
- const wsGit = simpleGit7({ baseDir: branchPath });
18559
- const status = await wsGit.status();
18560
- if (status.conflicted.length > 0) {
18561
- p2.log.error("Branch workspace has unresolved merge conflicts.");
18562
- if (!options.force) {
18563
- const shouldAbort = await p2.confirm({
18564
- message: "Abort the merge and restore workspace to pre-merge state?",
18565
- initialValue: false
18566
- });
18567
- if (!p2.isCancel(shouldAbort) && shouldAbort) {
18568
- if (await abortWorkspaceMerge(branchPath, branchName)) {
18569
- p2.log.success("Merge aborted. Workspace restored to pre-merge state.");
18570
- }
18571
- }
18572
- }
18573
- return { fileCount: 0 };
18574
- }
18575
- const editorChanges = status.files.filter((f) => !isCanopyInternalPath(f.path));
18576
- if (editorChanges.length > 0 && !options.force) {
18577
- p2.log.warn(`Branch workspace has ${editorChanges.length} uncommitted change(s) that will be committed to history then overwritten:`);
18578
- for (const file of editorChanges.slice(0, 10)) {
18579
- p2.log.warn(` ${file.path}`);
18580
- }
18581
- if (editorChanges.length > 10) {
18582
- p2.log.warn(` ... and ${editorChanges.length - 10} more`);
18583
- }
18584
- const confirm5 = await p2.confirm({
18585
- message: "Continue? Editor changes will be preserved in git history.",
18586
- initialValue: false
18587
- });
18588
- if (p2.isCancel(confirm5) || !confirm5) {
18589
- p2.cancel("Push cancelled.");
18590
- return { fileCount: 0 };
18591
- }
18871
+ var init_branch_param = __esm({
18872
+ "dist/branch-param.js"() {
18873
+ "use strict";
18874
+ init_branch_metadata();
18875
+ init_operating_mode2();
18876
+ init_branch();
18877
+ init_validation3();
18878
+ init_base_branch();
18592
18879
  }
18593
- p2.log.step(`Pushing content to branch workspace: ${branchName}`);
18594
- let fileCount;
18880
+ });
18881
+
18882
+ // dist/utils/git.js
18883
+ import { simpleGit as simpleGit4 } from "simple-git";
18884
+ function isCanopyInternalPath(repoRelativePath) {
18885
+ return repoRelativePath === CANOPY_META_DIR2 || repoRelativePath.startsWith(`${CANOPY_META_DIR2}/`);
18886
+ }
18887
+ async function stageAllExceptCanopyState2(git) {
18888
+ await git.raw(["add", "-A", "--sparse"]);
18889
+ await git.raw(["reset", "-q", "--", CANOPY_META_DIR2]);
18890
+ }
18891
+ async function detectHeadBranch2(repoRoot, fallback = "main") {
18595
18892
  try {
18596
- const result = await pushContentToWorkspace({
18597
- srcContentDir,
18598
- branchPath,
18599
- contentRoot,
18600
- baseTag: SYNC_BASE_TAG,
18601
- saveEditorStateMessage: "sync: save editor state before push"
18602
- });
18603
- fileCount = result.fileCount;
18604
- if (result.savedEditorState)
18605
- p2.log.info("Committed editor changes to history before push");
18606
- } catch (err2) {
18607
- throw lockBusyAsSyncError(err2, branchName);
18608
- }
18609
- if (fileCount === 0) {
18610
- p2.log.info("Content is already up to date \u2014 nothing to push");
18611
- } else {
18612
- p2.log.success(`Pushed ${fileCount} file change(s) to branch "${branchName}"`);
18893
+ const git = simpleGit4({ baseDir: repoRoot });
18894
+ const head = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
18895
+ return head && head !== "HEAD" ? head : fallback;
18896
+ } catch {
18897
+ return fallback;
18613
18898
  }
18614
- return { fileCount };
18615
18899
  }
18616
- async function syncPull(options) {
18617
- const { projectDir } = options;
18618
- const contentRoot = options.contentRoot || "content";
18619
- const branchesDir = path40.join(projectDir, ".canopy-dev", "content-branches");
18620
- const branchName = await selectBranch({ ...options, projectDir }, branchesDir);
18621
- if (!branchName)
18622
- return { fileCount: 0 };
18623
- const branchPath = path40.join(branchesDir, branchName);
18624
- assertWithinDir(branchPath, branchesDir, "--branch");
18625
- const branchContentDir = path40.join(branchPath, contentRoot);
18626
- assertWithinDir(branchContentDir, branchPath, "--content-root");
18627
- if (!await filePathExists(branchContentDir)) {
18628
- throw new SyncError(`Content directory not found in branch workspace: ${branchName}/${contentRoot}`);
18629
- }
18630
- p2.log.step(`Pulling content from branch: ${branchName}`);
18631
- const destContentDir = path40.join(projectDir, contentRoot);
18632
- assertWithinDir(destContentDir, projectDir, "--content-root");
18633
- const sourceGit = simpleGit7({ baseDir: projectDir });
18634
- const status = await sourceGit.status();
18635
- const uncommittedContent = status.files.filter((f) => f.path.startsWith(contentRoot + "/") || f.path === contentRoot);
18636
- const untrackedLosses = [];
18637
- if (await filePathExists(destContentDir)) {
18638
- const localFiles = await listFilesRecursive(destContentDir);
18639
- const branchFiles = new Set(await listFilesRecursive(branchContentDir));
18640
- for (const file of localFiles) {
18641
- if (!branchFiles.has(file)) {
18642
- const relativePath = contentRoot + "/" + file;
18643
- if (!uncommittedContent.some((f) => f.path === relativePath)) {
18644
- untrackedLosses.push(relativePath);
18645
- }
18646
- }
18647
- }
18900
+ var CANOPY_META_DIR2;
18901
+ var init_git = __esm({
18902
+ "dist/utils/git.js"() {
18903
+ "use strict";
18904
+ CANOPY_META_DIR2 = ".canopy-meta";
18648
18905
  }
18649
- const totalWarnings = uncommittedContent.length + untrackedLosses.length;
18650
- if (totalWarnings > 0 && !options.force) {
18651
- if (uncommittedContent.length > 0) {
18652
- p2.log.warn(`You have ${uncommittedContent.length} uncommitted change(s) in ${contentRoot}/:`);
18653
- for (const file of uncommittedContent.slice(0, 10)) {
18654
- p2.log.warn(` ${file.path}`);
18655
- }
18656
- if (uncommittedContent.length > 10) {
18657
- p2.log.warn(` ... and ${uncommittedContent.length - 10} more`);
18658
- }
18659
- }
18660
- if (untrackedLosses.length > 0) {
18661
- p2.log.warn(`${untrackedLosses.length} file(s) in ${contentRoot}/ not present in branch "${branchName}" will be deleted:`);
18662
- for (const file of untrackedLosses.slice(0, 10)) {
18663
- p2.log.warn(` ${file}`);
18664
- }
18665
- if (untrackedLosses.length > 10) {
18666
- p2.log.warn(` ... and ${untrackedLosses.length - 10} more`);
18906
+ });
18907
+
18908
+ // dist/content-index-registry.js
18909
+ import path43 from "node:path";
18910
+ function invalidateContentIndexesForRoot2(root) {
18911
+ const rootKey = path43.resolve(root);
18912
+ const prefix = rootKey + path43.sep;
18913
+ for (const [storeRoot, refs] of registry2) {
18914
+ if (storeRoot !== rootKey && !storeRoot.startsWith(prefix))
18915
+ continue;
18916
+ for (const ref of refs) {
18917
+ const target = ref.deref();
18918
+ if (target) {
18919
+ target.invalidateIndex();
18920
+ } else {
18921
+ refs.delete(ref);
18667
18922
  }
18668
18923
  }
18669
- const confirm5 = await p2.confirm({
18670
- message: "Overwrite content directory? Files listed above will be lost.",
18671
- initialValue: false
18672
- });
18673
- if (p2.isCancel(confirm5) || !confirm5) {
18674
- p2.cancel("Pull cancelled. Commit or stash your changes first.");
18675
- return { fileCount: 0 };
18676
- }
18924
+ if (refs.size === 0)
18925
+ registry2.delete(storeRoot);
18677
18926
  }
18678
- const tmpDestDir = `${destContentDir}.sync-tmp-${Date.now()}`;
18679
- try {
18680
- await copyDir(branchContentDir, tmpDestDir);
18681
- await safeReplaceDir(destContentDir, tmpDestDir);
18682
- } catch (err2) {
18683
- await fs34.rm(tmpDestDir, { recursive: true, force: true }).catch(() => {
18927
+ }
18928
+ var registry2, finalization2;
18929
+ var init_content_index_registry = __esm({
18930
+ "dist/content-index-registry.js"() {
18931
+ "use strict";
18932
+ registry2 = /* @__PURE__ */ new Map();
18933
+ finalization2 = new FinalizationRegistry(({ rootKey, ref }) => {
18934
+ const refs = registry2.get(rootKey);
18935
+ if (!refs)
18936
+ return;
18937
+ refs.delete(ref);
18938
+ if (refs.size === 0)
18939
+ registry2.delete(rootKey);
18684
18940
  });
18685
- throw err2;
18686
- }
18687
- const postStatus = await sourceGit.status();
18688
- const contentChanges = postStatus.files.filter((f) => f.path.startsWith(contentRoot + "/") || f.path === contentRoot);
18689
- if (contentChanges.length === 0) {
18690
- p2.log.info("Content is already up to date \u2014 nothing to pull");
18691
- } else {
18692
- p2.log.success(`Pulled ${contentChanges.length} changed file(s) from branch "${branchName}"`);
18693
- for (const file of contentChanges.slice(0, 20)) {
18694
- const indicator = file.working_dir === "?" ? "A" : file.working_dir || file.index;
18695
- p2.log.info(` ${indicator} ${file.path}`);
18696
- }
18697
- if (contentChanges.length > 20) {
18698
- p2.log.info(` ... and ${contentChanges.length - 20} more`);
18699
- }
18700
- p2.log.info("\nReview the changes, then git add and commit when ready.");
18701
18941
  }
18702
- return { fileCount: contentChanges.length };
18942
+ });
18943
+
18944
+ // dist/id.js
18945
+ import { generate as generate2 } from "short-uuid";
18946
+ function generateId2() {
18947
+ const full = generate2();
18948
+ return full.substring(0, 12);
18703
18949
  }
18704
- async function syncBoth(options) {
18705
- const { projectDir } = options;
18706
- const contentRoot = options.contentRoot || "content";
18707
- const branchesDir = path40.join(projectDir, ".canopy-dev", "content-branches");
18708
- const srcContentDir = path40.join(projectDir, contentRoot);
18709
- assertWithinDir(srcContentDir, projectDir, "--content-root");
18710
- if (!await filePathExists(srcContentDir)) {
18711
- throw new SyncError(`Content directory not found: ${contentRoot}/ (expected at ${srcContentDir})`);
18950
+ var isValidId2;
18951
+ var init_id = __esm({
18952
+ "dist/id.js"() {
18953
+ "use strict";
18954
+ init_validation3();
18955
+ isValidId2 = isValidContentId2;
18712
18956
  }
18713
- const branchName = await selectBranch({ ...options, projectDir, autoCreate: true }, branchesDir);
18714
- if (!branchName)
18715
- return { pushed: 0, pulled: 0 };
18716
- const branchPath = path40.join(branchesDir, branchName);
18717
- assertWithinDir(branchPath, branchesDir, "--branch");
18718
- const wsGit = simpleGit7({ baseDir: branchPath });
18719
- const status = await wsGit.status();
18720
- if (status.conflicted.length > 0) {
18721
- p2.log.error("Branch workspace has unresolved merge conflicts.");
18722
- if (!options.force) {
18723
- const shouldAbort = await p2.confirm({
18724
- message: "Abort the merge and restore workspace to pre-merge state?",
18725
- initialValue: false
18726
- });
18727
- if (!p2.isCancel(shouldAbort) && shouldAbort) {
18728
- if (await abortWorkspaceMerge(branchPath, branchName)) {
18729
- p2.log.success("Merge aborted. Workspace restored to pre-merge state.");
18730
- }
18957
+ });
18958
+
18959
+ // dist/utils/format.js
18960
+ var getFormatExtension2;
18961
+ var init_format = __esm({
18962
+ "dist/utils/format.js"() {
18963
+ "use strict";
18964
+ getFormatExtension2 = (format) => {
18965
+ if (format === "md")
18966
+ return ".md";
18967
+ if (format === "mdx")
18968
+ return ".mdx";
18969
+ if (format === "yaml")
18970
+ return ".yaml";
18971
+ return ".json";
18972
+ };
18973
+ }
18974
+ });
18975
+
18976
+ // dist/content-id-index.js
18977
+ function extractIdFromFilename2(filename) {
18978
+ if (filename.startsWith(".")) {
18979
+ return null;
18980
+ }
18981
+ const parts = filename.split(".");
18982
+ if (parts.length >= 3) {
18983
+ const candidate = parts[parts.length - 2];
18984
+ if (isValidId2(candidate))
18985
+ return candidate;
18986
+ }
18987
+ if (parts.length === 2) {
18988
+ const candidate = parts[parts.length - 1];
18989
+ if (isValidId2(candidate))
18990
+ return candidate;
18991
+ }
18992
+ return null;
18993
+ }
18994
+ function extractSlugFromFilename2(filename, entryTypeName) {
18995
+ const parts = filename.split(".");
18996
+ if (parts.length >= 3) {
18997
+ const possibleId = parts[parts.length - 2];
18998
+ if (isValidId2(possibleId)) {
18999
+ let slugParts = parts.slice(0, parts.length - 2);
19000
+ if (entryTypeName && slugParts.length > 1 && slugParts[0] === entryTypeName) {
19001
+ slugParts = slugParts.slice(1);
19002
+ } else if (parts.length >= 4 && slugParts.length > 1) {
19003
+ slugParts = slugParts.slice(1);
18731
19004
  }
19005
+ return slugParts.join(".").toLowerCase();
18732
19006
  }
18733
- return { pushed: 0, pulled: 0 };
18734
19007
  }
18735
- return withWorkspaceLock(branchPath, branchName, () => mergeIntoWorkspace(options, { branchName, branchPath, srcContentDir, contentRoot }));
18736
- }
18737
- async function mergeIntoWorkspace(options, ws) {
18738
- const { branchName, branchPath, srcContentDir, contentRoot } = ws;
18739
- const wsGit = simpleGit7({ baseDir: branchPath });
18740
- const status = await wsGit.status();
18741
- if (status.files.some((f) => !isCanopyInternalPath(f.path))) {
18742
- await stageAllExceptCanopyState2(wsGit);
18743
- await wsGit.commit("sync: save editor state before merge");
18744
- p2.log.info("Committed editor changes before merge");
19008
+ if (parts.length === 2) {
19009
+ const possibleId = parts[parts.length - 1];
19010
+ if (isValidId2(possibleId)) {
19011
+ return parts[0].toLowerCase();
19012
+ }
18745
19013
  }
18746
- let baseRef;
18747
- try {
18748
- await wsGit.raw(["rev-parse", SYNC_BASE_TAG]);
18749
- baseRef = SYNC_BASE_TAG;
18750
- } catch {
18751
- baseRef = "HEAD";
19014
+ if (parts.length > 1) {
19015
+ return parts.slice(0, -1).join(".").toLowerCase();
18752
19016
  }
18753
- const currentBranch = (await wsGit.revparse(["--abbrev-ref", "HEAD"])).trim();
19017
+ return filename.toLowerCase();
19018
+ }
19019
+ var ENTRY_EXTENSIONS2;
19020
+ var init_content_id_index = __esm({
19021
+ "dist/content-id-index.js"() {
19022
+ "use strict";
19023
+ init_id();
19024
+ init_format();
19025
+ ENTRY_EXTENSIONS2 = new Set(Object.keys({ md: true, mdx: true, json: true, yaml: true }).map((format) => getFormatExtension2(format)));
19026
+ }
19027
+ });
19028
+
19029
+ // dist/schema/meta-loader.js
19030
+ import { promises as fs35 } from "fs";
19031
+ import { join as join3 } from "pathe";
19032
+ import { z as z18 } from "zod";
19033
+ import chokidar2 from "chokidar";
19034
+ function stripEmbeddedIdFromName2(name) {
19035
+ return extractSlugFromFilename2(name);
19036
+ }
19037
+ async function scanForCollectionMeta2(baseDir, relativePath = "") {
19038
+ const collections = [];
18754
19039
  try {
18755
- const incomingBranch = `sync-incoming-${Date.now()}`;
18756
- await wsGit.raw(["checkout", "-b", incomingBranch, baseRef]);
18757
- const wsContentDir = path40.join(branchPath, contentRoot);
18758
- assertWithinDir(wsContentDir, branchPath, "--content-root");
18759
- const tmpDir = `${wsContentDir}.sync-tmp-${Date.now()}`;
18760
- try {
18761
- await copyDir(srcContentDir, tmpDir);
18762
- await safeReplaceDir(wsContentDir, tmpDir);
18763
- } catch (err2) {
18764
- await fs34.rm(tmpDir, { recursive: true, force: true }).catch(() => {
18765
- });
18766
- await wsGit.checkout(currentBranch);
18767
- await wsGit.raw(["branch", "-D", incomingBranch]).catch(() => {
18768
- });
18769
- throw err2;
18770
- }
18771
- await stageAllExceptCanopyState2(wsGit);
18772
- const incomingFiles = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
18773
- if (incomingFiles.length === 0) {
18774
- await wsGit.checkout(currentBranch);
18775
- await wsGit.raw(["branch", "-D", incomingBranch]);
18776
- p2.log.info("No working-tree changes to merge \u2014 pulling editor changes only");
18777
- const pullResult2 = await syncPull({ ...options, branch: branchName, force: true });
18778
- return { pushed: 0, pulled: pullResult2.fileCount };
18779
- }
18780
- await wsGit.commit("sync: incoming working-tree changes");
18781
- await wsGit.checkout(currentBranch);
18782
- p2.log.step("Merging working-tree changes with editor changes...");
18783
- try {
18784
- await wsGit.merge([incomingBranch, "--no-edit"]);
18785
- } catch (mergeError) {
18786
- let mergeStatus;
19040
+ const entries = await fs35.readdir(baseDir, { withFileTypes: true });
19041
+ for (const entry of entries) {
19042
+ if (!entry.isDirectory())
19043
+ continue;
19044
+ const folderName = entry.name;
19045
+ const logicalName = stripEmbeddedIdFromName2(folderName);
19046
+ const collectionContentId = extractIdFromFilename2(folderName) ?? void 0;
19047
+ const folderPath = relativePath ? `${relativePath}/${logicalName}` : logicalName;
19048
+ const absolutePath = join3(baseDir, folderName);
19049
+ const metaPath = join3(absolutePath, ".collection.json");
18787
19050
  try {
18788
- mergeStatus = await wsGit.status();
18789
- } catch {
18790
- await wsGit.raw(["branch", "-D", incomingBranch]).catch(() => {
19051
+ await fs35.access(metaPath);
19052
+ const content = await fs35.readFile(metaPath, "utf-8");
19053
+ const parsed = JSON.parse(content);
19054
+ const meta = collectionMetaSchema2.parse(parsed);
19055
+ collections.push({
19056
+ ...meta,
19057
+ path: folderPath,
19058
+ contentId: collectionContentId
18791
19059
  });
18792
- throw mergeError;
18793
- }
18794
- if (mergeStatus.conflicted.length > 0) {
18795
- p2.log.error("Merge conflicts detected in the following files:");
18796
- for (const file of mergeStatus.conflicted) {
18797
- p2.log.error(` ${file}`);
19060
+ const nestedCollections = await scanForCollectionMeta2(absolutePath, folderPath);
19061
+ collections.push(...nestedCollections);
19062
+ } catch (err2) {
19063
+ if (err2.code !== "ENOENT") {
19064
+ canopyLogError2(`Error loading ${metaPath}:`, err2);
19065
+ throw new Error(`Invalid .collection.json in ${folderPath}: ${err2.message}`);
18798
19066
  }
18799
- p2.log.info("The branch workspace is now in a merge state.");
18800
- p2.log.info("Resolve the conflicts in the workspace, then run: canopycms sync pull");
18801
- p2.log.info("Or abort the merge with: canopycms sync abort");
18802
- await wsGit.raw(["branch", "-D", incomingBranch]).catch(() => {
18803
- });
18804
- return { pushed: 0, pulled: 0 };
19067
+ const nestedCollections = await scanForCollectionMeta2(absolutePath, folderPath);
19068
+ collections.push(...nestedCollections);
18805
19069
  }
18806
- await wsGit.raw(["branch", "-D", incomingBranch]).catch(() => {
18807
- });
18808
- throw mergeError;
18809
19070
  }
18810
- await wsGit.raw(["branch", "-D", incomingBranch]);
18811
- await wsGit.tag(["-f", SYNC_BASE_TAG]);
18812
- p2.log.success("Merged working-tree changes with editor changes");
18813
- const pullResult = await syncPull({ ...options, branch: branchName, force: true });
18814
- return { pushed: incomingFiles.length, pulled: pullResult.fileCount };
18815
- } finally {
18816
- await invalidateBranchContentCaches2(branchPath);
18817
- }
18818
- }
18819
- async function syncAbort(options) {
18820
- const { projectDir } = options;
18821
- const branchesDir = path40.join(projectDir, ".canopy-dev", "content-branches");
18822
- const branchName = await selectBranch({ ...options, projectDir }, branchesDir);
18823
- if (!branchName)
18824
- return;
18825
- const branchPath = path40.join(branchesDir, branchName);
18826
- assertWithinDir(branchPath, branchesDir, "--branch");
18827
- if (!await abortWorkspaceMerge(branchPath, branchName)) {
18828
- p2.log.info(`Branch workspace "${branchName}" is not in a merge state \u2014 nothing to abort.`);
18829
- return;
19071
+ return collections;
19072
+ } catch (err2) {
19073
+ if (err2.code === "ENOENT") {
19074
+ return [];
19075
+ }
19076
+ throw err2;
18830
19077
  }
18831
- p2.log.success(`Merge aborted in branch workspace "${branchName}". Workspace restored to pre-merge state.`);
18832
19078
  }
18833
- async function sync(options) {
18834
- p2.intro("CanopyCMS sync");
18835
- let pushed = 0;
18836
- let pulled = 0;
18837
- if (options.direction === "abort") {
18838
- await syncAbort(options);
18839
- } else if (options.direction === "both") {
18840
- const result = await syncBoth(options);
18841
- pushed = result.pushed;
18842
- pulled = result.pulled;
18843
- } else if (options.direction === "push") {
18844
- const result = await syncPush(options);
18845
- pushed = result.fileCount;
18846
- } else {
18847
- const result = await syncPull(options);
18848
- pulled = result.fileCount;
19079
+ async function loadCollectionMetaFiles2(contentRoot) {
19080
+ let root = null;
19081
+ const rootMetaPath = join3(contentRoot, ".collection.json");
19082
+ try {
19083
+ await fs35.access(rootMetaPath);
19084
+ } catch (err2) {
19085
+ if (err2.code === "ENOENT") {
19086
+ } else {
19087
+ throw err2;
19088
+ }
18849
19089
  }
18850
- p2.outro("Done!");
18851
- return { pushed, pulled };
19090
+ try {
19091
+ const content = await fs35.readFile(rootMetaPath, "utf-8");
19092
+ const parsed = JSON.parse(content);
19093
+ root = rootCollectionMetaSchema2.parse(parsed);
19094
+ } catch (err2) {
19095
+ const errno = err2.code;
19096
+ if (errno !== "ENOENT") {
19097
+ throw new Error(`Invalid root .collection.json`);
19098
+ }
19099
+ }
19100
+ const collections = await scanForCollectionMeta2(contentRoot);
19101
+ return { root, collections };
18852
19102
  }
18853
- var SyncError;
18854
- var init_sync = __esm({
18855
- "dist/cli/sync.js"() {
19103
+ var entryTypeMetaSchema2, collectionMetaSchema2, rootCollectionMetaSchema2;
19104
+ var init_meta_loader = __esm({
19105
+ "dist/schema/meta-loader.js"() {
18856
19106
  "use strict";
18857
- init_fs();
18858
- init_git();
18859
- init_sync_core();
18860
- init_content_index_generation();
18861
- init_content_write_lock();
18862
- init_git_manager();
18863
- init_operating_mode2();
18864
- SyncError = class extends Error {
18865
- constructor(message) {
18866
- super(message);
18867
- this.name = "SyncError";
18868
- }
18869
- };
19107
+ init_logger3();
19108
+ init_content_id_index();
19109
+ entryTypeMetaSchema2 = z18.object({
19110
+ name: z18.string().min(1),
19111
+ format: z18.enum(["md", "mdx", "json", "yaml"]),
19112
+ schema: z18.string().min(1),
19113
+ // Entry schema registry key (validated at resolution time)
19114
+ label: z18.string().optional(),
19115
+ description: z18.string().optional(),
19116
+ default: z18.boolean().optional(),
19117
+ maxItems: z18.number().int().positive().optional()
19118
+ });
19119
+ collectionMetaSchema2 = z18.object({
19120
+ name: z18.string().min(1),
19121
+ label: z18.string().optional(),
19122
+ description: z18.string().optional(),
19123
+ entries: z18.array(entryTypeMetaSchema2).optional(),
19124
+ order: z18.array(z18.string()).default([])
19125
+ // Embedded IDs for ordering; optional in the file, [] = alphabetical
19126
+ }).refine((data) => data.entries && data.entries.length > 0, {
19127
+ message: "Collection must have at least one entry type"
19128
+ });
19129
+ rootCollectionMetaSchema2 = z18.object({
19130
+ label: z18.string().optional(),
19131
+ entries: z18.array(entryTypeMetaSchema2).optional(),
19132
+ order: z18.array(z18.string()).optional()
19133
+ // Embedded IDs for ordering items
19134
+ });
18870
19135
  }
18871
19136
  });
18872
19137
 
18873
- // dist/paths/index.js
18874
- var init_paths = __esm({
18875
- "dist/paths/index.js"() {
19138
+ // dist/branch-schema-cache.js
19139
+ import { LRUCache as LRUCache2 } from "lru-cache";
19140
+ var SNAPSHOT_MEMO_MAX_BYTES2, SCHEMA_GENERATION_RESOURCE2;
19141
+ var init_branch_schema_cache = __esm({
19142
+ "dist/branch-schema-cache.js"() {
18876
19143
  "use strict";
18877
- init_validation3();
19144
+ SNAPSHOT_MEMO_MAX_BYTES2 = 64 * 1024 * 1024;
19145
+ SCHEMA_GENERATION_RESOURCE2 = "schema";
18878
19146
  }
18879
19147
  });
18880
19148
 
18881
- // dist/schema/index.js
18882
- var init_schema = __esm({
18883
- "dist/schema/index.js"() {
19149
+ // dist/content-index-generation.js
19150
+ async function bumpContentIndexGeneration2(root) {
19151
+ return bumpResourceGeneration2(root, RESOURCE4);
19152
+ }
19153
+ async function invalidateBranchContentCaches2(root) {
19154
+ await bumpContentIndexGeneration2(root);
19155
+ await bumpResourceGeneration2(root, SCHEMA_GENERATION_RESOURCE2);
19156
+ invalidateContentIndexesForRoot2(root);
19157
+ }
19158
+ var RESOURCE4;
19159
+ var init_content_index_generation = __esm({
19160
+ "dist/content-index-generation.js"() {
18884
19161
  "use strict";
18885
- init_meta_loader();
19162
+ init_resource_generation();
19163
+ init_content_index_registry();
19164
+ init_branch_schema_cache();
19165
+ RESOURCE4 = "content-index";
18886
19166
  }
18887
19167
  });
18888
19168
 
18889
- // dist/utils/occ-json-write.js
18890
- import fs35 from "node:fs/promises";
18891
- import path41 from "node:path";
18892
- import { randomUUID as randomUUID6 } from "node:crypto";
18893
- import lockfile4 from "proper-lockfile";
18894
- async function writeOccJsonFile2(filePath, payload, options) {
18895
- const { expectedVersion, settleMs = 50, trailingNewline = false } = options;
18896
- const newVersion = expectedVersion === null ? 1 : expectedVersion + 1;
18897
- const writeId = randomUUID6();
18898
- const data = { ...payload, version: newVersion, writeId };
18899
- const content = JSON.stringify(data, null, 2) + (trailingNewline ? "\n" : "");
18900
- const dir = path41.dirname(filePath);
18901
- await fs35.mkdir(dir, { recursive: true });
18902
- const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
18903
- await fs35.writeFile(tempPath, content, "utf-8");
18904
- if (expectedVersion === null) {
19169
+ // dist/utils/content-write-lock.js
19170
+ import { AsyncLocalStorage as AsyncLocalStorage3 } from "node:async_hooks";
19171
+ import path44 from "node:path";
19172
+ function lockTargetDir2(branchRoot) {
19173
+ return path44.join(path44.resolve(branchRoot), META_DIR4);
19174
+ }
19175
+ function tryAcquireContentWriteLock2(branchRoot, onCompromised) {
19176
+ return tryAcquireProvisioningLock2(lockTargetDir2(branchRoot), CONTENT_WRITE_LOCK_NAME2, onCompromised, CONTENT_WRITE_LOCK_STALE_MS2, false);
19177
+ }
19178
+ async function acquireContentWriteLock2(branchRoot, waitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2, onCompromised) {
19179
+ const startedAt = Date.now();
19180
+ for (let attempt = 0; ; attempt++) {
18905
19181
  try {
18906
- await fs35.link(tempPath, filePath);
19182
+ return await tryAcquireContentWriteLock2(branchRoot, onCompromised);
18907
19183
  } catch (err2) {
18908
- await fs35.unlink(tempPath).catch(() => {
18909
- });
18910
- if (isFileExistsError(err2)) {
18911
- throw new OccWriteConflictError2();
18912
- }
18913
- throw err2;
19184
+ if (!isNodeError(err2) || err2.code !== "ELOCKED")
19185
+ throw err2;
19186
+ const remaining = waitMs - (Date.now() - startedAt);
19187
+ if (remaining <= 0)
19188
+ throw new ContentWriteLockBusyError2();
19189
+ const base = Math.min(40 * 2 ** attempt, 250);
19190
+ await sleep3(Math.min(base * (0.5 + Math.random()), remaining));
18914
19191
  }
18915
- await fs35.unlink(tempPath).catch(() => {
19192
+ }
19193
+ }
19194
+ async function withContentWriteLock2(branchRoot, fn, waitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2) {
19195
+ let compromised = false;
19196
+ const release = await acquireContentWriteLock2(branchRoot, waitMs, (err2) => {
19197
+ compromised = true;
19198
+ canopyLogWarn3(`[canopy] Content-write lock compromised mid-hold for ${branchRoot}:`, getErrorMessage(err2));
19199
+ });
19200
+ let result;
19201
+ try {
19202
+ const held = new Set(heldRoots2.getStore());
19203
+ held.add(path44.resolve(branchRoot));
19204
+ result = await heldRoots2.run(held, fn);
19205
+ } finally {
19206
+ await release();
19207
+ }
19208
+ if (compromised) {
19209
+ throw new ContentWriteLockBusyError2("We couldn't confirm your change was saved because the branch was syncing at the same time. Reload to check before saving again (your unsaved edits will be lost).", "unknown");
19210
+ }
19211
+ return result;
19212
+ }
19213
+ var META_DIR4, CONTENT_WRITE_LOCK_NAME2, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2, ContentWriteLockBusyError2, sleep3, CONTENT_WRITE_LOCK_STALE_MS2, heldRoots2;
19214
+ var init_content_write_lock = __esm({
19215
+ "dist/utils/content-write-lock.js"() {
19216
+ "use strict";
19217
+ init_error();
19218
+ init_logger3();
19219
+ init_provisioning_lock();
19220
+ META_DIR4 = ".canopy-meta";
19221
+ CONTENT_WRITE_LOCK_NAME2 = "content-write.lock";
19222
+ DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2 = 2e3;
19223
+ ContentWriteLockBusyError2 = class extends Error {
19224
+ constructor(message = "This branch is busy syncing or saving another change, so your change was not saved. Try again in a moment.", outcome = "not-run") {
19225
+ super(message);
19226
+ this.outcome = outcome;
19227
+ this.name = "ContentWriteLockBusyError";
19228
+ }
19229
+ };
19230
+ sleep3 = (ms) => new Promise((resolve2) => {
19231
+ setTimeout(resolve2, ms);
18916
19232
  });
18917
- return { version: newVersion, writeId };
19233
+ CONTENT_WRITE_LOCK_STALE_MS2 = 3e4;
19234
+ heldRoots2 = new AsyncLocalStorage3();
19235
+ }
19236
+ });
19237
+
19238
+ // dist/sync-core.js
19239
+ import fs36 from "node:fs/promises";
19240
+ import path45 from "node:path";
19241
+ import { simpleGit as simpleGit5 } from "simple-git";
19242
+ function assertWithinDir(resolved, parent, label) {
19243
+ const normalizedResolved = path45.resolve(resolved);
19244
+ const normalizedParent = path45.resolve(parent);
19245
+ if (!normalizedResolved.startsWith(normalizedParent + path45.sep) && normalizedResolved !== normalizedParent) {
19246
+ throw new Error(`${label} escapes the expected directory: ${resolved}`);
19247
+ }
19248
+ }
19249
+ async function safeReplaceDir(oldDir, newDir) {
19250
+ const backupDir = `${oldDir}.sync-backup-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
19251
+ const oldExists = await filePathExists(oldDir);
19252
+ if (oldExists) {
19253
+ await fs36.rename(oldDir, backupDir);
18918
19254
  }
18919
19255
  try {
18920
- let currentVersion = null;
18921
- try {
18922
- const current = JSON.parse(await fs35.readFile(filePath, "utf-8"));
18923
- currentVersion = current.version ?? 0;
18924
- } catch {
18925
- currentVersion = null;
18926
- }
18927
- if (currentVersion !== expectedVersion) {
18928
- throw new OccWriteConflictError2();
18929
- }
18930
- await fs35.rename(tempPath, filePath);
18931
- if (settleMs > 0) {
18932
- await new Promise((resolve2) => setTimeout(resolve2, settleMs));
18933
- }
18934
- const afterWrite = JSON.parse(await fs35.readFile(filePath, "utf-8"));
18935
- if (afterWrite.writeId !== writeId) {
18936
- throw new OccWriteConflictError2();
18937
- }
19256
+ await fs36.rename(newDir, oldDir);
18938
19257
  } catch (err2) {
18939
- await fs35.unlink(tempPath).catch(() => {
18940
- });
19258
+ if (oldExists) {
19259
+ await fs36.rename(backupDir, oldDir).catch(() => {
19260
+ });
19261
+ }
18941
19262
  throw err2;
18942
19263
  }
18943
- return { version: newVersion, writeId };
19264
+ if (oldExists) {
19265
+ await fs36.rm(backupDir, { recursive: true, force: true }).catch(() => {
19266
+ });
19267
+ }
18944
19268
  }
18945
- async function withOccRetry2(operation, options) {
18946
- const maxAttempts = options?.maxAttempts ?? 10;
18947
- const isRetryable = options?.isRetryable;
18948
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
18949
- try {
18950
- return await operation();
18951
- } catch (err2) {
18952
- const retryable = err2 instanceof OccWriteConflictError2 || (isRetryable?.(err2) ?? false);
18953
- if (retryable && attempt < maxAttempts) {
18954
- const baseDelay = Math.min(10 * Math.pow(2, attempt - 1), 100);
18955
- const jitter = Math.random() * baseDelay;
18956
- await new Promise((resolve2) => setTimeout(resolve2, baseDelay + jitter));
18957
- continue;
18958
- }
18959
- throw err2;
19269
+ async function listFilesRecursive(dir, prefix = "") {
19270
+ const results = [];
19271
+ let entries;
19272
+ try {
19273
+ entries = await fs36.readdir(dir, { withFileTypes: true });
19274
+ } catch {
19275
+ return results;
19276
+ }
19277
+ for (const entry of entries) {
19278
+ if (entry.name === ".git")
19279
+ continue;
19280
+ if (entry.isSymbolicLink())
19281
+ continue;
19282
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
19283
+ if (entry.isDirectory()) {
19284
+ results.push(...await listFilesRecursive(path45.join(dir, entry.name), rel));
19285
+ } else {
19286
+ results.push(rel);
19287
+ }
19288
+ }
19289
+ return results;
19290
+ }
19291
+ async function copyDir(src, dest) {
19292
+ await fs36.mkdir(dest, { recursive: true });
19293
+ const entries = await fs36.readdir(src, { withFileTypes: true });
19294
+ for (const entry of entries) {
19295
+ if (entry.name === ".git")
19296
+ continue;
19297
+ if (entry.isSymbolicLink())
19298
+ continue;
19299
+ const srcPath = path45.join(src, entry.name);
19300
+ const destPath = path45.join(dest, entry.name);
19301
+ if (entry.isDirectory()) {
19302
+ await copyDir(srcPath, destPath);
19303
+ } else {
19304
+ await fs36.copyFile(srcPath, destPath);
18960
19305
  }
18961
19306
  }
18962
- throw new Error("Unreachable");
18963
19307
  }
18964
- async function withOccFileLock2(filePath, fn) {
18965
- try {
18966
- await fs35.mkdir(path41.dirname(filePath));
18967
- } catch (err2) {
18968
- if (!isNodeError(err2) || err2.code !== "EEXIST") {
18969
- throw new OccWriteConflictError2(`Could not acquire file lock: ${getErrorMessage(err2)}`);
18970
- }
19308
+ async function pushContentToWorkspace(options) {
19309
+ const { srcContentDir, branchPath, contentRoot } = options;
19310
+ if (!await filePathExists(srcContentDir)) {
19311
+ return { fileCount: 0, savedEditorState: false };
18971
19312
  }
18972
- const MAX_ATTEMPTS = 14;
18973
- let release = null;
18974
- for (let attempt = 1; release === null; attempt++) {
18975
- try {
18976
- release = await lockfile4.lock(filePath, {
18977
- lockfilePath: `${filePath}.lock`,
18978
- realpath: false,
18979
- stale: 1e4,
18980
- retries: 0,
18981
- // A compromised lock (the lock dir vanished or refresh failed mid-hold, e.g. the branch
18982
- // directory containing it was deleted, or an EFS waiter misread a cached mtime and took
18983
- // it over) must not crash the process, which is proper-lockfile's default. Our critical
18984
- // sections are short and idempotent-on-conflict (OCC verify inside); log and let the
18985
- // section finish.
18986
- onCompromised: guardOnCompromised2((err2) => canopyLogWarn3(`[canopy] OCC file lock compromised mid-hold for ${filePath}:`, getErrorMessage(err2)), filePath)
18987
- });
18988
- } catch (err2) {
18989
- const code = isNodeError(err2) ? err2.code : void 0;
18990
- const contended = code === "ELOCKED";
18991
- if (!contended || attempt >= MAX_ATTEMPTS) {
18992
- throw new OccWriteConflictError2(`Could not acquire file lock: ${getErrorMessage(err2)}`);
18993
- }
18994
- const baseDelay = Math.min(50 * Math.pow(1.6, attempt - 1), 2e3);
18995
- const jitter = Math.random() * baseDelay;
18996
- await new Promise((resolve2) => setTimeout(resolve2, baseDelay + jitter));
19313
+ const wsContentDir = path45.join(branchPath, contentRoot);
19314
+ assertWithinDir(wsContentDir, branchPath, "content-root");
19315
+ return withContentWriteLock2(branchPath, () => replaceWorkspaceContent(options, wsContentDir));
19316
+ }
19317
+ async function replaceWorkspaceContent(options, wsContentDir) {
19318
+ const { srcContentDir, branchPath, commitMessage, baseTag, saveEditorStateMessage } = options;
19319
+ const wsGit = simpleGit5({ baseDir: branchPath });
19320
+ let savedEditorState = false;
19321
+ if (saveEditorStateMessage) {
19322
+ const editorChanges = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
19323
+ if (editorChanges.length > 0) {
19324
+ await stageAllExceptCanopyState2(wsGit);
19325
+ await wsGit.commit(saveEditorStateMessage);
19326
+ savedEditorState = true;
18997
19327
  }
18998
19328
  }
19329
+ const tmpDir = `${wsContentDir}.sync-tmp-${Date.now()}`;
18999
19330
  try {
19000
- return await fn();
19001
- } finally {
19002
- await release().catch((err2) => {
19003
- if (isNodeError(err2) && err2.code === "ENOENT")
19004
- return;
19005
- log14.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage(err2) });
19331
+ await copyDir(srcContentDir, tmpDir);
19332
+ await safeReplaceDir(wsContentDir, tmpDir);
19333
+ } catch (err2) {
19334
+ await fs36.rm(tmpDir, { recursive: true, force: true }).catch(() => {
19006
19335
  });
19336
+ throw err2;
19007
19337
  }
19008
- }
19009
- var log14, OccWriteConflictError2;
19010
- var init_occ_json_write = __esm({
19011
- "dist/utils/occ-json-write.js"() {
19012
- "use strict";
19013
- init_error();
19014
- init_debug2();
19015
- init_logger3();
19016
- init_provisioning_lock();
19017
- log14 = createDebugLogger3({ prefix: "OccJsonWrite" });
19018
- OccWriteConflictError2 = class extends Error {
19019
- constructor(message = "Concurrent modification detected") {
19020
- super(message);
19021
- this.name = "OccWriteConflictError";
19022
- }
19023
- };
19024
- }
19025
- });
19026
-
19027
- // dist/utils/async-mutex.js
19028
- async function withLock2(key, fn) {
19029
- const prev = locks2.get(key) ?? Promise.resolve();
19030
- let resolve2;
19031
- const next = new Promise((r) => {
19032
- resolve2 = r;
19033
- });
19034
- locks2.set(key, next);
19035
- await prev;
19036
19338
  try {
19037
- return await fn();
19339
+ await stageAllExceptCanopyState2(wsGit);
19340
+ const staged = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
19341
+ if (staged.length === 0) {
19342
+ if (baseTag)
19343
+ await wsGit.tag(["-f", baseTag]);
19344
+ return { fileCount: 0, savedEditorState };
19345
+ }
19346
+ await wsGit.commit(commitMessage ?? "sync: update content from working tree");
19347
+ if (baseTag)
19348
+ await wsGit.tag(["-f", baseTag]);
19349
+ return { fileCount: staged.length, savedEditorState };
19038
19350
  } finally {
19039
- resolve2();
19040
- if (locks2.get(key) === next)
19041
- locks2.delete(key);
19351
+ await invalidateBranchContentCaches2(branchPath);
19042
19352
  }
19043
19353
  }
19044
- var locks2;
19045
- var init_async_mutex = __esm({
19046
- "dist/utils/async-mutex.js"() {
19354
+ var SYNC_BASE_TAG;
19355
+ var init_sync_core = __esm({
19356
+ "dist/sync-core.js"() {
19047
19357
  "use strict";
19048
- locks2 = /* @__PURE__ */ new Map();
19358
+ init_content_index_generation();
19359
+ init_content_write_lock();
19360
+ init_fs();
19361
+ init_git();
19362
+ SYNC_BASE_TAG = "canopycms-sync-base";
19049
19363
  }
19050
19364
  });
19051
19365
 
19052
- // dist/schema/schema-store.js
19053
- import path43 from "node:path";
19054
- import { z as z17 } from "zod";
19055
- async function withBranchSchemaLock(branchRoot, fn, contentWriteLockWaitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2) {
19056
- const schemaLockPath = path43.join(path43.resolve(branchRoot), ".canopy-meta", "schema");
19057
- return withContentWriteLock2(branchRoot, () => withLock2(schemaLockPath, () => withOccFileLock2(schemaLockPath, fn)), contentWriteLockWaitMs);
19366
+ // dist/git-manager.js
19367
+ import fs37 from "node:fs/promises";
19368
+ import path46 from "node:path";
19369
+ import { simpleGit as simpleGit6 } from "simple-git";
19370
+ async function ensureGitExcludePattern2(repoPath, pattern) {
19371
+ const excludePath = path46.join(repoPath, ".git", "info", "exclude");
19372
+ await fs37.mkdir(path46.dirname(excludePath), { recursive: true });
19373
+ let content = "";
19374
+ try {
19375
+ content = await fs37.readFile(excludePath, "utf-8");
19376
+ } catch (err2) {
19377
+ if (!isNotFoundError(err2))
19378
+ throw err2;
19379
+ }
19380
+ const lines = content.split("\n");
19381
+ if (lines.some((line) => line.trim() === pattern)) {
19382
+ log14.debug("git", "Pattern already in .git/info/exclude", { pattern });
19383
+ return;
19384
+ }
19385
+ const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
19386
+ const newContent = content + (needsLeadingNewline ? "\n" : "") + pattern + "\n";
19387
+ await fs37.writeFile(excludePath, newContent, "utf-8");
19388
+ log14.debug("git", "Added pattern to .git/info/exclude", { pattern });
19058
19389
  }
19059
- var MAX_NAME_LENGTH, MAX_LABEL_LENGTH, SAFE_NAME_PATTERN, SAFE_NAME_MESSAGE, entryTypeInputSchema, createCollectionInputSchema, updateCollectionInputSchema, updateEntryTypeInputSchema, log15;
19060
- var init_schema_store = __esm({
19061
- "dist/schema/schema-store.js"() {
19390
+ var log14;
19391
+ var init_git_manager = __esm({
19392
+ "dist/git-manager.js"() {
19062
19393
  "use strict";
19063
- init_async_mutex();
19064
19394
  init_debug2();
19065
- init_content_write_lock();
19066
- init_occ_json_write();
19067
- MAX_NAME_LENGTH = 64;
19068
- MAX_LABEL_LENGTH = 128;
19069
- SAFE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
19070
- SAFE_NAME_MESSAGE = "must start with a letter and contain only lowercase letters, numbers, and hyphens";
19071
- entryTypeInputSchema = z17.object({
19072
- name: z17.string().min(1).max(MAX_NAME_LENGTH).regex(SAFE_NAME_PATTERN, `Entry type name ${SAFE_NAME_MESSAGE}`),
19073
- label: z17.string().max(MAX_LABEL_LENGTH).optional(),
19074
- format: z17.enum(["md", "mdx", "json", "yaml"]),
19075
- schema: z17.string().min(1),
19076
- default: z17.boolean().optional(),
19077
- maxItems: z17.number().int().positive().optional()
19078
- });
19079
- createCollectionInputSchema = z17.object({
19080
- name: z17.string().min(1).max(MAX_NAME_LENGTH).regex(SAFE_NAME_PATTERN, `Collection name ${SAFE_NAME_MESSAGE}`),
19081
- label: z17.string().max(MAX_LABEL_LENGTH).optional(),
19082
- parentPath: z17.string().optional(),
19083
- entries: z17.array(entryTypeInputSchema).min(1, "Collection must have at least one entry type")
19084
- });
19085
- updateCollectionInputSchema = z17.object({
19086
- name: z17.string().min(1).max(MAX_NAME_LENGTH).regex(SAFE_NAME_PATTERN, `Collection name ${SAFE_NAME_MESSAGE}`).optional(),
19087
- label: z17.string().max(MAX_LABEL_LENGTH).optional(),
19088
- // Directory name (e.g., "posts" in "posts.{id}/")
19089
- slug: z17.string().min(1).max(MAX_NAME_LENGTH).regex(SAFE_NAME_PATTERN, `Slug ${SAFE_NAME_MESSAGE}`).optional(),
19090
- order: z17.array(z17.string()).optional()
19091
- });
19092
- updateEntryTypeInputSchema = z17.object({
19093
- label: z17.string().max(MAX_LABEL_LENGTH).optional(),
19094
- format: z17.enum(["md", "mdx", "json", "yaml"]).optional(),
19095
- schema: z17.string().min(1).optional(),
19096
- default: z17.boolean().optional(),
19097
- maxItems: z17.number().int().positive().optional()
19098
- });
19099
- log15 = createDebugLogger3({ prefix: "SchemaOps" });
19395
+ init_error();
19396
+ log14 = createDebugLogger3({ prefix: "GitManager" });
19100
19397
  }
19101
19398
  });
19102
19399
 
19103
- // dist/branch-metadata-error.js
19104
- var BranchMetadataCorruptError2;
19105
- var init_branch_metadata_error = __esm({
19106
- "dist/branch-metadata-error.js"() {
19107
- "use strict";
19108
- BranchMetadataCorruptError2 = class extends Error {
19109
- constructor(branchRoot, cause) {
19110
- super(`Corrupt branch metadata in '${branchRoot}': ${cause}`);
19111
- this.name = "BranchMetadataCorruptError";
19112
- this.branchRoot = branchRoot;
19113
- this.parseCause = cause;
19114
- }
19115
- };
19116
- }
19400
+ // dist/cli/sync.js
19401
+ var sync_exports = {};
19402
+ __export(sync_exports, {
19403
+ sync: () => sync
19117
19404
  });
19118
-
19119
- // dist/branch-metadata-file.js
19120
- import fs36 from "node:fs/promises";
19121
- import path44 from "node:path";
19122
- import { z as z18 } from "zod";
19123
- function describeSchemaIssues2(issues) {
19124
- const missing = /* @__PURE__ */ new Set();
19125
- const invalid = /* @__PURE__ */ new Set();
19126
- for (const issue of issues) {
19127
- const field = issue.path.length > 0 ? issue.path.join(".") : "the file";
19128
- const isMissing = issue.code === z18.ZodIssueCode.invalid_type && issue.received === z18.ZodParsedType.undefined;
19129
- (isMissing ? missing : invalid).add(field);
19405
+ import fs38 from "node:fs/promises";
19406
+ import path47 from "node:path";
19407
+ import { simpleGit as simpleGit7 } from "simple-git";
19408
+ import * as p2 from "@clack/prompts";
19409
+ import { z as z19 } from "zod";
19410
+ function lockBusyAsSyncError(err2, branchName) {
19411
+ if (!(err2 instanceof ContentWriteLockBusyError2))
19412
+ return err2;
19413
+ return new SyncError(err2.outcome === "not-run" ? `Branch workspace "${branchName}" is busy (the worker is syncing it, or an editor save is in flight); nothing was changed. Try again in a moment.` : `Branch workspace "${branchName}" was being synced during this run, so it may be partly updated. Check its state before running sync again.`);
19414
+ }
19415
+ async function withWorkspaceLock(branchPath, branchName, fn) {
19416
+ try {
19417
+ return await withContentWriteLock2(branchPath, fn);
19418
+ } catch (err2) {
19419
+ throw lockBusyAsSyncError(err2, branchName);
19130
19420
  }
19131
- const parts = [];
19132
- if (missing.size > 0)
19133
- parts.push(`Missing: ${[...missing].join(", ")}`);
19134
- if (invalid.size > 0)
19135
- parts.push(`Invalid: ${[...invalid].join(", ")}`);
19136
- return `Not branch metadata. ${parts.join(". ")}`;
19137
19421
  }
19138
- async function readBranchMetadataFile2(branchRoot) {
19139
- const resolvedRoot = path44.resolve(branchRoot);
19140
- let raw;
19422
+ async function abortWorkspaceMerge(branchPath, branchName) {
19423
+ return withWorkspaceLock(branchPath, branchName, async () => {
19424
+ const wsGit = simpleGit7({ baseDir: branchPath });
19425
+ if ((await wsGit.status()).conflicted.length === 0)
19426
+ return false;
19427
+ await wsGit.merge(["--abort"]);
19428
+ await invalidateBranchContentCaches2(branchPath);
19429
+ return true;
19430
+ });
19431
+ }
19432
+ async function detectCurrentBranch(projectDir) {
19433
+ const git = simpleGit7({ baseDir: projectDir });
19434
+ const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
19435
+ if (branch === "HEAD")
19436
+ throw new Error("Detached HEAD \u2014 use --branch to specify a branch name");
19437
+ return branch;
19438
+ }
19439
+ async function readBranchConfigFields(projectDir) {
19440
+ if (!await filePathExists(path47.join(projectDir, "canopycms.config.ts")))
19441
+ return {};
19442
+ let why;
19141
19443
  try {
19142
- raw = await fs36.readFile(branchMetadataFilePath2(resolvedRoot), "utf8");
19444
+ const parsed = branchConfigSchema.safeParse((await importSiteServerConfig(projectDir)).server);
19445
+ if (parsed.success)
19446
+ return parsed.data;
19447
+ why = parsed.error.message;
19143
19448
  } catch (err2) {
19144
- if (isNotFoundError(err2)) {
19449
+ why = getErrorMessage(err2);
19450
+ }
19451
+ p2.log.warn(`Could not use canopycms.config.ts (${why}); branch names resolve against the default branch settings.`);
19452
+ return {};
19453
+ }
19454
+ async function loadBranchParamConfig(projectDir) {
19455
+ const fields = await readBranchConfigFields(projectDir);
19456
+ const head = await detectHeadBranch2(projectDir, fields.defaultBaseBranch ?? "main");
19457
+ return {
19458
+ ...fields,
19459
+ mode: "dev",
19460
+ defaultBaseBranch: fields.defaultBaseBranch ?? head,
19461
+ defaultActiveBranch: fields.defaultActiveBranch ?? head
19462
+ };
19463
+ }
19464
+ async function assertOwnRepository(branchPath) {
19465
+ const top = await simpleGit7({ baseDir: branchPath }).revparse(["--show-toplevel"]).then((out) => out.trim()).catch(() => "");
19466
+ if (top && await fs38.realpath(top) === await fs38.realpath(branchPath))
19467
+ return;
19468
+ throw new SyncError(`"${path47.basename(branchPath)}" under content-branches/ is not a branch workspace: git does not report it as the top of its own repository.`);
19469
+ }
19470
+ async function workspaceHead(branchPath) {
19471
+ return simpleGit7({ baseDir: branchPath }).raw(["symbolic-ref", "--short", "-q", "HEAD"]).then((out) => out.trim() || null).catch(() => null);
19472
+ }
19473
+ async function keyWorkspace(key, projectDir) {
19474
+ const branchPath = path47.join(operatingStrategy3("dev").getContentBranchesRoot(projectDir), key);
19475
+ await assertOwnRepository(branchPath);
19476
+ const meta = await readBranchMetadataFile2(branchPath);
19477
+ return { branchName: meta?.branch.name ?? await workspaceHead(branchPath) ?? key, branchPath };
19478
+ }
19479
+ function refOf(name) {
19480
+ if (!isExactBranchForm(name))
19481
+ return name;
19482
+ const ref = exactBranchName(name);
19483
+ if (ref === null)
19484
+ throw new SyncError(`"${name}" is not a valid branch name.`);
19485
+ return ref;
19486
+ }
19487
+ async function resolveSyncWorkspace(name, projectDir, keys, byRef) {
19488
+ const config = await loadBranchParamConfig(projectDir);
19489
+ const param = byRef ? exactBranchSegment(name) : name;
19490
+ const resolved = await resolveBranchParam(param, config, { basePathOverride: projectDir }).catch((err2) => {
19491
+ if (namesNoWorkspace(err2))
19492
+ return null;
19493
+ throw err2;
19494
+ });
19495
+ if (resolved) {
19496
+ const ref2 = refOf(name);
19497
+ if (resolved.branch.name !== ref2 && resolved.branchRoot === refKeyPath(ref2, projectDir)) {
19498
+ throw holdsAnotherBranch(resolved.branchRoot, resolved.branch.name, ref2);
19499
+ }
19500
+ await assertOwnRepository(resolved.branchRoot);
19501
+ return { branchName: resolved.branch.name, branchPath: resolved.branchRoot };
19502
+ }
19503
+ if (!byRef && keys.includes(name))
19504
+ return keyWorkspace(name, projectDir);
19505
+ const ref = refOf(name);
19506
+ const branchPath = refKeyPath(ref, projectDir);
19507
+ const present = await filePathExists(branchPath).catch((err2) => {
19508
+ if (namesNoWorkspace(err2))
19509
+ throw new SyncError(`"${ref}" cannot name a branch workspace.`);
19510
+ throw err2;
19511
+ });
19512
+ if (!present)
19513
+ return null;
19514
+ await assertOwnRepository(branchPath);
19515
+ const meta = await readBranchMetadataFile2(branchPath);
19516
+ const holder = meta ? meta.branch.name : await workspaceHead(branchPath);
19517
+ if (holder !== ref)
19518
+ throw holdsAnotherBranch(branchPath, holder, ref);
19519
+ return { branchName: ref, branchPath };
19520
+ }
19521
+ function holdsAnotherBranch(branchPath, holder, ref) {
19522
+ const recovery = holder?.startsWith("sync-incoming-") ? ` An interrupted "sync both" left it there: run "git checkout ${ref}" in that workspace.` : "";
19523
+ return new SyncError(`Branch workspace "${path47.basename(branchPath)}" holds ${holder === null ? "a detached HEAD" : `branch "${holder}"`}, not "${ref}".${recovery}`);
19524
+ }
19525
+ function refKeyPath(name, projectDir) {
19526
+ try {
19527
+ return resolveBranchPath2({ mode: "dev", branchName: name, basePathOverride: projectDir }).branchRoot;
19528
+ } catch (err2) {
19529
+ if (namesNoWorkspace(err2))
19530
+ throw new SyncError(`"${name}" cannot name a branch workspace.`);
19531
+ throw err2;
19532
+ }
19533
+ }
19534
+ async function createSyncWorkspace(name, projectDir) {
19535
+ const ref = refOf(name);
19536
+ const parsed = parseBranchRef3(ref);
19537
+ if (!parsed.ok)
19538
+ throw new SyncError(`"${ref}" is not a valid branch name: ${parsed.error}`);
19539
+ const branchPath = refKeyPath(ref, projectDir);
19540
+ await fs38.mkdir(branchPath, { recursive: true });
19541
+ const wsGit = simpleGit7({ baseDir: branchPath });
19542
+ await wsGit.init();
19543
+ await wsGit.checkoutLocalBranch(ref);
19544
+ await wsGit.raw(["commit", "--allow-empty", "-m", "init: workspace created by sync"]);
19545
+ await ensureGitExcludePattern2(branchPath, operatingStrategy3("dev").getGitExcludePattern());
19546
+ p2.log.info(`Created branch workspace: ${path47.basename(branchPath)}`);
19547
+ return { branchName: ref, branchPath };
19548
+ }
19549
+ async function selectBranch(options) {
19550
+ const branchesDir = operatingStrategy3("dev").getContentBranchesRoot(options.projectDir);
19551
+ let branches = [];
19552
+ if (await filePathExists(branchesDir)) {
19553
+ const entries = await fs38.readdir(branchesDir, { withFileTypes: true });
19554
+ branches = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
19555
+ }
19556
+ let branchName = options.branch;
19557
+ const byRef = !branchName;
19558
+ if (!branchName) {
19559
+ try {
19560
+ branchName = await detectCurrentBranch(options.projectDir);
19561
+ } catch {
19562
+ }
19563
+ }
19564
+ if (!branchName) {
19565
+ if (branches.length === 0) {
19566
+ throw new SyncError("No branch workspaces found and could not detect current git branch. Use --branch to specify.");
19567
+ }
19568
+ if (branches.length === 1)
19569
+ return keyWorkspace(branches[0], options.projectDir);
19570
+ const result = await p2.select({
19571
+ message: "Which branch?",
19572
+ options: branches.map((b) => ({ value: b, label: b }))
19573
+ });
19574
+ if (p2.isCancel(result)) {
19575
+ p2.cancel("Sync cancelled.");
19145
19576
  return null;
19146
19577
  }
19147
- throw err2;
19578
+ return keyWorkspace(result, options.projectDir);
19148
19579
  }
19149
- return parseBranchMetadataFile2(resolvedRoot, raw);
19580
+ const existing = await resolveSyncWorkspace(branchName, options.projectDir, branches, byRef);
19581
+ if (existing)
19582
+ return existing;
19583
+ if (options.autoCreate)
19584
+ return createSyncWorkspace(branchName, options.projectDir);
19585
+ const available = branches.length > 0 ? ` Available branches: ${branches.join(", ")}` : "";
19586
+ throw new SyncError(`Branch workspace "${branchName}" not found.${available}`);
19150
19587
  }
19151
- function parseBranchMetadataFile2(branchRoot, raw) {
19152
- let json;
19588
+ async function syncPush(options) {
19589
+ const { projectDir } = options;
19590
+ const contentRoot = options.contentRoot || "content";
19591
+ const srcContentDir = path47.join(projectDir, contentRoot);
19592
+ assertWithinDir(srcContentDir, projectDir, "--content-root");
19593
+ if (!await filePathExists(srcContentDir)) {
19594
+ throw new SyncError(`Content directory not found: ${contentRoot}/ (expected at ${srcContentDir})`);
19595
+ }
19596
+ const ws = await selectBranch({ ...options, projectDir, autoCreate: true });
19597
+ if (!ws)
19598
+ return { fileCount: 0 };
19599
+ const { branchName, branchPath } = ws;
19600
+ const wsGit = simpleGit7({ baseDir: branchPath });
19601
+ const status = await wsGit.status();
19602
+ if (status.conflicted.length > 0) {
19603
+ p2.log.error("Branch workspace has unresolved merge conflicts.");
19604
+ if (!options.force) {
19605
+ const shouldAbort = await p2.confirm({
19606
+ message: "Abort the merge and restore workspace to pre-merge state?",
19607
+ initialValue: false
19608
+ });
19609
+ if (!p2.isCancel(shouldAbort) && shouldAbort) {
19610
+ if (await abortWorkspaceMerge(branchPath, branchName)) {
19611
+ p2.log.success("Merge aborted. Workspace restored to pre-merge state.");
19612
+ }
19613
+ }
19614
+ }
19615
+ return { fileCount: 0 };
19616
+ }
19617
+ const editorChanges = status.files.filter((f) => !isCanopyInternalPath(f.path));
19618
+ if (editorChanges.length > 0 && !options.force) {
19619
+ p2.log.warn(`Branch workspace has ${editorChanges.length} uncommitted change(s) that will be committed to history then overwritten:`);
19620
+ for (const file of editorChanges.slice(0, 10)) {
19621
+ p2.log.warn(` ${file.path}`);
19622
+ }
19623
+ if (editorChanges.length > 10) {
19624
+ p2.log.warn(` ... and ${editorChanges.length - 10} more`);
19625
+ }
19626
+ const confirm5 = await p2.confirm({
19627
+ message: "Continue? Editor changes will be preserved in git history.",
19628
+ initialValue: false
19629
+ });
19630
+ if (p2.isCancel(confirm5) || !confirm5) {
19631
+ p2.cancel("Push cancelled.");
19632
+ return { fileCount: 0 };
19633
+ }
19634
+ }
19635
+ p2.log.step(`Pushing content to branch workspace: ${branchName}`);
19636
+ let fileCount;
19153
19637
  try {
19154
- json = JSON.parse(raw);
19638
+ const result = await pushContentToWorkspace({
19639
+ srcContentDir,
19640
+ branchPath,
19641
+ contentRoot,
19642
+ baseTag: SYNC_BASE_TAG,
19643
+ saveEditorStateMessage: "sync: save editor state before push"
19644
+ });
19645
+ fileCount = result.fileCount;
19646
+ if (result.savedEditorState)
19647
+ p2.log.info("Committed editor changes to history before push");
19155
19648
  } catch (err2) {
19156
- throw new BranchMetadataCorruptError2(branchRoot, `Not valid JSON: ${getErrorMessage(err2)}`);
19649
+ throw lockBusyAsSyncError(err2, branchName);
19157
19650
  }
19158
- const result = branchMetadataFileSchema2.safeParse(json);
19159
- if (!result.success) {
19160
- throw new BranchMetadataCorruptError2(branchRoot, describeSchemaIssues2(result.error.issues));
19651
+ if (fileCount === 0) {
19652
+ p2.log.info("Content is already up to date \u2014 nothing to push");
19653
+ } else {
19654
+ p2.log.success(`Pushed ${fileCount} file change(s) to branch "${branchName}"`);
19161
19655
  }
19162
- const file = result.data;
19163
- return file;
19656
+ return { fileCount };
19164
19657
  }
19165
- var BRANCH_META_DIR2, BRANCH_META_FILE2, branchMetadataFilePath2, UNKNOWN_TIME2, enumOf2, branchMetadataShape2, branchMetadataSchema2, branchMetadataFileSchema2;
19166
- var init_branch_metadata_file = __esm({
19167
- "dist/branch-metadata-file.js"() {
19168
- "use strict";
19169
- init_error();
19170
- init_branch_metadata_error();
19171
- BRANCH_META_DIR2 = ".canopy-meta";
19172
- BRANCH_META_FILE2 = "branch.json";
19173
- branchMetadataFilePath2 = (branchRoot) => path44.join(path44.resolve(branchRoot), BRANCH_META_DIR2, BRANCH_META_FILE2);
19174
- UNKNOWN_TIME2 = (/* @__PURE__ */ new Date(0)).toISOString();
19175
- enumOf2 = (values) => z18.nativeEnum(values);
19176
- branchMetadataShape2 = {
19177
- name: z18.string().min(1),
19178
- title: z18.string().optional(),
19179
- description: z18.string().optional(),
19180
- status: enumOf2({
19181
- editing: "editing",
19182
- submitted: "submitted",
19183
- approved: "approved",
19184
- archived: "archived"
19185
- }),
19186
- access: z18.object({
19187
- allowedUsers: z18.array(z18.string()).optional(),
19188
- allowedGroups: z18.array(z18.string()).optional(),
19189
- managerOrAdminAllowed: z18.boolean().optional()
19190
- }).passthrough(),
19191
- createdBy: z18.string().default("unknown"),
19192
- createdAt: z18.string().default(UNKNOWN_TIME2),
19193
- updatedAt: z18.string().default(UNKNOWN_TIME2),
19194
- baseBranch: z18.string().optional(),
19195
- pullRequestUrl: z18.string().optional(),
19196
- pullRequestNumber: z18.number().int().optional(),
19197
- submittedAt: z18.string().optional(),
19198
- pushedToGitHubAt: z18.string().optional(),
19199
- editorUrl: z18.string().optional(),
19200
- syncStatus: enumOf2({
19201
- synced: "synced",
19202
- "pending-sync": "pending-sync",
19203
- "sync-failed": "sync-failed"
19204
- }).optional(),
19205
- conflictStatus: enumOf2({
19206
- clean: "clean",
19207
- "conflicts-detected": "conflicts-detected"
19208
- }).optional(),
19209
- conflictFiles: z18.array(z18.custom((v) => typeof v === "string")).optional(),
19210
- pullRequestState: enumOf2({
19211
- open: "open",
19212
- closed: "closed",
19213
- merged: "merged"
19214
- }).optional(),
19215
- mergedAt: z18.string().optional(),
19216
- rebaseFailure: z18.object({ message: z18.string(), firstAt: z18.string(), lastAt: z18.string() }).optional(),
19217
- historyRewrittenFrom: z18.string().optional(),
19218
- lastGitHubTip: z18.string().optional(),
19219
- keptOverGitHub: z18.array(z18.string()).optional(),
19220
- rewrittenOnGitHub: z18.string().optional(),
19221
- submitUnfinished: z18.boolean().optional(),
19222
- submitNotQueued: z18.boolean().optional(),
19223
- unpublishedAtMerge: z18.boolean().optional(),
19224
- syncFailureReason: z18.string().optional(),
19225
- editors: z18.array(z18.string()).optional(),
19226
- uncommittedEditors: z18.array(z18.string()).optional(),
19227
- // Dropped when malformed: it only decorates the branch card, so it never fails a read.
19228
- pullRequestHold: z18.discriminatedUnion("kind", [
19229
- z18.object({ kind: z18.literal("conflicts") }),
19230
- z18.object({ kind: z18.literal("retargeted"), base: z18.string() })
19231
- ]).optional().catch(void 0),
19232
- // Dropped when malformed: it only decorates the PR body, so it never fails a read.
19233
- review: z18.object({
19234
- state: z18.enum(["approved", "changes-requested"]),
19235
- by: z18.object({ userId: z18.string(), name: z18.string().optional() }),
19236
- at: z18.string()
19237
- }).optional().catch(void 0)
19238
- };
19239
- branchMetadataSchema2 = z18.object(branchMetadataShape2).passthrough();
19240
- branchMetadataFileSchema2 = z18.object({
19241
- schemaVersion: z18.number().int().default(1),
19242
- version: z18.number().int().nonnegative().default(0),
19243
- writeId: z18.string().optional(),
19244
- branch: branchMetadataSchema2
19245
- }).passthrough();
19658
+ async function syncPull(options) {
19659
+ const ws = await selectBranch({ ...options, projectDir: options.projectDir });
19660
+ if (!ws)
19661
+ return { fileCount: 0 };
19662
+ return pullFromWorkspace(options, ws);
19663
+ }
19664
+ async function pullFromWorkspace(options, ws) {
19665
+ const { projectDir } = options;
19666
+ const contentRoot = options.contentRoot || "content";
19667
+ const { branchName, branchPath } = ws;
19668
+ const branchContentDir = path47.join(branchPath, contentRoot);
19669
+ assertWithinDir(branchContentDir, branchPath, "--content-root");
19670
+ if (!await filePathExists(branchContentDir)) {
19671
+ throw new SyncError(`Content directory not found in branch workspace: ${path47.basename(branchPath)}/${contentRoot}`);
19246
19672
  }
19247
- });
19248
-
19249
- // dist/branch-registry.js
19250
- import fs37 from "node:fs/promises";
19251
- import path45 from "node:path";
19252
- import { z as z19 } from "zod";
19253
- var log16, REGISTRY_FILE2, REGISTRY_STALE_FILE2, REGISTRY_TEMP_FILE2, REGISTRY_VERSION2, RESOURCE4, GET_MISS_REFRESH_MIN_INTERVAL_MS2, snapshotSchema2, reportedUnreadable2, BranchRegistry2;
19254
- var init_branch_registry = __esm({
19255
- "dist/branch-registry.js"() {
19256
- "use strict";
19257
- init_branch_metadata_file();
19258
- init_error();
19259
- init_debug2();
19260
- init_logger3();
19261
- init_resource_generation();
19262
- log16 = createDebugLogger3({ prefix: "BranchRegistry" });
19263
- REGISTRY_FILE2 = "branches.json";
19264
- REGISTRY_STALE_FILE2 = "branches.stale.json";
19265
- REGISTRY_TEMP_FILE2 = "branches.tmp.json";
19266
- REGISTRY_VERSION2 = 2;
19267
- RESOURCE4 = "branch-registry";
19268
- GET_MISS_REFRESH_MIN_INTERVAL_MS2 = 5e3;
19269
- snapshotSchema2 = z19.object({
19270
- version: z19.literal(REGISTRY_VERSION2),
19271
- branches: z19.array(z19.object({ branch: branchMetadataSchema2, branchRoot: z19.string(), baseRoot: z19.string() })),
19272
- generation: z19.string().nullable()
19273
- });
19274
- reportedUnreadable2 = /* @__PURE__ */ new Set();
19275
- BranchRegistry2 = class {
19276
- constructor(root) {
19277
- this.regenInFlight = null;
19278
- this.lastForcedRefreshMs = 0;
19279
- this.root = path45.resolve(root);
19280
- this.registryPath = path45.join(this.root, REGISTRY_FILE2);
19281
- this.stalePath = path45.join(this.root, REGISTRY_STALE_FILE2);
19282
- this.tempPath = path45.join(this.root, REGISTRY_TEMP_FILE2);
19283
- }
19284
- /**
19285
- * Returns all branches. Uses the cached snapshot if its embedded generation
19286
- * token matches the live marker, regenerates otherwise.
19287
- *
19288
- * An unparseable or wrong-shaped snapshot is treated like an absent one: the
19289
- * snapshot is a cache of the branch directories, so rebuilding it loses
19290
- * nothing, while rethrowing would fail every editor's branch listing until
19291
- * someone deleted the file. Other read failures (permissions, IO) propagate.
19292
- */
19293
- async list() {
19294
- let raw;
19295
- try {
19296
- raw = await fs37.readFile(this.registryPath, "utf8");
19297
- } catch (err2) {
19298
- if (isNotFoundError(err2)) {
19299
- return await this.regenerate();
19300
- }
19301
- throw err2;
19302
- }
19303
- const parsed = this.parseSnapshot(raw);
19304
- if (!parsed)
19305
- return await this.regenerate();
19306
- const read = await readResourceGeneration2(this.root, RESOURCE4);
19307
- if (isGenerationCurrent2(parsed.generation, read)) {
19308
- return parsed.branches;
19309
- }
19310
- return await this.regenerate();
19311
- }
19312
- /** The snapshot in `raw`, or null when it must be rebuilt. */
19313
- parseSnapshot(raw) {
19314
- let json;
19315
- try {
19316
- json = JSON.parse(raw);
19317
- } catch (err2) {
19318
- this.reportUnreadable(getErrorMessage(err2));
19319
- return null;
19320
- }
19321
- if (typeof json === "object" && json !== null && "version" in json && typeof json.version === "number" && json.version !== REGISTRY_VERSION2) {
19322
- return null;
19323
- }
19324
- const result = snapshotSchema2.safeParse(json);
19325
- if (!result.success) {
19326
- this.reportUnreadable("it does not have the registry snapshot shape");
19327
- return null;
19673
+ p2.log.step(`Pulling content from branch: ${branchName}`);
19674
+ const destContentDir = path47.join(projectDir, contentRoot);
19675
+ assertWithinDir(destContentDir, projectDir, "--content-root");
19676
+ const sourceGit = simpleGit7({ baseDir: projectDir });
19677
+ const status = await sourceGit.status();
19678
+ const uncommittedContent = status.files.filter((f) => f.path.startsWith(contentRoot + "/") || f.path === contentRoot);
19679
+ const untrackedLosses = [];
19680
+ if (await filePathExists(destContentDir)) {
19681
+ const localFiles = await listFilesRecursive(destContentDir);
19682
+ const branchFiles = new Set(await listFilesRecursive(branchContentDir));
19683
+ for (const file of localFiles) {
19684
+ if (!branchFiles.has(file)) {
19685
+ const relativePath = contentRoot + "/" + file;
19686
+ if (!uncommittedContent.some((f) => f.path === relativePath)) {
19687
+ untrackedLosses.push(relativePath);
19328
19688
  }
19329
- reportedUnreadable2.delete(this.registryPath);
19330
- const snapshot = result.data;
19331
- return snapshot;
19332
- }
19333
- reportUnreadable(reason) {
19334
- if (reportedUnreadable2.has(this.registryPath))
19335
- return;
19336
- reportedUnreadable2.add(this.registryPath);
19337
- canopyLogWarn3(`CanopyCMS: Branch registry snapshot ${this.registryPath} is unreadable (${reason}); regenerating it from the branch directories`);
19338
- }
19339
- /**
19340
- * Returns a single branch by name, from the cached snapshot when it is fresh.
19341
- *
19342
- * Suspicious-miss backstop: a `name` missing from the list is the signal that
19343
- * the snapshot may predate a branch that now exists, so force one fresh
19344
- * regeneration and re-search before giving up. Throttled per instance, so a
19345
- * genuinely-absent branch costs at most one extra scan per window.
19346
- */
19347
- async get(name) {
19348
- return this.find((b) => b.branch.name === name);
19349
- }
19350
- /**
19351
- * Returns the branch whose workspace directory is `key` (`sanitizeBranchName` of its ref),
19352
- * whatever its name, with {@link get}'s backstop.
19353
- */
19354
- async getByKey(key) {
19355
- return this.find((b) => path45.basename(b.branchRoot) === key);
19356
19689
  }
19357
- async find(matches) {
19358
- const found = (await this.list()).find(matches);
19359
- if (found)
19360
- return found;
19361
- if (!this.shouldForceRefresh())
19362
- return void 0;
19363
- const refreshed = await this.regenerate();
19364
- return refreshed.find(matches);
19690
+ }
19691
+ }
19692
+ const totalWarnings = uncommittedContent.length + untrackedLosses.length;
19693
+ if (totalWarnings > 0 && !options.force) {
19694
+ if (uncommittedContent.length > 0) {
19695
+ p2.log.warn(`You have ${uncommittedContent.length} uncommitted change(s) in ${contentRoot}/:`);
19696
+ for (const file of uncommittedContent.slice(0, 10)) {
19697
+ p2.log.warn(` ${file.path}`);
19365
19698
  }
19366
- shouldForceRefresh() {
19367
- const now = Date.now();
19368
- if (now - this.lastForcedRefreshMs < GET_MISS_REFRESH_MIN_INTERVAL_MS2)
19369
- return false;
19370
- this.lastForcedRefreshMs = now;
19371
- return true;
19699
+ if (uncommittedContent.length > 10) {
19700
+ p2.log.warn(` ... and ${uncommittedContent.length - 10} more`);
19372
19701
  }
19373
- /**
19374
- * Marks the cache stale for every process sharing this root by bumping the
19375
- * marker, then eager-regenerates on this host. The bump must succeed —
19376
- * swallowing that failure leaves the registry stale indefinitely with no
19377
- * backstop. A failed eager regen must NOT fail the caller's save/delete: the
19378
- * bump alone already restored correctness for every future reader.
19379
- */
19380
- async invalidate() {
19381
- await bumpResourceGeneration2(this.root, RESOURCE4, { mustSucceed: true });
19382
- try {
19383
- if (this.regenInFlight)
19384
- await this.regenInFlight.catch(() => {
19385
- });
19386
- await this.regenerate();
19387
- } catch (err2) {
19388
- log16.warn("invalidate", "Eager regeneration after invalidate() failed", {
19389
- error: getErrorMessage(err2)
19390
- });
19391
- }
19702
+ }
19703
+ if (untrackedLosses.length > 0) {
19704
+ p2.log.warn(`${untrackedLosses.length} file(s) in ${contentRoot}/ not present in branch "${branchName}" will be deleted:`);
19705
+ for (const file of untrackedLosses.slice(0, 10)) {
19706
+ p2.log.warn(` ${file}`);
19392
19707
  }
19393
- /**
19394
- * Scans branch directories and rebuilds the cache, deduping concurrent
19395
- * callers on this instance to a single underlying scan. Never loops waiting
19396
- * for the embedded token to match the marker — that livelocks under a bump
19397
- * storm; a caller wanting the very latest state calls list() again.
19398
- */
19399
- async regenerate() {
19400
- if (this.regenInFlight)
19401
- return this.regenInFlight;
19402
- const run = this.performRegenerate();
19403
- this.regenInFlight = run;
19404
- try {
19405
- return await run;
19406
- } finally {
19407
- this.regenInFlight = null;
19708
+ if (untrackedLosses.length > 10) {
19709
+ p2.log.warn(` ... and ${untrackedLosses.length - 10} more`);
19710
+ }
19711
+ }
19712
+ const confirm5 = await p2.confirm({
19713
+ message: "Overwrite content directory? Files listed above will be lost.",
19714
+ initialValue: false
19715
+ });
19716
+ if (p2.isCancel(confirm5) || !confirm5) {
19717
+ p2.cancel("Pull cancelled. Commit or stash your changes first.");
19718
+ return { fileCount: 0 };
19719
+ }
19720
+ }
19721
+ const tmpDestDir = `${destContentDir}.sync-tmp-${Date.now()}`;
19722
+ try {
19723
+ await copyDir(branchContentDir, tmpDestDir);
19724
+ await safeReplaceDir(destContentDir, tmpDestDir);
19725
+ } catch (err2) {
19726
+ await fs38.rm(tmpDestDir, { recursive: true, force: true }).catch(() => {
19727
+ });
19728
+ throw err2;
19729
+ }
19730
+ const postStatus = await sourceGit.status();
19731
+ const contentChanges = postStatus.files.filter((f) => f.path.startsWith(contentRoot + "/") || f.path === contentRoot);
19732
+ if (contentChanges.length === 0) {
19733
+ p2.log.info("Content is already up to date \u2014 nothing to pull");
19734
+ } else {
19735
+ p2.log.success(`Pulled ${contentChanges.length} changed file(s) from branch "${branchName}"`);
19736
+ for (const file of contentChanges.slice(0, 20)) {
19737
+ const indicator = file.working_dir === "?" ? "A" : file.working_dir || file.index;
19738
+ p2.log.info(` ${indicator} ${file.path}`);
19739
+ }
19740
+ if (contentChanges.length > 20) {
19741
+ p2.log.info(` ... and ${contentChanges.length - 20} more`);
19742
+ }
19743
+ p2.log.info("\nReview the changes, then git add and commit when ready.");
19744
+ }
19745
+ return { fileCount: contentChanges.length };
19746
+ }
19747
+ async function syncBoth(options) {
19748
+ const { projectDir } = options;
19749
+ const contentRoot = options.contentRoot || "content";
19750
+ const srcContentDir = path47.join(projectDir, contentRoot);
19751
+ assertWithinDir(srcContentDir, projectDir, "--content-root");
19752
+ if (!await filePathExists(srcContentDir)) {
19753
+ throw new SyncError(`Content directory not found: ${contentRoot}/ (expected at ${srcContentDir})`);
19754
+ }
19755
+ const ws = await selectBranch({ ...options, projectDir, autoCreate: true });
19756
+ if (!ws)
19757
+ return { pushed: 0, pulled: 0 };
19758
+ const { branchName, branchPath } = ws;
19759
+ const wsGit = simpleGit7({ baseDir: branchPath });
19760
+ const status = await wsGit.status();
19761
+ if (status.conflicted.length > 0) {
19762
+ p2.log.error("Branch workspace has unresolved merge conflicts.");
19763
+ if (!options.force) {
19764
+ const shouldAbort = await p2.confirm({
19765
+ message: "Abort the merge and restore workspace to pre-merge state?",
19766
+ initialValue: false
19767
+ });
19768
+ if (!p2.isCancel(shouldAbort) && shouldAbort) {
19769
+ if (await abortWorkspaceMerge(branchPath, branchName)) {
19770
+ p2.log.success("Merge aborted. Workspace restored to pre-merge state.");
19408
19771
  }
19409
19772
  }
19410
- async performRegenerate() {
19411
- const read = await readResourceGeneration2(this.root, RESOURCE4);
19412
- const branches = await this.scanBranchDirectories();
19413
- await fs37.unlink(this.stalePath).catch(() => {
19773
+ }
19774
+ return { pushed: 0, pulled: 0 };
19775
+ }
19776
+ return withWorkspaceLock(branchPath, branchName, () => mergeIntoWorkspace(options, { branchName, branchPath, srcContentDir, contentRoot }));
19777
+ }
19778
+ async function mergeIntoWorkspace(options, ws) {
19779
+ const { branchPath, srcContentDir, contentRoot } = ws;
19780
+ const wsGit = simpleGit7({ baseDir: branchPath });
19781
+ const status = await wsGit.status();
19782
+ if (status.files.some((f) => !isCanopyInternalPath(f.path))) {
19783
+ await stageAllExceptCanopyState2(wsGit);
19784
+ await wsGit.commit("sync: save editor state before merge");
19785
+ p2.log.info("Committed editor changes before merge");
19786
+ }
19787
+ let baseRef;
19788
+ try {
19789
+ await wsGit.raw(["rev-parse", SYNC_BASE_TAG]);
19790
+ baseRef = SYNC_BASE_TAG;
19791
+ } catch {
19792
+ baseRef = "HEAD";
19793
+ }
19794
+ const currentBranch = (await wsGit.revparse(["--abbrev-ref", "HEAD"])).trim();
19795
+ try {
19796
+ const incomingBranch = `sync-incoming-${Date.now()}`;
19797
+ await wsGit.raw(["checkout", "-b", incomingBranch, baseRef]);
19798
+ const wsContentDir = path47.join(branchPath, contentRoot);
19799
+ assertWithinDir(wsContentDir, branchPath, "--content-root");
19800
+ const tmpDir = `${wsContentDir}.sync-tmp-${Date.now()}`;
19801
+ try {
19802
+ await copyDir(srcContentDir, tmpDir);
19803
+ await safeReplaceDir(wsContentDir, tmpDir);
19804
+ } catch (err2) {
19805
+ await fs38.rm(tmpDir, { recursive: true, force: true }).catch(() => {
19806
+ });
19807
+ await wsGit.checkout(currentBranch);
19808
+ await wsGit.raw(["branch", "-D", incomingBranch]).catch(() => {
19809
+ });
19810
+ throw err2;
19811
+ }
19812
+ await stageAllExceptCanopyState2(wsGit);
19813
+ const incomingFiles = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
19814
+ if (incomingFiles.length === 0) {
19815
+ await wsGit.checkout(currentBranch);
19816
+ await wsGit.raw(["branch", "-D", incomingBranch]);
19817
+ p2.log.info("No working-tree changes to merge \u2014 pulling editor changes only");
19818
+ const pullResult2 = await pullFromWorkspace({ ...options, force: true }, ws);
19819
+ return { pushed: 0, pulled: pullResult2.fileCount };
19820
+ }
19821
+ await wsGit.commit("sync: incoming working-tree changes");
19822
+ await wsGit.checkout(currentBranch);
19823
+ p2.log.step("Merging working-tree changes with editor changes...");
19824
+ try {
19825
+ await wsGit.merge([incomingBranch, "--no-edit"]);
19826
+ } catch (mergeError) {
19827
+ let mergeStatus;
19828
+ try {
19829
+ mergeStatus = await wsGit.status();
19830
+ } catch {
19831
+ await wsGit.raw(["branch", "-D", incomingBranch]).catch(() => {
19414
19832
  });
19415
- if (!read.ok) {
19416
- return branches;
19417
- }
19418
- const snapshot = {
19419
- version: REGISTRY_VERSION2,
19420
- branches,
19421
- generation: read.token
19422
- };
19423
- const uniqueTempPath = `${this.tempPath}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
19424
- await fs37.mkdir(this.root, { recursive: true });
19425
- await fs37.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
19426
- try {
19427
- await fs37.rename(uniqueTempPath, this.registryPath);
19428
- } catch (err2) {
19429
- await fs37.unlink(uniqueTempPath).catch(() => {
19430
- });
19431
- throw err2;
19432
- }
19433
- return branches;
19833
+ throw mergeError;
19434
19834
  }
19435
- /**
19436
- * Scans the root directory for branch subdirectories with valid branch.json files.
19437
- * Protected (rather than exported as a test hook) so tests can subclass and
19438
- * override to simulate cross-process interleavings.
19439
- */
19440
- async scanBranchDirectories() {
19441
- const branches = [];
19442
- try {
19443
- const entries = await fs37.readdir(this.root, { withFileTypes: true });
19444
- for (const entry of entries) {
19445
- if (!entry.isDirectory() || entry.name.startsWith(".")) {
19446
- continue;
19447
- }
19448
- const branchRoot = path45.join(this.root, entry.name);
19449
- let meta;
19450
- try {
19451
- meta = await readBranchMetadataFile2(branchRoot);
19452
- } catch (err2) {
19453
- canopyLogWarn3(`CanopyCMS: Skipping branch directory '${entry.name}' during registry scan: ${getErrorMessage(err2)}`);
19454
- continue;
19455
- }
19456
- if (meta) {
19457
- branches.push({
19458
- branch: meta.branch,
19459
- branchRoot,
19460
- baseRoot: this.root
19461
- });
19462
- }
19463
- }
19464
- } catch (err2) {
19465
- if (isNotFoundError(err2)) {
19466
- return [];
19467
- }
19468
- throw err2;
19835
+ if (mergeStatus.conflicted.length > 0) {
19836
+ p2.log.error("Merge conflicts detected in the following files:");
19837
+ for (const file of mergeStatus.conflicted) {
19838
+ p2.log.error(` ${file}`);
19469
19839
  }
19470
- return branches;
19840
+ p2.log.info("The branch workspace is now in a merge state.");
19841
+ const branchFlag = options.branch ? ` --branch ${options.branch}` : "";
19842
+ p2.log.info(`Resolve the conflicts in the workspace, then run: canopycms sync pull${branchFlag}`);
19843
+ p2.log.info(`Or abort the merge with: canopycms sync abort${branchFlag}`);
19844
+ await wsGit.raw(["branch", "-D", incomingBranch]).catch(() => {
19845
+ });
19846
+ return { pushed: 0, pulled: 0 };
19847
+ }
19848
+ await wsGit.raw(["branch", "-D", incomingBranch]).catch(() => {
19849
+ });
19850
+ throw mergeError;
19851
+ }
19852
+ await wsGit.raw(["branch", "-D", incomingBranch]);
19853
+ await wsGit.tag(["-f", SYNC_BASE_TAG]);
19854
+ p2.log.success("Merged working-tree changes with editor changes");
19855
+ const pullResult = await pullFromWorkspace({ ...options, force: true }, ws);
19856
+ return { pushed: incomingFiles.length, pulled: pullResult.fileCount };
19857
+ } finally {
19858
+ await invalidateBranchContentCaches2(branchPath);
19859
+ }
19860
+ }
19861
+ async function syncAbort(options) {
19862
+ const ws = await selectBranch({ ...options, projectDir: options.projectDir });
19863
+ if (!ws)
19864
+ return;
19865
+ const { branchName, branchPath } = ws;
19866
+ if (!await abortWorkspaceMerge(branchPath, branchName)) {
19867
+ p2.log.info(`Branch workspace "${branchName}" is not in a merge state \u2014 nothing to abort.`);
19868
+ return;
19869
+ }
19870
+ p2.log.success(`Merge aborted in branch workspace "${branchName}". Workspace restored to pre-merge state.`);
19871
+ }
19872
+ async function sync(options) {
19873
+ p2.intro("CanopyCMS sync");
19874
+ let pushed = 0;
19875
+ let pulled = 0;
19876
+ if (options.direction === "abort") {
19877
+ await syncAbort(options);
19878
+ } else if (options.direction === "both") {
19879
+ const result = await syncBoth(options);
19880
+ pushed = result.pushed;
19881
+ pulled = result.pulled;
19882
+ } else if (options.direction === "push") {
19883
+ const result = await syncPush(options);
19884
+ pushed = result.fileCount;
19885
+ } else {
19886
+ const result = await syncPull(options);
19887
+ pulled = result.fileCount;
19888
+ }
19889
+ p2.outro("Done!");
19890
+ return { pushed, pulled };
19891
+ }
19892
+ var SyncError, branchConfigSchema;
19893
+ var init_sync = __esm({
19894
+ "dist/cli/sync.js"() {
19895
+ "use strict";
19896
+ init_branch_param();
19897
+ init_branch_metadata_file();
19898
+ init_branch();
19899
+ init_validation3();
19900
+ init_error();
19901
+ init_fs();
19902
+ init_git();
19903
+ init_sync_core();
19904
+ init_content_index_generation();
19905
+ init_content_write_lock();
19906
+ init_git_manager();
19907
+ init_operating_mode2();
19908
+ init_site_config();
19909
+ SyncError = class extends Error {
19910
+ constructor(message) {
19911
+ super(message);
19912
+ this.name = "SyncError";
19471
19913
  }
19472
19914
  };
19915
+ branchConfigSchema = z19.object({
19916
+ branchNamespace: z19.string().optional(),
19917
+ deploymentName: z19.string().optional(),
19918
+ defaultBaseBranch: z19.string().optional(),
19919
+ defaultActiveBranch: z19.string().optional()
19920
+ });
19473
19921
  }
19474
19922
  });
19475
19923
 
19476
- // dist/branch-metadata.js
19477
- import fs38 from "node:fs/promises";
19478
- import path46 from "node:path";
19479
- function isRecordedEditor2(branch, userId) {
19480
- return (branch.editors?.includes(userId) ?? false) && (branch.uncommittedEditors?.includes(userId) ?? false);
19481
- }
19482
- function withMember2(list, id) {
19483
- return list?.includes(id) ? [...list] : [...list ?? [], id];
19484
- }
19485
- function mergeBranchMetadata2(existing, version, incoming, now = (/* @__PURE__ */ new Date()).toISOString()) {
19486
- const defaults = {
19487
- name: "unknown",
19488
- status: "editing",
19489
- access: {},
19490
- createdBy: "unknown",
19491
- createdAt: now,
19492
- updatedAt: now
19493
- };
19494
- return {
19495
- // Envelope keys this code does not know survive, as unknown branch keys do below.
19496
- ...existing,
19497
- schemaVersion: CURRENT_SCHEMA_VERSION2,
19498
- version: version ?? 0,
19499
- branch: {
19500
- ...defaults,
19501
- ...existing?.branch,
19502
- ...incoming.branch,
19503
- access: {
19504
- ...existing?.branch?.access,
19505
- ...incoming.branch?.access
19506
- },
19507
- // Immutable after creation
19508
- createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
19509
- createdAt: existing?.branch.createdAt ?? defaults.createdAt,
19510
- // Fork point is recorded once at creation; later saves must not move it
19511
- baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch,
19512
- // Always stamped fresh; the spreads above would otherwise let the creation-time value win
19513
- // forever, freezing the timestamp the editor's Branches panel sorts and displays by
19514
- updatedAt: now
19515
- }
19516
- };
19924
+ // dist/schema/index.js
19925
+ var init_schema = __esm({
19926
+ "dist/schema/index.js"() {
19927
+ "use strict";
19928
+ init_meta_loader();
19929
+ }
19930
+ });
19931
+
19932
+ // dist/schema/schema-store.js
19933
+ import path48 from "node:path";
19934
+ import { z as z20 } from "zod";
19935
+ async function withBranchSchemaLock(branchRoot, fn, contentWriteLockWaitMs = DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS2) {
19936
+ const schemaLockPath = path48.join(path48.resolve(branchRoot), ".canopy-meta", "schema");
19937
+ return withContentWriteLock2(branchRoot, () => withLock2(schemaLockPath, () => withOccFileLock2(schemaLockPath, fn)), contentWriteLockWaitMs);
19517
19938
  }
19518
- var CURRENT_SCHEMA_VERSION2, BranchMetadataConflictError2, BranchMetadataFileManager2;
19519
- var init_branch_metadata = __esm({
19520
- "dist/branch-metadata.js"() {
19939
+ var MAX_NAME_LENGTH, MAX_LABEL_LENGTH, SAFE_NAME_PATTERN, SAFE_NAME_MESSAGE, entryTypeInputSchema, createCollectionInputSchema, updateCollectionInputSchema, updateEntryTypeInputSchema, log16;
19940
+ var init_schema_store = __esm({
19941
+ "dist/schema/schema-store.js"() {
19521
19942
  "use strict";
19522
- init_branch_registry();
19523
- init_branch_metadata_file();
19524
- init_error();
19525
19943
  init_async_mutex();
19944
+ init_debug2();
19945
+ init_content_write_lock();
19526
19946
  init_occ_json_write();
19527
- CURRENT_SCHEMA_VERSION2 = 1;
19528
- BranchMetadataConflictError2 = class extends Error {
19529
- constructor(message = "Concurrent modification detected in branch metadata") {
19530
- super(message);
19531
- this.name = "BranchMetadataConflictError";
19532
- }
19533
- };
19534
- BranchMetadataFileManager2 = class _BranchMetadataFileManager2 {
19535
- constructor(branchRoot, baseRoot, options) {
19536
- this.branchRoot = path46.resolve(branchRoot);
19537
- this.filePath = path46.join(this.branchRoot, BRANCH_META_DIR2, BRANCH_META_FILE2);
19538
- this.baseRoot = baseRoot;
19539
- this.settleMs = options?.settleMs;
19540
- }
19541
- /** Read-only load, needing no baseRoot: registry scanning, loadBranchContext. */
19542
- static async loadOnly(branchRoot) {
19543
- return readBranchMetadataFile2(branchRoot);
19544
- }
19545
- /**
19546
- * An instance wired for registry invalidation. API handlers use this, so the
19547
- * registry cache is invalidated on update.
19548
- */
19549
- static get(branchRoot, baseRoot, options) {
19550
- return new _BranchMetadataFileManager2(branchRoot, baseRoot, options);
19551
- }
19552
- /**
19553
- * The file the save cycle merges over. A corrupt one throws
19554
- * {@link BranchMetadataCorruptError} rather than reading as absent, so a save
19555
- * never lays defaults (status 'editing', no ACL) over a branch whose real
19556
- * state is unknown; repair-metadata archives the file before it saves.
19557
- */
19558
- async load() {
19559
- const meta = await readBranchMetadataFile2(this.branchRoot);
19560
- return { meta, version: meta?.version ?? null };
19561
- }
19562
- /**
19563
- * Write branch.json via the shared OCC helper, applying the schemaVersion
19564
- * default here since payload shaping is branch-metadata's concern.
19565
- *
19566
- * Throws the helper's raw {@link OccWriteConflictError}, which is what the
19567
- * surrounding {@link withOccRetry} in save() recognizes and retries;
19568
- * translating to the public `BranchMetadataConflictError` earlier than the
19569
- * save() boundary would make withOccRetry's predicate miss it.
19570
- *
19571
- * branch.json is written WITH a trailing newline (`trailingNewline: true`),
19572
- * unlike comments.json.
19573
- */
19574
- async write(meta, expectedVersion) {
19575
- const payload = {
19576
- ...meta,
19577
- schemaVersion: meta.schemaVersion ?? CURRENT_SCHEMA_VERSION2
19578
- };
19579
- return writeOccJsonFile2(this.filePath, payload, {
19580
- expectedVersion,
19581
- settleMs: this.settleMs,
19582
- trailingNewline: true
19583
- });
19584
- }
19585
- /**
19586
- * Run a save cycle under the full lock + OCC-retry stack (class doc). A
19587
- * conflict surviving every retry surfaces as `BranchMetadataConflictError`.
19588
- *
19589
- * The stat guards a phantom-resurrection race with branch deletion: a
19590
- * caller's branchContext can resolve BEFORE a concurrent deleteBranchHandler
19591
- * removes the branch directory, while the save reaches here after it, and
19592
- * write()'s `mkdir({recursive:true})` would then recreate `.canopy-meta/` and
19593
- * branch.json from defaults inside a tree nothing else refers to — a registry
19594
- * entry with no clone behind it. It runs BEFORE the lock stack so a doomed
19595
- * save fails fast instead of paying for a lock.
19596
- *
19597
- * A save that passes the check just before a delete still cannot write: the
19598
- * delete renames the tree away under the same lockfile, and that lockfile
19599
- * creates only its own directory (`withOccFileLock`), so the save fails there.
19600
- */
19601
- async save(incoming) {
19602
- const saved = await this.saveIf(incoming, () => true);
19603
- if (saved === null)
19604
- throw new Error("unreachable: an unconditional save was skipped");
19605
- return saved;
19606
- }
19607
- /**
19608
- * {@link save}, applied only when `onlyIf` accepts the metadata on disk; resolves null when it
19609
- * does not, having written nothing. `onlyIf` runs inside the lock stack against the version
19610
- * the write is checked against, so the decision and the write are one compare-and-set.
19611
- */
19612
- async saveIf(incoming, onlyIf) {
19613
- return this.update((existing) => onlyIf(existing) ? incoming : null);
19614
- }
19615
- /**
19616
- * A save whose update `compute` derives from the metadata on disk, inside the lock stack, so
19617
- * an update built from the existing value (adding to a list) cannot lose a concurrent one.
19618
- * `compute` returning null writes nothing and resolves null. `keepUpdatedAt` leaves
19619
- * `updatedAt` as it was, for bookkeeping writes that are not a change to the branch.
19620
- */
19621
- async update(compute, options = {}) {
19622
- try {
19623
- await fs38.stat(this.branchRoot);
19624
- } catch (err2) {
19625
- if (isNotFoundError(err2)) {
19626
- throw new BranchMetadataConflictError2("Branch no longer exists");
19627
- }
19628
- throw err2;
19629
- }
19630
- let saved;
19631
- try {
19632
- saved = await withLock2(this.filePath, () => withOccFileLock2(this.filePath, () => withOccRetry2(async () => {
19633
- const { meta: existing, version } = await this.load();
19634
- const incoming = compute(existing);
19635
- if (incoming === null)
19636
- return null;
19637
- const merged = mergeBranchMetadata2(existing, version, incoming);
19638
- if (options.keepUpdatedAt && existing)
19639
- merged.branch.updatedAt = existing.branch.updatedAt;
19640
- const written = await this.write(merged, version);
19641
- merged.version = written.version;
19642
- merged.writeId = written.writeId;
19643
- return merged;
19644
- })));
19645
- } catch (err2) {
19646
- if (err2 instanceof OccWriteConflictError2) {
19647
- throw new BranchMetadataConflictError2();
19648
- }
19649
- throw err2;
19650
- }
19651
- if (saved !== null)
19652
- await this.invalidateRegistry();
19653
- return saved;
19654
- }
19655
- /**
19656
- * Adds `userId` to `editors` and `uncommittedEditors`. A lock-free read skips the write when
19657
- * both already hold it, so only a user's first save since the last submit commit pays for the lock.
19658
- * Never creates branch.json.
19659
- */
19660
- async recordEditor(userId) {
19661
- const current = await readBranchMetadataFile2(this.branchRoot);
19662
- if (!current || isRecordedEditor2(current.branch, userId))
19663
- return;
19664
- await this.update((existing) => existing && !isRecordedEditor2(existing.branch, userId) ? {
19665
- branch: {
19666
- editors: withMember2(existing.branch.editors, userId),
19667
- uncommittedEditors: withMember2(existing.branch.uncommittedEditors, userId)
19668
- }
19669
- } : null, { keepUpdatedAt: true });
19670
- }
19671
- /**
19672
- * Removes `userIds` from `uncommittedEditors` once a submit commit's trailers name them. Ids
19673
- * recorded after the commit read them stay, so the next commit names them.
19674
- */
19675
- async markEditorsCommitted(userIds) {
19676
- const committed = new Set(userIds);
19677
- if (committed.size === 0)
19678
- return;
19679
- await this.update((existing) => {
19680
- const pending = existing?.branch.uncommittedEditors ?? [];
19681
- const remaining = pending.filter((id) => !committed.has(id));
19682
- if (remaining.length === pending.length)
19683
- return null;
19684
- return { branch: { uncommittedEditors: remaining.length > 0 ? remaining : void 0 } };
19685
- }, { keepUpdatedAt: true });
19686
- }
19687
- /** Invalidate the registry cache so the next list() regenerates. */
19688
- async invalidateRegistry() {
19689
- const registry3 = new BranchRegistry2(this.baseRoot);
19690
- await registry3.invalidate();
19691
- }
19692
- };
19947
+ MAX_NAME_LENGTH = 64;
19948
+ MAX_LABEL_LENGTH = 128;
19949
+ SAFE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
19950
+ SAFE_NAME_MESSAGE = "must start with a letter and contain only lowercase letters, numbers, and hyphens";
19951
+ entryTypeInputSchema = z20.object({
19952
+ name: z20.string().min(1).max(MAX_NAME_LENGTH).regex(SAFE_NAME_PATTERN, `Entry type name ${SAFE_NAME_MESSAGE}`),
19953
+ label: z20.string().max(MAX_LABEL_LENGTH).optional(),
19954
+ format: z20.enum(["md", "mdx", "json", "yaml"]),
19955
+ schema: z20.string().min(1),
19956
+ default: z20.boolean().optional(),
19957
+ maxItems: z20.number().int().positive().optional()
19958
+ });
19959
+ createCollectionInputSchema = z20.object({
19960
+ name: z20.string().min(1).max(MAX_NAME_LENGTH).regex(SAFE_NAME_PATTERN, `Collection name ${SAFE_NAME_MESSAGE}`),
19961
+ label: z20.string().max(MAX_LABEL_LENGTH).optional(),
19962
+ parentPath: z20.string().optional(),
19963
+ entries: z20.array(entryTypeInputSchema).min(1, "Collection must have at least one entry type")
19964
+ });
19965
+ updateCollectionInputSchema = z20.object({
19966
+ name: z20.string().min(1).max(MAX_NAME_LENGTH).regex(SAFE_NAME_PATTERN, `Collection name ${SAFE_NAME_MESSAGE}`).optional(),
19967
+ label: z20.string().max(MAX_LABEL_LENGTH).optional(),
19968
+ // Directory name (e.g., "posts" in "posts.{id}/")
19969
+ slug: z20.string().min(1).max(MAX_NAME_LENGTH).regex(SAFE_NAME_PATTERN, `Slug ${SAFE_NAME_MESSAGE}`).optional(),
19970
+ order: z20.array(z20.string()).optional()
19971
+ });
19972
+ updateEntryTypeInputSchema = z20.object({
19973
+ label: z20.string().max(MAX_LABEL_LENGTH).optional(),
19974
+ format: z20.enum(["md", "mdx", "json", "yaml"]).optional(),
19975
+ schema: z20.string().min(1).optional(),
19976
+ default: z20.boolean().optional(),
19977
+ maxItems: z20.number().int().positive().optional()
19978
+ });
19979
+ log16 = createDebugLogger3({ prefix: "SchemaOps" });
19693
19980
  }
19694
19981
  });
19695
19982
 
@@ -19701,7 +19988,7 @@ __export(migrate_exports, {
19701
19988
  slugifyName: () => slugifyName
19702
19989
  });
19703
19990
  import fs39 from "node:fs/promises";
19704
- import path47 from "node:path";
19991
+ import path49 from "node:path";
19705
19992
  import * as p3 from "@clack/prompts";
19706
19993
  function slugifyName(name) {
19707
19994
  const slug = name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
@@ -19735,7 +20022,7 @@ async function planDirectory(dirPath, isRoot, ctx) {
19735
20022
  if (entry.name.startsWith("."))
19736
20023
  continue;
19737
20024
  if (entry.isDirectory()) {
19738
- const subPath = path47.join(dirPath, entry.name);
20025
+ const subPath = path49.join(dirPath, entry.name);
19739
20026
  const sub = await planDirectory(subPath, false, ctx);
19740
20027
  if (!sub.hasContent)
19741
20028
  continue;
@@ -19743,10 +20030,10 @@ async function planDirectory(dirPath, isRoot, ctx) {
19743
20030
  ops.push(...sub.ops);
19744
20031
  if (!extractIdFromFilename2(entry.name)) {
19745
20032
  const newName = `${toValidSlug(entry.name)}.${generateId2()}`;
19746
- ops.push({ kind: "rename", from: subPath, to: path47.join(dirPath, newName) });
20033
+ ops.push({ kind: "rename", from: subPath, to: path49.join(dirPath, newName) });
19747
20034
  }
19748
20035
  } else if (entry.isFile()) {
19749
- const ext = path47.extname(entry.name).slice(1);
20036
+ const ext = path49.extname(entry.name).slice(1);
19750
20037
  const matchesFormat = ext === ctx.format || ctx.format === "yaml" && ext === "yml";
19751
20038
  if (!matchesFormat)
19752
20039
  continue;
@@ -19757,17 +20044,17 @@ async function planDirectory(dirPath, isRoot, ctx) {
19757
20044
  const newName = `${ctx.entryType}.${toValidSlug(base)}.${generateId2()}.${ext}`;
19758
20045
  ops.push({
19759
20046
  kind: "rename",
19760
- from: path47.join(dirPath, entry.name),
19761
- to: path47.join(dirPath, newName)
20047
+ from: path49.join(dirPath, entry.name),
20048
+ to: path49.join(dirPath, newName)
19762
20049
  });
19763
20050
  }
19764
20051
  }
19765
20052
  }
19766
20053
  const needsMeta = isRoot ? hasOwnFiles : hasContent;
19767
- const metaPath = path47.join(dirPath, ".collection.json");
20054
+ const metaPath = path49.join(dirPath, ".collection.json");
19768
20055
  if (needsMeta && !await filePathExists(metaPath)) {
19769
20056
  const entryTypeMeta = { name: ctx.entryType, format: ctx.format, schema: ctx.schema };
19770
- const meta = isRoot ? { entries: [entryTypeMeta] } : { name: toValidSlug(stripDirIdSuffix(path47.basename(dirPath))), entries: [entryTypeMeta] };
20057
+ const meta = isRoot ? { entries: [entryTypeMeta] } : { name: toValidSlug(stripDirIdSuffix(path49.basename(dirPath))), entries: [entryTypeMeta] };
19771
20058
  ops.push({ kind: "write", filePath: metaPath, content: JSON.stringify(meta, null, 2) + "\n" });
19772
20059
  }
19773
20060
  return { ops, hasContent };
@@ -19776,7 +20063,7 @@ async function migrate(options) {
19776
20063
  const { projectDir } = options;
19777
20064
  const contentRoot = options.contentRoot || "content";
19778
20065
  p3.intro("CanopyCMS migrate");
19779
- const contentDir = path47.join(projectDir, contentRoot);
20066
+ const contentDir = path49.join(projectDir, contentRoot);
19780
20067
  if (!await filePathExists(contentDir)) {
19781
20068
  throw new MigrateError(`Content directory not found: ${contentRoot}/ (expected at ${contentDir})`);
19782
20069
  }
@@ -19911,7 +20198,7 @@ var init_migrate = __esm({
19911
20198
  this.name = "MigrateError";
19912
20199
  }
19913
20200
  };
19914
- describeOp = (op, baseDir) => op.kind === "rename" ? `rename ${path47.relative(baseDir, op.from)} \u2192 ${path47.basename(op.to)}` : `create ${path47.relative(baseDir, op.filePath)}`;
20201
+ describeOp = (op, baseDir) => op.kind === "rename" ? `rename ${path49.relative(baseDir, op.from)} \u2192 ${path49.basename(op.to)}` : `create ${path49.relative(baseDir, op.filePath)}`;
19915
20202
  }
19916
20203
  });
19917
20204