@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,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
|
+
}
|
|
@@ -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 UsersUnlock extends BaseCommand {
|
|
14
|
+
static override summary = "clear a user's lockout and failed sign-ins"
|
|
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: 'Unlock', 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(UsersUnlock)
|
|
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.unlock(OPERATOR, [user.key]))
|
|
45
|
+
const after = await snapshot()
|
|
46
|
+
const state = after.state
|
|
47
|
+
const changed = before.state !== after.state || before.failed !== after.failed
|
|
48
|
+
this.say(` unlocked ${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
|
+
}
|
package/src/load.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's commands, with the optional plugins found beside it (story 158).
|
|
3
|
+
*
|
|
4
|
+
* oclif loads a plugin only when the CLI's own `package.json` depends on it,
|
|
5
|
+
* and warns when one is missing; an optional plugin is neither. So each name in
|
|
6
|
+
* `oclif.optionalPlugins` is looked for here — in the site's `node_modules`,
|
|
7
|
+
* then beside the CLI, as a global install puts it — loaded when it is found,
|
|
8
|
+
* and passed over in silence when it is not.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import { Config, Plugin } from '@oclif/core'
|
|
13
|
+
import { CLI_DIR } from './manifest.ts'
|
|
14
|
+
|
|
15
|
+
/** The directory a package is installed in, looked for from each of `from` upward. */
|
|
16
|
+
export function findInstalled(name: string, from: readonly string[]): string | undefined {
|
|
17
|
+
for (const start of from) {
|
|
18
|
+
let dir = start
|
|
19
|
+
for (;;) {
|
|
20
|
+
const candidate = join(dir, 'node_modules', name)
|
|
21
|
+
if (existsSync(join(candidate, 'package.json'))) return candidate
|
|
22
|
+
const parent = dirname(dir)
|
|
23
|
+
if (parent === dir) break
|
|
24
|
+
dir = parent
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function optionalPlugins(): string[] {
|
|
31
|
+
const pjson = JSON.parse(readFileSync(join(CLI_DIR, 'package.json'), 'utf8')) as {
|
|
32
|
+
oclif?: { optionalPlugins?: string[] }
|
|
33
|
+
}
|
|
34
|
+
return pjson.oclif?.optionalPlugins ?? []
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LoadedCli {
|
|
38
|
+
config: Config
|
|
39
|
+
/** The optional plugins that are not installed. */
|
|
40
|
+
missing: string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function loadCli(cwd: string = process.cwd()): Promise<LoadedCli> {
|
|
44
|
+
const root = new Plugin({ root: CLI_DIR, isRoot: true, type: 'core' })
|
|
45
|
+
await root.load()
|
|
46
|
+
const plugins = new Map<string, Plugin>([[root.name, root]])
|
|
47
|
+
const missing: string[] = []
|
|
48
|
+
for (const name of optionalPlugins()) {
|
|
49
|
+
const dir = findInstalled(name, [cwd, CLI_DIR])
|
|
50
|
+
if (!dir) {
|
|
51
|
+
missing.push(name)
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
const plugin = new Plugin({ root: dir, name, type: 'core' })
|
|
55
|
+
await plugin.load()
|
|
56
|
+
plugins.set(plugin.name, plugin)
|
|
57
|
+
}
|
|
58
|
+
return { config: await Config.load({ root: CLI_DIR, plugins }), missing }
|
|
59
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -5,15 +5,33 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { Errors, flush, run } from '@oclif/core'
|
|
7
7
|
import { wantsJson } from './base.ts'
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { CliError, exitCodeOf } from './errors.ts'
|
|
9
|
+
import { loadCli } from './load.ts'
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/** The topic each optional plugin brings, and how to add it. */
|
|
12
|
+
const PLUGIN_TOPICS: Record<string, { topic: string; what: string }> = {
|
|
13
|
+
'@crombolo/cloud': { topic: 'cloud', what: 'Crombolo Cloud' },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
|
12
17
|
try {
|
|
13
|
-
await
|
|
18
|
+
const { config, missing } = await loadCli()
|
|
19
|
+
const absent = missing
|
|
20
|
+
.map((name) => ({ name, ...PLUGIN_TOPICS[name] }))
|
|
21
|
+
.find((plugin) => plugin.topic && argv[0] === plugin.topic)
|
|
22
|
+
if (absent)
|
|
23
|
+
throw new CliError(
|
|
24
|
+
'usage',
|
|
25
|
+
`\`crombolo ${absent.topic}\` comes with the ${absent.what} plugin, which is not installed.`,
|
|
26
|
+
{
|
|
27
|
+
hint: `Add it to the site: bun add -d ${absent.name}`,
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
await run(argv, config)
|
|
14
31
|
await flush()
|
|
15
32
|
} catch (error) {
|
|
16
33
|
if (error instanceof Errors.ExitError) process.exit(error.oclif.exit ?? 0)
|
|
34
|
+
const { classify, reportFailure } = await import('./classify.ts')
|
|
17
35
|
const failure = classify(error)
|
|
18
36
|
if (failure.kind === 'usage' && !failure.hint)
|
|
19
37
|
Object.assign(failure, { hint: '`crombolo --help` lists the commands.' })
|
package/src/manifest.ts
CHANGED
|
@@ -18,3 +18,13 @@ export async function buildManifest(): Promise<Interfaces.Manifest> {
|
|
|
18
18
|
// Through JSON, as oclif reads it back: each command's `load` is dropped.
|
|
19
19
|
return JSON.parse(JSON.stringify({ commands, version: config.version })) as Interfaces.Manifest
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
/** The manifest's shape for any loaded config: the CLI's commands and its plugins'. */
|
|
23
|
+
export function manifestOf(config: Config): Interfaces.Manifest {
|
|
24
|
+
const commands = Object.fromEntries(
|
|
25
|
+
config.commands
|
|
26
|
+
.map((command) => [command.id, command] as const)
|
|
27
|
+
.sort(([a], [b]) => a.localeCompare(b)),
|
|
28
|
+
)
|
|
29
|
+
return JSON.parse(JSON.stringify({ commands, version: config.version })) as Interfaces.Manifest
|
|
30
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@crombolo/cli/plugin`: what a plugin's commands are built from (story 158),
|
|
3
|
+
* so that they behave like the CLI's own — the global flags, the JSON envelope,
|
|
4
|
+
* the exit codes, an entry in `crombolo schema` — and sign in where `crombolo
|
|
5
|
+
* login` does. Covered by semver like the main entry. It loads none of the
|
|
6
|
+
* CLI's commands, and none of the site packages until a command fails.
|
|
7
|
+
*
|
|
8
|
+
* import { Args, BaseCommand, Flags } from '@crombolo/cli/plugin'
|
|
9
|
+
*
|
|
10
|
+
* export default class CloudStatus extends BaseCommand {
|
|
11
|
+
* static override summary = 'what the platform says about this site'
|
|
12
|
+
* static override spec = { effects: 'read_only', cardinality: 'single', outputFields: [...] } as const
|
|
13
|
+
* async run() { … return result }
|
|
14
|
+
* }
|
|
15
|
+
*/
|
|
16
|
+
export { Args, Flags } from '@oclif/core'
|
|
17
|
+
export type { AuditSpec, CommandSpec, OutputField } from './base.ts'
|
|
18
|
+
export { BaseCommand } from './base.ts'
|
|
19
|
+
export {
|
|
20
|
+
CliError,
|
|
21
|
+
ERROR_KINDS,
|
|
22
|
+
type ErrorKind,
|
|
23
|
+
exitCodeOf,
|
|
24
|
+
OUTCOMES,
|
|
25
|
+
type OutcomeName,
|
|
26
|
+
} from './errors.ts'
|
|
27
|
+
export {
|
|
28
|
+
type Discovery,
|
|
29
|
+
discover,
|
|
30
|
+
type Job,
|
|
31
|
+
problemOf,
|
|
32
|
+
RemoteClient,
|
|
33
|
+
RemoteError,
|
|
34
|
+
signIn,
|
|
35
|
+
TimedOut,
|
|
36
|
+
} from './remote/client.ts'
|
|
37
|
+
export { keychain, openInBrowser } from './support/remote.ts'
|
|
38
|
+
export type { Io } from './support/site.ts'
|
package/src/remote/client.ts
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createHash } from 'node:crypto'
|
|
8
8
|
import { createWriteStream } from 'node:fs'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
freshAccessToken,
|
|
11
|
+
type Keychain,
|
|
12
|
+
readSignIn,
|
|
13
|
+
saveSignIn,
|
|
14
|
+
} from '@crombolo/server/remote-credentials'
|
|
10
15
|
|
|
11
16
|
export const PROTOCOL_VERSION = '1'
|
|
12
17
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync, readFileSync } from 'node:fs'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
|
-
import { writeFileAtomic } from '@crombolo/server'
|
|
11
|
+
import { writeFileAtomic } from '@crombolo/server/atomic-write'
|
|
12
12
|
|
|
13
13
|
export const REMOTES_FILE = 'remotes.toml'
|
|
14
14
|
|
|
@@ -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
|
+
}
|
package/src/support/remote.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/** What the remote commands share: the remote a command names, and the browser it opens. */
|
|
2
|
-
import { osKeychain } from '@crombolo/server'
|
|
2
|
+
import { osKeychain } from '@crombolo/server/remote-credentials'
|
|
3
3
|
import { CliError } from '../errors.ts'
|
|
4
4
|
import { discover, RemoteClient } from '../remote/client.ts'
|
|
5
5
|
import { pickRemote, readRemotes } from '../remote/remotes-file.ts'
|
|
6
|
-
import { cwd } from './site.ts'
|
|
7
6
|
|
|
8
7
|
export const keychain = () => osKeychain()
|
|
9
8
|
|
|
@@ -24,9 +23,9 @@ export function openInBrowser(url: string, disabled: boolean): void {
|
|
|
24
23
|
|
|
25
24
|
/** The remote a command names, signed in or not, with the environment it goes to. */
|
|
26
25
|
export async function connectRemote(name: string | undefined, env: string | undefined) {
|
|
27
|
-
const entry = pickRemote(cwd(), name)
|
|
26
|
+
const entry = pickRemote(process.cwd(), name)
|
|
28
27
|
if (!entry) {
|
|
29
|
-
const known = readRemotes(cwd())
|
|
28
|
+
const known = readRemotes(process.cwd())
|
|
30
29
|
if (known.length === 0)
|
|
31
30
|
throw new CliError('not_found', 'No remotes yet.', {
|
|
32
31
|
hint: '`crombolo remote add <name> <url>` adds one.',
|