@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/cli",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "The crombolo command line: scaffolding a site, schema, views, domains, content transfer and upgrades.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -19,7 +19,8 @@
19
19
  "type": "module",
20
20
  "exports": {
21
21
  ".": "./src/index.ts",
22
- "./bin": "./bin/crombolo.ts"
22
+ "./bin": "./bin/crombolo.ts",
23
+ "./plugin": "./src/plugin.ts"
23
24
  },
24
25
  "bin": {
25
26
  "crombolo": "./bin/crombolo.ts"
@@ -35,12 +36,13 @@
35
36
  "access": "public"
36
37
  },
37
38
  "dependencies": {
38
- "@crombolo/auth": "0.15.0",
39
- "@crombolo/core": "0.15.0",
40
- "@crombolo/data": "0.15.0",
41
- "@crombolo/schema": "0.15.0",
42
- "@crombolo/server": "0.15.0",
43
- "@crombolo/transfer": "0.15.0",
39
+ "@crombolo/api-management": "0.16.0",
40
+ "@crombolo/auth": "0.16.0",
41
+ "@crombolo/core": "0.16.0",
42
+ "@crombolo/data": "0.16.0",
43
+ "@crombolo/schema": "0.16.0",
44
+ "@crombolo/server": "0.16.0",
45
+ "@crombolo/transfer": "0.16.0",
44
46
  "@oclif/core": "^5.1.8"
45
47
  },
46
48
  "oclif": {
@@ -79,7 +81,7 @@
79
81
  "description": "pause and resume editors' saves, cluster-wide"
80
82
  },
81
83
  "media": {
82
- "description": "a pulled site's media files"
84
+ "description": "the media library in bulk: import a folder, export one; a pulled site's files"
83
85
  },
84
86
  "remote": {
85
87
  "description": "the remotes this site pulls from and pushes to (remotes.toml)"
@@ -89,7 +91,25 @@
89
91
  },
90
92
  "upgrade": {
91
93
  "description": "framework migrations: check, plan, apply, ledger"
94
+ },
95
+ "users": {
96
+ "description": "the back-office users: unlock, enable, reset a password or a second factor, invite"
97
+ },
98
+ "api-client": {
99
+ "description": "the API client a site seeds from its environment"
100
+ },
101
+ "index": {
102
+ "description": "the search indexes: what they hold, and rebuilding them"
103
+ },
104
+ "cache": {
105
+ "description": "the published cache on every node"
106
+ },
107
+ "recycle-bin": {
108
+ "description": "what has been deleted, before it is gone for good"
92
109
  }
93
- }
110
+ },
111
+ "optionalPlugins": [
112
+ "@crombolo/cloud"
113
+ ]
94
114
  }
95
115
  }
package/src/base.ts CHANGED
@@ -5,11 +5,9 @@
5
5
  * result is the output, and in text mode the command says it as it goes.
6
6
  */
7
7
  import { Command, Errors, Flags } from '@oclif/core'
8
- import { classify, reportFailure } from './classify.ts'
9
8
  import { CliError, type ErrorKind, exitCodeOf, OUTCOMES, type OutcomeName } from './errors.ts'
10
- import * as ops from './operations/commands.ts'
9
+ import type * as ops from './operations/commands.ts'
11
10
  import type { Io } from './support/site.ts'
12
- import { siteConfig } from './support/site.ts'
13
11
 
14
12
  /** A field of one output record, in clispec's closed set of types. */
