@crombolo/cli 0.13.2 → 0.14.4

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 (31) hide show
  1. package/bin/crombolo.ts +689 -23
  2. package/package.json +7 -7
  3. package/src/operations/commands.ts +108 -4
  4. package/src/remote/client.ts +288 -0
  5. package/src/remote/remotes-file.ts +80 -0
  6. package/src/remote/transfer.ts +337 -0
  7. package/templates/basic/bundle/package.json +1 -1
  8. package/templates/demo/harbourstone/bundle/content/bundle.json +11 -4
  9. package/templates/demo/harbourstone/bundle/content/nodes/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +7 -0
  10. package/templates/demo/harbourstone/bundle/content/nodes/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +7 -0
  11. package/templates/demo/harbourstone/bundle/content/nodes/9468d1b4-f521-48ef-b044-2ab07b461b96.json +42 -0
  12. package/templates/demo/harbourstone/bundle/content/nodes/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +7 -0
  13. package/templates/demo/harbourstone/bundle/content/nodes/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +7 -0
  14. package/templates/demo/harbourstone/bundle/package.json +1 -1
  15. package/templates/demo/harbourstone/files/components/pages/searchPage.tsx +76 -0
  16. package/templates/demo/harbourstone/files/components/shared/site-header.tsx +13 -1
  17. package/templates/demo/harbourstone/files/css/site.css +113 -0
  18. package/templates/demo/harbourstone/files/schema/content-types/contact-page.toml +2 -0
  19. package/templates/demo/harbourstone/files/schema/content-types/content-page.toml +2 -0
  20. package/templates/demo/harbourstone/files/schema/content-types/expression.toml +8 -0
  21. package/templates/demo/harbourstone/files/schema/content-types/home-page.toml +5 -1
  22. package/templates/demo/harbourstone/files/schema/content-types/search-page.toml +33 -0
  23. package/templates/demo/harbourstone/files/schema/content-types/whisky-list.toml +2 -0
  24. package/templates/starter/bundle/content/bundle.json +12 -5
  25. package/templates/starter/bundle/content/nodes/c720ef2a-b5c8-48ec-a504-065e8188d4aa.json +28 -0
  26. package/templates/starter/bundle/package.json +1 -1
  27. package/templates/starter/files/components/pages/searchPage.tsx +57 -0
  28. package/templates/starter/files/components/shared/site-header.tsx +11 -2
  29. package/templates/starter/files/css/site.css +66 -0
  30. package/templates/starter/files/schema/content-types/content-page.toml +4 -1
  31. 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,11 @@
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
35
+ * crombolo remote add|list|remove the remotes this site pulls from and pushes to (remotes.toml)
36
+ * crombolo login|logout|whoami sign in to a remote with a code you approve in its back office
37
+ * crombolo pull [<remote>] a copy of a remote environment: started here, or replacing this site
38
+ * crombolo push [<remote>] replace a remote environment with this site
32
39
  * crombolo import umbraco report read-only: what importing an Umbraco backup would and would not bring
33
40
  * crombolo import umbraco apply write a crombolo site from an Umbraco backup
34
41
  *
@@ -42,6 +49,7 @@
42
49
  */
43
50
  import { existsSync } from 'node:fs'
44
51
  import { mkdir, writeFile } from 'node:fs/promises'
52
+ import { hostname } from 'node:os'
45
53
  import { basename, join, relative, resolve } from 'node:path'
46
54
  import { AuthStore, resetAdminPassword } from '@crombolo/auth'
47
55
  import { ObjectTypes } from '@crombolo/core'
