@crombolo/cli 0.14.4 → 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.
Files changed (85) hide show
  1. package/bin/crombolo.ts +4 -3166
  2. package/oclif.manifest.json +6710 -0
  3. package/package.json +58 -7
  4. package/src/base.ts +129 -0
  5. package/src/classify.ts +119 -0
  6. package/src/clispec.ts +163 -0
  7. package/src/commands/admin/reset-password.ts +53 -0
  8. package/src/commands/assets/index.ts +6 -0
  9. package/src/commands/assets/list.ts +27 -0
  10. package/src/commands/assets/new.ts +42 -0
  11. package/src/commands/bundle/purge.ts +71 -0
  12. package/src/commands/components/check.ts +58 -0
  13. package/src/commands/components/index.ts +6 -0
  14. package/src/commands/components/list.ts +40 -0
  15. package/src/commands/components/new.ts +49 -0
  16. package/src/commands/content/check.ts +102 -0
  17. package/src/commands/content/export.ts +116 -0
  18. package/src/commands/content/import.ts +109 -0
  19. package/src/commands/content/publish.ts +21 -0
  20. package/src/commands/content/revert.ts +137 -0
  21. package/src/commands/content/runs.ts +62 -0
  22. package/src/commands/content/unpublish.ts +21 -0
  23. package/src/commands/dictionary/export.ts +49 -0
  24. package/src/commands/dictionary/import.ts +46 -0
  25. package/src/commands/domains/apply.ts +23 -0
  26. package/src/commands/domains/clear.ts +27 -0
  27. package/src/commands/domains/index.ts +6 -0
  28. package/src/commands/domains/list.ts +86 -0
  29. package/src/commands/domains/set.ts +55 -0
  30. package/src/commands/domains/undo.ts +29 -0
  31. package/src/commands/export.ts +100 -0
  32. package/src/commands/generate.ts +33 -0
  33. package/src/commands/import/index.ts +175 -0
  34. package/src/commands/import/umbraco/apply.ts +128 -0
  35. package/src/commands/import/umbraco/report.ts +56 -0
  36. package/src/commands/init.ts +76 -0
  37. package/src/commands/login.ts +38 -0
  38. package/src/commands/logout.ts +36 -0
  39. package/src/commands/maintenance/pause.ts +29 -0
  40. package/src/commands/maintenance/resume.ts +20 -0
  41. package/src/commands/maintenance/status.ts +34 -0
  42. package/src/commands/media/fetch.ts +62 -0
  43. package/src/commands/pull.ts +210 -0
  44. package/src/commands/push.ts +108 -0
  45. package/src/commands/remote/add.ts +78 -0
  46. package/src/commands/remote/job.ts +53 -0
  47. package/src/commands/remote/list.ts +41 -0
  48. package/src/commands/remote/remove.ts +26 -0
  49. package/src/commands/schema.ts +32 -0
  50. package/src/commands/start.ts +64 -0
  51. package/src/commands/status.ts +103 -0
  52. package/src/commands/types/add-property.ts +116 -0
  53. package/src/commands/types/check.ts +88 -0
  54. package/src/commands/types/export.ts +32 -0
  55. package/src/commands/types/new.ts +116 -0
  56. package/src/commands/types/purge.ts +65 -0
  57. package/src/commands/types/rewrite.ts +30 -0
  58. package/src/commands/types/sync.ts +66 -0
  59. package/src/commands/upgrade/check.ts +69 -0
  60. package/src/commands/upgrade/index.ts +120 -0
  61. package/src/commands/upgrade/ledger.ts +55 -0
  62. package/src/commands/whoami.ts +41 -0
  63. package/src/errors.ts +90 -0
  64. package/src/main.ts +23 -0
  65. package/src/manifest.ts +20 -0
  66. package/src/reference.ts +118 -0
  67. package/src/remote/client.ts +19 -10
  68. package/src/remote/remotes-file.ts +3 -2
  69. package/src/remote/transfer.ts +27 -11
  70. package/src/support/components.ts +7 -0
  71. package/src/support/describe.ts +179 -0
  72. package/src/support/domains.ts +89 -0
  73. package/src/support/flags.ts +157 -0
  74. package/src/support/prompt.ts +126 -0
  75. package/src/support/publish.ts +142 -0
  76. package/src/support/remote.ts +58 -0
  77. package/src/support/serve.ts +460 -0
  78. package/src/support/site.ts +26 -0
  79. package/src/support/types.ts +112 -0
  80. package/src/support/umbraco.ts +86 -0
  81. package/templates/basic/bundle/content/bundle.json +1 -1
  82. package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
  83. package/templates/basic/bundle/package.json +1 -1
  84. package/templates/demo/harbourstone/bundle/package.json +1 -1
  85. 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
+ }