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