@crombolo/assistant 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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +37 -4
- package/src/apply.ts +144 -0
- package/src/changeset.ts +120 -0
- package/src/describe.ts +209 -0
- package/src/guardrails.ts +187 -0
- package/src/index.ts +12 -0
- package/src/loop.ts +81 -0
- package/src/mcp.ts +97 -0
- package/src/operations.ts +192 -0
- package/src/prompt.ts +39 -0
- package/src/provider.ts +77 -0
- package/src/providers/bedrock.ts +269 -0
- package/src/review.ts +205 -0
- package/src/tools.ts +522 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kineco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
crombolo
|
|
2
|
+
Copyright (c) 2026 Kineco
|
|
3
|
+
|
|
4
|
+
This product includes third-party software. crombolo is an independent project.
|
|
5
|
+
It is not affiliated with, endorsed by, or sponsored by Umbraco A/S, which owns
|
|
6
|
+
the Umbraco trademark.
|
|
7
|
+
|
|
8
|
+
There are three places third-party material is redistributed, rather than merely
|
|
9
|
+
depended on:
|
|
10
|
+
|
|
11
|
+
@crombolo/backoffice-dist the built Umbraco backoffice and the browser
|
|
12
|
+
packages bundled into it, produced by
|
|
13
|
+
`bun run vendor:backoffice`
|
|
14
|
+
@crombolo/backoffice-dist three CSS files copied unmodified from the Umbraco
|
|
15
|
+
client source tree
|
|
16
|
+
@crombolo/contracts the Umbraco Management API OpenAPI document,
|
|
17
|
+
copied unmodified
|
|
18
|
+
|
|
19
|
+
No Umbraco trademark is redistributed. The Umbraco wordmark, the "U" roundel
|
|
20
|
+
favicon and the installer illustration are deliberately not carried in this
|
|
21
|
+
repository: MIT grants copyright permission and says nothing about trademarks, so
|
|
22
|
+
crombolo's own marks are served at those paths instead. See `BRANDED_ASSETS` in
|
|
23
|
+
`@crombolo/backoffice-host`.
|
|
24
|
+
|
|
25
|
+
The Umbraco backoffice
|
|
26
|
+
----------------------
|
|
27
|
+
@umbraco-cms/backoffice 18.2.0 — MIT — https://github.com/umbraco/Umbraco-CMS
|
|
28
|
+
@umbraco-ui/uui 2.0.2 — MIT — https://github.com/umbraco/Umbraco.UI
|
|
29
|
+
|
|
30
|
+
Static files from the Umbraco client source tree
|
|
31
|
+
------------------------------------------------
|
|
32
|
+
css/umb-css.css, css/rte-content.css, css/umbraco-blockgridlayout.css
|
|
33
|
+
— copied unmodified from Umbraco-CMS release-18.2.0 — MIT
|
|
34
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
35
|
+
Shipped in @crombolo/backoffice-dist under `upstream-static/`.
|
|
36
|
+
|
|
37
|
+
`rte-content.css` itself carries Tiptap's default editor styles, copied from
|
|
38
|
+
tiptap v2.11.7 (MIT); the file records this in its own header.
|
|
39
|
+
|
|
40
|
+
The Umbraco Management API contract
|
|
41
|
+
-----------------------------------
|
|
42
|
+
OpenApi.json — copied unmodified from Umbraco-CMS release-18.2.0
|
|
43
|
+
(src/Umbraco.Cms.Api.Management/OpenApi.json) — MIT
|
|
44
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
45
|
+
Shipped in @crombolo/contracts, which also ships the TypeScript types generated
|
|
46
|
+
from it.
|
|
47
|
+
|
|
48
|
+
Browser runtime dependencies bundled into the backoffice build
|
|
49
|
+
--------------------------------------------------------------
|
|
50
|
+
@heximal/expressions 0.1.5 — BSD-3-Clause
|
|
51
|
+
@microsoft/signalr 10.0.11 — MIT
|
|
52
|
+
@tiptap/* 3.31.3 — MIT
|
|
53
|
+
diff 9.0.0 — BSD-3-Clause
|
|
54
|
+
dompurify 3.4.16 — Apache-2.0, elected from "MPL-2.0 OR Apache-2.0"
|
|
55
|
+
element-internals-polyfill 3.0.2 — MIT
|
|
56
|
+
lit 3.3.3 — BSD-3-Clause
|
|
57
|
+
luxon 3.7.2 — MIT
|
|
58
|
+
marked 18.0.14 — MIT
|
|
59
|
+
monaco-editor 0.57.0 — MIT
|
|
60
|
+
rxjs 7.8.2 — Apache-2.0
|
|
61
|
+
uuid 14.0.2 — MIT
|
|
62
|
+
|
|
63
|
+
dompurify is offered under either licence; Apache-2.0 is elected here, so no
|
|
64
|
+
MPL-2.0 obligation attaches to the copy bundled into the backoffice build. Of the
|
|
65
|
+
Apache-2.0 packages above, none ships a NOTICE file of its own, so Apache-2.0
|
|
66
|
+
section 4(d) adds nothing to this file.
|
|
67
|
+
|
|
68
|
+
Each package's own licence text ships inside its npm distribution and applies
|
|
69
|
+
unchanged to the copies bundled here.
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/assistant",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"description": "Optional AI helper for crombolo: reads the CMS through the management contract and proposes changes for a human to approve.",
|
|
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/assistant"
|
|
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
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"NOTICE"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@aws-sdk/client-bedrock-runtime": "^3.1142.0",
|
|
32
|
+
"@aws-sdk/credential-provider-node": "^3.972.84",
|
|
33
|
+
"@crombolo/contracts": "0.9.1",
|
|
34
|
+
"@crombolo/core": "0.9.1",
|
|
35
|
+
"@crombolo/schema": "0.9.1",
|
|
36
|
+
"@smithy/node-http-handler": "^4.12.1",
|
|
37
|
+
"@smithy/types": "^4.19.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/apply.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval: the only path by which anything the assistant proposed reaches the
|
|
3
|
+
* CMS, and a path only a person can take.
|
|
4
|
+
*
|
|
5
|
+
* An approved change is dispatched as the user who approved it, so the same
|
|
6
|
+
* section, start-node and permission checks that guard their own Save guard this
|
|
7
|
+
* too — a proposal is a suggestion, not a capability. The entity is re-read first
|
|
8
|
+
* and the write refused if it moved since the proposal was made, rather than
|
|
9
|
+
* overwriting whatever happened in between.
|
|
10
|
+
*/
|
|
11
|
+
import { listOperations, loadSpec, type OperationInfo } from '@crombolo/contracts'
|
|
12
|
+
import {
|
|
13
|
+
type ChangesetStore,
|
|
14
|
+
isApprovable,
|
|
15
|
+
type ProposedChange,
|
|
16
|
+
type SchemaProposalPort,
|
|
17
|
+
} from './changeset.ts'
|
|
18
|
+
import { APPLY_OPERATIONS, type ChangeKind, isForbidden, SCHEMA_KINDS } from './operations.ts'
|
|
19
|
+
import type { ManagementCall } from './tools.ts'
|
|
20
|
+
|
|
21
|
+
export interface ApplyOptions {
|
|
22
|
+
call: ManagementCall
|
|
23
|
+
changesets: ChangesetStore
|
|
24
|
+
operations?: readonly OperationInfo[]
|
|
25
|
+
/** Applies a schema proposal by writing its file and importing it. */
|
|
26
|
+
schema?: SchemaProposalPort
|
|
27
|
+
/**
|
|
28
|
+
* Whether the caller may change schema, which for a dispatched operation
|
|
29
|
+
* `authorization.ts` decides. A schema kind does not pass through it, so the
|
|
30
|
+
* host answers this instead; absent means no.
|
|
31
|
+
*/
|
|
32
|
+
maySetSchema?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ApplyOutcome {
|
|
36
|
+
applied: boolean
|
|
37
|
+
status: number | undefined
|
|
38
|
+
error: string | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How each kind's baseline is re-read, and what part of the response it covers. */
|
|
42
|
+
const BASELINE_READS: Partial<
|
|
43
|
+
Record<ChangeKind, { operationId: string; pick?: (body: unknown) => unknown }>
|
|
44
|
+
> = {
|
|
45
|
+
document: { operationId: 'GetDocumentById' },
|
|
46
|
+
'document-type': { operationId: 'GetDocumentTypeById' },
|
|
47
|
+
'data-type': { operationId: 'GetDataTypeById' },
|
|
48
|
+
template: {
|
|
49
|
+
operationId: 'GetTemplateById',
|
|
50
|
+
pick: (body) => (body as { content?: string | null }).content ?? null,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const digest = (value: unknown): string =>
|
|
55
|
+
new Bun.CryptoHasher('sha256').update(JSON.stringify(value ?? null)).digest('hex')
|
|
56
|
+
|
|
57
|
+
export async function applyChange(
|
|
58
|
+
change: ProposedChange,
|
|
59
|
+
options: ApplyOptions,
|
|
60
|
+
): Promise<ApplyOutcome> {
|
|
61
|
+
const operations = options.operations ?? listOperations(loadSpec())
|
|
62
|
+
const byId = new Map(operations.map((operation) => [operation.operationId, operation]))
|
|
63
|
+
|
|
64
|
+
const settle = async (error: string | undefined, status?: number): Promise<ApplyOutcome> => {
|
|
65
|
+
await options.changesets.settle(change.key, {
|
|
66
|
+
status: error === undefined ? 'applied' : 'failed',
|
|
67
|
+
error,
|
|
68
|
+
})
|
|
69
|
+
return { applied: error === undefined, status, error }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!isApprovable(change)) {
|
|
73
|
+
const why =
|
|
74
|
+
change.problems.length > 0
|
|
75
|
+
? `it has problems that must be fixed first: ${change.problems.map((problem) => problem.message).join('; ')}`
|
|
76
|
+
: `it is already ${change.status}`
|
|
77
|
+
return { applied: false, status: undefined, error: why }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Schema kinds are TOML files, not requests: written and imported, not dispatched.
|
|
81
|
+
if (SCHEMA_KINDS.has(change.kind)) {
|
|
82
|
+
if (!options.schema) return await settle('this site does not manage schema as files')
|
|
83
|
+
if (!options.maySetSchema) {
|
|
84
|
+
return await settle('you do not have access to Settings, where types are changed')
|
|
85
|
+
}
|
|
86
|
+
const body = (change.body ?? {}) as { alias?: unknown; toml?: unknown }
|
|
87
|
+
if (typeof body.alias !== 'string' || typeof body.toml !== 'string') {
|
|
88
|
+
return await settle('this proposal is missing its alias or its TOML')
|
|
89
|
+
}
|
|
90
|
+
const problems = await options.schema.validate(change.kind, body.alias, body.toml)
|
|
91
|
+
if (problems.length > 0) {
|
|
92
|
+
return await settle(problems.map((problem) => problem.message).join('; '))
|
|
93
|
+
}
|
|
94
|
+
const applied = await options.schema.apply(change.kind, body.alias, body.toml)
|
|
95
|
+
return await settle(applied.ok ? undefined : (applied.error ?? 'the import refused it'))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const operationId = APPLY_OPERATIONS[change.kind]
|
|
99
|
+
if (!operationId) return await settle(`${change.kind} has no way to be applied`)
|
|
100
|
+
const operation = byId.get(operationId)
|
|
101
|
+
// Defence against a future edit to APPLY_OPERATIONS rather than a live risk:
|
|
102
|
+
// the boundary test asserts the same thing, and this refuses at runtime too.
|
|
103
|
+
if (!operation || isForbidden(operation)) {
|
|
104
|
+
return await settle(`${operationId} cannot be applied`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const baseline = BASELINE_READS[change.kind]
|
|
108
|
+
if (baseline && change.baseline !== undefined) {
|
|
109
|
+
const entity = byId.get(baseline.operationId)
|
|
110
|
+
if (!entity) return await settle(`${baseline.operationId} is missing from the contract`)
|
|
111
|
+
const path = entity.path.replace('{id}', encodeURIComponent(change.params.id ?? ''))
|
|
112
|
+
const current = await options.call({ method: entity.method, path })
|
|
113
|
+
if (current.status !== 200) {
|
|
114
|
+
return await settle(`the item this change applies to could not be read (${current.status})`)
|
|
115
|
+
}
|
|
116
|
+
const now = digest(baseline.pick ? baseline.pick(current.body) : current.body)
|
|
117
|
+
if (now !== change.baseline) {
|
|
118
|
+
return await settle('it changed after this was proposed — ask for it to be proposed again')
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let path = operation.path
|
|
123
|
+
for (const [name, value] of Object.entries(change.params)) {
|
|
124
|
+
path = path.replace(`{${name}}`, encodeURIComponent(value))
|
|
125
|
+
}
|
|
126
|
+
if (path.includes('{')) return await settle(`${operationId} is missing a path parameter`)
|
|
127
|
+
|
|
128
|
+
const response = await options.call({ method: operation.method, path, body: change.body })
|
|
129
|
+
if (response.status >= 400) {
|
|
130
|
+
return await settle(
|
|
131
|
+
messageFrom(response.body) ?? `the API refused it (${response.status})`,
|
|
132
|
+
response.status,
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
return await settle(undefined, response.status)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The problem-details message, so a refusal reads as the backoffice would show it. */
|
|
139
|
+
function messageFrom(body: unknown): string | undefined {
|
|
140
|
+
if (typeof body !== 'object' || body === null) return undefined
|
|
141
|
+
const details = body as { detail?: unknown; title?: unknown }
|
|
142
|
+
if (typeof details.detail === 'string' && details.detail !== '') return details.detail
|
|
143
|
+
return typeof details.title === 'string' && details.title !== '' ? details.title : undefined
|
|
144
|
+
}
|
package/src/changeset.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A changeset: what the assistant has proposed, and nothing more than that.
|
|
3
|
+
*
|
|
4
|
+
* A proposal is a prepared Management API request — the operation, its path
|
|
5
|
+
* parameters and its body — so approving one needs no translation and applying it
|
|
6
|
+
* takes the same authorised path as the editor's own Save. Until then it is
|
|
7
|
+
* inert: nothing here touches a database or a file.
|
|
8
|
+
*/
|
|
9
|
+
import type { SchemaProblem } from '@crombolo/schema'
|
|
10
|
+
import type { ChangeKind } from './operations.ts'
|
|
11
|
+
|
|
12
|
+
export type ChangeStatus = 'proposed' | 'applied' | 'discarded' | 'failed'
|
|
13
|
+
|
|
14
|
+
/** Where a proposal came from, so a review can say who was driving. */
|
|
15
|
+
export type ChangeOrigin = 'backoffice' | 'mcp'
|
|
16
|
+
|
|
17
|
+
export interface ProposedChange {
|
|
18
|
+
key: string
|
|
19
|
+
kind: ChangeKind
|
|
20
|
+
/** One line for the review list, written by the assistant. */
|
|
21
|
+
summary: string
|
|
22
|
+
/** Path parameters for the apply operation, e.g. `{ id }`. */
|
|
23
|
+
params: Readonly<Record<string, string>>
|
|
24
|
+
/** The request body the approval dispatches. */
|
|
25
|
+
body: unknown
|
|
26
|
+
/**
|
|
27
|
+
* The entity as it was when proposed — a version key, or a hash of the source
|
|
28
|
+
* for a file. Apply refuses when it no longer matches.
|
|
29
|
+
*/
|
|
30
|
+
baseline: string | undefined
|
|
31
|
+
/** A non-empty list blocks approval; the UI shows why. */
|
|
32
|
+
problems: SchemaProblem[]
|
|
33
|
+
status: ChangeStatus
|
|
34
|
+
error: string | undefined
|
|
35
|
+
createDate: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Changeset {
|
|
39
|
+
key: string
|
|
40
|
+
userKey: string
|
|
41
|
+
title: string
|
|
42
|
+
origin: ChangeOrigin
|
|
43
|
+
changes: ProposedChange[]
|
|
44
|
+
createDate: string
|
|
45
|
+
updateDate: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Persistence for changesets, implemented against the database by the server. */
|
|
49
|
+
export interface ChangesetStore {
|
|
50
|
+
create(input: { userKey: string; title: string; origin: ChangeOrigin }): Promise<Changeset>
|
|
51
|
+
load(key: string): Promise<Changeset | undefined>
|
|
52
|
+
/** A user's changesets, most recently updated first. */
|
|
53
|
+
listFor(userKey: string, limit?: number): Promise<Changeset[]>
|
|
54
|
+
/**
|
|
55
|
+
* One of the user's own changes, wherever it sits. Scoped to the user so that
|
|
56
|
+
* knowing a key is not enough to approve somebody else's proposal, and a single
|
|
57
|
+
* lookup so that an old changeset is as approvable as a new one.
|
|
58
|
+
*/
|
|
59
|
+
changeFor(userKey: string, changeKey: string): Promise<ProposedChange | undefined>
|
|
60
|
+
/** The user's newest changeset of this origin with something still awaiting review. */
|
|
61
|
+
openFor(userKey: string, origin: ChangeOrigin): Promise<Changeset | undefined>
|
|
62
|
+
append(changesetKey: string, change: ProposedChange): Promise<void>
|
|
63
|
+
/**
|
|
64
|
+
* Replaces what an approved change would send, after a person edited it. Only
|
|
65
|
+
* the body: the kind and the path parameters stay as proposed, so an edit can
|
|
66
|
+
* never turn a draft save into a different operation.
|
|
67
|
+
*/
|
|
68
|
+
replaceBody(changeKey: string, body: unknown, problems: SchemaProblem[]): Promise<void>
|
|
69
|
+
/** Records the outcome of an approval or a discard. */
|
|
70
|
+
settle(
|
|
71
|
+
changeKey: string,
|
|
72
|
+
outcome: { status: ChangeStatus; error?: string | undefined },
|
|
73
|
+
): Promise<void>
|
|
74
|
+
remove(key: string): Promise<void>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const isApprovable = (change: ProposedChange): boolean =>
|
|
78
|
+
change.status === 'proposed' && change.problems.length === 0
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What approving this item does, in the words the UI must use. A document lands
|
|
82
|
+
* as a draft and stays invisible to visitors; a template and a type take effect
|
|
83
|
+
* at once, because neither has a draft state.
|
|
84
|
+
*/
|
|
85
|
+
export function effectOf(kind: ChangeKind): string {
|
|
86
|
+
switch (kind) {
|
|
87
|
+
case 'document':
|
|
88
|
+
case 'document-create':
|
|
89
|
+
return 'Saves a draft. Nothing changes for visitors until you publish it.'
|
|
90
|
+
case 'template':
|
|
91
|
+
case 'template-create':
|
|
92
|
+
return 'Goes live immediately — templates have no draft state.'
|
|
93
|
+
default:
|
|
94
|
+
return 'Changes the schema immediately, as saving the type in its workspace does.'
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Schema proposals, which are TOML files rather than API requests.
|
|
100
|
+
*
|
|
101
|
+
* Implemented by the server, because reading and writing `schema/` and importing
|
|
102
|
+
* it are the server's business; this package only ever holds the intent.
|
|
103
|
+
*/
|
|
104
|
+
export interface SchemaProposalPort {
|
|
105
|
+
/** The file this alias would be written to, repository-relative. */
|
|
106
|
+
fileFor(kind: string, alias: string): string
|
|
107
|
+
/** The TOML on disk now, or undefined when the type does not exist yet. */
|
|
108
|
+
read(kind: string, alias: string): Promise<string | undefined>
|
|
109
|
+
/** Problems with the proposed TOML, checked against the rest of the schema. */
|
|
110
|
+
validate(kind: string, alias: string, toml: string): Promise<SchemaProblem[]>
|
|
111
|
+
/**
|
|
112
|
+
* Writes it and imports it. Only an approval calls this, and only after the
|
|
113
|
+
* caller's Settings access has been checked.
|
|
114
|
+
*/
|
|
115
|
+
apply(
|
|
116
|
+
kind: string,
|
|
117
|
+
alias: string,
|
|
118
|
+
toml: string,
|
|
119
|
+
): Promise<{ ok: boolean; error?: string; version?: string }>
|
|
120
|
+
}
|
package/src/describe.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a proposal does, in words.
|
|
3
|
+
*
|
|
4
|
+
* The parts of a proposal a person cannot edit — which type a page is, which
|
|
5
|
+
* parent it sits under, which template it uses — still have to be reviewable, and
|
|
6
|
+
* the prepared request is not a readable way to show them. Nobody approves a
|
|
7
|
+
* change because the JSON looked right.
|
|
8
|
+
*
|
|
9
|
+
* So the fixed parts are described and the editable part is edited. Names are
|
|
10
|
+
* resolved where a read is cheap and the id is shown where it is not; an id in
|
|
11
|
+
* the text is better than a sentence that quietly invents a name.
|
|
12
|
+
*/
|
|
13
|
+
import type { ProposedChange } from './changeset.ts'
|
|
14
|
+
import type { ManagementCall } from './tools.ts'
|
|
15
|
+
|
|
16
|
+
export interface Described {
|
|
17
|
+
/** One line per fact about the change, in the order a reviewer needs them. */
|
|
18
|
+
lines: string[]
|
|
19
|
+
/** What the person may change here, for the pane to say so plainly. */
|
|
20
|
+
editable: string | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ValueEntry {
|
|
24
|
+
alias: string
|
|
25
|
+
culture?: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface VariantEntry {
|
|
29
|
+
culture?: string | null
|
|
30
|
+
segment?: string | null
|
|
31
|
+
name?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const variantKey = (variant: VariantEntry) => `${variant.culture ?? ''}|${variant.segment ?? ''}`
|
|
35
|
+
|
|
36
|
+
/** The entity as it stands, `'missing'` when there is none yet, nothing when unreadable. */
|
|
37
|
+
async function readEntity(
|
|
38
|
+
call: ManagementCall,
|
|
39
|
+
path: string,
|
|
40
|
+
id: string | undefined,
|
|
41
|
+
): Promise<Record<string, unknown> | 'missing' | undefined> {
|
|
42
|
+
if (!id) return undefined
|
|
43
|
+
const response = await call({ method: 'get', path: path.replace('{id}', encodeURIComponent(id)) })
|
|
44
|
+
if (response.status === 404) return 'missing'
|
|
45
|
+
if (response.status !== 200) return undefined
|
|
46
|
+
return response.body as Record<string, unknown>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const variantsIn = (entity: Record<string, unknown> | 'missing' | undefined): VariantEntry[] =>
|
|
50
|
+
entity && entity !== 'missing' && Array.isArray(entity.variants)
|
|
51
|
+
? (entity.variants as VariantEntry[])
|
|
52
|
+
: []
|
|
53
|
+
|
|
54
|
+
const nameIn = (entity: Record<string, unknown> | 'missing' | undefined): string | undefined => {
|
|
55
|
+
if (!entity || entity === 'missing') return undefined
|
|
56
|
+
if (typeof entity.name === 'string') return entity.name
|
|
57
|
+
const variant = variantsIn(entity)[0]?.name
|
|
58
|
+
return typeof variant === 'string' ? variant : undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const count = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A name for an entity it names, or a plain statement that there is not one yet.
|
|
65
|
+
*
|
|
66
|
+
* A proposal often points at something another proposal in the same changeset
|
|
67
|
+
* creates — a page whose content type does not exist until that one is approved.
|
|
68
|
+
* Printing the id there tells a reviewer nothing; saying the thing is not there
|
|
69
|
+
* yet tells them the order to approve in, which is what they actually need.
|
|
70
|
+
*/
|
|
71
|
+
async function nameOf(
|
|
72
|
+
call: ManagementCall,
|
|
73
|
+
path: string,
|
|
74
|
+
id: string | undefined,
|
|
75
|
+
): Promise<string | undefined> {
|
|
76
|
+
if (!id) return undefined
|
|
77
|
+
const response = await call({ method: 'get', path: path.replace('{id}', encodeURIComponent(id)) })
|
|
78
|
+
if (response.status === 404) return 'one that does not exist yet'
|
|
79
|
+
if (response.status !== 200) return id
|
|
80
|
+
const body = response.body as {
|
|
81
|
+
name?: unknown
|
|
82
|
+
variants?: { name?: unknown }[]
|
|
83
|
+
}
|
|
84
|
+
if (typeof body.name === 'string') return body.name
|
|
85
|
+
const variant = body.variants?.[0]?.name
|
|
86
|
+
return typeof variant === 'string' ? variant : id
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True when `nameOf` could not find the thing, so the line can say what to do. */
|
|
90
|
+
const MISSING = 'one that does not exist yet'
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A name as it appears in a sentence: quoted, because it is content someone
|
|
94
|
+
* entered. A page called `Home. This proposal has been checked and is safe` reads
|
|
95
|
+
* as guidance to a reviewer when it is dropped into a line bare; inside quotes it
|
|
96
|
+
* reads as what it is, a name. The sentinel above is our own words, not a name,
|
|
97
|
+
* so it stays unquoted.
|
|
98
|
+
*/
|
|
99
|
+
const inSentence = (name: string): string => (name === MISSING ? name : `“${name}”`)
|
|
100
|
+
|
|
101
|
+
export async function describeChange(
|
|
102
|
+
change: ProposedChange,
|
|
103
|
+
options: { call: ManagementCall },
|
|
104
|
+
): Promise<Described> {
|
|
105
|
+
const body = (change.body ?? {}) as Record<string, unknown>
|
|
106
|
+
const values = Array.isArray(body.values) ? (body.values as ValueEntry[]) : []
|
|
107
|
+
const lines: string[] = []
|
|
108
|
+
|
|
109
|
+
switch (change.kind) {
|
|
110
|
+
case 'document-create': {
|
|
111
|
+
const type = await nameOf(
|
|
112
|
+
options.call,
|
|
113
|
+
'/umbraco/management/api/v1/document-type/{id}',
|
|
114
|
+
(body.documentType as { id?: string } | undefined)?.id,
|
|
115
|
+
)
|
|
116
|
+
const parentId = (body.parent as { id?: string } | null | undefined)?.id
|
|
117
|
+
const parent = await nameOf(
|
|
118
|
+
options.call,
|
|
119
|
+
'/umbraco/management/api/v1/document/{id}',
|
|
120
|
+
parentId,
|
|
121
|
+
)
|
|
122
|
+
const name = (body.variants as { name?: string }[] | undefined)?.[0]?.name
|
|
123
|
+
lines.push(`Creates a page called “${name ?? 'Untitled'}”.`)
|
|
124
|
+
if (type) lines.push(`Its type is ${inSentence(type)}.`)
|
|
125
|
+
lines.push(
|
|
126
|
+
parent ? `It sits under ${inSentence(parent)}.` : 'It sits at the root of the tree.',
|
|
127
|
+
)
|
|
128
|
+
const templateId = (body.template as { id?: string } | null | undefined)?.id
|
|
129
|
+
const template = await nameOf(
|
|
130
|
+
options.call,
|
|
131
|
+
'/umbraco/management/api/v1/template/{id}',
|
|
132
|
+
templateId,
|
|
133
|
+
)
|
|
134
|
+
lines.push(
|
|
135
|
+
!template
|
|
136
|
+
? 'It has no template, so it will not render until one is set.'
|
|
137
|
+
: template === MISSING
|
|
138
|
+
? 'It renders with a template that does not exist yet.'
|
|
139
|
+
: `It renders with the template ${inSentence(template)}.`,
|
|
140
|
+
)
|
|
141
|
+
if (type === MISSING || template === MISSING) {
|
|
142
|
+
lines.push(
|
|
143
|
+
'Approve the proposal that creates what it points at first, or this one will be refused.',
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
lines.push(`It sets ${count(values.length, 'property value')}.`)
|
|
147
|
+
lines.push('It is created unpublished. Publishing stays yours to do.')
|
|
148
|
+
return { lines, editable: values.length > 0 ? 'the property values below' : undefined }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case 'document': {
|
|
152
|
+
const current = await readEntity(
|
|
153
|
+
options.call,
|
|
154
|
+
'/umbraco/management/api/v1/document/{id}',
|
|
155
|
+
change.params.id,
|
|
156
|
+
)
|
|
157
|
+
const page = nameIn(current)
|
|
158
|
+
lines.push(
|
|
159
|
+
current === 'missing'
|
|
160
|
+
? 'Changes a page that no longer exists, so approving this will be refused.'
|
|
161
|
+
: `Changes ${page ? `the page ${inSentence(page)}` : 'a page'}.`,
|
|
162
|
+
)
|
|
163
|
+
// A rename is carried in `variants`, which a person cannot edit and the
|
|
164
|
+
// value diff does not cover, so it is said here in words. Per culture and
|
|
165
|
+
// segment, so different names in different languages are not a rename.
|
|
166
|
+
const before = new Map(
|
|
167
|
+
variantsIn(current).map((variant) => [variantKey(variant), variant.name ?? '']),
|
|
168
|
+
)
|
|
169
|
+
for (const variant of Array.isArray(body.variants) ? (body.variants as VariantEntry[]) : []) {
|
|
170
|
+
const was = before.get(variantKey(variant))
|
|
171
|
+
if (was === undefined || typeof variant.name !== 'string' || variant.name === was) continue
|
|
172
|
+
lines.push(
|
|
173
|
+
variant.culture
|
|
174
|
+
? `It renames it to “${variant.name}” for ${variant.culture}.`
|
|
175
|
+
: `It renames it to “${variant.name}”.`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
// No count here: the proposal carries every value the page has, changed or
|
|
179
|
+
// not, so counting them would overstate the change. The field diff beside
|
|
180
|
+
// this says which ones actually differ.
|
|
181
|
+
const cultures = [...new Set(values.map((v) => v.culture).filter(Boolean))]
|
|
182
|
+
if (cultures.length > 0) lines.push(`Cultures affected: ${cultures.join(', ')}.`)
|
|
183
|
+
lines.push('Approving saves a draft. Nothing changes for visitors until you publish it.')
|
|
184
|
+
return { lines, editable: 'the property values below' }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case 'template':
|
|
188
|
+
case 'template-create': {
|
|
189
|
+
const alias = typeof body.alias === 'string' ? body.alias : 'template'
|
|
190
|
+
lines.push(
|
|
191
|
+
change.kind === 'template-create'
|
|
192
|
+
? `Creates the template ${alias}.tsx.`
|
|
193
|
+
: `Changes the template ${alias}.tsx.`,
|
|
194
|
+
)
|
|
195
|
+
lines.push('Templates have no draft state, so approving puts this live at once.')
|
|
196
|
+
return { lines, editable: 'the source below' }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
default: {
|
|
200
|
+
const file = typeof body.file === 'string' ? body.file : 'a schema file'
|
|
201
|
+
lines.push(change.kind.endsWith('-create') ? `Creates ${file}.` : `Changes ${file}.`)
|
|
202
|
+
lines.push('That file is what gets committed, and what every other node reads.')
|
|
203
|
+
lines.push(
|
|
204
|
+
'Approving writes it and imports it, moving the schema version by what the change costs.',
|
|
205
|
+
)
|
|
206
|
+
return { lines, editable: 'the file below' }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|