@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.
@@ -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 { classify, reportFailure } from './classify.ts'
9
- import { exitCodeOf } from './errors.ts'
8
+ import { CliError, exitCodeOf } from './errors.ts'
9
+ import { loadCli } from './load.ts'
10
10
 
11
- export async function main(dir: string, argv: string[] = process.argv.slice(2)): Promise<void> {
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 run(argv, dir)
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'
@@ -6,7 +6,12 @@
6
6
  */
7
7
  import { createHash } from 'node:crypto'
8
8
  import { createWriteStream } from 'node:fs'
9
- import { freshAccessToken, type Keychain, readSignIn, saveSignIn } from '@crombolo/server'
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
+ }
@@ -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.',
@@ -6,7 +6,7 @@
6
6
  "content"
7
7
  ],
8
8
  "peerDependencies": {
9
- "@crombolo/crombolo": ">=0.15.0"
9
+ "@crombolo/crombolo": ">=0.16.0"
10
10
  },
11
11
  "crombolo": {
12
12
  "id": "basic",
@@ -6,7 +6,7 @@
6
6
  "content"
7
7
  ],
8
8
  "peerDependencies": {
9
- "@crombolo/crombolo": ">=0.15.0"
9
+ "@crombolo/crombolo": ">=0.16.0"
10
10
  },
11
11
  "crombolo": {
12
12
  "id": "demo-harbourstone",
@@ -6,7 +6,7 @@
6
6
  "content"
7
7
  ],
8
8
  "peerDependencies": {
9
- "@crombolo/crombolo": ">=0.15.0"
9
+ "@crombolo/crombolo": ">=0.16.0"
10
10
  },
11
11
  "crombolo": {
12
12
  "id": "starter",