15
13
  export interface OutputField {
@@ -107,6 +105,9 @@ export abstract class BaseCommand extends Command implements Io {
107
105
 
108
106
  protected override async catch(error: Error): Promise<never> {
109
107
  if (error instanceof Errors.ExitError) throw error
108
+ // Loaded when something fails, so a command — or a plugin's — that succeeds
109
+ // never pays for the site packages it knows how to name.
110
+ const { classify, reportFailure } = await import('./classify.ts')
110
111
  const failure = classify(error)
111
112
  reportFailure(failure, this.jsonEnabled())
112
113
  throw new Errors.ExitError(exitCodeOf(failure.kind))
@@ -115,7 +116,11 @@ export abstract class BaseCommand extends Command implements Io {
115
116
  protected override async finally(error: Error | undefined): Promise<void> {
116
117
  const audit = (this.constructor as typeof BaseCommand).audit
117
118
  if (error || !audit || this.changedNothing) return
118
- await ops.recordCommand(await siteConfig(), {
119
+ const [{ recordCommand }, { siteConfig }] = await Promise.all([
120
+ import('./operations/commands.ts'),
121
+ import('./support/site.ts'),
122
+ ])
123
+ await recordCommand(await siteConfig(), {
119
124
  ...audit,
120
125
  command: `crombolo ${(this.id ?? '').replaceAll(':', ' ')}`,
121
126
  entityKey: this.auditKey,
@@ -0,0 +1,57 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { hashPassword } from '@crombolo/auth'
3
+ import { UserRepository } from '@crombolo/data'
4
+ import { bootstrapDatabase } from '@crombolo/server'
5
+ import { Flags } from '@oclif/core'
6
+ import { BaseCommand } from '../../base.ts'
7
+ import { siteConfig } from '../../support/site.ts'
8
+
9
+ /** A new secret for the API client a site seeds from its environment (story 034). */
10
+ export default class ApiClientRotate extends BaseCommand {
11
+ static override summary = 'issue a new secret for the API client, and print it once'
12
+ static override description =
13
+ 'The old secret stops working at once. Put the new one in CROMBOLO_API_CLIENT_SECRET: a start keeps the client it finds, so it never sets the secret back.'
14
+ static override flags = {
15
+ client: Flags.string({
16
+ summary: 'the client id; CROMBOLO_API_CLIENT_ID when not given',
17
+ helpValue: '<id>',
18
+ }),
19
+ }
20
+ static override audit = {
21
+ category: 'user',
22
+ action: 'RotateClientSecret',
23
+ entityType: 'User',
24
+ } as const
25
+ static override spec = {
26
+ effects: 'non_idempotent',
27
+ cardinality: 'single',
28
+ outputFields: [
29
+ { name: 'client', type: 'string' },
30
+ { name: 'secret', type: 'string' },
31
+ ],
32
+ errors: ['usage', 'not_found'],
33
+ } as const
34
+
35
+ async run() {
36
+ const { flags } = await this.parse(ApiClientRotate)
37
+ const config = await siteConfig()
38
+ const client = flags.client ?? config.apiClient?.id
39
+ if (!client)
40
+ this.fail('usage', 'Which API client?', 'Pass --client <id>, or set CROMBOLO_API_CLIENT_ID.')
41
+ const { db } = await bootstrapDatabase(config)
42
+ try {
43
+ const users = new UserRepository(db)
44
+ const owner = await users.clientCredentialOwner(client)
45
+ if (!owner) this.fail('not_found', `There is no API client "${client}".`)
46
+ this.auditKey = client
47
+ const secret = randomBytes(32).toString('base64url')
48
+ await users.removeClientCredential(owner.userId, client)
49
+ await users.addClientCredential(owner.userId, client, await hashPassword(secret))
50
+ this.say(` rotated ${client}; put this in CROMBOLO_API_CLIENT_SECRET:`)
51
+ this.say(secret)
52
+ return { client, secret }
53
+ } finally {
54
+ await db.close()
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,56 @@
1
+ import { appendCacheInstruction, latestCacheInstructionId, liveNodes } from '@crombolo/data'
2
+ import { bootstrapDatabase } from '@crombolo/server'
3
+ import { BaseCommand } from '../../base.ts'
4
+ import { siteConfig } from '../../support/site.ts'
5
+
6
+ /**
7
+ * Each node keeps its own published cache, so this asks all of them, the way an
8
+ * editor's save does: a cache instruction every node acts on at its next poll.
9
+ */
10
+ export default class CacheRebuild extends BaseCommand {
11
+ static override summary = 'rebuild the published cache on every node of the site'
12
+ static override description =
13
+ 'Each node rebuilds at its next poll, within a few seconds. Nothing has to be reachable from here but the database.'
14
+ static override audit = {
15
+ category: 'system',
16
+ action: 'RebuildCache',
17
+ entityType: 'Cache',
18
+ } as const
19
+ static override spec = {
20
+ effects: 'idempotent',
21
+ cardinality: 'single',
22
+ outputFields: [
23
+ { name: 'instruction', type: 'integer' },
24
+ {
25
+ name: 'nodes',
26
+ type: 'array',
27
+ items: { type: 'string' },
28
+ description: 'the nodes seen in the last minute',
29
+ },
30
+ ],
31
+ } as const
32
+
33
+ async run() {
34
+ await this.parse(CacheRebuild)
35
+ const config = await siteConfig()
36
+ const { db } = await bootstrapDatabase(config)
37
+ try {
38
+ // Stamped as the command line, never as a node: a node skips its own.
39
+ await appendCacheInstruction(db, {
40
+ kind: 'content',
41
+ payload: { all: true, reason: 'crombolo cache rebuild' },
42
+ by: `cli:${config.nodeId}`,
43
+ })
44
+ const instruction = await latestCacheInstructionId(db)
45
+ const nodes = (await liveNodes(db, { seenWithinMs: 60_000 })).map((node) => node.nodeId)
46
+ this.say(
47
+ nodes.length > 0
48
+ ? ` asked ${nodes.length} node(s) to rebuild: ${nodes.join(', ')}`
49
+ : ' asked every node to rebuild; none has been seen in the last minute',
50
+ )
51
+ return { instruction, nodes }
52
+ } finally {
53
+ await db.close()
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,90 @@
1
+ import {
2
+ bootstrapDatabase,
3
+ createDiagnosticsPort,
4
+ mediaStoreFor,
5
+ resolveEmailPort,
6
+ } from '@crombolo/server'
7
+ import { Flags } from '@oclif/core'
8
+ import { BaseCommand } from '../base.ts'
9
+ import { siteConfig } from '../support/site.ts'
10
+
11
+ /** The checks the Settings section's Health Check dashboard runs, for a monitor or a deploy to gate on. */
12
+ export default class Health extends BaseCommand {
13
+ static override summary = "run the site's health checks"
14
+ static override description =
15
+ 'The same checks as Settings › Health Check. Exits 20 when a check fails; a warning is reported, not counted.'
16
+ static override flags = {
17
+ group: Flags.string({ summary: 'only this group of checks', helpValue: '<name>' }),
18
+ }
19
+ static override spec = {
20
+ effects: 'read_only',
21
+ cardinality: 'bounded',
22
+ outputFields: [
23
+ {
24
+ name: 'groups',
25
+ type: 'array',
26
+ items: {
27
+ type: 'object',
28
+ fields: [
29
+ { name: 'name', type: 'string' },
30
+ { name: 'checks', type: 'array', items: { type: 'object' } },
31
+ ],
32
+ },
33
+ },
34
+ { name: 'failed', type: 'integer' },
35
+ { name: 'warnings', type: 'integer' },
36
+ ],
37
+ errors: ['not_found'],
38
+ outcomes: ['problems_found'],
39
+ } as const
40
+
41
+ async run() {
42
+ const { flags } = await this.parse(Health)
43
+ const config = await siteConfig()
44
+ const { db } = await bootstrapDatabase(config)
45
+ try {
46
+ const diagnostics = createDiagnosticsPort({
47
+ config,
48
+ db,
49
+ media: await mediaStoreFor(config),
50
+ email: resolveEmailPort(config),
51
+ })
52
+ const groups = diagnostics.groups()
53
+ const chosen = flags.group
54
+ ? groups.filter((group) => group.name.toLowerCase() === flags.group?.toLowerCase())
55
+ : groups
56
+ if (chosen.length === 0)
57
+ this.fail(
58
+ 'not_found',
59
+ `There is no group "${flags.group}".`,
60
+ `The groups: ${groups.map((g) => g.name).join(', ')}.`,
61
+ )
62
+ let failed = 0
63
+ let warnings = 0
64
+ const report = []
65
+ for (const group of chosen) {
66
+ const results = (await diagnostics.run(group.name)) ?? []
67
+ const checks = group.checks.map((check) => {
68
+ const found = results.find((result) => result.id === check.id)?.results ?? []
69
+ for (const result of found) {
70
+ if (result.resultType === 'Error') failed += 1
71
+ if (result.resultType === 'Warning') warnings += 1
72
+ }
73
+ return {
74
+ name: check.name,
75
+ results: found.map((r) => ({ type: r.resultType, message: r.message })),
76
+ }
77
+ })
78
+ this.say(group.name)
79
+ for (const check of checks)
80
+ for (const result of check.results)
81
+ this.say(` [${result.type.padEnd(7)}] ${check.name}: ${result.message}`)
82
+ report.push({ name: group.name, checks })
83
+ }
84
+ if (failed > 0) this.outcome = 'problems_found'
85
+ return { groups: report, failed, warnings }
86
+ } finally {
87
+ await db.close()
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,45 @@
1
+ import { SEARCH_INDEXES, SearchIndex } from '@crombolo/data'
2
+ import { bootstrapDatabase } from '@crombolo/server'
3
+ import { BaseCommand } from '../../base.ts'
4
+ import { siteConfig } from '../../support/site.ts'
5
+
6
+ export default class IndexList extends BaseCommand {
7
+ static override summary = 'the search indexes: what each holds, and what is waiting to be indexed'
8
+ static override spec = {
9
+ effects: 'read_only',
10
+ cardinality: 'bounded',
11
+ outputFields: [
12
+ { name: 'name', type: 'string' },
13
+ { name: 'documents', type: 'integer' },
14
+ { name: 'fields', type: 'integer' },
15
+ { name: 'pending', type: 'integer' },
16
+ { name: 'healthy', type: 'boolean', description: 'nothing waiting to be indexed' },
17
+ ],
18
+ } as const
19
+
20
+ async run() {
21
+ await this.parse(IndexList)
22
+ const { db } = await bootstrapDatabase(await siteConfig())
23
+ try {
24
+ const search = new SearchIndex(db)
25
+ const items = []
26
+ for (const index of SEARCH_INDEXES) {
27
+ const stats = await search.stats(index)
28
+ items.push({
29
+ name: index.name,
30
+ documents: stats.documentCount,
31
+ fields: stats.fieldCount,
32
+ pending: stats.pending,
33
+ healthy: stats.pending === 0,
34
+ })
35
+ }
36
+ for (const index of items)
37
+ this.say(
38
+ ` ${index.name.padEnd(14)} ${index.documents} item(s), ${index.fields} field(s)${index.pending > 0 ? `, ${index.pending} waiting` : ''}`,
39
+ )
40
+ return { items }
41
+ } finally {
42
+ await db.close()
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,66 @@
1
+ import { SEARCH_INDEXES, SearchIndex } from '@crombolo/data'
2
+ import { bootstrapDatabase } from '@crombolo/server'
3
+ import { Args, Flags } from '@oclif/core'
4
+ import { BaseCommand } from '../../base.ts'
5
+ import { siteConfig } from '../../support/site.ts'
6
+
7
+ /**
8
+ * The index lives in the database, so rebuilding it here rebuilds it for every
9
+ * node: there is no copy per server to reach. Search keeps answering from the
10
+ * old entries while each item is indexed again.
11
+ */
12
+ export default class IndexRebuild extends BaseCommand {
13
+ static override summary = 'index every item again, for every node: the index is in the database'
14
+ static override description =
15
+ 'Search keeps answering while it runs. --queue-only queues everything and leaves the indexing to the running site.'
16
+ static override args = {
17
+ index: Args.string({
18
+ description: 'one index; both when not given',
19
+ options: SEARCH_INDEXES.map((index) => index.name),
20
+ }),
21
+ }
22
+ static override flags = {
23
+ 'queue-only': Flags.boolean({ summary: 'queue everything, and let the site index it' }),
24
+ }
25
+ static override audit = {
26
+ category: 'system',
27
+ action: 'RebuildIndex',
28
+ entityType: 'Index',
29
+ } as const
30
+ static override spec = {
31
+ effects: 'idempotent',
32
+ cardinality: 'single',
33
+ outputFields: [
34
+ { name: 'indexes', type: 'array', items: { type: 'string' } },
35
+ { name: 'indexed', type: 'integer', description: 'items indexed here; 0 with --queue-only' },
36
+ { name: 'queued', type: 'boolean' },
37
+ ],
38
+ } as const
39
+
40
+ async run() {
41
+ const { args, flags } = await this.parse(IndexRebuild)
42
+ const chosen = SEARCH_INDEXES.filter((index) => !args.index || index.name === args.index)
43
+ this.auditKey = args.index
44
+ const { db } = await bootstrapDatabase(await siteConfig())
45
+ try {
46
+ const search = new SearchIndex(db)
47
+ for (const index of chosen) await search.rebuild(index)
48
+ const names = chosen.map((index) => index.name)
49
+ if (flags['queue-only']) {
50
+ this.say(` queued ${names.join(', ')}: the running site indexes it`)
51
+ return { indexes: names, indexed: 0, queued: true }
52
+ }
53
+ let indexed = 0
54
+ for (;;) {
55
+ const batch = await search.indexQueued()
56
+ if (batch === 0) break
57
+ indexed += batch
58
+ this.progress(` indexed ${indexed}`)
59
+ }
60
+ this.say(` rebuilt ${names.join(', ')}: ${indexed} item(s) indexed`)
61
+ return { indexes: names, indexed, queued: false }
62
+ } finally {
63
+ await db.close()
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,91 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { extname, join, resolve } from 'node:path'
3
+ import type { NodeRow } from '@crombolo/data'
4
+ import { bootstrapDatabase, mediaFileOf, safeFileName } from '@crombolo/server'
5
+ import { Args, Flags } from '@oclif/core'
6
+ import { BaseCommand } from '../../base.ts'
7
+ import { here } from '../../support/describe.ts'
8
+ import { childrenByName, mediaParent, openMedia } from '../../support/media-tree.ts'
9
+ import { siteConfig } from '../../support/site.ts'
10
+
11
+ /** The media library, or a folder of it, back out as files in folders. */
12
+ export default class MediaExport extends BaseCommand {
13
+ static override summary = 'write media files back out as a folder tree'
14
+ static override examples = ['<%= config.bin %> media export /Gallery --out ./gallery']
15
+ static override args = {
16
+ folder: Args.string({
17
+ description: 'a media folder, as a path or uuid; everything when not given',
18
+ }),
19
+ }
20
+ static override flags = {
21
+ out: Flags.string({ summary: 'the folder to write into', helpValue: '<dir>', required: true }),
22
+ }
23
+ static override spec = {
24
+ effects: 'idempotent',
25
+ cardinality: 'bounded',
26
+ outputFields: [
27
+ { name: 'out', type: 'string' },
28
+ { name: 'files', type: 'integer' },
29
+ { name: 'bytes', type: 'integer' },
30
+ {
31
+ name: 'missing',
32
+ type: 'array',
33
+ items: { type: 'string' },
34
+ description: 'items whose file is not in the store',
35
+ },
36
+ ],
37
+ errors: ['not_found'],
38
+ } as const
39
+
40
+ async run() {
41
+ const { args, flags } = await this.parse(MediaExport)
42
+ const config = await siteConfig()
43
+ const { db } = await bootstrapDatabase(config)
44
+ try {
45
+ const tree = await openMedia(db, config)
46
+ const start = await mediaParent(db, args.folder)
47
+ const out = resolve(flags.out)
48
+ let files = 0
49
+ let bytes = 0
50
+ const missing: string[] = []
51
+
52
+ const write = async (node: NodeRow, dir: string) => {
53
+ const media = await tree.repo.byKey(node.key)
54
+ const src = media ? mediaFileOf(media) : null
55
+ const name = node.text ?? node.key
56
+ if (!src) {
57
+ // A folder, or an item with nothing uploaded: its children follow.
58
+ const children = await childrenByName(tree, node)
59
+ if (children.size === 0 && media) return
60
+ const into = join(dir, safeFileName(name))
61
+ for (const child of children.values()) await write(child, into)
62
+ return
63
+ }
64
+ const stored = await tree.files.open(src)
65
+ if (!stored) {
66
+ missing.push(name)
67
+ return
68
+ }
69
+ const extension = extname(src)
70
+ const fileName = safeFileName(
71
+ extname(name).toLowerCase() === extension.toLowerCase() ? name : `${name}${extension}`,
72
+ )
73
+ const content = await stored.bytes()
74
+ mkdirSync(dir, { recursive: true })
75
+ writeFileSync(join(dir, fileName), content)
76
+ files += 1
77
+ bytes += content.length
78
+ }
79
+
80
+ if (start) await write(start, out)
81
+ else for (const child of (await childrenByName(tree, null)).values()) await write(child, out)
82
+ mkdirSync(out, { recursive: true })
83
+ this.say(` wrote ${files} file(s) to ${here(out)}`)
84
+ for (const name of missing)
85
+ this.warning(` missing ${name}: its file is not in the media store`)
86
+ return { out: here(out), files, bytes, missing }
87
+ } finally {
88
+ await db.close()
89
+ }
90
+ }
91
+ }