@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/cli",
|
|
3
|
-
"version": "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/
|
|
39
|
-
"@crombolo/
|
|
40
|
-
"@crombolo/
|
|
41
|
-
"@crombolo/
|
|
42
|
-
"@crombolo/
|
|
43
|
-
"@crombolo/
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|