@crombolo/cli 0.9.1 → 0.11.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 (58) hide show
  1. package/bin/crombolo.ts +161 -37
  2. package/package.json +7 -7
  3. package/src/operations/commands.ts +244 -333
  4. package/src/templates.ts +1 -1
  5. package/templates/basic/bundle/package.json +16 -0
  6. package/templates/demo/harbourstone/bundle/package.json +16 -0
  7. package/templates/demo/harbourstone/files/schema/forms/contact.toml +2 -2
  8. package/templates/starter/bundle/package.json +16 -0
  9. package/src/operations/sections.ts +0 -423
  10. /package/templates/basic/bundle/{bundle.json → content/bundle.json} +0 -0
  11. /package/templates/basic/bundle/{nodes → content/nodes}/36955f6e-d110-469e-b280-72096eccd148.json +0 -0
  12. /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/180cc8ef/distillery-from-the-cliff-path.jpg +0 -0
  13. /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/68c9cfdf/bottle-brine-and-kiln.jpg +0 -0
  14. /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/6be45535/bottle-oloroso-pier.jpg +0 -0
  15. /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/6d8d6398/bottle-the-cove.jpg +0 -0
  16. /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/7b4713f8/copper-pot-stills.jpg +0 -0
  17. /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/c114b7d8/bottle-neap-tide.jpg +0 -0
  18. /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/fd337567/casks-in-the-sea-facing-warehouse.jpg +0 -0
  19. /package/templates/demo/harbourstone/bundle/{bundle.json → content/bundle.json} +0 -0
  20. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/069cc3c5-c9f5-4792-90c9-e2bfcc4855b6.json +0 -0
  21. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/13788fe7-1a45-4b48-8ae4-f4227e154e1e.json +0 -0
  22. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/180cc8ef-caf1-4316-b6a7-e3b5aee41ffd.json +0 -0
  23. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/22144db2-e214-4006-a0bb-53b5bd121fd6.json +0 -0
  24. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/26e87610-e2d6-4b47-bf92-3967f7186907.json +0 -0
  25. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/2caa099b-3884-472d-9cea-797a0f0c376f.json +0 -0
  26. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/2efbdb5b-7888-4a49-bca5-c0f85b76e472.json +0 -0
  27. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/2fe03891-1294-4344-8e24-47e8b212e5b0.json +0 -0
  28. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/38b009f7-03c0-4fda-b5c7-843ad6044e9c.json +0 -0
  29. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/3d312cc8-2937-40e1-ba14-fd781c9a862d.json +0 -0
  30. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/3dc30fc1-973a-4217-b26f-24902210be17.json +0 -0
  31. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +0 -0
  32. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/483a0d3b-a10c-4c9d-8c81-d9607d37e968.json +0 -0
  33. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/4b760c0b-b83a-4eee-aa29-c225cfabf19b.json +0 -0
  34. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/4e5c7f3f-54cb-4328-ad61-bc4c2736b890.json +0 -0
  35. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/62f479ff-0875-453e-99ae-3bf1bec5e3c8.json +0 -0
  36. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/68c9cfdf-6b40-47da-a1d2-17256a6cc6db.json +0 -0
  37. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +0 -0
  38. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/6be45535-83e5-4469-acf0-4127016721ae.json +0 -0
  39. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/6d8d6398-738f-4f38-ad3c-e5d6e9a971fe.json +0 -0
  40. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/7b4713f8-d917-457d-a9d8-7dd01a3617b4.json +0 -0
  41. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/88ec1185-53b5-439a-a2ce-3f56603be4ff.json +0 -0
  42. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/97469df2-107c-4010-8c14-4de88f735529.json +0 -0
  43. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/a2eb439f-71d9-4ea6-89d7-c5bc72618281.json +0 -0
  44. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +0 -0
  45. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/a5e36a18-a2ef-4eb5-85b4-7e12b21cca06.json +0 -0
  46. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/b1876d3a-50a7-4358-84bf-a0899932ffa9.json +0 -0
  47. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/b4e38bbd-c1a3-431a-b7a9-5868aa32993a.json +0 -0
  48. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/c114b7d8-0138-404b-9d8f-a39d992577a2.json +0 -0
  49. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/cb4c569e-c28a-43b6-a4d7-d33bb544e973.json +0 -0
  50. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +0 -0
  51. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/d60ef12b-0f0a-4db5-9763-21fa52b8d4da.json +0 -0
  52. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/d7f13222-d060-4496-b86d-bef21da963c1.json +0 -0
  53. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/f36dd4a9-927c-4ca3-a790-c9c859ac7302.json +0 -0
  54. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/fd337567-df67-41ca-ae94-9d84d8d6042f.json +0 -0
  55. /package/templates/starter/bundle/{bundle.json → content/bundle.json} +0 -0
  56. /package/templates/starter/bundle/{nodes → content/nodes}/634dd948-cf83-4cad-9b7b-7c938b33da76.json +0 -0
  57. /package/templates/starter/bundle/{nodes → content/nodes}/64d95359-d91a-4050-b440-3e5124a74999.json +0 -0
  58. /package/templates/starter/bundle/{nodes → content/nodes}/d2997ebb-36ca-47cb-bbc8-31fefca787c0.json +0 -0
