@crombolo/cli 0.13.1 → 0.14.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.
Files changed (28) hide show
  1. package/bin/crombolo.ts +289 -22
  2. package/package.json +7 -7
  3. package/src/operations/commands.ts +108 -4
  4. package/templates/basic/bundle/package.json +1 -1
  5. package/templates/demo/harbourstone/bundle/content/bundle.json +11 -4
  6. package/templates/demo/harbourstone/bundle/content/nodes/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +7 -0
  7. package/templates/demo/harbourstone/bundle/content/nodes/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +7 -0
  8. package/templates/demo/harbourstone/bundle/content/nodes/9468d1b4-f521-48ef-b044-2ab07b461b96.json +42 -0
  9. package/templates/demo/harbourstone/bundle/content/nodes/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +7 -0
  10. package/templates/demo/harbourstone/bundle/content/nodes/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +7 -0
  11. package/templates/demo/harbourstone/bundle/package.json +1 -1
  12. package/templates/demo/harbourstone/files/components/pages/searchPage.tsx +76 -0
  13. package/templates/demo/harbourstone/files/components/shared/site-header.tsx +13 -1
  14. package/templates/demo/harbourstone/files/css/site.css +113 -0
  15. package/templates/demo/harbourstone/files/schema/content-types/contact-page.toml +2 -0
  16. package/templates/demo/harbourstone/files/schema/content-types/content-page.toml +2 -0
  17. package/templates/demo/harbourstone/files/schema/content-types/expression.toml +8 -0
  18. package/templates/demo/harbourstone/files/schema/content-types/home-page.toml +5 -1
  19. package/templates/demo/harbourstone/files/schema/content-types/search-page.toml +33 -0
  20. package/templates/demo/harbourstone/files/schema/content-types/whisky-list.toml +2 -0
  21. package/templates/starter/bundle/content/bundle.json +12 -5
  22. package/templates/starter/bundle/content/nodes/c720ef2a-b5c8-48ec-a504-065e8188d4aa.json +28 -0
  23. package/templates/starter/bundle/package.json +1 -1
  24. package/templates/starter/files/components/pages/searchPage.tsx +57 -0
  25. package/templates/starter/files/components/shared/site-header.tsx +11 -2
  26. package/templates/starter/files/css/site.css +66 -0
  27. package/templates/starter/files/schema/content-types/content-page.toml +4 -1
  28. package/templates/starter/files/schema/content-types/search-page.toml +21 -0
package/bin/crombolo.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * crombolo start boot the site; resets and prints the admin password
7
7
  * crombolo start --keep-admin boot without touching the admin password
8
8
  * crombolo start --bundle <pkg> apply a bundle's content, once, then serve
9
+ * crombolo start --from <file> in an empty folder: a working site from a whole-site file
9
10
  * crombolo init scaffold a site in the current directory
10
11
  * crombolo init --template <id> …from a starter template, content included
11
12
  * crombolo status what this environment is, without starting it
@@ -20,6 +21,7 @@
20
21
  * crombolo components check compile every component before a visitor does
21
22
  * crombolo assets list the stylesheets and scripts beside them
22
23
  *
24
+ * crombolo export --site write the whole site to one file, to move it to another machine or host
23
25
  * crombolo content export write a subtree as a private bundle package, to move to another environment
24
26
  * crombolo content check read-only: what installing a bundle's content here would do
25
27
  * crombolo content import apply a bundle's content, in one transaction, and record the run
@@ -29,6 +31,7 @@
29
31
  * crombolo domains the hostnames this site answers on
30
32
  * crombolo domains set write domains.toml and apply it
31
33
  *
34
+ * crombolo import <file> replace this site with a whole-site file; says what first
32
35
  * crombolo import umbraco report read-only: what importing an Umbraco backup would and would not bring
33
36
  * crombolo import umbraco apply write a crombolo site from an Umbraco backup
34
37
  *
@@ -213,7 +216,91 @@ function stopOnSignal(
213
216
  for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => stop(signal))
214
217
  }
215
218
 
