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.
@@ -4,11 +4,11 @@
4
4
  * site's own dependencies, which a release job given `--bucket` does not have.
5
5
  */
6
6
  import path from 'node:path';
7
- import { createJiti } from 'jiti';
8
7
  import { z } from 'zod';
9
8
  import { createAssetStore } from '../assets/factory.js';
10
9
  import { mediaSchema } from '../config/schemas/media.js';
11
10
  import { operatingStrategy } from '../operating-mode/index.js';
11
+ import { importSiteServerConfig } from './site-config.js';
12
12
  const serverConfigSchema = z.object({
13
13
  mode: z.enum(['prod', 'dev']),
14
14
  media: mediaSchema.optional(),
@@ -19,13 +19,7 @@ const serverConfigSchema = z.object({
19
19
  * local store, prod never does.
20
20
  */
21
21
  export async function loadConfiguredAssetStore(projectDir) {
22
- const configPath = path.join(projectDir, 'canopycms.config.ts');
23
- const jiti = createJiti(import.meta.url);
24
- const mod = (await jiti.import(configPath));
25
- const configExport = mod.default ?? mod.config ?? mod;
26
- const server = typeof configExport === 'object' && configExport !== null && 'server' in configExport
27
- ? configExport.server
28
- : configExport;
22
+ const { configPath, server } = await importSiteServerConfig(projectDir);
29
23
  const parsed = serverConfigSchema.safeParse(server);
30
24
  if (!parsed.success) {
31
25
  throw new Error(`Invalid CanopyCMS config at ${configPath}: ${parsed.error.message}`);
@@ -10937,6 +10937,9 @@ async function loadOrCreateBranchContext(options) {
10937
10937
  });
10938
10938
  }
10939
10939
 
10940
+ // dist/system-users.js
10941
+ var AI_USER_ID = "canopycms-ai";
10942
+
10940
10943
  // dist/ai/resolve-branch.js
10941
10944
  async function resolveBranchRoot(config) {
10942
10945
  if (readsFromCheckout(config)) {
@@ -10955,7 +10958,7 @@ async function resolveBranchRoot(config) {
10955
10958
  config: resolved,
10956
10959
  branchName: activeBranch,
10957
10960
  mode: config.mode,
10958
- createdBy: "canopycms-ai",
10961
+ createdBy: AI_USER_ID,
10959
10962
  remoteUrl: config.defaultRemoteUrl
10960
10963
  });
10961
10964
  return context.branchRoot;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Imports a site's `canopycms.config.ts` for CLI commands that need it (`sync`, and
3
+ * `configured-asset-store.ts`). Importing runs the site's config and loads its own dependencies.
4
+ */
5
+ /** The site's server config export (`server` of a split config, else the export itself). */
6
+ export declare function importSiteServerConfig(projectDir: string): Promise<{
7
+ configPath: string;
8
+ server: unknown;
9
+ }>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Imports a site's `canopycms.config.ts` for CLI commands that need it (`sync`, and
3
+ * `configured-asset-store.ts`). Importing runs the site's config and loads its own dependencies.
4
+ */
5
+ import path from 'node:path';
6
+ import { createJiti } from 'jiti';
7
+ /** The site's server config export (`server` of a split config, else the export itself). */
8
+ export async function importSiteServerConfig(projectDir) {
9
+ const configPath = path.join(projectDir, 'canopycms.config.ts');
10
+ const jiti = createJiti(import.meta.url);
11
+ const mod = (await jiti.import(configPath));
12
+ const configExport = mod.default ?? mod.config ?? mod;
13
+ const server = typeof configExport === 'object' && configExport !== null && 'server' in configExport
14
+ ? configExport.server
15
+ : configExport;
16
+ return { configPath, server };
17
+ }
package/dist/cli/sync.js CHANGED
@@ -16,13 +16,20 @@ import fs from 'node:fs/promises';
16
16
  import path from 'node:path';
17
17
  import { simpleGit } from 'simple-git';
18
18
  import * as p from '@clack/prompts';
19
+ import { z } from 'zod';
20
+ import { resolveBranchParam } from '../branch-param.js';
21
+ import { readBranchMetadataFile } from '../branch-metadata-file.js';
22
+ import { namesNoWorkspace, resolveBranchPath } from '../paths/branch.js';
23
+ import { exactBranchName, exactBranchSegment, isExactBranchForm, parseBranchRef, } from '../paths/validation.js';
24
+ import { getErrorMessage } from '../utils/error.js';
19
25
  import { filePathExists } from '../utils/fs.js';
20
- import { isCanopyInternalPath, stageAllExceptCanopyState } from '../utils/git.js';
26
+ import { detectHeadBranch, isCanopyInternalPath, stageAllExceptCanopyState } from '../utils/git.js';
21
27
  import { assertWithinDir, copyDir, listFilesRecursive, pushContentToWorkspace, safeReplaceDir, SYNC_BASE_TAG, } from '../sync-core.js';
22
28
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
23
29
  import { ContentWriteLockBusyError, withContentWriteLock } from '../utils/content-write-lock.js';
24
30
  import { ensureGitExcludePattern } from '../git-manager.js';
25
31
  import { operatingStrategy } from '../operating-mode/index.js';
32
+ import { importSiteServerConfig } from './site-config.js';
26
33
  /**
27
34
  * Sync precondition failure (missing content dir, unknown branch, …).
28
35
  * Thrown so the CLI exits non-zero — scripts/CI must not sail past a
@@ -78,12 +85,183 @@ async function detectCurrentBranch(projectDir) {
78
85
  throw new Error('Detached HEAD — use --branch to specify a branch name');
79
86
  return branch;
80
87
  }
88
+ /** The site config fields that decide which workspace a branch name names. */
89
+ const branchConfigSchema = z.object({
90
+ branchNamespace: z.string().optional(),
91
+ deploymentName: z.string().optional(),
92
+ defaultBaseBranch: z.string().optional(),
93
+ defaultActiveBranch: z.string().optional(),
94
+ });
95
+ /** {@link branchConfigSchema}'s fields from the site config: none, with a warning, when unusable. */
96
+ async function readBranchConfigFields(projectDir) {
97
+ if (!(await filePathExists(path.join(projectDir, 'canopycms.config.ts'))))
98
+ return {};
99
+ let why;
100
+ try {
101
+ const parsed = branchConfigSchema.safeParse((await importSiteServerConfig(projectDir)).server);
102
+ if (parsed.success)
103
+ return parsed.data;
104
+ why = parsed.error.message;
105
+ }
106
+ catch (err) {
107
+ why = getErrorMessage(err);
108
+ }
109
+ p.log.warn(`Could not use canopycms.config.ts (${why}); branch names resolve against the default branch settings.`);
110
+ return {};
111
+ }
112
+ /**
113
+ * The config {@link resolveBranchParam} resolves names against, built as the dev server builds
114
+ * it: dev mode, the site config's branch fields, and an unset base or active branch taken from the
115
+ * project's git HEAD (services.ts).
116
+ */
117
+ async function loadBranchParamConfig(projectDir) {
118
+ const fields = await readBranchConfigFields(projectDir);
119
+ const head = await detectHeadBranch(projectDir, fields.defaultBaseBranch ?? 'main');
120
+ return {
121
+ ...fields,
122
+ mode: 'dev',
123
+ defaultBaseBranch: fields.defaultBaseBranch ?? head,
124
+ defaultActiveBranch: fields.defaultActiveBranch ?? head,
125
+ };
126
+ }
127
+ /**
128
+ * Refuses a directory that is not the top of its own git repository. Git run there would walk up
129
+ * to the developer's own repository and commit and tag in it.
130
+ */
131
+ async function assertOwnRepository(branchPath) {
132
+ const top = await simpleGit({ baseDir: branchPath })
133
+ .revparse(['--show-toplevel'])
134
+ .then((out) => out.trim())
135
+ .catch(() => '');
136
+ if (top && (await fs.realpath(top)) === (await fs.realpath(branchPath)))
137
+ return;
138
+ throw new SyncError(`"${path.basename(branchPath)}" under content-branches/ is not a branch workspace: git does not report it as the top of its own repository.`);
139
+ }
140
+ /** The branch a workspace's HEAD is on (an unborn one included), or null when it is detached. */
141
+ async function workspaceHead(branchPath) {
142
+ return simpleGit({ baseDir: branchPath })
143
+ .raw(['symbolic-ref', '--short', '-q', 'HEAD'])
144
+ .then((out) => out.trim() || null)
145
+ .catch(() => null);
146
+ }
147
+ /** The workspace at directory `key` under content-branches/, named by its branch.json or HEAD. */
148
+ async function keyWorkspace(key, projectDir) {
149
+ const branchPath = path.join(operatingStrategy('dev').getContentBranchesRoot(projectDir), key);
150
+ await assertOwnRepository(branchPath);
151
+ const meta = await readBranchMetadataFile(branchPath);
152
+ return { branchName: meta?.branch.name ?? (await workspaceHead(branchPath)) ?? key, branchPath };
153
+ }
154
+ /** The ref `name` gives: decoded from the exact form (`~fix~x`), else `name` itself. */
155
+ function refOf(name) {
156
+ if (!isExactBranchForm(name))
157
+ return name;
158
+ const ref = exactBranchName(name);
159
+ if (ref === null)
160
+ throw new SyncError(`"${name}" is not a valid branch name.`);
161
+ return ref;
162
+ }
163
+ /**
164
+ * The workspace `name` names, as the dev server resolves it, or null:
165
+ * 1. what the runtime resolves it to (`resolveBranchParam`): the ref, a namespace branch's short
166
+ * name, or the exact form;
167
+ * 2. a workspace key, the directory itself;
168
+ * 3. the directory the ref keys to, when it holds a workspace sync built (no branch.json) on that
169
+ * ref. Two refs can sanitize to one key, so one whose branch.json or HEAD names another branch
170
+ * is refused.
171
+ * A `name` read from git HEAD (`byRef`) is a ref, never a short name or key: it resolves in its
172
+ * exact form and skips step 2, so a local branch never lands in an editor's branch or in another
173
+ * ref's workspace that shares its key.
174
+ */
175
+ async function resolveSyncWorkspace(name, projectDir, keys, byRef) {
176
+ const config = await loadBranchParamConfig(projectDir);
177
+ const param = byRef ? exactBranchSegment(name) : name;
178
+ const resolved = await resolveBranchParam(param, config, { basePathOverride: projectDir }).catch((err) => {
179
+ if (namesNoWorkspace(err))
180
+ return null;
181
+ throw err;
182
+ });
183
+ if (resolved) {
184
+ // The base and active branch resolve by directory alone, without branch.json's name.
185
+ const ref = refOf(name);
186
+ if (resolved.branch.name !== ref && resolved.branchRoot === refKeyPath(ref, projectDir)) {
187
+ throw holdsAnotherBranch(resolved.branchRoot, resolved.branch.name, ref);
188
+ }
189
+ await assertOwnRepository(resolved.branchRoot);
190
+ return { branchName: resolved.branch.name, branchPath: resolved.branchRoot };
191
+ }
192
+ if (!byRef && keys.includes(name))
193
+ return keyWorkspace(name, projectDir);
194
+ const ref = refOf(name);
195
+ const branchPath = refKeyPath(ref, projectDir);
196
+ const present = await filePathExists(branchPath).catch((err) => {
197
+ if (namesNoWorkspace(err))
198
+ throw new SyncError(`"${ref}" cannot name a branch workspace.`);
199
+ throw err;
200
+ });
201
+ if (!present)
202
+ return null;
203
+ await assertOwnRepository(branchPath);
204
+ const meta = await readBranchMetadataFile(branchPath);
205
+ const holder = meta ? meta.branch.name : await workspaceHead(branchPath);
206
+ if (holder !== ref)
207
+ throw holdsAnotherBranch(branchPath, holder, ref);
208
+ return { branchName: ref, branchPath };
209
+ }
210
+ /** The refusal for a workspace another ref, sharing `ref`'s key, holds (`holder`; null: detached). */
211
+ function holdsAnotherBranch(branchPath, holder, ref) {
212
+ // mergeIntoWorkspace's temporary branch, left checked out when `both` was interrupted.
213
+ const recovery = holder?.startsWith('sync-incoming-')
214
+ ? ` An interrupted "sync both" left it there: run "git checkout ${ref}" in that workspace.`
215
+ : '';
216
+ return new SyncError(`Branch workspace "${path.basename(branchPath)}" holds ${holder === null ? 'a detached HEAD' : `branch "${holder}"`}, not "${ref}".${recovery}`);
217
+ }
218
+ /** The workspace directory the runtime keys ref `name` to (`resolveBranchPath`). */
219
+ function refKeyPath(name, projectDir) {
220
+ try {
221
+ return resolveBranchPath({ mode: 'dev', branchName: name, basePathOverride: projectDir })
222
+ .branchRoot;
223
+ }
224
+ catch (err) {
225
+ if (namesNoWorkspace(err))
226
+ throw new SyncError(`"${name}" cannot name a branch workspace.`);
227
+ throw err;
228
+ }
229
+ }
230
+ /**
231
+ * Auto-create a minimal workspace for the branch `name` gives (git init + empty commit) so push
232
+ * can copy content in, at the directory the dev server reads for that ref. This is lighter than
233
+ * BranchWorkspaceManager's clone-based setup -- no remote config, content structure or
234
+ * branch.json -- and the dev server adopts it in place when it provisions that ref
235
+ * (BranchWorkspaceManager.provisionBranch).
236
+ */
237
+ async function createSyncWorkspace(name, projectDir) {
238
+ const ref = refOf(name);
239
+ const parsed = parseBranchRef(ref);
240
+ if (!parsed.ok)
241
+ throw new SyncError(`"${ref}" is not a valid branch name: ${parsed.error}`);
242
+ const branchPath = refKeyPath(ref, projectDir);
243
+ await fs.mkdir(branchPath, { recursive: true });
244
+ const wsGit = simpleGit({ baseDir: branchPath });
245
+ await wsGit.init();
246
+ await wsGit.checkoutLocalBranch(ref);
247
+ await wsGit.raw(['commit', '--allow-empty', '-m', 'init: workspace created by sync']);
248
+ // Runtime metadata (.canopy-meta/: branch metadata, comments, content-index
249
+ // marker) must never be staged by sync's `add -A`. Fully provisioned workspaces
250
+ // get this exclude from GitManager.initializeWorkspace; this minimal one needs it
251
+ // too. Sync is dev-mode-only, so this asks the dev strategy for the pattern
252
+ // rather than hardcoding it.
253
+ await ensureGitExcludePattern(branchPath, operatingStrategy('dev').getGitExcludePattern());
254
+ p.log.info(`Created branch workspace: ${path.basename(branchPath)}`);
255
+ return { branchName: ref, branchPath };
256
+ }
81
257
  /**
82
- * Select a branch workspace. Auto-selects if only one exists, prompts if
83
- * multiple, or uses the --branch flag (defaulting to the current git branch).
84
- * When autoCreate is true, creates a new workspace if the branch doesn't exist.
258
+ * Select a branch workspace. Uses the --branch flag (defaulting to the current git branch),
259
+ * resolved as the dev server resolves it ({@link resolveSyncWorkspace}); else auto-selects the
260
+ * only workspace directory, or prompts for one. When autoCreate is true, creates a new workspace
261
+ * if a named branch has none.
85
262
  */
86
- async function selectBranch(options, branchesDir) {
263
+ async function selectBranch(options) {
264
+ const branchesDir = operatingStrategy('dev').getContentBranchesRoot(options.projectDir);
87
265
  let branches = [];
88
266
  if (await filePathExists(branchesDir)) {
89
267
  const entries = await fs.readdir(branchesDir, { withFileTypes: true });
@@ -93,6 +271,7 @@ async function selectBranch(options, branchesDir) {
93
271
  branches = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name);
94
272
  }
95
273
  let branchName = options.branch;
274
+ const byRef = !branchName;
96
275
  if (!branchName) {
97
276
  try {
98
277
  branchName = await detectCurrentBranch(options.projectDir);
@@ -105,48 +284,25 @@ async function selectBranch(options, branchesDir) {
105
284
  if (branches.length === 0) {
106
285
  throw new SyncError('No branch workspaces found and could not detect current git branch. Use --branch to specify.');
107
286
  }
108
- if (branches.length === 1) {
109
- branchName = branches[0];
110
- }
111
- else {
112
- const result = await p.select({
113
- message: 'Which branch?',
114
- options: branches.map((b) => ({ value: b, label: b })),
115
- });
116
- if (p.isCancel(result)) {
117
- p.cancel('Sync cancelled.');
118
- return null;
119
- }
120
- branchName = result;
121
- }
122
- }
123
- // Validate branch name does not escape the branches directory
124
- const branchPath = path.join(branchesDir, branchName);
125
- assertWithinDir(branchPath, branchesDir, '--branch');
126
- if (!branches.includes(branchName)) {
127
- if (options.autoCreate) {
128
- // Auto-create a minimal workspace (git init + empty commit) so push can
129
- // copy content in. This is lighter than BranchWorkspaceManager's clone-based
130
- // setup — no remote config or content structure — but sufficient for sync.
131
- await fs.mkdir(branchPath, { recursive: true });
132
- const wsGit = simpleGit({ baseDir: branchPath });
133
- await wsGit.init();
134
- await wsGit.checkoutLocalBranch(branchName);
135
- await wsGit.raw(['commit', '--allow-empty', '-m', 'init: workspace created by sync']);
136
- // Runtime metadata (.canopy-meta/: branch metadata, comments, content-index
137
- // marker) must never be staged by sync's `add -A`. Fully provisioned workspaces
138
- // get this exclude from GitManager.initializeWorkspace; this minimal one needs it
139
- // too. Sync is dev-mode-only (see branchesDir), so this asks the dev strategy for
140
- // the pattern rather than hardcoding it.
141
- await ensureGitExcludePattern(branchPath, operatingStrategy('dev').getGitExcludePattern());
142
- p.log.info(`Created branch workspace: ${branchName}`);
143
- }
144
- else {
145
- const available = branches.length > 0 ? ` Available branches: ${branches.join(', ')}` : '';
146
- throw new SyncError(`Branch workspace "${branchName}" not found.${available}`);
287
+ if (branches.length === 1)
288
+ return keyWorkspace(branches[0], options.projectDir);
289
+ const result = await p.select({
290
+ message: 'Which branch?',
291
+ options: branches.map((b) => ({ value: b, label: b })),
292
+ });
293
+ if (p.isCancel(result)) {
294
+ p.cancel('Sync cancelled.');
295
+ return null;
147
296
  }
148
- }
149
- return branchName;
297
+ return keyWorkspace(result, options.projectDir);
298
+ }
299
+ const existing = await resolveSyncWorkspace(branchName, options.projectDir, branches, byRef);
300
+ if (existing)
301
+ return existing;
302
+ if (options.autoCreate)
303
+ return createSyncWorkspace(branchName, options.projectDir);
304
+ const available = branches.length > 0 ? ` Available branches: ${branches.join(', ')}` : '';
305
+ throw new SyncError(`Branch workspace "${branchName}" not found.${available}`);
150
306
  }
151
307
  /**
152
308
  * Push: copy working-tree content into a branch workspace and commit.
@@ -155,7 +311,6 @@ async function selectBranch(options, branchesDir) {
155
311
  async function syncPush(options) {
156
312
  const { projectDir } = options;
157
313
  const contentRoot = options.contentRoot || 'content';
158
- const branchesDir = path.join(projectDir, '.canopy-dev', 'content-branches');
159
314
  // Validate source content BEFORE selectBranch, which may create a workspace —
160
315
  // a push with nothing to push must not leave side effects behind.
161
316
  const srcContentDir = path.join(projectDir, contentRoot);
@@ -163,11 +318,10 @@ async function syncPush(options) {
163
318
  if (!(await filePathExists(srcContentDir))) {
164
319
  throw new SyncError(`Content directory not found: ${contentRoot}/ (expected at ${srcContentDir})`);
165
320
  }
166
- const branchName = await selectBranch({ ...options, projectDir, autoCreate: true }, branchesDir);
167
- if (!branchName)
321
+ const ws = await selectBranch({ ...options, projectDir, autoCreate: true });
322
+ if (!ws)
168
323
  return { fileCount: 0 };
169
- const branchPath = path.join(branchesDir, branchName);
170
- assertWithinDir(branchPath, branchesDir, '--branch');
324
+ const { branchName, branchPath } = ws;
171
325
  const wsGit = simpleGit({ baseDir: branchPath });
172
326
  const status = await wsGit.status();
173
327
  if (status.conflicted.length > 0) {
@@ -235,18 +389,20 @@ async function syncPush(options) {
235
389
  * Pull: copy content from a branch workspace back into the working tree.
236
390
  */
237
391
  async function syncPull(options) {
392
+ const ws = await selectBranch({ ...options, projectDir: options.projectDir });
393
+ if (!ws)
394
+ return { fileCount: 0 };
395
+ return pullFromWorkspace(options, ws);
396
+ }
397
+ /** The body of {@link syncPull}, once its workspace is chosen. */
398
+ async function pullFromWorkspace(options, ws) {
238
399
  const { projectDir } = options;
239
400
  const contentRoot = options.contentRoot || 'content';
240
- const branchesDir = path.join(projectDir, '.canopy-dev', 'content-branches');
241
- const branchName = await selectBranch({ ...options, projectDir }, branchesDir);
242
- if (!branchName)
243
- return { fileCount: 0 };
244
- const branchPath = path.join(branchesDir, branchName);
245
- assertWithinDir(branchPath, branchesDir, '--branch');
401
+ const { branchName, branchPath } = ws;
246
402
  const branchContentDir = path.join(branchPath, contentRoot);
247
403
  assertWithinDir(branchContentDir, branchPath, '--content-root');
248
404
  if (!(await filePathExists(branchContentDir))) {
249
- throw new SyncError(`Content directory not found in branch workspace: ${branchName}/${contentRoot}`);
405
+ throw new SyncError(`Content directory not found in branch workspace: ${path.basename(branchPath)}/${contentRoot}`);
250
406
  }
251
407
  p.log.step(`Pulling content from branch: ${branchName}`);
252
408
  const destContentDir = path.join(projectDir, contentRoot);
@@ -342,18 +498,16 @@ async function syncPull(options) {
342
498
  async function syncBoth(options) {
343
499
  const { projectDir } = options;
344
500
  const contentRoot = options.contentRoot || 'content';
345
- const branchesDir = path.join(projectDir, '.canopy-dev', 'content-branches');
346
501
  // Validate source content BEFORE selectBranch, which may create a workspace
347
502
  const srcContentDir = path.join(projectDir, contentRoot);
348
503
  assertWithinDir(srcContentDir, projectDir, '--content-root');
349
504
  if (!(await filePathExists(srcContentDir))) {
350
505
  throw new SyncError(`Content directory not found: ${contentRoot}/ (expected at ${srcContentDir})`);
351
506
  }
352
- const branchName = await selectBranch({ ...options, projectDir, autoCreate: true }, branchesDir);
353
- if (!branchName)
507
+ const ws = await selectBranch({ ...options, projectDir, autoCreate: true });
508
+ if (!ws)
354
509
  return { pushed: 0, pulled: 0 };
355
- const branchPath = path.join(branchesDir, branchName);
356
- assertWithinDir(branchPath, branchesDir, '--branch');
510
+ const { branchName, branchPath } = ws;
357
511
  const wsGit = simpleGit({ baseDir: branchPath });
358
512
  const status = await wsGit.status();
359
513
  if (status.conflicted.length > 0) {
@@ -375,7 +529,7 @@ async function syncBoth(options) {
375
529
  }
376
530
  /** The locked body of {@link syncBoth}: everything that rewrites the workspace. */
377
531
  async function mergeIntoWorkspace(options, ws) {
378
- const { branchName, branchPath, srcContentDir, contentRoot } = ws;
532
+ const { branchPath, srcContentDir, contentRoot } = ws;
379
533
  const wsGit = simpleGit({ baseDir: branchPath });
380
534
  const status = await wsGit.status();
381
535
  // Auto-commit uncommitted workspace changes (preserves editor work for the merge)
@@ -421,7 +575,7 @@ async function mergeIntoWorkspace(options, ws) {
421
575
  await wsGit.checkout(currentBranch);
422
576
  await wsGit.raw(['branch', '-D', incomingBranch]);
423
577
  p.log.info('No working-tree changes to merge — pulling editor changes only');
424
- const pullResult = await syncPull({ ...options, branch: branchName, force: true });
578
+ const pullResult = await pullFromWorkspace({ ...options, force: true }, ws);
425
579
  return { pushed: 0, pulled: pullResult.fileCount };
426
580
  }
427
581
  await wsGit.commit('sync: incoming working-tree changes');
@@ -446,8 +600,9 @@ async function mergeIntoWorkspace(options, ws) {
446
600
  p.log.error(` ${file}`);
447
601
  }
448
602
  p.log.info('The branch workspace is now in a merge state.');
449
- p.log.info('Resolve the conflicts in the workspace, then run: canopycms sync pull');
450
- p.log.info('Or abort the merge with: canopycms sync abort');
603
+ const branchFlag = options.branch ? ` --branch ${options.branch}` : '';
604
+ p.log.info(`Resolve the conflicts in the workspace, then run: canopycms sync pull${branchFlag}`);
605
+ p.log.info(`Or abort the merge with: canopycms sync abort${branchFlag}`);
451
606
  // Clean up the incoming branch (leave merge state for user resolution)
452
607
  await wsGit.raw(['branch', '-D', incomingBranch]).catch(() => { });
453
608
  return { pushed: 0, pulled: 0 };
@@ -459,7 +614,7 @@ async function mergeIntoWorkspace(options, ws) {
459
614
  await wsGit.raw(['branch', '-D', incomingBranch]);
460
615
  await wsGit.tag(['-f', SYNC_BASE_TAG]);
461
616
  p.log.success('Merged working-tree changes with editor changes');
462
- const pullResult = await syncPull({ ...options, branch: branchName, force: true });
617
+ const pullResult = await pullFromWorkspace({ ...options, force: true }, ws);
463
618
  return { pushed: incomingFiles.length, pulled: pullResult.fileCount };
464
619
  }
465
620
  finally {
@@ -471,13 +626,10 @@ async function mergeIntoWorkspace(options, ws) {
471
626
  * Restores the workspace to its pre-merge state.
472
627
  */
473
628
  async function syncAbort(options) {
474
- const { projectDir } = options;
475
- const branchesDir = path.join(projectDir, '.canopy-dev', 'content-branches');
476
- const branchName = await selectBranch({ ...options, projectDir }, branchesDir);
477
- if (!branchName)
629
+ const ws = await selectBranch({ ...options, projectDir: options.projectDir });
630
+ if (!ws)
478
631
  return;
479
- const branchPath = path.join(branchesDir, branchName);
480
- assertWithinDir(branchPath, branchesDir, '--branch');
632
+ const { branchName, branchPath } = ws;
481
633
  if (!(await abortWorkspaceMerge(branchPath, branchName))) {
482
634
  p.log.info(`Branch workspace "${branchName}" is not in a merge state — nothing to abort.`);
483
635
  return;
@@ -36,8 +36,9 @@ export interface CanopyServices {
36
36
  config: CanopyConfig;
37
37
  /**
38
38
  * Re-detect the active branch from git HEAD (dev mode only, cached 5s).
39
- * Silently updates config if the branch changed — only affects non-editor
40
- * content serving since the editor is pinned to its own branch via URL params.
39
+ * Silently updates config if the branch changed — non-editor content serving follows it, as
40
+ * does which branch gets dev's active-branch access grant (authorization/branch.ts); the editor
41
+ * is pinned to its own branch via URL params.
41
42
  */
42
43
  refreshActiveBranch: () => Promise<void>;
43
44
  /**
@@ -8,5 +8,7 @@ export declare const SYSTEM_USER_ID = "canopycms-system";
8
8
  export declare const CONTENT_READER_USER_ID = "canopycms-content-reader";
9
9
  /** Owner recorded for the active branch a server context provisions (context.ts). */
10
10
  export declare const CONTEXT_USER_ID = "canopycms-context";
11
+ /** Owner recorded for the active branch the AI content route provisions (ai/resolve-branch.ts). */
12
+ export declare const AI_USER_ID = "canopycms-ai";
11
13
  /** True for a user id CanopyCMS acts as; the editor shows these as "CanopyCMS bot". */
12
14
  export declare const isSystemUserId: (userId: string) => boolean;
@@ -8,5 +8,10 @@ export const SYSTEM_USER_ID = 'canopycms-system';
8
8
  export const CONTENT_READER_USER_ID = 'canopycms-content-reader';
9
9
  /** Owner recorded for the active branch a server context provisions (context.ts). */
10
10
  export const CONTEXT_USER_ID = 'canopycms-context';
11
+ /** Owner recorded for the active branch the AI content route provisions (ai/resolve-branch.ts). */
12
+ export const AI_USER_ID = 'canopycms-ai';
11
13
  /** True for a user id CanopyCMS acts as; the editor shows these as "CanopyCMS bot". */
12
- export const isSystemUserId = (userId) => userId === SYSTEM_USER_ID || userId === CONTENT_READER_USER_ID || userId === CONTEXT_USER_ID;
14
+ export const isSystemUserId = (userId) => userId === SYSTEM_USER_ID ||
15
+ userId === CONTENT_READER_USER_ID ||
16
+ userId === CONTEXT_USER_ID ||
17
+ userId === AI_USER_ID;
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CANOPYCMS_VERSION = "0.8.2";
1
+ export declare const CANOPYCMS_VERSION = "0.8.3";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
2
2
  // tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
3
- export const CANOPYCMS_VERSION = '0.8.2';
3
+ export const CANOPYCMS_VERSION = '0.8.3';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
3
3
  "name": "canopycms",
4
- "version": "0.8.2",
4
+ "version": "0.8.3",
5
5
  "description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
6
6
  "license": "MIT",
7
7
  "repository": {