@crombolo/cli 0.14.4 → 0.16.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 (104) hide show
  1. package/bin/crombolo.ts +4 -3166
  2. package/oclif.manifest.json +8077 -0
  3. package/package.json +79 -8
  4. package/src/base.ts +134 -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/api-client/rotate.ts +57 -0
  9. package/src/commands/assets/index.ts +6 -0
  10. package/src/commands/assets/list.ts +27 -0
  11. package/src/commands/assets/new.ts +42 -0
  12. package/src/commands/bundle/purge.ts +71 -0
  13. package/src/commands/cache/rebuild.ts +56 -0
  14. package/src/commands/components/check.ts +58 -0
  15. package/src/commands/components/index.ts +6 -0
  16. package/src/commands/components/list.ts +40 -0
  17. package/src/commands/components/new.ts +49 -0
  18. package/src/commands/content/check.ts +102 -0
  19. package/src/commands/content/export.ts +116 -0
  20. package/src/commands/content/import.ts +109 -0
  21. package/src/commands/content/publish.ts +21 -0
  22. package/src/commands/content/revert.ts +137 -0
  23. package/src/commands/content/runs.ts +62 -0
  24. package/src/commands/content/unpublish.ts +21 -0
  25. package/src/commands/dictionary/export.ts +49 -0
  26. package/src/commands/dictionary/import.ts +46 -0
  27. package/src/commands/domains/apply.ts +23 -0
  28. package/src/commands/domains/clear.ts +27 -0
  29. package/src/commands/domains/index.ts +6 -0
  30. package/src/commands/domains/list.ts +86 -0
  31. package/src/commands/domains/set.ts +55 -0
  32. package/src/commands/domains/undo.ts +29 -0
  33. package/src/commands/export.ts +100 -0
  34. package/src/commands/generate.ts +33 -0
  35. package/src/commands/health.ts +90 -0
  36. package/src/commands/import/index.ts +175 -0
  37. package/src/commands/import/umbraco/apply.ts +128 -0
  38. package/src/commands/import/umbraco/report.ts +56 -0
  39. package/src/commands/index/list.ts +45 -0
  40. package/src/commands/index/rebuild.ts +66 -0
  41. package/src/commands/init.ts +76 -0
  42. package/src/commands/login.ts +38 -0
  43. package/src/commands/logout.ts +36 -0
  44. package/src/commands/maintenance/pause.ts +29 -0
  45. package/src/commands/maintenance/resume.ts +20 -0
  46. package/src/commands/maintenance/status.ts +34 -0
  47. package/src/commands/media/export.ts +91 -0
  48. package/src/commands/media/fetch.ts +62 -0
  49. package/src/commands/media/import.ts +175 -0
  50. package/src/commands/pull.ts +210 -0
  51. package/src/commands/push.ts +108 -0
  52. package/src/commands/recycle-bin/empty.ts +157 -0
  53. package/src/commands/remote/add.ts +78 -0
  54. package/src/commands/remote/job.ts +53 -0
  55. package/src/commands/remote/list.ts +41 -0
  56. package/src/commands/remote/remove.ts +26 -0
  57. package/src/commands/schema.ts +32 -0
  58. package/src/commands/start.ts +64 -0
  59. package/src/commands/status.ts +103 -0
  60. package/src/commands/types/add-property.ts +116 -0
  61. package/src/commands/types/check.ts +88 -0
  62. package/src/commands/types/export.ts +32 -0
  63. package/src/commands/types/new.ts +116 -0
  64. package/src/commands/types/purge.ts +65 -0
  65. package/src/commands/types/rewrite.ts +30 -0
  66. package/src/commands/types/sync.ts +66 -0
  67. package/src/commands/upgrade/check.ts +69 -0
  68. package/src/commands/upgrade/index.ts +120 -0
  69. package/src/commands/upgrade/ledger.ts +55 -0
  70. package/src/commands/users/disable.ts +54 -0
  71. package/src/commands/users/enable.ts +54 -0
  72. package/src/commands/users/invite.ts +82 -0
  73. package/src/commands/users/list.ts +62 -0
  74. package/src/commands/users/reset-2fa.ts +48 -0
  75. package/src/commands/users/reset-password.ts +58 -0
  76. package/src/commands/users/unlock.ts +54 -0
  77. package/src/commands/whoami.ts +41 -0
  78. package/src/errors.ts +90 -0
  79. package/src/load.ts +59 -0
  80. package/src/main.ts +41 -0
  81. package/src/manifest.ts +30 -0
  82. package/src/plugin.ts +38 -0
  83. package/src/reference.ts +118 -0
  84. package/src/remote/client.ts +25 -11
  85. package/src/remote/remotes-file.ts +3 -2
  86. package/src/remote/transfer.ts +27 -11
  87. package/src/support/components.ts +7 -0
  88. package/src/support/describe.ts +179 -0
  89. package/src/support/domains.ts +89 -0
  90. package/src/support/flags.ts +157 -0
  91. package/src/support/media-tree.ts +72 -0
  92. package/src/support/people.ts +134 -0
  93. package/src/support/prompt.ts +126 -0
  94. package/src/support/publish.ts +142 -0
  95. package/src/support/remote.ts +57 -0
  96. package/src/support/serve.ts +460 -0
  97. package/src/support/site.ts +26 -0
  98. package/src/support/types.ts +112 -0
  99. package/src/support/umbraco.ts +86 -0
  100. package/templates/basic/bundle/content/bundle.json +1 -1
  101. package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
  102. package/templates/basic/bundle/package.json +1 -1
  103. package/templates/demo/harbourstone/bundle/package.json +1 -1
  104. package/templates/starter/bundle/package.json +1 -1
