canopycms 0.0.68-int.107 → 0.0.68-int.109
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 +1 -1
- package/dist/api/branch-status.js +28 -5
- package/dist/api/content.d.ts +2 -2
- package/dist/api/content.js +50 -6
- package/dist/api/github-sync.d.ts +11 -3
- package/dist/api/github-sync.js +16 -8
- package/dist/api/guards.js +19 -3
- package/dist/api/types.d.ts +3 -2
- package/dist/authorization/protected-branch.d.ts +5 -4
- package/dist/authorization/protected-branch.js +5 -4
- package/dist/branch-health.js +5 -4
- package/dist/branch-metadata-error.d.ts +27 -0
- package/dist/branch-metadata-error.js +23 -0
- package/dist/branch-metadata-file.d.ts +199 -24
- package/dist/branch-metadata-file.js +124 -25
- package/dist/branch-metadata.d.ts +12 -0
- package/dist/branch-metadata.js +25 -13
- package/dist/branch-registry.d.ts +8 -0
- package/dist/branch-registry.js +61 -9
- package/dist/branch-workspace.d.ts +1 -1
- package/dist/branch-workspace.js +1 -1
- package/dist/cli/cli.js +1787 -741
- package/dist/cli/generate-ai-content.js +905 -290
- package/dist/cli/init.js +184 -13
- package/dist/config/helpers.js +2 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -0
- package/dist/config/schemas/field.d.ts +3 -0
- package/dist/config/schemas/field.js +52 -4
- package/dist/config/types.d.ts +46 -7
- package/dist/config/types.js +3 -9
- package/dist/config/validation.d.ts +12 -1
- package/dist/config/validation.js +55 -0
- package/dist/content-store.js +6 -4
- package/dist/editor/BranchManager.js +2 -3
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -1
- package/dist/editor/Editor.d.ts +3 -1
- package/dist/editor/Editor.js +20 -10
- package/dist/editor/FormRenderer.d.ts +3 -1
- package/dist/editor/FormRenderer.js +11 -3
- package/dist/editor/PreviewFrame.d.ts +11 -1
- package/dist/editor/PreviewFrame.js +13 -2
- package/dist/editor/admin/SystemHealthPanel.js +7 -1
- package/dist/editor/canopy-path.d.ts +9 -6
- package/dist/editor/canopy-path.js +8 -11
- package/dist/editor/components/EditorSidebar.d.ts +6 -5
- package/dist/editor/components/EditorSidebar.js +20 -4
- package/dist/editor/components/ReferencedByList.js +10 -4
- package/dist/editor/context/SiteMdxAllowContext.d.ts +4 -0
- package/dist/editor/context/SiteMdxAllowContext.js +7 -0
- package/dist/editor/context/index.d.ts +1 -0
- package/dist/editor/context/index.js +1 -0
- package/dist/editor/field-props.d.ts +83 -0
- package/dist/editor/field-props.js +27 -0
- package/dist/editor/fields/BlockField.js +3 -3
- package/dist/editor/fields/MarkdownField.d.ts +3 -0
- package/dist/editor/fields/MarkdownField.js +46 -6
- package/dist/editor/fields/markdown-fidelity-visitors.d.ts +11 -0
- package/dist/editor/fields/markdown-fidelity-visitors.js +160 -0
- package/dist/editor/hooks/useCommentSystem.js +32 -16
- package/dist/editor/hooks/usePreviewMarks.d.ts +21 -0
- package/dist/editor/hooks/usePreviewMarks.js +33 -0
- package/dist/editor/preview-bridge.d.ts +18 -4
- package/dist/editor/preview-bridge.js +69 -4
- package/dist/editor/preview-marks.d.ts +15 -0
- package/dist/editor/preview-marks.js +74 -0
- package/dist/entry-schema-registry.d.ts +2 -2
- package/dist/entry-schema-registry.js +5 -3
- package/dist/git-manager.d.ts +20 -2
- package/dist/git-manager.js +42 -13
- package/dist/github-service.d.ts +6 -0
- package/dist/github-service.js +20 -0
- package/dist/http/handler.js +15 -2
- package/dist/http/worker-not-ready.d.ts +8 -4
- package/dist/http/worker-not-ready.js +40 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/services.d.ts +10 -1
- package/dist/services.js +33 -17
- package/dist/static/index.d.ts +18 -3
- package/dist/static/index.js +54 -0
- package/dist/task-queue/cms-task-queue.d.ts +1 -1
- package/dist/task-queue/cms-task-queue.js +1 -1
- package/dist/task-queue/index.d.ts +1 -1
- package/dist/task-queue/index.js +1 -1
- package/dist/task-queue/task-queue.d.ts +6 -0
- package/dist/task-queue/task-queue.js +29 -0
- package/dist/task-queue/worker-status.d.ts +35 -9
- package/dist/task-queue/worker-status.js +83 -17
- package/dist/types.d.ts +42 -6
- package/dist/utils/content-serialize.d.ts +4 -1
- package/dist/utils/content-serialize.js +110 -9
- package/dist/utils/json-source-splice.d.ts +14 -0
- package/dist/utils/json-source-splice.js +120 -0
- package/dist/validation/markdown-safety.d.ts +68 -0
- package/dist/validation/markdown-safety.js +575 -0
- package/dist/validation/mdx-allowlist.d.ts +55 -0
- package/dist/validation/mdx-allowlist.js +183 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/cms-worker.d.ts +96 -9
- package/dist/worker/cms-worker.js +320 -53
- package/dist/worker/git-sync.d.ts +1 -1
- package/dist/worker/git-sync.js +34 -5
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +6 -0
- package/dist/worker/task-runner.d.ts +2 -2
- package/dist/worker/task-runner.js +142 -49
- package/dist/worker/worker-context.d.ts +16 -3
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -442,7 +442,7 @@ export function PostView({ data }: { data: PostContent }) {
|
|
|
442
442
|
- `data`: The current content data (initial data on first render, then live updates from editor)
|
|
443
443
|
- `isLoading`: Object mirroring your data structure with boolean loading states for reference fields
|
|
444
444
|
- `highlightEnabled`: Boolean indicating if field highlighting is active in the editor
|
|
445
|
-
- `fieldProps`:
|
|
445
|
+
- `fieldProps`: marks the element rendering a field (`data-canopy-path`), with the path typed against your content; `fieldAttrs` and `scopeFieldProps` from `canopycms` thread it through section components (see the root README's Live Preview)
|
|
446
446
|
|
|
447
447
|
**Reference fields and loading states:**
|
|
448
448
|
|
|
@@ -11,6 +11,7 @@ import { getErrorMessage, redactCredentials, sanitizeErrorMessage } from '../uti
|
|
|
11
11
|
import { isNonFastForwardRejection } from '../utils/git.js';
|
|
12
12
|
import { ContentWriteLockBusyError } from '../utils/content-write-lock.js';
|
|
13
13
|
import { submissionEditorFromUser } from '../submission-attribution.js';
|
|
14
|
+
import { NothingToSubmitError } from '../services.js';
|
|
14
15
|
const getBranchStatusHandler = async (gc, _ctx, _req, _params) => {
|
|
15
16
|
const { branchContext } = gc;
|
|
16
17
|
return { ok: true, status: 200, data: { branch: branchContext.branch } };
|
|
@@ -38,9 +39,8 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
|
|
|
38
39
|
// (merged) branch could be re-submitted by its creator, re-stamping it
|
|
39
40
|
// 'submitted' and overwriting the merged PR's title/body.
|
|
40
41
|
//
|
|
41
|
-
// Fails closed on a missing status
|
|
42
|
-
//
|
|
43
|
-
// `undefined`) -- same as runWritableBranchGuard's unreadable-status arm.
|
|
42
|
+
// Fails closed on a missing status, same as runWritableBranchGuard's
|
|
43
|
+
// unreadable-status arm.
|
|
44
44
|
const status = branchContext.branch.status;
|
|
45
45
|
if (status !== 'editing') {
|
|
46
46
|
return {
|
|
@@ -59,6 +59,9 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
|
|
|
59
59
|
({ changedPaths } = await ctx.services.submitBranch({ context: branchContext, submitter }));
|
|
60
60
|
}
|
|
61
61
|
catch (err) {
|
|
62
|
+
if (err instanceof NothingToSubmitError) {
|
|
63
|
+
return { ok: false, status: 400, error: err.message };
|
|
64
|
+
}
|
|
62
65
|
// Retriable either way: a resubmit commits nothing new and pushes only
|
|
63
66
|
// what has not reached the remote.
|
|
64
67
|
if (err instanceof ContentWriteLockBusyError) {
|
|
@@ -107,13 +110,33 @@ const submitBranchForMergeHandler = async (gc, ctx, req, _params) => {
|
|
|
107
110
|
};
|
|
108
111
|
}
|
|
109
112
|
// Create or update PR (sync via githubService, or async via task queue)
|
|
110
|
-
const
|
|
111
|
-
|
|
113
|
+
const submittedAt = new Date().toISOString();
|
|
114
|
+
const prResult = await syncSubmitPr(ctx, branchContext, {
|
|
115
|
+
submitter,
|
|
116
|
+
changedPaths,
|
|
117
|
+
submittedAt,
|
|
118
|
+
});
|
|
112
119
|
const meta = getBranchMetadataFileManager(branchContext.branchRoot, branchContext.baseRoot);
|
|
120
|
+
const pushed = prResult.pushedToGitHub ? { pushedToGitHubAt: submittedAt } : {};
|
|
121
|
+
// GitHub found nothing between the pushed branch and its base, though the diff above found
|
|
122
|
+
// changes: GitHub's base can already hold them, or the diff could not be computed. The branch
|
|
123
|
+
// stays editable; the stamp records that it is now on GitHub, for delete.
|
|
124
|
+
if (prResult.nothingToSubmit) {
|
|
125
|
+
await meta.save({ branch: { name: branchContext.branch.name, ...pushed } });
|
|
126
|
+
const base = branchContext.branch.baseBranch ?? ctx.services.config.defaultBaseBranch ?? 'main';
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
status: 400,
|
|
130
|
+
error: new NothingToSubmitError(branchContext.branch.name, base).message,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
113
133
|
const updated = await meta.save({
|
|
114
134
|
branch: {
|
|
115
135
|
name: branchContext.branch.name,
|
|
116
136
|
status: 'submitted',
|
|
137
|
+
submittedAt,
|
|
138
|
+
...pushed,
|
|
139
|
+
syncFailureReason: prResult.syncFailureReason,
|
|
117
140
|
pullRequestUrl: prResult.prUrl ?? branchContext.branch.pullRequestUrl,
|
|
118
141
|
pullRequestNumber: prResult.prNumber ?? branchContext.branch.pullRequestNumber,
|
|
119
142
|
...(prResult.syncStatus !== undefined ? { syncStatus: prResult.syncStatus } : {}),
|
package/dist/api/content.d.ts
CHANGED
|
@@ -104,14 +104,14 @@ export declare const CONTENT_ROUTES: {
|
|
|
104
104
|
expectedVersion: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
105
105
|
}, "strip", z.ZodTypeAny, {
|
|
106
106
|
format: "mdx" | "md" | "json" | "yaml";
|
|
107
|
+
data?: Record<string, unknown> | undefined;
|
|
107
108
|
expectedVersion?: number | null | undefined;
|
|
108
109
|
body?: string | undefined;
|
|
109
|
-
data?: Record<string, unknown> | undefined;
|
|
110
110
|
}, {
|
|
111
111
|
format: "mdx" | "md" | "json" | "yaml";
|
|
112
|
+
data?: Record<string, unknown> | undefined;
|
|
112
113
|
expectedVersion?: number | null | undefined;
|
|
113
114
|
body?: string | undefined;
|
|
114
|
-
data?: Record<string, unknown> | undefined;
|
|
115
115
|
}>, ContentWriteResponse>;
|
|
116
116
|
readonly validateReferences: import("./route-builder.js").RouteDefinition<z.ZodObject<{
|
|
117
117
|
branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
|
package/dist/api/content.js
CHANGED
|
@@ -5,11 +5,13 @@ import { SchemaUnavailableError } from '../schema/schema-unavailable-error.js';
|
|
|
5
5
|
import { ReferenceValidator } from '../validation/reference-validator.js';
|
|
6
6
|
import { findUnknownKeys, mergeBodyIntoData, normalizeReferenceValues, validateEntryData, } from '../validation/entry-validator.js';
|
|
7
7
|
import { validateEntryLinks } from '../validation/entry-link-validator.js';
|
|
8
|
+
import { findMarkdownSafetyIssues, splitByStored } from '../validation/markdown-safety.js';
|
|
8
9
|
import { collectReferenceIds } from '../validation/field-traversal.js';
|
|
9
10
|
import { branchNameSchema, logicalPathSchema, slugSchema } from './validators.js';
|
|
10
11
|
import { entryLogicalPath, parseSlug } from '../paths/index.js';
|
|
11
12
|
import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from '../utils/error.js';
|
|
12
13
|
import { isDataOnlyFormat } from '../utils/format.js';
|
|
14
|
+
import { findBodyFieldName } from '../utils/body-field.js';
|
|
13
15
|
/**
|
|
14
16
|
* Parse an API path into logical path segments, prepending the content root if needed.
|
|
15
17
|
* Shared by all content API handlers.
|
|
@@ -123,6 +125,23 @@ const storedReferenceSites = async (store, collectionPath, slug, fields) => {
|
|
|
123
125
|
const doc = await store.read(collectionPath, slug, { resolveReferences: false });
|
|
124
126
|
return new Set(collectReferenceIds(fields, doc.data).map((ref) => referenceSite(ref.path, ref.id)));
|
|
125
127
|
};
|
|
128
|
+
function withoutKey(record, key) {
|
|
129
|
+
if (!(key in record))
|
|
130
|
+
return record;
|
|
131
|
+
const { [key]: _dropped, ...rest } = record;
|
|
132
|
+
return rest;
|
|
133
|
+
}
|
|
134
|
+
/** The policy's issues in the stored entry, which a save may keep. */
|
|
135
|
+
const storedMarkdownSafetyIssues = async (store, collectionPath, slug, fields, siteAllow) => {
|
|
136
|
+
// A stored file that does not read keeps nothing, so the save's code is refused, not a 500.
|
|
137
|
+
const doc = await store
|
|
138
|
+
.read(collectionPath, slug, { resolveReferences: false })
|
|
139
|
+
.catch(() => undefined);
|
|
140
|
+
if (doc === undefined)
|
|
141
|
+
return [];
|
|
142
|
+
const data = 'body' in doc ? mergeBodyIntoData(fields, doc.data, doc.body) : doc.data;
|
|
143
|
+
return findMarkdownSafetyIssues(fields, doc.format, data, siteAllow);
|
|
144
|
+
};
|
|
126
145
|
const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
127
146
|
const { branchContext } = gc;
|
|
128
147
|
const flatSchema = branchContext.flatSchema;
|
|
@@ -216,15 +235,20 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
216
235
|
if (resolvedEntryType?.unavailable) {
|
|
217
236
|
throw new SchemaUnavailableError(resolvedEntryType.name, resolvedEntryType.unavailable);
|
|
218
237
|
}
|
|
219
|
-
const data = body.data ?? {};
|
|
220
238
|
const isDataOnly = isDataOnlyFormat(body.format);
|
|
239
|
+
// An md/mdx file's body field is its body, so a frontmatter key of that name is dropped: no
|
|
240
|
+
// check would see it, and a read or a resolved reference would serve it as the body.
|
|
241
|
+
const requestData = isDataOnly || body.data === undefined
|
|
242
|
+
? body.data
|
|
243
|
+
: withoutKey(body.data, findBodyFieldName(fields));
|
|
244
|
+
const data = requestData ?? {};
|
|
221
245
|
// The store's own `undefined` (blind write) is never reachable from here: an omitted token is
|
|
222
246
|
// a create, so an update that lost its token 409s instead of overwriting unchecked.
|
|
223
247
|
const expectedVersion = body.expectedVersion ?? null;
|
|
224
248
|
const createConflictError = body.expectedVersion === null
|
|
225
249
|
? `An entry with slug "${slug}" already exists`
|
|
226
250
|
: `An entry with slug "${slug}" already exists; an update must send the expectedVersion from its last read`;
|
|
227
|
-
const
|
|
251
|
+
const keptWarnings = [];
|
|
228
252
|
try {
|
|
229
253
|
const exists = await store.documentExists(schemaItem.logicalPath, slug);
|
|
230
254
|
// Create guard: a create against a slug that already has content must never silently
|
|
@@ -276,6 +300,24 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
276
300
|
// as the schema's isBody field.
|
|
277
301
|
const dataForValidation = isDataOnly ? data : mergeBodyIntoData(fields, data, body.body ?? '');
|
|
278
302
|
const fieldErrors = validateEntryData(fields, dataForValidation);
|
|
303
|
+
// Code in markdown or MDX (validation/markdown-safety.ts). A field the stored entry held
|
|
304
|
+
// with code is kept, with a warning, when saved unchanged; any other code is refused.
|
|
305
|
+
const { mdxAllow } = ctx.services.config;
|
|
306
|
+
const unsafe = findMarkdownSafetyIssues(fields, body.format, dataForValidation, mdxAllow);
|
|
307
|
+
if (unsafe.length > 0) {
|
|
308
|
+
const stored = exists
|
|
309
|
+
? await storedMarkdownSafetyIssues(store, schemaItem.logicalPath, slug, fields, mdxAllow)
|
|
310
|
+
: [];
|
|
311
|
+
const { refused, kept } = splitByStored(unsafe, stored);
|
|
312
|
+
fieldErrors.push(...refused);
|
|
313
|
+
for (const e of kept) {
|
|
314
|
+
keptWarnings.push({
|
|
315
|
+
level: 'warning',
|
|
316
|
+
fieldPath: e.fieldPath,
|
|
317
|
+
message: `holds content this field refuses, kept because the saved entry already had it: ${e.message}. Ask a developer to move it into a component, or to allow it.`,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
279
321
|
// Reference existence (server-only: reads the content ID index). Editor
|
|
280
322
|
// payloads may still carry resolved `{ id, ... }` objects from a prior
|
|
281
323
|
// read, so collapse them to id strings before checking.
|
|
@@ -292,7 +334,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
292
334
|
: new Set();
|
|
293
335
|
for (const e of refResult.errors) {
|
|
294
336
|
if (e.dangling && storedSites.has(referenceSite(e.fieldPath, e.id))) {
|
|
295
|
-
|
|
337
|
+
keptWarnings.push({
|
|
296
338
|
level: 'warning',
|
|
297
339
|
fieldPath: e.fieldPath,
|
|
298
340
|
message: `references a missing entry (${e.id}). It is kept as it was; repoint or clear it.`,
|
|
@@ -323,7 +365,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
323
365
|
// Adopter save-time validation, run BEFORE the file is written: 'error' issues
|
|
324
366
|
// refuse the save (e.g. a body that would break the site's production build),
|
|
325
367
|
// 'warning' issues are returned alongside the successful write.
|
|
326
|
-
let validationWarnings =
|
|
368
|
+
let validationWarnings = keptWarnings.length > 0 ? keptWarnings : undefined;
|
|
327
369
|
const validateEntry = ctx.services.config.validateEntry;
|
|
328
370
|
// Collapse resolved reference objects back to bare ID strings before persisting (the reference
|
|
329
371
|
// validator above gets its own copy). The editor's GET resolves references by default, so form
|
|
@@ -331,7 +373,7 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
331
373
|
// left unnormalized, a save would freeze that snapshot into the file (resolution only
|
|
332
374
|
// re-resolves plain ID strings), permanently severing the reference from its target. Idempotent:
|
|
333
375
|
// a payload that already holds ID strings is unchanged.
|
|
334
|
-
const normalizedData =
|
|
376
|
+
const normalizedData = requestData === undefined ? undefined : normalizeReferenceValues(fields, requestData);
|
|
335
377
|
// Computed before the validateEntry hook and the entry-link scan too, not just the write, so
|
|
336
378
|
// every consumer agrees on the same bytes — otherwise an adopter's hook could see a resolved
|
|
337
379
|
// object while the file got an ID string, depending on whether the post came from the editor.
|
|
@@ -366,7 +408,9 @@ const writeContentHandler = async (gc, ctx, req, params, body) => {
|
|
|
366
408
|
issues = await validateEntry({
|
|
367
409
|
entryPath: logicalPathSegments.join('/'),
|
|
368
410
|
branch: params.branch,
|
|
369
|
-
|
|
411
|
+
// The type the store writes with, so a request that omits `entryType` can't skip a
|
|
412
|
+
// type-gated rule.
|
|
413
|
+
...(entryTypeName ? { entryType: entryTypeName } : {}),
|
|
370
414
|
format: body.format,
|
|
371
415
|
data: normalizedData ?? {},
|
|
372
416
|
body: body.body,
|
|
@@ -8,11 +8,19 @@ export interface GitHubSyncResult {
|
|
|
8
8
|
prUrl?: string;
|
|
9
9
|
prNumber?: number;
|
|
10
10
|
syncStatus?: SyncStatus;
|
|
11
|
+
/** Client-safe reason a direct GitHub call failed, recorded as the branch's syncFailureReason. */
|
|
12
|
+
syncFailureReason?: string;
|
|
13
|
+
/** GitHub refused the PR because the pushed branch has no commits its base lacks. */
|
|
14
|
+
nothingToSubmit?: boolean;
|
|
15
|
+
/** The direct path, whose submit pushes to GitHub itself: the branch is there whatever the PR call did. */
|
|
16
|
+
pushedToGitHub?: boolean;
|
|
11
17
|
}
|
|
12
18
|
/** What the PR body records about a submit. */
|
|
13
19
|
export interface SubmissionRecord {
|
|
14
20
|
submitter?: SubmissionEditor;
|
|
15
21
|
changedPaths: readonly string[];
|
|
22
|
+
/** The submit's `submittedAt`, carried to the worker so its failure handling targets this submit. */
|
|
23
|
+
submittedAt?: string;
|
|
16
24
|
}
|
|
17
25
|
/**
|
|
18
26
|
* Uses githubService directly if available, otherwise queues a task for the worker.
|
|
@@ -28,9 +36,9 @@ export declare function syncSubmitPr(ctx: ApiContext, context: BranchContext, su
|
|
|
28
36
|
export declare function syncConvertToDraft(ctx: ApiContext, context: BranchContext): Promise<void>;
|
|
29
37
|
/**
|
|
30
38
|
* Used by delete, after the local delete succeeded. Deletes the branch on GitHub when the CMS
|
|
31
|
-
* put it there: a
|
|
32
|
-
*
|
|
33
|
-
* else's (the create-time collision check is best-effort) and is never touched.
|
|
39
|
+
* put it there: a recorded PR number or `pushedToGitHubAt` stamp is the proof (a submit whose PR
|
|
40
|
+
* GitHub refused pushed the branch but has no PR number). Without either, a same-named GitHub
|
|
41
|
+
* branch is someone else's (the create-time collision check is best-effort) and is never touched.
|
|
34
42
|
*
|
|
35
43
|
* Returns a client-facing warning when the delete could not be done or queued; never throws.
|
|
36
44
|
*/
|
package/dist/api/github-sync.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { enqueueTask } from '../task-queue/cms-task-queue.js';
|
|
2
2
|
import { getTaskQueueDir } from '../task-queue/task-queue-config.js';
|
|
3
3
|
import { clientOperatingStrategy } from '../operating-mode/index.js';
|
|
4
|
-
import { getErrorMessage } from '../utils/error.js';
|
|
4
|
+
import { getErrorMessage, sanitizeErrorMessage } from '../utils/error.js';
|
|
5
5
|
import { sanitizeBranchName } from '../paths/branch-name.js';
|
|
6
6
|
import { buildPrSection, mergePrSection } from '../submission-attribution.js';
|
|
7
|
-
import { isRefAlreadyGoneError } from '../github-service.js';
|
|
7
|
+
import { isNoCommitsBetweenError, isRefAlreadyGoneError } from '../github-service.js';
|
|
8
8
|
/**
|
|
9
9
|
* Uses githubService directly if available, otherwise queues a task for the worker.
|
|
10
10
|
*
|
|
@@ -65,6 +65,7 @@ export async function syncSubmitPr(ctx, context, submission) {
|
|
|
65
65
|
prUrl: context.branch.pullRequestUrl,
|
|
66
66
|
prNumber: context.branch.pullRequestNumber,
|
|
67
67
|
syncStatus: 'synced',
|
|
68
|
+
pushedToGitHub: true,
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
71
|
else {
|
|
@@ -92,16 +93,21 @@ export async function syncSubmitPr(ctx, context, submission) {
|
|
|
92
93
|
prUrl: result.url,
|
|
93
94
|
prNumber: result.number,
|
|
94
95
|
syncStatus: 'synced',
|
|
96
|
+
pushedToGitHub: true,
|
|
95
97
|
};
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
100
|
catch (err) {
|
|
99
|
-
const message = err
|
|
101
|
+
const message = getErrorMessage(err);
|
|
100
102
|
console.error(`CanopyCMS: Failed to create/update PR for ${context.branch.name}:`, message);
|
|
103
|
+
if (isNoCommitsBetweenError(err))
|
|
104
|
+
return { nothingToSubmit: true, pushedToGitHub: true };
|
|
101
105
|
return {
|
|
102
106
|
prUrl: context.branch.pullRequestUrl,
|
|
103
107
|
prNumber: context.branch.pullRequestNumber,
|
|
104
108
|
syncStatus: 'sync-failed',
|
|
109
|
+
syncFailureReason: sanitizeErrorMessage(message),
|
|
110
|
+
pushedToGitHub: true,
|
|
105
111
|
};
|
|
106
112
|
}
|
|
107
113
|
}
|
|
@@ -124,6 +130,7 @@ export async function syncSubmitPr(ctx, context, submission) {
|
|
|
124
130
|
// A content submit is an explicit "ready for review" action: convert a
|
|
125
131
|
// pre-existing draft PR to ready.
|
|
126
132
|
markReadyIfDraft: true,
|
|
133
|
+
submittedAt: submission.submittedAt,
|
|
127
134
|
},
|
|
128
135
|
});
|
|
129
136
|
}
|
|
@@ -159,14 +166,15 @@ export async function syncConvertToDraft(ctx, context) {
|
|
|
159
166
|
}
|
|
160
167
|
/**
|
|
161
168
|
* Used by delete, after the local delete succeeded. Deletes the branch on GitHub when the CMS
|
|
162
|
-
* put it there: a
|
|
163
|
-
*
|
|
164
|
-
* else's (the create-time collision check is best-effort) and is never touched.
|
|
169
|
+
* put it there: a recorded PR number or `pushedToGitHubAt` stamp is the proof (a submit whose PR
|
|
170
|
+
* GitHub refused pushed the branch but has no PR number). Without either, a same-named GitHub
|
|
171
|
+
* branch is someone else's (the create-time collision check is best-effort) and is never touched.
|
|
165
172
|
*
|
|
166
173
|
* Returns a client-facing warning when the delete could not be done or queued; never throws.
|
|
167
174
|
*/
|
|
168
175
|
export async function syncDeleteRemoteBranch(ctx, context) {
|
|
169
|
-
|
|
176
|
+
const { pullRequestNumber, pushedToGitHubAt } = context.branch;
|
|
177
|
+
if (!pullRequestNumber && !pushedToGitHubAt)
|
|
170
178
|
return undefined;
|
|
171
179
|
const { githubService } = ctx.services;
|
|
172
180
|
const branch = context.branch.name;
|
|
@@ -187,7 +195,7 @@ export async function syncDeleteRemoteBranch(ctx, context) {
|
|
|
187
195
|
return undefined;
|
|
188
196
|
const result = await enqueueGitHubTask(ctx, context, {
|
|
189
197
|
action: 'delete-remote-branch',
|
|
190
|
-
payload: { branch, pullRequestNumber
|
|
198
|
+
payload: { branch, pullRequestNumber, pushedToGitHubAt },
|
|
191
199
|
});
|
|
192
200
|
return result.syncStatus === 'sync-failed'
|
|
193
201
|
? 'Deleting the branch on GitHub could not be queued; delete it there by hand'
|
package/dist/api/guards.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* error response (404/403/500) to short-circuit, or produce a guard context (e.g. branch context
|
|
4
4
|
* with schema) for the handler. See api/AGENTS.md for the full guard reference.
|
|
5
5
|
*/
|
|
6
|
+
import { BRANCH_METADATA_CORRUPT_MESSAGE, BranchMetadataCorruptError, } from '../branch-metadata-error.js';
|
|
7
|
+
import { canopyLogError } from '../utils/logger.js';
|
|
6
8
|
import { isAdmin, isReviewer, isPrivileged } from '../authorization/helpers.js';
|
|
7
9
|
import { getBranchProtection, getBranchWriteProtection } from '../authorization/protected-branch.js';
|
|
8
10
|
function extractBranchName(params) {
|
|
@@ -117,8 +119,8 @@ const runWritableBranchGuard = async (ctx, _req, params, accumulated) => {
|
|
|
117
119
|
const branchStatus = context.branch.status;
|
|
118
120
|
const error = branchStatus === 'submitted'
|
|
119
121
|
? `Branch "${context.branch.name}" is submitted for review and cannot be edited. Withdraw it or request changes to resume editing.`
|
|
120
|
-
: //
|
|
121
|
-
//
|
|
122
|
+
: // A branch.json without a status is refused as corrupt before this
|
|
123
|
+
// point; a context built elsewhere can still lack one. Refuse rather than guess.
|
|
122
124
|
branchStatus === undefined
|
|
123
125
|
? `Branch "${context.branch.name}" has no readable workflow status and cannot be edited until its metadata is repaired.`
|
|
124
126
|
: `Branch "${context.branch.name}" is ${branchStatus} and cannot be edited.`;
|
|
@@ -196,7 +198,21 @@ export async function executeGuards(guards, ctx, req, params) {
|
|
|
196
198
|
const accumulated = {};
|
|
197
199
|
for (const guardId of guards) {
|
|
198
200
|
const runner = GUARD_RUNNERS[guardId];
|
|
199
|
-
|
|
201
|
+
let result;
|
|
202
|
+
try {
|
|
203
|
+
result = await runner(ctx, req, params, accumulated);
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
// Every branch guard resolves the branch from its branch.json, and a
|
|
207
|
+
// corrupt one has no status or ACL anyone can trust: deny, whatever the guard.
|
|
208
|
+
if (!(err instanceof BranchMetadataCorruptError))
|
|
209
|
+
throw err;
|
|
210
|
+
canopyLogError(`CanopyCMS: ${err.message}`);
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
response: { ok: false, status: 500, error: BRANCH_METADATA_CORRUPT_MESSAGE },
|
|
214
|
+
};
|
|
215
|
+
}
|
|
200
216
|
if (!result.ok) {
|
|
201
217
|
return { ok: false, response: result.response };
|
|
202
218
|
}
|
package/dist/api/types.d.ts
CHANGED
|
@@ -53,6 +53,7 @@ export interface ApiResponse<TData = unknown> {
|
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
55
|
* `SCHEMA_UNAVAILABLE`: the entry's type names an entry schema the running code lacks (a 503,
|
|
56
|
-
* see `SchemaUnavailableError`).
|
|
56
|
+
* see `SchemaUnavailableError`). `WORKER_FAILED`: the CMS worker recorded that its latest start
|
|
57
|
+
* failed, so a retry will not help until an admin fixes it (a 503 with no `Retry-After`).
|
|
57
58
|
*/
|
|
58
|
-
export type ApiErrorCode = 'SCHEMA_UNAVAILABLE';
|
|
59
|
+
export type ApiErrorCode = 'SCHEMA_UNAVAILABLE' | 'WORKER_FAILED';
|
|
@@ -76,9 +76,10 @@ export declare function getBranchProtection(config: Pick<CanopyConfig, 'mode' |
|
|
|
76
76
|
* branches-list wire flag and the editor.
|
|
77
77
|
*
|
|
78
78
|
* `status` is REQUIRED and admits `undefined` because a missing status must
|
|
79
|
-
* FAIL CLOSED
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
79
|
+
* FAIL CLOSED. A branch.json without one never gets this far (its read
|
|
80
|
+
* boundary, branch-metadata-file.ts, rejects it as corrupt), but a status can
|
|
81
|
+
* still arrive unset from a context built anywhere else. Required, not
|
|
82
|
+
* optional, so "argument omitted" and "status unknown" stay distinguishable --
|
|
83
|
+
* the safe answer differs.
|
|
83
84
|
*/
|
|
84
85
|
export declare function getBranchWriteProtection(config: Pick<CanopyConfig, 'mode' | 'defaultBaseBranch'>, branchName: string, recordedBaseBranch: string | undefined, status: BranchStatus | undefined): BranchWriteProtection;
|
|
@@ -47,10 +47,11 @@ export function getBranchProtection(config, branchName, recordedBaseBranch) {
|
|
|
47
47
|
* branches-list wire flag and the editor.
|
|
48
48
|
*
|
|
49
49
|
* `status` is REQUIRED and admits `undefined` because a missing status must
|
|
50
|
-
* FAIL CLOSED
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
50
|
+
* FAIL CLOSED. A branch.json without one never gets this far (its read
|
|
51
|
+
* boundary, branch-metadata-file.ts, rejects it as corrupt), but a status can
|
|
52
|
+
* still arrive unset from a context built anywhere else. Required, not
|
|
53
|
+
* optional, so "argument omitted" and "status unknown" stay distinguishable --
|
|
54
|
+
* the safe answer differs.
|
|
54
55
|
*/
|
|
55
56
|
export function getBranchWriteProtection(config, branchName, recordedBaseBranch, status) {
|
|
56
57
|
const protection = getBranchProtection(config, branchName, recordedBaseBranch);
|
package/dist/branch-health.js
CHANGED
|
@@ -100,13 +100,14 @@ export async function scanBranchHealth(baseRoot, opts) {
|
|
|
100
100
|
}
|
|
101
101
|
if (loadErr) {
|
|
102
102
|
// Every loadOnly failure lands here — BranchMetadataCorruptError for bad
|
|
103
|
-
// JSON, EACCES/EISDIR and the rest for
|
|
104
|
-
// them need admin attention and none may
|
|
103
|
+
// JSON or a failed schema check, EACCES/EISDIR and the rest for
|
|
104
|
+
// everything else — since all of them need admin attention and none may
|
|
105
|
+
// throw out of the scan.
|
|
105
106
|
//
|
|
106
107
|
// [REDACT] parseError reaches the browser through the admin branch-health
|
|
107
108
|
// endpoint, so it must never carry the absolute workspace path.
|
|
108
|
-
// BranchMetadataCorruptError's `parseCause` is the path-free
|
|
109
|
-
//
|
|
109
|
+
// BranchMetadataCorruptError's `parseCause` is the path-free cause
|
|
110
|
+
// for exactly this, while its `message` embeds branchRoot and is
|
|
110
111
|
// for server logs only. Node errors embed the path in `message` too, so
|
|
111
112
|
// only their `code` is safe to surface.
|
|
112
113
|
const parseError = loadErr instanceof BranchMetadataCorruptError
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The corrupt-`branch.json` error and what the API says about it. Free of
|
|
3
|
+
* node built-ins, so the API guards, which the client bundle reaches, can
|
|
4
|
+
* recognize the error without pulling in the reader (branch-metadata-file.ts).
|
|
5
|
+
*/
|
|
6
|
+
/** What the editor API answers for a branch whose branch.json is corrupt. */
|
|
7
|
+
export declare const BRANCH_METADATA_CORRUPT_MESSAGE = "This branch's metadata file is damaged, so the branch can't be opened or edited. An admin can repair it in System health.";
|
|
8
|
+
/**
|
|
9
|
+
* branch.json exists but is not valid JSON, or fails the schema in branch-metadata-file.ts.
|
|
10
|
+
* Distinguished from provisioning and IO failures so callers can degrade instead
|
|
11
|
+
* of failing hard: the registry scan quarantines the branch, the API answers
|
|
12
|
+
* {@link BRANCH_METADATA_CORRUPT_MESSAGE}, and the request handler keeps serving
|
|
13
|
+
* (with empty internal groups) when the BASE branch is the corrupt one —
|
|
14
|
+
* otherwise the admin recovery surface is unreachable exactly when it is needed.
|
|
15
|
+
*/
|
|
16
|
+
export declare class BranchMetadataCorruptError extends Error {
|
|
17
|
+
readonly branchRoot: string;
|
|
18
|
+
/**
|
|
19
|
+
* [REDACT] What is wrong with the file, with no embedded path: the JSON.parse
|
|
20
|
+
* failure, or the fields that failed the schema.
|
|
21
|
+
* `message` above keeps the `branchRoot`-qualified text for server logs;
|
|
22
|
+
* `parseCause` is the one callers surface to clients (branch-health.ts's
|
|
23
|
+
* `parseError`), so no scan leaks the absolute workspace path.
|
|
24
|
+
*/
|
|
25
|
+
readonly parseCause: string;
|
|
26
|
+
constructor(branchRoot: string, cause: string);
|
|
27
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The corrupt-`branch.json` error and what the API says about it. Free of
|
|
3
|
+
* node built-ins, so the API guards, which the client bundle reaches, can
|
|
4
|
+
* recognize the error without pulling in the reader (branch-metadata-file.ts).
|
|
5
|
+
*/
|
|
6
|
+
/** What the editor API answers for a branch whose branch.json is corrupt. */
|
|
7
|
+
export const BRANCH_METADATA_CORRUPT_MESSAGE = "This branch's metadata file is damaged, so the branch can't be opened or edited. An admin can repair it in System health.";
|
|
8
|
+
/**
|
|
9
|
+
* branch.json exists but is not valid JSON, or fails the schema in branch-metadata-file.ts.
|
|
10
|
+
* Distinguished from provisioning and IO failures so callers can degrade instead
|
|
11
|
+
* of failing hard: the registry scan quarantines the branch, the API answers
|
|
12
|
+
* {@link BRANCH_METADATA_CORRUPT_MESSAGE}, and the request handler keeps serving
|
|
13
|
+
* (with empty internal groups) when the BASE branch is the corrupt one —
|
|
14
|
+
* otherwise the admin recovery surface is unreachable exactly when it is needed.
|
|
15
|
+
*/
|
|
16
|
+
export class BranchMetadataCorruptError extends Error {
|
|
17
|
+
constructor(branchRoot, cause) {
|
|
18
|
+
super(`Corrupt branch metadata in '${branchRoot}': ${cause}`);
|
|
19
|
+
this.name = 'BranchMetadataCorruptError';
|
|
20
|
+
this.branchRoot = branchRoot;
|
|
21
|
+
this.parseCause = cause;
|
|
22
|
+
}
|
|
23
|
+
}
|