@frontera-sdk/cli 1.50.60 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.50.60",
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.60",
43
- "@frontera-sdk/core": "1.50.60",
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.60",
49
+ "@frontera-sdk/forge-contracts": "1.50.64",
50
50
  "@types/bun": "^1.3.14",
51
51
  "typescript": "^5.9.3"
52
52
  }
@@ -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 { authorization: `Bearer ${c.kind === 'apiKey' ? c.key : c.token}` }
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: { authorization: `Bearer ${this.token}`, 'content-type': 'application/json' },
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)
@@ -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
  }
@@ -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 { authorization: `Bearer ${c.kind === 'apiKey' ? c.key : c.token}` }
270
+ return {
271
+ authorization: `Bearer ${c.kind === 'apiKey' ? c.key : c.token}`,
272
+ ...organizationHeaders(),
273
+ }
266
274
  }
267
275
 
268
276
  /**
@@ -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
  }
@@ -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(ctx: CommandContext, verb: string, example: string): Kind {
92
- const kind = ctx.positional[0] as Kind
93
- if (!KINDS.includes(kind)) {
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: ${KINDS.join(', ')}.`,
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: ${KINDS.join(', ')}` },
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: ['frontera blueprint delete metric revenuePerCustomer'],
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(ctx, 'delete', 'frontera blueprint delete metric revenuePerCustomer')
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 = kind === 'link-type'
231
- ? await client.deleteLinkType(apiName, revision)
232
- : kind === 'action'
233
- ? await client.deleteAction(apiName, revision)
234
- : await client.deleteMetric(apiName, revision)
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. `governance`, `validations` and `lifecycle`
549
- * have no command at all.
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-level metadata the file states and the draft does not already agree with. */
663
- function objectTypeMetadataChanges(
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 !== undefined && value !== live?.[fileField]) patch[wireField] = 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 ?? [])) {
@@ -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
+ }