@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.
- package/bin/crombolo.ts +4 -3166
- package/oclif.manifest.json +8077 -0
- package/package.json +79 -8
- package/src/base.ts +134 -0
- package/src/classify.ts +119 -0
- package/src/clispec.ts +163 -0
- package/src/commands/admin/reset-password.ts +53 -0
- package/src/commands/api-client/rotate.ts +57 -0
- package/src/commands/assets/index.ts +6 -0
- package/src/commands/assets/list.ts +27 -0
- package/src/commands/assets/new.ts +42 -0
- package/src/commands/bundle/purge.ts +71 -0
- package/src/commands/cache/rebuild.ts +56 -0
- package/src/commands/components/check.ts +58 -0
- package/src/commands/components/index.ts +6 -0
- package/src/commands/components/list.ts +40 -0
- package/src/commands/components/new.ts +49 -0
- package/src/commands/content/check.ts +102 -0
- package/src/commands/content/export.ts +116 -0
- package/src/commands/content/import.ts +109 -0
- package/src/commands/content/publish.ts +21 -0
- package/src/commands/content/revert.ts +137 -0
- package/src/commands/content/runs.ts +62 -0
- package/src/commands/content/unpublish.ts +21 -0
- package/src/commands/dictionary/export.ts +49 -0
- package/src/commands/dictionary/import.ts +46 -0
- package/src/commands/domains/apply.ts +23 -0
- package/src/commands/domains/clear.ts +27 -0
- package/src/commands/domains/index.ts +6 -0
- package/src/commands/domains/list.ts +86 -0
- package/src/commands/domains/set.ts +55 -0
- package/src/commands/domains/undo.ts +29 -0
- package/src/commands/export.ts +100 -0
- package/src/commands/generate.ts +33 -0
- package/src/commands/health.ts +90 -0
- package/src/commands/import/index.ts +175 -0
- package/src/commands/import/umbraco/apply.ts +128 -0
- package/src/commands/import/umbraco/report.ts +56 -0
- package/src/commands/index/list.ts +45 -0
- package/src/commands/index/rebuild.ts +66 -0
- package/src/commands/init.ts +76 -0
- package/src/commands/login.ts +38 -0
- package/src/commands/logout.ts +36 -0
- package/src/commands/maintenance/pause.ts +29 -0
- package/src/commands/maintenance/resume.ts +20 -0
- package/src/commands/maintenance/status.ts +34 -0
- package/src/commands/media/export.ts +91 -0
- package/src/commands/media/fetch.ts +62 -0
- package/src/commands/media/import.ts +175 -0
- package/src/commands/pull.ts +210 -0
- package/src/commands/push.ts +108 -0
- package/src/commands/recycle-bin/empty.ts +157 -0
- package/src/commands/remote/add.ts +78 -0
- package/src/commands/remote/job.ts +53 -0
- package/src/commands/remote/list.ts +41 -0
- package/src/commands/remote/remove.ts +26 -0
- package/src/commands/schema.ts +32 -0
- package/src/commands/start.ts +64 -0
- package/src/commands/status.ts +103 -0
- package/src/commands/types/add-property.ts +116 -0
- package/src/commands/types/check.ts +88 -0
- package/src/commands/types/export.ts +32 -0
- package/src/commands/types/new.ts +116 -0
- package/src/commands/types/purge.ts +65 -0
- package/src/commands/types/rewrite.ts +30 -0
- package/src/commands/types/sync.ts +66 -0
- package/src/commands/upgrade/check.ts +69 -0
- package/src/commands/upgrade/index.ts +120 -0
- package/src/commands/upgrade/ledger.ts +55 -0
- package/src/commands/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/commands/whoami.ts +41 -0
- package/src/errors.ts +90 -0
- package/src/load.ts +59 -0
- package/src/main.ts +41 -0
- package/src/manifest.ts +30 -0
- package/src/plugin.ts +38 -0
- package/src/reference.ts +118 -0
- package/src/remote/client.ts +25 -11
- package/src/remote/remotes-file.ts +3 -2
- package/src/remote/transfer.ts +27 -11
- package/src/support/components.ts +7 -0
- package/src/support/describe.ts +179 -0
- package/src/support/domains.ts +89 -0
- package/src/support/flags.ts +157 -0
- package/src/support/media-tree.ts +72 -0
- package/src/support/people.ts +134 -0
- package/src/support/prompt.ts +126 -0
- package/src/support/publish.ts +142 -0
- package/src/support/remote.ts +57 -0
- package/src/support/serve.ts +460 -0
- package/src/support/site.ts +26 -0
- package/src/support/types.ts +112 -0
- package/src/support/umbraco.ts +86 -0
- package/templates/basic/bundle/content/bundle.json +1 -1
- package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
- package/templates/basic/bundle/package.json +1 -1
- package/templates/demo/harbourstone/bundle/package.json +1 -1
- package/templates/starter/bundle/package.json +1 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { bootstrapDatabase, syncSchemaDirectory } from '@crombolo/server'
|
|
2
|
+
import { Flags } from '@oclif/core'
|
|
3
|
+
import { BaseCommand } from '../../base.ts'
|
|
4
|
+
import { describeProblem, describeSync, printProblems } from '../../support/describe.ts'
|
|
5
|
+
import { siteConfig } from '../../support/site.ts'
|
|
6
|
+
import { needSchemaDir } from '../../support/types.ts'
|
|
7
|
+
|
|
8
|
+
export default class TypesSync extends BaseCommand {
|
|
9
|
+
static override summary = 'apply schema/ to the database'
|
|
10
|
+
static override aliases = ['schema:sync']
|
|
11
|
+
static override deprecateAliases = true
|
|
12
|
+
static override flags = {
|
|
13
|
+
'force-retire-types': Flags.boolean({
|
|
14
|
+
summary: 'retire a type the files no longer have even though content uses it',
|
|
15
|
+
}),
|
|
16
|
+
}
|
|
17
|
+
static override audit = { category: 'schema', action: 'Sync', entityType: 'Schema' } as const
|
|
18
|
+
static override spec = {
|
|
19
|
+
effects: 'idempotent',
|
|
20
|
+
cardinality: 'single',
|
|
21
|
+
outputFields: [
|
|
22
|
+
{
|
|
23
|
+
name: 'action',
|
|
24
|
+
type: 'string',
|
|
25
|
+
enum: ['applied', 'would-apply', 'skipped-same', 'skipped-older', 'refused'],
|
|
26
|
+
},
|
|
27
|
+
{ name: 'changed', type: 'boolean' },
|
|
28
|
+
{ name: 'summary', type: 'string' },
|
|
29
|
+
{ name: 'problems', type: 'array', items: { type: 'string' } },
|
|
30
|
+
],
|
|
31
|
+
errors: ['not_found', 'refused'],
|
|
32
|
+
} as const
|
|
33
|
+
|
|
34
|
+
async run() {
|
|
35
|
+
const { flags } = await this.parse(TypesSync)
|
|
36
|
+
const config = await siteConfig()
|
|
37
|
+
needSchemaDir(config)
|
|
38
|
+
const { db } = await bootstrapDatabase(config)
|
|
39
|
+
try {
|
|
40
|
+
const report = await syncSchemaDirectory(
|
|
41
|
+
db,
|
|
42
|
+
config,
|
|
43
|
+
{ nodeId: config.nodeId, revision: config.schemaRevision },
|
|
44
|
+
{ forceRetireTypes: flags['force-retire-types'] },
|
|
45
|
+
)
|
|
46
|
+
if (report.problems.length > 0) printProblems(this, report.problems)
|
|
47
|
+
const summary = describeSync(report)
|
|
48
|
+
if (report.action === 'refused')
|
|
49
|
+
this.fail(
|
|
50
|
+
'refused',
|
|
51
|
+
`The schema was not applied: ${summary}.`,
|
|
52
|
+
undefined,
|
|
53
|
+
report.problems.map(describeProblem),
|
|
54
|
+
)
|
|
55
|
+
this.say(summary)
|
|
56
|
+
return {
|
|
57
|
+
action: report.action,
|
|
58
|
+
changed: report.action === 'applied',
|
|
59
|
+
summary,
|
|
60
|
+
problems: report.problems.map(describeProblem),
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
await db.close()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core'
|
|
2
|
+
import { BaseCommand } from '../../base.ts'
|
|
3
|
+
import * as ops from '../../operations/commands.ts'
|
|
4
|
+
import { findingsOf, printFindings } from '../../support/describe.ts'
|
|
5
|
+
import { backupTaken, many } from '../../support/flags.ts'
|
|
6
|
+
import { siteConfig } from '../../support/site.ts'
|
|
7
|
+
import { parseSet, upgradeCheck } from '../../support/types.ts'
|
|
8
|
+
|
|
9
|
+
export default class UpgradeCheck extends BaseCommand {
|
|
10
|
+
static override summary = "what the deployed schema/ would do to this database's data"
|
|
11
|
+
static override description = [
|
|
12
|
+
'Exits 21 when a finding needs a person first.',
|
|
13
|
+
'--fix backs up, then applies the additive part and every conversion early, so the cut-over is short.',
|
|
14
|
+
].join('\n\n')
|
|
15
|
+
static override flags = {
|
|
16
|
+
fix: Flags.boolean({
|
|
17
|
+
summary: 'back up, then apply the additive part and every conversion early',
|
|
18
|
+
}),
|
|
19
|
+
set: many('a value for a new mandatory property: <type>.<property>=<value>', '<t.p=v>'),
|
|
20
|
+
'backup-taken': backupTaken,
|
|
21
|
+
}
|
|
22
|
+
static override audit = { category: 'system', action: 'UpgradeFix' } as const
|
|
23
|
+
static override spec = {
|
|
24
|
+
effects: 'idempotent',
|
|
25
|
+
cardinality: 'single',
|
|
26
|
+
outputFields: [
|
|
27
|
+
{ name: 'classification', type: 'string' },
|
|
28
|
+
{ name: 'findings', type: 'array', items: { type: 'object' } },
|
|
29
|
+
{ name: 'blocking', type: 'boolean' },
|
|
30
|
+
{ name: 'applied', type: 'array', items: { type: 'string' } },
|
|
31
|
+
{ name: 'backup', type: 'string', nullable: true },
|
|
32
|
+
],
|
|
33
|
+
outcomes: ['blocked'],
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
async run() {
|
|
37
|
+
const { flags } = await this.parse(UpgradeCheck)
|
|
38
|
+
const config = await siteConfig()
|
|
39
|
+
const set = parseSet(flags.set)
|
|
40
|
+
if (!flags.fix) {
|
|
41
|
+
this.changedNothing = true
|
|
42
|
+
const report = await upgradeCheck(this, config, set)
|
|
43
|
+
if (report.blocking) this.outcome = 'blocked'
|
|
44
|
+
return {
|
|
45
|
+
classification: report.classification,
|
|
46
|
+
findings: findingsOf(report.findings),
|
|
47
|
+
blocking: report.blocking,
|
|
48
|
+
applied: [],
|
|
49
|
+
backup: null,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const fix = await ops.upgradeFix(config, { set, backupTaken: flags['backup-taken'] })
|
|
53
|
+
if (fix.backup.path) this.progress(` backup ${fix.backup.path}`)
|
|
54
|
+
this.say(
|
|
55
|
+
` sync ${fix.sync.action}${fix.sync.deferred.length > 0 ? ` (deferred to the cut-over: ${fix.sync.deferred.join(', ')})` : ''}`,
|
|
56
|
+
)
|
|
57
|
+
for (const name of fix.applied) this.say(` applied ${name}`)
|
|
58
|
+
if (fix.skipped > 0)
|
|
59
|
+
this.say(` skipped ${fix.skipped} value(s) a converter refused; see the findings`)
|
|
60
|
+
printFindings(this, fix)
|
|
61
|
+
return {
|
|
62
|
+
classification: fix.classification,
|
|
63
|
+
findings: findingsOf(fix.findings),
|
|
64
|
+
blocking: fix.findings.some((f) => f.kind === 'blocking'),
|
|
65
|
+
applied: fix.applied,
|
|
66
|
+
backup: fix.backup.path ?? null,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Flags } from '@oclif/core'
|
|
2
|
+
import { BaseCommand } from '../../base.ts'
|
|
3
|
+
import * as ops from '../../operations/commands.ts'
|
|
4
|
+
import { findingsOf } from '../../support/describe.ts'
|
|
5
|
+
import { backupTaken, many } from '../../support/flags.ts'
|
|
6
|
+
import { siteConfig } from '../../support/site.ts'
|
|
7
|
+
import { parseSet } from '../../support/types.ts'
|
|
8
|
+
|
|
9
|
+
export default class Upgrade extends BaseCommand {
|
|
10
|
+
static override summary = 'back up, apply pending framework migrations, cut over, and record them'
|
|
11
|
+
static override description = [
|
|
12
|
+
'Re-checks first, and refuses while a finding is outstanding; `upgrade check --fix` resolves what it can early.',
|
|
13
|
+
'--plan prints the DDL each pending migration would run, and changes nothing.',
|
|
14
|
+
].join('\n\n')
|
|
15
|
+
static override flags = {
|
|
16
|
+
plan: Flags.boolean({
|
|
17
|
+
summary: 'print the DDL pending framework migrations would run, and stop',
|
|
18
|
+
}),
|
|
19
|
+
force: Flags.string({
|
|
20
|
+
summary: 'go on over outstanding findings, saying why',
|
|
21
|
+
helpValue: '<reason>',
|
|
22
|
+
}),
|
|
23
|
+
set: many('a value for a new mandatory property: <type>.<property>=<value>', '<t.p=v>'),
|
|
24
|
+
'backup-taken': backupTaken,
|
|
25
|
+
}
|
|
26
|
+
static override audit = { category: 'system', action: 'Upgrade' } as const
|
|
27
|
+
static override spec = {
|
|
28
|
+
effects: 'idempotent',
|
|
29
|
+
cardinality: 'single',
|
|
30
|
+
outputFields: [
|
|
31
|
+
{ name: 'action', type: 'string' },
|
|
32
|
+
{ name: 'changed', type: 'boolean' },
|
|
33
|
+
{ name: 'backup', type: 'string', nullable: true },
|
|
34
|
+
{ name: 'frameworkApplied', type: 'array', items: { type: 'string' } },
|
|
35
|
+
{ name: 'bundlesApplied', type: 'array', items: { type: 'object' } },
|
|
36
|
+
{ name: 'schema', type: 'object', nullable: true },
|
|
37
|
+
{ name: 'plan', type: 'object', nullable: true },
|
|
38
|
+
],
|
|
39
|
+
errors: ['refused'],
|
|
40
|
+
} as const
|
|
41
|
+
|
|
42
|
+
async run() {
|
|
43
|
+
const { flags } = await this.parse(Upgrade)
|
|
44
|
+
const config = await siteConfig()
|
|
45
|
+
if (flags.plan) {
|
|
46
|
+
this.changedNothing = true
|
|
47
|
+
const plan = await ops.upgradePlan(config)
|
|
48
|
+
if (plan.steps.length === 0) this.say('No framework migration is pending.')
|
|
49
|
+
const printStep = (step: ops.PlannedStepResult, indent = '') => {
|
|
50
|
+
this.say(
|
|
51
|
+
`\n${indent}== ${step.name} (${step.kind}${step.release ? `, release ${step.release}` : ''})`,
|
|
52
|
+
)
|
|
53
|
+
if (step.unplannable) this.say(`${indent} unplannable: ${step.unplannable}`)
|
|
54
|
+
for (const sql of step.statements) this.say(`${indent} ${sql};`)
|
|
55
|
+
}
|
|
56
|
+
for (const step of plan.steps) printStep(step)
|
|
57
|
+
// After core's, in the order `bundles` lists them, because that is the order they run.
|
|
58
|
+
for (const bundle of plan.bundles) {
|
|
59
|
+
this.say(
|
|
60
|
+
`\nbundle ${bundle.id} (${bundle.name}): ${bundle.steps.length === 0 ? 'nothing pending' : `${bundle.steps.length} step(s)`}`,
|
|
61
|
+
)
|
|
62
|
+
for (const step of bundle.steps) printStep(step, ' ')
|
|
63
|
+
}
|
|
64
|
+
this.say(
|
|
65
|
+
plan.site.checked
|
|
66
|
+
? `\nsite schema: ${plan.site.action} (${plan.site.classification})`
|
|
67
|
+
: '\nsite schema: checked once the framework migrations have run',
|
|
68
|
+
)
|
|
69
|
+
return {
|
|
70
|
+
action: 'plan',
|
|
71
|
+
changed: false,
|
|
72
|
+
backup: null,
|
|
73
|
+
frameworkApplied: [],
|
|
74
|
+
bundlesApplied: [],
|
|
75
|
+
schema: null,
|
|
76
|
+
plan,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const result = await ops.upgradeRun(config, {
|
|
80
|
+
force: flags.force,
|
|
81
|
+
set: parseSet(flags.set),
|
|
82
|
+
backupTaken: flags['backup-taken'],
|
|
83
|
+
})
|
|
84
|
+
if (result.backup.path) this.progress(` backup ${result.backup.path}`)
|
|
85
|
+
if (result.action === 'refused')
|
|
86
|
+
this.fail(
|
|
87
|
+
'refused',
|
|
88
|
+
`upgrade refused: ${result.reasons.length} outstanding finding(s).`,
|
|
89
|
+
'Run `crombolo upgrade check --fix`, resolve what needs a person, and try again.',
|
|
90
|
+
findingsOf(result.reasons),
|
|
91
|
+
)
|
|
92
|
+
if (result.action === 'nothing') {
|
|
93
|
+
this.say('Nothing to upgrade: the database is at this version.')
|
|
94
|
+
return {
|
|
95
|
+
action: result.action,
|
|
96
|
+
changed: false,
|
|
97
|
+
backup: result.backup.path ?? null,
|
|
98
|
+
frameworkApplied: [],
|
|
99
|
+
bundlesApplied: [],
|
|
100
|
+
schema: null,
|
|
101
|
+
plan: null,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const name of result.frameworkApplied) this.say(` applied ${name}`)
|
|
105
|
+
for (const bundle of result.bundlesApplied)
|
|
106
|
+
for (const name of bundle.applied) this.say(` applied [${bundle.id}] ${name}`)
|
|
107
|
+
this.say(
|
|
108
|
+
` schema ${result.schema?.action} ${result.schema?.version ?? ''}+${result.schema?.revision ?? ''}`,
|
|
109
|
+
)
|
|
110
|
+
return {
|
|
111
|
+
action: result.action,
|
|
112
|
+
changed: true,
|
|
113
|
+
backup: result.backup.path ?? null,
|
|
114
|
+
frameworkApplied: result.frameworkApplied,
|
|
115
|
+
bundlesApplied: result.bundlesApplied,
|
|
116
|
+
schema: result.schema ?? null,
|
|
117
|
+
plan: null,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { connect, readLedger } from '@crombolo/data'
|
|
2
|
+
import { BaseCommand } from '../../base.ts'
|
|
3
|
+
import { page, pageFlags, pagination } from '../../support/flags.ts'
|
|
4
|
+
import { siteConfig } from '../../support/site.ts'
|
|
5
|
+
|
|
6
|
+
export default class UpgradeLedger extends BaseCommand {
|
|
7
|
+
static override summary = 'print migration_history: every migration, sync and import applied here'
|
|
8
|
+
static override flags = { ...pageFlags(1000) }
|
|
9
|
+
static override spec = {
|
|
10
|
+
effects: 'read_only',
|
|
11
|
+
cardinality: 'unbounded',
|
|
12
|
+
pagination,
|
|
13
|
+
fieldsArg: '--fields',
|
|
14
|
+
outputFields: [
|
|
15
|
+
{ name: 'id', type: 'integer' },
|
|
16
|
+
{ name: 'name', type: 'string' },
|
|
17
|
+
{ name: 'kind', type: 'string' },
|
|
18
|
+
{ name: 'appliedAt', type: 'string' },
|
|
19
|
+
{ name: 'durationMs', type: 'integer' },
|
|
20
|
+
{ name: 'appliedBy', type: 'string', nullable: true },
|
|
21
|
+
{ name: 'bundle', type: 'string', nullable: true },
|
|
22
|
+
{ name: 'note', type: 'string', nullable: true },
|
|
23
|
+
],
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
async run() {
|
|
27
|
+
const { flags } = await this.parse(UpgradeLedger)
|
|
28
|
+
const config = await siteConfig()
|
|
29
|
+
const db = await connect({ file: config.sqliteFile })
|
|
30
|
+
try {
|
|
31
|
+
const rows = await readLedger(db)
|
|
32
|
+
const items = rows.slice(flags.offset, flags.offset + flags.limit).map((row) => ({
|
|
33
|
+
id: row.id,
|
|
34
|
+
name: row.name,
|
|
35
|
+
kind: row.kind,
|
|
36
|
+
appliedAt: row.appliedAt,
|
|
37
|
+
durationMs: row.durationMs,
|
|
38
|
+
appliedBy: row.appliedBy ?? null,
|
|
39
|
+
bundle: row.bundle ?? null,
|
|
40
|
+
note: row.note ?? null,
|
|
41
|
+
}))
|
|
42
|
+
for (const row of items)
|
|
43
|
+
this.say(
|
|
44
|
+
` ${row.appliedAt} ${row.kind.padEnd(8)} ${row.bundle ? `[${row.bundle}] ` : ''}${row.name} ${row.durationMs}ms${row.appliedBy ? ` by ${row.appliedBy}` : ''}${row.note ? ` (${row.note})` : ''}`,
|
|
45
|
+
)
|
|
46
|
+
if (flags.offset + items.length < rows.length)
|
|
47
|
+
this.say(
|
|
48
|
+
` … ${rows.length - flags.offset - items.length} more: --offset ${flags.offset + items.length}`,
|
|
49
|
+
)
|
|
50
|
+
return page(items, rows.length, flags)
|
|
51
|
+
} finally {
|
|
52
|
+
await db.close()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core'
|
|
2
|
+
import { BaseCommand } from '../../base.ts'
|
|
3
|
+
import { checkStatus, describeUser, OPERATOR, openPeople, userNamed } from '../../support/people.ts'
|
|
4
|
+
import { readStdin } from '../../support/prompt.ts'
|
|
5
|
+
import { siteConfig } from '../../support/site.ts'
|
|
6
|
+
|
|
7
|
+
export default class UsersResetPassword extends BaseCommand {
|
|
8
|
+
static override summary = 'give a user a new password, and end their sessions'
|
|
9
|
+
static override description =
|
|
10
|
+
"A new one is generated and printed, unless --stdin gives it. `admin reset-password` does the same for the site's own administrator."
|
|
11
|
+
static override examples = [
|
|
12
|
+
'<%= config.bin %> users reset-password editor@example.com',
|
|
13
|
+
'printf %s "$PASSWORD" | <%= config.bin %> users reset-password editor@example.com --stdin',
|
|
14
|
+
]
|
|
15
|
+
static override args = {
|
|
16
|
+
user: Args.string({ required: true, description: 'their e-mail address or username' }),
|
|
17
|
+
}
|
|
18
|
+
static override flags = { stdin: Flags.boolean({ summary: 'read the new password from stdin' }) }
|
|
19
|
+
static override audit = { category: 'user', action: 'ResetPassword', entityType: 'User' } as const
|
|
20
|
+
static override spec = {
|
|
21
|
+
effects: 'non_idempotent',
|
|
22
|
+
cardinality: 'single',
|
|
23
|
+
outputFields: [
|
|
24
|
+
{ name: 'id', type: 'string' },
|
|
25
|
+
{ name: 'email', type: 'string' },
|
|
26
|
+
{
|
|
27
|
+
name: 'password',
|
|
28
|
+
type: 'string',
|
|
29
|
+
nullable: true,
|
|
30
|
+
description: 'the generated one; null when given',
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
errors: ['not_found', 'usage', 'refused'],
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
async run() {
|
|
37
|
+
const { args, flags } = await this.parse(UsersResetPassword)
|
|
38
|
+
const given = flags.stdin ? await readStdin() : undefined
|
|
39
|
+
if (flags.stdin && !given) this.fail('usage', 'Nothing came on stdin to use as the password.')
|
|
40
|
+
const people = await openPeople(await siteConfig())
|
|
41
|
+
try {
|
|
42
|
+
const user = await userNamed(people, args.user)
|
|
43
|
+
this.auditKey = user.key
|
|
44
|
+
let password: string | null = null
|
|
45
|
+
if (given) checkStatus(await people.users.changePassword(OPERATOR, user.key, given, null))
|
|
46
|
+
else {
|
|
47
|
+
const result = await people.users.resetPassword(OPERATOR, user.key)
|
|
48
|
+
checkStatus(result.status)
|
|
49
|
+
password = result.status === 'Success' ? result.value : null
|
|
50
|
+
}
|
|
51
|
+
this.say(` reset ${describeUser(user)}; their sessions are ended`)
|
|
52
|
+
if (password) this.say(password)
|
|
53
|
+
return { id: user.key, email: user.email, password }
|
|
54
|
+
} finally {
|
|
55
|
+
await people.db.close()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|