@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.
Files changed (85) hide show
  1. package/bin/crombolo.ts +4 -2767
  2. package/oclif.manifest.json +6710 -0
  3. package/package.json +58 -7
  4. package/src/base.ts +129 -0
  5. package/src/classify.ts +119 -0
  6. package/src/clispec.ts +163 -0
  7. package/src/commands/admin/reset-password.ts +53 -0
  8. package/src/commands/assets/index.ts +6 -0
  9. package/src/commands/assets/list.ts +27 -0
  10. package/src/commands/assets/new.ts +42 -0
  11. package/src/commands/bundle/purge.ts +71 -0
  12. package/src/commands/components/check.ts +58 -0
  13. package/src/commands/components/index.ts +6 -0
  14. package/src/commands/components/list.ts +40 -0
  15. package/src/commands/components/new.ts +49 -0
  16. package/src/commands/content/check.ts +102 -0
  17. package/src/commands/content/export.ts +116 -0
  18. package/src/commands/content/import.ts +109 -0
  19. package/src/commands/content/publish.ts +21 -0
  20. package/src/commands/content/revert.ts +137 -0
  21. package/src/commands/content/runs.ts +62 -0
  22. package/src/commands/content/unpublish.ts +21 -0
  23. package/src/commands/dictionary/export.ts +49 -0
  24. package/src/commands/dictionary/import.ts +46 -0
  25. package/src/commands/domains/apply.ts +23 -0
  26. package/src/commands/domains/clear.ts +27 -0
  27. package/src/commands/domains/index.ts +6 -0
  28. package/src/commands/domains/list.ts +86 -0
  29. package/src/commands/domains/set.ts +55 -0
  30. package/src/commands/domains/undo.ts +29 -0
  31. package/src/commands/export.ts +100 -0
  32. package/src/commands/generate.ts +33 -0
  33. package/src/commands/import/index.ts +175 -0
  34. package/src/commands/import/umbraco/apply.ts +128 -0
  35. package/src/commands/import/umbraco/report.ts +56 -0
  36. package/src/commands/init.ts +76 -0
  37. package/src/commands/login.ts +38 -0
  38. package/src/commands/logout.ts +36 -0
  39. package/src/commands/maintenance/pause.ts +29 -0
  40. package/src/commands/maintenance/resume.ts +20 -0
  41. package/src/commands/maintenance/status.ts +34 -0
  42. package/src/commands/media/fetch.ts +62 -0
  43. package/src/commands/pull.ts +210 -0
  44. package/src/commands/push.ts +108 -0
  45. package/src/commands/remote/add.ts +78 -0
  46. package/src/commands/remote/job.ts +53 -0
  47. package/src/commands/remote/list.ts +41 -0
  48. package/src/commands/remote/remove.ts +26 -0
  49. package/src/commands/schema.ts +32 -0
  50. package/src/commands/start.ts +64 -0
  51. package/src/commands/status.ts +103 -0
  52. package/src/commands/types/add-property.ts +116 -0
  53. package/src/commands/types/check.ts +88 -0
  54. package/src/commands/types/export.ts +32 -0
  55. package/src/commands/types/new.ts +116 -0
  56. package/src/commands/types/purge.ts +65 -0
  57. package/src/commands/types/rewrite.ts +30 -0
  58. package/src/commands/types/sync.ts +66 -0
  59. package/src/commands/upgrade/check.ts +69 -0
  60. package/src/commands/upgrade/index.ts +120 -0
  61. package/src/commands/upgrade/ledger.ts +55 -0
  62. package/src/commands/whoami.ts +41 -0
  63. package/src/errors.ts +90 -0
  64. package/src/main.ts +23 -0
  65. package/src/manifest.ts +20 -0
  66. package/src/reference.ts +118 -0
  67. package/src/remote/client.ts +297 -0
  68. package/src/remote/remotes-file.ts +81 -0
  69. package/src/remote/transfer.ts +353 -0
  70. package/src/support/components.ts +7 -0
  71. package/src/support/describe.ts +179 -0
  72. package/src/support/domains.ts +89 -0
  73. package/src/support/flags.ts +157 -0
  74. package/src/support/prompt.ts +126 -0
  75. package/src/support/publish.ts +142 -0
  76. package/src/support/remote.ts +58 -0
  77. package/src/support/serve.ts +460 -0
  78. package/src/support/site.ts +26 -0
  79. package/src/support/types.ts +112 -0
  80. package/src/support/umbraco.ts +86 -0
  81. package/templates/basic/bundle/content/bundle.json +1 -1
  82. package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
  83. package/templates/basic/bundle/package.json +1 -1
  84. package/templates/demo/harbourstone/bundle/package.json +1 -1
  85. package/templates/starter/bundle/package.json +1 -1
