@crombolo/server 0.0.0-stage → 0.9.1

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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +432 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundles.ts +508 -0
  27. package/src/component-snippets.ts +163 -0
  28. package/src/components.ts +266 -0
  29. package/src/config.ts +548 -0
  30. package/src/content-type-udt.ts +256 -0
  31. package/src/database.ts +109 -0
  32. package/src/diagnostics.ts +143 -0
  33. package/src/dictionary-transfer.ts +123 -0
  34. package/src/domains.ts +384 -0
  35. package/src/editor-types.ts +108 -0
  36. package/src/email-http.ts +79 -0
  37. package/src/email-notice.ts +36 -0
  38. package/src/email-postmark.ts +78 -0
  39. package/src/email-resend.ts +69 -0
  40. package/src/email-ses.ts +307 -0
  41. package/src/email.ts +402 -0
  42. package/src/extensions-notice.ts +35 -0
  43. package/src/file-intake.ts +84 -0
  44. package/src/form-files.ts +241 -0
  45. package/src/form-submit.ts +397 -0
  46. package/src/form-workflows.ts +397 -0
  47. package/src/forms.ts +170 -0
  48. package/src/git-github.ts +171 -0
  49. package/src/git-routes.ts +154 -0
  50. package/src/git.ts +88 -0
  51. package/src/imaging.ts +434 -0
  52. package/src/index.ts +62 -0
  53. package/src/jobs.ts +140 -0
  54. package/src/listen.ts +36 -0
  55. package/src/log-expressions.ts +524 -0
  56. package/src/log-viewer.ts +190 -0
  57. package/src/logging.ts +126 -0
  58. package/src/marketplace.ts +297 -0
  59. package/src/media-files.ts +276 -0
  60. package/src/media-store-azure.ts +218 -0
  61. package/src/media-store-s3.ts +113 -0
  62. package/src/media-store.ts +248 -0
  63. package/src/member-auth.ts +384 -0
  64. package/src/models-builder.ts +106 -0
  65. package/src/no-nodes.ts +64 -0
  66. package/src/not-implemented.ts +44 -0
  67. package/src/oembed.ts +144 -0
  68. package/src/ports.ts +553 -0
  69. package/src/redirects.ts +132 -0
  70. package/src/schema-classify.ts +82 -0
  71. package/src/schema-store.ts +145 -0
  72. package/src/schema.ts +388 -0
  73. package/src/seo.ts +371 -0
  74. package/src/server-events.ts +131 -0
  75. package/src/server.ts +1579 -0
  76. package/src/status.ts +246 -0
  77. package/src/udt.ts +168 -0
  78. package/src/value-schemas.ts +64 -0
  79. package/src/view-runtime.ts +42 -0
  80. package/src/zip.ts +152 -0
  81. package/README.md +0 -3
