@crombolo/cli 0.0.0-stage → 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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/bin/crombolo.ts +2183 -0
- package/package.json +42 -4
- package/src/index.ts +7 -0
- package/src/operations/commands.ts +645 -0
- package/src/templates.ts +309 -0
- package/templates/basic/bundle/content/bundle.json +45 -0
- package/templates/basic/bundle/content/nodes/36955f6e-d110-469e-b280-72096eccd148.json +38 -0
- package/templates/basic/bundle/package.json +16 -0
- package/templates/basic/files/components/homePage.tsx +22 -0
- package/templates/basic/files/schema/content-types/home-page.toml +28 -0
- package/templates/basic/files/schema/schema.toml +2 -0
- package/templates/basic/template.json +4 -0
- package/templates/demo/harbourstone/CREDITS.md +42 -0
- package/templates/demo/harbourstone/bundle/content/blobs/180cc8ef/distillery-from-the-cliff-path.jpg +0 -0
- package/templates/demo/harbourstone/bundle/content/blobs/68c9cfdf/bottle-brine-and-kiln.jpg +0 -0
- package/templates/demo/harbourstone/bundle/content/blobs/6be45535/bottle-oloroso-pier.jpg +0 -0
- package/templates/demo/harbourstone/bundle/content/blobs/6d8d6398/bottle-the-cove.jpg +0 -0
- package/templates/demo/harbourstone/bundle/content/blobs/7b4713f8/copper-pot-stills.jpg +0 -0
- package/templates/demo/harbourstone/bundle/content/blobs/c114b7d8/bottle-neap-tide.jpg +0 -0
- package/templates/demo/harbourstone/bundle/content/blobs/fd337567/casks-in-the-sea-facing-warehouse.jpg +0 -0
- package/templates/demo/harbourstone/bundle/content/bundle.json +197 -0
- package/templates/demo/harbourstone/bundle/content/nodes/069cc3c5-c9f5-4792-90c9-e2bfcc4855b6.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/13788fe7-1a45-4b48-8ae4-f4227e154e1e.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/180cc8ef-caf1-4316-b6a7-e3b5aee41ffd.json +60 -0
- package/templates/demo/harbourstone/bundle/content/nodes/22144db2-e214-4006-a0bb-53b5bd121fd6.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/26e87610-e2d6-4b47-bf92-3967f7186907.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/2caa099b-3884-472d-9cea-797a0f0c376f.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/2efbdb5b-7888-4a49-bca5-c0f85b76e472.json +42 -0
- package/templates/demo/harbourstone/bundle/content/nodes/2fe03891-1294-4344-8e24-47e8b212e5b0.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/38b009f7-03c0-4fda-b5c7-843ad6044e9c.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/3d312cc8-2937-40e1-ba14-fd781c9a862d.json +95 -0
- package/templates/demo/harbourstone/bundle/content/nodes/3dc30fc1-973a-4217-b26f-24902210be17.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +101 -0
- package/templates/demo/harbourstone/bundle/content/nodes/483a0d3b-a10c-4c9d-8c81-d9607d37e968.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/4b760c0b-b83a-4eee-aa29-c225cfabf19b.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/4e5c7f3f-54cb-4328-ad61-bc4c2736b890.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/62f479ff-0875-453e-99ae-3bf1bec5e3c8.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/68c9cfdf-6b40-47da-a1d2-17256a6cc6db.json +60 -0
- package/templates/demo/harbourstone/bundle/content/nodes/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +101 -0
- package/templates/demo/harbourstone/bundle/content/nodes/6be45535-83e5-4469-acf0-4127016721ae.json +60 -0
- package/templates/demo/harbourstone/bundle/content/nodes/6d8d6398-738f-4f38-ad3c-e5d6e9a971fe.json +60 -0
- package/templates/demo/harbourstone/bundle/content/nodes/7b4713f8-d917-457d-a9d8-7dd01a3617b4.json +60 -0
- package/templates/demo/harbourstone/bundle/content/nodes/88ec1185-53b5-439a-a2ce-3f56603be4ff.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/97469df2-107c-4010-8c14-4de88f735529.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/a2eb439f-71d9-4ea6-89d7-c5bc72618281.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +101 -0
- package/templates/demo/harbourstone/bundle/content/nodes/a5e36a18-a2ef-4eb5-85b4-7e12b21cca06.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/b1876d3a-50a7-4358-84bf-a0899932ffa9.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/b4e38bbd-c1a3-431a-b7a9-5868aa32993a.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/c114b7d8-0138-404b-9d8f-a39d992577a2.json +60 -0
- package/templates/demo/harbourstone/bundle/content/nodes/cb4c569e-c28a-43b6-a4d7-d33bb544e973.json +136 -0
- package/templates/demo/harbourstone/bundle/content/nodes/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +101 -0
- package/templates/demo/harbourstone/bundle/content/nodes/d60ef12b-0f0a-4db5-9763-21fa52b8d4da.json +150 -0
- package/templates/demo/harbourstone/bundle/content/nodes/d7f13222-d060-4496-b86d-bef21da963c1.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/f36dd4a9-927c-4ca3-a790-c9c859ac7302.json +35 -0
- package/templates/demo/harbourstone/bundle/content/nodes/fd337567-df67-41ca-ae94-9d84d8d6042f.json +60 -0
- package/templates/demo/harbourstone/bundle/package.json +16 -0
- package/templates/demo/harbourstone/files/components/pages/contactPage.tsx +36 -0
- package/templates/demo/harbourstone/files/components/pages/contentPage.tsx +54 -0
- package/templates/demo/harbourstone/files/components/pages/expression.tsx +33 -0
- package/templates/demo/harbourstone/files/components/pages/homePage.tsx +71 -0
- package/templates/demo/harbourstone/files/components/pages/whiskyList.tsx +19 -0
- package/templates/demo/harbourstone/files/components/shared/bottling.tsx +59 -0
- package/templates/demo/harbourstone/files/components/shared/highlights.tsx +39 -0
- package/templates/demo/harbourstone/files/components/shared/layout.tsx +49 -0
- package/templates/demo/harbourstone/files/components/shared/section-intro.tsx +40 -0
- package/templates/demo/harbourstone/files/components/shared/site-footer.tsx +14 -0
- package/templates/demo/harbourstone/files/components/shared/site-header.tsx +57 -0
- package/templates/demo/harbourstone/files/css/site.css +720 -0
- package/templates/demo/harbourstone/files/schema/content-types/contact-page.toml +75 -0
- package/templates/demo/harbourstone/files/schema/content-types/content-page.toml +89 -0
- package/templates/demo/harbourstone/files/schema/content-types/expression.toml +78 -0
- package/templates/demo/harbourstone/files/schema/content-types/highlight.toml +27 -0
- package/templates/demo/harbourstone/files/schema/content-types/home-page.toml +104 -0
- package/templates/demo/harbourstone/files/schema/content-types/tasting-note.toml +26 -0
- package/templates/demo/harbourstone/files/schema/content-types/whisky-list.toml +35 -0
- package/templates/demo/harbourstone/files/schema/data-types/highlight-picker.toml +10 -0
- package/templates/demo/harbourstone/files/schema/data-types/tasting-note-picker.toml +11 -0
- package/templates/demo/harbourstone/files/schema/forms/contact.toml +119 -0
- package/templates/demo/harbourstone/files/schema/schema.toml +2 -0
- package/templates/demo/harbourstone/template.json +10 -0
- package/templates/starter/bundle/content/bundle.json +49 -0
- package/templates/starter/bundle/content/nodes/634dd948-cf83-4cad-9b7b-7c938b33da76.json +62 -0
- package/templates/starter/bundle/content/nodes/64d95359-d91a-4050-b440-3e5124a74999.json +62 -0
- package/templates/starter/bundle/content/nodes/d2997ebb-36ca-47cb-bbc8-31fefca787c0.json +45 -0
- package/templates/starter/bundle/package.json +16 -0
- package/templates/starter/files/components/pages/contentPage.tsx +25 -0
- package/templates/starter/files/components/shared/layout.tsx +24 -0
- package/templates/starter/files/components/shared/site-footer.tsx +9 -0
- package/templates/starter/files/components/shared/site-header.tsx +40 -0
- package/templates/starter/files/css/site.css +117 -0
- package/templates/starter/files/schema/content-types/content-page.toml +51 -0
- package/templates/starter/files/schema/schema.toml +2 -0
- package/templates/starter/template.json +4 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "The crombolo command line: scaffolding a site, schema, views, domains, content transfer and upgrades.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Crombolo",
|
|
7
|
+
"homepage": "https://github.com/kineco-au/crombolo-cms#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kineco-au/crombolo-cms.git",
|
|
11
|
+
"directory": "packages/cli"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/kineco-au/crombolo-cms/issues"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"bun": ">=1.3.0"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.ts",
|
|
22
|
+
"./bin": "./bin/crombolo.ts"
|
|
23
|
+
},
|
|
24
|
+
"bin": {
|
|
25
|
+
"crombolo": "./bin/crombolo.ts"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"bin",
|
|
30
|
+
"templates",
|
|
31
|
+
"NOTICE"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@crombolo/auth": "0.10.0",
|
|
38
|
+
"@crombolo/core": "0.10.0",
|
|
39
|
+
"@crombolo/data": "0.10.0",
|
|
40
|
+
"@crombolo/schema": "0.10.0",
|
|
41
|
+
"@crombolo/server": "0.10.0",
|
|
42
|
+
"@crombolo/transfer": "0.10.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command line's public surface: the starter-template catalogue, and the
|
|
3
|
+
* commands themselves as functions — the same ones the `crombolo` binary runs —
|
|
4
|
+
* for tooling that drives an environment without a terminal.
|
|
5
|
+
*/
|
|
6
|
+
export * from './operations/commands.ts'
|
|
7
|
+
export * from './templates.ts'
|
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commands an operator runs against an environment, as functions: they take
|
|
3
|
+
* options and return what happened, and never print or exit. The `crombolo`
|
|
4
|
+
* binary renders their results for a terminal; anything else that runs them —
|
|
5
|
+
* a platform's own tooling — gets the same results as data. One implementation,
|
|
6
|
+
* so the two can never disagree about what an import or an upgrade does.
|
|
7
|
+
*/
|
|
8
|
+
import { ROOT_ACCESS } from '@crombolo/core'
|
|
9
|
+
import {
|
|
10
|
+
bundleStateKey,
|
|
11
|
+
bundleTablesIn,
|
|
12
|
+
connect,
|
|
13
|
+
cromboloPlan,
|
|
14
|
+
type Db,
|
|
15
|
+
describeRefFailure,
|
|
16
|
+
type NodeRow,
|
|
17
|
+
type PlannedStep,
|
|
18
|
+
pauseWrites,
|
|
19
|
+
pendingMigrations,
|
|
20
|
+
planUpgrade,
|
|
21
|
+
purgeBundleTables,
|
|
22
|
+
readWritesPaused,
|
|
23
|
+
resolveNodeRef,
|
|
24
|
+
resumeWrites,
|
|
25
|
+
type WritesPaused,
|
|
26
|
+
writeReport,
|
|
27
|
+
} from '@crombolo/data'
|
|
28
|
+
import { loadSchemaDirectory, loadValueMigrations, runFix, runUpgrade } from '@crombolo/schema'
|
|
29
|
+
import {
|
|
30
|
+
type BackupResult,
|
|
31
|
+
type BundleChain,
|
|
32
|
+
BundleConfigurationError,
|
|
33
|
+
backupBefore,
|
|
34
|
+
bootSchema,
|
|
35
|
+
bootstrapDatabase,
|
|
36
|
+
bundleChainsFor,
|
|
37
|
+
CONTENT_OBJECT_TYPES,
|
|
38
|
+
CommandError,
|
|
39
|
+
type ContentCheckResult,
|
|
40
|
+
type ContentImportInput,
|
|
41
|
+
type ContentImportResult,
|
|
42
|
+
type CromboloConfig,
|
|
43
|
+
checkBundleContent,
|
|
44
|
+
checkSchemaDirectory,
|
|
45
|
+
createFileIntake,
|
|
46
|
+
createMediaPort,
|
|
47
|
+
installBundleContent,
|
|
48
|
+
installDatabase,
|
|
49
|
+
MediaFileStore,
|
|
50
|
+
mediaStoreFor,
|
|
51
|
+
pendingBundleSteps,
|
|
52
|
+
planBundleUpgrades,
|
|
53
|
+
readBlobs,
|
|
54
|
+
readBundle,
|
|
55
|
+
syncOptionsFor,
|
|
56
|
+
type TransferInput,
|
|
57
|
+
VERSION,
|
|
58
|
+
} from '@crombolo/server'
|
|
59
|
+
import {
|
|
60
|
+
bundleIdFor,
|
|
61
|
+
exportBundle,
|
|
62
|
+
isPackageName,
|
|
63
|
+
packageNameFor,
|
|
64
|
+
writeBundle,
|
|
65
|
+
writeBundlePackage,
|
|
66
|
+
} from '@crombolo/transfer'
|
|
67
|
+
|
|
68
|
+
// The installer lives in the server, so the backoffice can run it too; these
|
|
69
|
+
// are exported for the same reason the commands are: tooling that applies a
|
|
70
|
+
// bundle without a terminal needs the plan and the refusals as well.
|
|
71
|
+
export {
|
|
72
|
+
applySections,
|
|
73
|
+
type BundleSummary,
|
|
74
|
+
type CarriedSections,
|
|
75
|
+
CONTENT_OBJECT_TYPES,
|
|
76
|
+
CommandError,
|
|
77
|
+
type ContentCheckResult,
|
|
78
|
+
type ContentImportInput,
|
|
79
|
+
type ContentImportResult,
|
|
80
|
+
carriedSections,
|
|
81
|
+
carriesSections,
|
|
82
|
+
type FileAction,
|
|
83
|
+
planSections,
|
|
84
|
+
promoteBackups,
|
|
85
|
+
type ResolvedBundle,
|
|
86
|
+
type RestoredFiles,
|
|
87
|
+
RUN_BACKUP_DIR,
|
|
88
|
+
readBundle,
|
|
89
|
+
replacementsNeedingPermission,
|
|
90
|
+
restoreSectionFiles,
|
|
91
|
+
type SectionFile,
|
|
92
|
+
type SectionOptions,
|
|
93
|
+
type SectionPlan,
|
|
94
|
+
type SectionResult,
|
|
95
|
+
stageBackups,
|
|
96
|
+
type TransferInput,
|
|
97
|
+
transferOptions,
|
|
98
|
+
} from '@crombolo/server'
|
|
99
|
+
export {
|
|
100
|
+
isResolution,
|
|
101
|
+
RESOLUTIONS,
|
|
102
|
+
type Resolution,
|
|
103
|
+
type TransferCheck,
|
|
104
|
+
} from '@crombolo/transfer'
|
|
105
|
+
export type { BackupResult, WritesPaused }
|
|
106
|
+
|
|
107
|
+
/** Read-only: what installing a bundle here would do. Its findings go to the Changes dashboard. */
|
|
108
|
+
export async function contentCheck(
|
|
109
|
+
config: CromboloConfig,
|
|
110
|
+
input: TransferInput,
|
|
111
|
+
): Promise<ContentCheckResult> {
|
|
112
|
+
const { db } = await bootstrapDatabase(config)
|
|
113
|
+
try {
|
|
114
|
+
return await checkBundleContent(db, config, input)
|
|
115
|
+
} finally {
|
|
116
|
+
await db.close()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Back up, re-check, then apply in one transaction and record the run. */
|
|
121
|
+
export async function contentImport(
|
|
122
|
+
config: CromboloConfig,
|
|
123
|
+
input: ContentImportInput,
|
|
124
|
+
): Promise<ContentImportResult> {
|
|
125
|
+
// Read before anything is opened or backed up, so a bundle that is not one is
|
|
126
|
+
// refused before a backup is taken on its behalf.
|
|
127
|
+
readBundle(input.bundle, config.siteDir)
|
|
128
|
+
const { db } = await bootstrapDatabase(config)
|
|
129
|
+
try {
|
|
130
|
+
return await installBundleContent(db, config, input)
|
|
131
|
+
} finally {
|
|
132
|
+
await db.close()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ContentExportInput {
|
|
137
|
+
/** Paths or keys of the subtrees to export. */
|
|
138
|
+
roots: string[]
|
|
139
|
+
only?: boolean
|
|
140
|
+
drafts?: boolean
|
|
141
|
+
withBlobs?: boolean
|
|
142
|
+
withBlueprints?: boolean
|
|
143
|
+
/**
|
|
144
|
+
* The private package the export is written as. The name defaults to one
|
|
145
|
+
* derived from the bundle's id, the version to `1.0.0`.
|
|
146
|
+
*/
|
|
147
|
+
package?: { name?: string; version?: string }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ContentExportResult {
|
|
151
|
+
bundleId: string
|
|
152
|
+
/** The package the files make up. */
|
|
153
|
+
package: { name: string; version: string }
|
|
154
|
+
snapshot: string
|
|
155
|
+
counts: Record<string, number>
|
|
156
|
+
expected: { key: string; name: string; why: string }[]
|
|
157
|
+
/** The package's files — `package.json` and the payload under `content/` — for the caller to place. */
|
|
158
|
+
files: { path: string; bytes?: Uint8Array; text?: string }[]
|
|
159
|
+
blobs: { carried: number; bytes: number; missing: string[]; notCarried: number }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Writes nothing: returns the export as a private package's files, for the
|
|
164
|
+
* caller to place. An environment's export is a bundle like any other, but one
|
|
165
|
+
* applied from its directory and never published or depended on.
|
|
166
|
+
*/
|
|
167
|
+
export async function contentExport(
|
|
168
|
+
config: CromboloConfig,
|
|
169
|
+
input: ContentExportInput,
|
|
170
|
+
): Promise<ContentExportResult> {
|
|
171
|
+
if (input.roots.length === 0) throw new CommandError('An export needs at least one root.')
|
|
172
|
+
const requested = input.package?.name
|
|
173
|
+
if (requested !== undefined && !isPackageName(requested))
|
|
174
|
+
throw new CommandError(`"${requested}" is not a package name npm accepts.`)
|
|
175
|
+
const { db } = await bootstrapDatabase(config)
|
|
176
|
+
try {
|
|
177
|
+
// A path is resolved against the tree it is exported from, and the key is
|
|
178
|
+
// what lands in the bundle, so the artifact is identity-stable either way.
|
|
179
|
+
const roots: NodeRow[] = []
|
|
180
|
+
for (const ref of input.roots) {
|
|
181
|
+
const resolved = await resolveNodeRef(db, CONTENT_OBJECT_TYPES, ref)
|
|
182
|
+
if (!resolved.ok) throw new CommandError(describeRefFailure(ref, resolved))
|
|
183
|
+
roots.push(resolved.node)
|
|
184
|
+
}
|
|
185
|
+
const { set, blobKeys } = await exportBundle(db, {
|
|
186
|
+
roots,
|
|
187
|
+
asGiven: input.roots,
|
|
188
|
+
descendants: !input.only,
|
|
189
|
+
snapshot: input.drafts ? 'drafts' : 'published',
|
|
190
|
+
blueprints: input.withBlueprints ?? false,
|
|
191
|
+
withBlobs: input.withBlobs ?? false,
|
|
192
|
+
siteName: config.siteName,
|
|
193
|
+
nodeId: config.nodeId,
|
|
194
|
+
})
|
|
195
|
+
// The bytes are read here rather than in the exporter: where media lives is
|
|
196
|
+
// the store's business, and `@crombolo/transfer` knows nothing about it.
|
|
197
|
+
let carried = new Map<string, Uint8Array>()
|
|
198
|
+
let missing: string[] = []
|
|
199
|
+
if (input.withBlobs && blobKeys.length > 0) {
|
|
200
|
+
const read = await readBlobs(await mediaStoreFor(config), blobKeys)
|
|
201
|
+
carried = read.bytes
|
|
202
|
+
missing = read.missing
|
|
203
|
+
}
|
|
204
|
+
const pkg = {
|
|
205
|
+
name: requested ?? packageNameFor(`export-${set.manifest.id}`),
|
|
206
|
+
version: input.package?.version ?? '1.0.0',
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
bundleId: set.manifest.id,
|
|
210
|
+
package: pkg,
|
|
211
|
+
snapshot: set.manifest.snapshot,
|
|
212
|
+
counts: { ...set.manifest.counts },
|
|
213
|
+
expected: set.manifest.dependencies.expected.map((e) => ({
|
|
214
|
+
key: e.key,
|
|
215
|
+
name: e.name,
|
|
216
|
+
why: e.why,
|
|
217
|
+
})),
|
|
218
|
+
files: writeBundlePackage(writeBundle(set, carried), {
|
|
219
|
+
...pkg,
|
|
220
|
+
id: bundleIdFor(pkg.name.replace(/^@[^/]+\//, '')),
|
|
221
|
+
label: `Content from ${config.siteName}`,
|
|
222
|
+
private: true,
|
|
223
|
+
peerRange: `^${VERSION}`,
|
|
224
|
+
}),
|
|
225
|
+
blobs: {
|
|
226
|
+
carried: carried.size,
|
|
227
|
+
bytes: [...carried.values()].reduce((total, file) => total + file.length, 0),
|
|
228
|
+
missing,
|
|
229
|
+
notCarried: input.withBlobs ? 0 : blobKeys.length,
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
await db.close()
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The wired bundles' chains, checked as boot checks them, or a refusal naming every problem. */
|
|
238
|
+
function chainsFor(config: CromboloConfig): BundleChain[] {
|
|
239
|
+
try {
|
|
240
|
+
return bundleChainsFor(config)
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (error instanceof BundleConfigurationError)
|
|
243
|
+
throw new CommandError('The configured bundles cannot be used.', error.problems)
|
|
244
|
+
throw error
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const flat = (sql: string) => sql.replace(/\s+/g, ' ').trim()
|
|
249
|
+
|
|
250
|
+
async function upgradeContext(config: CromboloConfig, set: Record<string, unknown>) {
|
|
251
|
+
const chains = chainsFor(config)
|
|
252
|
+
const db = await connect({ file: config.sqliteFile })
|
|
253
|
+
const node = { nodeId: config.nodeId, revision: config.schemaRevision }
|
|
254
|
+
const loaded = () => loadSchemaDirectory(config.schemaDir)
|
|
255
|
+
const syncNode = () => ({ ...node, version: loaded().set.version })
|
|
256
|
+
const checkOptions = async () => ({
|
|
257
|
+
...syncOptionsFor(config),
|
|
258
|
+
set,
|
|
259
|
+
migrations: await loadValueMigrations(config.schemaDir),
|
|
260
|
+
backOfficePath: config.backOfficePath,
|
|
261
|
+
})
|
|
262
|
+
return { db, node, loaded, syncNode, checkOptions, chains }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export interface PlannedStepResult {
|
|
266
|
+
name: string
|
|
267
|
+
kind: string
|
|
268
|
+
release: string | null
|
|
269
|
+
statements: string[]
|
|
270
|
+
unplannable: string | null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface UpgradePlanResult {
|
|
274
|
+
steps: PlannedStepResult[]
|
|
275
|
+
/** Each wired bundle that owns tables, with the steps it would run after core's. */
|
|
276
|
+
bundles: { id: string; name: string; steps: PlannedStepResult[] }[]
|
|
277
|
+
site: { checked: boolean; action: string | null; classification: string | null }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const plannedStep = (step: PlannedStep): PlannedStepResult => ({
|
|
281
|
+
name: step.name,
|
|
282
|
+
kind: step.kind,
|
|
283
|
+
release: step.release ?? null,
|
|
284
|
+
statements: (step.statements ?? []).map(flat),
|
|
285
|
+
unplannable: step.unplannable ?? null,
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
/** The DDL every pending framework step would run, and what the site's schema would do. Writes nothing. */
|
|
289
|
+
export async function upgradePlan(config: CromboloConfig): Promise<UpgradePlanResult> {
|
|
290
|
+
const { db, node, chains } = await upgradeContext(config, {})
|
|
291
|
+
try {
|
|
292
|
+
const steps = await planUpgrade(db, cromboloPlan)
|
|
293
|
+
const bundles = await planBundleUpgrades(db, chains)
|
|
294
|
+
const check = await checkSchemaDirectory(db, config, node)
|
|
295
|
+
return {
|
|
296
|
+
steps: steps.map(plannedStep),
|
|
297
|
+
bundles: bundles.map((bundle) => ({
|
|
298
|
+
id: bundle.id,
|
|
299
|
+
name: bundle.name,
|
|
300
|
+
steps: bundle.steps.map(plannedStep),
|
|
301
|
+
})),
|
|
302
|
+
site: {
|
|
303
|
+
checked: check.siteChecked,
|
|
304
|
+
action: check.siteChecked ? check.sync.action : null,
|
|
305
|
+
classification: check.siteChecked ? check.classification : null,
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
} finally {
|
|
309
|
+
await db.close()
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The pre-upgrade report. Its findings go to the Changes dashboard once the site is checkable. */
|
|
314
|
+
export async function upgradeCheck(
|
|
315
|
+
config: CromboloConfig,
|
|
316
|
+
input: { set?: Record<string, unknown> } = {},
|
|
317
|
+
) {
|
|
318
|
+
const { db, node, chains } = await upgradeContext(config, input.set ?? {})
|
|
319
|
+
try {
|
|
320
|
+
const report = await checkSchemaDirectory(db, config, node, { set: input.set ?? {} })
|
|
321
|
+
if (report.siteChecked) await writeReport(db, report.findings, { source: 'upgrade' })
|
|
322
|
+
const bundles = await pendingBundleSteps(db, chains)
|
|
323
|
+
return {
|
|
324
|
+
bundles: bundles.map((bundle) => ({
|
|
325
|
+
id: bundle.id,
|
|
326
|
+
name: bundle.name,
|
|
327
|
+
pending: bundle.pending.map((step) => ({ name: step.name, kind: step.kind ?? 'expand' })),
|
|
328
|
+
})),
|
|
329
|
+
siteChecked: report.siteChecked,
|
|
330
|
+
classification: report.classification,
|
|
331
|
+
findings: report.findings,
|
|
332
|
+
outstanding: report.outstanding,
|
|
333
|
+
blocking: report.outstanding.some((finding) => finding.kind === 'blocking'),
|
|
334
|
+
}
|
|
335
|
+
} finally {
|
|
336
|
+
await db.close()
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Back up, then apply the additive part of an upgrade and every conversion, early. */
|
|
341
|
+
export async function upgradeFix(
|
|
342
|
+
config: CromboloConfig,
|
|
343
|
+
input: { set?: Record<string, unknown>; backupTaken?: boolean } = {},
|
|
344
|
+
) {
|
|
345
|
+
const backup = await backupBefore(config, { reason: 'fix', backupTaken: input.backupTaken })
|
|
346
|
+
const { db, loaded, syncNode, checkOptions, chains } = await upgradeContext(
|
|
347
|
+
config,
|
|
348
|
+
input.set ?? {},
|
|
349
|
+
)
|
|
350
|
+
try {
|
|
351
|
+
await installDatabase(db, config, chains)
|
|
352
|
+
const fix = await runFix(db, loaded(), syncNode(), {
|
|
353
|
+
...(await checkOptions()),
|
|
354
|
+
by: config.nodeId,
|
|
355
|
+
})
|
|
356
|
+
return {
|
|
357
|
+
backup,
|
|
358
|
+
sync: { action: fix.sync.action, deferred: fix.sync.deferred },
|
|
359
|
+
applied: fix.applied,
|
|
360
|
+
skipped: fix.skipped,
|
|
361
|
+
classification: fix.check.classification,
|
|
362
|
+
findings: fix.check.findings,
|
|
363
|
+
outstanding: fix.check.outstanding,
|
|
364
|
+
}
|
|
365
|
+
} finally {
|
|
366
|
+
await db.close()
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export type UpgradeRunResult =
|
|
371
|
+
| {
|
|
372
|
+
action: 'refused'
|
|
373
|
+
backup: BackupResult
|
|
374
|
+
reasons: { kind: string; message: string; link: string | null }[]
|
|
375
|
+
}
|
|
376
|
+
| { action: 'nothing'; backup: BackupResult }
|
|
377
|
+
| {
|
|
378
|
+
action: 'upgraded'
|
|
379
|
+
backup: BackupResult
|
|
380
|
+
frameworkApplied: string[]
|
|
381
|
+
/** Each bundle's steps that ran, after core's. */
|
|
382
|
+
bundlesApplied: { id: string; applied: string[] }[]
|
|
383
|
+
schema: { action: string; version: string | null; revision: string | null } | null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Re-check, back up, apply framework steps, cut over, ledger. */
|
|
387
|
+
export async function upgradeRun(
|
|
388
|
+
config: CromboloConfig,
|
|
389
|
+
input: { force?: string; set?: Record<string, unknown>; backupTaken?: boolean } = {},
|
|
390
|
+
): Promise<UpgradeRunResult> {
|
|
391
|
+
const backup = await backupBefore(config, { reason: 'upgrade', backupTaken: input.backupTaken })
|
|
392
|
+
const { db, loaded, syncNode, checkOptions, chains } = await upgradeContext(
|
|
393
|
+
config,
|
|
394
|
+
input.set ?? {},
|
|
395
|
+
)
|
|
396
|
+
try {
|
|
397
|
+
// Read before anything runs: `installDatabase` applies core's steps and then
|
|
398
|
+
// each bundle's, so this is what the upgrade itself applied.
|
|
399
|
+
const corePending = (await pendingMigrations(db, cromboloPlan)).map((step) => step.name)
|
|
400
|
+
const bundlesPending = await pendingBundleSteps(db, chains)
|
|
401
|
+
await installDatabase(db, config, chains)
|
|
402
|
+
const bundlesApplied = bundlesPending
|
|
403
|
+
.filter((bundle) => bundle.pending.length > 0)
|
|
404
|
+
.map((bundle) => ({ id: bundle.id, applied: bundle.pending.map((step) => step.name) }))
|
|
405
|
+
const result = await runUpgrade(db, loaded(), syncNode(), {
|
|
406
|
+
...(await checkOptions()),
|
|
407
|
+
by: config.nodeId,
|
|
408
|
+
policy: config.upgradePolicy,
|
|
409
|
+
force: input.force,
|
|
410
|
+
})
|
|
411
|
+
if (result.action === 'refused')
|
|
412
|
+
return {
|
|
413
|
+
action: 'refused',
|
|
414
|
+
backup,
|
|
415
|
+
reasons: result.reasons.map((f) => ({ kind: f.kind, message: f.message, link: f.link })),
|
|
416
|
+
}
|
|
417
|
+
const frameworkApplied = [...corePending, ...result.frameworkApplied]
|
|
418
|
+
if (result.action === 'nothing' && frameworkApplied.length === 0 && bundlesApplied.length === 0)
|
|
419
|
+
return { action: 'nothing', backup }
|
|
420
|
+
return {
|
|
421
|
+
action: 'upgraded',
|
|
422
|
+
backup,
|
|
423
|
+
frameworkApplied,
|
|
424
|
+
bundlesApplied,
|
|
425
|
+
schema: result.sync
|
|
426
|
+
? {
|
|
427
|
+
action: result.sync.action,
|
|
428
|
+
version: result.sync.state?.version ?? null,
|
|
429
|
+
revision: result.sync.state?.revision ?? null,
|
|
430
|
+
}
|
|
431
|
+
: null,
|
|
432
|
+
}
|
|
433
|
+
} finally {
|
|
434
|
+
await db.close()
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export interface MediaAddInput {
|
|
439
|
+
/** Where the file already is, in this environment's own media store. */
|
|
440
|
+
key: string
|
|
441
|
+
/** What the item is called. Defaults to the file name in `key`. */
|
|
442
|
+
name?: string
|
|
443
|
+
/** The folder to put it in, by node key. The media root when absent. */
|
|
444
|
+
parent?: string
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export interface MediaAddResult {
|
|
448
|
+
/** The new media item's node key. */
|
|
449
|
+
key: string
|
|
450
|
+
name: string
|
|
451
|
+
/** The media type it was created as, chosen from the file's extension. */
|
|
452
|
+
mediaType: string
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Registers a file that is already in the store as a media item, so it appears
|
|
457
|
+
* in the Media section and can be used in content.
|
|
458
|
+
*
|
|
459
|
+
* For a host offering an upload without opening the backoffice. The caller puts
|
|
460
|
+
* the bytes in the store — a presigned upload, typically — and names the key
|
|
461
|
+
* here, so a large file never travels through whatever asked for this. The
|
|
462
|
+
* media type is chosen from the extension the same way the backoffice chooses
|
|
463
|
+
* it, and the file is placed by the CMS in its own layout rather than by the
|
|
464
|
+
* caller, which is what keeps the store free of files nothing knows about.
|
|
465
|
+
*/
|
|
466
|
+
export async function mediaAdd(
|
|
467
|
+
config: CromboloConfig,
|
|
468
|
+
input: MediaAddInput,
|
|
469
|
+
): Promise<MediaAddResult> {
|
|
470
|
+
const name = (input.name ?? input.key.split('/').at(-1) ?? '').trim()
|
|
471
|
+
if (!name) throw new CommandError('A media item needs a name.')
|
|
472
|
+
|
|
473
|
+
const store = await mediaStoreFor(config)
|
|
474
|
+
const found = await store.get(input.key)
|
|
475
|
+
if (!found) throw new CommandError(`There is nothing at '${input.key}' in the store.`)
|
|
476
|
+
|
|
477
|
+
const files = new MediaFileStore(store)
|
|
478
|
+
const { db } = await bootstrapDatabase(config)
|
|
479
|
+
// The schema the site is actually on. Without it the port falls back to the
|
|
480
|
+
// baseline state and the write gate refuses every write, because a server at
|
|
481
|
+
// an older schema than its database must not write to it.
|
|
482
|
+
const schema = await bootSchema(db, { ...config, syncSchemaAtBoot: false })
|
|
483
|
+
const port = createMediaPort(db, files, {
|
|
484
|
+
nodeState: schema.nodeState,
|
|
485
|
+
nodeId: schema.nodeId,
|
|
486
|
+
valueIntake: createFileIntake(files),
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
// The same choice the backoffice makes: a type whose file property allows
|
|
490
|
+
// this extension, falling back to one that allows anything at all.
|
|
491
|
+
const extension = name.includes('.') ? (name.split('.').at(-1) as string) : ''
|
|
492
|
+
const types = await port.typesForExtension(extension)
|
|
493
|
+
const type = types.find((candidate) => candidate.matched) ?? types[0]
|
|
494
|
+
if (!type)
|
|
495
|
+
throw new CommandError(
|
|
496
|
+
`No media type in this site accepts a '${extension}' file, so it cannot be added.`,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
// Through the temporary-file path, so the CMS places the file and records the
|
|
500
|
+
// value exactly as it would for an upload made in the backoffice.
|
|
501
|
+
const temporaryId = crypto.randomUUID()
|
|
502
|
+
// A fresh Uint8Array from the store, handed over as a blob part. `bytes()` is
|
|
503
|
+
// typed over ArrayBufferLike, which a File constructor will not take.
|
|
504
|
+
const bytes = await found.bytes()
|
|
505
|
+
await files.saveTemporary(
|
|
506
|
+
temporaryId,
|
|
507
|
+
new File([new Blob([bytes as BlobPart], { type: found.contentType })], name),
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
const created = await port.create(
|
|
511
|
+
{
|
|
512
|
+
key: crypto.randomUUID(),
|
|
513
|
+
contentTypeKey: type.key,
|
|
514
|
+
componentKey: null,
|
|
515
|
+
parentKey: input.parent ?? null,
|
|
516
|
+
values: [
|
|
517
|
+
{
|
|
518
|
+
alias: 'umbracoFile',
|
|
519
|
+
culture: null,
|
|
520
|
+
segment: null,
|
|
521
|
+
value: { temporaryFileId: temporaryId },
|
|
522
|
+
},
|
|
523
|
+
],
|
|
524
|
+
variants: [{ culture: null, segment: null, name }],
|
|
525
|
+
},
|
|
526
|
+
// The platform acting as itself: no user account, every permission, so the
|
|
527
|
+
// item is created rather than refused for an actor the site has never seen.
|
|
528
|
+
// `create` resolves a user id from the key and simply finds none.
|
|
529
|
+
{
|
|
530
|
+
id: '',
|
|
531
|
+
userName: 'operations',
|
|
532
|
+
name: 'operations',
|
|
533
|
+
email: '',
|
|
534
|
+
isAdmin: true,
|
|
535
|
+
languageIsoCode: undefined,
|
|
536
|
+
avatarUrls: [],
|
|
537
|
+
allowedSections: [],
|
|
538
|
+
permissions: [],
|
|
539
|
+
groupKeys: [],
|
|
540
|
+
hasAccessToAllLanguages: true,
|
|
541
|
+
languages: [],
|
|
542
|
+
startNodes: { document: ROOT_ACCESS, media: ROOT_ACCESS, element: ROOT_ACCESS },
|
|
543
|
+
groups: [],
|
|
544
|
+
},
|
|
545
|
+
)
|
|
546
|
+
if (!created.ok) {
|
|
547
|
+
// Nothing was registered, so the staged copy is litter.
|
|
548
|
+
await files.deleteTemporary(temporaryId)
|
|
549
|
+
throw new CommandError(
|
|
550
|
+
`The media item could not be created: ${created.reason}.`,
|
|
551
|
+
(created.errors ?? []).map((error) => `${error.alias}: ${error.messages.join('; ')}`),
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
return { key: created.key, name, mediaType: type.name }
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Takes the backup a write would take first: a copy of SQLite, or `CROMBOLO_PG_DUMP` on Postgres. */
|
|
558
|
+
export async function backup(
|
|
559
|
+
config: CromboloConfig,
|
|
560
|
+
input: { reason?: string; backupTaken?: boolean } = {},
|
|
561
|
+
): Promise<BackupResult> {
|
|
562
|
+
return backupBefore(config, {
|
|
563
|
+
reason: input.reason ?? 'backup',
|
|
564
|
+
backupTaken: input.backupTaken,
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function withDatabase<T>(config: CromboloConfig, use: (db: Db) => Promise<T>): Promise<T> {
|
|
569
|
+
const db = await connect({ file: config.sqliteFile })
|
|
570
|
+
try {
|
|
571
|
+
return await use(db)
|
|
572
|
+
} finally {
|
|
573
|
+
await db.close()
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Refuses every editor's save, on every node, until resumed; readers are unaffected. */
|
|
578
|
+
export function pauseEditing(
|
|
579
|
+
config: CromboloConfig,
|
|
580
|
+
input: { reason?: string; by?: string } = {},
|
|
581
|
+
): Promise<WritesPaused> {
|
|
582
|
+
return withDatabase(config, (db) =>
|
|
583
|
+
pauseWrites(db, { reason: input.reason ?? '', by: input.by ?? config.nodeId }),
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function resumeEditing(config: CromboloConfig): Promise<void> {
|
|
588
|
+
return withDatabase(config, resumeWrites)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Whether editing is paused, by whom and why; undefined when it is not. */
|
|
592
|
+
export function editingPaused(config: CromboloConfig): Promise<WritesPaused | undefined> {
|
|
593
|
+
return withDatabase(config, readWritesPaused)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export interface BundlePurgePlan {
|
|
597
|
+
id: string
|
|
598
|
+
/** The tables that would be dropped, as the database names them. */
|
|
599
|
+
tables: string[]
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Refuses a purge of a bundle the site still wires: its code would recreate what was dropped. */
|
|
603
|
+
function refuseWired(config: CromboloConfig, id: string): void {
|
|
604
|
+
if (config.bundles.some((bundle) => bundle.id === id))
|
|
605
|
+
throw new CommandError(
|
|
606
|
+
`The bundle "${id}" is still wired in crombolo.config.ts. Take it out of \`bundles\` and deploy that first.`,
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Read-only: what purging a bundle would drop. */
|
|
611
|
+
export async function bundlePurgePlan(
|
|
612
|
+
config: CromboloConfig,
|
|
613
|
+
id: string,
|
|
614
|
+
): Promise<BundlePurgePlan> {
|
|
615
|
+
refuseWired(config, id)
|
|
616
|
+
const db = await connect({ file: config.sqliteFile })
|
|
617
|
+
try {
|
|
618
|
+
return { id, tables: await bundleTablesIn(db, id) }
|
|
619
|
+
} finally {
|
|
620
|
+
await db.close()
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Backs up, then drops every table under the bundle's prefix and forgets its
|
|
626
|
+
* state, ledgered as a `purge` against the bundle.
|
|
627
|
+
*/
|
|
628
|
+
export async function bundlePurge(
|
|
629
|
+
config: CromboloConfig,
|
|
630
|
+
id: string,
|
|
631
|
+
input: { backupTaken?: boolean } = {},
|
|
632
|
+
): Promise<{ backup: BackupResult; dropped: string[] }> {
|
|
633
|
+
refuseWired(config, id)
|
|
634
|
+
const backup = await backupBefore(config, { reason: 'purge', backupTaken: input.backupTaken })
|
|
635
|
+
const db = await connect({ file: config.sqliteFile })
|
|
636
|
+
try {
|
|
637
|
+
const dropped = await purgeBundleTables(db, id, {
|
|
638
|
+
by: config.nodeId,
|
|
639
|
+
stateKey: bundleStateKey(id),
|
|
640
|
+
})
|
|
641
|
+
return { backup, dropped }
|
|
642
|
+
} finally {
|
|
643
|
+
await db.close()
|
|
644
|
+
}
|
|
645
|
+
}
|