@crombolo/cli 0.15.0 → 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.
- package/bin/crombolo.ts +1 -1
- package/oclif.manifest.json +1510 -143
- package/package.json +30 -10
- package/src/base.ts +9 -4
- package/src/commands/api-client/rotate.ts +57 -0
- package/src/commands/cache/rebuild.ts +56 -0
- package/src/commands/health.ts +90 -0
- package/src/commands/index/list.ts +45 -0
- package/src/commands/index/rebuild.ts +66 -0
- package/src/commands/media/export.ts +91 -0
- package/src/commands/media/import.ts +175 -0
- package/src/commands/recycle-bin/empty.ts +157 -0
- package/src/commands/users/disable.ts +54 -0
- package/src/commands/users/enable.ts +54 -0
- package/src/commands/users/invite.ts +82 -0
- package/src/commands/users/list.ts +62 -0
- package/src/commands/users/reset-2fa.ts +48 -0
- package/src/commands/users/reset-password.ts +58 -0
- package/src/commands/users/unlock.ts +54 -0
- package/src/load.ts +59 -0
- package/src/main.ts +22 -4
- package/src/manifest.ts +10 -0
- package/src/plugin.ts +38 -0
- package/src/remote/client.ts +6 -1
- package/src/remote/remotes-file.ts +1 -1
- package/src/support/media-tree.ts +72 -0
- package/src/support/people.ts +134 -0
- package/src/support/remote.ts +3 -4
- package/templates/basic/bundle/package.json +1 -1
- package/templates/demo/harbourstone/bundle/package.json +1 -1
- package/templates/starter/bundle/package.json +1 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { extname, join, relative, resolve } from 'node:path'
|
|
3
|
+
import { appendCacheInstruction, type NodeRow } from '@crombolo/data'
|
|
4
|
+
import { bootstrapDatabase, mediaFileOf } from '@crombolo/server'
|
|
5
|
+
import { Args, Flags } from '@oclif/core'
|
|
6
|
+
import { BaseCommand } from '../../base.ts'
|
|
7
|
+
import { dryRun } from '../../support/flags.ts'
|
|
8
|
+
import { childrenByName, mediaParent, openMedia, sha256 } from '../../support/media-tree.ts'
|
|
9
|
+
import { siteConfig } from '../../support/site.ts'
|
|
10
|
+
|
|
11
|
+
interface Outcome {
|
|
12
|
+
path: string
|
|
13
|
+
action: 'created' | 'folder' | 'skipped' | 'differs' | 'unsupported'
|
|
14
|
+
type?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A folder of files into the media library: a media folder for each directory,
|
|
19
|
+
* an item for each file, typed by its extension as an upload is. A file already
|
|
20
|
+
* there with the same bytes is skipped, so an import that stopped part-way runs
|
|
21
|
+
* again from where it got to.
|
|
22
|
+
*/
|
|
23
|
+
export default class MediaImport extends BaseCommand {
|
|
24
|
+
static override summary = 'import a folder of files into the media library, keeping its folders'
|
|
25
|
+
static override description = [
|
|
26
|
+
'Each directory becomes a media folder and each file a media item, typed by its extension as an upload is. Files no media type takes are reported and left.',
|
|
27
|
+
'Run it again after a stop: a file already imported with the same bytes is skipped, and one whose bytes differ is reported and left alone.',
|
|
28
|
+
].join('\n\n')
|
|
29
|
+
static override examples = ['<%= config.bin %> media import ./photos --under /Gallery --dry-run']
|
|
30
|
+
static override args = {
|
|
31
|
+
dir: Args.string({ required: true, description: 'the folder to import' }),
|
|
32
|
+
}
|
|
33
|
+
static override flags = {
|
|
34
|
+
under: Flags.string({
|
|
35
|
+
summary: 'the media folder to import into; the root when not given',
|
|
36
|
+
helpValue: '<path|uuid>',
|
|
37
|
+
}),
|
|
38
|
+
'dry-run': dryRun,
|
|
39
|
+
}
|
|
40
|
+
static override audit = { category: 'media', action: 'Import', entityType: 'Media' } as const
|
|
41
|
+
static override spec = {
|
|
42
|
+
effects: 'idempotent',
|
|
43
|
+
cardinality: 'bounded',
|
|
44
|
+
outputFields: [
|
|
45
|
+
{ name: 'created', type: 'integer' },
|
|
46
|
+
{ name: 'folders', type: 'integer' },
|
|
47
|
+
{ name: 'skipped', type: 'integer' },
|
|
48
|
+
{ name: 'differs', type: 'array', items: { type: 'string' } },
|
|
49
|
+
{ name: 'unsupported', type: 'array', items: { type: 'string' } },
|
|
50
|
+
{ name: 'changed', type: 'boolean' },
|
|
51
|
+
],
|
|
52
|
+
errors: ['not_found', 'io'],
|
|
53
|
+
} as const
|
|
54
|
+
|
|
55
|
+
async run() {
|
|
56
|
+
const { args, flags } = await this.parse(MediaImport)
|
|
57
|
+
const root = resolve(args.dir)
|
|
58
|
+
if (!statSync(root, { throwIfNoEntry: false })?.isDirectory())
|
|
59
|
+
this.fail('not_found', `${args.dir} is not a folder.`)
|
|
60
|
+
const config = await siteConfig()
|
|
61
|
+
const { db } = await bootstrapDatabase(config)
|
|
62
|
+
try {
|
|
63
|
+
const tree = await openMedia(db, config)
|
|
64
|
+
const parent = await mediaParent(db, flags.under)
|
|
65
|
+
const folderTypes = await tree.port.folderTypes()
|
|
66
|
+
const folderType = folderTypes.find((t) => t.name === 'Folder') ?? folderTypes[0]
|
|
67
|
+
if (!folderType) this.fail('refused', 'This site has no media folder type to import into.')
|
|
68
|
+
const typeFor = new Map<string, { key: string; name: string } | undefined>()
|
|
69
|
+
const outcomes: Outcome[] = []
|
|
70
|
+
const dry = flags['dry-run']
|
|
71
|
+
|
|
72
|
+
const walk = async (dir: string, into: NodeRow | null, intoKey: string | null) => {
|
|
73
|
+
const existing =
|
|
74
|
+
into || !dry ? await childrenByName(tree, into) : new Map<string, NodeRow>()
|
|
75
|
+
for (const name of readdirSync(dir).sort()) {
|
|
76
|
+
if (name.startsWith('.')) continue
|
|
77
|
+
const path = join(dir, name)
|
|
78
|
+
const shown = relative(root, path)
|
|
79
|
+
if (statSync(path).isDirectory()) {
|
|
80
|
+
let folder = existing.get(name) ?? null
|
|
81
|
+
if (!folder) {
|
|
82
|
+
outcomes.push({ path: shown, action: 'folder' })
|
|
83
|
+
if (!dry) {
|
|
84
|
+
const key = crypto.randomUUID()
|
|
85
|
+
await tree.repo.create({
|
|
86
|
+
key,
|
|
87
|
+
contentTypeKey: folderType.key,
|
|
88
|
+
componentKey: null,
|
|
89
|
+
parentKey: intoKey,
|
|
90
|
+
values: [],
|
|
91
|
+
variants: [{ culture: null, segment: null, name }],
|
|
92
|
+
})
|
|
93
|
+
folder = (await tree.nodes.byKey(key)) ?? null
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
await walk(path, folder, folder?.key ?? null)
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
const extension = extname(name).slice(1).toLowerCase()
|
|
100
|
+
if (!typeFor.has(extension)) {
|
|
101
|
+
const types = await tree.port.typesForExtension(extension)
|
|
102
|
+
typeFor.set(extension, types.find((t) => t.matched) ?? types[0])
|
|
103
|
+
}
|
|
104
|
+
const type = typeFor.get(extension)
|
|
105
|
+
if (!type) {
|
|
106
|
+
outcomes.push({ path: shown, action: 'unsupported' })
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
const bytes = await Bun.file(path).bytes()
|
|
110
|
+
const there = existing.get(name)
|
|
111
|
+
if (there) {
|
|
112
|
+
const media = await tree.repo.byKey(there.key)
|
|
113
|
+
const src = media ? mediaFileOf(media) : null
|
|
114
|
+
const stored = src ? await tree.files.open(src) : undefined
|
|
115
|
+
const same = stored ? sha256(await stored.bytes()) === sha256(bytes) : false
|
|
116
|
+
outcomes.push({ path: shown, action: same ? 'skipped' : 'differs', type: type.name })
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
outcomes.push({ path: shown, action: 'created', type: type.name })
|
|
120
|
+
if (dry) continue
|
|
121
|
+
const upload = crypto.randomUUID()
|
|
122
|
+
await tree.files.saveTemporary(upload, new File([bytes], name))
|
|
123
|
+
await tree.repo.create({
|
|
124
|
+
key: crypto.randomUUID(),
|
|
125
|
+
contentTypeKey: type.key,
|
|
126
|
+
componentKey: null,
|
|
127
|
+
parentKey: intoKey,
|
|
128
|
+
values: [
|
|
129
|
+
{
|
|
130
|
+
alias: 'umbracoFile',
|
|
131
|
+
culture: null,
|
|
132
|
+
segment: null,
|
|
133
|
+
value: { temporaryFileId: upload },
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
variants: [{ culture: null, segment: null, name }],
|
|
137
|
+
})
|
|
138
|
+
const made = outcomes.filter((o) => o.action === 'created').length
|
|
139
|
+
if (made % 25 === 0) this.progress(` imported ${made}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
await walk(root, parent, parent?.key ?? null)
|
|
143
|
+
|
|
144
|
+
const count = (action: Outcome['action']) => outcomes.filter((o) => o.action === action)
|
|
145
|
+
const verb = dry ? 'would create' : 'created'
|
|
146
|
+
for (const o of outcomes.filter((o) => o.action !== 'skipped'))
|
|
147
|
+
this.say(
|
|
148
|
+
` ${(o.action === 'created' ? verb : o.action === 'folder' ? `${verb} folder` : o.action).padEnd(20)} ${o.path}${o.type ? ` (${o.type})` : ''}`,
|
|
149
|
+
)
|
|
150
|
+
this.say(
|
|
151
|
+
`\n ${count('created').length} file(s) and ${count('folder').length} folder(s) ${dry ? 'to create' : 'created'}, ${count('skipped').length} already here` +
|
|
152
|
+
`${count('differs').length > 0 ? `, ${count('differs').length} differ` : ''}${count('unsupported').length > 0 ? `, ${count('unsupported').length} no media type takes` : ''}`,
|
|
153
|
+
)
|
|
154
|
+
const changed = !dry && (count('created').length > 0 || count('folder').length > 0)
|
|
155
|
+
if (dry) this.changedNothing = true
|
|
156
|
+
// Once for the run: the nodes refresh what shows media.
|
|
157
|
+
if (changed)
|
|
158
|
+
await appendCacheInstruction(db, {
|
|
159
|
+
kind: 'content',
|
|
160
|
+
payload: { media: true },
|
|
161
|
+
by: `cli:${config.nodeId}`,
|
|
162
|
+
})
|
|
163
|
+
return {
|
|
164
|
+
created: count('created').length,
|
|
165
|
+
folders: count('folder').length,
|
|
166
|
+
skipped: count('skipped').length,
|
|
167
|
+
differs: count('differs').map((o) => o.path),
|
|
168
|
+
unsupported: count('unsupported').map((o) => o.path),
|
|
169
|
+
changed,
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
await db.close()
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { ObjectTypes } from '@crombolo/core'
|
|
2
|
+
import {
|
|
3
|
+
appendCacheInstruction,
|
|
4
|
+
currentSchemaState,
|
|
5
|
+
DocumentRepository,
|
|
6
|
+
type NodeRow,
|
|
7
|
+
} from '@crombolo/data'
|
|
8
|
+
import { bootstrapDatabase, MediaFileStore, mediaFileOf, mediaStoreFor } from '@crombolo/server'
|
|
9
|
+
import { Flags } from '@oclif/core'
|
|
10
|
+
import { BaseCommand } from '../../base.ts'
|
|
11
|
+
import { dryRun } from '../../support/flags.ts'
|
|
12
|
+
import { confirm } from '../../support/prompt.ts'
|
|
13
|
+
import { siteConfig } from '../../support/site.ts'
|
|
14
|
+
|
|
15
|
+
/** Deletes for good what is in the recycle bin: pages, media and their files. */
|
|
16
|
+
export default class RecycleBinEmpty extends BaseCommand {
|
|
17
|
+
static override summary = 'delete what is in the recycle bin, for good'
|
|
18
|
+
static override description = [
|
|
19
|
+
'Pages and media both, unless --content or --media names one. --older-than keeps what was trashed recently; an item trashed before crombolo recorded when is kept by it too, and counted.',
|
|
20
|
+
'Asks first, unless --yes; --dry-run lists what would go.',
|
|
21
|
+
].join('\n\n')
|
|
22
|
+
static override flags = {
|
|
23
|
+
content: Flags.boolean({ summary: 'only pages', exclusive: ['media'] }),
|
|
24
|
+
media: Flags.boolean({ summary: 'only media, with their files', exclusive: ['content'] }),
|
|
25
|
+
'older-than': Flags.integer({
|
|
26
|
+
summary: 'only what was trashed more than this many days ago',
|
|
27
|
+
min: 0,
|
|
28
|
+
}),
|
|
29
|
+
yes: Flags.boolean({ summary: "don't ask" }),
|
|
30
|
+
'dry-run': dryRun,
|
|
31
|
+
}
|
|
32
|
+
static override audit = {
|
|
33
|
+
category: 'content',
|
|
34
|
+
action: 'EmptyRecycleBin',
|
|
35
|
+
entityType: 'Document',
|
|
36
|
+
} as const
|
|
37
|
+
static override spec = {
|
|
38
|
+
effects: 'idempotent',
|
|
39
|
+
cardinality: 'bounded',
|
|
40
|
+
confirmationBypassArg: '--yes',
|
|
41
|
+
outputFields: [
|
|
42
|
+
{
|
|
43
|
+
name: 'items',
|
|
44
|
+
type: 'array',
|
|
45
|
+
items: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
fields: [
|
|
48
|
+
{ name: 'key', type: 'string' },
|
|
49
|
+
{ name: 'name', type: 'string' },
|
|
50
|
+
{ name: 'kind', type: 'string', enum: ['content', 'media'] },
|
|
51
|
+
{ name: 'trashedAt', type: 'string', nullable: true },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{ name: 'kept', type: 'integer', description: 'trashed too recently, or at an unknown time' },
|
|
56
|
+
{ name: 'changed', type: 'boolean' },
|
|
57
|
+
],
|
|
58
|
+
errors: ['confirmation_required', 'refused'],
|
|
59
|
+
} as const
|
|
60
|
+
|
|
61
|
+
async run() {
|
|
62
|
+
const { flags } = await this.parse(RecycleBinEmpty)
|
|
63
|
+
const config = await siteConfig()
|
|
64
|
+
const { db } = await bootstrapDatabase(config)
|
|
65
|
+
try {
|
|
66
|
+
const kinds = (['content', 'media'] as const).filter(
|
|
67
|
+
(kind) => (!flags.content && !flags.media) || flags[kind],
|
|
68
|
+
)
|
|
69
|
+
const files = new MediaFileStore(await mediaStoreFor(config))
|
|
70
|
+
const state = await currentSchemaState(db)
|
|
71
|
+
const cutoff =
|
|
72
|
+
flags['older-than'] === undefined
|
|
73
|
+
? undefined
|
|
74
|
+
: Date.now() - flags['older-than'] * 86_400_000
|
|
75
|
+
const chosen: Array<{
|
|
76
|
+
repo: DocumentRepository
|
|
77
|
+
node: NodeRow
|
|
78
|
+
kind: 'content' | 'media'
|
|
79
|
+
trashedAt: Date | undefined
|
|
80
|
+
}> = []
|
|
81
|
+
let kept = 0
|
|
82
|
+
for (const kind of kinds) {
|
|
83
|
+
const repo = new DocumentRepository(db, {
|
|
84
|
+
kind: kind === 'media' ? 'media' : 'document',
|
|
85
|
+
nodeState: { version: state.version, revision: state.revision },
|
|
86
|
+
nodeId: `cli:${config.nodeId}`,
|
|
87
|
+
})
|
|
88
|
+
const roots = await repo.trashedRoots()
|
|
89
|
+
const dates = await repo.trashedDates(roots.map((node) => node.id))
|
|
90
|
+
for (const node of roots) {
|
|
91
|
+
const trashedAt = dates.get(node.id)
|
|
92
|
+
if (cutoff !== undefined && (!trashedAt || trashedAt.getTime() > cutoff)) {
|
|
93
|
+
kept += 1
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
chosen.push({ repo, node, kind, trashedAt })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const items = chosen.map(({ node, kind, trashedAt }) => ({
|
|
100
|
+
key: node.key,
|
|
101
|
+
name: node.text ?? node.key,
|
|
102
|
+
kind,
|
|
103
|
+
trashedAt: trashedAt?.toISOString() ?? null,
|
|
104
|
+
}))
|
|
105
|
+
for (const item of items)
|
|
106
|
+
this.say(
|
|
107
|
+
` ${flags['dry-run'] ? 'would delete' : 'deletes'} ${item.kind.padEnd(7)} ${item.name}`,
|
|
108
|
+
)
|
|
109
|
+
if (kept > 0)
|
|
110
|
+
this.say(` keeps ${kept} item(s) trashed too recently, or before the date was recorded`)
|
|
111
|
+
if (items.length === 0) {
|
|
112
|
+
this.say(' The recycle bin has nothing to delete.')
|
|
113
|
+
this.changedNothing = true
|
|
114
|
+
return { items, kept, changed: false }
|
|
115
|
+
}
|
|
116
|
+
if (flags['dry-run']) {
|
|
117
|
+
this.changedNothing = true
|
|
118
|
+
return { items, kept, changed: false }
|
|
119
|
+
}
|
|
120
|
+
if (!flags.yes)
|
|
121
|
+
await confirm(
|
|
122
|
+
` Delete ${items.length} item(s) and everything below them, for good? [y/N]`,
|
|
123
|
+
{
|
|
124
|
+
bypass: '--yes',
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
let mediaChanged = false
|
|
128
|
+
for (const { repo, node, kind } of chosen) {
|
|
129
|
+
// A media item's file goes with it, as emptying the bin in the back office does.
|
|
130
|
+
const paths: string[] = []
|
|
131
|
+
if (kind === 'media') {
|
|
132
|
+
const rows = await db.query<{ unique_id: string }>(
|
|
133
|
+
'SELECT unique_id FROM node WHERE (id = ? OR path LIKE ?) AND node_object_type = ?',
|
|
134
|
+
[node.id, `${node.path},%`, ObjectTypes.Media],
|
|
135
|
+
)
|
|
136
|
+
for (const row of rows) {
|
|
137
|
+
const media = await repo.byKey(String(row.unique_id))
|
|
138
|
+
const file = media ? mediaFileOf(media) : null
|
|
139
|
+
if (file) paths.push(file)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (await repo.delete(node.key)) for (const path of paths) await files.remove(path)
|
|
143
|
+
if (kind === 'media') mediaChanged = true
|
|
144
|
+
}
|
|
145
|
+
// One instruction for the run, as a node's own empty-bin does.
|
|
146
|
+
await appendCacheInstruction(db, {
|
|
147
|
+
kind: 'content',
|
|
148
|
+
payload: mediaChanged ? { media: true } : { recycleBin: true },
|
|
149
|
+
by: `cli:${config.nodeId}`,
|
|
150
|
+
})
|
|
151
|
+
this.say(` deleted ${items.length} item(s)`)
|
|
152
|
+
return { items, kept, changed: true }
|
|
153
|
+
} finally {
|
|
154
|
+
await db.close()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Args } from '@oclif/core'
|
|
2
|
+
import { BaseCommand } from '../../base.ts'
|
|
3
|
+
import {
|
|
4
|
+
checkStatus,
|
|
5
|
+
describeUser,
|
|
6
|
+
OBSERVER,
|
|
7
|
+
OPERATOR,
|
|
8
|
+
openPeople,
|
|
9
|
+
userNamed,
|
|
10
|
+
} from '../../support/people.ts'
|
|
11
|
+
import { siteConfig } from '../../support/site.ts'
|
|
12
|
+
|
|
13
|
+
export default class UsersDisable extends BaseCommand {
|
|
14
|
+
static override summary = 'stop a user signing in, and end their sessions'
|
|
15
|
+
static override args = {
|
|
16
|
+
user: Args.string({ required: true, description: 'their e-mail address or username' }),
|
|
17
|
+
}
|
|
18
|
+
static override audit = { category: 'user', action: 'Disable', entityType: 'User' } as const
|
|
19
|
+
static override spec = {
|
|
20
|
+
effects: 'idempotent',
|
|
21
|
+
cardinality: 'single',
|
|
22
|
+
outputFields: [
|
|
23
|
+
{ name: 'id', type: 'string' },
|
|
24
|
+
{ name: 'email', type: 'string' },
|
|
25
|
+
{ name: 'state', type: 'string' },
|
|
26
|
+
{ name: 'changed', type: 'boolean' },
|
|
27
|
+
],
|
|
28
|
+
errors: ['not_found', 'refused'],
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
async run() {
|
|
32
|
+
const { args } = await this.parse(UsersDisable)
|
|
33
|
+
const people = await openPeople(await siteConfig())
|
|
34
|
+
try {
|
|
35
|
+
const user = await userNamed(people, args.user)
|
|
36
|
+
this.auditKey = user.key
|
|
37
|
+
const snapshot = async () => {
|
|
38
|
+
const found = await people.users.get(OBSERVER, user.key)
|
|
39
|
+
return found.status === 'Success'
|
|
40
|
+
? { state: found.value.state, failed: found.value.failedLoginAttempts }
|
|
41
|
+
: { state: 'unknown', failed: 0 }
|
|
42
|
+
}
|
|
43
|
+
const before = await snapshot()
|
|
44
|
+
checkStatus(await people.users.setApproved(OPERATOR, [user.key], false))
|
|
45
|
+
const after = await snapshot()
|
|
46
|
+
const state = after.state
|
|
47
|
+
const changed = before.state !== after.state || before.failed !== after.failed
|
|
48
|
+
this.say(` disabled ${describeUser(user)}${changed ? '' : ' (no change)'}`)
|
|
49
|
+
return { id: user.key, email: user.email, state, changed }
|
|
50
|
+
} finally {
|
|
51
|
+
await people.db.close()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Args } from '@oclif/core'
|
|
2
|
+
import { BaseCommand } from '../../base.ts'
|
|
3
|
+
import {
|
|
4
|
+
checkStatus,
|
|
5
|
+
describeUser,
|
|
6
|
+
OBSERVER,
|
|
7
|
+
OPERATOR,
|
|
8
|
+
openPeople,
|
|
9
|
+
userNamed,
|
|
10
|
+
} from '../../support/people.ts'
|
|
11
|
+
import { siteConfig } from '../../support/site.ts'
|
|
12
|
+
|
|
13
|
+
export default class UsersEnable extends BaseCommand {
|
|
14
|
+
static override summary = 'let a disabled user sign in again'
|
|
15
|
+
static override args = {
|
|
16
|
+
user: Args.string({ required: true, description: 'their e-mail address or username' }),
|
|
17
|
+
}
|
|
18
|
+
static override audit = { category: 'user', action: 'Enable', entityType: 'User' } as const
|
|
19
|
+
static override spec = {
|
|
20
|
+
effects: 'idempotent',
|
|
21
|
+
cardinality: 'single',
|
|
22
|
+
outputFields: [
|
|
23
|
+
{ name: 'id', type: 'string' },
|
|
24
|
+
{ name: 'email', type: 'string' },
|
|
25
|
+
{ name: 'state', type: 'string' },
|
|
26
|
+
{ name: 'changed', type: 'boolean' },
|
|
27
|
+
],
|
|
28
|
+
errors: ['not_found', 'refused'],
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
async run() {
|
|
32
|
+
const { args } = await this.parse(UsersEnable)
|
|
33
|
+
const people = await openPeople(await siteConfig())
|
|
34
|
+
try {
|
|
35
|
+
const user = await userNamed(people, args.user)
|
|
36
|
+
this.auditKey = user.key
|
|
37
|
+
const snapshot = async () => {
|
|
38
|
+
const found = await people.users.get(OBSERVER, user.key)
|
|
39
|
+
return found.status === 'Success'
|
|
40
|
+
? { state: found.value.state, failed: found.value.failedLoginAttempts }
|
|
41
|
+
: { state: 'unknown', failed: 0 }
|
|
42
|
+
}
|
|
43
|
+
const before = await snapshot()
|
|
44
|
+
checkStatus(await people.users.setApproved(OPERATOR, [user.key], true))
|
|
45
|
+
const after = await snapshot()
|
|
46
|
+
const state = after.state
|
|
47
|
+
const changed = before.state !== after.state || before.failed !== after.failed
|
|
48
|
+
this.say(` enabled ${describeUser(user)}${changed ? '' : ' (no change)'}`)
|
|
49
|
+
return { id: user.key, email: user.email, state, changed }
|
|
50
|
+
} finally {
|
|
51
|
+
await people.db.close()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { UserGroupRepository } from '@crombolo/data'
|
|
2
|
+
import { Args, Flags } from '@oclif/core'
|
|
3
|
+
import { BaseCommand } from '../../base.ts'
|
|
4
|
+
import { many } from '../../support/flags.ts'
|
|
5
|
+
import { checkStatus, OPERATOR, openPeople } from '../../support/people.ts'
|
|
6
|
+
import { siteConfig } from '../../support/site.ts'
|
|
7
|
+
|
|
8
|
+
export default class UsersInvite extends BaseCommand {
|
|
9
|
+
static override summary = 'create a user and print the link they set their password with'
|
|
10
|
+
static override description =
|
|
11
|
+
'The link is printed rather than e-mailed, so it works on a site with no e-mail delivery. It is used once, and expires as an invitation does.'
|
|
12
|
+
static override examples = [
|
|
13
|
+
'<%= config.bin %> users invite editor@example.com --group editor --name "Ada Lovelace"',
|
|
14
|
+
]
|
|
15
|
+
static override args = {
|
|
16
|
+
email: Args.string({ required: true, description: 'their e-mail address' }),
|
|
17
|
+
}
|
|
18
|
+
static override flags = {
|
|
19
|
+
group: many('a user group, by alias; repeatable', '<alias>'),
|
|
20
|
+
name: Flags.string({ summary: 'their name; the address when not given', helpValue: '<name>' }),
|
|
21
|
+
}
|
|
22
|
+
static override audit = { category: 'user', action: 'Invite', entityType: 'User' } as const
|
|
23
|
+
static override spec = {
|
|
24
|
+
effects: 'non_idempotent',
|
|
25
|
+
cardinality: 'single',
|
|
26
|
+
outputFields: [
|
|
27
|
+
{ name: 'id', type: 'string' },
|
|
28
|
+
{ name: 'email', type: 'string' },
|
|
29
|
+
{ name: 'groups', type: 'array', items: { type: 'string' } },
|
|
30
|
+
{ name: 'link', type: 'string' },
|
|
31
|
+
],
|
|
32
|
+
errors: ['usage', 'not_found', 'conflict', 'refused'],
|
|
33
|
+
} as const
|
|
34
|
+
|
|
35
|
+
async run() {
|
|
36
|
+
const { args, flags } = await this.parse(UsersInvite)
|
|
37
|
+
if (flags.group.length === 0)
|
|
38
|
+
this.fail('usage', 'An invited user needs at least one group.', 'Pass --group <alias>.')
|
|
39
|
+
const config = await siteConfig()
|
|
40
|
+
const people = await openPeople(config)
|
|
41
|
+
try {
|
|
42
|
+
const repository = new UserGroupRepository(people.db)
|
|
43
|
+
const groupKeys: string[] = []
|
|
44
|
+
for (const alias of flags.group) {
|
|
45
|
+
const group = await repository.byAlias(alias)
|
|
46
|
+
if (!group)
|
|
47
|
+
this.fail(
|
|
48
|
+
'not_found',
|
|
49
|
+
`There is no user group "${alias}".`,
|
|
50
|
+
`It has: ${(await repository.all()).map((g) => g.alias).join(', ')}.`,
|
|
51
|
+
)
|
|
52
|
+
groupKeys.push(group.key)
|
|
53
|
+
}
|
|
54
|
+
const result = await people.users.invite(
|
|
55
|
+
OPERATOR,
|
|
56
|
+
{
|
|
57
|
+
kind: 'Default',
|
|
58
|
+
email: args.email,
|
|
59
|
+
userName: args.email,
|
|
60
|
+
name: flags.name ?? args.email,
|
|
61
|
+
groupKeys,
|
|
62
|
+
},
|
|
63
|
+
null,
|
|
64
|
+
)
|
|
65
|
+
checkStatus(result.status)
|
|
66
|
+
const id = result.status === 'Success' ? result.value : ''
|
|
67
|
+
this.auditKey = id
|
|
68
|
+
const captured = people.lastLink() ?? ''
|
|
69
|
+
// Without an application URL the link is relative: it is the path on the site's own address.
|
|
70
|
+
const link = config.applicationUrl ? captured : captured.replace(/^[^/]*/, '')
|
|
71
|
+
this.say(` invited ${args.email} to ${flags.group.join(', ')}`)
|
|
72
|
+
this.say(link)
|
|
73
|
+
if (!config.applicationUrl)
|
|
74
|
+
this.warning(
|
|
75
|
+
' note the link is a path on the site: set CROMBOLO_APPLICATION_URL to print the whole address',
|
|
76
|
+
)
|
|
77
|
+
return { id, email: args.email, groups: flags.group, link }
|
|
78
|
+
} finally {
|
|
79
|
+
await people.db.close()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { UserGroupRepository } from '@crombolo/data'
|
|
2
|
+
import { BaseCommand } from '../../base.ts'
|
|
3
|
+
import { page, pageFlags, pagination } from '../../support/flags.ts'
|
|
4
|
+
import { OBSERVER, openPeople } from '../../support/people.ts'
|
|
5
|
+
import { siteConfig } from '../../support/site.ts'
|
|
6
|
+
|
|
7
|
+
export default class UsersList extends BaseCommand {
|
|
8
|
+
static override summary = 'the back-office users: who, their groups, their state and last sign-in'
|
|
9
|
+
static override flags = { ...pageFlags(50) }
|
|
10
|
+
static override spec = {
|
|
11
|
+
effects: 'read_only',
|
|
12
|
+
cardinality: 'unbounded',
|
|
13
|
+
pagination,
|
|
14
|
+
fieldsArg: '--fields',
|
|
15
|
+
outputFields: [
|
|
16
|
+
{ name: 'id', type: 'string' },
|
|
17
|
+
{ name: 'name', type: 'string' },
|
|
18
|
+
{ name: 'email', type: 'string' },
|
|
19
|
+
{ name: 'userName', type: 'string' },
|
|
20
|
+
{ name: 'kind', type: 'string', enum: ['Default', 'Api'] },
|
|
21
|
+
{ name: 'state', type: 'string' },
|
|
22
|
+
{ name: 'groups', type: 'array', items: { type: 'string' } },
|
|
23
|
+
{ name: 'failedLoginAttempts', type: 'integer' },
|
|
24
|
+
{ name: 'lastLoginDate', type: 'string', nullable: true },
|
|
25
|
+
],
|
|
26
|
+
} as const
|
|
27
|
+
|
|
28
|
+
async run() {
|
|
29
|
+
const { flags } = await this.parse(UsersList)
|
|
30
|
+
const people = await openPeople(await siteConfig())
|
|
31
|
+
try {
|
|
32
|
+
const found = await people.users.list(OBSERVER, flags.offset, flags.limit)
|
|
33
|
+
const groups = new Map(
|
|
34
|
+
(await new UserGroupRepository(people.db).all()).map((g) => [g.key, g.alias]),
|
|
35
|
+
)
|
|
36
|
+
const items = found.items.map((user) => ({
|
|
37
|
+
id: user.id,
|
|
38
|
+
name: user.name,
|
|
39
|
+
email: user.email,
|
|
40
|
+
userName: user.userName,
|
|
41
|
+
kind: user.kind,
|
|
42
|
+
state: user.state,
|
|
43
|
+
groups: user.userGroupIds.map((g) => groups.get(g.id) ?? g.id),
|
|
44
|
+
failedLoginAttempts: user.failedLoginAttempts,
|
|
45
|
+
lastLoginDate: user.lastLoginDate ?? null,
|
|
46
|
+
}))
|
|
47
|
+
if (items.length === 0) this.say('No users.')
|
|
48
|
+
for (const user of items)
|
|
49
|
+
this.say(
|
|
50
|
+
` ${user.state.padEnd(9)} ${user.name} <${user.email || user.userName}> ${user.groups.join(', ') || 'no groups'}` +
|
|
51
|
+
`${user.lastLoginDate ? ` last in ${user.lastLoginDate}` : ' never signed in'}`,
|
|
52
|
+
)
|
|
53
|
+
if (flags.offset + items.length < found.total)
|
|
54
|
+
this.say(
|
|
55
|
+
` … ${found.total - flags.offset - items.length} more: --offset ${flags.offset + items.length}`,
|
|
56
|
+
)
|
|
57
|
+
return page(items, found.total, flags)
|
|
58
|
+
} finally {
|
|
59
|
+
await people.db.close()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Args } from '@oclif/core'
|
|
2
|
+
import { BaseCommand } from '../../base.ts'
|
|
3
|
+
import { describeUser, openPeople, userNamed } from '../../support/people.ts'
|
|
4
|
+
import { siteConfig } from '../../support/site.ts'
|
|
5
|
+
|
|
6
|
+
/** For someone who has lost their authenticator: their next sign-in enrols again. */
|
|
7
|
+
export default class UsersReset2fa extends BaseCommand {
|
|
8
|
+
static override summary =
|
|
9
|
+
"remove a user's second factors, so their next sign-in sets one up again"
|
|
10
|
+
static override args = {
|
|
11
|
+
user: Args.string({ required: true, description: 'their e-mail address or username' }),
|
|
12
|
+
}
|
|
13
|
+
static override audit = {
|
|
14
|
+
category: 'user',
|
|
15
|
+
action: 'ResetTwoFactor',
|
|
16
|
+
entityType: 'User',
|
|
17
|
+
} as const
|
|
18
|
+
static override spec = {
|
|
19
|
+
effects: 'idempotent',
|
|
20
|
+
cardinality: 'single',
|
|
21
|
+
outputFields: [
|
|
22
|
+
{ name: 'id', type: 'string' },
|
|
23
|
+
{ name: 'email', type: 'string' },
|
|
24
|
+
{ name: 'removed', type: 'array', items: { type: 'string' } },
|
|
25
|
+
{ name: 'changed', type: 'boolean' },
|
|
26
|
+
],
|
|
27
|
+
errors: ['not_found'],
|
|
28
|
+
} as const
|
|
29
|
+
|
|
30
|
+
async run() {
|
|
31
|
+
const { args } = await this.parse(UsersReset2fa)
|
|
32
|
+
const people = await openPeople(await siteConfig())
|
|
33
|
+
try {
|
|
34
|
+
const user = await userNamed(people, args.user)
|
|
35
|
+
this.auditKey = user.key
|
|
36
|
+
const removed = await people.twoFactor.providers(user.key)
|
|
37
|
+
await people.twoFactor.reset(user.key)
|
|
38
|
+
this.say(
|
|
39
|
+
removed.length > 0
|
|
40
|
+
? ` removed ${removed.join(', ')} from ${describeUser(user)}`
|
|
41
|
+
: ` ${describeUser(user)} has no second factor; nothing changed`,
|
|
42
|
+
)
|
|
43
|
+
return { id: user.key, email: user.email, removed, changed: removed.length > 0 }
|
|
44
|
+
} finally {
|
|
45
|
+
await people.db.close()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|