@crombolo/cli 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/crombolo.ts +4 -2767
- package/oclif.manifest.json +6710 -0
- package/package.json +58 -7
- package/src/base.ts +129 -0
- package/src/classify.ts +119 -0
- package/src/clispec.ts +163 -0
- package/src/commands/admin/reset-password.ts +53 -0
- package/src/commands/assets/index.ts +6 -0
- package/src/commands/assets/list.ts +27 -0
- package/src/commands/assets/new.ts +42 -0
- package/src/commands/bundle/purge.ts +71 -0
- package/src/commands/components/check.ts +58 -0
- package/src/commands/components/index.ts +6 -0
- package/src/commands/components/list.ts +40 -0
- package/src/commands/components/new.ts +49 -0
- package/src/commands/content/check.ts +102 -0
- package/src/commands/content/export.ts +116 -0
- package/src/commands/content/import.ts +109 -0
- package/src/commands/content/publish.ts +21 -0
- package/src/commands/content/revert.ts +137 -0
- package/src/commands/content/runs.ts +62 -0
- package/src/commands/content/unpublish.ts +21 -0
- package/src/commands/dictionary/export.ts +49 -0
- package/src/commands/dictionary/import.ts +46 -0
- package/src/commands/domains/apply.ts +23 -0
- package/src/commands/domains/clear.ts +27 -0
- package/src/commands/domains/index.ts +6 -0
- package/src/commands/domains/list.ts +86 -0
- package/src/commands/domains/set.ts +55 -0
- package/src/commands/domains/undo.ts +29 -0
- package/src/commands/export.ts +100 -0
- package/src/commands/generate.ts +33 -0
- package/src/commands/import/index.ts +175 -0
- package/src/commands/import/umbraco/apply.ts +128 -0
- package/src/commands/import/umbraco/report.ts +56 -0
- package/src/commands/init.ts +76 -0
- package/src/commands/login.ts +38 -0
- package/src/commands/logout.ts +36 -0
- package/src/commands/maintenance/pause.ts +29 -0
- package/src/commands/maintenance/resume.ts +20 -0
- package/src/commands/maintenance/status.ts +34 -0
- package/src/commands/media/fetch.ts +62 -0
- package/src/commands/pull.ts +210 -0
- package/src/commands/push.ts +108 -0
- package/src/commands/remote/add.ts +78 -0
- package/src/commands/remote/job.ts +53 -0
- package/src/commands/remote/list.ts +41 -0
- package/src/commands/remote/remove.ts +26 -0
- package/src/commands/schema.ts +32 -0
- package/src/commands/start.ts +64 -0
- package/src/commands/status.ts +103 -0
- package/src/commands/types/add-property.ts +116 -0
- package/src/commands/types/check.ts +88 -0
- package/src/commands/types/export.ts +32 -0
- package/src/commands/types/new.ts +116 -0
- package/src/commands/types/purge.ts +65 -0
- package/src/commands/types/rewrite.ts +30 -0
- package/src/commands/types/sync.ts +66 -0
- package/src/commands/upgrade/check.ts +69 -0
- package/src/commands/upgrade/index.ts +120 -0
- package/src/commands/upgrade/ledger.ts +55 -0
- package/src/commands/whoami.ts +41 -0
- package/src/errors.ts +90 -0
- package/src/main.ts +23 -0
- package/src/manifest.ts +20 -0
- package/src/reference.ts +118 -0
- package/src/remote/client.ts +297 -0
- package/src/remote/remotes-file.ts +81 -0
- package/src/remote/transfer.ts +353 -0
- package/src/support/components.ts +7 -0
- package/src/support/describe.ts +179 -0
- package/src/support/domains.ts +89 -0
- package/src/support/flags.ts +157 -0
- package/src/support/prompt.ts +126 -0
- package/src/support/publish.ts +142 -0
- package/src/support/remote.ts +58 -0
- package/src/support/serve.ts +460 -0
- package/src/support/site.ts +26 -0
- package/src/support/types.ts +112 -0
- package/src/support/umbraco.ts +86 -0
- package/templates/basic/bundle/content/bundle.json +1 -1
- package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
- package/templates/basic/bundle/package.json +1 -1
- package/templates/demo/harbourstone/bundle/package.json +1 -1
- package/templates/starter/bundle/package.json +1 -1
package/bin/crombolo.ts
CHANGED
|
@@ -1,2772 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
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 <pkg> apply a bundle's content, once, then serve
|
|
9
|
-
* crombolo start --from <file> in an empty folder: a working site from a whole-site file
|
|
10
|
-
* crombolo init scaffold a site in the current directory
|
|
11
|
-
* crombolo init --template <id> …from a starter template, content included
|
|
12
|
-
* crombolo status what this environment is, without starting it
|
|
13
|
-
* crombolo admin reset-password reset the administrator's password and print it
|
|
14
|
-
* crombolo schema check validate schema/ and report what a sync would do
|
|
15
|
-
* crombolo schema check --static validate the files only, no database
|
|
16
|
-
* crombolo schema sync apply schema/ to the database
|
|
17
|
-
* crombolo schema export write the database's definitions as schema/ files
|
|
18
|
-
* crombolo schema new scaffold a type file, and the view it points at
|
|
19
|
-
* crombolo schema add-property append a property to a type, and apply it
|
|
20
|
-
* crombolo generate TypeScript for the content types in schema/
|
|
21
|
-
* crombolo components check compile every component before a visitor does
|
|
22
|
-
* crombolo assets list the stylesheets and scripts beside them
|
|
23
|
-
*
|
|
24
|
-
* crombolo export --site write the whole site to one file, to move it to another machine or host
|
|
25
|
-
* crombolo content export write a subtree as a private bundle package, to move to another environment
|
|
26
|
-
* crombolo content check read-only: what installing a bundle's content here would do
|
|
27
|
-
* crombolo content import apply a bundle's content, in one transaction, and record the run
|
|
28
|
-
* crombolo content runs the imports applied here
|
|
29
|
-
* crombolo content revert put an import back, restoring what the site served
|
|
30
|
-
*
|
|
31
|
-
* crombolo domains the hostnames this site answers on
|
|
32
|
-
* crombolo domains set write domains.toml and apply it
|
|
33
|
-
*
|
|
34
|
-
* crombolo import <file> replace this site with a whole-site file; says what first
|
|
35
|
-
* crombolo import umbraco report read-only: what importing an Umbraco backup would and would not bring
|
|
36
|
-
* crombolo import umbraco apply write a crombolo site from an Umbraco backup
|
|
37
|
-
*
|
|
38
|
-
* crombolo upgrade --plan print the DDL each pending framework migration would run
|
|
39
|
-
* crombolo upgrade back up, apply pending framework migrations, record them
|
|
40
|
-
* crombolo upgrade ledger print migration_history
|
|
41
|
-
* crombolo bundle purge <id> back up, drop a bundle's tables and clear its state
|
|
42
|
-
*
|
|
43
|
-
* crombolo maintenance pause refuse editors' saves cluster-wide; readers unaffected
|
|
44
|
-
* crombolo maintenance resume let editors save again
|
|
4
|
+
* there when present. Each command is a class under `src/commands/`; `crombolo
|
|
5
|
+
* --help` lists them, and `crombolo schema` describes them for a machine.
|
|
45
6
|
*/
|
|
46
|
-
import {
|
|
47
|
-
import { mkdir, writeFile } from 'node:fs/promises'
|
|
48
|
-
import { basename, join, relative, resolve } from 'node:path'
|
|
49
|
-
import { AuthStore, resetAdminPassword } from '@crombolo/auth'
|
|
50
|
-
import { ObjectTypes } from '@crombolo/core'
|
|
51
|
-
import {
|
|
52
|
-
ContentTypeRepository,
|
|
53
|
-
connect,
|
|
54
|
-
currentSchemaState,
|
|
55
|
-
DictionaryRepository,
|
|
56
|
-
DocumentRepository,
|
|
57
|
-
DomainRepository,
|
|
58
|
-
describeRefFailure,
|
|
59
|
-
type Finding,
|
|
60
|
-
MigrationStepError,
|
|
61
|
-
NodeRepository,
|
|
62
|
-
readLedger,
|
|
63
|
-
readSetupState,
|
|
64
|
-
recordInLedger,
|
|
65
|
-
resolveNodeRef,
|
|
66
|
-
TransferRunRepository,
|
|
67
|
-
UpgradeStateError,
|
|
68
|
-
writeReport,
|
|
69
|
-
} from '@crombolo/data'
|
|
70
|
-
import {
|
|
71
|
-
addProperty,
|
|
72
|
-
allProperties,
|
|
73
|
-
BUILTIN_DATA_TYPES,
|
|
74
|
-
exportSchemaSet,
|
|
75
|
-
fileNameFor,
|
|
76
|
-
generateTypes,
|
|
77
|
-
loadSchemaDirectory,
|
|
78
|
-
loadValueMigrations,
|
|
79
|
-
newType,
|
|
80
|
-
type SchemaContentType,
|
|
81
|
-
type SchemaTypeKind,
|
|
82
|
-
type SyncReport,
|
|
83
|
-
TYPE_KIND_FILES,
|
|
84
|
-
type ValueMigration,
|
|
85
|
-
validateSchemaSet,
|
|
86
|
-
viewScaffold,
|
|
87
|
-
writeContentType,
|
|
88
|
-
writeSchemaFiles,
|
|
89
|
-
} from '@crombolo/schema'
|
|
90
|
-
import {
|
|
91
|
-
assertPortAvailable,
|
|
92
|
-
assetDirs,
|
|
93
|
-
assetScaffold,
|
|
94
|
-
type BootFailure,
|
|
95
|
-
backupBefore,
|
|
96
|
-
bootFailurePage,
|
|
97
|
-
bootRetryDelays,
|
|
98
|
-
bootstrapDatabase,
|
|
99
|
-
type CromboloConfig,
|
|
100
|
-
checkComponents,
|
|
101
|
-
componentAliasesIn,
|
|
102
|
-
componentFileFor,
|
|
103
|
-
componentScaffold,
|
|
104
|
-
createServer,
|
|
105
|
-
DOMAINS_FILE,
|
|
106
|
-
type DomainDeclaration,
|
|
107
|
-
describeBootFailure,
|
|
108
|
-
describeDatabase,
|
|
109
|
-
dictionaryToUdt,
|
|
110
|
-
domainsFilePath,
|
|
111
|
-
formTypesFor,
|
|
112
|
-
importDictionaryUdt,
|
|
113
|
-
isSafeAlias,
|
|
114
|
-
listAssets,
|
|
115
|
-
listComponents,
|
|
116
|
-
loadConfig,
|
|
117
|
-
mediaStoreFor,
|
|
118
|
-
placeBlobs,
|
|
119
|
-
readDomainsFile,
|
|
120
|
-
resolveDomainNode,
|
|
121
|
-
resolvePlaceholders,
|
|
122
|
-
type ServerHandle,
|
|
123
|
-
siteStatus,
|
|
124
|
-
syncDomainsFile,
|
|
125
|
-
syncSchemaDirectory,
|
|
126
|
-
undoDomainsFile,
|
|
127
|
-
VERSION,
|
|
128
|
-
withoutNode,
|
|
129
|
-
writeDictionaryUdt,
|
|
130
|
-
writeDomains,
|
|
131
|
-
writeDomainsFile,
|
|
132
|
-
writeUdtAll,
|
|
133
|
-
} from '@crombolo/server'
|
|
134
|
-
import {
|
|
135
|
-
importBundle,
|
|
136
|
-
isResolution,
|
|
137
|
-
isRevertResolution,
|
|
138
|
-
type NodeResolutions,
|
|
139
|
-
packageNameFor,
|
|
140
|
-
parseResolveFlags,
|
|
141
|
-
propertyHandle,
|
|
142
|
-
RESOLUTIONS,
|
|
143
|
-
RESOLUTIONS_FILE,
|
|
144
|
-
REVERT_RESOLUTIONS,
|
|
145
|
-
type Resolution,
|
|
146
|
-
type RevertResolution,
|
|
147
|
-
resolutionsFor,
|
|
148
|
-
revertResolutionsFor,
|
|
149
|
-
revertRun,
|
|
150
|
-
type TransferCheck,
|
|
151
|
-
writeResolutions,
|
|
152
|
-
} from '@crombolo/transfer'
|
|
153
|
-
import * as ops from '../src/operations/commands.ts'
|
|
154
|
-
import { findTemplate, listTemplates, scaffoldFiles } from '../src/templates.ts'
|
|
7
|
+
import { main } from '../src/main.ts'
|
|
155
8
|
|
|
156
|
-
|
|
157
|
-
const [command = 'help', ...rest] = Bun.argv.slice(2)
|
|
158
|
-
const flags = new Set(rest.filter((arg) => arg.startsWith('--')))
|
|
159
|
-
const positional = rest.filter((arg) => !arg.startsWith('--'))
|
|
160
|
-
|
|
161
|
-
async function siteConfig(): Promise<CromboloConfig> {
|
|
162
|
-
const file = join(cwd, 'crombolo.config.ts')
|
|
163
|
-
const overrides = existsSync(file)
|
|
164
|
-
? ((await import(file)).default as Partial<CromboloConfig>)
|
|
165
|
-
: {}
|
|
166
|
-
return loadConfig(overrides, cwd)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function banner(lines: string[]): void {
|
|
170
|
-
const width = Math.max(...lines.map((line) => line.length))
|
|
171
|
-
console.log(`┌─${'─'.repeat(width)}─┐`)
|
|
172
|
-
for (const line of lines) console.log(`│ ${line.padEnd(width)} │`)
|
|
173
|
-
console.log(`└─${'─'.repeat(width)}─┘`)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** How long a shutdown gets before it is abandoned and the process leaves anyway. */
|
|
177
|
-
const SHUTDOWN_MS = 10_000
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Stops the site on SIGINT and SIGTERM.
|
|
181
|
-
*
|
|
182
|
-
* Without a handler neither signal does anything at all in a container: `bun` is
|
|
183
|
-
* PID 1 there, and the kernel drops a signal PID 1 has registered no handler
|
|
184
|
-
* for. That is why Ctrl-C did nothing under `docker compose run` — the keystroke
|
|
185
|
-
* reaches the container's own terminal, which raises a SIGINT nothing was
|
|
186
|
-
* listening for — and why `docker stop` sat out its ten-second grace and then
|
|
187
|
-
* resorted to SIGKILL. Outside a container the default would at least have
|
|
188
|
-
* killed the process, so this was invisible there.
|
|
189
|
-
*
|
|
190
|
-
* `server.close()` is the point of it either way: the job runner, the event
|
|
191
|
-
* hubs, the component watcher, the poller and the database all get to stop,
|
|
192
|
-
* rather than being taken away mid-write. Connections are closed with the
|
|
193
|
-
* listener because the live-update sockets are long-lived, and waiting for a
|
|
194
|
-
* socket that is meant to stay open would be waiting for ever.
|
|
195
|
-
*
|
|
196
|
-
* A second signal, or a close that will not finish, leaves immediately.
|
|
197
|
-
*/
|
|
198
|
-
function stopOnSignal(
|
|
199
|
-
listening: { stop: (closeActiveConnections?: boolean) => void },
|
|
200
|
-
current: () => ServerHandle | undefined,
|
|
201
|
-
): void {
|
|
202
|
-
let stopping = false
|
|
203
|
-
const stop = (signal: string) => {
|
|
204
|
-
// 130 is what a shell reports for a SIGINT death, and what this was.
|
|
205
|
-
if (stopping) process.exit(130)
|
|
206
|
-
stopping = true
|
|
207
|
-
console.log(`\nStopping (${signal})…`)
|
|
208
|
-
setTimeout(() => process.exit(130), SHUTDOWN_MS).unref()
|
|
209
|
-
void (async () => {
|
|
210
|
-
listening.stop(true)
|
|
211
|
-
const server = current()
|
|
212
|
-
if (server) await server.close()
|
|
213
|
-
process.exit(0)
|
|
214
|
-
})()
|
|
215
|
-
}
|
|
216
|
-
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => stop(signal))
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* `start --from <file>` (story 105): an empty folder in, a working site out —
|
|
221
|
-
* scaffolded as `init` scaffolds one, its packages installed, and the file
|
|
222
|
-
* imported as a replace so nothing of the scaffold's own starting point stays.
|
|
223
|
-
* The new administrator's password is printed here, once.
|
|
224
|
-
*/
|
|
225
|
-
async function startFromFile(from: string): Promise<void> {
|
|
226
|
-
const file = resolve(cwd, from)
|
|
227
|
-
if (!existsSync(file)) {
|
|
228
|
-
console.error(`${from} does not exist.`)
|
|
229
|
-
process.exit(1)
|
|
230
|
-
}
|
|
231
|
-
if (existsSync(join(cwd, 'crombolo.config.ts'))) {
|
|
232
|
-
console.error(
|
|
233
|
-
'There is a site here already. `crombolo import <file> --replace` replaces it; start --from starts a new one in an empty folder.',
|
|
234
|
-
)
|
|
235
|
-
process.exit(1)
|
|
236
|
-
}
|
|
237
|
-
const dir = await ops.unpackBundle(file, cwd)
|
|
238
|
-
const manifest = ops.readBundle(dir, cwd).set.manifest
|
|
239
|
-
if (manifest.scope !== 'site') {
|
|
240
|
-
console.error(`${from} is not a whole-site file: write one with \`crombolo export --site\`.`)
|
|
241
|
-
process.exit(1)
|
|
242
|
-
}
|
|
243
|
-
const passphrase = manifest.people
|
|
244
|
-
? (process.env[PASSPHRASE_ENV] ??
|
|
245
|
-
(process.stdin.isTTY ? await readSecret(' Passphrase for its people: ') : undefined))
|
|
246
|
-
: undefined
|
|
247
|
-
if (manifest.people && !passphrase) {
|
|
248
|
-
console.error(`${from} carries people, sealed: set ${PASSPHRASE_ENV} to its passphrase.`)
|
|
249
|
-
process.exit(1)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
for (const scaffolded of scaffoldFiles({
|
|
253
|
-
siteName: flagValue('--name') ?? manifest.provenance.siteName,
|
|
254
|
-
postgres: flags.has('--postgres'),
|
|
255
|
-
})) {
|
|
256
|
-
const target = join(cwd, scaffolded.path)
|
|
257
|
-
if (existsSync(target)) continue
|
|
258
|
-
await mkdir(join(target, '..'), { recursive: true })
|
|
259
|
-
if (scaffolded.copyFrom === undefined) await writeFile(target, scaffolded.text ?? '')
|
|
260
|
-
else await Bun.write(target, Bun.file(scaffolded.copyFrom))
|
|
261
|
-
}
|
|
262
|
-
console.log(` scaffolded a site for ${manifest.provenance.siteName || 'the file'}`)
|
|
263
|
-
if (!flags.has('--no-install')) {
|
|
264
|
-
const install = Bun.spawnSync(['bun', 'install'], { cwd, stdout: 'inherit', stderr: 'inherit' })
|
|
265
|
-
if (install.exitCode !== 0) {
|
|
266
|
-
console.error('`bun install` failed; run it, then `crombolo import <file> --replace`.')
|
|
267
|
-
process.exit(1)
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const config = await siteConfig()
|
|
272
|
-
const { db, seededAdminPassword } = await bootstrapDatabase(config)
|
|
273
|
-
await db.close()
|
|
274
|
-
const result = await ops.contentImport(config, {
|
|
275
|
-
bundle: dir,
|
|
276
|
-
passphrase,
|
|
277
|
-
replace: 'replace',
|
|
278
|
-
allowMissingBlobs: flags.has('--allow-missing-media'),
|
|
279
|
-
backupTaken: true,
|
|
280
|
-
label: `start --from ${basename(file)}`,
|
|
281
|
-
})
|
|
282
|
-
for (const problem of result.problems) console.error(` ${problem}`)
|
|
283
|
-
if (!result.runId) {
|
|
284
|
-
printTransferFindings(result.check)
|
|
285
|
-
console.error('\nThe file could not be imported; the scaffolded site is left as it is.')
|
|
286
|
-
process.exit(1)
|
|
287
|
-
}
|
|
288
|
-
console.log(` imported ${result.check.counts.create} item(s), run ${result.runId}`)
|
|
289
|
-
const placed = describePlaced(result.blobs)
|
|
290
|
-
if (placed) console.log(` media ${placed}`)
|
|
291
|
-
if (seededAdminPassword)
|
|
292
|
-
banner([
|
|
293
|
-
'Sign in with (shown once)',
|
|
294
|
-
` username ${config.adminLogin}`,
|
|
295
|
-
` password ${seededAdminPassword}`,
|
|
296
|
-
])
|
|
297
|
-
// The password above is the one to use: this first start leaves it alone.
|
|
298
|
-
flags.add('--keep-admin')
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
async function start(): Promise<void> {
|
|
302
|
-
const from = flagValue('--from')
|
|
303
|
-
if (from) await startFromFile(from)
|
|
304
|
-
const config = await siteConfig()
|
|
305
|
-
// Before the migration and the seed, so a clash with the container stack is
|
|
306
|
-
// reported in a second rather than after the database work.
|
|
307
|
-
await assertPortAvailable(config.port)
|
|
308
|
-
const { listening, server, bundles } = await bootWithRetries(config)
|
|
309
|
-
const seededAdminPassword = server.seededAdminPassword
|
|
310
|
-
|
|
311
|
-
const lines = ['Crombolo is running', '']
|
|
312
|
-
// What this node actually answers, so a split deployment's banner does not
|
|
313
|
-
// advertise an address that 404s here.
|
|
314
|
-
if (config.role !== 'web')
|
|
315
|
-
lines.push(` backoffice ${listening.url}${config.backOfficePath.slice(1)}`)
|
|
316
|
-
if (config.role !== 'api') lines.push(` site ${listening.url}`)
|
|
317
|
-
if (config.role !== 'all') lines.push(` role ${config.role}`)
|
|
318
|
-
lines.push('')
|
|
319
|
-
|
|
320
|
-
// A `web` node owns nothing and writes nothing at boot: resetting the password
|
|
321
|
-
// here would be a write from the node whose whole point is not to make them,
|
|
322
|
-
// and would end the sessions of editors signed in to the api node.
|
|
323
|
-
if (config.role === 'web') {
|
|
324
|
-
// Nothing to print: this node has no sign-in.
|
|
325
|
-
} else if (!config.development) {
|
|
326
|
-
// Production never touches an existing administrator at start: a restart an
|
|
327
|
-
// attacker forced must not reset, re-enable or sign anyone out.
|
|
328
|
-
if (await setupPending(server))
|
|
329
|
-
lines.push(
|
|
330
|
-
' Set up your new site at',
|
|
331
|
-
` ${listening.url}${config.backOfficePath.slice(1)}/setup`,
|
|
332
|
-
config.setupCode
|
|
333
|
-
? ' with the code in CROMBOLO_SETUP_CODE.'
|
|
334
|
-
: ' First set CROMBOLO_SETUP_CODE (16+ characters) and restart.',
|
|
335
|
-
'',
|
|
336
|
-
)
|
|
337
|
-
} else if (flags.has('--keep-admin')) {
|
|
338
|
-
if (seededAdminPassword)
|
|
339
|
-
lines.push(
|
|
340
|
-
' Sign in with',
|
|
341
|
-
` username ${config.adminLogin}`,
|
|
342
|
-
` password ${seededAdminPassword}`,
|
|
343
|
-
'',
|
|
344
|
-
)
|
|
345
|
-
} else {
|
|
346
|
-
const admin = await resetAdminPassword(
|
|
347
|
-
new AuthStore(server.db),
|
|
348
|
-
config.adminLogin,
|
|
349
|
-
config.adminPassword,
|
|
350
|
-
)
|
|
351
|
-
if (admin) {
|
|
352
|
-
lines.push(
|
|
353
|
-
' Sign in with',
|
|
354
|
-
` username ${admin.login}`,
|
|
355
|
-
` password ${admin.password}`,
|
|
356
|
-
'',
|
|
357
|
-
)
|
|
358
|
-
lines.push(
|
|
359
|
-
config.adminPassword
|
|
360
|
-
? ' (from CROMBOLO_ADMIN_PASSWORD)'
|
|
361
|
-
: ' A fresh password is generated on every start; set',
|
|
362
|
-
)
|
|
363
|
-
if (!config.adminPassword)
|
|
364
|
-
lines.push(' CROMBOLO_ADMIN_PASSWORD to keep one, or pass --keep-admin.')
|
|
365
|
-
if (admin.sessionsEnded > 0)
|
|
366
|
-
lines.push(` Ended ${admin.sessionsEnded} session(s): the password changed.`)
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
const components = server.health().components
|
|
370
|
-
lines.push(
|
|
371
|
-
` database ${describeDatabase(config)}`,
|
|
372
|
-
` components ${config.componentsDir}${components.hash ? ` generation ${components.hash.slice(0, 8)}` : ''}`,
|
|
373
|
-
)
|
|
374
|
-
for (const line of bundles) lines.push(line)
|
|
375
|
-
const report = server.schema.report
|
|
376
|
-
if (report) lines.push(` schema ${describeSync(report)}`)
|
|
377
|
-
if (server.schema.compatibilityMode)
|
|
378
|
-
lines.push(' ⚠ database is ahead of schema/: reads only, writes refused with 409')
|
|
379
|
-
banner(lines)
|
|
380
|
-
if (seededAdminPassword) console.log('Database created and seeded.')
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Listens at once, serving a page that says why the site is not up yet, and
|
|
385
|
-
* retries the boot with backoff: a database that comes up after the server is
|
|
386
|
-
* picked up without a restart. After the last attempt it exits, so the
|
|
387
|
-
* platform's restart policy and alerts take over. docs/operations.md.
|
|
388
|
-
*/
|
|
389
|
-
async function bootWithRetries(config: CromboloConfig) {
|
|
390
|
-
const attempts = Math.max(1, Number(Bun.env.CROMBOLO_BOOT_ATTEMPTS ?? 10) || 10)
|
|
391
|
-
const delays = bootRetryDelays(attempts)
|
|
392
|
-
let failure: BootFailure | undefined
|
|
393
|
-
let progress = { attempt: 1, attempts, retryInSeconds: undefined as number | undefined }
|
|
394
|
-
const holding = (request: Request) => {
|
|
395
|
-
if (new URL(request.url).pathname === '/health')
|
|
396
|
-
return Response.json(
|
|
397
|
-
{ ok: false, starting: true, problem: failure?.title ?? null },
|
|
398
|
-
{ status: 503 },
|
|
399
|
-
)
|
|
400
|
-
return new Response(bootFailurePage(failure, progress), {
|
|
401
|
-
status: 503,
|
|
402
|
-
headers: { 'content-type': 'text/html; charset=utf-8', 'retry-after': '10' },
|
|
403
|
-
})
|
|
404
|
-
}
|
|
405
|
-
const listening = Bun.serve({
|
|
406
|
-
port: config.port,
|
|
407
|
-
maxRequestBodySize: config.maxRequestBodyBytes,
|
|
408
|
-
fetch: holding,
|
|
409
|
-
websocket: { message() {} },
|
|
410
|
-
})
|
|
411
|
-
let current: ServerHandle | undefined
|
|
412
|
-
// Registered the moment it listens, so there is no window, retries included,
|
|
413
|
-
// in which it answers requests and cannot be asked to stop.
|
|
414
|
-
stopOnSignal(listening, () => current)
|
|
415
|
-
for (let attempt = 1; ; attempt++) {
|
|
416
|
-
let server: ServerHandle | undefined
|
|
417
|
-
try {
|
|
418
|
-
server = await createServer(config)
|
|
419
|
-
current = server
|
|
420
|
-
// After the schema sync inside `createServer`, which is what gives the
|
|
421
|
-
// bundle content types to land on, and before anything is served.
|
|
422
|
-
const bundles = await importStartupBundles(server, config)
|
|
423
|
-
listening.reload(server.serveOptions as never)
|
|
424
|
-
return { listening, server, bundles }
|
|
425
|
-
} catch (error) {
|
|
426
|
-
current = undefined
|
|
427
|
-
await server?.close().catch(() => {})
|
|
428
|
-
failure = describeBootFailure(error, describeDatabase(config))
|
|
429
|
-
const delay = delays[attempt - 1]
|
|
430
|
-
console.error(
|
|
431
|
-
`crombolo: ${failure.title}. ${failure.detail}\n To fix it: ${failure.remedy}\n (${error instanceof Error ? error.message : String(error)})`,
|
|
432
|
-
)
|
|
433
|
-
if (delay === undefined || error instanceof ops.CommandError) {
|
|
434
|
-
progress = { attempt, attempts, retryInSeconds: undefined }
|
|
435
|
-
listening.stop(true)
|
|
436
|
-
if (error instanceof ops.CommandError) throw error
|
|
437
|
-
throw new ops.CommandError(`Gave up after ${attempt} attempt(s): ${failure.title}.`)
|
|
438
|
-
}
|
|
439
|
-
progress = { attempt, attempts, retryInSeconds: Math.round(delay / 1000) }
|
|
440
|
-
if (attempt === 1) console.error(` ${listening.url} explains this while it retries.`)
|
|
441
|
-
console.error(
|
|
442
|
-
` Trying again in ${Math.round(delay / 1000)}s (attempt ${attempt} of ${attempts}).`,
|
|
443
|
-
)
|
|
444
|
-
await Bun.sleep(delay)
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
async function setupPending(server: ServerHandle): Promise<boolean> {
|
|
450
|
-
return (await readSetupState(server.db)) === 'pending'
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* `start --bundle <dir>`, repeatable: apply a bundle before the site serves
|
|
455
|
-
* anything, taking the flags `content import` takes.
|
|
456
|
-
*
|
|
457
|
-
* Imported once per bundle — a run still standing here means this site already
|
|
458
|
-
* has that content — so a restart costs a query and a line in the banner. It
|
|
459
|
-
* runs after the schema sync, which is what gives the bundle its content types,
|
|
460
|
-
* and before anything is served. A refusal stops the boot: a site missing the
|
|
461
|
-
* content the command line asked for is worse than a site that did not come up.
|
|
462
|
-
*/
|
|
463
|
-
async function importStartupBundles(
|
|
464
|
-
server: ServerHandle,
|
|
465
|
-
config: CromboloConfig,
|
|
466
|
-
): Promise<string[]> {
|
|
467
|
-
const targets = flagValues('--bundle')
|
|
468
|
-
if (targets.length === 0) return []
|
|
469
|
-
|
|
470
|
-
const lines: string[] = []
|
|
471
|
-
const db = server.db
|
|
472
|
-
let imported = false
|
|
473
|
-
for (const target of targets) {
|
|
474
|
-
let resolved: ops.ResolvedBundle
|
|
475
|
-
try {
|
|
476
|
-
resolved = ops.readBundle(target, config.siteDir, await passphraseToOpen(target, config))
|
|
477
|
-
} catch (error) {
|
|
478
|
-
if (!(error instanceof ops.CommandError)) throw error
|
|
479
|
-
console.error(` ${error.message}`)
|
|
480
|
-
for (const problem of error.problems) console.error(` ${problem}`)
|
|
481
|
-
console.error(`\n${target} cannot be read. The site has not started.`)
|
|
482
|
-
process.exit(1)
|
|
483
|
-
}
|
|
484
|
-
const set = resolved.set
|
|
485
|
-
const standing = await new TransferRunRepository(db).appliedFor(set.manifest.id)
|
|
486
|
-
if (standing) {
|
|
487
|
-
lines.push(` bundle ${set.manifest.id} already imported (run ${standing.id})`)
|
|
488
|
-
continue
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// Nothing to roll back to on a database this boot created, so the backup
|
|
492
|
-
// rule — which on Postgres means refusing without one — applies only to a
|
|
493
|
-
// site that already had content.
|
|
494
|
-
if (!server.seededAdminPassword) {
|
|
495
|
-
const backup = await backupBefore(config, {
|
|
496
|
-
reason: 'content-import',
|
|
497
|
-
backupTaken: flags.has('--backup-taken'),
|
|
498
|
-
})
|
|
499
|
-
if (backup.path) console.log(` backup ${backup.path}`)
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
const prepared = await ops.transferOptions(db, config, resolved, transferInput(target))
|
|
503
|
-
for (const problem of prepared.problems) console.error(` ${problem}`)
|
|
504
|
-
const started = performance.now()
|
|
505
|
-
const result = await importBundle(db, set, {
|
|
506
|
-
...prepared.options,
|
|
507
|
-
// The administrator this boot created stands in until the site's own arrive.
|
|
508
|
-
adoptUsers: Boolean(server.seededAdminPassword),
|
|
509
|
-
publish: flags.has('--publish'),
|
|
510
|
-
nodeId: config.nodeId,
|
|
511
|
-
label: flagValue('--label'),
|
|
512
|
-
package: { name: resolved.package.packageName, version: resolved.package.version },
|
|
513
|
-
})
|
|
514
|
-
await writeReport(db, result.check.findings, { source: 'transfer', scope: set.manifest.id })
|
|
515
|
-
if (!result.runId) {
|
|
516
|
-
printTransferFindings(result.check)
|
|
517
|
-
console.error(
|
|
518
|
-
`\n${target} was refused: ${result.check.outstanding.length} finding(s) outstanding. ` +
|
|
519
|
-
'Nothing was written, and the site has not started.',
|
|
520
|
-
)
|
|
521
|
-
process.exit(1)
|
|
522
|
-
}
|
|
523
|
-
const c = result.check.counts
|
|
524
|
-
const placed = describePlaced(
|
|
525
|
-
resolved.blobs.size > 0
|
|
526
|
-
? await placeBlobs(await mediaStoreFor(config), resolved.blobs, resolved.openBlob)
|
|
527
|
-
: null,
|
|
528
|
-
)
|
|
529
|
-
lines.push(
|
|
530
|
-
` bundle ${set.manifest.id}: ${c.create} created, ${c.update} updated` +
|
|
531
|
-
`${result.published.length > 0 ? `, ${result.published.length} published` : ''} (run ${result.runId})`,
|
|
532
|
-
)
|
|
533
|
-
if (placed) lines.push(` blobs ${placed}`)
|
|
534
|
-
const people = result.people.filter((p) => p.kind === 'member' || p.kind === 'user')
|
|
535
|
-
if (people.length > 0)
|
|
536
|
-
lines.push(
|
|
537
|
-
` people ${people.filter((p) => p.kind === 'member').length} member(s), ${people.filter((p) => p.kind === 'user').length} user(s); their passwords are the ones they had`,
|
|
538
|
-
)
|
|
539
|
-
for (const finding of result.check.findings.filter((f) => f.code === 'user-adopted'))
|
|
540
|
-
lines.push(` admin ${finding.message}`)
|
|
541
|
-
for (const failure of result.publishFailures)
|
|
542
|
-
console.error(` not published ${failure.key}: ${failure.reason}`)
|
|
543
|
-
await recordInLedger(db, {
|
|
544
|
-
name: `content import ${set.manifest.id}`,
|
|
545
|
-
kind: 'transfer',
|
|
546
|
-
durationMs: Math.round(performance.now() - started),
|
|
547
|
-
appliedBy: config.nodeId,
|
|
548
|
-
note: `run ${result.runId}, ${resolved.package.packageName}@${resolved.package.version} (start --bundle)`,
|
|
549
|
-
})
|
|
550
|
-
imported = true
|
|
551
|
-
}
|
|
552
|
-
// The content landed under a cache this process had already constructed.
|
|
553
|
-
if (imported) server.cache.invalidate()
|
|
554
|
-
return lines
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
async function init(): Promise<void> {
|
|
558
|
-
const requested = flagValue('--template')
|
|
559
|
-
if (requested === 'list') {
|
|
560
|
-
for (const t of listTemplates()) console.log(` ${t.id.padEnd(20)} ${t.description}`)
|
|
561
|
-
console.log('\n (no --template scaffolds the files and no content)')
|
|
562
|
-
return
|
|
563
|
-
}
|
|
564
|
-
const template = requested ? findTemplate(requested) : undefined
|
|
565
|
-
if (requested && !template) {
|
|
566
|
-
console.error(`No template "${requested}". \`crombolo init --template list\` prints them.`)
|
|
567
|
-
process.exit(1)
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
const files = scaffoldFiles({
|
|
571
|
-
siteName: flagValue('--name'),
|
|
572
|
-
postgres: flags.has('--postgres'),
|
|
573
|
-
template,
|
|
574
|
-
})
|
|
575
|
-
|
|
576
|
-
for (const file of files) {
|
|
577
|
-
const target = join(cwd, file.path)
|
|
578
|
-
if (existsSync(target) && !flags.has('--force')) {
|
|
579
|
-
console.log(` exists ${file.path}`)
|
|
580
|
-
continue
|
|
581
|
-
}
|
|
582
|
-
await mkdir(join(target, '..'), { recursive: true })
|
|
583
|
-
if (file.copyFrom === undefined) await writeFile(target, file.text ?? '')
|
|
584
|
-
else await Bun.write(target, Bun.file(file.copyFrom))
|
|
585
|
-
console.log(` created ${file.path}`)
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
console.log(`\nNext:\n bun install\n bun start`)
|
|
589
|
-
if (template?.bundle)
|
|
590
|
-
console.log(
|
|
591
|
-
`\n\`bun start\` imports ${template.bundle} on the first boot of an empty database,\n` +
|
|
592
|
-
'and says so. Nothing happens on later boots; it is one line in package.json.',
|
|
593
|
-
)
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
/**
|
|
597
|
-
* `crombolo status`: what this environment resolved to, and what is wrong with
|
|
598
|
-
* it, without starting it.
|
|
599
|
-
*
|
|
600
|
-
* Read-only by construction — it opens the database rather than bootstrapping
|
|
601
|
-
* it, so asking a production node how it is never migrates it.
|
|
602
|
-
*/
|
|
603
|
-
async function status(): Promise<void> {
|
|
604
|
-
const config = await siteConfig()
|
|
605
|
-
const report = await siteStatus(config)
|
|
606
|
-
|
|
607
|
-
if (flags.has('--json')) {
|
|
608
|
-
console.log(JSON.stringify(report, null, 2))
|
|
609
|
-
if (report.problems.length > 0) process.exit(1)
|
|
610
|
-
return
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
const line = (label: string, value: string) => console.log(` ${label.padEnd(10)} ${value}`)
|
|
614
|
-
line('site', `${report.site.name}${report.site.dir ? ` ${report.site.dir}` : ''}`)
|
|
615
|
-
line('components', `${report.site.components} snapshots in ${report.site.componentsCache}`)
|
|
616
|
-
line(
|
|
617
|
-
'database',
|
|
618
|
-
`${report.database.description} ${
|
|
619
|
-
report.database.reachable
|
|
620
|
-
? report.database.installed
|
|
621
|
-
? 'reachable'
|
|
622
|
-
: 'reachable, not installed'
|
|
623
|
-
: 'unreachable'
|
|
624
|
-
}`,
|
|
625
|
-
)
|
|
626
|
-
const files = report.schema.files ?? 'none'
|
|
627
|
-
const database = report.schema.database
|
|
628
|
-
? `${report.schema.database.version}+${report.schema.database.revision}`
|
|
629
|
-
: 'not installed'
|
|
630
|
-
line(
|
|
631
|
-
'schema',
|
|
632
|
-
`files ${files} database ${database} ${
|
|
633
|
-
!report.database.installed
|
|
634
|
-
? 'nothing applied yet'
|
|
635
|
-
: report.schema.compatibilityMode
|
|
636
|
-
? 'compatibility mode'
|
|
637
|
-
: report.schema.pending
|
|
638
|
-
? 'a deploy is pending'
|
|
639
|
-
: 'up to date'
|
|
640
|
-
}`,
|
|
641
|
-
)
|
|
642
|
-
const pending = report.framework.pending
|
|
643
|
-
line(
|
|
644
|
-
'framework',
|
|
645
|
-
pending.length === 0
|
|
646
|
-
? 'no migration pending'
|
|
647
|
-
: // The whole list is a wall of names on a fresh database; `upgrade --plan`
|
|
648
|
-
// is where somebody goes when they want them.
|
|
649
|
-
pending.length > 3
|
|
650
|
-
? `${pending.length} pending (\`crombolo upgrade --plan\` lists them)`
|
|
651
|
-
: `${pending.length} pending: ${pending.join(', ')}`,
|
|
652
|
-
)
|
|
653
|
-
line(
|
|
654
|
-
'content',
|
|
655
|
-
`${report.content.documents} document(s), ${report.content.media} media, ${report.content.elements} element(s)` +
|
|
656
|
-
`${report.content.lastImport ? `; last import ${report.content.lastImport}` : ''}`,
|
|
657
|
-
)
|
|
658
|
-
line(
|
|
659
|
-
'email',
|
|
660
|
-
report.email.available
|
|
661
|
-
? (report.email.description ?? 'configured')
|
|
662
|
-
: report.email.provider === 'log'
|
|
663
|
-
? 'console only, nothing is sent'
|
|
664
|
-
: `none — ${report.email.affects.join(', ')} unavailable`,
|
|
665
|
-
)
|
|
666
|
-
line(
|
|
667
|
-
'findings',
|
|
668
|
-
`${report.findings.blocking} blocking, ${report.findings.person} need a person, ` +
|
|
669
|
-
`${report.findings.auto} automatic, ${report.findings.resolved} resolved`,
|
|
670
|
-
)
|
|
671
|
-
line(
|
|
672
|
-
'versions',
|
|
673
|
-
`crombolo ${report.versions.crombolo}${report.versions.backoffice ? `, backoffice ${report.versions.backoffice}` : ''}, bun ${report.versions.bun}`,
|
|
674
|
-
)
|
|
675
|
-
|
|
676
|
-
for (const problem of report.problems) console.error(`\n ⚠ ${problem}`)
|
|
677
|
-
if (report.problems.length > 0) process.exit(1)
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
async function admin(): Promise<void> {
|
|
681
|
-
if (positional[0] !== 'reset-password') return help()
|
|
682
|
-
const config = await siteConfig()
|
|
683
|
-
// The database, not the server: resetting a password has nothing to do with
|
|
684
|
-
// the schema sync, the caches or the port, and should not need them to be
|
|
685
|
-
// healthy to work.
|
|
686
|
-
const { db } = await bootstrapDatabase(config)
|
|
687
|
-
const result = await resetAdminPassword(new AuthStore(db), config.adminLogin, positional[1])
|
|
688
|
-
await db.close()
|
|
689
|
-
if (!result) {
|
|
690
|
-
console.error(`No account '${config.adminLogin}'.`)
|
|
691
|
-
process.exit(1)
|
|
692
|
-
}
|
|
693
|
-
console.log(`${result.login}\n${result.password}`)
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
function describeSync(report: SyncReport): string {
|
|
697
|
-
switch (report.action) {
|
|
698
|
-
case 'applied':
|
|
699
|
-
case 'would-apply': {
|
|
700
|
-
const verb = report.action === 'applied' ? 'applied' : 'would apply'
|
|
701
|
-
const c = report.created
|
|
702
|
-
const parts = [
|
|
703
|
-
`${c.types} type(s)`,
|
|
704
|
-
`${c.properties} propert${c.properties === 1 ? 'y' : 'ies'}`,
|
|
705
|
-
`${c.dataTypes} data type(s)`,
|
|
706
|
-
`${c.languages} language(s)`,
|
|
707
|
-
]
|
|
708
|
-
const extra = [
|
|
709
|
-
report.retired.length > 0 ? `retired ${report.retired.join(', ')}` : '',
|
|
710
|
-
report.retiredTypes.length > 0 ? `retired types ${report.retiredTypes.join(', ')}` : '',
|
|
711
|
-
report.revived.length > 0 ? `revived ${report.revived.join(', ')}` : '',
|
|
712
|
-
report.assignedKeys.length > 0 ? `${report.assignedKeys.length} key(s) assigned` : '',
|
|
713
|
-
].filter(Boolean)
|
|
714
|
-
return `${verb} ${report.state?.version ?? ''}: created ${parts.join(', ')}${extra.length > 0 ? `; ${extra.join('; ')}` : ''}`
|
|
715
|
-
}
|
|
716
|
-
case 'skipped-same':
|
|
717
|
-
return 'up to date'
|
|
718
|
-
case 'skipped-older':
|
|
719
|
-
return `compatibility mode: ${report.reason ?? ''}`
|
|
720
|
-
case 'refused':
|
|
721
|
-
return `refused: ${report.reason ?? `${report.problems.length} problem(s)`}`
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
function printProblems(problems: SyncReport['problems']): void {
|
|
726
|
-
for (const p of problems)
|
|
727
|
-
console.error(` ${p.file}${p.path ? ` (${p.path})` : ''}: ${p.message}`)
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
async function schema(): Promise<void> {
|
|
731
|
-
const config = await siteConfig()
|
|
732
|
-
const sub = positional[0]
|
|
733
|
-
if (!existsSync(config.schemaDir) && sub !== 'export') {
|
|
734
|
-
console.error(`No schema directory at ${relative(cwd, config.schemaDir) || '.'}.`)
|
|
735
|
-
process.exit(1)
|
|
736
|
-
}
|
|
737
|
-
switch (sub) {
|
|
738
|
-
case 'check': {
|
|
739
|
-
const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
|
|
740
|
-
const problems = [
|
|
741
|
-
...loaded.problems,
|
|
742
|
-
...validateSchemaSet(loaded.set, {
|
|
743
|
-
componentAliases: componentAliasesIn(config.componentsDir),
|
|
744
|
-
types: formTypesFor(config),
|
|
745
|
-
}),
|
|
746
|
-
]
|
|
747
|
-
if (problems.length > 0) {
|
|
748
|
-
console.error(`${problems.length} problem(s):`)
|
|
749
|
-
printProblems(problems)
|
|
750
|
-
process.exit(1)
|
|
751
|
-
}
|
|
752
|
-
console.log(
|
|
753
|
-
`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`,
|
|
754
|
-
)
|
|
755
|
-
if (flags.has('--static')) {
|
|
756
|
-
const issues = staticProblems(loaded, await loadValueMigrations(config.schemaDir))
|
|
757
|
-
for (const issue of issues) console.error(` ${issue}`)
|
|
758
|
-
if (issues.length > 0) process.exit(1)
|
|
759
|
-
return
|
|
760
|
-
}
|
|
761
|
-
// The same check an upgrade runs, over the site's own diff.
|
|
762
|
-
positional[0] = 'check'
|
|
763
|
-
await upgrade()
|
|
764
|
-
return
|
|
765
|
-
}
|
|
766
|
-
case 'sync': {
|
|
767
|
-
const { db } = await bootstrapDatabase(config)
|
|
768
|
-
try {
|
|
769
|
-
const report = await syncSchemaDirectory(
|
|
770
|
-
db,
|
|
771
|
-
config,
|
|
772
|
-
{ nodeId: config.nodeId, revision: config.schemaRevision },
|
|
773
|
-
{ forceRetireTypes: flags.has('--force-retire-types') },
|
|
774
|
-
)
|
|
775
|
-
if (report.problems.length > 0) printProblems(report.problems)
|
|
776
|
-
console.log(describeSync(report))
|
|
777
|
-
if (report.action === 'refused') process.exit(1)
|
|
778
|
-
} finally {
|
|
779
|
-
await db.close()
|
|
780
|
-
}
|
|
781
|
-
return
|
|
782
|
-
}
|
|
783
|
-
case 'purge':
|
|
784
|
-
return schemaPurge(config)
|
|
785
|
-
case 'new':
|
|
786
|
-
return schemaNew(config)
|
|
787
|
-
case 'add-property':
|
|
788
|
-
return schemaAddProperty(config)
|
|
789
|
-
case 'rewrite': {
|
|
790
|
-
// Every file in the canonical layout: a reviewable commit when the vocabulary changes.
|
|
791
|
-
const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
|
|
792
|
-
if (loaded.problems.length > 0) {
|
|
793
|
-
printProblems(loaded.problems)
|
|
794
|
-
process.exit(1)
|
|
795
|
-
}
|
|
796
|
-
for (const file of writeSchemaFiles(config.schemaDir, loaded.set))
|
|
797
|
-
console.log(` wrote ${relative(cwd, file)}`)
|
|
798
|
-
return
|
|
799
|
-
}
|
|
800
|
-
case 'export': {
|
|
801
|
-
const { db } = await bootstrapDatabase(config)
|
|
802
|
-
try {
|
|
803
|
-
const version = existsSync(config.schemaDir)
|
|
804
|
-
? loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) }).set.version
|
|
805
|
-
: '1.0.0'
|
|
806
|
-
const set = await exportSchemaSet(db, version)
|
|
807
|
-
for (const file of writeSchemaFiles(config.schemaDir, set))
|
|
808
|
-
console.log(` wrote ${relative(cwd, file)}`)
|
|
809
|
-
} finally {
|
|
810
|
-
await db.close()
|
|
811
|
-
}
|
|
812
|
-
return
|
|
813
|
-
}
|
|
814
|
-
default:
|
|
815
|
-
return help()
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
const TYPE_KINDS: Record<string, SchemaTypeKind> = {
|
|
820
|
-
'content-type': 'document',
|
|
821
|
-
'media-type': 'media',
|
|
822
|
-
'member-type': 'member',
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
/**
|
|
826
|
-
* `schema new <kind> <alias>`: the file a site would otherwise write from
|
|
827
|
-
* memory, keys and all, and the view it points at.
|
|
828
|
-
*/
|
|
829
|
-
async function schemaNew(config: CromboloConfig): Promise<void> {
|
|
830
|
-
const kindArg = positional[1] ?? ''
|
|
831
|
-
const alias = positional[2]
|
|
832
|
-
const kind = TYPE_KINDS[kindArg]
|
|
833
|
-
if (!kind || !alias) {
|
|
834
|
-
console.error(
|
|
835
|
-
`schema new needs a kind and an alias: ${Object.keys(TYPE_KINDS).join(', ')}.\n` +
|
|
836
|
-
' crombolo schema new content-type article --at-root',
|
|
837
|
-
)
|
|
838
|
-
process.exit(1)
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
|
|
842
|
-
const existing = [
|
|
843
|
-
...loaded.set.contentTypes,
|
|
844
|
-
...(loaded.set.mediaTypes ?? []),
|
|
845
|
-
...(loaded.set.memberTypes ?? []),
|
|
846
|
-
]
|
|
847
|
-
if (existing.some((type) => type.alias === alias)) {
|
|
848
|
-
console.error(`There is already a type with the alias "${alias}".`)
|
|
849
|
-
process.exit(1)
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
const element = flags.has('--element')
|
|
853
|
-
const type = newType({
|
|
854
|
-
alias,
|
|
855
|
-
kind,
|
|
856
|
-
name: flagValue('--name'),
|
|
857
|
-
description: flagValue('--description'),
|
|
858
|
-
icon: flagValue('--icon'),
|
|
859
|
-
allowAtRoot: flags.has('--at-root'),
|
|
860
|
-
element,
|
|
861
|
-
template: !flags.has('--no-view'),
|
|
862
|
-
// A content type with nothing in it cannot be published, so it starts with
|
|
863
|
-
// the one property every page has; the other kinds get what they are given.
|
|
864
|
-
tab: kind === 'document' ? (flagValue('--tab') ?? 'Content') : undefined,
|
|
865
|
-
})
|
|
866
|
-
|
|
867
|
-
const file = join(config.schemaDir, TYPE_KIND_FILES[kind].dir, fileNameFor(alias))
|
|
868
|
-
await mkdir(join(file, '..'), { recursive: true })
|
|
869
|
-
await writeFile(file, writeContentType(type, kind))
|
|
870
|
-
console.log(` created ${relative(cwd, file)}`)
|
|
871
|
-
|
|
872
|
-
// A type nothing allows cannot be created anywhere, which is a confusing
|
|
873
|
-
// first five minutes: the editor simply never offers it.
|
|
874
|
-
if (kind === 'document' && !element && !type.allowAtRoot) {
|
|
875
|
-
const parents = loaded.set.contentTypes.filter((t) => t.allowChildren.includes(alias))
|
|
876
|
-
if (parents.length === 0)
|
|
877
|
-
console.log(
|
|
878
|
-
` note nothing allows a "${alias}" yet — add it to a parent's allow-children, or pass --at-root`,
|
|
879
|
-
)
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
if (type.components.length > 0) {
|
|
883
|
-
const view = join(config.componentsDir, `${alias}.tsx`)
|
|
884
|
-
if (existsSync(view)) console.log(` exists ${relative(cwd, view)}`)
|
|
885
|
-
else {
|
|
886
|
-
await mkdir(join(view, '..'), { recursive: true })
|
|
887
|
-
await writeFile(view, viewScaffold(type))
|
|
888
|
-
console.log(` created ${relative(cwd, view)}`)
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
return applySchema(config)
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
/** `schema add-property <type> <alias> --type <data type>`: the repetitive edit. */
|
|
895
|
-
async function schemaAddProperty(config: CromboloConfig): Promise<void> {
|
|
896
|
-
const typeAlias = positional[1]
|
|
897
|
-
const alias = positional[2]
|
|
898
|
-
const dataType = flagValue('--type')
|
|
899
|
-
if (!typeAlias || !alias || !dataType) {
|
|
900
|
-
console.error(
|
|
901
|
-
'schema add-property needs the type, the property alias and --type <data type>:\n' +
|
|
902
|
-
' crombolo schema add-property article summary --type textarea --mandatory',
|
|
903
|
-
)
|
|
904
|
-
process.exit(1)
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
|
|
908
|
-
if (loaded.problems.length > 0) {
|
|
909
|
-
printProblems(loaded.problems)
|
|
910
|
-
process.exit(1)
|
|
911
|
-
}
|
|
912
|
-
const kinds: Array<[SchemaTypeKind, SchemaContentType[]]> = [
|
|
913
|
-
['document', loaded.set.contentTypes],
|
|
914
|
-
['media', loaded.set.mediaTypes ?? []],
|
|
915
|
-
['member', loaded.set.memberTypes ?? []],
|
|
916
|
-
]
|
|
917
|
-
const found = kinds
|
|
918
|
-
.map(([kind, types]) => ({ kind, type: types.find((t) => t.alias === typeAlias) }))
|
|
919
|
-
.find((candidate) => candidate.type)
|
|
920
|
-
if (!found?.type) {
|
|
921
|
-
console.error(`No type "${typeAlias}" in ${relative(cwd, config.schemaDir)}.`)
|
|
922
|
-
process.exit(1)
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
const known = new Set([
|
|
926
|
-
...BUILTIN_DATA_TYPES.map((d) => d.alias),
|
|
927
|
-
...loaded.set.dataTypes.map((d) => d.alias),
|
|
928
|
-
])
|
|
929
|
-
if (!known.has(dataType)) {
|
|
930
|
-
console.error(`No data type "${dataType}". Built in: ${[...known].sort().join(', ')}`)
|
|
931
|
-
process.exit(1)
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
const result = addProperty(found.type, {
|
|
935
|
-
alias,
|
|
936
|
-
type: dataType,
|
|
937
|
-
name: flagValue('--name'),
|
|
938
|
-
description: flagValue('--description'),
|
|
939
|
-
mandatory: flags.has('--mandatory'),
|
|
940
|
-
tab: flagValue('--tab'),
|
|
941
|
-
})
|
|
942
|
-
if (!result.ok) {
|
|
943
|
-
console.error(` ${result.reason}`)
|
|
944
|
-
process.exit(1)
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
const file = join(config.schemaDir, TYPE_KIND_FILES[found.kind].dir, fileNameFor(typeAlias))
|
|
948
|
-
// The whole file is re-emitted from the model, as `schema rewrite` does, so a
|
|
949
|
-
// comment in it does not survive. Said out loud rather than discovered in a
|
|
950
|
-
// diff; `--dry-run` prints the file instead of writing it.
|
|
951
|
-
const text = writeContentType(result.type, found.kind)
|
|
952
|
-
if (flags.has('--dry-run')) {
|
|
953
|
-
console.log(text)
|
|
954
|
-
return
|
|
955
|
-
}
|
|
956
|
-
const before = existsSync(file) ? await Bun.file(file).text() : ''
|
|
957
|
-
await writeFile(file, text)
|
|
958
|
-
console.log(` wrote ${relative(cwd, file)}`)
|
|
959
|
-
console.log(
|
|
960
|
-
` ${result.property.alias} ${result.property.name} (${dataType}${result.property.mandatory ? ', mandatory' : ''}) in tab "${result.tab}"`,
|
|
961
|
-
)
|
|
962
|
-
if (/^\s*#/m.test(before))
|
|
963
|
-
console.log(' note the file is rewritten canonically, so its comments are not kept')
|
|
964
|
-
return applySchema(config)
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
/** Applies `schema/` as a boot or a deploy step would, and reports what it did. */
|
|
968
|
-
async function applySchema(config: CromboloConfig): Promise<void> {
|
|
969
|
-
const { db } = await bootstrapDatabase(config)
|
|
970
|
-
try {
|
|
971
|
-
const report = await syncSchemaDirectory(db, config, {
|
|
972
|
-
nodeId: config.nodeId,
|
|
973
|
-
revision: config.schemaRevision,
|
|
974
|
-
})
|
|
975
|
-
if (report.problems.length > 0) printProblems(report.problems)
|
|
976
|
-
console.log(` schema ${describeSync(report)}`)
|
|
977
|
-
if (report.action === 'refused') process.exit(1)
|
|
978
|
-
} finally {
|
|
979
|
-
await db.close()
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
/** The pull-request rules: keys present, migrations well-formed, nothing the database needs. */
|
|
984
|
-
function staticProblems(
|
|
985
|
-
loaded: ReturnType<typeof loadSchemaDirectory>,
|
|
986
|
-
migrations: ValueMigration[],
|
|
987
|
-
): string[] {
|
|
988
|
-
const problems: string[] = []
|
|
989
|
-
for (const t of loaded.set.contentTypes) {
|
|
990
|
-
if (!t.key)
|
|
991
|
-
problems.push(
|
|
992
|
-
`${t.alias}: no key — run \`crombolo schema sync\` in development to assign one`,
|
|
993
|
-
)
|
|
994
|
-
for (const p of allProperties(t)) if (!p.key) problems.push(`${t.alias}.${p.alias}: no key`)
|
|
995
|
-
}
|
|
996
|
-
for (const d of loaded.set.dataTypes) if (!d.key) problems.push(`data type ${d.alias}: no key`)
|
|
997
|
-
for (const m of migrations) {
|
|
998
|
-
const type = loaded.set.contentTypes.find((t) => t.alias === m.from.type)
|
|
999
|
-
if (!type) {
|
|
1000
|
-
problems.push(`migration ${m.id}: no content type "${m.from.type}"`)
|
|
1001
|
-
continue
|
|
1002
|
-
}
|
|
1003
|
-
for (const target of m.to)
|
|
1004
|
-
if (!allProperties(type).some((p) => p.alias === target.property))
|
|
1005
|
-
problems.push(
|
|
1006
|
-
`migration ${m.id}: "${m.from.type}" has no property "${target.property}" to write to`,
|
|
1007
|
-
)
|
|
1008
|
-
}
|
|
1009
|
-
return problems
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
async function schemaPurge(config: CromboloConfig): Promise<void> {
|
|
1013
|
-
const index = rest.indexOf('--older-than')
|
|
1014
|
-
const days = index >= 0 ? Number(rest[index + 1]) : Number.NaN
|
|
1015
|
-
if (!Number.isFinite(days) || days < 0) {
|
|
1016
|
-
console.error(
|
|
1017
|
-
'schema purge needs --older-than <days>: how long a property must have been retired.',
|
|
1018
|
-
)
|
|
1019
|
-
process.exit(1)
|
|
1020
|
-
}
|
|
1021
|
-
const backup = await backupBefore(config, {
|
|
1022
|
-
reason: 'purge',
|
|
1023
|
-
backupTaken: flags.has('--backup-taken'),
|
|
1024
|
-
})
|
|
1025
|
-
if (backup.path) console.log(` backup ${backup.path}`)
|
|
1026
|
-
const { db } = await bootstrapDatabase(config)
|
|
1027
|
-
try {
|
|
1028
|
-
const before = new Date(Date.now() - days * 86_400_000)
|
|
1029
|
-
const started = performance.now()
|
|
1030
|
-
const purged = await new ContentTypeRepository(db).purgeRetired(before)
|
|
1031
|
-
for (const p of purged) console.log(` purged ${p.type}.${p.alias}`)
|
|
1032
|
-
if (purged.length === 0) console.log(`Nothing retired more than ${days} day(s) ago.`)
|
|
1033
|
-
else
|
|
1034
|
-
await recordInLedger(db, {
|
|
1035
|
-
name: `purge ${purged.map((p) => `${p.type}.${p.alias}`).join(', ')}`,
|
|
1036
|
-
kind: 'contract',
|
|
1037
|
-
durationMs: Math.round(performance.now() - started),
|
|
1038
|
-
appliedBy: config.nodeId,
|
|
1039
|
-
note: `--older-than ${days}`,
|
|
1040
|
-
})
|
|
1041
|
-
} finally {
|
|
1042
|
-
await db.close()
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
async function generate(): Promise<void> {
|
|
1047
|
-
const config = await siteConfig()
|
|
1048
|
-
const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
|
|
1049
|
-
const problems = [
|
|
1050
|
-
...loaded.problems,
|
|
1051
|
-
...validateSchemaSet(loaded.set, { types: formTypesFor(config) }),
|
|
1052
|
-
]
|
|
1053
|
-
if (problems.length > 0) {
|
|
1054
|
-
printProblems(problems)
|
|
1055
|
-
process.exit(1)
|
|
1056
|
-
}
|
|
1057
|
-
const target = join(config.schemaDir, 'content-types.d.ts')
|
|
1058
|
-
await writeFile(target, generateTypes(loaded.set))
|
|
1059
|
-
console.log(` wrote ${relative(cwd, target)}`)
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
/** Every value given for a repeatable flag, in the order they appeared. */
|
|
1063
|
-
function flagValues(name: string): string[] {
|
|
1064
|
-
const out: string[] = []
|
|
1065
|
-
for (let i = 0; i < rest.length; i++) {
|
|
1066
|
-
if (rest[i] !== name) continue
|
|
1067
|
-
const value = rest[i + 1]
|
|
1068
|
-
if (value !== undefined && !value.startsWith('--')) out.push(value)
|
|
1069
|
-
}
|
|
1070
|
-
return out
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
function flagValue(name: string): string | undefined {
|
|
1074
|
-
return flagValues(name)[0]
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
/** Where a script gives a bundle's passphrase, rather than typing it. */
|
|
1078
|
-
const PASSPHRASE_ENV = 'CROMBOLO_BUNDLE_PASSPHRASE'
|
|
1079
|
-
const MIN_PASSPHRASE = 12
|
|
1080
|
-
|
|
1081
|
-
/** A line typed without echo. */
|
|
1082
|
-
async function readSecret(question: string): Promise<string> {
|
|
1083
|
-
process.stdout.write(question)
|
|
1084
|
-
const stdin = process.stdin
|
|
1085
|
-
stdin.setRawMode(true)
|
|
1086
|
-
stdin.resume()
|
|
1087
|
-
return new Promise((done) => {
|
|
1088
|
-
let typed = ''
|
|
1089
|
-
const onData = (chunk: Buffer) => {
|
|
1090
|
-
for (const char of chunk.toString('utf8')) {
|
|
1091
|
-
if (char === '\u0003') {
|
|
1092
|
-
process.stdout.write('\n')
|
|
1093
|
-
process.exit(130)
|
|
1094
|
-
}
|
|
1095
|
-
if (char === '\r' || char === '\n') {
|
|
1096
|
-
stdin.setRawMode(false)
|
|
1097
|
-
stdin.pause()
|
|
1098
|
-
stdin.off('data', onData)
|
|
1099
|
-
process.stdout.write('\n')
|
|
1100
|
-
done(typed)
|
|
1101
|
-
return
|
|
1102
|
-
}
|
|
1103
|
-
if (char === '\u007f' || char === '\b') typed = typed.slice(0, -1)
|
|
1104
|
-
else typed += char
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
stdin.on('data', onData)
|
|
1108
|
-
})
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
/** The passphrase to open a bundle with, when it is sealed: the environment's, or asked for. */
|
|
1112
|
-
async function passphraseToOpen(
|
|
1113
|
-
target: string,
|
|
1114
|
-
config: CromboloConfig,
|
|
1115
|
-
): Promise<string | undefined> {
|
|
1116
|
-
const given = process.env[PASSPHRASE_ENV]
|
|
1117
|
-
if (given) return given
|
|
1118
|
-
let sealed = false
|
|
1119
|
-
try {
|
|
1120
|
-
sealed = ops.readBundle(target, config.siteDir).set.sealed === true
|
|
1121
|
-
} catch {
|
|
1122
|
-
// The command that follows reads it again and says what is wrong with it.
|
|
1123
|
-
return undefined
|
|
1124
|
-
}
|
|
1125
|
-
if (!sealed) return undefined
|
|
1126
|
-
if (!process.stdin.isTTY) {
|
|
1127
|
-
console.error(
|
|
1128
|
-
`${target} is encrypted. Set ${PASSPHRASE_ENV} to the passphrase it was exported with.`,
|
|
1129
|
-
)
|
|
1130
|
-
process.exit(1)
|
|
1131
|
-
}
|
|
1132
|
-
return readSecret(' Passphrase for this bundle: ')
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
/** A new passphrase to seal an export with: the environment's, or asked for twice. */
|
|
1136
|
-
async function passphraseToSeal(): Promise<string> {
|
|
1137
|
-
const given = process.env[PASSPHRASE_ENV]
|
|
1138
|
-
if (given) {
|
|
1139
|
-
if (given.length < MIN_PASSPHRASE) {
|
|
1140
|
-
console.error(`${PASSPHRASE_ENV} is shorter than ${MIN_PASSPHRASE} characters.`)
|
|
1141
|
-
process.exit(1)
|
|
1142
|
-
}
|
|
1143
|
-
return given
|
|
1144
|
-
}
|
|
1145
|
-
if (!process.stdin.isTTY) {
|
|
1146
|
-
console.error(`An encrypted export needs a passphrase: set ${PASSPHRASE_ENV}.`)
|
|
1147
|
-
process.exit(1)
|
|
1148
|
-
}
|
|
1149
|
-
for (;;) {
|
|
1150
|
-
const first = await readSecret(' Passphrase to encrypt this bundle with: ')
|
|
1151
|
-
if (first.length < MIN_PASSPHRASE) {
|
|
1152
|
-
console.error(` At least ${MIN_PASSPHRASE} characters, please.`)
|
|
1153
|
-
continue
|
|
1154
|
-
}
|
|
1155
|
-
if ((await readSecret(' Again, to confirm: ')) === first) return first
|
|
1156
|
-
console.error(' They did not match.')
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
async function content(): Promise<void> {
|
|
1161
|
-
const config = await siteConfig()
|
|
1162
|
-
switch (positional[0]) {
|
|
1163
|
-
case 'export':
|
|
1164
|
-
return contentExport(config)
|
|
1165
|
-
case 'check':
|
|
1166
|
-
return contentCheck(config)
|
|
1167
|
-
case 'import':
|
|
1168
|
-
return contentImport(config)
|
|
1169
|
-
case 'runs':
|
|
1170
|
-
return contentRuns(config)
|
|
1171
|
-
case 'publish':
|
|
1172
|
-
return contentPublish(config, true)
|
|
1173
|
-
case 'unpublish':
|
|
1174
|
-
return contentPublish(config, false)
|
|
1175
|
-
case 'revert':
|
|
1176
|
-
return contentRevert(config)
|
|
1177
|
-
default:
|
|
1178
|
-
return help()
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
async function contentRevert(config: CromboloConfig): Promise<void> {
|
|
1183
|
-
const runId = positional[1]
|
|
1184
|
-
if (!runId) {
|
|
1185
|
-
console.error('content revert needs the run id. `crombolo content runs` lists them.')
|
|
1186
|
-
process.exit(1)
|
|
1187
|
-
}
|
|
1188
|
-
const resolutions: Record<string, RevertResolution> = {}
|
|
1189
|
-
for (const pair of flagValues('--resolve')) {
|
|
1190
|
-
const eq = pair.indexOf('=')
|
|
1191
|
-
const choice = eq >= 0 ? pair.slice(eq + 1) : ''
|
|
1192
|
-
if (eq < 0 || !isRevertResolution(choice)) {
|
|
1193
|
-
console.error(` --resolve wants <key>=${REVERT_RESOLUTIONS.join('|')}, not "${pair}"`)
|
|
1194
|
-
process.exit(1)
|
|
1195
|
-
}
|
|
1196
|
-
resolutions[pair.slice(0, eq)] = choice
|
|
1197
|
-
}
|
|
1198
|
-
const allFlag = flagValue('--resolve-all')
|
|
1199
|
-
if (allFlag !== undefined && !isRevertResolution(allFlag)) {
|
|
1200
|
-
console.error(` "${allFlag}" is not one of ${REVERT_RESOLUTIONS.join(', ')}`)
|
|
1201
|
-
process.exit(1)
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
const backup = await backupBefore(config, {
|
|
1205
|
-
reason: 'content-revert',
|
|
1206
|
-
backupTaken: flags.has('--backup-taken'),
|
|
1207
|
-
})
|
|
1208
|
-
if (backup.path) console.log(` backup ${backup.path}`)
|
|
1209
|
-
|
|
1210
|
-
const { db } = await bootstrapDatabase(config)
|
|
1211
|
-
try {
|
|
1212
|
-
const started = performance.now()
|
|
1213
|
-
const forceIndex = rest.indexOf('--force')
|
|
1214
|
-
const result = await revertRun(db, runId, {
|
|
1215
|
-
resolutions,
|
|
1216
|
-
resolveAll: allFlag as RevertResolution | undefined,
|
|
1217
|
-
force: forceIndex >= 0,
|
|
1218
|
-
nodeId: config.nodeId,
|
|
1219
|
-
backOfficePath: config.backOfficePath,
|
|
1220
|
-
})
|
|
1221
|
-
|
|
1222
|
-
if (!result.runId) {
|
|
1223
|
-
for (const f of result.check.findings) {
|
|
1224
|
-
console.error(` [${f.kind.padEnd(8)}] ${f.message}`)
|
|
1225
|
-
for (const suggestion of revertResolutionsFor(f.code))
|
|
1226
|
-
console.error(` → ${suggestion}`)
|
|
1227
|
-
}
|
|
1228
|
-
console.error('\nrevert refused. Nothing was changed.')
|
|
1229
|
-
process.exit(1)
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
// The files an install replaced come back from the run's own copies, not
|
|
1233
|
-
// from git: these files are editable in the backoffice, so a site need not
|
|
1234
|
-
// be a checkout at all.
|
|
1235
|
-
const files = await ops.revertSections(db, config, runId)
|
|
1236
|
-
for (const file of files.restored) console.log(` restored ${relative(cwd, file)}`)
|
|
1237
|
-
for (const file of files.removed)
|
|
1238
|
-
console.log(` removed ${relative(cwd, file)} (created by that run)`)
|
|
1239
|
-
if (files.schema) console.log(` schema ${files.schema}`)
|
|
1240
|
-
if (files.dictionary) console.log(' restored the dictionary')
|
|
1241
|
-
|
|
1242
|
-
for (const key of result.restored) console.log(` restored ${key}`)
|
|
1243
|
-
for (const key of result.recycled) console.log(` recycled ${key} (created by that run)`)
|
|
1244
|
-
for (const entry of result.check.plan.filter((p) => p.action === 'skip'))
|
|
1245
|
-
console.log(` left ${entry.name}`)
|
|
1246
|
-
await recordInLedger(db, {
|
|
1247
|
-
name: `content revert ${runId}`,
|
|
1248
|
-
kind: 'revert',
|
|
1249
|
-
durationMs: Math.round(performance.now() - started),
|
|
1250
|
-
appliedBy: config.nodeId,
|
|
1251
|
-
note: `run ${result.runId}`,
|
|
1252
|
-
})
|
|
1253
|
-
console.log(
|
|
1254
|
-
`\nReverted ${runId} as run ${result.runId}. ` +
|
|
1255
|
-
`\`crombolo content revert ${result.runId}\` would put it back again.`,
|
|
1256
|
-
)
|
|
1257
|
-
} finally {
|
|
1258
|
-
await db.close()
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
/**
|
|
1263
|
-
* Puts the media files a bundle carries into this environment's store.
|
|
1264
|
-
*
|
|
1265
|
-
* After the content, because the run is what decides whether any of this
|
|
1266
|
-
* happened: a refused import leaves the store alone. The keys are the ones the
|
|
1267
|
-
* values already name, so a picked image finds its file wherever media lives
|
|
1268
|
-
* here — a disk, a bucket, a container.
|
|
1269
|
-
*/
|
|
1270
|
-
function describePlaced(placed: { placed: number; bytes: number } | null): string | undefined {
|
|
1271
|
-
if (!placed) return undefined
|
|
1272
|
-
return `${placed.placed} media file(s) placed, ${describeBytes(placed.bytes)}`
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
/** The flags `check` and `import` share: resolutions, placement, blobs. */
|
|
1276
|
-
function transferInput(bundle: string): ops.TransferInput {
|
|
1277
|
-
const allFlag = flagValue('--resolve-all')
|
|
1278
|
-
if (allFlag !== undefined && !isResolution(allFlag)) {
|
|
1279
|
-
console.error(` "${allFlag}" is not one of ${RESOLUTIONS.join(', ')}`)
|
|
1280
|
-
process.exit(1)
|
|
1281
|
-
}
|
|
1282
|
-
return {
|
|
1283
|
-
bundle,
|
|
1284
|
-
under: flagValue('--under'),
|
|
1285
|
-
resolutions: parseResolutions(),
|
|
1286
|
-
resolveAll: allFlag as Resolution | undefined,
|
|
1287
|
-
allowMissingBlobs: flags.has('--allow-missing-blobs'),
|
|
1288
|
-
publish: flags.has('--publish'),
|
|
1289
|
-
// What a bundle carries is applied by default — that is the point of it
|
|
1290
|
-
// carrying anything — so these decline a half, for someone who wants the
|
|
1291
|
-
// content without the structure that came with it.
|
|
1292
|
-
withoutSchema: flags.has('--no-schema'),
|
|
1293
|
-
withoutFiles: flags.has('--no-files'),
|
|
1294
|
-
replaceFiles: flags.has('--replace-files'),
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
/** What the carried sections would do, or did. */
|
|
1299
|
-
function printSections(plan: ops.SectionPlan | undefined): void {
|
|
1300
|
-
if (!plan) return
|
|
1301
|
-
console.log(` carries ${plan.sections.join(', ')}`)
|
|
1302
|
-
const counts = new Map<string, number>()
|
|
1303
|
-
for (const file of plan.files) counts.set(file.action, (counts.get(file.action) ?? 0) + 1)
|
|
1304
|
-
const summary = [...counts].map(([action, n]) => `${n} ${action}`).join(', ')
|
|
1305
|
-
if (summary) console.log(` files ${summary}`)
|
|
1306
|
-
for (const file of plan.files)
|
|
1307
|
-
if (file.action === 'overwrite') console.log(` overwrites ${relative(cwd, file.target)}`)
|
|
1308
|
-
if (plan.dictionaryItems > 0) console.log(` dict ${plan.dictionaryItems} file(s)`)
|
|
1309
|
-
if (plan.schema) {
|
|
1310
|
-
console.log(` schema ${plan.schema.classification}`)
|
|
1311
|
-
for (const finding of plan.schema.findings) console.log(` ${finding}`)
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
/** Bytes as somebody reads them, since a bundle of media is measured in them. */
|
|
1316
|
-
function describeBytes(bytes: number): string {
|
|
1317
|
-
if (bytes < 1024) return `${bytes} B`
|
|
1318
|
-
const units = ['KB', 'MB', 'GB']
|
|
1319
|
-
let value = bytes / 1024
|
|
1320
|
-
let unit = 0
|
|
1321
|
-
while (value >= 1024 && unit < units.length - 1) {
|
|
1322
|
-
value /= 1024
|
|
1323
|
-
unit += 1
|
|
1324
|
-
}
|
|
1325
|
-
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
function describeBundle(bundle: ops.BundleSummary): void {
|
|
1329
|
-
console.log(
|
|
1330
|
-
` bundle ${bundle.id} from ${bundle.from || 'elsewhere'} ` +
|
|
1331
|
-
`(schema ${bundle.schemaVersion}+${bundle.schemaRevision}, ` +
|
|
1332
|
-
`${bundle.snapshot} snapshot, ${bundle.nodes} node(s))`,
|
|
1333
|
-
)
|
|
1334
|
-
if (bundle.blobsCarried > 0)
|
|
1335
|
-
console.log(
|
|
1336
|
-
` blobs ${bundle.blobsCarried} media file(s) carried, ${describeBytes(bundle.blobBytes)}`,
|
|
1337
|
-
)
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
async function contentImport(config: CromboloConfig): Promise<void> {
|
|
1341
|
-
const dir = positional[1]
|
|
1342
|
-
if (!dir) {
|
|
1343
|
-
console.error(
|
|
1344
|
-
'content import needs a bundle: a package directory, or the name of a bundle this site depends on.',
|
|
1345
|
-
)
|
|
1346
|
-
process.exit(1)
|
|
1347
|
-
}
|
|
1348
|
-
const result = await ops.contentImport(config, {
|
|
1349
|
-
...transferInput(dir),
|
|
1350
|
-
passphrase: await passphraseToOpen(dir, config),
|
|
1351
|
-
publish: flags.has('--publish'),
|
|
1352
|
-
label: flagValue('--label'),
|
|
1353
|
-
backupTaken: flags.has('--backup-taken'),
|
|
1354
|
-
})
|
|
1355
|
-
for (const problem of result.problems) console.error(` ${problem}`)
|
|
1356
|
-
if (result.backup.path) console.log(` backup ${result.backup.path}`)
|
|
1357
|
-
describeBundle(result.bundle)
|
|
1358
|
-
|
|
1359
|
-
if (!result.runId) {
|
|
1360
|
-
printTransferFindings(result.check)
|
|
1361
|
-
// Staged, then applied in one transaction: a refusal leaves nothing behind.
|
|
1362
|
-
console.error(
|
|
1363
|
-
`\nimport refused: ${result.check.outstanding.length || result.problems.length} finding(s) outstanding. Nothing was written.`,
|
|
1364
|
-
)
|
|
1365
|
-
process.exit(1)
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
const c = result.check.counts
|
|
1369
|
-
console.log(` run ${result.runId}`)
|
|
1370
|
-
console.log(
|
|
1371
|
-
` applied ${c.create} created, ${c.update} updated, ${c.unchanged} unchanged, ${c.skip} skipped`,
|
|
1372
|
-
)
|
|
1373
|
-
const placed = describePlaced(result.blobs)
|
|
1374
|
-
if (placed) console.log(` blobs ${placed}`)
|
|
1375
|
-
if (result.sections) {
|
|
1376
|
-
console.log(
|
|
1377
|
-
` files ${result.sections.written.length} written` +
|
|
1378
|
-
(result.sections.replaced.length > 0
|
|
1379
|
-
? `, ${result.sections.replaced.length} replaced`
|
|
1380
|
-
: ''),
|
|
1381
|
-
)
|
|
1382
|
-
if (result.sections.schema) console.log(` schema ${result.sections.schema.action}`)
|
|
1383
|
-
if (result.sections.dictionary)
|
|
1384
|
-
console.log(` dict ${result.sections.dictionary.imported} imported`)
|
|
1385
|
-
// Named, because this is what makes reverting the install possible.
|
|
1386
|
-
if (result.replacedFilesKept)
|
|
1387
|
-
console.log(` kept ${relative(cwd, result.replacedFilesKept)} (the files it replaced)`)
|
|
1388
|
-
}
|
|
1389
|
-
if (result.published > 0) console.log(` published ${result.published} node(s)`)
|
|
1390
|
-
for (const failure of result.publishFailures)
|
|
1391
|
-
console.error(` not published ${failure.key}: ${failure.reason}`)
|
|
1392
|
-
for (const finding of result.check.findings.filter((f) => f.kind === 'auto'))
|
|
1393
|
-
console.log(` [auto ] ${finding.message}`)
|
|
1394
|
-
console.log(`\nDone. \`crombolo content revert ${result.runId}\` puts it back.`)
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
/**
|
|
1398
|
-
* `content publish` / `content unpublish`: taking a page or a branch live, and
|
|
1399
|
-
* back off again, without the backoffice.
|
|
1400
|
-
*
|
|
1401
|
-
* A publish can be refused per node — an unpublished ancestor, an unpublished
|
|
1402
|
-
* mandatory language, an empty mandatory property — so the whole selection is
|
|
1403
|
-
* checked before any of it is written. Half a published branch is a state
|
|
1404
|
-
* nobody asked for and nobody can reason about.
|
|
1405
|
-
*/
|
|
1406
|
-
async function contentPublish(config: CromboloConfig, publishing: boolean): Promise<void> {
|
|
1407
|
-
const ref = positional[1]
|
|
1408
|
-
const verb = publishing ? 'publish' : 'unpublish'
|
|
1409
|
-
if (!ref) {
|
|
1410
|
-
console.error(`content ${verb} needs the page: a path or a uuid.`)
|
|
1411
|
-
process.exit(1)
|
|
1412
|
-
}
|
|
1413
|
-
const cultures = flagValues('--culture')
|
|
1414
|
-
const at = flagValue('--at')
|
|
1415
|
-
const until = flagValue('--until')
|
|
1416
|
-
const when = (value: string | undefined, what: string): Date | null => {
|
|
1417
|
-
if (value === undefined) return null
|
|
1418
|
-
const date = new Date(value)
|
|
1419
|
-
if (Number.isNaN(date.getTime())) {
|
|
1420
|
-
console.error(` ${what} "${value}" is not a date I can read; try 2026-10-01T09:00`)
|
|
1421
|
-
process.exit(1)
|
|
1422
|
-
}
|
|
1423
|
-
return date
|
|
1424
|
-
}
|
|
1425
|
-
const publishTime = when(at, '--at')
|
|
1426
|
-
const unpublishTime = when(until, '--until')
|
|
1427
|
-
|
|
1428
|
-
const { db } = await bootstrapDatabase(config)
|
|
1429
|
-
try {
|
|
1430
|
-
const resolved = await resolveNodeRef(db, [ObjectTypes.Document], ref)
|
|
1431
|
-
if (!resolved.ok) {
|
|
1432
|
-
console.error(` ${describeRefFailure(ref, resolved)}`)
|
|
1433
|
-
process.exit(1)
|
|
1434
|
-
}
|
|
1435
|
-
const state = await currentSchemaState(db)
|
|
1436
|
-
const docs = new DocumentRepository(db, {
|
|
1437
|
-
nodeState: { version: state.version, revision: state.revision },
|
|
1438
|
-
nodeId: config.nodeId,
|
|
1439
|
-
})
|
|
1440
|
-
// Parents before children, which is the order publishing has to happen in.
|
|
1441
|
-
const targets = [
|
|
1442
|
-
resolved.node,
|
|
1443
|
-
...(flags.has('--descendants')
|
|
1444
|
-
? await docs.nodes.descendants(resolved.node, [ObjectTypes.Document])
|
|
1445
|
-
: []),
|
|
1446
|
-
]
|
|
1447
|
-
|
|
1448
|
-
if (publishTime || unpublishTime) {
|
|
1449
|
-
// A schedule is a statement about the future, so today's blockers are not
|
|
1450
|
-
// the question: the background job checks them when the time comes.
|
|
1451
|
-
for (const node of targets) {
|
|
1452
|
-
await docs.setSchedule(node.key, [
|
|
1453
|
-
{ culture: cultures[0] ?? null, publishTime, unpublishTime },
|
|
1454
|
-
])
|
|
1455
|
-
console.log(
|
|
1456
|
-
` scheduled ${node.text ?? node.key}${publishTime ? ` from ${publishTime.toISOString()}` : ''}${unpublishTime ? ` until ${unpublishTime.toISOString()}` : ''}`,
|
|
1457
|
-
)
|
|
1458
|
-
}
|
|
1459
|
-
return
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
if (publishing) {
|
|
1463
|
-
const blocked: Array<{ name: string; reasons: string[] }> = []
|
|
1464
|
-
// Everything in the selection counts as published for the check: the run
|
|
1465
|
-
// publishes parents first, so a child is not blocked by an ancestor this
|
|
1466
|
-
// very command is about to take live.
|
|
1467
|
-
const selection = new Set(targets.map((node) => node.id))
|
|
1468
|
-
for (const node of targets) {
|
|
1469
|
-
const reasons = await docs.publishBlockers(node.key, cultures, selection)
|
|
1470
|
-
if (reasons.length > 0) blocked.push({ name: node.text ?? node.key, reasons })
|
|
1471
|
-
}
|
|
1472
|
-
if (blocked.length > 0) {
|
|
1473
|
-
console.error(
|
|
1474
|
-
` refused ${blocked.length} of ${targets.length} page(s) cannot be published:`,
|
|
1475
|
-
)
|
|
1476
|
-
for (const entry of blocked) console.error(` ${entry.name} ${entry.reasons.join('; ')}`)
|
|
1477
|
-
console.error(' nothing was published')
|
|
1478
|
-
process.exit(1)
|
|
1479
|
-
}
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
for (const node of targets) {
|
|
1483
|
-
const result = publishing
|
|
1484
|
-
? await docs.publish(node.key, cultures.length > 0 ? cultures : null)
|
|
1485
|
-
: await docs.unpublish(node.key, cultures.length > 0 ? cultures : null)
|
|
1486
|
-
if (!result) continue
|
|
1487
|
-
console.log(` ${publishing ? 'published' : 'unpublished'} ${node.text ?? node.key}`)
|
|
1488
|
-
}
|
|
1489
|
-
console.log(
|
|
1490
|
-
`\n${targets.length} page(s) ${publishing ? 'published' : 'unpublished'}${cultures.length > 0 ? ` in ${cultures.join(', ')}` : ''}.`,
|
|
1491
|
-
)
|
|
1492
|
-
} finally {
|
|
1493
|
-
await db.close()
|
|
1494
|
-
}
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
async function contentRuns(config: CromboloConfig): Promise<void> {
|
|
1498
|
-
const limitIndex = rest.indexOf('--limit')
|
|
1499
|
-
const limit = limitIndex >= 0 ? Number(rest[limitIndex + 1]) : 20
|
|
1500
|
-
const { db } = await bootstrapDatabase(config)
|
|
1501
|
-
try {
|
|
1502
|
-
const runs = await new TransferRunRepository(db).recent(
|
|
1503
|
-
Number.isFinite(limit) && limit > 0 ? limit : 20,
|
|
1504
|
-
)
|
|
1505
|
-
if (runs.length === 0) return console.log('No content has been imported here.')
|
|
1506
|
-
for (const run of runs)
|
|
1507
|
-
console.log(
|
|
1508
|
-
` ${run.startedAt.toISOString()} ${run.direction.padEnd(6)} ${run.status.padEnd(8)} ` +
|
|
1509
|
-
`${run.id} ${run.nodeCount} node(s) bundle ${run.bundleId}` +
|
|
1510
|
-
`${run.appliedBy ? ` by ${run.appliedBy}` : ''}${run.bundleLabel ? ` (${run.bundleLabel})` : ''}`,
|
|
1511
|
-
)
|
|
1512
|
-
} finally {
|
|
1513
|
-
await db.close()
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
/** `--resolve <key>=<choice>` or `--resolve <key>/<property>[@<culture>]=<choice>`, repeatable. */
|
|
1518
|
-
function parseResolutions(): Record<string, NodeResolutions> {
|
|
1519
|
-
const parsed = parseResolveFlags(flagValues('--resolve'))
|
|
1520
|
-
if (parsed.problems.length > 0) {
|
|
1521
|
-
for (const problem of parsed.problems) console.error(` ${problem}`)
|
|
1522
|
-
process.exit(1)
|
|
1523
|
-
}
|
|
1524
|
-
return parsed.byNode
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
function printTransferFindings(check: TransferCheck): void {
|
|
1528
|
-
const byKind = { blocking: 0, person: 0, auto: 0 }
|
|
1529
|
-
for (const f of check.findings) byKind[f.kind] += 1
|
|
1530
|
-
const c = check.counts
|
|
1531
|
-
console.log(
|
|
1532
|
-
` plan ${c.create} new, ${c.update} updated, ${c.unchanged} unchanged, ${c.skip} skipped`,
|
|
1533
|
-
)
|
|
1534
|
-
console.log(
|
|
1535
|
-
` findings ${byKind.blocking} blocking, ${byKind.person} need a person, ${byKind.auto} automatic`,
|
|
1536
|
-
)
|
|
1537
|
-
for (const f of check.findings) {
|
|
1538
|
-
console.log(` [${f.kind.padEnd(8)}] ${f.message}`)
|
|
1539
|
-
// The handle is the CLI's to name; the dashboard offers the same answers as choices.
|
|
1540
|
-
if (f.code === 'local-edit' && f.propertyAlias)
|
|
1541
|
-
console.log(
|
|
1542
|
-
` answer it as ${f.subjectKey}/${propertyHandle(f.propertyAlias, f.culture)}`,
|
|
1543
|
-
)
|
|
1544
|
-
for (const suggestion of resolutionsFor(f.code)) console.log(` → ${suggestion}`)
|
|
1545
|
-
if (f.link) console.log(` ${f.link}`)
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
async function contentCheck(config: CromboloConfig): Promise<void> {
|
|
1550
|
-
const dir = positional[1]
|
|
1551
|
-
if (!dir) {
|
|
1552
|
-
console.error(
|
|
1553
|
-
'content check needs a bundle: a package directory, or the name of a bundle this site depends on.',
|
|
1554
|
-
)
|
|
1555
|
-
process.exit(1)
|
|
1556
|
-
}
|
|
1557
|
-
const result = await ops.contentCheck(config, {
|
|
1558
|
-
...transferInput(dir),
|
|
1559
|
-
passphrase: await passphraseToOpen(dir, config),
|
|
1560
|
-
})
|
|
1561
|
-
for (const problem of result.problems) console.error(` ${problem}`)
|
|
1562
|
-
describeBundle(result.bundle)
|
|
1563
|
-
if (result.under) console.log(` under ${result.under}`)
|
|
1564
|
-
printSections(result.sections)
|
|
1565
|
-
printTransferFindings(result.check)
|
|
1566
|
-
// The reasons are publish's own: the check rehearsed the import to find them.
|
|
1567
|
-
for (const refusal of result.publishRefusals)
|
|
1568
|
-
console.error(` would not publish ${refusal.key}: ${refusal.reason}`)
|
|
1569
|
-
|
|
1570
|
-
if (flags.has('--save')) {
|
|
1571
|
-
const target = join(result.payloadDir, RESOLUTIONS_FILE)
|
|
1572
|
-
await writeFile(
|
|
1573
|
-
target,
|
|
1574
|
-
writeResolutions({
|
|
1575
|
-
bundleId: result.bundle.id,
|
|
1576
|
-
under: flagValue('--under') ?? null,
|
|
1577
|
-
all: result.decisions.all,
|
|
1578
|
-
byNode: result.decisions.byNode,
|
|
1579
|
-
}),
|
|
1580
|
-
)
|
|
1581
|
-
console.log(` wrote ${relative(cwd, target)}`)
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
if (!result.clean) {
|
|
1585
|
-
console.log(
|
|
1586
|
-
`\n${result.check.outstanding.length} finding(s) would stop an import. Resolve them, or pass --resolve.`,
|
|
1587
|
-
)
|
|
1588
|
-
process.exit(1)
|
|
1589
|
-
}
|
|
1590
|
-
console.log('\nNothing outstanding: this bundle would import cleanly.')
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
/**
|
|
1594
|
-
* What an export or an import does with property values and media files:
|
|
1595
|
-
* left out, sealed, or — when asked twice — in the open.
|
|
1596
|
-
*/
|
|
1597
|
-
function contentFlags(): { content: 'omitted' | 'sealed' | 'open' } {
|
|
1598
|
-
const include = flags.has('--include-content')
|
|
1599
|
-
const open = flags.has('--allow-content-unencrypted')
|
|
1600
|
-
if (open && !include) {
|
|
1601
|
-
console.error('--allow-content-unencrypted goes with --include-content.')
|
|
1602
|
-
process.exit(1)
|
|
1603
|
-
}
|
|
1604
|
-
if (flags.has('--with-blobs') && !include) {
|
|
1605
|
-
console.error('Media files are content: --with-blobs goes with --include-content.')
|
|
1606
|
-
process.exit(1)
|
|
1607
|
-
}
|
|
1608
|
-
if (open)
|
|
1609
|
-
console.error(
|
|
1610
|
-
' warning property values and media files are written unencrypted: anyone with the files can read any personal information in them',
|
|
1611
|
-
)
|
|
1612
|
-
return { content: !include ? 'omitted' : open ? 'open' : 'sealed' }
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
function describeContentMode(
|
|
1616
|
-
content: 'omitted' | 'sealed' | 'open',
|
|
1617
|
-
people?: { members: number; users: number },
|
|
1618
|
-
): void {
|
|
1619
|
-
if (content === 'omitted')
|
|
1620
|
-
console.log(
|
|
1621
|
-
' content not included: pages are placed and named, and no value is written. Pass --include-content to bring the values and media files',
|
|
1622
|
-
)
|
|
1623
|
-
else if (content === 'sealed') console.log(' content encrypted: importing needs the passphrase')
|
|
1624
|
-
else console.log(' content unencrypted')
|
|
1625
|
-
if (people && people.members + people.users > 0)
|
|
1626
|
-
console.log(` people ${people.members} member(s), ${people.users} user(s), encrypted`)
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
async function contentExport(config: CromboloConfig): Promise<void> {
|
|
1630
|
-
const out = flagValue('--out')
|
|
1631
|
-
const refs = flagValues('--root')
|
|
1632
|
-
if (!out || refs.length === 0) {
|
|
1633
|
-
console.error('content export needs --root <path|uuid> (repeatable) and --out <dir>.')
|
|
1634
|
-
process.exit(1)
|
|
1635
|
-
}
|
|
1636
|
-
const content = contentFlags()
|
|
1637
|
-
const result = await ops.contentExport(config, {
|
|
1638
|
-
...content,
|
|
1639
|
-
roots: refs,
|
|
1640
|
-
only: flags.has('--only'),
|
|
1641
|
-
drafts: flags.has('--drafts'),
|
|
1642
|
-
withBlobs: flags.has('--with-blobs'),
|
|
1643
|
-
withMembers: flags.has('--with-members'),
|
|
1644
|
-
withUsers: flags.has('--with-users'),
|
|
1645
|
-
passphrase:
|
|
1646
|
-
content.content === 'sealed' || flags.has('--with-members') || flags.has('--with-users')
|
|
1647
|
-
? await passphraseToSeal()
|
|
1648
|
-
: undefined,
|
|
1649
|
-
withBlueprints: flags.has('--with-blueprints'),
|
|
1650
|
-
// An export is a private package; without --name it is named for its directory.
|
|
1651
|
-
package: {
|
|
1652
|
-
name: flagValue('--name') ?? packageNameFor(basename(resolve(out))),
|
|
1653
|
-
version: flagValue('--package-version'),
|
|
1654
|
-
},
|
|
1655
|
-
})
|
|
1656
|
-
for (const key of result.blobs.missing)
|
|
1657
|
-
console.error(` missing ${key} is not in this environment's media store`)
|
|
1658
|
-
for (const file of result.files) {
|
|
1659
|
-
const target = join(out, file.path)
|
|
1660
|
-
await mkdir(join(target, '..'), { recursive: true })
|
|
1661
|
-
await writeFile(target, file.bytes ?? (file.text as string))
|
|
1662
|
-
}
|
|
1663
|
-
const counts = Object.entries(result.counts)
|
|
1664
|
-
console.log(` wrote ${relative(cwd, out) || '.'}`)
|
|
1665
|
-
console.log(` package ${result.package.name}@${result.package.version} (private)`)
|
|
1666
|
-
console.log(
|
|
1667
|
-
` bundle ${result.bundleId} (${result.snapshot}): ${
|
|
1668
|
-
counts.length > 0 ? counts.map(([kind, n]) => `${n} ${kind}`).join(', ') : 'nothing selected'
|
|
1669
|
-
}`,
|
|
1670
|
-
)
|
|
1671
|
-
if (result.expected.length > 0) {
|
|
1672
|
-
console.log(` expects ${result.expected.length} node(s) to exist at the destination:`)
|
|
1673
|
-
for (const e of result.expected) console.log(` ${e.key} ${e.name} (${e.why})`)
|
|
1674
|
-
}
|
|
1675
|
-
describeContentMode(result.content, result.people)
|
|
1676
|
-
if (result.blobs.carried > 0)
|
|
1677
|
-
console.log(
|
|
1678
|
-
` blobs ${result.blobs.carried} media file(s), ${describeBytes(result.blobs.bytes)}`,
|
|
1679
|
-
)
|
|
1680
|
-
else if (result.blobs.notCarried > 0)
|
|
1681
|
-
console.log(
|
|
1682
|
-
` blobs ${result.blobs.notCarried} media file(s) are not carried — pass --with-blobs to include them`,
|
|
1683
|
-
)
|
|
1684
|
-
if (counts.length === 0) process.exit(1)
|
|
1685
|
-
}
|
|
1686
|
-
|
|
1687
|
-
/** `crombolo export --site`: the whole site as one file (story 104). */
|
|
1688
|
-
async function exportCommand(): Promise<void> {
|
|
1689
|
-
if (!flags.has('--site')) {
|
|
1690
|
-
console.error(
|
|
1691
|
-
'export needs --site: the whole site, as one file. For a part of it, use content export.',
|
|
1692
|
-
)
|
|
1693
|
-
process.exit(1)
|
|
1694
|
-
}
|
|
1695
|
-
const config = await siteConfig()
|
|
1696
|
-
const withPeople = flags.has('--with-members') || flags.has('--with-users')
|
|
1697
|
-
const anonymise = flags.has('--anonymise')
|
|
1698
|
-
const out = resolve(
|
|
1699
|
-
flagValue('--out') ??
|
|
1700
|
-
`${packageNameFor(`${config.siteName}-site`)}-${new Date().toISOString().slice(0, 10)}.tgz`,
|
|
1701
|
-
)
|
|
1702
|
-
const result = await ops.siteExport(config, {
|
|
1703
|
-
out,
|
|
1704
|
-
media: !flags.has('--no-media'),
|
|
1705
|
-
drafts: flags.has('--drafts'),
|
|
1706
|
-
withMembers: flags.has('--with-members'),
|
|
1707
|
-
withUsers: flags.has('--with-users'),
|
|
1708
|
-
anonymise,
|
|
1709
|
-
passphrase:
|
|
1710
|
-
withPeople && !(anonymise && !flags.has('--with-members'))
|
|
1711
|
-
? await passphraseToSeal()
|
|
1712
|
-
: undefined,
|
|
1713
|
-
package: { name: flagValue('--name'), version: flagValue('--package-version') },
|
|
1714
|
-
})
|
|
1715
|
-
const counts = Object.entries(result.counts)
|
|
1716
|
-
console.log(` wrote ${relative(cwd, result.out) || result.out}`)
|
|
1717
|
-
console.log(` package ${result.package.name}@${result.package.version} (private)`)
|
|
1718
|
-
console.log(
|
|
1719
|
-
` content ${counts.length > 0 ? counts.map(([kind, n]) => `${n} ${kind}`).join(', ') : 'none'}, unencrypted`,
|
|
1720
|
-
)
|
|
1721
|
-
const sections = Object.entries(result.sections)
|
|
1722
|
-
if (sections.length > 0)
|
|
1723
|
-
console.log(` files ${sections.map(([section, n]) => `${n} ${section}`).join(', ')}`)
|
|
1724
|
-
if (result.media.omitted)
|
|
1725
|
-
console.log(
|
|
1726
|
-
' media left out (--no-media): importing it brings the media items without their files',
|
|
1727
|
-
)
|
|
1728
|
-
else
|
|
1729
|
-
console.log(` media ${result.media.carried} file(s), ${describeBytes(result.media.bytes)}`)
|
|
1730
|
-
for (const key of result.media.missing)
|
|
1731
|
-
console.error(` missing ${key} is not in this environment's media store`)
|
|
1732
|
-
if (result.people.members + result.people.users > 0)
|
|
1733
|
-
console.log(
|
|
1734
|
-
` people ${result.people.members} member(s), ${result.people.users} user(s), encrypted`,
|
|
1735
|
-
)
|
|
1736
|
-
else console.log(' people left out: pass --with-members or --with-users to bring them')
|
|
1737
|
-
if (result.anonymised !== undefined)
|
|
1738
|
-
console.log(
|
|
1739
|
-
` anonymised ${result.anonymised} email address(es) replaced; no back-office users`,
|
|
1740
|
-
)
|
|
1741
|
-
}
|
|
1742
|
-
|
|
1743
|
-
/**
|
|
1744
|
-
* `schema-as-code.md` leaves the dictionary in the database, because
|
|
1745
|
-
* translators work in production where `schema/` is read-only — so this is how
|
|
1746
|
-
* a lower environment gets the translations.
|
|
1747
|
-
*/
|
|
1748
|
-
async function dictionary(): Promise<void> {
|
|
1749
|
-
const config = await siteConfig()
|
|
1750
|
-
const sub = positional[0]
|
|
1751
|
-
if (sub !== 'export' && sub !== 'import') return help()
|
|
1752
|
-
const { db } = await bootstrapDatabase(config)
|
|
1753
|
-
try {
|
|
1754
|
-
if (sub === 'export') {
|
|
1755
|
-
const out = flagValue('--out')
|
|
1756
|
-
if (!out) {
|
|
1757
|
-
console.error('dictionary export needs --out <file.udt>.')
|
|
1758
|
-
process.exit(1)
|
|
1759
|
-
}
|
|
1760
|
-
const items = await dictionaryToUdt(new DictionaryRepository(db), flagValue('--key'))
|
|
1761
|
-
if (items.length === 0) {
|
|
1762
|
-
console.error('Nothing to export: this site has no dictionary items.')
|
|
1763
|
-
process.exit(1)
|
|
1764
|
-
}
|
|
1765
|
-
await mkdir(join(out, '..'), { recursive: true })
|
|
1766
|
-
await writeFile(out, writeDictionaryUdt(items))
|
|
1767
|
-
const count = (list: typeof items): number =>
|
|
1768
|
-
list.reduce((total, item) => total + 1 + count(item.children), 0)
|
|
1769
|
-
console.log(` wrote ${relative(cwd, out)}`)
|
|
1770
|
-
console.log(` items ${count(items)}`)
|
|
1771
|
-
return
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
const file = positional[1]
|
|
1775
|
-
if (!file) {
|
|
1776
|
-
console.error('dictionary import needs the .udt file.')
|
|
1777
|
-
process.exit(1)
|
|
1778
|
-
}
|
|
1779
|
-
if (!existsSync(file)) {
|
|
1780
|
-
console.error(`No file at ${file}.`)
|
|
1781
|
-
process.exit(1)
|
|
1782
|
-
}
|
|
1783
|
-
const outcome = await importDictionaryUdt(db, await Bun.file(file).text(), null)
|
|
1784
|
-
if (!outcome.ok) {
|
|
1785
|
-
console.error(` refused: ${outcome.reason}`)
|
|
1786
|
-
process.exit(1)
|
|
1787
|
-
}
|
|
1788
|
-
console.log(` imported ${outcome.imported} item(s)`)
|
|
1789
|
-
// Umbraco's own behaviour, and worth saying out loud rather than dropping
|
|
1790
|
-
// translations silently: a language this site has not got is skipped.
|
|
1791
|
-
if (outcome.skipped.length > 0)
|
|
1792
|
-
console.log(
|
|
1793
|
-
` skipped translations for ${outcome.skipped.join(', ')} — no such language here`,
|
|
1794
|
-
)
|
|
1795
|
-
} finally {
|
|
1796
|
-
await db.close()
|
|
1797
|
-
}
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
/**
|
|
1801
|
-
* `crombolo domains`: which hostnames reach which page.
|
|
1802
|
-
*
|
|
1803
|
-
* `domains.toml` is the truth and the database follows it, so `set` and `clear`
|
|
1804
|
-
* write the file and then apply it — one command, rather than an edit here and
|
|
1805
|
-
* a deploy to make it real. The file is committed; `${VAR}` in a host is read
|
|
1806
|
-
* from the environment when it is applied, so one file serves every
|
|
1807
|
-
* environment.
|
|
1808
|
-
*/
|
|
1809
|
-
async function domains(): Promise<void> {
|
|
1810
|
-
const config = await siteConfig()
|
|
1811
|
-
const sub = positional[0] ?? 'list'
|
|
1812
|
-
if (sub !== 'list' && sub !== 'apply' && sub !== 'set' && sub !== 'clear' && sub !== 'undo')
|
|
1813
|
-
return help()
|
|
1814
|
-
|
|
1815
|
-
const file = domainsFilePath(config.siteDir)
|
|
1816
|
-
const read = readDomainsFile(config.siteDir)
|
|
1817
|
-
const declarations = read?.declarations ?? []
|
|
1818
|
-
for (const problem of read?.problems ?? []) console.error(` ${file}: ${problem}`)
|
|
1819
|
-
|
|
1820
|
-
if (sub === 'list') {
|
|
1821
|
-
const { db } = await bootstrapDatabase(config)
|
|
1822
|
-
try {
|
|
1823
|
-
if (!read) console.log(`No ${DOMAINS_FILE}; nothing is deployed from a file here.`)
|
|
1824
|
-
for (const declaration of declarations) {
|
|
1825
|
-
const resolved = resolvePlaceholders(declaration.host)
|
|
1826
|
-
const culture = declaration.culture ? ` ${declaration.culture}` : ''
|
|
1827
|
-
// What the file says, and what it comes to here, which is the whole
|
|
1828
|
-
// point of a placeholder and the first thing to check when one is wrong.
|
|
1829
|
-
const here =
|
|
1830
|
-
resolved.missing.length > 0
|
|
1831
|
-
? ` (${resolved.missing.join(', ')} not set here)`
|
|
1832
|
-
: resolved.value !== declaration.host
|
|
1833
|
-
? ` → ${resolved.value}`
|
|
1834
|
-
: ''
|
|
1835
|
-
console.log(
|
|
1836
|
-
` file ${declaration.node.padEnd(24)} ${declaration.host}${here}${culture}`,
|
|
1837
|
-
)
|
|
1838
|
-
}
|
|
1839
|
-
// What the database actually answers on, which is the thing a person is
|
|
1840
|
-
// usually asking about when a hostname is not working.
|
|
1841
|
-
const nodes = new NodeRepository(db)
|
|
1842
|
-
for (const row of await new DomainRepository(db).all()) {
|
|
1843
|
-
const node = await nodes.byId(row.nodeId)
|
|
1844
|
-
console.log(
|
|
1845
|
-
` bound ${(node?.text ?? String(row.nodeId)).padEnd(24)} ${row.domainName}${row.isoCode ? ` ${row.isoCode}` : ''}`,
|
|
1846
|
-
)
|
|
1847
|
-
}
|
|
1848
|
-
} finally {
|
|
1849
|
-
await db.close()
|
|
1850
|
-
}
|
|
1851
|
-
return
|
|
1852
|
-
}
|
|
1853
|
-
|
|
1854
|
-
if (sub === 'apply') return applyDomains(config)
|
|
1855
|
-
|
|
1856
|
-
if (sub === 'undo') {
|
|
1857
|
-
const undone = await undoDomainsFile(config.siteDir)
|
|
1858
|
-
if (!undone.ok) {
|
|
1859
|
-
console.error(` ${undone.message}`)
|
|
1860
|
-
process.exit(1)
|
|
1861
|
-
}
|
|
1862
|
-
console.log(` restored ${relative(cwd, undone.file)}`)
|
|
1863
|
-
return applyDomains(config)
|
|
1864
|
-
}
|
|
1865
|
-
|
|
1866
|
-
const ref = positional[1]
|
|
1867
|
-
if (!ref) {
|
|
1868
|
-
console.error(`domains ${sub} needs the page: a path, a uuid, or / for the root page.`)
|
|
1869
|
-
process.exit(1)
|
|
1870
|
-
}
|
|
1871
|
-
|
|
1872
|
-
// Stating a node's hostnames says what they are, as the backoffice's dialog
|
|
1873
|
-
// does: whatever that page had is replaced, so running this twice is the same
|
|
1874
|
-
// as running it once. Which entries belong to that page is decided by
|
|
1875
|
-
// resolving them, not by matching the text — `/` and `/Home` can be the same
|
|
1876
|
-
// page, and clearing one while the other stayed would do nothing at all.
|
|
1877
|
-
const { db: resolving } = await bootstrapDatabase(config)
|
|
1878
|
-
let kept: DomainDeclaration[] = []
|
|
1879
|
-
try {
|
|
1880
|
-
const target = await resolveDomainNode(resolving, ref)
|
|
1881
|
-
if (!target.ok) {
|
|
1882
|
-
console.error(` ${target.message}`)
|
|
1883
|
-
process.exit(1)
|
|
1884
|
-
}
|
|
1885
|
-
kept = await withoutNode(resolving, declarations, target.node)
|
|
1886
|
-
} finally {
|
|
1887
|
-
await resolving.close()
|
|
1888
|
-
}
|
|
1889
|
-
const defaultCulture = flagValue('--default-culture')
|
|
1890
|
-
const next: DomainDeclaration[] = [...kept]
|
|
1891
|
-
if (sub === 'set') {
|
|
1892
|
-
const hosts = flagValues('--host')
|
|
1893
|
-
if (hosts.length === 0 && !defaultCulture) {
|
|
1894
|
-
console.error('domains set needs --host <host>[=<culture>], or --default-culture <iso>.')
|
|
1895
|
-
process.exit(1)
|
|
1896
|
-
}
|
|
1897
|
-
if (hosts.length === 0 && defaultCulture) next.push({ node: ref, host: '', defaultCulture })
|
|
1898
|
-
for (const [index, entry] of hosts.entries()) {
|
|
1899
|
-
const [host = '', culture] = entry.split('=')
|
|
1900
|
-
next.push({
|
|
1901
|
-
node: ref,
|
|
1902
|
-
host,
|
|
1903
|
-
culture,
|
|
1904
|
-
// The node's own fallback culture belongs to the node, so it is written
|
|
1905
|
-
// once rather than repeated on every hostname.
|
|
1906
|
-
defaultCulture: index === 0 ? defaultCulture : undefined,
|
|
1907
|
-
})
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
const written = await writeDomains(config.siteDir, next)
|
|
1912
|
-
console.log(
|
|
1913
|
-
` wrote ${relative(cwd, written.file)}${written.backup ? ' (previous kept; `crombolo domains undo` puts it back)' : ''}`,
|
|
1914
|
-
)
|
|
1915
|
-
return applyDomains(config)
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
/** Applies the file as a boot would, and reports what it did. */
|
|
1919
|
-
async function applyDomains(config: CromboloConfig): Promise<void> {
|
|
1920
|
-
const { db } = await bootstrapDatabase(config)
|
|
1921
|
-
try {
|
|
1922
|
-
const report = await syncDomainsFile(db, config.siteDir)
|
|
1923
|
-
for (const problem of report.problems) console.error(` ${problem}`)
|
|
1924
|
-
for (const applied of report.applied)
|
|
1925
|
-
console.log(
|
|
1926
|
-
` applied ${applied.node.padEnd(24)} ${applied.host}${applied.culture ? ` ${applied.culture}` : ''}`,
|
|
1927
|
-
)
|
|
1928
|
-
if (report.removed > 0) console.log(` removed ${report.removed} binding(s)`)
|
|
1929
|
-
if (report.problems.length > 0) process.exit(1)
|
|
1930
|
-
} finally {
|
|
1931
|
-
await db.close()
|
|
1932
|
-
}
|
|
1933
|
-
}
|
|
1934
|
-
|
|
1935
|
-
/**
|
|
1936
|
-
* `crombolo import <file>`: replace this site with a whole-site file (story
|
|
1937
|
-
* 105). It always says first what would be replaced, and changes nothing
|
|
1938
|
-
* without `--replace` or `--replace-all`.
|
|
1939
|
-
*/
|
|
1940
|
-
async function importFile(file: string): Promise<void> {
|
|
1941
|
-
const config = await siteConfig()
|
|
1942
|
-
const replace: ops.ReplaceMode | undefined = flags.has('--replace-all')
|
|
1943
|
-
? 'replace-all'
|
|
1944
|
-
: flags.has('--replace')
|
|
1945
|
-
? 'replace'
|
|
1946
|
-
: undefined
|
|
1947
|
-
const dir = await ops.unpackBundle(file, config.siteDir)
|
|
1948
|
-
let carriesPeople = false
|
|
1949
|
-
try {
|
|
1950
|
-
carriesPeople = ops.readBundle(dir, config.siteDir).set.manifest.people !== undefined
|
|
1951
|
-
} catch (error) {
|
|
1952
|
-
console.error(` ${error instanceof Error ? error.message : String(error)}`)
|
|
1953
|
-
process.exit(1)
|
|
1954
|
-
}
|
|
1955
|
-
const passphrase = carriesPeople
|
|
1956
|
-
? (process.env[PASSPHRASE_ENV] ??
|
|
1957
|
-
(process.stdin.isTTY ? await readSecret(' Passphrase for its people: ') : undefined))
|
|
1958
|
-
: await passphraseToOpen(dir, config)
|
|
1959
|
-
const allowMissingBlobs = flags.has('--allow-missing-media')
|
|
1960
|
-
|
|
1961
|
-
const { check, summary } = await ops.siteReplaceCheck(config, {
|
|
1962
|
-
bundle: dir,
|
|
1963
|
-
passphrase,
|
|
1964
|
-
replace: replace ?? 'replace',
|
|
1965
|
-
allowMissingBlobs,
|
|
1966
|
-
})
|
|
1967
|
-
describeBundle(check.bundle)
|
|
1968
|
-
const c = check.check.counts
|
|
1969
|
-
console.log(` brings ${c.create} new, ${c.update} changed, ${c.unchanged} unchanged`)
|
|
1970
|
-
const leaving = Object.entries(summary.leaving)
|
|
1971
|
-
const fate = replace === 'replace-all' ? 'deleted' : 'recycled'
|
|
1972
|
-
if (leaving.length > 0)
|
|
1973
|
-
console.log(
|
|
1974
|
-
` ${fate.padEnd(8)} ${leaving.map(([kind, n]) => `${n} ${kind}`).join(', ')} not in the file`,
|
|
1975
|
-
)
|
|
1976
|
-
if (summary.filesRemoved.length > 0) {
|
|
1977
|
-
console.log(
|
|
1978
|
-
` removes ${summary.filesRemoved.length} file(s) not in the file, kept for a revert:`,
|
|
1979
|
-
)
|
|
1980
|
-
for (const path of summary.filesRemoved.slice(0, 20)) console.log(` ${relative(cwd, path)}`)
|
|
1981
|
-
if (summary.filesRemoved.length > 20)
|
|
1982
|
-
console.log(` … and ${summary.filesRemoved.length - 20} more`)
|
|
1983
|
-
}
|
|
1984
|
-
if (replace === 'replace-all' && summary.people.members + summary.people.users > 0)
|
|
1985
|
-
console.log(
|
|
1986
|
-
` deletes ${summary.people.members} member(s) and ${summary.people.users} user(s) not in the file`,
|
|
1987
|
-
)
|
|
1988
|
-
console.log(" keeps domains, the secret key and this environment's settings")
|
|
1989
|
-
for (const problem of check.problems) console.error(` ${problem}`)
|
|
1990
|
-
|
|
1991
|
-
if (!replace) {
|
|
1992
|
-
console.error(
|
|
1993
|
-
'\nNothing was changed. Pass --replace to replace this site (what it loses is recycled, people kept), or --replace-all to make it exactly the file (deleted, people too).',
|
|
1994
|
-
)
|
|
1995
|
-
process.exit(1)
|
|
1996
|
-
}
|
|
1997
|
-
if (replace === 'replace-all' && !flags.has('--yes')) {
|
|
1998
|
-
if (!process.stdin.isTTY) {
|
|
1999
|
-
console.error(
|
|
2000
|
-
'\n--replace-all deletes what the file does not have: pass --yes to confirm it.',
|
|
2001
|
-
)
|
|
2002
|
-
process.exit(1)
|
|
2003
|
-
}
|
|
2004
|
-
const answer = prompt(
|
|
2005
|
-
` This deletes for good. Type the site's name (${config.siteName}) to go on:`,
|
|
2006
|
-
)
|
|
2007
|
-
if (answer?.trim() !== config.siteName) {
|
|
2008
|
-
console.error(' Nothing was changed.')
|
|
2009
|
-
process.exit(1)
|
|
2010
|
-
}
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
|
-
const result = await ops.contentImport(config, {
|
|
2014
|
-
bundle: dir,
|
|
2015
|
-
passphrase,
|
|
2016
|
-
replace,
|
|
2017
|
-
allowMissingBlobs,
|
|
2018
|
-
label: `${replace} from ${basename(file)}`,
|
|
2019
|
-
backupTaken: flags.has('--backup-taken'),
|
|
2020
|
-
})
|
|
2021
|
-
for (const problem of result.problems) console.error(` ${problem}`)
|
|
2022
|
-
if (result.backup.path) console.log(` backup ${result.backup.path}`)
|
|
2023
|
-
if (!result.runId) {
|
|
2024
|
-
printTransferFindings(result.check)
|
|
2025
|
-
console.error('\nreplace refused. Nothing was changed.')
|
|
2026
|
-
process.exit(1)
|
|
2027
|
-
}
|
|
2028
|
-
const counts = result.check.counts
|
|
2029
|
-
console.log(` run ${result.runId}`)
|
|
2030
|
-
console.log(
|
|
2031
|
-
` applied ${counts.create} created, ${counts.update} updated, ${counts.unchanged} unchanged`,
|
|
2032
|
-
)
|
|
2033
|
-
const placed = describePlaced(result.blobs)
|
|
2034
|
-
if (placed) console.log(` media ${placed}`)
|
|
2035
|
-
if (result.sections?.schema) console.log(` schema ${result.sections.schema.action}`)
|
|
2036
|
-
if (result.published > 0) console.log(` published ${result.published} page(s)`)
|
|
2037
|
-
for (const failure of result.publishFailures)
|
|
2038
|
-
console.error(` not published ${failure.key}: ${failure.reason}`)
|
|
2039
|
-
console.log(
|
|
2040
|
-
replace === 'replace-all'
|
|
2041
|
-
? `\nDone. \`crombolo content revert ${result.runId}\` puts the files and the pages it changed back; what was deleted is in the backup.`
|
|
2042
|
-
: `\nDone. \`crombolo content revert ${result.runId}\` puts it back.`,
|
|
2043
|
-
)
|
|
2044
|
-
}
|
|
2045
|
-
|
|
2046
|
-
/**
|
|
2047
|
-
* `crombolo import umbraco`: a compatibility report for an Umbraco backup, and
|
|
2048
|
-
* then a crombolo site written from it.
|
|
2049
|
-
*
|
|
2050
|
-
* Neither subcommand touches a database. `report` reads the backup and prints
|
|
2051
|
-
* what will and will not come across; `apply` writes a site directory — schema
|
|
2052
|
-
* files, view stubs, a content bundle — whose `start` script does the import
|
|
2053
|
-
* through `start --bundle`, the path every other bundle takes.
|
|
2054
|
-
*
|
|
2055
|
-
* The importer is a package of its own, loaded here on demand, so a site that
|
|
2056
|
-
* never imports anything does not carry it or the `.bacpac` reader it needs.
|
|
2057
|
-
*/
|
|
2058
|
-
async function importSite(): Promise<void> {
|
|
2059
|
-
const VALUE_FLAGS = new Set(['--out', '--site', '--media', '--name', '--staging'])
|
|
2060
|
-
const args: string[] = []
|
|
2061
|
-
for (let i = 0; i < rest.length; i++) {
|
|
2062
|
-
const arg = rest[i] as string
|
|
2063
|
-
if (VALUE_FLAGS.has(arg)) i++
|
|
2064
|
-
else if (!arg.startsWith('--')) args.push(arg)
|
|
2065
|
-
}
|
|
2066
|
-
const [kind, sub, backup] = args
|
|
2067
|
-
if (kind && kind !== 'umbraco') return importFile(kind)
|
|
2068
|
-
if (kind !== 'umbraco' || (sub !== 'report' && sub !== 'apply')) return help()
|
|
2069
|
-
// Not into --json output, which a machine reads.
|
|
2070
|
-
if (!flags.has('--json'))
|
|
2071
|
-
console.error(
|
|
2072
|
-
' deprecated `crombolo import umbraco` is going away: an Umbraco extension will write a crombolo file that `crombolo import <file>` reads (story 074)',
|
|
2073
|
-
)
|
|
2074
|
-
if (!backup) {
|
|
2075
|
-
console.error(`import umbraco ${sub} needs the backup: a .bacpac, or Umbraco's SQLite file.`)
|
|
2076
|
-
process.exit(1)
|
|
2077
|
-
}
|
|
2078
|
-
const out = flagValue('--out')
|
|
2079
|
-
if (sub === 'apply' && !out) {
|
|
2080
|
-
console.error('import umbraco apply needs --out <dir>: the site directory to write.')
|
|
2081
|
-
process.exit(1)
|
|
2082
|
-
}
|
|
2083
|
-
|
|
2084
|
-
let importer: typeof import('@crombolo/import-umbraco')
|
|
2085
|
-
try {
|
|
2086
|
-
importer = await import('@crombolo/import-umbraco')
|
|
2087
|
-
} catch {
|
|
2088
|
-
console.error(
|
|
2089
|
-
'The importer is a separate package. Add it, then run this again:\n\n bun add -d @crombolo/import-umbraco',
|
|
2090
|
-
)
|
|
2091
|
-
process.exit(1)
|
|
2092
|
-
}
|
|
2093
|
-
|
|
2094
|
-
const { content } = sub === 'apply' ? contentFlags() : { content: 'omitted' as const }
|
|
2095
|
-
const withMembers = flags.has('--with-members')
|
|
2096
|
-
const withUsers = flags.has('--with-users')
|
|
2097
|
-
const sealing = content === 'sealed' || withMembers || withUsers
|
|
2098
|
-
// A report writes no bundle, so what it would seal with is never kept.
|
|
2099
|
-
const passphrase = !sealing
|
|
2100
|
-
? undefined
|
|
2101
|
-
: sub === 'apply'
|
|
2102
|
-
? await passphraseToSeal()
|
|
2103
|
-
: crypto.randomUUID()
|
|
2104
|
-
let plan: Awaited<ReturnType<typeof importer.planImport>>
|
|
2105
|
-
try {
|
|
2106
|
-
plan = await importer.planImport({
|
|
2107
|
-
source: backup,
|
|
2108
|
-
site: flagValue('--site'),
|
|
2109
|
-
media: flagValue('--media'),
|
|
2110
|
-
siteName: flagValue('--name'),
|
|
2111
|
-
drafts: flags.has('--drafts'),
|
|
2112
|
-
staging: flagValue('--staging'),
|
|
2113
|
-
coreVersion: VERSION,
|
|
2114
|
-
content: sub === 'apply' ? content : flags.has('--include-content') ? 'open' : 'omitted',
|
|
2115
|
-
withMembers,
|
|
2116
|
-
withUsers,
|
|
2117
|
-
passphrase,
|
|
2118
|
-
})
|
|
2119
|
-
} catch (error) {
|
|
2120
|
-
console.error(` ${error instanceof Error ? error.message : error}`)
|
|
2121
|
-
process.exit(1)
|
|
2122
|
-
}
|
|
2123
|
-
const { report } = plan
|
|
2124
|
-
|
|
2125
|
-
if (sub === 'report') {
|
|
2126
|
-
if (flags.has('--json')) console.log(importer.reportJson(report).trimEnd())
|
|
2127
|
-
else for (const line of importer.reportSummary(report)) console.log(` ${line}`)
|
|
2128
|
-
if (out) {
|
|
2129
|
-
await mkdir(out, { recursive: true })
|
|
2130
|
-
await writeFile(join(out, 'report.md'), importer.reportMarkdown(report))
|
|
2131
|
-
await writeFile(join(out, 'report.json'), importer.reportJson(report))
|
|
2132
|
-
if (!flags.has('--json')) console.log(`\n wrote ${relative(cwd, join(out, 'report.md'))}`)
|
|
2133
|
-
}
|
|
2134
|
-
if (!report.ready) process.exit(1)
|
|
2135
|
-
return
|
|
2136
|
-
}
|
|
2137
|
-
|
|
2138
|
-
const target = out as string
|
|
2139
|
-
if (!report.ready) {
|
|
2140
|
-
for (const finding of report.findings.filter((f) => f.class === 'blocking'))
|
|
2141
|
-
console.error(
|
|
2142
|
-
` blocked ${finding.title}${finding.detail ? `\n ${finding.detail}` : ''}`,
|
|
2143
|
-
)
|
|
2144
|
-
console.error('\nNothing was written.')
|
|
2145
|
-
process.exit(1)
|
|
2146
|
-
}
|
|
2147
|
-
if (
|
|
2148
|
-
existsSync(target) &&
|
|
2149
|
-
(await Array.fromAsync(new Bun.Glob('*').scan({ cwd: target, onlyFiles: false, dot: true })))
|
|
2150
|
-
.length > 0 &&
|
|
2151
|
-
!flags.has('--force')
|
|
2152
|
-
) {
|
|
2153
|
-
console.error(
|
|
2154
|
-
`${target} is not empty. Choose a new directory, or pass --force to write into it.`,
|
|
2155
|
-
)
|
|
2156
|
-
process.exit(1)
|
|
2157
|
-
}
|
|
2158
|
-
|
|
2159
|
-
const files = new Map<string, { text?: string; bytes?: Uint8Array; copyFrom?: string }>()
|
|
2160
|
-
const scaffold = scaffoldFiles({
|
|
2161
|
-
siteName: plan.siteName,
|
|
2162
|
-
postgres: flags.has('--postgres'),
|
|
2163
|
-
bundle: {
|
|
2164
|
-
path: importer.IMPORT_BUNDLE,
|
|
2165
|
-
// A media item whose file was not in the backup is reported, not refused.
|
|
2166
|
-
flags: plan.missingMedia > 0 ? ['--allow-missing-blobs'] : [],
|
|
2167
|
-
},
|
|
2168
|
-
})
|
|
2169
|
-
for (const file of scaffold) files.set(file.path, file)
|
|
2170
|
-
// The importer's files win: the source site's media types replace the defaults.
|
|
2171
|
-
for (const file of plan.files) files.set(file.path, file)
|
|
2172
|
-
if (plan.dictionary.length > 0)
|
|
2173
|
-
files.set(`${importer.IMPORT_DIR}/dictionary.udt`, { text: writeUdtAll(plan.dictionary) })
|
|
2174
|
-
if (plan.domains.length > 0)
|
|
2175
|
-
files.set(`${importer.IMPORT_DIR}/domains.toml`, { text: writeDomainsFile(plan.domains) })
|
|
2176
|
-
|
|
2177
|
-
for (const [path, file] of files) {
|
|
2178
|
-
const destination = join(target, path)
|
|
2179
|
-
await mkdir(join(destination, '..'), { recursive: true })
|
|
2180
|
-
if (file.copyFrom !== undefined) await Bun.write(destination, Bun.file(file.copyFrom))
|
|
2181
|
-
else await Bun.write(destination, file.bytes ?? file.text ?? '')
|
|
2182
|
-
}
|
|
2183
|
-
|
|
2184
|
-
for (const line of importer.reportSummary(report)) console.log(` ${line}`)
|
|
2185
|
-
console.log(`\n wrote ${files.size} files to ${relative(cwd, target) || '.'}`)
|
|
2186
|
-
console.log(` report ${relative(cwd, join(target, importer.IMPORT_DIR, 'report.md'))}`)
|
|
2187
|
-
console.log(`\nNext:\n cd ${relative(cwd, target) || '.'}\n bun install\n bun start`)
|
|
2188
|
-
console.log(
|
|
2189
|
-
`\n\`bun start\` imports ${importer.IMPORT_BUNDLE} on the first boot of an empty database.`,
|
|
2190
|
-
)
|
|
2191
|
-
if (sealing)
|
|
2192
|
-
console.log(
|
|
2193
|
-
`It is encrypted: \`bun start\` asks for the passphrase, or reads ${PASSPHRASE_ENV}.`,
|
|
2194
|
-
)
|
|
2195
|
-
if (content === 'omitted')
|
|
2196
|
-
console.log(
|
|
2197
|
-
'Pages and media were brought without what they hold. Run again with --include-content to bring it.',
|
|
2198
|
-
)
|
|
2199
|
-
if (plan.dictionary.length > 0)
|
|
2200
|
-
console.log(`Then: crombolo dictionary import ${importer.IMPORT_DIR}/dictionary.udt`)
|
|
2201
|
-
}
|
|
2202
|
-
|
|
2203
|
-
/**
|
|
2204
|
-
* `crombolo views`: what is on disk, whether it compiles, and a new one.
|
|
2205
|
-
*
|
|
2206
|
-
* A view is loaded when a request renders it, so one that does not compile is a
|
|
2207
|
-
* 500 waiting for a visitor. `components check` is that failure brought forward.
|
|
2208
|
-
*/
|
|
2209
|
-
async function components(): Promise<void> {
|
|
2210
|
-
const config = await siteConfig()
|
|
2211
|
-
const sub = positional[0] ?? 'check'
|
|
2212
|
-
if (sub !== 'check' && sub !== 'list' && sub !== 'new') return help()
|
|
2213
|
-
|
|
2214
|
-
const loaded = existsSync(config.schemaDir)
|
|
2215
|
-
? loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
|
|
2216
|
-
: undefined
|
|
2217
|
-
const types = loaded?.set.contentTypes ?? []
|
|
2218
|
-
|
|
2219
|
-
if (sub === 'list') {
|
|
2220
|
-
const found = listComponents(config.componentsDir)
|
|
2221
|
-
if (found.length === 0) console.log(`Nothing in ${relative(cwd, config.componentsDir) || '.'}.`)
|
|
2222
|
-
for (const component of found) {
|
|
2223
|
-
// A component is a template because a content type names it, not
|
|
2224
|
-
// because of where it sits — so the label is read off the schema.
|
|
2225
|
-
const declaredBy = types
|
|
2226
|
-
.filter((type) => type.components.includes(component.alias))
|
|
2227
|
-
.map((type) => type.alias)
|
|
2228
|
-
const kind = declaredBy.length > 0 ? 'template' : 'component'
|
|
2229
|
-
const note = declaredBy.length > 0 ? ` ← ${declaredBy.join(', ')}` : ''
|
|
2230
|
-
console.log(` ${kind.padEnd(9)} ${relative(cwd, component.path)}${note}`)
|
|
2231
|
-
}
|
|
2232
|
-
return
|
|
2233
|
-
}
|
|
2234
|
-
|
|
2235
|
-
if (sub === 'new') {
|
|
2236
|
-
const alias = positional[1]
|
|
2237
|
-
if (!alias) {
|
|
2238
|
-
console.error('components new needs a name: `crombolo components new pages/article`.')
|
|
2239
|
-
process.exit(1)
|
|
2240
|
-
}
|
|
2241
|
-
if (!isSafeAlias(alias)) {
|
|
2242
|
-
console.error(`"${alias}" is not a path inside ${relative(cwd, config.componentsDir)}.`)
|
|
2243
|
-
process.exit(1)
|
|
2244
|
-
}
|
|
2245
|
-
const file = componentFileFor(config.componentsDir, alias)
|
|
2246
|
-
if (existsSync(file)) {
|
|
2247
|
-
console.error(`${relative(cwd, file)} already exists.`)
|
|
2248
|
-
process.exit(1)
|
|
2249
|
-
}
|
|
2250
|
-
// A content type that names this alias gets the page scaffold, which takes
|
|
2251
|
-
// PageProps; anything else gets the plain one. Nothing turns on the folder.
|
|
2252
|
-
const type = types.find((candidate) => candidate.components.includes(alias))
|
|
2253
|
-
if (!type)
|
|
2254
|
-
console.log(
|
|
2255
|
-
` note no content type declares the template "${alias}" — add it to one's templates, or \`crombolo schema new\``,
|
|
2256
|
-
)
|
|
2257
|
-
await mkdir(join(file, '..'), { recursive: true })
|
|
2258
|
-
await writeFile(file, type ? viewScaffold(type) : componentScaffold(alias))
|
|
2259
|
-
console.log(` created ${relative(cwd, file)}`)
|
|
2260
|
-
return
|
|
2261
|
-
}
|
|
2262
|
-
|
|
2263
|
-
const report = await checkComponents({
|
|
2264
|
-
componentsDir: config.componentsDir,
|
|
2265
|
-
siteDir: config.siteDir,
|
|
2266
|
-
declaredComponents: types.flatMap((type) => type.components),
|
|
2267
|
-
})
|
|
2268
|
-
const templates = report.declared.length
|
|
2269
|
-
console.log(
|
|
2270
|
-
` checked ${report.components.length} file(s): ${templates} template(s), ` +
|
|
2271
|
-
`${report.components.length - templates} component(s)`,
|
|
2272
|
-
)
|
|
2273
|
-
for (const problem of report.problems)
|
|
2274
|
-
console.error(` [error] ${problem.file}: ${problem.message}`)
|
|
2275
|
-
if (report.types.note) console.log(` types ${report.types.note}`)
|
|
2276
|
-
else {
|
|
2277
|
-
console.log(` types tsc --noEmit over ${relative(cwd, config.siteDir) || '.'}`)
|
|
2278
|
-
for (const problem of report.types.problems) console.error(` [types] ${problem}`)
|
|
2279
|
-
}
|
|
2280
|
-
if (report.problems.length > 0 || report.types.problems.length > 0) process.exit(1)
|
|
2281
|
-
}
|
|
2282
|
-
|
|
2283
|
-
/** `crombolo assets`: the stylesheets and scripts the backoffice also edits. */
|
|
2284
|
-
async function assets(): Promise<void> {
|
|
2285
|
-
const config = await siteConfig()
|
|
2286
|
-
const sub = positional[0] ?? 'list'
|
|
2287
|
-
if (sub !== 'list' && sub !== 'new') return help()
|
|
2288
|
-
|
|
2289
|
-
if (sub === 'list') {
|
|
2290
|
-
const found = listAssets(config)
|
|
2291
|
-
if (found.length === 0) console.log('No stylesheets or scripts here.')
|
|
2292
|
-
for (const asset of found)
|
|
2293
|
-
console.log(` ${asset.kind.padEnd(10)} ${relative(cwd, asset.path)}`)
|
|
2294
|
-
return
|
|
2295
|
-
}
|
|
2296
|
-
|
|
2297
|
-
const kind = positional[1]
|
|
2298
|
-
const name = positional[2]
|
|
2299
|
-
if ((kind !== 'stylesheet' && kind !== 'script') || !name) {
|
|
2300
|
-
console.error('assets new needs a kind and a name: `crombolo assets new stylesheet site.css`.')
|
|
2301
|
-
process.exit(1)
|
|
2302
|
-
}
|
|
2303
|
-
const where = assetDirs(config)[kind]
|
|
2304
|
-
const file = join(where.dir, name.endsWith(where.extension) ? name : `${name}${where.extension}`)
|
|
2305
|
-
if (existsSync(file)) {
|
|
2306
|
-
console.error(`${relative(cwd, file)} already exists.`)
|
|
2307
|
-
process.exit(1)
|
|
2308
|
-
}
|
|
2309
|
-
await mkdir(join(file, '..'), { recursive: true })
|
|
2310
|
-
await writeFile(file, assetScaffold(kind, relative(where.dir, file)))
|
|
2311
|
-
console.log(` created ${relative(cwd, file)}`)
|
|
2312
|
-
}
|
|
2313
|
-
|
|
2314
|
-
function parseSet(): Record<string, unknown> {
|
|
2315
|
-
const out: Record<string, unknown> = {}
|
|
2316
|
-
for (let i = 0; i < rest.length; i++) {
|
|
2317
|
-
if (rest[i] !== '--set') continue
|
|
2318
|
-
const pair = rest[i + 1] ?? ''
|
|
2319
|
-
const eq = pair.indexOf('=')
|
|
2320
|
-
if (eq < 0) continue
|
|
2321
|
-
const raw = pair.slice(eq + 1)
|
|
2322
|
-
let value: unknown = raw
|
|
2323
|
-
try {
|
|
2324
|
-
value = JSON.parse(raw)
|
|
2325
|
-
} catch {}
|
|
2326
|
-
out[pair.slice(0, eq)] = value
|
|
2327
|
-
}
|
|
2328
|
-
return out
|
|
2329
|
-
}
|
|
2330
|
-
|
|
2331
|
-
function printFindings(report: { classification: string; findings: Finding[] }): void {
|
|
2332
|
-
const byKind = { blocking: 0, person: 0, auto: 0 }
|
|
2333
|
-
for (const f of report.findings) byKind[f.kind] += 1
|
|
2334
|
-
console.log(
|
|
2335
|
-
`change: ${report.classification}; findings: ${byKind.blocking} blocking, ${byKind.person} need a person, ${byKind.auto} automatic`,
|
|
2336
|
-
)
|
|
2337
|
-
for (const f of report.findings) {
|
|
2338
|
-
const where = f.link ? ` ${f.link}` : ''
|
|
2339
|
-
console.log(` [${f.kind.padEnd(8)}] ${f.message}${where}`)
|
|
2340
|
-
}
|
|
2341
|
-
}
|
|
2342
|
-
|
|
2343
|
-
async function upgrade(): Promise<void> {
|
|
2344
|
-
const config = await siteConfig()
|
|
2345
|
-
switch (positional[0]) {
|
|
2346
|
-
case 'ledger': {
|
|
2347
|
-
const db = await connect({ file: config.sqliteFile })
|
|
2348
|
-
try {
|
|
2349
|
-
for (const row of await readLedger(db))
|
|
2350
|
-
console.log(
|
|
2351
|
-
` ${row.appliedAt} ${row.kind.padEnd(8)} ${row.bundle ? `[${row.bundle}] ` : ''}${row.name} ${row.durationMs}ms${row.appliedBy ? ` by ${row.appliedBy}` : ''}${row.note ? ` (${row.note})` : ''}`,
|
|
2352
|
-
)
|
|
2353
|
-
} finally {
|
|
2354
|
-
await db.close()
|
|
2355
|
-
}
|
|
2356
|
-
return
|
|
2357
|
-
}
|
|
2358
|
-
case 'check': {
|
|
2359
|
-
if (!flags.has('--fix')) {
|
|
2360
|
-
const report = await ops.upgradeCheck(config, { set: parseSet() })
|
|
2361
|
-
for (const bundle of report.bundles)
|
|
2362
|
-
if (bundle.pending.length > 0)
|
|
2363
|
-
console.log(
|
|
2364
|
-
` bundle ${bundle.id}: ${bundle.pending.map((step) => `${step.name} (${step.kind})`).join(', ')}`,
|
|
2365
|
-
)
|
|
2366
|
-
printFindings(report)
|
|
2367
|
-
if (!report.siteChecked)
|
|
2368
|
-
console.log(
|
|
2369
|
-
'The site schema is checked once the framework migrations have run (`crombolo upgrade`).',
|
|
2370
|
-
)
|
|
2371
|
-
if (report.blocking) process.exit(1)
|
|
2372
|
-
return
|
|
2373
|
-
}
|
|
2374
|
-
const fix = await ops.upgradeFix(config, {
|
|
2375
|
-
set: parseSet(),
|
|
2376
|
-
backupTaken: flags.has('--backup-taken'),
|
|
2377
|
-
})
|
|
2378
|
-
if (fix.backup.path) console.log(` backup ${fix.backup.path}`)
|
|
2379
|
-
console.log(
|
|
2380
|
-
` sync ${fix.sync.action}${fix.sync.deferred.length > 0 ? ` (deferred to the cut-over: ${fix.sync.deferred.join(', ')})` : ''}`,
|
|
2381
|
-
)
|
|
2382
|
-
for (const name of fix.applied) console.log(` applied ${name}`)
|
|
2383
|
-
if (fix.skipped > 0)
|
|
2384
|
-
console.log(` skipped ${fix.skipped} value(s) a converter refused; see the findings`)
|
|
2385
|
-
printFindings(fix)
|
|
2386
|
-
return
|
|
2387
|
-
}
|
|
2388
|
-
case 'schema': {
|
|
2389
|
-
positional[0] = 'rewrite'
|
|
2390
|
-
return schema()
|
|
2391
|
-
}
|
|
2392
|
-
case undefined: {
|
|
2393
|
-
if (flags.has('--plan')) {
|
|
2394
|
-
const plan = await ops.upgradePlan(config)
|
|
2395
|
-
if (plan.steps.length === 0) console.log('No framework migration is pending.')
|
|
2396
|
-
const printStep = (step: ops.PlannedStepResult, indent = '') => {
|
|
2397
|
-
console.log(
|
|
2398
|
-
`\n${indent}== ${step.name} (${step.kind}${step.release ? `, release ${step.release}` : ''})`,
|
|
2399
|
-
)
|
|
2400
|
-
if (step.unplannable) console.log(`${indent} unplannable: ${step.unplannable}`)
|
|
2401
|
-
for (const sql of step.statements) console.log(`${indent} ${sql};`)
|
|
2402
|
-
}
|
|
2403
|
-
for (const step of plan.steps) printStep(step)
|
|
2404
|
-
// After core's, in the order `bundles` lists them, because that is the order they run.
|
|
2405
|
-
for (const bundle of plan.bundles) {
|
|
2406
|
-
console.log(
|
|
2407
|
-
`\nbundle ${bundle.id} (${bundle.name}): ${bundle.steps.length === 0 ? 'nothing pending' : `${bundle.steps.length} step(s)`}`,
|
|
2408
|
-
)
|
|
2409
|
-
for (const step of bundle.steps) printStep(step, ' ')
|
|
2410
|
-
}
|
|
2411
|
-
console.log(
|
|
2412
|
-
plan.site.checked
|
|
2413
|
-
? `\nsite schema: ${plan.site.action} (${plan.site.classification})`
|
|
2414
|
-
: '\nsite schema: checked once the framework migrations have run',
|
|
2415
|
-
)
|
|
2416
|
-
return
|
|
2417
|
-
}
|
|
2418
|
-
const forceIndex = rest.indexOf('--force')
|
|
2419
|
-
const result = await ops.upgradeRun(config, {
|
|
2420
|
-
force: forceIndex >= 0 ? rest[forceIndex + 1] : undefined,
|
|
2421
|
-
set: parseSet(),
|
|
2422
|
-
backupTaken: flags.has('--backup-taken'),
|
|
2423
|
-
})
|
|
2424
|
-
if (result.backup.path) console.log(` backup ${result.backup.path}`)
|
|
2425
|
-
if (result.action === 'refused') {
|
|
2426
|
-
console.error(`upgrade refused: ${result.reasons.length} outstanding finding(s)`)
|
|
2427
|
-
for (const f of result.reasons)
|
|
2428
|
-
console.error(` [${f.kind.padEnd(8)}] ${f.message}${f.link ? ` ${f.link}` : ''}`)
|
|
2429
|
-
console.error(
|
|
2430
|
-
'Run `crombolo upgrade check --fix`, resolve what needs a person, and try again.',
|
|
2431
|
-
)
|
|
2432
|
-
process.exit(1)
|
|
2433
|
-
}
|
|
2434
|
-
if (result.action === 'nothing')
|
|
2435
|
-
return console.log('Nothing to upgrade: the database is at this version.')
|
|
2436
|
-
for (const name of result.frameworkApplied) console.log(` applied ${name}`)
|
|
2437
|
-
for (const bundle of result.bundlesApplied)
|
|
2438
|
-
for (const name of bundle.applied) console.log(` applied [${bundle.id}] ${name}`)
|
|
2439
|
-
console.log(
|
|
2440
|
-
` schema ${result.schema?.action} ${result.schema?.version ?? ''}+${result.schema?.revision ?? ''}`,
|
|
2441
|
-
)
|
|
2442
|
-
return
|
|
2443
|
-
}
|
|
2444
|
-
default:
|
|
2445
|
-
return help()
|
|
2446
|
-
}
|
|
2447
|
-
}
|
|
2448
|
-
|
|
2449
|
-
/**
|
|
2450
|
-
* `bundle purge <id>`: drops a bundle's tables and forgets its state. Taking a
|
|
2451
|
-
* bundle out of `bundles` leaves them in place, so re-adding it resumes; this is
|
|
2452
|
-
* the deliberate act that does not.
|
|
2453
|
-
*/
|
|
2454
|
-
async function bundlePurge(): Promise<void> {
|
|
2455
|
-
const config = await siteConfig()
|
|
2456
|
-
const id = positional[1]
|
|
2457
|
-
if (!id) {
|
|
2458
|
-
console.error('bundle purge needs the bundle id.')
|
|
2459
|
-
process.exit(1)
|
|
2460
|
-
}
|
|
2461
|
-
const plan = await ops.bundlePurgePlan(config, id)
|
|
2462
|
-
if (plan.tables.length === 0) console.log(` The bundle "${id}" has no tables in this database.`)
|
|
2463
|
-
else {
|
|
2464
|
-
console.log(` This drops ${plan.tables.length} table(s), and every row in them:`)
|
|
2465
|
-
for (const table of plan.tables) console.log(` ${table}`)
|
|
2466
|
-
}
|
|
2467
|
-
if (!flags.has('--yes')) {
|
|
2468
|
-
const answer = prompt(` Purge the bundle "${id}"? Type its id to confirm:`)
|
|
2469
|
-
if (answer?.trim() !== id) {
|
|
2470
|
-
console.log(' Nothing was dropped.')
|
|
2471
|
-
process.exit(1)
|
|
2472
|
-
}
|
|
2473
|
-
}
|
|
2474
|
-
const result = await ops.bundlePurge(config, id, { backupTaken: flags.has('--backup-taken') })
|
|
2475
|
-
if (result.backup.path) console.log(` backup ${result.backup.path}`)
|
|
2476
|
-
for (const table of result.dropped) console.log(` dropped ${table}`)
|
|
2477
|
-
console.log(` The bundle "${id}"'s state is cleared; adding it again starts its tables afresh.`)
|
|
2478
|
-
}
|
|
2479
|
-
|
|
2480
|
-
/**
|
|
2481
|
-
* `maintenance`: the cluster-wide pause on editing that a restore or a swap
|
|
2482
|
-
* needs. It is a database flag, so it reaches every node on its next write.
|
|
2483
|
-
*/
|
|
2484
|
-
async function maintenance(): Promise<void> {
|
|
2485
|
-
const config = await siteConfig()
|
|
2486
|
-
switch (positional[0]) {
|
|
2487
|
-
case 'pause': {
|
|
2488
|
-
const paused = await ops.pauseEditing(config, { reason: flagValue('--reason') })
|
|
2489
|
-
console.log(`Editing paused${paused.reason ? `: ${paused.reason}` : ''}.`)
|
|
2490
|
-
return
|
|
2491
|
-
}
|
|
2492
|
-
case 'resume':
|
|
2493
|
-
await ops.resumeEditing(config)
|
|
2494
|
-
console.log('Editing resumed.')
|
|
2495
|
-
return
|
|
2496
|
-
case 'status': {
|
|
2497
|
-
const paused = await ops.editingPaused(config)
|
|
2498
|
-
if (flags.has('--json')) {
|
|
2499
|
-
console.log(JSON.stringify({ paused: paused ?? null }, null, 2))
|
|
2500
|
-
return
|
|
2501
|
-
}
|
|
2502
|
-
console.log(
|
|
2503
|
-
paused
|
|
2504
|
-
? `Editing paused since ${paused.at}${paused.by ? ` by ${paused.by}` : ''}${paused.reason ? `: ${paused.reason}` : ''}.`
|
|
2505
|
-
: 'Editing is not paused.',
|
|
2506
|
-
)
|
|
2507
|
-
return
|
|
2508
|
-
}
|
|
2509
|
-
default:
|
|
2510
|
-
return help()
|
|
2511
|
-
}
|
|
2512
|
-
}
|
|
2513
|
-
|
|
2514
|
-
function help(): void {
|
|
2515
|
-
console.log(`crombolo <command>
|
|
2516
|
-
|
|
2517
|
-
start [--keep-admin] boot the site in the current directory
|
|
2518
|
-
[--bundle <pkg>] apply a bundle's content first, once per bundle, then serve;
|
|
2519
|
-
takes the \`content import\` flags (--publish, --under, …)
|
|
2520
|
-
[--from <file>] in an empty folder: scaffold a site, install it, import a
|
|
2521
|
-
whole-site file (crombolo export --site) and serve it; the
|
|
2522
|
-
new administrator's password is printed once
|
|
2523
|
-
[--postgres] [--name <site name>] [--no-install]
|
|
2524
|
-
init [--force] [--name <site name>] [--postgres] [--template <id>]
|
|
2525
|
-
scaffold a site here; --template list prints the
|
|
2526
|
-
starter templates, which bring schema, views and content
|
|
2527
|
-
status [--json] what this environment resolved to, and what is wrong with
|
|
2528
|
-
it; non-zero when something would stop the site working
|
|
2529
|
-
admin reset-password [pw] reset the administrator's password
|
|
2530
|
-
schema check [--static] validate schema/ and report what a sync would do
|
|
2531
|
-
schema sync [--force-retire-types]
|
|
2532
|
-
apply schema/ to the database
|
|
2533
|
-
schema export write the database's definitions as schema/ files
|
|
2534
|
-
schema rewrite rewrite every schema/ file canonically
|
|
2535
|
-
schema new <content-type|media-type|member-type> <alias>
|
|
2536
|
-
write the type file, with keys, and the view it points at
|
|
2537
|
-
[--name <name>] [--description <text>] [--icon <icon>]
|
|
2538
|
-
[--at-root] [--element] [--tab <tab>] [--no-view]
|
|
2539
|
-
schema add-property <type> <alias> --type <data type>
|
|
2540
|
-
append a property to that type and apply it
|
|
2541
|
-
[--name <name>] [--mandatory] [--tab <tab>] [--dry-run]
|
|
2542
|
-
schema purge --older-than N delete properties retired more than N days ago, with their values
|
|
2543
|
-
generate schema/content-types.d.ts from schema/
|
|
2544
|
-
components check compile every component, and report one a type declares with no file
|
|
2545
|
-
components list what is in components/, and which type declares each
|
|
2546
|
-
components new <alias> components/<alias>.tsx; the alias may carry folders
|
|
2547
|
-
assets list the stylesheets and scripts this site has
|
|
2548
|
-
assets new <stylesheet|script> <name>
|
|
2549
|
-
write an empty one in the place the backoffice edits
|
|
2550
|
-
export --site [--out <file.tgz>]
|
|
2551
|
-
the whole site in one file: schema, components, styles,
|
|
2552
|
-
scripts, dictionary, every page and media item and their
|
|
2553
|
-
files. Domains and people stay behind
|
|
2554
|
-
[--no-media] [--drafts] [--with-members] [--with-users]
|
|
2555
|
-
[--anonymise] replaces every personal value as it writes
|
|
2556
|
-
content export --root <path|uuid> --out <dir>
|
|
2557
|
-
write a private bundle package: the subtree, its
|
|
2558
|
-
dependencies and what the destination must already have
|
|
2559
|
-
[--only] [--drafts] [--with-blueprints]
|
|
2560
|
-
[--name <npm name>] [--package-version <version>]
|
|
2561
|
-
Names and the tree only, unless [--include-content]:
|
|
2562
|
-
values, media files [--with-blobs] and protected-page
|
|
2563
|
-
usernames, encrypted under a passphrase (prompted, or
|
|
2564
|
-
CROMBOLO_BUNDLE_PASSPHRASE); [--allow-content-unencrypted]
|
|
2565
|
-
writes them readable. [--with-members] [--with-users]
|
|
2566
|
-
add people, always encrypted
|
|
2567
|
-
A bundle is an npm package. <pkg> is its directory, or the name of one this
|
|
2568
|
-
site depends on.
|
|
2569
|
-
content check <pkg> what installing its content here would do, and what it
|
|
2570
|
-
needs from a person first; writes to the Changes dashboard
|
|
2571
|
-
[--under <path|uuid>] [--resolve <key>=take-bundle|keep-local|skip]
|
|
2572
|
-
[--resolve <key>/<property>[@<culture>]=take-bundle|keep-local]
|
|
2573
|
-
[--resolve-all <choice>] [--allow-missing-blobs] [--save]
|
|
2574
|
-
[--publish: also say which pages the import would not publish]
|
|
2575
|
-
An encrypted bundle asks for its passphrase, or reads
|
|
2576
|
-
CROMBOLO_BUNDLE_PASSPHRASE
|
|
2577
|
-
content import <pkg> re-check, back up, then apply: documents and elements
|
|
2578
|
-
arrive as drafts, media is live. One transaction, so a
|
|
2579
|
-
failure leaves nothing behind
|
|
2580
|
-
[--publish] [--label <text>] [--backup-taken] + the check flags
|
|
2581
|
-
[--no-schema] [--no-files] decline a section it carries
|
|
2582
|
-
[--replace-files] allow replacing files this site has;
|
|
2583
|
-
refused without it, and the originals are kept for a revert
|
|
2584
|
-
bundle check <pkg> the same as content check
|
|
2585
|
-
bundle install <pkg> the same as content import; a bundle carrying its own
|
|
2586
|
-
schema and views installs into a site that has neither
|
|
2587
|
-
content runs [--limit N] the imports applied here, newest first
|
|
2588
|
-
content publish <path|uuid> take it live; --descendants for the branch below it,
|
|
2589
|
-
--culture <iso> per culture, --at/--until to schedule
|
|
2590
|
-
content unpublish <path|uuid>
|
|
2591
|
-
take it down; the same flags
|
|
2592
|
-
content revert <run-id> put an import back: values and published state restored,
|
|
2593
|
-
nodes it created unpublished and recycled. Itself a run,
|
|
2594
|
-
so it can be reverted in turn
|
|
2595
|
-
[--resolve <key>=discard|skip] [--resolve-all <choice>]
|
|
2596
|
-
[--force] [--backup-taken]
|
|
2597
|
-
dictionary export --out <file.udt> [--key <uuid>]
|
|
2598
|
-
the dictionary as Umbraco's .udt, to seed another environment
|
|
2599
|
-
dictionary import <file.udt>
|
|
2600
|
-
apply one: items upsert by key, and a translation for a
|
|
2601
|
-
language this site has not got is skipped
|
|
2602
|
-
domains the hostnames domains.toml declares and what is bound here
|
|
2603
|
-
domains set <path|uuid|/> --host <host>[=<culture>] [--host ...] [--default-culture <iso>]
|
|
2604
|
-
write domains.toml and apply it; the page's hostnames are
|
|
2605
|
-
replaced, and \${VAR} in a host is read from the environment
|
|
2606
|
-
domains clear <path|uuid|/> unbind that page, in the file and in the database
|
|
2607
|
-
domains apply apply domains.toml here, as a boot does
|
|
2608
|
-
domains undo put back the file the last write replaced, and apply it
|
|
2609
|
-
import <file> [--replace | --replace-all]
|
|
2610
|
-
replace this site with a whole-site file. Says first what
|
|
2611
|
-
would be replaced, and changes nothing without a mode:
|
|
2612
|
-
--replace recycles content and media the file lacks and
|
|
2613
|
-
keeps this site's people; --replace-all deletes them, and
|
|
2614
|
-
people the file lacks (typed confirmation, or --yes).
|
|
2615
|
-
Domains, the secret key and settings always stay.
|
|
2616
|
-
Undone with \`content revert <run>\`
|
|
2617
|
-
[--allow-missing-media] [--backup-taken]
|
|
2618
|
-
import umbraco report <backup> [--site <dir>] [--media <dir>] [--out <dir>] [--json]
|
|
2619
|
-
read-only: what importing an Umbraco backup would bring,
|
|
2620
|
-
and what it would not — packages, Razor, custom code.
|
|
2621
|
-
<backup> is a .bacpac or Umbraco's SQLite file; --site is
|
|
2622
|
-
the site's files, for its views, media and plugins.
|
|
2623
|
-
Non-zero when something blocks the import
|
|
2624
|
-
import umbraco apply <backup> --out <dir> [--site <dir>] [--media <dir>]
|
|
2625
|
-
write a crombolo site: schema, view stubs, media and a
|
|
2626
|
-
content bundle that \`bun start\` imports on first boot
|
|
2627
|
-
[--name <site name>] [--drafts] [--postgres] [--force]
|
|
2628
|
-
[--include-content] [--allow-content-unencrypted]
|
|
2629
|
-
[--with-members] [--with-users], as content export
|
|
2630
|
-
upgrade check [--set t.p=v] what the deployed schema/ would do to this database's data
|
|
2631
|
-
upgrade check --fix back up; apply the additive part and every conversion early
|
|
2632
|
-
upgrade [--force <reason>] re-check, back up, apply framework steps, cut over, ledger
|
|
2633
|
-
upgrade --plan the DDL pending framework migrations would run
|
|
2634
|
-
upgrade ledger print migration_history
|
|
2635
|
-
bundle purge <id> [--yes] back up, then drop a bundle's tables and clear its state;
|
|
2636
|
-
refused while crombolo.config.ts still wires it
|
|
2637
|
-
maintenance pause [--reason <text>]
|
|
2638
|
-
refuse every editor's save, on every node, until resumed;
|
|
2639
|
-
readers are unaffected and no node drains
|
|
2640
|
-
maintenance resume let editors save again
|
|
2641
|
-
maintenance status [--json] whether editing is paused, by whom and why
|
|
2642
|
-
(Postgres: --backup-taken or CROMBOLO_PG_DUMP before anything that writes)`)
|
|
2643
|
-
}
|
|
2644
|
-
|
|
2645
|
-
try {
|
|
2646
|
-
// Before dispatching, which rewrites `bundle install` as `content import`.
|
|
2647
|
-
const audited = commandAudit()
|
|
2648
|
-
await dispatch()
|
|
2649
|
-
if (audited) await ops.recordCommand(await siteConfig(), audited)
|
|
2650
|
-
} catch (error) {
|
|
2651
|
-
// These already say what happened and what to do; a stack trace would bury it.
|
|
2652
|
-
if (error instanceof MigrationStepError || error instanceof UpgradeStateError) {
|
|
2653
|
-
console.error(error.message)
|
|
2654
|
-
process.exit(1)
|
|
2655
|
-
}
|
|
2656
|
-
if (!(error instanceof ops.CommandError)) throw error
|
|
2657
|
-
for (const problem of error.problems) console.error(` ${problem}`)
|
|
2658
|
-
console.error(error.message)
|
|
2659
|
-
process.exit(1)
|
|
2660
|
-
}
|
|
2661
|
-
|
|
2662
|
-
// A declaration, not a const: the top-level dispatch above runs before this line.
|
|
2663
|
-
function capital(word: string): string {
|
|
2664
|
-
return `${word.charAt(0).toUpperCase()}${word.slice(1)}`
|
|
2665
|
-
}
|
|
2666
|
-
|
|
2667
|
-
/** The audit log entry for a command that changes the environment; undefined for one that only reads. */
|
|
2668
|
-
function commandAudit(): ops.CommandAudit | undefined {
|
|
2669
|
-
if (flags.has('--dry-run') || flags.has('--plan') || flags.has('--help')) return undefined
|
|
2670
|
-
const sub = positional[0]
|
|
2671
|
-
const typed = `crombolo ${command}${sub ? ` ${sub}` : ''}`
|
|
2672
|
-
const entry = (
|
|
2673
|
-
category: ops.CommandAudit['category'],
|
|
2674
|
-
action: string,
|
|
2675
|
-
entityType?: string,
|
|
2676
|
-
entityKey?: string,
|
|
2677
|
-
): ops.CommandAudit => ({ category, action, command: typed, entityType, entityKey })
|
|
2678
|
-
switch (command) {
|
|
2679
|
-
case 'admin':
|
|
2680
|
-
return sub === 'reset-password' ? entry('user', 'ResetPassword', 'User') : undefined
|
|
2681
|
-
case 'schema':
|
|
2682
|
-
if (sub === 'sync') return entry('schema', 'Sync', 'Schema')
|
|
2683
|
-
return sub === 'purge' ? entry('schema', 'Purge', 'Schema') : undefined
|
|
2684
|
-
case 'content':
|
|
2685
|
-
case 'bundle':
|
|
2686
|
-
if (sub === 'purge') return entry('system', 'Purge', 'Bundle', positional[1])
|
|
2687
|
-
if (sub === 'import' || sub === 'install') return entry('content', 'Import', 'Bundle')
|
|
2688
|
-
return sub === 'publish' || sub === 'unpublish' || sub === 'revert'
|
|
2689
|
-
? entry('content', capital(sub), 'Document', positional[1])
|
|
2690
|
-
: undefined
|
|
2691
|
-
case 'dictionary':
|
|
2692
|
-
return sub === 'import' ? entry('settings', 'Import', 'DictionaryItem') : undefined
|
|
2693
|
-
case 'domains':
|
|
2694
|
-
return sub && sub !== 'list'
|
|
2695
|
-
? entry('settings', `${capital(sub)}Domains`, 'Domain')
|
|
2696
|
-
: undefined
|
|
2697
|
-
case 'upgrade':
|
|
2698
|
-
if (sub === undefined) return entry('system', 'Upgrade')
|
|
2699
|
-
return sub === 'check' && flags.has('--fix') ? entry('system', 'UpgradeFix') : undefined
|
|
2700
|
-
case 'maintenance':
|
|
2701
|
-
if (sub === 'pause') return entry('system', 'PauseEditing')
|
|
2702
|
-
return sub === 'resume' ? entry('system', 'ResumeEditing') : undefined
|
|
2703
|
-
default:
|
|
2704
|
-
return undefined
|
|
2705
|
-
}
|
|
2706
|
-
}
|
|
2707
|
-
|
|
2708
|
-
async function dispatch(): Promise<void> {
|
|
2709
|
-
switch (command) {
|
|
2710
|
-
case 'start':
|
|
2711
|
-
await start()
|
|
2712
|
-
break
|
|
2713
|
-
case 'init':
|
|
2714
|
-
await init()
|
|
2715
|
-
break
|
|
2716
|
-
case 'status':
|
|
2717
|
-
await status()
|
|
2718
|
-
break
|
|
2719
|
-
case 'admin':
|
|
2720
|
-
await admin()
|
|
2721
|
-
break
|
|
2722
|
-
case 'schema':
|
|
2723
|
-
await schema()
|
|
2724
|
-
break
|
|
2725
|
-
case 'generate':
|
|
2726
|
-
await generate()
|
|
2727
|
-
break
|
|
2728
|
-
case 'content':
|
|
2729
|
-
await content()
|
|
2730
|
-
break
|
|
2731
|
-
/**
|
|
2732
|
-
* The artifact's own name. A bundle that carries its own structure is what
|
|
2733
|
-
* the Packages section builds (`docs/bundles.md`), and `install` reads
|
|
2734
|
-
* better than `import` for one — but it is the same operation on the same
|
|
2735
|
-
* artifact, so this is the same code path, not a second one.
|
|
2736
|
-
*/
|
|
2737
|
-
case 'bundle':
|
|
2738
|
-
if (positional[0] === 'purge') {
|
|
2739
|
-
await bundlePurge()
|
|
2740
|
-
break
|
|
2741
|
-
}
|
|
2742
|
-
if (positional[0] === 'install') positional[0] = 'import'
|
|
2743
|
-
await content()
|
|
2744
|
-
break
|
|
2745
|
-
case 'dictionary':
|
|
2746
|
-
await dictionary()
|
|
2747
|
-
break
|
|
2748
|
-
case 'domains':
|
|
2749
|
-
await domains()
|
|
2750
|
-
break
|
|
2751
|
-
case 'components':
|
|
2752
|
-
await components()
|
|
2753
|
-
break
|
|
2754
|
-
case 'assets':
|
|
2755
|
-
await assets()
|
|
2756
|
-
break
|
|
2757
|
-
case 'export':
|
|
2758
|
-
await exportCommand()
|
|
2759
|
-
break
|
|
2760
|
-
case 'import':
|
|
2761
|
-
await importSite()
|
|
2762
|
-
break
|
|
2763
|
-
case 'upgrade':
|
|
2764
|
-
await upgrade()
|
|
2765
|
-
break
|
|
2766
|
-
case 'maintenance':
|
|
2767
|
-
await maintenance()
|
|
2768
|
-
break
|
|
2769
|
-
default:
|
|
2770
|
-
help()
|
|
2771
|
-
}
|
|
2772
|
-
}
|
|
9
|
+
await main(import.meta.url)
|