@crombolo/cli 0.0.0-stage → 0.9.1

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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/bin/crombolo.ts +2108 -0
  4. package/package.json +42 -4
  5. package/src/index.ts +7 -0
  6. package/src/operations/commands.ts +775 -0
  7. package/src/operations/sections.ts +423 -0
  8. package/src/templates.ts +309 -0
  9. package/templates/basic/bundle/bundle.json +45 -0
  10. package/templates/basic/bundle/nodes/36955f6e-d110-469e-b280-72096eccd148.json +38 -0
  11. package/templates/basic/files/components/homePage.tsx +22 -0
  12. package/templates/basic/files/schema/content-types/home-page.toml +28 -0
  13. package/templates/basic/files/schema/schema.toml +2 -0
  14. package/templates/basic/template.json +4 -0
  15. package/templates/demo/harbourstone/CREDITS.md +42 -0
  16. package/templates/demo/harbourstone/bundle/blobs/180cc8ef/distillery-from-the-cliff-path.jpg +0 -0
  17. package/templates/demo/harbourstone/bundle/blobs/68c9cfdf/bottle-brine-and-kiln.jpg +0 -0
  18. package/templates/demo/harbourstone/bundle/blobs/6be45535/bottle-oloroso-pier.jpg +0 -0
  19. package/templates/demo/harbourstone/bundle/blobs/6d8d6398/bottle-the-cove.jpg +0 -0
  20. package/templates/demo/harbourstone/bundle/blobs/7b4713f8/copper-pot-stills.jpg +0 -0
  21. package/templates/demo/harbourstone/bundle/blobs/c114b7d8/bottle-neap-tide.jpg +0 -0
  22. package/templates/demo/harbourstone/bundle/blobs/fd337567/casks-in-the-sea-facing-warehouse.jpg +0 -0
  23. package/templates/demo/harbourstone/bundle/bundle.json +197 -0
  24. package/templates/demo/harbourstone/bundle/nodes/069cc3c5-c9f5-4792-90c9-e2bfcc4855b6.json +35 -0
  25. package/templates/demo/harbourstone/bundle/nodes/13788fe7-1a45-4b48-8ae4-f4227e154e1e.json +35 -0
  26. package/templates/demo/harbourstone/bundle/nodes/180cc8ef-caf1-4316-b6a7-e3b5aee41ffd.json +60 -0
  27. package/templates/demo/harbourstone/bundle/nodes/22144db2-e214-4006-a0bb-53b5bd121fd6.json +35 -0
  28. package/templates/demo/harbourstone/bundle/nodes/26e87610-e2d6-4b47-bf92-3967f7186907.json +35 -0
  29. package/templates/demo/harbourstone/bundle/nodes/2caa099b-3884-472d-9cea-797a0f0c376f.json +35 -0
  30. package/templates/demo/harbourstone/bundle/nodes/2efbdb5b-7888-4a49-bca5-c0f85b76e472.json +42 -0
  31. package/templates/demo/harbourstone/bundle/nodes/2fe03891-1294-4344-8e24-47e8b212e5b0.json +35 -0
  32. package/templates/demo/harbourstone/bundle/nodes/38b009f7-03c0-4fda-b5c7-843ad6044e9c.json +35 -0
  33. package/templates/demo/harbourstone/bundle/nodes/3d312cc8-2937-40e1-ba14-fd781c9a862d.json +95 -0
  34. package/templates/demo/harbourstone/bundle/nodes/3dc30fc1-973a-4217-b26f-24902210be17.json +35 -0
  35. package/templates/demo/harbourstone/bundle/nodes/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +101 -0
  36. package/templates/demo/harbourstone/bundle/nodes/483a0d3b-a10c-4c9d-8c81-d9607d37e968.json +35 -0
  37. package/templates/demo/harbourstone/bundle/nodes/4b760c0b-b83a-4eee-aa29-c225cfabf19b.json +35 -0
  38. package/templates/demo/harbourstone/bundle/nodes/4e5c7f3f-54cb-4328-ad61-bc4c2736b890.json +35 -0
  39. package/templates/demo/harbourstone/bundle/nodes/62f479ff-0875-453e-99ae-3bf1bec5e3c8.json +35 -0
  40. package/templates/demo/harbourstone/bundle/nodes/68c9cfdf-6b40-47da-a1d2-17256a6cc6db.json +60 -0
  41. package/templates/demo/harbourstone/bundle/nodes/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +101 -0
  42. package/templates/demo/harbourstone/bundle/nodes/6be45535-83e5-4469-acf0-4127016721ae.json +60 -0
  43. package/templates/demo/harbourstone/bundle/nodes/6d8d6398-738f-4f38-ad3c-e5d6e9a971fe.json +60 -0
  44. package/templates/demo/harbourstone/bundle/nodes/7b4713f8-d917-457d-a9d8-7dd01a3617b4.json +60 -0
  45. package/templates/demo/harbourstone/bundle/nodes/88ec1185-53b5-439a-a2ce-3f56603be4ff.json +35 -0
  46. package/templates/demo/harbourstone/bundle/nodes/97469df2-107c-4010-8c14-4de88f735529.json +35 -0
  47. package/templates/demo/harbourstone/bundle/nodes/a2eb439f-71d9-4ea6-89d7-c5bc72618281.json +35 -0
  48. package/templates/demo/harbourstone/bundle/nodes/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +101 -0
  49. package/templates/demo/harbourstone/bundle/nodes/a5e36a18-a2ef-4eb5-85b4-7e12b21cca06.json +35 -0
  50. package/templates/demo/harbourstone/bundle/nodes/b1876d3a-50a7-4358-84bf-a0899932ffa9.json +35 -0
  51. package/templates/demo/harbourstone/bundle/nodes/b4e38bbd-c1a3-431a-b7a9-5868aa32993a.json +35 -0
  52. package/templates/demo/harbourstone/bundle/nodes/c114b7d8-0138-404b-9d8f-a39d992577a2.json +60 -0
  53. package/templates/demo/harbourstone/bundle/nodes/cb4c569e-c28a-43b6-a4d7-d33bb544e973.json +136 -0
  54. package/templates/demo/harbourstone/bundle/nodes/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +101 -0
  55. package/templates/demo/harbourstone/bundle/nodes/d60ef12b-0f0a-4db5-9763-21fa52b8d4da.json +150 -0
  56. package/templates/demo/harbourstone/bundle/nodes/d7f13222-d060-4496-b86d-bef21da963c1.json +35 -0
  57. package/templates/demo/harbourstone/bundle/nodes/f36dd4a9-927c-4ca3-a790-c9c859ac7302.json +35 -0
  58. package/templates/demo/harbourstone/bundle/nodes/fd337567-df67-41ca-ae94-9d84d8d6042f.json +60 -0
  59. package/templates/demo/harbourstone/files/components/pages/contactPage.tsx +36 -0
  60. package/templates/demo/harbourstone/files/components/pages/contentPage.tsx +54 -0
  61. package/templates/demo/harbourstone/files/components/pages/expression.tsx +33 -0
  62. package/templates/demo/harbourstone/files/components/pages/homePage.tsx +71 -0
  63. package/templates/demo/harbourstone/files/components/pages/whiskyList.tsx +19 -0
  64. package/templates/demo/harbourstone/files/components/shared/bottling.tsx +59 -0
  65. package/templates/demo/harbourstone/files/components/shared/highlights.tsx +39 -0
  66. package/templates/demo/harbourstone/files/components/shared/layout.tsx +49 -0
  67. package/templates/demo/harbourstone/files/components/shared/section-intro.tsx +40 -0
  68. package/templates/demo/harbourstone/files/components/shared/site-footer.tsx +14 -0
  69. package/templates/demo/harbourstone/files/components/shared/site-header.tsx +57 -0
  70. package/templates/demo/harbourstone/files/css/site.css +720 -0
  71. package/templates/demo/harbourstone/files/schema/content-types/contact-page.toml +75 -0
  72. package/templates/demo/harbourstone/files/schema/content-types/content-page.toml +89 -0
  73. package/templates/demo/harbourstone/files/schema/content-types/expression.toml +78 -0
  74. package/templates/demo/harbourstone/files/schema/content-types/highlight.toml +27 -0
  75. package/templates/demo/harbourstone/files/schema/content-types/home-page.toml +104 -0
  76. package/templates/demo/harbourstone/files/schema/content-types/tasting-note.toml +26 -0
  77. package/templates/demo/harbourstone/files/schema/content-types/whisky-list.toml +35 -0
  78. package/templates/demo/harbourstone/files/schema/data-types/highlight-picker.toml +10 -0
  79. package/templates/demo/harbourstone/files/schema/data-types/tasting-note-picker.toml +11 -0
  80. package/templates/demo/harbourstone/files/schema/forms/contact.toml +119 -0
  81. package/templates/demo/harbourstone/files/schema/schema.toml +2 -0
  82. package/templates/demo/harbourstone/template.json +10 -0
  83. package/templates/starter/bundle/bundle.json +49 -0
  84. package/templates/starter/bundle/nodes/634dd948-cf83-4cad-9b7b-7c938b33da76.json +62 -0
  85. package/templates/starter/bundle/nodes/64d95359-d91a-4050-b440-3e5124a74999.json +62 -0
  86. package/templates/starter/bundle/nodes/d2997ebb-36ca-47cb-bbc8-31fefca787c0.json +45 -0
  87. package/templates/starter/files/components/pages/contentPage.tsx +25 -0
  88. package/templates/starter/files/components/shared/layout.tsx +24 -0
  89. package/templates/starter/files/components/shared/site-footer.tsx +9 -0
  90. package/templates/starter/files/components/shared/site-header.tsx +40 -0
  91. package/templates/starter/files/css/site.css +117 -0
  92. package/templates/starter/files/schema/content-types/content-page.toml +51 -0
  93. package/templates/starter/files/schema/schema.toml +2 -0
  94. package/templates/starter/template.json +4 -0
  95. package/README.md +0 -3
