@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.
Files changed (85) hide show
  1. package/bin/crombolo.ts +4 -3166
  2. package/oclif.manifest.json +6710 -0
  3. package/package.json +58 -7
  4. package/src/base.ts +129 -0
  5. package/src/classify.ts +119 -0
  6. package/src/clispec.ts +163 -0
  7. package/src/commands/admin/reset-password.ts +53 -0
  8. package/src/commands/assets/index.ts +6 -0
  9. package/src/commands/assets/list.ts +27 -0
  10. package/src/commands/assets/new.ts +42 -0
  11. package/src/commands/bundle/purge.ts +71 -0
  12. package/src/commands/components/check.ts +58 -0
  13. package/src/commands/components/index.ts +6 -0
  14. package/src/commands/components/list.ts +40 -0
  15. package/src/commands/components/new.ts +49 -0
  16. package/src/commands/content/check.ts +102 -0
  17. package/src/commands/content/export.ts +116 -0
  18. package/src/commands/content/import.ts +109 -0
  19. package/src/commands/content/publish.ts +21 -0
  20. package/src/commands/content/revert.ts +137 -0
  21. package/src/commands/content/runs.ts +62 -0
  22. package/src/commands/content/unpublish.ts +21 -0
  23. package/src/commands/dictionary/export.ts +49 -0
  24. package/src/commands/dictionary/import.ts +46 -0
  25. package/src/commands/domains/apply.ts +23 -0
  26. package/src/commands/domains/clear.ts +27 -0
  27. package/src/commands/domains/index.ts +6 -0
  28. package/src/commands/domains/list.ts +86 -0
  29. package/src/commands/domains/set.ts +55 -0
  30. package/src/commands/domains/undo.ts +29 -0
  31. package/src/commands/export.ts +100 -0
  32. package/src/commands/generate.ts +33 -0
  33. package/src/commands/import/index.ts +175 -0
  34. package/src/commands/import/umbraco/apply.ts +128 -0
  35. package/src/commands/import/umbraco/report.ts +56 -0
  36. package/src/commands/init.ts +76 -0
  37. package/src/commands/login.ts +38 -0
  38. package/src/commands/logout.ts +36 -0
  39. package/src/commands/maintenance/pause.ts +29 -0
  40. package/src/commands/maintenance/resume.ts +20 -0
  41. package/src/commands/maintenance/status.ts +34 -0
  42. package/src/commands/media/fetch.ts +62 -0
  43. package/src/commands/pull.ts +210 -0
  44. package/src/commands/push.ts +108 -0
  45. package/src/commands/remote/add.ts +78 -0
  46. package/src/commands/remote/job.ts +53 -0
  47. package/src/commands/remote/list.ts +41 -0
  48. package/src/commands/remote/remove.ts +26 -0
  49. package/src/commands/schema.ts +32 -0
  50. package/src/commands/start.ts +64 -0
  51. package/src/commands/status.ts +103 -0
  52. package/src/commands/types/add-property.ts +116 -0
  53. package/src/commands/types/check.ts +88 -0
  54. package/src/commands/types/export.ts +32 -0
  55. package/src/commands/types/new.ts +116 -0
  56. package/src/commands/types/purge.ts +65 -0
  57. package/src/commands/types/rewrite.ts +30 -0
  58. package/src/commands/types/sync.ts +66 -0
  59. package/src/commands/upgrade/check.ts +69 -0
  60. package/src/commands/upgrade/index.ts +120 -0
  61. package/src/commands/upgrade/ledger.ts +55 -0
  62. package/src/commands/whoami.ts +41 -0
  63. package/src/errors.ts +90 -0
  64. package/src/main.ts +23 -0
  65. package/src/manifest.ts +20 -0
  66. package/src/reference.ts +118 -0
  67. package/src/remote/client.ts +19 -10
  68. package/src/remote/remotes-file.ts +3 -2
  69. package/src/remote/transfer.ts +27 -11
  70. package/src/support/components.ts +7 -0
  71. package/src/support/describe.ts +179 -0
  72. package/src/support/domains.ts +89 -0
  73. package/src/support/flags.ts +157 -0
  74. package/src/support/prompt.ts +126 -0
  75. package/src/support/publish.ts +142 -0
  76. package/src/support/remote.ts +58 -0
  77. package/src/support/serve.ts +460 -0
  78. package/src/support/site.ts +26 -0
  79. package/src/support/types.ts +112 -0
  80. package/src/support/umbraco.ts +86 -0
  81. package/templates/basic/bundle/content/bundle.json +1 -1
  82. package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
  83. package/templates/basic/bundle/package.json +1 -1
  84. package/templates/demo/harbourstone/bundle/package.json +1 -1
  85. package/templates/starter/bundle/package.json +1 -1
@@ -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, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
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, exit code 2. */
31
- export class RefusedBeforeSending extends Error {}
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
- mkdirSync(join(options.siteDir, '.crombolo'), { recursive: true })
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
- job: Job
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
- `${env.name} is protected: pushing replaces what people rely on. Pass --force to go on.`,
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
+ }