@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.
- package/bin/crombolo.ts +689 -23
- package/package.json +7 -7
- package/src/operations/commands.ts +108 -4
- package/src/remote/client.ts +288 -0
- package/src/remote/remotes-file.ts +80 -0
- package/src/remote/transfer.ts +337 -0
- 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,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
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|