@@ -0,0 +1,190 @@
1
+ /**
2
+ * The log viewer over the site's log files, as Umbraco's reads Serilog's: the
3
+ * files of each UTC day in the range (`*yyyyMMdd*.json`), one CLEF event per
4
+ * line (a line that is not one is skipped), and the entries whose time falls
5
+ * in the range, both ends included.
6
+ */
7
+ import { existsSync } from 'node:fs'
8
+ import { readdir, stat } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+ import type { LogEntry, LogLevelName, LogRange, LogViewerPort } from '@crombolo/api-management'
11
+ import { type Db, LogSearchRepository } from '@crombolo/data'
12
+ import type { CromboloConfig } from './config.ts'
13
+ import { type LogEvent, logFilter } from './log-expressions.ts'
14
+ import { SERILOG_LEVELS } from './logging.ts'
15
+
16
+ /** Umbraco's `FileSizeCap`: ranges whose files come to more are refused. */
17
+ export const LOG_SIZE_CAP_MB = 100
18
+
19
+ const DAY = 24 * 60 * 60 * 1000
20
+
21
+ /** Umbraco's default: when either end is missing, the last day. */
22
+ function resolveRange(range: LogRange, now = new Date()): { start: Date; end: Date } {
23
+ if (!range.start || !range.end) return { start: new Date(now.getTime() - DAY), end: now }
24
+ return { start: range.start, end: range.end }
25
+ }
26
+
27
+ const dayStamp = (date: Date) => date.toISOString().slice(0, 10).replaceAll('-', '')
28
+
29
+ async function filesFor(dir: string, range: { start: Date; end: Date }): Promise<string[]> {
30
+ if (!existsSync(dir)) return []
31
+ const names = (await readdir(dir)).filter((n) => n.endsWith('.json'))
32
+ const days = new Set<string>()
33
+ for (
34
+ let day = Date.UTC(
35
+ range.start.getUTCFullYear(),
36
+ range.start.getUTCMonth(),
37
+ range.start.getUTCDate(),
38
+ );
39
+ day <= range.end.getTime();
40
+ day += DAY
41
+ )
42
+ days.add(dayStamp(new Date(day)))
43
+ return names.filter((n) => [...days].some((d) => n.includes(d))).map((n) => join(dir, n))
44
+ }
45
+
46
+ /** Serilog's rendering of a property value in a message: strings quoted, the rest as written. */
47
+ function renderProperty(value: unknown, format: string | undefined): string {
48
+ if (typeof value === 'string') return format === 'l' ? value : `"${value}"`
49
+ if (value === null) return 'null'
50
+ if (typeof value === 'object') return JSON.stringify(value)
51
+ return String(value)
52
+ }
53
+
54
+ /** A message template with its properties filled in, as Serilog's `RenderMessage` does. */
55
+ export function renderMessage(template: string, properties: Record<string, unknown>): string {
56
+ return template.replace(
57
+ /\{\{|\}\}|\{([@$]?)([A-Za-z0-9_]+)(?:,[^:}]*)?(?::([^}]*))?\}/g,
58
+ (whole, _hint: string | undefined, name: string | undefined, format: string | undefined) => {
59
+ if (whole === '{{') return '{'
60
+ if (whole === '}}') return '}'
61
+ if (!name || !(name in properties)) return whole
62
+ return renderProperty(properties[name], format)
63
+ },
64
+ )
65
+ }
66
+
67
+ const LEVEL_NAMES = new Set(Object.values(SERILOG_LEVELS))
68
+
69
+ /** One CLEF line as an event the viewer shows and filters; undefined when it is not one. */
70
+ export function readClefLine(line: string): (LogEvent & { level: LogLevelName }) | undefined {
71
+ let raw: Record<string, unknown>
72
+ try {
73
+ raw = JSON.parse(line)
74
+ } catch {
75
+ return undefined
76
+ }
77
+ if (!raw || typeof raw !== 'object' || typeof raw['@t'] !== 'string') return undefined
78
+ const properties: Record<string, unknown> = {}
79
+ for (const [name, value] of Object.entries(raw))
80
+ if (!name.startsWith('@')) properties[name] = value
81
+ const template =
82
+ typeof raw['@mt'] === 'string' ? raw['@mt'] : typeof raw['@m'] === 'string' ? raw['@m'] : ''
83
+ const level =
84
+ typeof raw['@l'] === 'string' && LEVEL_NAMES.has(raw['@l']) ? raw['@l'] : 'Information'
85
+ return {
86
+ timestamp: raw['@t'],
87
+ level: level as LogLevelName,
88
+ messageTemplate: template,
89
+ message: typeof raw['@m'] === 'string' ? raw['@m'] : renderMessage(template, properties),
90
+ exception: typeof raw['@x'] === 'string' ? raw['@x'] : null,
91
+ properties,
92
+ }
93
+ }
94
+
95
+ async function readEvents(dir: string, range: { start: Date; end: Date }) {
96
+ const events: Array<LogEvent & { level: LogLevelName }> = []
97
+ const from = range.start.getTime()
98
+ const to = range.end.getTime()
99
+ for (const file of await filesFor(dir, range)) {
100
+ let content: string
101
+ try {
102
+ content = await Bun.file(file).text()
103
+ } catch {
104
+ continue
105
+ }
106
+ for (const line of content.split('\n')) {
107
+ if (!line.trim()) continue
108
+ const event = readClefLine(line)
109
+ if (!event) continue
110
+ const time = new Date(event.timestamp).getTime()
111
+ if (time >= from && time <= to) events.push(event)
112
+ }
113
+ }
114
+ return events
115
+ }
116
+
117
+ const propertyValue = (value: unknown): string | null =>
118
+ value === null || value === undefined
119
+ ? null
120
+ : typeof value === 'object'
121
+ ? JSON.stringify(value)
122
+ : String(value)
123
+
124
+ export function createLogViewerPort(
125
+ db: Db,
126
+ config: Pick<CromboloConfig, 'logsDir' | 'logLevel'>,
127
+ ): LogViewerPort {
128
+ const searches = new LogSearchRepository(db)
129
+ return {
130
+ async canView(range) {
131
+ let bytes = 0
132
+ for (const file of await filesFor(config.logsDir, resolveRange(range)))
133
+ bytes += (await stat(file)).size
134
+ return Math.floor(bytes / 1024 / 1024) <= LOG_SIZE_CAP_MB
135
+ },
136
+
137
+ async levelCounts(range) {
138
+ const counts = { information: 0, debug: 0, warning: 0, error: 0, fatal: 0 }
139
+ for (const event of await readEvents(config.logsDir, resolveRange(range))) {
140
+ // Verbose is not counted, as in Umbraco
141
+ const key = event.level.toLowerCase() as keyof typeof counts
142
+ if (key in counts) counts[key] += 1
143
+ }
144
+ return counts
145
+ },
146
+
147
+ async templates(range) {
148
+ const counts = new Map<string, number>()
149
+ for (const event of await readEvents(config.logsDir, resolveRange(range)))
150
+ counts.set(event.messageTemplate, (counts.get(event.messageTemplate) ?? 0) + 1)
151
+ return [...counts]
152
+ .map(([messageTemplate, count]) => ({ messageTemplate, count }))
153
+ .sort((a, b) => b.count - a.count)
154
+ },
155
+
156
+ async logs(range, query) {
157
+ const matches = logFilter(query.filter)
158
+ const levels = new Set(query.levels.map((l) => l.toLowerCase()))
159
+ const events = (await readEvents(config.logsDir, resolveRange(range)))
160
+ .filter(matches)
161
+ .filter((event) => levels.size === 0 || levels.has(event.level.toLowerCase()))
162
+ const sign = query.direction === 'Ascending' ? 1 : -1
163
+ events.sort((a, b) => (Date.parse(a.timestamp) - Date.parse(b.timestamp)) * sign)
164
+ return events.map(
165
+ (event): LogEntry => ({
166
+ timestamp: event.timestamp,
167
+ level: event.level,
168
+ messageTemplate: event.messageTemplate,
169
+ renderedMessage: event.message,
170
+ exception: event.exception,
171
+ properties: Object.entries(event.properties).map(([name, value]) => ({
172
+ name,
173
+ value: propertyValue(value),
174
+ })),
175
+ }),
176
+ )
177
+ },
178
+
179
+ loggers: () => [
180
+ { name: 'Global', level: SERILOG_LEVELS[config.logLevel] as LogLevelName },
181
+ // Everything the loggers pass reaches the file
182
+ { name: 'CromboloFile', level: 'Verbose' },
183
+ ],
184
+
185
+ savedSearches: () => searches.all(),
186
+ savedSearch: (name) => searches.byName(name),
187
+ saveSearch: (name, query) => searches.create(name, query),
188
+ deleteSearch: (name) => searches.delete(name),
189
+ }
190
+ }
package/src/logging.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Logging, through LogTape, into the files Umbraco's log viewer reads: Serilog's
3
+ * compact JSON (CLEF), one event per line, one file per machine per UTC day in
4
+ * the site's `logs/` — `CromboloTraceLog.<machine>.<yyyyMMdd>.json`, after
5
+ * Umbraco's `UmbracoTraceLog.<machine>.<yyyyMMdd>.json`. A message is a
6
+ * template (`'Published {name}'`) with its properties, as Serilog's are; an
7
+ * `error` property carries the exception.
8
+ */
9
+ import { appendFileSync, mkdirSync } from 'node:fs'
10
+ import { hostname } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import {
13
+ configureSync,
14
+ getConsoleSink,
15
+ getLogger,
16
+ type Logger,
17
+ type LogLevel,
18
+ type LogRecord,
19
+ type Sink,
20
+ } from '@logtape/logtape'
21
+
22
+ /** Serilog's level names, which the log viewer filters and counts by. */
23
+ export const SERILOG_LEVELS: Record<LogLevel, string> = {
24
+ trace: 'Verbose',
25
+ debug: 'Debug',
26
+ info: 'Information',
27
+ warning: 'Warning',
28
+ error: 'Error',
29
+ fatal: 'Fatal',
30
+ }
31
+
32
+ export const LOG_FILE_PREFIX = 'CromboloTraceLog'
33
+
34
+ /** Today's file for this machine: the day is UTC, as the timestamps are. */
35
+ export function logFileName(date: Date, machine = hostname()): string {
36
+ const day = date.toISOString().slice(0, 10).replaceAll('-', '')
37
+ return `${LOG_FILE_PREFIX}.${machine.replace(/[^A-Za-z0-9_-]/g, '_')}.${day}.json`
38
+ }
39
+
40
+ const renderValue = (value: unknown): unknown => {
41
+ if (value instanceof Error) return value.message
42
+ if (value instanceof Date) return value.toISOString()
43
+ return value
44
+ }
45
+
46
+ /** One CLEF event: `@t`, `@mt`, `@l` (left out for Information, as Serilog does), `@x`, then properties. */
47
+ export function toClef(record: LogRecord): Record<string, unknown> {
48
+ const { error, ...properties } = record.properties
49
+ const event: Record<string, unknown> = {
50
+ '@t': new Date(record.timestamp).toISOString(),
51
+ '@mt': typeof record.rawMessage === 'string' ? record.rawMessage : record.rawMessage.join('{}'),
52
+ }
53
+ if (record.level !== 'info') event['@l'] = SERILOG_LEVELS[record.level]
54
+ if (error !== undefined)
55
+ event['@x'] =
56
+ error instanceof Error ? (error.stack ?? `${error.name}: ${error.message}`) : String(error)
57
+ for (const [name, value] of Object.entries(properties)) event[name] = renderValue(value)
58
+ event.SourceContext = record.category.join('.')
59
+ event.MachineName = hostname()
60
+ event.ProcessId = process.pid
61
+ return event
62
+ }
63
+
64
+ /** Appends each event to the day's file; written synchronously, so nothing is lost on a crash. */
65
+ export function getClefFileSink(dir: string): Sink {
66
+ let made = false
67
+ return (record) => {
68
+ if (!made) {
69
+ mkdirSync(dir, { recursive: true })
70
+ made = true
71
+ }
72
+ appendFileSync(
73
+ join(dir, logFileName(new Date(record.timestamp))),
74
+ `${JSON.stringify(toClef(record))}\n`,
75
+ )
76
+ }
77
+ }
78
+
79
+ export interface LoggingOptions {
80
+ logsDir: string
81
+ /** The least severe level written, for the files and the console alike. */
82
+ level: LogLevel
83
+ /** Whether events are also printed; tests turn it off. */
84
+ console: boolean
85
+ }
86
+
87
+ /**
88
+ * Points every `crombolo` logger at the site's files (and the console). LogTape
89
+ * holds one configuration per process, so the most recently started server's
90
+ * wins.
91
+ */
92
+ export function configureLogging(options: LoggingOptions): void {
93
+ configureSync({
94
+ reset: true,
95
+ sinks: {
96
+ file: getClefFileSink(options.logsDir),
97
+ console: getConsoleSink(),
98
+ },
99
+ loggers: [
100
+ {
101
+ category: ['crombolo'],
102
+ lowestLevel: options.level,
103
+ sinks: options.console ? ['file', 'console'] : ['file'],
104
+ },
105
+ // The development 501 log prints its own line; its events only go to the file
106
+ {
107
+ category: ['crombolo', 'not-implemented'],
108
+ sinks: ['file'],
109
+ parentSinks: 'override',
110
+ },
111
+ // The request trace is thousands of lines per backoffice boot: the console
112
+ // only, so the log viewer's files stay readable
113
+ {
114
+ category: ['crombolo', 'http'],
115
+ sinks: options.console ? ['console'] : [],
116
+ parentSinks: 'override',
117
+ },
118
+ { category: ['logtape', 'meta'], lowestLevel: 'warning', sinks: ['console'] },
119
+ ],
120
+ })
121
+ }
122
+
123
+ /** A logger for one part of the server; its category becomes the event's `SourceContext`. */
124
+ export function logger(...category: string[]): Logger {
125
+ return getLogger(['crombolo', ...category])
126
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * The Bundles section's marketplace, over npm. `docs/17-bundles.md`.
3
+ *
4
+ * Discovery and declaration are two different things, because npm indexes only
5
+ * one of them: a bundle is *found* by the `crombolo-bundle` keyword, which is
6
+ * the only thing registry search will match, and it *declares* itself with a
7
+ * `crombolo` field in its `package.json`. Search for the keyword, then read each
8
+ * hit's packument — custom fields survive there — and offer only the packages
9
+ * that actually declare extensions. A keyword squatter never reaches the list.
10
+ *
11
+ * The search is proxied rather than done in the browser: it works where the
12
+ * browser cannot reach npm, and the backoffice keeps making same-origin
13
+ * requests only.
14
+ */
15
+ import { join } from 'node:path'
16
+ import { EXTENSION_KEYWORD, type ExtensionRegistry } from '@crombolo/backoffice-host'
17
+ import { logger } from './logging.ts'
18
+
19
+ const log = logger('packages')
20
+
21
+ /** How long a search is reused. Long enough to page a list, short enough to stay fresh. */
22
+ const CACHE_MS = 5 * 60 * 1000
23
+
24
+ export interface MarketplaceListing {
25
+ name: string
26
+ version: string
27
+ description: string
28
+ publisher: string | null
29
+ links: { npm: string; homepage: string | null; repository: string | null }
30
+ /** Whether the latest version declares backoffice extensions. */
31
+ declaresExtensions: boolean
32
+ installedVersion: string | null
33
+ }
34
+
35
+ /** An installed extension as the Installed view lists it. */
36
+ export interface InstalledListing {
37
+ /** The npm package name, which is what an uninstall names. */
38
+ packageName: string
39
+ /** The display name the package chose. */
40
+ name: string
41
+ id: string
42
+ version: string
43
+ extensionCount: number
44
+ }
45
+
46
+ export interface MarketplaceOptions {
47
+ registry: string
48
+ keyword: string
49
+ marketplaceUrl: string
50
+ extensions: ExtensionRegistry
51
+ fetch?: typeof fetch
52
+ /** Where `bun add` runs, which is the site that owns the package.json. */
53
+ siteDir: string
54
+ /** Overridden by tests; the real one shells out to Bun. */
55
+ install?: PackageInstaller
56
+ }
57
+
58
+ export interface InstallOutcome {
59
+ ok: boolean
60
+ /**
61
+ * The dependency line the site's `package.json` now holds, so the UI can show
62
+ * exactly what there is to commit: the range as written, and the version that
63
+ * resolved to.
64
+ */
65
+ dependency?: { name: string; range: string; version: string }
66
+ message: string
67
+ }
68
+
69
+ export type PackageInstaller = (
70
+ action: 'add' | 'remove',
71
+ specifier: string,
72
+ siteDir: string,
73
+ ) => Promise<{ ok: boolean; output: string }>
74
+
75
+ interface SearchObject {
76
+ package?: {
77
+ name?: string
78
+ version?: string
79
+ description?: string
80
+ publisher?: { username?: string }
81
+ links?: Record<string, string>
82
+ }
83
+ }
84
+
85
+ /** A name npm would accept, so nothing shell-ish or path-ish reaches `bun add`. */
86
+ export function isValidPackageName(name: string): boolean {
87
+ return /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(name) && name.length <= 214
88
+ }
89
+
90
+ /**
91
+ * A version or range that cannot be mistaken for another argument.
92
+ *
93
+ * Ranges start with an operator — `^2.1`, `>=1 <2` — so those lead; `-` never
94
+ * does, because `bun add` would read it as a flag. No shell is involved
95
+ * (`Bun.spawn` takes an argv), so the space in a range is harmless.
96
+ */
97
+ export function isValidVersion(version: string): boolean {
98
+ return /^[A-Za-z0-9^~>=<*][A-Za-z0-9.^~>=<| *+-]*$/.test(version) && version.length <= 64
99
+ }
100
+
101
+ export interface Marketplace {
102
+ /** Where someone browsing should be sent, when they want more than this list. */
103
+ url(): string
104
+ search(query: string | undefined): Promise<MarketplaceListing[]>
105
+ installed(): InstalledListing[]
106
+ install(name: string, version: string | undefined): Promise<InstallOutcome>
107
+ uninstall(name: string): Promise<InstallOutcome>
108
+ }
109
+
110
+ /**
111
+ * `bun add` in the site directory: Bun owns resolution and writes the lockfile.
112
+ *
113
+ * The spawn itself can fail rather than the command — an unreadable site
114
+ * directory or a `bun` that is not on the path both throw out of `Bun.spawn` —
115
+ * and that has to come back as a failed install with a reason, not as an
116
+ * exception the route turns into a 500.
117
+ */
118
+ export const bunInstaller: PackageInstaller = async (action, specifier, siteDir) => {
119
+ try {
120
+ const proc = Bun.spawn(['bun', action, specifier], {
121
+ cwd: siteDir,
122
+ stdout: 'pipe',
123
+ stderr: 'pipe',
124
+ })
125
+ const [stdout, stderr, code] = await Promise.all([
126
+ new Response(proc.stdout).text(),
127
+ new Response(proc.stderr).text(),
128
+ proc.exited,
129
+ ])
130
+ return { ok: code === 0, output: `${stdout}${stderr}`.trim() }
131
+ } catch (error) {
132
+ return {
133
+ ok: false,
134
+ output: `bun ${action} could not be run in ${siteDir}: ${
135
+ error instanceof Error ? error.message : String(error)
136
+ }`,
137
+ }
138
+ }
139
+ }
140
+
141
+ export function createMarketplace(options: MarketplaceOptions): Marketplace {
142
+ const call = options.fetch ?? fetch
143
+ const install = options.install ?? bunInstaller
144
+
145
+ /** The range `package.json` holds for a dependency, as `bun add` wrote it. */
146
+ const rangeFor = async (name: string): Promise<string | undefined> => {
147
+ try {
148
+ const json = (await Bun.file(join(options.siteDir, 'package.json')).json()) as {
149
+ dependencies?: Record<string, string>
150
+ }
151
+ return json.dependencies?.[name]
152
+ } catch {
153
+ return undefined
154
+ }
155
+ }
156
+ let cached: { at: number; query: string; listings: MarketplaceListing[] } | undefined
157
+
158
+ const installedVersion = (name: string): string | null =>
159
+ options.extensions.list().find((extension) => extension.packageName === name)?.version ?? null
160
+
161
+ /** Whether the registry says this package's latest version declares extensions. */
162
+ const declares = async (name: string): Promise<boolean> => {
163
+ try {
164
+ const response = await call(`${options.registry}/${encodeURIComponent(name)}`, {
165
+ headers: { accept: 'application/json' },
166
+ })
167
+ if (!response.ok) return false
168
+ const packument = (await response.json()) as {
169
+ 'dist-tags'?: { latest?: string }
170
+ versions?: Record<string, { crombolo?: { extensions?: unknown[] } }>
171
+ }
172
+ const latest = packument['dist-tags']?.latest
173
+ const version = latest ? packument.versions?.[latest] : undefined
174
+ return Array.isArray(version?.crombolo?.extensions) && version.crombolo.extensions.length > 0
175
+ } catch {
176
+ return false
177
+ }
178
+ }
179
+
180
+ return {
181
+ url: () => options.marketplaceUrl,
182
+
183
+ async search(query) {
184
+ const text = [`keywords:${options.keyword}`, query?.trim()].filter(Boolean).join(' ')
185
+ if (cached && cached.query === text && Date.now() - cached.at < CACHE_MS)
186
+ return cached.listings.map((listing) => ({
187
+ ...listing,
188
+ installedVersion: installedVersion(listing.name),
189
+ }))
190
+
191
+ let objects: SearchObject[] = []
192
+ try {
193
+ const response = await call(
194
+ `${options.registry}/-/v1/search?text=${encodeURIComponent(text)}&size=50`,
195
+ { headers: { accept: 'application/json' } },
196
+ )
197
+ if (!response.ok) throw new Error(`the registry answered ${response.status}`)
198
+ objects = ((await response.json()) as { objects?: SearchObject[] }).objects ?? []
199
+ } catch (error) {
200
+ // An unreachable registry is an empty list with a log line, not a 500:
201
+ // an air-gapped site should still be able to open the section.
202
+ log.warning('The package registry could not be reached: {detail}', {
203
+ detail: error instanceof Error ? error.message : String(error),
204
+ })
205
+ return []
206
+ }
207
+
208
+ // The packuments are read together: one request per hit, and a page of
209
+ // hits read one at a time would make the view feel broken.
210
+ const listings = await Promise.all(
211
+ objects
212
+ .map((object) => object.package)
213
+ .filter((pkg): pkg is NonNullable<typeof pkg> =>
214
+ Boolean(pkg?.name && isValidPackageName(pkg.name)),
215
+ )
216
+ .map(async (pkg): Promise<MarketplaceListing> => {
217
+ const name = pkg.name as string
218
+ return {
219
+ name,
220
+ version: pkg.version ?? '0.0.0',
221
+ description: pkg.description ?? '',
222
+ publisher: pkg.publisher?.username ?? null,
223
+ links: {
224
+ npm: pkg.links?.npm ?? `https://www.npmjs.com/package/${name}`,
225
+ homepage: pkg.links?.homepage ?? null,
226
+ repository: pkg.links?.repository ?? null,
227
+ },
228
+ declaresExtensions: await declares(name),
229
+ installedVersion: installedVersion(name),
230
+ }
231
+ }),
232
+ )
233
+ // Only what really declares extensions: the keyword is a claim, the field
234
+ // is the thing itself.
235
+ const offered = listings.filter((listing) => listing.declaresExtensions)
236
+ cached = { at: Date.now(), query: text, listings: offered }
237
+ return offered
238
+ },
239
+
240
+ installed() {
241
+ return options.extensions.list().map((extension) => ({
242
+ packageName: extension.packageName,
243
+ name: extension.manifest.name,
244
+ id: extension.manifest.id ?? extension.packageName,
245
+ version: extension.version,
246
+ extensionCount: extension.manifest.extensions.length,
247
+ }))
248
+ },
249
+
250
+ async install(name, version) {
251
+ if (!isValidPackageName(name))
252
+ return { ok: false, message: `'${name}' is not a package name npm would accept.` }
253
+ if (version !== undefined && !isValidVersion(version))
254
+ return { ok: false, message: `'${version}' is not a version npm would accept.` }
255
+
256
+ const specifier = version ? `${name}@${version}` : name
257
+ const result = await install('add', specifier, options.siteDir)
258
+ options.extensions.invalidate()
259
+ if (!result.ok)
260
+ return {
261
+ ok: false,
262
+ message: result.output || `Installing ${specifier} failed.`,
263
+ }
264
+
265
+ const range = (await rangeFor(name)) ?? version ?? 'latest'
266
+ const found = options.extensions.list().find((extension) => extension.packageName === name)
267
+ if (!found)
268
+ return {
269
+ ok: true,
270
+ dependency: { name, range, version: version ?? range },
271
+ message: `${name} was installed, but it declares no backoffice extensions, so nothing was added to the backoffice.`,
272
+ }
273
+ return {
274
+ ok: true,
275
+ dependency: { name, range, version: found.version },
276
+ // The install lives in this node's node_modules until the dependency is
277
+ // committed; saying so is the difference between a surprise and a plan.
278
+ message: `${name}@${found.version} is installed and active. Commit the changed package.json and bun.lock to keep it across deployments.`,
279
+ }
280
+ },
281
+
282
+ async uninstall(name) {
283
+ if (!isValidPackageName(name))
284
+ return { ok: false, message: `'${name}' is not a package name npm would accept.` }
285
+ const result = await install('remove', name, options.siteDir)
286
+ options.extensions.invalidate()
287
+ return result.ok
288
+ ? {
289
+ ok: true,
290
+ message: `${name} was removed. Commit the changed package.json and bun.lock.`,
291
+ }
292
+ : { ok: false, message: result.output || `Removing ${name} failed.` }
293
+ },
294
+ }
295
+ }
296
+
297
+ export { EXTENSION_KEYWORD }