@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,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
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** What the `types` commands share: loading `schema/`, applying it, and the upgrade check. */
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import {
|
|
4
|
+
allProperties,
|
|
5
|
+
loadSchemaDirectory,
|
|
6
|
+
type SyncReport,
|
|
7
|
+
type ValueMigration,
|
|
8
|
+
} from '@crombolo/schema'
|
|
9
|
+
import {
|
|
10
|
+
bootstrapDatabase,
|
|
11
|
+
type CromboloConfig,
|
|
12
|
+
formTypesFor,
|
|
13
|
+
syncSchemaDirectory,
|
|
14
|
+
} from '@crombolo/server'
|
|
15
|
+
import { CliError } from '../errors.ts'
|
|
16
|
+
import * as ops from '../operations/commands.ts'
|
|
17
|
+
import { describeProblem, describeSync, here, printFindings, printProblems } from './describe.ts'
|
|
18
|
+
import type { Io } from './site.ts'
|
|
19
|
+
|
|
20
|
+
export const loadTypes = (config: CromboloConfig) =>
|
|
21
|
+
loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
|
|
22
|
+
|
|
23
|
+
export function needSchemaDir(config: CromboloConfig): void {
|
|
24
|
+
if (!existsSync(config.schemaDir))
|
|
25
|
+
throw new CliError('not_found', `No schema directory at ${here(config.schemaDir)}.`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function refuseProblems(problems: SyncReport['problems'], message?: string): void {
|
|
29
|
+
if (problems.length > 0)
|
|
30
|
+
throw new CliError('refused', message ?? `${problems.length} problem(s) in schema/.`, {
|
|
31
|
+
details: problems.map(describeProblem),
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Applies `schema/` as a boot or a deploy step would, and reports what it did. */
|
|
36
|
+
export async function applySchema(io: Io, config: CromboloConfig): Promise<SyncReport> {
|
|
37
|
+
const { db } = await bootstrapDatabase(config)
|
|
38
|
+
try {
|
|
39
|
+
const report = await syncSchemaDirectory(db, config, {
|
|
40
|
+
nodeId: config.nodeId,
|
|
41
|
+
revision: config.schemaRevision,
|
|
42
|
+
})
|
|
43
|
+
if (report.problems.length > 0) printProblems(io, report.problems)
|
|
44
|
+
io.say(` schema ${describeSync(report)}`)
|
|
45
|
+
if (report.action === 'refused')
|
|
46
|
+
throw new CliError('refused', `The schema was not applied: ${describeSync(report)}.`, {
|
|
47
|
+
details: report.problems.map(describeProblem),
|
|
48
|
+
})
|
|
49
|
+
return report
|
|
50
|
+
} finally {
|
|
51
|
+
await db.close()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The pull-request rules: keys present, migrations well-formed, nothing the database needs. */
|
|
56
|
+
export function staticProblems(
|
|
57
|
+
loaded: ReturnType<typeof loadSchemaDirectory>,
|
|
58
|
+
migrations: ValueMigration[],
|
|
59
|
+
): string[] {
|
|
60
|
+
const problems: string[] = []
|
|
61
|
+
for (const t of loaded.set.contentTypes) {
|
|
62
|
+
if (!t.key)
|
|
63
|
+
problems.push(`${t.alias}: no key — run \`crombolo types sync\` in development to assign one`)
|
|
64
|
+
for (const p of allProperties(t)) if (!p.key) problems.push(`${t.alias}.${p.alias}: no key`)
|
|
65
|
+
}
|
|
66
|
+
for (const d of loaded.set.dataTypes) if (!d.key) problems.push(`data type ${d.alias}: no key`)
|
|
67
|
+
for (const m of migrations) {
|
|
68
|
+
const type = loaded.set.contentTypes.find((t) => t.alias === m.from.type)
|
|
69
|
+
if (!type) {
|
|
70
|
+
problems.push(`migration ${m.id}: no content type "${m.from.type}"`)
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
for (const target of m.to)
|
|
74
|
+
if (!allProperties(type).some((p) => p.alias === target.property))
|
|
75
|
+
problems.push(
|
|
76
|
+
`migration ${m.id}: "${m.from.type}" has no property "${target.property}" to write to`,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
return problems
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `--set type.property=value`, repeatable: a value for a new mandatory property. */
|
|
83
|
+
export function parseSet(pairs: readonly string[]): Record<string, unknown> {
|
|
84
|
+
const out: Record<string, unknown> = {}
|
|
85
|
+
for (const pair of pairs) {
|
|
86
|
+
const eq = pair.indexOf('=')
|
|
87
|
+
if (eq < 0) throw new CliError('usage', `--set wants <type>.<property>=<value>, not "${pair}".`)
|
|
88
|
+
const raw = pair.slice(eq + 1)
|
|
89
|
+
let value: unknown = raw
|
|
90
|
+
try {
|
|
91
|
+
value = JSON.parse(raw)
|
|
92
|
+
} catch {}
|
|
93
|
+
out[pair.slice(0, eq)] = value
|
|
94
|
+
}
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What the deployed `schema/` would do to this database's data: the check an upgrade runs. */
|
|
99
|
+
export async function upgradeCheck(io: Io, config: CromboloConfig, set: Record<string, unknown>) {
|
|
100
|
+
const report = await ops.upgradeCheck(config, { set })
|
|
101
|
+
for (const bundle of report.bundles)
|
|
102
|
+
if (bundle.pending.length > 0)
|
|
103
|
+
io.say(
|
|
104
|
+
` bundle ${bundle.id}: ${bundle.pending.map((step) => `${step.name} (${step.kind})`).join(', ')}`,
|
|
105
|
+
)
|
|
106
|
+
printFindings(io, report)
|
|
107
|
+
if (!report.siteChecked)
|
|
108
|
+
io.say(
|
|
109
|
+
'The site schema is checked once the framework migrations have run (`crombolo upgrade`).',
|
|
110
|
+
)
|
|
111
|
+
return report
|
|
112
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `crombolo import umbraco`: a compatibility report for an Umbraco backup, and
|
|
3
|
+
* then a crombolo site written from it. Neither touches a database.
|
|
4
|
+
*
|
|
5
|
+
* The importer is a package of its own, loaded here on demand, so a site that
|
|
6
|
+
* never imports anything does not carry it or the `.bacpac` reader it needs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { VERSION } from '@crombolo/server'
|
|
10
|
+
import { Args, Flags } from '@oclif/core'
|
|
11
|
+
import { CliError } from '../errors.ts'
|
|
12
|
+
import { contentFlags } from './flags.ts'
|
|
13
|
+
import type { Io } from './site.ts'
|
|
14
|
+
|
|
15
|
+
export const umbracoArgs = {
|
|
16
|
+
backup: Args.string({ required: true, description: "a .bacpac, or Umbraco's SQLite file" }),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const umbracoFlags = {
|
|
20
|
+
site: Flags.string({
|
|
21
|
+
summary: "the Umbraco site's files, for its views, media and plugins",
|
|
22
|
+
helpValue: '<dir>',
|
|
23
|
+
}),
|
|
24
|
+
media: Flags.string({
|
|
25
|
+
summary: 'where its media files are, if not under --site',
|
|
26
|
+
helpValue: '<dir>',
|
|
27
|
+
}),
|
|
28
|
+
name: Flags.string({ summary: "the new site's name", helpValue: '<site name>' }),
|
|
29
|
+
drafts: Flags.boolean({ summary: 'bring drafts too' }),
|
|
30
|
+
staging: Flags.string({ summary: 'where to unpack a .bacpac', helpValue: '<dir>' }),
|
|
31
|
+
'with-members': Flags.boolean({ summary: 'bring members, encrypted' }),
|
|
32
|
+
'with-users': Flags.boolean({ summary: 'bring back-office users, encrypted' }),
|
|
33
|
+
...contentFlags,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function deprecated(io: Io, json: boolean): void {
|
|
37
|
+
// Not into JSON output, which a machine reads.
|
|
38
|
+
if (!json)
|
|
39
|
+
io.warning(
|
|
40
|
+
' deprecated `crombolo import umbraco` is going away: an Umbraco extension will write a crombolo file that `crombolo import <file>` reads (story 074)',
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function loadImporter(): Promise<typeof import('@crombolo/import-umbraco')> {
|
|
45
|
+
try {
|
|
46
|
+
return await import('@crombolo/import-umbraco')
|
|
47
|
+
} catch {
|
|
48
|
+
throw new CliError('not_found', 'The importer is a separate package.', {
|
|
49
|
+
hint: 'Add it, then run this again: bun add -d @crombolo/import-umbraco',
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function planUmbraco(
|
|
55
|
+
importer: typeof import('@crombolo/import-umbraco'),
|
|
56
|
+
backup: string,
|
|
57
|
+
flags: {
|
|
58
|
+
site?: string
|
|
59
|
+
media?: string
|
|
60
|
+
name?: string
|
|
61
|
+
drafts: boolean
|
|
62
|
+
staging?: string
|
|
63
|
+
'with-members': boolean
|
|
64
|
+
'with-users': boolean
|
|
65
|
+
},
|
|
66
|
+
content: 'omitted' | 'sealed' | 'open',
|
|
67
|
+
passphrase: string | undefined,
|
|
68
|
+
) {
|
|
69
|
+
try {
|
|
70
|
+
return await importer.planImport({
|
|
71
|
+
source: backup,
|
|
72
|
+
site: flags.site,
|
|
73
|
+
media: flags.media,
|
|
74
|
+
siteName: flags.name,
|
|
75
|
+
drafts: flags.drafts,
|
|
76
|
+
staging: flags.staging,
|
|
77
|
+
coreVersion: VERSION,
|
|
78
|
+
content,
|
|
79
|
+
withMembers: flags['with-members'],
|
|
80
|
+
withUsers: flags['with-users'],
|
|
81
|
+
passphrase,
|
|
82
|
+
})
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw new CliError('refused', error instanceof Error ? error.message : String(error))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"id": "2de473a2-d391-4161-801e-e34b202759b8",
|
|
4
4
|
"createdAt": "2026-10-01T00:00:00.000Z",
|
|
5
5
|
"createdBy": "bun run build:template",
|
|
6
|
-
"integrity": "sha256-
|
|
6
|
+
"integrity": "sha256-fa7b7651e0726518570df713624c1ab29af47f7f04a615b514e29d47de301b97",
|
|
7
7
|
"snapshot": "published",
|
|
8
8
|
"provenance": {
|
|
9
9
|
"siteName": "Basic",
|