@crombolo/cli 0.14.4 → 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 -3166
- 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 +19 -10
- package/src/remote/remotes-file.ts +3 -2
- package/src/remote/transfer.ts +27 -11
- 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
package/src/remote/transfer.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* uploads it as a job — which the remote may hold for approval.
|
|
9
9
|
*/
|
|
10
10
|
import { randomBytes } from 'node:crypto'
|
|
11
|
-
import { existsSync,
|
|
11
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
12
12
|
import { tmpdir } from 'node:os'
|
|
13
13
|
import { join } from 'node:path'
|
|
14
14
|
import {
|
|
@@ -18,8 +18,10 @@ import {
|
|
|
18
18
|
extractTarball,
|
|
19
19
|
type MediaStore,
|
|
20
20
|
mediaStoreFor,
|
|
21
|
+
writeFileAtomic,
|
|
21
22
|
} from '@crombolo/server'
|
|
22
23
|
import { type BundleBlob, loadBundle } from '@crombolo/transfer'
|
|
24
|
+
import { CliError, type ErrorKind } from '../errors.ts'
|
|
23
25
|
import { type Job, problemOf, type RemoteClient, RemoteError, sha256File } from './client.ts'
|
|
24
26
|
|
|
25
27
|
export type MediaMode = 'included' | 'omitted' | 'referenced'
|
|
@@ -27,8 +29,12 @@ export type MediaMode = 'included' | 'omitted' | 'referenced'
|
|
|
27
29
|
/** A passphrase for one transfer: never shown, never stored. */
|
|
28
30
|
export const transferPassphrase = () => randomBytes(24).toString('base64url')
|
|
29
31
|
|
|
30
|
-
/** Refused before anything was sent: the CLI's own guard
|
|
31
|
-
export class RefusedBeforeSending extends
|
|
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
|
+
}
|
|
32
38
|
|
|
33
39
|
export interface PullResult {
|
|
34
40
|
job: Job
|
|
@@ -54,15 +60,18 @@ export async function pullFile(
|
|
|
54
60
|
const env = environments.find((e) => e.id === options.environment)
|
|
55
61
|
if (!env)
|
|
56
62
|
throw new RefusedBeforeSending(
|
|
63
|
+
'not_found',
|
|
57
64
|
`${client.discovery.name} has no environment "${options.environment}": it has ${environments.map((e) => e.id).join(', ')}.`,
|
|
58
65
|
)
|
|
59
|
-
if (!env.may.pull) throw new RefusedBeforeSending(`You may not pull from ${env.name}.`)
|
|
66
|
+
if (!env.may.pull) throw new RefusedBeforeSending('refused', `You may not pull from ${env.name}.`)
|
|
60
67
|
if (options.full && !env.may.pullFull)
|
|
61
68
|
throw new RefusedBeforeSending(
|
|
69
|
+
'refused',
|
|
62
70
|
`You may not pull a full copy of ${env.name}; pull without --full for an anonymised one.`,
|
|
63
71
|
)
|
|
64
72
|
if (options.media === 'referenced' && !client.discovery.capabilities.includes('media-fetch'))
|
|
65
73
|
throw new RefusedBeforeSending(
|
|
74
|
+
'refused',
|
|
66
75
|
`${client.discovery.name} does not serve media by reference; pull without --media-info-only.`,
|
|
67
76
|
)
|
|
68
77
|
const passphrase = transferPassphrase()
|
|
@@ -163,8 +172,7 @@ export async function fetchReferencedMedia(
|
|
|
163
172
|
}
|
|
164
173
|
await Promise.all(Array.from({ length: 6 }, worker))
|
|
165
174
|
}
|
|
166
|
-
|
|
167
|
-
writeFileSync(join(options.siteDir, FETCHED_FILE), `${JSON.stringify(fetched, null, 2)}\n`)
|
|
175
|
+
writeFileAtomic(join(options.siteDir, FETCHED_FILE), `${JSON.stringify(fetched, null, 2)}\n`)
|
|
168
176
|
return result
|
|
169
177
|
}
|
|
170
178
|
|
|
@@ -192,7 +200,8 @@ export interface PushCheck {
|
|
|
192
200
|
|
|
193
201
|
export interface PushResult {
|
|
194
202
|
check: PushCheck
|
|
195
|
-
|
|
203
|
+
/** Absent for a dry run, which stops after the check. */
|
|
204
|
+
job?: Job
|
|
196
205
|
}
|
|
197
206
|
|
|
198
207
|
/**
|
|
@@ -209,6 +218,8 @@ export async function pushSite(
|
|
|
209
218
|
force?: boolean
|
|
210
219
|
autoApprove?: boolean
|
|
211
220
|
mediaInfoOnly?: boolean
|
|
221
|
+
/** Export and check, then stop: nothing is uploaded. */
|
|
222
|
+
dryRun?: boolean
|
|
212
223
|
confirm?(check: PushCheck): Promise<boolean>
|
|
213
224
|
onCheck?(check: PushCheck): void
|
|
214
225
|
onProgress?(job: Job): void
|
|
@@ -222,15 +233,19 @@ export async function pushSite(
|
|
|
222
233
|
const env = environments.find((e) => e.id === options.environment)
|
|
223
234
|
if (!env)
|
|
224
235
|
throw new RefusedBeforeSending(
|
|
236
|
+
'not_found',
|
|
225
237
|
`${client.discovery.name} has no environment "${options.environment}".`,
|
|
226
238
|
)
|
|
227
|
-
if (!env.may.push) throw new RefusedBeforeSending(`You may not push to ${env.name}.`)
|
|
228
|
-
if (env.protected && !options.force)
|
|
239
|
+
if (!env.may.push) throw new RefusedBeforeSending('refused', `You may not push to ${env.name}.`)
|
|
240
|
+
if (env.protected && !options.force && !options.dryRun)
|
|
229
241
|
throw new RefusedBeforeSending(
|
|
230
|
-
|
|
242
|
+
'confirmation_required',
|
|
243
|
+
`${env.name} is protected: pushing replaces what people rely on.`,
|
|
244
|
+
'Pass --force to go on.',
|
|
231
245
|
)
|
|
232
246
|
if (options.mediaInfoOnly && !client.discovery.capabilities.includes('media-fetch'))
|
|
233
247
|
throw new RefusedBeforeSending(
|
|
248
|
+
'refused',
|
|
234
249
|
`${client.discovery.name} does not take media by reference; push without --media-info-only.`,
|
|
235
250
|
)
|
|
236
251
|
|
|
@@ -280,9 +295,10 @@ export async function pushSite(
|
|
|
280
295
|
sending,
|
|
281
296
|
}
|
|
282
297
|
options.onCheck?.(check)
|
|
298
|
+
if (options.dryRun) return { check }
|
|
283
299
|
if (env.protected && !options.autoApprove) {
|
|
284
300
|
const yes = (await options.confirm?.(check)) ?? false
|
|
285
|
-
if (!yes) throw new RefusedBeforeSending('Nothing was sent.')
|
|
301
|
+
if (!yes) throw new RefusedBeforeSending('refused', 'Nothing was sent.')
|
|
286
302
|
}
|
|
287
303
|
|
|
288
304
|
if (options.mediaInfoOnly && manifest.blobs.length > 0) {
|
|
@@ -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
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** Flags more than one command takes, and what they come to. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isResolution,
|
|
5
|
+
type NodeResolutions,
|
|
6
|
+
parseResolveFlags,
|
|
7
|
+
RESOLUTIONS,
|
|
8
|
+
type Resolution,
|
|
9
|
+
} from '@crombolo/transfer'
|
|
10
|
+
import { Flags } from '@oclif/core'
|
|
11
|
+
import { CliError } from '../errors.ts'
|
|
12
|
+
import type * as ops from '../operations/commands.ts'
|
|
13
|
+
import type { Io } from './site.ts'
|
|
14
|
+
|
|
15
|
+
export const backupTaken = Flags.boolean({
|
|
16
|
+
summary: 'a backup was taken another way: Postgres needs this, or CROMBOLO_PG_DUMP',
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export const dryRun = Flags.boolean({ summary: 'say what it would do, and change nothing' })
|
|
20
|
+
|
|
21
|
+
/** A repeatable value flag that takes one value each time, so it never swallows an argument. */
|
|
22
|
+
export const many = (summary: string, helpValue?: string) =>
|
|
23
|
+
Flags.string({ summary, helpValue, multiple: true, multipleNonGreedy: true, default: [] })
|
|
24
|
+
|
|
25
|
+
/** The flags `content check` and `content import` share: resolutions, placement, files. */
|
|
26
|
+
export const transferFlags = {
|
|
27
|
+
under: Flags.string({ summary: 'place the bundle under this page', helpValue: '<path|uuid>' }),
|
|
28
|
+
resolve: many(
|
|
29
|
+
'answer a finding: <key>=take-bundle|keep-local|skip, or <key>/<property>[@<culture>]=take-bundle|keep-local',
|
|
30
|
+
'<answer>',
|
|
31
|
+
),
|
|
32
|
+
'resolve-all': Flags.string({
|
|
33
|
+
summary: 'answer every finding the same way',
|
|
34
|
+
options: [...RESOLUTIONS],
|
|
35
|
+
}),
|
|
36
|
+
'allow-missing-blobs': Flags.boolean({
|
|
37
|
+
summary: 'import media items whose files the bundle does not carry',
|
|
38
|
+
}),
|
|
39
|
+
publish: Flags.boolean({ summary: 'publish what it imports' }),
|
|
40
|
+
'no-schema': Flags.boolean({ summary: 'decline the schema the bundle carries' }),
|
|
41
|
+
'no-files': Flags.boolean({ summary: 'decline the views, styles and scripts it carries' }),
|
|
42
|
+
'replace-files': Flags.boolean({
|
|
43
|
+
summary: 'allow replacing files this site has; the originals are kept for a revert',
|
|
44
|
+
}),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TransferFlagValues {
|
|
48
|
+
under?: string
|
|
49
|
+
resolve: string[]
|
|
50
|
+
'resolve-all'?: string
|
|
51
|
+
'allow-missing-blobs': boolean
|
|
52
|
+
publish: boolean
|
|
53
|
+
'no-schema': boolean
|
|
54
|
+
'no-files': boolean
|
|
55
|
+
'replace-files': boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseResolutions(values: readonly string[]): Record<string, NodeResolutions> {
|
|
59
|
+
const parsed = parseResolveFlags([...values])
|
|
60
|
+
if (parsed.problems.length > 0)
|
|
61
|
+
throw new CliError('usage', 'A --resolve answer could not be read.', {
|
|
62
|
+
details: parsed.problems,
|
|
63
|
+
})
|
|
64
|
+
return parsed.byNode
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function transferInput(bundle: string, flags: TransferFlagValues): ops.TransferInput {
|
|
68
|
+
const all = flags['resolve-all']
|
|
69
|
+
if (all !== undefined && !isResolution(all))
|
|
70
|
+
throw new CliError('usage', `"${all}" is not one of ${RESOLUTIONS.join(', ')}.`)
|
|
71
|
+
return {
|
|
72
|
+
bundle,
|
|
73
|
+
under: flags.under,
|
|
74
|
+
resolutions: parseResolutions(flags.resolve),
|
|
75
|
+
resolveAll: all as Resolution | undefined,
|
|
76
|
+
allowMissingBlobs: flags['allow-missing-blobs'],
|
|
77
|
+
publish: flags.publish,
|
|
78
|
+
// What a bundle carries is applied by default — that is the point of it
|
|
79
|
+
// carrying anything — so these decline a half, for someone who wants the
|
|
80
|
+
// content without the structure that came with it.
|
|
81
|
+
withoutSchema: flags['no-schema'],
|
|
82
|
+
withoutFiles: flags['no-files'],
|
|
83
|
+
replaceFiles: flags['replace-files'],
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Property values and media files in an export: left out, sealed, or — asked twice — open. */
|
|
88
|
+
export const contentFlags = {
|
|
89
|
+
'include-content': Flags.boolean({
|
|
90
|
+
summary: 'bring property values and media files, encrypted under a passphrase',
|
|
91
|
+
}),
|
|
92
|
+
'allow-content-unencrypted': Flags.boolean({
|
|
93
|
+
summary: 'with --include-content: write them readable',
|
|
94
|
+
}),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function contentMode(
|
|
98
|
+
io: Io,
|
|
99
|
+
flags: {
|
|
100
|
+
'include-content': boolean
|
|
101
|
+
'allow-content-unencrypted': boolean
|
|
102
|
+
'with-blobs'?: boolean
|
|
103
|
+
},
|
|
104
|
+
): 'omitted' | 'sealed' | 'open' {
|
|
105
|
+
const include = flags['include-content']
|
|
106
|
+
const open = flags['allow-content-unencrypted']
|
|
107
|
+
if (open && !include)
|
|
108
|
+
throw new CliError('usage', '--allow-content-unencrypted goes with --include-content.')
|
|
109
|
+
if (flags['with-blobs'] && !include)
|
|
110
|
+
throw new CliError(
|
|
111
|
+
'usage',
|
|
112
|
+
'Media files are content: --with-blobs goes with --include-content.',
|
|
113
|
+
)
|
|
114
|
+
if (open)
|
|
115
|
+
io.warning(
|
|
116
|
+
' warning property values and media files are written unencrypted: anyone with the files can read any personal information in them',
|
|
117
|
+
)
|
|
118
|
+
return !include ? 'omitted' : open ? 'open' : 'sealed'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Offset pagination for a list: `--limit` and `--offset`, and what was left out. */
|
|
122
|
+
export const pageFlags = (limit: number) => ({
|
|
123
|
+
limit: Flags.integer({ summary: 'how many to list', default: limit, min: 1 }),
|
|
124
|
+
offset: Flags.integer({ summary: 'how many to skip first', default: 0, min: 0 }),
|
|
125
|
+
fields: Flags.string({
|
|
126
|
+
summary: 'only these fields of each item, comma-separated',
|
|
127
|
+
helpValue: '<a,b>',
|
|
128
|
+
}),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
export const pagination = { style: 'offset', offsetArg: '--offset', limitArg: '--limit' } as const
|
|
132
|
+
|
|
133
|
+
/** One page of a list, in the envelope clispec asks for, narrowed to `--fields` when given. */
|
|
134
|
+
export function page<T extends object>(
|
|
135
|
+
items: readonly T[],
|
|
136
|
+
total: number,
|
|
137
|
+
flags: { offset: number; fields?: string },
|
|
138
|
+
): { items: Partial<T>[]; total: number; offset: number; truncated: boolean } {
|
|
139
|
+
const wanted = flags.fields
|
|
140
|
+
?.split(',')
|
|
141
|
+
.map((field) => field.trim())
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
const narrowed = wanted
|
|
144
|
+
? items.map(
|
|
145
|
+
(item) =>
|
|
146
|
+
Object.fromEntries(
|
|
147
|
+
Object.entries(item).filter(([key]) => wanted.includes(key)),
|
|
148
|
+
) as Partial<T>,
|
|
149
|
+
)
|
|
150
|
+
: [...items]
|
|
151
|
+
return {
|
|
152
|
+
items: narrowed,
|
|
153
|
+
total,
|
|
154
|
+
offset: flags.offset,
|
|
155
|
+
truncated: flags.offset + items.length < total,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking a person: a confirmation, a typed name, a passphrase. Every question
|
|
3
|
+
* goes to stderr, so stdout stays the result; with no terminal to ask on, a
|
|
4
|
+
* confirmation refuses at once, naming the flag that answers it, and never
|
|
5
|
+
* reads stdin.
|
|
6
|
+
*/
|
|
7
|
+
import type { CromboloConfig } from '@crombolo/server'
|
|
8
|
+
import { CliError } from '../errors.ts'
|
|
9
|
+
import * as ops from '../operations/commands.ts'
|
|
10
|
+
|
|
11
|
+
/** Where a script gives a bundle's passphrase, rather than typing it. */
|
|
12
|
+
export const PASSPHRASE_ENV = 'CROMBOLO_BUNDLE_PASSPHRASE'
|
|
13
|
+
export const MIN_PASSPHRASE = 12
|
|
14
|
+
|
|
15
|
+
export const interactive = (): boolean => process.stdin.isTTY === true
|
|
16
|
+
|
|
17
|
+
async function readLine(question: string): Promise<string> {
|
|
18
|
+
process.stderr.write(`${question} `)
|
|
19
|
+
for await (const line of console) return line
|
|
20
|
+
return ''
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Asks before something that cannot be taken back. `answer` is what must be
|
|
25
|
+
* typed (a name, or `y`); off a terminal it throws `confirmation_required`, and
|
|
26
|
+
* a person who answers anything else gets `refused`.
|
|
27
|
+
*/
|
|
28
|
+
export async function confirm(
|
|
29
|
+
question: string,
|
|
30
|
+
options: { bypass: string; answer?: string; declined?: string },
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
if (!interactive())
|
|
33
|
+
throw new CliError('confirmation_required', question.replace(/[?:]\s*$/, '.'), {
|
|
34
|
+
hint: `Pass ${options.bypass} to go on without being asked.`,
|
|
35
|
+
})
|
|
36
|
+
const typed = (await readLine(question)).trim()
|
|
37
|
+
const yes = options.answer === undefined ? /^y(es)?$/i.test(typed) : typed === options.answer
|
|
38
|
+
if (!yes) throw new CliError('refused', options.declined ?? 'Nothing was changed.')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A line typed without echo. */
|
|
42
|
+
export async function readSecret(question: string): Promise<string> {
|
|
43
|
+
process.stderr.write(question)
|
|
44
|
+
const stdin = process.stdin
|
|
45
|
+
stdin.setRawMode(true)
|
|
46
|
+
stdin.resume()
|
|
47
|
+
return new Promise((done) => {
|
|
48
|
+
let typed = ''
|
|
49
|
+
const onData = (chunk: Buffer) => {
|
|
50
|
+
for (const char of chunk.toString('utf8')) {
|
|
51
|
+
if (char === '\u0003') {
|
|
52
|
+
process.stderr.write('\n')
|
|
53
|
+
process.exit(130)
|
|
54
|
+
}
|
|
55
|
+
if (char === '\r' || char === '\n') {
|
|
56
|
+
stdin.setRawMode(false)
|
|
57
|
+
stdin.pause()
|
|
58
|
+
stdin.off('data', onData)
|
|
59
|
+
process.stderr.write('\n')
|
|
60
|
+
done(typed)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if (char === '\u007f' || char === '\b') typed = typed.slice(0, -1)
|
|
64
|
+
else typed += char
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
stdin.on('data', onData)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Everything piped to stdin, without its trailing newline. */
|
|
72
|
+
export async function readStdin(): Promise<string> {
|
|
73
|
+
return (await Bun.stdin.text()).replace(/\r?\n$/, '')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const needPassphrase = (what: string) =>
|
|
77
|
+
new CliError('auth', `${what} needs a passphrase.`, {
|
|
78
|
+
hint: `Set ${PASSPHRASE_ENV}, or run it in a terminal to be asked.`,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/** The passphrase to open a bundle with, when it is sealed: the environment's, or asked for. */
|
|
82
|
+
export async function passphraseToOpen(
|
|
83
|
+
target: string,
|
|
84
|
+
config: CromboloConfig,
|
|
85
|
+
): Promise<string | undefined> {
|
|
86
|
+
const given = process.env[PASSPHRASE_ENV]
|
|
87
|
+
if (given) return given
|
|
88
|
+
let sealed = false
|
|
89
|
+
try {
|
|
90
|
+
sealed = ops.readBundle(target, config.siteDir).set.sealed === true
|
|
91
|
+
} catch {
|
|
92
|
+
// The command that follows reads it again and says what is wrong with it.
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
if (!sealed) return undefined
|
|
96
|
+
if (!interactive()) throw needPassphrase(`${target} is encrypted: opening it`)
|
|
97
|
+
return readSecret(' Passphrase for this bundle: ')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The passphrase for the people a file carries: the environment's, asked for, or none. */
|
|
101
|
+
export async function passphraseForPeople(): Promise<string | undefined> {
|
|
102
|
+
return (
|
|
103
|
+
process.env[PASSPHRASE_ENV] ??
|
|
104
|
+
(interactive() ? await readSecret(' Passphrase for its people: ') : undefined)
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A new passphrase to seal an export with: the environment's, or asked for twice. */
|
|
109
|
+
export async function passphraseToSeal(): Promise<string> {
|
|
110
|
+
const given = process.env[PASSPHRASE_ENV]
|
|
111
|
+
if (given) {
|
|
112
|
+
if (given.length < MIN_PASSPHRASE)
|
|
113
|
+
throw new CliError('usage', `${PASSPHRASE_ENV} is shorter than ${MIN_PASSPHRASE} characters.`)
|
|
114
|
+
return given
|
|
115
|
+
}
|
|
116
|
+
if (!interactive()) throw needPassphrase('An encrypted export')
|
|
117
|
+
for (;;) {
|
|
118
|
+
const first = await readSecret(' Passphrase to encrypt this bundle with: ')
|
|
119
|
+
if (first.length < MIN_PASSPHRASE) {
|
|
120
|
+
process.stderr.write(` At least ${MIN_PASSPHRASE} characters, please.\n`)
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
if ((await readSecret(' Again, to confirm: ')) === first) return first
|
|
124
|
+
process.stderr.write(' They did not match.\n')
|
|
125
|
+
}
|
|
126
|
+
}
|