canopycms 0.12.0 → 0.13.0
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 +326 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.d.ts +20 -2
- package/dist/api/branch-review.js +153 -32
- package/dist/api/branch-status.d.ts +23 -2
- package/dist/api/branch-status.js +16 -2
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +227 -0
- package/dist/api/branch.d.ts +10 -0
- package/dist/api/branch.js +28 -2
- package/dist/api/client.d.ts +15 -2
- package/dist/api/client.js +24 -2
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +38 -1
- package/dist/api/content.js +41 -5
- package/dist/api/merge-conflicts.d.ts +235 -0
- package/dist/api/merge-conflicts.js +709 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +8 -3
- package/dist/base-update.d.ts +32 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +16 -1
- package/dist/branch-schema-cache.d.ts +12 -0
- package/dist/branch-schema-cache.js +27 -2
- package/dist/cli/backfill-item-keys.d.ts +77 -0
- package/dist/cli/backfill-item-keys.js +320 -0
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.js +9798 -1509
- package/dist/cli/generate-ai-content.js +373 -249
- package/dist/cli/github-app-manifest.d.ts +4 -1
- package/dist/cli/github-app-manifest.js +5 -1
- package/dist/cli/init.js +12 -0
- package/dist/cli/site-config.d.ts +14 -2
- package/dist/cli/site-config.js +20 -4
- package/dist/comment-store.d.ts +8 -0
- package/dist/comment-store.js +28 -0
- package/dist/content-index-generation.d.ts +1 -4
- package/dist/content-index-generation.js +1 -4
- package/dist/content-store.d.ts +39 -3
- package/dist/content-store.js +48 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +462 -62
- package/dist/editor/EntryNavigator.d.ts +10 -2
- package/dist/editor/EntryNavigator.js +12 -4
- package/dist/editor/FormRenderer.d.ts +8 -2
- package/dist/editor/FormRenderer.js +5 -6
- package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
- package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
- package/dist/editor/admin/SystemHealthPanel.js +4 -3
- package/dist/editor/base-update-wait.d.ts +78 -0
- package/dist/editor/base-update-wait.js +98 -0
- package/dist/editor/branch-status.js +1 -1
- package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
- package/dist/editor/components/BaseUpdateNotice.js +24 -0
- package/dist/editor/components/BranchPill.d.ts +4 -1
- package/dist/editor/components/BranchPill.js +5 -2
- package/dist/editor/components/ConflictsGroup.d.ts +112 -0
- package/dist/editor/components/ConflictsGroup.js +404 -0
- package/dist/editor/components/EditorHeader.d.ts +18 -1
- package/dist/editor/components/EditorHeader.js +99 -33
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +85 -167
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -2
- package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
- package/dist/editor/components/GetLatestChangesButton.js +29 -0
- package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
- package/dist/editor/components/ReleaseHoldButton.js +36 -0
- package/dist/editor/components/RetryToastMessage.js +1 -1
- package/dist/editor/components/change-parts.d.ts +51 -0
- package/dist/editor/components/change-parts.js +171 -0
- package/dist/editor/conflict-cards.d.ts +93 -0
- package/dist/editor/conflict-cards.js +284 -0
- package/dist/editor/conflict-choices.d.ts +60 -0
- package/dist/editor/conflict-choices.js +108 -0
- package/dist/editor/conflict-notice.d.ts +25 -0
- package/dist/editor/conflict-notice.js +39 -0
- package/dist/editor/copy.d.ts +12 -0
- package/dist/editor/copy.js +12 -0
- package/dist/editor/entry-diff.d.ts +2 -1
- package/dist/editor/entry-diff.js +5 -21
- package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
- package/dist/editor/hooks/useBaseUpdate.js +279 -0
- package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
- package/dist/editor/hooks/useBranchChangesData.js +13 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -1
- package/dist/editor/hooks/useBranchManager.js +108 -13
- package/dist/editor/hooks/useBranchesData.js +3 -0
- package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
- package/dist/editor/hooks/useConflictChoices.js +75 -0
- package/dist/editor/hooks/useConflictsData.d.ts +26 -0
- package/dist/editor/hooks/useConflictsData.js +89 -0
- package/dist/editor/hooks/useDraftManager.d.ts +13 -1
- package/dist/editor/hooks/useDraftManager.js +40 -9
- package/dist/editor/hooks/useEntryManager.d.ts +17 -3
- package/dist/editor/hooks/useEntryManager.js +11 -5
- package/dist/editor/release-hold.d.ts +12 -0
- package/dist/editor/release-hold.js +39 -0
- package/dist/editor/server-clock.d.ts +4 -0
- package/dist/editor/server-clock.js +15 -0
- package/dist/editor/undo-change.d.ts +7 -0
- package/dist/editor/undo-change.js +19 -4
- package/dist/git-manager.d.ts +6 -0
- package/dist/git-manager.js +38 -3
- package/dist/item-keys.d.ts +13 -0
- package/dist/item-keys.js +36 -14
- package/dist/merge/conflict-path.d.ts +40 -0
- package/dist/merge/conflict-path.js +128 -0
- package/dist/merge/diff3.d.ts +27 -0
- package/dist/merge/diff3.js +113 -0
- package/dist/merge/entry-merge.d.ts +94 -0
- package/dist/merge/entry-merge.js +876 -0
- package/dist/merge/list-alignment.d.ts +55 -0
- package/dist/merge/list-alignment.js +490 -0
- package/dist/merge/rekey.d.ts +11 -0
- package/dist/merge/rekey.js +75 -0
- package/dist/merge/text-merge.d.ts +29 -0
- package/dist/merge/text-merge.js +59 -0
- package/dist/merge/value-equality.d.ts +18 -0
- package/dist/merge/value-equality.js +50 -0
- package/dist/merge-conflicts.d.ts +232 -0
- package/dist/merge-conflicts.js +451 -0
- package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
- package/dist/operating-mode/client-safe-strategy.js +6 -0
- package/dist/operating-mode/client-unsafe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +13 -0
- package/dist/services.d.ts +24 -1
- package/dist/services.js +138 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +63 -5
- package/dist/utils/content-serialize.d.ts +18 -1
- package/dist/utils/content-serialize.js +12 -0
- package/dist/utils/content-write-lock.d.ts +3 -3
- package/dist/utils/content-write-lock.js +3 -3
- package/dist/utils/git.d.ts +7 -0
- package/dist/utils/git.js +35 -0
- package/dist/utils/markdown-body-splice.d.ts +1 -1
- package/dist/utils/markdown-body-splice.js +1 -19
- package/dist/utils/markdown-parse.d.ts +9 -0
- package/dist/utils/markdown-parse.js +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +80 -40
- package/dist/worker/branch-merge.js +244 -58
- package/dist/worker/branch-policy.d.ts +18 -2
- package/dist/worker/branch-policy.js +36 -0
- package/dist/worker/cms-worker.d.ts +37 -3
- package/dist/worker/cms-worker.js +88 -16
- package/dist/worker/conflicts-status.d.ts +20 -0
- package/dist/worker/conflicts-status.js +204 -0
- package/dist/worker/field-merge.d.ts +80 -0
- package/dist/worker/field-merge.js +729 -0
- package/dist/worker/git-sync.d.ts +3 -2
- package/dist/worker/git-sync.js +16 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +444 -52
- package/dist/worker/github-errors.d.ts +15 -0
- package/dist/worker/github-errors.js +47 -0
- package/dist/worker/github-gateway-http.js +24 -9
- package/dist/worker/github-gateway-wire.d.ts +49 -20
- package/dist/worker/github-gateway-wire.js +19 -3
- package/dist/worker/github-gateway.d.ts +27 -1
- package/dist/worker/github-gateway.js +55 -7
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/history-rewrite.d.ts +40 -8
- package/dist/worker/history-rewrite.js +51 -23
- package/dist/worker/publish-decision.d.ts +37 -0
- package/dist/worker/publish-decision.js +16 -0
- package/dist/worker/rebase.d.ts +29 -8
- package/dist/worker/rebase.js +492 -211
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +30 -41
- package/dist/worker/worker-context.d.ts +24 -2
- package/package.json +1 -1
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: npx canopycms backfill-item-keys [--check] [--force] [--app-dir <dir>]
|
|
3
|
+
*
|
|
4
|
+
* Writes the `_key` of every object-list item and block (`item-keys.ts`) into the site's content,
|
|
5
|
+
* so an editor's first save of an entry adds no key lines and a merge sees only real edits.
|
|
6
|
+
*
|
|
7
|
+
* Each entry goes through the editor's own save path, with nothing re-derived:
|
|
8
|
+
* `ContentStore.read` keys the items exactly as the editor's form holds them, and
|
|
9
|
+
* `ContentStore.write` writes that data back with the read's version, through the serialiser,
|
|
10
|
+
* content-write lock and version check a save uses, after the content API's own
|
|
11
|
+
* `entryDataAsSaved`. So a written file is byte for byte what an editor save of the unedited entry
|
|
12
|
+
* leaves, a second run finds every item keyed and writes nothing, and the next editor save adds no
|
|
13
|
+
* lines of its own, a CRLF markdown file aside (safeinsights/canopycms#1234).
|
|
14
|
+
*
|
|
15
|
+
* A file is written only when its items lack keys. The write then also carries whatever else an
|
|
16
|
+
* unedited save normalises (a one-line JSON file comes out pretty-printed, a stray frontmatter key
|
|
17
|
+
* named as the body goes), and those files are listed apart for review. Out of scope, as for the editor: list fields the schema does not
|
|
18
|
+
* describe, `.collection.json`, and entries of a type this code's registry lacks.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs/promises';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { diffArrays } from 'diff';
|
|
23
|
+
import { simpleGit } from 'simple-git';
|
|
24
|
+
import { flattenSchema } from '../config/index.js';
|
|
25
|
+
import { contentIndexGenerationPath } from '../content-index-generation.js';
|
|
26
|
+
import { listCollectionEntries, parseEntryParts, } from '../content-listing.js';
|
|
27
|
+
import { entryDataAsSaved } from '../api/content.js';
|
|
28
|
+
import { ContentStore } from '../content-store.js';
|
|
29
|
+
import { ITEM_KEY, withItemKeys } from '../item-keys.js';
|
|
30
|
+
import { isValidSchema, resolveSchema } from '../schema/index.js';
|
|
31
|
+
import { serializeEntry } from '../utils/content-serialize.js';
|
|
32
|
+
import { getErrorMessage, isNodeError } from '../utils/error.js';
|
|
33
|
+
import { filePathExists } from '../utils/fs.js';
|
|
34
|
+
import { CANOPY_META_DIR } from '../utils/git.js';
|
|
35
|
+
import { isPlainRecord } from '../validation/field-traversal.js';
|
|
36
|
+
import { importSiteEntrySchemaRegistry, importSiteServerConfig } from './site-config.js';
|
|
37
|
+
/**
|
|
38
|
+
* Keys every entry's items under `root`, or with `check`, reports which files a run would write.
|
|
39
|
+
* @internal Exported for tests.
|
|
40
|
+
*/
|
|
41
|
+
export async function backfillItemKeys(options) {
|
|
42
|
+
const { root, flatSchema, contentRootName, check } = options;
|
|
43
|
+
const store = new ContentStore(root, flatSchema, { contentRootName });
|
|
44
|
+
const result = { changed: [], failed: [], unavailable: [], malformed: [] };
|
|
45
|
+
const collections = flatSchema.filter((item) => item.type === 'collection' && !!item.entries);
|
|
46
|
+
for (const collection of collections) {
|
|
47
|
+
const entryTypes = collection.entries ?? [];
|
|
48
|
+
let listed;
|
|
49
|
+
try {
|
|
50
|
+
listed = await listCollectionEntries(root, collection, (file) => result.malformed.push(path.posix.join(file.collectionPath, file.filename)));
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
result.failed.push({ path: `${collection.logicalPath}/`, error: getErrorMessage(err) });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
for (const item of listed) {
|
|
57
|
+
const entryType = entryTypes.find((type) => type.name === item.entryType);
|
|
58
|
+
if (!entryType || entryType.unavailable) {
|
|
59
|
+
result.unavailable.push(item.physicalPath);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const changed = await backfillEntry(store, root, item, entryType.schema, check);
|
|
64
|
+
if (changed)
|
|
65
|
+
result.changed.push({ path: item.physicalPath, ...changed });
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
result.failed.push({ path: item.physicalPath, error: getErrorMessage(err) });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const byPath = (a, b) => a.path.localeCompare(b.path);
|
|
73
|
+
result.changed.sort(byPath);
|
|
74
|
+
result.failed.sort(byPath);
|
|
75
|
+
result.unavailable.sort();
|
|
76
|
+
result.malformed.sort();
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
/** The entry's counts when its items lack keys, after writing it unless `check`; else null. */
|
|
80
|
+
async function backfillEntry(store, root, item, fields, check) {
|
|
81
|
+
const doc = await store.read(item.collectionPath, item.slug, { resolveReferences: false });
|
|
82
|
+
// The store finds a slug's file by name alone, so a slug another file in the collection also
|
|
83
|
+
// carries (another entry type's, or a stray copy) addresses only one of them.
|
|
84
|
+
if (doc.absolutePath !== path.resolve(root, item.physicalPath)) {
|
|
85
|
+
throw new Error(`Another file in its collection has the slug "${item.slug}", so neither a save nor the ` +
|
|
86
|
+
'backfill can address this one. Rename or remove one of them.');
|
|
87
|
+
}
|
|
88
|
+
// After the read: its version was taken before its own read of the file, so a change landing
|
|
89
|
+
// between the two fails the write's version check rather than writing over it.
|
|
90
|
+
const before = await fs.readFile(doc.absolutePath, 'utf8');
|
|
91
|
+
const asWritten = parseEntryParts(before, doc.format).data;
|
|
92
|
+
const keyed = withItemKeys(fields, asWritten, 'content');
|
|
93
|
+
if (keyed === asWritten)
|
|
94
|
+
return null;
|
|
95
|
+
const entry = savedEntry(doc, fields);
|
|
96
|
+
let after;
|
|
97
|
+
if (check) {
|
|
98
|
+
after = serializeEntry(entry, before, fields);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
if (doc.version === undefined)
|
|
102
|
+
throw new Error('The read carried no version to write against.');
|
|
103
|
+
await store.write(item.collectionPath, item.slug, { ...entry, expectedVersion: doc.version }, item.entryType);
|
|
104
|
+
after = await fs.readFile(doc.absolutePath, 'utf8');
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
itemsKeyed: countAddedKeys(keyed, asWritten),
|
|
108
|
+
otherLines: countOtherLines(before, after),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** What a save of the entry, opened and saved unedited, hands the serialiser. */
|
|
112
|
+
function savedEntry(doc, fields) {
|
|
113
|
+
const data = entryDataAsSaved(fields, doc.format, doc.data);
|
|
114
|
+
if (doc.format === 'json')
|
|
115
|
+
return { format: 'json', data };
|
|
116
|
+
if (doc.format === 'yaml')
|
|
117
|
+
return { format: 'yaml', data };
|
|
118
|
+
return { format: doc.format, data, body: doc.body };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The `_key`s `keyed` holds that `asWritten` lacks or holds differently. `withItemKeys` returns
|
|
122
|
+
* every subtree it left alone as the same object, so the walk skips those.
|
|
123
|
+
*/
|
|
124
|
+
function countAddedKeys(keyed, asWritten) {
|
|
125
|
+
if (keyed === asWritten)
|
|
126
|
+
return 0;
|
|
127
|
+
if (Array.isArray(keyed)) {
|
|
128
|
+
const was = Array.isArray(asWritten) ? asWritten : [];
|
|
129
|
+
return keyed.reduce((n, value, i) => n + countAddedKeys(value, was[i]), 0);
|
|
130
|
+
}
|
|
131
|
+
if (!isPlainRecord(keyed))
|
|
132
|
+
return 0;
|
|
133
|
+
const was = isPlainRecord(asWritten) ? asWritten : {};
|
|
134
|
+
let count = ITEM_KEY in keyed && keyed[ITEM_KEY] !== was[ITEM_KEY] ? 1 : 0;
|
|
135
|
+
for (const [name, value] of Object.entries(keyed)) {
|
|
136
|
+
if (name !== ITEM_KEY)
|
|
137
|
+
count += countAddedKeys(value, was[name]);
|
|
138
|
+
}
|
|
139
|
+
return count;
|
|
140
|
+
}
|
|
141
|
+
/** A `_key` line as YAML (frontmatter included) or JSON spells it, its trailing comma trimmed. */
|
|
142
|
+
function isKeyLine(line) {
|
|
143
|
+
const text = line.trim().replace(/^-\s/, '').trimStart();
|
|
144
|
+
for (const name of ['_key:', '"_key":']) {
|
|
145
|
+
if (text.startsWith(name))
|
|
146
|
+
return /^\s*\S+$/.test(text.slice(name.length));
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Lines changed between `before` and `after` other than added `_key` lines, a changed line ending
|
|
152
|
+
* included. A trailing comma is ignored, since a JSON key appended to an object adds one to the
|
|
153
|
+
* line above it.
|
|
154
|
+
*/
|
|
155
|
+
function countOtherLines(before, after) {
|
|
156
|
+
const lines = (text) => text.split('\n').map((line) => line.replace(/,(\r?)$/, '$1'));
|
|
157
|
+
let other = 0;
|
|
158
|
+
for (const change of diffArrays(lines(before), lines(after))) {
|
|
159
|
+
if (change.removed)
|
|
160
|
+
other += change.value.length;
|
|
161
|
+
else if (change.added)
|
|
162
|
+
other += change.value.filter((line) => !isKeyLine(line)).length;
|
|
163
|
+
}
|
|
164
|
+
return other;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Exit codes. `--check`: 0 when no file needs keys, 1 when some would change. A run: 0 when done,
|
|
168
|
+
* 1 when it would not write: uncommitted content changes, or no git to ask. Both: 2 when the
|
|
169
|
+
* site's config or schema could not be loaded, a collection listed, or an entry read or written.
|
|
170
|
+
* @internal Exported for tests.
|
|
171
|
+
*/
|
|
172
|
+
export const BACKFILL_EXIT_CODES = { ok: 0, changesOrRefused: 1, loadError: 2 };
|
|
173
|
+
/** Runs the command and returns its exit code. */
|
|
174
|
+
export async function backfillItemKeysCLI(options) {
|
|
175
|
+
const { projectDir, appDir = 'app', check = false, force = false } = options;
|
|
176
|
+
console.log('\nCanopyCMS backfill-item-keys\n');
|
|
177
|
+
let contentRootName;
|
|
178
|
+
let registry;
|
|
179
|
+
try {
|
|
180
|
+
const { server } = await importSiteServerConfig(projectDir);
|
|
181
|
+
const contentRoot = isPlainRecord(server) ? (server.contentRoot ?? 'content') : null;
|
|
182
|
+
if (typeof contentRoot !== 'string') {
|
|
183
|
+
throw new Error('canopycms.config.ts does not export a config made by defineCanopyConfig().');
|
|
184
|
+
}
|
|
185
|
+
contentRootName = contentRoot;
|
|
186
|
+
registry = (await importSiteEntrySchemaRegistry(projectDir, appDir)).registry;
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
console.error(`Could not load the site: ${getErrorMessage(err)}`);
|
|
190
|
+
return BACKFILL_EXIT_CODES.loadError;
|
|
191
|
+
}
|
|
192
|
+
let flatSchema;
|
|
193
|
+
try {
|
|
194
|
+
const contentDir = path.join(projectDir, contentRootName);
|
|
195
|
+
const resolved = await resolveSchema(contentDir, registry, {
|
|
196
|
+
unknownSchema: 'degrade',
|
|
197
|
+
});
|
|
198
|
+
if (!isValidSchema(resolved.schema)) {
|
|
199
|
+
throw new Error(`No .collection.json files describe ${contentRootName}/.`);
|
|
200
|
+
}
|
|
201
|
+
for (const issue of resolved.issues)
|
|
202
|
+
console.warn(` Warning: ${issue.message}`);
|
|
203
|
+
flatSchema = flattenSchema(resolved.schema, contentRootName);
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
console.error(`Could not load the schema: ${getErrorMessage(err)}`);
|
|
207
|
+
return BACKFILL_EXIT_CODES.loadError;
|
|
208
|
+
}
|
|
209
|
+
if (!check && !force) {
|
|
210
|
+
const refusal = await uncommittedContent(projectDir, contentRootName);
|
|
211
|
+
if (refusal) {
|
|
212
|
+
console.error(refusal);
|
|
213
|
+
return BACKFILL_EXIT_CODES.changesOrRefused;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const metaDir = path.join(projectDir, CANOPY_META_DIR);
|
|
217
|
+
const metaDirExisted = await filePathExists(metaDir);
|
|
218
|
+
let result;
|
|
219
|
+
try {
|
|
220
|
+
result = await backfillItemKeys({ root: projectDir, flatSchema, contentRootName, check });
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
console.error(`Could not finish: ${getErrorMessage(err)}` +
|
|
224
|
+
(check ? '' : '\nFiles keyed before the error stay written; finish with --force.'));
|
|
225
|
+
return BACKFILL_EXIT_CODES.loadError;
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
// The marker tells other processes to rebuild their ID index, and the backfill moves no file,
|
|
229
|
+
// so no index goes stale. An adopter's .gitignore need not cover `.canopy-meta/`, so at a
|
|
230
|
+
// project root that had none, remove what this run created there.
|
|
231
|
+
if (!check && !metaDirExisted)
|
|
232
|
+
await removeOwnMetaDir(projectDir, metaDir);
|
|
233
|
+
}
|
|
234
|
+
printResult(result, check);
|
|
235
|
+
if (result.failed.length > 0)
|
|
236
|
+
return BACKFILL_EXIT_CODES.loadError;
|
|
237
|
+
if (check && result.changed.length > 0)
|
|
238
|
+
return BACKFILL_EXIT_CODES.changesOrRefused;
|
|
239
|
+
return BACKFILL_EXIT_CODES.ok;
|
|
240
|
+
}
|
|
241
|
+
/** Why a run must not write: the content tree has uncommitted changes, or no git to ask. */
|
|
242
|
+
async function uncommittedContent(projectDir, contentRootName) {
|
|
243
|
+
let status;
|
|
244
|
+
try {
|
|
245
|
+
status = await simpleGit({ baseDir: projectDir }).raw([
|
|
246
|
+
'status',
|
|
247
|
+
'--porcelain',
|
|
248
|
+
'--untracked-files=all',
|
|
249
|
+
'--',
|
|
250
|
+
contentRootName,
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
return (`Could not ask git whether ${contentRootName}/ has uncommitted changes ` +
|
|
255
|
+
`(${getErrorMessage(err)}). Run inside the site's git checkout, or pass --force.`);
|
|
256
|
+
}
|
|
257
|
+
const dirty = status.split('\n').filter((line) => line.trim() !== '');
|
|
258
|
+
if (dirty.length === 0)
|
|
259
|
+
return null;
|
|
260
|
+
const shown = dirty.slice(0, 10).map((line) => ` ${line}`);
|
|
261
|
+
if (dirty.length > shown.length)
|
|
262
|
+
shown.push(` … and ${dirty.length - shown.length} more`);
|
|
263
|
+
return (`${contentRootName}/ has uncommitted changes, so the backfill's own changes could not be ` +
|
|
264
|
+
`reviewed apart from them:\n${shown.join('\n')}\n` +
|
|
265
|
+
'Commit or stash them first, or pass --force.');
|
|
266
|
+
}
|
|
267
|
+
async function removeOwnMetaDir(projectDir, metaDir) {
|
|
268
|
+
try {
|
|
269
|
+
await fs.rm(contentIndexGenerationPath(projectDir), { force: true });
|
|
270
|
+
await fs.rmdir(metaDir);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
if (isNodeError(err) && err.code === 'ENOENT')
|
|
274
|
+
return;
|
|
275
|
+
console.warn(` Left ${CANOPY_META_DIR}/ in place: ${getErrorMessage(err)}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function printResult(result, check) {
|
|
279
|
+
const plural = (n, word, words = `${word}s`) => `${n} ${n === 1 ? word : words}`;
|
|
280
|
+
const keysOnly = result.changed.filter((file) => file.otherLines === 0);
|
|
281
|
+
const normalised = result.changed.filter((file) => file.otherLines > 0);
|
|
282
|
+
const verb = check ? 'Would key' : 'Keyed';
|
|
283
|
+
if (keysOnly.length > 0) {
|
|
284
|
+
console.log(` ${verb} items in ${plural(keysOnly.length, 'file')}, adding only _key lines:`);
|
|
285
|
+
for (const file of keysOnly)
|
|
286
|
+
console.log(` ${file.path} ${plural(file.itemsKeyed, 'item')}`);
|
|
287
|
+
}
|
|
288
|
+
if (normalised.length > 0) {
|
|
289
|
+
console.log(`\n ${verb} items in ${plural(normalised.length, 'file')} that an unedited save also ` +
|
|
290
|
+
'normalises in other lines. Review these before committing:');
|
|
291
|
+
for (const file of normalised) {
|
|
292
|
+
console.log(` ${file.path} ${plural(file.itemsKeyed, 'item')}, ` +
|
|
293
|
+
`${plural(file.otherLines, 'other line')}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (result.unavailable.length > 0) {
|
|
297
|
+
console.warn(`\n Skipped ${plural(result.unavailable.length, 'entry', 'entries')} whose entry type the schema ` +
|
|
298
|
+
'registry does not have.');
|
|
299
|
+
}
|
|
300
|
+
if (result.malformed.length > 0) {
|
|
301
|
+
console.warn(`\n Skipped ${plural(result.malformed.length, 'file')} that look like entries but whose ` +
|
|
302
|
+
`names don't parse:\n${result.malformed.map((file) => ` ${file}`).join('\n')}`);
|
|
303
|
+
}
|
|
304
|
+
if (result.failed.length > 0) {
|
|
305
|
+
console.error(`\n Could not ${check ? 'check' : 'key'}:`);
|
|
306
|
+
for (const file of result.failed)
|
|
307
|
+
console.error(` ${file.path}: ${file.error}`);
|
|
308
|
+
}
|
|
309
|
+
const skippedAny = result.failed.length + result.unavailable.length + result.malformed.length > 0;
|
|
310
|
+
if (result.changed.length === 0) {
|
|
311
|
+
console.log(` Every ${skippedAny ? 'other ' : ''}entry's items already have their keys.`);
|
|
312
|
+
}
|
|
313
|
+
else if (check) {
|
|
314
|
+
console.log(`\n Run \`canopycms backfill-item-keys\` to write them.`);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
console.log('\n Commit these changes on their own, so the commit holds nothing else.');
|
|
318
|
+
}
|
|
319
|
+
console.log('');
|
|
320
|
+
}
|
package/dist/cli/cli.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Routes commands to their implementations:
|
|
6
6
|
* init, init-deploy, init-github-app, worker, generate-ai-content, collect-asset-refs,
|
|
7
|
-
* materialize-assets, sync, migrate
|
|
7
|
+
* materialize-assets, sync, migrate, backfill-item-keys
|
|
8
8
|
*
|
|
9
9
|
* Command implementations live in separate files (init.ts, sync.ts, etc.)
|
|
10
10
|
* and are dynamically imported to keep startup fast.
|