@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.
Files changed (104) hide show
  1. package/bin/crombolo.ts +4 -3166
  2. package/oclif.manifest.json +8077 -0
  3. package/package.json +79 -8
  4. package/src/base.ts +134 -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/api-client/rotate.ts +57 -0
  9. package/src/commands/assets/index.ts +6 -0
  10. package/src/commands/assets/list.ts +27 -0
  11. package/src/commands/assets/new.ts +42 -0
  12. package/src/commands/bundle/purge.ts +71 -0
  13. package/src/commands/cache/rebuild.ts +56 -0
  14. package/src/commands/components/check.ts +58 -0
  15. package/src/commands/components/index.ts +6 -0
  16. package/src/commands/components/list.ts +40 -0
  17. package/src/commands/components/new.ts +49 -0
  18. package/src/commands/content/check.ts +102 -0
  19. package/src/commands/content/export.ts +116 -0
  20. package/src/commands/content/import.ts +109 -0
  21. package/src/commands/content/publish.ts +21 -0
  22. package/src/commands/content/revert.ts +137 -0
  23. package/src/commands/content/runs.ts +62 -0
  24. package/src/commands/content/unpublish.ts +21 -0
  25. package/src/commands/dictionary/export.ts +49 -0
  26. package/src/commands/dictionary/import.ts +46 -0
  27. package/src/commands/domains/apply.ts +23 -0
  28. package/src/commands/domains/clear.ts +27 -0
  29. package/src/commands/domains/index.ts +6 -0
  30. package/src/commands/domains/list.ts +86 -0
  31. package/src/commands/domains/set.ts +55 -0
  32. package/src/commands/domains/undo.ts +29 -0
  33. package/src/commands/export.ts +100 -0
  34. package/src/commands/generate.ts +33 -0
  35. package/src/commands/health.ts +90 -0
  36. package/src/commands/import/index.ts +175 -0
  37. package/src/commands/import/umbraco/apply.ts +128 -0
  38. package/src/commands/import/umbraco/report.ts +56 -0
  39. package/src/commands/index/list.ts +45 -0
  40. package/src/commands/index/rebuild.ts +66 -0
  41. package/src/commands/init.ts +76 -0
  42. package/src/commands/login.ts +38 -0
  43. package/src/commands/logout.ts +36 -0
  44. package/src/commands/maintenance/pause.ts +29 -0
  45. package/src/commands/maintenance/resume.ts +20 -0
  46. package/src/commands/maintenance/status.ts +34 -0
  47. package/src/commands/media/export.ts +91 -0
  48. package/src/commands/media/fetch.ts +62 -0
  49. package/src/commands/media/import.ts +175 -0
  50. package/src/commands/pull.ts +210 -0
  51. package/src/commands/push.ts +108 -0
  52. package/src/commands/recycle-bin/empty.ts +157 -0
  53. package/src/commands/remote/add.ts +78 -0
  54. package/src/commands/remote/job.ts +53 -0
  55. package/src/commands/remote/list.ts +41 -0
  56. package/src/commands/remote/remove.ts +26 -0
  57. package/src/commands/schema.ts +32 -0
  58. package/src/commands/start.ts +64 -0
  59. package/src/commands/status.ts +103 -0
  60. package/src/commands/types/add-property.ts +116 -0
  61. package/src/commands/types/check.ts +88 -0
  62. package/src/commands/types/export.ts +32 -0
  63. package/src/commands/types/new.ts +116 -0
  64. package/src/commands/types/purge.ts +65 -0
  65. package/src/commands/types/rewrite.ts +30 -0
  66. package/src/commands/types/sync.ts +66 -0
  67. package/src/commands/upgrade/check.ts +69 -0
  68. package/src/commands/upgrade/index.ts +120 -0
  69. package/src/commands/upgrade/ledger.ts +55 -0
  70. package/src/commands/users/disable.ts +54 -0
  71. package/src/commands/users/enable.ts +54 -0
  72. package/src/commands/users/invite.ts +82 -0
  73. package/src/commands/users/list.ts +62 -0
  74. package/src/commands/users/reset-2fa.ts +48 -0
  75. package/src/commands/users/reset-password.ts +58 -0
  76. package/src/commands/users/unlock.ts +54 -0
  77. package/src/commands/whoami.ts +41 -0
  78. package/src/errors.ts +90 -0
  79. package/src/load.ts +59 -0
  80. package/src/main.ts +41 -0
  81. package/src/manifest.ts +30 -0
  82. package/src/plugin.ts +38 -0
  83. package/src/reference.ts +118 -0
  84. package/src/remote/client.ts +25 -11
  85. package/src/remote/remotes-file.ts +3 -2
  86. package/src/remote/transfer.ts +27 -11
  87. package/src/support/components.ts +7 -0
  88. package/src/support/describe.ts +179 -0
  89. package/src/support/domains.ts +89 -0
  90. package/src/support/flags.ts +157 -0
  91. package/src/support/media-tree.ts +72 -0
  92. package/src/support/people.ts +134 -0
  93. package/src/support/prompt.ts +126 -0
  94. package/src/support/publish.ts +142 -0
  95. package/src/support/remote.ts +57 -0
  96. package/src/support/serve.ts +460 -0
  97. package/src/support/site.ts +26 -0
  98. package/src/support/types.ts +112 -0
  99. package/src/support/umbraco.ts +86 -0
  100. package/templates/basic/bundle/content/bundle.json +1 -1
  101. package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
  102. package/templates/basic/bundle/package.json +1 -1
  103. package/templates/demo/harbourstone/bundle/package.json +1 -1
  104. package/templates/starter/bundle/package.json +1 -1
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Asking a person: a confirmation, a typed name, a passphrase. Every question
3
+ * goes to stderr, so stdout stays the result; with no terminal to ask on, a
4
+ * confirmation refuses at once, naming the flag that answers it, and never
5
+ * reads stdin.
6
+ */
7
+ import type { CromboloConfig } from '@crombolo/server'
8
+ import { CliError } from '../errors.ts'
9
+ import * as ops from '../operations/commands.ts'
10
+
11
+ /** Where a script gives a bundle's passphrase, rather than typing it. */
12
+ export const PASSPHRASE_ENV = 'CROMBOLO_BUNDLE_PASSPHRASE'
13
+ export const MIN_PASSPHRASE = 12
14
+
15
+ export const interactive = (): boolean => process.stdin.isTTY === true
16
+
17
+ async function readLine(question: string): Promise<string> {
18
+ process.stderr.write(`${question} `)
19
+ for await (const line of console) return line
20
+ return ''
21
+ }
22
+
23
+ /**
24
+ * Asks before something that cannot be taken back. `answer` is what must be
25
+ * typed (a name, or `y`); off a terminal it throws `confirmation_required`, and
26
+ * a person who answers anything else gets `refused`.
27
+ */
28
+ export async function confirm(
29
+ question: string,
30
+ options: { bypass: string; answer?: string; declined?: string },
31
+ ): Promise<void> {
32
+ if (!interactive())
33
+ throw new CliError('confirmation_required', question.replace(/[?:]\s*$/, '.'), {
34
+ hint: `Pass ${options.bypass} to go on without being asked.`,
35
+ })
36
+ const typed = (await readLine(question)).trim()
37
+ const yes = options.answer === undefined ? /^y(es)?$/i.test(typed) : typed === options.answer
38
+ if (!yes) throw new CliError('refused', options.declined ?? 'Nothing was changed.')
39
+ }
40
+
41
+ /** A line typed without echo. */
42
+ export async function readSecret(question: string): Promise<string> {
43
+ process.stderr.write(question)
44
+ const stdin = process.stdin
45
+ stdin.setRawMode(true)
46
+ stdin.resume()
47
+ return new Promise((done) => {
48
+ let typed = ''
49
+ const onData = (chunk: Buffer) => {
50
+ for (const char of chunk.toString('utf8')) {
51
+ if (char === '\u0003') {
52
+ process.stderr.write('\n')
53
+ process.exit(130)
54
+ }
55
+ if (char === '\r' || char === '\n') {
56
+ stdin.setRawMode(false)
57
+ stdin.pause()
58
+ stdin.off('data', onData)
59
+ process.stderr.write('\n')
60
+ done(typed)
61
+ return
62
+ }
63
+ if (char === '\u007f' || char === '\b') typed = typed.slice(0, -1)
64
+ else typed += char
65
+ }
66
+ }
67
+ stdin.on('data', onData)
68
+ })
69
+ }
70
+
71
+ /** Everything piped to stdin, without its trailing newline. */
72
+ export async function readStdin(): Promise<string> {
73
+ return (await Bun.stdin.text()).replace(/\r?\n$/, '')
74
+ }
75
+
76
+ const needPassphrase = (what: string) =>
77
+ new CliError('auth', `${what} needs a passphrase.`, {
78
+ hint: `Set ${PASSPHRASE_ENV}, or run it in a terminal to be asked.`,
79
+ })
80
+
81
+ /** The passphrase to open a bundle with, when it is sealed: the environment's, or asked for. */
82
+ export async function passphraseToOpen(
83
+ target: string,
84
+ config: CromboloConfig,
85
+ ): Promise<string | undefined> {
86
+ const given = process.env[PASSPHRASE_ENV]
87
+ if (given) return given
88
+ let sealed = false
89
+ try {
90
+ sealed = ops.readBundle(target, config.siteDir).set.sealed === true
91
+ } catch {
92
+ // The command that follows reads it again and says what is wrong with it.
93
+ return undefined
94
+ }
95
+ if (!sealed) return undefined
96
+ if (!interactive()) throw needPassphrase(`${target} is encrypted: opening it`)
97
+ return readSecret(' Passphrase for this bundle: ')
98
+ }
99
+
100
+ /** The passphrase for the people a file carries: the environment's, asked for, or none. */
101
+ export async function passphraseForPeople(): Promise<string | undefined> {
102
+ return (
103
+ process.env[PASSPHRASE_ENV] ??
104
+ (interactive() ? await readSecret(' Passphrase for its people: ') : undefined)
105
+ )
106
+ }
107
+
108
+ /** A new passphrase to seal an export with: the environment's, or asked for twice. */
109
+ export async function passphraseToSeal(): Promise<string> {
110
+ const given = process.env[PASSPHRASE_ENV]
111
+ if (given) {
112
+ if (given.length < MIN_PASSPHRASE)
113
+ throw new CliError('usage', `${PASSPHRASE_ENV} is shorter than ${MIN_PASSPHRASE} characters.`)
114
+ return given
115
+ }
116
+ if (!interactive()) throw needPassphrase('An encrypted export')
117
+ for (;;) {
118
+ const first = await readSecret(' Passphrase to encrypt this bundle with: ')
119
+ if (first.length < MIN_PASSPHRASE) {
120
+ process.stderr.write(` At least ${MIN_PASSPHRASE} characters, please.\n`)
121
+ continue
122
+ }
123
+ if ((await readSecret(' Again, to confirm: ')) === first) return first
124
+ process.stderr.write(' They did not match.\n')
125
+ }
126
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Taking a page or a branch live, and back off again, without the backoffice.
3
+ *
4
+ * A publish can be refused per node — an unpublished ancestor, an unpublished
5
+ * mandatory language, an empty mandatory property — so the whole selection is
6
+ * checked before any of it is written. Half a published branch is a state
7
+ * nobody asked for and nobody can reason about.
8
+ */
9
+
10
+ import { ObjectTypes } from '@crombolo/core'
11
+ import {
12
+ currentSchemaState,
13
+ DocumentRepository,
14
+ describeRefFailure,
15
+ resolveNodeRef,
16
+ } from '@crombolo/data'
17
+ import { bootstrapDatabase } from '@crombolo/server'
18
+ import { Args, Flags } from '@oclif/core'
19
+ import { CliError } from '../errors.ts'
20
+ import { many } from './flags.ts'
21
+ import { type Io, siteConfig } from './site.ts'
22
+
23
+ export const publishArgs = {
24
+ page: Args.string({ required: true, description: 'the page: a path or a uuid' }),
25
+ }
26
+
27
+ export const publishFlags = {
28
+ descendants: Flags.boolean({ summary: 'the branch below it too' }),
29
+ culture: many('only this culture; repeatable', '<iso>'),
30
+ at: Flags.string({ summary: 'schedule it for this time instead', helpValue: '<time>' }),
31
+ until: Flags.string({ summary: 'schedule the opposite for this time', helpValue: '<time>' }),
32
+ }
33
+
34
+ export const publishSpec = {
35
+ effects: 'idempotent',
36
+ cardinality: 'bounded',
37
+ outputFields: [
38
+ {
39
+ name: 'pages',
40
+ type: 'array',
41
+ items: {
42
+ type: 'object',
43
+ fields: [
44
+ { name: 'key', type: 'string' },
45
+ { name: 'name', type: 'string' },
46
+ { name: 'changed', type: 'boolean' },
47
+ ],
48
+ },
49
+ },
50
+ { name: 'scheduled', type: 'boolean' },
51
+ { name: 'cultures', type: 'array', items: { type: 'string' } },
52
+ ],
53
+ errors: ['not_found', 'refused', 'usage'],
54
+ } as const
55
+
56
+ const when = (value: string | undefined, what: string): Date | null => {
57
+ if (value === undefined) return null
58
+ const date = new Date(value)
59
+ if (Number.isNaN(date.getTime()))
60
+ throw new CliError('usage', `${what} "${value}" is not a date I can read.`, {
61
+ hint: 'Try 2026-10-01T09:00.',
62
+ })
63
+ return date
64
+ }
65
+
66
+ export async function publishPages(
67
+ io: Io,
68
+ publishing: boolean,
69
+ ref: string,
70
+ flags: { descendants: boolean; culture: string[]; at?: string; until?: string },
71
+ ) {
72
+ const cultures = flags.culture
73
+ const publishTime = when(flags.at, '--at')
74
+ const unpublishTime = when(flags.until, '--until')
75
+ const config = await siteConfig()
76
+ const { db } = await bootstrapDatabase(config)
77
+ try {
78
+ const resolved = await resolveNodeRef(db, [ObjectTypes.Document], ref)
79
+ if (!resolved.ok) throw new CliError('not_found', describeRefFailure(ref, resolved))
80
+ const state = await currentSchemaState(db)
81
+ const docs = new DocumentRepository(db, {
82
+ nodeState: { version: state.version, revision: state.revision },
83
+ nodeId: config.nodeId,
84
+ })
85
+ // Parents before children, which is the order publishing has to happen in.
86
+ const targets = [
87
+ resolved.node,
88
+ ...(flags.descendants
89
+ ? await docs.nodes.descendants(resolved.node, [ObjectTypes.Document])
90
+ : []),
91
+ ]
92
+ const items: { key: string; name: string; changed: boolean }[] = []
93
+
94
+ if (publishTime || unpublishTime) {
95
+ // A schedule is a statement about the future, so today's blockers are not
96
+ // the question: the background job checks them when the time comes.
97
+ for (const node of targets) {
98
+ await docs.setSchedule(node.key, [
99
+ { culture: cultures[0] ?? null, publishTime, unpublishTime },
100
+ ])
101
+ items.push({ key: node.key, name: node.text ?? node.key, changed: true })
102
+ io.say(
103
+ ` scheduled ${node.text ?? node.key}${publishTime ? ` from ${publishTime.toISOString()}` : ''}${unpublishTime ? ` until ${unpublishTime.toISOString()}` : ''}`,
104
+ )
105
+ }
106
+ return { pages: items, scheduled: true, cultures }
107
+ }
108
+
109
+ if (publishing) {
110
+ const blocked: Array<{ name: string; reasons: string[] }> = []
111
+ // Everything in the selection counts as published for the check: the run
112
+ // publishes parents first, so a child is not blocked by an ancestor this
113
+ // very command is about to take live.
114
+ const selection = new Set(targets.map((node) => node.id))
115
+ for (const node of targets) {
116
+ const reasons = await docs.publishBlockers(node.key, cultures, selection)
117
+ if (reasons.length > 0) blocked.push({ name: node.text ?? node.key, reasons })
118
+ }
119
+ if (blocked.length > 0)
120
+ throw new CliError(
121
+ 'refused',
122
+ `${blocked.length} of ${targets.length} page(s) cannot be published, so nothing was published.`,
123
+ { details: blocked.map((entry) => `${entry.name} ${entry.reasons.join('; ')}`) },
124
+ )
125
+ }
126
+
127
+ for (const node of targets) {
128
+ const result = publishing
129
+ ? await docs.publish(node.key, cultures.length > 0 ? cultures : null)
130
+ : await docs.unpublish(node.key, cultures.length > 0 ? cultures : null)
131
+ items.push({ key: node.key, name: node.text ?? node.key, changed: Boolean(result) })
132
+ if (!result) continue
133
+ io.say(` ${publishing ? 'published' : 'unpublished'} ${node.text ?? node.key}`)
134
+ }
135
+ io.say(
136
+ `\n${targets.length} page(s) ${publishing ? 'published' : 'unpublished'}${cultures.length > 0 ? ` in ${cultures.join(', ')}` : ''}.`,
137
+ )
138
+ return { pages: items, scheduled: false, cultures }
139
+ } finally {
140
+ await db.close()
141
+ }
142
+ }
@@ -0,0 +1,57 @@
1
+ /** What the remote commands share: the remote a command names, and the browser it opens. */
2
+ import { osKeychain } from '@crombolo/server/remote-credentials'
3
+ import { CliError } from '../errors.ts'
4
+ import { discover, RemoteClient } from '../remote/client.ts'
5
+ import { pickRemote, readRemotes } from '../remote/remotes-file.ts'
6
+
7
+ export const keychain = () => osKeychain()
8
+
9
+ export function openInBrowser(url: string, disabled: boolean): void {
10
+ if (disabled) return
11
+ const opener =
12
+ process.platform === 'darwin'
13
+ ? ['open']
14
+ : process.platform === 'win32'
15
+ ? ['cmd', '/c', 'start', '']
16
+ : ['xdg-open']
17
+ try {
18
+ Bun.spawn([...opener, url], { stdout: 'ignore', stderr: 'ignore' })
19
+ } catch {
20
+ // Printed already; opening it is a convenience.
21
+ }
22
+ }
23
+
24
+ /** The remote a command names, signed in or not, with the environment it goes to. */
25
+ export async function connectRemote(name: string | undefined, env: string | undefined) {
26
+ const entry = pickRemote(process.cwd(), name)
27
+ if (!entry) {
28
+ const known = readRemotes(process.cwd())
29
+ if (known.length === 0)
30
+ throw new CliError('not_found', 'No remotes yet.', {
31
+ hint: '`crombolo remote add <name> <url>` adds one.',
32
+ })
33
+ throw new CliError(
34
+ name ? 'not_found' : 'usage',
35
+ name ? `There is no remote "${name}".` : 'Which remote?',
36
+ { hint: `Name one of: ${known.map((r) => r.name).join(', ')}.` },
37
+ )
38
+ }
39
+ const discovery = await discover(entry.url)
40
+ const client = new RemoteClient(discovery, keychain())
41
+ const environment =
42
+ env ??
43
+ entry.environment ??
44
+ (discovery.environments.length === 1 ? discovery.environments[0]?.id : undefined)
45
+ return { entry, discovery, client, environment }
46
+ }
47
+
48
+ /** The environment, or a usage error naming the ones there are. */
49
+ export function needEnvironment(
50
+ environment: string | undefined,
51
+ discovery: { environments: { id: string }[] },
52
+ ): string {
53
+ if (environment) return environment
54
+ throw new CliError('usage', 'Which environment?', {
55
+ hint: `Pass --env with one of: ${discovery.environments.map((e) => e.id).join(', ')}.`,
56
+ })
57
+ }