@crombolo/cli 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/crombolo.ts CHANGED
@@ -54,11 +54,14 @@ import {
54
54
  DomainRepository,
55
55
  describeRefFailure,
56
56
  type Finding,
57
+ MigrationStepError,
57
58
  NodeRepository,
58
59
  readLedger,
60
+ readSetupState,
59
61
  recordInLedger,
60
62
  resolveNodeRef,
61
63
  TransferRunRepository,
64
+ UpgradeStateError,
62
65
  writeReport,
63
66
  } from '@crombolo/data'
64
67
  import {
@@ -85,7 +88,10 @@ import {
85
88
  assertPortAvailable,
86
89
  assetDirs,
87
90
  assetScaffold,
91
+ type BootFailure,
88
92
  backupBefore,
93
+ bootFailurePage,
94
+ bootRetryDelays,
89
95
  bootstrapDatabase,
90
96
  type CromboloConfig,
91
97
  checkComponents,
@@ -95,9 +101,11 @@ import {
95
101
  createServer,
96
102
  DOMAINS_FILE,
97
103
  type DomainDeclaration,
104
+ describeBootFailure,
98
105
  describeDatabase,
99
106
  dictionaryToUdt,
100
107
  domainsFilePath,
108
+ formTypesFor,
101
109
  importDictionaryUdt,
102
110
  isSafeAlias,
103
111
  listAssets,
@@ -124,7 +132,10 @@ import {
124
132
  importBundle,
125
133
  isResolution,
126
134
  isRevertResolution,
135
+ type NodeResolutions,
127
136
  packageNameFor,
137
+ parseResolveFlags,
138
+ propertyHandle,
128
139
  RESOLUTIONS,
129
140
  RESOLUTIONS_FILE,
130
141
  REVERT_RESOLUTIONS,
@@ -183,7 +194,7 @@ const SHUTDOWN_MS = 10_000
183
194
  */
184
195
  function stopOnSignal(
185
196
  listening: { stop: (closeActiveConnections?: boolean) => void },
186
- server: ServerHandle,
197
+ current: () => ServerHandle | undefined,
187
198
  ): void {
188
199
  let stopping = false
189
200
  const stop = (signal: string) => {
@@ -194,7 +205,8 @@ function stopOnSignal(
194
205
  setTimeout(() => process.exit(130), SHUTDOWN_MS).unref()
195
206
  void (async () => {
196
207
  listening.stop(true)
197
- await server.close()
208
+ const server = current()
209
+ if (server) await server.close()
198
210
  process.exit(0)
199
211
  })()
200
212
  }
@@ -206,17 +218,10 @@ async function start(): Promise<void> {
206
218
  // Before the migration and the seed, so a clash with the container stack is
207
219
  // reported in a second rather than after the database work.
208
220
  await assertPortAvailable(config.port)
209
- const server = await createServer(config)
210
- // After the schema sync inside `createServer`, which is what gives the bundle
211
- // content types to land on, and before anything is served.
212
- const bundles = await importStartupBundles(server, config)
221
+ const { listening, server, bundles } = await bootWithRetries(config)
213
222
  const seededAdminPassword = server.seededAdminPassword
214
223
 
215
224
  const lines = ['Crombolo is running', '']
216
- const listening = Bun.serve(server.serveOptions)
217
- // Registered the moment it is listening, so there is no window in which the
218
- // site answers requests and cannot be asked to stop.
219
- stopOnSignal(listening, server)
220
225
  // What this node actually answers, so a split deployment's banner does not
221
226
  // advertise an address that 404s here.
222
227
  if (config.role !== 'web')
@@ -230,6 +235,18 @@ async function start(): Promise<void> {
230
235
  // and would end the sessions of editors signed in to the api node.
231
236
  if (config.role === 'web') {
232
237
  // Nothing to print: this node has no sign-in.
238
+ } else if (!config.development) {
239
+ // Production never touches an existing administrator at start: a restart an
240
+ // attacker forced must not reset, re-enable or sign anyone out.
241
+ if (await setupPending(server))
242
+ lines.push(
243
+ ' Set up your new site at',
244
+ ` ${listening.url}${config.backOfficePath.slice(1)}/setup`,
245
+ config.setupCode
246
+ ? ' with the code in CROMBOLO_SETUP_CODE.'
247
+ : ' First set CROMBOLO_SETUP_CODE (16+ characters) and restart.',
248
+ '',
249
+ )
233
250
  } else if (flags.has('--keep-admin')) {
234
251
  if (seededAdminPassword)
235
252
  lines.push(
@@ -276,6 +293,76 @@ async function start(): Promise<void> {
276
293
  if (seededAdminPassword) console.log('Database created and seeded.')
277
294
  }
278
295
 
296
+ /**
297
+ * Listens at once, serving a page that says why the site is not up yet, and
298
+ * retries the boot with backoff: a database that comes up after the server is
299
+ * picked up without a restart. After the last attempt it exits, so the
300
+ * platform's restart policy and alerts take over. docs/operations.md.
301
+ */
302
+ async function bootWithRetries(config: CromboloConfig) {
303
+ const attempts = Math.max(1, Number(Bun.env.CROMBOLO_BOOT_ATTEMPTS ?? 10) || 10)
304
+ const delays = bootRetryDelays(attempts)
305
+ let failure: BootFailure | undefined
306
+ let progress = { attempt: 1, attempts, retryInSeconds: undefined as number | undefined }
307
+ const holding = (request: Request) => {
308
+ if (new URL(request.url).pathname === '/health')
309
+ return Response.json(
310
+ { ok: false, starting: true, problem: failure?.title ?? null },
311
+ { status: 503 },
312
+ )
313
+ return new Response(bootFailurePage(failure, progress), {
314
+ status: 503,
315
+ headers: { 'content-type': 'text/html; charset=utf-8', 'retry-after': '10' },
316
+ })
317
+ }
318
+ const listening = Bun.serve({
319
+ port: config.port,
320
+ maxRequestBodySize: config.maxRequestBodyBytes,
321
+ fetch: holding,
322
+ websocket: { message() {} },
323
+ })
324
+ let current: ServerHandle | undefined
325
+ // Registered the moment it listens, so there is no window, retries included,
326
+ // in which it answers requests and cannot be asked to stop.
327
+ stopOnSignal(listening, () => current)
328
+ for (let attempt = 1; ; attempt++) {
329
+ let server: ServerHandle | undefined
330
+ try {
331
+ server = await createServer(config)
332
+ current = server
333
+ // After the schema sync inside `createServer`, which is what gives the
334
+ // bundle content types to land on, and before anything is served.
335
+ const bundles = await importStartupBundles(server, config)
336
+ listening.reload(server.serveOptions as never)
337
+ return { listening, server, bundles }
338
+ } catch (error) {
339
+ current = undefined
340
+ await server?.close().catch(() => {})
341
+ failure = describeBootFailure(error, describeDatabase(config))
342
+ const delay = delays[attempt - 1]
343
+ console.error(
344
+ `crombolo: ${failure.title}. ${failure.detail}\n To fix it: ${failure.remedy}\n (${error instanceof Error ? error.message : String(error)})`,
345
+ )
346
+ if (delay === undefined || error instanceof ops.CommandError) {
347
+ progress = { attempt, attempts, retryInSeconds: undefined }
348
+ listening.stop(true)
349
+ if (error instanceof ops.CommandError) throw error
350
+ throw new ops.CommandError(`Gave up after ${attempt} attempt(s): ${failure.title}.`)
351
+ }
352
+ progress = { attempt, attempts, retryInSeconds: Math.round(delay / 1000) }
353
+ if (attempt === 1) console.error(` ${listening.url} explains this while it retries.`)
354
+ console.error(
355
+ ` Trying again in ${Math.round(delay / 1000)}s (attempt ${attempt} of ${attempts}).`,
356
+ )
357
+ await Bun.sleep(delay)
358
+ }
359
+ }
360
+ }
361
+
362
+ async function setupPending(server: ServerHandle): Promise<boolean> {
363
+ return (await readSetupState(server.db)) === 'pending'
364
+ }
365
+
279
366
  /**
280
367
  * `start --bundle <dir>`, repeatable: apply a bundle before the site serves
281
368
  * anything, taking the flags `content import` takes.
@@ -299,7 +386,7 @@ async function importStartupBundles(
299
386
  for (const target of targets) {
300
387
  let resolved: ops.ResolvedBundle
301
388
  try {
302
- resolved = ops.readBundle(target, config.siteDir)
389
+ resolved = ops.readBundle(target, config.siteDir, await passphraseToOpen(target, config))
303
390
  } catch (error) {
304
391
  if (!(error instanceof ops.CommandError)) throw error
305
392
  console.error(` ${error.message}`)
@@ -330,6 +417,8 @@ async function importStartupBundles(
330
417
  const started = performance.now()
331
418
  const result = await importBundle(db, set, {
332
419
  ...prepared.options,
420
+ // The administrator this boot created stands in until the site's own arrive.
421
+ adoptUsers: Boolean(server.seededAdminPassword),
333
422
  publish: flags.has('--publish'),
334
423
  nodeId: config.nodeId,
335
424
  label: flagValue('--label'),
@@ -347,7 +436,7 @@ async function importStartupBundles(
347
436
  const c = result.check.counts
348
437
  const placed = describePlaced(
349
438
  resolved.blobs.size > 0
350
- ? await placeBlobs(await mediaStoreFor(config), resolved.blobs)
439
+ ? await placeBlobs(await mediaStoreFor(config), resolved.blobs, resolved.openBlob)
351
440
  : null,
352
441
  )
353
442
  lines.push(
@@ -355,6 +444,13 @@ async function importStartupBundles(
355
444
  `${result.published.length > 0 ? `, ${result.published.length} published` : ''} (run ${result.runId})`,
356
445
  )
357
446
  if (placed) lines.push(` blobs ${placed}`)
447
+ const people = result.people.filter((p) => p.kind === 'member' || p.kind === 'user')
448
+ if (people.length > 0)
449
+ lines.push(
450
+ ` 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`,
451
+ )
452
+ for (const finding of result.check.findings.filter((f) => f.code === 'user-adopted'))
453
+ lines.push(` admin ${finding.message}`)
358
454
  for (const failure of result.publishFailures)
359
455
  console.error(` not published ${failure.key}: ${failure.reason}`)
360
456
  await recordInLedger(db, {
@@ -553,11 +649,12 @@ async function schema(): Promise<void> {
553
649
  }
554
650
  switch (sub) {
555
651
  case 'check': {
556
- const loaded = loadSchemaDirectory(config.schemaDir)
652
+ const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
557
653
  const problems = [
558
654
  ...loaded.problems,
559
655
  ...validateSchemaSet(loaded.set, {
560
656
  componentAliases: componentAliasesIn(config.componentsDir),
657
+ types: formTypesFor(config),
561
658
  }),
562
659
  ]
563
660
  if (problems.length > 0) {
@@ -604,7 +701,7 @@ async function schema(): Promise<void> {
604
701
  return schemaAddProperty(config)
605
702
  case 'rewrite': {
606
703
  // Every file in the canonical layout: a reviewable commit when the vocabulary changes.
607
- const loaded = loadSchemaDirectory(config.schemaDir)
704
+ const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
608
705
  if (loaded.problems.length > 0) {
609
706
  printProblems(loaded.problems)
610
707
  process.exit(1)
@@ -617,7 +714,7 @@ async function schema(): Promise<void> {
617
714
  const { db } = await bootstrapDatabase(config)
618
715
  try {
619
716
  const version = existsSync(config.schemaDir)
620
- ? loadSchemaDirectory(config.schemaDir).set.version
717
+ ? loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) }).set.version
621
718
  : '1.0.0'
622
719
  const set = await exportSchemaSet(db, version)
623
720
  for (const file of writeSchemaFiles(config.schemaDir, set))
@@ -654,7 +751,7 @@ async function schemaNew(config: CromboloConfig): Promise<void> {
654
751
  process.exit(1)
655
752
  }
656
753
 
657
- const loaded = loadSchemaDirectory(config.schemaDir)
754
+ const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
658
755
  const existing = [
659
756
  ...loaded.set.contentTypes,
660
757
  ...(loaded.set.mediaTypes ?? []),
@@ -720,7 +817,7 @@ async function schemaAddProperty(config: CromboloConfig): Promise<void> {
720
817
  process.exit(1)
721
818
  }
722
819
 
723
- const loaded = loadSchemaDirectory(config.schemaDir)
820
+ const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
724
821
  if (loaded.problems.length > 0) {
725
822
  printProblems(loaded.problems)
726
823
  process.exit(1)
@@ -861,8 +958,11 @@ async function schemaPurge(config: CromboloConfig): Promise<void> {
861
958
 
862
959
  async function generate(): Promise<void> {
863
960
  const config = await siteConfig()
864
- const loaded = loadSchemaDirectory(config.schemaDir)
865
- const problems = [...loaded.problems, ...validateSchemaSet(loaded.set)]
961
+ const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
962
+ const problems = [
963
+ ...loaded.problems,
964
+ ...validateSchemaSet(loaded.set, { types: formTypesFor(config) }),
965
+ ]
866
966
  if (problems.length > 0) {
867
967
  printProblems(problems)
868
968
  process.exit(1)
@@ -887,6 +987,89 @@ function flagValue(name: string): string | undefined {
887
987
  return flagValues(name)[0]
888
988
  }
889
989
 
990
+ /** Where a script gives a bundle's passphrase, rather than typing it. */
991
+ const PASSPHRASE_ENV = 'CROMBOLO_BUNDLE_PASSPHRASE'
992
+ const MIN_PASSPHRASE = 12
993
+
994
+ /** A line typed without echo. */
995
+ async function readSecret(question: string): Promise<string> {
996
+ process.stdout.write(question)
997
+ const stdin = process.stdin
998
+ stdin.setRawMode(true)
999
+ stdin.resume()
1000
+ return new Promise((done) => {
1001
+ let typed = ''
1002
+ const onData = (chunk: Buffer) => {
1003
+ for (const char of chunk.toString('utf8')) {
1004
+ if (char === '\u0003') {
1005
+ process.stdout.write('\n')
1006
+ process.exit(130)
1007
+ }
1008
+ if (char === '\r' || char === '\n') {
1009
+ stdin.setRawMode(false)
1010
+ stdin.pause()
1011
+ stdin.off('data', onData)
1012
+ process.stdout.write('\n')
1013
+ done(typed)
1014
+ return
1015
+ }
1016
+ if (char === '\u007f' || char === '\b') typed = typed.slice(0, -1)
1017
+ else typed += char
1018
+ }
1019
+ }
1020
+ stdin.on('data', onData)
1021
+ })
1022
+ }
1023
+
1024
+ /** The passphrase to open a bundle with, when it is sealed: the environment's, or asked for. */
1025
+ async function passphraseToOpen(
1026
+ target: string,
1027
+ config: CromboloConfig,
1028
+ ): Promise<string | undefined> {
1029
+ const given = process.env[PASSPHRASE_ENV]
1030
+ if (given) return given
1031
+ let sealed = false
1032
+ try {
1033
+ sealed = ops.readBundle(target, config.siteDir).set.sealed === true
1034
+ } catch {
1035
+ // The command that follows reads it again and says what is wrong with it.
1036
+ return undefined
1037
+ }
1038
+ if (!sealed) return undefined
1039
+ if (!process.stdin.isTTY) {
1040
+ console.error(
1041
+ `${target} is encrypted. Set ${PASSPHRASE_ENV} to the passphrase it was exported with.`,
1042
+ )
1043
+ process.exit(1)
1044
+ }
1045
+ return readSecret(' Passphrase for this bundle: ')
1046
+ }
1047
+
1048
+ /** A new passphrase to seal an export with: the environment's, or asked for twice. */
1049
+ async function passphraseToSeal(): Promise<string> {
1050
+ const given = process.env[PASSPHRASE_ENV]
1051
+ if (given) {
1052
+ if (given.length < MIN_PASSPHRASE) {
1053
+ console.error(`${PASSPHRASE_ENV} is shorter than ${MIN_PASSPHRASE} characters.`)
1054
+ process.exit(1)
1055
+ }
1056
+ return given
1057
+ }
1058
+ if (!process.stdin.isTTY) {
1059
+ console.error(`An encrypted export needs a passphrase: set ${PASSPHRASE_ENV}.`)
1060
+ process.exit(1)
1061
+ }
1062
+ for (;;) {
1063
+ const first = await readSecret(' Passphrase to encrypt this bundle with: ')
1064
+ if (first.length < MIN_PASSPHRASE) {
1065
+ console.error(` At least ${MIN_PASSPHRASE} characters, please.`)
1066
+ continue
1067
+ }
1068
+ if ((await readSecret(' Again, to confirm: ')) === first) return first
1069
+ console.error(' They did not match.')
1070
+ }
1071
+ }
1072
+
890
1073
  async function content(): Promise<void> {
891
1074
  const config = await siteConfig()
892
1075
  switch (positional[0]) {
@@ -1013,6 +1196,7 @@ function transferInput(bundle: string): ops.TransferInput {
1013
1196
  resolutions: parseResolutions(),
1014
1197
  resolveAll: allFlag as Resolution | undefined,
1015
1198
  allowMissingBlobs: flags.has('--allow-missing-blobs'),
1199
+ publish: flags.has('--publish'),
1016
1200
  // What a bundle carries is applied by default — that is the point of it
1017
1201
  // carrying anything — so these decline a half, for someone who wants the
1018
1202
  // content without the structure that came with it.
@@ -1074,6 +1258,7 @@ async function contentImport(config: CromboloConfig): Promise<void> {
1074
1258
  }
1075
1259
  const result = await ops.contentImport(config, {
1076
1260
  ...transferInput(dir),
1261
+ passphrase: await passphraseToOpen(dir, config),
1077
1262
  publish: flags.has('--publish'),
1078
1263
  label: flagValue('--label'),
1079
1264
  backupTaken: flags.has('--backup-taken'),
@@ -1257,23 +1442,14 @@ async function contentRuns(config: CromboloConfig): Promise<void> {
1257
1442
  }
1258
1443
  }
1259
1444
 
1260
- /** `--resolve <key>=take-bundle`, repeatable; the same shape as `--set`. */
1261
- function parseResolutions(): Record<string, Resolution> {
1262
- const out: Record<string, Resolution> = {}
1263
- for (const pair of flagValues('--resolve')) {
1264
- const eq = pair.indexOf('=')
1265
- if (eq < 0) {
1266
- console.error(` --resolve wants <key>=<choice>, not "${pair}"`)
1267
- process.exit(1)
1268
- }
1269
- const choice = pair.slice(eq + 1)
1270
- if (!isResolution(choice)) {
1271
- console.error(` "${choice}" is not one of ${RESOLUTIONS.join(', ')}`)
1272
- process.exit(1)
1273
- }
1274
- out[pair.slice(0, eq)] = choice
1445
+ /** `--resolve <key>=<choice>` or `--resolve <key>/<property>[@<culture>]=<choice>`, repeatable. */
1446
+ function parseResolutions(): Record<string, NodeResolutions> {
1447
+ const parsed = parseResolveFlags(flagValues('--resolve'))
1448
+ if (parsed.problems.length > 0) {
1449
+ for (const problem of parsed.problems) console.error(` ${problem}`)
1450
+ process.exit(1)
1275
1451
  }
1276
- return out
1452
+ return parsed.byNode
1277
1453
  }
1278
1454
 
1279
1455
  function printTransferFindings(check: TransferCheck): void {
@@ -1288,6 +1464,11 @@ function printTransferFindings(check: TransferCheck): void {
1288
1464
  )
1289
1465
  for (const f of check.findings) {
1290
1466
  console.log(` [${f.kind.padEnd(8)}] ${f.message}`)
1467
+ // The handle is the CLI's to name; the dashboard offers the same answers as choices.
1468
+ if (f.code === 'local-edit' && f.propertyAlias)
1469
+ console.log(
1470
+ ` answer it as ${f.subjectKey}/${propertyHandle(f.propertyAlias, f.culture)}`,
1471
+ )
1291
1472
  for (const suggestion of resolutionsFor(f.code)) console.log(` → ${suggestion}`)
1292
1473
  if (f.link) console.log(` ${f.link}`)
1293
1474
  }
@@ -1301,12 +1482,18 @@ async function contentCheck(config: CromboloConfig): Promise<void> {
1301
1482
  )
1302
1483
  process.exit(1)
1303
1484
  }
1304
- const result = await ops.contentCheck(config, transferInput(dir))
1485
+ const result = await ops.contentCheck(config, {
1486
+ ...transferInput(dir),
1487
+ passphrase: await passphraseToOpen(dir, config),
1488
+ })
1305
1489
  for (const problem of result.problems) console.error(` ${problem}`)
1306
1490
  describeBundle(result.bundle)
1307
1491
  if (result.under) console.log(` under ${result.under}`)
1308
1492
  printSections(result.sections)
1309
1493
  printTransferFindings(result.check)
1494
+ // The reasons are publish's own: the check rehearsed the import to find them.
1495
+ for (const refusal of result.publishRefusals)
1496
+ console.error(` would not publish ${refusal.key}: ${refusal.reason}`)
1310
1497
 
1311
1498
  if (flags.has('--save')) {
1312
1499
  const target = join(result.payloadDir, RESOLUTIONS_FILE)
@@ -1331,6 +1518,42 @@ async function contentCheck(config: CromboloConfig): Promise<void> {
1331
1518
  console.log('\nNothing outstanding: this bundle would import cleanly.')
1332
1519
  }
1333
1520
 
1521
+ /**
1522
+ * What an export or an import does with property values and media files:
1523
+ * left out, sealed, or — when asked twice — in the open.
1524
+ */
1525
+ function contentFlags(): { content: 'omitted' | 'sealed' | 'open' } {
1526
+ const include = flags.has('--include-content')
1527
+ const open = flags.has('--allow-content-unencrypted')
1528
+ if (open && !include) {
1529
+ console.error('--allow-content-unencrypted goes with --include-content.')
1530
+ process.exit(1)
1531
+ }
1532
+ if (flags.has('--with-blobs') && !include) {
1533
+ console.error('Media files are content: --with-blobs goes with --include-content.')
1534
+ process.exit(1)
1535
+ }
1536
+ if (open)
1537
+ console.error(
1538
+ ' warning property values and media files are written unencrypted: anyone with the files can read any personal information in them',
1539
+ )
1540
+ return { content: !include ? 'omitted' : open ? 'open' : 'sealed' }
1541
+ }
1542
+
1543
+ function describeContentMode(
1544
+ content: 'omitted' | 'sealed' | 'open',
1545
+ people?: { members: number; users: number },
1546
+ ): void {
1547
+ if (content === 'omitted')
1548
+ console.log(
1549
+ ' content not included: pages are placed and named, and no value is written. Pass --include-content to bring the values and media files',
1550
+ )
1551
+ else if (content === 'sealed') console.log(' content encrypted: importing needs the passphrase')
1552
+ else console.log(' content unencrypted')
1553
+ if (people && people.members + people.users > 0)
1554
+ console.log(` people ${people.members} member(s), ${people.users} user(s), encrypted`)
1555
+ }
1556
+
1334
1557
  async function contentExport(config: CromboloConfig): Promise<void> {
1335
1558
  const out = flagValue('--out')
1336
1559
  const refs = flagValues('--root')
@@ -1338,11 +1561,19 @@ async function contentExport(config: CromboloConfig): Promise<void> {
1338
1561
  console.error('content export needs --root <path|uuid> (repeatable) and --out <dir>.')
1339
1562
  process.exit(1)
1340
1563
  }
1564
+ const content = contentFlags()
1341
1565
  const result = await ops.contentExport(config, {
1566
+ ...content,
1342
1567
  roots: refs,
1343
1568
  only: flags.has('--only'),
1344
1569
  drafts: flags.has('--drafts'),
1345
1570
  withBlobs: flags.has('--with-blobs'),
1571
+ withMembers: flags.has('--with-members'),
1572
+ withUsers: flags.has('--with-users'),
1573
+ passphrase:
1574
+ content.content === 'sealed' || flags.has('--with-members') || flags.has('--with-users')
1575
+ ? await passphraseToSeal()
1576
+ : undefined,
1346
1577
  withBlueprints: flags.has('--with-blueprints'),
1347
1578
  // An export is a private package; without --name it is named for its directory.
1348
1579
  package: {
@@ -1369,6 +1600,7 @@ async function contentExport(config: CromboloConfig): Promise<void> {
1369
1600
  console.log(` expects ${result.expected.length} node(s) to exist at the destination:`)
1370
1601
  for (const e of result.expected) console.log(` ${e.key} ${e.name} (${e.why})`)
1371
1602
  }
1603
+ describeContentMode(result.content, result.people)
1372
1604
  if (result.blobs.carried > 0)
1373
1605
  console.log(
1374
1606
  ` blobs ${result.blobs.carried} media file(s), ${describeBytes(result.blobs.bytes)}`,
@@ -1614,6 +1846,16 @@ async function importSite(): Promise<void> {
1614
1846
  process.exit(1)
1615
1847
  }
1616
1848
 
1849
+ const { content } = sub === 'apply' ? contentFlags() : { content: 'omitted' as const }
1850
+ const withMembers = flags.has('--with-members')
1851
+ const withUsers = flags.has('--with-users')
1852
+ const sealing = content === 'sealed' || withMembers || withUsers
1853
+ // A report writes no bundle, so what it would seal with is never kept.
1854
+ const passphrase = !sealing
1855
+ ? undefined
1856
+ : sub === 'apply'
1857
+ ? await passphraseToSeal()
1858
+ : crypto.randomUUID()
1617
1859
  let plan: Awaited<ReturnType<typeof importer.planImport>>
1618
1860
  try {
1619
1861
  plan = await importer.planImport({
@@ -1624,6 +1866,10 @@ async function importSite(): Promise<void> {
1624
1866
  drafts: flags.has('--drafts'),
1625
1867
  staging: flagValue('--staging'),
1626
1868
  coreVersion: VERSION,
1869
+ content: sub === 'apply' ? content : flags.has('--include-content') ? 'open' : 'omitted',
1870
+ withMembers,
1871
+ withUsers,
1872
+ passphrase,
1627
1873
  })
1628
1874
  } catch (error) {
1629
1875
  console.error(` ${error instanceof Error ? error.message : error}`)
@@ -1697,6 +1943,14 @@ async function importSite(): Promise<void> {
1697
1943
  console.log(
1698
1944
  `\n\`bun start\` imports ${importer.IMPORT_BUNDLE} on the first boot of an empty database.`,
1699
1945
  )
1946
+ if (sealing)
1947
+ console.log(
1948
+ `It is encrypted: \`bun start\` asks for the passphrase, or reads ${PASSPHRASE_ENV}.`,
1949
+ )
1950
+ if (content === 'omitted')
1951
+ console.log(
1952
+ 'Pages and media were brought without what they hold. Run again with --include-content to bring it.',
1953
+ )
1700
1954
  if (plan.dictionary.length > 0)
1701
1955
  console.log(`Then: crombolo dictionary import ${importer.IMPORT_DIR}/dictionary.udt`)
1702
1956
  }
@@ -1712,7 +1966,9 @@ async function components(): Promise<void> {
1712
1966
  const sub = positional[0] ?? 'check'
1713
1967
  if (sub !== 'check' && sub !== 'list' && sub !== 'new') return help()
1714
1968
 
1715
- const loaded = existsSync(config.schemaDir) ? loadSchemaDirectory(config.schemaDir) : undefined
1969
+ const loaded = existsSync(config.schemaDir)
1970
+ ? loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
1971
+ : undefined
1716
1972
  const types = loaded?.set.contentTypes ?? []
1717
1973
 
1718
1974
  if (sub === 'list') {
@@ -2045,14 +2301,24 @@ function help(): void {
2045
2301
  content export --root <path|uuid> --out <dir>
2046
2302
  write a private bundle package: the subtree, its
2047
2303
  dependencies and what the destination must already have
2048
- [--only] [--drafts] [--with-blobs] [--with-blueprints]
2304
+ [--only] [--drafts] [--with-blueprints]
2049
2305
  [--name <npm name>] [--package-version <version>]
2306
+ Names and the tree only, unless [--include-content]:
2307
+ values, media files [--with-blobs] and protected-page
2308
+ usernames, encrypted under a passphrase (prompted, or
2309
+ CROMBOLO_BUNDLE_PASSPHRASE); [--allow-content-unencrypted]
2310
+ writes them readable. [--with-members] [--with-users]
2311
+ add people, always encrypted
2050
2312
  A bundle is an npm package. <pkg> is its directory, or the name of one this
2051
2313
  site depends on.
2052
2314
  content check <pkg> what installing its content here would do, and what it
2053
2315
  needs from a person first; writes to the Changes dashboard
2054
2316
  [--under <path|uuid>] [--resolve <key>=take-bundle|keep-local|skip]
2317
+ [--resolve <key>/<property>[@<culture>]=take-bundle|keep-local]
2055
2318
  [--resolve-all <choice>] [--allow-missing-blobs] [--save]
2319
+ [--publish: also say which pages the import would not publish]
2320
+ An encrypted bundle asks for its passphrase, or reads
2321
+ CROMBOLO_BUNDLE_PASSPHRASE
2056
2322
  content import <pkg> re-check, back up, then apply: documents and elements
2057
2323
  arrive as drafts, media is live. One transaction, so a
2058
2324
  failure leaves nothing behind
@@ -2095,6 +2361,8 @@ function help(): void {
2095
2361
  write a crombolo site: schema, view stubs, media and a
2096
2362
  content bundle that \`bun start\` imports on first boot
2097
2363
  [--name <site name>] [--drafts] [--postgres] [--force]
2364
+ [--include-content] [--allow-content-unencrypted]
2365
+ [--with-members] [--with-users], as content export
2098
2366
  upgrade check [--set t.p=v] what the deployed schema/ would do to this database's data
2099
2367
  upgrade check --fix back up; apply the additive part and every conversion early
2100
2368
  upgrade [--force <reason>] re-check, back up, apply framework steps, cut over, ledger
@@ -2116,6 +2384,11 @@ try {
2116
2384
  await dispatch()
2117
2385
  if (audited) await ops.recordCommand(await siteConfig(), audited)
2118
2386
  } catch (error) {
2387
+ // These already say what happened and what to do; a stack trace would bury it.
2388
+ if (error instanceof MigrationStepError || error instanceof UpgradeStateError) {
2389
+ console.error(error.message)
2390
+ process.exit(1)
2391
+ }
2119
2392
  if (!(error instanceof ops.CommandError)) throw error
2120
2393
  for (const problem of error.problems) console.error(` ${problem}`)
2121
2394
  console.error(error.message)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "The crombolo command line: scaffolding a site, schema, views, domains, content transfer and upgrades.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -34,11 +34,11 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@crombolo/auth": "0.11.0",
38
- "@crombolo/core": "0.11.0",
39
- "@crombolo/data": "0.11.0",
40
- "@crombolo/schema": "0.11.0",
41
- "@crombolo/server": "0.11.0",
42
- "@crombolo/transfer": "0.11.0"
37
+ "@crombolo/auth": "0.12.1",
38
+ "@crombolo/core": "0.12.1",
39
+ "@crombolo/data": "0.12.1",
40
+ "@crombolo/schema": "0.12.1",
41
+ "@crombolo/server": "0.12.1",
42
+ "@crombolo/transfer": "0.12.1"
43
43
  }
44
44
  }
@@ -46,6 +46,7 @@ import {
46
46
  checkSchemaDirectory,
47
47
  createFileIntake,
48
48
  createMediaPort,
49
+ formTypesFor,
49
50
  installBundleContent,
50
51
  installDatabase,
51
52
  MediaFileStore,
@@ -62,7 +63,9 @@ import {
62
63
  import {
63
64
  bundleIdFor,
64
65
  exportBundle,
66
+ exportPeople,
65
67
  isPackageName,
68
+ newSeal,
66
69
  packageNameFor,
67
70
  writeBundle,
68
71
  writeBundlePackage,
@@ -143,6 +146,12 @@ export interface ContentExportInput {
143
146
  drafts?: boolean
144
147
  withBlobs?: boolean
145
148
  withBlueprints?: boolean
149
+ /** Property values and media files: left out (the default), sealed, or in the open. */
150
+ content?: 'omitted' | 'sealed' | 'open'
151
+ withMembers?: boolean
152
+ withUsers?: boolean
153
+ /** Seals the content and the people; needed for either. */
154
+ passphrase?: string
146
155
  /**
147
156
  * The private package the export is written as. The name defaults to one
148
157
  * derived from the bundle's id, the version to `1.0.0`.
@@ -160,6 +169,8 @@ export interface ContentExportResult {
160
169
  /** The package's files — `package.json` and the payload under `content/` — for the caller to place. */
161
170
  files: { path: string; bytes?: Uint8Array; text?: string }[]
162
171
  blobs: { carried: number; bytes: number; missing: string[]; notCarried: number }
172
+ content: 'omitted' | 'sealed' | 'open'
173
+ people: { members: number; users: number }
163
174
  }
164
175
 
165
176
  /**
@@ -175,6 +186,12 @@ export async function contentExport(
175
186
  const requested = input.package?.name
176
187
  if (requested !== undefined && !isPackageName(requested))
177
188
  throw new CommandError(`"${requested}" is not a package name npm accepts.`)
189
+ const content = input.content ?? 'omitted'
190
+ const withPeople = Boolean(input.withMembers || input.withUsers)
191
+ if ((content === 'sealed' || withPeople) && !input.passphrase)
192
+ throw new CommandError('An encrypted export needs a passphrase.')
193
+ if (input.withBlobs && content === 'omitted')
194
+ throw new CommandError('Media files are content: --with-blobs goes with --include-content.')
178
195
  const { db } = await bootstrapDatabase(config)
179
196
  try {
180
197
  // A path is resolved against the tree it is exported from, and the key is
@@ -199,6 +216,12 @@ export async function contentExport(
199
216
  // the store's business, and `@crombolo/transfer` knows nothing about it.
200
217
  let carried = new Map<string, Uint8Array>()
201
218
  let missing: string[] = []
219
+ const people = withPeople
220
+ ? await exportPeople(db, {
221
+ members: input.withMembers ?? false,
222
+ users: input.withUsers ?? false,
223
+ })
224
+ : undefined
202
225
  if (input.withBlobs && blobKeys.length > 0) {
203
226
  const read = await readBlobs(await mediaStoreFor(config), blobKeys)
204
227
  carried = read.bytes
@@ -218,19 +241,31 @@ export async function contentExport(
218
241
  name: e.name,
219
242
  why: e.why,
220
243
  })),
221
- files: writeBundlePackage(writeBundle(set, carried), {
222
- ...pkg,
223
- id: bundleIdFor(pkg.name.replace(/^@[^/]+\//, '')),
224
- label: `Content from ${config.siteName}`,
225
- private: true,
226
- peerRange: `^${VERSION}`,
227
- }),
244
+ files: writeBundlePackage(
245
+ writeBundle(set, carried, [], {
246
+ content,
247
+ seal:
248
+ input.passphrase && (content === 'sealed' || people)
249
+ ? newSeal(input.passphrase)
250
+ : undefined,
251
+ people,
252
+ }),
253
+ {
254
+ ...pkg,
255
+ id: bundleIdFor(pkg.name.replace(/^@[^/]+\//, '')),
256
+ label: `Content from ${config.siteName}`,
257
+ private: true,
258
+ peerRange: `^${VERSION}`,
259
+ },
260
+ ),
228
261
  blobs: {
229
262
  carried: carried.size,
230
263
  bytes: [...carried.values()].reduce((total, file) => total + file.length, 0),
231
264
  missing,
232
- notCarried: input.withBlobs ? 0 : blobKeys.length,
265
+ notCarried: input.withBlobs || content === 'omitted' ? 0 : blobKeys.length,
233
266
  },
267
+ content,
268
+ people: { members: people?.members.length ?? 0, users: people?.users.length ?? 0 },
234
269
  }
235
270
  } finally {
236
271
  await db.close()
@@ -254,7 +289,7 @@ async function upgradeContext(config: CromboloConfig, set: Record<string, unknow
254
289
  const chains = chainsFor(config)
255
290
  const db = await connect({ file: config.sqliteFile })
256
291
  const node = { nodeId: config.nodeId, revision: config.schemaRevision }
257
- const loaded = () => loadSchemaDirectory(config.schemaDir)
292
+ const loaded = () => loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
258
293
  const syncNode = () => ({ ...node, version: loaded().set.version })
259
294
  const checkOptions = async () => ({
260
295
  ...syncOptionsFor(config),
package/src/templates.ts CHANGED
@@ -19,8 +19,13 @@ import { join, relative } from 'node:path'
19
19
  import { siteMediaTypeFiles } from '@crombolo/schema'
20
20
  import { VERSION } from '@crombolo/server'
21
21
 
22
- /** `templates/`, beside `src/` and `bin/` in the published package. */
23
- export const TEMPLATES_DIR = join(import.meta.dir, '..', 'templates')
22
+ /**
23
+ * `templates/`, beside `src/` and `bin/` in the published package.
24
+ * `CROMBOLO_TEMPLATES_DIR` stands in for it, so a test can stage the sites
25
+ * without writing into the package that other runs read.
26
+ */
27
+ export const TEMPLATES_DIR =
28
+ process.env.CROMBOLO_TEMPLATES_DIR || join(import.meta.dir, '..', 'templates')
24
29
 
25
30
  /**
26
31
  * A bundle whose server half a template wires into the site it scaffolds: the
@@ -6,7 +6,7 @@
6
6
  "content"
7
7
  ],
8
8
  "peerDependencies": {
9
- "@crombolo/crombolo": "^0.11.0"
9
+ "@crombolo/crombolo": "^0.12.1"
10
10
  },
11
11
  "crombolo": {
12
12
  "id": "basic",
@@ -6,7 +6,7 @@
6
6
  "content"
7
7
  ],
8
8
  "peerDependencies": {
9
- "@crombolo/crombolo": "^0.11.0"
9
+ "@crombolo/crombolo": "^0.12.1"
10
10
  },
11
11
  "crombolo": {
12
12
  "id": "demo-harbourstone",
@@ -6,7 +6,7 @@
6
6
  "content"
7
7
  ],
8
8
  "peerDependencies": {
9
- "@crombolo/crombolo": "^0.11.0"
9
+ "@crombolo/crombolo": "^0.12.1"
10
10
  },
11
11
  "crombolo": {
12
12
  "id": "starter",