@crombolo/cli 0.15.0 → 0.17.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.
@@ -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
+ }