@frontera-sdk/cli 1.50.61 → 1.50.64
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 -4
- package/src/api/apps-api.ts +9 -1
- package/src/api/automation-api.ts +7 -0
- package/src/api/blueprint-authoring-api.ts +7 -1
- package/src/api/dataset-api.ts +2 -0
- package/src/api/governed-action-api.ts +2 -0
- package/src/api/platform-api.ts +9 -1
- package/src/api/workflow-api.ts +2 -0
- package/src/blueprint/compile.ts +12 -0
- package/src/blueprint/projection.ts +21 -1
- package/src/commands/blueprint/authoring.ts +46 -12
- package/src/commands/blueprint/declarative.ts +23 -5
- package/src/commands/registry.ts +6 -0
- package/src/exit.ts +3 -0
- package/src/flag-help.ts +4 -0
- package/src/main.ts +29 -0
- package/src/organization.ts +161 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.50.
|
|
3
|
+
"version": "1.50.64",
|
|
4
4
|
"description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -39,14 +39,14 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@anthropic-ai/claude-agent-sdk": "^0.3.251",
|
|
42
|
-
"@frontera-sdk/functions": "1.50.
|
|
43
|
-
"@frontera-sdk/core": "1.50.
|
|
42
|
+
"@frontera-sdk/functions": "1.50.64",
|
|
43
|
+
"@frontera-sdk/core": "1.50.64",
|
|
44
44
|
"ai": "^6.0.116",
|
|
45
45
|
"gray-matter": "^4.0.3",
|
|
46
46
|
"yaml": "^2.9.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@frontera-sdk/forge-contracts": "1.50.
|
|
49
|
+
"@frontera-sdk/forge-contracts": "1.50.64",
|
|
50
50
|
"@types/bun": "^1.3.14",
|
|
51
51
|
"typescript": "^5.9.3"
|
|
52
52
|
}
|
package/src/api/apps-api.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { FronteraClient } from '@frontera-sdk/core/client'
|
|
2
|
+
|
|
3
|
+
import { organizationHeaders, selectedOrganization } from '../organization'
|
|
2
4
|
import { FronteraError } from '@frontera-sdk/core/errors'
|
|
3
5
|
|
|
4
6
|
export interface AppVersionSummary {
|
|
@@ -45,6 +47,9 @@ export class AppsApi {
|
|
|
45
47
|
this.client = new FronteraClient({
|
|
46
48
|
apiBaseUrl,
|
|
47
49
|
credential: { kind: 'apiKey', key: token },
|
|
50
|
+
// The SDK transport turns this into `x-org-id`. Without it the service
|
|
51
|
+
// falls back to the caller's first organization membership.
|
|
52
|
+
...(selectedOrganization() ? { orgId: selectedOrganization()! } : {}),
|
|
48
53
|
})
|
|
49
54
|
}
|
|
50
55
|
|
|
@@ -54,7 +59,10 @@ export class AppsApi {
|
|
|
54
59
|
|
|
55
60
|
private authHeader(): Record<string, string> {
|
|
56
61
|
const c = this.client.config.credential
|
|
57
|
-
return {
|
|
62
|
+
return {
|
|
63
|
+
authorization: `Bearer ${c.kind === 'apiKey' ? c.key : c.token}`,
|
|
64
|
+
...organizationHeaders(),
|
|
65
|
+
}
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
private async multipart<T>(path: string, form: FormData, method = 'POST'): Promise<T> {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { FronteraClient } from '@frontera-sdk/core/client'
|
|
2
|
+
|
|
3
|
+
import { organizationHeaders, selectedOrganization } from '../organization'
|
|
2
4
|
import { FronteraError } from '@frontera-sdk/core/errors'
|
|
3
5
|
|
|
4
6
|
import { CliError } from '../errors'
|
|
@@ -169,6 +171,9 @@ export class AutomationApi {
|
|
|
169
171
|
this.client = new FronteraClient({
|
|
170
172
|
apiBaseUrl,
|
|
171
173
|
credential: { kind: 'apiKey', key: token },
|
|
174
|
+
// The SDK transport turns this into `x-org-id`. Without it the service
|
|
175
|
+
// falls back to the caller's first organization membership.
|
|
176
|
+
...(selectedOrganization() ? { orgId: selectedOrganization()! } : {}),
|
|
172
177
|
...(workspaceId ? { workspaceId } : {}),
|
|
173
178
|
})
|
|
174
179
|
}
|
|
@@ -406,6 +411,7 @@ export class AutomationApi {
|
|
|
406
411
|
{
|
|
407
412
|
headers: {
|
|
408
413
|
authorization: `Bearer ${credential.kind === 'apiKey' ? credential.key : credential.token}`,
|
|
414
|
+
...organizationHeaders(),
|
|
409
415
|
},
|
|
410
416
|
},
|
|
411
417
|
)
|
|
@@ -462,6 +468,7 @@ export class AutomationApi {
|
|
|
462
468
|
method: 'POST',
|
|
463
469
|
headers: {
|
|
464
470
|
authorization: `Bearer ${credential.kind === 'apiKey' ? credential.key : credential.token}`,
|
|
471
|
+
...organizationHeaders(),
|
|
465
472
|
},
|
|
466
473
|
body: form,
|
|
467
474
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError } from '../errors'
|
|
2
|
+
import { organizationHeaders } from '../organization'
|
|
2
3
|
import { credentialFailure } from './credential-failure'
|
|
3
4
|
import { formatValidationDetails } from './validation-detail'
|
|
4
5
|
import type { DefinitionBundle } from '../blueprint/model'
|
|
@@ -30,6 +31,7 @@ export class BlueprintAuthoringApi {
|
|
|
30
31
|
method: init.method ?? 'GET',
|
|
31
32
|
headers: {
|
|
32
33
|
authorization: `Bearer ${this.token}`,
|
|
34
|
+
...organizationHeaders(),
|
|
33
35
|
'content-type': 'application/json',
|
|
34
36
|
},
|
|
35
37
|
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
@@ -612,7 +614,11 @@ export class BlueprintAuthoringApi {
|
|
|
612
614
|
private async migrationBlockers(path: string, body: Record<string, unknown>): Promise<string[]> {
|
|
613
615
|
const res = await fetch(`${this.apiBaseUrl}${path}`, {
|
|
614
616
|
method: 'POST',
|
|
615
|
-
headers: {
|
|
617
|
+
headers: {
|
|
618
|
+
authorization: `Bearer ${this.token}`,
|
|
619
|
+
...organizationHeaders(),
|
|
620
|
+
'content-type': 'application/json',
|
|
621
|
+
},
|
|
616
622
|
body: JSON.stringify(body),
|
|
617
623
|
})
|
|
618
624
|
const payload: any = await res.json().catch(() => null)
|
package/src/api/dataset-api.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
|
|
|
2
2
|
import { basename } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { CliError } from '../errors'
|
|
5
|
+
import { organizationHeaders } from '../organization'
|
|
5
6
|
import { credentialFailure } from './credential-failure'
|
|
6
7
|
import { formatValidationDetails } from './validation-detail'
|
|
7
8
|
|
|
@@ -91,6 +92,7 @@ export class DatasetApi {
|
|
|
91
92
|
method: options.form ? 'POST' : (options.method ?? 'GET'),
|
|
92
93
|
headers: {
|
|
93
94
|
Authorization: `Bearer ${this.token}`,
|
|
95
|
+
...organizationHeaders(),
|
|
94
96
|
// Never set for multipart: `fetch` writes the boundary itself, and a
|
|
95
97
|
// hand-written Content-Type loses it and the body parses as nothing.
|
|
96
98
|
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError } from '../errors'
|
|
2
|
+
import { organizationHeaders } from '../organization'
|
|
2
3
|
import { credentialFailure } from './credential-failure'
|
|
3
4
|
import type { ObjectTypeShape, PublishedActionDefinition } from '../blueprint/ontology-edit-plan'
|
|
4
5
|
|
|
@@ -53,6 +54,7 @@ export class GovernedActionApi {
|
|
|
53
54
|
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
|
54
55
|
return {
|
|
55
56
|
authorization: `Bearer ${this.token}`,
|
|
57
|
+
...organizationHeaders(),
|
|
56
58
|
...(this.workspaceId ? { 'x-workspace-id': this.workspaceId } : {}),
|
|
57
59
|
...extra,
|
|
58
60
|
}
|
package/src/api/platform-api.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { FronteraClient } from '@frontera-sdk/core/client'
|
|
2
2
|
|
|
3
|
+
import { organizationHeaders, selectedOrganization } from '../organization'
|
|
4
|
+
|
|
3
5
|
import { CliError } from '../errors'
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -64,6 +66,9 @@ export class PlatformApi {
|
|
|
64
66
|
this.client = new FronteraClient({
|
|
65
67
|
apiBaseUrl,
|
|
66
68
|
credential: { kind: 'apiKey', key: token },
|
|
69
|
+
// The SDK transport turns this into `x-org-id`. Without it the service
|
|
70
|
+
// falls back to the caller's first organization membership.
|
|
71
|
+
...(selectedOrganization() ? { orgId: selectedOrganization()! } : {}),
|
|
67
72
|
...(workspaceId ? { workspaceId } : {}),
|
|
68
73
|
})
|
|
69
74
|
}
|
|
@@ -262,7 +267,10 @@ export class PlatformApi {
|
|
|
262
267
|
|
|
263
268
|
private authHeader(): Record<string, string> {
|
|
264
269
|
const c = this.client.config.credential
|
|
265
|
-
return {
|
|
270
|
+
return {
|
|
271
|
+
authorization: `Bearer ${c.kind === 'apiKey' ? c.key : c.token}`,
|
|
272
|
+
...organizationHeaders(),
|
|
273
|
+
}
|
|
266
274
|
}
|
|
267
275
|
|
|
268
276
|
/**
|
package/src/api/workflow-api.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError } from '../errors'
|
|
2
|
+
import { organizationHeaders } from '../organization'
|
|
2
3
|
import { credentialFailure } from './credential-failure'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -145,6 +146,7 @@ export class WorkflowApi {
|
|
|
145
146
|
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
|
146
147
|
return {
|
|
147
148
|
authorization: `Bearer ${this.token}`,
|
|
149
|
+
...organizationHeaders(),
|
|
148
150
|
...(this.workspaceId ? { 'x-workspace-id': this.workspaceId } : {}),
|
|
149
151
|
...extra,
|
|
150
152
|
}
|
package/src/blueprint/compile.ts
CHANGED
|
@@ -222,6 +222,12 @@ export async function objectTypeCreateBody(
|
|
|
222
222
|
...optional(document.groups as string[] | undefined, 'groups'),
|
|
223
223
|
...optional(document.status as string | undefined, 'status'),
|
|
224
224
|
...optional(document.visibility as string | undefined, 'visibility'),
|
|
225
|
+
// Row access is set AT CREATION for the same reason a shared field is: a first
|
|
226
|
+
// apply against an empty organization creates the type in one call, so a field the
|
|
227
|
+
// create body drops is unreachable on exactly that run. An authored `null` is
|
|
228
|
+
// nothing to carry here — a create has no column to clear.
|
|
229
|
+
...optional((document.rowAccess as string | null | undefined) ?? undefined, 'rowAccess'),
|
|
230
|
+
...optional((document.markingsColumn as string | null | undefined) ?? undefined, 'markingsColumn'),
|
|
225
231
|
datasetRevisionId: revisionId,
|
|
226
232
|
properties: properties.map((entry) => {
|
|
227
233
|
const property = entry as Record<string, unknown>
|
|
@@ -292,6 +298,12 @@ export function objectTypePatchBody(
|
|
|
292
298
|
...optional(document.groups as string[] | undefined, 'groups'),
|
|
293
299
|
...optional(document.status as string | undefined, 'status'),
|
|
294
300
|
...optional(document.visibility as string | undefined, 'visibility'),
|
|
301
|
+
// `rowAccess` has no clear arm on the route — it is one of two literals, or absent
|
|
302
|
+
// — so an authored `null` is omitted rather than sent as a 422.
|
|
303
|
+
...optional((document.rowAccess as string | null | undefined) ?? undefined, 'rowAccess'),
|
|
304
|
+
// `markingsColumn` does have one, and it is the only field in this body where an
|
|
305
|
+
// authored `null` carries meaning: it detaches the column from the type.
|
|
306
|
+
...optional(document.markingsColumn as string | null | undefined, 'markingsColumn'),
|
|
295
307
|
expectedRevision,
|
|
296
308
|
}
|
|
297
309
|
}
|
|
@@ -43,6 +43,16 @@ const LINK_RESOLVED = new Set([
|
|
|
43
43
|
'datasetBacking',
|
|
44
44
|
])
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Governance fields a file states at its TOP level rather than inside `governance`.
|
|
48
|
+
*
|
|
49
|
+
* They are the two the object-type routes can actually carry. `governance` as a whole
|
|
50
|
+
* cannot be applicable — it also holds `ownership` and `sensitivity`, which no route
|
|
51
|
+
* takes — so a file that stated these inside it would be refused by `assertApplicable`
|
|
52
|
+
* for a change the routes are perfectly able to make.
|
|
53
|
+
*/
|
|
54
|
+
const OBJECT_LIFTED_GOVERNANCE = ['rowAccess', 'markingsColumn'] as const
|
|
55
|
+
|
|
46
56
|
function rest(source: Record<string, unknown>, resolved: Set<string>): Record<string, unknown> {
|
|
47
57
|
const out: Record<string, unknown> = {}
|
|
48
58
|
for (const [key, value] of Object.entries(source)) {
|
|
@@ -165,11 +175,21 @@ function objectToFile(
|
|
|
165
175
|
): Record<string, unknown> {
|
|
166
176
|
const byId = new Map(object.properties.map((property) => [property.id, property.apiName]))
|
|
167
177
|
const backing = backingToFile(object, datasetNameByRevisionId)
|
|
178
|
+
const lifted: Record<string, unknown> = {}
|
|
179
|
+
for (const field of OBJECT_LIFTED_GOVERNANCE) {
|
|
180
|
+
// Absent stays absent. `rowAccess` has no default the projection may invent — the
|
|
181
|
+
// service reads its absence as a distinct third state — and a file that wrote
|
|
182
|
+
// `rowAccess: null` would compile back to a bundle the one it came from does not
|
|
183
|
+
// match, which is the round trip this module exists to hold.
|
|
184
|
+
const value = object.governance[field]
|
|
185
|
+
if (value !== undefined) lifted[field] = value
|
|
186
|
+
}
|
|
168
187
|
return {
|
|
169
188
|
...rest(object, OBJECT_RESOLVED),
|
|
170
189
|
// `governance.sourceMappings` is the binding, which `bind` owns. Everything else
|
|
171
190
|
// in governance — ownership, sensitivity — is authored, so it stays.
|
|
172
|
-
governance: rest(object.governance, new Set(['sourceMappings'])),
|
|
191
|
+
governance: rest(object.governance, new Set(['sourceMappings', ...OBJECT_LIFTED_GOVERNANCE])),
|
|
192
|
+
...lifted,
|
|
173
193
|
...(backing ? { backing } : {}),
|
|
174
194
|
primaryKey: byId.get(object.primaryKeyPropertyId) ?? object.primaryKeyPropertyId,
|
|
175
195
|
title: byId.get(object.titlePropertyId) ?? object.titlePropertyId,
|
|
@@ -79,6 +79,25 @@ function readDocument(ctx: CommandContext, positionalIndex = 0): Record<string,
|
|
|
79
79
|
const KINDS = ['object-type', 'link-type', 'metric', 'action', 'object-set'] as const
|
|
80
80
|
type Kind = (typeof KINDS)[number]
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* `delete` reaches two kinds the other verbs do not.
|
|
84
|
+
*
|
|
85
|
+
* `apply` creates semantic types and shared fields — the declarative path has
|
|
86
|
+
* carried both for as long as they have existed — while `delete` refused them
|
|
87
|
+
* with "Unknown kind", so a semantic type applied by mistake could only be
|
|
88
|
+
* removed by publishing and rolling back over it. The service has had the
|
|
89
|
+
* commands the whole time (`deleteDraftSemanticType`,
|
|
90
|
+
* `deleteDraftSharedProperty`); nothing here reached them.
|
|
91
|
+
*
|
|
92
|
+
* Deliberately not added to `create` and `update` in the same change. Both
|
|
93
|
+
* branch on kind with a metric fallthrough, so a kind they do not handle would
|
|
94
|
+
* be silently treated as a metric — a wrong write rather than a refusal. The
|
|
95
|
+
* declarative path already authors both, which is what this asymmetry means:
|
|
96
|
+
* removal had no path at all, authoring has one.
|
|
97
|
+
*/
|
|
98
|
+
const DELETE_KINDS = [...KINDS, 'semantic-type', 'shared-field'] as const
|
|
99
|
+
type DeleteKind = (typeof DELETE_KINDS)[number]
|
|
100
|
+
|
|
82
101
|
/**
|
|
83
102
|
* `create`, `update` and `delete` all start with a kind, so they all land here
|
|
84
103
|
* — and the hint named `create` for all three. Someone who mistyped
|
|
@@ -88,11 +107,16 @@ type Kind = (typeof KINDS)[number]
|
|
|
88
107
|
* The caller's own verb is passed in, and the hint is that verb's first
|
|
89
108
|
* example, which shows every argument rather than only the one that failed.
|
|
90
109
|
*/
|
|
91
|
-
function readKind
|
|
92
|
-
|
|
93
|
-
|
|
110
|
+
function readKind<T extends string>(
|
|
111
|
+
ctx: CommandContext,
|
|
112
|
+
verb: string,
|
|
113
|
+
example: string,
|
|
114
|
+
kinds: ReadonlyArray<T> = KINDS as unknown as ReadonlyArray<T>,
|
|
115
|
+
): T {
|
|
116
|
+
const kind = ctx.positional[0] as T
|
|
117
|
+
if (!kinds.includes(kind)) {
|
|
94
118
|
throw new CliError(
|
|
95
|
-
`Unknown kind "${ctx.positional[0] ?? ''}". Expected one of: ${
|
|
119
|
+
`Unknown kind "${ctx.positional[0] ?? ''}". Expected one of: ${kinds.join(', ')}.`,
|
|
96
120
|
{ code: 'USAGE', hint: `${example} — see \`frontera blueprint ${verb} --help\`` },
|
|
97
121
|
)
|
|
98
122
|
}
|
|
@@ -189,15 +213,20 @@ export const blueprintDelete: Command = {
|
|
|
189
213
|
noun: 'blueprint',
|
|
190
214
|
verb: 'delete',
|
|
191
215
|
args: [
|
|
192
|
-
{ name: 'kind', required: true, description: `One of: ${
|
|
216
|
+
{ name: 'kind', required: true, description: `One of: ${DELETE_KINDS.join(', ')}` },
|
|
193
217
|
{ name: 'apiName', required: true, description: 'The artifact’s API name' },
|
|
194
218
|
],
|
|
195
219
|
flags: {},
|
|
196
220
|
summary: 'Remove an artifact from the shared draft',
|
|
197
|
-
examples: [
|
|
221
|
+
examples: [
|
|
222
|
+
'frontera blueprint delete metric revenuePerCustomer',
|
|
223
|
+
'frontera blueprint delete semantic-type emailAddress',
|
|
224
|
+
],
|
|
198
225
|
},
|
|
199
226
|
async run(ctx) {
|
|
200
|
-
const kind = readKind(
|
|
227
|
+
const kind: DeleteKind = readKind(
|
|
228
|
+
ctx, 'delete', 'frontera blueprint delete metric revenuePerCustomer', DELETE_KINDS,
|
|
229
|
+
)
|
|
201
230
|
const apiName = ctx.positional[1]
|
|
202
231
|
if (!apiName) {
|
|
203
232
|
throw new CliError('An apiName is required.', {
|
|
@@ -227,11 +256,16 @@ export const blueprintDelete: Command = {
|
|
|
227
256
|
}
|
|
228
257
|
|
|
229
258
|
const revision = await client.revision()
|
|
230
|
-
const removed =
|
|
231
|
-
? await client.deleteLinkType(apiName, revision)
|
|
232
|
-
:
|
|
233
|
-
|
|
234
|
-
|
|
259
|
+
const removed =
|
|
260
|
+
kind === 'link-type' ? await client.deleteLinkType(apiName, revision)
|
|
261
|
+
// Both succeed with carriers still attached: a field that was carrying the
|
|
262
|
+
// semantic type reverts to an unconstrained one, and an implementer of a
|
|
263
|
+
// shared field reverts to a regular property keeping the values it was
|
|
264
|
+
// inheriting. Removing the label never removes what was underneath it.
|
|
265
|
+
: kind === 'semantic-type' ? await client.deleteSemanticType(apiName, revision)
|
|
266
|
+
: kind === 'shared-field' ? await client.deleteSharedProperty(apiName, revision)
|
|
267
|
+
: kind === 'action' ? await client.deleteAction(apiName, revision)
|
|
268
|
+
: await client.deleteMetric(apiName, revision)
|
|
235
269
|
return { data: removed, text: `Removed ${kind} "${apiName}" on draft revision ${revision}.` }
|
|
236
270
|
},
|
|
237
271
|
}
|
|
@@ -545,8 +545,10 @@ function nextRevision(response: unknown, previous: number): number {
|
|
|
545
545
|
* dropped it, and every kind has fields outside what its route takes.
|
|
546
546
|
*
|
|
547
547
|
* OBJECT TYPE — `metadata` travels the PUT, the keys travel `set_object_keys`,
|
|
548
|
-
* `properties` travels the field commands. `
|
|
549
|
-
*
|
|
548
|
+
* `properties` travels the field commands. `rowAccess` and `markingsColumn` travel the
|
|
549
|
+
* PUT too, which is why the projection lifts them out of `governance` and states them
|
|
550
|
+
* at the top level of the file. The rest of `governance`, plus `validations` and
|
|
551
|
+
* `lifecycle`, have no command at all.
|
|
550
552
|
*
|
|
551
553
|
* LINK TYPE — `PUT /link-types/:apiName` takes the whole structure, so nearly
|
|
552
554
|
* everything a file states is expressible. `status` is not: the bundle carries it and
|
|
@@ -572,6 +574,7 @@ const APPLICABLE_FIELDS: Record<ArtifactKind, Set<string>> = {
|
|
|
572
574
|
'object-type': new Set([
|
|
573
575
|
'apiName', 'displayName', 'pluralDisplayName', 'description', 'icon', 'color',
|
|
574
576
|
'status', 'visibility', 'groups', 'primaryKey', 'title', 'properties', 'backing',
|
|
577
|
+
'rowAccess', 'markingsColumn',
|
|
575
578
|
]),
|
|
576
579
|
'link-type': new Set(['apiName', 'cardinality', 'description', 'from', 'to']),
|
|
577
580
|
metric: new Set(['apiName', 'displayName', 'description', 'definition', 'formatConfig']),
|
|
@@ -659,8 +662,16 @@ async function applyObjectKeys(
|
|
|
659
662
|
return result.revision
|
|
660
663
|
}
|
|
661
664
|
|
|
662
|
-
/** Object-
|
|
663
|
-
|
|
665
|
+
/** Object-type file fields whose route accepts `null` as "unset this". */
|
|
666
|
+
const CLEARABLE_OBJECT_FIELDS = new Set(['markingsColumn'])
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Object-level metadata the file states and the draft does not already agree with.
|
|
670
|
+
*
|
|
671
|
+
* Exported for the same reason `assertApplicable` is: this decides what an `apply`
|
|
672
|
+
* actually sends, and it has been wrong in ways only a direct test can see.
|
|
673
|
+
*/
|
|
674
|
+
export function objectTypeMetadataChanges(
|
|
664
675
|
file: AuthoredFile,
|
|
665
676
|
live: Record<string, unknown> | undefined,
|
|
666
677
|
): Record<string, unknown> {
|
|
@@ -674,10 +685,17 @@ function objectTypeMetadataChanges(
|
|
|
674
685
|
['color', 'color'],
|
|
675
686
|
['status', 'status'],
|
|
676
687
|
['visibility', 'visibility'],
|
|
688
|
+
['rowAccess', 'rowAccess'],
|
|
689
|
+
['markingsColumn', 'markingsColumn'],
|
|
677
690
|
]
|
|
678
691
|
for (const [fileField, wireField] of pairs) {
|
|
679
692
|
const value = document[fileField]
|
|
680
|
-
if (value
|
|
693
|
+
if (value === undefined) continue
|
|
694
|
+
// `markingsColumn` is the one field here the route can be told to CLEAR, so a null
|
|
695
|
+
// it states travels. A null on any other field is a body the route refuses, and
|
|
696
|
+
// sending it would fail the run rather than unset anything.
|
|
697
|
+
if (value === null && !CLEARABLE_OBJECT_FIELDS.has(fileField)) continue
|
|
698
|
+
if (value !== live?.[fileField]) patch[wireField] = value
|
|
681
699
|
}
|
|
682
700
|
const groups = document.groups
|
|
683
701
|
if (Array.isArray(groups) && JSON.stringify(groups) !== JSON.stringify(live?.groups ?? [])) {
|
package/src/commands/registry.ts
CHANGED
|
@@ -62,6 +62,12 @@ export const GLOBAL_FLAGS: FlagSpec = {
|
|
|
62
62
|
// organization key spans a list, and the service refuses to guess — leaving
|
|
63
63
|
// an unaddressed request at org scope is WIDER than what was asked for.
|
|
64
64
|
workspace: 'string',
|
|
65
|
+
// Which organization the request addresses. Global for the same reason as
|
|
66
|
+
// `--profile`: the service resolves the organization on EVERY route, and
|
|
67
|
+
// falls back to the caller's first membership when nothing names one — so an
|
|
68
|
+
// escape hatch that existed on some commands and not others would leave the
|
|
69
|
+
// rest silently guessing.
|
|
70
|
+
org: 'string',
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
/** Additionally accepted by any command that resolves a project. */
|
package/src/exit.ts
CHANGED
|
@@ -48,6 +48,9 @@ const USAGE_CODES = new Set([
|
|
|
48
48
|
// not binding at all — never by retrying, which is why it sits with USAGE
|
|
49
49
|
// rather than FAILURE.
|
|
50
50
|
'PROJECT_CONTEXT_UNWRITABLE',
|
|
51
|
+
// Organization selection. The command named no organization and the person
|
|
52
|
+
// belongs to several — fixed by naming one, never by retrying.
|
|
53
|
+
'ORG_NOT_SELECTED',
|
|
51
54
|
// Authoring kit. Both are resolved by running a named command, not by retry.
|
|
52
55
|
'KIT_VERSION_MISMATCH',
|
|
53
56
|
'GENERATED_FILE_CONFLICT',
|
package/src/flag-help.ts
CHANGED
|
@@ -51,6 +51,9 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
51
51
|
workspace:
|
|
52
52
|
"workspace to act in, for an organization key (sk-org-…) — it must be on that key's own "
|
|
53
53
|
+ 'list. A workspace key already names one and ignores this',
|
|
54
|
+
org:
|
|
55
|
+
'organization to act in, by id or slug — also FRONTERA_ORG_ID. Belonging to more than one '
|
|
56
|
+
+ 'and naming none is refused rather than guessed',
|
|
54
57
|
output: 'project-relative .ts path for generated output',
|
|
55
58
|
check: 'verify generated output is current without writing it',
|
|
56
59
|
|
|
@@ -175,6 +178,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
175
178
|
const PLACEHOLDER: Readonly<Record<string, string>> = {
|
|
176
179
|
'api-url': 'origin',
|
|
177
180
|
workspace: 'id',
|
|
181
|
+
org: 'id-or-slug',
|
|
178
182
|
profile: 'name',
|
|
179
183
|
output: 'path',
|
|
180
184
|
dataset: 'name',
|
package/src/main.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { resolveCredential } from './config'
|
|
|
4
4
|
import { findProjectRoot } from './context'
|
|
5
5
|
import { CliError, UsageError } from './errors'
|
|
6
6
|
import { EXIT } from './exit'
|
|
7
|
+
import { resolveOrganization, setSelectedOrganization } from './organization'
|
|
7
8
|
import { createOutput, type OutputMode } from './output'
|
|
8
9
|
import { readProject } from './project'
|
|
9
10
|
import { commandScope } from './scopes'
|
|
@@ -209,6 +210,7 @@ async function main(): Promise<number> {
|
|
|
209
210
|
)
|
|
210
211
|
|
|
211
212
|
const workspaceFlag = typeof flags.workspace === 'string' ? flags.workspace.trim() : undefined
|
|
213
|
+
const orgFlag = typeof flags.org === 'string' ? flags.org.trim() : undefined
|
|
212
214
|
|
|
213
215
|
/**
|
|
214
216
|
* `--workspace` is global — same reasoning as `--profile`: an escape hatch
|
|
@@ -276,6 +278,33 @@ async function main(): Promise<number> {
|
|
|
276
278
|
)
|
|
277
279
|
}
|
|
278
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Which organization this request addresses — decided once, for every
|
|
283
|
+
* client the command builds.
|
|
284
|
+
*
|
|
285
|
+
* Offline commands open no connection, so naming an organization means
|
|
286
|
+
* nothing to them; saying so beats accepting the flag and ignoring it,
|
|
287
|
+
* which is the same silent-wrong-answer shape `--workspace` refuses above.
|
|
288
|
+
*/
|
|
289
|
+
if (command.meta.offline) {
|
|
290
|
+
if (orgFlag) {
|
|
291
|
+
throw new UsageError(
|
|
292
|
+
`--org does nothing on \`${label}\``,
|
|
293
|
+
'this command touches no service, so there is no request to address.',
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
setSelectedOrganization(
|
|
298
|
+
await resolveOrganization({
|
|
299
|
+
apiUrl: credential.apiUrl,
|
|
300
|
+
token: credential.token,
|
|
301
|
+
flag: orgFlag,
|
|
302
|
+
env: process.env.FRONTERA_ORG_ID,
|
|
303
|
+
profileOrgId: 'orgId' in credential ? credential.orgId : null,
|
|
304
|
+
}),
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
279
308
|
const result = await command.run({
|
|
280
309
|
cwd,
|
|
281
310
|
apiUrl: credential.apiUrl,
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { CliError, UsageError } from './errors'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which organization this invocation addresses.
|
|
5
|
+
*
|
|
6
|
+
* The service resolves the organization from `x-org-id` and, when the header is
|
|
7
|
+
* absent, falls back to the caller's FIRST organization membership
|
|
8
|
+
* (`session-context.ts`). The CLI never sent that header, so a person who
|
|
9
|
+
* belongs to more than one organization ran `blueprint apply` against whichever
|
|
10
|
+
* one happened to be first — no flag, no warning, no error. This module is
|
|
11
|
+
* where the CLI decides, once per invocation, which organization it means.
|
|
12
|
+
*/
|
|
13
|
+
export interface OrganizationSummary {
|
|
14
|
+
id: string
|
|
15
|
+
name: string
|
|
16
|
+
slug: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The selection is process-global on purpose.
|
|
21
|
+
*
|
|
22
|
+
* It is a property of the invocation rather than of any one client: `--org` is
|
|
23
|
+
* a global flag, and the CLI constructs a fresh API client at roughly forty
|
|
24
|
+
* call sites. Threading the id through every constructor would be forty
|
|
25
|
+
* chances to forget one, and a forgotten one is exactly the defect this fixes —
|
|
26
|
+
* a request that silently lands in the wrong organization.
|
|
27
|
+
*/
|
|
28
|
+
let selected: string | null = null
|
|
29
|
+
|
|
30
|
+
export function setSelectedOrganization(orgId: string | null): void {
|
|
31
|
+
selected = orgId && orgId.trim() ? orgId.trim() : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function selectedOrganization(): string | undefined {
|
|
35
|
+
return selected ?? undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `x-org-id` for the selected organization, or nothing when none is selected.
|
|
40
|
+
*
|
|
41
|
+
* Spread into every outgoing request's headers. Empty rather than absent so a
|
|
42
|
+
* caller never has to branch.
|
|
43
|
+
*/
|
|
44
|
+
export function organizationHeaders(): Record<string, string> {
|
|
45
|
+
return selected ? { 'x-org-id': selected } : {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The organizations this credential's person belongs to, or null when the
|
|
52
|
+
* question could not be asked.
|
|
53
|
+
*
|
|
54
|
+
* Null and empty are different answers and the caller acts on them differently:
|
|
55
|
+
* an unreachable or older service must not turn every command into a refusal,
|
|
56
|
+
* while a genuinely empty list means there is nothing to choose between.
|
|
57
|
+
*
|
|
58
|
+
* better-auth's own route, because it is the one the platform web app uses and
|
|
59
|
+
* the only list a session bearer can read without a permission grant.
|
|
60
|
+
*/
|
|
61
|
+
export async function listOrganizations(
|
|
62
|
+
apiUrl: string,
|
|
63
|
+
token: string,
|
|
64
|
+
): Promise<OrganizationSummary[] | null> {
|
|
65
|
+
const res = await fetch(`${apiUrl}/api/auth/organization/list`, {
|
|
66
|
+
headers: { authorization: `Bearer ${token}` },
|
|
67
|
+
}).catch(() => null)
|
|
68
|
+
if (!res || !res.ok) return null
|
|
69
|
+
const body = (await res.json().catch(() => null)) as unknown
|
|
70
|
+
if (!Array.isArray(body)) return null
|
|
71
|
+
return body.flatMap((row) => {
|
|
72
|
+
const org = row as { id?: unknown; name?: unknown; slug?: unknown }
|
|
73
|
+
if (typeof org.id !== 'string') return []
|
|
74
|
+
return [{
|
|
75
|
+
id: org.id,
|
|
76
|
+
name: typeof org.name === 'string' ? org.name : org.id,
|
|
77
|
+
slug: typeof org.slug === 'string' ? org.slug : '',
|
|
78
|
+
}]
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function render(organizations: readonly OrganizationSummary[]): string {
|
|
83
|
+
return organizations
|
|
84
|
+
.map((org) => ` ${org.slug || org.name} — ${org.name} (${org.id})`)
|
|
85
|
+
.join('\n')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ResolveOrganizationOptions {
|
|
89
|
+
apiUrl: string
|
|
90
|
+
token: string
|
|
91
|
+
/** `--org`, which outranks everything. */
|
|
92
|
+
flag?: string | undefined
|
|
93
|
+
/** `FRONTERA_ORG_ID`. */
|
|
94
|
+
env?: string | undefined
|
|
95
|
+
/** The selected profile's recorded organization, when it has one. */
|
|
96
|
+
profileOrgId?: string | null | undefined
|
|
97
|
+
/** Injected so the precedence rules are testable without a service. */
|
|
98
|
+
list?: (apiUrl: string, token: string) => Promise<OrganizationSummary[] | null>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve the organization for this invocation, or refuse.
|
|
103
|
+
*
|
|
104
|
+
* Precedence: `--org`, then `FRONTERA_ORG_ID`, then the profile. A stated value
|
|
105
|
+
* may be an id or a slug; an id is taken at its word so the common path costs
|
|
106
|
+
* no request, and only a slug has to be looked up.
|
|
107
|
+
*
|
|
108
|
+
* With nothing stated the answer depends on the credential. An `sk-` key
|
|
109
|
+
* already names its own scope, so there is nothing to choose and nothing to
|
|
110
|
+
* ask. A person's session does not: if they belong to more than one
|
|
111
|
+
* organization the command REFUSES rather than guessing, because guessing is
|
|
112
|
+
* the defect. One organization keeps working exactly as before.
|
|
113
|
+
*/
|
|
114
|
+
export async function resolveOrganization(opts: ResolveOrganizationOptions): Promise<string | null> {
|
|
115
|
+
const list = opts.list ?? listOrganizations
|
|
116
|
+
const stated = opts.flag?.trim() || opts.env?.trim()
|
|
117
|
+
|
|
118
|
+
if (stated) {
|
|
119
|
+
if (UUID.test(stated)) return stated
|
|
120
|
+
const organizations = await list(opts.apiUrl, opts.token)
|
|
121
|
+
if (!organizations) {
|
|
122
|
+
throw new CliError(`could not read the organization list to resolve "${stated}"`, {
|
|
123
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
124
|
+
hint: `check the origin (currently ${opts.apiUrl}), or pass --org <id> instead of a slug`,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
const match = organizations.find((org) => org.slug === stated || org.name === stated)
|
|
128
|
+
if (match) return match.id
|
|
129
|
+
throw new UsageError(
|
|
130
|
+
`no organization named "${stated}"`,
|
|
131
|
+
organizations.length
|
|
132
|
+
? `you belong to:\n${render(organizations)}`
|
|
133
|
+
: 'this credential belongs to no organization',
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const fromProfile = opts.profileOrgId?.trim()
|
|
138
|
+
if (fromProfile) return fromProfile
|
|
139
|
+
|
|
140
|
+
// An `sk-ws-` or `sk-org-` key carries its own organization; the service
|
|
141
|
+
// resolves it from the key, never from a membership list, so there is
|
|
142
|
+
// nothing ambiguous to refuse and no reason to spend a request.
|
|
143
|
+
if (opts.token.startsWith('sk-')) return null
|
|
144
|
+
|
|
145
|
+
const organizations = await list(opts.apiUrl, opts.token)
|
|
146
|
+
// Unreachable, refused, or an older service: leave the request exactly as it
|
|
147
|
+
// was before this check existed. Turning a transport failure into a refusal
|
|
148
|
+
// would break every command for a fault that is not the one being fixed.
|
|
149
|
+
if (!organizations) return null
|
|
150
|
+
if (organizations.length <= 1) return organizations[0]?.id ?? null
|
|
151
|
+
|
|
152
|
+
throw new CliError(
|
|
153
|
+
`you belong to ${organizations.length} organizations and this command names none`,
|
|
154
|
+
{
|
|
155
|
+
code: 'ORG_NOT_SELECTED',
|
|
156
|
+
hint:
|
|
157
|
+
'pass --org <id-or-slug>, or set FRONTERA_ORG_ID — otherwise the request '
|
|
158
|
+
+ `lands in whichever one is first. You belong to:\n${render(organizations)}`,
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
}
|