@@ -0,0 +1,775 @@
1
+ /**
2
+ * The commands an operator runs against an environment, as functions: they take
3
+ * options and return what happened, and never print or exit. The `crombolo`
4
+ * binary renders their results for a terminal; anything else that runs them —
5
+ * a platform's own tooling — gets the same results as data. One implementation,
6
+ * so the two can never disagree about what an import or an upgrade does.
7
+ */
8
+ import { ObjectTypes, ROOT_ACCESS } from '@crombolo/core'
9
+ import {
10
+ connect,
11
+ cromboloPlan,
12
+ type Db,
13
+ describeRefFailure,
14
+ type NodeRow,
15
+ pauseWrites,
16
+ planUpgrade,
17
+ readWritesPaused,
18
+ recordInLedger,
19
+ resolveNodeRef,
20
+ resumeWrites,
21
+ type WritesPaused,
22
+ writeReport,
23
+ } from '@crombolo/data'
24
+ import { loadSchemaDirectory, loadValueMigrations, runFix, runUpgrade } from '@crombolo/schema'
25
+ import {
26
+ type BackupResult,
27
+ backupBefore,
28
+ bootSchema,
29
+ bootstrapDatabase,
30
+ type CromboloConfig,
31
+ checkSchemaDirectory,
32
+ componentAliasesIn,
33
+ createFileIntake,
34
+ createMediaPort,
35
+ installDatabase,
36
+ MediaFileStore,
37
+ mediaStoreFor,
38
+ placeBlobs,
39
+ readBlobs,
40
+ syncOptionsFor,
41
+ } from '@crombolo/server'
42
+ import {
43
+ type CheckOptions,
44
+ type ContentSet,
45
+ checkBundle,
46
+ exportBundle,
47
+ importBundle,
48
+ loadBundle,
49
+ loadResolutions,
50
+ type Resolution,
51
+ type TransferCheck,
52
+ writeBundle,
53
+ } from '@crombolo/transfer'
54
+ import {
55
+ applySections,
56
+ type CarriedSections,
57
+ carriedSections,
58
+ carriesSections,
59
+ planSections,
60
+ promoteBackups,
61
+ replacementsNeedingPermission,
62
+ type SectionFile,
63
+ type SectionOptions,
64
+ type SectionPlan,
65
+ type SectionResult,
66
+ stageBackups,
67
+ } from './sections.ts'
68
+
69
+ export {
70
+ isResolution,
71
+ RESOLUTIONS,
72
+ type Resolution,
73
+ type TransferCheck,
74
+ } from '@crombolo/transfer'
75
+ // The section half of an install, exported for the same reason the commands
76
+ // are: tooling that applies a bundle without a terminal needs the plan too.
77
+ export {
78
+ applySections,
79
+ type CarriedSections,
80
+ carriedSections,
81
+ carriesSections,
82
+ type FileAction,
83
+ planSections,
84
+ promoteBackups,
85
+ type RestoredFiles,
86
+ RUN_BACKUP_DIR,
87
+ replacementsNeedingPermission,
88
+ restoreSectionFiles,
89
+ type SectionFile,
90
+ type SectionOptions,
91
+ type SectionPlan,
92
+ type SectionResult,
93
+ stageBackups,
94
+ } from './sections.ts'
95
+ export type { BackupResult, WritesPaused }
96
+
97
+ /** The trees a content selector or placement may name. */
98
+ export const CONTENT_OBJECT_TYPES = [ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Element]
99
+
100
+ /** A refusal the caller should read: nothing was written. */
101
+ export class CommandError extends Error {
102
+ constructor(
103
+ message: string,
104
+ readonly problems: string[] = [],
105
+ ) {
106
+ super(message)
107
+ }
108
+ }
109
+
110
+ export interface BundleSummary {
111
+ id: string
112
+ from: string
113
+ schemaVersion: string
114
+ schemaRevision: string
115
+ snapshot: string
116
+ nodes: number
117
+ blobsCarried: number
118
+ blobBytes: number
119
+ }
120
+
121
+ /**
122
+ * The check an install that never reached the content reports.
123
+ *
124
+ * Nothing was examined, so nothing is outstanding: the refusal is in
125
+ * `problems`, and a caller reading `outstanding.length` must not be told there
126
+ * were content findings when there were none.
127
+ */
128
+ const emptyCheck = (bundleId = ''): TransferCheck => ({
129
+ bundleId,
130
+ findings: [],
131
+ outstanding: [],
132
+ plan: [],
133
+ counts: { create: 0, update: 0, unchanged: 0, skip: 0 },
134
+ })
135
+
136
+ function summarise(set: ContentSet): BundleSummary {
137
+ const carried = set.manifest.blobs.filter((blob) => blob.included)
138
+ return {
139
+ id: set.manifest.id,
140
+ from: set.manifest.provenance.siteName || '',
141
+ schemaVersion: set.manifest.provenance.schemaVersion,
142
+ schemaRevision: set.manifest.provenance.schemaRevision,
143
+ snapshot: set.manifest.snapshot,
144
+ nodes: set.nodes.length,
145
+ blobsCarried: carried.length,
146
+ blobBytes: carried.reduce((total, blob) => total + (blob.size ?? 0), 0),
147
+ }
148
+ }
149
+
150
+ /** Reads a bundle whole, or refuses: a half-copied bundle must never be checked as though complete. */
151
+ export function readBundle(dir: string): {
152
+ set: ContentSet
153
+ blobs: ReadonlyMap<string, string>
154
+ /** The sections it carries beyond its content; empty for a content-only bundle. */
155
+ carried: CarriedSections
156
+ } {
157
+ const loaded = loadBundle(dir)
158
+ const problems = loaded.problems.map((problem) => `${problem.file}: ${problem.message}`)
159
+ if (!loaded.set || problems.length > 0)
160
+ throw new CommandError(
161
+ 'The bundle is incomplete or damaged; nothing was read from it.',
162
+ problems,
163
+ )
164
+ return { set: loaded.set, blobs: loaded.blobs, carried: carriedSections(loaded) }
165
+ }
166
+
167
+ export interface TransferInput extends SectionOptions {
168
+ /** The bundle's directory on this machine. */
169
+ dir: string
170
+ /** Where the bundle's roots go: a path or a key. */
171
+ under?: string
172
+ /** A decision per node key, on top of any the bundle carries. */
173
+ resolutions?: Record<string, Resolution>
174
+ resolveAll?: Resolution
175
+ allowMissingBlobs?: boolean
176
+ }
177
+
178
+ /** The options `check` and `import` share: saved decisions, then the caller's, then placement. */
179
+ export async function transferOptions(
180
+ db: Db,
181
+ config: CromboloConfig,
182
+ set: ContentSet,
183
+ input: TransferInput,
184
+ ): Promise<{ options: CheckOptions; under: NodeRow | undefined; problems: string[] }> {
185
+ const saved = loadResolutions(input.dir, set.manifest.id)
186
+ const resolutions = { ...(saved.file?.byNode ?? {}), ...(input.resolutions ?? {}) }
187
+ const resolveAll = input.resolveAll ?? saved.file?.all ?? undefined
188
+ const underRef = input.under ?? saved.file?.under ?? undefined
189
+ let under: NodeRow | undefined
190
+ if (underRef) {
191
+ const resolved = await resolveNodeRef(db, CONTENT_OBJECT_TYPES, underRef)
192
+ if (!resolved.ok) throw new CommandError(describeRefFailure(underRef, resolved))
193
+ under = resolved.node
194
+ }
195
+ // Whether this environment has a media file is a question only its store can
196
+ // answer; without it `missing-blob` is a finding the check could never raise.
197
+ const store = await mediaStoreFor(config)
198
+ return {
199
+ options: {
200
+ under,
201
+ resolutions,
202
+ resolveAll,
203
+ componentAliases: componentAliasesIn(config.componentsDir),
204
+ allowMissingBlobs: input.allowMissingBlobs ?? false,
205
+ hasBlob: async (key: string) => Boolean(await store.get(key)),
206
+ backOfficePath: config.backOfficePath,
207
+ },
208
+ under,
209
+ problems: saved.problems,
210
+ }
211
+ }
212
+
213
+ export interface ContentCheckResult {
214
+ bundle: BundleSummary
215
+ under: string | null
216
+ check: TransferCheck
217
+ /** What the carried sections would do here; undefined for a content-only bundle. */
218
+ sections: SectionPlan | undefined
219
+ /** Nothing outstanding: the bundle would import cleanly. */
220
+ clean: boolean
221
+ /** The decisions this check ran with — the bundle's saved ones under the caller's. */
222
+ decisions: { all: Resolution | null; byNode: Record<string, Resolution> }
223
+ problems: string[]
224
+ }
225
+
226
+ /** Read-only: what importing a bundle here would do. Its findings go to the Changes dashboard. */
227
+ export async function contentCheck(
228
+ config: CromboloConfig,
229
+ input: TransferInput,
230
+ ): Promise<ContentCheckResult> {
231
+ const { set, carried } = readBundle(input.dir)
232
+ const { db } = await bootstrapDatabase(config)
233
+ try {
234
+ const prepared = await transferOptions(db, config, set, input)
235
+ const check = await checkBundle(db, set, prepared.options)
236
+ // Under this bundle's own scope, so two bundles in flight do not resolve each other's.
237
+ await writeReport(db, check.findings, { source: 'transfer', scope: set.manifest.id })
238
+ return {
239
+ bundle: summarise(set),
240
+ under: prepared.under ? (prepared.under.text ?? prepared.under.key) : null,
241
+ check,
242
+ sections: carriesSections(carried)
243
+ ? await planSections(db, config, carried, input)
244
+ : undefined,
245
+ clean: check.outstanding.length === 0,
246
+ decisions: {
247
+ all: prepared.options.resolveAll ?? null,
248
+ byNode: prepared.options.resolutions ?? {},
249
+ },
250
+ problems: prepared.problems,
251
+ }
252
+ } finally {
253
+ await db.close()
254
+ }
255
+ }
256
+
257
+ export interface ContentImportInput extends TransferInput {
258
+ publish?: boolean
259
+ label?: string
260
+ /** Postgres: the caller has taken a backup itself. */
261
+ backupTaken?: boolean
262
+ }
263
+
264
+ export interface ContentImportResult {
265
+ bundle: BundleSummary
266
+ backup: BackupResult
267
+ /**
268
+ * What the carried sections did, when the bundle carried any.
269
+ *
270
+ * Applied before the content and outside the content run's ledger entry: the
271
+ * files are now in `schema/` and the views directory, where the repository
272
+ * owns them — so a revert takes the content back and git takes the files.
273
+ */
274
+ sections: SectionResult | undefined
275
+ /** Where the originals of any replaced files were kept, for a revert. */
276
+ replacedFilesKept?: string | undefined
277
+ /** Undefined when the import was refused; then nothing was written. */
278
+ runId: string | undefined
279
+ check: TransferCheck
280
+ blobs: { placed: number; bytes: number } | null
281
+ published: number
282
+ publishFailures: { key: string; reason: string }[]
283
+ problems: string[]
284
+ }
285
+
286
+ /** Re-check, back up, then apply in one transaction and record the run. */
287
+ export async function contentImport(
288
+ config: CromboloConfig,
289
+ input: ContentImportInput,
290
+ ): Promise<ContentImportResult> {
291
+ const { set, blobs, carried } = readBundle(input.dir)
292
+ // The coarse layer, under the fine-grained revert: in-place conversion is not
293
+ // reversible by itself, so the same rule as `upgrade` applies here.
294
+ const backup = await backupBefore(config, {
295
+ reason: 'content-import',
296
+ backupTaken: input.backupTaken,
297
+ })
298
+ const { db } = await bootstrapDatabase(config)
299
+ try {
300
+ const prepared = await transferOptions(db, config, set, input)
301
+ const started = performance.now()
302
+
303
+ /**
304
+ * Structure first, but planned before anything is written.
305
+ *
306
+ * The order is forced: content naming a type this bundle brings cannot pass
307
+ * a check until that type exists, so the schema has to land before the
308
+ * content is checked. Which means a blocking schema change has to be caught
309
+ * *before* the first file is written — otherwise a refused install leaves a
310
+ * half-applied site behind. `planSections` answers that from an overlay
311
+ * copy, so the site is untouched until it is known the schema can apply.
312
+ */
313
+ const plan = carriesSections(carried)
314
+ ? await planSections(db, config, carried, input)
315
+ : undefined
316
+ const refuse = (problems: string[]): ContentImportResult => ({
317
+ bundle: summarise(set),
318
+ backup,
319
+ runId: undefined,
320
+ check: emptyCheck(set.manifest.id),
321
+ blobs: null,
322
+ published: 0,
323
+ publishFailures: [],
324
+ problems,
325
+ sections: undefined,
326
+ })
327
+
328
+ if (plan?.schema?.classification === 'breaking')
329
+ return refuse([
330
+ 'the schema this bundle carries cannot be applied here without data work',
331
+ ...plan.schema.findings,
332
+ ])
333
+
334
+ /**
335
+ * A file this site already has is not replaced without being asked.
336
+ *
337
+ * Every section is editable in the backoffice — a stylesheet, a template, a
338
+ * content type — so an overwrite destroys somebody's work, and the site
339
+ * may have no git to recover it from. Refused by name, like any other
340
+ * finding a person has to answer.
341
+ */
342
+ const needingPermission = plan ? replacementsNeedingPermission(plan, input) : []
343
+ if (needingPermission.length > 0)
344
+ return refuse([
345
+ `${needingPermission.length} file(s) here would be replaced; pass --replace-files to allow it`,
346
+ ...needingPermission.map((file: SectionFile) => file.path),
347
+ ])
348
+
349
+ // Staged whenever something *will* be replaced — which is decided by the
350
+ // plan, not by the permission check: by this point permission has been
351
+ // given, so the files needing it are exactly the ones to back up.
352
+ const willReplace = (plan?.files ?? []).some((file) => file.action === 'overwrite')
353
+ const staging = plan && willReplace ? stageBackups(config) : undefined
354
+ const sections = plan
355
+ ? await applySections(db, config, carried, { ...input, backupDir: staging })
356
+ : undefined
357
+ const result = await importBundle(db, set, {
358
+ ...prepared.options,
359
+ publish: input.publish ?? false,
360
+ nodeId: config.nodeId,
361
+ label: input.label,
362
+ })
363
+ await writeReport(db, result.check.findings, { source: 'transfer', scope: set.manifest.id })
364
+ const outcome: ContentImportResult = {
365
+ bundle: summarise(set),
366
+ backup,
367
+ runId: result.runId,
368
+ check: result.check,
369
+ blobs: null,
370
+ published: result.published.length,
371
+ publishFailures: result.publishFailures,
372
+ problems: prepared.problems,
373
+ sections,
374
+ }
375
+ if (!result.runId) return outcome
376
+ // The backups become the run's, so `content revert` can find them.
377
+ if (staging) outcome.replacedFilesKept = promoteBackups(config, staging, result.runId)
378
+ if (blobs.size > 0) outcome.blobs = await placeBlobs(await mediaStoreFor(config), blobs)
379
+ await recordInLedger(db, {
380
+ name: `content import ${set.manifest.id}`,
381
+ kind: 'transfer',
382
+ durationMs: Math.round(performance.now() - started),
383
+ appliedBy: config.nodeId,
384
+ note: `run ${result.runId}`,
385
+ })
386
+ return outcome
387
+ } finally {
388
+ await db.close()
389
+ }
390
+ }
391
+
392
+ export interface ContentExportInput {
393
+ /** Paths or keys of the subtrees to export. */
394
+ roots: string[]
395
+ only?: boolean
396
+ drafts?: boolean
397
+ withBlobs?: boolean
398
+ withBlueprints?: boolean
399
+ }
400
+
401
+ export interface ContentExportResult {
402
+ bundleId: string
403
+ snapshot: string
404
+ counts: Record<string, number>
405
+ expected: { key: string; name: string; why: string }[]
406
+ /** The bundle's files, for the caller to write wherever it is going. */
407
+ files: { path: string; bytes?: Uint8Array; text?: string }[]
408
+ blobs: { carried: number; bytes: number; missing: string[]; notCarried: number }
409
+ }
410
+
411
+ /** Writes nothing: returns the bundle's files for the caller to place. */
412
+ export async function contentExport(
413
+ config: CromboloConfig,
414
+ input: ContentExportInput,
415
+ ): Promise<ContentExportResult> {
416
+ if (input.roots.length === 0) throw new CommandError('An export needs at least one root.')
417
+ const { db } = await bootstrapDatabase(config)
418
+ try {
419
+ // A path is resolved against the tree it is exported from, and the key is
420
+ // what lands in the bundle, so the artifact is identity-stable either way.
421
+ const roots: NodeRow[] = []
422
+ for (const ref of input.roots) {
423
+ const resolved = await resolveNodeRef(db, CONTENT_OBJECT_TYPES, ref)
424
+ if (!resolved.ok) throw new CommandError(describeRefFailure(ref, resolved))
425
+ roots.push(resolved.node)
426
+ }
427
+ const { set, blobKeys } = await exportBundle(db, {
428
+ roots,
429
+ asGiven: input.roots,
430
+ descendants: !input.only,
431
+ snapshot: input.drafts ? 'drafts' : 'published',
432
+ blueprints: input.withBlueprints ?? false,
433
+ withBlobs: input.withBlobs ?? false,
434
+ siteName: config.siteName,
435
+ nodeId: config.nodeId,
436
+ })
437
+ // The bytes are read here rather than in the exporter: where media lives is
438
+ // the store's business, and `@crombolo/transfer` knows nothing about it.
439
+ let carried = new Map<string, Uint8Array>()
440
+ let missing: string[] = []
441
+ if (input.withBlobs && blobKeys.length > 0) {
442
+ const read = await readBlobs(await mediaStoreFor(config), blobKeys)
443
+ carried = read.bytes
444
+ missing = read.missing
445
+ }
446
+ return {
447
+ bundleId: set.manifest.id,
448
+ snapshot: set.manifest.snapshot,
449
+ counts: { ...set.manifest.counts },
450
+ expected: set.manifest.dependencies.expected.map((e) => ({
451
+ key: e.key,
452
+ name: e.name,
453
+ why: e.why,
454
+ })),
455
+ files: writeBundle(set, carried),
456
+ blobs: {
457
+ carried: carried.size,
458
+ bytes: [...carried.values()].reduce((total, file) => total + file.length, 0),
459
+ missing,
460
+ notCarried: input.withBlobs ? 0 : blobKeys.length,
461
+ },
462
+ }
463
+ } finally {
464
+ await db.close()
465
+ }
466
+ }
467
+
468
+ async function upgradeContext(config: CromboloConfig, set: Record<string, unknown>) {
469
+ const db = await connect({ file: config.sqliteFile })
470
+ const node = { nodeId: config.nodeId, revision: config.schemaRevision }
471
+ const loaded = () => loadSchemaDirectory(config.schemaDir)
472
+ const syncNode = () => ({ ...node, version: loaded().set.version })
473
+ const checkOptions = async () => ({
474
+ ...syncOptionsFor(config),
475
+ set,
476
+ migrations: await loadValueMigrations(config.schemaDir),
477
+ backOfficePath: config.backOfficePath,
478
+ })
479
+ return { db, node, loaded, syncNode, checkOptions }
480
+ }
481
+
482
+ export interface UpgradePlanResult {
483
+ steps: {
484
+ name: string
485
+ kind: string
486
+ release: string | null
487
+ statements: string[]
488
+ unplannable: string | null
489
+ }[]
490
+ site: { checked: boolean; action: string | null; classification: string | null }
491
+ }
492
+
493
+ /** The DDL every pending framework step would run, and what the site's schema would do. Writes nothing. */
494
+ export async function upgradePlan(config: CromboloConfig): Promise<UpgradePlanResult> {
495
+ const { db, node } = await upgradeContext(config, {})
496
+ try {
497
+ const steps = await planUpgrade(db, cromboloPlan)
498
+ const check = await checkSchemaDirectory(db, config, node)
499
+ return {
500
+ steps: steps.map((step) => ({
501
+ name: step.name,
502
+ kind: step.kind,
503
+ release: step.release ?? null,
504
+ statements: (step.statements ?? []).map((sql) => sql.replace(/\s+/g, ' ').trim()),
505
+ unplannable: step.unplannable ?? null,
506
+ })),
507
+ site: {
508
+ checked: check.siteChecked,
509
+ action: check.siteChecked ? check.sync.action : null,
510
+ classification: check.siteChecked ? check.classification : null,
511
+ },
512
+ }
513
+ } finally {
514
+ await db.close()
515
+ }
516
+ }
517
+
518
+ /** The pre-upgrade report. Its findings go to the Changes dashboard once the site is checkable. */
519
+ export async function upgradeCheck(
520
+ config: CromboloConfig,
521
+ input: { set?: Record<string, unknown> } = {},
522
+ ) {
523
+ const { db, node } = await upgradeContext(config, input.set ?? {})
524
+ try {
525
+ const report = await checkSchemaDirectory(db, config, node, { set: input.set ?? {} })
526
+ if (report.siteChecked) await writeReport(db, report.findings, { source: 'upgrade' })
527
+ return {
528
+ siteChecked: report.siteChecked,
529
+ classification: report.classification,
530
+ findings: report.findings,
531
+ outstanding: report.outstanding,
532
+ blocking: report.outstanding.some((finding) => finding.kind === 'blocking'),
533
+ }
534
+ } finally {
535
+ await db.close()
536
+ }
537
+ }
538
+
539
+ /** Back up, then apply the additive part of an upgrade and every conversion, early. */
540
+ export async function upgradeFix(
541
+ config: CromboloConfig,
542
+ input: { set?: Record<string, unknown>; backupTaken?: boolean } = {},
543
+ ) {
544
+ const backup = await backupBefore(config, { reason: 'fix', backupTaken: input.backupTaken })
545
+ const { db, loaded, syncNode, checkOptions } = await upgradeContext(config, input.set ?? {})
546
+ try {
547
+ await installDatabase(db, config)
548
+ const fix = await runFix(db, loaded(), syncNode(), {
549
+ ...(await checkOptions()),
550
+ by: config.nodeId,
551
+ })
552
+ return {
553
+ backup,
554
+ sync: { action: fix.sync.action, deferred: fix.sync.deferred },
555
+ applied: fix.applied,
556
+ skipped: fix.skipped,
557
+ classification: fix.check.classification,
558
+ findings: fix.check.findings,
559
+ outstanding: fix.check.outstanding,
560
+ }
561
+ } finally {
562
+ await db.close()
563
+ }
564
+ }
565
+
566
+ export type UpgradeRunResult =
567
+ | {
568
+ action: 'refused'
569
+ backup: BackupResult
570
+ reasons: { kind: string; message: string; link: string | null }[]
571
+ }
572
+ | { action: 'nothing'; backup: BackupResult }
573
+ | {
574
+ action: 'upgraded'
575
+ backup: BackupResult
576
+ frameworkApplied: string[]
577
+ schema: { action: string; version: string | null; revision: string | null } | null
578
+ }
579
+
580
+ /** Re-check, back up, apply framework steps, cut over, ledger. */
581
+ export async function upgradeRun(
582
+ config: CromboloConfig,
583
+ input: { force?: string; set?: Record<string, unknown>; backupTaken?: boolean } = {},
584
+ ): Promise<UpgradeRunResult> {
585
+ const backup = await backupBefore(config, { reason: 'upgrade', backupTaken: input.backupTaken })
586
+ const { db, loaded, syncNode, checkOptions } = await upgradeContext(config, input.set ?? {})
587
+ try {
588
+ await installDatabase(db, config)
589
+ const result = await runUpgrade(db, loaded(), syncNode(), {
590
+ ...(await checkOptions()),
591
+ by: config.nodeId,
592
+ policy: config.upgradePolicy,
593
+ force: input.force,
594
+ })
595
+ if (result.action === 'refused')
596
+ return {
597
+ action: 'refused',
598
+ backup,
599
+ reasons: result.reasons.map((f) => ({ kind: f.kind, message: f.message, link: f.link })),
600
+ }
601
+ if (result.action === 'nothing') return { action: 'nothing', backup }
602
+ return {
603
+ action: 'upgraded',
604
+ backup,
605
+ frameworkApplied: result.frameworkApplied,
606
+ schema: result.sync
607
+ ? {
608
+ action: result.sync.action,
609
+ version: result.sync.state?.version ?? null,
610
+ revision: result.sync.state?.revision ?? null,
611
+ }
612
+ : null,
613
+ }
614
+ } finally {
615
+ await db.close()
616
+ }
617
+ }
618
+
619
+ export interface MediaAddInput {
620
+ /** Where the file already is, in this environment's own media store. */
621
+ key: string
622
+ /** What the item is called. Defaults to the file name in `key`. */
623
+ name?: string
624
+ /** The folder to put it in, by node key. The media root when absent. */
625
+ parent?: string
626
+ }
627
+
628
+ export interface MediaAddResult {
629
+ /** The new media item's node key. */
630
+ key: string
631
+ name: string
632
+ /** The media type it was created as, chosen from the file's extension. */
633
+ mediaType: string
634
+ }
635
+
636
+ /**
637
+ * Registers a file that is already in the store as a media item, so it appears
638
+ * in the Media section and can be used in content.
639
+ *
640
+ * For a host offering an upload without opening the backoffice. The caller puts
641
+ * the bytes in the store — a presigned upload, typically — and names the key
642
+ * here, so a large file never travels through whatever asked for this. The
643
+ * media type is chosen from the extension the same way the backoffice chooses
644
+ * it, and the file is placed by the CMS in its own layout rather than by the
645
+ * caller, which is what keeps the store free of files nothing knows about.
646
+ */
647
+ export async function mediaAdd(
648
+ config: CromboloConfig,
649
+ input: MediaAddInput,
650
+ ): Promise<MediaAddResult> {
651
+ const name = (input.name ?? input.key.split('/').at(-1) ?? '').trim()
652
+ if (!name) throw new CommandError('A media item needs a name.')
653
+
654
+ const store = await mediaStoreFor(config)
655
+ const found = await store.get(input.key)
656
+ if (!found) throw new CommandError(`There is nothing at '${input.key}' in the store.`)
657
+
658
+ const files = new MediaFileStore(store)
659
+ const { db } = await bootstrapDatabase(config)
660
+ // The schema the site is actually on. Without it the port falls back to the
661
+ // baseline state and the write gate refuses every write, because a server at
662
+ // an older schema than its database must not write to it.
663
+ const schema = await bootSchema(db, { ...config, syncSchemaAtBoot: false })
664
+ const port = createMediaPort(db, files, {
665
+ nodeState: schema.nodeState,
666
+ nodeId: schema.nodeId,
667
+ valueIntake: createFileIntake(files),
668
+ })
669
+
670
+ // The same choice the backoffice makes: a type whose file property allows
671
+ // this extension, falling back to one that allows anything at all.
672
+ const extension = name.includes('.') ? (name.split('.').at(-1) as string) : ''
673
+ const types = await port.typesForExtension(extension)
674
+ const type = types.find((candidate) => candidate.matched) ?? types[0]
675
+ if (!type)
676
+ throw new CommandError(
677
+ `No media type in this site accepts a '${extension}' file, so it cannot be added.`,
678
+ )
679
+
680
+ // Through the temporary-file path, so the CMS places the file and records the
681
+ // value exactly as it would for an upload made in the backoffice.
682
+ const temporaryId = crypto.randomUUID()
683
+ // A fresh Uint8Array from the store, handed over as a blob part. `bytes()` is
684
+ // typed over ArrayBufferLike, which a File constructor will not take.
685
+ const bytes = await found.bytes()
686
+ await files.saveTemporary(
687
+ temporaryId,
688
+ new File([new Blob([bytes as BlobPart], { type: found.contentType })], name),
689
+ )
690
+
691
+ const created = await port.create(
692
+ {
693
+ key: crypto.randomUUID(),
694
+ contentTypeKey: type.key,
695
+ componentKey: null,
696
+ parentKey: input.parent ?? null,
697
+ values: [
698
+ {
699
+ alias: 'umbracoFile',
700
+ culture: null,
701
+ segment: null,
702
+ value: { temporaryFileId: temporaryId },
703
+ },
704
+ ],
705
+ variants: [{ culture: null, segment: null, name }],
706
+ },
707
+ // The platform acting as itself: no user account, every permission, so the
708
+ // item is created rather than refused for an actor the site has never seen.
709
+ // `create` resolves a user id from the key and simply finds none.
710
+ {
711
+ id: '',
712
+ userName: 'operations',
713
+ name: 'operations',
714
+ email: '',
715
+ isAdmin: true,
716
+ languageIsoCode: undefined,
717
+ avatarUrls: [],
718
+ allowedSections: [],
719
+ permissions: [],
720
+ groupKeys: [],
721
+ hasAccessToAllLanguages: true,
722
+ languages: [],
723
+ startNodes: { document: ROOT_ACCESS, media: ROOT_ACCESS, element: ROOT_ACCESS },
724
+ groups: [],
725
+ },
726
+ )
727
+ if (!created.ok) {
728
+ // Nothing was registered, so the staged copy is litter.
729
+ await files.deleteTemporary(temporaryId)
730
+ throw new CommandError(
731
+ `The media item could not be created: ${created.reason}.`,
732
+ (created.errors ?? []).map((error) => `${error.alias}: ${error.messages.join('; ')}`),
733
+ )
734
+ }
735
+ return { key: created.key, name, mediaType: type.name }
736
+ }
737
+
738
+ /** Takes the backup a write would take first: a copy of SQLite, or `CROMBOLO_PG_DUMP` on Postgres. */
739
+ export async function backup(
740
+ config: CromboloConfig,
741
+ input: { reason?: string; backupTaken?: boolean } = {},
742
+ ): Promise<BackupResult> {
743
+ return backupBefore(config, {
744
+ reason: input.reason ?? 'backup',
745
+ backupTaken: input.backupTaken,
746
+ })
747
+ }
748
+
749
+ async function withDatabase<T>(config: CromboloConfig, use: (db: Db) => Promise<T>): Promise<T> {
750
+ const db = await connect({ file: config.sqliteFile })
751
+ try {
752
+ return await use(db)
753
+ } finally {
754
+ await db.close()
755
+ }
756
+ }
757
+
758
+ /** Refuses every editor's save, on every node, until resumed; readers are unaffected. */
759
+ export function pauseEditing(
760
+ config: CromboloConfig,
761
+ input: { reason?: string; by?: string } = {},
762
+ ): Promise<WritesPaused> {
763
+ return withDatabase(config, (db) =>
764
+ pauseWrites(db, { reason: input.reason ?? '', by: input.by ?? config.nodeId }),
765
+ )
766
+ }
767
+
768
+ export function resumeEditing(config: CromboloConfig): Promise<void> {
769
+ return withDatabase(config, resumeWrites)
770
+ }
771
+
772
+ /** Whether editing is paused, by whom and why; undefined when it is not. */
773
+ export function editingPaused(config: CromboloConfig): Promise<WritesPaused | undefined> {
774
+ return withDatabase(config, readWritesPaused)
775
+ }