@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 +407 -8
- package/package.json +7 -7
- 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/package.json +1 -1
- package/templates/starter/bundle/package.json +1 -1
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
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.
|
|
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.
|
|
38
|
-
"@crombolo/core": "0.14.
|
|
39
|
-
"@crombolo/data": "0.14.
|
|
40
|
-
"@crombolo/schema": "0.14.
|
|
41
|
-
"@crombolo/server": "0.14.
|
|
42
|
-
"@crombolo/transfer": "0.14.
|
|
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
|
+
}
|