@frontera-sdk/cli 0.1.0 → 1.43.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +4 -2
  2. package/src/api/apps-api.ts +13 -1
  3. package/src/api/automation-api.ts +129 -1
  4. package/src/api/blueprint-authoring-api.ts +574 -0
  5. package/src/api/dataset-api.ts +199 -0
  6. package/src/api/platform-api.ts +300 -0
  7. package/src/automation-template.ts +224 -0
  8. package/src/blueprint/compile.ts +371 -0
  9. package/src/blueprint/dataset-revision.ts +33 -0
  10. package/src/blueprint/diff.ts +223 -0
  11. package/src/blueprint/model.ts +227 -0
  12. package/src/blueprint/projection.ts +254 -0
  13. package/src/blueprint/render.ts +73 -0
  14. package/src/blueprint/scaffold.ts +79 -0
  15. package/src/blueprint/tree.ts +121 -0
  16. package/src/commands/agent/index-commands.ts +87 -1
  17. package/src/commands/app/deploy.ts +43 -3
  18. package/src/commands/app/init.ts +23 -1
  19. package/src/commands/app/pull.ts +12 -35
  20. package/src/commands/automation/index-commands.ts +42 -1
  21. package/src/commands/automation/init.ts +52 -0
  22. package/src/commands/automation/project-root.ts +58 -0
  23. package/src/commands/automation/pull.ts +124 -0
  24. package/src/commands/automation/run.ts +271 -0
  25. package/src/commands/blueprint/authoring.ts +410 -0
  26. package/src/commands/blueprint/bind.ts +228 -0
  27. package/src/commands/blueprint/declarative.ts +1052 -0
  28. package/src/commands/blueprint/grants.ts +164 -0
  29. package/src/commands/dataset/index-commands.ts +431 -0
  30. package/src/commands/knowledge/index-commands.ts +278 -27
  31. package/src/commands/knowledge/upload-batch.ts +146 -0
  32. package/src/commands/knowledge/upload-plan.ts +127 -0
  33. package/src/commands/login.ts +49 -11
  34. package/src/commands/pack/index-commands.ts +373 -0
  35. package/src/commands/registry.ts +19 -2
  36. package/src/commands/secret/index-commands.ts +195 -0
  37. package/src/commands/skill/bundle-commands.ts +327 -0
  38. package/src/commands/skill/index-commands.ts +36 -42
  39. package/src/commands/skill/resolve.ts +34 -0
  40. package/src/dev-env.ts +114 -0
  41. package/src/flag-help.ts +34 -0
  42. package/src/harness.ts +30 -3
  43. package/src/main.ts +10 -3
  44. package/src/render-evidence.ts +152 -0
  45. package/src/template.ts +4 -0
  46. package/src/untar.ts +44 -0
  47. package/src/vendor/sdk-sources.json +13 -11
  48. package/src/commands/blueprint/reserved.ts +0 -40
