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/CHANGELOG.md +65 -47
- package/dist/ai/resolve-branch.js +2 -1
- package/dist/authorization/branch.d.ts +10 -4
- package/dist/authorization/branch.js +24 -4
- package/dist/authorization/types.d.ts +1 -1
- package/dist/cli/cli.js +1893 -1606
- package/dist/cli/configured-asset-store.js +2 -8
- package/dist/cli/generate-ai-content.js +4 -1
- package/dist/cli/site-config.d.ts +9 -0
- package/dist/cli/site-config.js +17 -0
- package/dist/cli/sync.js +226 -74
- package/dist/services.d.ts +3 -2
- package/dist/system-users.d.ts +2 -0
- package/dist/system-users.js +6 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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 =
|
|
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:
|
|
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.
|
|
83
|
-
*
|
|
84
|
-
* When autoCreate is true, creates a new workspace
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
167
|
-
if (!
|
|
321
|
+
const ws = await selectBranch({ ...options, projectDir, autoCreate: true });
|
|
322
|
+
if (!ws)
|
|
168
323
|
return { fileCount: 0 };
|
|
169
|
-
const branchPath =
|
|
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
|
|
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: ${
|
|
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
|
|
353
|
-
if (!
|
|
507
|
+
const ws = await selectBranch({ ...options, projectDir, autoCreate: true });
|
|
508
|
+
if (!ws)
|
|
354
509
|
return { pushed: 0, pulled: 0 };
|
|
355
|
-
const branchPath =
|
|
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 {
|
|
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
|
|
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
|
-
|
|
450
|
-
p.log.info(
|
|
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
|
|
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 }
|
|
475
|
-
|
|
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 =
|
|
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;
|
package/dist/services.d.ts
CHANGED
|
@@ -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 —
|
|
40
|
-
*
|
|
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
|
/**
|
package/dist/system-users.d.ts
CHANGED
|
@@ -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;
|
package/dist/system-users.js
CHANGED
|
@@ -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 ||
|
|
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.
|
|
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.
|
|
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.
|
|
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": {
|