@@ -5,17 +5,23 @@
5
5
  * a platform's own tooling — gets the same results as data. One implementation,
6
6
  * so the two can never disagree about what an import or an upgrade does.
7
7
  */
8
- import { ObjectTypes, ROOT_ACCESS } from '@crombolo/core'
8
+ import { userInfo } from 'node:os'
9
+ import { ROOT_ACCESS } from '@crombolo/core'
9
10
  import {
11
+ type AuditCategory,
12
+ bundleStateKey,
13
+ bundleTablesIn,
10
14
  connect,
11
15
  cromboloPlan,
12
16
  type Db,
13
17
  describeRefFailure,
14
18
  type NodeRow,
19
+ type PlannedStep,
15
20
  pauseWrites,
21
+ pendingMigrations,
16
22
  planUpgrade,
23
+ purgeBundleTables,
17
24
  readWritesPaused,
18
- recordInLedger,
19
25
  resolveNodeRef,
20
26
  resumeWrites,
21
27
  type WritesPaused,
@@ -24,66 +30,65 @@ import {
24
30
  import { loadSchemaDirectory, loadValueMigrations, runFix, runUpgrade } from '@crombolo/schema'
25
31
  import {
26
32
  type BackupResult,
33
+ type BundleChain,
34
+ BundleConfigurationError,
27
35
  backupBefore,
28
36
  bootSchema,
29
37
  bootstrapDatabase,
38
+ bundleChainsFor,
39
+ CONTENT_OBJECT_TYPES,
40
+ CommandError,
41
+ type ContentCheckResult,
42
+ type ContentImportInput,
43
+ type ContentImportResult,
30
44
  type CromboloConfig,
45
+ checkBundleContent,
31
46
  checkSchemaDirectory,
32
- componentAliasesIn,
33
47
  createFileIntake,
34
48
  createMediaPort,
49
+ installBundleContent,
35
50
  installDatabase,
36
51
  MediaFileStore,
37
52
  mediaStoreFor,
38
- placeBlobs,
53
+ pendingBundleSteps,
54
+ planBundleUpgrades,
39
55
  readBlobs,
56
+ readBundle,
57
+ recordAudit,
40
58
  syncOptionsFor,
59
+ type TransferInput,
60
+ VERSION,
41
61
  } from '@crombolo/server'
42
62
  import {
43
- type CheckOptions,
44
- type ContentSet,
45
- checkBundle,
63
+ bundleIdFor,
46
64
  exportBundle,
47
- importBundle,
48
- loadBundle,
49
- loadResolutions,
50
- type Resolution,
51
- type TransferCheck,
65
+ isPackageName,
66
+ packageNameFor,
52
67
  writeBundle,
68
+ writeBundlePackage,
53
69
  } from '@crombolo/transfer'
54
- import {
55
- applySections,
56
- type CarriedSections,
57
- carriedSections,
58
- carriesSections,
59
- planSections,
60
- promoteBackups,
61
- replacementsNeedingPermission,
62
- type SectionFile,
63
- type SectionOptions,
64
- type SectionPlan,
65
- type SectionResult,
66
- stageBackups,
67
- } from './sections.ts'
68
70
 
69
- export {
70
- isResolution,
71
- RESOLUTIONS,
72
- type Resolution,
73
- type TransferCheck,
74
- } from '@crombolo/transfer'
75
- // The section half of an install, exported for the same reason the commands
76
- // are: tooling that applies a bundle without a terminal needs the plan too.
71
+ // The installer lives in the server, so the backoffice can run it too; these
72
+ // are exported for the same reason the commands are: tooling that applies a
73
+ // bundle without a terminal needs the plan and the refusals as well.
77
74
  export {
78
75
  applySections,
76
+ type BundleSummary,
79
77
  type CarriedSections,
78
+ CONTENT_OBJECT_TYPES,
79
+ CommandError,
80
+ type ContentCheckResult,
81
+ type ContentImportInput,
82
+ type ContentImportResult,
80
83
  carriedSections,
81
84
  carriesSections,
82
85
  type FileAction,
83
86
  planSections,
84
87
  promoteBackups,
88
+ type ResolvedBundle,
85
89
  type RestoredFiles,
86
90
  RUN_BACKUP_DIR,
91
+ readBundle,
87
92
  replacementsNeedingPermission,
88
93
  restoreSectionFiles,
89
94
  type SectionFile,
@@ -91,299 +96,41 @@ export {
91
96
  type SectionPlan,
92
97
  type SectionResult,
93
98
  stageBackups,
94
- } from './sections.ts'
99
+ type TransferInput,
100
+ transferOptions,
101
+ } from '@crombolo/server'
102
+ export {
103
+ isResolution,
104
+ RESOLUTIONS,
105
+ type Resolution,
106
+ type TransferCheck,
107
+ } from '@crombolo/transfer'
95
108
  export type { BackupResult, WritesPaused }
96
109
 
97
- /** The trees a content selector or placement may name. */
98
- export const CONTENT_OBJECT_TYPES = [ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Element]
99
-
100
- /** A refusal the caller should read: nothing was written. */
101
- export class CommandError extends Error {
102
- constructor(
103
- message: string,
104
- readonly problems: string[] = [],
105
- ) {
106
- super(message)
107
- }
108
- }
109
-
110
- export interface BundleSummary {
111
- id: string
112
- from: string
113
- schemaVersion: string
114
- schemaRevision: string
115
- snapshot: string
116
- nodes: number
117
- blobsCarried: number
118
- blobBytes: number
119
- }
120
-
121
- /**
122
- * The check an install that never reached the content reports.
123
- *
124
- * Nothing was examined, so nothing is outstanding: the refusal is in
125
- * `problems`, and a caller reading `outstanding.length` must not be told there
126
- * were content findings when there were none.
127
- */
128
- const emptyCheck = (bundleId = ''): TransferCheck => ({
129
- bundleId,
130
- findings: [],
131
- outstanding: [],
132
- plan: [],
133
- counts: { create: 0, update: 0, unchanged: 0, skip: 0 },
134
- })
135
-
136
- function summarise(set: ContentSet): BundleSummary {
137
- const carried = set.manifest.blobs.filter((blob) => blob.included)
138
- return {
139
- id: set.manifest.id,
140
- from: set.manifest.provenance.siteName || '',
141
- schemaVersion: set.manifest.provenance.schemaVersion,
142
- schemaRevision: set.manifest.provenance.schemaRevision,
143
- snapshot: set.manifest.snapshot,
144
- nodes: set.nodes.length,
145
- blobsCarried: carried.length,
146
- blobBytes: carried.reduce((total, blob) => total + (blob.size ?? 0), 0),
147
- }
148
- }
149
-
150
- /** Reads a bundle whole, or refuses: a half-copied bundle must never be checked as though complete. */
151
- export function readBundle(dir: string): {
152
- set: ContentSet
153
- blobs: ReadonlyMap<string, string>
154
- /** The sections it carries beyond its content; empty for a content-only bundle. */
155
- carried: CarriedSections
156
- } {
157
- const loaded = loadBundle(dir)
158
- const problems = loaded.problems.map((problem) => `${problem.file}: ${problem.message}`)
159
- if (!loaded.set || problems.length > 0)
160
- throw new CommandError(
161
- 'The bundle is incomplete or damaged; nothing was read from it.',
162
- problems,
163
- )
164
- return { set: loaded.set, blobs: loaded.blobs, carried: carriedSections(loaded) }
165
- }
166
-
167
- export interface TransferInput extends SectionOptions {
168
- /** The bundle's directory on this machine. */
169
- dir: string
170
- /** Where the bundle's roots go: a path or a key. */
171
- under?: string
172
- /** A decision per node key, on top of any the bundle carries. */
173
- resolutions?: Record<string, Resolution>
174
- resolveAll?: Resolution
175
- allowMissingBlobs?: boolean
176
- }
177
-
178
- /** The options `check` and `import` share: saved decisions, then the caller's, then placement. */
179
- export async function transferOptions(
180
- db: Db,
181
- config: CromboloConfig,
182
- set: ContentSet,
183
- input: TransferInput,
184
- ): Promise<{ options: CheckOptions; under: NodeRow | undefined; problems: string[] }> {
185
- const saved = loadResolutions(input.dir, set.manifest.id)
186
- const resolutions = { ...(saved.file?.byNode ?? {}), ...(input.resolutions ?? {}) }
187
- const resolveAll = input.resolveAll ?? saved.file?.all ?? undefined
188
- const underRef = input.under ?? saved.file?.under ?? undefined
189
- let under: NodeRow | undefined
190
- if (underRef) {
191
- const resolved = await resolveNodeRef(db, CONTENT_OBJECT_TYPES, underRef)
192
- if (!resolved.ok) throw new CommandError(describeRefFailure(underRef, resolved))
193
- under = resolved.node
194
- }
195
- // Whether this environment has a media file is a question only its store can
196
- // answer; without it `missing-blob` is a finding the check could never raise.
197
- const store = await mediaStoreFor(config)
198
- return {
199
- options: {
200
- under,
201
- resolutions,
202
- resolveAll,
203
- componentAliases: componentAliasesIn(config.componentsDir),
204
- allowMissingBlobs: input.allowMissingBlobs ?? false,
205
- hasBlob: async (key: string) => Boolean(await store.get(key)),
206
- backOfficePath: config.backOfficePath,
207
- },
208
- under,
209
- problems: saved.problems,
210
- }
211
- }
212
-
213
- export interface ContentCheckResult {
214
- bundle: BundleSummary
215
- under: string | null
216
- check: TransferCheck
217
- /** What the carried sections would do here; undefined for a content-only bundle. */
218
- sections: SectionPlan | undefined
219
- /** Nothing outstanding: the bundle would import cleanly. */
220
- clean: boolean
221
- /** The decisions this check ran with — the bundle's saved ones under the caller's. */
222
- decisions: { all: Resolution | null; byNode: Record<string, Resolution> }
223
- problems: string[]
224
- }
225
-
226
- /** Read-only: what importing a bundle here would do. Its findings go to the Changes dashboard. */
110
+ /** Read-only: what installing a bundle here would do. Its findings go to the Changes dashboard. */
227
111
  export async function contentCheck(
228
112
  config: CromboloConfig,
229
113
  input: TransferInput,
230
114
  ): Promise<ContentCheckResult> {
231
- const { set, carried } = readBundle(input.dir)
232
115
  const { db } = await bootstrapDatabase(config)
233
116
  try {
234
- const prepared = await transferOptions(db, config, set, input)
235
- const check = await checkBundle(db, set, prepared.options)
236
- // Under this bundle's own scope, so two bundles in flight do not resolve each other's.
237
- await writeReport(db, check.findings, { source: 'transfer', scope: set.manifest.id })
238
- return {
239
- bundle: summarise(set),
240
- under: prepared.under ? (prepared.under.text ?? prepared.under.key) : null,
241
- check,
242
- sections: carriesSections(carried)
243
- ? await planSections(db, config, carried, input)
244
- : undefined,
245
- clean: check.outstanding.length === 0,
246
- decisions: {
247
- all: prepared.options.resolveAll ?? null,
248
- byNode: prepared.options.resolutions ?? {},
249
- },
250
- problems: prepared.problems,
251
- }
117
+ return await checkBundleContent(db, config, input)
252
118
  } finally {
253
119
  await db.close()
254
120
  }
255
121
  }
256
122
 
257
- export interface ContentImportInput extends TransferInput {
258
- publish?: boolean
259
- label?: string
260
- /** Postgres: the caller has taken a backup itself. */
261
- backupTaken?: boolean
262
- }
263
-
264
- export interface ContentImportResult {
265
- bundle: BundleSummary
266
- backup: BackupResult
267
- /**
268
- * What the carried sections did, when the bundle carried any.
269
- *
270
- * Applied before the content and outside the content run's ledger entry: the
271
- * files are now in `schema/` and the views directory, where the repository
272
- * owns them — so a revert takes the content back and git takes the files.
273
- */
274
- sections: SectionResult | undefined
275
- /** Where the originals of any replaced files were kept, for a revert. */
276
- replacedFilesKept?: string | undefined
277
- /** Undefined when the import was refused; then nothing was written. */
278
- runId: string | undefined
279
- check: TransferCheck
280
- blobs: { placed: number; bytes: number } | null
281
- published: number
282
- publishFailures: { key: string; reason: string }[]
283
- problems: string[]
284
- }
285
-
286
- /** Re-check, back up, then apply in one transaction and record the run. */
123
+ /** Back up, re-check, then apply in one transaction and record the run. */
287
124
  export async function contentImport(
288
125
  config: CromboloConfig,
289
126
  input: ContentImportInput,
290
127
  ): Promise<ContentImportResult> {
291
- const { set, blobs, carried } = readBundle(input.dir)
292
- // The coarse layer, under the fine-grained revert: in-place conversion is not
293
- // reversible by itself, so the same rule as `upgrade` applies here.
294
- const backup = await backupBefore(config, {
295
- reason: 'content-import',
296
- backupTaken: input.backupTaken,
297
- })
128
+ // Read before anything is opened or backed up, so a bundle that is not one is
129
+ // refused before a backup is taken on its behalf.
130
+ readBundle(input.bundle, config.siteDir)
298
131
  const { db } = await bootstrapDatabase(config)
299
132
  try {
300
- const prepared = await transferOptions(db, config, set, input)
301
- const started = performance.now()
302
-
303
- /**
304
- * Structure first, but planned before anything is written.
305
- *
306
- * The order is forced: content naming a type this bundle brings cannot pass
307
- * a check until that type exists, so the schema has to land before the
308
- * content is checked. Which means a blocking schema change has to be caught
309
- * *before* the first file is written — otherwise a refused install leaves a
310
- * half-applied site behind. `planSections` answers that from an overlay
311
- * copy, so the site is untouched until it is known the schema can apply.
312
- */
313
- const plan = carriesSections(carried)
314
- ? await planSections(db, config, carried, input)
315
- : undefined
316
- const refuse = (problems: string[]): ContentImportResult => ({
317
- bundle: summarise(set),
318
- backup,
319
- runId: undefined,
320
- check: emptyCheck(set.manifest.id),
321
- blobs: null,
322
- published: 0,
323
- publishFailures: [],
324
- problems,
325
- sections: undefined,
326
- })
327
-
328
- if (plan?.schema?.classification === 'breaking')
329
- return refuse([
330
- 'the schema this bundle carries cannot be applied here without data work',
331
- ...plan.schema.findings,
332
- ])
333
-
334
- /**
335
- * A file this site already has is not replaced without being asked.
336
- *
337
- * Every section is editable in the backoffice — a stylesheet, a template, a
338
- * content type — so an overwrite destroys somebody's work, and the site
339
- * may have no git to recover it from. Refused by name, like any other
340
- * finding a person has to answer.
341
- */
342
- const needingPermission = plan ? replacementsNeedingPermission(plan, input) : []
343
- if (needingPermission.length > 0)
344
- return refuse([
345
- `${needingPermission.length} file(s) here would be replaced; pass --replace-files to allow it`,
346
- ...needingPermission.map((file: SectionFile) => file.path),
347
- ])
348
-
349
- // Staged whenever something *will* be replaced — which is decided by the
350
- // plan, not by the permission check: by this point permission has been
351
- // given, so the files needing it are exactly the ones to back up.
352
- const willReplace = (plan?.files ?? []).some((file) => file.action === 'overwrite')
353
- const staging = plan && willReplace ? stageBackups(config) : undefined
354
- const sections = plan
355
- ? await applySections(db, config, carried, { ...input, backupDir: staging })
356
- : undefined
357
- const result = await importBundle(db, set, {
358
- ...prepared.options,
359
- publish: input.publish ?? false,
360
- nodeId: config.nodeId,
361
- label: input.label,
362
- })
363
- await writeReport(db, result.check.findings, { source: 'transfer', scope: set.manifest.id })
364
- const outcome: ContentImportResult = {
365
- bundle: summarise(set),
366
- backup,
367
- runId: result.runId,
368
- check: result.check,
369
- blobs: null,
370
- published: result.published.length,
371
- publishFailures: result.publishFailures,
372
- problems: prepared.problems,
373
- sections,
374
- }
375
- if (!result.runId) return outcome
376
- // The backups become the run's, so `content revert` can find them.
377
- if (staging) outcome.replacedFilesKept = promoteBackups(config, staging, result.runId)
378
- if (blobs.size > 0) outcome.blobs = await placeBlobs(await mediaStoreFor(config), blobs)
379
- await recordInLedger(db, {
380
- name: `content import ${set.manifest.id}`,
381
- kind: 'transfer',
382
- durationMs: Math.round(performance.now() - started),
383
- appliedBy: config.nodeId,
384
- note: `run ${result.runId}`,
385
- })
386
- return outcome
133
+ return await installBundleContent(db, config, input)
387
134
  } finally {
388
135
  await db.close()
389
136
  }
@@ -396,24 +143,38 @@ export interface ContentExportInput {
396
143
  drafts?: boolean
397
144
  withBlobs?: boolean
398
145
  withBlueprints?: boolean
146
+ /**
147
+ * The private package the export is written as. The name defaults to one
148
+ * derived from the bundle's id, the version to `1.0.0`.
149
+ */
150
+ package?: { name?: string; version?: string }
399
151
  }
400
152
 
401
153
  export interface ContentExportResult {
402
154
  bundleId: string
155
+ /** The package the files make up. */
156
+ package: { name: string; version: string }
403
157
  snapshot: string
404
158
  counts: Record<string, number>
405
159
  expected: { key: string; name: string; why: string }[]
406
- /** The bundle's files, for the caller to write wherever it is going. */
160
+ /** The package's files — `package.json` and the payload under `content/` — for the caller to place. */
407
161
  files: { path: string; bytes?: Uint8Array; text?: string }[]
408
162
  blobs: { carried: number; bytes: number; missing: string[]; notCarried: number }
409
163
  }
410
164
 
411
- /** Writes nothing: returns the bundle's files for the caller to place. */
165
+ /**
166
+ * Writes nothing: returns the export as a private package's files, for the
167
+ * caller to place. An environment's export is a bundle like any other, but one
168
+ * applied from its directory and never published or depended on.
169
+ */
412
170
  export async function contentExport(
413
171
  config: CromboloConfig,
414
172
  input: ContentExportInput,
415
173
  ): Promise<ContentExportResult> {
416
174
  if (input.roots.length === 0) throw new CommandError('An export needs at least one root.')
175
+ const requested = input.package?.name
176
+ if (requested !== undefined && !isPackageName(requested))
177
+ throw new CommandError(`"${requested}" is not a package name npm accepts.`)
417
178
  const { db } = await bootstrapDatabase(config)
418
179
  try {
419
180
  // A path is resolved against the tree it is exported from, and the key is
@@ -443,8 +204,13 @@ export async function contentExport(
443
204
  carried = read.bytes
444
205
  missing = read.missing
445
206
  }
207
+ const pkg = {
208
+ name: requested ?? packageNameFor(`export-${set.manifest.id}`),
209
+ version: input.package?.version ?? '1.0.0',
210
+ }
446
211
  return {
447
212
  bundleId: set.manifest.id,
213
+ package: pkg,
448
214
  snapshot: set.manifest.snapshot,
449
215
  counts: { ...set.manifest.counts },
450
216
  expected: set.manifest.dependencies.expected.map((e) => ({
@@ -452,7 +218,13 @@ export async function contentExport(
452
218
  name: e.name,
453
219
  why: e.why,
454
220
  })),
455
- files: writeBundle(set, carried),
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
+ }),
456
228
  blobs: {
457
229
  carried: carried.size,
458
230
  bytes: [...carried.values()].reduce((total, file) => total + file.length, 0),
@@ -465,7 +237,21 @@ export async function contentExport(
465
237
  }
466
238
  }
467
239
 
240
+ /** The wired bundles' chains, checked as boot checks them, or a refusal naming every problem. */
241
+ function chainsFor(config: CromboloConfig): BundleChain[] {
242
+ try {
243
+ return bundleChainsFor(config)
244
+ } catch (error) {
245
+ if (error instanceof BundleConfigurationError)
246
+ throw new CommandError('The configured bundles cannot be used.', error.problems)
247
+ throw error
248
+ }
249
+ }
250
+
251
+ const flat = (sql: string) => sql.replace(/\s+/g, ' ').trim()
252
+
468
253
  async function upgradeContext(config: CromboloConfig, set: Record<string, unknown>) {
254
+ const chains = chainsFor(config)
469
255
  const db = await connect({ file: config.sqliteFile })
470
256
  const node = { nodeId: config.nodeId, revision: config.schemaRevision }
471
257
  const loaded = () => loadSchemaDirectory(config.schemaDir)
@@ -476,33 +262,45 @@ async function upgradeContext(config: CromboloConfig, set: Record<string, unknow
476
262
  migrations: await loadValueMigrations(config.schemaDir),
477
263
  backOfficePath: config.backOfficePath,
478
264
  })
479
- return { db, node, loaded, syncNode, checkOptions }
265
+ return { db, node, loaded, syncNode, checkOptions, chains }
266
+ }
267
+
268
+ export interface PlannedStepResult {
269
+ name: string
270
+ kind: string
271
+ release: string | null
272
+ statements: string[]
273
+ unplannable: string | null
480
274
  }
481
275
 
482
276
  export interface UpgradePlanResult {
483
- steps: {
484
- name: string
485
- kind: string
486
- release: string | null
487
- statements: string[]
488
- unplannable: string | null
489
- }[]
277
+ steps: PlannedStepResult[]
278
+ /** Each wired bundle that owns tables, with the steps it would run after core's. */
279
+ bundles: { id: string; name: string; steps: PlannedStepResult[] }[]
490
280
  site: { checked: boolean; action: string | null; classification: string | null }
491
281
  }
492
282
 
283
+ const plannedStep = (step: PlannedStep): PlannedStepResult => ({
284
+ name: step.name,
285
+ kind: step.kind,
286
+ release: step.release ?? null,
287
+ statements: (step.statements ?? []).map(flat),
288
+ unplannable: step.unplannable ?? null,
289
+ })
290
+
493
291
  /** The DDL every pending framework step would run, and what the site's schema would do. Writes nothing. */
494
292
  export async function upgradePlan(config: CromboloConfig): Promise<UpgradePlanResult> {
495
- const { db, node } = await upgradeContext(config, {})
293
+ const { db, node, chains } = await upgradeContext(config, {})
496
294
  try {
497
295
  const steps = await planUpgrade(db, cromboloPlan)
296
+ const bundles = await planBundleUpgrades(db, chains)
498
297
  const check = await checkSchemaDirectory(db, config, node)
499
298
  return {
500
- steps: steps.map((step) => ({
501
- name: step.name,
502
- kind: step.kind,
503
- release: step.release ?? null,
504
- statements: (step.statements ?? []).map((sql) => sql.replace(/\s+/g, ' ').trim()),
505
- unplannable: step.unplannable ?? null,
299
+ steps: steps.map(plannedStep),
300
+ bundles: bundles.map((bundle) => ({
301
+ id: bundle.id,
302
+ name: bundle.name,
303
+ steps: bundle.steps.map(plannedStep),
506
304
  })),
507
305
  site: {
508
306
  checked: check.siteChecked,
@@ -520,11 +318,17 @@ export async function upgradeCheck(
520
318
  config: CromboloConfig,
521
319
  input: { set?: Record<string, unknown> } = {},
522
320
  ) {
523
- const { db, node } = await upgradeContext(config, input.set ?? {})
321
+ const { db, node, chains } = await upgradeContext(config, input.set ?? {})
524
322
  try {
525
323
  const report = await checkSchemaDirectory(db, config, node, { set: input.set ?? {} })
526
324
  if (report.siteChecked) await writeReport(db, report.findings, { source: 'upgrade' })
325
+ const bundles = await pendingBundleSteps(db, chains)
527
326
  return {
327
+ bundles: bundles.map((bundle) => ({
328
+ id: bundle.id,
329
+ name: bundle.name,
330
+ pending: bundle.pending.map((step) => ({ name: step.name, kind: step.kind ?? 'expand' })),
331
+ })),
528
332
  siteChecked: report.siteChecked,
529
333
  classification: report.classification,
530
334
  findings: report.findings,
@@ -542,9 +346,12 @@ export async function upgradeFix(
542
346
  input: { set?: Record<string, unknown>; backupTaken?: boolean } = {},
543
347
  ) {
544
348
  const backup = await backupBefore(config, { reason: 'fix', backupTaken: input.backupTaken })
545
- const { db, loaded, syncNode, checkOptions } = await upgradeContext(config, input.set ?? {})
349
+ const { db, loaded, syncNode, checkOptions, chains } = await upgradeContext(
350
+ config,
351
+ input.set ?? {},
352
+ )
546
353
  try {
547
- await installDatabase(db, config)
354
+ await installDatabase(db, config, chains)
548
355
  const fix = await runFix(db, loaded(), syncNode(), {
549
356
  ...(await checkOptions()),
550
357
  by: config.nodeId,
@@ -574,6 +381,8 @@ export type UpgradeRunResult =
574
381
  action: 'upgraded'
575
382
  backup: BackupResult
576
383
  frameworkApplied: string[]
384
+ /** Each bundle's steps that ran, after core's. */
385
+ bundlesApplied: { id: string; applied: string[] }[]
577
386
  schema: { action: string; version: string | null; revision: string | null } | null
578
387
  }
579
388
 
@@ -583,9 +392,19 @@ export async function upgradeRun(
583
392
  input: { force?: string; set?: Record<string, unknown>; backupTaken?: boolean } = {},
584
393
  ): Promise<UpgradeRunResult> {
585
394
  const backup = await backupBefore(config, { reason: 'upgrade', backupTaken: input.backupTaken })
586
- const { db, loaded, syncNode, checkOptions } = await upgradeContext(config, input.set ?? {})
395
+ const { db, loaded, syncNode, checkOptions, chains } = await upgradeContext(
396
+ config,
397
+ input.set ?? {},
398
+ )
587
399
  try {
588
- await installDatabase(db, config)
400
+ // Read before anything runs: `installDatabase` applies core's steps and then
401
+ // each bundle's, so this is what the upgrade itself applied.
402
+ const corePending = (await pendingMigrations(db, cromboloPlan)).map((step) => step.name)
403
+ const bundlesPending = await pendingBundleSteps(db, chains)
404
+ await installDatabase(db, config, chains)
405
+ const bundlesApplied = bundlesPending
406
+ .filter((bundle) => bundle.pending.length > 0)
407
+ .map((bundle) => ({ id: bundle.id, applied: bundle.pending.map((step) => step.name) }))
589
408
  const result = await runUpgrade(db, loaded(), syncNode(), {
590
409
  ...(await checkOptions()),
591
410
  by: config.nodeId,
@@ -598,11 +417,14 @@ export async function upgradeRun(
598
417
  backup,
599
418
  reasons: result.reasons.map((f) => ({ kind: f.kind, message: f.message, link: f.link })),
600
419
  }
601
- if (result.action === 'nothing') return { action: 'nothing', backup }
420
+ const frameworkApplied = [...corePending, ...result.frameworkApplied]
421
+ if (result.action === 'nothing' && frameworkApplied.length === 0 && bundlesApplied.length === 0)
422
+ return { action: 'nothing', backup }
602
423
  return {
603
424
  action: 'upgraded',
604
425
  backup,
605
- frameworkApplied: result.frameworkApplied,
426
+ frameworkApplied,
427
+ bundlesApplied,
606
428
  schema: result.sync
607
429
  ? {
608
430
  action: result.sync.action,
@@ -746,6 +568,44 @@ export async function backup(
746
568
  })
747
569
  }
748
570
 
571
+ export interface CommandAudit {
572
+ category: AuditCategory
573
+ action: string
574
+ /** The command as typed, without its arguments, which can hold a password. */
575
+ command: string
576
+ entityType?: string
577
+ entityKey?: string
578
+ }
579
+
580
+ /**
581
+ * Records a command that changed this environment, in the audit log, as the
582
+ * account that ran it — `CROMBOLO_ACTOR` when a pipeline names who it runs for.
583
+ * Never fails the command: what it records has already happened.
584
+ */
585
+ export async function recordCommand(config: CromboloConfig, entry: CommandAudit): Promise<void> {
586
+ let actor = Bun.env.CROMBOLO_ACTOR
587
+ try {
588
+ actor ??= userInfo().username
589
+ } catch {
590
+ actor ??= undefined
591
+ }
592
+ try {
593
+ await withDatabase(config, (db) =>
594
+ recordAudit(db, {
595
+ category: entry.category,
596
+ action: entry.action,
597
+ source: 'cli',
598
+ userName: actor ?? null,
599
+ entityType: entry.entityType ?? null,
600
+ entityKey: entry.entityKey ?? null,
601
+ detail: entry.command,
602
+ }),
603
+ )
604
+ } catch {
605
+ // No database to write to, or one without the log yet: the command still happened.
606
+ }
607
+ }
608
+
749
609
  async function withDatabase<T>(config: CromboloConfig, use: (db: Db) => Promise<T>): Promise<T> {
750
610
  const db = await connect({ file: config.sqliteFile })
751
611
  try {
@@ -773,3 +633,54 @@ export function resumeEditing(config: CromboloConfig): Promise<void> {
773
633
  export function editingPaused(config: CromboloConfig): Promise<WritesPaused | undefined> {
774
634
  return withDatabase(config, readWritesPaused)
775
635
  }
636
+
637
+ export interface BundlePurgePlan {
638
+ id: string
639
+ /** The tables that would be dropped, as the database names them. */
640
+ tables: string[]
641
+ }
642
+
643
+ /** Refuses a purge of a bundle the site still wires: its code would recreate what was dropped. */
644
+ function refuseWired(config: CromboloConfig, id: string): void {
645
+ if (config.bundles.some((bundle) => bundle.id === id))
646
+ throw new CommandError(
647
+ `The bundle "${id}" is still wired in crombolo.config.ts. Take it out of \`bundles\` and deploy that first.`,
648
+ )
649
+ }
650
+
651
+ /** Read-only: what purging a bundle would drop. */
652
+ export async function bundlePurgePlan(
653
+ config: CromboloConfig,
654
+ id: string,
655
+ ): Promise<BundlePurgePlan> {
656
+ refuseWired(config, id)
657
+ const db = await connect({ file: config.sqliteFile })
658
+ try {
659
+ return { id, tables: await bundleTablesIn(db, id) }
660
+ } finally {
661
+ await db.close()
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Backs up, then drops every table under the bundle's prefix and forgets its
667
+ * state, ledgered as a `purge` against the bundle.
668
+ */
669
+ export async function bundlePurge(
670
+ config: CromboloConfig,
671
+ id: string,
672
+ input: { backupTaken?: boolean } = {},
673
+ ): Promise<{ backup: BackupResult; dropped: string[] }> {
674
+ refuseWired(config, id)
675
+ const backup = await backupBefore(config, { reason: 'purge', backupTaken: input.backupTaken })
676
+ const db = await connect({ file: config.sqliteFile })
677
+ try {
678
+ const dropped = await purgeBundleTables(db, id, {
679
+ by: config.nodeId,
680
+ stateKey: bundleStateKey(id),
681
+ })
682
+ return { backup, dropped }
683
+ } finally {
684
+ await db.close()
685
+ }
686
+ }