@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,21 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.ts'
|
|
2
|
+
import { publishArgs, publishFlags, publishPages, publishSpec } from '../../support/publish.ts'
|
|
3
|
+
|
|
4
|
+
export default class ContentPublish extends BaseCommand {
|
|
5
|
+
static override summary = 'take a page live; --descendants for the branch below it'
|
|
6
|
+
static override description = '--culture per culture; --at and --until schedule it instead.'
|
|
7
|
+
static override args = publishArgs
|
|
8
|
+
static override flags = publishFlags
|
|
9
|
+
static override audit = {
|
|
10
|
+
category: 'content',
|
|
11
|
+
action: 'Publish',
|
|
12
|
+
entityType: 'Document',
|
|
13
|
+
} as const
|
|
14
|
+
static override spec = publishSpec
|
|
15
|
+
|
|
16
|
+
async run() {
|
|
17
|
+
const { args, flags } = await this.parse(ContentPublish)
|
|
18
|
+
this.auditKey = args.page
|
|
19
|
+
return publishPages(this, true, args.page, flags)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { recordInLedger } from '@crombolo/data'
|
|
2
|
+
import { backupBefore, bootstrapDatabase } from '@crombolo/server'
|
|
3
|
+
import {
|
|
4
|
+
isRevertResolution,
|
|
5
|
+
REVERT_RESOLUTIONS,
|
|
6
|
+
type RevertResolution,
|
|
7
|
+
revertResolutionsFor,
|
|
8
|
+
revertRun,
|
|
9
|
+
} from '@crombolo/transfer'
|
|
10
|
+
import { Args, Flags } from '@oclif/core'
|
|
11
|
+
import { BaseCommand } from '../../base.ts'
|
|
12
|
+
import * as ops from '../../operations/commands.ts'
|
|
13
|
+
import { here } from '../../support/describe.ts'
|
|
14
|
+
import { backupTaken, many } from '../../support/flags.ts'
|
|
15
|
+
import { siteConfig } from '../../support/site.ts'
|
|
16
|
+
|
|
17
|
+
export default class ContentRevert extends BaseCommand {
|
|
18
|
+
static override summary = 'put an import back, restoring what the site served'
|
|
19
|
+
static override description = [
|
|
20
|
+
'Values and published state are restored; nodes the run created are unpublished and recycled.',
|
|
21
|
+
'A revert is itself a run, so it can be reverted in turn.',
|
|
22
|
+
].join('\n\n')
|
|
23
|
+
static override args = {
|
|
24
|
+
run: Args.string({
|
|
25
|
+
required: true,
|
|
26
|
+
description: 'the run id: `crombolo content runs` lists them',
|
|
27
|
+
}),
|
|
28
|
+
}
|
|
29
|
+
static override flags = {
|
|
30
|
+
resolve: many(`answer a finding: <key>=${REVERT_RESOLUTIONS.join('|')}`, '<answer>'),
|
|
31
|
+
'resolve-all': Flags.string({
|
|
32
|
+
summary: 'answer every finding the same way',
|
|
33
|
+
options: [...REVERT_RESOLUTIONS],
|
|
34
|
+
}),
|
|
35
|
+
force: Flags.boolean({ summary: 'revert over changes made since the run' }),
|
|
36
|
+
'backup-taken': backupTaken,
|
|
37
|
+
}
|
|
38
|
+
static override audit = { category: 'content', action: 'Revert', entityType: 'Document' } as const
|
|
39
|
+
static override spec = {
|
|
40
|
+
effects: 'non_idempotent',
|
|
41
|
+
cardinality: 'single',
|
|
42
|
+
outputFields: [
|
|
43
|
+
{ name: 'runId', type: 'string' },
|
|
44
|
+
{ name: 'reverted', type: 'string' },
|
|
45
|
+
{ name: 'restored', type: 'array', items: { type: 'string' } },
|
|
46
|
+
{ name: 'recycled', type: 'array', items: { type: 'string' } },
|
|
47
|
+
{ name: 'files', type: 'object' },
|
|
48
|
+
{ name: 'backup', type: 'string', nullable: true },
|
|
49
|
+
],
|
|
50
|
+
errors: ['usage', 'refused', 'not_found'],
|
|
51
|
+
} as const
|
|
52
|
+
|
|
53
|
+
async run() {
|
|
54
|
+
const { args, flags } = await this.parse(ContentRevert)
|
|
55
|
+
const runId = args.run
|
|
56
|
+
this.auditKey = runId
|
|
57
|
+
const resolutions: Record<string, RevertResolution> = {}
|
|
58
|
+
for (const pair of flags.resolve) {
|
|
59
|
+
const eq = pair.indexOf('=')
|
|
60
|
+
const choice = eq >= 0 ? pair.slice(eq + 1) : ''
|
|
61
|
+
if (eq < 0 || !isRevertResolution(choice))
|
|
62
|
+
this.fail('usage', `--resolve wants <key>=${REVERT_RESOLUTIONS.join('|')}, not "${pair}".`)
|
|
63
|
+
resolutions[pair.slice(0, eq)] = choice
|
|
64
|
+
}
|
|
65
|
+
const config = await siteConfig()
|
|
66
|
+
const backup = await backupBefore(config, {
|
|
67
|
+
reason: 'content-revert',
|
|
68
|
+
backupTaken: flags['backup-taken'],
|
|
69
|
+
})
|
|
70
|
+
if (backup.path) this.progress(` backup ${backup.path}`)
|
|
71
|
+
|
|
72
|
+
const { db } = await bootstrapDatabase(config)
|
|
73
|
+
try {
|
|
74
|
+
const started = performance.now()
|
|
75
|
+
const result = await revertRun(db, runId, {
|
|
76
|
+
resolutions,
|
|
77
|
+
resolveAll: flags['resolve-all'] as RevertResolution | undefined,
|
|
78
|
+
force: flags.force,
|
|
79
|
+
nodeId: config.nodeId,
|
|
80
|
+
backOfficePath: config.backOfficePath,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
if (!result.runId)
|
|
84
|
+
this.fail(
|
|
85
|
+
'refused',
|
|
86
|
+
'revert refused. Nothing was changed.',
|
|
87
|
+
'Answer the findings with --resolve, or pass --force.',
|
|
88
|
+
result.check.findings.map((f) => ({
|
|
89
|
+
kind: f.kind,
|
|
90
|
+
code: f.code,
|
|
91
|
+
message: f.message,
|
|
92
|
+
answers: revertResolutionsFor(f.code),
|
|
93
|
+
})),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
// The files an install replaced come back from the run's own copies, not
|
|
97
|
+
// from git: these files are editable in the backoffice, so a site need not
|
|
98
|
+
// be a checkout at all.
|
|
99
|
+
const files = await ops.revertSections(db, config, runId)
|
|
100
|
+
for (const file of files.restored) this.say(` restored ${here(file)}`)
|
|
101
|
+
for (const file of files.removed) this.say(` removed ${here(file)} (created by that run)`)
|
|
102
|
+
if (files.schema) this.say(` schema ${files.schema}`)
|
|
103
|
+
if (files.dictionary) this.say(' restored the dictionary')
|
|
104
|
+
|
|
105
|
+
for (const key of result.restored) this.say(` restored ${key}`)
|
|
106
|
+
for (const key of result.recycled) this.say(` recycled ${key} (created by that run)`)
|
|
107
|
+
for (const entry of result.check.plan.filter((p) => p.action === 'skip'))
|
|
108
|
+
this.say(` left ${entry.name}`)
|
|
109
|
+
await recordInLedger(db, {
|
|
110
|
+
name: `content revert ${runId}`,
|
|
111
|
+
kind: 'revert',
|
|
112
|
+
durationMs: Math.round(performance.now() - started),
|
|
113
|
+
appliedBy: config.nodeId,
|
|
114
|
+
note: `run ${result.runId}`,
|
|
115
|
+
})
|
|
116
|
+
this.say(
|
|
117
|
+
`\nReverted ${runId} as run ${result.runId}. ` +
|
|
118
|
+
`\`crombolo content revert ${result.runId}\` would put it back again.`,
|
|
119
|
+
)
|
|
120
|
+
return {
|
|
121
|
+
runId: result.runId,
|
|
122
|
+
reverted: runId,
|
|
123
|
+
restored: result.restored,
|
|
124
|
+
recycled: result.recycled,
|
|
125
|
+
files: {
|
|
126
|
+
restored: files.restored.map(here),
|
|
127
|
+
removed: files.removed.map(here),
|
|
128
|
+
schema: files.schema ?? null,
|
|
129
|
+
dictionary: Boolean(files.dictionary),
|
|
130
|
+
},
|
|
131
|
+
backup: backup.path ?? null,
|
|
132
|
+
}
|
|
133
|
+
} finally {
|
|
134
|
+
await db.close()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { TransferRunRepository } from '@crombolo/data'
|
|
2
|
+
import { bootstrapDatabase } from '@crombolo/server'
|
|
3
|
+
import { BaseCommand } from '../../base.ts'
|
|
4
|
+
import { page, pageFlags, pagination } from '../../support/flags.ts'
|
|
5
|
+
import { siteConfig } from '../../support/site.ts'
|
|
6
|
+
|
|
7
|
+
export default class ContentRuns extends BaseCommand {
|
|
8
|
+
static override summary = 'the imports applied here, newest first'
|
|
9
|
+
static override flags = { ...pageFlags(20) }
|
|
10
|
+
static override spec = {
|
|
11
|
+
effects: 'read_only',
|
|
12
|
+
cardinality: 'unbounded',
|
|
13
|
+
pagination,
|
|
14
|
+
fieldsArg: '--fields',
|
|
15
|
+
outputFields: [
|
|
16
|
+
{ name: 'id', type: 'string' },
|
|
17
|
+
{ name: 'startedAt', type: 'string' },
|
|
18
|
+
{ name: 'direction', type: 'string' },
|
|
19
|
+
{ name: 'status', type: 'string' },
|
|
20
|
+
{ name: 'nodeCount', type: 'integer' },
|
|
21
|
+
{ name: 'bundleId', type: 'string' },
|
|
22
|
+
{ name: 'bundleLabel', type: 'string', nullable: true },
|
|
23
|
+
{ name: 'appliedBy', type: 'string', nullable: true },
|
|
24
|
+
],
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
async run() {
|
|
28
|
+
const { flags } = await this.parse(ContentRuns)
|
|
29
|
+
const { db } = await bootstrapDatabase(await siteConfig())
|
|
30
|
+
try {
|
|
31
|
+
const repository = new TransferRunRepository(db)
|
|
32
|
+
const [runs, total] = await Promise.all([
|
|
33
|
+
repository.recent(flags.limit, flags.offset),
|
|
34
|
+
repository.count(),
|
|
35
|
+
])
|
|
36
|
+
if (runs.length === 0) this.say('No content has been imported here.')
|
|
37
|
+
const items = runs.map((run) => ({
|
|
38
|
+
id: run.id,
|
|
39
|
+
startedAt: run.startedAt.toISOString(),
|
|
40
|
+
direction: run.direction,
|
|
41
|
+
status: run.status,
|
|
42
|
+
nodeCount: run.nodeCount,
|
|
43
|
+
bundleId: run.bundleId,
|
|
44
|
+
bundleLabel: run.bundleLabel ?? null,
|
|
45
|
+
appliedBy: run.appliedBy ?? null,
|
|
46
|
+
}))
|
|
47
|
+
for (const run of items)
|
|
48
|
+
this.say(
|
|
49
|
+
` ${run.startedAt} ${run.direction.padEnd(6)} ${run.status.padEnd(8)} ` +
|
|
50
|
+
`${run.id} ${run.nodeCount} node(s) bundle ${run.bundleId}` +
|
|
51
|
+
`${run.appliedBy ? ` by ${run.appliedBy}` : ''}${run.bundleLabel ? ` (${run.bundleLabel})` : ''}`,
|
|
52
|
+
)
|
|
53
|
+
if (flags.offset + runs.length < total)
|
|
54
|
+
this.say(
|
|
55
|
+
` … ${total - flags.offset - runs.length} more: --offset ${flags.offset + runs.length}`,
|
|
56
|
+
)
|
|
57
|
+
return page(items, total, flags)
|
|
58
|
+
} finally {
|
|
59
|
+
await db.close()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.ts'
|
|
2
|
+
import { publishArgs, publishFlags, publishPages, publishSpec } from '../../support/publish.ts'
|
|
3
|
+
|
|
4
|
+
export default class ContentUnpublish extends BaseCommand {
|
|
5
|
+
static override summary = 'take a page down; --descendants for the branch below it'
|
|
6
|
+
static override description = '--culture per culture; --at and --until schedule it instead.'
|
|
7
|
+
static override args = publishArgs
|
|
8
|
+
static override flags = publishFlags
|
|
9
|
+
static override audit = {
|
|
10
|
+
category: 'content',
|
|
11
|
+
action: 'Unpublish',
|
|
12
|
+
entityType: 'Document',
|
|
13
|
+
} as const
|
|
14
|
+
static override spec = publishSpec
|
|
15
|
+
|
|
16
|
+
async run() {
|
|
17
|
+
const { args, flags } = await this.parse(ContentUnpublish)
|
|
18
|
+
this.auditKey = args.page
|
|
19
|
+
return publishPages(this, false, args.page, flags)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { DictionaryRepository } from '@crombolo/data'
|
|
4
|
+
import { bootstrapDatabase, dictionaryToUdt, writeDictionaryUdt } from '@crombolo/server'
|
|
5
|
+
import { Flags } from '@oclif/core'
|
|
6
|
+
import { BaseCommand } from '../../base.ts'
|
|
7
|
+
import { here } from '../../support/describe.ts'
|
|
8
|
+
import { siteConfig } from '../../support/site.ts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The dictionary stays in the database, because translators work in
|
|
12
|
+
* production where `schema/` is read-only — so this is how a lower environment
|
|
13
|
+
* gets the translations.
|
|
14
|
+
*/
|
|
15
|
+
export default class DictionaryExport extends BaseCommand {
|
|
16
|
+
static override summary = "the dictionary as Umbraco's .udt, to seed another environment"
|
|
17
|
+
static override flags = {
|
|
18
|
+
out: Flags.string({ summary: 'the file to write', helpValue: '<file.udt>', required: true }),
|
|
19
|
+
key: Flags.string({ summary: 'only this item and its children', helpValue: '<uuid>' }),
|
|
20
|
+
}
|
|
21
|
+
static override spec = {
|
|
22
|
+
effects: 'idempotent',
|
|
23
|
+
cardinality: 'single',
|
|
24
|
+
outputFields: [
|
|
25
|
+
{ name: 'wrote', type: 'string' },
|
|
26
|
+
{ name: 'items', type: 'integer' },
|
|
27
|
+
],
|
|
28
|
+
errors: ['not_found'],
|
|
29
|
+
} as const
|
|
30
|
+
|
|
31
|
+
async run() {
|
|
32
|
+
const { flags } = await this.parse(DictionaryExport)
|
|
33
|
+
const { db } = await bootstrapDatabase(await siteConfig())
|
|
34
|
+
try {
|
|
35
|
+
const items = await dictionaryToUdt(new DictionaryRepository(db), flags.key)
|
|
36
|
+
if (items.length === 0)
|
|
37
|
+
this.fail('not_found', 'Nothing to export: this site has no dictionary items.')
|
|
38
|
+
await mkdir(join(flags.out, '..'), { recursive: true })
|
|
39
|
+
await writeFile(flags.out, writeDictionaryUdt(items))
|
|
40
|
+
const count = (list: typeof items): number =>
|
|
41
|
+
list.reduce((total, item) => total + 1 + count(item.children), 0)
|
|
42
|
+
this.say(` wrote ${here(flags.out)}`)
|
|
43
|
+
this.say(` items ${count(items)}`)
|
|
44
|
+
return { wrote: here(flags.out), items: count(items) }
|
|
45
|
+
} finally {
|
|
46
|
+
await db.close()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { bootstrapDatabase, importDictionaryUdt } from '@crombolo/server'
|
|
3
|
+
import { Args } from '@oclif/core'
|
|
4
|
+
import { BaseCommand } from '../../base.ts'
|
|
5
|
+
import { siteConfig } from '../../support/site.ts'
|
|
6
|
+
|
|
7
|
+
export default class DictionaryImport extends BaseCommand {
|
|
8
|
+
static override summary = 'apply a .udt file: items upsert by key'
|
|
9
|
+
static override description =
|
|
10
|
+
'A translation for a language this site has not got is skipped, and said so.'
|
|
11
|
+
static override args = { file: Args.string({ required: true, description: 'the .udt file' }) }
|
|
12
|
+
static override audit = {
|
|
13
|
+
category: 'settings',
|
|
14
|
+
action: 'Import',
|
|
15
|
+
entityType: 'DictionaryItem',
|
|
16
|
+
} as const
|
|
17
|
+
static override spec = {
|
|
18
|
+
effects: 'idempotent',
|
|
19
|
+
cardinality: 'single',
|
|
20
|
+
outputFields: [
|
|
21
|
+
{ name: 'imported', type: 'integer' },
|
|
22
|
+
{ name: 'skipped', type: 'array', items: { type: 'string' } },
|
|
23
|
+
],
|
|
24
|
+
errors: ['not_found', 'refused'],
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
async run() {
|
|
28
|
+
const { args } = await this.parse(DictionaryImport)
|
|
29
|
+
if (!existsSync(args.file)) this.fail('not_found', `No file at ${args.file}.`)
|
|
30
|
+
const { db } = await bootstrapDatabase(await siteConfig())
|
|
31
|
+
try {
|
|
32
|
+
const outcome = await importDictionaryUdt(db, await Bun.file(args.file).text(), null)
|
|
33
|
+
if (!outcome.ok) this.fail('refused', `refused: ${outcome.reason}`)
|
|
34
|
+
this.say(` imported ${outcome.imported} item(s)`)
|
|
35
|
+
// Umbraco's own behaviour, and worth saying out loud rather than dropping
|
|
36
|
+
// translations silently: a language this site has not got is skipped.
|
|
37
|
+
if (outcome.skipped.length > 0)
|
|
38
|
+
this.say(
|
|
39
|
+
` skipped translations for ${outcome.skipped.join(', ')} — no such language here`,
|
|
40
|
+
)
|
|
41
|
+
return { imported: outcome.imported, skipped: outcome.skipped }
|
|
42
|
+
} finally {
|
|
43
|
+
await db.close()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { BaseCommand } from '../../base.ts'
|
|
2
|
+
import { appliedFields, applyDomains } from '../../support/domains.ts'
|
|
3
|
+
import { siteConfig } from '../../support/site.ts'
|
|
4
|
+
|
|
5
|
+
export default class DomainsApply extends BaseCommand {
|
|
6
|
+
static override summary = 'apply domains.toml here, as a boot does'
|
|
7
|
+
static override audit = {
|
|
8
|
+
category: 'settings',
|
|
9
|
+
action: 'ApplyDomains',
|
|
10
|
+
entityType: 'Domain',
|
|
11
|
+
} as const
|
|
12
|
+
static override spec = {
|
|
13
|
+
effects: 'idempotent',
|
|
14
|
+
cardinality: 'bounded',
|
|
15
|
+
outputFields: appliedFields,
|
|
16
|
+
errors: ['refused'],
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
async run() {
|
|
20
|
+
await this.parse(DomainsApply)
|
|
21
|
+
return applyDomains(this, await siteConfig())
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Args } from '@oclif/core'
|
|
2
|
+
import { BaseCommand } from '../../base.ts'
|
|
3
|
+
import { appliedFields, rewriteDomains } from '../../support/domains.ts'
|
|
4
|
+
import { siteConfig } from '../../support/site.ts'
|
|
5
|
+
|
|
6
|
+
export default class DomainsClear extends BaseCommand {
|
|
7
|
+
static override summary = 'unbind a page, in the file and in the database'
|
|
8
|
+
static override args = {
|
|
9
|
+
page: Args.string({ required: true, description: 'a path, a uuid, or / for the root page' }),
|
|
10
|
+
}
|
|
11
|
+
static override audit = {
|
|
12
|
+
category: 'settings',
|
|
13
|
+
action: 'ClearDomains',
|
|
14
|
+
entityType: 'Domain',
|
|
15
|
+
} as const
|
|
16
|
+
static override spec = {
|
|
17
|
+
effects: 'idempotent',
|
|
18
|
+
cardinality: 'bounded',
|
|
19
|
+
outputFields: [{ name: 'wrote', type: 'string' }, ...appliedFields],
|
|
20
|
+
errors: ['not_found', 'refused'],
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
async run() {
|
|
24
|
+
const { args } = await this.parse(DomainsClear)
|
|
25
|
+
return rewriteDomains(this, await siteConfig(), args.page, [])
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { DomainRepository, NodeRepository } from '@crombolo/data'
|
|
2
|
+
import { bootstrapDatabase, DOMAINS_FILE, resolvePlaceholders } from '@crombolo/server'
|
|
3
|
+
import { BaseCommand } from '../../base.ts'
|
|
4
|
+
import { readDeclarations } from '../../support/domains.ts'
|
|
5
|
+
import { siteConfig } from '../../support/site.ts'
|
|
6
|
+
|
|
7
|
+
export default class DomainsList extends BaseCommand {
|
|
8
|
+
static override summary = 'the hostnames domains.toml declares, and what is bound here'
|
|
9
|
+
static override spec = {
|
|
10
|
+
effects: 'read_only',
|
|
11
|
+
cardinality: 'bounded',
|
|
12
|
+
outputFields: [
|
|
13
|
+
{
|
|
14
|
+
name: 'file',
|
|
15
|
+
type: 'array',
|
|
16
|
+
items: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
fields: [
|
|
19
|
+
{ name: 'node', type: 'string' },
|
|
20
|
+
{ name: 'host', type: 'string' },
|
|
21
|
+
{ name: 'resolved', type: 'string' },
|
|
22
|
+
{ name: 'missing', type: 'array', items: { type: 'string' } },
|
|
23
|
+
{ name: 'culture', type: 'string', nullable: true },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'bound',
|
|
29
|
+
type: 'array',
|
|
30
|
+
items: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
fields: [
|
|
33
|
+
{ name: 'node', type: 'string' },
|
|
34
|
+
{ name: 'host', type: 'string' },
|
|
35
|
+
{ name: 'culture', type: 'string', nullable: true },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
} as const
|
|
41
|
+
|
|
42
|
+
async run() {
|
|
43
|
+
await this.parse(this.ctor)
|
|
44
|
+
const config = await siteConfig()
|
|
45
|
+
const { read, declarations } = readDeclarations(this, config)
|
|
46
|
+
const { db } = await bootstrapDatabase(config)
|
|
47
|
+
try {
|
|
48
|
+
if (!read) this.say(`No ${DOMAINS_FILE}; nothing is deployed from a file here.`)
|
|
49
|
+
const file = declarations.map((declaration) => {
|
|
50
|
+
const resolved = resolvePlaceholders(declaration.host)
|
|
51
|
+
// What the file says, and what it comes to here, which is the whole
|
|
52
|
+
// point of a placeholder and the first thing to check when one is wrong.
|
|
53
|
+
const note =
|
|
54
|
+
resolved.missing.length > 0
|
|
55
|
+
? ` (${resolved.missing.join(', ')} not set here)`
|
|
56
|
+
: resolved.value !== declaration.host
|
|
57
|
+
? ` → ${resolved.value}`
|
|
58
|
+
: ''
|
|
59
|
+
this.say(
|
|
60
|
+
` file ${declaration.node.padEnd(24)} ${declaration.host}${note}${declaration.culture ? ` ${declaration.culture}` : ''}`,
|
|
61
|
+
)
|
|
62
|
+
return {
|
|
63
|
+
node: declaration.node,
|
|
64
|
+
host: declaration.host,
|
|
65
|
+
resolved: resolved.value,
|
|
66
|
+
missing: resolved.missing,
|
|
67
|
+
culture: declaration.culture ?? null,
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
// What the database actually answers on, which is the thing a person is
|
|
71
|
+
// usually asking about when a hostname is not working.
|
|
72
|
+
const nodes = new NodeRepository(db)
|
|
73
|
+
const bound = []
|
|
74
|
+
for (const row of await new DomainRepository(db).all()) {
|
|
75
|
+
const node = (await nodes.byId(row.nodeId))?.text ?? String(row.nodeId)
|
|
76
|
+
this.say(
|
|
77
|
+
` bound ${node.padEnd(24)} ${row.domainName}${row.isoCode ? ` ${row.isoCode}` : ''}`,
|
|
78
|
+
)
|
|
79
|
+
bound.push({ node, host: row.domainName, culture: row.isoCode ?? null })
|
|
80
|
+
}
|
|
81
|
+
return { file, bound }
|
|
82
|
+
} finally {
|
|
83
|
+
await db.close()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { DomainDeclaration } from '@crombolo/server'
|
|
2
|
+
import { Args, Flags } from '@oclif/core'
|
|
3
|
+
import { BaseCommand } from '../../base.ts'
|
|
4
|
+
import { appliedFields, rewriteDomains } from '../../support/domains.ts'
|
|
5
|
+
import { many } from '../../support/flags.ts'
|
|
6
|
+
import { siteConfig } from '../../support/site.ts'
|
|
7
|
+
|
|
8
|
+
export default class DomainsSet extends BaseCommand {
|
|
9
|
+
static override summary = "write a page's hostnames to domains.toml and apply it"
|
|
10
|
+
static override description =
|
|
11
|
+
`The page's hostnames are replaced, so running it twice is running it once. \${VAR} in a host is read from the environment when it is applied.`
|
|
12
|
+
static override examples = [
|
|
13
|
+
'<%= config.bin %> domains set / --host example.com --host fr.example.com=fr-FR',
|
|
14
|
+
]
|
|
15
|
+
static override args = {
|
|
16
|
+
page: Args.string({ required: true, description: 'a path, a uuid, or / for the root page' }),
|
|
17
|
+
}
|
|
18
|
+
static override flags = {
|
|
19
|
+
host: many('a hostname, with its culture after =; repeatable', '<host>[=<culture>]'),
|
|
20
|
+
'default-culture': Flags.string({ summary: "the page's fallback culture", helpValue: '<iso>' }),
|
|
21
|
+
}
|
|
22
|
+
static override audit = {
|
|
23
|
+
category: 'settings',
|
|
24
|
+
action: 'SetDomains',
|
|
25
|
+
entityType: 'Domain',
|
|
26
|
+
} as const
|
|
27
|
+
static override spec = {
|
|
28
|
+
effects: 'idempotent',
|
|
29
|
+
cardinality: 'bounded',
|
|
30
|
+
outputFields: [{ name: 'wrote', type: 'string' }, ...appliedFields],
|
|
31
|
+
errors: ['usage', 'not_found', 'refused'],
|
|
32
|
+
} as const
|
|
33
|
+
|
|
34
|
+
async run() {
|
|
35
|
+
const { args, flags } = await this.parse(DomainsSet)
|
|
36
|
+
const defaultCulture = flags['default-culture']
|
|
37
|
+
if (flags.host.length === 0 && !defaultCulture)
|
|
38
|
+
this.fail('usage', 'domains set needs --host <host>[=<culture>], or --default-culture <iso>.')
|
|
39
|
+
const add: DomainDeclaration[] = []
|
|
40
|
+
if (flags.host.length === 0 && defaultCulture)
|
|
41
|
+
add.push({ node: args.page, host: '', defaultCulture })
|
|
42
|
+
for (const [index, entry] of flags.host.entries()) {
|
|
43
|
+
const [host = '', culture] = entry.split('=')
|
|
44
|
+
add.push({
|
|
45
|
+
node: args.page,
|
|
46
|
+
host,
|
|
47
|
+
culture,
|
|
48
|
+
// The node's own fallback culture belongs to the node, so it is written
|
|
49
|
+
// once rather than repeated on every hostname.
|
|
50
|
+
defaultCulture: index === 0 ? defaultCulture : undefined,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
return rewriteDomains(this, await siteConfig(), args.page, add)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { undoDomainsFile } from '@crombolo/server'
|
|
2
|
+
import { BaseCommand } from '../../base.ts'
|
|
3
|
+
import { here } from '../../support/describe.ts'
|
|
4
|
+
import { appliedFields, applyDomains } from '../../support/domains.ts'
|
|
5
|
+
import { siteConfig } from '../../support/site.ts'
|
|
6
|
+
|
|
7
|
+
export default class DomainsUndo extends BaseCommand {
|
|
8
|
+
static override summary = 'put back the file the last write replaced, and apply it'
|
|
9
|
+
static override audit = {
|
|
10
|
+
category: 'settings',
|
|
11
|
+
action: 'UndoDomains',
|
|
12
|
+
entityType: 'Domain',
|
|
13
|
+
} as const
|
|
14
|
+
static override spec = {
|
|
15
|
+
effects: 'non_idempotent',
|
|
16
|
+
cardinality: 'bounded',
|
|
17
|
+
outputFields: [{ name: 'restored', type: 'string' }, ...appliedFields],
|
|
18
|
+
errors: ['not_found', 'refused'],
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
async run() {
|
|
22
|
+
await this.parse(DomainsUndo)
|
|
23
|
+
const config = await siteConfig()
|
|
24
|
+
const undone = await undoDomainsFile(config.siteDir)
|
|
25
|
+
if (!undone.ok) this.fail('not_found', undone.message ?? 'There is nothing to undo.')
|
|
26
|
+
this.say(` restored ${here(undone.file)}`)
|
|
27
|
+
return { restored: here(undone.file), ...(await applyDomains(this, config)) }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { packageNameFor } from '@crombolo/transfer'
|
|
3
|
+
import { Flags } from '@oclif/core'
|
|
4
|
+
import { BaseCommand } from '../base.ts'
|
|
5
|
+
import * as ops from '../operations/commands.ts'
|
|
6
|
+
import { counted, describeBytes, here } from '../support/describe.ts'
|
|
7
|
+
import { passphraseToSeal } from '../support/prompt.ts'
|
|
8
|
+
import { siteConfig } from '../support/site.ts'
|
|
9
|
+
|
|
10
|
+
/** The whole site as one file (story 104). */
|
|
11
|
+
export default class Export extends BaseCommand {
|
|
12
|
+
static override summary =
|
|
13
|
+
'write the whole site to one file, to move it to another machine or host'
|
|
14
|
+
static override description = [
|
|
15
|
+
'Schema, components, styles, scripts, dictionary, every page and media item and their files. Domains and people stay behind unless --with-members or --with-users, which are encrypted.',
|
|
16
|
+
'--anonymise replaces every personal value as it writes.',
|
|
17
|
+
].join('\n\n')
|
|
18
|
+
static override examples = ['<%= config.bin %> export --site --out site.tgz']
|
|
19
|
+
static override flags = {
|
|
20
|
+
site: Flags.boolean({
|
|
21
|
+
summary: 'the whole site (required: a part of it is `content export`)',
|
|
22
|
+
required: true,
|
|
23
|
+
}),
|
|
24
|
+
out: Flags.string({ summary: 'the file to write', helpValue: '<file.tgz>' }),
|
|
25
|
+
'no-media': Flags.boolean({ summary: 'leave the media files out' }),
|
|
26
|
+
drafts: Flags.boolean({ summary: 'drafts too' }),
|
|
27
|
+
'with-members': Flags.boolean({ summary: 'members, encrypted' }),
|
|
28
|
+
'with-users': Flags.boolean({ summary: 'back-office users, encrypted' }),
|
|
29
|
+
anonymise: Flags.boolean({ summary: 'replace every personal value as it writes' }),
|
|
30
|
+
name: Flags.string({ summary: "the package's npm name", helpValue: '<npm name>' }),
|
|
31
|
+
'package-version': Flags.string({ summary: "the package's version", helpValue: '<version>' }),
|
|
32
|
+
}
|
|
33
|
+
static override spec = {
|
|
34
|
+
effects: 'non_idempotent',
|
|
35
|
+
cardinality: 'single',
|
|
36
|
+
outputFields: [
|
|
37
|
+
{ name: 'out', type: 'string' },
|
|
38
|
+
{ name: 'package', type: 'object' },
|
|
39
|
+
{ name: 'counts', type: 'object' },
|
|
40
|
+
{ name: 'sections', type: 'object' },
|
|
41
|
+
{ name: 'media', type: 'object' },
|
|
42
|
+
{ name: 'people', type: 'object' },
|
|
43
|
+
{ name: 'anonymised', type: 'integer', nullable: true },
|
|
44
|
+
],
|
|
45
|
+
errors: ['usage', 'auth', 'io'],
|
|
46
|
+
} as const
|
|
47
|
+
|
|
48
|
+
async run() {
|
|
49
|
+
const { flags } = await this.parse(Export)
|
|
50
|
+
const config = await siteConfig()
|
|
51
|
+
const withPeople = flags['with-members'] || flags['with-users']
|
|
52
|
+
const out = resolve(
|
|
53
|
+
flags.out ??
|
|
54
|
+
`${packageNameFor(`${config.siteName}-site`)}-${new Date().toISOString().slice(0, 10)}.tgz`,
|
|
55
|
+
)
|
|
56
|
+
const result = await ops.siteExport(config, {
|
|
57
|
+
out,
|
|
58
|
+
media: !flags['no-media'],
|
|
59
|
+
drafts: flags.drafts,
|
|
60
|
+
withMembers: flags['with-members'],
|
|
61
|
+
withUsers: flags['with-users'],
|
|
62
|
+
anonymise: flags.anonymise,
|
|
63
|
+
passphrase:
|
|
64
|
+
withPeople && !(flags.anonymise && !flags['with-members'])
|
|
65
|
+
? await passphraseToSeal()
|
|
66
|
+
: undefined,
|
|
67
|
+
package: { name: flags.name, version: flags['package-version'] },
|
|
68
|
+
})
|
|
69
|
+
this.say(` wrote ${here(result.out)}`)
|
|
70
|
+
this.say(` package ${result.package.name}@${result.package.version} (private)`)
|
|
71
|
+
this.say(` content ${counted(result.counts, 'none')}, unencrypted`)
|
|
72
|
+
if (Object.keys(result.sections).length > 0) this.say(` files ${counted(result.sections)}`)
|
|
73
|
+
if (result.media.omitted)
|
|
74
|
+
this.say(
|
|
75
|
+
' media left out (--no-media): importing it brings the media items without their files',
|
|
76
|
+
)
|
|
77
|
+
else
|
|
78
|
+
this.say(` media ${result.media.carried} file(s), ${describeBytes(result.media.bytes)}`)
|
|
79
|
+
for (const key of result.media.missing)
|
|
80
|
+
this.warning(` missing ${key} is not in this environment's media store`)
|
|
81
|
+
if (result.people.members + result.people.users > 0)
|
|
82
|
+
this.say(
|
|
83
|
+
` people ${result.people.members} member(s), ${result.people.users} user(s), encrypted`,
|
|
84
|
+
)
|
|
85
|
+
else this.say(' people left out: pass --with-members or --with-users to bring them')
|
|
86
|
+
if (result.anonymised !== undefined)
|
|
87
|
+
this.say(
|
|
88
|
+
` anonymised ${result.anonymised} email address(es) replaced; no back-office users`,
|
|
89
|
+
)
|
|
90
|
+
return {
|
|
91
|
+
out: here(result.out),
|
|
92
|
+
package: result.package,
|
|
93
|
+
counts: result.counts,
|
|
94
|
+
sections: result.sections,
|
|
95
|
+
media: result.media,
|
|
96
|
+
people: result.people,
|
|
97
|
+
anonymised: result.anonymised ?? null,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|