@crombolo/cli 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/crombolo.ts +4 -2767
- package/oclif.manifest.json +6710 -0
- package/package.json +58 -7
- package/src/base.ts +129 -0
- package/src/classify.ts +119 -0
- package/src/clispec.ts +163 -0
- package/src/commands/admin/reset-password.ts +53 -0
- package/src/commands/assets/index.ts +6 -0
- package/src/commands/assets/list.ts +27 -0
- package/src/commands/assets/new.ts +42 -0
- package/src/commands/bundle/purge.ts +71 -0
- package/src/commands/components/check.ts +58 -0
- package/src/commands/components/index.ts +6 -0
- package/src/commands/components/list.ts +40 -0
- package/src/commands/components/new.ts +49 -0
- package/src/commands/content/check.ts +102 -0
- package/src/commands/content/export.ts +116 -0
- package/src/commands/content/import.ts +109 -0
- package/src/commands/content/publish.ts +21 -0
- package/src/commands/content/revert.ts +137 -0
- package/src/commands/content/runs.ts +62 -0
- package/src/commands/content/unpublish.ts +21 -0
- package/src/commands/dictionary/export.ts +49 -0
- package/src/commands/dictionary/import.ts +46 -0
- package/src/commands/domains/apply.ts +23 -0
- package/src/commands/domains/clear.ts +27 -0
- package/src/commands/domains/index.ts +6 -0
- package/src/commands/domains/list.ts +86 -0
- package/src/commands/domains/set.ts +55 -0
- package/src/commands/domains/undo.ts +29 -0
- package/src/commands/export.ts +100 -0
- package/src/commands/generate.ts +33 -0
- package/src/commands/import/index.ts +175 -0
- package/src/commands/import/umbraco/apply.ts +128 -0
- package/src/commands/import/umbraco/report.ts +56 -0
- package/src/commands/init.ts +76 -0
- package/src/commands/login.ts +38 -0
- package/src/commands/logout.ts +36 -0
- package/src/commands/maintenance/pause.ts +29 -0
- package/src/commands/maintenance/resume.ts +20 -0
- package/src/commands/maintenance/status.ts +34 -0
- package/src/commands/media/fetch.ts +62 -0
- package/src/commands/pull.ts +210 -0
- package/src/commands/push.ts +108 -0
- package/src/commands/remote/add.ts +78 -0
- package/src/commands/remote/job.ts +53 -0
- package/src/commands/remote/list.ts +41 -0
- package/src/commands/remote/remove.ts +26 -0
- package/src/commands/schema.ts +32 -0
- package/src/commands/start.ts +64 -0
- package/src/commands/status.ts +103 -0
- package/src/commands/types/add-property.ts +116 -0
- package/src/commands/types/check.ts +88 -0
- package/src/commands/types/export.ts +32 -0
- package/src/commands/types/new.ts +116 -0
- package/src/commands/types/purge.ts +65 -0
- package/src/commands/types/rewrite.ts +30 -0
- package/src/commands/types/sync.ts +66 -0
- package/src/commands/upgrade/check.ts +69 -0
- package/src/commands/upgrade/index.ts +120 -0
- package/src/commands/upgrade/ledger.ts +55 -0
- package/src/commands/whoami.ts +41 -0
- package/src/errors.ts +90 -0
- package/src/main.ts +23 -0
- package/src/manifest.ts +20 -0
- package/src/reference.ts +118 -0
- package/src/remote/client.ts +297 -0
- package/src/remote/remotes-file.ts +81 -0
- package/src/remote/transfer.ts +353 -0
- package/src/support/components.ts +7 -0
- package/src/support/describe.ts +179 -0
- package/src/support/domains.ts +89 -0
- package/src/support/flags.ts +157 -0
- package/src/support/prompt.ts +126 -0
- package/src/support/publish.ts +142 -0
- package/src/support/remote.ts +58 -0
- package/src/support/serve.ts +460 -0
- package/src/support/site.ts +26 -0
- package/src/support/types.ts +112 -0
- package/src/support/umbraco.ts +86 -0
- package/templates/basic/bundle/content/bundle.json +1 -1
- package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
- package/templates/basic/bundle/package.json +1 -1
- package/templates/demo/harbourstone/bundle/package.json +1 -1
- package/templates/starter/bundle/package.json +1 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** Flags more than one command takes, and what they come to. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isResolution,
|
|
5
|
+
type NodeResolutions,
|
|
6
|
+
parseResolveFlags,
|
|
7
|
+
RESOLUTIONS,
|
|
8
|
+
type Resolution,
|
|
9
|
+
} from '@crombolo/transfer'
|
|
10
|
+
import { Flags } from '@oclif/core'
|
|
11
|
+
import { CliError } from '../errors.ts'
|
|
12
|
+
import type * as ops from '../operations/commands.ts'
|
|
13
|
+
import type { Io } from './site.ts'
|
|
14
|
+
|
|
15
|
+
export const backupTaken = Flags.boolean({
|
|
16
|
+
summary: 'a backup was taken another way: Postgres needs this, or CROMBOLO_PG_DUMP',
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export const dryRun = Flags.boolean({ summary: 'say what it would do, and change nothing' })
|
|
20
|
+
|
|
21
|
+
/** A repeatable value flag that takes one value each time, so it never swallows an argument. */
|
|
22
|
+
export const many = (summary: string, helpValue?: string) =>
|
|
23
|
+
Flags.string({ summary, helpValue, multiple: true, multipleNonGreedy: true, default: [] })
|
|
24
|
+
|
|
25
|
+
/** The flags `content check` and `content import` share: resolutions, placement, files. */
|
|
26
|
+
export const transferFlags = {
|
|
27
|
+
under: Flags.string({ summary: 'place the bundle under this page', helpValue: '<path|uuid>' }),
|
|
28
|
+
resolve: many(
|
|
29
|
+
'answer a finding: <key>=take-bundle|keep-local|skip, or <key>/<property>[@<culture>]=take-bundle|keep-local',
|
|
30
|
+
'<answer>',
|
|
31
|
+
),
|
|
32
|
+
'resolve-all': Flags.string({
|
|
33
|
+
summary: 'answer every finding the same way',
|
|
34
|
+
options: [...RESOLUTIONS],
|
|
35
|
+
}),
|
|
36
|
+
'allow-missing-blobs': Flags.boolean({
|
|
37
|
+
summary: 'import media items whose files the bundle does not carry',
|
|
38
|
+
}),
|
|
39
|
+
publish: Flags.boolean({ summary: 'publish what it imports' }),
|
|
40
|
+
'no-schema': Flags.boolean({ summary: 'decline the schema the bundle carries' }),
|
|
41
|
+
'no-files': Flags.boolean({ summary: 'decline the views, styles and scripts it carries' }),
|
|
42
|
+
'replace-files': Flags.boolean({
|
|
43
|
+
summary: 'allow replacing files this site has; the originals are kept for a revert',
|
|
44
|
+
}),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TransferFlagValues {
|
|
48
|
+
under?: string
|
|
49
|
+
resolve: string[]
|
|
50
|
+
'resolve-all'?: string
|
|
51
|
+
'allow-missing-blobs': boolean
|
|
52
|
+
publish: boolean
|
|
53
|
+
'no-schema': boolean
|
|
54
|
+
'no-files': boolean
|
|
55
|
+
'replace-files': boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseResolutions(values: readonly string[]): Record<string, NodeResolutions> {
|
|
59
|
+
const parsed = parseResolveFlags([...values])
|
|
60
|
+
if (parsed.problems.length > 0)
|
|
61
|
+
throw new CliError('usage', 'A --resolve answer could not be read.', {
|
|
62
|
+
details: parsed.problems,
|
|
63
|
+
})
|
|
64
|
+
return parsed.byNode
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function transferInput(bundle: string, flags: TransferFlagValues): ops.TransferInput {
|
|
68
|
+
const all = flags['resolve-all']
|
|
69
|
+
if (all !== undefined && !isResolution(all))
|
|
70
|
+
throw new CliError('usage', `"${all}" is not one of ${RESOLUTIONS.join(', ')}.`)
|
|
71
|
+
return {
|
|
72
|
+
bundle,
|
|
73
|
+
under: flags.under,
|
|
74
|
+
resolutions: parseResolutions(flags.resolve),
|
|
75
|
+
resolveAll: all as Resolution | undefined,
|
|
76
|
+
allowMissingBlobs: flags['allow-missing-blobs'],
|
|
77
|
+
publish: flags.publish,
|
|
78
|
+
// What a bundle carries is applied by default — that is the point of it
|
|
79
|
+
// carrying anything — so these decline a half, for someone who wants the
|
|
80
|
+
// content without the structure that came with it.
|
|
81
|
+
withoutSchema: flags['no-schema'],
|
|
82
|
+
withoutFiles: flags['no-files'],
|
|
83
|
+
replaceFiles: flags['replace-files'],
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Property values and media files in an export: left out, sealed, or — asked twice — open. */
|
|
88
|
+
export const contentFlags = {
|
|
89
|
+
'include-content': Flags.boolean({
|
|
90
|
+
summary: 'bring property values and media files, encrypted under a passphrase',
|
|
91
|
+
}),
|
|
92
|
+
'allow-content-unencrypted': Flags.boolean({
|
|
93
|
+
summary: 'with --include-content: write them readable',
|
|
94
|
+
}),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function contentMode(
|
|
98
|
+
io: Io,
|
|
99
|
+
flags: {
|
|
100
|
+
'include-content': boolean
|
|
101
|
+
'allow-content-unencrypted': boolean
|
|
102
|
+
'with-blobs'?: boolean
|
|
103
|
+
},
|
|
104
|
+
): 'omitted' | 'sealed' | 'open' {
|
|
105
|
+
const include = flags['include-content']
|
|
106
|
+
const open = flags['allow-content-unencrypted']
|
|
107
|
+
if (open && !include)
|
|
108
|
+
throw new CliError('usage', '--allow-content-unencrypted goes with --include-content.')
|
|
109
|
+
if (flags['with-blobs'] && !include)
|
|
110
|
+
throw new CliError(
|
|
111
|
+
'usage',
|
|
112
|
+
'Media files are content: --with-blobs goes with --include-content.',
|
|
113
|
+
)
|
|
114
|
+
if (open)
|
|
115
|
+
io.warning(
|
|
116
|
+
' warning property values and media files are written unencrypted: anyone with the files can read any personal information in them',
|
|
117
|
+
)
|
|
118
|
+
return !include ? 'omitted' : open ? 'open' : 'sealed'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Offset pagination for a list: `--limit` and `--offset`, and what was left out. */
|
|
122
|
+
export const pageFlags = (limit: number) => ({
|
|
123
|
+
limit: Flags.integer({ summary: 'how many to list', default: limit, min: 1 }),
|
|
124
|
+
offset: Flags.integer({ summary: 'how many to skip first', default: 0, min: 0 }),
|
|
125
|
+
fields: Flags.string({
|
|
126
|
+
summary: 'only these fields of each item, comma-separated',
|
|
127
|
+
helpValue: '<a,b>',
|
|
128
|
+
}),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
export const pagination = { style: 'offset', offsetArg: '--offset', limitArg: '--limit' } as const
|
|
132
|
+
|
|
133
|
+
/** One page of a list, in the envelope clispec asks for, narrowed to `--fields` when given. */
|
|
134
|
+
export function page<T extends object>(
|
|
135
|
+
items: readonly T[],
|
|
136
|
+
total: number,
|
|
137
|
+
flags: { offset: number; fields?: string },
|
|
138
|
+
): { items: Partial<T>[]; total: number; offset: number; truncated: boolean } {
|
|
139
|
+
const wanted = flags.fields
|
|
140
|
+
?.split(',')
|
|
141
|
+
.map((field) => field.trim())
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
const narrowed = wanted
|
|
144
|
+
? items.map(
|
|
145
|
+
(item) =>
|
|
146
|
+
Object.fromEntries(
|
|
147
|
+
Object.entries(item).filter(([key]) => wanted.includes(key)),
|
|
148
|
+
) as Partial<T>,
|
|
149
|
+
)
|
|
150
|
+
: [...items]
|
|
151
|
+
return {
|
|
152
|
+
items: narrowed,
|
|
153
|
+
total,
|
|
154
|
+
offset: flags.offset,
|
|
155
|
+
truncated: flags.offset + items.length < total,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -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,58 @@
|
|
|
1
|
+
/** What the remote commands share: the remote a command names, and the browser it opens. */
|
|
2
|
+
import { osKeychain } from '@crombolo/server'
|
|
3
|
+
import { CliError } from '../errors.ts'
|
|
4
|
+
import { discover, RemoteClient } from '../remote/client.ts'
|
|
5
|
+
import { pickRemote, readRemotes } from '../remote/remotes-file.ts'
|
|
6
|
+
import { cwd } from './site.ts'
|
|
7
|
+
|
|
8
|
+
export const keychain = () => osKeychain()
|
|
9
|
+
|
|
10
|
+
export function openInBrowser(url: string, disabled: boolean): void {
|
|
11
|
+
if (disabled) return
|
|
12
|
+
const opener =
|
|
13
|
+
process.platform === 'darwin'
|
|
14
|
+
? ['open']
|
|
15
|
+
: process.platform === 'win32'
|
|
16
|
+
? ['cmd', '/c', 'start', '']
|
|
17
|
+
: ['xdg-open']
|
|
18
|
+
try {
|
|
19
|
+
Bun.spawn([...opener, url], { stdout: 'ignore', stderr: 'ignore' })
|
|
20
|
+
} catch {
|
|
21
|
+
// Printed already; opening it is a convenience.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The remote a command names, signed in or not, with the environment it goes to. */
|
|
26
|
+
export async function connectRemote(name: string | undefined, env: string | undefined) {
|
|
27
|
+
const entry = pickRemote(cwd(), name)
|
|
28
|
+
if (!entry) {
|
|
29
|
+
const known = readRemotes(cwd())
|
|
30
|
+
if (known.length === 0)
|
|
31
|
+
throw new CliError('not_found', 'No remotes yet.', {
|
|
32
|
+
hint: '`crombolo remote add <name> <url>` adds one.',
|
|
33
|
+
})
|
|
34
|
+
throw new CliError(
|
|
35
|
+
name ? 'not_found' : 'usage',
|
|
36
|
+
name ? `There is no remote "${name}".` : 'Which remote?',
|
|
37
|
+
{ hint: `Name one of: ${known.map((r) => r.name).join(', ')}.` },
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
const discovery = await discover(entry.url)
|
|
41
|
+
const client = new RemoteClient(discovery, keychain())
|
|
42
|
+
const environment =
|
|
43
|
+
env ??
|
|
44
|
+
entry.environment ??
|
|
45
|
+
(discovery.environments.length === 1 ? discovery.environments[0]?.id : undefined)
|
|
46
|
+
return { entry, discovery, client, environment }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The environment, or a usage error naming the ones there are. */
|
|
50
|
+
export function needEnvironment(
|
|
51
|
+
environment: string | undefined,
|
|
52
|
+
discovery: { environments: { id: string }[] },
|
|
53
|
+
): string {
|
|
54
|
+
if (environment) return environment
|
|
55
|
+
throw new CliError('usage', 'Which environment?', {
|
|
56
|
+
hint: `Pass --env with one of: ${discovery.environments.map((e) => e.id).join(', ')}.`,
|
|
57
|
+
})
|
|
58
|
+
}
|