@crombolo/server 0.9.1 → 0.10.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/package.json +11 -11
- package/src/adapters/bundles.ts +84 -9
- package/src/adapters/schema-files.ts +1 -1
- package/src/backup.ts +1 -1
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +139 -38
- package/src/config.ts +11 -3
- package/src/database.ts +50 -11
- package/src/dictionary-transfer.ts +1 -1
- package/src/email.ts +1 -1
- package/src/extensions-notice.ts +2 -2
- package/src/form-files.ts +2 -2
- package/src/form-submit.ts +1 -1
- package/src/form-workflows.ts +2 -2
- package/src/forms.ts +1 -1
- package/src/index.ts +3 -0
- package/src/marketplace.ts +117 -32
- package/src/no-nodes.ts +1 -1
- package/src/ports.ts +1 -1
- package/src/schema.ts +1 -1
- package/src/server.ts +73 -10
- package/src/status.ts +29 -0
- package/src/tar.ts +85 -0
- package/src/zip.ts +1 -1
package/src/marketplace.ts
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Bundles section's marketplace, over npm. `docs/
|
|
2
|
+
* The Bundles section's marketplace, over npm. `docs/bundles.md`.
|
|
3
3
|
*
|
|
4
4
|
* Discovery and declaration are two different things, because npm indexes only
|
|
5
5
|
* one of them: a bundle is *found* by the `crombolo-bundle` keyword, which is
|
|
6
6
|
* the only thing registry search will match, and it *declares* itself with a
|
|
7
7
|
* `crombolo` field in its `package.json`. Search for the keyword, then read each
|
|
8
8
|
* hit's packument — custom fields survive there — and offer only the packages
|
|
9
|
-
*
|
|
9
|
+
* whose manifest is a valid bundle. A keyword squatter never reaches the list.
|
|
10
|
+
*
|
|
11
|
+
* An install is `bun add`, and then — unless the site or the request says not
|
|
12
|
+
* to — the content the bundle carries, through the same installer as
|
|
13
|
+
* `crombolo bundle install`. A bundle's server half is never loaded by an
|
|
14
|
+
* install: that still takes an edit to `crombolo.config.ts` and a deploy.
|
|
10
15
|
*
|
|
11
16
|
* The search is proxied rather than done in the browser: it works where the
|
|
12
17
|
* browser cannot reach npm, and the backoffice keeps making same-origin
|
|
13
18
|
* requests only.
|
|
14
19
|
*/
|
|
15
20
|
import { join } from 'node:path'
|
|
16
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
type BundlePackage,
|
|
23
|
+
EXTENSION_KEYWORD,
|
|
24
|
+
type ExtensionRegistry,
|
|
25
|
+
type PackageJson,
|
|
26
|
+
validateBundlePackage,
|
|
27
|
+
} from '@crombolo/backoffice-host'
|
|
17
28
|
import { logger } from './logging.ts'
|
|
18
29
|
|
|
19
30
|
const log = logger('packages')
|
|
@@ -27,12 +38,12 @@ export interface MarketplaceListing {
|
|
|
27
38
|
description: string
|
|
28
39
|
publisher: string | null
|
|
29
40
|
links: { npm: string; homepage: string | null; repository: string | null }
|
|
30
|
-
/**
|
|
31
|
-
|
|
41
|
+
/** What the latest version carries; only a valid bundle is listed at all. */
|
|
42
|
+
carries: BundlePackage['carries']
|
|
32
43
|
installedVersion: string | null
|
|
33
44
|
}
|
|
34
45
|
|
|
35
|
-
/**
|
|
46
|
+
/** A bundle the site depends on, as the Installed view lists it. */
|
|
36
47
|
export interface InstalledListing {
|
|
37
48
|
/** The npm package name, which is what an uninstall names. */
|
|
38
49
|
packageName: string
|
|
@@ -41,6 +52,19 @@ export interface InstalledListing {
|
|
|
41
52
|
id: string
|
|
42
53
|
version: string
|
|
43
54
|
extensionCount: number
|
|
55
|
+
carries: BundlePackage['carries']
|
|
56
|
+
/** Whether its server half is imported into `crombolo.config.ts`; only meaningful when it carries one. */
|
|
57
|
+
wired: boolean
|
|
58
|
+
/** The version whose content was applied here, or null when none has been. */
|
|
59
|
+
contentApplied: string | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What became of the content half of an install. */
|
|
63
|
+
export interface ContentOutcome {
|
|
64
|
+
applied: boolean
|
|
65
|
+
/** Set when it was applied: the transfer run that can revert it. */
|
|
66
|
+
runId?: string
|
|
67
|
+
message: string
|
|
44
68
|
}
|
|
45
69
|
|
|
46
70
|
export interface MarketplaceOptions {
|
|
@@ -53,6 +77,17 @@ export interface MarketplaceOptions {
|
|
|
53
77
|
siteDir: string
|
|
54
78
|
/** Overridden by tests; the real one shells out to Bun. */
|
|
55
79
|
install?: PackageInstaller
|
|
80
|
+
/** The ids of the bundles whose server half this node has mounted. */
|
|
81
|
+
wired?: () => readonly string[]
|
|
82
|
+
/** The version of a package whose content has been applied here. */
|
|
83
|
+
contentApplied?: (packageName: string) => Promise<string | null>
|
|
84
|
+
/**
|
|
85
|
+
* Installs the content half of a bundle the site now depends on. Absent where
|
|
86
|
+
* content cannot be applied, which declines it for every install.
|
|
87
|
+
*/
|
|
88
|
+
applyContent?: (packageName: string) => Promise<ContentOutcome>
|
|
89
|
+
/** The site's `applyBundleContentOnInstall`. */
|
|
90
|
+
applyContentOnInstall?: boolean
|
|
56
91
|
}
|
|
57
92
|
|
|
58
93
|
export interface InstallOutcome {
|
|
@@ -64,6 +99,8 @@ export interface InstallOutcome {
|
|
|
64
99
|
*/
|
|
65
100
|
dependency?: { name: string; range: string; version: string }
|
|
66
101
|
message: string
|
|
102
|
+
/** What happened to the content the bundle carries; absent when it carries none. */
|
|
103
|
+
content?: ContentOutcome
|
|
67
104
|
}
|
|
68
105
|
|
|
69
106
|
export type PackageInstaller = (
|
|
@@ -102,8 +139,12 @@ export interface Marketplace {
|
|
|
102
139
|
/** Where someone browsing should be sent, when they want more than this list. */
|
|
103
140
|
url(): string
|
|
104
141
|
search(query: string | undefined): Promise<MarketplaceListing[]>
|
|
105
|
-
installed(): InstalledListing[]
|
|
106
|
-
install(
|
|
142
|
+
installed(): Promise<InstalledListing[]>
|
|
143
|
+
install(
|
|
144
|
+
name: string,
|
|
145
|
+
version: string | undefined,
|
|
146
|
+
options?: { applyContent?: boolean },
|
|
147
|
+
): Promise<InstallOutcome>
|
|
107
148
|
uninstall(name: string): Promise<InstallOutcome>
|
|
108
149
|
}
|
|
109
150
|
|
|
@@ -156,24 +197,35 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
|
|
|
156
197
|
let cached: { at: number; query: string; listings: MarketplaceListing[] } | undefined
|
|
157
198
|
|
|
158
199
|
const installedVersion = (name: string): string | null =>
|
|
159
|
-
options.extensions.
|
|
200
|
+
options.extensions.bundles().bundles.find((bundle) => bundle.packageName === name)?.version ??
|
|
201
|
+
null
|
|
160
202
|
|
|
161
|
-
/**
|
|
162
|
-
const declares = async (name: string): Promise<
|
|
203
|
+
/** What the registry says this package's latest version carries, when it is a valid bundle. */
|
|
204
|
+
const declares = async (name: string): Promise<BundlePackage['carries'] | undefined> => {
|
|
163
205
|
try {
|
|
164
206
|
const response = await call(`${options.registry}/${encodeURIComponent(name)}`, {
|
|
165
207
|
headers: { accept: 'application/json' },
|
|
166
208
|
})
|
|
167
|
-
if (!response.ok) return
|
|
209
|
+
if (!response.ok) return undefined
|
|
168
210
|
const packument = (await response.json()) as {
|
|
169
211
|
'dist-tags'?: { latest?: string }
|
|
170
|
-
versions?: Record<string,
|
|
212
|
+
versions?: Record<string, PackageJson>
|
|
171
213
|
}
|
|
172
214
|
const latest = packument['dist-tags']?.latest
|
|
173
215
|
const version = latest ? packument.versions?.[latest] : undefined
|
|
174
|
-
|
|
216
|
+
if (!version || validateBundlePackage(version).length > 0) return undefined
|
|
217
|
+
const field = version.crombolo as {
|
|
218
|
+
extensions?: unknown[]
|
|
219
|
+
content?: string
|
|
220
|
+
server?: string
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
extensions: Array.isArray(field.extensions) && field.extensions.length > 0,
|
|
224
|
+
content: field.content !== undefined,
|
|
225
|
+
server: field.server !== undefined,
|
|
226
|
+
}
|
|
175
227
|
} catch {
|
|
176
|
-
return
|
|
228
|
+
return undefined
|
|
177
229
|
}
|
|
178
230
|
}
|
|
179
231
|
|
|
@@ -213,8 +265,10 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
|
|
|
213
265
|
.filter((pkg): pkg is NonNullable<typeof pkg> =>
|
|
214
266
|
Boolean(pkg?.name && isValidPackageName(pkg.name)),
|
|
215
267
|
)
|
|
216
|
-
.map(async (pkg): Promise<MarketplaceListing> => {
|
|
268
|
+
.map(async (pkg): Promise<MarketplaceListing | undefined> => {
|
|
217
269
|
const name = pkg.name as string
|
|
270
|
+
const carries = await declares(name)
|
|
271
|
+
if (!carries) return undefined
|
|
218
272
|
return {
|
|
219
273
|
name,
|
|
220
274
|
version: pkg.version ?? '0.0.0',
|
|
@@ -225,29 +279,35 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
|
|
|
225
279
|
homepage: pkg.links?.homepage ?? null,
|
|
226
280
|
repository: pkg.links?.repository ?? null,
|
|
227
281
|
},
|
|
228
|
-
|
|
282
|
+
carries,
|
|
229
283
|
installedVersion: installedVersion(name),
|
|
230
284
|
}
|
|
231
285
|
}),
|
|
232
286
|
)
|
|
233
|
-
// Only what really
|
|
234
|
-
//
|
|
235
|
-
const offered = listings.filter((listing) => listing
|
|
287
|
+
// Only what really is a bundle: the keyword is a claim, the manifest is
|
|
288
|
+
// the thing itself.
|
|
289
|
+
const offered = listings.filter((listing): listing is MarketplaceListing => Boolean(listing))
|
|
236
290
|
cached = { at: Date.now(), query: text, listings: offered }
|
|
237
291
|
return offered
|
|
238
292
|
},
|
|
239
293
|
|
|
240
|
-
installed() {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
294
|
+
async installed() {
|
|
295
|
+
const wired = new Set(options.wired?.() ?? [])
|
|
296
|
+
return Promise.all(
|
|
297
|
+
options.extensions.bundles().bundles.map(async (bundle) => ({
|
|
298
|
+
packageName: bundle.packageName,
|
|
299
|
+
name: bundle.name,
|
|
300
|
+
id: bundle.id,
|
|
301
|
+
version: bundle.version,
|
|
302
|
+
extensionCount: bundle.field.extensions?.length ?? 0,
|
|
303
|
+
carries: bundle.carries,
|
|
304
|
+
wired: wired.has(bundle.id),
|
|
305
|
+
contentApplied: (await options.contentApplied?.(bundle.packageName)) ?? null,
|
|
306
|
+
})),
|
|
307
|
+
)
|
|
248
308
|
},
|
|
249
309
|
|
|
250
|
-
async install(name, version) {
|
|
310
|
+
async install(name, version, request = {}) {
|
|
251
311
|
if (!isValidPackageName(name))
|
|
252
312
|
return { ok: false, message: `'${name}' is not a package name npm would accept.` }
|
|
253
313
|
if (version !== undefined && !isValidVersion(version))
|
|
@@ -263,19 +323,44 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
|
|
|
263
323
|
}
|
|
264
324
|
|
|
265
325
|
const range = (await rangeFor(name)) ?? version ?? 'latest'
|
|
266
|
-
const found = options.extensions
|
|
326
|
+
const found = options.extensions
|
|
327
|
+
.bundles()
|
|
328
|
+
.bundles.find((bundle) => bundle.packageName === name)
|
|
267
329
|
if (!found)
|
|
268
330
|
return {
|
|
269
331
|
ok: true,
|
|
270
332
|
dependency: { name, range, version: version ?? range },
|
|
271
|
-
message: `${name} was installed, but it
|
|
333
|
+
message: `${name} was installed, but it is not a valid bundle, so nothing was added to the backoffice.`,
|
|
272
334
|
}
|
|
335
|
+
|
|
336
|
+
// The content half lands after the dependency, so a refused check leaves
|
|
337
|
+
// the package installed and says why, rather than undoing an install that
|
|
338
|
+
// succeeded.
|
|
339
|
+
let content: ContentOutcome | undefined
|
|
340
|
+
if (found.carries.content) {
|
|
341
|
+
const declined = request.applyContent === false || options.applyContentOnInstall === false
|
|
342
|
+
content =
|
|
343
|
+
declined || !options.applyContent
|
|
344
|
+
? {
|
|
345
|
+
applied: false,
|
|
346
|
+
message: `Its content was not applied. Run \`crombolo bundle install ${name}\` to apply it.`,
|
|
347
|
+
}
|
|
348
|
+
: await options.applyContent(name).catch((error: unknown) => ({
|
|
349
|
+
applied: false,
|
|
350
|
+
message: `Its content could not be applied: ${error instanceof Error ? error.message : String(error)}`,
|
|
351
|
+
}))
|
|
352
|
+
}
|
|
273
353
|
return {
|
|
274
354
|
ok: true,
|
|
275
355
|
dependency: { name, range, version: found.version },
|
|
276
356
|
// The install lives in this node's node_modules until the dependency is
|
|
277
357
|
// committed; saying so is the difference between a surprise and a plan.
|
|
278
|
-
message: `${name}@${found.version} is installed
|
|
358
|
+
message: `${name}@${found.version} is installed. Commit the changed package.json and bun.lock to keep it across deployments.${
|
|
359
|
+
found.carries.server
|
|
360
|
+
? ' Its server half runs only once crombolo.config.ts imports it and the site is deployed.'
|
|
361
|
+
: ''
|
|
362
|
+
}`,
|
|
363
|
+
...(content ? { content } : {}),
|
|
279
364
|
}
|
|
280
365
|
},
|
|
281
366
|
|
package/src/no-nodes.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The holding page for a site with nothing published — Umbraco's "no nodes"
|
|
3
|
-
* screen, which `docs/
|
|
3
|
+
* screen, which `docs/features.md` asks for.
|
|
4
4
|
*
|
|
5
5
|
* It answers 404, not 200: a brand-new site is not a working one, and anything
|
|
6
6
|
* watching the URL should keep saying so until a page is published. It is only
|
package/src/ports.ts
CHANGED
|
@@ -89,7 +89,7 @@ const CULTURES = [
|
|
|
89
89
|
|
|
90
90
|
function createServerPort(config: CromboloConfig, canSendUserLinks: boolean): ServerPort {
|
|
91
91
|
return {
|
|
92
|
-
//
|
|
92
|
+
// Story 080 derives this from migration state; until then the site is always up.
|
|
93
93
|
status: () => ({ serverStatus: 'Run' }),
|
|
94
94
|
configuration: () => ({
|
|
95
95
|
// Reported false when no link could be delivered, so the client does not
|
package/src/schema.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Schema-as-code at runtime: the boot sync, the development watcher that
|
|
3
3
|
* re-syncs on file changes, and the cache-instruction poll that keeps this
|
|
4
|
-
* node's caches coherent with the others. docs/
|
|
4
|
+
* node's caches coherent with the others. docs/schema-as-code.md.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, watch } from 'node:fs'
|
package/src/server.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Crombolo server.
|
|
3
3
|
*
|
|
4
|
-
* Route order matters and mirrors docs/
|
|
4
|
+
* Route order matters and mirrors docs/architecture.md: static assets, the
|
|
5
5
|
* non-contract security and branding endpoints, the contract-first Management
|
|
6
6
|
* API, the SPA shells, then the front-end site.
|
|
7
7
|
*/
|
|
@@ -41,11 +41,13 @@ import {
|
|
|
41
41
|
} from '@crombolo/core'
|
|
42
42
|
import {
|
|
43
43
|
appendCacheInstruction,
|
|
44
|
+
createTablesCapability,
|
|
44
45
|
type Db,
|
|
45
46
|
FormEntryRepository,
|
|
46
47
|
FormWorkflowRepository,
|
|
47
48
|
RedirectRepository,
|
|
48
49
|
readReport,
|
|
50
|
+
TransferRunRepository,
|
|
49
51
|
type WritesPaused,
|
|
50
52
|
} from '@crombolo/data'
|
|
51
53
|
import {
|
|
@@ -63,9 +65,12 @@ import {
|
|
|
63
65
|
import type { SyncReport } from '@crombolo/schema'
|
|
64
66
|
import type { Server } from 'bun'
|
|
65
67
|
import { createNodeLookup, loadAccess } from './access.ts'
|
|
68
|
+
import { readCreatedBundlePackage, writeCreatedBundlePackage } from './adapters/bundles.ts'
|
|
66
69
|
import { createPublishedContentSource, loadDraftNode } from './adapters/documents.ts'
|
|
67
70
|
import { createSchemaProposals } from './adapters/schema-proposals.ts'
|
|
68
|
-
import {
|
|
71
|
+
import { installBundleContent } from './bundle-install.ts'
|
|
72
|
+
import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
|
|
73
|
+
import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
|
|
69
74
|
import { type CromboloConfig, loadConfig, type ServerRole, VERSION } from './config.ts'
|
|
70
75
|
import { attachDatabase, bootstrapDatabase } from './database.ts'
|
|
71
76
|
import { createDiagnostics } from './diagnostics.ts'
|
|
@@ -198,7 +203,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
198
203
|
}
|
|
199
204
|
}
|
|
200
205
|
const domainLog = logger('domains')
|
|
201
|
-
// Extensions are the site's own npm dependencies (`docs/
|
|
206
|
+
// Extensions are the site's own npm dependencies (`docs/bundles.md`).
|
|
202
207
|
// Uncached in development so an author editing one sees a reload pick it up.
|
|
203
208
|
const extensions = createExtensionRegistry(config.siteDir, { cache: !config.development })
|
|
204
209
|
const marketplace = createMarketplace({
|
|
@@ -207,6 +212,26 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
207
212
|
marketplaceUrl: config.marketplaceUrl,
|
|
208
213
|
extensions,
|
|
209
214
|
siteDir: config.siteDir,
|
|
215
|
+
wired: () => bundles?.mounted.map((bundle) => bundle.id) ?? [],
|
|
216
|
+
contentApplied: async (packageName) =>
|
|
217
|
+
(await new TransferRunRepository(db).appliedForPackage(packageName))?.packageVersion ?? null,
|
|
218
|
+
applyContentOnInstall: config.applyBundleContentOnInstall,
|
|
219
|
+
applyContent: async (packageName) => {
|
|
220
|
+
const result = await installBundleContent(db, config, { bundle: packageName })
|
|
221
|
+
if (!result.runId) {
|
|
222
|
+
const reasons = [
|
|
223
|
+
...result.problems,
|
|
224
|
+
...result.check.outstanding.map((finding) => finding.message),
|
|
225
|
+
]
|
|
226
|
+
return {
|
|
227
|
+
applied: false,
|
|
228
|
+
message: `Its content was not applied: ${reasons.join('; ') || 'the check refused it'}. Resolve that, then run \`crombolo bundle install ${packageName}\`.`,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// The content landed under a cache this process had already constructed.
|
|
232
|
+
cache.invalidate()
|
|
233
|
+
return { applied: true, runId: result.runId, message: 'Its content was applied.' }
|
|
234
|
+
},
|
|
210
235
|
})
|
|
211
236
|
const paths = createBackOfficePaths({ backOfficePath: config.backOfficePath })
|
|
212
237
|
// Two questions, not one: what this process answers, and what it is
|
|
@@ -214,8 +239,17 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
214
239
|
// `all` and `web` serve the public site.
|
|
215
240
|
const servesBackOffice = config.role !== 'web'
|
|
216
241
|
const owns = config.role !== 'web'
|
|
242
|
+
// Bundles are checked before the database is touched, because a bundle that
|
|
243
|
+
// owns tables is migrated at boot, and a bad declaration must not get that far.
|
|
244
|
+
const bundlePackages = servesBackOffice ? extensions.bundles().bundles : []
|
|
245
|
+
if (servesBackOffice) {
|
|
246
|
+
const problems = validateServerBundles(config.bundles, bundlePackages, VERSION)
|
|
247
|
+
if (problems.length > 0)
|
|
248
|
+
throw new Error(`The configured bundles cannot be mounted:\n- ${problems.join('\n- ')}`)
|
|
249
|
+
}
|
|
250
|
+
const chains = servesBackOffice ? bundleChains(config.bundles, bundlePackages) : []
|
|
217
251
|
const { db, seededAdminPassword } = owns
|
|
218
|
-
? await bootstrapDatabase(config)
|
|
252
|
+
? await bootstrapDatabase(config, chains)
|
|
219
253
|
: await attachDatabase(config)
|
|
220
254
|
// A configured schema store is copied down before anything reads `schema/`, and
|
|
221
255
|
// becomes the schema directory for this boot. Everything after here — loading,
|
|
@@ -351,6 +385,12 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
351
385
|
const bundles = servesBackOffice
|
|
352
386
|
? createServerBundles({
|
|
353
387
|
bundles: config.bundles,
|
|
388
|
+
packages: bundlePackages,
|
|
389
|
+
coreVersion: VERSION,
|
|
390
|
+
tables: (bundle) => {
|
|
391
|
+
const chain = chains.find((candidate) => candidate.id === bundle.id)
|
|
392
|
+
return chain ? createTablesCapability(db, chain.compiled.schema) : undefined
|
|
393
|
+
},
|
|
354
394
|
pluginApiPath: `${paths.pluginPath}/api`,
|
|
355
395
|
host: {
|
|
356
396
|
redirects: createBundleRedirects({ db, cache }),
|
|
@@ -592,7 +632,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
592
632
|
nodeId: schema.nodeId,
|
|
593
633
|
role: config.role,
|
|
594
634
|
})
|
|
595
|
-
// App_Plugins is gone (`docs/
|
|
635
|
+
// App_Plugins is gone (`docs/bundles.md`). Saying so beats a site whose
|
|
596
636
|
// extensions silently stopped loading after an upgrade; remove at 1.0.
|
|
597
637
|
warnAboutAppPlugins(config.siteDir, log)
|
|
598
638
|
noticeAboutEmail(emailStatus, logger('email'), { development: config.development })
|
|
@@ -847,24 +887,44 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
847
887
|
return Response.json({
|
|
848
888
|
url: marketplace.url(),
|
|
849
889
|
keyword: config.packageKeyword,
|
|
890
|
+
appliesContent: config.applyBundleContentOnInstall,
|
|
850
891
|
items: await marketplace.search(url.searchParams.get('q') ?? undefined),
|
|
851
892
|
})
|
|
852
893
|
}
|
|
853
894
|
|
|
895
|
+
// A created bundle's npm name and version: not in Umbraco's definition, so
|
|
896
|
+
// not in the contract, and set from the Created view beside it.
|
|
897
|
+
const created = /^created\/([0-9a-f-]{36})\/package$/i.exec(action)
|
|
898
|
+
if (created) {
|
|
899
|
+
const id = created[1] as string
|
|
900
|
+
if (request.method === 'GET') {
|
|
901
|
+
const found = await readCreatedBundlePackage(db, id)
|
|
902
|
+
return found ? Response.json(found) : new Response('Not Found', { status: 404 })
|
|
903
|
+
}
|
|
904
|
+
if (request.method === 'PUT') {
|
|
905
|
+
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>
|
|
906
|
+
const outcome = await writeCreatedBundlePackage(db, id, body)
|
|
907
|
+
return Response.json(outcome, { status: outcome.ok ? 200 : 400 })
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
854
911
|
if (action === 'installed' && request.method === 'GET') {
|
|
855
|
-
return Response.json({ items: marketplace.installed() })
|
|
912
|
+
return Response.json({ items: await marketplace.installed() })
|
|
856
913
|
}
|
|
857
914
|
|
|
858
915
|
if ((action === 'install' || action === 'uninstall') && request.method === 'POST') {
|
|
859
916
|
const body = (await request.json().catch(() => ({}))) as {
|
|
860
917
|
name?: unknown
|
|
861
918
|
version?: unknown
|
|
919
|
+
applyContent?: unknown
|
|
862
920
|
}
|
|
863
921
|
const name = typeof body.name === 'string' ? body.name : ''
|
|
864
922
|
const version = typeof body.version === 'string' ? body.version : undefined
|
|
865
923
|
const outcome =
|
|
866
924
|
action === 'install'
|
|
867
|
-
? await marketplace.install(name, version
|
|
925
|
+
? await marketplace.install(name, version, {
|
|
926
|
+
applyContent: body.applyContent !== false,
|
|
927
|
+
})
|
|
868
928
|
: await marketplace.uninstall(name)
|
|
869
929
|
log.info('{user} {action} the package {name}: {outcome}', {
|
|
870
930
|
user: principal.email,
|
|
@@ -901,7 +961,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
901
961
|
/**
|
|
902
962
|
* Non-contract: forms and their entries. There is no form operation in the
|
|
903
963
|
* vendored contract — Umbraco Forms ships its own API and its own client,
|
|
904
|
-
* and neither is here — so this is ours (`docs/
|
|
964
|
+
* and neither is here — so this is ours (`docs/forms.md`).
|
|
905
965
|
*
|
|
906
966
|
* Definitions are read from `schema/` per request rather than cached: the
|
|
907
967
|
* files are the truth, the backoffice may have just rewritten one, and a
|
|
@@ -924,7 +984,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
924
984
|
const byKey = formsByKey(definitions)
|
|
925
985
|
// Submissions are personal data, so reading them asks for more than a
|
|
926
986
|
// session. Four verbs, because designing a form and reading what people
|
|
927
|
-
// sent it are different jobs (`docs/
|
|
987
|
+
// sent it are different jobs (`docs/forms.md`).
|
|
928
988
|
const may = (verb: string) => principal.isAdmin || principal.permissions.includes(verb)
|
|
929
989
|
const formFiles = {
|
|
930
990
|
schemaDir: config.schemaDir,
|
|
@@ -1122,7 +1182,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
|
|
|
1122
1182
|
source[f.kind] = (source[f.kind] ?? 0) + 1
|
|
1123
1183
|
bySource[f.source] = source
|
|
1124
1184
|
}
|
|
1125
|
-
|
|
1185
|
+
// A bundle's tables are part of what an upgrade changes, so the dashboard
|
|
1186
|
+
// that shows an upgrade's findings shows their steps too.
|
|
1187
|
+
const bundles = await bundleTablesReport(db, chains)
|
|
1188
|
+
return Response.json({ health: health(), counts, bySource, findings, bundles })
|
|
1126
1189
|
}
|
|
1127
1190
|
|
|
1128
1191
|
const staticMatch = resolveStaticFile(paths, pathname, {
|
package/src/status.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
TransferRunRepository,
|
|
28
28
|
} from '@crombolo/data'
|
|
29
29
|
import { loadSchemaDirectory } from '@crombolo/schema'
|
|
30
|
+
import { BundleConfigurationError, bundleChainsFor, pendingBundleSteps } from './bundle-tables.ts'
|
|
30
31
|
import { type CromboloConfig, describeDatabase, VERSION } from './config.ts'
|
|
31
32
|
import { type EmailAvailability, emailAvailability, resolveEmailPort } from './email.ts'
|
|
32
33
|
import { assertViewRuntime } from './view-runtime.ts'
|
|
@@ -60,6 +61,12 @@ export interface SiteStatus {
|
|
|
60
61
|
pending: boolean
|
|
61
62
|
}
|
|
62
63
|
framework: { pending: string[] }
|
|
64
|
+
/**
|
|
65
|
+
* Each wired bundle that owns tables, by id: its chain's state and the steps
|
|
66
|
+
* still to run. Only a node that mounts bundles applies them, so this is what
|
|
67
|
+
* such a node would refuse to boot over in production.
|
|
68
|
+
*/
|
|
69
|
+
bundles: Record<string, { name: string; state: string | null; pending: string[] }>
|
|
63
70
|
content: { documents: number; media: number; elements: number; lastImport?: string }
|
|
64
71
|
/**
|
|
65
72
|
* Open findings by kind. `auto` ones are reported, not counted against the
|
|
@@ -140,6 +147,7 @@ export async function siteStatus(config: CromboloConfig): Promise<SiteStatus> {
|
|
|
140
147
|
pending: false,
|
|
141
148
|
},
|
|
142
149
|
framework: { pending: [] },
|
|
150
|
+
bundles: {},
|
|
143
151
|
content: { documents: 0, media: 0, elements: 0 },
|
|
144
152
|
findings: { blocking: 0, person: 0, auto: 0, resolved: 0 },
|
|
145
153
|
versions: { crombolo: VERSION, backoffice: backofficeVersion(), bun: Bun.version },
|
|
@@ -189,6 +197,27 @@ export async function siteStatus(config: CromboloConfig): Promise<SiteStatus> {
|
|
|
189
197
|
`${status.framework.pending.length} framework migration(s) pending: run \`crombolo upgrade\``,
|
|
190
198
|
)
|
|
191
199
|
|
|
200
|
+
try {
|
|
201
|
+
const chains = bundleChainsFor(config)
|
|
202
|
+
for (const chain of chains)
|
|
203
|
+
status.bundles[chain.id] = { name: chain.name, state: null, pending: [] }
|
|
204
|
+
for (const bundle of await pendingBundleSteps(db, chains)) {
|
|
205
|
+
status.bundles[bundle.id] = {
|
|
206
|
+
name: bundle.name,
|
|
207
|
+
state: bundle.state,
|
|
208
|
+
pending: bundle.pending.map((step) => step.name),
|
|
209
|
+
}
|
|
210
|
+
if (bundle.pending.length > 0 && !config.development)
|
|
211
|
+
status.problems.push(
|
|
212
|
+
`the bundle "${bundle.id}" has ${bundle.pending.length} step(s) pending: run \`crombolo upgrade\``,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (error instanceof BundleConfigurationError)
|
|
217
|
+
for (const problem of error.problems) status.problems.push(problem)
|
|
218
|
+
else status.problems.push((error as Error).message)
|
|
219
|
+
}
|
|
220
|
+
|
|
192
221
|
const state = await currentSchemaState(db)
|
|
193
222
|
status.schema.database = { version: state.version, revision: state.revision }
|
|
194
223
|
if (loaded) {
|
package/src/tar.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal npm package tarball writer: a gzipped ustar archive with every entry
|
|
3
|
+
* under `package/`, which is the layout `npm pack` produces and `bun add
|
|
4
|
+
* ./file.tgz` and `bun publish` read (`docs/bundles.md`).
|
|
5
|
+
*
|
|
6
|
+
* No dependency: the format is 512-byte headers and padded file bodies, and
|
|
7
|
+
* `Bun.gzipSync` does the compression. Timestamps are fixed, as npm fixes them,
|
|
8
|
+
* so the same selection packs to the same bytes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface TarEntry {
|
|
12
|
+
/** The path inside the package, `/`-separated and never absolute. */
|
|
13
|
+
path: string
|
|
14
|
+
bytes: Uint8Array
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const BLOCK = 512
|
|
18
|
+
|
|
19
|
+
/** 1985-10-26T08:15:00Z, the mtime npm writes into every packed file. */
|
|
20
|
+
const NPM_MTIME = 499162500
|
|
21
|
+
|
|
22
|
+
const encoder = new TextEncoder()
|
|
23
|
+
|
|
24
|
+
/** An octal field, NUL-terminated and zero-padded to its width. */
|
|
25
|
+
function octal(value: number, width: number): Uint8Array {
|
|
26
|
+
return encoder.encode(`${value.toString(8).padStart(width - 1, '0')}\0`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Splits a path across ustar's `prefix` (155 bytes) and `name` (100 bytes) at a
|
|
31
|
+
* slash, which is how a path longer than 100 bytes is written without an
|
|
32
|
+
* extension header.
|
|
33
|
+
*/
|
|
34
|
+
function splitPath(path: string): { name: string; prefix: string } {
|
|
35
|
+
if (encoder.encode(path).length <= 100) return { name: path, prefix: '' }
|
|
36
|
+
for (let i = path.lastIndexOf('/'); i > 0; i = path.lastIndexOf('/', i - 1)) {
|
|
37
|
+
const prefix = path.slice(0, i)
|
|
38
|
+
const name = path.slice(i + 1)
|
|
39
|
+
if (encoder.encode(prefix).length <= 155 && encoder.encode(name).length <= 100)
|
|
40
|
+
return { name, prefix }
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`${path} is too long to pack`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function header(path: string, size: number): Uint8Array {
|
|
46
|
+
const block = new Uint8Array(BLOCK)
|
|
47
|
+
const put = (bytes: Uint8Array, offset: number) => block.set(bytes, offset)
|
|
48
|
+
const { name, prefix } = splitPath(path)
|
|
49
|
+
put(encoder.encode(name), 0)
|
|
50
|
+
put(octal(0o644, 8), 100)
|
|
51
|
+
put(octal(0, 8), 108)
|
|
52
|
+
put(octal(0, 8), 116)
|
|
53
|
+
put(octal(size, 12), 124)
|
|
54
|
+
put(octal(NPM_MTIME, 12), 136)
|
|
55
|
+
// The checksum is computed with its own field read as spaces.
|
|
56
|
+
put(encoder.encode(' '), 148)
|
|
57
|
+
put(encoder.encode('0'), 156)
|
|
58
|
+
put(encoder.encode('ustar\0'), 257)
|
|
59
|
+
put(encoder.encode('00'), 263)
|
|
60
|
+
put(encoder.encode(prefix), 345)
|
|
61
|
+
const sum = block.reduce((total, byte) => total + byte, 0)
|
|
62
|
+
put(encoder.encode(`${sum.toString(8).padStart(6, '0')}\0 `), 148)
|
|
63
|
+
return block
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The entries as an npm package tarball, each under `package/`. */
|
|
67
|
+
export function writeNpmTarball(entries: readonly TarEntry[]): Uint8Array<ArrayBuffer> {
|
|
68
|
+
const parts: Uint8Array[] = []
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
if (entry.path.startsWith('/') || entry.path.split('/').includes('..'))
|
|
71
|
+
throw new Error(`${entry.path} is not a path inside the package`)
|
|
72
|
+
parts.push(header(`package/${entry.path}`, entry.bytes.length), entry.bytes)
|
|
73
|
+
const pad = (BLOCK - (entry.bytes.length % BLOCK)) % BLOCK
|
|
74
|
+
if (pad > 0) parts.push(new Uint8Array(pad))
|
|
75
|
+
}
|
|
76
|
+
// Two empty blocks end the archive.
|
|
77
|
+
parts.push(new Uint8Array(BLOCK * 2))
|
|
78
|
+
const tar = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
|
|
79
|
+
let offset = 0
|
|
80
|
+
for (const part of parts) {
|
|
81
|
+
tar.set(part, offset)
|
|
82
|
+
offset += part.length
|
|
83
|
+
}
|
|
84
|
+
return Bun.gzipSync(tar) as Uint8Array<ArrayBuffer>
|
|
85
|
+
}
|
package/src/zip.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A minimal zip writer, because a created package is a zip and nothing else
|
|
3
|
-
* here needs one (`docs/
|
|
3
|
+
* here needs one (`docs/bundles.md`).
|
|
4
4
|
*
|
|
5
5
|
* No dependency: `node:zlib` provides `deflateRawSync` and `crc32`, which is
|
|
6
6
|
* everything the format needs. Entries are written in the order given, each
|