@crombolo/cli 0.14.1 → 0.15.0
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 +4 -2767
- package/oclif.manifest.json +6710 -0
- package/package.json +58 -7
- package/src/base.ts +129 -0
- package/src/classify.ts +119 -0
- package/src/clispec.ts +163 -0
- package/src/commands/admin/reset-password.ts +53 -0
- package/src/commands/assets/index.ts +6 -0
- package/src/commands/assets/list.ts +27 -0
- package/src/commands/assets/new.ts +42 -0
- package/src/commands/bundle/purge.ts +71 -0
- package/src/commands/components/check.ts +58 -0
- package/src/commands/components/index.ts +6 -0
- package/src/commands/components/list.ts +40 -0
- package/src/commands/components/new.ts +49 -0
- package/src/commands/content/check.ts +102 -0
- package/src/commands/content/export.ts +116 -0
- package/src/commands/content/import.ts +109 -0
- package/src/commands/content/publish.ts +21 -0
- package/src/commands/content/revert.ts +137 -0
- package/src/commands/content/runs.ts +62 -0
- package/src/commands/content/unpublish.ts +21 -0
- package/src/commands/dictionary/export.ts +49 -0
- package/src/commands/dictionary/import.ts +46 -0
- package/src/commands/domains/apply.ts +23 -0
- package/src/commands/domains/clear.ts +27 -0
- package/src/commands/domains/index.ts +6 -0
- package/src/commands/domains/list.ts +86 -0
- package/src/commands/domains/set.ts +55 -0
- package/src/commands/domains/undo.ts +29 -0
- package/src/commands/export.ts +100 -0
- package/src/commands/generate.ts +33 -0
- package/src/commands/import/index.ts +175 -0
- package/src/commands/import/umbraco/apply.ts +128 -0
- package/src/commands/import/umbraco/report.ts +56 -0
- package/src/commands/init.ts +76 -0
- package/src/commands/login.ts +38 -0
- package/src/commands/logout.ts +36 -0
- package/src/commands/maintenance/pause.ts +29 -0
- package/src/commands/maintenance/resume.ts +20 -0
- package/src/commands/maintenance/status.ts +34 -0
- package/src/commands/media/fetch.ts +62 -0
- package/src/commands/pull.ts +210 -0
- package/src/commands/push.ts +108 -0
- package/src/commands/remote/add.ts +78 -0
- package/src/commands/remote/job.ts +53 -0
- package/src/commands/remote/list.ts +41 -0
- package/src/commands/remote/remove.ts +26 -0
- package/src/commands/schema.ts +32 -0
- package/src/commands/start.ts +64 -0
- package/src/commands/status.ts +103 -0
- package/src/commands/types/add-property.ts +116 -0
- package/src/commands/types/check.ts +88 -0
- package/src/commands/types/export.ts +32 -0
- package/src/commands/types/new.ts +116 -0
- package/src/commands/types/purge.ts +65 -0
- package/src/commands/types/rewrite.ts +30 -0
- package/src/commands/types/sync.ts +66 -0
- package/src/commands/upgrade/check.ts +69 -0
- package/src/commands/upgrade/index.ts +120 -0
- package/src/commands/upgrade/ledger.ts +55 -0
- package/src/commands/whoami.ts +41 -0
- package/src/errors.ts +90 -0
- package/src/main.ts +23 -0
- package/src/manifest.ts +20 -0
- package/src/reference.ts +118 -0
- package/src/remote/client.ts +297 -0
- package/src/remote/remotes-file.ts +81 -0
- package/src/remote/transfer.ts +353 -0
- package/src/support/components.ts +7 -0
- package/src/support/describe.ts +179 -0
- package/src/support/domains.ts +89 -0
- package/src/support/flags.ts +157 -0
- package/src/support/prompt.ts +126 -0
- package/src/support/publish.ts +142 -0
- package/src/support/remote.ts +58 -0
- package/src/support/serve.ts +460 -0
- package/src/support/site.ts +26 -0
- package/src/support/types.ts +112 -0
- package/src/support/umbraco.ts +86 -0
- package/templates/basic/bundle/content/bundle.json +1 -1
- package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
- 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
|
@@ -0,0 +1,353 @@
|
|
|
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, mkdtempSync, readFileSync, rmSync } 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
|
+
writeFileAtomic,
|
|
22
|
+
} from '@crombolo/server'
|
|
23
|
+
import { type BundleBlob, loadBundle } from '@crombolo/transfer'
|
|
24
|
+
import { CliError, type ErrorKind } from '../errors.ts'
|
|
25
|
+
import { type Job, problemOf, type RemoteClient, RemoteError, sha256File } from './client.ts'
|
|
26
|
+
|
|
27
|
+
export type MediaMode = 'included' | 'omitted' | 'referenced'
|
|
28
|
+
|
|
29
|
+
/** A passphrase for one transfer: never shown, never stored. */
|
|
30
|
+
export const transferPassphrase = () => randomBytes(24).toString('base64url')
|
|
31
|
+
|
|
32
|
+
/** Refused before anything was sent: the CLI's own guard. */
|
|
33
|
+
export class RefusedBeforeSending extends CliError {
|
|
34
|
+
constructor(kind: ErrorKind, message: string, hint?: string) {
|
|
35
|
+
super(kind, message, { hint })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PullResult {
|
|
40
|
+
job: Job
|
|
41
|
+
file: string
|
|
42
|
+
passphrase: string
|
|
43
|
+
size: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Asks the remote for its site and downloads the file to `out`. */
|
|
47
|
+
export async function pullFile(
|
|
48
|
+
client: RemoteClient,
|
|
49
|
+
options: {
|
|
50
|
+
environment: string
|
|
51
|
+
out: string
|
|
52
|
+
full?: boolean
|
|
53
|
+
media?: MediaMode
|
|
54
|
+
onProgress?(job: Job): void
|
|
55
|
+
timeoutMs?: number
|
|
56
|
+
sleep?: (ms: number) => Promise<void>
|
|
57
|
+
},
|
|
58
|
+
): Promise<PullResult> {
|
|
59
|
+
const environments = await client.environments()
|
|
60
|
+
const env = environments.find((e) => e.id === options.environment)
|
|
61
|
+
if (!env)
|
|
62
|
+
throw new RefusedBeforeSending(
|
|
63
|
+
'not_found',
|
|
64
|
+
`${client.discovery.name} has no environment "${options.environment}": it has ${environments.map((e) => e.id).join(', ')}.`,
|
|
65
|
+
)
|
|
66
|
+
if (!env.may.pull) throw new RefusedBeforeSending('refused', `You may not pull from ${env.name}.`)
|
|
67
|
+
if (options.full && !env.may.pullFull)
|
|
68
|
+
throw new RefusedBeforeSending(
|
|
69
|
+
'refused',
|
|
70
|
+
`You may not pull a full copy of ${env.name}; pull without --full for an anonymised one.`,
|
|
71
|
+
)
|
|
72
|
+
if (options.media === 'referenced' && !client.discovery.capabilities.includes('media-fetch'))
|
|
73
|
+
throw new RefusedBeforeSending(
|
|
74
|
+
'refused',
|
|
75
|
+
`${client.discovery.name} does not serve media by reference; pull without --media-info-only.`,
|
|
76
|
+
)
|
|
77
|
+
const passphrase = transferPassphrase()
|
|
78
|
+
const started = await client.post<Job>(`environments/${env.id}/pulls`, {
|
|
79
|
+
passphrase,
|
|
80
|
+
full: options.full === true,
|
|
81
|
+
media: options.media ?? 'included',
|
|
82
|
+
})
|
|
83
|
+
const job = await client.settle(started.id, {
|
|
84
|
+
onProgress: options.onProgress,
|
|
85
|
+
timeoutMs: options.timeoutMs,
|
|
86
|
+
sleep: options.sleep,
|
|
87
|
+
})
|
|
88
|
+
if (job.state !== 'done') throw new RemoteError(job.problem ?? jobProblem(job))
|
|
89
|
+
const { size } = await client.download(job, options.out)
|
|
90
|
+
return { job, file: options.out, passphrase, size }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const jobProblem = (job: Job) => ({
|
|
94
|
+
status: 0,
|
|
95
|
+
code: job.state,
|
|
96
|
+
title: `The ${job.kind} ended ${job.state}`,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
/** What the local site has already fetched of a remote's media, by key: the remote's etag. */
|
|
100
|
+
const FETCHED_FILE = join('.crombolo', 'remote-media-fetched.json')
|
|
101
|
+
|
|
102
|
+
function readFetched(siteDir: string): Record<string, string> {
|
|
103
|
+
const file = join(siteDir, FETCHED_FILE)
|
|
104
|
+
if (!existsSync(file)) return {}
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(readFileSync(file, 'utf8')) as Record<string, string>
|
|
107
|
+
} catch {
|
|
108
|
+
return {}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface MediaFetchResult {
|
|
113
|
+
fetched: number
|
|
114
|
+
skipped: number
|
|
115
|
+
bytes: number
|
|
116
|
+
failed: string[]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Fetches the media files a file names by reference into the local store, a
|
|
121
|
+
* batch of links at a time and a few files at once. A file already fetched at
|
|
122
|
+
* the same etag is skipped, so a second pull downloads only what changed.
|
|
123
|
+
*/
|
|
124
|
+
export async function fetchReferencedMedia(
|
|
125
|
+
client: RemoteClient,
|
|
126
|
+
options: {
|
|
127
|
+
environment: string
|
|
128
|
+
blobs: readonly Pick<BundleBlob, 'key' | 'etag'>[]
|
|
129
|
+
store: MediaStore
|
|
130
|
+
siteDir: string
|
|
131
|
+
onProgress?(done: number, total: number): void
|
|
132
|
+
http?: typeof fetch
|
|
133
|
+
},
|
|
134
|
+
): Promise<MediaFetchResult> {
|
|
135
|
+
const fetched = readFetched(options.siteDir)
|
|
136
|
+
const result: MediaFetchResult = { fetched: 0, skipped: 0, bytes: 0, failed: [] }
|
|
137
|
+
const wanted: string[] = []
|
|
138
|
+
for (const blob of options.blobs) {
|
|
139
|
+
const have = await options.store.get(blob.key)
|
|
140
|
+
if (have && blob.etag && fetched[blob.key] === blob.etag) result.skipped++
|
|
141
|
+
else wanted.push(blob.key)
|
|
142
|
+
}
|
|
143
|
+
let done = 0
|
|
144
|
+
for (let at = 0; at < wanted.length; at += 200) {
|
|
145
|
+
const batch = wanted.slice(at, at + 200)
|
|
146
|
+
const { links } = await client.post<{
|
|
147
|
+
links: Record<string, { url: string; etag: string; size: number } | null>
|
|
148
|
+
}>(`environments/${options.environment}/media/links`, { keys: batch })
|
|
149
|
+
const queue = [...batch]
|
|
150
|
+
const worker = async () => {
|
|
151
|
+
for (;;) {
|
|
152
|
+
const key = queue.shift()
|
|
153
|
+
if (!key) return
|
|
154
|
+
const link = links[key]
|
|
155
|
+
try {
|
|
156
|
+
if (!link) throw new Error('not on the remote')
|
|
157
|
+
// A link through the API needs the sign-in; a presigned one does not.
|
|
158
|
+
const response = link.url.startsWith(client.discovery.api)
|
|
159
|
+
? await client.call(link.url)
|
|
160
|
+
: await (options.http ?? fetch)(link.url)
|
|
161
|
+
if (!response.ok) throw await problemOf(response)
|
|
162
|
+
const body = await response.blob()
|
|
163
|
+
await options.store.put(key, body)
|
|
164
|
+
fetched[key] = link.etag
|
|
165
|
+
result.fetched++
|
|
166
|
+
result.bytes += body.size
|
|
167
|
+
} catch {
|
|
168
|
+
result.failed.push(key)
|
|
169
|
+
}
|
|
170
|
+
options.onProgress?.(++done, wanted.length)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
await Promise.all(Array.from({ length: 6 }, worker))
|
|
174
|
+
}
|
|
175
|
+
writeFileAtomic(join(options.siteDir, FETCHED_FILE), `${JSON.stringify(fetched, null, 2)}\n`)
|
|
176
|
+
return result
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The blobs a file names by reference, read from its manifest. */
|
|
180
|
+
export async function referencedBlobs(file: string): Promise<BundleBlob[]> {
|
|
181
|
+
const dir = mkdtempSync(join(tmpdir(), 'crombolo-pulled-'))
|
|
182
|
+
try {
|
|
183
|
+
await extractTarball(file, dir)
|
|
184
|
+
const manifest = loadBundle(join(dir, 'content')).set?.manifest
|
|
185
|
+
return manifest?.media === 'referenced' ? manifest.blobs : []
|
|
186
|
+
} finally {
|
|
187
|
+
rmSync(dir, { recursive: true, force: true })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface PushCheck {
|
|
192
|
+
environment: string
|
|
193
|
+
recycles: Record<string, number>
|
|
194
|
+
removesFiles: string[]
|
|
195
|
+
keepsPeople: boolean
|
|
196
|
+
/** Whether the remote answered it, or it is only what this side is sending. */
|
|
197
|
+
fromRemote: boolean
|
|
198
|
+
sending: { counts: Record<string, number>; files: number; media: number }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface PushResult {
|
|
202
|
+
check: PushCheck
|
|
203
|
+
/** Absent for a dry run, which stops after the check. */
|
|
204
|
+
job?: Job
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Exports this site and sends it. `confirm` is asked with the check for a
|
|
209
|
+
* protected environment unless `autoApprove`; `onApproval` hears the job when
|
|
210
|
+
* the remote holds it for approval. The flags are this side's guard against a
|
|
211
|
+
* slip, never authority: the remote decides.
|
|
212
|
+
*/
|
|
213
|
+
export async function pushSite(
|
|
214
|
+
config: CromboloConfig,
|
|
215
|
+
client: RemoteClient,
|
|
216
|
+
options: {
|
|
217
|
+
environment: string
|
|
218
|
+
force?: boolean
|
|
219
|
+
autoApprove?: boolean
|
|
220
|
+
mediaInfoOnly?: boolean
|
|
221
|
+
/** Export and check, then stop: nothing is uploaded. */
|
|
222
|
+
dryRun?: boolean
|
|
223
|
+
confirm?(check: PushCheck): Promise<boolean>
|
|
224
|
+
onCheck?(check: PushCheck): void
|
|
225
|
+
onProgress?(job: Job): void
|
|
226
|
+
onApproval?(job: Job): void | Promise<void>
|
|
227
|
+
onMedia?(done: number, total: number): void
|
|
228
|
+
timeoutMs?: number
|
|
229
|
+
sleep?: (ms: number) => Promise<void>
|
|
230
|
+
},
|
|
231
|
+
): Promise<PushResult> {
|
|
232
|
+
const environments = await client.environments()
|
|
233
|
+
const env = environments.find((e) => e.id === options.environment)
|
|
234
|
+
if (!env)
|
|
235
|
+
throw new RefusedBeforeSending(
|
|
236
|
+
'not_found',
|
|
237
|
+
`${client.discovery.name} has no environment "${options.environment}".`,
|
|
238
|
+
)
|
|
239
|
+
if (!env.may.push) throw new RefusedBeforeSending('refused', `You may not push to ${env.name}.`)
|
|
240
|
+
if (env.protected && !options.force && !options.dryRun)
|
|
241
|
+
throw new RefusedBeforeSending(
|
|
242
|
+
'confirmation_required',
|
|
243
|
+
`${env.name} is protected: pushing replaces what people rely on.`,
|
|
244
|
+
'Pass --force to go on.',
|
|
245
|
+
)
|
|
246
|
+
if (options.mediaInfoOnly && !client.discovery.capabilities.includes('media-fetch'))
|
|
247
|
+
throw new RefusedBeforeSending(
|
|
248
|
+
'refused',
|
|
249
|
+
`${client.discovery.name} does not take media by reference; push without --media-info-only.`,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
const work = mkdtempSync(join(tmpdir(), 'crombolo-push-'))
|
|
253
|
+
try {
|
|
254
|
+
const out = join(work, 'site.tgz')
|
|
255
|
+
const passphrase = transferPassphrase()
|
|
256
|
+
const { db } = await bootstrapDatabase(config)
|
|
257
|
+
try {
|
|
258
|
+
await exportSite(db, config, {
|
|
259
|
+
out,
|
|
260
|
+
sealContent: true,
|
|
261
|
+
passphrase,
|
|
262
|
+
media: options.mediaInfoOnly ? 'referenced' : true,
|
|
263
|
+
})
|
|
264
|
+
} finally {
|
|
265
|
+
await db.close()
|
|
266
|
+
}
|
|
267
|
+
const unpacked = join(work, 'unpacked')
|
|
268
|
+
await extractTarball(out, unpacked)
|
|
269
|
+
const loaded = loadBundle(join(unpacked, 'content'))
|
|
270
|
+
const manifest = loaded.set?.manifest
|
|
271
|
+
if (!manifest) throw new Error('The export could not be read back.')
|
|
272
|
+
const sending = {
|
|
273
|
+
counts: { ...manifest.counts } as Record<string, number>,
|
|
274
|
+
files: loaded.files.size,
|
|
275
|
+
media: manifest.blobs.length,
|
|
276
|
+
}
|
|
277
|
+
const check: PushCheck = client.discovery.capabilities.includes('push-check')
|
|
278
|
+
? {
|
|
279
|
+
...(await client.post<Omit<PushCheck, 'fromRemote' | 'sending'>>(
|
|
280
|
+
`environments/${env.id}/pushes/check`,
|
|
281
|
+
{
|
|
282
|
+
nodes: (loaded.set?.nodes ?? []).map((node) => node.key),
|
|
283
|
+
files: [...loaded.files.keys()],
|
|
284
|
+
},
|
|
285
|
+
)),
|
|
286
|
+
fromRemote: true,
|
|
287
|
+
sending,
|
|
288
|
+
}
|
|
289
|
+
: {
|
|
290
|
+
environment: env.id,
|
|
291
|
+
recycles: {},
|
|
292
|
+
removesFiles: [],
|
|
293
|
+
keepsPeople: true,
|
|
294
|
+
fromRemote: false,
|
|
295
|
+
sending,
|
|
296
|
+
}
|
|
297
|
+
options.onCheck?.(check)
|
|
298
|
+
if (options.dryRun) return { check }
|
|
299
|
+
if (env.protected && !options.autoApprove) {
|
|
300
|
+
const yes = (await options.confirm?.(check)) ?? false
|
|
301
|
+
if (!yes) throw new RefusedBeforeSending('refused', 'Nothing was sent.')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (options.mediaInfoOnly && manifest.blobs.length > 0) {
|
|
305
|
+
const store = await mediaStoreFor(config)
|
|
306
|
+
const { have } = await client.post<{ have: string[] }>(`environments/${env.id}/media/have`, {
|
|
307
|
+
blobs: manifest.blobs.map((blob) => ({ key: blob.key, etag: blob.etag ?? undefined })),
|
|
308
|
+
})
|
|
309
|
+
const present = new Set(have)
|
|
310
|
+
const missing = manifest.blobs.filter((blob) => !present.has(blob.key))
|
|
311
|
+
let done = 0
|
|
312
|
+
for (const blob of missing) {
|
|
313
|
+
const file = await store.get(blob.key)
|
|
314
|
+
if (file) {
|
|
315
|
+
const bytes = await file.bytes()
|
|
316
|
+
const response = await client.api(
|
|
317
|
+
`environments/${env.id}/media/${blob.key.split('/').map(encodeURIComponent).join('/')}`,
|
|
318
|
+
{
|
|
319
|
+
method: 'PUT',
|
|
320
|
+
headers: {
|
|
321
|
+
'crombolo-sha256': new Bun.CryptoHasher('sha256').update(bytes).digest('hex'),
|
|
322
|
+
},
|
|
323
|
+
body: bytes as BodyInit,
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
if (!response.ok) throw await problemOf(response)
|
|
327
|
+
}
|
|
328
|
+
options.onMedia?.(++done, missing.length)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const response = await client.api(`environments/${env.id}/pushes`, {
|
|
333
|
+
method: 'PUT',
|
|
334
|
+
headers: {
|
|
335
|
+
'content-type': 'application/gzip',
|
|
336
|
+
'crombolo-sha256': await sha256File(out),
|
|
337
|
+
'crombolo-passphrase': passphrase,
|
|
338
|
+
},
|
|
339
|
+
body: Bun.file(out),
|
|
340
|
+
})
|
|
341
|
+
if (!response.ok) throw await problemOf(response)
|
|
342
|
+
const started = (await response.json()) as Job
|
|
343
|
+
const job = await client.settle(started.id, {
|
|
344
|
+
onProgress: options.onProgress,
|
|
345
|
+
onApproval: options.onApproval,
|
|
346
|
+
timeoutMs: options.timeoutMs,
|
|
347
|
+
sleep: options.sleep,
|
|
348
|
+
})
|
|
349
|
+
return { check, job }
|
|
350
|
+
} finally {
|
|
351
|
+
rmSync(work, { recursive: true, force: true })
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The content types a site declares, which say which components are templates. */
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import type { CromboloConfig } from '@crombolo/server'
|
|
4
|
+
import { loadTypes } from './types.ts'
|
|
5
|
+
|
|
6
|
+
export const declaredTypes = (config: CromboloConfig) =>
|
|
7
|
+
existsSync(config.schemaDir) ? loadTypes(config).set.contentTypes : []
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/** How the CLI says what happened: the lines shared between commands. */
|
|
2
|
+
import { relative } from 'node:path'
|
|
3
|
+
import type { Finding } from '@crombolo/data'
|
|
4
|
+
import type { SyncReport } from '@crombolo/schema'
|
|
5
|
+
import { propertyHandle, resolutionsFor, type TransferCheck } from '@crombolo/transfer'
|
|
6
|
+
import type * as ops from '../operations/commands.ts'
|
|
7
|
+
import type { Job } from '../remote/client.ts'
|
|
8
|
+
import { cwd, type Io } from './site.ts'
|
|
9
|
+
|
|
10
|
+
export function banner(io: Io, lines: string[]): void {
|
|
11
|
+
const width = Math.max(...lines.map((line) => line.length))
|
|
12
|
+
io.say(`┌─${'─'.repeat(width)}─┐`)
|
|
13
|
+
for (const line of lines) io.say(`│ ${line.padEnd(width)} │`)
|
|
14
|
+
io.say(`└─${'─'.repeat(width)}─┘`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const here = (path: string): string => relative(cwd(), path) || '.'
|
|
18
|
+
|
|
19
|
+
/** Bytes as somebody reads them, since a bundle of media is measured in them. */
|
|
20
|
+
export function describeBytes(bytes: number): string {
|
|
21
|
+
if (bytes < 1024) return `${bytes} B`
|
|
22
|
+
const units = ['KB', 'MB', 'GB']
|
|
23
|
+
let value = bytes / 1024
|
|
24
|
+
let unit = 0
|
|
25
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
26
|
+
value /= 1024
|
|
27
|
+
unit += 1
|
|
28
|
+
}
|
|
29
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const counted = (counts: Record<string, number>, none = 'nothing'): string => {
|
|
33
|
+
const entries = Object.entries(counts)
|
|
34
|
+
return entries.length > 0 ? entries.map(([kind, n]) => `${n} ${kind}`).join(', ') : none
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Puts the media files a bundle carries into this environment's store.
|
|
39
|
+
*
|
|
40
|
+
* After the content, because the run is what decides whether any of this
|
|
41
|
+
* happened: a refused import leaves the store alone.
|
|
42
|
+
*/
|
|
43
|
+
export function describePlaced(
|
|
44
|
+
placed: { placed: number; bytes: number } | null,
|
|
45
|
+
): string | undefined {
|
|
46
|
+
if (!placed) return undefined
|
|
47
|
+
return `${placed.placed} media file(s) placed, ${describeBytes(placed.bytes)}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function describeSync(report: SyncReport): string {
|
|
51
|
+
switch (report.action) {
|
|
52
|
+
case 'applied':
|
|
53
|
+
case 'would-apply': {
|
|
54
|
+
const verb = report.action === 'applied' ? 'applied' : 'would apply'
|
|
55
|
+
const c = report.created
|
|
56
|
+
const parts = [
|
|
57
|
+
`${c.types} type(s)`,
|
|
58
|
+
`${c.properties} propert${c.properties === 1 ? 'y' : 'ies'}`,
|
|
59
|
+
`${c.dataTypes} data type(s)`,
|
|
60
|
+
`${c.languages} language(s)`,
|
|
61
|
+
]
|
|
62
|
+
const extra = [
|
|
63
|
+
report.retired.length > 0 ? `retired ${report.retired.join(', ')}` : '',
|
|
64
|
+
report.retiredTypes.length > 0 ? `retired types ${report.retiredTypes.join(', ')}` : '',
|
|
65
|
+
report.revived.length > 0 ? `revived ${report.revived.join(', ')}` : '',
|
|
66
|
+
report.assignedKeys.length > 0 ? `${report.assignedKeys.length} key(s) assigned` : '',
|
|
67
|
+
].filter(Boolean)
|
|
68
|
+
return `${verb} ${report.state?.version ?? ''}: created ${parts.join(', ')}${extra.length > 0 ? `; ${extra.join('; ')}` : ''}`
|
|
69
|
+
}
|
|
70
|
+
case 'skipped-same':
|
|
71
|
+
return 'up to date'
|
|
72
|
+
case 'skipped-older':
|
|
73
|
+
return `compatibility mode: ${report.reason ?? ''}`
|
|
74
|
+
case 'refused':
|
|
75
|
+
return `refused: ${report.reason ?? `${report.problems.length} problem(s)`}`
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const describeProblem = (p: SyncReport['problems'][number]): string =>
|
|
80
|
+
`${p.file}${p.path ? ` (${p.path})` : ''}: ${p.message}`
|
|
81
|
+
|
|
82
|
+
export function printProblems(io: Io, problems: SyncReport['problems']): void {
|
|
83
|
+
for (const p of problems) io.warning(` ${describeProblem(p)}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function describeBundle(io: Io, bundle: ops.BundleSummary): void {
|
|
87
|
+
io.say(
|
|
88
|
+
` bundle ${bundle.id} from ${bundle.from || 'elsewhere'} ` +
|
|
89
|
+
`(schema ${bundle.schemaVersion}+${bundle.schemaRevision}, ` +
|
|
90
|
+
`${bundle.snapshot} snapshot, ${bundle.nodes} node(s))`,
|
|
91
|
+
)
|
|
92
|
+
if (bundle.blobsCarried > 0)
|
|
93
|
+
io.say(
|
|
94
|
+
` blobs ${bundle.blobsCarried} media file(s) carried, ${describeBytes(bundle.blobBytes)}`,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What the carried sections would do, or did. */
|
|
99
|
+
export function printSections(io: Io, plan: ops.SectionPlan | undefined): void {
|
|
100
|
+
if (!plan) return
|
|
101
|
+
io.say(` carries ${plan.sections.join(', ')}`)
|
|
102
|
+
const counts = new Map<string, number>()
|
|
103
|
+
for (const file of plan.files) counts.set(file.action, (counts.get(file.action) ?? 0) + 1)
|
|
104
|
+
const summary = [...counts].map(([action, n]) => `${n} ${action}`).join(', ')
|
|
105
|
+
if (summary) io.say(` files ${summary}`)
|
|
106
|
+
for (const file of plan.files)
|
|
107
|
+
if (file.action === 'overwrite') io.say(` overwrites ${here(file.target)}`)
|
|
108
|
+
if (plan.dictionaryItems > 0) io.say(` dict ${plan.dictionaryItems} file(s)`)
|
|
109
|
+
if (plan.schema) {
|
|
110
|
+
io.say(` schema ${plan.schema.classification}`)
|
|
111
|
+
for (const finding of plan.schema.findings) io.say(` ${finding}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function printTransferFindings(io: Io, check: TransferCheck): void {
|
|
116
|
+
const byKind = { blocking: 0, person: 0, auto: 0 }
|
|
117
|
+
for (const f of check.findings) byKind[f.kind] += 1
|
|
118
|
+
const c = check.counts
|
|
119
|
+
io.say(
|
|
120
|
+
` plan ${c.create} new, ${c.update} updated, ${c.unchanged} unchanged, ${c.skip} skipped`,
|
|
121
|
+
)
|
|
122
|
+
io.say(
|
|
123
|
+
` findings ${byKind.blocking} blocking, ${byKind.person} need a person, ${byKind.auto} automatic`,
|
|
124
|
+
)
|
|
125
|
+
for (const f of check.findings) {
|
|
126
|
+
io.say(` [${f.kind.padEnd(8)}] ${f.message}`)
|
|
127
|
+
// The handle is the CLI's to name; the dashboard offers the same answers as choices.
|
|
128
|
+
if (f.code === 'local-edit' && f.propertyAlias)
|
|
129
|
+
io.say(
|
|
130
|
+
` answer it as ${f.subjectKey}/${propertyHandle(f.propertyAlias, f.culture)}`,
|
|
131
|
+
)
|
|
132
|
+
for (const suggestion of resolutionsFor(f.code)) io.say(` → ${suggestion}`)
|
|
133
|
+
if (f.link) io.say(` ${f.link}`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The findings a check reports, as data: what the envelope's `details` carry. */
|
|
138
|
+
export const findingsOf = (
|
|
139
|
+
findings: readonly { kind: string; message: string; code?: string; link?: string | null }[],
|
|
140
|
+
) =>
|
|
141
|
+
findings.map((f) => ({
|
|
142
|
+
kind: f.kind,
|
|
143
|
+
...(f.code ? { code: f.code } : {}),
|
|
144
|
+
message: f.message,
|
|
145
|
+
...(f.link ? { link: f.link } : {}),
|
|
146
|
+
}))
|
|
147
|
+
|
|
148
|
+
export function printFindings(
|
|
149
|
+
io: Io,
|
|
150
|
+
report: { classification: string; findings: Finding[] },
|
|
151
|
+
): void {
|
|
152
|
+
const byKind = { blocking: 0, person: 0, auto: 0 }
|
|
153
|
+
for (const f of report.findings) byKind[f.kind] += 1
|
|
154
|
+
io.say(
|
|
155
|
+
`change: ${report.classification}; findings: ${byKind.blocking} blocking, ${byKind.person} need a person, ${byKind.auto} automatic`,
|
|
156
|
+
)
|
|
157
|
+
for (const f of report.findings) {
|
|
158
|
+
const where = f.link ? ` ${f.link}` : ''
|
|
159
|
+
io.say(` [${f.kind.padEnd(8)}] ${f.message}${where}`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const describeJob = (job: Job): string =>
|
|
164
|
+
` ${job.kind.padEnd(5)} ${job.state}${job.stage ? `, ${job.stage}` : ''}${job.progress != null ? ` (${job.progress}%)` : ''}`
|
|
165
|
+
|
|
166
|
+
export function describeContentMode(
|
|
167
|
+
io: Io,
|
|
168
|
+
content: 'omitted' | 'sealed' | 'open',
|
|
169
|
+
people?: { members: number; users: number },
|
|
170
|
+
): void {
|
|
171
|
+
if (content === 'omitted')
|
|
172
|
+
io.say(
|
|
173
|
+
' content not included: pages are placed and named, and no value is written. Pass --include-content to bring the values and media files',
|
|
174
|
+
)
|
|
175
|
+
else if (content === 'sealed') io.say(' content encrypted: importing needs the passphrase')
|
|
176
|
+
else io.say(' content unencrypted')
|
|
177
|
+
if (people && people.members + people.users > 0)
|
|
178
|
+
io.say(` people ${people.members} member(s), ${people.users} user(s), encrypted`)
|
|
179
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which hostnames reach which page. `domains.toml` is the truth and the
|
|
3
|
+
* database follows it, so `set` and `clear` write the file and then apply it —
|
|
4
|
+
* one command, rather than an edit here and a deploy to make it real.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
bootstrapDatabase,
|
|
8
|
+
type CromboloConfig,
|
|
9
|
+
type DomainDeclaration,
|
|
10
|
+
domainsFilePath,
|
|
11
|
+
readDomainsFile,
|
|
12
|
+
resolveDomainNode,
|
|
13
|
+
syncDomainsFile,
|
|
14
|
+
withoutNode,
|
|
15
|
+
writeDomains,
|
|
16
|
+
} from '@crombolo/server'
|
|
17
|
+
import { CliError } from '../errors.ts'
|
|
18
|
+
import { here } from './describe.ts'
|
|
19
|
+
import type { Io } from './site.ts'
|
|
20
|
+
|
|
21
|
+
/** The file's declarations, saying what is wrong with it as it goes. */
|
|
22
|
+
export function readDeclarations(io: Io, config: CromboloConfig) {
|
|
23
|
+
const read = readDomainsFile(config.siteDir)
|
|
24
|
+
for (const problem of read?.problems ?? [])
|
|
25
|
+
io.warning(` ${domainsFilePath(config.siteDir)}: ${problem}`)
|
|
26
|
+
return { read, declarations: read?.declarations ?? [] }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Applies the file as a boot would, and reports what it did. */
|
|
30
|
+
export async function applyDomains(io: Io, config: CromboloConfig) {
|
|
31
|
+
const { db } = await bootstrapDatabase(config)
|
|
32
|
+
try {
|
|
33
|
+
const report = await syncDomainsFile(db, config.siteDir)
|
|
34
|
+
for (const applied of report.applied)
|
|
35
|
+
io.say(
|
|
36
|
+
` applied ${applied.node.padEnd(24)} ${applied.host}${applied.culture ? ` ${applied.culture}` : ''}`,
|
|
37
|
+
)
|
|
38
|
+
if (report.removed > 0) io.say(` removed ${report.removed} binding(s)`)
|
|
39
|
+
if (report.problems.length > 0)
|
|
40
|
+
throw new CliError('refused', `${report.problems.length} domain(s) could not be applied.`, {
|
|
41
|
+
details: report.problems,
|
|
42
|
+
})
|
|
43
|
+
return {
|
|
44
|
+
applied: report.applied.map((a) => ({
|
|
45
|
+
node: a.node,
|
|
46
|
+
host: a.host,
|
|
47
|
+
culture: a.culture ?? null,
|
|
48
|
+
})),
|
|
49
|
+
removed: report.removed,
|
|
50
|
+
}
|
|
51
|
+
} finally {
|
|
52
|
+
await db.close()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stating a node's hostnames says what they are, as the backoffice's dialog
|
|
58
|
+
* does: whatever that page had is replaced, so running this twice is the same
|
|
59
|
+
* as running it once. Which entries belong to that page is decided by
|
|
60
|
+
* resolving them, not by matching the text — `/` and `/Home` can be the same
|
|
61
|
+
* page, and clearing one while the other stayed would do nothing at all.
|
|
62
|
+
*/
|
|
63
|
+
export async function rewriteDomains(
|
|
64
|
+
io: Io,
|
|
65
|
+
config: CromboloConfig,
|
|
66
|
+
ref: string,
|
|
67
|
+
add: DomainDeclaration[],
|
|
68
|
+
) {
|
|
69
|
+
const { declarations } = readDeclarations(io, config)
|
|
70
|
+
const { db } = await bootstrapDatabase(config)
|
|
71
|
+
let kept: DomainDeclaration[] = []
|
|
72
|
+
try {
|
|
73
|
+
const target = await resolveDomainNode(db, ref)
|
|
74
|
+
if (!target.ok) throw new CliError('not_found', target.message)
|
|
75
|
+
kept = await withoutNode(db, declarations, target.node)
|
|
76
|
+
} finally {
|
|
77
|
+
await db.close()
|
|
78
|
+
}
|
|
79
|
+
const written = await writeDomains(config.siteDir, [...kept, ...add])
|
|
80
|
+
io.say(
|
|
81
|
+
` wrote ${here(written.file)}${written.backup ? ' (previous kept; `crombolo domains undo` puts it back)' : ''}`,
|
|
82
|
+
)
|
|
83
|
+
return { wrote: here(written.file), ...(await applyDomains(io, config)) }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const appliedFields = [
|
|
87
|
+
{ name: 'applied', type: 'array', items: { type: 'object' } },
|
|
88
|
+
{ name: 'removed', type: 'integer' },
|
|
89
|
+
] as const
|