@@ -0,0 +1,460 @@
1
+ /**
2
+ * Serving a site: the boot with its retries, the bundles a start applies, the
3
+ * signals that stop it, and `start --from`, which makes a site out of a file.
4
+ * `crombolo start` and `crombolo pull` both end here.
5
+ */
6
+ import { existsSync } from 'node:fs'
7
+ import { mkdir, writeFile } from 'node:fs/promises'
8
+ import { basename, join, resolve } from 'node:path'
9
+ import { AuthStore, resetAdminPassword } from '@crombolo/auth'
10
+ import { readSetupState, recordInLedger, TransferRunRepository, writeReport } from '@crombolo/data'
11
+ import {
12
+ assertPortAvailable,
13
+ type BootFailure,
14
+ backupBefore,
15
+ bootFailurePage,
16
+ bootRetryDelays,
17
+ bootstrapDatabase,
18
+ type CromboloConfig,
19
+ createServer,
20
+ describeBootFailure,
21
+ describeDatabase,
22
+ listeningBodyLimit,
23
+ mediaStoreFor,
24
+ placeBlobs,
25
+ type ServerHandle,
26
+ } from '@crombolo/server'
27
+ import { importBundle } from '@crombolo/transfer'
28
+ import { CliError } from '../errors.ts'
29
+ import * as ops from '../operations/commands.ts'
30
+ import { scaffoldFiles } from '../templates.ts'
31
+ import { banner, describePlaced, describeSync, printTransferFindings } from './describe.ts'
32
+ import { type TransferFlagValues, transferInput } from './flags.ts'
33
+ import { PASSPHRASE_ENV, passphraseForPeople, passphraseToOpen } from './prompt.ts'
34
+ import { cwd, hasSite, type Io, siteConfig } from './site.ts'
35
+
36
+ /** How long a shutdown gets before it is abandoned and the process leaves anyway. */
37
+ const SHUTDOWN_MS = 10_000
38
+
39
+ /**
40
+ * Stops the site on SIGINT and SIGTERM.
41
+ *
42
+ * Without a handler neither signal does anything at all in a container: `bun` is
43
+ * PID 1 there, and the kernel drops a signal PID 1 has registered no handler
44
+ * for. That is why Ctrl-C did nothing under `docker compose run` — the keystroke
45
+ * reaches the container's own terminal, which raises a SIGINT nothing was
46
+ * listening for — and why `docker stop` sat out its ten-second grace and then
47
+ * resorted to SIGKILL. Outside a container the default would at least have
48
+ * killed the process, so this was invisible there.
49
+ *
50
+ * `server.close()` is the point of it either way: the job runner, the event
51
+ * hubs, the component watcher, the poller and the database all get to stop,
52
+ * rather than being taken away mid-write. Connections are closed with the
53
+ * listener because the live-update sockets are long-lived, and waiting for a
54
+ * socket that is meant to stay open would be waiting for ever.
55
+ *
56
+ * A second signal, or a close that will not finish, leaves immediately.
57
+ */
58
+ function stopOnSignal(
59
+ listening: { stop: (closeActiveConnections?: boolean) => void },
60
+ current: () => ServerHandle | undefined,
61
+ ): void {
62
+ let stopping = false
63
+ const stop = (signal: string) => {
64
+ // 130 is what a shell reports for a SIGINT death, and what this was.
65
+ if (stopping) process.exit(130)
66
+ stopping = true
67
+ process.stderr.write(`\nStopping (${signal})…\n`)
68
+ setTimeout(() => process.exit(130), SHUTDOWN_MS).unref()
69
+ void (async () => {
70
+ listening.stop(true)
71
+ const server = current()
72
+ if (server) await server.close()
73
+ process.exit(0)
74
+ })()
75
+ }
76
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => stop(signal))
77
+ }
78
+
79
+ export interface StartOptions extends TransferFlagValues {
80
+ keepAdmin: boolean
81
+ bundles: string[]
82
+ label?: string
83
+ backupTaken: boolean
84
+ }
85
+
86
+ /** What a started site answers on, and the sign-in a development start printed. */
87
+ export interface Started {
88
+ backoffice: string | null
89
+ site: string | null
90
+ role: string
91
+ admin: { username: string; password: string } | null
92
+ setupPending: boolean
93
+ bundles: string[]
94
+ }
95
+
96
+ /** Boots the site and says how to reach it; it serves until a signal stops it. */
97
+ export async function serveSite(io: Io, options: StartOptions): Promise<Started> {
98
+ const config = await siteConfig()
99
+ // Before the migration and the seed, so a clash with the container stack is
100
+ // reported in a second rather than after the database work.
101
+ await assertPortAvailable(config.port).catch((error: unknown) => {
102
+ throw new CliError('unavailable', error instanceof Error ? error.message : String(error))
103
+ })
104
+ const { listening, server, bundles } = await bootWithRetries(io, config, options)
105
+ const seededAdminPassword = server.seededAdminPassword
106
+ const backoffice =
107
+ config.role !== 'web' ? `${listening.url}${config.backOfficePath.slice(1)}` : null
108
+ const site = config.role !== 'api' ? String(listening.url) : null
109
+ const started: Started = {
110
+ backoffice,
111
+ site,
112
+ role: config.role,
113
+ admin: null,
114
+ setupPending: false,
115
+ bundles,
116
+ }
117
+
118
+ const lines = ['Crombolo is running', '']
119
+ // What this node actually answers, so a split deployment's banner does not
120
+ // advertise an address that 404s here.
121
+ if (backoffice) lines.push(` backoffice ${backoffice}`)
122
+ if (site) lines.push(` site ${site}`)
123
+ if (config.role !== 'all') lines.push(` role ${config.role}`)
124
+ lines.push('')
125
+
126
+ // A `web` node owns nothing and writes nothing at boot: resetting the password
127
+ // here would be a write from the node whose whole point is not to make them,
128
+ // and would end the sessions of editors signed in to the api node.
129
+ if (config.role === 'web') {
130
+ // Nothing to print: this node has no sign-in.
131
+ } else if (!config.development) {
132
+ // Production never touches an existing administrator at start: a restart an
133
+ // attacker forced must not reset, re-enable or sign anyone out.
134
+ if ((await readSetupState(server.db)) === 'pending') {
135
+ started.setupPending = true
136
+ lines.push(
137
+ ' Set up your new site at',
138
+ ` ${backoffice}/setup`,
139
+ config.setupCode
140
+ ? ' with the code in CROMBOLO_SETUP_CODE.'
141
+ : ' First set CROMBOLO_SETUP_CODE (16+ characters) and restart.',
142
+ '',
143
+ )
144
+ }
145
+ } else if (options.keepAdmin) {
146
+ if (seededAdminPassword) {
147
+ started.admin = { username: config.adminLogin, password: seededAdminPassword }
148
+ lines.push(
149
+ ' Sign in with',
150
+ ` username ${config.adminLogin}`,
151
+ ` password ${seededAdminPassword}`,
152
+ '',
153
+ )
154
+ }
155
+ } else {
156
+ const admin = await resetAdminPassword(
157
+ new AuthStore(server.db),
158
+ config.adminLogin,
159
+ config.adminPassword,
160
+ )
161
+ if (admin) {
162
+ started.admin = { username: admin.login, password: admin.password }
163
+ lines.push(
164
+ ' Sign in with',
165
+ ` username ${admin.login}`,
166
+ ` password ${admin.password}`,
167
+ '',
168
+ )
169
+ lines.push(
170
+ config.adminPassword
171
+ ? ' (from CROMBOLO_ADMIN_PASSWORD)'
172
+ : ' A fresh password is generated on every start; set',
173
+ )
174
+ if (!config.adminPassword)
175
+ lines.push(' CROMBOLO_ADMIN_PASSWORD to keep one, or pass --keep-admin.')
176
+ if (admin.sessionsEnded > 0)
177
+ lines.push(` Ended ${admin.sessionsEnded} session(s): the password changed.`)
178
+ }
179
+ }
180
+ const components = server.health().components
181
+ lines.push(
182
+ ` database ${describeDatabase(config)}`,
183
+ ` components ${config.componentsDir}${components.hash ? ` generation ${components.hash.slice(0, 8)}` : ''}`,
184
+ )
185
+ for (const line of bundles) lines.push(line)
186
+ const report = server.schema.report
187
+ if (report) lines.push(` schema ${describeSync(report)}`)
188
+ if (server.schema.compatibilityMode)
189
+ lines.push(' ⚠ database is ahead of schema/: reads only, writes refused with 409')
190
+ banner(io, lines)
191
+ if (seededAdminPassword) io.say('Database created and seeded.')
192
+ return started
193
+ }
194
+
195
+ /**
196
+ * Listens at once, serving a page that says why the site is not up yet, and
197
+ * retries the boot with backoff: a database that comes up after the server is
198
+ * picked up without a restart. After the last attempt it exits, so the
199
+ * platform's restart policy and alerts take over. docs/operations.md.
200
+ */
201
+ async function bootWithRetries(io: Io, config: CromboloConfig, options: StartOptions) {
202
+ const attempts = Math.max(1, Number(Bun.env.CROMBOLO_BOOT_ATTEMPTS ?? 10) || 10)
203
+ const delays = bootRetryDelays(attempts)
204
+ let failure: BootFailure | undefined
205
+ let progress = { attempt: 1, attempts, retryInSeconds: undefined as number | undefined }
206
+ const holding = (request: Request) => {
207
+ if (new URL(request.url).pathname === '/health')
208
+ return Response.json(
209
+ { ok: false, starting: true, problem: failure?.title ?? null },
210
+ { status: 503 },
211
+ )
212
+ return new Response(bootFailurePage(failure, progress), {
213
+ status: 503,
214
+ headers: { 'content-type': 'text/html; charset=utf-8', 'retry-after': '10' },
215
+ })
216
+ }
217
+ const listening = Bun.serve({
218
+ port: config.port,
219
+ maxRequestBodySize: listeningBodyLimit(config),
220
+ fetch: holding,
221
+ websocket: { message() {} },
222
+ })
223
+ let current: ServerHandle | undefined
224
+ // Registered the moment it listens, so there is no window, retries included,
225
+ // in which it answers requests and cannot be asked to stop.
226
+ stopOnSignal(listening, () => current)
227
+ for (let attempt = 1; ; attempt++) {
228
+ let server: ServerHandle | undefined
229
+ try {
230
+ server = await createServer(config)
231
+ current = server
232
+ // After the schema sync inside `createServer`, which is what gives the
233
+ // bundle content types to land on, and before anything is served.
234
+ const bundles = await importStartupBundles(io, server, config, options)
235
+ listening.reload(server.serveOptions as never)
236
+ return { listening, server, bundles }
237
+ } catch (error) {
238
+ current = undefined
239
+ await server?.close().catch(() => {})
240
+ failure = describeBootFailure(error, describeDatabase(config))
241
+ const delay = delays[attempt - 1]
242
+ io.warning(
243
+ `crombolo: ${failure.title}. ${failure.detail}\n To fix it: ${failure.remedy}\n (${error instanceof Error ? error.message : String(error)})`,
244
+ )
245
+ if (delay === undefined || error instanceof ops.CommandError || error instanceof CliError) {
246
+ progress = { attempt, attempts, retryInSeconds: undefined }
247
+ listening.stop(true)
248
+ if (error instanceof ops.CommandError || error instanceof CliError) throw error
249
+ throw new CliError('unavailable', `Gave up after ${attempt} attempt(s): ${failure.title}.`)
250
+ }
251
+ progress = { attempt, attempts, retryInSeconds: Math.round(delay / 1000) }
252
+ if (attempt === 1) io.warning(` ${listening.url} explains this while it retries.`)
253
+ io.warning(
254
+ ` Trying again in ${Math.round(delay / 1000)}s (attempt ${attempt} of ${attempts}).`,
255
+ )
256
+ await Bun.sleep(delay)
257
+ }
258
+ }
259
+ }
260
+
261
+ /**
262
+ * `start --bundle <dir>`, repeatable: apply a bundle before the site serves
263
+ * anything, taking the flags `content import` takes.
264
+ *
265
+ * Imported once per bundle — a run still standing here means this site already
266
+ * has that content — so a restart costs a query and a line in the banner. It
267
+ * runs after the schema sync, which is what gives the bundle its content types,
268
+ * and before anything is served. A refusal stops the boot: a site missing the
269
+ * content the command line asked for is worse than a site that did not come up.
270
+ */
271
+ async function importStartupBundles(
272
+ io: Io,
273
+ server: ServerHandle,
274
+ config: CromboloConfig,
275
+ options: StartOptions,
276
+ ): Promise<string[]> {
277
+ if (options.bundles.length === 0) return []
278
+
279
+ const lines: string[] = []
280
+ const db = server.db
281
+ let imported = false
282
+ for (const target of options.bundles) {
283
+ let resolved: ops.ResolvedBundle
284
+ try {
285
+ resolved = ops.readBundle(target, config.siteDir, await passphraseToOpen(target, config))
286
+ } catch (error) {
287
+ if (!(error instanceof ops.CommandError)) throw error
288
+ throw new CliError('refused', `${target} cannot be read. The site has not started.`, {
289
+ details: [error.message, ...error.problems],
290
+ })
291
+ }
292
+ const set = resolved.set
293
+ const standing = await new TransferRunRepository(db).appliedFor(set.manifest.id)
294
+ if (standing) {
295
+ lines.push(` bundle ${set.manifest.id} already imported (run ${standing.id})`)
296
+ continue
297
+ }
298
+
299
+ // Nothing to roll back to on a database this boot created, so the backup
300
+ // rule — which on Postgres means refusing without one — applies only to a
301
+ // site that already had content.
302
+ if (!server.seededAdminPassword) {
303
+ const backup = await backupBefore(config, {
304
+ reason: 'content-import',
305
+ backupTaken: options.backupTaken,
306
+ })
307
+ if (backup.path) io.progress(` backup ${backup.path}`)
308
+ }
309
+
310
+ const prepared = await ops.transferOptions(db, config, resolved, transferInput(target, options))
311
+ for (const problem of prepared.problems) io.warning(` ${problem}`)
312
+ const started = performance.now()
313
+ const result = await importBundle(db, set, {
314
+ ...prepared.options,
315
+ // The administrator this boot created stands in until the site's own arrive.
316
+ adoptUsers: Boolean(server.seededAdminPassword),
317
+ publish: options.publish,
318
+ nodeId: config.nodeId,
319
+ label: options.label,
320
+ package: { name: resolved.package.packageName, version: resolved.package.version },
321
+ })
322
+ await writeReport(db, result.check.findings, { source: 'transfer', scope: set.manifest.id })
323
+ if (!result.runId) {
324
+ printTransferFindings(io, result.check)
325
+ throw new CliError(
326
+ 'refused',
327
+ `${target} was refused: ${result.check.outstanding.length} finding(s) outstanding. Nothing was written, and the site has not started.`,
328
+ )
329
+ }
330
+ const c = result.check.counts
331
+ const placed = describePlaced(
332
+ resolved.blobs.size > 0
333
+ ? await placeBlobs(await mediaStoreFor(config), resolved.blobs, resolved.openBlob)
334
+ : null,
335
+ )
336
+ lines.push(
337
+ ` bundle ${set.manifest.id}: ${c.create} created, ${c.update} updated` +
338
+ `${result.published.length > 0 ? `, ${result.published.length} published` : ''} (run ${result.runId})`,
339
+ )
340
+ if (placed) lines.push(` blobs ${placed}`)
341
+ const people = result.people.filter((p) => p.kind === 'member' || p.kind === 'user')
342
+ if (people.length > 0)
343
+ lines.push(
344
+ ` 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`,
345
+ )
346
+ for (const finding of result.check.findings.filter((f) => f.code === 'user-adopted'))
347
+ lines.push(` admin ${finding.message}`)
348
+ for (const failure of result.publishFailures)
349
+ io.warning(` not published ${failure.key}: ${failure.reason}`)
350
+ await recordInLedger(db, {
351
+ name: `content import ${set.manifest.id}`,
352
+ kind: 'transfer',
353
+ durationMs: Math.round(performance.now() - started),
354
+ appliedBy: config.nodeId,
355
+ note: `run ${result.runId}, ${resolved.package.packageName}@${resolved.package.version} (start --bundle)`,
356
+ })
357
+ imported = true
358
+ }
359
+ // The content landed under a cache this process had already constructed.
360
+ if (imported) server.cache.invalidate()
361
+ return lines
362
+ }
363
+
364
+ export interface FromFileOptions {
365
+ name?: string
366
+ postgres: boolean
367
+ install: boolean
368
+ allowMissingMedia: boolean
369
+ /** A pull already holds the passphrase it sealed the file with. */
370
+ passphrase?: string
371
+ }
372
+
373
+ /**
374
+ * `start --from <file>` (story 105): an empty folder in, a working site out —
375
+ * scaffolded as `init` scaffolds one, its packages installed, and the file
376
+ * imported as a replace so nothing of the scaffold's own starting point stays.
377
+ * The new administrator's password is printed here, once.
378
+ */
379
+ export async function startFromFile(
380
+ io: Io,
381
+ from: string,
382
+ options: FromFileOptions,
383
+ ): Promise<{ runId: string; admin: { username: string; password: string } | null }> {
384
+ const file = resolve(cwd(), from)
385
+ if (!existsSync(file)) throw new CliError('not_found', `${from} does not exist.`)
386
+ if (hasSite())
387
+ throw new CliError('conflict', 'There is a site here already.', {
388
+ hint: '`crombolo import <file> --replace` replaces it; start --from starts a new one in an empty folder.',
389
+ })
390
+ const dir = await ops.unpackBundle(file, cwd())
391
+ const manifest = ops.readBundle(dir, cwd()).set.manifest
392
+ if (manifest.scope !== 'site')
393
+ throw new CliError('usage', `${from} is not a whole-site file.`, {
394
+ hint: 'Write one with `crombolo export --site`.',
395
+ })
396
+ const sealed = manifest.people !== undefined || manifest.content === 'sealed'
397
+ const passphrase = options.passphrase ?? (sealed ? await passphraseForPeople() : undefined)
398
+ if (sealed && !passphrase)
399
+ throw new CliError('auth', `${from} is sealed.`, {
400
+ hint: `Set ${PASSPHRASE_ENV} to its passphrase.`,
401
+ })
402
+
403
+ for (const scaffolded of scaffoldFiles({
404
+ siteName: options.name ?? manifest.provenance.siteName,
405
+ postgres: options.postgres,
406
+ })) {
407
+ const target = join(cwd(), scaffolded.path)
408
+ if (existsSync(target)) continue
409
+ await mkdir(join(target, '..'), { recursive: true })
410
+ if (scaffolded.copyFrom === undefined) await writeFile(target, scaffolded.text ?? '')
411
+ else await Bun.write(target, Bun.file(scaffolded.copyFrom))
412
+ }
413
+ io.progress(` scaffolded a site for ${manifest.provenance.siteName || 'the file'}`)
414
+ if (options.install) {
415
+ const install = Bun.spawnSync(['bun', 'install'], {
416
+ cwd: cwd(),
417
+ stdout: 'inherit',
418
+ stderr: 'inherit',
419
+ })
420
+ if (install.exitCode !== 0)
421
+ throw new CliError('internal', '`bun install` failed.', {
422
+ hint: 'Run it, then `crombolo import <file> --replace`.',
423
+ })
424
+ }
425
+
426
+ const config = await siteConfig()
427
+ const { db, seededAdminPassword } = await bootstrapDatabase(config)
428
+ await db.close()
429
+ const result = await ops.contentImport(config, {
430
+ bundle: dir,
431
+ passphrase,
432
+ replace: 'replace',
433
+ allowMissingBlobs: options.allowMissingMedia,
434
+ backupTaken: true,
435
+ label: `start --from ${basename(file)}`,
436
+ })
437
+ for (const problem of result.problems) io.warning(` ${problem}`)
438
+ if (!result.runId) {
439
+ printTransferFindings(io, result.check)
440
+ throw new CliError(
441
+ 'refused',
442
+ 'The file could not be imported; the scaffolded site is left as it is.',
443
+ )
444
+ }
445
+ io.say(` imported ${result.check.counts.create} item(s), run ${result.runId}`)
446
+ const placed = describePlaced(result.blobs)
447
+ if (placed) io.say(` media ${placed}`)
448
+ if (seededAdminPassword)
449
+ banner(io, [
450
+ 'Sign in with (shown once)',
451
+ ` username ${config.adminLogin}`,
452
+ ` password ${seededAdminPassword}`,
453
+ ])
454
+ return {
455
+ runId: result.runId,
456
+ admin: seededAdminPassword
457
+ ? { username: config.adminLogin, password: seededAdminPassword }
458
+ : null,
459
+ }
460
+ }
@@ -0,0 +1,26 @@
1
+ /** The site the CLI runs in: the current directory and its `crombolo.config.ts`. */
2
+ import { existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { type CromboloConfig, loadConfig } from '@crombolo/server'
5
+
6
+ export const cwd = (): string => process.cwd()
7
+
8
+ export async function siteConfig(): Promise<CromboloConfig> {
9
+ const file = join(cwd(), 'crombolo.config.ts')
10
+ const overrides = existsSync(file)
11
+ ? ((await import(file)).default as Partial<CromboloConfig>)
12
+ : {}
13
+ return loadConfig(overrides, cwd())
14
+ }
15
+
16
+ export const hasSite = (): boolean => existsSync(join(cwd(), 'crombolo.config.ts'))
17
+
18
+ /** Where a command says things: its result on stdout, everything else on stderr. */
19
+ export interface Io {
20
+ /** A line of the result, for people; nothing in JSON mode, where the result is returned. */
21
+ say(line?: string): void
22
+ /** Progress and narration, on stderr in every mode. */
23
+ progress(line: string): void
24
+ /** A problem that does not stop the command, on stderr. */
25
+ warning(line: string): void
26
+ }
@@ -0,0 +1,112 @@
1
+ /** What the `types` commands share: loading `schema/`, applying it, and the upgrade check. */
2
+ import { existsSync } from 'node:fs'
3
+ import {
4
+ allProperties,
5
+ loadSchemaDirectory,
6
+ type SyncReport,
7
+ type ValueMigration,
8
+ } from '@crombolo/schema'
9
+ import {
10
+ bootstrapDatabase,
11
+ type CromboloConfig,
12
+ formTypesFor,
13
+ syncSchemaDirectory,
14
+ } from '@crombolo/server'
15
+ import { CliError } from '../errors.ts'
16
+ import * as ops from '../operations/commands.ts'
17
+ import { describeProblem, describeSync, here, printFindings, printProblems } from './describe.ts'
18
+ import type { Io } from './site.ts'
19
+
20
+ export const loadTypes = (config: CromboloConfig) =>
21
+ loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
22
+
23
+ export function needSchemaDir(config: CromboloConfig): void {
24
+ if (!existsSync(config.schemaDir))
25
+ throw new CliError('not_found', `No schema directory at ${here(config.schemaDir)}.`)
26
+ }
27
+
28
+ export function refuseProblems(problems: SyncReport['problems'], message?: string): void {
29
+ if (problems.length > 0)
30
+ throw new CliError('refused', message ?? `${problems.length} problem(s) in schema/.`, {
31
+ details: problems.map(describeProblem),
32
+ })
33
+ }
34
+
35
+ /** Applies `schema/` as a boot or a deploy step would, and reports what it did. */
36
+ export async function applySchema(io: Io, config: CromboloConfig): Promise<SyncReport> {
37
+ const { db } = await bootstrapDatabase(config)
38
+ try {
39
+ const report = await syncSchemaDirectory(db, config, {
40
+ nodeId: config.nodeId,
41
+ revision: config.schemaRevision,
42
+ })
43
+ if (report.problems.length > 0) printProblems(io, report.problems)
44
+ io.say(` schema ${describeSync(report)}`)
45
+ if (report.action === 'refused')
46
+ throw new CliError('refused', `The schema was not applied: ${describeSync(report)}.`, {
47
+ details: report.problems.map(describeProblem),
48
+ })
49
+ return report
50
+ } finally {
51
+ await db.close()
52
+ }
53
+ }
54
+
55
+ /** The pull-request rules: keys present, migrations well-formed, nothing the database needs. */
56
+ export function staticProblems(
57
+ loaded: ReturnType<typeof loadSchemaDirectory>,
58
+ migrations: ValueMigration[],
59
+ ): string[] {
60
+ const problems: string[] = []
61
+ for (const t of loaded.set.contentTypes) {
62
+ if (!t.key)
63
+ problems.push(`${t.alias}: no key — run \`crombolo types sync\` in development to assign one`)
64
+ for (const p of allProperties(t)) if (!p.key) problems.push(`${t.alias}.${p.alias}: no key`)
65
+ }
66
+ for (const d of loaded.set.dataTypes) if (!d.key) problems.push(`data type ${d.alias}: no key`)
67
+ for (const m of migrations) {
68
+ const type = loaded.set.contentTypes.find((t) => t.alias === m.from.type)
69
+ if (!type) {
70
+ problems.push(`migration ${m.id}: no content type "${m.from.type}"`)
71
+ continue
72
+ }
73
+ for (const target of m.to)
74
+ if (!allProperties(type).some((p) => p.alias === target.property))
75
+ problems.push(
76
+ `migration ${m.id}: "${m.from.type}" has no property "${target.property}" to write to`,
77
+ )
78
+ }
79
+ return problems
80
+ }
81
+
82
+ /** `--set type.property=value`, repeatable: a value for a new mandatory property. */
83
+ export function parseSet(pairs: readonly string[]): Record<string, unknown> {
84
+ const out: Record<string, unknown> = {}
85
+ for (const pair of pairs) {
86
+ const eq = pair.indexOf('=')
87
+ if (eq < 0) throw new CliError('usage', `--set wants <type>.<property>=<value>, not "${pair}".`)
88
+ const raw = pair.slice(eq + 1)
89
+ let value: unknown = raw
90
+ try {
91
+ value = JSON.parse(raw)
92
+ } catch {}
93
+ out[pair.slice(0, eq)] = value
94
+ }
95
+ return out
96
+ }
97
+
98
+ /** What the deployed `schema/` would do to this database's data: the check an upgrade runs. */
99
+ export async function upgradeCheck(io: Io, config: CromboloConfig, set: Record<string, unknown>) {
100
+ const report = await ops.upgradeCheck(config, { set })
101
+ for (const bundle of report.bundles)
102
+ if (bundle.pending.length > 0)
103
+ io.say(
104
+ ` bundle ${bundle.id}: ${bundle.pending.map((step) => `${step.name} (${step.kind})`).join(', ')}`,
105
+ )
106
+ printFindings(io, report)
107
+ if (!report.siteChecked)
108
+ io.say(
109
+ 'The site schema is checked once the framework migrations have run (`crombolo upgrade`).',
110
+ )
111
+ return report
112
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `crombolo import umbraco`: a compatibility report for an Umbraco backup, and
3
+ * then a crombolo site written from it. Neither touches a database.
4
+ *
5
+ * The importer is a package of its own, loaded here on demand, so a site that
6
+ * never imports anything does not carry it or the `.bacpac` reader it needs.
7
+ */
8
+
9
+ import { VERSION } from '@crombolo/server'
10
+ import { Args, Flags } from '@oclif/core'
11
+ import { CliError } from '../errors.ts'
12
+ import { contentFlags } from './flags.ts'
13
+ import type { Io } from './site.ts'
14
+
15
+ export const umbracoArgs = {
16
+ backup: Args.string({ required: true, description: "a .bacpac, or Umbraco's SQLite file" }),
17
+ }
18
+
19
+ export const umbracoFlags = {
20
+ site: Flags.string({
21
+ summary: "the Umbraco site's files, for its views, media and plugins",
22
+ helpValue: '<dir>',
23
+ }),
24
+ media: Flags.string({
25
+ summary: 'where its media files are, if not under --site',
26
+ helpValue: '<dir>',
27
+ }),
28
+ name: Flags.string({ summary: "the new site's name", helpValue: '<site name>' }),
29
+ drafts: Flags.boolean({ summary: 'bring drafts too' }),
30
+ staging: Flags.string({ summary: 'where to unpack a .bacpac', helpValue: '<dir>' }),
31
+ 'with-members': Flags.boolean({ summary: 'bring members, encrypted' }),
32
+ 'with-users': Flags.boolean({ summary: 'bring back-office users, encrypted' }),
33
+ ...contentFlags,
34
+ }
35
+
36
+ export function deprecated(io: Io, json: boolean): void {
37
+ // Not into JSON output, which a machine reads.
38
+ if (!json)
39
+ io.warning(
40
+ ' deprecated `crombolo import umbraco` is going away: an Umbraco extension will write a crombolo file that `crombolo import <file>` reads (story 074)',
41
+ )
42
+ }
43
+
44
+ export async function loadImporter(): Promise<typeof import('@crombolo/import-umbraco')> {
45
+ try {
46
+ return await import('@crombolo/import-umbraco')
47
+ } catch {
48
+ throw new CliError('not_found', 'The importer is a separate package.', {
49
+ hint: 'Add it, then run this again: bun add -d @crombolo/import-umbraco',
50
+ })
51
+ }
52
+ }
53
+
54
+ export async function planUmbraco(
55
+ importer: typeof import('@crombolo/import-umbraco'),
56
+ backup: string,
57
+ flags: {
58
+ site?: string
59
+ media?: string
60
+ name?: string
61
+ drafts: boolean
62
+ staging?: string
63
+ 'with-members': boolean
64
+ 'with-users': boolean
65
+ },
66
+ content: 'omitted' | 'sealed' | 'open',
67
+ passphrase: string | undefined,
68
+ ) {
69
+ try {
70
+ return await importer.planImport({
71
+ source: backup,
72
+ site: flags.site,
73
+ media: flags.media,
74
+ siteName: flags.name,
75
+ drafts: flags.drafts,
76
+ staging: flags.staging,
77
+ coreVersion: VERSION,
78
+ content,
79
+ withMembers: flags['with-members'],
80
+ withUsers: flags['with-users'],
81
+ passphrase,
82
+ })
83
+ } catch (error) {
84
+ throw new CliError('refused', error instanceof Error ? error.message : String(error))
85
+ }
86
+ }
@@ -3,7 +3,7 @@
3
3
  "id": "2de473a2-d391-4161-801e-e34b202759b8",
4
4
  "createdAt": "2026-10-01T00:00:00.000Z",
5
5
  "createdBy": "bun run build:template",
6
- "integrity": "sha256-5e0caa815b7238c8f691bb51692c4b222e8d564fd6490366752f80b98bbd9c70",
6
+ "integrity": "sha256-fa7b7651e0726518570df713624c1ab29af47f7f04a615b514e29d47de301b97",
7
7
  "snapshot": "published",
8
8
  "provenance": {
9
9
  "siteName": "Basic",