@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,423 @@
1
+ /**
2
+ * The sections a bundle carries beyond its content: schema, views, partials,
3
+ * styles, scripts and dictionary items. `docs/17-bundles.md`.
4
+ *
5
+ * A bundle names what a file *is*, never where it goes — the destination site
6
+ * decides that from its own configuration, so a bundle written against one
7
+ * layout installs into another. This module owns that mapping, and the order
8
+ * applying them has to happen in: structure before the content that needs it,
9
+ * views before the content that names a template.
10
+ *
11
+ * Checking is read-only and has to answer "what would the schema do" *before*
12
+ * anything is written, which `checkSchemaDirectory` cannot do against files that
13
+ * are not there yet. So the check runs against a copy of the site's `schema/`
14
+ * with the bundle's files laid over it — the site's own directory is never
15
+ * touched until an apply.
16
+ */
17
+ import {
18
+ copyFileSync,
19
+ cpSync,
20
+ existsSync,
21
+ mkdirSync,
22
+ mkdtempSync,
23
+ readFileSync,
24
+ renameSync,
25
+ rmSync,
26
+ writeFileSync,
27
+ } from 'node:fs'
28
+ import { tmpdir } from 'node:os'
29
+ import { dirname, join } from 'node:path'
30
+ import type { Db } from '@crombolo/data'
31
+ import type { CromboloConfig } from '@crombolo/server'
32
+ import { checkSchemaDirectory, importDictionaryUdt, syncSchemaDirectory } from '@crombolo/server'
33
+ import type { BundleSection, LoadedBundle } from '@crombolo/transfer'
34
+ import { BUNDLE_SECTIONS } from '@crombolo/transfer'
35
+
36
+ /** What applying one carried file would do to the destination. */
37
+ export type FileAction = 'create' | 'overwrite' | 'unchanged'
38
+
39
+ export interface SectionFile {
40
+ /** The path inside the bundle, e.g. `views/homePage.tsx`. */
41
+ path: string
42
+ /** Where it would land on this site. */
43
+ target: string
44
+ action: FileAction
45
+ }
46
+
47
+ export interface SectionPlan {
48
+ files: SectionFile[]
49
+ /** How many dictionary items the bundle carries, when it carries any. */
50
+ dictionaryItems: number
51
+ /**
52
+ * What the schema half would do, checked against an overlay rather than the
53
+ * site: `none` when the bundle carries no schema.
54
+ */
55
+ schema: { classification: string; findings: string[] } | undefined
56
+ /** Sections present in the bundle, for a caller that only wants to say so. */
57
+ sections: BundleSection[]
58
+ }
59
+
60
+ export interface SectionOptions {
61
+ /** Skip the schema half, leaving the site's own structure alone. */
62
+ withoutSchema?: boolean
63
+ /** Skip views, partials, styles and scripts. */
64
+ withoutFiles?: boolean
65
+ /**
66
+ * Allow replacing files this site already has.
67
+ *
68
+ * Off by default, and an install is refused while any file would be
69
+ * replaced. These files are editable in the backoffice — a stylesheet, a
70
+ * template, a content type — so overwriting one destroys somebody's work,
71
+ * and this site may well have no git to recover it from.
72
+ */
73
+ replaceFiles?: boolean
74
+ }
75
+
76
+ /**
77
+ * Where an install keeps the files it replaced, under the site.
78
+ *
79
+ * A sibling of the components cache, not inside it: `.crombolo/components` is
80
+ * rebuilt from `components/` and cleared at boot, and these have to outlive a
81
+ * restart to be worth taking.
82
+ */
83
+ export const RUN_BACKUP_DIR = join('.crombolo', 'transfer')
84
+
85
+ /** What one install did to the files, so a revert can undo exactly that. */
86
+ interface FileRecord {
87
+ path: string
88
+ action: FileAction
89
+ }
90
+
91
+ const RECORD_FILE = 'files.json'
92
+ const REPLACED_DIR = 'replaced'
93
+
94
+ /** Where each section lands, from the site's own configuration. */
95
+ function destinationOf(config: CromboloConfig, section: BundleSection): string | undefined {
96
+ switch (section) {
97
+ case 'schema':
98
+ return config.schemaDir
99
+ case 'components':
100
+ return config.componentsDir
101
+ case 'styles':
102
+ return config.stylesheetsDir
103
+ case 'scripts':
104
+ return config.scriptsDir
105
+ case 'dictionary':
106
+ return undefined
107
+ }
108
+ }
109
+
110
+ const FILE_SECTIONS: readonly BundleSection[] = ['components', 'styles', 'scripts']
111
+
112
+ /** The path inside a section, with the section's own directory stripped. */
113
+ const withinSection = (path: string, section: BundleSection): string =>
114
+ path.slice(`${BUNDLE_SECTIONS[section]}/`.length)
115
+
116
+ function actionFor(target: string, content: string): FileAction {
117
+ if (!existsSync(target)) return 'create'
118
+ try {
119
+ return readFileSync(target, 'utf8') === content ? 'unchanged' : 'overwrite'
120
+ } catch {
121
+ return 'overwrite'
122
+ }
123
+ }
124
+
125
+ export interface CarriedSections {
126
+ /** Section → the bundle paths it carries. */
127
+ carries: Partial<Record<BundleSection, string[]>>
128
+ /** Bundle path → the file on disk holding it. */
129
+ files: Map<string, string>
130
+ }
131
+
132
+ /** What a loaded bundle carries, or nothing when it is content-only. */
133
+ export function carriedSections(loaded: LoadedBundle): CarriedSections {
134
+ return { carries: loaded.set?.manifest.carries ?? {}, files: loaded.files }
135
+ }
136
+
137
+ const read = (carried: CarriedSections, path: string): string => {
138
+ const file = carried.files.get(path)
139
+ if (!file) throw new Error(`${path} is declared but was not read`)
140
+ return readFileSync(file, 'utf8')
141
+ }
142
+
143
+ /** Read-only: what applying the carried sections here would do. */
144
+ export async function planSections(
145
+ db: Db,
146
+ config: CromboloConfig,
147
+ carried: CarriedSections,
148
+ options: SectionOptions = {},
149
+ ): Promise<SectionPlan> {
150
+ const sections = Object.keys(carried.carries) as BundleSection[]
151
+ const files: SectionFile[] = []
152
+
153
+ if (!options.withoutFiles) {
154
+ for (const section of FILE_SECTIONS) {
155
+ const destination = destinationOf(config, section)
156
+ if (!destination) continue
157
+ for (const path of carried.carries[section] ?? []) {
158
+ const target = join(destination, withinSection(path, section))
159
+ files.push({ path, target, action: actionFor(target, read(carried, path)) })
160
+ }
161
+ }
162
+ }
163
+
164
+ const schemaPaths = options.withoutSchema ? [] : (carried.carries.schema ?? [])
165
+ if (!options.withoutSchema) {
166
+ for (const path of schemaPaths) {
167
+ const target = join(config.schemaDir, withinSection(path, 'schema'))
168
+ files.push({ path, target, action: actionFor(target, read(carried, path)) })
169
+ }
170
+ }
171
+
172
+ return {
173
+ files,
174
+ dictionaryItems: (carried.carries.dictionary ?? []).length,
175
+ schema:
176
+ schemaPaths.length > 0
177
+ ? await checkSchemaOverlay(db, config, carried, schemaPaths)
178
+ : undefined,
179
+ sections,
180
+ }
181
+ }
182
+
183
+ /**
184
+ * The schema check, run against a copy of the site's `schema/` with the
185
+ * bundle's files laid over it.
186
+ *
187
+ * The site's directory is not written to: a check has to be able to say "this
188
+ * would need data work" without having already changed the thing it is
189
+ * reporting on.
190
+ */
191
+ async function checkSchemaOverlay(
192
+ db: Db,
193
+ config: CromboloConfig,
194
+ carried: CarriedSections,
195
+ paths: readonly string[],
196
+ ): Promise<{ classification: string; findings: string[] }> {
197
+ const overlay = mkdtempSync(join(tmpdir(), 'crombolo-schema-overlay-'))
198
+ try {
199
+ if (existsSync(config.schemaDir)) cpSync(config.schemaDir, overlay, { recursive: true })
200
+ for (const path of paths) {
201
+ const target = join(overlay, withinSection(path, 'schema'))
202
+ mkdirSync(dirname(target), { recursive: true })
203
+ writeFileSync(target, read(carried, path))
204
+ }
205
+ const report = await checkSchemaDirectory(
206
+ db,
207
+ { ...config, schemaDir: overlay },
208
+ { nodeId: config.nodeId, revision: config.schemaRevision },
209
+ )
210
+ return {
211
+ classification: report.classification,
212
+ findings: report.outstanding.map((finding) => finding.message),
213
+ }
214
+ } finally {
215
+ rmSync(overlay, { recursive: true, force: true })
216
+ }
217
+ }
218
+
219
+ export interface SectionResult {
220
+ /** The files written, by the path they were written to. */
221
+ written: string[]
222
+ /** Those that replaced something, whose originals were kept. */
223
+ replaced: string[]
224
+ /** The schema sync's outcome, when the bundle carried schema. */
225
+ schema: { action: string } | undefined
226
+ dictionary: { imported: number; skipped: string[] } | undefined
227
+ }
228
+
229
+ /**
230
+ * Applies the carried sections, structure first.
231
+ *
232
+ * Schema goes to `schema/` and is then synced — the same path a commit takes,
233
+ * so an install cannot produce a database the files do not describe. Views land
234
+ * before content is imported, because a node naming a template whose view is
235
+ * absent arrives without one.
236
+ */
237
+ export async function applySections(
238
+ db: Db,
239
+ config: CromboloConfig,
240
+ carried: CarriedSections,
241
+ options: SectionOptions & { backupDir?: string } = {},
242
+ ): Promise<SectionResult> {
243
+ const written: string[] = []
244
+ const replaced: string[] = []
245
+ const records: FileRecord[] = []
246
+
247
+ /**
248
+ * Writes one file, keeping what was there.
249
+ *
250
+ * The original is copied under its *bundle* path rather than its path on this
251
+ * site, so a revert re-derives the destination through the same mapping — and
252
+ * restores to where the file would go now, not where it went then.
253
+ */
254
+ const put = (target: string, content: string, bundlePath: string) => {
255
+ const action = actionFor(target, content)
256
+ if (action === 'overwrite' && options.backupDir) {
257
+ const keep = join(options.backupDir, REPLACED_DIR, bundlePath)
258
+ mkdirSync(dirname(keep), { recursive: true })
259
+ copyFileSync(target, keep)
260
+ replaced.push(target)
261
+ }
262
+ records.push({ path: bundlePath, action })
263
+ mkdirSync(dirname(target), { recursive: true })
264
+ writeFileSync(target, content)
265
+ written.push(target)
266
+ }
267
+
268
+ const schemaPaths = options.withoutSchema ? [] : (carried.carries.schema ?? [])
269
+ for (const path of schemaPaths)
270
+ put(join(config.schemaDir, withinSection(path, 'schema')), read(carried, path), path)
271
+
272
+ let schema: { action: string } | undefined
273
+ if (schemaPaths.length > 0) {
274
+ const report = await syncSchemaDirectory(db, config, {
275
+ nodeId: config.nodeId,
276
+ revision: config.schemaRevision,
277
+ })
278
+ schema = { action: report.action }
279
+ }
280
+
281
+ if (!options.withoutFiles) {
282
+ for (const section of FILE_SECTIONS) {
283
+ const destination = destinationOf(config, section)
284
+ if (!destination) continue
285
+ for (const path of carried.carries[section] ?? [])
286
+ put(join(destination, withinSection(path, section)), read(carried, path), path)
287
+ }
288
+ }
289
+
290
+ let dictionary: SectionResult['dictionary']
291
+ for (const path of carried.carries.dictionary ?? []) {
292
+ const outcome = await importDictionaryUdt(db, read(carried, path))
293
+ dictionary = {
294
+ imported: (dictionary?.imported ?? 0) + outcome.imported,
295
+ skipped: [...(dictionary?.skipped ?? []), ...outcome.skipped],
296
+ }
297
+ }
298
+
299
+ // Written last: a record that exists means the files beside it are complete.
300
+ if (options.backupDir && records.length > 0) {
301
+ mkdirSync(options.backupDir, { recursive: true })
302
+ writeFileSync(join(options.backupDir, RECORD_FILE), `${JSON.stringify(records, null, 2)}\n`)
303
+ }
304
+
305
+ return { written, replaced, schema, dictionary }
306
+ }
307
+
308
+ /** Whether a bundle carries anything beyond its content. */
309
+ export const carriesSections = (carried: CarriedSections): boolean =>
310
+ Object.keys(carried.carries).length > 0
311
+
312
+ /**
313
+ * The files an install would replace, and so may not touch without being told
314
+ * it may.
315
+ *
316
+ * Its own function rather than a condition inside the importer, because this is
317
+ * the rule that stops an install destroying an editor's work: a stylesheet, a
318
+ * template or a content type changed in the backoffice, on a site that may
319
+ * have no git behind it. `unchanged` is not a replacement, and a declined
320
+ * section never reaches the plan.
321
+ */
322
+ export function replacementsNeedingPermission(
323
+ plan: SectionPlan,
324
+ options: SectionOptions = {},
325
+ ): SectionFile[] {
326
+ if (options.replaceFiles) return []
327
+ return plan.files.filter((file) => file.action === 'overwrite')
328
+ }
329
+
330
+ /** A staging directory for an install's backups, before the run it belongs to exists. */
331
+ export function stageBackups(config: CromboloConfig): string {
332
+ const dir = join(config.siteDir, RUN_BACKUP_DIR, `pending-${Date.now().toString(36)}`)
333
+ mkdirSync(dir, { recursive: true })
334
+ return dir
335
+ }
336
+
337
+ /**
338
+ * Files an install replaced belong to the run that imported its content, and
339
+ * the run id only exists once that import has happened — so the backups are
340
+ * staged first and moved here afterwards.
341
+ *
342
+ * A staging directory that is never promoted is an install whose content was
343
+ * refused after its files had landed. It is left where it is rather than
344
+ * deleted: it holds the only copy of what was replaced.
345
+ */
346
+ export function promoteBackups(
347
+ config: CromboloConfig,
348
+ staging: string,
349
+ runId: string,
350
+ ): string | undefined {
351
+ if (!existsSync(join(staging, RECORD_FILE))) {
352
+ rmSync(staging, { recursive: true, force: true })
353
+ return undefined
354
+ }
355
+ const target = join(config.siteDir, RUN_BACKUP_DIR, runId)
356
+ rmSync(target, { recursive: true, force: true })
357
+ mkdirSync(dirname(target), { recursive: true })
358
+ renameSync(staging, target)
359
+ return target
360
+ }
361
+
362
+ export interface RestoredFiles {
363
+ /** Files put back to what they were before the install. */
364
+ restored: string[]
365
+ /** Files the install created, and which reverting therefore removes. */
366
+ removed: string[]
367
+ }
368
+
369
+ /**
370
+ * Puts back what an install replaced, for a run that is being reverted.
371
+ *
372
+ * Undoing an install is one action because of this: the content comes back
373
+ * through the ledger, and the files come back from here. Nothing depends on the
374
+ * site being a git checkout — these files are editable in the backoffice, so
375
+ * assuming a repository would have been assuming somebody else's workflow.
376
+ */
377
+ export function restoreSectionFiles(config: CromboloConfig, runId: string): RestoredFiles {
378
+ const dir = join(config.siteDir, RUN_BACKUP_DIR, runId)
379
+ const record = join(dir, RECORD_FILE)
380
+ if (!existsSync(record)) return { restored: [], removed: [] }
381
+
382
+ let records: FileRecord[]
383
+ try {
384
+ records = JSON.parse(readFileSync(record, 'utf8')) as FileRecord[]
385
+ } catch {
386
+ return { restored: [], removed: [] }
387
+ }
388
+
389
+ const restored: string[] = []
390
+ const removed: string[] = []
391
+ for (const entry of records) {
392
+ const section = sectionFor(entry.path)
393
+ if (!section) continue
394
+ const destination = destinationOf(config, section)
395
+ if (!destination) continue
396
+ const target = join(destination, withinSection(entry.path, section))
397
+
398
+ if (entry.action === 'overwrite') {
399
+ const kept = join(dir, REPLACED_DIR, entry.path)
400
+ if (!existsSync(kept)) continue
401
+ mkdirSync(dirname(target), { recursive: true })
402
+ copyFileSync(kept, target)
403
+ restored.push(target)
404
+ continue
405
+ }
406
+ // A file the install created: reverting takes it away again. `unchanged`
407
+ // is left alone — the install did not put it there.
408
+ if (entry.action === 'create' && existsSync(target)) {
409
+ rmSync(target, { force: true })
410
+ removed.push(target)
411
+ }
412
+ }
413
+ return { restored, removed }
414
+ }
415
+
416
+ /** The section a bundle path belongs to, by its leading directory. */
417
+ function sectionFor(path: string): BundleSection | undefined {
418
+ if (path === `${BUNDLE_SECTIONS.dictionary}.udt`) return 'dictionary'
419
+ const top = path.split('/')[0]
420
+ for (const [section, dir] of Object.entries(BUNDLE_SECTIONS))
421
+ if (top === dir) return section as BundleSection
422
+ return undefined
423
+ }