@@ -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,72 @@
1
+ /**
2
+ * The media tree, for the bulk commands (story 153): its folders by name, what
3
+ * a file's extension makes it, and the repository and file store an upload
4
+ * goes through, so an imported file is placed exactly as the back office
5
+ * places one.
6
+ */
7
+ import { ObjectTypes, SystemNodes } from '@crombolo/core'
8
+ import {
9
+ currentSchemaState,
10
+ type Db,
11
+ DocumentRepository,
12
+ describeRefFailure,
13
+ NodeRepository,
14
+ type NodeRow,
15
+ resolveNodeRef,
16
+ } from '@crombolo/data'
17
+ import {
18
+ type CromboloConfig,
19
+ createFileIntake,
20
+ createMediaPort,
21
+ MediaFileStore,
22
+ mediaStoreFor,
23
+ } from '@crombolo/server'
24
+ import { CliError } from '../errors.ts'
25
+
26
+ export async function openMedia(db: Db, config: CromboloConfig) {
27
+ const files = new MediaFileStore(await mediaStoreFor(config))
28
+ const state = await currentSchemaState(db)
29
+ const options = {
30
+ nodeState: { version: state.version, revision: state.revision },
31
+ nodeId: `cli:${config.nodeId}`,
32
+ }
33
+ return {
34
+ files,
35
+ nodes: new NodeRepository(db),
36
+ repo: new DocumentRepository(db, {
37
+ ...options,
38
+ kind: 'media',
39
+ valueIntake: createFileIntake(files),
40
+ }),
41
+ // For the rules alone — which type takes which file — not for writing: a
42
+ // write through the port tells the cluster once per item.
43
+ port: createMediaPort(db, files, options),
44
+ }
45
+ }
46
+
47
+ export type MediaTree = Awaited<ReturnType<typeof openMedia>>
48
+
49
+ /** The node a `--under` names, or the root. */
50
+ export async function mediaParent(db: Db, ref: string | undefined): Promise<NodeRow | null> {
51
+ if (!ref) return null
52
+ const resolved = await resolveNodeRef(db, [ObjectTypes.Media], ref)
53
+ if (!resolved.ok) throw new CliError('not_found', describeRefFailure(ref, resolved))
54
+ return resolved.node
55
+ }
56
+
57
+ /** The media directly under a node, by name. */
58
+ export async function childrenByName(
59
+ tree: MediaTree,
60
+ parent: NodeRow | null,
61
+ ): Promise<Map<string, NodeRow>> {
62
+ const page = await tree.nodes.children(
63
+ parent?.id ?? SystemNodes.Root,
64
+ ObjectTypes.Media,
65
+ 0,
66
+ Number.MAX_SAFE_INTEGER,
67
+ )
68
+ return new Map(page.items.map((node) => [node.text ?? '', node]))
69
+ }
70
+
71
+ export const sha256 = (bytes: Uint8Array): string =>
72
+ new Bun.CryptoHasher('sha256').update(bytes).digest('hex')
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Back-office users from the command line (story 151), through the same user
3
+ * port the Management API uses, so a rule the back office keeps — who may act
4
+ * on an admin, what an invite is — is the rule here too. It opens the site's
5
+ * database directly, not through the server, so it works when the site will not
6
+ * boot; in a container it runs through `docker exec`.
7
+ */
8
+ import type { Principal, UserPort, UserStatus } from '@crombolo/api-management'
9
+ import { AuthStore } from '@crombolo/auth'
10
+ import { type Db, SUPER_USER_KEY, UserRepository } from '@crombolo/data'
11
+ import {
12
+ bootstrapDatabase,
13
+ type CromboloConfig,
14
+ createNodeLookup,
15
+ createUserPorts,
16
+ MediaFileStore,
17
+ mediaStoreFor,
18
+ TwoFactorService,
19
+ } from '@crombolo/server'
20
+ import { CliError, type ErrorKind } from '../errors.ts'
21
+
22
+ /** The operator: an administrator who is nobody in particular, so no rule about acting on oneself applies. */
23
+ const CLI_PRINCIPAL_ID = '00000000-0000-4000-8000-0000000c1c1e'
24
+
25
+ const principal = (id: string): Principal =>
26
+ ({
27
+ id,
28
+ userName: 'crombolo',
29
+ name: 'The command line',
30
+ email: '',
31
+ isAdmin: true,
32
+ languageIsoCode: undefined,
33
+ avatarUrls: [],
34
+ allowedSections: [],
35
+ permissions: [],
36
+ groupKeys: [],
37
+ hasAccessToAllLanguages: true,
38
+ languages: [],
39
+ }) as unknown as Principal
40
+
41
+ /** Sees every user, the built-in administrator included, as only that administrator may. */
42
+ export const OBSERVER = principal(SUPER_USER_KEY)
43
+ /** Acts on any user, the built-in administrator included. */
44
+ export const OPERATOR = principal(CLI_PRINCIPAL_ID)
45
+
46
+ export interface People {
47
+ config: CromboloConfig
48
+ db: Db
49
+ users: UserPort
50
+ repository: UserRepository
51
+ twoFactor: TwoFactorService
52
+ /** The link the last invite would have e-mailed. */
53
+ lastLink(): string | undefined
54
+ }
55
+
56
+ export async function openPeople(config: CromboloConfig): Promise<People> {
57
+ const { db } = await bootstrapDatabase(config)
58
+ const store = new AuthStore(db)
59
+ let link: string | undefined
60
+ const { users } = createUserPorts(db, {
61
+ config: {
62
+ ...config,
63
+ // Captured, not sent: the command prints it, for a site with no e-mail.
64
+ sendUserLink: async (message) => {
65
+ link = message.link
66
+ },
67
+ },
68
+ mediaFiles: new MediaFileStore(await mediaStoreFor(config)),
69
+ nodes: createNodeLookup(db),
70
+ endSessions: async (userId) => {
71
+ await store.endAllSessionsForUser(userId)
72
+ },
73
+ })
74
+ return {
75
+ config,
76
+ db,
77
+ users,
78
+ repository: new UserRepository(db),
79
+ twoFactor: new TwoFactorService(db, { secretKey: config.secretKey, issuer: config.siteName }),
80
+ lastLink: () => link,
81
+ }
82
+ }
83
+
84
+ /** A user by e-mail or username, whatever the casing; `not_found` otherwise. */
85
+ export async function userNamed(people: People, who: string) {
86
+ const wanted = who.trim().toLowerCase()
87
+ const rows = await people.db.query<{ key: string }>(
88
+ 'SELECT key FROM user_account WHERE LOWER(email) = ? OR LOWER(user_name) = ?',
89
+ [wanted, wanted],
90
+ )
91
+ const key = rows[0]?.key
92
+ const user = key ? await people.repository.byKey(String(key)) : undefined
93
+ if (!user)
94
+ throw new CliError('not_found', `No user "${who}".`, {
95
+ hint: '`crombolo users list` lists them.',
96
+ })
97
+ return user
98
+ }
99
+
100
+ const STATUS_KINDS: Partial<Record<UserStatus, ErrorKind>> = {
101
+ UserNotFound: 'not_found',
102
+ MissingUserGroup: 'not_found',
103
+ DuplicateUserName: 'conflict',
104
+ DuplicateEmail: 'conflict',
105
+ InvalidEmail: 'usage',
106
+ InvalidUserName: 'usage',
107
+ UserNameIsNotEmail: 'usage',
108
+ InvalidPassword: 'usage',
109
+ NoUserGroup: 'usage',
110
+ }
111
+
112
+ const STATUS_MESSAGES: Partial<Record<UserStatus, string>> = {
113
+ CannotDisableInvitedUser: 'An invited user cannot be disabled; delete the invitation instead.',
114
+ CannotInvite: 'The invitation could not be made.',
115
+ InvalidPassword: "That password does not meet this site's password rules.",
116
+ DuplicateEmail: 'Another user has that e-mail address.',
117
+ DuplicateUserName: 'Another user has that username.',
118
+ MissingUserGroup: 'There is no group with that alias.',
119
+ }
120
+
121
+ /** A user port status other than `Success`, as a failure. */
122
+ export function checkStatus(status: UserStatus): void {
123
+ if (status === 'Success') return
124
+ throw new CliError(STATUS_KINDS[status] ?? 'refused', STATUS_MESSAGES[status] ?? status)
125
+ }
126
+
127
+ export function describeUser(user: {
128
+ key: string
129
+ name: string
130
+ email: string
131
+ userName: string
132
+ }): string {
133
+ return `${user.name} <${user.email || user.userName}>`
134
+ }