@crombolo/cli 0.14.1 → 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 CHANGED
@@ -32,6 +32,10 @@
32
32
  * crombolo domains set write domains.toml and apply it
33
33
  *
34
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
35
39
  * crombolo import umbraco report read-only: what importing an Umbraco backup would and would not bring
36
40
  * crombolo import umbraco apply write a crombolo site from an Umbraco backup
37
41
  *
@@ -45,6 +49,7 @@
45
49
  */
46
50
  import { existsSync } from 'node:fs'
47
51
  import { mkdir, writeFile } from 'node:fs/promises'
52
+ import { hostname } from 'node:os'
48
53
  import { basename, join, relative, resolve } from 'node:path'
49
54
  import { AuthStore, resetAdminPassword } from '@crombolo/auth'
50
55
  import { ObjectTypes } from '@crombolo/core'
@@ -108,15 +113,20 @@ import {
108
113
  describeDatabase,
109
114
  dictionaryToUdt,
110
115
  domainsFilePath,
116
+ forgetSignIn,
111
117
  formTypesFor,
112
118
  importDictionaryUdt,
113
119
  isSafeAlias,
114
120
  listAssets,
115
121
  listComponents,
122
+ listeningBodyLimit,
116
123
  loadConfig,
117
124
  mediaStoreFor,
125
+ osKeychain,
118
126
  placeBlobs,
119
127
  readDomainsFile,
128
+ readRemoteMedia,
129
+ readSignIn,
120
130
  resolveDomainNode,
121
131
  resolvePlaceholders,
122
132
  type ServerHandle,
@@ -129,6 +139,7 @@ import {
129
139
  writeDictionaryUdt,
130
140
  writeDomains,
131
141
  writeDomainsFile,
142
+ writeRemoteMedia,
132
143
  writeUdtAll,
133
144
  } from '@crombolo/server'
134
145
  import {
@@ -151,6 +162,23 @@ import {
151
162
  writeResolutions,
152
163
  } from '@crombolo/transfer'
153
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'
154
182
  import { findTemplate, listTemplates, scaffoldFiles } from '../src/templates.ts'
155
183
 
156
184
  const cwd = process.cwd()
@@ -222,7 +250,7 @@ function stopOnSignal(
222
250
  * imported as a replace so nothing of the scaffold's own starting point stays.
223
251
  * The new administrator's password is printed here, once.
224
252
  */
225
- async function startFromFile(from: string): Promise<void> {
253
+ async function startFromFile(from: string, known?: string): Promise<void> {
226
254
  const file = resolve(cwd, from)
227
255
  if (!existsSync(file)) {
228
256
  console.error(`${from} does not exist.`)
@@ -240,12 +268,16 @@ async function startFromFile(from: string): Promise<void> {
240
268
  console.error(`${from} is not a whole-site file: write one with \`crombolo export --site\`.`)
241
269
  process.exit(1)
242
270
  }
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.`)
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.`)
249
281
  process.exit(1)
250
282
  }
251
283
 
@@ -404,7 +436,7 @@ async function bootWithRetries(config: CromboloConfig) {
404
436
  }
405
437
  const listening = Bun.serve({
406
438
  port: config.port,
407
- maxRequestBodySize: config.maxRequestBodyBytes,
439
+ maxRequestBodySize: listeningBodyLimit(config),
408
440
  fetch: holding,
409
441
  websocket: { message() {} },
410
442
  })
@@ -2606,6 +2638,27 @@ function help(): void {
2606
2638
  domains clear <path|uuid|/> unbind that page, in the file and in the database
2607
2639
  domains apply apply domains.toml here, as a boot does
2608
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
2609
2662
  import <file> [--replace | --replace-all]
2610
2663
  replace this site with a whole-site file. Says first what
2611
2664
  would be replaced, and changes nothing without a mode:
@@ -2705,6 +2758,331 @@ function commandAudit(): ops.CommandAudit | undefined {
2705
2758
  }
2706
2759
  }
2707
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
+
2708
3086
  async function dispatch(): Promise<void> {
2709
3087
  switch (command) {
2710
3088
  case 'start':
@@ -2757,6 +3135,27 @@ async function dispatch(): Promise<void> {
2757
3135
  case 'export':
2758
3136
  await exportCommand()
2759
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
2760
3159
  case 'import':
2761
3160
  await importSite()
2762
3161
  break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/cli",
3
- "version": "0.14.1",
3
+ "version": "0.14.4",
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.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"
37
+ "@crombolo/auth": "0.14.4",
38
+ "@crombolo/core": "0.14.4",
39
+ "@crombolo/data": "0.14.4",
40
+ "@crombolo/schema": "0.14.4",
41
+ "@crombolo/server": "0.14.4",
42
+ "@crombolo/transfer": "0.14.4"
43
43
  }
44
44
  }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * The CLI's side of the remote protocol (story 108, `docs/remote-protocol.md`):
3
+ * discovery, signing in by the device grant, and the API — jobs, downloads,
4
+ * uploads and media — under a sign-in kept in the keychain and refreshed as it
5
+ * runs out.
6
+ */
7
+ import { createHash } from 'node:crypto'
8
+ import { createWriteStream } from 'node:fs'
9
+ import { freshAccessToken, type Keychain, readSignIn, saveSignIn } from '@crombolo/server'
10
+
11
+ export const PROTOCOL_VERSION = '1'
12
+
13
+ export interface Discovery {
14
+ protocol: string
15
+ versions: string[]
16
+ name: string
17
+ api: string
18
+ auth: { deviceAuthorization: string; token: string; clientId: string; grantType: string }
19
+ capabilities: string[]
20
+ environments: Array<{ id: string; name: string; protected: boolean }>
21
+ }
22
+
23
+ export interface Problem {
24
+ status: number
25
+ code: string
26
+ title: string
27
+ detail?: string
28
+ items?: string[]
29
+ }
30
+
31
+ export class RemoteError extends Error {
32
+ constructor(readonly problem: Problem) {
33
+ super(problem.detail ? `${problem.title}: ${problem.detail}` : problem.title)
34
+ }
35
+ }
36
+
37
+ export interface Job {
38
+ id: string
39
+ kind: 'pull' | 'push'
40
+ environment: string
41
+ state: 'queued' | 'running' | 'approval-required' | 'done' | 'failed' | 'refused'
42
+ stage?: string | null
43
+ progress?: number | null
44
+ pollAfter?: number
45
+ approvalUrl?: string
46
+ download?: { url: string; expiresAt: string; size: number | null; sha256: string | null }
47
+ problem?: Problem
48
+ runId?: string
49
+ }
50
+
51
+ /** Reads a remote's discovery document; refuses a site that is not a remote, or one speaking only other versions. */
52
+ export async function discover(url: string, http: typeof fetch = fetch): Promise<Discovery> {
53
+ const origin = new URL(url).origin
54
+ let response: Response
55
+ try {
56
+ response = await http(`${origin}/.well-known/crombolo-remote`)
57
+ } catch (error) {
58
+ throw new Error(
59
+ `${origin} could not be reached: ${error instanceof Error ? error.message : error}`,
60
+ )
61
+ }
62
+ if (response.status === 404) throw new Error(`${origin} is not a crombolo remote.`)
63
+ if (!response.ok)
64
+ throw new Error(`${origin} answered ${response.status} for its discovery document.`)
65
+ const body = (await response.json()) as Discovery
66
+ if (body.protocol !== 'crombolo-remote') throw new Error(`${origin} is not a crombolo remote.`)
67
+ if (!body.versions?.includes(PROTOCOL_VERSION))
68
+ throw new Error(
69
+ `${origin} speaks protocol ${body.versions?.join(', ') || 'none'}; this crombolo speaks ${PROTOCOL_VERSION}.`,
70
+ )
71
+ return body
72
+ }
73
+
74
+ /**
75
+ * Signs in by the device grant (RFC 8628): `onCode` shows the person the code
76
+ * and the page to approve it on, then this polls until they decide. The tokens
77
+ * go into the keychain.
78
+ */
79
+ export async function signIn(
80
+ discovery: Discovery,
81
+ keychain: Keychain,
82
+ options: {
83
+ clientName: string
84
+ onCode(code: string, url: string): void | Promise<void>
85
+ http?: typeof fetch
86
+ sleep?: (ms: number) => Promise<void>
87
+ },
88
+ ): Promise<void> {
89
+ const http = options.http ?? fetch
90
+ const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms))
91
+ const form = (url: string, fields: Record<string, string>) =>
92
+ http(url, {
93
+ method: 'POST',
94
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
95
+ body: new URLSearchParams(fields).toString(),
96
+ })
97
+ const started = await form(discovery.auth.deviceAuthorization, {
98
+ client_id: discovery.auth.clientId,
99
+ client_name: options.clientName,
100
+ })
101
+ if (!started.ok) throw new Error(`The remote would not start a sign-in (${started.status}).`)
102
+ const codes = (await started.json()) as {
103
+ device_code: string
104
+ user_code: string
105
+ verification_uri: string
106
+ verification_uri_complete?: string
107
+ expires_in: number
108
+ interval: number
109
+ }
110
+ await options.onCode(codes.user_code, codes.verification_uri_complete ?? codes.verification_uri)
111
+ let interval = codes.interval * 1000
112
+ const deadline = Date.now() + codes.expires_in * 1000
113
+ for (;;) {
114
+ await sleep(interval)
115
+ const polled = await form(discovery.auth.token, {
116
+ grant_type: discovery.auth.grantType,
117
+ device_code: codes.device_code,
118
+ client_id: discovery.auth.clientId,
119
+ })
120
+ const body = (await polled.json().catch(() => ({}))) as {
121
+ error?: string
122
+ access_token?: string
123
+ refresh_token?: string
124
+ expires_in?: number
125
+ }
126
+ if (polled.ok && body.access_token) {
127
+ await saveSignIn(keychain, discovery.api, {
128
+ accessToken: body.access_token,
129
+ refreshToken: body.refresh_token ?? '',
130
+ expiresAt: Date.now() + (body.expires_in ?? 0) * 1000,
131
+ tokenUrl: discovery.auth.token,
132
+ clientId: discovery.auth.clientId,
133
+ })
134
+ return
135
+ }
136
+ if (body.error === 'slow_down') interval += 5000
137
+ else if (body.error === 'access_denied') throw new Error('The sign-in was declined.')
138
+ else if (body.error === 'expired_token' || Date.now() > deadline)
139
+ throw new Error('The code expired before it was approved. Sign in again.')
140
+ else if (body.error !== 'authorization_pending')
141
+ throw new Error(`The sign-in failed: ${body.error ?? polled.status}`)
142
+ }
143
+ }
144
+
145
+ /** The protocol's API for one remote, as the signed-in person. */
146
+ export class RemoteClient {
147
+ readonly discovery: Discovery
148
+ #keychain: Keychain
149
+ #http: typeof fetch
150
+
151
+ constructor(discovery: Discovery, keychain: Keychain, http: typeof fetch = fetch) {
152
+ this.discovery = discovery
153
+ this.#keychain = keychain
154
+ this.#http = http
155
+ }
156
+
157
+ async signedIn(): Promise<boolean> {
158
+ return (await readSignIn(this.#keychain, this.discovery.api)) !== undefined
159
+ }
160
+
161
+ /** One authorised request; a 401 refreshes the sign-in once and tries again. */
162
+ async call(url: string, init: RequestInit = {}): Promise<Response> {
163
+ const attempt = async (force: boolean) => {
164
+ const token = await freshAccessToken(this.#keychain, this.discovery.api, this.#http, force)
165
+ if (!token)
166
+ throw new RemoteError({
167
+ status: 401,
168
+ code: 'sign-in-required',
169
+ title: `Not signed in to ${new URL(this.discovery.api).origin}`,
170
+ detail: 'Run `crombolo login` first.',
171
+ })
172
+ const headers = new Headers(init.headers)
173
+ headers.set('authorization', `Bearer ${token}`)
174
+ return this.#http(url, { ...init, headers })
175
+ }
176
+ const first = await attempt(false)
177
+ if (first.status !== 401) return first
178
+ return attempt(true)
179
+ }
180
+
181
+ async api(path: string, init: RequestInit = {}): Promise<Response> {
182
+ return this.call(`${this.discovery.api}/${path}`, init)
183
+ }
184
+
185
+ async json<T>(path: string, init: RequestInit = {}): Promise<T> {
186
+ const response = await this.api(path, init)
187
+ if (!response.ok) throw await problemOf(response)
188
+ return (await response.json()) as T
189
+ }
190
+
191
+ post<T>(path: string, body: unknown): Promise<T> {
192
+ return this.json<T>(path, {
193
+ method: 'POST',
194
+ headers: { 'content-type': 'application/json' },
195
+ body: JSON.stringify(body),
196
+ })
197
+ }
198
+
199
+ environments() {
200
+ return this.json<
201
+ Array<{
202
+ id: string
203
+ name: string
204
+ protected: boolean
205
+ may: { pull: boolean; pullFull: boolean; push: boolean }
206
+ }>
207
+ >('environments')
208
+ }
209
+
210
+ /** Waits for a job to stop moving, reporting each change; `onApproval` hears it once. */
211
+ async settle(
212
+ id: string,
213
+ options: {
214
+ onProgress?(job: Job): void
215
+ onApproval?(job: Job): void | Promise<void>
216
+ timeoutMs?: number
217
+ sleep?: (ms: number) => Promise<void>
218
+ } = {},
219
+ ): Promise<Job> {
220
+ const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms))
221
+ const deadline = Date.now() + (options.timeoutMs ?? 30 * 60_000)
222
+ let announced = false
223
+ let last = ''
224
+ for (;;) {
225
+ const job = await this.json<Job>(`jobs/${id}`)
226
+ const seen = `${job.state}|${job.stage ?? ''}|${job.progress ?? ''}`
227
+ if (seen !== last) options.onProgress?.(job)
228
+ last = seen
229
+ if (job.state === 'approval-required' && !announced) {
230
+ announced = true
231
+ await options.onApproval?.(job)
232
+ }
233
+ if (job.state !== 'queued' && job.state !== 'running' && job.state !== 'approval-required')
234
+ return job
235
+ if (Date.now() > deadline) throw new TimedOut(job)
236
+ await sleep((job.pollAfter ?? 2) * 1000)
237
+ }
238
+ }
239
+
240
+ /** Streams a finished pull to `path`, checking it against the hash the job stated. */
241
+ async download(job: Job, path: string): Promise<{ size: number }> {
242
+ if (!job.download) throw new Error('The job has nothing to download.')
243
+ const response = await this.call(job.download.url)
244
+ if (!response.ok || !response.body) throw await problemOf(response)
245
+ const hasher = createHash('sha256')
246
+ const sink = createWriteStream(path)
247
+ let size = 0
248
+ const reader = response.body.getReader()
249
+ for (;;) {
250
+ const { done, value } = await reader.read()
251
+ if (done) break
252
+ hasher.update(value)
253
+ size += value.length
254
+ if (!sink.write(value)) await new Promise((r) => sink.once('drain', r))
255
+ }
256
+ await new Promise<void>((resolve, reject) =>
257
+ sink.end((error?: Error | null) => (error ? reject(error) : resolve())),
258
+ )
259
+ if (job.download.sha256 && hasher.digest('hex') !== job.download.sha256)
260
+ throw new Error('The download does not match the hash the remote stated; try again.')
261
+ return { size }
262
+ }
263
+ }
264
+
265
+ /** A job still moving when the client stopped waiting. */
266
+ export class TimedOut extends Error {
267
+ constructor(readonly job: Job) {
268
+ super(`Still ${job.state} when the wait ran out.`)
269
+ }
270
+ }
271
+
272
+ export async function problemOf(response: Response): Promise<RemoteError> {
273
+ const body = (await response.json().catch(() => ({}))) as Partial<Problem>
274
+ return new RemoteError({
275
+ status: response.status,
276
+ code: body.code ?? `http-${response.status}`,
277
+ title: body.title ?? `The remote answered ${response.status}`,
278
+ ...(body.detail ? { detail: body.detail } : {}),
279
+ ...(body.items ? { items: body.items } : {}),
280
+ })
281
+ }
282
+
283
+ /** SHA-256 of a file, streamed. */
284
+ export async function sha256File(path: string): Promise<string> {
285
+ const hasher = createHash('sha256')
286
+ for await (const chunk of Bun.file(path).stream()) hasher.update(chunk)
287
+ return hasher.digest('hex')
288
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `remotes.toml` at a site's root (story 108): the remotes a team pulls from
3
+ * and pushes to, by name — URLs only, never credentials, so it is committed.
4
+ *
5
+ * [remotes.production]
6
+ * url = "https://example.com"
7
+ * environment = "production"
8
+ */
9
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+
12
+ export const REMOTES_FILE = 'remotes.toml'
13
+
14
+ export interface RemoteEntry {
15
+ name: string
16
+ url: string
17
+ /** The environment a pull or push goes to when `--env` is not given. */
18
+ environment?: string
19
+ }
20
+
21
+ const NAME = /^[a-z][a-z0-9-]{0,39}$/
22
+
23
+ export function readRemotes(siteDir: string): RemoteEntry[] {
24
+ const file = join(siteDir, REMOTES_FILE)
25
+ if (!existsSync(file)) return []
26
+ const parsed = Bun.TOML.parse(readFileSync(file, 'utf8')) as {
27
+ remotes?: Record<string, { url?: unknown; environment?: unknown }>
28
+ }
29
+ return Object.entries(parsed.remotes ?? {}).flatMap(([name, entry]) =>
30
+ typeof entry?.url === 'string'
31
+ ? [
32
+ {
33
+ name,
34
+ url: entry.url,
35
+ ...(typeof entry.environment === 'string' ? { environment: entry.environment } : {}),
36
+ },
37
+ ]
38
+ : [],
39
+ )
40
+ }
41
+
42
+ const quote = (value: string) => JSON.stringify(value)
43
+
44
+ export function writeRemotes(siteDir: string, remotes: readonly RemoteEntry[]): void {
45
+ const lines = [
46
+ '# The remotes `crombolo pull` and `push` reach, by name. URLs only: sign-ins',
47
+ '# are kept in your keychain, never here.',
48
+ ]
49
+ for (const remote of remotes) {
50
+ lines.push('', `[remotes.${remote.name}]`, `url = ${quote(remote.url)}`)
51
+ if (remote.environment) lines.push(`environment = ${quote(remote.environment)}`)
52
+ }
53
+ writeFileSync(join(siteDir, REMOTES_FILE), `${lines.join('\n')}\n`)
54
+ }
55
+
56
+ /** Adds or replaces one, refusing a name that would not survive as a TOML key. */
57
+ export function addRemote(siteDir: string, remote: RemoteEntry): void {
58
+ if (!NAME.test(remote.name))
59
+ throw new Error(`"${remote.name}" is not a remote name: lower case letters, digits and dashes.`)
60
+ const others = readRemotes(siteDir).filter((r) => r.name !== remote.name)
61
+ writeRemotes(
62
+ siteDir,
63
+ [...others, remote].sort((a, b) => a.name.localeCompare(b.name)),
64
+ )
65
+ }
66
+
67
+ export function removeRemote(siteDir: string, name: string): boolean {
68
+ const remotes = readRemotes(siteDir)
69
+ const kept = remotes.filter((r) => r.name !== name)
70
+ if (kept.length === remotes.length) return false
71
+ writeRemotes(siteDir, kept)
72
+ return true
73
+ }
74
+
75
+ /** The remote a command names, or the only one there is. */
76
+ export function pickRemote(siteDir: string, name: string | undefined): RemoteEntry | undefined {
77
+ const remotes = readRemotes(siteDir)
78
+ if (name) return remotes.find((r) => r.name === name)
79
+ return remotes.length === 1 ? remotes[0] : undefined
80
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * `crombolo pull` and `crombolo push` as functions (story 108): they take a
3
+ * signed-in client and options, and return what happened; the binary prints.
4
+ *
5
+ * A pull is a job on the remote ending in a sealed file, opened here with a
6
+ * passphrase made for that one pull and kept in memory. A push exports this
7
+ * site sealed the same way, checks with the remote what it would replace, and
8
+ * uploads it as a job — which the remote may hold for approval.
9
+ */
10
+ import { randomBytes } from 'node:crypto'
11
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import {
15
+ bootstrapDatabase,
16
+ type CromboloConfig,
17
+ exportSite,
18
+ extractTarball,
19
+ type MediaStore,
20
+ mediaStoreFor,
21
+ } from '@crombolo/server'
22
+ import { type BundleBlob, loadBundle } from '@crombolo/transfer'
23
+ import { type Job, problemOf, type RemoteClient, RemoteError, sha256File } from './client.ts'
24
+
25
+ export type MediaMode = 'included' | 'omitted' | 'referenced'
26
+
27
+ /** A passphrase for one transfer: never shown, never stored. */
28
+ export const transferPassphrase = () => randomBytes(24).toString('base64url')
29
+
30
+ /** Refused before anything was sent: the CLI's own guard, exit code 2. */
31
+ export class RefusedBeforeSending extends Error {}
32
+
33
+ export interface PullResult {
34
+ job: Job
35
+ file: string
36
+ passphrase: string
37
+ size: number
38
+ }
39
+
40
+ /** Asks the remote for its site and downloads the file to `out`. */
41
+ export async function pullFile(
42
+ client: RemoteClient,
43
+ options: {
44
+ environment: string
45
+ out: string
46
+ full?: boolean
47
+ media?: MediaMode
48
+ onProgress?(job: Job): void
49
+ timeoutMs?: number
50
+ sleep?: (ms: number) => Promise<void>
51
+ },
52
+ ): Promise<PullResult> {
53
+ const environments = await client.environments()
54
+ const env = environments.find((e) => e.id === options.environment)
55
+ if (!env)
56
+ throw new RefusedBeforeSending(
57
+ `${client.discovery.name} has no environment "${options.environment}": it has ${environments.map((e) => e.id).join(', ')}.`,
58
+ )
59
+ if (!env.may.pull) throw new RefusedBeforeSending(`You may not pull from ${env.name}.`)
60
+ if (options.full && !env.may.pullFull)
61
+ throw new RefusedBeforeSending(
62
+ `You may not pull a full copy of ${env.name}; pull without --full for an anonymised one.`,
63
+ )
64
+ if (options.media === 'referenced' && !client.discovery.capabilities.includes('media-fetch'))
65
+ throw new RefusedBeforeSending(
66
+ `${client.discovery.name} does not serve media by reference; pull without --media-info-only.`,
67
+ )
68
+ const passphrase = transferPassphrase()
69
+ const started = await client.post<Job>(`environments/${env.id}/pulls`, {
70
+ passphrase,
71
+ full: options.full === true,
72
+ media: options.media ?? 'included',
73
+ })
74
+ const job = await client.settle(started.id, {
75
+ onProgress: options.onProgress,
76
+ timeoutMs: options.timeoutMs,
77
+ sleep: options.sleep,
78
+ })
79
+ if (job.state !== 'done') throw new RemoteError(job.problem ?? jobProblem(job))
80
+ const { size } = await client.download(job, options.out)
81
+ return { job, file: options.out, passphrase, size }
82
+ }
83
+
84
+ const jobProblem = (job: Job) => ({
85
+ status: 0,
86
+ code: job.state,
87
+ title: `The ${job.kind} ended ${job.state}`,
88
+ })
89
+
90
+ /** What the local site has already fetched of a remote's media, by key: the remote's etag. */
91
+ const FETCHED_FILE = join('.crombolo', 'remote-media-fetched.json')
92
+
93
+ function readFetched(siteDir: string): Record<string, string> {
94
+ const file = join(siteDir, FETCHED_FILE)
95
+ if (!existsSync(file)) return {}
96
+ try {
97
+ return JSON.parse(readFileSync(file, 'utf8')) as Record<string, string>
98
+ } catch {
99
+ return {}
100
+ }
101
+ }
102
+
103
+ export interface MediaFetchResult {
104
+ fetched: number
105
+ skipped: number
106
+ bytes: number
107
+ failed: string[]
108
+ }
109
+
110
+ /**
111
+ * Fetches the media files a file names by reference into the local store, a
112
+ * batch of links at a time and a few files at once. A file already fetched at
113
+ * the same etag is skipped, so a second pull downloads only what changed.
114
+ */
115
+ export async function fetchReferencedMedia(
116
+ client: RemoteClient,
117
+ options: {
118
+ environment: string
119
+ blobs: readonly Pick<BundleBlob, 'key' | 'etag'>[]
120
+ store: MediaStore
121
+ siteDir: string
122
+ onProgress?(done: number, total: number): void
123
+ http?: typeof fetch
124
+ },
125
+ ): Promise<MediaFetchResult> {
126
+ const fetched = readFetched(options.siteDir)
127
+ const result: MediaFetchResult = { fetched: 0, skipped: 0, bytes: 0, failed: [] }
128
+ const wanted: string[] = []
129
+ for (const blob of options.blobs) {
130
+ const have = await options.store.get(blob.key)
131
+ if (have && blob.etag && fetched[blob.key] === blob.etag) result.skipped++
132
+ else wanted.push(blob.key)
133
+ }
134
+ let done = 0
135
+ for (let at = 0; at < wanted.length; at += 200) {
136
+ const batch = wanted.slice(at, at + 200)
137
+ const { links } = await client.post<{
138
+ links: Record<string, { url: string; etag: string; size: number } | null>
139
+ }>(`environments/${options.environment}/media/links`, { keys: batch })
140
+ const queue = [...batch]
141
+ const worker = async () => {
142
+ for (;;) {
143
+ const key = queue.shift()
144
+ if (!key) return
145
+ const link = links[key]
146
+ try {
147
+ if (!link) throw new Error('not on the remote')
148
+ // A link through the API needs the sign-in; a presigned one does not.
149
+ const response = link.url.startsWith(client.discovery.api)
150
+ ? await client.call(link.url)
151
+ : await (options.http ?? fetch)(link.url)
152
+ if (!response.ok) throw await problemOf(response)
153
+ const body = await response.blob()
154
+ await options.store.put(key, body)
155
+ fetched[key] = link.etag
156
+ result.fetched++
157
+ result.bytes += body.size
158
+ } catch {
159
+ result.failed.push(key)
160
+ }
161
+ options.onProgress?.(++done, wanted.length)
162
+ }
163
+ }
164
+ await Promise.all(Array.from({ length: 6 }, worker))
165
+ }
166
+ mkdirSync(join(options.siteDir, '.crombolo'), { recursive: true })
167
+ writeFileSync(join(options.siteDir, FETCHED_FILE), `${JSON.stringify(fetched, null, 2)}\n`)
168
+ return result
169
+ }
170
+
171
+ /** The blobs a file names by reference, read from its manifest. */
172
+ export async function referencedBlobs(file: string): Promise<BundleBlob[]> {
173
+ const dir = mkdtempSync(join(tmpdir(), 'crombolo-pulled-'))
174
+ try {
175
+ await extractTarball(file, dir)
176
+ const manifest = loadBundle(join(dir, 'content')).set?.manifest
177
+ return manifest?.media === 'referenced' ? manifest.blobs : []
178
+ } finally {
179
+ rmSync(dir, { recursive: true, force: true })
180
+ }
181
+ }
182
+
183
+ export interface PushCheck {
184
+ environment: string
185
+ recycles: Record<string, number>
186
+ removesFiles: string[]
187
+ keepsPeople: boolean
188
+ /** Whether the remote answered it, or it is only what this side is sending. */
189
+ fromRemote: boolean
190
+ sending: { counts: Record<string, number>; files: number; media: number }
191
+ }
192
+
193
+ export interface PushResult {
194
+ check: PushCheck
195
+ job: Job
196
+ }
197
+
198
+ /**
199
+ * Exports this site and sends it. `confirm` is asked with the check for a
200
+ * protected environment unless `autoApprove`; `onApproval` hears the job when
201
+ * the remote holds it for approval. The flags are this side's guard against a
202
+ * slip, never authority: the remote decides.
203
+ */
204
+ export async function pushSite(
205
+ config: CromboloConfig,
206
+ client: RemoteClient,
207
+ options: {
208
+ environment: string
209
+ force?: boolean
210
+ autoApprove?: boolean
211
+ mediaInfoOnly?: boolean
212
+ confirm?(check: PushCheck): Promise<boolean>
213
+ onCheck?(check: PushCheck): void
214
+ onProgress?(job: Job): void
215
+ onApproval?(job: Job): void | Promise<void>
216
+ onMedia?(done: number, total: number): void
217
+ timeoutMs?: number
218
+ sleep?: (ms: number) => Promise<void>
219
+ },
220
+ ): Promise<PushResult> {
221
+ const environments = await client.environments()
222
+ const env = environments.find((e) => e.id === options.environment)
223
+ if (!env)
224
+ throw new RefusedBeforeSending(
225
+ `${client.discovery.name} has no environment "${options.environment}".`,
226
+ )
227
+ if (!env.may.push) throw new RefusedBeforeSending(`You may not push to ${env.name}.`)
228
+ if (env.protected && !options.force)
229
+ throw new RefusedBeforeSending(
230
+ `${env.name} is protected: pushing replaces what people rely on. Pass --force to go on.`,
231
+ )
232
+ if (options.mediaInfoOnly && !client.discovery.capabilities.includes('media-fetch'))
233
+ throw new RefusedBeforeSending(
234
+ `${client.discovery.name} does not take media by reference; push without --media-info-only.`,
235
+ )
236
+
237
+ const work = mkdtempSync(join(tmpdir(), 'crombolo-push-'))
238
+ try {
239
+ const out = join(work, 'site.tgz')
240
+ const passphrase = transferPassphrase()
241
+ const { db } = await bootstrapDatabase(config)
242
+ try {
243
+ await exportSite(db, config, {
244
+ out,
245
+ sealContent: true,
246
+ passphrase,
247
+ media: options.mediaInfoOnly ? 'referenced' : true,
248
+ })
249
+ } finally {
250
+ await db.close()
251
+ }
252
+ const unpacked = join(work, 'unpacked')
253
+ await extractTarball(out, unpacked)
254
+ const loaded = loadBundle(join(unpacked, 'content'))
255
+ const manifest = loaded.set?.manifest
256
+ if (!manifest) throw new Error('The export could not be read back.')
257
+ const sending = {
258
+ counts: { ...manifest.counts } as Record<string, number>,
259
+ files: loaded.files.size,
260
+ media: manifest.blobs.length,
261
+ }
262
+ const check: PushCheck = client.discovery.capabilities.includes('push-check')
263
+ ? {
264
+ ...(await client.post<Omit<PushCheck, 'fromRemote' | 'sending'>>(
265
+ `environments/${env.id}/pushes/check`,
266
+ {
267
+ nodes: (loaded.set?.nodes ?? []).map((node) => node.key),
268
+ files: [...loaded.files.keys()],
269
+ },
270
+ )),
271
+ fromRemote: true,
272
+ sending,
273
+ }
274
+ : {
275
+ environment: env.id,
276
+ recycles: {},
277
+ removesFiles: [],
278
+ keepsPeople: true,
279
+ fromRemote: false,
280
+ sending,
281
+ }
282
+ options.onCheck?.(check)
283
+ if (env.protected && !options.autoApprove) {
284
+ const yes = (await options.confirm?.(check)) ?? false
285
+ if (!yes) throw new RefusedBeforeSending('Nothing was sent.')
286
+ }
287
+
288
+ if (options.mediaInfoOnly && manifest.blobs.length > 0) {
289
+ const store = await mediaStoreFor(config)
290
+ const { have } = await client.post<{ have: string[] }>(`environments/${env.id}/media/have`, {
291
+ blobs: manifest.blobs.map((blob) => ({ key: blob.key, etag: blob.etag ?? undefined })),
292
+ })
293
+ const present = new Set(have)
294
+ const missing = manifest.blobs.filter((blob) => !present.has(blob.key))
295
+ let done = 0
296
+ for (const blob of missing) {
297
+ const file = await store.get(blob.key)
298
+ if (file) {
299
+ const bytes = await file.bytes()
300
+ const response = await client.api(
301
+ `environments/${env.id}/media/${blob.key.split('/').map(encodeURIComponent).join('/')}`,
302
+ {
303
+ method: 'PUT',
304
+ headers: {
305
+ 'crombolo-sha256': new Bun.CryptoHasher('sha256').update(bytes).digest('hex'),
306
+ },
307
+ body: bytes as BodyInit,
308
+ },
309
+ )
310
+ if (!response.ok) throw await problemOf(response)
311
+ }
312
+ options.onMedia?.(++done, missing.length)
313
+ }
314
+ }
315
+
316
+ const response = await client.api(`environments/${env.id}/pushes`, {
317
+ method: 'PUT',
318
+ headers: {
319
+ 'content-type': 'application/gzip',
320
+ 'crombolo-sha256': await sha256File(out),
321
+ 'crombolo-passphrase': passphrase,
322
+ },
323
+ body: Bun.file(out),
324
+ })
325
+ if (!response.ok) throw await problemOf(response)
326
+ const started = (await response.json()) as Job
327
+ const job = await client.settle(started.id, {
328
+ onProgress: options.onProgress,
329
+ onApproval: options.onApproval,
330
+ timeoutMs: options.timeoutMs,
331
+ sleep: options.sleep,
332
+ })
333
+ return { check, job }
334
+ } finally {
335
+ rmSync(work, { recursive: true, force: true })
336
+ }
337
+ }
@@ -6,7 +6,7 @@
6
6
  "content"
7
7
  ],
8
8
  "peerDependencies": {
9
- "@crombolo/crombolo": ">=0.14.1"
9
+ "@crombolo/crombolo": ">=0.14.4"
10
10
  },
11
11
  "crombolo": {
12
12
  "id": "basic",
@@ -6,7 +6,7 @@
6
6
  "content"
7
7
  ],
8
8
  "peerDependencies": {
9
- "@crombolo/crombolo": ">=0.14.1"
9
+ "@crombolo/crombolo": ">=0.14.4"
10
10
  },
11
11
  "crombolo": {
12
12
  "id": "demo-harbourstone",
@@ -6,7 +6,7 @@
6
6
  "content"
7
7
  ],
8
8
  "peerDependencies": {
9
- "@crombolo/crombolo": ">=0.14.1"
9
+ "@crombolo/crombolo": ">=0.14.4"
10
10
  },
11
11
  "crombolo": {
12
12
  "id": "starter",