@crombolo/cli 0.14.1 → 0.15.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 -2767
- package/oclif.manifest.json +6710 -0
- package/package.json +58 -7
- package/src/base.ts +129 -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/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/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/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/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/fetch.ts +62 -0
- package/src/commands/pull.ts +210 -0
- package/src/commands/push.ts +108 -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/whoami.ts +41 -0
- package/src/errors.ts +90 -0
- package/src/main.ts +23 -0
- package/src/manifest.ts +20 -0
- package/src/reference.ts +118 -0
- package/src/remote/client.ts +297 -0
- package/src/remote/remotes-file.ts +81 -0
- package/src/remote/transfer.ts +353 -0
- 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/prompt.ts +126 -0
- package/src/support/publish.ts +142 -0
- package/src/support/remote.ts +58 -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,103 @@
|
|
|
1
|
+
import { siteStatus } from '@crombolo/server'
|
|
2
|
+
import { BaseCommand } from '../base.ts'
|
|
3
|
+
import { siteConfig } from '../support/site.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What this environment resolved to, and what is wrong with it, without
|
|
7
|
+
* starting it. Read-only by construction — it opens the database rather than
|
|
8
|
+
* bootstrapping it, so asking a production node how it is never migrates it.
|
|
9
|
+
*/
|
|
10
|
+
export default class Status extends BaseCommand {
|
|
11
|
+
static override summary =
|
|
12
|
+
'what this environment is, and what is wrong with it, without starting it'
|
|
13
|
+
static override description =
|
|
14
|
+
'Exits 20 when something would stop the site working; the problems are in the output.'
|
|
15
|
+
static override spec = {
|
|
16
|
+
effects: 'read_only',
|
|
17
|
+
cardinality: 'single',
|
|
18
|
+
outputFields: [
|
|
19
|
+
{ name: 'site', type: 'object' },
|
|
20
|
+
{ name: 'database', type: 'object' },
|
|
21
|
+
{ name: 'schema', type: 'object' },
|
|
22
|
+
{ name: 'framework', type: 'object' },
|
|
23
|
+
{ name: 'bundles', type: 'object' },
|
|
24
|
+
{ name: 'content', type: 'object' },
|
|
25
|
+
{ name: 'findings', type: 'object' },
|
|
26
|
+
{ name: 'email', type: 'object' },
|
|
27
|
+
{ name: 'versions', type: 'object' },
|
|
28
|
+
{ name: 'problems', type: 'array', items: { type: 'string' } },
|
|
29
|
+
],
|
|
30
|
+
outcomes: ['problems_found'],
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
async run() {
|
|
34
|
+
await this.parse(Status)
|
|
35
|
+
const report = await siteStatus(await siteConfig())
|
|
36
|
+
if (report.problems.length > 0) this.outcome = 'problems_found'
|
|
37
|
+
|
|
38
|
+
const line = (label: string, value: string) => this.say(` ${label.padEnd(10)} ${value}`)
|
|
39
|
+
line('site', `${report.site.name}${report.site.dir ? ` ${report.site.dir}` : ''}`)
|
|
40
|
+
line('components', `${report.site.components} snapshots in ${report.site.componentsCache}`)
|
|
41
|
+
line(
|
|
42
|
+
'database',
|
|
43
|
+
`${report.database.description} ${
|
|
44
|
+
report.database.reachable
|
|
45
|
+
? report.database.installed
|
|
46
|
+
? 'reachable'
|
|
47
|
+
: 'reachable, not installed'
|
|
48
|
+
: 'unreachable'
|
|
49
|
+
}`,
|
|
50
|
+
)
|
|
51
|
+
const files = report.schema.files ?? 'none'
|
|
52
|
+
const database = report.schema.database
|
|
53
|
+
? `${report.schema.database.version}+${report.schema.database.revision}`
|
|
54
|
+
: 'not installed'
|
|
55
|
+
line(
|
|
56
|
+
'schema',
|
|
57
|
+
`files ${files} database ${database} ${
|
|
58
|
+
!report.database.installed
|
|
59
|
+
? 'nothing applied yet'
|
|
60
|
+
: report.schema.compatibilityMode
|
|
61
|
+
? 'compatibility mode'
|
|
62
|
+
: report.schema.pending
|
|
63
|
+
? 'a deploy is pending'
|
|
64
|
+
: 'up to date'
|
|
65
|
+
}`,
|
|
66
|
+
)
|
|
67
|
+
const pending = report.framework.pending
|
|
68
|
+
line(
|
|
69
|
+
'framework',
|
|
70
|
+
pending.length === 0
|
|
71
|
+
? 'no migration pending'
|
|
72
|
+
: // The whole list is a wall of names on a fresh database; `upgrade --plan`
|
|
73
|
+
// is where somebody goes when they want them.
|
|
74
|
+
pending.length > 3
|
|
75
|
+
? `${pending.length} pending (\`crombolo upgrade --plan\` lists them)`
|
|
76
|
+
: `${pending.length} pending: ${pending.join(', ')}`,
|
|
77
|
+
)
|
|
78
|
+
line(
|
|
79
|
+
'content',
|
|
80
|
+
`${report.content.documents} document(s), ${report.content.media} media, ${report.content.elements} element(s)` +
|
|
81
|
+
`${report.content.lastImport ? `; last import ${report.content.lastImport}` : ''}`,
|
|
82
|
+
)
|
|
83
|
+
line(
|
|
84
|
+
'email',
|
|
85
|
+
report.email.available
|
|
86
|
+
? (report.email.description ?? 'configured')
|
|
87
|
+
: report.email.provider === 'log'
|
|
88
|
+
? 'console only, nothing is sent'
|
|
89
|
+
: `none — ${report.email.affects.join(', ')} unavailable`,
|
|
90
|
+
)
|
|
91
|
+
line(
|
|
92
|
+
'findings',
|
|
93
|
+
`${report.findings.blocking} blocking, ${report.findings.person} need a person, ` +
|
|
94
|
+
`${report.findings.auto} automatic, ${report.findings.resolved} resolved`,
|
|
95
|
+
)
|
|
96
|
+
line(
|
|
97
|
+
'versions',
|
|
98
|
+
`crombolo ${report.versions.crombolo}${report.versions.backoffice ? `, backoffice ${report.versions.backoffice}` : ''}, bun ${report.versions.bun}`,
|
|
99
|
+
)
|
|
100
|
+
for (const problem of report.problems) this.say(`\n ⚠ ${problem}`)
|
|
101
|
+
return report
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
addProperty,
|
|
6
|
+
BUILTIN_DATA_TYPES,
|
|
7
|
+
fileNameFor,
|
|
8
|
+
type SchemaContentType,
|
|
9
|
+
type SchemaTypeKind,
|
|
10
|
+
TYPE_KIND_FILES,
|
|
11
|
+
writeContentType,
|
|
12
|
+
} from '@crombolo/schema'
|
|
13
|
+
import { Args, Flags } from '@oclif/core'
|
|
14
|
+
import { BaseCommand } from '../../base.ts'
|
|
15
|
+
import { describeSync, here } from '../../support/describe.ts'
|
|
16
|
+
import { dryRun } from '../../support/flags.ts'
|
|
17
|
+
import { siteConfig } from '../../support/site.ts'
|
|
18
|
+
import { applySchema, loadTypes, needSchemaDir, refuseProblems } from '../../support/types.ts'
|
|
19
|
+
|
|
20
|
+
/** The repetitive edit: one property appended to a type, and applied. */
|
|
21
|
+
export default class TypesAddProperty extends BaseCommand {
|
|
22
|
+
static override summary = 'append a property to a type, and apply it'
|
|
23
|
+
static override description =
|
|
24
|
+
'The whole file is re-emitted from the model, so comments in it are not kept; --dry-run prints the file instead.'
|
|
25
|
+
static override examples = [
|
|
26
|
+
'<%= config.bin %> types add-property article summary --type textarea --mandatory',
|
|
27
|
+
]
|
|
28
|
+
static override aliases = ['schema:add-property']
|
|
29
|
+
static override deprecateAliases = true
|
|
30
|
+
static override args = {
|
|
31
|
+
type: Args.string({ required: true, description: "the type's alias" }),
|
|
32
|
+
alias: Args.string({ required: true, description: "the property's alias" }),
|
|
33
|
+
}
|
|
34
|
+
static override flags = {
|
|
35
|
+
type: Flags.string({ summary: 'the data type', helpValue: '<data type>', required: true }),
|
|
36
|
+
name: Flags.string({ summary: "the property's name", helpValue: '<name>' }),
|
|
37
|
+
description: Flags.string({ summary: "the property's description", helpValue: '<text>' }),
|
|
38
|
+
mandatory: Flags.boolean({ summary: 'an editor must fill it in' }),
|
|
39
|
+
tab: Flags.string({ summary: 'the tab it goes in', helpValue: '<tab>' }),
|
|
40
|
+
'dry-run': dryRun,
|
|
41
|
+
}
|
|
42
|
+
static override spec = {
|
|
43
|
+
effects: 'non_idempotent',
|
|
44
|
+
cardinality: 'single',
|
|
45
|
+
outputFields: [
|
|
46
|
+
{ name: 'file', type: 'string' },
|
|
47
|
+
{ name: 'property', type: 'object' },
|
|
48
|
+
{ name: 'tab', type: 'string' },
|
|
49
|
+
{ name: 'text', type: 'string', nullable: true },
|
|
50
|
+
{ name: 'schema', type: 'string', nullable: true },
|
|
51
|
+
],
|
|
52
|
+
errors: ['not_found', 'conflict', 'refused'],
|
|
53
|
+
} as const
|
|
54
|
+
|
|
55
|
+
async run() {
|
|
56
|
+
const { args, flags } = await this.parse(TypesAddProperty)
|
|
57
|
+
const config = await siteConfig()
|
|
58
|
+
needSchemaDir(config)
|
|
59
|
+
const loaded = loadTypes(config)
|
|
60
|
+
refuseProblems(loaded.problems)
|
|
61
|
+
const kinds: Array<[SchemaTypeKind, SchemaContentType[]]> = [
|
|
62
|
+
['document', loaded.set.contentTypes],
|
|
63
|
+
['media', loaded.set.mediaTypes ?? []],
|
|
64
|
+
['member', loaded.set.memberTypes ?? []],
|
|
65
|
+
]
|
|
66
|
+
const found = kinds
|
|
67
|
+
.map(([kind, types]) => ({ kind, type: types.find((t) => t.alias === args.type) }))
|
|
68
|
+
.find((candidate) => candidate.type)
|
|
69
|
+
if (!found?.type) this.fail('not_found', `No type "${args.type}" in ${here(config.schemaDir)}.`)
|
|
70
|
+
|
|
71
|
+
const known = new Set([
|
|
72
|
+
...BUILTIN_DATA_TYPES.map((d) => d.alias),
|
|
73
|
+
...loaded.set.dataTypes.map((d) => d.alias),
|
|
74
|
+
])
|
|
75
|
+
if (!known.has(flags.type))
|
|
76
|
+
this.fail(
|
|
77
|
+
'not_found',
|
|
78
|
+
`No data type "${flags.type}".`,
|
|
79
|
+
`Built in: ${[...known].sort().join(', ')}`,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const result = addProperty(found.type, {
|
|
83
|
+
alias: args.alias,
|
|
84
|
+
type: flags.type,
|
|
85
|
+
name: flags.name,
|
|
86
|
+
description: flags.description,
|
|
87
|
+
mandatory: flags.mandatory,
|
|
88
|
+
tab: flags.tab,
|
|
89
|
+
})
|
|
90
|
+
if (!result.ok) this.fail('conflict', result.reason)
|
|
91
|
+
|
|
92
|
+
const file = join(config.schemaDir, TYPE_KIND_FILES[found.kind].dir, fileNameFor(args.type))
|
|
93
|
+
const text = writeContentType(result.type, found.kind)
|
|
94
|
+
const property = {
|
|
95
|
+
alias: result.property.alias,
|
|
96
|
+
name: result.property.name,
|
|
97
|
+
type: flags.type,
|
|
98
|
+
mandatory: result.property.mandatory === true,
|
|
99
|
+
}
|
|
100
|
+
if (flags['dry-run']) {
|
|
101
|
+
this.changedNothing = true
|
|
102
|
+
this.say(text)
|
|
103
|
+
return { file: here(file), property, tab: result.tab, text, schema: null }
|
|
104
|
+
}
|
|
105
|
+
const before = existsSync(file) ? await Bun.file(file).text() : ''
|
|
106
|
+
await writeFile(file, text)
|
|
107
|
+
this.say(` wrote ${here(file)}`)
|
|
108
|
+
this.say(
|
|
109
|
+
` ${property.alias} ${property.name} (${flags.type}${property.mandatory ? ', mandatory' : ''}) in tab "${result.tab}"`,
|
|
110
|
+
)
|
|
111
|
+
if (/^\s*#/m.test(before))
|
|
112
|
+
this.say(' note the file is rewritten canonically, so its comments are not kept')
|
|
113
|
+
const report = await applySchema(this, config)
|
|
114
|
+
return { file: here(file), property, tab: result.tab, text: null, schema: describeSync(report) }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { loadValueMigrations, validateSchemaSet } from '@crombolo/schema'
|
|
2
|
+
import { componentAliasesIn, formTypesFor } from '@crombolo/server'
|
|
3
|
+
import { Flags } from '@oclif/core'
|
|
4
|
+
import { BaseCommand } from '../../base.ts'
|
|
5
|
+
import { describeProblem, findingsOf } from '../../support/describe.ts'
|
|
6
|
+
import { siteConfig } from '../../support/site.ts'
|
|
7
|
+
import { loadTypes, needSchemaDir, staticProblems, upgradeCheck } from '../../support/types.ts'
|
|
8
|
+
|
|
9
|
+
export default class TypesCheck extends BaseCommand {
|
|
10
|
+
static override summary = 'validate schema/ and report what a sync would do to this database'
|
|
11
|
+
static override description =
|
|
12
|
+
'--static checks the files alone, with no database: the check for a pull request. Exits 20 when it finds problems, 21 when a finding needs a person.'
|
|
13
|
+
static override aliases = ['schema:check']
|
|
14
|
+
static override deprecateAliases = true
|
|
15
|
+
static override flags = {
|
|
16
|
+
static: Flags.boolean({ summary: 'validate the files only, no database' }),
|
|
17
|
+
}
|
|
18
|
+
static override spec = {
|
|
19
|
+
effects: 'read_only',
|
|
20
|
+
cardinality: 'single',
|
|
21
|
+
outputFields: [
|
|
22
|
+
{ name: 'version', type: 'string' },
|
|
23
|
+
{
|
|
24
|
+
name: 'counts',
|
|
25
|
+
type: 'object',
|
|
26
|
+
fields: [
|
|
27
|
+
{ name: 'contentTypes', type: 'integer' },
|
|
28
|
+
{ name: 'mediaTypes', type: 'integer' },
|
|
29
|
+
{ name: 'dataTypes', type: 'integer' },
|
|
30
|
+
{ name: 'languages', type: 'integer' },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
{ name: 'problems', type: 'array', items: { type: 'string' } },
|
|
34
|
+
{ name: 'classification', type: 'string', nullable: true },
|
|
35
|
+
{ name: 'findings', type: 'array', items: { type: 'object' } },
|
|
36
|
+
],
|
|
37
|
+
errors: ['not_found'],
|
|
38
|
+
outcomes: ['problems_found', 'blocked'],
|
|
39
|
+
} as const
|
|
40
|
+
|
|
41
|
+
async run() {
|
|
42
|
+
const { flags } = await this.parse(TypesCheck)
|
|
43
|
+
const config = await siteConfig()
|
|
44
|
+
needSchemaDir(config)
|
|
45
|
+
const loaded = loadTypes(config)
|
|
46
|
+
const counts = {
|
|
47
|
+
contentTypes: loaded.set.contentTypes.length,
|
|
48
|
+
mediaTypes: (loaded.set.mediaTypes ?? []).length,
|
|
49
|
+
dataTypes: loaded.set.dataTypes.length,
|
|
50
|
+
languages: loaded.set.languages.length,
|
|
51
|
+
}
|
|
52
|
+
const result = {
|
|
53
|
+
version: loaded.set.version,
|
|
54
|
+
counts,
|
|
55
|
+
problems: [] as string[],
|
|
56
|
+
classification: null as string | null,
|
|
57
|
+
findings: [] as ReturnType<typeof findingsOf>,
|
|
58
|
+
}
|
|
59
|
+
result.problems = [
|
|
60
|
+
...loaded.problems,
|
|
61
|
+
...validateSchemaSet(loaded.set, {
|
|
62
|
+
componentAliases: componentAliasesIn(config.componentsDir),
|
|
63
|
+
types: formTypesFor(config),
|
|
64
|
+
}),
|
|
65
|
+
].map(describeProblem)
|
|
66
|
+
if (result.problems.length > 0) {
|
|
67
|
+
this.say(`${result.problems.length} problem(s):`)
|
|
68
|
+
for (const problem of result.problems) this.say(` ${problem}`)
|
|
69
|
+
this.outcome = 'problems_found'
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
this.say(
|
|
73
|
+
`schema ${loaded.set.version}: ${counts.contentTypes} content type(s), ${counts.mediaTypes} media type(s), ${counts.dataTypes} data type(s), ${counts.languages} language(s) — valid`,
|
|
74
|
+
)
|
|
75
|
+
if (flags.static) {
|
|
76
|
+
result.problems = staticProblems(loaded, await loadValueMigrations(config.schemaDir))
|
|
77
|
+
for (const issue of result.problems) this.say(` ${issue}`)
|
|
78
|
+
if (result.problems.length > 0) this.outcome = 'problems_found'
|
|
79
|
+
return result
|
|
80
|
+
}
|
|
81
|
+
// The same check an upgrade runs, over the site's own diff.
|
|
82
|
+
const report = await upgradeCheck(this, config, {})
|
|
83
|
+
result.classification = report.classification
|
|
84
|
+
result.findings = findingsOf(report.findings)
|
|
85
|
+
if (report.blocking) this.outcome = 'blocked'
|
|
86
|
+
return result
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { exportSchemaSet, writeSchemaFiles } from '@crombolo/schema'
|
|
3
|
+
import { bootstrapDatabase } from '@crombolo/server'
|
|
4
|
+
import { BaseCommand } from '../../base.ts'
|
|
5
|
+
import { here } from '../../support/describe.ts'
|
|
6
|
+
import { siteConfig } from '../../support/site.ts'
|
|
7
|
+
import { loadTypes } from '../../support/types.ts'
|
|
8
|
+
|
|
9
|
+
export default class TypesExport extends BaseCommand {
|
|
10
|
+
static override summary = "write the database's definitions as schema/ files"
|
|
11
|
+
static override aliases = ['schema:export']
|
|
12
|
+
static override deprecateAliases = true
|
|
13
|
+
static override spec = {
|
|
14
|
+
effects: 'idempotent',
|
|
15
|
+
cardinality: 'bounded',
|
|
16
|
+
outputFields: [{ name: 'wrote', type: 'array', items: { type: 'string' } }],
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
async run() {
|
|
20
|
+
await this.parse(TypesExport)
|
|
21
|
+
const config = await siteConfig()
|
|
22
|
+
const { db } = await bootstrapDatabase(config)
|
|
23
|
+
try {
|
|
24
|
+
const version = existsSync(config.schemaDir) ? loadTypes(config).set.version : '1.0.0'
|
|
25
|
+
const wrote = writeSchemaFiles(config.schemaDir, await exportSchemaSet(db, version)).map(here)
|
|
26
|
+
for (const file of wrote) this.say(` wrote ${file}`)
|
|
27
|
+
return { wrote }
|
|
28
|
+
} finally {
|
|
29
|
+
await db.close()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
fileNameFor,
|
|
6
|
+
newType,
|
|
7
|
+
type SchemaTypeKind,
|
|
8
|
+
TYPE_KIND_FILES,
|
|
9
|
+
viewScaffold,
|
|
10
|
+
writeContentType,
|
|
11
|
+
} from '@crombolo/schema'
|
|
12
|
+
import { Args, Flags } from '@oclif/core'
|
|
13
|
+
import { BaseCommand } from '../../base.ts'
|
|
14
|
+
import { describeSync, here } from '../../support/describe.ts'
|
|
15
|
+
import { siteConfig } from '../../support/site.ts'
|
|
16
|
+
import { applySchema, loadTypes, needSchemaDir } from '../../support/types.ts'
|
|
17
|
+
|
|
18
|
+
const TYPE_KINDS: Record<string, SchemaTypeKind> = {
|
|
19
|
+
'content-type': 'document',
|
|
20
|
+
'media-type': 'media',
|
|
21
|
+
'member-type': 'member',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The file a site would otherwise write from memory, keys and all, and the view it points at. */
|
|
25
|
+
export default class TypesNew extends BaseCommand {
|
|
26
|
+
static override summary = 'write a type file, with keys, and the view it points at, then apply it'
|
|
27
|
+
static override examples = ['<%= config.bin %> types new content-type article --at-root']
|
|
28
|
+
static override aliases = ['schema:new']
|
|
29
|
+
static override deprecateAliases = true
|
|
30
|
+
static override args = {
|
|
31
|
+
kind: Args.string({
|
|
32
|
+
required: true,
|
|
33
|
+
options: Object.keys(TYPE_KINDS),
|
|
34
|
+
description: 'what kind of type',
|
|
35
|
+
}),
|
|
36
|
+
alias: Args.string({ required: true, description: "the type's alias" }),
|
|
37
|
+
}
|
|
38
|
+
static override flags = {
|
|
39
|
+
name: Flags.string({ summary: "the type's name", helpValue: '<name>' }),
|
|
40
|
+
description: Flags.string({ summary: "the type's description", helpValue: '<text>' }),
|
|
41
|
+
icon: Flags.string({ summary: "the type's icon", helpValue: '<icon>' }),
|
|
42
|
+
'at-root': Flags.boolean({ summary: 'allow it at the root of the tree' }),
|
|
43
|
+
element: Flags.boolean({ summary: 'an element type, for blocks' }),
|
|
44
|
+
tab: Flags.string({ summary: 'the tab its first property goes in', helpValue: '<tab>' }),
|
|
45
|
+
'no-view': Flags.boolean({ summary: 'no view: a type nothing renders' }),
|
|
46
|
+
}
|
|
47
|
+
static override spec = {
|
|
48
|
+
effects: 'non_idempotent',
|
|
49
|
+
cardinality: 'single',
|
|
50
|
+
outputFields: [
|
|
51
|
+
{ name: 'created', type: 'array', items: { type: 'string' } },
|
|
52
|
+
{ name: 'schema', type: 'string' },
|
|
53
|
+
],
|
|
54
|
+
errors: ['not_found', 'conflict', 'refused'],
|
|
55
|
+
} as const
|
|
56
|
+
|
|
57
|
+
async run() {
|
|
58
|
+
const { args, flags } = await this.parse(TypesNew)
|
|
59
|
+
const kind = TYPE_KINDS[args.kind] as SchemaTypeKind
|
|
60
|
+
const config = await siteConfig()
|
|
61
|
+
needSchemaDir(config)
|
|
62
|
+
const loaded = loadTypes(config)
|
|
63
|
+
const existing = [
|
|
64
|
+
...loaded.set.contentTypes,
|
|
65
|
+
...(loaded.set.mediaTypes ?? []),
|
|
66
|
+
...(loaded.set.memberTypes ?? []),
|
|
67
|
+
]
|
|
68
|
+
if (existing.some((type) => type.alias === args.alias))
|
|
69
|
+
this.fail('conflict', `There is already a type with the alias "${args.alias}".`)
|
|
70
|
+
|
|
71
|
+
const element = flags.element
|
|
72
|
+
const type = newType({
|
|
73
|
+
alias: args.alias,
|
|
74
|
+
kind,
|
|
75
|
+
name: flags.name,
|
|
76
|
+
description: flags.description,
|
|
77
|
+
icon: flags.icon,
|
|
78
|
+
allowAtRoot: flags['at-root'],
|
|
79
|
+
element,
|
|
80
|
+
template: !flags['no-view'],
|
|
81
|
+
// A content type with nothing in it cannot be published, so it starts with
|
|
82
|
+
// the one property every page has; the other kinds get what they are given.
|
|
83
|
+
tab: kind === 'document' ? (flags.tab ?? 'Content') : undefined,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const created: string[] = []
|
|
87
|
+
const file = join(config.schemaDir, TYPE_KIND_FILES[kind].dir, fileNameFor(args.alias))
|
|
88
|
+
await mkdir(join(file, '..'), { recursive: true })
|
|
89
|
+
await writeFile(file, writeContentType(type, kind))
|
|
90
|
+
created.push(here(file))
|
|
91
|
+
this.say(` created ${here(file)}`)
|
|
92
|
+
|
|
93
|
+
// A type nothing allows cannot be created anywhere, which is a confusing
|
|
94
|
+
// first five minutes: the editor simply never offers it.
|
|
95
|
+
if (kind === 'document' && !element && !type.allowAtRoot) {
|
|
96
|
+
const parents = loaded.set.contentTypes.filter((t) => t.allowChildren.includes(args.alias))
|
|
97
|
+
if (parents.length === 0)
|
|
98
|
+
this.say(
|
|
99
|
+
` note nothing allows a "${args.alias}" yet — add it to a parent's allow-children, or pass --at-root`,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (type.components.length > 0) {
|
|
104
|
+
const view = join(config.componentsDir, `${args.alias}.tsx`)
|
|
105
|
+
if (existsSync(view)) this.say(` exists ${here(view)}`)
|
|
106
|
+
else {
|
|
107
|
+
await mkdir(join(view, '..'), { recursive: true })
|
|
108
|
+
await writeFile(view, viewScaffold(type))
|
|
109
|
+
created.push(here(view))
|
|
110
|
+
this.say(` created ${here(view)}`)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const report = await applySchema(this, config)
|
|
114
|
+
return { created, schema: describeSync(report) }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ContentTypeRepository, recordInLedger } from '@crombolo/data'
|
|
2
|
+
import { backupBefore, bootstrapDatabase } from '@crombolo/server'
|
|
3
|
+
import { Flags } from '@oclif/core'
|
|
4
|
+
import { BaseCommand } from '../../base.ts'
|
|
5
|
+
import { backupTaken } from '../../support/flags.ts'
|
|
6
|
+
import { siteConfig } from '../../support/site.ts'
|
|
7
|
+
import { needSchemaDir } from '../../support/types.ts'
|
|
8
|
+
|
|
9
|
+
export default class TypesPurge extends BaseCommand {
|
|
10
|
+
static override summary = 'delete properties retired more than N days ago, with their values'
|
|
11
|
+
static override aliases = ['schema:purge']
|
|
12
|
+
static override deprecateAliases = true
|
|
13
|
+
static override flags = {
|
|
14
|
+
'older-than': Flags.integer({
|
|
15
|
+
summary: 'how long, in days, a property must have been retired',
|
|
16
|
+
required: true,
|
|
17
|
+
min: 0,
|
|
18
|
+
}),
|
|
19
|
+
'backup-taken': backupTaken,
|
|
20
|
+
}
|
|
21
|
+
static override audit = { category: 'schema', action: 'Purge', entityType: 'Schema' } as const
|
|
22
|
+
static override spec = {
|
|
23
|
+
effects: 'idempotent',
|
|
24
|
+
cardinality: 'bounded',
|
|
25
|
+
outputFields: [
|
|
26
|
+
{ name: 'purged', type: 'array', items: { type: 'string' } },
|
|
27
|
+
{ name: 'backup', type: 'string', nullable: true },
|
|
28
|
+
{ name: 'changed', type: 'boolean' },
|
|
29
|
+
],
|
|
30
|
+
errors: ['not_found', 'refused'],
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
async run() {
|
|
34
|
+
const { flags } = await this.parse(TypesPurge)
|
|
35
|
+
const days = flags['older-than']
|
|
36
|
+
const config = await siteConfig()
|
|
37
|
+
needSchemaDir(config)
|
|
38
|
+
const backup = await backupBefore(config, {
|
|
39
|
+
reason: 'purge',
|
|
40
|
+
backupTaken: flags['backup-taken'],
|
|
41
|
+
})
|
|
42
|
+
if (backup.path) this.progress(` backup ${backup.path}`)
|
|
43
|
+
const { db } = await bootstrapDatabase(config)
|
|
44
|
+
try {
|
|
45
|
+
const before = new Date(Date.now() - days * 86_400_000)
|
|
46
|
+
const started = performance.now()
|
|
47
|
+
const purged = (await new ContentTypeRepository(db).purgeRetired(before)).map(
|
|
48
|
+
(p) => `${p.type}.${p.alias}`,
|
|
49
|
+
)
|
|
50
|
+
for (const name of purged) this.say(` purged ${name}`)
|
|
51
|
+
if (purged.length === 0) this.say(`Nothing retired more than ${days} day(s) ago.`)
|
|
52
|
+
else
|
|
53
|
+
await recordInLedger(db, {
|
|
54
|
+
name: `purge ${purged.join(', ')}`,
|
|
55
|
+
kind: 'contract',
|
|
56
|
+
durationMs: Math.round(performance.now() - started),
|
|
57
|
+
appliedBy: config.nodeId,
|
|
58
|
+
note: `--older-than ${days}`,
|
|
59
|
+
})
|
|
60
|
+
return { purged, backup: backup.path ?? null, changed: purged.length > 0 }
|
|
61
|
+
} finally {
|
|
62
|
+
await db.close()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { writeSchemaFiles } from '@crombolo/schema'
|
|
2
|
+
import { BaseCommand } from '../../base.ts'
|
|
3
|
+
import { here } from '../../support/describe.ts'
|
|
4
|
+
import { siteConfig } from '../../support/site.ts'
|
|
5
|
+
import { loadTypes, needSchemaDir, refuseProblems } from '../../support/types.ts'
|
|
6
|
+
|
|
7
|
+
export default class TypesRewrite extends BaseCommand {
|
|
8
|
+
static override summary = 'rewrite every schema/ file canonically'
|
|
9
|
+
static override description =
|
|
10
|
+
'Every file in the canonical layout: a reviewable commit when the vocabulary changes.'
|
|
11
|
+
static override aliases = ['schema:rewrite', 'upgrade:schema']
|
|
12
|
+
static override deprecateAliases = true
|
|
13
|
+
static override spec = {
|
|
14
|
+
effects: 'idempotent',
|
|
15
|
+
cardinality: 'bounded',
|
|
16
|
+
outputFields: [{ name: 'wrote', type: 'array', items: { type: 'string' } }],
|
|
17
|
+
errors: ['not_found', 'refused'],
|
|
18
|
+
} as const
|
|
19
|
+
|
|
20
|
+
async run() {
|
|
21
|
+
await this.parse(TypesRewrite)
|
|
22
|
+
const config = await siteConfig()
|
|
23
|
+
needSchemaDir(config)
|
|
24
|
+
const loaded = loadTypes(config)
|
|
25
|
+
refuseProblems(loaded.problems)
|
|
26
|
+
const wrote = writeSchemaFiles(config.schemaDir, loaded.set).map(here)
|
|
27
|
+
for (const file of wrote) this.say(` wrote ${file}`)
|
|
28
|
+
return { wrote }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { bootstrapDatabase, syncSchemaDirectory } from '@crombolo/server'
|
|
2
|
+
import { Flags } from '@oclif/core'
|
|
3
|
+
import { BaseCommand } from '../../base.ts'
|
|
4
|
+
import { describeProblem, describeSync, printProblems } from '../../support/describe.ts'
|
|
5
|
+
import { siteConfig } from '../../support/site.ts'
|
|
6
|
+
import { needSchemaDir } from '../../support/types.ts'
|
|
7
|
+
|
|
8
|
+
export default class TypesSync extends BaseCommand {
|
|
9
|
+
static override summary = 'apply schema/ to the database'
|
|
10
|
+
static override aliases = ['schema:sync']
|
|
11
|
+
static override deprecateAliases = true
|
|
12
|
+
static override flags = {
|
|
13
|
+
'force-retire-types': Flags.boolean({
|
|
14
|
+
summary: 'retire a type the files no longer have even though content uses it',
|
|
15
|
+
}),
|
|
16
|
+
}
|
|
17
|
+
static override audit = { category: 'schema', action: 'Sync', entityType: 'Schema' } as const
|
|
18
|
+
static override spec = {
|
|
19
|
+
effects: 'idempotent',
|
|
20
|
+
cardinality: 'single',
|
|
21
|
+
outputFields: [
|
|
22
|
+
{
|
|
23
|
+
name: 'action',
|
|
24
|
+
type: 'string',
|
|
25
|
+
enum: ['applied', 'would-apply', 'skipped-same', 'skipped-older', 'refused'],
|
|
26
|
+
},
|
|
27
|
+
{ name: 'changed', type: 'boolean' },
|
|
28
|
+
{ name: 'summary', type: 'string' },
|
|
29
|
+
{ name: 'problems', type: 'array', items: { type: 'string' } },
|
|
30
|
+
],
|
|
31
|
+
errors: ['not_found', 'refused'],
|
|
32
|
+
} as const
|
|
33
|
+
|
|
34
|
+
async run() {
|
|
35
|
+
const { flags } = await this.parse(TypesSync)
|
|
36
|
+
const config = await siteConfig()
|
|
37
|
+
needSchemaDir(config)
|
|
38
|
+
const { db } = await bootstrapDatabase(config)
|
|
39
|
+
try {
|
|
40
|
+
const report = await syncSchemaDirectory(
|
|
41
|
+
db,
|
|
42
|
+
config,
|
|
43
|
+
{ nodeId: config.nodeId, revision: config.schemaRevision },
|
|
44
|
+
{ forceRetireTypes: flags['force-retire-types'] },
|
|
45
|
+
)
|
|
46
|
+
if (report.problems.length > 0) printProblems(this, report.problems)
|
|
47
|
+
const summary = describeSync(report)
|
|
48
|
+
if (report.action === 'refused')
|
|
49
|
+
this.fail(
|
|
50
|
+
'refused',
|
|
51
|
+
`The schema was not applied: ${summary}.`,
|
|
52
|
+
undefined,
|
|
53
|
+
report.problems.map(describeProblem),
|
|
54
|
+
)
|
|
55
|
+
this.say(summary)
|
|
56
|
+
return {
|
|
57
|
+
action: report.action,
|
|
58
|
+
changed: report.action === 'applied',
|
|
59
|
+
summary,
|
|
60
|
+
problems: report.problems.map(describeProblem),
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
await db.close()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|