219
+ /**
220
+ * `start --from <file>` (story 105): an empty folder in, a working site out —
221
+ * scaffolded as `init` scaffolds one, its packages installed, and the file
222
+ * imported as a replace so nothing of the scaffold's own starting point stays.
223
+ * The new administrator's password is printed here, once.
224
+ */
225
+ async function startFromFile(from: string): Promise<void> {
226
+ const file = resolve(cwd, from)
227
+ if (!existsSync(file)) {
228
+ console.error(`${from} does not exist.`)
229
+ process.exit(1)
230
+ }
231
+ if (existsSync(join(cwd, 'crombolo.config.ts'))) {
232
+ console.error(
233
+ 'There is a site here already. `crombolo import <file> --replace` replaces it; start --from starts a new one in an empty folder.',
234
+ )
235
+ process.exit(1)
236
+ }
237
+ const dir = await ops.unpackBundle(file, cwd)
238
+ const manifest = ops.readBundle(dir, cwd).set.manifest
239
+ if (manifest.scope !== 'site') {
240
+ console.error(`${from} is not a whole-site file: write one with \`crombolo export --site\`.`)
241
+ process.exit(1)
242
+ }
243
+ const passphrase = manifest.people
244
+ ? (process.env[PASSPHRASE_ENV] ??
245
+ (process.stdin.isTTY ? await readSecret(' Passphrase for its people: ') : undefined))
246
+ : undefined
247
+ if (manifest.people && !passphrase) {
248
+ console.error(`${from} carries people, sealed: set ${PASSPHRASE_ENV} to its passphrase.`)
249
+ process.exit(1)
250
+ }
251
+
252
+ for (const scaffolded of scaffoldFiles({
253
+ siteName: flagValue('--name') ?? manifest.provenance.siteName,
254
+ postgres: flags.has('--postgres'),
255
+ })) {
256
+ const target = join(cwd, scaffolded.path)
257
+ if (existsSync(target)) continue
258
+ await mkdir(join(target, '..'), { recursive: true })
259
+ if (scaffolded.copyFrom === undefined) await writeFile(target, scaffolded.text ?? '')
260
+ else await Bun.write(target, Bun.file(scaffolded.copyFrom))
261
+ }
262
+ console.log(` scaffolded a site for ${manifest.provenance.siteName || 'the file'}`)
263
+ if (!flags.has('--no-install')) {
264
+ const install = Bun.spawnSync(['bun', 'install'], { cwd, stdout: 'inherit', stderr: 'inherit' })
265
+ if (install.exitCode !== 0) {
266
+ console.error('`bun install` failed; run it, then `crombolo import <file> --replace`.')
267
+ process.exit(1)
268
+ }
269
+ }
270
+
271
+ const config = await siteConfig()
272
+ const { db, seededAdminPassword } = await bootstrapDatabase(config)
273
+ await db.close()
274
+ const result = await ops.contentImport(config, {
275
+ bundle: dir,
276
+ passphrase,
277
+ replace: 'replace',
278
+ allowMissingBlobs: flags.has('--allow-missing-media'),
279
+ backupTaken: true,
280
+ label: `start --from ${basename(file)}`,
281
+ })
282
+ for (const problem of result.problems) console.error(` ${problem}`)
283
+ if (!result.runId) {
284
+ printTransferFindings(result.check)
285
+ console.error('\nThe file could not be imported; the scaffolded site is left as it is.')
286
+ process.exit(1)
287
+ }
288
+ console.log(` imported ${result.check.counts.create} item(s), run ${result.runId}`)
289
+ const placed = describePlaced(result.blobs)
290
+ if (placed) console.log(` media ${placed}`)
291
+ if (seededAdminPassword)
292
+ banner([
293
+ 'Sign in with (shown once)',
294
+ ` username ${config.adminLogin}`,
295
+ ` password ${seededAdminPassword}`,
296
+ ])
297
+ // The password above is the one to use: this first start leaves it alone.
298
+ flags.add('--keep-admin')
299
+ }
300
+
216
301
  async function start(): Promise<void> {
302
+ const from = flagValue('--from')
303
+ if (from) await startFromFile(from)
217
304
  const config = await siteConfig()
218
305
  // Before the migration and the seed, so a clash with the container stack is
219
306
  // reported in a second rather than after the database work.
@@ -1145,10 +1232,12 @@ async function contentRevert(config: CromboloConfig): Promise<void> {
1145
1232
  // The files an install replaced come back from the run's own copies, not
1146
1233
  // from git: these files are editable in the backoffice, so a site need not
1147
1234
  // be a checkout at all.
1148
- const files = ops.restoreSectionFiles(config, runId)
1235
+ const files = await ops.revertSections(db, config, runId)
1149
1236
  for (const file of files.restored) console.log(` restored ${relative(cwd, file)}`)
1150
1237
  for (const file of files.removed)
1151
1238
  console.log(` removed ${relative(cwd, file)} (created by that run)`)
1239
+ if (files.schema) console.log(` schema ${files.schema}`)
1240
+ if (files.dictionary) console.log(' restored the dictionary')
1152
1241
 
1153
1242
  for (const key of result.restored) console.log(` restored ${key}`)
1154
1243
  for (const key of result.recycled) console.log(` recycled ${key} (created by that run)`)
@@ -1269,27 +1358,10 @@ async function contentImport(config: CromboloConfig): Promise<void> {
1269
1358
 
1270
1359
  if (!result.runId) {
1271
1360
  printTransferFindings(result.check)
1272
- // Sections are applied before the content is checked, because content
1273
- // naming a type the bundle brings cannot pass a check until that type
1274
- // exists. So a refusal here may still have written files, and saying
1275
- // otherwise would send somebody looking in the wrong place.
1276
- if (result.sections) {
1277
- console.error(
1278
- `\nimport refused, but this bundle's structure was already applied: ` +
1279
- `${result.sections.written.length} file(s) written${
1280
- result.sections.schema ? `, schema ${result.sections.schema.action}` : ''
1281
- }.`,
1282
- )
1283
- for (const file of result.sections.written) console.error(` ${relative(cwd, file)}`)
1284
- if (result.sections.replaced.length > 0)
1285
- console.error(
1286
- ` the originals are under ${relative(cwd, join(config.siteDir, ops.RUN_BACKUP_DIR))}`,
1287
- )
1288
- } else {
1289
- console.error(
1290
- `\nimport refused: ${result.check.outstanding.length} finding(s) outstanding. Nothing was written.`,
1291
- )
1292
- }
1361
+ // Staged, then applied in one transaction: a refusal leaves nothing behind.
1362
+ console.error(
1363
+ `\nimport refused: ${result.check.outstanding.length || result.problems.length} finding(s) outstanding. Nothing was written.`,
1364
+ )
1293
1365
  process.exit(1)
1294
1366
  }
1295
1367
 
@@ -1612,6 +1684,62 @@ async function contentExport(config: CromboloConfig): Promise<void> {
1612
1684
  if (counts.length === 0) process.exit(1)
1613
1685
  }
1614
1686
 
1687
+ /** `crombolo export --site`: the whole site as one file (story 104). */
1688
+ async function exportCommand(): Promise<void> {
1689
+ if (!flags.has('--site')) {
1690
+ console.error(
1691
+ 'export needs --site: the whole site, as one file. For a part of it, use content export.',
1692
+ )
1693
+ process.exit(1)
1694
+ }
1695
+ const config = await siteConfig()
1696
+ const withPeople = flags.has('--with-members') || flags.has('--with-users')
1697
+ const anonymise = flags.has('--anonymise')
1698
+ const out = resolve(
1699
+ flagValue('--out') ??
1700
+ `${packageNameFor(`${config.siteName}-site`)}-${new Date().toISOString().slice(0, 10)}.tgz`,
1701
+ )
1702
+ const result = await ops.siteExport(config, {
1703
+ out,
1704
+ media: !flags.has('--no-media'),
1705
+ drafts: flags.has('--drafts'),
1706
+ withMembers: flags.has('--with-members'),
1707
+ withUsers: flags.has('--with-users'),
1708
+ anonymise,
1709
+ passphrase:
1710
+ withPeople && !(anonymise && !flags.has('--with-members'))
1711
+ ? await passphraseToSeal()
1712
+ : undefined,
1713
+ package: { name: flagValue('--name'), version: flagValue('--package-version') },
1714
+ })
1715
+ const counts = Object.entries(result.counts)
1716
+ console.log(` wrote ${relative(cwd, result.out) || result.out}`)
1717
+ console.log(` package ${result.package.name}@${result.package.version} (private)`)
1718
+ console.log(
1719
+ ` content ${counts.length > 0 ? counts.map(([kind, n]) => `${n} ${kind}`).join(', ') : 'none'}, unencrypted`,
1720
+ )
1721
+ const sections = Object.entries(result.sections)
1722
+ if (sections.length > 0)
1723
+ console.log(` files ${sections.map(([section, n]) => `${n} ${section}`).join(', ')}`)
1724
+ if (result.media.omitted)
1725
+ console.log(
1726
+ ' media left out (--no-media): importing it brings the media items without their files',
1727
+ )
1728
+ else
1729
+ console.log(` media ${result.media.carried} file(s), ${describeBytes(result.media.bytes)}`)
1730
+ for (const key of result.media.missing)
1731
+ console.error(` missing ${key} is not in this environment's media store`)
1732
+ if (result.people.members + result.people.users > 0)
1733
+ console.log(
1734
+ ` people ${result.people.members} member(s), ${result.people.users} user(s), encrypted`,
1735
+ )
1736
+ else console.log(' people left out: pass --with-members or --with-users to bring them')
1737
+ if (result.anonymised !== undefined)
1738
+ console.log(
1739
+ ` anonymised ${result.anonymised} email address(es) replaced; no back-office users`,
1740
+ )
1741
+ }
1742
+
1615
1743
  /**
1616
1744
  * `schema-as-code.md` leaves the dictionary in the database, because
1617
1745
  * translators work in production where `schema/` is read-only — so this is how
@@ -1804,6 +1932,117 @@ async function applyDomains(config: CromboloConfig): Promise<void> {
1804
1932
  }
1805
1933
  }
1806
1934
 
1935
+ /**
1936
+ * `crombolo import <file>`: replace this site with a whole-site file (story
1937
+ * 105). It always says first what would be replaced, and changes nothing
1938
+ * without `--replace` or `--replace-all`.
1939
+ */
1940
+ async function importFile(file: string): Promise<void> {
1941
+ const config = await siteConfig()
1942
+ const replace: ops.ReplaceMode | undefined = flags.has('--replace-all')
1943
+ ? 'replace-all'
1944
+ : flags.has('--replace')
1945
+ ? 'replace'
1946
+ : undefined
1947
+ const dir = await ops.unpackBundle(file, config.siteDir)
1948
+ let carriesPeople = false
1949
+ try {
1950
+ carriesPeople = ops.readBundle(dir, config.siteDir).set.manifest.people !== undefined
1951
+ } catch (error) {
1952
+ console.error(` ${error instanceof Error ? error.message : String(error)}`)
1953
+ process.exit(1)
1954
+ }
1955
+ const passphrase = carriesPeople
1956
+ ? (process.env[PASSPHRASE_ENV] ??
1957
+ (process.stdin.isTTY ? await readSecret(' Passphrase for its people: ') : undefined))
1958
+ : await passphraseToOpen(dir, config)
1959
+ const allowMissingBlobs = flags.has('--allow-missing-media')
1960
+
1961
+ const { check, summary } = await ops.siteReplaceCheck(config, {
1962
+ bundle: dir,
1963
+ passphrase,
1964
+ replace: replace ?? 'replace',
1965
+ allowMissingBlobs,
1966
+ })
1967
+ describeBundle(check.bundle)
1968
+ const c = check.check.counts
1969
+ console.log(` brings ${c.create} new, ${c.update} changed, ${c.unchanged} unchanged`)
1970
+ const leaving = Object.entries(summary.leaving)
1971
+ const fate = replace === 'replace-all' ? 'deleted' : 'recycled'
1972
+ if (leaving.length > 0)
1973
+ console.log(
1974
+ ` ${fate.padEnd(8)} ${leaving.map(([kind, n]) => `${n} ${kind}`).join(', ')} not in the file`,
1975
+ )
1976
+ if (summary.filesRemoved.length > 0) {
1977
+ console.log(
1978
+ ` removes ${summary.filesRemoved.length} file(s) not in the file, kept for a revert:`,
1979
+ )
1980
+ for (const path of summary.filesRemoved.slice(0, 20)) console.log(` ${relative(cwd, path)}`)
1981
+ if (summary.filesRemoved.length > 20)
1982
+ console.log(` … and ${summary.filesRemoved.length - 20} more`)
1983
+ }
1984
+ if (replace === 'replace-all' && summary.people.members + summary.people.users > 0)
1985
+ console.log(
1986
+ ` deletes ${summary.people.members} member(s) and ${summary.people.users} user(s) not in the file`,
1987
+ )
1988
+ console.log(" keeps domains, the secret key and this environment's settings")
1989
+ for (const problem of check.problems) console.error(` ${problem}`)
1990
+
1991
+ if (!replace) {
1992
+ console.error(
1993
+ '\nNothing was changed. Pass --replace to replace this site (what it loses is recycled, people kept), or --replace-all to make it exactly the file (deleted, people too).',
1994
+ )
1995
+ process.exit(1)
1996
+ }
1997
+ if (replace === 'replace-all' && !flags.has('--yes')) {
1998
+ if (!process.stdin.isTTY) {
1999
+ console.error(
2000
+ '\n--replace-all deletes what the file does not have: pass --yes to confirm it.',
2001
+ )
2002
+ process.exit(1)
2003
+ }
2004
+ const answer = prompt(
2005
+ ` This deletes for good. Type the site's name (${config.siteName}) to go on:`,
2006
+ )
2007
+ if (answer?.trim() !== config.siteName) {
2008
+ console.error(' Nothing was changed.')
2009
+ process.exit(1)
2010
+ }
2011
+ }
2012
+
2013
+ const result = await ops.contentImport(config, {
2014
+ bundle: dir,
2015
+ passphrase,
2016
+ replace,
2017
+ allowMissingBlobs,
2018
+ label: `${replace} from ${basename(file)}`,
2019
+ backupTaken: flags.has('--backup-taken'),
2020
+ })
2021
+ for (const problem of result.problems) console.error(` ${problem}`)
2022
+ if (result.backup.path) console.log(` backup ${result.backup.path}`)
2023
+ if (!result.runId) {
2024
+ printTransferFindings(result.check)
2025
+ console.error('\nreplace refused. Nothing was changed.')
2026
+ process.exit(1)
2027
+ }
2028
+ const counts = result.check.counts
2029
+ console.log(` run ${result.runId}`)
2030
+ console.log(
2031
+ ` applied ${counts.create} created, ${counts.update} updated, ${counts.unchanged} unchanged`,
2032
+ )
2033
+ const placed = describePlaced(result.blobs)
2034
+ if (placed) console.log(` media ${placed}`)
2035
+ if (result.sections?.schema) console.log(` schema ${result.sections.schema.action}`)
2036
+ if (result.published > 0) console.log(` published ${result.published} page(s)`)
2037
+ for (const failure of result.publishFailures)
2038
+ console.error(` not published ${failure.key}: ${failure.reason}`)
2039
+ console.log(
2040
+ replace === 'replace-all'
2041
+ ? `\nDone. \`crombolo content revert ${result.runId}\` puts the files and the pages it changed back; what was deleted is in the backup.`
2042
+ : `\nDone. \`crombolo content revert ${result.runId}\` puts it back.`,
2043
+ )
2044
+ }
2045
+
1807
2046
  /**
1808
2047
  * `crombolo import umbraco`: a compatibility report for an Umbraco backup, and
1809
2048
  * then a crombolo site written from it.
@@ -1825,7 +2064,13 @@ async function importSite(): Promise<void> {
1825
2064
  else if (!arg.startsWith('--')) args.push(arg)
1826
2065
  }
1827
2066
  const [kind, sub, backup] = args
2067
+ if (kind && kind !== 'umbraco') return importFile(kind)
1828
2068
  if (kind !== 'umbraco' || (sub !== 'report' && sub !== 'apply')) return help()
2069
+ // Not into --json output, which a machine reads.
2070
+ if (!flags.has('--json'))
2071
+ console.error(
2072
+ ' deprecated `crombolo import umbraco` is going away: an Umbraco extension will write a crombolo file that `crombolo import <file>` reads (story 074)',
2073
+ )
1829
2074
  if (!backup) {
1830
2075
  console.error(`import umbraco ${sub} needs the backup: a .bacpac, or Umbraco's SQLite file.`)
1831
2076
  process.exit(1)
@@ -2272,6 +2517,10 @@ function help(): void {
2272
2517
  start [--keep-admin] boot the site in the current directory
2273
2518
  [--bundle <pkg>] apply a bundle's content first, once per bundle, then serve;
2274
2519
  takes the \`content import\` flags (--publish, --under, …)
2520
+ [--from <file>] in an empty folder: scaffold a site, install it, import a
2521
+ whole-site file (crombolo export --site) and serve it; the
2522
+ new administrator's password is printed once
2523
+ [--postgres] [--name <site name>] [--no-install]
2275
2524
  init [--force] [--name <site name>] [--postgres] [--template <id>]
2276
2525
  scaffold a site here; --template list prints the
2277
2526
  starter templates, which bring schema, views and content
@@ -2298,6 +2547,12 @@ function help(): void {
2298
2547
  assets list the stylesheets and scripts this site has
2299
2548
  assets new <stylesheet|script> <name>
2300
2549
  write an empty one in the place the backoffice edits
2550
+ export --site [--out <file.tgz>]
2551
+ the whole site in one file: schema, components, styles,
2552
+ scripts, dictionary, every page and media item and their
2553
+ files. Domains and people stay behind
2554
+ [--no-media] [--drafts] [--with-members] [--with-users]
2555
+ [--anonymise] replaces every personal value as it writes
2301
2556
  content export --root <path|uuid> --out <dir>
2302
2557
  write a private bundle package: the subtree, its
2303
2558
  dependencies and what the destination must already have
@@ -2351,6 +2606,15 @@ function help(): void {
2351
2606
  domains clear <path|uuid|/> unbind that page, in the file and in the database
2352
2607
  domains apply apply domains.toml here, as a boot does
2353
2608
  domains undo put back the file the last write replaced, and apply it
2609
+ import <file> [--replace | --replace-all]
2610
+ replace this site with a whole-site file. Says first what
2611
+ would be replaced, and changes nothing without a mode:
2612
+ --replace recycles content and media the file lacks and
2613
+ keeps this site's people; --replace-all deletes them, and
2614
+ people the file lacks (typed confirmation, or --yes).
2615
+ Domains, the secret key and settings always stay.
2616
+ Undone with \`content revert <run>\`
2617
+ [--allow-missing-media] [--backup-taken]
2354
2618
  import umbraco report <backup> [--site <dir>] [--media <dir>] [--out <dir>] [--json]
2355
2619
  read-only: what importing an Umbraco backup would bring,
2356
2620
  and what it would not — packages, Razor, custom code.
@@ -2490,6 +2754,9 @@ async function dispatch(): Promise<void> {
2490
2754
  case 'assets':
2491
2755
  await assets()
2492
2756
  break
2757
+ case 'export':
2758
+ await exportCommand()
2759
+ break
2493
2760
  case 'import':
2494
2761
  await importSite()
2495
2762
  break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/cli",
3
- "version": "0.13.1",
3
+ "version": "0.14.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.13.1",
38
- "@crombolo/core": "0.13.1",
39
- "@crombolo/data": "0.13.1",
40
- "@crombolo/schema": "0.13.1",
41
- "@crombolo/server": "0.13.1",
42
- "@crombolo/transfer": "0.13.1"
37
+ "@crombolo/auth": "0.14.1",
38
+ "@crombolo/core": "0.14.1",
39
+ "@crombolo/data": "0.14.1",
40
+ "@crombolo/schema": "0.14.1",
41
+ "@crombolo/server": "0.14.1",
42
+ "@crombolo/transfer": "0.14.1"
43
43
  }
44
44
  }
@@ -5,7 +5,10 @@
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 { existsSync } from 'node:fs'
9
+ import { rm } from 'node:fs/promises'
8
10
  import { userInfo } from 'node:os'
11
+ import { basename, join, resolve } from 'node:path'
9
12
  import { ROOT_ACCESS } from '@crombolo/core'
10
13
  import {
11
14
  type AuditCategory,
@@ -46,6 +49,8 @@ import {
46
49
  checkSchemaDirectory,
47
50
  createFileIntake,
48
51
  createMediaPort,
52
+ exportSite,
53
+ extractTarball,
49
54
  formTypesFor,
50
55
  installBundleContent,
51
56
  installDatabase,
@@ -53,11 +58,18 @@ import {
53
58
  mediaStoreFor,
54
59
  pendingBundleSteps,
55
60
  planBundleUpgrades,
61
+ type ReplaceMode,
62
+ type ReplaceSummary,
63
+ RUN_BACKUP_DIR,
56
64
  readBlobs,
57
65
  readBundle,
58
66
  recordAudit,
67
+ replaceSummary,
68
+ type SiteExportOptions,
69
+ type SiteExportResult,
59
70
  syncOptionsFor,
60
71
  type TransferInput,
72
+ useSchemaStore,
61
73
  VERSION,
62
74
  } from '@crombolo/server'
63
75
  import {
@@ -95,6 +107,7 @@ export {
95
107
  readBundle,
96
108
  replacementsNeedingPermission,
97
109
  restoreSectionFiles,
110
+ revertSections,
98
111
  type SectionFile,
99
112
  type SectionOptions,
100
113
  type SectionPlan,
@@ -109,16 +122,39 @@ export {
109
122
  type Resolution,
110
123
  type TransferCheck,
111
124
  } from '@crombolo/transfer'
112
- export type { BackupResult, WritesPaused }
125
+ export type { BackupResult, ReplaceMode, ReplaceSummary, WritesPaused }
126
+
127
+ /**
128
+ * A bundle file (`.tgz`, as `export --site` writes) unpacked under the site's
129
+ * transfer directory, where an import can read it; a directory or a package
130
+ * name is a bundle as it is.
131
+ */
132
+ export async function unpackBundle(target: string, siteDir: string): Promise<string> {
133
+ if (!/\.(tgz|tar\.gz)$/i.test(target)) return target
134
+ const file = resolve(siteDir, target)
135
+ if (!existsSync(file)) throw new CommandError(`${target} does not exist.`)
136
+ const dir = join(
137
+ siteDir,
138
+ RUN_BACKUP_DIR,
139
+ 'incoming',
140
+ basename(file).replace(/\.(tgz|tar\.gz)$/i, ''),
141
+ )
142
+ await rm(dir, { recursive: true, force: true })
143
+ await extractTarball(file, dir)
144
+ return dir
145
+ }
113
146
 
114
147
  /** Read-only: what installing a bundle here would do. Its findings go to the Changes dashboard. */
115
148
  export async function contentCheck(
116
149
  config: CromboloConfig,
117
150
  input: TransferInput,
118
151
  ): Promise<ContentCheckResult> {
152
+ // Against the store's schema, not whatever local directory there is (story 106).
153
+ await useSchemaStore(config)
154
+ const bundle = await unpackBundle(input.bundle, config.siteDir)
119
155
  const { db } = await bootstrapDatabase(config)
120
156
  try {
121
- return await checkBundleContent(db, config, input)
157
+ return await checkBundleContent(db, config, { ...input, bundle })
122
158
  } finally {
123
159
  await db.close()
124
160
  }
@@ -129,12 +165,80 @@ export async function contentImport(
129
165
  config: CromboloConfig,
130
166
  input: ContentImportInput,
131
167
  ): Promise<ContentImportResult> {
168
+ const bundle = await unpackBundle(input.bundle, config.siteDir)
132
169
  // Read before anything is opened or backed up, so a bundle that is not one is
133
170
  // refused before a backup is taken on its behalf.
134
- readBundle(input.bundle, config.siteDir)
171
+ readBundle(bundle, config.siteDir)
172
+ await useSchemaStore(config)
173
+ const { db } = await bootstrapDatabase(config)
174
+ try {
175
+ return await installBundleContent(db, config, { ...input, bundle })
176
+ } finally {
177
+ await db.close()
178
+ }
179
+ }
180
+
181
+ export interface SiteReplaceCheck {
182
+ check: ContentCheckResult
183
+ summary: ReplaceSummary
184
+ }
185
+
186
+ /**
187
+ * Read-only: what `crombolo import <file>` would do (story 105) — the content
188
+ * check a replace runs with, and what it would take out of this site.
189
+ */
190
+ export async function siteReplaceCheck(
191
+ config: CromboloConfig,
192
+ input: TransferInput & { replace: ReplaceMode },
193
+ ): Promise<SiteReplaceCheck> {
194
+ await useSchemaStore(config)
195
+ const bundle = await unpackBundle(input.bundle, config.siteDir)
196
+ const resolved = readBundle(bundle, config.siteDir, input.passphrase)
135
197
  const { db } = await bootstrapDatabase(config)
136
198
  try {
137
- return await installBundleContent(db, config, input)
199
+ const check = await checkBundleContent(db, config, {
200
+ ...input,
201
+ bundle,
202
+ resolveAll: 'take-bundle',
203
+ replaceFiles: true,
204
+ })
205
+ const summary = await replaceSummary(
206
+ db,
207
+ resolved.set,
208
+ new Set(resolved.carried.files.keys()),
209
+ [
210
+ { section: 'schema', dir: config.schemaDir },
211
+ { section: 'components', dir: config.componentsDir },
212
+ { section: 'styles', dir: config.stylesheetsDir },
213
+ { section: 'scripts', dir: config.scriptsDir },
214
+ ],
215
+ input.replace,
216
+ )
217
+ return { check, summary }
218
+ } finally {
219
+ await db.close()
220
+ }
221
+ }
222
+
223
+ export type SiteExportInput = SiteExportOptions
224
+
225
+ /**
226
+ * Writes the whole site to one file (story 104), streaming its media from the
227
+ * store. Unlike `contentExport` this writes: a site's media does not fit in a
228
+ * returned value.
229
+ */
230
+ export async function siteExport(
231
+ config: CromboloConfig,
232
+ input: SiteExportInput,
233
+ ): Promise<SiteExportResult> {
234
+ await useSchemaStore(config)
235
+ const { db } = await bootstrapDatabase(config)
236
+ try {
237
+ return await exportSite(db, config, input)
238
+ } catch (error) {
239
+ if (error instanceof Error && !(error instanceof CommandError))
240
+ throw new CommandError(error.message)
241
+ throw error
138
242
  } finally {
139
243
  await db.close()
140
244
  }
@@ -6,7 +6,7 @@
6
6
  "content"
7
7
  ],
8
8
  "peerDependencies": {
9
- "@crombolo/crombolo": ">=0.13.1"
9
+ "@crombolo/crombolo": ">=0.14.1"
10
10
  },
11
11
  "crombolo": {
12
12
  "id": "basic",
@@ -3,13 +3,13 @@
3
3
  "id": "af030aee-ba70-4bed-8a27-a3806a79100d",
4
4
  "createdAt": "2026-10-01T00:00:00.000Z",
5
5
  "createdBy": "bun run build:template",
6
- "integrity": "sha256-dda870bf7e8fadb391bcadc9e75a5721cfe209a9e8aa794df2801e0b17d8e958",
6
+ "integrity": "sha256-d5915f80be85d8eb3aca0488721ea3b7884e6649049cd6261ced0e7f58d44c80",
7
7
  "snapshot": "published",
8
8
  "provenance": {
9
9
  "siteName": "Harbourstone Distillery",
10
10
  "schemaVersion": "1.0.0",
11
11
  "schemaRevision": "0",
12
- "schemaHash": "9c729977ea20bcaca36022f92fd57b76321825b82d817f7e688bd8dc6b7ba289"
12
+ "schemaHash": "ac93ee497083881b983ce2f3e305e8d450a31ca1d27203223d8472fd247cae66"
13
13
  },
14
14
  "selector": {
15
15
  "roots": [
@@ -47,7 +47,8 @@
47
47
  "a562f0a2-cb6e-4ead-a1ce-4d180632419f",
48
48
  "cd681b4b-9c4e-4624-9c13-450aa6a9c135",
49
49
  "40107d59-f3e9-4592-aee8-5b03d4586cbd",
50
- "3d312cc8-2937-40e1-ba14-fd781c9a862d"
50
+ "3d312cc8-2937-40e1-ba14-fd781c9a862d",
51
+ "9468d1b4-f521-48ef-b044-2ab07b461b96"
51
52
  ],
52
53
  "descendants": true,
53
54
  "asGiven": [
@@ -57,7 +58,7 @@
57
58
  "counts": {
58
59
  "media": 7,
59
60
  "element": 20,
60
- "document": 8
61
+ "document": 9
61
62
  },
62
63
  "dependencies": {
63
64
  "carried": [
@@ -83,6 +84,7 @@
83
84
  "6d8d6398-738f-4f38-ad3c-e5d6e9a971fe",
84
85
  "7b4713f8-d917-457d-a9d8-7dd01a3617b4",
85
86
  "88ec1185-53b5-439a-a2ce-3f56603be4ff",
87
+ "9468d1b4-f521-48ef-b044-2ab07b461b96",
86
88
  "97469df2-107c-4010-8c14-4de88f735529",
87
89
  "a2eb439f-71d9-4ea6-89d7-c5bc72618281",
88
90
  "a562f0a2-cb6e-4ead-a1ce-4d180632419f",
@@ -124,6 +126,10 @@
124
126
  "key": "cc07b313-0843-4aa8-bbda-871c8da728c8",
125
127
  "alias": "Image"
126
128
  },
129
+ {
130
+ "key": "4308082e-5024-47fc-950d-1ee93a3e304f",
131
+ "alias": "searchPage"
132
+ },
127
133
  {
128
134
  "key": "55138391-faf2-4795-9ed6-43d581d350a4",
129
135
  "alias": "tastingNote"
@@ -138,6 +144,7 @@
138
144
  "pages/contentPage",
139
145
  "pages/expression",
140
146
  "pages/homePage",
147
+ "pages/searchPage",
141
148
  "pages/whiskyList"
142
149
  ],
143
150
  "languages": []
@@ -67,6 +67,13 @@
67
67
  "editor": "Umbraco.TextBox",
68
68
  "value": "Cask strength"
69
69
  },
70
+ {
71
+ "property": "productType",
72
+ "culture": null,
73
+ "segment": null,
74
+ "editor": "Umbraco.TextBox",
75
+ "value": "Whisky"
76
+ },
70
77
  {
71
78
  "property": "summary",
72
79
  "culture": null,