@@ -0,0 +1,223 @@
1
+ import { toFiles } from './projection'
2
+ import {
3
+ ARTIFACT_KINDS,
4
+ type ArtifactKind,
5
+ type AuthoredFile,
6
+ type DefinitionBundle,
7
+ } from './model'
8
+
9
+ /**
10
+ * The plan: what the files say that the live draft does not, expressed as typed
11
+ * operations the executor dispatches one by one.
12
+ *
13
+ * Comparison is by `apiName`, the only key both sides share — the files carry no ids
14
+ * (see `model.ts`). It runs over PARSED structures rather than serialised text, so
15
+ * reformatting a YAML file, reordering its keys or requoting a string never shows up
16
+ * as a change.
17
+ */
18
+
19
+ export type OperationKind = 'create' | 'update' | 'delete'
20
+
21
+ export interface ModelOperation {
22
+ operation: OperationKind
23
+ kind: ArtifactKind
24
+ apiName: string
25
+ /** The authored document, for a create or an update. Absent on a delete. */
26
+ document?: Record<string, unknown>
27
+ /** One line of human-readable detail, e.g. `+1 property (tier)`. */
28
+ detail?: string
29
+ /**
30
+ * The artifact as the draft currently has it, on an update.
31
+ *
32
+ * Carried on the operation because the executor has to know what ALREADY agrees:
33
+ * the service refuses a command that would change nothing, so sending an unchanged
34
+ * field is not merely wasteful, it fails the run.
35
+ */
36
+ liveDocument?: Record<string, unknown>
37
+ }
38
+
39
+ export interface BindingDrift {
40
+ objectApiName: string
41
+ datasetRevisionId: string
42
+ pinnedDigest: string
43
+ }
44
+
45
+ export interface Plan {
46
+ /** The draft revision this plan was computed against. */
47
+ revision: number
48
+ operations: ModelOperation[]
49
+ /** Artifacts in the draft but absent from the tree. Applied only with `--prune`. */
50
+ prunes: ModelOperation[]
51
+ drift: BindingDrift[]
52
+ unchanged: number
53
+ }
54
+
55
+ export function isEmpty(plan: Plan): boolean {
56
+ return plan.operations.length === 0 && plan.prunes.length === 0
57
+ }
58
+
59
+ /**
60
+ * Does the live artifact already say everything the file declares?
61
+ *
62
+ * A SUBSET comparison, not an equality one, and this is the difference between a
63
+ * tool that converges and one that reports a change forever. The service normalises
64
+ * on creation: it derives each property's `baseType` from the pinned column, decides
65
+ * `unique` and `uniqueBasis` from what the source vouches for (ADR 0009 decision 5),
66
+ * and defaults `status` and `visibility`. None of that is in a hand-authored file,
67
+ * and none of it should be — a file that had to restate every derived value would
68
+ * have to be regenerated after every apply.
69
+ *
70
+ * So a field the file does not mention is a field the file has no opinion about.
71
+ * What that costs is stated rather than hidden: DELETING a line from a file is not a
72
+ * change this can see. Removing a property is done by removing it from
73
+ * `properties`, which IS seen, because the property set is compared by name below.
74
+ *
75
+ * Exported because the EXECUTOR must answer the same question with the same predicate:
76
+ * the plan decides an artifact changed with this, so the executor deciding WHICH field
77
+ * changed has to agree. A second comparison there — a `JSON.stringify` equality, say —
78
+ * disagrees about key order and about fields the file omits, and refuses a run naming a
79
+ * field the plan never called changed.
80
+ */
81
+ export function satisfies(local: unknown, live: unknown, key?: string): boolean {
82
+ // `backing` is owned by `bind`, which travels the probe and the contract-change
83
+ // gate. Letting it into the plan would mean `apply` silently rebinding a type
84
+ // because somebody edited a column name in a file.
85
+ if (key === 'backing') return true
86
+
87
+ if (Array.isArray(local)) {
88
+ if (!Array.isArray(live)) return false
89
+ const named = local.every((entry) => isNamed(entry))
90
+ if (!named) return canonical(local) === canonical(live)
91
+ // Compared by apiName, so reordering `properties:` is not a change and adding
92
+ // or removing one is.
93
+ const liveByName = new Map(
94
+ live.filter(isNamed).map((entry) => [entry.apiName, entry as Record<string, unknown>]),
95
+ )
96
+ const localNames = local.filter(isNamed).map((entry) => entry.apiName)
97
+ if (localNames.length !== liveByName.size) return false
98
+ return localNames.every((name) => {
99
+ const counterpart = liveByName.get(name)
100
+ return counterpart !== undefined
101
+ && satisfies(local.find((entry) => isNamed(entry) && entry.apiName === name), counterpart)
102
+ })
103
+ }
104
+
105
+ if (local && typeof local === 'object') {
106
+ if (!live || typeof live !== 'object') return false
107
+ return Object.entries(local as Record<string, unknown>).every(([field, value]) =>
108
+ satisfies(value, (live as Record<string, unknown>)[field], field))
109
+ }
110
+
111
+ return canonical(local) === canonical(live)
112
+ }
113
+
114
+ function isNamed(value: unknown): value is { apiName: string } {
115
+ return Boolean(value) && typeof value === 'object'
116
+ && typeof (value as { apiName?: unknown }).apiName === 'string'
117
+ }
118
+
119
+ function canonical(value: unknown): string {
120
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
121
+ if (value && typeof value === 'object') {
122
+ const entries = Object.entries(value as Record<string, unknown>)
123
+ .filter(([, entry]) => entry !== undefined)
124
+ .sort(([left], [right]) => left.localeCompare(right))
125
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`)
126
+ return `{${entries.join(',')}}`
127
+ }
128
+ return JSON.stringify(value) ?? 'null'
129
+ }
130
+
131
+ function propertyNames(document: Record<string, unknown>): string[] {
132
+ const properties = document.properties
133
+ if (!Array.isArray(properties)) return []
134
+ return properties
135
+ .map((property) => (property as { apiName?: unknown }).apiName)
136
+ .filter((name): name is string => typeof name === 'string')
137
+ }
138
+
139
+ /** `+1 property (tier)` / `-1 property (legacyCode)`, or a bare field count. */
140
+ function describeUpdate(local: Record<string, unknown>, live: Record<string, unknown>): string {
141
+ const before = new Set(propertyNames(live))
142
+ const after = propertyNames(local)
143
+ const added = after.filter((name) => !before.has(name))
144
+ const removed = [...before].filter((name) => !after.includes(name))
145
+ const parts: string[] = []
146
+ if (added.length) parts.push(`+${added.length} propert${added.length === 1 ? 'y' : 'ies'} (${added.join(', ')})`)
147
+ if (removed.length) parts.push(`-${removed.length} propert${removed.length === 1 ? 'y' : 'ies'} (${removed.join(', ')})`)
148
+ return parts.length ? parts.join(', ') : 'fields changed'
149
+ }
150
+
151
+ export function diff(
152
+ localFiles: AuthoredFile[],
153
+ live: DefinitionBundle,
154
+ revision: number,
155
+ ): Plan {
156
+ const liveModel = toFiles(live)
157
+ const liveByKey = new Map(liveModel.files.map((file) => [`${file.kind}:${file.apiName}`, file]))
158
+ const localByKey = new Map(localFiles.map((file) => [`${file.kind}:${file.apiName}`, file]))
159
+
160
+ const operations: ModelOperation[] = []
161
+ const prunes: ModelOperation[] = []
162
+ let unchanged = 0
163
+
164
+ // Fixed kind order so the plan reads the same way every run, and so creations of
165
+ // a kind that others reference come first within the listing.
166
+ for (const kind of ARTIFACT_KINDS) {
167
+ for (const file of localFiles.filter((candidate) => candidate.kind === kind)) {
168
+ const key = `${kind}:${file.apiName}`
169
+ const existing = liveByKey.get(key)
170
+ if (!existing) {
171
+ operations.push({
172
+ operation: 'create',
173
+ kind,
174
+ apiName: file.apiName,
175
+ document: file.document,
176
+ ...(kind === 'object-type'
177
+ ? { detail: `${propertyNames(file.document).length} properties` }
178
+ : {}),
179
+ })
180
+ continue
181
+ }
182
+ if (satisfies(file.document, existing.document)) {
183
+ unchanged += 1
184
+ continue
185
+ }
186
+ operations.push({
187
+ operation: 'update',
188
+ kind,
189
+ apiName: file.apiName,
190
+ document: file.document,
191
+ liveDocument: existing.document,
192
+ detail: describeUpdate(file.document, existing.document),
193
+ })
194
+ }
195
+ for (const file of liveModel.files.filter((candidate) => candidate.kind === kind)) {
196
+ if (localByKey.has(`${kind}:${file.apiName}`)) continue
197
+ prunes.push({ operation: 'delete', kind, apiName: file.apiName })
198
+ }
199
+ }
200
+
201
+ return { revision, operations, prunes, drift: driftOf(live), unchanged }
202
+ }
203
+
204
+ /**
205
+ * Bound object types whose pinned schema digest can be checked against the dataset's
206
+ * current revision. The comparison itself needs the dataset read, so this only
207
+ * collects the pins; `plan` fills in the verdict.
208
+ */
209
+ export function driftOf(live: DefinitionBundle): BindingDrift[] {
210
+ const pins: BindingDrift[] = []
211
+ for (const object of live.objects) {
212
+ const mapping = object.governance.sourceMappings[0] as
213
+ | { datasetRevisionId?: string; datasetSchemaDigest?: string }
214
+ | undefined
215
+ if (!mapping?.datasetRevisionId || !mapping.datasetSchemaDigest) continue
216
+ pins.push({
217
+ objectApiName: object.apiName,
218
+ datasetRevisionId: mapping.datasetRevisionId,
219
+ pinnedDigest: mapping.datasetSchemaDigest,
220
+ })
221
+ }
222
+ return pins
223
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The two shapes this package moves between: the DEFINITION BUNDLE the service
3
+ * speaks, and the AUTHORED MODEL a person keeps in files.
4
+ *
5
+ * They differ in exactly one way, and every rule in `projection.ts` follows from it:
6
+ * the bundle is UUID-keyed, and the files carry no UUID at all. The service mints
7
+ * every identifier — object ids, property ids, and a source system for an
8
+ * organization's first object type — so a UUID written into a shared tree would be
9
+ * correct in the deployment that minted it and wrong in every other. `apiName` is
10
+ * the only key that means the same thing everywhere.
11
+ *
12
+ * The bundle types here are deliberately loose where the CLI does not reason about
13
+ * the contents. `unknown` on a field means "carried through untouched", which is
14
+ * what a projection must do with anything it does not itself interpret — the
15
+ * alternative is a second copy of the service's schema that silently drops a field
16
+ * the day the service adds one.
17
+ */
18
+
19
+ /** A property as the bundle carries it. */
20
+ export interface BundleProperty {
21
+ id: string
22
+ apiName: string
23
+ [field: string]: unknown
24
+ }
25
+
26
+ /** An object type as the bundle carries it. */
27
+ export interface BundleObject {
28
+ id: string
29
+ apiName: string
30
+ properties: BundleProperty[]
31
+ primaryKeyPropertyId: string
32
+ titlePropertyId: string
33
+ governance: {
34
+ sourceMappings: unknown[]
35
+ [field: string]: unknown
36
+ }
37
+ [field: string]: unknown
38
+ }
39
+
40
+ /** A link type (`relationships[]` in the bundle) as the bundle carries it. */
41
+ export interface BundleRelationship {
42
+ id: string
43
+ apiName: string
44
+ fromObjectId: string
45
+ toObjectId: string
46
+ fromPropertyId: string
47
+ toPropertyId: string
48
+ [field: string]: unknown
49
+ }
50
+
51
+ export interface BundleNamed {
52
+ id: string
53
+ apiName: string
54
+ [field: string]: unknown
55
+ }
56
+
57
+ export interface DefinitionBundle {
58
+ schemaVersion: number
59
+ objects: BundleObject[]
60
+ sourceSystems: BundleNamed[]
61
+ relationships: BundleRelationship[]
62
+ metrics: BundleNamed[]
63
+ /**
64
+ * Optional in both bundle versions: an organization that has never authored one
65
+ * carries no key at all, and the schemas are strict.
66
+ */
67
+ sharedProperties?: BundleNamed[]
68
+ /** v2 only. Absent on a v1 bundle rather than empty — the schemas are strict. */
69
+ actions?: BundleNamed[]
70
+ }
71
+
72
+ /**
73
+ * The authorable kinds, in the order a reader meets them — which is also the order
74
+ * `apply` writes them in, so a kind that others reference comes first. A property
75
+ * names its shared field, so shared fields are created before object types.
76
+ *
77
+ * Two things are deliberately absent, for the same reason: a file the CLI can write
78
+ * but never apply is worse than no file, because it breaks the round trip the tree
79
+ * promises and fails only at the end of a deployment.
80
+ *
81
+ * **Source systems** have no route of their own — `createDraftObjectType` synthesises
82
+ * one for an organization's first object type and nothing else ever creates one.
83
+ *
84
+ * **Actions** carry several uuid references the projection cannot turn into names:
85
+ * `subject.objectTypeId`, `effect.authoritySourceSystemId` and `compensationActionId`
86
+ * at least. Emitting them as raw uuids makes the tree unportable — the identifiers are
87
+ * per-deployment — and the create route takes the contract whole, so there is nowhere
88
+ * to resolve them back. `blueprint create|update|delete action` still authors them
89
+ * imperatively; only the FILE path is withheld, and withheld visibly rather than
90
+ * shipped broken.
91
+ */
92
+ export const ARTIFACT_KINDS = [
93
+ 'shared-field',
94
+ 'object-type',
95
+ 'link-type',
96
+ 'metric',
97
+ ] as const
98
+
99
+ export type ArtifactKind = (typeof ARTIFACT_KINDS)[number]
100
+
101
+ /** `object-type` → `object-types/`. Plural directory, singular command word. */
102
+ export const KIND_DIRECTORY: Record<ArtifactKind, string> = {
103
+ 'shared-field': 'shared-fields',
104
+ 'object-type': 'object-types',
105
+ 'link-type': 'link-types',
106
+ metric: 'metrics',
107
+ }
108
+
109
+ export const DIRECTORY_KIND: Record<string, ArtifactKind> = Object.fromEntries(
110
+ Object.entries(KIND_DIRECTORY).map(([kind, directory]) => [directory, kind as ArtifactKind]),
111
+ ) as Record<string, ArtifactKind>
112
+
113
+ /**
114
+ * What an `apiName` may be, per kind — the same rules the service enforces
115
+ * (`catalog-types.ts:258-259`): object types are PascalCase, everything else is
116
+ * camelCase.
117
+ *
118
+ * Checked in the CLI as well as the service, and not only to fail earlier: an apiName
119
+ * becomes a FILE NAME here, so `../../escaped` wrote outside the tree root before this
120
+ * existed. A name that cannot name a file is refused before anything is written.
121
+ */
122
+ export const API_NAME_RULE: Record<ArtifactKind, RegExp> = {
123
+ 'shared-field': /^[a-z][A-Za-z0-9]{0,99}$/,
124
+ 'object-type': /^[A-Z][A-Za-z0-9]{0,99}$/,
125
+ 'link-type': /^[a-z][A-Za-z0-9]{0,99}$/,
126
+ metric: /^[a-z][A-Za-z0-9]{0,99}$/,
127
+ }
128
+
129
+ export function describeApiNameRule(kind: ArtifactKind): string {
130
+ return kind === 'object-type'
131
+ ? 'PascalCase: a capital letter, then letters and digits'
132
+ : 'camelCase: a lowercase letter, then letters and digits'
133
+ }
134
+
135
+ /** One authored file: its kind, its apiName, and the document itself. */
136
+ export interface AuthoredFile {
137
+ kind: ArtifactKind
138
+ apiName: string
139
+ /** Path relative to the tree root, e.g. `object-types/Customer.yaml`. */
140
+ path: string
141
+ document: Record<string, unknown>
142
+ }
143
+
144
+ export interface AuthoredModel {
145
+ schemaVersion: number
146
+ files: AuthoredFile[]
147
+ }
148
+
149
+ /** Everything the compiler needs from the live draft to resolve apiName → UUID. */
150
+ export interface LiveIndex {
151
+ objectIdByApiName: Map<string, string>
152
+ /** `<objectApiName>.<propertyApiName>` → property id. */
153
+ propertyIdByPath: Map<string, string>
154
+ sourceSystemIdByApiName: Map<string, string>
155
+ relationshipIdByApiName: Map<string, string>
156
+ metricIdByApiName: Map<string, string>
157
+ actionIdByApiName: Map<string, string>
158
+ /**
159
+ * The one reference a FILE writes by name that the wire takes as a uuid.
160
+ *
161
+ * A property entry says `sharedField: accountCode`; the object-create body and
162
+ * the field commands say `sharedPropertyId`. The authored tree speaks FIELD and
163
+ * the wire speaks property — this map is where the two meet, and it is the only
164
+ * reference a file writes that is not addressed by name on both sides.
165
+ */
166
+ sharedPropertyIdByApiName: Map<string, string>
167
+ /** The live object, by apiName — the merge reads `governance.sourceMappings` off it. */
168
+ objectByApiName: Map<string, BundleObject>
169
+ relationshipByApiName: Map<string, BundleRelationship>
170
+ }
171
+
172
+ export function emptyIndex(): LiveIndex {
173
+ return {
174
+ objectIdByApiName: new Map(),
175
+ propertyIdByPath: new Map(),
176
+ sourceSystemIdByApiName: new Map(),
177
+ relationshipIdByApiName: new Map(),
178
+ metricIdByApiName: new Map(),
179
+ actionIdByApiName: new Map(),
180
+ sharedPropertyIdByApiName: new Map(),
181
+ objectByApiName: new Map(),
182
+ relationshipByApiName: new Map(),
183
+ }
184
+ }
185
+
186
+ export function indexBundle(bundle: DefinitionBundle): LiveIndex {
187
+ const index = emptyIndex()
188
+ for (const object of bundle.objects) {
189
+ index.objectIdByApiName.set(object.apiName, object.id)
190
+ index.objectByApiName.set(object.apiName, object)
191
+ for (const property of object.properties) {
192
+ index.propertyIdByPath.set(`${object.apiName}.${property.apiName}`, property.id)
193
+ }
194
+ }
195
+ for (const system of bundle.sourceSystems) index.sourceSystemIdByApiName.set(system.apiName, system.id)
196
+ for (const link of bundle.relationships) {
197
+ index.relationshipIdByApiName.set(link.apiName, link.id)
198
+ index.relationshipByApiName.set(link.apiName, link)
199
+ }
200
+ for (const metric of bundle.metrics) index.metricIdByApiName.set(metric.apiName, metric.id)
201
+ for (const action of bundle.actions ?? []) index.actionIdByApiName.set(action.apiName, action.id)
202
+ for (const shared of bundle.sharedProperties ?? []) {
203
+ index.sharedPropertyIdByApiName.set(shared.apiName, shared.id)
204
+ }
205
+ return index
206
+ }
207
+
208
+ /**
209
+ * An unresolved reference, collected rather than thrown.
210
+ *
211
+ * All of them are reported at once: fixing references one error per run is the
212
+ * failure mode that makes a declarative tool feel worse than the imperative commands
213
+ * it replaces.
214
+ */
215
+ export interface RefError {
216
+ /** The file the reference is written in. */
217
+ path: string
218
+ /** Where in that file, dotted — e.g. `from.property`. */
219
+ field: string
220
+ /** The name that resolved to nothing. */
221
+ name: string
222
+ message: string
223
+ }
224
+
225
+ export type Result<T> =
226
+ | { ok: true; value: T }
227
+ | { ok: false; errors: RefError[] }