@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,33 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { generateTypes, validateSchemaSet } from '@crombolo/schema'
|
|
4
|
+
import { formTypesFor } from '@crombolo/server'
|
|
5
|
+
import { BaseCommand } from '../base.ts'
|
|
6
|
+
import { here } from '../support/describe.ts'
|
|
7
|
+
import { siteConfig } from '../support/site.ts'
|
|
8
|
+
import { loadTypes, refuseProblems } from '../support/types.ts'
|
|
9
|
+
|
|
10
|
+
export default class Generate extends BaseCommand {
|
|
11
|
+
static override summary = 'TypeScript for the content types in schema/'
|
|
12
|
+
static override description = 'Writes schema/content-types.d.ts.'
|
|
13
|
+
static override spec = {
|
|
14
|
+
effects: 'idempotent',
|
|
15
|
+
cardinality: 'single',
|
|
16
|
+
outputFields: [{ name: 'wrote', type: 'string' }],
|
|
17
|
+
errors: ['refused'],
|
|
18
|
+
} as const
|
|
19
|
+
|
|
20
|
+
async run() {
|
|
21
|
+
await this.parse(Generate)
|
|
22
|
+
const config = await siteConfig()
|
|
23
|
+
const loaded = loadTypes(config)
|
|
24
|
+
refuseProblems([
|
|
25
|
+
...loaded.problems,
|
|
26
|
+
...validateSchemaSet(loaded.set, { types: formTypesFor(config) }),
|
|
27
|
+
])
|
|
28
|
+
const target = join(config.schemaDir, 'content-types.d.ts')
|
|
29
|
+
await writeFile(target, generateTypes(loaded.set))
|
|
30
|
+
this.say(` wrote ${here(target)}`)
|
|
31
|
+
return { wrote: here(target) }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -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,175 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
import { Args, Flags } from '@oclif/core'
|
|
3
|
+
import { BaseCommand } from '../../base.ts'
|
|
4
|
+
import * as ops from '../../operations/commands.ts'
|
|
5
|
+
import {
|
|
6
|
+
counted,
|
|
7
|
+
describeBundle,
|
|
8
|
+
describePlaced,
|
|
9
|
+
findingsOf,
|
|
10
|
+
here,
|
|
11
|
+
printTransferFindings,
|
|
12
|
+
} from '../../support/describe.ts'
|
|
13
|
+
import { backupTaken } from '../../support/flags.ts'
|
|
14
|
+
import { confirm, passphraseForPeople, passphraseToOpen } from '../../support/prompt.ts'
|
|
15
|
+
import { siteConfig } from '../../support/site.ts'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Replace this site with a whole-site file (story 105). It always says first
|
|
19
|
+
* what would be replaced, and changes nothing without `--replace` or
|
|
20
|
+
* `--replace-all`.
|
|
21
|
+
*/
|
|
22
|
+
export default class Import extends BaseCommand {
|
|
23
|
+
static override summary = 'replace this site with a whole-site file; says what first'
|
|
24
|
+
static override description = [
|
|
25
|
+
"Changes nothing without a mode. --replace recycles content and media the file lacks and keeps this site's people; --replace-all deletes them, and people the file lacks (typed confirmation, or --yes).",
|
|
26
|
+
'Domains, the secret key and settings always stay. Undone with `content revert <run>`.',
|
|
27
|
+
].join('\n\n')
|
|
28
|
+
static override examples = [
|
|
29
|
+
'<%= config.bin %> import site.tgz',
|
|
30
|
+
'<%= config.bin %> import site.tgz --replace',
|
|
31
|
+
]
|
|
32
|
+
static override args = {
|
|
33
|
+
file: Args.string({
|
|
34
|
+
required: true,
|
|
35
|
+
description: 'a whole-site file (crombolo export --site)',
|
|
36
|
+
}),
|
|
37
|
+
}
|
|
38
|
+
static override flags = {
|
|
39
|
+
replace: Flags.boolean({
|
|
40
|
+
summary: 'replace this site: what it loses is recycled, people kept',
|
|
41
|
+
}),
|
|
42
|
+
'replace-all': Flags.boolean({
|
|
43
|
+
summary: 'make it exactly the file: what it loses is deleted, people too',
|
|
44
|
+
}),
|
|
45
|
+
yes: Flags.boolean({ summary: "with --replace-all: don't ask" }),
|
|
46
|
+
'allow-missing-media': Flags.boolean({
|
|
47
|
+
summary: 'import media items whose files the file does not carry',
|
|
48
|
+
}),
|
|
49
|
+
'backup-taken': backupTaken,
|
|
50
|
+
}
|
|
51
|
+
static override audit = { category: 'content', action: 'Import', entityType: 'Bundle' } as const
|
|
52
|
+
static override spec = {
|
|
53
|
+
effects: 'non_idempotent',
|
|
54
|
+
cardinality: 'single',
|
|
55
|
+
confirmationBypassArg: '--yes',
|
|
56
|
+
outputFields: [
|
|
57
|
+
{ name: 'bundle', type: 'object' },
|
|
58
|
+
{ name: 'brings', type: 'object' },
|
|
59
|
+
{ name: 'leaving', type: 'object' },
|
|
60
|
+
{ name: 'filesRemoved', type: 'array', items: { type: 'string' } },
|
|
61
|
+
{ name: 'runId', type: 'string', nullable: true },
|
|
62
|
+
{ name: 'backup', type: 'string', nullable: true },
|
|
63
|
+
],
|
|
64
|
+
errors: ['usage', 'confirmation_required', 'refused', 'auth', 'not_found'],
|
|
65
|
+
} as const
|
|
66
|
+
|
|
67
|
+
async run() {
|
|
68
|
+
const { args, flags } = await this.parse(Import)
|
|
69
|
+
if (args.file === 'umbraco')
|
|
70
|
+
this.fail(
|
|
71
|
+
'usage',
|
|
72
|
+
'import umbraco needs report or apply.',
|
|
73
|
+
'`crombolo import umbraco --help` says more.',
|
|
74
|
+
)
|
|
75
|
+
const config = await siteConfig()
|
|
76
|
+
const replace: ops.ReplaceMode | undefined = flags['replace-all']
|
|
77
|
+
? 'replace-all'
|
|
78
|
+
: flags.replace
|
|
79
|
+
? 'replace'
|
|
80
|
+
: undefined
|
|
81
|
+
const dir = await ops.unpackBundle(args.file, config.siteDir)
|
|
82
|
+
const carriesPeople = ops.readBundle(dir, config.siteDir).set.manifest.people !== undefined
|
|
83
|
+
const passphrase = carriesPeople
|
|
84
|
+
? await passphraseForPeople()
|
|
85
|
+
: await passphraseToOpen(dir, config)
|
|
86
|
+
const allowMissingBlobs = flags['allow-missing-media']
|
|
87
|
+
|
|
88
|
+
const { check, summary } = await ops.siteReplaceCheck(config, {
|
|
89
|
+
bundle: dir,
|
|
90
|
+
passphrase,
|
|
91
|
+
replace: replace ?? 'replace',
|
|
92
|
+
allowMissingBlobs,
|
|
93
|
+
})
|
|
94
|
+
describeBundle(this, check.bundle)
|
|
95
|
+
const c = check.check.counts
|
|
96
|
+
this.say(` brings ${c.create} new, ${c.update} changed, ${c.unchanged} unchanged`)
|
|
97
|
+
const fate = replace === 'replace-all' ? 'deleted' : 'recycled'
|
|
98
|
+
if (Object.keys(summary.leaving).length > 0)
|
|
99
|
+
this.say(` ${fate.padEnd(8)} ${counted(summary.leaving)} not in the file`)
|
|
100
|
+
if (summary.filesRemoved.length > 0) {
|
|
101
|
+
this.say(
|
|
102
|
+
` removes ${summary.filesRemoved.length} file(s) not in the file, kept for a revert:`,
|
|
103
|
+
)
|
|
104
|
+
for (const path of summary.filesRemoved.slice(0, 20)) this.say(` ${here(path)}`)
|
|
105
|
+
if (summary.filesRemoved.length > 20)
|
|
106
|
+
this.say(` … and ${summary.filesRemoved.length - 20} more`)
|
|
107
|
+
}
|
|
108
|
+
if (replace === 'replace-all' && summary.people.members + summary.people.users > 0)
|
|
109
|
+
this.say(
|
|
110
|
+
` deletes ${summary.people.members} member(s) and ${summary.people.users} user(s) not in the file`,
|
|
111
|
+
)
|
|
112
|
+
this.say(" keeps domains, the secret key and this environment's settings")
|
|
113
|
+
for (const problem of check.problems) this.warning(` ${problem}`)
|
|
114
|
+
const result = {
|
|
115
|
+
bundle: check.bundle,
|
|
116
|
+
brings: c,
|
|
117
|
+
leaving: summary.leaving,
|
|
118
|
+
filesRemoved: summary.filesRemoved.map(here),
|
|
119
|
+
runId: null as string | null,
|
|
120
|
+
backup: null as string | null,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!replace)
|
|
124
|
+
this.fail(
|
|
125
|
+
'usage',
|
|
126
|
+
'Nothing was changed: a mode says how to replace this site.',
|
|
127
|
+
'Pass --replace (what it loses is recycled, people kept), or --replace-all to make it exactly the file (deleted, people too).',
|
|
128
|
+
)
|
|
129
|
+
if (replace === 'replace-all' && !flags.yes)
|
|
130
|
+
await confirm(
|
|
131
|
+
` This deletes for good. Type the site's name (${config.siteName}) to go on:`,
|
|
132
|
+
{
|
|
133
|
+
bypass: '--yes',
|
|
134
|
+
answer: config.siteName,
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const imported = await ops.contentImport(config, {
|
|
139
|
+
bundle: dir,
|
|
140
|
+
passphrase,
|
|
141
|
+
replace,
|
|
142
|
+
allowMissingBlobs,
|
|
143
|
+
label: `${replace} from ${basename(args.file)}`,
|
|
144
|
+
backupTaken: flags['backup-taken'],
|
|
145
|
+
})
|
|
146
|
+
for (const problem of imported.problems) this.warning(` ${problem}`)
|
|
147
|
+
if (imported.backup.path) this.progress(` backup ${imported.backup.path}`)
|
|
148
|
+
if (!imported.runId) {
|
|
149
|
+
printTransferFindings(this, imported.check)
|
|
150
|
+
this.fail(
|
|
151
|
+
'refused',
|
|
152
|
+
'replace refused. Nothing was changed.',
|
|
153
|
+
undefined,
|
|
154
|
+
findingsOf(imported.check.outstanding),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
const counts = imported.check.counts
|
|
158
|
+
this.say(` run ${imported.runId}`)
|
|
159
|
+
this.say(
|
|
160
|
+
` applied ${counts.create} created, ${counts.update} updated, ${counts.unchanged} unchanged`,
|
|
161
|
+
)
|
|
162
|
+
const placed = describePlaced(imported.blobs)
|
|
163
|
+
if (placed) this.say(` media ${placed}`)
|
|
164
|
+
if (imported.sections?.schema) this.say(` schema ${imported.sections.schema.action}`)
|
|
165
|
+
if (imported.published > 0) this.say(` published ${imported.published} page(s)`)
|
|
166
|
+
for (const failure of imported.publishFailures)
|
|
167
|
+
this.warning(` not published ${failure.key}: ${failure.reason}`)
|
|
168
|
+
this.say(
|
|
169
|
+
replace === 'replace-all'
|
|
170
|
+
? `\nDone. \`crombolo content revert ${imported.runId}\` puts the files and the pages it changed back; what was deleted is in the backup.`
|
|
171
|
+
: `\nDone. \`crombolo content revert ${imported.runId}\` puts it back.`,
|
|
172
|
+
)
|
|
173
|
+
return { ...result, runId: imported.runId, backup: imported.backup.path ?? null }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { mkdir } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { writeDomainsFile, writeUdtAll } from '@crombolo/server'
|
|
5
|
+
import { Flags } from '@oclif/core'
|
|
6
|
+
import { BaseCommand } from '../../../base.ts'
|
|
7
|
+
import { here } from '../../../support/describe.ts'
|
|
8
|
+
import { contentMode } from '../../../support/flags.ts'
|
|
9
|
+
import { PASSPHRASE_ENV, passphraseToSeal } from '../../../support/prompt.ts'
|
|
10
|
+
import {
|
|
11
|
+
deprecated,
|
|
12
|
+
loadImporter,
|
|
13
|
+
planUmbraco,
|
|
14
|
+
umbracoArgs,
|
|
15
|
+
umbracoFlags,
|
|
16
|
+
} from '../../../support/umbraco.ts'
|
|
17
|
+
import { scaffoldFiles } from '../../../templates.ts'
|
|
18
|
+
|
|
19
|
+
export default class ImportUmbracoApply extends BaseCommand {
|
|
20
|
+
static override summary = 'write a crombolo site from an Umbraco backup'
|
|
21
|
+
static override description =
|
|
22
|
+
'Schema, view stubs, media and a content bundle that `bun start` imports on first boot. Deprecated: story 074 replaces it.'
|
|
23
|
+
static override args = umbracoArgs
|
|
24
|
+
static override flags = {
|
|
25
|
+
...umbracoFlags,
|
|
26
|
+
out: Flags.string({
|
|
27
|
+
summary: 'the site directory to write',
|
|
28
|
+
helpValue: '<dir>',
|
|
29
|
+
required: true,
|
|
30
|
+
}),
|
|
31
|
+
postgres: Flags.boolean({ summary: 'configure Postgres' }),
|
|
32
|
+
force: Flags.boolean({ summary: 'write into a directory that is not empty' }),
|
|
33
|
+
}
|
|
34
|
+
static override spec = {
|
|
35
|
+
effects: 'non_idempotent',
|
|
36
|
+
cardinality: 'single',
|
|
37
|
+
outputFields: [
|
|
38
|
+
{ name: 'wrote', type: 'integer' },
|
|
39
|
+
{ name: 'out', type: 'string' },
|
|
40
|
+
{ name: 'report', type: 'string' },
|
|
41
|
+
{ name: 'sealed', type: 'boolean' },
|
|
42
|
+
],
|
|
43
|
+
errors: ['not_found', 'refused', 'conflict', 'auth'],
|
|
44
|
+
stability: 'deprecated',
|
|
45
|
+
} as const
|
|
46
|
+
|
|
47
|
+
async run() {
|
|
48
|
+
const { args, flags } = await this.parse(ImportUmbracoApply)
|
|
49
|
+
deprecated(this, this.jsonEnabled())
|
|
50
|
+
const target = flags.out
|
|
51
|
+
const importer = await loadImporter()
|
|
52
|
+
const content = contentMode(this, flags)
|
|
53
|
+
const sealing = content === 'sealed' || flags['with-members'] || flags['with-users']
|
|
54
|
+
const plan = await planUmbraco(
|
|
55
|
+
importer,
|
|
56
|
+
args.backup,
|
|
57
|
+
flags,
|
|
58
|
+
content,
|
|
59
|
+
sealing ? await passphraseToSeal() : undefined,
|
|
60
|
+
)
|
|
61
|
+
const { report } = plan
|
|
62
|
+
if (!report.ready)
|
|
63
|
+
this.fail(
|
|
64
|
+
'refused',
|
|
65
|
+
'Nothing was written: something blocks the import.',
|
|
66
|
+
undefined,
|
|
67
|
+
report.findings
|
|
68
|
+
.filter((f) => f.class === 'blocking')
|
|
69
|
+
.map((f) => `${f.title}${f.detail ? `: ${f.detail}` : ''}`),
|
|
70
|
+
)
|
|
71
|
+
if (
|
|
72
|
+
existsSync(target) &&
|
|
73
|
+
(await Array.fromAsync(new Bun.Glob('*').scan({ cwd: target, onlyFiles: false, dot: true })))
|
|
74
|
+
.length > 0 &&
|
|
75
|
+
!flags.force
|
|
76
|
+
)
|
|
77
|
+
this.fail(
|
|
78
|
+
'conflict',
|
|
79
|
+
`${target} is not empty.`,
|
|
80
|
+
'Choose a new directory, or pass --force to write into it.',
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const files = new Map<string, { text?: string; bytes?: Uint8Array; copyFrom?: string }>()
|
|
84
|
+
const scaffold = scaffoldFiles({
|
|
85
|
+
siteName: plan.siteName,
|
|
86
|
+
postgres: flags.postgres,
|
|
87
|
+
bundle: {
|
|
88
|
+
path: importer.IMPORT_BUNDLE,
|
|
89
|
+
// A media item whose file was not in the backup is reported, not refused.
|
|
90
|
+
flags: plan.missingMedia > 0 ? ['--allow-missing-blobs'] : [],
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
for (const file of scaffold) files.set(file.path, file)
|
|
94
|
+
// The importer's files win: the source site's media types replace the defaults.
|
|
95
|
+
for (const file of plan.files) files.set(file.path, file)
|
|
96
|
+
if (plan.dictionary.length > 0)
|
|
97
|
+
files.set(`${importer.IMPORT_DIR}/dictionary.udt`, { text: writeUdtAll(plan.dictionary) })
|
|
98
|
+
if (plan.domains.length > 0)
|
|
99
|
+
files.set(`${importer.IMPORT_DIR}/domains.toml`, { text: writeDomainsFile(plan.domains) })
|
|
100
|
+
|
|
101
|
+
for (const [path, file] of files) {
|
|
102
|
+
const destination = join(target, path)
|
|
103
|
+
await mkdir(join(destination, '..'), { recursive: true })
|
|
104
|
+
if (file.copyFrom !== undefined) await Bun.write(destination, Bun.file(file.copyFrom))
|
|
105
|
+
else await Bun.write(destination, file.bytes ?? file.text ?? '')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const reportFile = here(join(target, importer.IMPORT_DIR, 'report.md'))
|
|
109
|
+
for (const line of importer.reportSummary(report)) this.say(` ${line}`)
|
|
110
|
+
this.say(`\n wrote ${files.size} files to ${here(target)}`)
|
|
111
|
+
this.say(` report ${reportFile}`)
|
|
112
|
+
this.say(`\nNext:\n cd ${here(target)}\n bun install\n bun start`)
|
|
113
|
+
this.say(
|
|
114
|
+
`\n\`bun start\` imports ${importer.IMPORT_BUNDLE} on the first boot of an empty database.`,
|
|
115
|
+
)
|
|
116
|
+
if (sealing)
|
|
117
|
+
this.say(
|
|
118
|
+
`It is encrypted: \`bun start\` asks for the passphrase, or reads ${PASSPHRASE_ENV}.`,
|
|
119
|
+
)
|
|
120
|
+
if (content === 'omitted')
|
|
121
|
+
this.say(
|
|
122
|
+
'Pages and media were brought without what they hold. Run again with --include-content to bring it.',
|
|
123
|
+
)
|
|
124
|
+
if (plan.dictionary.length > 0)
|
|
125
|
+
this.say(`Then: crombolo dictionary import ${importer.IMPORT_DIR}/dictionary.udt`)
|
|
126
|
+
return { wrote: files.size, out: here(target), report: reportFile, sealed: sealing }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { Flags } from '@oclif/core'
|
|
4
|
+
import { BaseCommand } from '../../../base.ts'
|
|
5
|
+
import { here } from '../../../support/describe.ts'
|
|
6
|
+
import {
|
|
7
|
+
deprecated,
|
|
8
|
+
loadImporter,
|
|
9
|
+
planUmbraco,
|
|
10
|
+
umbracoArgs,
|
|
11
|
+
umbracoFlags,
|
|
12
|
+
} from '../../../support/umbraco.ts'
|
|
13
|
+
|
|
14
|
+
export default class ImportUmbracoReport extends BaseCommand {
|
|
15
|
+
static override summary =
|
|
16
|
+
'read-only: what importing an Umbraco backup would bring, and what it would not'
|
|
17
|
+
static override description =
|
|
18
|
+
'Packages, Razor and custom code are reported, not brought. Exits 21 when something blocks the import. Deprecated: story 074 replaces it.'
|
|
19
|
+
static override args = umbracoArgs
|
|
20
|
+
static override flags = {
|
|
21
|
+
...umbracoFlags,
|
|
22
|
+
out: Flags.string({ summary: 'also write report.md and report.json here', helpValue: '<dir>' }),
|
|
23
|
+
}
|
|
24
|
+
static override spec = {
|
|
25
|
+
effects: 'read_only',
|
|
26
|
+
cardinality: 'single',
|
|
27
|
+
stdoutSchema: { type: 'object', description: "the importer's report" },
|
|
28
|
+
errors: ['not_found', 'refused'],
|
|
29
|
+
outcomes: ['blocked'],
|
|
30
|
+
stability: 'deprecated',
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
async run() {
|
|
34
|
+
const { args, flags } = await this.parse(ImportUmbracoReport)
|
|
35
|
+
deprecated(this, this.jsonEnabled())
|
|
36
|
+
const importer = await loadImporter()
|
|
37
|
+
// A report writes no bundle, so what it would seal with is never kept.
|
|
38
|
+
const sealing = flags['with-members'] || flags['with-users']
|
|
39
|
+
const { report } = await planUmbraco(
|
|
40
|
+
importer,
|
|
41
|
+
args.backup,
|
|
42
|
+
flags,
|
|
43
|
+
flags['include-content'] ? 'open' : 'omitted',
|
|
44
|
+
sealing ? crypto.randomUUID() : undefined,
|
|
45
|
+
)
|
|
46
|
+
for (const line of importer.reportSummary(report)) this.say(` ${line}`)
|
|
47
|
+
if (flags.out) {
|
|
48
|
+
await mkdir(flags.out, { recursive: true })
|
|
49
|
+
await writeFile(join(flags.out, 'report.md'), importer.reportMarkdown(report))
|
|
50
|
+
await writeFile(join(flags.out, 'report.json'), importer.reportJson(report))
|
|
51
|
+
this.say(`\n wrote ${here(join(flags.out, 'report.md'))}`)
|
|
52
|
+
}
|
|
53
|
+
if (!report.ready) this.outcome = 'blocked'
|
|
54
|
+
return JSON.parse(importer.reportJson(report)) as Record<string, unknown>
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -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,76 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { Flags } from '@oclif/core'
|
|
5
|
+
import { BaseCommand } from '../base.ts'
|
|
6
|
+
import { cwd } from '../support/site.ts'
|
|
7
|
+
import { findTemplate, listTemplates, scaffoldFiles } from '../templates.ts'
|
|
8
|
+
|
|
9
|
+
export default class Init extends BaseCommand {
|
|
10
|
+
static override summary = 'scaffold a site in the current directory'
|
|
11
|
+
static override description =
|
|
12
|
+
'A --template brings schema, views and content; `--template list` prints the templates.'
|
|
13
|
+
static override examples = ['<%= config.bin %> init', '<%= config.bin %> init --template list']
|
|
14
|
+
static override flags = {
|
|
15
|
+
force: Flags.boolean({ summary: 'overwrite files that are already here' }),
|
|
16
|
+
name: Flags.string({ summary: "the site's name", helpValue: '<site name>' }),
|
|
17
|
+
postgres: Flags.boolean({ summary: 'configure Postgres rather than SQLite' }),
|
|
18
|
+
template: Flags.string({ summary: 'a starter template, or `list`', helpValue: '<id>' }),
|
|
19
|
+
}
|
|
20
|
+
static override spec = {
|
|
21
|
+
effects: 'non_idempotent',
|
|
22
|
+
cardinality: 'bounded',
|
|
23
|
+
outputFields: [
|
|
24
|
+
{ name: 'created', type: 'array', items: { type: 'string' } },
|
|
25
|
+
{ name: 'existing', type: 'array', items: { type: 'string' } },
|
|
26
|
+
{ name: 'templates', type: 'array', items: { type: 'object' } },
|
|
27
|
+
],
|
|
28
|
+
errors: ['not_found'],
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
async run() {
|
|
32
|
+
const { flags } = await this.parse(Init)
|
|
33
|
+
if (flags.template === 'list') {
|
|
34
|
+
const templates = listTemplates().map((t) => ({ id: t.id, description: t.description }))
|
|
35
|
+
for (const t of templates) this.say(` ${t.id.padEnd(20)} ${t.description}`)
|
|
36
|
+
this.say('\n (no --template scaffolds the files and no content)')
|
|
37
|
+
this.changedNothing = true
|
|
38
|
+
return { created: [], existing: [], templates }
|
|
39
|
+
}
|
|
40
|
+
const template = flags.template ? findTemplate(flags.template) : undefined
|
|
41
|
+
if (flags.template && !template)
|
|
42
|
+
this.fail(
|
|
43
|
+
'not_found',
|
|
44
|
+
`No template "${flags.template}".`,
|
|
45
|
+
'`crombolo init --template list` prints them.',
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const created: string[] = []
|
|
49
|
+
const existing: string[] = []
|
|
50
|
+
for (const file of scaffoldFiles({
|
|
51
|
+
siteName: flags.name,
|
|
52
|
+
postgres: flags.postgres,
|
|
53
|
+
template,
|
|
54
|
+
})) {
|
|
55
|
+
const target = join(cwd(), file.path)
|
|
56
|
+
if (existsSync(target) && !flags.force) {
|
|
57
|
+
existing.push(file.path)
|
|
58
|
+
this.say(` exists ${file.path}`)
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
await mkdir(join(target, '..'), { recursive: true })
|
|
62
|
+
if (file.copyFrom === undefined) await writeFile(target, file.text ?? '')
|
|
63
|
+
else await Bun.write(target, Bun.file(file.copyFrom))
|
|
64
|
+
created.push(file.path)
|
|
65
|
+
this.say(` created ${file.path}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.say('\nNext:\n bun install\n bun start')
|
|
69
|
+
if (template?.bundle)
|
|
70
|
+
this.say(
|
|
71
|
+
`\n\`bun start\` imports ${template.bundle} on the first boot of an empty database,\n` +
|
|
72
|
+
'and says so. Nothing happens on later boots; it is one line in package.json.',
|
|
73
|
+
)
|
|
74
|
+
return { created, existing, templates: [] }
|
|
75
|
+
}
|
|
76
|
+
}
|