canopycms 0.0.67 → 0.0.68-int.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +12 -7
- package/dist/api/admin.d.ts +21 -9
- package/dist/api/admin.js +18 -2
- package/dist/api/branch-status.js +19 -4
- package/dist/api/branch.js +11 -11
- package/dist/api/client.d.ts +12 -0
- package/dist/api/client.js +34 -11
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +10 -1
- package/dist/api/github-sync.js +20 -10
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.js +3 -3
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-workspace.js +8 -3
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/cli.js +1070 -388
- package/dist/cli/generate-ai-content.js +537 -150
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +21 -11
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +6 -0
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/config/schemas/config.d.ts +11 -3
- package/dist/config/schemas/config.js +5 -1
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/url.d.ts +2 -0
- package/dist/config/schemas/url.js +7 -0
- package/dist/config/types.d.ts +28 -4
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -1
- package/dist/editor/Editor.d.ts +2 -0
- package/dist/editor/Editor.js +6 -5
- package/dist/editor/FormRenderer.js +20 -5
- package/dist/editor/admin/SystemHealthPanel.js +45 -7
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/editor-utils.d.ts +17 -15
- package/dist/editor/editor-utils.js +43 -36
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/hooks/useCommentSystem.js +5 -1
- package/dist/editor/hooks/useDraftManager.d.ts +1 -1
- package/dist/editor/hooks/useDraftManager.js +2 -1
- package/dist/editor/hooks/useEntriesData.js +2 -2
- package/dist/editor/hooks/useEntryManager.js +17 -11
- package/dist/editor/preview-bridge.js +4 -1
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +79 -5
- package/dist/git-manager.js +308 -18
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +35 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/worker-not-ready.d.ts +9 -0
- package/dist/http/worker-not-ready.js +17 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +0 -3
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -1
- package/dist/paths/branch.js +15 -2
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +48 -22
- package/dist/schema/schema-store.js +66 -22
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.js +42 -4
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +28 -0
- package/dist/utils/git.js +38 -0
- package/dist/utils/provisioning-lock.d.ts +15 -5
- package/dist/utils/provisioning-lock.js +31 -11
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.js +23 -2
- package/dist/worker/git-sync.d.ts +8 -2
- package/dist/worker/git-sync.js +138 -24
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +83 -12
- package/dist/worker/task-runner.js +39 -4
- package/package.json +1 -1
package/dist/content-tree.js
CHANGED
|
@@ -70,11 +70,11 @@ export async function buildContentTree(branchRoot, flatSchema, contentRootName,
|
|
|
70
70
|
// read once for the entire tree rather than once per collection. Null unless opted in —
|
|
71
71
|
// see the `resolveReferences` option above.
|
|
72
72
|
const resolver = options?.resolveReferences
|
|
73
|
-
? createReferenceResolver(branchRoot, flatSchema, contentRootName)
|
|
73
|
+
? createReferenceResolver(branchRoot, flatSchema, contentRootName, visibility)
|
|
74
74
|
: null;
|
|
75
75
|
const listVisibleEntries = async (collection) => {
|
|
76
76
|
const entries = await listCollectionEntries(branchRoot, collection);
|
|
77
|
-
const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.
|
|
77
|
+
const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.logicalPath)) : entries;
|
|
78
78
|
// Resolved here rather than at the two node-building sites so BOTH inherit it — the same
|
|
79
79
|
// reason the ACL filter lives here. A denied entry is filtered above and never resolved.
|
|
80
80
|
return resolver ? resolveCollectionItemReferences(visible, collection, resolver) : visible;
|
package/dist/context.d.ts
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* Only request-time reads on a server deployment resolve a branch workspace —
|
|
16
16
|
* in dev, the clone under `.canopy-dev/content-branches/`.
|
|
17
17
|
*
|
|
18
|
+
* A `branch` arrives from the request (a page passing `?branch=`), so only the
|
|
19
|
+
* active branch is ever provisioned here; any other name must already exist and
|
|
20
|
+
* pass the user's branch access before its files are read (`resolveBranch`).
|
|
21
|
+
*
|
|
18
22
|
* The Next.js adapter wraps this in React `cache()` for per-request memoization
|
|
19
23
|
* (`canopycms-next/src/context-wrapper.ts`).
|
|
20
24
|
*
|
|
@@ -58,8 +62,12 @@ export interface CanopyBuildContext {
|
|
|
58
62
|
* `meta.indexEntry` handed to `extract`, and a collection whose children are
|
|
59
63
|
* all filtered out is pruned. On the build context and on static deployments
|
|
60
64
|
* nothing is filtered — the synthetic admin sees everything.
|
|
65
|
+
*
|
|
66
|
+
* Branch: the `branch` option behaves as on `listEntries`.
|
|
61
67
|
*/
|
|
62
|
-
buildContentTree: <T = unknown, TEntryTypes = DefaultEntryTypes>(options?: BuildContentTreeOptions<T, TEntryTypes>
|
|
68
|
+
buildContentTree: <T = unknown, TEntryTypes = DefaultEntryTypes>(options?: BuildContentTreeOptions<T, TEntryTypes> & {
|
|
69
|
+
branch?: string;
|
|
70
|
+
}) => Promise<ContentTreeNode<T>[]>;
|
|
63
71
|
/**
|
|
64
72
|
* Every content entry as a flat array.
|
|
65
73
|
*
|
|
@@ -67,13 +75,18 @@ export interface CanopyBuildContext {
|
|
|
67
75
|
* cannot `read` are omitted before `extract` runs; on the build context and
|
|
68
76
|
* static deployments nothing is filtered.
|
|
69
77
|
*
|
|
70
|
-
* Branch:
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
78
|
+
* Branch: with no `branch` it lists the active branch
|
|
79
|
+
* (`defaultActiveBranch ?? defaultBaseBranch`): git HEAD in `dev` when that is
|
|
80
|
+
* unset, otherwise usually the base branch in `prod`. Pass the preview
|
|
81
|
+
* iframe's `?branch=` as `branch`, as for `read`, so an index page previewed
|
|
82
|
+
* on a content branch lists that branch. Any other branch must already exist
|
|
83
|
+
* and, on the request-scoped context, be readable by the user; otherwise, or
|
|
84
|
+
* for an array, the result is empty. It selects nothing at build
|
|
85
|
+
* time or on static deployments.
|
|
75
86
|
*/
|
|
76
|
-
listEntries: <T = Record<string, unknown>>(options?: ListEntriesOptions<T>
|
|
87
|
+
listEntries: <T = Record<string, unknown>>(options?: ListEntriesOptions<T> & {
|
|
88
|
+
branch?: string;
|
|
89
|
+
}) => Promise<ListEntriesItem<T>[]>;
|
|
77
90
|
/**
|
|
78
91
|
* Content reader, with the auth context applied automatically (admin at build
|
|
79
92
|
* time).
|
|
@@ -83,6 +96,11 @@ export interface CanopyBuildContext {
|
|
|
83
96
|
* output, since it reveals the deployment's filesystem layout (home dir, EFS
|
|
84
97
|
* mount, branch name). It is for build-time reads of colocated artifacts, e.g.
|
|
85
98
|
* `fs.readFile(path.join(path.dirname(result.meta.physicalPath), 'profile.json'))`.
|
|
99
|
+
*
|
|
100
|
+
* Branch: with no `branch` it reads the active branch, provisioning its workspace if missing.
|
|
101
|
+
* Any other branch must already exist and, on the request-scoped context, be readable by the
|
|
102
|
+
* user; otherwise, or for an array, it throws NOT_FOUND, so a hidden branch reads as a missing
|
|
103
|
+
* one. It selects nothing at build time or on static deployments.
|
|
86
104
|
*/
|
|
87
105
|
read: <T = unknown>(input: {
|
|
88
106
|
entryPath: string;
|
|
@@ -109,8 +127,8 @@ export interface CanopyBuildContext {
|
|
|
109
127
|
*
|
|
110
128
|
* Null when nothing matches — a collection URL with no index entry (use
|
|
111
129
|
* buildContentTree), a non-entry path like `/favicon.ico` the slug validator
|
|
112
|
-
* rejects, or a path the user may not read, so a
|
|
113
|
-
* a 404 via `notFound()
|
|
130
|
+
* rejects, a missing branch, or a path or branch the user may not read, so a
|
|
131
|
+
* denial renders as a 404 via `notFound()`, not a 500. Strict `read()` throws.
|
|
114
132
|
*
|
|
115
133
|
* `meta.physicalPath` is **server-only**, for the reason given on `read`.
|
|
116
134
|
* `meta.entryType`/`entryId` come free with path resolution, so one catch-all
|
package/dist/context.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { isDeployedStatic, isBuildMode, STATIC_DEPLOY_USER } from './build-mode.js';
|
|
1
|
+
import { isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER } from './build-mode.js';
|
|
2
2
|
import { createContentReader } from './content-reader.js';
|
|
3
3
|
import { ContentStoreError } from './content-store.js';
|
|
4
|
-
import { createLogicalPath, parseSlug, resolveBranchPaths } from './paths/index.js';
|
|
4
|
+
import { createLogicalPath, namesNoWorkspace, parseSlug, resolveBranchPaths, } from './paths/index.js';
|
|
5
5
|
import { resolveUrlPathCandidates } from './url-path-resolver.js';
|
|
6
|
-
import { loadOrCreateBranchContext } from './branch-workspace.js';
|
|
6
|
+
import { loadBranchContext, loadOrCreateBranchContext } from './branch-workspace.js';
|
|
7
7
|
import { buildContentTree as buildContentTreeImpl, } from './content-tree.js';
|
|
8
8
|
import { listEntries as listEntriesImpl, } from './content-listing.js';
|
|
9
9
|
import { createDebugLogger } from './utils/debug.js';
|
|
@@ -22,6 +22,30 @@ function isLookupFailure(err) {
|
|
|
22
22
|
function isPageSwallowable(err) {
|
|
23
23
|
return isLookupFailure(err) || err.code === 'FORBIDDEN';
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* A `branch` option is typed `string` but usually handed over from untyped
|
|
27
|
+
* request input: a repeated `?branch=` arrives as an array, which names no
|
|
28
|
+
* branch, and `URLSearchParams.get` gives null for an absent one, which is no
|
|
29
|
+
* `branch` at all.
|
|
30
|
+
*/
|
|
31
|
+
function isBranchOption(branch) {
|
|
32
|
+
return branch == null || typeof branch === 'string';
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A branch that already has a workspace, or null. A name that cannot name one
|
|
36
|
+
* is null too (`namesNoWorkspace`): it arrives from the request, so it reads as
|
|
37
|
+
* not-found rather than failing the page.
|
|
38
|
+
*/
|
|
39
|
+
async function loadExistingBranch(branchName, mode) {
|
|
40
|
+
try {
|
|
41
|
+
return await loadBranchContext({ branchName, mode });
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (namesNoWorkspace(err))
|
|
45
|
+
return null;
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
25
49
|
/**
|
|
26
50
|
* Create a Canopy context managing auth + content reading. Framework-agnostic:
|
|
27
51
|
* the adapter supplies extractUser.
|
|
@@ -46,7 +70,66 @@ export function createCanopyContext(options) {
|
|
|
46
70
|
// switching branches mid-session updates what getCanopy() serves.
|
|
47
71
|
await services.refreshActiveBranch();
|
|
48
72
|
const user = await getUser();
|
|
49
|
-
|
|
73
|
+
// Read once, so every read and listing in this request agrees on it even if
|
|
74
|
+
// a concurrent request's refreshActiveBranch() replaces services.config.
|
|
75
|
+
const activeBranch = services.config.defaultActiveBranch ?? services.config.defaultBaseBranch ?? 'main';
|
|
76
|
+
// No ACL enforcement at build time, on static deployments, or for the
|
|
77
|
+
// synthetic admin. For the last this is load-bearing, not an optimization:
|
|
78
|
+
// that user has unconditional access anyway (path checks bypass entirely for
|
|
79
|
+
// an Admins-group user — authorization/path.ts), but BUILDING a path checker
|
|
80
|
+
// costs a getSettingsBranchRoot() call, which in modes with a separate
|
|
81
|
+
// settings branch provisions that branch's git workspace: an EFS round trip
|
|
82
|
+
// in prod. `createBuildCanopy` (build-canopy.ts) runs outside a request or
|
|
83
|
+
// Next.js build phase, so neither other guard fires for it, and without this
|
|
84
|
+
// one every such script pays for a settings-workspace clone it never needed —
|
|
85
|
+
// and hard-fails where that workspace cannot be provisioned. Compared by
|
|
86
|
+
// reference to the STATIC_DEPLOY_USER singleton, not by group membership, so
|
|
87
|
+
// a real authenticated admin at request time still goes through the real
|
|
88
|
+
// check.
|
|
89
|
+
const enforceAcls = !(isDeployedStatic(services.config) ||
|
|
90
|
+
isBuildMode() ||
|
|
91
|
+
user === STATIC_DEPLOY_USER);
|
|
92
|
+
/**
|
|
93
|
+
* `branch` when it names a branch other than the active one, else undefined:
|
|
94
|
+
* no `branch`, an empty one, the active one named explicitly, or any name
|
|
95
|
+
* when reading from the checkout, where it selects nothing.
|
|
96
|
+
*/
|
|
97
|
+
const otherBranch = (branch) => branch && branch !== activeBranch && !readsFromCheckout(services.config) ? branch : undefined;
|
|
98
|
+
/**
|
|
99
|
+
* The workspace for the active branch (undefined) or another named one, or
|
|
100
|
+
* null when it yields nothing this user may see.
|
|
101
|
+
*
|
|
102
|
+
* The active branch is provisioned if missing. Any other name is load-only,
|
|
103
|
+
* never provisioned — a name with no workspace is null (`loadExistingBranch`) — and its branch
|
|
104
|
+
* access, held in its own metadata, is checked here, before its schema or
|
|
105
|
+
* content files are read, so a denied branch is indistinguishable from a
|
|
106
|
+
* missing one. The active branch's access is left to the caller: a listing
|
|
107
|
+
* of it lists nothing, and a strict `read` of it throws FORBIDDEN.
|
|
108
|
+
*/
|
|
109
|
+
const resolveBranch = async (requested) => {
|
|
110
|
+
if (requested === undefined) {
|
|
111
|
+
return loadOrCreateBranchContext({
|
|
112
|
+
config: services.config,
|
|
113
|
+
branchName: activeBranch,
|
|
114
|
+
mode: services.config.mode,
|
|
115
|
+
createdBy: 'canopycms-context',
|
|
116
|
+
remoteUrl: services.config.defaultRemoteUrl,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const context = await loadExistingBranch(requested, services.config.mode);
|
|
120
|
+
if (!context)
|
|
121
|
+
return null;
|
|
122
|
+
if (enforceAcls && !services.checkBranchAccess(context, user).allowed)
|
|
123
|
+
return null;
|
|
124
|
+
return context;
|
|
125
|
+
};
|
|
126
|
+
// The reader resolves every branch through resolveBranch; a null reads as
|
|
127
|
+
// NOT_FOUND. Reads from the checkout bypass the resolver inside the reader.
|
|
128
|
+
const baseReader = createContentReader({
|
|
129
|
+
services,
|
|
130
|
+
defaultBranch: activeBranch,
|
|
131
|
+
getBranchContext: (branchName) => resolveBranch(otherBranch(branchName)),
|
|
132
|
+
});
|
|
50
133
|
// Injects the user and validates strings → branded types at this boundary.
|
|
51
134
|
// `extra` carries options that are NOT part of the public `read` surface —
|
|
52
135
|
// today just readByUrlPath's URL-addressability gate. A separate inner
|
|
@@ -63,10 +146,13 @@ export function createCanopyContext(options) {
|
|
|
63
146
|
}
|
|
64
147
|
slug = slugResult.slug;
|
|
65
148
|
}
|
|
149
|
+
if (!isBranchOption(input.branch)) {
|
|
150
|
+
throw new ContentStoreError(`Branch not found: ${String(input.branch)}`, 'NOT_FOUND');
|
|
151
|
+
}
|
|
66
152
|
const readInput = {
|
|
67
153
|
entryPath,
|
|
68
154
|
slug,
|
|
69
|
-
branch: input.branch,
|
|
155
|
+
branch: input.branch ?? undefined,
|
|
70
156
|
user,
|
|
71
157
|
resolveReferences: input.resolveReferences ?? true,
|
|
72
158
|
...extra,
|
|
@@ -119,80 +205,78 @@ export function createCanopyContext(options) {
|
|
|
119
205
|
}
|
|
120
206
|
return null;
|
|
121
207
|
};
|
|
122
|
-
/** Resolve branch workspace and schema — shared by buildContentTree and listEntries. Memoized per getContext call. */
|
|
123
|
-
let schemaContextPromise = null;
|
|
124
|
-
const resolveSchemaContextImpl = async () => {
|
|
125
|
-
const operatingMode = services.config.mode;
|
|
126
|
-
const defaultBranch = services.config.defaultActiveBranch ?? services.config.defaultBaseBranch ?? 'main';
|
|
127
|
-
const branchContext = await loadOrCreateBranchContext({
|
|
128
|
-
config: services.config,
|
|
129
|
-
branchName: defaultBranch,
|
|
130
|
-
mode: operatingMode,
|
|
131
|
-
createdBy: 'canopycms-context',
|
|
132
|
-
remoteUrl: services.config.defaultRemoteUrl,
|
|
133
|
-
});
|
|
134
|
-
const { branchRoot } = resolveBranchPaths(branchContext, operatingMode);
|
|
135
|
-
const contentRootName = services.config.contentRoot || 'content';
|
|
136
|
-
const { flatSchema } = await services.branchSchemaCache.getSchema(branchRoot, services.entrySchemaRegistry, contentRootName);
|
|
137
|
-
return { branchContext, branchRoot, flatSchema, contentRootName };
|
|
138
|
-
};
|
|
139
|
-
const resolveSchemaContext = () => {
|
|
140
|
-
if (!schemaContextPromise) {
|
|
141
|
-
schemaContextPromise = resolveSchemaContextImpl();
|
|
142
|
-
}
|
|
143
|
-
return schemaContextPromise;
|
|
144
|
-
};
|
|
145
208
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* `services.createContentAccessChecker` is the shared batch primitive
|
|
152
|
-
* (api/entries.ts uses it too): it resolves the request-constant work —
|
|
153
|
-
* branch access, the settings/permissions root, the rule set — once, and
|
|
154
|
-
* returns a synchronous per-path check, so the per-entry cost is an admin
|
|
155
|
-
* short-circuit or one minimatch per configured rule, with no extra I/O.
|
|
156
|
-
*
|
|
157
|
-
* The empty object (no predicate → unfiltered) at build time, on static
|
|
158
|
-
* deployments, and for the synthetic admin is load-bearing, not an
|
|
159
|
-
* optimization. That user has unconditional access anyway (path checks
|
|
160
|
-
* bypass entirely for an Admins-group user — authorization/path.ts), but
|
|
161
|
-
* BUILDING the checker costs a getSettingsBranchRoot() call, which in modes
|
|
162
|
-
* with a separate settings branch provisions that branch's git workspace: an
|
|
163
|
-
* EFS round trip in prod. `createBuildCanopy` (build-canopy.ts) runs outside
|
|
164
|
-
* a request or Next.js build phase, so neither other guard fires for it, and
|
|
165
|
-
* without this one every such script pays for a settings-workspace clone it
|
|
166
|
-
* never needed — and hard-fails where that workspace cannot be provisioned.
|
|
209
|
+
* What a batch read (listEntries / buildContentTree) lists from: the branch's
|
|
210
|
+
* schema plus its path-ACL predicate, or null when the branch yields nothing
|
|
211
|
+
* this user may see. The branch resolves as for `read` (`resolveBranch`). At
|
|
212
|
+
* build time and on static deployments `branch` selects nothing, as for
|
|
213
|
+
* `read` (see the module doc).
|
|
167
214
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* Deliberately NOT wrapped in try/catch — createContentAccessChecker is
|
|
173
|
-
* fail-loud by contract, and swallowing would serve an unfiltered listing.
|
|
215
|
+
* Memoized per getContext call and per branch name, on the in-flight
|
|
216
|
+
* promise; a null result is memoized too, so one request answers a given
|
|
217
|
+
* name consistently. The memo is request-scoped, so it carries no
|
|
218
|
+
* generation protocol (docs/concurrency.md, "Adding a call-scoped memo").
|
|
174
219
|
*/
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
178
|
-
return
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
|
|
220
|
+
const listingSources = new Map();
|
|
221
|
+
const resolveListingSource = (branch) => {
|
|
222
|
+
if (!isBranchOption(branch))
|
|
223
|
+
return Promise.resolve(null);
|
|
224
|
+
const requested = otherBranch(branch);
|
|
225
|
+
const key = requested ?? activeBranch;
|
|
226
|
+
let source = listingSources.get(key);
|
|
227
|
+
if (!source) {
|
|
228
|
+
source = resolveListingSourceImpl(requested);
|
|
229
|
+
listingSources.set(key, source);
|
|
230
|
+
}
|
|
231
|
+
return source;
|
|
182
232
|
};
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
233
|
+
const resolveListingSourceImpl = async (requested) => {
|
|
234
|
+
const operatingMode = services.config.mode;
|
|
235
|
+
const branchContext = await resolveBranch(requested);
|
|
236
|
+
if (!branchContext)
|
|
237
|
+
return null;
|
|
238
|
+
// resolveBranch has already checked any other branch.
|
|
239
|
+
if (requested === undefined &&
|
|
240
|
+
enforceAcls &&
|
|
241
|
+
!services.checkBranchAccess(branchContext, user).allowed) {
|
|
242
|
+
return null;
|
|
186
243
|
}
|
|
187
|
-
|
|
244
|
+
const { branchRoot } = resolveBranchPaths(branchContext, operatingMode);
|
|
245
|
+
const contentRootName = services.config.contentRoot || 'content';
|
|
246
|
+
const { flatSchema } = await services.branchSchemaCache.getSchema(branchRoot, services.entrySchemaRegistry, contentRootName);
|
|
247
|
+
if (!enforceAcls)
|
|
248
|
+
return { branchRoot, flatSchema, contentRootName, visibility: {} };
|
|
249
|
+
// The path-ACL predicate keeps an unfiltered listing on this ACL-enforcing
|
|
250
|
+
// context from disclosing entry `data` for paths the user cannot `read()`.
|
|
251
|
+
// `services.createContentAccessChecker` is the shared batch primitive
|
|
252
|
+
// (api/entries.ts uses it too): it resolves the request-constant work —
|
|
253
|
+
// branch access, the settings/permissions root, the rule set — once, and
|
|
254
|
+
// returns a synchronous per-path check, so the per-entry cost is an admin
|
|
255
|
+
// short-circuit or one minimatch per configured rule, with no extra I/O.
|
|
256
|
+
//
|
|
257
|
+
// Deliberately NOT wrapped in try/catch — createContentAccessChecker is
|
|
258
|
+
// fail-loud by contract, and swallowing would serve an unfiltered listing.
|
|
259
|
+
const checkAccess = await services.createContentAccessChecker(branchContext, branchRoot, user);
|
|
260
|
+
return {
|
|
261
|
+
branchRoot,
|
|
262
|
+
flatSchema,
|
|
263
|
+
contentRootName,
|
|
264
|
+
visibility: { shouldInclude: (logicalPath) => checkAccess(logicalPath, 'read').allowed },
|
|
265
|
+
};
|
|
188
266
|
};
|
|
189
267
|
const buildContentTree = async (options) => {
|
|
190
|
-
const {
|
|
191
|
-
|
|
268
|
+
const { branch, ...treeOptions } = options ?? {};
|
|
269
|
+
const source = await resolveListingSource(branch);
|
|
270
|
+
if (!source)
|
|
271
|
+
return [];
|
|
272
|
+
return buildContentTreeImpl(source.branchRoot, source.flatSchema, source.contentRootName, treeOptions, source.visibility);
|
|
192
273
|
};
|
|
193
274
|
const listEntries = async (options) => {
|
|
194
|
-
const {
|
|
195
|
-
|
|
275
|
+
const { branch, ...listOptions } = options ?? {};
|
|
276
|
+
const source = await resolveListingSource(branch);
|
|
277
|
+
if (!source)
|
|
278
|
+
return [];
|
|
279
|
+
return listEntriesImpl(source.branchRoot, source.flatSchema, source.contentRootName, listOptions, source.visibility);
|
|
196
280
|
};
|
|
197
281
|
return {
|
|
198
282
|
read,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import type { CanopyClientConfig } from '../config/index.js';
|
|
3
3
|
import type { EditorProps } from './Editor.js';
|
|
4
|
-
export interface CanopyEditorProps extends Omit<EditorProps, 'collections' | 'previewBaseByCollection' | 'title' | 'subtitle' | 'themeOptions' | 'entries' | 'contentRoot' | 'operatingMode'> {
|
|
4
|
+
export interface CanopyEditorProps extends Omit<EditorProps, 'collections' | 'previewBaseByCollection' | 'previewPrefix' | 'title' | 'subtitle' | 'themeOptions' | 'entries' | 'contentRoot' | 'operatingMode'> {
|
|
5
5
|
config: CanopyClientConfig;
|
|
6
6
|
entries?: EditorProps['entries'];
|
|
7
7
|
}
|
|
@@ -19,6 +19,6 @@ export const CanopyEditor = ({ config, entries = [], initialSelectedId, initialV
|
|
|
19
19
|
const resolvedTheme = config.editor?.theme ?? undefined;
|
|
20
20
|
// The gate owns the SWRProvider (keyed by user id), so it sits between the API client and the
|
|
21
21
|
// editor: see EditorAuthGate for what a signed-out or lapsed session renders.
|
|
22
|
-
return (_jsx(ApiClientProvider, { basePath: config.basePath, children: _jsx(EditorAuthGate, { SignInComponent: config.editor?.SignInComponent, themeOptions: resolvedTheme, children: _jsx(Editor, { entries: entries, title: resolvedTitle, subtitle: resolvedSubtitle, branchName: resolvedBranchName, operatingMode: config.mode, initialSelectedId: initialSelectedId, initialValues: initialValues, renderPreview: renderPreview, onCreateEntry: onCreateEntry, customRenderers: customRenderers, collections: collections, contentRoot: config.contentRoot, entryLinkUrl: config.entryLinkUrl, previewBaseByCollection: previewBase, assetBaseUrl: config.assetBaseUrl, basePath: config.basePath, themeOptions: resolvedTheme, AccountComponent: config.editor?.AccountComponent, onAccountClick: config.editor?.onAccountClick, onLogoutClick: config.editor?.onLogoutClick }) }) }));
|
|
22
|
+
return (_jsx(ApiClientProvider, { basePath: config.basePath, children: _jsx(EditorAuthGate, { SignInComponent: config.editor?.SignInComponent, themeOptions: resolvedTheme, children: _jsx(Editor, { entries: entries, title: resolvedTitle, subtitle: resolvedSubtitle, branchName: resolvedBranchName, operatingMode: config.mode, initialSelectedId: initialSelectedId, initialValues: initialValues, renderPreview: renderPreview, onCreateEntry: onCreateEntry, customRenderers: customRenderers, collections: collections, contentRoot: config.contentRoot, entryLinkUrl: config.entryLinkUrl, previewBaseByCollection: previewBase, previewPrefix: config.editor?.previewPrefix, assetBaseUrl: config.assetBaseUrl, basePath: config.basePath, themeOptions: resolvedTheme, AccountComponent: config.editor?.AccountComponent, onAccountClick: config.editor?.onAccountClick, onLogoutClick: config.editor?.onLogoutClick }) }) }));
|
|
23
23
|
};
|
|
24
24
|
export default CanopyEditor;
|
package/dist/editor/Editor.d.ts
CHANGED
|
@@ -58,6 +58,8 @@ export interface EditorProps {
|
|
|
58
58
|
onCreateEntry?: (collectionPath: LogicalPath) => Promise<void> | void;
|
|
59
59
|
themeOptions?: CanopyThemeOptions;
|
|
60
60
|
previewBaseByCollection?: Record<string, string>;
|
|
61
|
+
/** `editor.previewPrefix` from config: put in front of every preview iframe `src` (see `buildPreviewSrc`). */
|
|
62
|
+
previewPrefix?: string;
|
|
61
63
|
currentUser?: string;
|
|
62
64
|
canResolveComments?: boolean;
|
|
63
65
|
/** `media.publicBaseUrl` from config - prefixed onto asset URLs the editor builds (MediaLibrary/ImageField/MDX image dialog). Undefined means root-relative (editor and site share an origin). */
|
package/dist/editor/Editor.js
CHANGED
|
@@ -24,6 +24,7 @@ import { EditorFooter, EditorHeader, EditorSidebar } from './components/index.js
|
|
|
24
24
|
import { RenameEntryModal } from './components/RenameEntryModal.js';
|
|
25
25
|
import { EntryCreateModal } from './components/EntryCreateModal.js';
|
|
26
26
|
import { ConfirmDeleteModal } from './components/ConfirmDeleteModal.js';
|
|
27
|
+
import { NoEditPermissionNotice } from './components/NoEditPermissionNotice.js';
|
|
27
28
|
import { CollectionEditor } from './schema-editor/index.js';
|
|
28
29
|
import { AssetContextProvider, useApiClient } from './context/index.js';
|
|
29
30
|
import { EntryLinkContext } from './fields/entry-link/index.js';
|
|
@@ -32,7 +33,7 @@ import { MediaLibrary } from './media/MediaLibrary.js';
|
|
|
32
33
|
* High-level editor wrapper that wires entry navigation, form rendering,
|
|
33
34
|
* saving/loading, and preview rendering using entry definitions.
|
|
34
35
|
*/
|
|
35
|
-
export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtitle = subtitle, branchName = '', collections, contentRoot, entryLinkUrl, initialSelectedId, initialValues, renderPreview, onCreateEntry, themeOptions, operatingMode, previewBaseByCollection, currentUser = 'current-user', canResolveComments = true, assetBaseUrl, basePath, AccountComponent, onAccountClick, onLogoutClick, customRenderers, }) => {
|
|
36
|
+
export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtitle = subtitle, branchName = '', collections, contentRoot, entryLinkUrl, initialSelectedId, initialValues, renderPreview, onCreateEntry, themeOptions, operatingMode, previewBaseByCollection, previewPrefix, currentUser = 'current-user', canResolveComments = true, assetBaseUrl, basePath, AccountComponent, onAccountClick, onLogoutClick, customRenderers, }) => {
|
|
36
37
|
const [branchesLoading, setBranchesLoading] = useState(false);
|
|
37
38
|
const [entriesLoading, setEntriesLoading] = useState(false);
|
|
38
39
|
const [commentsLoading] = useState(false);
|
|
@@ -98,6 +99,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
98
99
|
resolvePreviewSrc: (entry) => buildPreviewSrc(entry, {
|
|
99
100
|
branchName: branchNameState,
|
|
100
101
|
previewBaseByCollection,
|
|
102
|
+
previewPrefix,
|
|
101
103
|
contentRoot,
|
|
102
104
|
basePath,
|
|
103
105
|
}),
|
|
@@ -224,9 +226,8 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
224
226
|
// branch's content. Writing it would (a) show it as the new branch's
|
|
225
227
|
// content and (b) satisfy the skip gate above, permanently suppressing
|
|
226
228
|
// the new branch's own load -- which also leaves the new branch's OCC
|
|
227
|
-
// token unset,
|
|
228
|
-
//
|
|
229
|
-
// under its own key.
|
|
229
|
+
// token unset, so saveEntry refuses every save of the entry. Drop it
|
|
230
|
+
// instead; the new branch has its own load running under its own key.
|
|
230
231
|
if (currentBranchRef.current !== requestBranch)
|
|
231
232
|
return;
|
|
232
233
|
setLoadedValues((prev) => ({ ...prev, [contentId]: loaded }));
|
|
@@ -644,7 +645,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
|
|
|
644
645
|
? 'Setting up your branch workspace…'
|
|
645
646
|
: entriesInitializing
|
|
646
647
|
? 'Loading content…'
|
|
647
|
-
: 'Select an item to start editing.' })) : currentEntry.canEdit === false ? (_jsx(
|
|
648
|
+
: 'Select an item to start editing.' })) : currentEntry.canEdit === false ? (_jsx(NoEditPermissionNotice, { entryPath: currentEntry.path })) : schema.length > 0 && effectiveValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(FormRenderer, { fields: schema, value: effectiveValue, onChange: (next) => {
|
|
648
649
|
const contentId = currentEntry?.contentId;
|
|
649
650
|
if (contentId) {
|
|
650
651
|
setDrafts((prev) => ({ ...prev, [contentId]: next }));
|
|
@@ -20,6 +20,18 @@ import { formatCanopyPath, normalizeCanopyPath } from './canopy-path.js';
|
|
|
20
20
|
import { FieldWrapper } from './comments/FieldWrapper.js';
|
|
21
21
|
import { EntryComments } from './comments/EntryComments.js';
|
|
22
22
|
import { useReferenceResolution } from './hooks/useReferenceResolution.js';
|
|
23
|
+
/**
|
|
24
|
+
* Card heading for an object-list item: the value of its `itemTitleField` when that is a
|
|
25
|
+
* non-blank string or a number, otherwise `fallback`.
|
|
26
|
+
*/
|
|
27
|
+
const listItemTitle = (item, itemTitleField, fallback) => {
|
|
28
|
+
const raw = itemTitleField ? item?.[itemTitleField] : undefined;
|
|
29
|
+
if (typeof raw === 'number' && Number.isFinite(raw))
|
|
30
|
+
return String(raw);
|
|
31
|
+
if (typeof raw === 'string' && raw.trim() !== '')
|
|
32
|
+
return raw.trim();
|
|
33
|
+
return fallback;
|
|
34
|
+
};
|
|
23
35
|
const normalizeOptions = (options) => {
|
|
24
36
|
if (!options)
|
|
25
37
|
return [];
|
|
@@ -135,11 +147,14 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
135
147
|
const items = Array.isArray(currentValue)
|
|
136
148
|
? currentValue
|
|
137
149
|
: [];
|
|
138
|
-
return wrapWithComments(_jsx(Paper, { withBorder: true, radius: "md", p: "md", shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "sm", fw: 600, children: label }), _jsx(Button, { size: "xs", variant: "light", onClick: () => update([...items, {}]), children: "Add item" })] }), _jsxs(Stack, { gap: "sm", children: [items.map((item, idx) =>
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
150
|
+
return wrapWithComments(_jsx(Paper, { withBorder: true, radius: "md", p: "md", shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "sm", fw: 600, children: label }), _jsx(Button, { size: "xs", variant: "light", onClick: () => update([...items, {}]), children: "Add item" })] }), _jsxs(Stack, { gap: "sm", children: [items.map((item, idx) => {
|
|
151
|
+
const itemTitle = listItemTitle(item, objectField.itemTitleField, `${label} #${idx + 1}`);
|
|
152
|
+
return (_jsx(Paper, { role: "group", "aria-label": itemTitle, withBorder: true, radius: "md", p: "sm", shadow: "xs", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", fw: 700, children: itemTitle }), _jsx(Button, { size: "xs", variant: "subtle", color: "red", onClick: () => update(items.filter((_, i) => i !== idx)), children: "Remove" })] }), _jsx(ObjectField, { fields: objectField.fields, value: item, onChange: (next) => {
|
|
153
|
+
const nextItems = [...items];
|
|
154
|
+
nextItems[idx] = next;
|
|
155
|
+
update(nextItems);
|
|
156
|
+
}, renderField: renderField, path: [...path, idx], dataCanopyField: normalizeCanopyPath([...path, idx]) })] }) }, fieldKey([...path, idx])));
|
|
157
|
+
}), items.length === 0 && (_jsx(Text, { size: "xs", c: "dimmed", children: "No items yet. Add one to get started." }))] })] }) }, fieldKey(path)));
|
|
143
158
|
}
|
|
144
159
|
// Unlike the list branch above (each item has its own Remove button), a
|
|
145
160
|
// non-list object has no other way back to `undefined`: a required child
|
|
@@ -59,7 +59,9 @@ function purgeGateFor(entry) {
|
|
|
59
59
|
}
|
|
60
60
|
return {
|
|
61
61
|
disabled: lockFresh,
|
|
62
|
-
tooltip: lockFresh
|
|
62
|
+
tooltip: lockFresh
|
|
63
|
+
? 'Provisioning, or the worker syncing this branch, may be in progress'
|
|
64
|
+
: undefined,
|
|
63
65
|
};
|
|
64
66
|
}
|
|
65
67
|
function workerLivenessBadge(worker, mode) {
|
|
@@ -76,6 +78,33 @@ function workerLivenessBadge(worker, mode) {
|
|
|
76
78
|
return { color: 'red', label: 'Worker: absent' };
|
|
77
79
|
}
|
|
78
80
|
}
|
|
81
|
+
const BASE_REFRESH_LABELS = {
|
|
82
|
+
refreshed: 'fast-forwarded',
|
|
83
|
+
'up-to-date': 'up to date',
|
|
84
|
+
'skipped-dirty': 'refresh skipped (uncommitted changes)',
|
|
85
|
+
'skipped-locked': 'refresh skipped (workspace busy: provisioning or an admin action)',
|
|
86
|
+
'skipped-not-provisioned': 'not yet provisioned',
|
|
87
|
+
failed: 'refresh failed',
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Why the base branch needs an operator, or null when its last refresh needs
|
|
91
|
+
* nothing. Shared by the overview and the base row's warning tooltip.
|
|
92
|
+
*/
|
|
93
|
+
function baseRefreshWarning(report) {
|
|
94
|
+
if (!report)
|
|
95
|
+
return null;
|
|
96
|
+
const lines = [];
|
|
97
|
+
if (report.outcome === 'skipped-dirty' || report.outcome === 'failed') {
|
|
98
|
+
lines.push(`Base branch ${BASE_REFRESH_LABELS[report.outcome]}${report.message ? `: ${report.message}` : ''}`);
|
|
99
|
+
if (report.dirtyFiles?.length)
|
|
100
|
+
lines.push(`Uncommitted: ${report.dirtyFiles.join(', ')}`);
|
|
101
|
+
}
|
|
102
|
+
if (report.trackedCanopyMeta?.length) {
|
|
103
|
+
lines.push(`The site repo tracks canopycms state (${report.trackedCanopyMeta.join(', ')}). ` +
|
|
104
|
+
'Untrack it with `git rm -r --cached .canopy-meta`, add `.canopy-meta/` to .gitignore, and commit.');
|
|
105
|
+
}
|
|
106
|
+
return lines.length > 0 ? lines.join('\n') : null;
|
|
107
|
+
}
|
|
79
108
|
// Mirrors BranchManager.tsx's statusColorMap -- kept local (not exported
|
|
80
109
|
// there) rather than shared, same tiny lookup either way.
|
|
81
110
|
const branchStatusColorMap = {
|
|
@@ -121,9 +150,17 @@ function OverviewTab({ health }) {
|
|
|
121
150
|
const liveness = workerLivenessBadge(status.worker, status.mode);
|
|
122
151
|
const lastFatalError = status.workerStatus?.lastFatalError;
|
|
123
152
|
const lastGitSync = status.workerStatus?.lastGitSync;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
153
|
+
const baseWarning = baseRefreshWarning(lastGitSync?.baseRefresh);
|
|
154
|
+
const { build } = status;
|
|
155
|
+
// Absent for a worker that predates the field, which is not evidence of skew;
|
|
156
|
+
// nor is a stale or absent worker's leftover status file, which names no running build.
|
|
157
|
+
const workerVersion = status.workerStatus?.workerVersion || undefined;
|
|
158
|
+
const versionSkew = status.worker.state === 'alive' &&
|
|
159
|
+
workerVersion !== undefined &&
|
|
160
|
+
workerVersion !== build.canopycmsVersion;
|
|
161
|
+
return (_jsxs(Stack, { gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Worker crash detected", children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at, " (", lastFatalError.phase, ")"] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", radius: "md", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-media", children: ["Media storage:", ' ', status.assetStore.configured ? 'configured' : 'not configured — uploads are disabled'] })] }), lastGitSync && (_jsxs(Paper, { withBorder: true, p: "sm", radius: "md", children: [_jsx(Text, { size: "sm", fw: 600, children: "Last git sync" }), _jsxs(Text, { size: "xs", c: "dimmed", children: [status.workerStatus?.lastGitSyncAt ?? 'unknown time', " \u00B7 ", lastGitSync.durationMs, "ms \u00B7", ' ', lastGitSync.rebased.length, " rebased \u00B7 ", lastGitSync.skippedDirty.length, " skipped (dirty)", lastGitSync.skippedLocked && lastGitSync.skippedLocked.length > 0
|
|
162
|
+
? ` · ${lastGitSync.skippedLocked.length} skipped (busy: a content write, provisioning or purge)`
|
|
163
|
+
: ''] }), lastGitSync.baseRefresh && (_jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "base-refresh-outcome", children: ["Base branch: ", BASE_REFRESH_LABELS[lastGitSync.baseRefresh.outcome]] })), baseWarning && (_jsx(Text, { size: "xs", c: "orange", style: { whiteSpace: 'pre-line' }, "data-testid": "base-refresh-warning", children: baseWarning })), lastGitSync.failed.length > 0 && (_jsx(Spoiler, { maxHeight: 0, showLabel: `${lastGitSync.failed.length} failed — show details`, hideLabel: "Hide", children: _jsx(Stack, { gap: 4, mt: 4, children: lastGitSync.failed.map((f) => (_jsxs(Text, { size: "xs", c: "red", children: [f.branch, ": ", f.error] }, f.branch))) }) }))] })), _jsx(SimpleGrid, { cols: 5, spacing: "xs", children: ['pending', 'processing', 'completed', 'failed', 'corrupt'].map((key) => (_jsxs(Paper, { withBorder: true, p: "xs", radius: "md", ta: "center", children: [_jsx(Text, { size: "xs", c: "dimmed", tt: "capitalize", children: key }), _jsx(Text, { size: "lg", fw: 700, c: (key === 'failed' || key === 'corrupt') && status.queue[key] > 0 ? 'red' : undefined, children: status.queue[key] })] }, key))) }), status.queue.oldestPendingAgeMs !== undefined && (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Oldest pending task: ", formatAgeMs(status.queue.oldestPendingAgeMs), " old"] })), _jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Generated at ", status.generatedAt] }), _jsx(Button, { size: "xs", variant: "light", onClick: () => refresh(), loading: statusLoading, children: "Refresh" })] }), _jsx(Text, { size: "xs", c: "dimmed", children: "Data may lag up to ~60s (shared-filesystem caching)" })] }));
|
|
127
164
|
}
|
|
128
165
|
function TasksTab({ health }) {
|
|
129
166
|
const { taskStatus, setTaskStatus, tasks, tasksLoading } = health;
|
|
@@ -160,6 +197,7 @@ function TasksTab({ health }) {
|
|
|
160
197
|
function BranchesTab({ health }) {
|
|
161
198
|
const { branchHealth, branchHealthLoading } = health;
|
|
162
199
|
const entries = branchHealth?.entries ?? [];
|
|
200
|
+
const baseWarning = baseRefreshWarning(health.status?.workerStatus?.lastGitSync?.baseRefresh);
|
|
163
201
|
const handleMarkMergedClick = (branchName) => {
|
|
164
202
|
modals.openConfirmModal({
|
|
165
203
|
title: 'Mark branch as merged',
|
|
@@ -193,9 +231,9 @@ function BranchesTab({ health }) {
|
|
|
193
231
|
if (entries.length === 0) {
|
|
194
232
|
return (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: "No branch directories found." }));
|
|
195
233
|
}
|
|
196
|
-
return (_jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, onMarkMerged: handleMarkMergedClick, onRepair: handleRepairClick, onPurge: handlePurgeClick }, entry.dirName))) })] }) }));
|
|
234
|
+
return (_jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, baseWarning: entry.isBaseBranch ? baseWarning : null, onMarkMerged: handleMarkMergedClick, onRepair: handleRepairClick, onPurge: handlePurgeClick }, entry.dirName))) })] }) }));
|
|
197
235
|
}
|
|
198
|
-
function BranchHealthRow({ entry, onMarkMerged, onRepair, onPurge, }) {
|
|
236
|
+
function BranchHealthRow({ entry, baseWarning, onMarkMerged, onRepair, onPurge, }) {
|
|
199
237
|
if (entry.kind === 'healthy' && entry.branch) {
|
|
200
238
|
const b = entry.branch;
|
|
201
239
|
// Mirrors the rebase loop's skip logic (worker/rebase.ts): the worker
|
|
@@ -207,7 +245,7 @@ function BranchHealthRow({ entry, onMarkMerged, onRepair, onPurge, }) {
|
|
|
207
245
|
const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') && !!b.pullRequestNumber;
|
|
208
246
|
return (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Text, { size: "sm", children: entry.dirName }), entry.isBaseBranch && (_jsx(Badge, { size: "xs", color: "gray", variant: "outline", children: "base" }))] }) }), _jsx(Table.Td, { children: _jsx(Badge, { color: branchStatusColorMap[b.status] ?? 'neutral', variant: "light", children: b.status }) }), _jsx(Table.Td, { children: b.pullRequestNumber ? (_jsxs(Text, { size: "xs", c: "blue", component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", style: { textDecoration: 'underline' }, children: ["PR #", b.pullRequestNumber, b.pullRequestState ? ` (${b.pullRequestState})` : ''] })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: b.syncStatus === 'sync-failed' ? (_jsx(Tooltip, { label: b.syncFailureReason
|
|
209
247
|
? `${b.syncFailureReason} — retry from the Tasks tab`
|
|
210
|
-
: 'GitHub sync failed — retry from the Tasks tab', multiline: true, maw: 320, children: _jsx(Badge, { color: "red", variant: "light", children: "sync-failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "pending-sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, wrap: "nowrap", children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), showRebaseFailure && b.rebaseFailure && (_jsx(Tooltip, { label: `${b.rebaseFailure.message} (failing since ${b.rebaseFailure.firstAt})`, multiline: true, maw: 320, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `rebase-failure-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), !!entry.duplicateContentIds?.length && (_jsx(Tooltip, { label: entry.duplicateContentIds
|
|
248
|
+
: 'GitHub sync failed — retry from the Tasks tab', multiline: true, maw: 320, children: _jsx(Badge, { color: "red", variant: "light", children: "sync-failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "pending-sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, wrap: "nowrap", children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), showRebaseFailure && b.rebaseFailure && (_jsx(Tooltip, { label: `${b.rebaseFailure.message} (failing since ${b.rebaseFailure.firstAt})`, multiline: true, maw: 320, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `rebase-failure-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), baseWarning && (_jsx(Tooltip, { label: baseWarning, multiline: true, maw: 420, style: { whiteSpace: 'pre-line' }, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `base-refresh-warning-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), !!entry.duplicateContentIds?.length && (_jsx(Tooltip, { label: entry.duplicateContentIds
|
|
211
249
|
.map((d) => `${d.id}: kept ${d.keptPath}; also on ${d.droppedPaths.join(', ')}`)
|
|
212
250
|
.join('\n'), multiline: true, maw: 420, style: { whiteSpace: 'pre-line' }, children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `duplicate-content-ids-${entry.dirName}`, children: [entry.duplicateContentIds.length, " duplicate ID", entry.duplicateContentIds.length === 1 ? '' : 's'] }) }))] }) }), _jsx(Table.Td, { children: _jsx(Text, { size: "xs", c: "dimmed", children: b.updatedAt }) }), _jsx(Table.Td, { children: canMarkMerged && (_jsx(Button, { size: "xs", variant: "light", "data-testid": `mark-merged-${b.name}`, onClick: () => onMarkMerged(b.name), children: "Mark merged" })) })] }));
|
|
213
251
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface NoEditPermissionNoticeProps {
|
|
2
|
+
/** Logical path of the entry the user cannot edit. */
|
|
3
|
+
entryPath: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
|
|
7
|
+
* when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives; a
|
|
8
|
+
* locked or protected branch shows its own banner in the header and a read-only form instead.
|
|
9
|
+
*/
|
|
10
|
+
export declare function NoEditPermissionNotice({ entryPath }: NoEditPermissionNoticeProps): import("react/jsx-runtime").JSX.Element;
|