@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,2108 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The crombolo CLI. Runs from a site directory and reads `crombolo.config.ts`
4
+ * there when present.
5
+ *
6
+ * crombolo start boot the site; resets and prints the admin password
7
+ * crombolo start --keep-admin boot without touching the admin password
8
+ * crombolo start --bundle <dir> apply a content bundle, once, then serve
9
+ * crombolo init scaffold a site in the current directory
10
+ * crombolo init --template <id> …from a starter template, content included
11
+ * crombolo status what this environment is, without starting it
12
+ * crombolo admin reset-password reset the administrator's password and print it
13
+ * crombolo schema check validate schema/ and report what a sync would do
14
+ * crombolo schema check --static validate the files only, no database
15
+ * crombolo schema sync apply schema/ to the database
16
+ * crombolo schema export write the database's definitions as schema/ files
17
+ * crombolo schema new scaffold a type file, and the view it points at
18
+ * crombolo schema add-property append a property to a type, and apply it
19
+ * crombolo generate TypeScript for the content types in schema/
20
+ * crombolo components check compile every component before a visitor does
21
+ * crombolo assets list the stylesheets and scripts beside them
22
+ *
23
+ * crombolo content export write a subtree as a bundle, to move to another environment
24
+ * crombolo content check read-only: what importing a bundle here would do
25
+ * crombolo content import apply a bundle, in one transaction, and record the run
26
+ * crombolo content runs the imports applied here
27
+ * crombolo content revert put an import back, restoring what the site served
28
+ *
29
+ * crombolo domains the hostnames this site answers on
30
+ * crombolo domains set write domains.toml and apply it
31
+ *
32
+ * crombolo import umbraco report read-only: what importing an Umbraco backup would and would not bring
33
+ * crombolo import umbraco apply write a crombolo site from an Umbraco backup
34
+ *
35
+ * crombolo upgrade --plan print the DDL each pending framework migration would run
36
+ * crombolo upgrade back up, apply pending framework migrations, record them
37
+ * crombolo upgrade ledger print migration_history
38
+ *
39
+ * crombolo maintenance pause refuse editors' saves cluster-wide; readers unaffected
40
+ * crombolo maintenance resume let editors save again
41
+ */
42
+ import { existsSync } from 'node:fs'
43
+ import { mkdir, writeFile } from 'node:fs/promises'
44
+ import { join, relative } from 'node:path'
45
+ import { AuthStore, resetAdminPassword } from '@crombolo/auth'
46
+ import { ObjectTypes } from '@crombolo/core'
47
+ import {
48
+ ContentTypeRepository,
49
+ connect,
50
+ currentSchemaState,
51
+ DictionaryRepository,
52
+ DocumentRepository,
53
+ DomainRepository,
54
+ describeRefFailure,
55
+ type Finding,
56
+ NodeRepository,
57
+ readLedger,
58
+ recordInLedger,
59
+ resolveNodeRef,
60
+ TransferRunRepository,
61
+ writeReport,
62
+ } from '@crombolo/data'
63
+ import {
64
+ addProperty,
65
+ allProperties,
66
+ BUILTIN_DATA_TYPES,
67
+ exportSchemaSet,
68
+ fileNameFor,
69
+ generateTypes,
70
+ loadSchemaDirectory,
71
+ loadValueMigrations,
72
+ newType,
73
+ type SchemaContentType,
74
+ type SchemaTypeKind,
75
+ type SyncReport,
76
+ TYPE_KIND_FILES,
77
+ type ValueMigration,
78
+ validateSchemaSet,
79
+ viewScaffold,
80
+ writeContentType,
81
+ writeSchemaFiles,
82
+ } from '@crombolo/schema'
83
+ import {
84
+ assertPortAvailable,
85
+ assetDirs,
86
+ assetScaffold,
87
+ backupBefore,
88
+ bootstrapDatabase,
89
+ type CromboloConfig,
90
+ checkComponents,
91
+ componentAliasesIn,
92
+ componentFileFor,
93
+ componentScaffold,
94
+ createServer,
95
+ DOMAINS_FILE,
96
+ type DomainDeclaration,
97
+ describeDatabase,
98
+ dictionaryToUdt,
99
+ domainsFilePath,
100
+ importDictionaryUdt,
101
+ isSafeAlias,
102
+ listAssets,
103
+ listComponents,
104
+ loadConfig,
105
+ mediaStoreFor,
106
+ placeBlobs,
107
+ readDomainsFile,
108
+ resolveDomainNode,
109
+ resolvePlaceholders,
110
+ type ServerHandle,
111
+ siteStatus,
112
+ syncDomainsFile,
113
+ syncSchemaDirectory,
114
+ undoDomainsFile,
115
+ withoutNode,
116
+ writeDictionaryUdt,
117
+ writeDomains,
118
+ writeDomainsFile,
119
+ writeUdtAll,
120
+ } from '@crombolo/server'
121
+ import {
122
+ importBundle,
123
+ isResolution,
124
+ isRevertResolution,
125
+ loadBundle,
126
+ RESOLUTIONS,
127
+ RESOLUTIONS_FILE,
128
+ REVERT_RESOLUTIONS,
129
+ type Resolution,
130
+ type RevertResolution,
131
+ resolutionsFor,
132
+ revertResolutionsFor,
133
+ revertRun,
134
+ type TransferCheck,
135
+ writeResolutions,
136
+ } from '@crombolo/transfer'
137
+ import * as ops from '../src/operations/commands.ts'
138
+ import { findTemplate, listTemplates, scaffoldFiles } from '../src/templates.ts'
139
+
140
+ const cwd = process.cwd()
141
+ const [command = 'help', ...rest] = Bun.argv.slice(2)
142
+ const flags = new Set(rest.filter((arg) => arg.startsWith('--')))
143
+ const positional = rest.filter((arg) => !arg.startsWith('--'))
144
+
145
+ async function siteConfig(): Promise<CromboloConfig> {
146
+ const file = join(cwd, 'crombolo.config.ts')
147
+ const overrides = existsSync(file)
148
+ ? ((await import(file)).default as Partial<CromboloConfig>)
149
+ : {}
150
+ return loadConfig(overrides, cwd)
151
+ }
152
+
153
+ function banner(lines: string[]): void {
154
+ const width = Math.max(...lines.map((line) => line.length))
155
+ console.log(`┌─${'─'.repeat(width)}─┐`)
156
+ for (const line of lines) console.log(`│ ${line.padEnd(width)} │`)
157
+ console.log(`└─${'─'.repeat(width)}─┘`)
158
+ }
159
+
160
+ /** How long a shutdown gets before it is abandoned and the process leaves anyway. */
161
+ const SHUTDOWN_MS = 10_000
162
+
163
+ /**
164
+ * Stops the site on SIGINT and SIGTERM.
165
+ *
166
+ * Without a handler neither signal does anything at all in a container: `bun` is
167
+ * PID 1 there, and the kernel drops a signal PID 1 has registered no handler
168
+ * for. That is why Ctrl-C did nothing under `docker compose run` — the keystroke
169
+ * reaches the container's own terminal, which raises a SIGINT nothing was
170
+ * listening for — and why `docker stop` sat out its ten-second grace and then
171
+ * resorted to SIGKILL. Outside a container the default would at least have
172
+ * killed the process, so this was invisible there.
173
+ *
174
+ * `server.close()` is the point of it either way: the job runner, the event
175
+ * hubs, the component watcher, the poller and the database all get to stop,
176
+ * rather than being taken away mid-write. Connections are closed with the
177
+ * listener because the live-update sockets are long-lived, and waiting for a
178
+ * socket that is meant to stay open would be waiting for ever.
179
+ *
180
+ * A second signal, or a close that will not finish, leaves immediately.
181
+ */
182
+ function stopOnSignal(
183
+ listening: { stop: (closeActiveConnections?: boolean) => void },
184
+ server: ServerHandle,
185
+ ): void {
186
+ let stopping = false
187
+ const stop = (signal: string) => {
188
+ // 130 is what a shell reports for a SIGINT death, and what this was.
189
+ if (stopping) process.exit(130)
190
+ stopping = true
191
+ console.log(`\nStopping (${signal})…`)
192
+ setTimeout(() => process.exit(130), SHUTDOWN_MS).unref()
193
+ void (async () => {
194
+ listening.stop(true)
195
+ await server.close()
196
+ process.exit(0)
197
+ })()
198
+ }
199
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => stop(signal))
200
+ }
201
+
202
+ async function start(): Promise<void> {
203
+ const config = await siteConfig()
204
+ // Before the migration and the seed, so a clash with the container stack is
205
+ // reported in a second rather than after the database work.
206
+ await assertPortAvailable(config.port)
207
+ const server = await createServer(config)
208
+ // After the schema sync inside `createServer`, which is what gives the bundle
209
+ // content types to land on, and before anything is served.
210
+ const bundles = await importStartupBundles(server, config)
211
+ const seededAdminPassword = server.seededAdminPassword
212
+
213
+ const lines = ['Crombolo is running', '']
214
+ const listening = Bun.serve(server.serveOptions)
215
+ // Registered the moment it is listening, so there is no window in which the
216
+ // site answers requests and cannot be asked to stop.
217
+ stopOnSignal(listening, server)
218
+ // What this node actually answers, so a split deployment's banner does not
219
+ // advertise an address that 404s here.
220
+ if (config.role !== 'web')
221
+ lines.push(` backoffice ${listening.url}${config.backOfficePath.slice(1)}`)
222
+ if (config.role !== 'api') lines.push(` site ${listening.url}`)
223
+ if (config.role !== 'all') lines.push(` role ${config.role}`)
224
+ lines.push('')
225
+
226
+ // A `web` node owns nothing and writes nothing at boot: resetting the password
227
+ // here would be a write from the node whose whole point is not to make them,
228
+ // and would end the sessions of editors signed in to the api node.
229
+ if (config.role === 'web') {
230
+ // Nothing to print: this node has no sign-in.
231
+ } else if (flags.has('--keep-admin')) {
232
+ if (seededAdminPassword)
233
+ lines.push(
234
+ ' Sign in with',
235
+ ` username ${config.adminLogin}`,
236
+ ` password ${seededAdminPassword}`,
237
+ '',
238
+ )
239
+ } else {
240
+ const admin = await resetAdminPassword(
241
+ new AuthStore(server.db),
242
+ config.adminLogin,
243
+ config.adminPassword,
244
+ )
245
+ if (admin) {
246
+ lines.push(
247
+ ' Sign in with',
248
+ ` username ${admin.login}`,
249
+ ` password ${admin.password}`,
250
+ '',
251
+ )
252
+ lines.push(
253
+ config.adminPassword
254
+ ? ' (from CROMBOLO_ADMIN_PASSWORD)'
255
+ : ' A fresh password is generated on every start; set',
256
+ )
257
+ if (!config.adminPassword)
258
+ lines.push(' CROMBOLO_ADMIN_PASSWORD to keep one, or pass --keep-admin.')
259
+ if (admin.sessionsEnded > 0)
260
+ lines.push(` Ended ${admin.sessionsEnded} session(s): the password changed.`)
261
+ }
262
+ }
263
+ const components = server.health().components
264
+ lines.push(
265
+ ` database ${describeDatabase(config)}`,
266
+ ` components ${config.componentsDir}${components.hash ? ` generation ${components.hash.slice(0, 8)}` : ''}`,
267
+ )
268
+ for (const line of bundles) lines.push(line)
269
+ const report = server.schema.report
270
+ if (report) lines.push(` schema ${describeSync(report)}`)
271
+ if (server.schema.compatibilityMode)
272
+ lines.push(' ⚠ database is ahead of schema/: reads only, writes refused with 409')
273
+ banner(lines)
274
+ if (seededAdminPassword) console.log('Database created and seeded.')
275
+ }
276
+
277
+ /**
278
+ * `start --bundle <dir>`, repeatable: apply a bundle before the site serves
279
+ * anything, taking the flags `content import` takes.
280
+ *
281
+ * Imported once per bundle — a run still standing here means this site already
282
+ * has that content — so a restart costs a query and a line in the banner. It
283
+ * runs after the schema sync, which is what gives the bundle its content types,
284
+ * and before anything is served. A refusal stops the boot: a site missing the
285
+ * content the command line asked for is worse than a site that did not come up.
286
+ */
287
+ async function importStartupBundles(
288
+ server: ServerHandle,
289
+ config: CromboloConfig,
290
+ ): Promise<string[]> {
291
+ const dirs = flagValues('--bundle')
292
+ if (dirs.length === 0) return []
293
+
294
+ const lines: string[] = []
295
+ const db = server.db
296
+ let imported = false
297
+ for (const dir of dirs) {
298
+ const loaded = loadBundle(dir)
299
+ for (const problem of loaded.problems) console.error(` ${problem.file}: ${problem.message}`)
300
+ if (!loaded.set || loaded.problems.length > 0) {
301
+ console.error(`\n${dir} cannot be read. The site has not started.`)
302
+ process.exit(1)
303
+ }
304
+ const set = loaded.set
305
+ const standing = await new TransferRunRepository(db).appliedFor(set.manifest.id)
306
+ if (standing) {
307
+ lines.push(` bundle ${set.manifest.id} already imported (run ${standing.id})`)
308
+ continue
309
+ }
310
+
311
+ // Nothing to roll back to on a database this boot created, so the backup
312
+ // rule — which on Postgres means refusing without one — applies only to a
313
+ // site that already had content.
314
+ if (!server.seededAdminPassword) {
315
+ const backup = await backupBefore(config, {
316
+ reason: 'content-import',
317
+ backupTaken: flags.has('--backup-taken'),
318
+ })
319
+ if (backup.path) console.log(` backup ${backup.path}`)
320
+ }
321
+
322
+ const prepared = await ops.transferOptions(db, config, set, transferInput(dir))
323
+ for (const problem of prepared.problems) console.error(` ${problem}`)
324
+ const started = performance.now()
325
+ const result = await importBundle(db, set, {
326
+ ...prepared.options,
327
+ publish: flags.has('--publish'),
328
+ nodeId: config.nodeId,
329
+ label: flagValue('--label'),
330
+ })
331
+ await writeReport(db, result.check.findings, { source: 'transfer', scope: set.manifest.id })
332
+ if (!result.runId) {
333
+ printTransferFindings(result.check)
334
+ console.error(
335
+ `\n${dir} was refused: ${result.check.outstanding.length} finding(s) outstanding. ` +
336
+ 'Nothing was written, and the site has not started.',
337
+ )
338
+ process.exit(1)
339
+ }
340
+ const c = result.check.counts
341
+ const placed = describePlaced(
342
+ loaded.blobs.size > 0 ? await placeBlobs(await mediaStoreFor(config), loaded.blobs) : null,
343
+ )
344
+ lines.push(
345
+ ` bundle ${set.manifest.id}: ${c.create} created, ${c.update} updated` +
346
+ `${result.published.length > 0 ? `, ${result.published.length} published` : ''} (run ${result.runId})`,
347
+ )
348
+ if (placed) lines.push(` blobs ${placed}`)
349
+ for (const failure of result.publishFailures)
350
+ console.error(` not published ${failure.key}: ${failure.reason}`)
351
+ await recordInLedger(db, {
352
+ name: `content import ${set.manifest.id}`,
353
+ kind: 'transfer',
354
+ durationMs: Math.round(performance.now() - started),
355
+ appliedBy: config.nodeId,
356
+ note: `run ${result.runId} (start --bundle)`,
357
+ })
358
+ imported = true
359
+ }
360
+ // The content landed under a cache this process had already constructed.
361
+ if (imported) server.cache.invalidate()
362
+ return lines
363
+ }
364
+
365
+ async function init(): Promise<void> {
366
+ const requested = flagValue('--template')
367
+ if (requested === 'list') {
368
+ for (const t of listTemplates()) console.log(` ${t.id.padEnd(20)} ${t.description}`)
369
+ console.log('\n (no --template scaffolds the files and no content)')
370
+ return
371
+ }
372
+ const template = requested ? findTemplate(requested) : undefined
373
+ if (requested && !template) {
374
+ console.error(`No template "${requested}". \`crombolo init --template list\` prints them.`)
375
+ process.exit(1)
376
+ }
377
+
378
+ const files = scaffoldFiles({
379
+ siteName: flagValue('--name'),
380
+ postgres: flags.has('--postgres'),
381
+ template,
382
+ })
383
+
384
+ for (const file of files) {
385
+ const target = join(cwd, file.path)
386
+ if (existsSync(target) && !flags.has('--force')) {
387
+ console.log(` exists ${file.path}`)
388
+ continue
389
+ }
390
+ await mkdir(join(target, '..'), { recursive: true })
391
+ if (file.copyFrom === undefined) await writeFile(target, file.text ?? '')
392
+ else await Bun.write(target, Bun.file(file.copyFrom))
393
+ console.log(` created ${file.path}`)
394
+ }
395
+
396
+ console.log(`\nNext:\n bun install\n bun start`)
397
+ if (template?.bundle)
398
+ console.log(
399
+ `\n\`bun start\` imports ${template.bundle} on the first boot of an empty database,\n` +
400
+ 'and says so. Nothing happens on later boots; it is one line in package.json.',
401
+ )
402
+ }
403
+
404
+ /**
405
+ * `crombolo status`: what this environment resolved to, and what is wrong with
406
+ * it, without starting it.
407
+ *
408
+ * Read-only by construction — it opens the database rather than bootstrapping
409
+ * it, so asking a production node how it is never migrates it.
410
+ */
411
+ async function status(): Promise<void> {
412
+ const config = await siteConfig()
413
+ const report = await siteStatus(config)
414
+
415
+ if (flags.has('--json')) {
416
+ console.log(JSON.stringify(report, null, 2))
417
+ if (report.problems.length > 0) process.exit(1)
418
+ return
419
+ }
420
+
421
+ const line = (label: string, value: string) => console.log(` ${label.padEnd(10)} ${value}`)
422
+ line('site', `${report.site.name}${report.site.dir ? ` ${report.site.dir}` : ''}`)
423
+ line('components', `${report.site.components} snapshots in ${report.site.componentsCache}`)
424
+ line(
425
+ 'database',
426
+ `${report.database.description} ${
427
+ report.database.reachable
428
+ ? report.database.installed
429
+ ? 'reachable'
430
+ : 'reachable, not installed'
431
+ : 'unreachable'
432
+ }`,
433
+ )
434
+ const files = report.schema.files ?? 'none'
435
+ const database = report.schema.database
436
+ ? `${report.schema.database.version}+${report.schema.database.revision}`
437
+ : 'not installed'
438
+ line(
439
+ 'schema',
440
+ `files ${files} database ${database} ${
441
+ !report.database.installed
442
+ ? 'nothing applied yet'
443
+ : report.schema.compatibilityMode
444
+ ? 'compatibility mode'
445
+ : report.schema.pending
446
+ ? 'a deploy is pending'
447
+ : 'up to date'
448
+ }`,
449
+ )
450
+ const pending = report.framework.pending
451
+ line(
452
+ 'framework',
453
+ pending.length === 0
454
+ ? 'no migration pending'
455
+ : // The whole list is a wall of names on a fresh database; `upgrade --plan`
456
+ // is where somebody goes when they want them.
457
+ pending.length > 3
458
+ ? `${pending.length} pending (\`crombolo upgrade --plan\` lists them)`
459
+ : `${pending.length} pending: ${pending.join(', ')}`,
460
+ )
461
+ line(
462
+ 'content',
463
+ `${report.content.documents} document(s), ${report.content.media} media, ${report.content.elements} element(s)` +
464
+ `${report.content.lastImport ? `; last import ${report.content.lastImport}` : ''}`,
465
+ )
466
+ line(
467
+ 'email',
468
+ report.email.available
469
+ ? (report.email.description ?? 'configured')
470
+ : report.email.provider === 'log'
471
+ ? 'console only, nothing is sent'
472
+ : `none — ${report.email.affects.join(', ')} unavailable`,
473
+ )
474
+ line(
475
+ 'findings',
476
+ `${report.findings.blocking} blocking, ${report.findings.person} need a person, ` +
477
+ `${report.findings.auto} automatic, ${report.findings.resolved} resolved`,
478
+ )
479
+ line(
480
+ 'versions',
481
+ `crombolo ${report.versions.crombolo}${report.versions.backoffice ? `, backoffice ${report.versions.backoffice}` : ''}, bun ${report.versions.bun}`,
482
+ )
483
+
484
+ for (const problem of report.problems) console.error(`\n ⚠ ${problem}`)
485
+ if (report.problems.length > 0) process.exit(1)
486
+ }
487
+
488
+ async function admin(): Promise<void> {
489
+ if (positional[0] !== 'reset-password') return help()
490
+ const config = await siteConfig()
491
+ // The database, not the server: resetting a password has nothing to do with
492
+ // the schema sync, the caches or the port, and should not need them to be
493
+ // healthy to work.
494
+ const { db } = await bootstrapDatabase(config)
495
+ const result = await resetAdminPassword(new AuthStore(db), config.adminLogin, positional[1])
496
+ await db.close()
497
+ if (!result) {
498
+ console.error(`No account '${config.adminLogin}'.`)
499
+ process.exit(1)
500
+ }
501
+ console.log(`${result.login}\n${result.password}`)
502
+ }
503
+
504
+ function describeSync(report: SyncReport): string {
505
+ switch (report.action) {
506
+ case 'applied':
507
+ case 'would-apply': {
508
+ const verb = report.action === 'applied' ? 'applied' : 'would apply'
509
+ const c = report.created
510
+ const parts = [
511
+ `${c.types} type(s)`,
512
+ `${c.properties} propert${c.properties === 1 ? 'y' : 'ies'}`,
513
+ `${c.dataTypes} data type(s)`,
514
+ `${c.languages} language(s)`,
515
+ ]
516
+ const extra = [
517
+ report.retired.length > 0 ? `retired ${report.retired.join(', ')}` : '',
518
+ report.retiredTypes.length > 0 ? `retired types ${report.retiredTypes.join(', ')}` : '',
519
+ report.revived.length > 0 ? `revived ${report.revived.join(', ')}` : '',
520
+ report.assignedKeys.length > 0 ? `${report.assignedKeys.length} key(s) assigned` : '',
521
+ ].filter(Boolean)
522
+ return `${verb} ${report.state?.version ?? ''}: created ${parts.join(', ')}${extra.length > 0 ? `; ${extra.join('; ')}` : ''}`
523
+ }
524
+ case 'skipped-same':
525
+ return 'up to date'
526
+ case 'skipped-older':
527
+ return `compatibility mode: ${report.reason ?? ''}`
528
+ case 'refused':
529
+ return `refused: ${report.reason ?? `${report.problems.length} problem(s)`}`
530
+ }
531
+ }
532
+
533
+ function printProblems(problems: SyncReport['problems']): void {
534
+ for (const p of problems)
535
+ console.error(` ${p.file}${p.path ? ` (${p.path})` : ''}: ${p.message}`)
536
+ }
537
+
538
+ async function schema(): Promise<void> {
539
+ const config = await siteConfig()
540
+ const sub = positional[0]
541
+ if (!existsSync(config.schemaDir) && sub !== 'export') {
542
+ console.error(`No schema directory at ${relative(cwd, config.schemaDir) || '.'}.`)
543
+ process.exit(1)
544
+ }
545
+ switch (sub) {
546
+ case 'check': {
547
+ const loaded = loadSchemaDirectory(config.schemaDir)
548
+ const problems = [
549
+ ...loaded.problems,
550
+ ...validateSchemaSet(loaded.set, {
551
+ componentAliases: componentAliasesIn(config.componentsDir),
552
+ }),
553
+ ]
554
+ if (problems.length > 0) {
555
+ console.error(`${problems.length} problem(s):`)
556
+ printProblems(problems)
557
+ process.exit(1)
558
+ }
559
+ console.log(
560
+ `schema ${loaded.set.version}: ${loaded.set.contentTypes.length} content type(s), ${(loaded.set.mediaTypes ?? []).length} media type(s), ${loaded.set.dataTypes.length} data type(s), ${loaded.set.languages.length} language(s) — valid`,
561
+ )
562
+ if (flags.has('--static')) {
563
+ const issues = staticProblems(loaded, await loadValueMigrations(config.schemaDir))
564
+ for (const issue of issues) console.error(` ${issue}`)
565
+ if (issues.length > 0) process.exit(1)
566
+ return
567
+ }
568
+ // The same check an upgrade runs, over the site's own diff.
569
+ positional[0] = 'check'
570
+ await upgrade()
571
+ return
572
+ }
573
+ case 'sync': {
574
+ const { db } = await bootstrapDatabase(config)
575
+ try {
576
+ const report = await syncSchemaDirectory(
577
+ db,
578
+ config,
579
+ { nodeId: config.nodeId, revision: config.schemaRevision },
580
+ { forceRetireTypes: flags.has('--force-retire-types') },
581
+ )
582
+ if (report.problems.length > 0) printProblems(report.problems)
583
+ console.log(describeSync(report))
584
+ if (report.action === 'refused') process.exit(1)
585
+ } finally {
586
+ await db.close()
587
+ }
588
+ return
589
+ }
590
+ case 'purge':
591
+ return schemaPurge(config)
592
+ case 'new':
593
+ return schemaNew(config)
594
+ case 'add-property':
595
+ return schemaAddProperty(config)
596
+ case 'rewrite': {
597
+ // Every file in the canonical layout: a reviewable commit when the vocabulary changes.
598
+ const loaded = loadSchemaDirectory(config.schemaDir)
599
+ if (loaded.problems.length > 0) {
600
+ printProblems(loaded.problems)
601
+ process.exit(1)
602
+ }
603
+ for (const file of writeSchemaFiles(config.schemaDir, loaded.set))
604
+ console.log(` wrote ${relative(cwd, file)}`)
605
+ return
606
+ }
607
+ case 'export': {
608
+ const { db } = await bootstrapDatabase(config)
609
+ try {
610
+ const version = existsSync(config.schemaDir)
611
+ ? loadSchemaDirectory(config.schemaDir).set.version
612
+ : '1.0.0'
613
+ const set = await exportSchemaSet(db, version)
614
+ for (const file of writeSchemaFiles(config.schemaDir, set))
615
+ console.log(` wrote ${relative(cwd, file)}`)
616
+ } finally {
617
+ await db.close()
618
+ }
619
+ return
620
+ }
621
+ default:
622
+ return help()
623
+ }
624
+ }
625
+
626
+ const TYPE_KINDS: Record<string, SchemaTypeKind> = {
627
+ 'content-type': 'document',
628
+ 'media-type': 'media',
629
+ 'member-type': 'member',
630
+ }
631
+
632
+ /**
633
+ * `schema new <kind> <alias>`: the file a site would otherwise write from
634
+ * memory, keys and all, and the view it points at.
635
+ */
636
+ async function schemaNew(config: CromboloConfig): Promise<void> {
637
+ const kindArg = positional[1] ?? ''
638
+ const alias = positional[2]
639
+ const kind = TYPE_KINDS[kindArg]
640
+ if (!kind || !alias) {
641
+ console.error(
642
+ `schema new needs a kind and an alias: ${Object.keys(TYPE_KINDS).join(', ')}.\n` +
643
+ ' crombolo schema new content-type article --at-root',
644
+ )
645
+ process.exit(1)
646
+ }
647
+
648
+ const loaded = loadSchemaDirectory(config.schemaDir)
649
+ const existing = [
650
+ ...loaded.set.contentTypes,
651
+ ...(loaded.set.mediaTypes ?? []),
652
+ ...(loaded.set.memberTypes ?? []),
653
+ ]
654
+ if (existing.some((type) => type.alias === alias)) {
655
+ console.error(`There is already a type with the alias "${alias}".`)
656
+ process.exit(1)
657
+ }
658
+
659
+ const element = flags.has('--element')
660
+ const type = newType({
661
+ alias,
662
+ kind,
663
+ name: flagValue('--name'),
664
+ description: flagValue('--description'),
665
+ icon: flagValue('--icon'),
666
+ allowAtRoot: flags.has('--at-root'),
667
+ element,
668
+ template: !flags.has('--no-view'),
669
+ // A content type with nothing in it cannot be published, so it starts with
670
+ // the one property every page has; the other kinds get what they are given.
671
+ tab: kind === 'document' ? (flagValue('--tab') ?? 'Content') : undefined,
672
+ })
673
+
674
+ const file = join(config.schemaDir, TYPE_KIND_FILES[kind].dir, fileNameFor(alias))
675
+ await mkdir(join(file, '..'), { recursive: true })
676
+ await writeFile(file, writeContentType(type, kind))
677
+ console.log(` created ${relative(cwd, file)}`)
678
+
679
+ // A type nothing allows cannot be created anywhere, which is a confusing
680
+ // first five minutes: the editor simply never offers it.
681
+ if (kind === 'document' && !element && !type.allowAtRoot) {
682
+ const parents = loaded.set.contentTypes.filter((t) => t.allowChildren.includes(alias))
683
+ if (parents.length === 0)
684
+ console.log(
685
+ ` note nothing allows a "${alias}" yet — add it to a parent's allow-children, or pass --at-root`,
686
+ )
687
+ }
688
+
689
+ if (type.components.length > 0) {
690
+ const view = join(config.componentsDir, `${alias}.tsx`)
691
+ if (existsSync(view)) console.log(` exists ${relative(cwd, view)}`)
692
+ else {
693
+ await mkdir(join(view, '..'), { recursive: true })
694
+ await writeFile(view, viewScaffold(type))
695
+ console.log(` created ${relative(cwd, view)}`)
696
+ }
697
+ }
698
+ return applySchema(config)
699
+ }
700
+
701
+ /** `schema add-property <type> <alias> --type <data type>`: the repetitive edit. */
702
+ async function schemaAddProperty(config: CromboloConfig): Promise<void> {
703
+ const typeAlias = positional[1]
704
+ const alias = positional[2]
705
+ const dataType = flagValue('--type')
706
+ if (!typeAlias || !alias || !dataType) {
707
+ console.error(
708
+ 'schema add-property needs the type, the property alias and --type <data type>:\n' +
709
+ ' crombolo schema add-property article summary --type textarea --mandatory',
710
+ )
711
+ process.exit(1)
712
+ }
713
+
714
+ const loaded = loadSchemaDirectory(config.schemaDir)
715
+ if (loaded.problems.length > 0) {
716
+ printProblems(loaded.problems)
717
+ process.exit(1)
718
+ }
719
+ const kinds: Array<[SchemaTypeKind, SchemaContentType[]]> = [
720
+ ['document', loaded.set.contentTypes],
721
+ ['media', loaded.set.mediaTypes ?? []],
722
+ ['member', loaded.set.memberTypes ?? []],
723
+ ]
724
+ const found = kinds
725
+ .map(([kind, types]) => ({ kind, type: types.find((t) => t.alias === typeAlias) }))
726
+ .find((candidate) => candidate.type)
727
+ if (!found?.type) {
728
+ console.error(`No type "${typeAlias}" in ${relative(cwd, config.schemaDir)}.`)
729
+ process.exit(1)
730
+ }
731
+
732
+ const known = new Set([
733
+ ...BUILTIN_DATA_TYPES.map((d) => d.alias),
734
+ ...loaded.set.dataTypes.map((d) => d.alias),
735
+ ])
736
+ if (!known.has(dataType)) {
737
+ console.error(`No data type "${dataType}". Built in: ${[...known].sort().join(', ')}`)
738
+ process.exit(1)
739
+ }
740
+
741
+ const result = addProperty(found.type, {
742
+ alias,
743
+ type: dataType,
744
+ name: flagValue('--name'),
745
+ description: flagValue('--description'),
746
+ mandatory: flags.has('--mandatory'),
747
+ tab: flagValue('--tab'),
748
+ })
749
+ if (!result.ok) {
750
+ console.error(` ${result.reason}`)
751
+ process.exit(1)
752
+ }
753
+
754
+ const file = join(config.schemaDir, TYPE_KIND_FILES[found.kind].dir, fileNameFor(typeAlias))
755
+ // The whole file is re-emitted from the model, as `schema rewrite` does, so a
756
+ // comment in it does not survive. Said out loud rather than discovered in a
757
+ // diff; `--dry-run` prints the file instead of writing it.
758
+ const text = writeContentType(result.type, found.kind)
759
+ if (flags.has('--dry-run')) {
760
+ console.log(text)
761
+ return
762
+ }
763
+ const before = existsSync(file) ? await Bun.file(file).text() : ''
764
+ await writeFile(file, text)
765
+ console.log(` wrote ${relative(cwd, file)}`)
766
+ console.log(
767
+ ` ${result.property.alias} ${result.property.name} (${dataType}${result.property.mandatory ? ', mandatory' : ''}) in tab "${result.tab}"`,
768
+ )
769
+ if (/^\s*#/m.test(before))
770
+ console.log(' note the file is rewritten canonically, so its comments are not kept')
771
+ return applySchema(config)
772
+ }
773
+
774
+ /** Applies `schema/` as a boot or a deploy step would, and reports what it did. */
775
+ async function applySchema(config: CromboloConfig): Promise<void> {
776
+ const { db } = await bootstrapDatabase(config)
777
+ try {
778
+ const report = await syncSchemaDirectory(db, config, {
779
+ nodeId: config.nodeId,
780
+ revision: config.schemaRevision,
781
+ })
782
+ if (report.problems.length > 0) printProblems(report.problems)
783
+ console.log(` schema ${describeSync(report)}`)
784
+ if (report.action === 'refused') process.exit(1)
785
+ } finally {
786
+ await db.close()
787
+ }
788
+ }
789
+
790
+ /** The pull-request rules: keys present, migrations well-formed, nothing the database needs. */
791
+ function staticProblems(
792
+ loaded: ReturnType<typeof loadSchemaDirectory>,
793
+ migrations: ValueMigration[],
794
+ ): string[] {
795
+ const problems: string[] = []
796
+ for (const t of loaded.set.contentTypes) {
797
+ if (!t.key)
798
+ problems.push(
799
+ `${t.alias}: no key — run \`crombolo schema sync\` in development to assign one`,
800
+ )
801
+ for (const p of allProperties(t)) if (!p.key) problems.push(`${t.alias}.${p.alias}: no key`)
802
+ }
803
+ for (const d of loaded.set.dataTypes) if (!d.key) problems.push(`data type ${d.alias}: no key`)
804
+ for (const m of migrations) {
805
+ const type = loaded.set.contentTypes.find((t) => t.alias === m.from.type)
806
+ if (!type) {
807
+ problems.push(`migration ${m.id}: no content type "${m.from.type}"`)
808
+ continue
809
+ }
810
+ for (const target of m.to)
811
+ if (!allProperties(type).some((p) => p.alias === target.property))
812
+ problems.push(
813
+ `migration ${m.id}: "${m.from.type}" has no property "${target.property}" to write to`,
814
+ )
815
+ }
816
+ return problems
817
+ }
818
+
819
+ async function schemaPurge(config: CromboloConfig): Promise<void> {
820
+ const index = rest.indexOf('--older-than')
821
+ const days = index >= 0 ? Number(rest[index + 1]) : Number.NaN
822
+ if (!Number.isFinite(days) || days < 0) {
823
+ console.error(
824
+ 'schema purge needs --older-than <days>: how long a property must have been retired.',
825
+ )
826
+ process.exit(1)
827
+ }
828
+ const backup = await backupBefore(config, {
829
+ reason: 'purge',
830
+ backupTaken: flags.has('--backup-taken'),
831
+ })
832
+ if (backup.path) console.log(` backup ${backup.path}`)
833
+ const { db } = await bootstrapDatabase(config)
834
+ try {
835
+ const before = new Date(Date.now() - days * 86_400_000)
836
+ const started = performance.now()
837
+ const purged = await new ContentTypeRepository(db).purgeRetired(before)
838
+ for (const p of purged) console.log(` purged ${p.type}.${p.alias}`)
839
+ if (purged.length === 0) console.log(`Nothing retired more than ${days} day(s) ago.`)
840
+ else
841
+ await recordInLedger(db, {
842
+ name: `purge ${purged.map((p) => `${p.type}.${p.alias}`).join(', ')}`,
843
+ kind: 'contract',
844
+ durationMs: Math.round(performance.now() - started),
845
+ appliedBy: config.nodeId,
846
+ note: `--older-than ${days}`,
847
+ })
848
+ } finally {
849
+ await db.close()
850
+ }
851
+ }
852
+
853
+ async function generate(): Promise<void> {
854
+ const config = await siteConfig()
855
+ const loaded = loadSchemaDirectory(config.schemaDir)
856
+ const problems = [...loaded.problems, ...validateSchemaSet(loaded.set)]
857
+ if (problems.length > 0) {
858
+ printProblems(problems)
859
+ process.exit(1)
860
+ }
861
+ const target = join(config.schemaDir, 'content-types.d.ts')
862
+ await writeFile(target, generateTypes(loaded.set))
863
+ console.log(` wrote ${relative(cwd, target)}`)
864
+ }
865
+
866
+ /** Every value given for a repeatable flag, in the order they appeared. */
867
+ function flagValues(name: string): string[] {
868
+ const out: string[] = []
869
+ for (let i = 0; i < rest.length; i++) {
870
+ if (rest[i] !== name) continue
871
+ const value = rest[i + 1]
872
+ if (value !== undefined && !value.startsWith('--')) out.push(value)
873
+ }
874
+ return out
875
+ }
876
+
877
+ function flagValue(name: string): string | undefined {
878
+ return flagValues(name)[0]
879
+ }
880
+
881
+ async function content(): Promise<void> {
882
+ const config = await siteConfig()
883
+ switch (positional[0]) {
884
+ case 'export':
885
+ return contentExport(config)
886
+ case 'check':
887
+ return contentCheck(config)
888
+ case 'import':
889
+ return contentImport(config)
890
+ case 'runs':
891
+ return contentRuns(config)
892
+ case 'publish':
893
+ return contentPublish(config, true)
894
+ case 'unpublish':
895
+ return contentPublish(config, false)
896
+ case 'revert':
897
+ return contentRevert(config)
898
+ default:
899
+ return help()
900
+ }
901
+ }
902
+
903
+ async function contentRevert(config: CromboloConfig): Promise<void> {
904
+ const runId = positional[1]
905
+ if (!runId) {
906
+ console.error('content revert needs the run id. `crombolo content runs` lists them.')
907
+ process.exit(1)
908
+ }
909
+ const resolutions: Record<string, RevertResolution> = {}
910
+ for (const pair of flagValues('--resolve')) {
911
+ const eq = pair.indexOf('=')
912
+ const choice = eq >= 0 ? pair.slice(eq + 1) : ''
913
+ if (eq < 0 || !isRevertResolution(choice)) {
914
+ console.error(` --resolve wants <key>=${REVERT_RESOLUTIONS.join('|')}, not "${pair}"`)
915
+ process.exit(1)
916
+ }
917
+ resolutions[pair.slice(0, eq)] = choice
918
+ }
919
+ const allFlag = flagValue('--resolve-all')
920
+ if (allFlag !== undefined && !isRevertResolution(allFlag)) {
921
+ console.error(` "${allFlag}" is not one of ${REVERT_RESOLUTIONS.join(', ')}`)
922
+ process.exit(1)
923
+ }
924
+
925
+ const backup = await backupBefore(config, {
926
+ reason: 'content-revert',
927
+ backupTaken: flags.has('--backup-taken'),
928
+ })
929
+ if (backup.path) console.log(` backup ${backup.path}`)
930
+
931
+ const { db } = await bootstrapDatabase(config)
932
+ try {
933
+ const started = performance.now()
934
+ const forceIndex = rest.indexOf('--force')
935
+ const result = await revertRun(db, runId, {
936
+ resolutions,
937
+ resolveAll: allFlag as RevertResolution | undefined,
938
+ force: forceIndex >= 0,
939
+ nodeId: config.nodeId,
940
+ backOfficePath: config.backOfficePath,
941
+ })
942
+
943
+ if (!result.runId) {
944
+ for (const f of result.check.findings) {
945
+ console.error(` [${f.kind.padEnd(8)}] ${f.message}`)
946
+ for (const suggestion of revertResolutionsFor(f.code))
947
+ console.error(` → ${suggestion}`)
948
+ }
949
+ console.error('\nrevert refused. Nothing was changed.')
950
+ process.exit(1)
951
+ }
952
+
953
+ // The files an install replaced come back from the run's own copies, not
954
+ // from git: these files are editable in the backoffice, so a site need not
955
+ // be a checkout at all.
956
+ const files = ops.restoreSectionFiles(config, runId)
957
+ for (const file of files.restored) console.log(` restored ${relative(cwd, file)}`)
958
+ for (const file of files.removed)
959
+ console.log(` removed ${relative(cwd, file)} (created by that run)`)
960
+
961
+ for (const key of result.restored) console.log(` restored ${key}`)
962
+ for (const key of result.recycled) console.log(` recycled ${key} (created by that run)`)
963
+ for (const entry of result.check.plan.filter((p) => p.action === 'skip'))
964
+ console.log(` left ${entry.name}`)
965
+ await recordInLedger(db, {
966
+ name: `content revert ${runId}`,
967
+ kind: 'revert',
968
+ durationMs: Math.round(performance.now() - started),
969
+ appliedBy: config.nodeId,
970
+ note: `run ${result.runId}`,
971
+ })
972
+ console.log(
973
+ `\nReverted ${runId} as run ${result.runId}. ` +
974
+ `\`crombolo content revert ${result.runId}\` would put it back again.`,
975
+ )
976
+ } finally {
977
+ await db.close()
978
+ }
979
+ }
980
+
981
+ /**
982
+ * Puts the media files a bundle carries into this environment's store.
983
+ *
984
+ * After the content, because the run is what decides whether any of this
985
+ * happened: a refused import leaves the store alone. The keys are the ones the
986
+ * values already name, so a picked image finds its file wherever media lives
987
+ * here — a disk, a bucket, a container.
988
+ */
989
+ function describePlaced(placed: { placed: number; bytes: number } | null): string | undefined {
990
+ if (!placed) return undefined
991
+ return `${placed.placed} media file(s) placed, ${describeBytes(placed.bytes)}`
992
+ }
993
+
994
+ /** The flags `check` and `import` share: resolutions, placement, blobs. */
995
+ function transferInput(dir: string): ops.TransferInput {
996
+ const allFlag = flagValue('--resolve-all')
997
+ if (allFlag !== undefined && !isResolution(allFlag)) {
998
+ console.error(` "${allFlag}" is not one of ${RESOLUTIONS.join(', ')}`)
999
+ process.exit(1)
1000
+ }
1001
+ return {
1002
+ dir,
1003
+ under: flagValue('--under'),
1004
+ resolutions: parseResolutions(),
1005
+ resolveAll: allFlag as Resolution | undefined,
1006
+ allowMissingBlobs: flags.has('--allow-missing-blobs'),
1007
+ // What a bundle carries is applied by default — that is the point of it
1008
+ // carrying anything — so these decline a half, for someone who wants the
1009
+ // content without the structure that came with it.
1010
+ withoutSchema: flags.has('--no-schema'),
1011
+ withoutFiles: flags.has('--no-files'),
1012
+ replaceFiles: flags.has('--replace-files'),
1013
+ }
1014
+ }
1015
+
1016
+ /** What the carried sections would do, or did. */
1017
+ function printSections(plan: ops.SectionPlan | undefined): void {
1018
+ if (!plan) return
1019
+ console.log(` carries ${plan.sections.join(', ')}`)
1020
+ const counts = new Map<string, number>()
1021
+ for (const file of plan.files) counts.set(file.action, (counts.get(file.action) ?? 0) + 1)
1022
+ const summary = [...counts].map(([action, n]) => `${n} ${action}`).join(', ')
1023
+ if (summary) console.log(` files ${summary}`)
1024
+ for (const file of plan.files)
1025
+ if (file.action === 'overwrite') console.log(` overwrites ${relative(cwd, file.target)}`)
1026
+ if (plan.dictionaryItems > 0) console.log(` dict ${plan.dictionaryItems} file(s)`)
1027
+ if (plan.schema) {
1028
+ console.log(` schema ${plan.schema.classification}`)
1029
+ for (const finding of plan.schema.findings) console.log(` ${finding}`)
1030
+ }
1031
+ }
1032
+
1033
+ /** Bytes as somebody reads them, since a bundle of media is measured in them. */
1034
+ function describeBytes(bytes: number): string {
1035
+ if (bytes < 1024) return `${bytes} B`
1036
+ const units = ['KB', 'MB', 'GB']
1037
+ let value = bytes / 1024
1038
+ let unit = 0
1039
+ while (value >= 1024 && unit < units.length - 1) {
1040
+ value /= 1024
1041
+ unit += 1
1042
+ }
1043
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`
1044
+ }
1045
+
1046
+ function describeBundle(bundle: ops.BundleSummary): void {
1047
+ console.log(
1048
+ ` bundle ${bundle.id} from ${bundle.from || 'elsewhere'} ` +
1049
+ `(schema ${bundle.schemaVersion}+${bundle.schemaRevision}, ` +
1050
+ `${bundle.snapshot} snapshot, ${bundle.nodes} node(s))`,
1051
+ )
1052
+ if (bundle.blobsCarried > 0)
1053
+ console.log(
1054
+ ` blobs ${bundle.blobsCarried} media file(s) carried, ${describeBytes(bundle.blobBytes)}`,
1055
+ )
1056
+ }
1057
+
1058
+ async function contentImport(config: CromboloConfig): Promise<void> {
1059
+ const dir = positional[1]
1060
+ if (!dir) {
1061
+ console.error('content import needs the bundle directory.')
1062
+ process.exit(1)
1063
+ }
1064
+ const result = await ops.contentImport(config, {
1065
+ ...transferInput(dir),
1066
+ publish: flags.has('--publish'),
1067
+ label: flagValue('--label'),
1068
+ backupTaken: flags.has('--backup-taken'),
1069
+ })
1070
+ for (const problem of result.problems) console.error(` ${problem}`)
1071
+ if (result.backup.path) console.log(` backup ${result.backup.path}`)
1072
+ describeBundle(result.bundle)
1073
+
1074
+ if (!result.runId) {
1075
+ printTransferFindings(result.check)
1076
+ // Sections are applied before the content is checked, because content
1077
+ // naming a type the bundle brings cannot pass a check until that type
1078
+ // exists. So a refusal here may still have written files, and saying
1079
+ // otherwise would send somebody looking in the wrong place.
1080
+ if (result.sections) {
1081
+ console.error(
1082
+ `\nimport refused, but this bundle's structure was already applied: ` +
1083
+ `${result.sections.written.length} file(s) written${
1084
+ result.sections.schema ? `, schema ${result.sections.schema.action}` : ''
1085
+ }.`,
1086
+ )
1087
+ for (const file of result.sections.written) console.error(` ${relative(cwd, file)}`)
1088
+ if (result.sections.replaced.length > 0)
1089
+ console.error(
1090
+ ` the originals are under ${relative(cwd, join(config.siteDir, ops.RUN_BACKUP_DIR))}`,
1091
+ )
1092
+ } else {
1093
+ console.error(
1094
+ `\nimport refused: ${result.check.outstanding.length} finding(s) outstanding. Nothing was written.`,
1095
+ )
1096
+ }
1097
+ process.exit(1)
1098
+ }
1099
+
1100
+ const c = result.check.counts
1101
+ console.log(` run ${result.runId}`)
1102
+ console.log(
1103
+ ` applied ${c.create} created, ${c.update} updated, ${c.unchanged} unchanged, ${c.skip} skipped`,
1104
+ )
1105
+ const placed = describePlaced(result.blobs)
1106
+ if (placed) console.log(` blobs ${placed}`)
1107
+ if (result.sections) {
1108
+ console.log(
1109
+ ` files ${result.sections.written.length} written` +
1110
+ (result.sections.replaced.length > 0
1111
+ ? `, ${result.sections.replaced.length} replaced`
1112
+ : ''),
1113
+ )
1114
+ if (result.sections.schema) console.log(` schema ${result.sections.schema.action}`)
1115
+ if (result.sections.dictionary)
1116
+ console.log(` dict ${result.sections.dictionary.imported} imported`)
1117
+ // Named, because this is what makes reverting the install possible.
1118
+ if (result.replacedFilesKept)
1119
+ console.log(` kept ${relative(cwd, result.replacedFilesKept)} (the files it replaced)`)
1120
+ }
1121
+ if (result.published > 0) console.log(` published ${result.published} node(s)`)
1122
+ for (const failure of result.publishFailures)
1123
+ console.error(` not published ${failure.key}: ${failure.reason}`)
1124
+ for (const finding of result.check.findings.filter((f) => f.kind === 'auto'))
1125
+ console.log(` [auto ] ${finding.message}`)
1126
+ console.log(`\nDone. \`crombolo content revert ${result.runId}\` puts it back.`)
1127
+ }
1128
+
1129
+ /**
1130
+ * `content publish` / `content unpublish`: taking a page or a branch live, and
1131
+ * back off again, without the backoffice.
1132
+ *
1133
+ * A publish can be refused per node — an unpublished ancestor, an unpublished
1134
+ * mandatory language, an empty mandatory property — so the whole selection is
1135
+ * checked before any of it is written. Half a published branch is a state
1136
+ * nobody asked for and nobody can reason about.
1137
+ */
1138
+ async function contentPublish(config: CromboloConfig, publishing: boolean): Promise<void> {
1139
+ const ref = positional[1]
1140
+ const verb = publishing ? 'publish' : 'unpublish'
1141
+ if (!ref) {
1142
+ console.error(`content ${verb} needs the page: a path or a uuid.`)
1143
+ process.exit(1)
1144
+ }
1145
+ const cultures = flagValues('--culture')
1146
+ const at = flagValue('--at')
1147
+ const until = flagValue('--until')
1148
+ const when = (value: string | undefined, what: string): Date | null => {
1149
+ if (value === undefined) return null
1150
+ const date = new Date(value)
1151
+ if (Number.isNaN(date.getTime())) {
1152
+ console.error(` ${what} "${value}" is not a date I can read; try 2026-10-01T09:00`)
1153
+ process.exit(1)
1154
+ }
1155
+ return date
1156
+ }
1157
+ const publishTime = when(at, '--at')
1158
+ const unpublishTime = when(until, '--until')
1159
+
1160
+ const { db } = await bootstrapDatabase(config)
1161
+ try {
1162
+ const resolved = await resolveNodeRef(db, [ObjectTypes.Document], ref)
1163
+ if (!resolved.ok) {
1164
+ console.error(` ${describeRefFailure(ref, resolved)}`)
1165
+ process.exit(1)
1166
+ }
1167
+ const state = await currentSchemaState(db)
1168
+ const docs = new DocumentRepository(db, {
1169
+ nodeState: { version: state.version, revision: state.revision },
1170
+ nodeId: config.nodeId,
1171
+ })
1172
+ // Parents before children, which is the order publishing has to happen in.
1173
+ const targets = [
1174
+ resolved.node,
1175
+ ...(flags.has('--descendants')
1176
+ ? await docs.nodes.descendants(resolved.node, [ObjectTypes.Document])
1177
+ : []),
1178
+ ]
1179
+
1180
+ if (publishTime || unpublishTime) {
1181
+ // A schedule is a statement about the future, so today's blockers are not
1182
+ // the question: the background job checks them when the time comes.
1183
+ for (const node of targets) {
1184
+ await docs.setSchedule(node.key, [
1185
+ { culture: cultures[0] ?? null, publishTime, unpublishTime },
1186
+ ])
1187
+ console.log(
1188
+ ` scheduled ${node.text ?? node.key}${publishTime ? ` from ${publishTime.toISOString()}` : ''}${unpublishTime ? ` until ${unpublishTime.toISOString()}` : ''}`,
1189
+ )
1190
+ }
1191
+ return
1192
+ }
1193
+
1194
+ if (publishing) {
1195
+ const blocked: Array<{ name: string; reasons: string[] }> = []
1196
+ // Everything in the selection counts as published for the check: the run
1197
+ // publishes parents first, so a child is not blocked by an ancestor this
1198
+ // very command is about to take live.
1199
+ const selection = new Set(targets.map((node) => node.id))
1200
+ for (const node of targets) {
1201
+ const reasons = await docs.publishBlockers(node.key, cultures, selection)
1202
+ if (reasons.length > 0) blocked.push({ name: node.text ?? node.key, reasons })
1203
+ }
1204
+ if (blocked.length > 0) {
1205
+ console.error(
1206
+ ` refused ${blocked.length} of ${targets.length} page(s) cannot be published:`,
1207
+ )
1208
+ for (const entry of blocked) console.error(` ${entry.name} ${entry.reasons.join('; ')}`)
1209
+ console.error(' nothing was published')
1210
+ process.exit(1)
1211
+ }
1212
+ }
1213
+
1214
+ for (const node of targets) {
1215
+ const result = publishing
1216
+ ? await docs.publish(node.key, cultures.length > 0 ? cultures : null)
1217
+ : await docs.unpublish(node.key, cultures.length > 0 ? cultures : null)
1218
+ if (!result) continue
1219
+ console.log(` ${publishing ? 'published' : 'unpublished'} ${node.text ?? node.key}`)
1220
+ }
1221
+ console.log(
1222
+ `\n${targets.length} page(s) ${publishing ? 'published' : 'unpublished'}${cultures.length > 0 ? ` in ${cultures.join(', ')}` : ''}.`,
1223
+ )
1224
+ } finally {
1225
+ await db.close()
1226
+ }
1227
+ }
1228
+
1229
+ async function contentRuns(config: CromboloConfig): Promise<void> {
1230
+ const limitIndex = rest.indexOf('--limit')
1231
+ const limit = limitIndex >= 0 ? Number(rest[limitIndex + 1]) : 20
1232
+ const { db } = await bootstrapDatabase(config)
1233
+ try {
1234
+ const runs = await new TransferRunRepository(db).recent(
1235
+ Number.isFinite(limit) && limit > 0 ? limit : 20,
1236
+ )
1237
+ if (runs.length === 0) return console.log('No content has been imported here.')
1238
+ for (const run of runs)
1239
+ console.log(
1240
+ ` ${run.startedAt.toISOString()} ${run.direction.padEnd(6)} ${run.status.padEnd(8)} ` +
1241
+ `${run.id} ${run.nodeCount} node(s) bundle ${run.bundleId}` +
1242
+ `${run.appliedBy ? ` by ${run.appliedBy}` : ''}${run.bundleLabel ? ` (${run.bundleLabel})` : ''}`,
1243
+ )
1244
+ } finally {
1245
+ await db.close()
1246
+ }
1247
+ }
1248
+
1249
+ /** `--resolve <key>=take-bundle`, repeatable; the same shape as `--set`. */
1250
+ function parseResolutions(): Record<string, Resolution> {
1251
+ const out: Record<string, Resolution> = {}
1252
+ for (const pair of flagValues('--resolve')) {
1253
+ const eq = pair.indexOf('=')
1254
+ if (eq < 0) {
1255
+ console.error(` --resolve wants <key>=<choice>, not "${pair}"`)
1256
+ process.exit(1)
1257
+ }
1258
+ const choice = pair.slice(eq + 1)
1259
+ if (!isResolution(choice)) {
1260
+ console.error(` "${choice}" is not one of ${RESOLUTIONS.join(', ')}`)
1261
+ process.exit(1)
1262
+ }
1263
+ out[pair.slice(0, eq)] = choice
1264
+ }
1265
+ return out
1266
+ }
1267
+
1268
+ function printTransferFindings(check: TransferCheck): void {
1269
+ const byKind = { blocking: 0, person: 0, auto: 0 }
1270
+ for (const f of check.findings) byKind[f.kind] += 1
1271
+ const c = check.counts
1272
+ console.log(
1273
+ ` plan ${c.create} new, ${c.update} updated, ${c.unchanged} unchanged, ${c.skip} skipped`,
1274
+ )
1275
+ console.log(
1276
+ ` findings ${byKind.blocking} blocking, ${byKind.person} need a person, ${byKind.auto} automatic`,
1277
+ )
1278
+ for (const f of check.findings) {
1279
+ console.log(` [${f.kind.padEnd(8)}] ${f.message}`)
1280
+ for (const suggestion of resolutionsFor(f.code)) console.log(` → ${suggestion}`)
1281
+ if (f.link) console.log(` ${f.link}`)
1282
+ }
1283
+ }
1284
+
1285
+ async function contentCheck(config: CromboloConfig): Promise<void> {
1286
+ const dir = positional[1]
1287
+ if (!dir) {
1288
+ console.error('content check needs the bundle directory.')
1289
+ process.exit(1)
1290
+ }
1291
+ const result = await ops.contentCheck(config, transferInput(dir))
1292
+ for (const problem of result.problems) console.error(` ${problem}`)
1293
+ describeBundle(result.bundle)
1294
+ if (result.under) console.log(` under ${result.under}`)
1295
+ printSections(result.sections)
1296
+ printTransferFindings(result.check)
1297
+
1298
+ if (flags.has('--save')) {
1299
+ const target = join(dir, RESOLUTIONS_FILE)
1300
+ await writeFile(
1301
+ target,
1302
+ writeResolutions({
1303
+ bundleId: result.bundle.id,
1304
+ under: flagValue('--under') ?? null,
1305
+ all: result.decisions.all,
1306
+ byNode: result.decisions.byNode,
1307
+ }),
1308
+ )
1309
+ console.log(` wrote ${relative(cwd, target)}`)
1310
+ }
1311
+
1312
+ if (!result.clean) {
1313
+ console.log(
1314
+ `\n${result.check.outstanding.length} finding(s) would stop an import. Resolve them, or pass --resolve.`,
1315
+ )
1316
+ process.exit(1)
1317
+ }
1318
+ console.log('\nNothing outstanding: this bundle would import cleanly.')
1319
+ }
1320
+
1321
+ async function contentExport(config: CromboloConfig): Promise<void> {
1322
+ const out = flagValue('--out')
1323
+ const refs = flagValues('--root')
1324
+ if (!out || refs.length === 0) {
1325
+ console.error('content export needs --root <path|uuid> (repeatable) and --out <dir>.')
1326
+ process.exit(1)
1327
+ }
1328
+ const result = await ops.contentExport(config, {
1329
+ roots: refs,
1330
+ only: flags.has('--only'),
1331
+ drafts: flags.has('--drafts'),
1332
+ withBlobs: flags.has('--with-blobs'),
1333
+ withBlueprints: flags.has('--with-blueprints'),
1334
+ })
1335
+ for (const key of result.blobs.missing)
1336
+ console.error(` missing ${key} is not in this environment's media store`)
1337
+ for (const file of result.files) {
1338
+ const target = join(out, file.path)
1339
+ await mkdir(join(target, '..'), { recursive: true })
1340
+ await writeFile(target, file.bytes ?? (file.text as string))
1341
+ }
1342
+ const counts = Object.entries(result.counts)
1343
+ console.log(` wrote ${relative(cwd, out) || '.'}`)
1344
+ console.log(
1345
+ ` bundle ${result.bundleId} (${result.snapshot}): ${
1346
+ counts.length > 0 ? counts.map(([kind, n]) => `${n} ${kind}`).join(', ') : 'nothing selected'
1347
+ }`,
1348
+ )
1349
+ if (result.expected.length > 0) {
1350
+ console.log(` expects ${result.expected.length} node(s) to exist at the destination:`)
1351
+ for (const e of result.expected) console.log(` ${e.key} ${e.name} (${e.why})`)
1352
+ }
1353
+ if (result.blobs.carried > 0)
1354
+ console.log(
1355
+ ` blobs ${result.blobs.carried} media file(s), ${describeBytes(result.blobs.bytes)}`,
1356
+ )
1357
+ else if (result.blobs.notCarried > 0)
1358
+ console.log(
1359
+ ` blobs ${result.blobs.notCarried} media file(s) are not carried — pass --with-blobs to include them`,
1360
+ )
1361
+ if (counts.length === 0) process.exit(1)
1362
+ }
1363
+
1364
+ /**
1365
+ * `09-schema-as-code.md` leaves the dictionary in the database, because
1366
+ * translators work in production where `schema/` is read-only — so this is how
1367
+ * a lower environment gets the translations.
1368
+ */
1369
+ async function dictionary(): Promise<void> {
1370
+ const config = await siteConfig()
1371
+ const sub = positional[0]
1372
+ if (sub !== 'export' && sub !== 'import') return help()
1373
+ const { db } = await bootstrapDatabase(config)
1374
+ try {
1375
+ if (sub === 'export') {
1376
+ const out = flagValue('--out')
1377
+ if (!out) {
1378
+ console.error('dictionary export needs --out <file.udt>.')
1379
+ process.exit(1)
1380
+ }
1381
+ const items = await dictionaryToUdt(new DictionaryRepository(db), flagValue('--key'))
1382
+ if (items.length === 0) {
1383
+ console.error('Nothing to export: this site has no dictionary items.')
1384
+ process.exit(1)
1385
+ }
1386
+ await mkdir(join(out, '..'), { recursive: true })
1387
+ await writeFile(out, writeDictionaryUdt(items))
1388
+ const count = (list: typeof items): number =>
1389
+ list.reduce((total, item) => total + 1 + count(item.children), 0)
1390
+ console.log(` wrote ${relative(cwd, out)}`)
1391
+ console.log(` items ${count(items)}`)
1392
+ return
1393
+ }
1394
+
1395
+ const file = positional[1]
1396
+ if (!file) {
1397
+ console.error('dictionary import needs the .udt file.')
1398
+ process.exit(1)
1399
+ }
1400
+ if (!existsSync(file)) {
1401
+ console.error(`No file at ${file}.`)
1402
+ process.exit(1)
1403
+ }
1404
+ const outcome = await importDictionaryUdt(db, await Bun.file(file).text(), null)
1405
+ if (!outcome.ok) {
1406
+ console.error(` refused: ${outcome.reason}`)
1407
+ process.exit(1)
1408
+ }
1409
+ console.log(` imported ${outcome.imported} item(s)`)
1410
+ // Umbraco's own behaviour, and worth saying out loud rather than dropping
1411
+ // translations silently: a language this site has not got is skipped.
1412
+ if (outcome.skipped.length > 0)
1413
+ console.log(
1414
+ ` skipped translations for ${outcome.skipped.join(', ')} — no such language here`,
1415
+ )
1416
+ } finally {
1417
+ await db.close()
1418
+ }
1419
+ }
1420
+
1421
+ /**
1422
+ * `crombolo domains`: which hostnames reach which page.
1423
+ *
1424
+ * `domains.toml` is the truth and the database follows it, so `set` and `clear`
1425
+ * write the file and then apply it — one command, rather than an edit here and
1426
+ * a deploy to make it real. The file is committed; `${VAR}` in a host is read
1427
+ * from the environment when it is applied, so one file serves every
1428
+ * environment.
1429
+ */
1430
+ async function domains(): Promise<void> {
1431
+ const config = await siteConfig()
1432
+ const sub = positional[0] ?? 'list'
1433
+ if (sub !== 'list' && sub !== 'apply' && sub !== 'set' && sub !== 'clear' && sub !== 'undo')
1434
+ return help()
1435
+
1436
+ const file = domainsFilePath(config.siteDir)
1437
+ const read = readDomainsFile(config.siteDir)
1438
+ const declarations = read?.declarations ?? []
1439
+ for (const problem of read?.problems ?? []) console.error(` ${file}: ${problem}`)
1440
+
1441
+ if (sub === 'list') {
1442
+ const { db } = await bootstrapDatabase(config)
1443
+ try {
1444
+ if (!read) console.log(`No ${DOMAINS_FILE}; nothing is deployed from a file here.`)
1445
+ for (const declaration of declarations) {
1446
+ const resolved = resolvePlaceholders(declaration.host)
1447
+ const culture = declaration.culture ? ` ${declaration.culture}` : ''
1448
+ // What the file says, and what it comes to here, which is the whole
1449
+ // point of a placeholder and the first thing to check when one is wrong.
1450
+ const here =
1451
+ resolved.missing.length > 0
1452
+ ? ` (${resolved.missing.join(', ')} not set here)`
1453
+ : resolved.value !== declaration.host
1454
+ ? ` → ${resolved.value}`
1455
+ : ''
1456
+ console.log(
1457
+ ` file ${declaration.node.padEnd(24)} ${declaration.host}${here}${culture}`,
1458
+ )
1459
+ }
1460
+ // What the database actually answers on, which is the thing a person is
1461
+ // usually asking about when a hostname is not working.
1462
+ const nodes = new NodeRepository(db)
1463
+ for (const row of await new DomainRepository(db).all()) {
1464
+ const node = await nodes.byId(row.nodeId)
1465
+ console.log(
1466
+ ` bound ${(node?.text ?? String(row.nodeId)).padEnd(24)} ${row.domainName}${row.isoCode ? ` ${row.isoCode}` : ''}`,
1467
+ )
1468
+ }
1469
+ } finally {
1470
+ await db.close()
1471
+ }
1472
+ return
1473
+ }
1474
+
1475
+ if (sub === 'apply') return applyDomains(config)
1476
+
1477
+ if (sub === 'undo') {
1478
+ const undone = await undoDomainsFile(config.siteDir)
1479
+ if (!undone.ok) {
1480
+ console.error(` ${undone.message}`)
1481
+ process.exit(1)
1482
+ }
1483
+ console.log(` restored ${relative(cwd, undone.file)}`)
1484
+ return applyDomains(config)
1485
+ }
1486
+
1487
+ const ref = positional[1]
1488
+ if (!ref) {
1489
+ console.error(`domains ${sub} needs the page: a path, a uuid, or / for the root page.`)
1490
+ process.exit(1)
1491
+ }
1492
+
1493
+ // Stating a node's hostnames says what they are, as the backoffice's dialog
1494
+ // does: whatever that page had is replaced, so running this twice is the same
1495
+ // as running it once. Which entries belong to that page is decided by
1496
+ // resolving them, not by matching the text — `/` and `/Home` can be the same
1497
+ // page, and clearing one while the other stayed would do nothing at all.
1498
+ const { db: resolving } = await bootstrapDatabase(config)
1499
+ let kept: DomainDeclaration[] = []
1500
+ try {
1501
+ const target = await resolveDomainNode(resolving, ref)
1502
+ if (!target.ok) {
1503
+ console.error(` ${target.message}`)
1504
+ process.exit(1)
1505
+ }
1506
+ kept = await withoutNode(resolving, declarations, target.node)
1507
+ } finally {
1508
+ await resolving.close()
1509
+ }
1510
+ const defaultCulture = flagValue('--default-culture')
1511
+ const next: DomainDeclaration[] = [...kept]
1512
+ if (sub === 'set') {
1513
+ const hosts = flagValues('--host')
1514
+ if (hosts.length === 0 && !defaultCulture) {
1515
+ console.error('domains set needs --host <host>[=<culture>], or --default-culture <iso>.')
1516
+ process.exit(1)
1517
+ }
1518
+ if (hosts.length === 0 && defaultCulture) next.push({ node: ref, host: '', defaultCulture })
1519
+ for (const [index, entry] of hosts.entries()) {
1520
+ const [host = '', culture] = entry.split('=')
1521
+ next.push({
1522
+ node: ref,
1523
+ host,
1524
+ culture,
1525
+ // The node's own fallback culture belongs to the node, so it is written
1526
+ // once rather than repeated on every hostname.
1527
+ defaultCulture: index === 0 ? defaultCulture : undefined,
1528
+ })
1529
+ }
1530
+ }
1531
+
1532
+ const written = await writeDomains(config.siteDir, next)
1533
+ console.log(
1534
+ ` wrote ${relative(cwd, written.file)}${written.backup ? ' (previous kept; `crombolo domains undo` puts it back)' : ''}`,
1535
+ )
1536
+ return applyDomains(config)
1537
+ }
1538
+
1539
+ /** Applies the file as a boot would, and reports what it did. */
1540
+ async function applyDomains(config: CromboloConfig): Promise<void> {
1541
+ const { db } = await bootstrapDatabase(config)
1542
+ try {
1543
+ const report = await syncDomainsFile(db, config.siteDir)
1544
+ for (const problem of report.problems) console.error(` ${problem}`)
1545
+ for (const applied of report.applied)
1546
+ console.log(
1547
+ ` applied ${applied.node.padEnd(24)} ${applied.host}${applied.culture ? ` ${applied.culture}` : ''}`,
1548
+ )
1549
+ if (report.removed > 0) console.log(` removed ${report.removed} binding(s)`)
1550
+ if (report.problems.length > 0) process.exit(1)
1551
+ } finally {
1552
+ await db.close()
1553
+ }
1554
+ }
1555
+
1556
+ /**
1557
+ * `crombolo import umbraco`: a compatibility report for an Umbraco backup, and
1558
+ * then a crombolo site written from it.
1559
+ *
1560
+ * Neither subcommand touches a database. `report` reads the backup and prints
1561
+ * what will and will not come across; `apply` writes a site directory — schema
1562
+ * files, view stubs, a content bundle — whose `start` script does the import
1563
+ * through `start --bundle`, the path every other bundle takes.
1564
+ *
1565
+ * The importer is a package of its own, loaded here on demand, so a site that
1566
+ * never imports anything does not carry it or the `.bacpac` reader it needs.
1567
+ */
1568
+ async function importSite(): Promise<void> {
1569
+ const VALUE_FLAGS = new Set(['--out', '--site', '--media', '--name', '--staging'])
1570
+ const args: string[] = []
1571
+ for (let i = 0; i < rest.length; i++) {
1572
+ const arg = rest[i] as string
1573
+ if (VALUE_FLAGS.has(arg)) i++
1574
+ else if (!arg.startsWith('--')) args.push(arg)
1575
+ }
1576
+ const [kind, sub, backup] = args
1577
+ if (kind !== 'umbraco' || (sub !== 'report' && sub !== 'apply')) return help()
1578
+ if (!backup) {
1579
+ console.error(`import umbraco ${sub} needs the backup: a .bacpac, or Umbraco's SQLite file.`)
1580
+ process.exit(1)
1581
+ }
1582
+ const out = flagValue('--out')
1583
+ if (sub === 'apply' && !out) {
1584
+ console.error('import umbraco apply needs --out <dir>: the site directory to write.')
1585
+ process.exit(1)
1586
+ }
1587
+
1588
+ let importer: typeof import('@crombolo/import-umbraco')
1589
+ try {
1590
+ importer = await import('@crombolo/import-umbraco')
1591
+ } catch {
1592
+ console.error(
1593
+ 'The importer is a separate package. Add it, then run this again:\n\n bun add -d @crombolo/import-umbraco',
1594
+ )
1595
+ process.exit(1)
1596
+ }
1597
+
1598
+ let plan: Awaited<ReturnType<typeof importer.planImport>>
1599
+ try {
1600
+ plan = await importer.planImport({
1601
+ source: backup,
1602
+ site: flagValue('--site'),
1603
+ media: flagValue('--media'),
1604
+ siteName: flagValue('--name'),
1605
+ drafts: flags.has('--drafts'),
1606
+ staging: flagValue('--staging'),
1607
+ })
1608
+ } catch (error) {
1609
+ console.error(` ${error instanceof Error ? error.message : error}`)
1610
+ process.exit(1)
1611
+ }
1612
+ const { report } = plan
1613
+
1614
+ if (sub === 'report') {
1615
+ if (flags.has('--json')) console.log(importer.reportJson(report).trimEnd())
1616
+ else for (const line of importer.reportSummary(report)) console.log(` ${line}`)
1617
+ if (out) {
1618
+ await mkdir(out, { recursive: true })
1619
+ await writeFile(join(out, 'report.md'), importer.reportMarkdown(report))
1620
+ await writeFile(join(out, 'report.json'), importer.reportJson(report))
1621
+ if (!flags.has('--json')) console.log(`\n wrote ${relative(cwd, join(out, 'report.md'))}`)
1622
+ }
1623
+ if (!report.ready) process.exit(1)
1624
+ return
1625
+ }
1626
+
1627
+ const target = out as string
1628
+ if (!report.ready) {
1629
+ for (const finding of report.findings.filter((f) => f.class === 'blocking'))
1630
+ console.error(
1631
+ ` blocked ${finding.title}${finding.detail ? `\n ${finding.detail}` : ''}`,
1632
+ )
1633
+ console.error('\nNothing was written.')
1634
+ process.exit(1)
1635
+ }
1636
+ if (
1637
+ existsSync(target) &&
1638
+ (await Array.fromAsync(new Bun.Glob('*').scan({ cwd: target, onlyFiles: false, dot: true })))
1639
+ .length > 0 &&
1640
+ !flags.has('--force')
1641
+ ) {
1642
+ console.error(
1643
+ `${target} is not empty. Choose a new directory, or pass --force to write into it.`,
1644
+ )
1645
+ process.exit(1)
1646
+ }
1647
+
1648
+ const files = new Map<string, { text?: string; bytes?: Uint8Array; copyFrom?: string }>()
1649
+ const scaffold = scaffoldFiles({
1650
+ siteName: plan.siteName,
1651
+ postgres: flags.has('--postgres'),
1652
+ bundle: {
1653
+ path: importer.IMPORT_BUNDLE,
1654
+ // A media item whose file was not in the backup is reported, not refused.
1655
+ flags: plan.missingMedia > 0 ? ['--allow-missing-blobs'] : [],
1656
+ },
1657
+ })
1658
+ for (const file of scaffold) files.set(file.path, file)
1659
+ // The importer's files win: the source site's media types replace the defaults.
1660
+ for (const file of plan.files) files.set(file.path, file)
1661
+ if (plan.dictionary.length > 0)
1662
+ files.set(`${importer.IMPORT_DIR}/dictionary.udt`, { text: writeUdtAll(plan.dictionary) })
1663
+ if (plan.domains.length > 0)
1664
+ files.set(`${importer.IMPORT_DIR}/domains.toml`, { text: writeDomainsFile(plan.domains) })
1665
+
1666
+ for (const [path, file] of files) {
1667
+ const destination = join(target, path)
1668
+ await mkdir(join(destination, '..'), { recursive: true })
1669
+ if (file.copyFrom !== undefined) await Bun.write(destination, Bun.file(file.copyFrom))
1670
+ else await Bun.write(destination, file.bytes ?? file.text ?? '')
1671
+ }
1672
+
1673
+ for (const line of importer.reportSummary(report)) console.log(` ${line}`)
1674
+ console.log(`\n wrote ${files.size} files to ${relative(cwd, target) || '.'}`)
1675
+ console.log(` report ${relative(cwd, join(target, importer.IMPORT_DIR, 'report.md'))}`)
1676
+ console.log(`\nNext:\n cd ${relative(cwd, target) || '.'}\n bun install\n bun start`)
1677
+ console.log(
1678
+ `\n\`bun start\` imports ${importer.IMPORT_BUNDLE} on the first boot of an empty database.`,
1679
+ )
1680
+ if (plan.dictionary.length > 0)
1681
+ console.log(`Then: crombolo dictionary import ${importer.IMPORT_DIR}/dictionary.udt`)
1682
+ }
1683
+
1684
+ /**
1685
+ * `crombolo views`: what is on disk, whether it compiles, and a new one.
1686
+ *
1687
+ * A view is loaded when a request renders it, so one that does not compile is a
1688
+ * 500 waiting for a visitor. `components check` is that failure brought forward.
1689
+ */
1690
+ async function components(): Promise<void> {
1691
+ const config = await siteConfig()
1692
+ const sub = positional[0] ?? 'check'
1693
+ if (sub !== 'check' && sub !== 'list' && sub !== 'new') return help()
1694
+
1695
+ const loaded = existsSync(config.schemaDir) ? loadSchemaDirectory(config.schemaDir) : undefined
1696
+ const types = loaded?.set.contentTypes ?? []
1697
+
1698
+ if (sub === 'list') {
1699
+ const found = listComponents(config.componentsDir)
1700
+ if (found.length === 0) console.log(`Nothing in ${relative(cwd, config.componentsDir) || '.'}.`)
1701
+ for (const component of found) {
1702
+ // A component is a template because a content type names it, not
1703
+ // because of where it sits — so the label is read off the schema.
1704
+ const declaredBy = types
1705
+ .filter((type) => type.components.includes(component.alias))
1706
+ .map((type) => type.alias)
1707
+ const kind = declaredBy.length > 0 ? 'template' : 'component'
1708
+ const note = declaredBy.length > 0 ? ` ← ${declaredBy.join(', ')}` : ''
1709
+ console.log(` ${kind.padEnd(9)} ${relative(cwd, component.path)}${note}`)
1710
+ }
1711
+ return
1712
+ }
1713
+
1714
+ if (sub === 'new') {
1715
+ const alias = positional[1]
1716
+ if (!alias) {
1717
+ console.error('components new needs a name: `crombolo components new pages/article`.')
1718
+ process.exit(1)
1719
+ }
1720
+ if (!isSafeAlias(alias)) {
1721
+ console.error(`"${alias}" is not a path inside ${relative(cwd, config.componentsDir)}.`)
1722
+ process.exit(1)
1723
+ }
1724
+ const file = componentFileFor(config.componentsDir, alias)
1725
+ if (existsSync(file)) {
1726
+ console.error(`${relative(cwd, file)} already exists.`)
1727
+ process.exit(1)
1728
+ }
1729
+ // A content type that names this alias gets the page scaffold, which takes
1730
+ // PageProps; anything else gets the plain one. Nothing turns on the folder.
1731
+ const type = types.find((candidate) => candidate.components.includes(alias))
1732
+ if (!type)
1733
+ console.log(
1734
+ ` note no content type declares the template "${alias}" — add it to one's templates, or \`crombolo schema new\``,
1735
+ )
1736
+ await mkdir(join(file, '..'), { recursive: true })
1737
+ await writeFile(file, type ? viewScaffold(type) : componentScaffold(alias))
1738
+ console.log(` created ${relative(cwd, file)}`)
1739
+ return
1740
+ }
1741
+
1742
+ const report = await checkComponents({
1743
+ componentsDir: config.componentsDir,
1744
+ siteDir: config.siteDir,
1745
+ declaredComponents: types.flatMap((type) => type.components),
1746
+ })
1747
+ const templates = report.declared.length
1748
+ console.log(
1749
+ ` checked ${report.components.length} file(s): ${templates} template(s), ` +
1750
+ `${report.components.length - templates} component(s)`,
1751
+ )
1752
+ for (const problem of report.problems)
1753
+ console.error(` [error] ${problem.file}: ${problem.message}`)
1754
+ if (report.types.note) console.log(` types ${report.types.note}`)
1755
+ else {
1756
+ console.log(` types tsc --noEmit over ${relative(cwd, config.siteDir) || '.'}`)
1757
+ for (const problem of report.types.problems) console.error(` [types] ${problem}`)
1758
+ }
1759
+ if (report.problems.length > 0 || report.types.problems.length > 0) process.exit(1)
1760
+ }
1761
+
1762
+ /** `crombolo assets`: the stylesheets and scripts the backoffice also edits. */
1763
+ async function assets(): Promise<void> {
1764
+ const config = await siteConfig()
1765
+ const sub = positional[0] ?? 'list'
1766
+ if (sub !== 'list' && sub !== 'new') return help()
1767
+
1768
+ if (sub === 'list') {
1769
+ const found = listAssets(config)
1770
+ if (found.length === 0) console.log('No stylesheets or scripts here.')
1771
+ for (const asset of found)
1772
+ console.log(` ${asset.kind.padEnd(10)} ${relative(cwd, asset.path)}`)
1773
+ return
1774
+ }
1775
+
1776
+ const kind = positional[1]
1777
+ const name = positional[2]
1778
+ if ((kind !== 'stylesheet' && kind !== 'script') || !name) {
1779
+ console.error('assets new needs a kind and a name: `crombolo assets new stylesheet site.css`.')
1780
+ process.exit(1)
1781
+ }
1782
+ const where = assetDirs(config)[kind]
1783
+ const file = join(where.dir, name.endsWith(where.extension) ? name : `${name}${where.extension}`)
1784
+ if (existsSync(file)) {
1785
+ console.error(`${relative(cwd, file)} already exists.`)
1786
+ process.exit(1)
1787
+ }
1788
+ await mkdir(join(file, '..'), { recursive: true })
1789
+ await writeFile(file, assetScaffold(kind, relative(where.dir, file)))
1790
+ console.log(` created ${relative(cwd, file)}`)
1791
+ }
1792
+
1793
+ function parseSet(): Record<string, unknown> {
1794
+ const out: Record<string, unknown> = {}
1795
+ for (let i = 0; i < rest.length; i++) {
1796
+ if (rest[i] !== '--set') continue
1797
+ const pair = rest[i + 1] ?? ''
1798
+ const eq = pair.indexOf('=')
1799
+ if (eq < 0) continue
1800
+ const raw = pair.slice(eq + 1)
1801
+ let value: unknown = raw
1802
+ try {
1803
+ value = JSON.parse(raw)
1804
+ } catch {}
1805
+ out[pair.slice(0, eq)] = value
1806
+ }
1807
+ return out
1808
+ }
1809
+
1810
+ function printFindings(report: { classification: string; findings: Finding[] }): void {
1811
+ const byKind = { blocking: 0, person: 0, auto: 0 }
1812
+ for (const f of report.findings) byKind[f.kind] += 1
1813
+ console.log(
1814
+ `change: ${report.classification}; findings: ${byKind.blocking} blocking, ${byKind.person} need a person, ${byKind.auto} automatic`,
1815
+ )
1816
+ for (const f of report.findings) {
1817
+ const where = f.link ? ` ${f.link}` : ''
1818
+ console.log(` [${f.kind.padEnd(8)}] ${f.message}${where}`)
1819
+ }
1820
+ }
1821
+
1822
+ async function upgrade(): Promise<void> {
1823
+ const config = await siteConfig()
1824
+ switch (positional[0]) {
1825
+ case 'ledger': {
1826
+ const db = await connect({ file: config.sqliteFile })
1827
+ try {
1828
+ for (const row of await readLedger(db))
1829
+ console.log(
1830
+ ` ${row.appliedAt} ${row.kind.padEnd(8)} ${row.name} ${row.durationMs}ms${row.appliedBy ? ` by ${row.appliedBy}` : ''}${row.note ? ` (${row.note})` : ''}`,
1831
+ )
1832
+ } finally {
1833
+ await db.close()
1834
+ }
1835
+ return
1836
+ }
1837
+ case 'check': {
1838
+ if (!flags.has('--fix')) {
1839
+ const report = await ops.upgradeCheck(config, { set: parseSet() })
1840
+ printFindings(report)
1841
+ if (!report.siteChecked)
1842
+ console.log(
1843
+ 'The site schema is checked once the framework migrations have run (`crombolo upgrade`).',
1844
+ )
1845
+ if (report.blocking) process.exit(1)
1846
+ return
1847
+ }
1848
+ const fix = await ops.upgradeFix(config, {
1849
+ set: parseSet(),
1850
+ backupTaken: flags.has('--backup-taken'),
1851
+ })
1852
+ if (fix.backup.path) console.log(` backup ${fix.backup.path}`)
1853
+ console.log(
1854
+ ` sync ${fix.sync.action}${fix.sync.deferred.length > 0 ? ` (deferred to the cut-over: ${fix.sync.deferred.join(', ')})` : ''}`,
1855
+ )
1856
+ for (const name of fix.applied) console.log(` applied ${name}`)
1857
+ if (fix.skipped > 0)
1858
+ console.log(` skipped ${fix.skipped} value(s) a converter refused; see the findings`)
1859
+ printFindings(fix)
1860
+ return
1861
+ }
1862
+ case 'schema': {
1863
+ positional[0] = 'rewrite'
1864
+ return schema()
1865
+ }
1866
+ case undefined: {
1867
+ if (flags.has('--plan')) {
1868
+ const plan = await ops.upgradePlan(config)
1869
+ if (plan.steps.length === 0) console.log('No framework migration is pending.')
1870
+ for (const step of plan.steps) {
1871
+ console.log(
1872
+ `\n== ${step.name} (${step.kind}${step.release ? `, release ${step.release}` : ''})`,
1873
+ )
1874
+ if (step.unplannable) console.log(` unplannable: ${step.unplannable}`)
1875
+ for (const sql of step.statements) console.log(` ${sql};`)
1876
+ }
1877
+ console.log(
1878
+ plan.site.checked
1879
+ ? `\nsite schema: ${plan.site.action} (${plan.site.classification})`
1880
+ : '\nsite schema: checked once the framework migrations have run',
1881
+ )
1882
+ return
1883
+ }
1884
+ const forceIndex = rest.indexOf('--force')
1885
+ const result = await ops.upgradeRun(config, {
1886
+ force: forceIndex >= 0 ? rest[forceIndex + 1] : undefined,
1887
+ set: parseSet(),
1888
+ backupTaken: flags.has('--backup-taken'),
1889
+ })
1890
+ if (result.backup.path) console.log(` backup ${result.backup.path}`)
1891
+ if (result.action === 'refused') {
1892
+ console.error(`upgrade refused: ${result.reasons.length} outstanding finding(s)`)
1893
+ for (const f of result.reasons)
1894
+ console.error(` [${f.kind.padEnd(8)}] ${f.message}${f.link ? ` ${f.link}` : ''}`)
1895
+ console.error(
1896
+ 'Run `crombolo upgrade check --fix`, resolve what needs a person, and try again.',
1897
+ )
1898
+ process.exit(1)
1899
+ }
1900
+ if (result.action === 'nothing')
1901
+ return console.log('Nothing to upgrade: the database is at this version.')
1902
+ for (const name of result.frameworkApplied) console.log(` applied ${name}`)
1903
+ console.log(
1904
+ ` schema ${result.schema?.action} ${result.schema?.version ?? ''}+${result.schema?.revision ?? ''}`,
1905
+ )
1906
+ return
1907
+ }
1908
+ default:
1909
+ return help()
1910
+ }
1911
+ }
1912
+
1913
+ /**
1914
+ * `maintenance`: the cluster-wide pause on editing that a restore or a swap
1915
+ * needs. It is a database flag, so it reaches every node on its next write.
1916
+ */
1917
+ async function maintenance(): Promise<void> {
1918
+ const config = await siteConfig()
1919
+ switch (positional[0]) {
1920
+ case 'pause': {
1921
+ const paused = await ops.pauseEditing(config, { reason: flagValue('--reason') })
1922
+ console.log(`Editing paused${paused.reason ? `: ${paused.reason}` : ''}.`)
1923
+ return
1924
+ }
1925
+ case 'resume':
1926
+ await ops.resumeEditing(config)
1927
+ console.log('Editing resumed.')
1928
+ return
1929
+ case 'status': {
1930
+ const paused = await ops.editingPaused(config)
1931
+ if (flags.has('--json')) {
1932
+ console.log(JSON.stringify({ paused: paused ?? null }, null, 2))
1933
+ return
1934
+ }
1935
+ console.log(
1936
+ paused
1937
+ ? `Editing paused since ${paused.at}${paused.by ? ` by ${paused.by}` : ''}${paused.reason ? `: ${paused.reason}` : ''}.`
1938
+ : 'Editing is not paused.',
1939
+ )
1940
+ return
1941
+ }
1942
+ default:
1943
+ return help()
1944
+ }
1945
+ }
1946
+
1947
+ function help(): void {
1948
+ console.log(`crombolo <command>
1949
+
1950
+ start [--keep-admin] boot the site in the current directory
1951
+ [--bundle <dir>] apply a content bundle first, once per bundle, then serve;
1952
+ takes the \`content import\` flags (--publish, --under, …)
1953
+ init [--force] [--name <site name>] [--postgres] [--template <id>]
1954
+ scaffold a site here; --template list prints the
1955
+ starter templates, which bring schema, views and content
1956
+ status [--json] what this environment resolved to, and what is wrong with
1957
+ it; non-zero when something would stop the site working
1958
+ admin reset-password [pw] reset the administrator's password
1959
+ schema check [--static] validate schema/ and report what a sync would do
1960
+ schema sync [--force-retire-types]
1961
+ apply schema/ to the database
1962
+ schema export write the database's definitions as schema/ files
1963
+ schema rewrite rewrite every schema/ file canonically
1964
+ schema new <content-type|media-type|member-type> <alias>
1965
+ write the type file, with keys, and the view it points at
1966
+ [--name <name>] [--description <text>] [--icon <icon>]
1967
+ [--at-root] [--element] [--tab <tab>] [--no-view]
1968
+ schema add-property <type> <alias> --type <data type>
1969
+ append a property to that type and apply it
1970
+ [--name <name>] [--mandatory] [--tab <tab>] [--dry-run]
1971
+ schema purge --older-than N delete properties retired more than N days ago, with their values
1972
+ generate schema/content-types.d.ts from schema/
1973
+ components check compile every component, and report one a type declares with no file
1974
+ components list what is in components/, and which type declares each
1975
+ components new <alias> components/<alias>.tsx; the alias may carry folders
1976
+ assets list the stylesheets and scripts this site has
1977
+ assets new <stylesheet|script> <name>
1978
+ write an empty one in the place the backoffice edits
1979
+ content export --root <path|uuid> --out <dir>
1980
+ write a content bundle: the subtree, its dependencies
1981
+ and what the destination must already have
1982
+ [--only] [--drafts] [--with-blobs] [--with-blueprints]
1983
+ content check <dir> what importing the bundle here would do, and what it
1984
+ needs from a person first; writes to the Changes dashboard
1985
+ [--under <path|uuid>] [--resolve <key>=take-bundle|keep-local|skip]
1986
+ [--resolve-all <choice>] [--allow-missing-blobs] [--save]
1987
+ content import <dir> re-check, back up, then apply: documents and elements
1988
+ arrive as drafts, media is live. One transaction, so a
1989
+ failure leaves nothing behind
1990
+ [--publish] [--label <text>] [--backup-taken] + the check flags
1991
+ [--no-schema] [--no-files] decline a section it carries
1992
+ [--replace-files] allow replacing files this site has;
1993
+ refused without it, and the originals are kept for a revert
1994
+ bundle check <dir> the same as content check
1995
+ bundle install <dir> the same as content import; a bundle carrying its own
1996
+ schema and views installs into a site that has neither
1997
+ content runs [--limit N] the imports applied here, newest first
1998
+ content publish <path|uuid> take it live; --descendants for the branch below it,
1999
+ --culture <iso> per culture, --at/--until to schedule
2000
+ content unpublish <path|uuid>
2001
+ take it down; the same flags
2002
+ content revert <run-id> put an import back: values and published state restored,
2003
+ nodes it created unpublished and recycled. Itself a run,
2004
+ so it can be reverted in turn
2005
+ [--resolve <key>=discard|skip] [--resolve-all <choice>]
2006
+ [--force] [--backup-taken]
2007
+ dictionary export --out <file.udt> [--key <uuid>]
2008
+ the dictionary as Umbraco's .udt, to seed another environment
2009
+ dictionary import <file.udt>
2010
+ apply one: items upsert by key, and a translation for a
2011
+ language this site has not got is skipped
2012
+ domains the hostnames domains.toml declares and what is bound here
2013
+ domains set <path|uuid|/> --host <host>[=<culture>] [--host ...] [--default-culture <iso>]
2014
+ write domains.toml and apply it; the page's hostnames are
2015
+ replaced, and \${VAR} in a host is read from the environment
2016
+ domains clear <path|uuid|/> unbind that page, in the file and in the database
2017
+ domains apply apply domains.toml here, as a boot does
2018
+ domains undo put back the file the last write replaced, and apply it
2019
+ import umbraco report <backup> [--site <dir>] [--media <dir>] [--out <dir>] [--json]
2020
+ read-only: what importing an Umbraco backup would bring,
2021
+ and what it would not — packages, Razor, custom code.
2022
+ <backup> is a .bacpac or Umbraco's SQLite file; --site is
2023
+ the site's files, for its views, media and plugins.
2024
+ Non-zero when something blocks the import
2025
+ import umbraco apply <backup> --out <dir> [--site <dir>] [--media <dir>]
2026
+ write a crombolo site: schema, view stubs, media and a
2027
+ content bundle that \`bun start\` imports on first boot
2028
+ [--name <site name>] [--drafts] [--postgres] [--force]
2029
+ upgrade check [--set t.p=v] what the deployed schema/ would do to this database's data
2030
+ upgrade check --fix back up; apply the additive part and every conversion early
2031
+ upgrade [--force <reason>] re-check, back up, apply framework steps, cut over, ledger
2032
+ upgrade --plan the DDL pending framework migrations would run
2033
+ upgrade ledger print migration_history
2034
+ maintenance pause [--reason <text>]
2035
+ refuse every editor's save, on every node, until resumed;
2036
+ readers are unaffected and no node drains
2037
+ maintenance resume let editors save again
2038
+ maintenance status [--json] whether editing is paused, by whom and why
2039
+ (Postgres: --backup-taken or CROMBOLO_PG_DUMP before anything that writes)`)
2040
+ }
2041
+
2042
+ try {
2043
+ await dispatch()
2044
+ } catch (error) {
2045
+ if (!(error instanceof ops.CommandError)) throw error
2046
+ for (const problem of error.problems) console.error(` ${problem}`)
2047
+ console.error(error.message)
2048
+ process.exit(1)
2049
+ }
2050
+
2051
+ async function dispatch(): Promise<void> {
2052
+ switch (command) {
2053
+ case 'start':
2054
+ await start()
2055
+ break
2056
+ case 'init':
2057
+ await init()
2058
+ break
2059
+ case 'status':
2060
+ await status()
2061
+ break
2062
+ case 'admin':
2063
+ await admin()
2064
+ break
2065
+ case 'schema':
2066
+ await schema()
2067
+ break
2068
+ case 'generate':
2069
+ await generate()
2070
+ break
2071
+ case 'content':
2072
+ await content()
2073
+ break
2074
+ /**
2075
+ * The artifact's own name. A bundle that carries its own structure is what
2076
+ * the Packages section builds (`docs/17-bundles.md`), and `install` reads
2077
+ * better than `import` for one — but it is the same operation on the same
2078
+ * artifact, so this is the same code path, not a second one.
2079
+ */
2080
+ case 'bundle':
2081
+ if (positional[0] === 'install') positional[0] = 'import'
2082
+ await content()
2083
+ break
2084
+ case 'dictionary':
2085
+ await dictionary()
2086
+ break
2087
+ case 'domains':
2088
+ await domains()
2089
+ break
2090
+ case 'components':
2091
+ await components()
2092
+ break
2093
+ case 'assets':
2094
+ await assets()
2095
+ break
2096
+ case 'import':
2097
+ await importSite()
2098
+ break
2099
+ case 'upgrade':
2100
+ await upgrade()
2101
+ break
2102
+ case 'maintenance':
2103
+ await maintenance()
2104
+ break
2105
+ default:
2106
+ help()
2107
+ }
2108
+ }