@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
|
-
|
|
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
|
-
|
|
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
|
|
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 = [
|
|
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
|
|
1261
|
-
function parseResolutions(): Record<string,
|
|
1262
|
-
const
|
|
1263
|
-
|
|
1264
|
-
const
|
|
1265
|
-
|
|
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
|
|
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,
|
|
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)
|
|
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-
|
|
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.
|
|
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.
|
|
38
|
-
"@crombolo/core": "0.
|
|
39
|
-
"@crombolo/data": "0.
|
|
40
|
-
"@crombolo/schema": "0.
|
|
41
|
-
"@crombolo/server": "0.
|
|
42
|
-
"@crombolo/transfer": "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(
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
/**
|
|
23
|
-
|
|
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
|