@frontera-sdk/cli 0.1.0 → 1.43.5
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 +4 -2
- package/src/api/apps-api.ts +13 -1
- package/src/api/automation-api.ts +129 -1
- package/src/api/blueprint-authoring-api.ts +574 -0
- package/src/api/dataset-api.ts +199 -0
- package/src/api/platform-api.ts +300 -0
- package/src/automation-template.ts +224 -0
- package/src/blueprint/compile.ts +371 -0
- package/src/blueprint/dataset-revision.ts +33 -0
- package/src/blueprint/diff.ts +223 -0
- package/src/blueprint/model.ts +227 -0
- package/src/blueprint/projection.ts +254 -0
- package/src/blueprint/render.ts +73 -0
- package/src/blueprint/scaffold.ts +79 -0
- package/src/blueprint/tree.ts +121 -0
- package/src/commands/agent/index-commands.ts +87 -1
- package/src/commands/app/deploy.ts +43 -3
- package/src/commands/app/init.ts +23 -1
- package/src/commands/app/pull.ts +12 -35
- package/src/commands/automation/index-commands.ts +42 -1
- package/src/commands/automation/init.ts +52 -0
- package/src/commands/automation/project-root.ts +58 -0
- package/src/commands/automation/pull.ts +124 -0
- package/src/commands/automation/run.ts +271 -0
- package/src/commands/blueprint/authoring.ts +410 -0
- package/src/commands/blueprint/bind.ts +228 -0
- package/src/commands/blueprint/declarative.ts +1052 -0
- package/src/commands/blueprint/grants.ts +164 -0
- package/src/commands/dataset/index-commands.ts +431 -0
- package/src/commands/knowledge/index-commands.ts +278 -27
- package/src/commands/knowledge/upload-batch.ts +146 -0
- package/src/commands/knowledge/upload-plan.ts +127 -0
- package/src/commands/login.ts +49 -11
- package/src/commands/pack/index-commands.ts +373 -0
- package/src/commands/registry.ts +19 -2
- package/src/commands/secret/index-commands.ts +195 -0
- package/src/commands/skill/bundle-commands.ts +327 -0
- package/src/commands/skill/index-commands.ts +36 -42
- package/src/commands/skill/resolve.ts +34 -0
- package/src/dev-env.ts +114 -0
- package/src/flag-help.ts +34 -0
- package/src/harness.ts +30 -3
- package/src/main.ts +10 -3
- package/src/render-evidence.ts +152 -0
- package/src/template.ts +4 -0
- package/src/untar.ts +44 -0
- package/src/vendor/sdk-sources.json +13 -11
- package/src/commands/blueprint/reserved.ts +0 -40
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { CliError } from '../errors'
|
|
2
|
+
import {
|
|
3
|
+
ARTIFACT_KINDS,
|
|
4
|
+
KIND_DIRECTORY,
|
|
5
|
+
type AuthoredFile,
|
|
6
|
+
type AuthoredModel,
|
|
7
|
+
type BundleObject,
|
|
8
|
+
type BundleRelationship,
|
|
9
|
+
type DefinitionBundle,
|
|
10
|
+
type LiveIndex,
|
|
11
|
+
} from './model'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The one place the file format and the definition bundle are translated.
|
|
15
|
+
*
|
|
16
|
+
* Nothing else may translate. A second translation site is how a projection stops
|
|
17
|
+
* being total — one side learns about a field and the other does not, and `pull`
|
|
18
|
+
* quietly drops it, and the next `apply` deletes it from the draft.
|
|
19
|
+
*
|
|
20
|
+
* Both directions are here so they can be read as a pair and tested as one:
|
|
21
|
+
* `fromFiles(toFiles(bundle))` compiles back to `bundle`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Fields the projection resolves rather than copies. Never written to a file. */
|
|
25
|
+
const OBJECT_RESOLVED = new Set([
|
|
26
|
+
'id',
|
|
27
|
+
'primaryKeyPropertyId',
|
|
28
|
+
'titlePropertyId',
|
|
29
|
+
'properties',
|
|
30
|
+
'governance',
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
const LINK_RESOLVED = new Set([
|
|
34
|
+
'id',
|
|
35
|
+
'fromObjectId',
|
|
36
|
+
'toObjectId',
|
|
37
|
+
'fromPropertyId',
|
|
38
|
+
'toPropertyId',
|
|
39
|
+
'fromDisplayName',
|
|
40
|
+
'toDisplayName',
|
|
41
|
+
'fromPhrase',
|
|
42
|
+
'toPhrase',
|
|
43
|
+
'datasetBacking',
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
function rest(source: Record<string, unknown>, resolved: Set<string>): Record<string, unknown> {
|
|
47
|
+
const out: Record<string, unknown> = {}
|
|
48
|
+
for (const [key, value] of Object.entries(source)) {
|
|
49
|
+
if (!resolved.has(key)) out[key] = value
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A property entry, with its shared field named rather than identified.
|
|
56
|
+
*
|
|
57
|
+
* The wire's `sharedPropertyId` is the one uuid a property carries that HAS a name
|
|
58
|
+
* to resolve to, so it is resolved rather than stripped — a file that kept it would
|
|
59
|
+
* be unportable in the precise sense this whole tree exists to avoid. The authored
|
|
60
|
+
* key is `sharedField`: the tree is what a person writes, and a person writes field.
|
|
61
|
+
*
|
|
62
|
+
* An id the bundle does not declare is a corrupt export, and it fails here rather
|
|
63
|
+
* than writing the uuid: a tree that round-trips a per-deployment identifier reads
|
|
64
|
+
* as portable and is not, and the failure would then appear at the second
|
|
65
|
+
* deployment instead of at the `pull` that produced it.
|
|
66
|
+
*/
|
|
67
|
+
function propertyToFile(
|
|
68
|
+
property: Record<string, unknown>,
|
|
69
|
+
objectApiName: string,
|
|
70
|
+
sharedApiNameById: Map<string, string>,
|
|
71
|
+
): Record<string, unknown> {
|
|
72
|
+
const body = rest(property, new Set(['id', 'sharedPropertyId']))
|
|
73
|
+
const sharedPropertyId = property.sharedPropertyId
|
|
74
|
+
if (sharedPropertyId === undefined || sharedPropertyId === null) return body
|
|
75
|
+
const apiName = sharedApiNameById.get(String(sharedPropertyId))
|
|
76
|
+
if (!apiName) {
|
|
77
|
+
throw new CliError(
|
|
78
|
+
`Field "${String(property.apiName)}" on ${objectApiName} references shared field `
|
|
79
|
+
+ `${String(sharedPropertyId)}, which this draft does not declare.`,
|
|
80
|
+
{
|
|
81
|
+
code: 'FAILURE',
|
|
82
|
+
hint: 'The draft is inconsistent — re-read it, and report it if it persists. '
|
|
83
|
+
+ 'Writing the identifier into the file would make the tree unportable.',
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
return { ...body, sharedField: apiName }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `governance.sourceMappings[0]` → the portable `backing:` a file writes.
|
|
92
|
+
*
|
|
93
|
+
* The dataset NAME, never the revision id: the name means the same thing in two
|
|
94
|
+
* deployments and the revision it points at does not. Emitted only when a name is
|
|
95
|
+
* resolvable — `diff` calls `toFiles` without a resolver precisely so that backings
|
|
96
|
+
* do not enter the comparison, because `bind` owns them and `apply` must not try to.
|
|
97
|
+
*/
|
|
98
|
+
function backingToFile(
|
|
99
|
+
object: BundleObject,
|
|
100
|
+
datasetNameByRevisionId: Map<string, string> | undefined,
|
|
101
|
+
): Record<string, unknown> | undefined {
|
|
102
|
+
if (!datasetNameByRevisionId) return undefined
|
|
103
|
+
const mapping = object.governance.sourceMappings[0] as {
|
|
104
|
+
datasetRevisionId?: string
|
|
105
|
+
propertyMappings?: Array<{ propertyId: string; sourcePath: string; sourceField?: string | null }>
|
|
106
|
+
editableProperties?: string[]
|
|
107
|
+
} | undefined
|
|
108
|
+
if (!mapping?.datasetRevisionId) return undefined
|
|
109
|
+
const dataset = datasetNameByRevisionId.get(mapping.datasetRevisionId)
|
|
110
|
+
if (!dataset) return undefined
|
|
111
|
+
const apiNameById = new Map(object.properties.map((property) => [property.id, property.apiName]))
|
|
112
|
+
const entries: Record<string, unknown> = {}
|
|
113
|
+
for (const entry of mapping.propertyMappings ?? []) {
|
|
114
|
+
const apiName = apiNameById.get(entry.propertyId)
|
|
115
|
+
if (!apiName) continue
|
|
116
|
+
entries[apiName] = entry.sourceField
|
|
117
|
+
? { column: entry.sourcePath, field: entry.sourceField }
|
|
118
|
+
: entry.sourcePath
|
|
119
|
+
}
|
|
120
|
+
const editable = (mapping.editableProperties ?? [])
|
|
121
|
+
.map((id) => apiNameById.get(id))
|
|
122
|
+
.filter((name): name is string => Boolean(name))
|
|
123
|
+
return {
|
|
124
|
+
dataset,
|
|
125
|
+
mapping: entries,
|
|
126
|
+
...(editable.length ? { editable } : {}),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function objectToFile(
|
|
131
|
+
object: BundleObject,
|
|
132
|
+
sharedApiNameById: Map<string, string>,
|
|
133
|
+
datasetNameByRevisionId?: Map<string, string>,
|
|
134
|
+
): Record<string, unknown> {
|
|
135
|
+
const byId = new Map(object.properties.map((property) => [property.id, property.apiName]))
|
|
136
|
+
const backing = backingToFile(object, datasetNameByRevisionId)
|
|
137
|
+
return {
|
|
138
|
+
...rest(object, OBJECT_RESOLVED),
|
|
139
|
+
// `governance.sourceMappings` is the binding, which `bind` owns. Everything else
|
|
140
|
+
// in governance — ownership, sensitivity — is authored, so it stays.
|
|
141
|
+
governance: rest(object.governance, new Set(['sourceMappings'])),
|
|
142
|
+
...(backing ? { backing } : {}),
|
|
143
|
+
primaryKey: byId.get(object.primaryKeyPropertyId) ?? object.primaryKeyPropertyId,
|
|
144
|
+
title: byId.get(object.titlePropertyId) ?? object.titlePropertyId,
|
|
145
|
+
properties: object.properties.map((property) =>
|
|
146
|
+
propertyToFile(property, object.apiName, sharedApiNameById)),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function linkToFile(link: BundleRelationship, objectApiNameById: Map<string, string>, propertyApiNameById: Map<string, string>): Record<string, unknown> {
|
|
151
|
+
const endpoint = (
|
|
152
|
+
objectId: string,
|
|
153
|
+
propertyId: string,
|
|
154
|
+
displayName: unknown,
|
|
155
|
+
phrase: unknown,
|
|
156
|
+
) => ({
|
|
157
|
+
objectType: objectApiNameById.get(objectId) ?? objectId,
|
|
158
|
+
property: propertyApiNameById.get(propertyId) ?? propertyId,
|
|
159
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
160
|
+
...(phrase === undefined ? {} : { phrase }),
|
|
161
|
+
})
|
|
162
|
+
return {
|
|
163
|
+
...rest(link, LINK_RESOLVED),
|
|
164
|
+
from: endpoint(link.fromObjectId, link.fromPropertyId, link.fromDisplayName, link.fromPhrase),
|
|
165
|
+
to: endpoint(link.toObjectId, link.toPropertyId, link.toDisplayName, link.toPhrase),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Live draft → the file tree a person edits. Lossless but for the resolved ids.
|
|
171
|
+
*
|
|
172
|
+
* `datasetNameByRevisionId` turns each object's pinned revision back into the dataset
|
|
173
|
+
* name a file can carry. `pull` supplies it; `diff` does not, and that asymmetry is
|
|
174
|
+
* deliberate — see `backingToFile`.
|
|
175
|
+
*/
|
|
176
|
+
export function toFiles(
|
|
177
|
+
bundle: DefinitionBundle,
|
|
178
|
+
datasetNameByRevisionId?: Map<string, string>,
|
|
179
|
+
): AuthoredModel {
|
|
180
|
+
const objectApiNameById = new Map(bundle.objects.map((object) => [object.id, object.apiName]))
|
|
181
|
+
const propertyApiNameById = new Map(
|
|
182
|
+
bundle.objects.flatMap((object) => object.properties.map((p) => [p.id, p.apiName] as const)),
|
|
183
|
+
)
|
|
184
|
+
const sharedApiNameById = new Map(
|
|
185
|
+
(bundle.sharedProperties ?? []).map((shared) => [shared.id, shared.apiName]),
|
|
186
|
+
)
|
|
187
|
+
const files: AuthoredFile[] = []
|
|
188
|
+
const push = (kind: AuthoredFile['kind'], apiName: string, document: Record<string, unknown>) => {
|
|
189
|
+
files.push({ kind, apiName, path: `${KIND_DIRECTORY[kind]}/${apiName}.yaml`, document })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const shared of bundle.sharedProperties ?? []) {
|
|
193
|
+
// A shared field owns no backing and references nothing, so stripping its id
|
|
194
|
+
// is the whole projection: what is left is portable as it stands.
|
|
195
|
+
push('shared-field', shared.apiName, rest(shared, new Set(['id'])))
|
|
196
|
+
}
|
|
197
|
+
for (const object of bundle.objects) {
|
|
198
|
+
push('object-type', object.apiName, objectToFile(object, sharedApiNameById, datasetNameByRevisionId))
|
|
199
|
+
}
|
|
200
|
+
for (const link of bundle.relationships) {
|
|
201
|
+
push('link-type', link.apiName, linkToFile(link, objectApiNameById, propertyApiNameById))
|
|
202
|
+
}
|
|
203
|
+
for (const metric of bundle.metrics) {
|
|
204
|
+
// A metric names the type it measures by `objectId`. Stripping only `id` left that
|
|
205
|
+
// uuid in the file — unportable, and unusable besides: the create route takes an
|
|
206
|
+
// `objectTypeApiName`, so a pulled metric could never be applied anywhere.
|
|
207
|
+
const { id: _id, objectId, ...body } = metric as Record<string, unknown> & { objectId?: string }
|
|
208
|
+
push('metric', metric.apiName, {
|
|
209
|
+
...body,
|
|
210
|
+
...(typeof objectId === 'string'
|
|
211
|
+
? { objectType: objectApiNameById.get(objectId) ?? objectId }
|
|
212
|
+
: {}),
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { schemaVersion: bundle.schemaVersion, files: files.sort(byPath) }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function byPath(left: AuthoredFile, right: AuthoredFile): number {
|
|
220
|
+
return left.path.localeCompare(right.path)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The file tree, grouped by kind, ready for the compiler.
|
|
225
|
+
*
|
|
226
|
+
* This does not resolve anything — resolution needs the live draft, which a pure
|
|
227
|
+
* read of the tree does not have. See `compile.ts`.
|
|
228
|
+
*/
|
|
229
|
+
export function fromFiles(files: AuthoredFile[], schemaVersion: number): AuthoredModel {
|
|
230
|
+
return { schemaVersion, files: [...files].sort(byPath) }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function filesOfKind(model: AuthoredModel, kind: AuthoredFile['kind']): AuthoredFile[] {
|
|
234
|
+
return model.files.filter((file) => file.kind === kind)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Every kind, in a fixed order, so callers never depend on tree iteration order. */
|
|
238
|
+
export function kinds(): readonly AuthoredFile['kind'][] {
|
|
239
|
+
return ARTIFACT_KINDS
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Does this bundle's schema version carry actions?
|
|
244
|
+
*
|
|
245
|
+
* v1 has no `actions` key at all and its schema is strict, so emitting an empty
|
|
246
|
+
* array would make a v1 bundle unparseable. The absence is meaningful.
|
|
247
|
+
*/
|
|
248
|
+
export function carriesActions(schemaVersion: number): boolean {
|
|
249
|
+
return schemaVersion >= 2
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function liveObject(index: LiveIndex, apiName: string): BundleObject | undefined {
|
|
253
|
+
return index.objectByApiName.get(apiName)
|
|
254
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ModelOperation, Plan } from './diff'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The plan, as a person reads it.
|
|
5
|
+
*
|
|
6
|
+
* One line per artifact, marker first, so a long plan scans vertically. `--json`
|
|
7
|
+
* emits the `Plan` itself and never this text — the two must not drift, which is why
|
|
8
|
+
* the renderer takes the plan rather than being fed by the executor.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MARKER: Record<ModelOperation['operation'], string> = {
|
|
12
|
+
create: '+',
|
|
13
|
+
update: '~',
|
|
14
|
+
delete: '-',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function pad(value: string, width: number): string {
|
|
18
|
+
return value.length >= width ? value : value + ' '.repeat(width - value.length)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renderPlan(plan: Plan, options: { prune: boolean } = { prune: false }): string {
|
|
22
|
+
const rows: Array<{ marker: string; kind: string; apiName: string; detail: string }> = []
|
|
23
|
+
|
|
24
|
+
for (const operation of plan.operations) {
|
|
25
|
+
rows.push({
|
|
26
|
+
marker: MARKER[operation.operation],
|
|
27
|
+
kind: operation.kind,
|
|
28
|
+
apiName: operation.apiName,
|
|
29
|
+
detail: operation.detail ?? (operation.operation === 'create' ? 'create' : ''),
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
for (const operation of plan.prunes) {
|
|
33
|
+
rows.push({
|
|
34
|
+
marker: MARKER.delete,
|
|
35
|
+
kind: operation.kind,
|
|
36
|
+
apiName: operation.apiName,
|
|
37
|
+
// Naming the flag on the line rather than in a footnote: the reader is
|
|
38
|
+
// looking at the artifact they care about, not at the bottom of the output.
|
|
39
|
+
detail: options.prune ? 'delete' : 'delete — needs --prune, skipped',
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (rows.length === 0) {
|
|
44
|
+
return plan.unchanged === 0
|
|
45
|
+
? 'No artifacts, and nothing to do.'
|
|
46
|
+
: `No changes. ${plan.unchanged} artifact${plan.unchanged === 1 ? ' matches' : 's match'} the draft.`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const kindWidth = Math.max(...rows.map((row) => row.kind.length))
|
|
50
|
+
const nameWidth = Math.max(...rows.map((row) => row.apiName.length))
|
|
51
|
+
const lines = rows.map((row) =>
|
|
52
|
+
` ${row.marker} ${pad(row.kind, kindWidth)} ${pad(row.apiName, nameWidth)} ${row.detail}`.trimEnd(),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if (plan.unchanged > 0) {
|
|
56
|
+
lines.push(` = ${plan.unchanged} unchanged`)
|
|
57
|
+
}
|
|
58
|
+
return lines.join('\n')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The `!` lines: a bound type whose dataset has moved to a different contract. */
|
|
62
|
+
export function renderDrift(
|
|
63
|
+
drift: Array<{ objectApiName: string; datasetName: string; currentDigest: string }>,
|
|
64
|
+
): string {
|
|
65
|
+
if (drift.length === 0) return ''
|
|
66
|
+
return drift
|
|
67
|
+
.map((entry) =>
|
|
68
|
+
` ! binding ${entry.objectApiName} dataset \`${entry.datasetName}\` publishes a different `
|
|
69
|
+
+ `column contract (${entry.currentDigest.slice(0, 14)}…)\n`
|
|
70
|
+
+ ` re-review it: frontera blueprint bind ${entry.objectApiName} --dataset ${entry.datasetName} --plan <file>`,
|
|
71
|
+
)
|
|
72
|
+
.join('\n')
|
|
73
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ArtifactKind } from './model'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A starting file per kind, complete enough to apply as written once the placeholders
|
|
5
|
+
* are filled in.
|
|
6
|
+
*
|
|
7
|
+
* "Validates as written" is the bar, and it is not decoration: a scaffold missing a
|
|
8
|
+
* required field teaches the reader that the format is approximate, and the next
|
|
9
|
+
* thing they write omits three more. Every field the create route requires is here,
|
|
10
|
+
* including the ones with dull answers.
|
|
11
|
+
*/
|
|
12
|
+
export function scaffold(kind: ArtifactKind, apiName: string): Record<string, unknown> {
|
|
13
|
+
if (kind === 'object-type') {
|
|
14
|
+
return {
|
|
15
|
+
apiName,
|
|
16
|
+
displayName: apiName,
|
|
17
|
+
pluralDisplayName: `${apiName}s`,
|
|
18
|
+
// `primaryKey` and `title` name properties by apiName. The service resolves
|
|
19
|
+
// them to ids; nothing in this tree ever carries one.
|
|
20
|
+
primaryKey: 'id',
|
|
21
|
+
title: 'id',
|
|
22
|
+
governance: {
|
|
23
|
+
ownership: { kind: 'team', ownerId: 'blueprint-administration' },
|
|
24
|
+
sensitivity: { classification: 'internal', categories: [] },
|
|
25
|
+
},
|
|
26
|
+
properties: [{
|
|
27
|
+
apiName: 'id',
|
|
28
|
+
displayName: 'ID',
|
|
29
|
+
valueType: 'string',
|
|
30
|
+
required: true,
|
|
31
|
+
propertyType: 'attribute',
|
|
32
|
+
}],
|
|
33
|
+
validations: [],
|
|
34
|
+
// The dataset NAME, not a revision id: the name means the same thing in every
|
|
35
|
+
// deployment, and the revision it points at does not. One column per property,
|
|
36
|
+
// and no name-match fallback — the service refuses a mapping it was not given.
|
|
37
|
+
backing: {
|
|
38
|
+
dataset: 'CHANGE_ME',
|
|
39
|
+
mapping: { id: 'CHANGE_ME' },
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (kind === 'shared-field') {
|
|
45
|
+
// Identity and semantics, and nothing about where the data lives: no column,
|
|
46
|
+
// no `required`, no `unique`. `propertyType` is required — a shared field
|
|
47
|
+
// states how every implementing property is read — so it is written here
|
|
48
|
+
// with the commonest answer rather than left for the create route to refuse.
|
|
49
|
+
return {
|
|
50
|
+
apiName,
|
|
51
|
+
displayName: apiName,
|
|
52
|
+
description: 'CHANGE_ME',
|
|
53
|
+
valueType: 'string',
|
|
54
|
+
propertyType: 'attribute',
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (kind === 'link-type') {
|
|
59
|
+
return {
|
|
60
|
+
apiName,
|
|
61
|
+
cardinality: 'one_to_many',
|
|
62
|
+
from: { objectType: 'CHANGE_ME', property: 'id', displayName: 'CHANGE_ME', phrase: 'has' },
|
|
63
|
+
to: { objectType: 'CHANGE_ME', property: 'id', displayName: 'CHANGE_ME', phrase: 'belongs to' },
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A metric names the type it measures and the measures it computes. Both are
|
|
68
|
+
// required by the create route, so a scaffold without them would contradict this
|
|
69
|
+
// command's own promise that what it writes validates as written.
|
|
70
|
+
return {
|
|
71
|
+
apiName,
|
|
72
|
+
displayName: apiName,
|
|
73
|
+
objectType: 'CHANGE_ME',
|
|
74
|
+
definition: {
|
|
75
|
+
measures: [{ agg: 'count', label: 'Count' }],
|
|
76
|
+
dimensions: [],
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, extname, join } from 'node:path'
|
|
3
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
4
|
+
import { CliError } from '../errors'
|
|
5
|
+
import {
|
|
6
|
+
ARTIFACT_KINDS,
|
|
7
|
+
DIRECTORY_KIND,
|
|
8
|
+
KIND_DIRECTORY,
|
|
9
|
+
type ArtifactKind,
|
|
10
|
+
type AuthoredFile,
|
|
11
|
+
} from './model'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reading and writing the `blueprint/` tree. No network, no resolution.
|
|
15
|
+
*
|
|
16
|
+
* YAML and JSON are both accepted, chosen by extension, because a tree generated by
|
|
17
|
+
* another tool should not have to be converted before it can be applied. `pull` and
|
|
18
|
+
* `new` emit YAML: it diffs better in a pull request, which is the whole reason the
|
|
19
|
+
* Blueprint is in files.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const READABLE = new Set(['.yaml', '.yml', '.json'])
|
|
23
|
+
|
|
24
|
+
export function parseDocument(path: string, text: string): Record<string, unknown> {
|
|
25
|
+
const parsed = extname(path) === '.json' ? JSON.parse(text) : parseYaml(text)
|
|
26
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
27
|
+
throw new CliError(`${path} does not contain a mapping.`, {
|
|
28
|
+
code: 'USAGE',
|
|
29
|
+
hint: 'Each file holds exactly one artifact, written as a mapping of fields.',
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
return parsed as Record<string, unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read the whole tree.
|
|
37
|
+
*
|
|
38
|
+
* A file whose `apiName` disagrees with its filename is an error rather than a
|
|
39
|
+
* rename: the two are the same identity written twice, and guessing which one the
|
|
40
|
+
* author meant is the difference between renaming an artifact and replacing it.
|
|
41
|
+
*/
|
|
42
|
+
export function readTree(root: string): AuthoredFile[] {
|
|
43
|
+
const files: AuthoredFile[] = []
|
|
44
|
+
for (const kind of ARTIFACT_KINDS) {
|
|
45
|
+
const directory = join(root, KIND_DIRECTORY[kind])
|
|
46
|
+
if (!existsSync(directory)) continue
|
|
47
|
+
for (const entry of readdirSync(directory).sort()) {
|
|
48
|
+
const extension = extname(entry)
|
|
49
|
+
if (!READABLE.has(extension)) continue
|
|
50
|
+
const relative = `${KIND_DIRECTORY[kind]}/${entry}`
|
|
51
|
+
const document = parseDocument(relative, readFileSync(join(directory, entry), 'utf8'))
|
|
52
|
+
const stem = entry.slice(0, -extension.length)
|
|
53
|
+
const apiName = document.apiName
|
|
54
|
+
if (typeof apiName !== 'string' || !apiName) {
|
|
55
|
+
throw new CliError(`${relative} declares no apiName.`, {
|
|
56
|
+
code: 'USAGE',
|
|
57
|
+
hint: `Add \`apiName: ${stem}\`, or delete the file.`,
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
if (apiName !== stem) {
|
|
61
|
+
throw new CliError(
|
|
62
|
+
`${relative} declares apiName "${apiName}", which is not its filename.`,
|
|
63
|
+
{
|
|
64
|
+
code: 'USAGE',
|
|
65
|
+
hint: `Renaming is an explicit act: \`frontera blueprint rename ${kind} ${stem} ${apiName}\`.`,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
files.push({ kind, apiName, path: relative, document })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return files
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Delete a file the tree should no longer contain.
|
|
77
|
+
*
|
|
78
|
+
* `writeTree` only ever wrote, which left `pull --force` keeping artifacts that had
|
|
79
|
+
* since left the draft — the next `plan` then reported a phantom create for something
|
|
80
|
+
* nobody had authored. "Two pulled trees are byte-identical" was true only of fresh
|
|
81
|
+
* directories.
|
|
82
|
+
*/
|
|
83
|
+
export function removeFromTree(root: string, paths: string[]): string[] {
|
|
84
|
+
const removed: string[] = []
|
|
85
|
+
for (const relative of paths) {
|
|
86
|
+
const absolute = join(root, relative)
|
|
87
|
+
if (!existsSync(absolute)) continue
|
|
88
|
+
rmSync(absolute)
|
|
89
|
+
removed.push(relative)
|
|
90
|
+
}
|
|
91
|
+
return removed
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function writeTree(root: string, files: AuthoredFile[]): string[] {
|
|
95
|
+
const written: string[] = []
|
|
96
|
+
for (const file of files) {
|
|
97
|
+
const absolute = join(root, file.path)
|
|
98
|
+
mkdirSync(dirname(absolute), { recursive: true })
|
|
99
|
+
writeFileSync(absolute, serialize(file.path, file.document))
|
|
100
|
+
written.push(file.path)
|
|
101
|
+
}
|
|
102
|
+
return written
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function serialize(path: string, document: Record<string, unknown>): string {
|
|
106
|
+
return extname(path) === '.json'
|
|
107
|
+
? `${JSON.stringify(document, null, 2)}\n`
|
|
108
|
+
// `lineWidth: 0` disables wrapping. A wrapped description reflows the moment
|
|
109
|
+
// someone edits a word before it, which turns a one-word change into a
|
|
110
|
+
// multi-line diff and hides what actually changed.
|
|
111
|
+
: stringifyYaml(document, { lineWidth: 0 })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Which files a tree already holds, so `new` can refuse to overwrite one. */
|
|
115
|
+
export function pathFor(kind: ArtifactKind, apiName: string): string {
|
|
116
|
+
return `${KIND_DIRECTORY[kind]}/${apiName}.yaml`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function kindOfDirectory(directory: string): ArtifactKind | undefined {
|
|
120
|
+
return DIRECTORY_KIND[directory]
|
|
121
|
+
}
|
|
@@ -86,6 +86,83 @@ async function readDocument(ctx: CommandContext): Promise<Record<string, unknown
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** `agent_configs.agentId` — a slug, lowercase, the value every verb takes. */
|
|
90
|
+
const SLUG_PATTERN = /^[a-z][a-z0-9-]*$/
|
|
91
|
+
|
|
92
|
+
const create: Command = {
|
|
93
|
+
meta: {
|
|
94
|
+
noun: 'agent',
|
|
95
|
+
verb: 'create',
|
|
96
|
+
args: [
|
|
97
|
+
{
|
|
98
|
+
name: 'slug',
|
|
99
|
+
required: true,
|
|
100
|
+
description: 'lowercase slug every other agent command takes, e.g. claims-triage',
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
flags: { name: 'string', description: 'string', kind: 'string' },
|
|
104
|
+
summary: 'Create an agent, ready for `agent apply` and `agent publish`',
|
|
105
|
+
examples: [
|
|
106
|
+
'frontera agent create claims-triage --name "Claims Triage"',
|
|
107
|
+
'frontera agent create nightly-extract --kind work',
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
async run(ctx) {
|
|
111
|
+
const slug = ctx.positional[0]
|
|
112
|
+
if (!slug) {
|
|
113
|
+
throw new UsageError('missing <slug>', 'frontera agent create <slug> [--name <text>]')
|
|
114
|
+
}
|
|
115
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
116
|
+
throw new UsageError(
|
|
117
|
+
`"${slug}" is not a valid slug`,
|
|
118
|
+
'lowercase letters, digits and hyphens, starting with a letter — e.g. claims-triage',
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const kind = flagString(ctx, 'kind')
|
|
123
|
+
if (kind && kind !== 'conversation' && kind !== 'work') {
|
|
124
|
+
throw new UsageError(`unknown --kind "${kind}"`, 'conversation (default) or work')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const client = api(ctx)
|
|
128
|
+
|
|
129
|
+
// Checked before the write: the service answers a duplicate slug from deep
|
|
130
|
+
// inside the create transaction, and the message names a constraint rather
|
|
131
|
+
// than the agent that already holds the name.
|
|
132
|
+
const existing = await client.agent(slug).catch((err: unknown) => {
|
|
133
|
+
if ((err as { code?: string }).code === 'NOT_FOUND') return null
|
|
134
|
+
throw err
|
|
135
|
+
})
|
|
136
|
+
if (existing) {
|
|
137
|
+
throw new CliError(`an agent with the slug "${slug}" already exists`, {
|
|
138
|
+
code: 'CONFLICT',
|
|
139
|
+
hint: `stage a change onto it with \`frontera agent apply ${slug} -f <file>\``,
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const name = flagString(ctx, 'name')
|
|
144
|
+
const description = flagString(ctx, 'description')
|
|
145
|
+
const row = (await client.createAgent({
|
|
146
|
+
agentId: slug,
|
|
147
|
+
...(name ? { name } : {}),
|
|
148
|
+
...(description ? { description } : {}),
|
|
149
|
+
...(kind ? { kind: kind as 'conversation' | 'work' } : {}),
|
|
150
|
+
})) as { data?: AgentRow } | AgentRow
|
|
151
|
+
const agent = ((row as { data?: AgentRow }).data ?? row) as AgentRow
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
data: agent,
|
|
155
|
+
text: [
|
|
156
|
+
`created ${slug}${agent.id ? ` ${agent.id}` : ''}`,
|
|
157
|
+
// Said plainly because the next thing a caller does is run `agent list`,
|
|
158
|
+
// see nothing, and conclude the create failed.
|
|
159
|
+
` it is in \`configuring\` and will NOT appear in \`frontera agent list\` until published`,
|
|
160
|
+
` next: frontera agent apply ${slug} -f <file> then frontera agent publish ${slug}`,
|
|
161
|
+
].join('\n'),
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
|
|
89
166
|
const list: Command = {
|
|
90
167
|
meta: {
|
|
91
168
|
noun: 'agent',
|
|
@@ -345,4 +422,13 @@ const versions: Command = {
|
|
|
345
422
|
},
|
|
346
423
|
}
|
|
347
424
|
|
|
348
|
-
export const agentCommands: Command[] = [
|
|
425
|
+
export const agentCommands: Command[] = [
|
|
426
|
+
list,
|
|
427
|
+
get,
|
|
428
|
+
create,
|
|
429
|
+
apply,
|
|
430
|
+
publish,
|
|
431
|
+
diff,
|
|
432
|
+
discard,
|
|
433
|
+
versions,
|
|
434
|
+
]
|