@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.
Files changed (85) hide show
  1. package/bin/crombolo.ts +4 -3166
  2. package/oclif.manifest.json +6710 -0
  3. package/package.json +58 -7
  4. package/src/base.ts +129 -0
  5. package/src/classify.ts +119 -0
  6. package/src/clispec.ts +163 -0
  7. package/src/commands/admin/reset-password.ts +53 -0
  8. package/src/commands/assets/index.ts +6 -0
  9. package/src/commands/assets/list.ts +27 -0
  10. package/src/commands/assets/new.ts +42 -0
  11. package/src/commands/bundle/purge.ts +71 -0
  12. package/src/commands/components/check.ts +58 -0
  13. package/src/commands/components/index.ts +6 -0
  14. package/src/commands/components/list.ts +40 -0
  15. package/src/commands/components/new.ts +49 -0
  16. package/src/commands/content/check.ts +102 -0
  17. package/src/commands/content/export.ts +116 -0
  18. package/src/commands/content/import.ts +109 -0
  19. package/src/commands/content/publish.ts +21 -0
  20. package/src/commands/content/revert.ts +137 -0
  21. package/src/commands/content/runs.ts +62 -0
  22. package/src/commands/content/unpublish.ts +21 -0
  23. package/src/commands/dictionary/export.ts +49 -0
  24. package/src/commands/dictionary/import.ts +46 -0
  25. package/src/commands/domains/apply.ts +23 -0
  26. package/src/commands/domains/clear.ts +27 -0
  27. package/src/commands/domains/index.ts +6 -0
  28. package/src/commands/domains/list.ts +86 -0
  29. package/src/commands/domains/set.ts +55 -0
  30. package/src/commands/domains/undo.ts +29 -0
  31. package/src/commands/export.ts +100 -0
  32. package/src/commands/generate.ts +33 -0
  33. package/src/commands/import/index.ts +175 -0
  34. package/src/commands/import/umbraco/apply.ts +128 -0
  35. package/src/commands/import/umbraco/report.ts +56 -0
  36. package/src/commands/init.ts +76 -0
  37. package/src/commands/login.ts +38 -0
  38. package/src/commands/logout.ts +36 -0
  39. package/src/commands/maintenance/pause.ts +29 -0
  40. package/src/commands/maintenance/resume.ts +20 -0
  41. package/src/commands/maintenance/status.ts +34 -0
  42. package/src/commands/media/fetch.ts +62 -0
  43. package/src/commands/pull.ts +210 -0
  44. package/src/commands/push.ts +108 -0
  45. package/src/commands/remote/add.ts +78 -0
  46. package/src/commands/remote/job.ts +53 -0
  47. package/src/commands/remote/list.ts +41 -0
  48. package/src/commands/remote/remove.ts +26 -0
  49. package/src/commands/schema.ts +32 -0
  50. package/src/commands/start.ts +64 -0
  51. package/src/commands/status.ts +103 -0
  52. package/src/commands/types/add-property.ts +116 -0
  53. package/src/commands/types/check.ts +88 -0
  54. package/src/commands/types/export.ts +32 -0
  55. package/src/commands/types/new.ts +116 -0
  56. package/src/commands/types/purge.ts +65 -0
  57. package/src/commands/types/rewrite.ts +30 -0
  58. package/src/commands/types/sync.ts +66 -0
  59. package/src/commands/upgrade/check.ts +69 -0
  60. package/src/commands/upgrade/index.ts +120 -0
  61. package/src/commands/upgrade/ledger.ts +55 -0
  62. package/src/commands/whoami.ts +41 -0
  63. package/src/errors.ts +90 -0
  64. package/src/main.ts +23 -0
  65. package/src/manifest.ts +20 -0
  66. package/src/reference.ts +118 -0
  67. package/src/remote/client.ts +19 -10
  68. package/src/remote/remotes-file.ts +3 -2
  69. package/src/remote/transfer.ts +27 -11
  70. package/src/support/components.ts +7 -0
  71. package/src/support/describe.ts +179 -0
  72. package/src/support/domains.ts +89 -0
  73. package/src/support/flags.ts +157 -0
  74. package/src/support/prompt.ts +126 -0
  75. package/src/support/publish.ts +142 -0
  76. package/src/support/remote.ts +58 -0
  77. package/src/support/serve.ts +460 -0
  78. package/src/support/site.ts +26 -0
  79. package/src/support/types.ts +112 -0
  80. package/src/support/umbraco.ts +86 -0
  81. package/templates/basic/bundle/content/bundle.json +1 -1
  82. package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +1 -1
  83. package/templates/basic/bundle/package.json +1 -1
  84. package/templates/demo/harbourstone/bundle/package.json +1 -1
  85. 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
+ }