@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,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
|
+
}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serving a site: the boot with its retries, the bundles a start applies, the
|
|
3
|
+
* signals that stop it, and `start --from`, which makes a site out of a file.
|
|
4
|
+
* `crombolo start` and `crombolo pull` both end here.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { basename, join, resolve } from 'node:path'
|
|
9
|
+
import { AuthStore, resetAdminPassword } from '@crombolo/auth'
|
|
10
|
+
import { readSetupState, recordInLedger, TransferRunRepository, writeReport } from '@crombolo/data'
|
|
11
|
+
import {
|
|
12
|
+
assertPortAvailable,
|
|
13
|
+
type BootFailure,
|
|
14
|
+
backupBefore,
|
|
15
|
+
bootFailurePage,
|
|
16
|
+
bootRetryDelays,
|
|
17
|
+
bootstrapDatabase,
|
|
18
|
+
type CromboloConfig,
|
|
19
|
+
createServer,
|
|
20
|
+
describeBootFailure,
|
|
21
|
+
describeDatabase,
|
|
22
|
+
listeningBodyLimit,
|
|
23
|
+
mediaStoreFor,
|
|
24
|
+
placeBlobs,
|
|
25
|
+
type ServerHandle,
|
|
26
|
+
} from '@crombolo/server'
|
|
27
|
+
import { importBundle } from '@crombolo/transfer'
|
|
28
|
+
import { CliError } from '../errors.ts'
|
|
29
|
+
import * as ops from '../operations/commands.ts'
|
|
30
|
+
import { scaffoldFiles } from '../templates.ts'
|
|
31
|
+
import { banner, describePlaced, describeSync, printTransferFindings } from './describe.ts'
|
|
32
|
+
import { type TransferFlagValues, transferInput } from './flags.ts'
|
|
33
|
+
import { PASSPHRASE_ENV, passphraseForPeople, passphraseToOpen } from './prompt.ts'
|
|
34
|
+
import { cwd, hasSite, type Io, siteConfig } from './site.ts'
|
|
35
|
+
|
|
36
|
+
/** How long a shutdown gets before it is abandoned and the process leaves anyway. */
|
|
37
|
+
const SHUTDOWN_MS = 10_000
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stops the site on SIGINT and SIGTERM.
|
|
41
|
+
*
|
|
42
|
+
* Without a handler neither signal does anything at all in a container: `bun` is
|
|
43
|
+
* PID 1 there, and the kernel drops a signal PID 1 has registered no handler
|
|
44
|
+
* for. That is why Ctrl-C did nothing under `docker compose run` — the keystroke
|
|
45
|
+
* reaches the container's own terminal, which raises a SIGINT nothing was
|
|
46
|
+
* listening for — and why `docker stop` sat out its ten-second grace and then
|
|
47
|
+
* resorted to SIGKILL. Outside a container the default would at least have
|
|
48
|
+
* killed the process, so this was invisible there.
|
|
49
|
+
*
|
|
50
|
+
* `server.close()` is the point of it either way: the job runner, the event
|
|
51
|
+
* hubs, the component watcher, the poller and the database all get to stop,
|
|
52
|
+
* rather than being taken away mid-write. Connections are closed with the
|
|
53
|
+
* listener because the live-update sockets are long-lived, and waiting for a
|
|
54
|
+
* socket that is meant to stay open would be waiting for ever.
|
|
55
|
+
*
|
|
56
|
+
* A second signal, or a close that will not finish, leaves immediately.
|
|
57
|
+
*/
|
|
58
|
+
function stopOnSignal(
|
|
59
|
+
listening: { stop: (closeActiveConnections?: boolean) => void },
|
|
60
|
+
current: () => ServerHandle | undefined,
|
|
61
|
+
): void {
|
|
62
|
+
let stopping = false
|
|
63
|
+
const stop = (signal: string) => {
|
|
64
|
+
// 130 is what a shell reports for a SIGINT death, and what this was.
|
|
65
|
+
if (stopping) process.exit(130)
|
|
66
|
+
stopping = true
|
|
67
|
+
process.stderr.write(`\nStopping (${signal})…\n`)
|
|
68
|
+
setTimeout(() => process.exit(130), SHUTDOWN_MS).unref()
|
|
69
|
+
void (async () => {
|
|
70
|
+
listening.stop(true)
|
|
71
|
+
const server = current()
|
|
72
|
+
if (server) await server.close()
|
|
73
|
+
process.exit(0)
|
|
74
|
+
})()
|
|
75
|
+
}
|
|
76
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.on(signal, () => stop(signal))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface StartOptions extends TransferFlagValues {
|
|
80
|
+
keepAdmin: boolean
|
|
81
|
+
bundles: string[]
|
|
82
|
+
label?: string
|
|
83
|
+
backupTaken: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What a started site answers on, and the sign-in a development start printed. */
|
|
87
|
+
export interface Started {
|
|
88
|
+
backoffice: string | null
|
|
89
|
+
site: string | null
|
|
90
|
+
role: string
|
|
91
|
+
admin: { username: string; password: string } | null
|
|
92
|
+
setupPending: boolean
|
|
93
|
+
bundles: string[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Boots the site and says how to reach it; it serves until a signal stops it. */
|
|
97
|
+
export async function serveSite(io: Io, options: StartOptions): Promise<Started> {
|
|
98
|
+
const config = await siteConfig()
|
|
99
|
+
// Before the migration and the seed, so a clash with the container stack is
|
|
100
|
+
// reported in a second rather than after the database work.
|
|
101
|
+
await assertPortAvailable(config.port).catch((error: unknown) => {
|
|
102
|
+
throw new CliError('unavailable', error instanceof Error ? error.message : String(error))
|
|
103
|
+
})
|
|
104
|
+
const { listening, server, bundles } = await bootWithRetries(io, config, options)
|
|
105
|
+
const seededAdminPassword = server.seededAdminPassword
|
|
106
|
+
const backoffice =
|
|
107
|
+
config.role !== 'web' ? `${listening.url}${config.backOfficePath.slice(1)}` : null
|
|
108
|
+
const site = config.role !== 'api' ? String(listening.url) : null
|
|
109
|
+
const started: Started = {
|
|
110
|
+
backoffice,
|
|
111
|
+
site,
|
|
112
|
+
role: config.role,
|
|
113
|
+
admin: null,
|
|
114
|
+
setupPending: false,
|
|
115
|
+
bundles,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const lines = ['Crombolo is running', '']
|
|
119
|
+
// What this node actually answers, so a split deployment's banner does not
|
|
120
|
+
// advertise an address that 404s here.
|
|
121
|
+
if (backoffice) lines.push(` backoffice ${backoffice}`)
|
|
122
|
+
if (site) lines.push(` site ${site}`)
|
|
123
|
+
if (config.role !== 'all') lines.push(` role ${config.role}`)
|
|
124
|
+
lines.push('')
|
|
125
|
+
|
|
126
|
+
// A `web` node owns nothing and writes nothing at boot: resetting the password
|
|
127
|
+
// here would be a write from the node whose whole point is not to make them,
|
|
128
|
+
// and would end the sessions of editors signed in to the api node.
|
|
129
|
+
if (config.role === 'web') {
|
|
130
|
+
// Nothing to print: this node has no sign-in.
|
|
131
|
+
} else if (!config.development) {
|
|
132
|
+
// Production never touches an existing administrator at start: a restart an
|
|
133
|
+
// attacker forced must not reset, re-enable or sign anyone out.
|
|
134
|
+
if ((await readSetupState(server.db)) === 'pending') {
|
|
135
|
+
started.setupPending = true
|
|
136
|
+
lines.push(
|
|
137
|
+
' Set up your new site at',
|
|
138
|
+
` ${backoffice}/setup`,
|
|
139
|
+
config.setupCode
|
|
140
|
+
? ' with the code in CROMBOLO_SETUP_CODE.'
|
|
141
|
+
: ' First set CROMBOLO_SETUP_CODE (16+ characters) and restart.',
|
|
142
|
+
'',
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
} else if (options.keepAdmin) {
|
|
146
|
+
if (seededAdminPassword) {
|
|
147
|
+
started.admin = { username: config.adminLogin, password: seededAdminPassword }
|
|
148
|
+
lines.push(
|
|
149
|
+
' Sign in with',
|
|
150
|
+
` username ${config.adminLogin}`,
|
|
151
|
+
` password ${seededAdminPassword}`,
|
|
152
|
+
'',
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
const admin = await resetAdminPassword(
|
|
157
|
+
new AuthStore(server.db),
|
|
158
|
+
config.adminLogin,
|
|
159
|
+
config.adminPassword,
|
|
160
|
+
)
|
|
161
|
+
if (admin) {
|
|
162
|
+
started.admin = { username: admin.login, password: admin.password }
|
|
163
|
+
lines.push(
|
|
164
|
+
' Sign in with',
|
|
165
|
+
` username ${admin.login}`,
|
|
166
|
+
` password ${admin.password}`,
|
|
167
|
+
'',
|
|
168
|
+
)
|
|
169
|
+
lines.push(
|
|
170
|
+
config.adminPassword
|
|
171
|
+
? ' (from CROMBOLO_ADMIN_PASSWORD)'
|
|
172
|
+
: ' A fresh password is generated on every start; set',
|
|
173
|
+
)
|
|
174
|
+
if (!config.adminPassword)
|
|
175
|
+
lines.push(' CROMBOLO_ADMIN_PASSWORD to keep one, or pass --keep-admin.')
|
|
176
|
+
if (admin.sessionsEnded > 0)
|
|
177
|
+
lines.push(` Ended ${admin.sessionsEnded} session(s): the password changed.`)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const components = server.health().components
|
|
181
|
+
lines.push(
|
|
182
|
+
` database ${describeDatabase(config)}`,
|
|
183
|
+
` components ${config.componentsDir}${components.hash ? ` generation ${components.hash.slice(0, 8)}` : ''}`,
|
|
184
|
+
)
|
|
185
|
+
for (const line of bundles) lines.push(line)
|
|
186
|
+
const report = server.schema.report
|
|
187
|
+
if (report) lines.push(` schema ${describeSync(report)}`)
|
|
188
|
+
if (server.schema.compatibilityMode)
|
|
189
|
+
lines.push(' ⚠ database is ahead of schema/: reads only, writes refused with 409')
|
|
190
|
+
banner(io, lines)
|
|
191
|
+
if (seededAdminPassword) io.say('Database created and seeded.')
|
|
192
|
+
return started
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Listens at once, serving a page that says why the site is not up yet, and
|
|
197
|
+
* retries the boot with backoff: a database that comes up after the server is
|
|
198
|
+
* picked up without a restart. After the last attempt it exits, so the
|
|
199
|
+
* platform's restart policy and alerts take over. docs/operations.md.
|
|
200
|
+
*/
|
|
201
|
+
async function bootWithRetries(io: Io, config: CromboloConfig, options: StartOptions) {
|
|
202
|
+
const attempts = Math.max(1, Number(Bun.env.CROMBOLO_BOOT_ATTEMPTS ?? 10) || 10)
|
|
203
|
+
const delays = bootRetryDelays(attempts)
|
|
204
|
+
let failure: BootFailure | undefined
|
|
205
|
+
let progress = { attempt: 1, attempts, retryInSeconds: undefined as number | undefined }
|
|
206
|
+
const holding = (request: Request) => {
|
|
207
|
+
if (new URL(request.url).pathname === '/health')
|
|
208
|
+
return Response.json(
|
|
209
|
+
{ ok: false, starting: true, problem: failure?.title ?? null },
|
|
210
|
+
{ status: 503 },
|
|
211
|
+
)
|
|
212
|
+
return new Response(bootFailurePage(failure, progress), {
|
|
213
|
+
status: 503,
|
|
214
|
+
headers: { 'content-type': 'text/html; charset=utf-8', 'retry-after': '10' },
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
const listening = Bun.serve({
|
|
218
|
+
port: config.port,
|
|
219
|
+
maxRequestBodySize: listeningBodyLimit(config),
|
|
220
|
+
fetch: holding,
|
|
221
|
+
websocket: { message() {} },
|
|
222
|
+
})
|
|
223
|
+
let current: ServerHandle | undefined
|
|
224
|
+
// Registered the moment it listens, so there is no window, retries included,
|
|
225
|
+
// in which it answers requests and cannot be asked to stop.
|
|
226
|
+
stopOnSignal(listening, () => current)
|
|
227
|
+
for (let attempt = 1; ; attempt++) {
|
|
228
|
+
let server: ServerHandle | undefined
|
|
229
|
+
try {
|
|
230
|
+
server = await createServer(config)
|
|
231
|
+
current = server
|
|
232
|
+
// After the schema sync inside `createServer`, which is what gives the
|
|
233
|
+
// bundle content types to land on, and before anything is served.
|
|
234
|
+
const bundles = await importStartupBundles(io, server, config, options)
|
|
235
|
+
listening.reload(server.serveOptions as never)
|
|
236
|
+
return { listening, server, bundles }
|
|
237
|
+
} catch (error) {
|
|
238
|
+
current = undefined
|
|
239
|
+
await server?.close().catch(() => {})
|
|
240
|
+
failure = describeBootFailure(error, describeDatabase(config))
|
|
241
|
+
const delay = delays[attempt - 1]
|
|
242
|
+
io.warning(
|
|
243
|
+
`crombolo: ${failure.title}. ${failure.detail}\n To fix it: ${failure.remedy}\n (${error instanceof Error ? error.message : String(error)})`,
|
|
244
|
+
)
|
|
245
|
+
if (delay === undefined || error instanceof ops.CommandError || error instanceof CliError) {
|
|
246
|
+
progress = { attempt, attempts, retryInSeconds: undefined }
|
|
247
|
+
listening.stop(true)
|
|
248
|
+
if (error instanceof ops.CommandError || error instanceof CliError) throw error
|
|
249
|
+
throw new CliError('unavailable', `Gave up after ${attempt} attempt(s): ${failure.title}.`)
|
|
250
|
+
}
|
|
251
|
+
progress = { attempt, attempts, retryInSeconds: Math.round(delay / 1000) }
|
|
252
|
+
if (attempt === 1) io.warning(` ${listening.url} explains this while it retries.`)
|
|
253
|
+
io.warning(
|
|
254
|
+
` Trying again in ${Math.round(delay / 1000)}s (attempt ${attempt} of ${attempts}).`,
|
|
255
|
+
)
|
|
256
|
+
await Bun.sleep(delay)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* `start --bundle <dir>`, repeatable: apply a bundle before the site serves
|
|
263
|
+
* anything, taking the flags `content import` takes.
|
|
264
|
+
*
|
|
265
|
+
* Imported once per bundle — a run still standing here means this site already
|
|
266
|
+
* has that content — so a restart costs a query and a line in the banner. It
|
|
267
|
+
* runs after the schema sync, which is what gives the bundle its content types,
|
|
268
|
+
* and before anything is served. A refusal stops the boot: a site missing the
|
|
269
|
+
* content the command line asked for is worse than a site that did not come up.
|
|
270
|
+
*/
|
|
271
|
+
async function importStartupBundles(
|
|
272
|
+
io: Io,
|
|
273
|
+
server: ServerHandle,
|
|
274
|
+
config: CromboloConfig,
|
|
275
|
+
options: StartOptions,
|
|
276
|
+
): Promise<string[]> {
|
|
277
|
+
if (options.bundles.length === 0) return []
|
|
278
|
+
|
|
279
|
+
const lines: string[] = []
|
|
280
|
+
const db = server.db
|
|
281
|
+
let imported = false
|
|
282
|
+
for (const target of options.bundles) {
|
|
283
|
+
let resolved: ops.ResolvedBundle
|
|
284
|
+
try {
|
|
285
|
+
resolved = ops.readBundle(target, config.siteDir, await passphraseToOpen(target, config))
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (!(error instanceof ops.CommandError)) throw error
|
|
288
|
+
throw new CliError('refused', `${target} cannot be read. The site has not started.`, {
|
|
289
|
+
details: [error.message, ...error.problems],
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
const set = resolved.set
|
|
293
|
+
const standing = await new TransferRunRepository(db).appliedFor(set.manifest.id)
|
|
294
|
+
if (standing) {
|
|
295
|
+
lines.push(` bundle ${set.manifest.id} already imported (run ${standing.id})`)
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Nothing to roll back to on a database this boot created, so the backup
|
|
300
|
+
// rule — which on Postgres means refusing without one — applies only to a
|
|
301
|
+
// site that already had content.
|
|
302
|
+
if (!server.seededAdminPassword) {
|
|
303
|
+
const backup = await backupBefore(config, {
|
|
304
|
+
reason: 'content-import',
|
|
305
|
+
backupTaken: options.backupTaken,
|
|
306
|
+
})
|
|
307
|
+
if (backup.path) io.progress(` backup ${backup.path}`)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const prepared = await ops.transferOptions(db, config, resolved, transferInput(target, options))
|
|
311
|
+
for (const problem of prepared.problems) io.warning(` ${problem}`)
|
|
312
|
+
const started = performance.now()
|
|
313
|
+
const result = await importBundle(db, set, {
|
|
314
|
+
...prepared.options,
|
|
315
|
+
// The administrator this boot created stands in until the site's own arrive.
|
|
316
|
+
adoptUsers: Boolean(server.seededAdminPassword),
|
|
317
|
+
publish: options.publish,
|
|
318
|
+
nodeId: config.nodeId,
|
|
319
|
+
label: options.label,
|
|
320
|
+
package: { name: resolved.package.packageName, version: resolved.package.version },
|
|
321
|
+
})
|
|
322
|
+
await writeReport(db, result.check.findings, { source: 'transfer', scope: set.manifest.id })
|
|
323
|
+
if (!result.runId) {
|
|
324
|
+
printTransferFindings(io, result.check)
|
|
325
|
+
throw new CliError(
|
|
326
|
+
'refused',
|
|
327
|
+
`${target} was refused: ${result.check.outstanding.length} finding(s) outstanding. Nothing was written, and the site has not started.`,
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
const c = result.check.counts
|
|
331
|
+
const placed = describePlaced(
|
|
332
|
+
resolved.blobs.size > 0
|
|
333
|
+
? await placeBlobs(await mediaStoreFor(config), resolved.blobs, resolved.openBlob)
|
|
334
|
+
: null,
|
|
335
|
+
)
|
|
336
|
+
lines.push(
|
|
337
|
+
` bundle ${set.manifest.id}: ${c.create} created, ${c.update} updated` +
|
|
338
|
+
`${result.published.length > 0 ? `, ${result.published.length} published` : ''} (run ${result.runId})`,
|
|
339
|
+
)
|
|
340
|
+
if (placed) lines.push(` blobs ${placed}`)
|
|
341
|
+
const people = result.people.filter((p) => p.kind === 'member' || p.kind === 'user')
|
|
342
|
+
if (people.length > 0)
|
|
343
|
+
lines.push(
|
|
344
|
+
` people ${people.filter((p) => p.kind === 'member').length} member(s), ${people.filter((p) => p.kind === 'user').length} user(s); their passwords are the ones they had`,
|
|
345
|
+
)
|
|
346
|
+
for (const finding of result.check.findings.filter((f) => f.code === 'user-adopted'))
|
|
347
|
+
lines.push(` admin ${finding.message}`)
|
|
348
|
+
for (const failure of result.publishFailures)
|
|
349
|
+
io.warning(` not published ${failure.key}: ${failure.reason}`)
|
|
350
|
+
await recordInLedger(db, {
|
|
351
|
+
name: `content import ${set.manifest.id}`,
|
|
352
|
+
kind: 'transfer',
|
|
353
|
+
durationMs: Math.round(performance.now() - started),
|
|
354
|
+
appliedBy: config.nodeId,
|
|
355
|
+
note: `run ${result.runId}, ${resolved.package.packageName}@${resolved.package.version} (start --bundle)`,
|
|
356
|
+
})
|
|
357
|
+
imported = true
|
|
358
|
+
}
|
|
359
|
+
// The content landed under a cache this process had already constructed.
|
|
360
|
+
if (imported) server.cache.invalidate()
|
|
361
|
+
return lines
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export interface FromFileOptions {
|
|
365
|
+
name?: string
|
|
366
|
+
postgres: boolean
|
|
367
|
+
install: boolean
|
|
368
|
+
allowMissingMedia: boolean
|
|
369
|
+
/** A pull already holds the passphrase it sealed the file with. */
|
|
370
|
+
passphrase?: string
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* `start --from <file>` (story 105): an empty folder in, a working site out —
|
|
375
|
+
* scaffolded as `init` scaffolds one, its packages installed, and the file
|
|
376
|
+
* imported as a replace so nothing of the scaffold's own starting point stays.
|
|
377
|
+
* The new administrator's password is printed here, once.
|
|
378
|
+
*/
|
|
379
|
+
export async function startFromFile(
|
|
380
|
+
io: Io,
|
|
381
|
+
from: string,
|
|
382
|
+
options: FromFileOptions,
|
|
383
|
+
): Promise<{ runId: string; admin: { username: string; password: string } | null }> {
|
|
384
|
+
const file = resolve(cwd(), from)
|
|
385
|
+
if (!existsSync(file)) throw new CliError('not_found', `${from} does not exist.`)
|
|
386
|
+
if (hasSite())
|
|
387
|
+
throw new CliError('conflict', 'There is a site here already.', {
|
|
388
|
+
hint: '`crombolo import <file> --replace` replaces it; start --from starts a new one in an empty folder.',
|
|
389
|
+
})
|
|
390
|
+
const dir = await ops.unpackBundle(file, cwd())
|
|
391
|
+
const manifest = ops.readBundle(dir, cwd()).set.manifest
|
|
392
|
+
if (manifest.scope !== 'site')
|
|
393
|
+
throw new CliError('usage', `${from} is not a whole-site file.`, {
|
|
394
|
+
hint: 'Write one with `crombolo export --site`.',
|
|
395
|
+
})
|
|
396
|
+
const sealed = manifest.people !== undefined || manifest.content === 'sealed'
|
|
397
|
+
const passphrase = options.passphrase ?? (sealed ? await passphraseForPeople() : undefined)
|
|
398
|
+
if (sealed && !passphrase)
|
|
399
|
+
throw new CliError('auth', `${from} is sealed.`, {
|
|
400
|
+
hint: `Set ${PASSPHRASE_ENV} to its passphrase.`,
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
for (const scaffolded of scaffoldFiles({
|
|
404
|
+
siteName: options.name ?? manifest.provenance.siteName,
|
|
405
|
+
postgres: options.postgres,
|
|
406
|
+
})) {
|
|
407
|
+
const target = join(cwd(), scaffolded.path)
|
|
408
|
+
if (existsSync(target)) continue
|
|
409
|
+
await mkdir(join(target, '..'), { recursive: true })
|
|
410
|
+
if (scaffolded.copyFrom === undefined) await writeFile(target, scaffolded.text ?? '')
|
|
411
|
+
else await Bun.write(target, Bun.file(scaffolded.copyFrom))
|
|
412
|
+
}
|
|
413
|
+
io.progress(` scaffolded a site for ${manifest.provenance.siteName || 'the file'}`)
|
|
414
|
+
if (options.install) {
|
|
415
|
+
const install = Bun.spawnSync(['bun', 'install'], {
|
|
416
|
+
cwd: cwd(),
|
|
417
|
+
stdout: 'inherit',
|
|
418
|
+
stderr: 'inherit',
|
|
419
|
+
})
|
|
420
|
+
if (install.exitCode !== 0)
|
|
421
|
+
throw new CliError('internal', '`bun install` failed.', {
|
|
422
|
+
hint: 'Run it, then `crombolo import <file> --replace`.',
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const config = await siteConfig()
|
|
427
|
+
const { db, seededAdminPassword } = await bootstrapDatabase(config)
|
|
428
|
+
await db.close()
|
|
429
|
+
const result = await ops.contentImport(config, {
|
|
430
|
+
bundle: dir,
|
|
431
|
+
passphrase,
|
|
432
|
+
replace: 'replace',
|
|
433
|
+
allowMissingBlobs: options.allowMissingMedia,
|
|
434
|
+
backupTaken: true,
|
|
435
|
+
label: `start --from ${basename(file)}`,
|
|
436
|
+
})
|
|
437
|
+
for (const problem of result.problems) io.warning(` ${problem}`)
|
|
438
|
+
if (!result.runId) {
|
|
439
|
+
printTransferFindings(io, result.check)
|
|
440
|
+
throw new CliError(
|
|
441
|
+
'refused',
|
|
442
|
+
'The file could not be imported; the scaffolded site is left as it is.',
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
io.say(` imported ${result.check.counts.create} item(s), run ${result.runId}`)
|
|
446
|
+
const placed = describePlaced(result.blobs)
|
|
447
|
+
if (placed) io.say(` media ${placed}`)
|
|
448
|
+
if (seededAdminPassword)
|
|
449
|
+
banner(io, [
|
|
450
|
+
'Sign in with (shown once)',
|
|
451
|
+
` username ${config.adminLogin}`,
|
|
452
|
+
` password ${seededAdminPassword}`,
|
|
453
|
+
])
|
|
454
|
+
return {
|
|
455
|
+
runId: result.runId,
|
|
456
|
+
admin: seededAdminPassword
|
|
457
|
+
? { username: config.adminLogin, password: seededAdminPassword }
|
|
458
|
+
: null,
|
|
459
|
+
}
|
|
460
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** The site the CLI runs in: the current directory and its `crombolo.config.ts`. */
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { type CromboloConfig, loadConfig } from '@crombolo/server'
|
|
5
|
+
|
|
6
|
+
export const cwd = (): string => process.cwd()
|
|
7
|
+
|
|
8
|
+
export async function siteConfig(): Promise<CromboloConfig> {
|
|
9
|
+
const file = join(cwd(), 'crombolo.config.ts')
|
|
10
|
+
const overrides = existsSync(file)
|
|
11
|
+
? ((await import(file)).default as Partial<CromboloConfig>)
|
|
12
|
+
: {}
|
|
13
|
+
return loadConfig(overrides, cwd())
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const hasSite = (): boolean => existsSync(join(cwd(), 'crombolo.config.ts'))
|
|
17
|
+
|
|
18
|
+
/** Where a command says things: its result on stdout, everything else on stderr. */
|
|
19
|
+
export interface Io {
|
|
20
|
+
/** A line of the result, for people; nothing in JSON mode, where the result is returned. */
|
|
21
|
+
say(line?: string): void
|
|
22
|
+
/** Progress and narration, on stderr in every mode. */
|
|
23
|
+
progress(line: string): void
|
|
24
|
+
/** A problem that does not stop the command, on stderr. */
|
|
25
|
+
warning(line: string): void
|
|
26
|
+
}
|