@@ -105,15 +113,20 @@ import {
105
113
  describeDatabase,
106
114
  dictionaryToUdt,
107
115
  domainsFilePath,
116
+ forgetSignIn,
108
117
  formTypesFor,
109
118
  importDictionaryUdt,
110
119
  isSafeAlias,
111
120
  listAssets,
112
121
  listComponents,
122
+ listeningBodyLimit,
113
123
  loadConfig,
114
124
  mediaStoreFor,
125
+ osKeychain,
115
126
  placeBlobs,
116
127
  readDomainsFile,
128
+ readRemoteMedia,
129
+ readSignIn,
117
130
  resolveDomainNode,
118
131
  resolvePlaceholders,
119
132
  type ServerHandle,
@@ -126,6 +139,7 @@ import {
126
139
  writeDictionaryUdt,
127
140
  writeDomains,
128
141
  writeDomainsFile,
142
+ writeRemoteMedia,
129
143
  writeUdtAll,
130
144
  } from '@crombolo/server'
131
145
  import {
@@ -148,6 +162,23 @@ import {
148
162
  writeResolutions,
149
163
  } from '@crombolo/transfer'
150
164
  import * as ops from '../src/operations/commands.ts'
165
+ import {
166
+ discover,
167
+ type Job,
168
+ problemOf,
169
+ RemoteClient,
170
+ RemoteError,
171
+ signIn,
172
+ TimedOut,
173
+ } from '../src/remote/client.ts'
174
+ import { addRemote, pickRemote, readRemotes, removeRemote } from '../src/remote/remotes-file.ts'
175
+ import {
176
+ fetchReferencedMedia,
177
+ pullFile,
178
+ pushSite,
179
+ RefusedBeforeSending,
180
+ referencedBlobs,
181
+ } from '../src/remote/transfer.ts'
151
182
  import { findTemplate, listTemplates, scaffoldFiles } from '../src/templates.ts'
152
183
 
153
184
  const cwd = process.cwd()
@@ -213,7 +244,95 @@ function stopOnSignal(
213
244
  for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => stop(signal))
214
245
  }
215
246
 
247
+ /**
248
+ * `start --from <file>` (story 105): an empty folder in, a working site out —
249
+ * scaffolded as `init` scaffolds one, its packages installed, and the file
250
+ * imported as a replace so nothing of the scaffold's own starting point stays.
251
+ * The new administrator's password is printed here, once.
252
+ */
253
+ async function startFromFile(from: string, known?: string): Promise<void> {
254
+ const file = resolve(cwd, from)
255
+ if (!existsSync(file)) {
256
+ console.error(`${from} does not exist.`)
257
+ process.exit(1)
258
+ }
259
+ if (existsSync(join(cwd, 'crombolo.config.ts'))) {
260
+ console.error(
261
+ 'There is a site here already. `crombolo import <file> --replace` replaces it; start --from starts a new one in an empty folder.',
262
+ )
263
+ process.exit(1)
264
+ }
265
+ const dir = await ops.unpackBundle(file, cwd)
266
+ const manifest = ops.readBundle(dir, cwd).set.manifest
267
+ if (manifest.scope !== 'site') {
268
+ console.error(`${from} is not a whole-site file: write one with \`crombolo export --site\`.`)
269
+ process.exit(1)
270
+ }
271
+ // A pull already holds the passphrase it sealed the file with.
272
+ const sealed = manifest.people !== undefined || manifest.content === 'sealed'
273
+ const passphrase = known
274
+ ? known
275
+ : sealed
276
+ ? (process.env[PASSPHRASE_ENV] ??
277
+ (process.stdin.isTTY ? await readSecret(' Passphrase for its people: ') : undefined))
278
+ : undefined
279
+ if (sealed && !passphrase) {
280
+ console.error(`${from} is sealed: set ${PASSPHRASE_ENV} to its passphrase.`)
281
+ process.exit(1)
282
+ }
283
+
284
+ for (const scaffolded of scaffoldFiles({
285
+ siteName: flagValue('--name') ?? manifest.provenance.siteName,
286
+ postgres: flags.has('--postgres'),
287
+ })) {
288
+ const target = join(cwd, scaffolded.path)
289
+ if (existsSync(target)) continue
290
+ await mkdir(join(target, '..'), { recursive: true })
291
+ if (scaffolded.copyFrom === undefined) await writeFile(target, scaffolded.text ?? '')
292
+ else await Bun.write(target, Bun.file(scaffolded.copyFrom))
293
+ }
294
+ console.log(` scaffolded a site for ${manifest.provenance.siteName || 'the file'}`)
295
+ if (!flags.has('--no-install')) {
296
+ const install = Bun.spawnSync(['bun', 'install'], { cwd, stdout: 'inherit', stderr: 'inherit' })
297
+ if (install.exitCode !== 0) {
298
+ console.error('`bun install` failed; run it, then `crombolo import <file> --replace`.')
299
+ process.exit(1)
300
+ }
301
+ }
302
+
303
+ const config = await siteConfig()
304
+ const { db, seededAdminPassword } = await bootstrapDatabase(config)
305
+ await db.close()
306
+ const result = await ops.contentImport(config, {
307
+ bundle: dir,
308
+ passphrase,
309
+ replace: 'replace',
310
+ allowMissingBlobs: flags.has('--allow-missing-media'),
311
+ backupTaken: true,
312
+ label: `start --from ${basename(file)}`,
313
+ })
314
+ for (const problem of result.problems) console.error(` ${problem}`)
315
+ if (!result.runId) {
316
+ printTransferFindings(result.check)
317
+ console.error('\nThe file could not be imported; the scaffolded site is left as it is.')
318
+ process.exit(1)
319
+ }
320
+ console.log(` imported ${result.check.counts.create} item(s), run ${result.runId}`)
321
+ const placed = describePlaced(result.blobs)
322
+ if (placed) console.log(` media ${placed}`)
323
+ if (seededAdminPassword)
324
+ banner([
325
+ 'Sign in with (shown once)',
326
+ ` username ${config.adminLogin}`,
327
+ ` password ${seededAdminPassword}`,
328
+ ])
329
+ // The password above is the one to use: this first start leaves it alone.
330
+ flags.add('--keep-admin')
331
+ }
332
+
216
333
  async function start(): Promise<void> {
334
+ const from = flagValue('--from')
335
+ if (from) await startFromFile(from)
217
336
  const config = await siteConfig()
218
337
  // Before the migration and the seed, so a clash with the container stack is
219
338
  // reported in a second rather than after the database work.
@@ -317,7 +436,7 @@ async function bootWithRetries(config: CromboloConfig) {
317
436
  }
318
437
  const listening = Bun.serve({
319
438
  port: config.port,
320
- maxRequestBodySize: config.maxRequestBodyBytes,
439
+ maxRequestBodySize: listeningBodyLimit(config),
321
440
  fetch: holding,
322
441
  websocket: { message() {} },
323
442
  })
@@ -1145,10 +1264,12 @@ async function contentRevert(config: CromboloConfig): Promise<void> {
1145
1264
  // The files an install replaced come back from the run's own copies, not
1146
1265
  // from git: these files are editable in the backoffice, so a site need not
1147
1266
  // be a checkout at all.
1148
- const files = ops.restoreSectionFiles(config, runId)
1267
+ const files = await ops.revertSections(db, config, runId)
1149
1268
  for (const file of files.restored) console.log(` restored ${relative(cwd, file)}`)
1150
1269
  for (const file of files.removed)
1151
1270
  console.log(` removed ${relative(cwd, file)} (created by that run)`)
1271
+ if (files.schema) console.log(` schema ${files.schema}`)
1272
+ if (files.dictionary) console.log(' restored the dictionary')
1152
1273
 
1153
1274
  for (const key of result.restored) console.log(` restored ${key}`)
1154
1275
  for (const key of result.recycled) console.log(` recycled ${key} (created by that run)`)
@@ -1269,27 +1390,10 @@ async function contentImport(config: CromboloConfig): Promise<void> {
1269
1390
 
1270
1391
  if (!result.runId) {
1271
1392
  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
- }
1393
+ // Staged, then applied in one transaction: a refusal leaves nothing behind.
1394
+ console.error(
1395
+ `\nimport refused: ${result.check.outstanding.length || result.problems.length} finding(s) outstanding. Nothing was written.`,
1396
+ )
1293
1397
  process.exit(1)
1294
1398
  }
1295
1399
 
@@ -1612,6 +1716,62 @@ async function contentExport(config: CromboloConfig): Promise<void> {
1612
1716
  if (counts.length === 0) process.exit(1)
1613
1717
  }
1614
1718
 
1719
+ /** `crombolo export --site`: the whole site as one file (story 104). */
1720
+ async function exportCommand(): Promise<void> {
1721
+ if (!flags.has('--site')) {
1722
+ console.error(
1723
+ 'export needs --site: the whole site, as one file. For a part of it, use content export.',
1724
+ )
1725
+ process.exit(1)
1726
+ }
1727
+ const config = await siteConfig()
1728
+ const withPeople = flags.has('--with-members') || flags.has('--with-users')
1729
+ const anonymise = flags.has('--anonymise')
1730
+ const out = resolve(
1731
+ flagValue('--out') ??
1732
+ `${packageNameFor(`${config.siteName}-site`)}-${new Date().toISOString().slice(0, 10)}.tgz`,
1733
+ )
1734
+ const result = await ops.siteExport(config, {
1735
+ out,
1736
+ media: !flags.has('--no-media'),
1737
+ drafts: flags.has('--drafts'),
1738
+ withMembers: flags.has('--with-members'),
1739
+ withUsers: flags.has('--with-users'),
1740
+ anonymise,
1741
+ passphrase:
1742
+ withPeople && !(anonymise && !flags.has('--with-members'))
1743
+ ? await passphraseToSeal()
1744
+ : undefined,
1745
+ package: { name: flagValue('--name'), version: flagValue('--package-version') },
1746
+ })
1747
+ const counts = Object.entries(result.counts)
1748
+ console.log(` wrote ${relative(cwd, result.out) || result.out}`)
1749
+ console.log(` package ${result.package.name}@${result.package.version} (private)`)
1750
+ console.log(
1751
+ ` content ${counts.length > 0 ? counts.map(([kind, n]) => `${n} ${kind}`).join(', ') : 'none'}, unencrypted`,
1752
+ )
1753
+ const sections = Object.entries(result.sections)
1754
+ if (sections.length > 0)
1755
+ console.log(` files ${sections.map(([section, n]) => `${n} ${section}`).join(', ')}`)
1756
+ if (result.media.omitted)
1757
+ console.log(
1758
+ ' media left out (--no-media): importing it brings the media items without their files',
1759
+ )
1760
+ else
1761
+ console.log(` media ${result.media.carried} file(s), ${describeBytes(result.media.bytes)}`)
1762
+ for (const key of result.media.missing)
1763
+ console.error(` missing ${key} is not in this environment's media store`)
1764
+ if (result.people.members + result.people.users > 0)
1765
+ console.log(
1766
+ ` people ${result.people.members} member(s), ${result.people.users} user(s), encrypted`,
1767
+ )
1768
+ else console.log(' people left out: pass --with-members or --with-users to bring them')
1769
+ if (result.anonymised !== undefined)
1770
+ console.log(
1771
+ ` anonymised ${result.anonymised} email address(es) replaced; no back-office users`,
1772
+ )
1773
+ }
1774
+
1615
1775
  /**
1616
1776
  * `schema-as-code.md` leaves the dictionary in the database, because
1617
1777
  * translators work in production where `schema/` is read-only — so this is how
@@ -1804,6 +1964,117 @@ async function applyDomains(config: CromboloConfig): Promise<void> {
1804
1964
  }
1805
1965
  }
1806
1966
 
1967
+ /**
1968
+ * `crombolo import <file>`: replace this site with a whole-site file (story
1969
+ * 105). It always says first what would be replaced, and changes nothing
1970
+ * without `--replace` or `--replace-all`.
1971
+ */
1972
+ async function importFile(file: string): Promise<void> {
1973
+ const config = await siteConfig()
1974
+ const replace: ops.ReplaceMode | undefined = flags.has('--replace-all')
1975
+ ? 'replace-all'
1976
+ : flags.has('--replace')
1977
+ ? 'replace'
1978
+ : undefined
1979
+ const dir = await ops.unpackBundle(file, config.siteDir)
1980
+ let carriesPeople = false
1981
+ try {
1982
+ carriesPeople = ops.readBundle(dir, config.siteDir).set.manifest.people !== undefined
1983
+ } catch (error) {
1984
+ console.error(` ${error instanceof Error ? error.message : String(error)}`)
1985
+ process.exit(1)
1986
+ }
1987
+ const passphrase = carriesPeople
1988
+ ? (process.env[PASSPHRASE_ENV] ??
1989
+ (process.stdin.isTTY ? await readSecret(' Passphrase for its people: ') : undefined))
1990
+ : await passphraseToOpen(dir, config)
1991
+ const allowMissingBlobs = flags.has('--allow-missing-media')
1992
+
1993
+ const { check, summary } = await ops.siteReplaceCheck(config, {
1994
+ bundle: dir,
1995
+ passphrase,
1996
+ replace: replace ?? 'replace',
1997
+ allowMissingBlobs,
1998
+ })
1999
+ describeBundle(check.bundle)
2000
+ const c = check.check.counts
2001
+ console.log(` brings ${c.create} new, ${c.update} changed, ${c.unchanged} unchanged`)
2002
+ const leaving = Object.entries(summary.leaving)
2003
+ const fate = replace === 'replace-all' ? 'deleted' : 'recycled'
2004
+ if (leaving.length > 0)
2005
+ console.log(
2006
+ ` ${fate.padEnd(8)} ${leaving.map(([kind, n]) => `${n} ${kind}`).join(', ')} not in the file`,
2007
+ )
2008
+ if (summary.filesRemoved.length > 0) {
2009
+ console.log(
2010
+ ` removes ${summary.filesRemoved.length} file(s) not in the file, kept for a revert:`,
2011
+ )
2012
+ for (const path of summary.filesRemoved.slice(0, 20)) console.log(` ${relative(cwd, path)}`)
2013
+ if (summary.filesRemoved.length > 20)
2014
+ console.log(` … and ${summary.filesRemoved.length - 20} more`)
2015
+ }
2016
+ if (replace === 'replace-all' && summary.people.members + summary.people.users > 0)
2017
+ console.log(
2018
+ ` deletes ${summary.people.members} member(s) and ${summary.people.users} user(s) not in the file`,
2019
+ )
2020
+ console.log(" keeps domains, the secret key and this environment's settings")
2021
+ for (const problem of check.problems) console.error(` ${problem}`)
2022
+
2023
+ if (!replace) {
2024
+ console.error(
2025
+ '\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).',
2026
+ )
2027
+ process.exit(1)
2028
+ }
2029
+ if (replace === 'replace-all' && !flags.has('--yes')) {
2030
+ if (!process.stdin.isTTY) {
2031
+ console.error(
2032
+ '\n--replace-all deletes what the file does not have: pass --yes to confirm it.',
2033
+ )
2034
+ process.exit(1)
2035
+ }
2036
+ const answer = prompt(
2037
+ ` This deletes for good. Type the site's name (${config.siteName}) to go on:`,
2038
+ )
2039
+ if (answer?.trim() !== config.siteName) {
2040
+ console.error(' Nothing was changed.')
2041
+ process.exit(1)
2042
+ }
2043
+ }
2044
+
2045
+ const result = await ops.contentImport(config, {
2046
+ bundle: dir,
2047
+ passphrase,
2048
+ replace,
2049
+ allowMissingBlobs,
2050
+ label: `${replace} from ${basename(file)}`,
2051
+ backupTaken: flags.has('--backup-taken'),
2052
+ })
2053
+ for (const problem of result.problems) console.error(` ${problem}`)
2054
+ if (result.backup.path) console.log(` backup ${result.backup.path}`)
2055
+ if (!result.runId) {
2056
+ printTransferFindings(result.check)
2057
+ console.error('\nreplace refused. Nothing was changed.')
2058
+ process.exit(1)
2059
+ }
2060
+ const counts = result.check.counts
2061
+ console.log(` run ${result.runId}`)
2062
+ console.log(
2063
+ ` applied ${counts.create} created, ${counts.update} updated, ${counts.unchanged} unchanged`,
2064
+ )
2065
+ const placed = describePlaced(result.blobs)
2066
+ if (placed) console.log(` media ${placed}`)
2067
+ if (result.sections?.schema) console.log(` schema ${result.sections.schema.action}`)
2068
+ if (result.published > 0) console.log(` published ${result.published} page(s)`)
2069
+ for (const failure of result.publishFailures)
2070
+ console.error(` not published ${failure.key}: ${failure.reason}`)
2071
+ console.log(
2072
+ replace === 'replace-all'
2073
+ ? `\nDone. \`crombolo content revert ${result.runId}\` puts the files and the pages it changed back; what was deleted is in the backup.`
2074
+ : `\nDone. \`crombolo content revert ${result.runId}\` puts it back.`,
2075
+ )
2076
+ }
2077
+
1807
2078
  /**
1808
2079
  * `crombolo import umbraco`: a compatibility report for an Umbraco backup, and
1809
2080
  * then a crombolo site written from it.
@@ -1825,7 +2096,13 @@ async function importSite(): Promise<void> {
1825
2096
  else if (!arg.startsWith('--')) args.push(arg)
1826
2097
  }
1827
2098
  const [kind, sub, backup] = args
2099
+ if (kind && kind !== 'umbraco') return importFile(kind)
1828
2100
  if (kind !== 'umbraco' || (sub !== 'report' && sub !== 'apply')) return help()
2101
+ // Not into --json output, which a machine reads.
2102
+ if (!flags.has('--json'))
2103
+ console.error(
2104
+ ' deprecated `crombolo import umbraco` is going away: an Umbraco extension will write a crombolo file that `crombolo import <file>` reads (story 074)',
2105
+ )
1829
2106
  if (!backup) {
1830
2107
  console.error(`import umbraco ${sub} needs the backup: a .bacpac, or Umbraco's SQLite file.`)
1831
2108
  process.exit(1)
@@ -2272,6 +2549,10 @@ function help(): void {
2272
2549
  start [--keep-admin] boot the site in the current directory
2273
2550
  [--bundle <pkg>] apply a bundle's content first, once per bundle, then serve;
2274
2551
  takes the \`content import\` flags (--publish, --under, …)
2552
+ [--from <file>] in an empty folder: scaffold a site, install it, import a
2553
+ whole-site file (crombolo export --site) and serve it; the
2554
+ new administrator's password is printed once
2555
+ [--postgres] [--name <site name>] [--no-install]
2275
2556
  init [--force] [--name <site name>] [--postgres] [--template <id>]
2276
2557
  scaffold a site here; --template list prints the
2277
2558
  starter templates, which bring schema, views and content
@@ -2298,6 +2579,12 @@ function help(): void {
2298
2579
  assets list the stylesheets and scripts this site has
2299
2580
  assets new <stylesheet|script> <name>
2300
2581
  write an empty one in the place the backoffice edits
2582
+ export --site [--out <file.tgz>]
2583
+ the whole site in one file: schema, components, styles,
2584
+ scripts, dictionary, every page and media item and their
2585
+ files. Domains and people stay behind
2586
+ [--no-media] [--drafts] [--with-members] [--with-users]
2587
+ [--anonymise] replaces every personal value as it writes
2301
2588
  content export --root <path|uuid> --out <dir>
2302
2589
  write a private bundle package: the subtree, its
2303
2590
  dependencies and what the destination must already have
@@ -2351,6 +2638,36 @@ function help(): void {
2351
2638
  domains clear <path|uuid|/> unbind that page, in the file and in the database
2352
2639
  domains apply apply domains.toml here, as a boot does
2353
2640
  domains undo put back the file the last write replaced, and apply it
2641
+ remote add <name> <url> [--env <id>]
2642
+ a remote this site pulls from and pushes to, kept in
2643
+ remotes.toml (URLs only; sign-ins are in your keychain)
2644
+ remote list | remove <name>
2645
+ login [<remote>] sign in with a code you approve in the remote's back office
2646
+ logout [<remote>] | whoami [<remote>]
2647
+ pull [<remote>] [--env <id>] [--full] [--yes]
2648
+ a copy of the environment, sealed and anonymised unless
2649
+ --full: started here in an empty folder, or replacing this
2650
+ site (what it loses is recycled; --yes skips the question)
2651
+ [--no-media] [--media-info-only] fetches media files one at a
2652
+ time, only what changed since the last pull; [--lazy-media]
2653
+ fetches each the first time it is asked for
2654
+ push [<remote>] [--env <id>] [--force] [--auto-approve] [--media-info-only]
2655
+ replace the environment with this site; shows what it would
2656
+ replace first. A protected one needs --force and its name
2657
+ typed back (--auto-approve skips that); the remote may also
2658
+ hold it for someone else's approval
2659
+ exit 0 done, 1 refused or failed, 2 refused before sending,
2660
+ 3 gave up waiting
2661
+ media fetch a lazily pulled site's media files, all of them now
2662
+ import <file> [--replace | --replace-all]
2663
+ replace this site with a whole-site file. Says first what
2664
+ would be replaced, and changes nothing without a mode:
2665
+ --replace recycles content and media the file lacks and
2666
+ keeps this site's people; --replace-all deletes them, and
2667
+ people the file lacks (typed confirmation, or --yes).
2668
+ Domains, the secret key and settings always stay.
2669
+ Undone with \`content revert <run>\`
2670
+ [--allow-missing-media] [--backup-taken]
2354
2671
  import umbraco report <backup> [--site <dir>] [--media <dir>] [--out <dir>] [--json]
2355
2672
  read-only: what importing an Umbraco backup would bring,
2356
2673
  and what it would not — packages, Razor, custom code.
@@ -2441,6 +2758,331 @@ function commandAudit(): ops.CommandAudit | undefined {
2441
2758
  }
2442
2759
  }
2443
2760
 
2761
+ // ---------------------------------------------------------------- remotes (story 108)
2762
+
2763
+ const keychain = () => osKeychain()
2764
+
2765
+ /** Exit codes a pull or push states: 1 the remote refused or failed, 2 refused before sending, 3 gave up waiting. */
2766
+ function remoteExit(error: unknown): never {
2767
+ if (error instanceof RefusedBeforeSending) {
2768
+ console.error(` ${error.message}`)
2769
+ process.exit(2)
2770
+ }
2771
+ if (error instanceof TimedOut) {
2772
+ console.error(` ${error.message} The job carries on at the remote.`)
2773
+ process.exit(3)
2774
+ }
2775
+ if (error instanceof RemoteError) {
2776
+ console.error(` ${error.message}`)
2777
+ for (const item of error.problem.items ?? []) console.error(` ${item}`)
2778
+ process.exit(error.problem.code === 'sign-in-required' ? 2 : 1)
2779
+ }
2780
+ console.error(` ${error instanceof Error ? error.message : String(error)}`)
2781
+ process.exit(1)
2782
+ }
2783
+
2784
+ function openInBrowser(url: string): void {
2785
+ if (flags.has('--no-browser')) return
2786
+ const opener =
2787
+ process.platform === 'darwin'
2788
+ ? ['open']
2789
+ : process.platform === 'win32'
2790
+ ? ['cmd', '/c', 'start', '']
2791
+ : ['xdg-open']
2792
+ try {
2793
+ Bun.spawn([...opener, url], { stdout: 'ignore', stderr: 'ignore' })
2794
+ } catch {
2795
+ // Printed above; opening it is a convenience.
2796
+ }
2797
+ }
2798
+
2799
+ /** The remote a command names, signed in or not, with the environment it goes to. */
2800
+ async function connectRemote(name: string | undefined) {
2801
+ const entry = pickRemote(cwd, name)
2802
+ if (!entry) {
2803
+ const known = readRemotes(cwd)
2804
+ console.error(
2805
+ known.length === 0
2806
+ ? ' No remotes yet: `crombolo remote add <name> <url>`.'
2807
+ : ` Which remote? ${known.map((r) => r.name).join(', ')}`,
2808
+ )
2809
+ process.exit(2)
2810
+ }
2811
+ const discovery = await discover(entry.url).catch(remoteExit)
2812
+ const client = new RemoteClient(discovery, keychain())
2813
+ const environment =
2814
+ flagValue('--env') ??
2815
+ entry.environment ??
2816
+ (discovery.environments.length === 1 ? discovery.environments[0]?.id : undefined)
2817
+ return { entry, discovery, client, environment }
2818
+ }
2819
+
2820
+ async function remoteCommand(): Promise<void> {
2821
+ const [sub, name, url] = positional
2822
+ if (sub === 'add') {
2823
+ if (!name || !url) {
2824
+ console.error("remote add needs a name and the site's URL.")
2825
+ process.exit(2)
2826
+ }
2827
+ const discovery = await discover(url).catch(remoteExit)
2828
+ addRemote(cwd, { name, url: new URL(url).origin, environment: flagValue('--env') })
2829
+ console.log(` added ${name} ${discovery.name} (${new URL(url).origin})`)
2830
+ for (const env of discovery.environments)
2831
+ console.log(` env ${env.id}${env.protected ? ' protected' : ''}`)
2832
+ console.log(`\n\`crombolo login ${name}\` signs in.`)
2833
+ return
2834
+ }
2835
+ if (sub === 'remove') {
2836
+ if (!name || !removeRemote(cwd, name)) {
2837
+ console.error(` There is no remote "${name ?? ''}".`)
2838
+ process.exit(2)
2839
+ }
2840
+ console.log(` removed ${name}`)
2841
+ return
2842
+ }
2843
+ const remotes = readRemotes(cwd)
2844
+ if (remotes.length === 0) console.log(' No remotes yet: `crombolo remote add <name> <url>`.')
2845
+ for (const remote of remotes) {
2846
+ const signed = (await readSignIn(keychain(), remote.url).catch(() => undefined)) !== undefined
2847
+ console.log(
2848
+ ` ${remote.name.padEnd(12)} ${remote.url}${remote.environment ? ` env ${remote.environment}` : ''}${signed ? '' : ' (not signed in)'}`,
2849
+ )
2850
+ }
2851
+ }
2852
+
2853
+ async function loginCommand(): Promise<void> {
2854
+ const { entry, discovery } = await connectRemote(positional[0])
2855
+ await signIn(discovery, keychain(), {
2856
+ clientName: `crombolo on ${hostname()}`,
2857
+ onCode: (code, url) => {
2858
+ console.log(` Approve this sign-in at ${url}`)
2859
+ console.log(` and check it shows ${code}`)
2860
+ openInBrowser(url)
2861
+ },
2862
+ }).catch(remoteExit)
2863
+ console.log(` signed in to ${entry.name}`)
2864
+ }
2865
+
2866
+ async function logoutCommand(): Promise<void> {
2867
+ const { entry, discovery } = await connectRemote(positional[0])
2868
+ const signedIn = await readSignIn(keychain(), discovery.api)
2869
+ // Ended at the remote too, best effort: the device leaves "Signed-in devices".
2870
+ if (signedIn)
2871
+ await fetch(signedIn.tokenUrl.replace(/\/token$/, '/revoke'), {
2872
+ method: 'POST',
2873
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
2874
+ body: new URLSearchParams({ token: signedIn.accessToken }).toString(),
2875
+ }).catch(() => undefined)
2876
+ await forgetSignIn(keychain(), discovery.api)
2877
+ console.log(` signed out of ${entry.name}`)
2878
+ }
2879
+
2880
+ async function whoamiCommand(): Promise<void> {
2881
+ const { entry, client, discovery } = await connectRemote(positional[0])
2882
+ const response = await client
2883
+ .call(`${new URL(discovery.api).origin}/umbraco/management/api/v1/user/current`)
2884
+ .catch(remoteExit)
2885
+ if (!response.ok) remoteExit(await problemOf(response))
2886
+ const me = (await response.json()) as { name: string; email: string }
2887
+ console.log(` ${me.name} <${me.email}> on ${entry.name}`)
2888
+ for (const env of await client.environments().catch(remoteExit)) {
2889
+ const may = [env.may.pull && 'pull', env.may.pullFull && 'full pull', env.may.push && 'push']
2890
+ .filter(Boolean)
2891
+ .join(', ')
2892
+ console.log(` ${env.id.padEnd(12)} ${may || 'nothing'}${env.protected ? ' (protected)' : ''}`)
2893
+ }
2894
+ }
2895
+
2896
+ const describeJob = (job: Job) =>
2897
+ ` ${job.kind.padEnd(5)} ${job.state}${job.stage ? `, ${job.stage}` : ''}${job.progress != null ? ` (${job.progress}%)` : ''}`
2898
+
2899
+ async function pullCommand(): Promise<void> {
2900
+ const { client, environment, discovery } = await connectRemote(positional[0])
2901
+ if (!environment) {
2902
+ console.error(
2903
+ ` Which environment? ${discovery.environments.map((e) => e.id).join(', ')} (--env)`,
2904
+ )
2905
+ process.exit(2)
2906
+ }
2907
+ const media = flags.has('--no-media')
2908
+ ? ('omitted' as const)
2909
+ : flags.has('--media-info-only') || flags.has('--lazy-media')
2910
+ ? ('referenced' as const)
2911
+ : ('included' as const)
2912
+ const incoming = join(cwd, '.crombolo', 'transfer', 'incoming')
2913
+ await mkdir(incoming, { recursive: true })
2914
+ const out = join(incoming, `pull-${environment}-${Date.now().toString(36)}.tgz`)
2915
+ const pulled = await pullFile(client, {
2916
+ environment,
2917
+ out,
2918
+ full: flags.has('--full'),
2919
+ media,
2920
+ onProgress: (job) => console.log(describeJob(job)),
2921
+ }).catch(remoteExit)
2922
+ console.log(
2923
+ ` pulled ${describeBytes(pulled.size)}${flags.has('--full') ? '' : ', anonymised'}`,
2924
+ )
2925
+ if (media !== 'included') flags.add('--allow-missing-media')
2926
+
2927
+ const existing = existsSync(join(cwd, 'crombolo.config.ts'))
2928
+ if (!existing) {
2929
+ await startFromFile(out, pulled.passphrase)
2930
+ } else {
2931
+ const config = await siteConfig()
2932
+ const { summary } = await ops.siteReplaceCheck(config, {
2933
+ bundle: out,
2934
+ passphrase: pulled.passphrase,
2935
+ replace: 'replace',
2936
+ allowMissingBlobs: media !== 'included',
2937
+ })
2938
+ const leaving = Object.entries(summary.leaving)
2939
+ if (leaving.length > 0)
2940
+ console.log(
2941
+ ` recycled ${leaving.map(([kind, n]) => `${n} ${kind}`).join(', ')} here, not in ${environment}`,
2942
+ )
2943
+ if (summary.filesRemoved.length > 0)
2944
+ console.log(` removes ${summary.filesRemoved.length} file(s) here, kept for a revert`)
2945
+ if (!flags.has('--yes')) {
2946
+ if (!process.stdin.isTTY) {
2947
+ console.error(`\nThis replaces this site with ${environment}: pass --yes to go on.`)
2948
+ process.exit(2)
2949
+ }
2950
+ const answer = prompt(` Replace this site with ${environment}? [y/N]`)
2951
+ if (!/^y(es)?$/i.test(answer?.trim() ?? '')) {
2952
+ console.error(' Nothing was changed.')
2953
+ process.exit(2)
2954
+ }
2955
+ }
2956
+ const result = await ops.contentImport(config, {
2957
+ bundle: out,
2958
+ passphrase: pulled.passphrase,
2959
+ replace: 'replace',
2960
+ allowMissingBlobs: media !== 'included',
2961
+ label: `pull from ${environment}`,
2962
+ backupTaken: flags.has('--backup-taken'),
2963
+ })
2964
+ for (const problem of result.problems) console.error(` ${problem}`)
2965
+ if (!result.runId) {
2966
+ printTransferFindings(result.check)
2967
+ console.error('\npull refused. Nothing was changed.')
2968
+ process.exit(1)
2969
+ }
2970
+ console.log(
2971
+ ` replaced, run ${result.runId}: \`crombolo content revert ${result.runId}\` puts it back`,
2972
+ )
2973
+ }
2974
+
2975
+ if (media === 'referenced') {
2976
+ const config = await siteConfig()
2977
+ const blobs = await referencedBlobs(out)
2978
+ if (flags.has('--lazy-media')) {
2979
+ writeRemoteMedia(cwd, { api: discovery.api, environment })
2980
+ await writeFile(
2981
+ join(cwd, '.crombolo', 'remote-media-blobs.json'),
2982
+ `${JSON.stringify(
2983
+ blobs.map((b) => ({ key: b.key, etag: b.etag })),
2984
+ null,
2985
+ 2,
2986
+ )}\n`,
2987
+ )
2988
+ console.log(
2989
+ ` media ${blobs.length} file(s) fetched from ${environment} as they are first asked for`,
2990
+ )
2991
+ } else {
2992
+ const fetched = await fetchReferencedMedia(client, {
2993
+ environment,
2994
+ blobs,
2995
+ store: await mediaStoreFor(config),
2996
+ siteDir: cwd,
2997
+ }).catch(remoteExit)
2998
+ console.log(
2999
+ ` media ${fetched.fetched} fetched (${describeBytes(fetched.bytes)}), ${fetched.skipped} already here`,
3000
+ )
3001
+ for (const key of fetched.failed)
3002
+ console.error(` missing ${key}: run the pull again to fetch it`)
3003
+ }
3004
+ }
3005
+ if (!existing) await start()
3006
+ }
3007
+
3008
+ async function pushCommand(): Promise<void> {
3009
+ const { client, environment, discovery } = await connectRemote(positional[0])
3010
+ if (!environment) {
3011
+ console.error(
3012
+ ` Which environment? ${discovery.environments.map((e) => e.id).join(', ')} (--env)`,
3013
+ )
3014
+ process.exit(2)
3015
+ }
3016
+ const config = await siteConfig()
3017
+ const result = await pushSite(config, client, {
3018
+ environment,
3019
+ force: flags.has('--force'),
3020
+ autoApprove: flags.has('--auto-approve'),
3021
+ mediaInfoOnly: flags.has('--media-info-only'),
3022
+ onCheck: (check) => {
3023
+ const counts = Object.entries(check.sending.counts)
3024
+ console.log(
3025
+ ` sending ${counts.map(([kind, n]) => `${n} ${kind}`).join(', ') || 'no content'}, ${check.sending.files} file(s)`,
3026
+ )
3027
+ if (check.fromRemote) {
3028
+ const recycles = Object.entries(check.recycles)
3029
+ if (recycles.length > 0)
3030
+ console.log(
3031
+ ` recycles ${recycles.map(([kind, n]) => `${n} ${kind}`).join(', ')} on ${environment}`,
3032
+ )
3033
+ if (check.removesFiles.length > 0)
3034
+ console.log(` removes ${check.removesFiles.join(', ')} on ${environment}`)
3035
+ }
3036
+ },
3037
+ confirm: async () => {
3038
+ if (!process.stdin.isTTY) return false
3039
+ const answer = prompt(` This replaces ${environment}. Type its name to go on:`)
3040
+ return answer?.trim() === environment
3041
+ },
3042
+ onProgress: (job) => console.log(describeJob(job)),
3043
+ onApproval: (job) => {
3044
+ console.log(` waiting for someone else to approve it: ${job.approvalUrl}`)
3045
+ if (job.approvalUrl) openInBrowser(job.approvalUrl)
3046
+ },
3047
+ }).catch(remoteExit)
3048
+ const { job } = result
3049
+ if (job.state === 'done') {
3050
+ console.log(`\n pushed ${environment} replaced, run ${job.runId ?? '—'}`)
3051
+ return
3052
+ }
3053
+ console.error(`\n ${job.problem?.title ?? `the push ended ${job.state}`}`)
3054
+ for (const item of job.problem?.items ?? []) console.error(` ${item}`)
3055
+ process.exit(1)
3056
+ }
3057
+
3058
+ async function mediaCommand(): Promise<void> {
3059
+ if (positional[0] !== 'fetch') return help()
3060
+ const source = readRemoteMedia(cwd)
3061
+ const listed = join(cwd, '.crombolo', 'remote-media-blobs.json')
3062
+ if (!source || !existsSync(listed)) {
3063
+ console.error(' This site was not pulled with --lazy-media: there is nothing to fetch.')
3064
+ process.exit(2)
3065
+ }
3066
+ const discovery = await discover(source.api).catch(remoteExit)
3067
+ const client = new RemoteClient(discovery, keychain())
3068
+ const config = await siteConfig()
3069
+ const blobs = JSON.parse(await Bun.file(listed).text()) as { key: string; etag: string | null }[]
3070
+ const fetched = await fetchReferencedMedia(client, {
3071
+ environment: source.environment,
3072
+ blobs,
3073
+ store: await mediaStoreFor(config),
3074
+ siteDir: cwd,
3075
+ onProgress: (done, total) => {
3076
+ if (done % 50 === 0 || done === total) console.log(` ${done}/${total}`)
3077
+ },
3078
+ }).catch(remoteExit)
3079
+ console.log(
3080
+ ` media ${fetched.fetched} fetched (${describeBytes(fetched.bytes)}), ${fetched.skipped} already here`,
3081
+ )
3082
+ for (const key of fetched.failed) console.error(` missing ${key}`)
3083
+ if (fetched.failed.length > 0) process.exit(1)
3084
+ }
3085
+
2444
3086
  async function dispatch(): Promise<void> {
2445
3087
  switch (command) {
2446
3088
  case 'start':
@@ -2490,6 +3132,30 @@ async function dispatch(): Promise<void> {
2490
3132
  case 'assets':
2491
3133
  await assets()
2492
3134
  break
3135
+ case 'export':
3136
+ await exportCommand()
3137
+ break
3138
+ case 'remote':
3139
+ await remoteCommand()
3140
+ break
3141
+ case 'login':
3142
+ await loginCommand()
3143
+ break
3144
+ case 'logout':
3145
+ await logoutCommand()
3146
+ break
3147
+ case 'whoami':
3148
+ await whoamiCommand()
3149
+ break
3150
+ case 'pull':
3151
+ await pullCommand()
3152
+ break
3153
+ case 'push':
3154
+ await pushCommand()
3155
+ break
3156
+ case 'media':
3157
+ await mediaCommand()
3158
+ break
2493
3159
  case 'import':
2494
3160
  await importSite()
2495
3161
  break