@crombolo/cli 0.13.2 → 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.
- package/bin/crombolo.ts +289 -22
- package/package.json +7 -7
- package/src/operations/commands.ts +108 -4
- package/templates/basic/bundle/package.json +1 -1
- package/templates/demo/harbourstone/bundle/content/bundle.json +11 -4
- package/templates/demo/harbourstone/bundle/content/nodes/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +7 -0
- package/templates/demo/harbourstone/bundle/content/nodes/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +7 -0
- package/templates/demo/harbourstone/bundle/content/nodes/9468d1b4-f521-48ef-b044-2ab07b461b96.json +42 -0
- package/templates/demo/harbourstone/bundle/content/nodes/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +7 -0
- package/templates/demo/harbourstone/bundle/content/nodes/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +7 -0
- package/templates/demo/harbourstone/bundle/package.json +1 -1
- package/templates/demo/harbourstone/files/components/pages/searchPage.tsx +76 -0
- package/templates/demo/harbourstone/files/components/shared/site-header.tsx +13 -1
- package/templates/demo/harbourstone/files/css/site.css +113 -0
- package/templates/demo/harbourstone/files/schema/content-types/contact-page.toml +2 -0
- package/templates/demo/harbourstone/files/schema/content-types/content-page.toml +2 -0
- package/templates/demo/harbourstone/files/schema/content-types/expression.toml +8 -0
- package/templates/demo/harbourstone/files/schema/content-types/home-page.toml +5 -1
- package/templates/demo/harbourstone/files/schema/content-types/search-page.toml +33 -0
- package/templates/demo/harbourstone/files/schema/content-types/whisky-list.toml +2 -0
- package/templates/starter/bundle/content/bundle.json +12 -5
- package/templates/starter/bundle/content/nodes/c720ef2a-b5c8-48ec-a504-065e8188d4aa.json +28 -0
- package/templates/starter/bundle/package.json +1 -1
- package/templates/starter/files/components/pages/searchPage.tsx +57 -0
- package/templates/starter/files/components/shared/site-header.tsx +11 -2
- package/templates/starter/files/css/site.css +66 -0
- package/templates/starter/files/schema/content-types/content-page.toml +4 -1
- 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.
|
|
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
|
-
//
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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.
|
|
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.
|
|
38
|
-
"@crombolo/core": "0.
|
|
39
|
-
"@crombolo/data": "0.
|
|
40
|
-
"@crombolo/schema": "0.
|
|
41
|
-
"@crombolo/server": "0.
|
|
42
|
-
"@crombolo/transfer": "0.
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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-
|
|
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": "
|
|
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":
|
|
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": []
|
package/templates/demo/harbourstone/bundle/content/nodes/40107d59-f3e9-4592-aee8-5b03d4586cbd.json
CHANGED
|
@@ -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,
|