@frontera-sdk/cli 1.50.79 → 1.50.81

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 (37) hide show
  1. package/README.md +6 -0
  2. package/package.json +4 -4
  3. package/src/api/blueprint-authoring-api.ts +52 -0
  4. package/src/api/credential-failure.ts +34 -4
  5. package/src/api/dataset-api.ts +1 -1
  6. package/src/api/governed-action-api.ts +75 -7
  7. package/src/api/media-api.ts +181 -0
  8. package/src/api/platform-api.ts +59 -0
  9. package/src/api/workflow-api.ts +1 -1
  10. package/src/auth-verify.ts +1 -1
  11. package/src/commands/action/available.ts +59 -0
  12. package/src/commands/action/cancel.ts +34 -0
  13. package/src/commands/action/decide.ts +54 -0
  14. package/src/commands/action/deploy.ts +4 -1
  15. package/src/commands/action/index-commands.ts +16 -6
  16. package/src/commands/action/prepare.ts +3 -1
  17. package/src/commands/action/request-summary.ts +13 -0
  18. package/src/commands/action/requests.ts +6 -5
  19. package/src/commands/action/submit.ts +183 -0
  20. package/src/commands/blueprint/get.ts +139 -29
  21. package/src/commands/chat-app/index-commands.ts +475 -0
  22. package/src/commands/knowledge/upload-plan.ts +14 -8
  23. package/src/commands/marking/index-commands.ts +171 -0
  24. package/src/commands/media/index-commands.ts +592 -0
  25. package/src/commands/registry.ts +28 -6
  26. package/src/commands/types.ts +6 -0
  27. package/src/commands/workflow/list.ts +1 -1
  28. package/src/errors.ts +3 -2
  29. package/src/exit.ts +126 -1
  30. package/src/flag-help.ts +24 -2
  31. package/src/forge/touches.ts +2 -0
  32. package/src/harness.ts +5 -3
  33. package/src/main.ts +23 -38
  34. package/src/scopes.ts +43 -0
  35. package/src/template.ts +6 -4
  36. package/src/templates/next-app-files.ts +8 -4
  37. package/src/vendor/sdk-sources.json +16 -15
@@ -0,0 +1,54 @@
1
+ import { GovernedActionApi } from '../../api/governed-action-api'
2
+ import { UsageError } from '../../errors'
3
+ import { flagString, type Command, type CommandContext } from '../types'
4
+ import { requestSummary } from './request-summary'
5
+
6
+ /**
7
+ * Approve or reject a request waiting on approval.
8
+ *
9
+ * Session-lane, and a person's decision in a stronger sense than submit: the
10
+ * approver needs `actionApproval: ['approve' | 'reject']` in their stored
11
+ * organization role, membership in the request's workspace, and — when the
12
+ * Action declares separation of duties — not to be the person who raised it.
13
+ * That last refusal reads "Approval separation of duties was not satisfied."
14
+ * With a threshold above one, an approval can leave the request still
15
+ * `awaiting_approval` until enough other people decide.
16
+ */
17
+
18
+ function decision(verb: 'approve' | 'reject'): Command {
19
+ return {
20
+ meta: {
21
+ noun: 'action',
22
+ verb,
23
+ authLane: 'session',
24
+ // The Requests tab of the Actions page carries the same two decisions.
25
+ sessionAlternative: 'Blueprint → Actions → Requests',
26
+ scope: 'workspace' as const,
27
+ args: [{ name: 'requestId', required: true, description: `The request to ${verb}` }],
28
+ flags: { reason: 'string' },
29
+ summary: verb === 'approve'
30
+ ? 'Approve an Action request waiting on approval'
31
+ : 'Reject an Action request waiting on approval',
32
+ examples: [`frontera action ${verb} <requestId> --reason "Checked against the ticket"`],
33
+ },
34
+
35
+ async run(ctx: CommandContext) {
36
+ const requestId = ctx.positional[0]
37
+ if (!requestId) {
38
+ throw new UsageError('missing <requestId>', 'frontera action requests --lifecycle awaiting_approval')
39
+ }
40
+ // Required by the service, and recorded with the decision.
41
+ const reason = flagString(ctx, 'reason')
42
+ if (!reason) {
43
+ throw new UsageError('missing --reason', `frontera action ${verb} ${requestId} --reason "…"`)
44
+ }
45
+
46
+ const result = await new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
47
+ .decideApproval(requestId, verb, reason)
48
+ return { data: result, text: requestSummary(result.request) }
49
+ },
50
+ }
51
+ }
52
+
53
+ export const actionApprove = decision('approve')
54
+ export const actionReject = decision('reject')
@@ -23,7 +23,10 @@ export const actionDeploy: Command = {
23
23
  meta: {
24
24
  noun: 'action',
25
25
  verb: 'deploy',
26
- authLane: 'session',
26
+ // The three writes and the catalog reads are on programmatic routers
27
+ // (`governed-action-deployment-router.ts`, `blueprint-router.ts`), so an
28
+ // organization key carrying `actionMutationPlan`/`actionBinding` runs this.
29
+ authLane: 'api-key',
27
30
  args: [{ name: 'action', required: true, description: 'The published Action’s API name' }],
28
31
  flags: { 'dry-run': 'boolean', 'plan-id': 'string', 'binding-id': 'string' },
29
32
  summary: 'Build and validate the write path for a published Action',
@@ -1,9 +1,13 @@
1
+ import { actionAvailable } from './available'
2
+ import { actionCancel } from './cancel'
3
+ import { actionApprove, actionReject } from './decide'
1
4
  import { actionDeploy } from './deploy'
2
5
  import { actionGrant } from './grant'
3
6
  import { actionList } from './list'
4
7
  import { actionPrepare } from './prepare'
5
8
  import { actionRequests } from './requests'
6
9
  import { actionReview } from './review'
10
+ import { actionSubmit } from './submit'
7
11
  import type { Command } from '../types'
8
12
 
9
13
  /**
@@ -16,13 +20,14 @@ import type { Command } from '../types'
16
20
  * review and a state-machine activation. Five admin calls with hand-minted
17
21
  * UUIDs, or nothing.
18
22
  *
19
- * Invoking is deliberately absent. Both CLI credential kinds present a
20
- * non-member principal — `wskey:…` and `orgkey:…` — and the invoke check joins
21
- * organization membership, so a `submit` verb here would refuse every call it
22
- * ever made. It belongs to a credential that represents a person.
23
+ * Invoking — `available`, `submit`, `approve`, `reject`, `cancel` — is
24
+ * session-lane. Both key kinds present a non-member principal — `wskey:…` and
25
+ * `orgkey:…` — and the invoke check joins organization membership, so those
26
+ * verbs need the credential of a person: a session token in `FRONTERA_TOKEN`,
27
+ * which `frontera login` cannot store.
23
28
  *
24
- * READING those invocations is not the same boundary, and `requests` is here:
25
- * the list gates on `actionRequest: ['read']` rather than on membership. The
29
+ * READING those invocations is not the same boundary, and `requests` runs on a
30
+ * key: the list gates on `actionRequest: ['read']` rather than on membership. The
26
31
  * distinction matters because an armed path that fails every request looks
27
32
  * exactly like one nothing has tried, and until now nothing in the CLI could
28
33
  * tell the two apart.
@@ -30,6 +35,11 @@ import type { Command } from '../types'
30
35
  export const actionCommands: readonly Command[] = [
31
36
  actionList,
32
37
  actionRequests,
38
+ actionAvailable,
39
+ actionSubmit,
40
+ actionApprove,
41
+ actionReject,
42
+ actionCancel,
33
43
  actionPrepare,
34
44
  actionDeploy,
35
45
  actionReview,
@@ -17,7 +17,9 @@ export const actionPrepare: Command = {
17
17
  meta: {
18
18
  noun: 'action',
19
19
  verb: 'prepare',
20
- authLane: 'session',
20
+ // `POST /deployments` moved to the programmatic deployment router, so an
21
+ // organization key carrying `actionBinding` runs this.
22
+ authLane: 'api-key',
21
23
  args: [{ name: 'action', required: true, description: 'The drafted Action’s API name' }],
22
24
  flags: { reason: 'string' },
23
25
  summary: 'Record that a drafted Action ships disabled, so it can be published',
@@ -0,0 +1,13 @@
1
+ import type { ActionRequestSummary } from '../../api/governed-action-api'
2
+
3
+ /**
4
+ * One line for a request a write verb just changed.
5
+ *
6
+ * `lifecycle` is what the caller acts on next — `awaiting_approval` means
7
+ * someone else has to `action approve`, `ready` means dispatch is queued.
8
+ */
9
+ export function requestSummary(request: ActionRequestSummary): string {
10
+ return `Request ${request.id ?? '?'} is ${request.lifecycle ?? 'unknown'}`
11
+ + (request.effectCertainty ? ` (effect ${request.effectCertainty})` : '')
12
+ + '.'
13
+ }
@@ -11,9 +11,9 @@ import { flagString, type Command } from '../types'
11
11
  * armed path that every caller's request fails against looks identical to one
12
12
  * nothing has tried yet. This is the only verb that can tell them apart.
13
13
  *
14
- * Read-only, and that is a boundary rather than an omission: invoking joins
15
- * organization membership, which no CLI credential satisfies. Listing gates on
16
- * `actionRequest: ['read']`, which a key can hold.
14
+ * Read-only, so it runs on a key: listing gates on `actionRequest: ['read']`,
15
+ * which a key can hold. Invoking joins organization membership and needs a
16
+ * session — see `action submit`.
17
17
  */
18
18
  export const actionRequests: Command = {
19
19
  meta: {
@@ -83,8 +83,9 @@ export const actionRequests: Command = {
83
83
  text: filtered
84
84
  ? `No requests with lifecycle "${filtered}".`
85
85
  : 'No Action requests recorded.\n'
86
- + ' Nothing has invoked a deployed Action yet — invoking needs a credential\n'
87
- + ' that represents a person, so it happens from an agent or the Console.',
86
+ + ' Nothing has invoked a deployed Action yet. Raise one with\n'
87
+ + ' `frontera action submit`, which acts as a person: set FRONTERA_TOKEN\n'
88
+ + ' to your session token and FRONTERA_API_URL.',
88
89
  }
89
90
  }
90
91
 
@@ -0,0 +1,183 @@
1
+ import { GovernedActionApi, type ActionDiscovery } from '../../api/governed-action-api'
2
+ import { CliError, UsageError } from '../../errors'
3
+ import { toEnvelope } from '../../exit'
4
+ import { parseInputFlag } from '../automation/run'
5
+ import { flagString, type Command } from '../types'
6
+ import { requestSummary } from './request-summary'
7
+
8
+ /**
9
+ * Raise one request against a deployed Action — the step `action review`
10
+ * arms and `action requests` reads back.
11
+ *
12
+ * Session-lane: the invoke check joins organization and workspace membership,
13
+ * which no `wskey:` or `orgkey:` principal satisfies.
14
+ *
15
+ * The CLI assembles the envelope so the caller never writes it: parameters
16
+ * under `input`, the subject under `subjectRef` with the object type the
17
+ * Action itself declares, and the version under `expectedSubjectVersion`.
18
+ * The service refuses a wrong envelope without naming the field it disliked,
19
+ * so everything the discovery schema can check is checked here first.
20
+ */
21
+
22
+ /**
23
+ * `priority=2` is the number 2 and `title=Broken` the string — unless the
24
+ * Action declares the input a string, when the text is taken as written, so
25
+ * `ticketId=123` stays "123".
26
+ */
27
+ function pair(raw: string, stringInputs: ReadonlySet<string>): [string, unknown] {
28
+ const eq = raw.indexOf('=')
29
+ if (eq < 1) {
30
+ throw new UsageError(`"${raw}" is not a key=value parameter`, 'frontera action submit <apiName> title=Broken priority=2')
31
+ }
32
+ const key = raw.slice(0, eq)
33
+ const text = raw.slice(eq + 1)
34
+ if (stringInputs.has(key)) return [key, text]
35
+ try {
36
+ return [key, JSON.parse(text)]
37
+ } catch {
38
+ return [key, text]
39
+ }
40
+ }
41
+
42
+ export const actionSubmit: Command = {
43
+ meta: {
44
+ noun: 'action',
45
+ verb: 'submit',
46
+ authLane: 'session',
47
+ scope: 'workspace' as const,
48
+ args: [
49
+ { name: 'apiName', required: true, description: 'The Action to submit, as `action available` lists it' },
50
+ { name: 'key=value...', required: false, description: 'Parameters; text for string inputs, otherwise read as JSON when it parses' },
51
+ ],
52
+ flags: {
53
+ input: 'string',
54
+ subject: 'string',
55
+ 'subject-version': 'string',
56
+ reason: 'string',
57
+ 'idempotency-key': 'string',
58
+ },
59
+ summary: 'Submit a request to a deployed Action',
60
+ examples: [
61
+ 'frontera action submit createTicket title=Broken priority=2',
62
+ 'frontera action submit escalateTicket --subject TKT-1 --subject-version 0',
63
+ 'frontera action submit createTicket --input @params.json',
64
+ 'frontera action submit createTicket --idempotency-key retry-2026-09-26-01',
65
+ ],
66
+ },
67
+
68
+ async run(ctx) {
69
+ const [apiName, ...pairs] = ctx.positional
70
+ if (!apiName) {
71
+ throw new UsageError('missing <apiName>', 'frontera action available — to list the Actions you can submit')
72
+ }
73
+ const subject = flagString(ctx, 'subject')
74
+ const version = flagString(ctx, 'subject-version')
75
+ if ((subject === undefined) !== (version === undefined)) {
76
+ throw new UsageError(
77
+ '--subject and --subject-version go together',
78
+ `pass both: frontera action submit ${apiName} --subject <objectId> --subject-version <version>`,
79
+ )
80
+ }
81
+
82
+ const client = new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
83
+ // One read before the write: the schema is what lets a wrong envelope be
84
+ // refused here, by name, instead of by the service without one.
85
+ const action = (await client.discover()).find((a) => a.apiName === apiName)
86
+ if (!action) {
87
+ throw new CliError(`You cannot submit "${apiName}" in this workspace.`, {
88
+ code: 'NOT_FOUND',
89
+ hint: 'frontera action available — to list the Actions you can submit',
90
+ })
91
+ }
92
+
93
+ const input = {
94
+ ...(await parseInputFlag(ctx)),
95
+ ...Object.fromEntries(pairs.map((raw) => pair(raw, stringInputs(action)))),
96
+ }
97
+ const missing = (action.inputSchema?.properties?.input?.required ?? []).filter((name) => !(name in input))
98
+ if (missing.length > 0) {
99
+ throw new UsageError(
100
+ `"${apiName}" needs ${missing.join(', ')}`,
101
+ `frontera action submit ${apiName} ${missing.map((name) => `${name}=…`).join(' ')}`,
102
+ )
103
+ }
104
+
105
+ const invocation: Record<string, unknown> = { input }
106
+ const existing = action.subject?.mode === 'existing' && action.subject.objectTypeId
107
+ if (existing && subject === undefined) {
108
+ throw new UsageError(
109
+ `"${apiName}" acts on an existing object`,
110
+ `name it: frontera action submit ${apiName} --subject <objectId> --subject-version <version>`,
111
+ )
112
+ }
113
+ if (!existing && subject !== undefined) {
114
+ throw new UsageError(
115
+ `"${apiName}" does not act on an existing object`,
116
+ `drop --subject and --subject-version: frontera action submit ${apiName} key=value…`,
117
+ )
118
+ }
119
+ if (existing) {
120
+ // The object type is the Action's, not the caller's to choose.
121
+ invocation.subjectRef = { objectTypeId: action.subject!.objectTypeId, objectId: subject }
122
+ invocation.expectedSubjectVersion = version
123
+ }
124
+ const reason = flagString(ctx, 'reason')
125
+ if (reason !== undefined) invocation.reason = reason
126
+ else if (action.inputSchema?.required?.includes('reason')) {
127
+ throw new UsageError(`"${apiName}" requires a reason`, `frontera action submit ${apiName} … --reason "…"`)
128
+ }
129
+
130
+ const idempotencyKey = flagString(ctx, 'idempotency-key') ?? `cli-${crypto.randomUUID()}`
131
+ if (!/^[!-~]{16,255}$/.test(idempotencyKey)) {
132
+ throw new UsageError(
133
+ '--idempotency-key must be 16–255 printable characters with no spaces',
134
+ 'omit it to have one generated',
135
+ )
136
+ }
137
+
138
+ // Announced BEFORE the request: the retry it enables is for exactly the
139
+ // case where no response ever arrives — a timeout, a dropped connection,
140
+ // Ctrl-C — and a key printed only on success is lost in all three.
141
+ ctx.output.note(`idempotency key ${idempotencyKey}`)
142
+ const retry = `retry with --idempotency-key ${idempotencyKey} to get the same request back, never a second one`
143
+ let request
144
+ try {
145
+ request = await client.submit(apiName, invocation, idempotencyKey)
146
+ } catch (err) {
147
+ // Only where the outcome is unknown. A refusal the service answered
148
+ // (400, 403, 404, 409) is replayed identically under the same key, so
149
+ // naming the retry there would invite a loop; it is rethrown as is.
150
+ if (!transient(err)) throw err
151
+ const envelope = toEnvelope(err)
152
+ throw new CliError(envelope.message, {
153
+ code: err instanceof CliError ? envelope.code : 'FAILURE',
154
+ hint: envelope.hint ? `${envelope.hint}; ${retry}` : retry,
155
+ cause: err,
156
+ })
157
+ }
158
+ return {
159
+ data: { request, idempotencyKey },
160
+ text: `${requestSummary(request)}\n`
161
+ + ` idempotency key ${idempotencyKey} — reuse it with --idempotency-key to retry safely\n`
162
+ + ` frontera action requests ${request.id ?? '<requestId>'}`,
163
+ }
164
+ },
165
+ }
166
+
167
+ /**
168
+ * No answer, or one that says "not now": a network error or timeout (anything
169
+ * that is not a CliError never reached a response), a 5xx, or a 429.
170
+ */
171
+ function transient(err: unknown): boolean {
172
+ if (!(err instanceof CliError)) return true
173
+ const status = (err.cause as { status?: unknown } | undefined)?.status
174
+ return typeof status === 'number' && (status >= 500 || status === 429)
175
+ }
176
+
177
+ /** Inputs the Action declares as strings, whose text must not be read as JSON. */
178
+ function stringInputs(action: ActionDiscovery): ReadonlySet<string> {
179
+ const properties = action.inputSchema?.properties?.input?.properties ?? {}
180
+ return new Set(Object.entries(properties)
181
+ .filter(([, schema]) => (schema as { type?: unknown } | null)?.type === 'string')
182
+ .map(([name]) => name))
183
+ }
@@ -29,6 +29,57 @@ interface ObjectTypeRow {
29
29
  description?: string | null
30
30
  }
31
31
 
32
+ interface Relation {
33
+ apiName?: string
34
+ direction: 'outbound' | 'inbound'
35
+ otherObjectType: string
36
+ cardinality?: string
37
+ }
38
+
39
+ /**
40
+ * One catalog read: the rows, or why they are missing.
41
+ *
42
+ * `unavailable` is not an error path — the command still prints. It is the
43
+ * difference between "this type has no relations" and "nobody could tell",
44
+ * which a bare `[]` erased.
45
+ */
46
+ type CatalogRead<T> =
47
+ | { ok: true; rows: T[] }
48
+ | { ok: false; unavailable: string }
49
+
50
+ /**
51
+ * Describe a failed read in the terms the caller can act on.
52
+ *
53
+ * The message is carried as well as the status and code, because the two say
54
+ * different things and only one of them is ever actionable: `403 FORBIDDEN`
55
+ * alone hides "Blueprint Steward assignment required", which is the sentence
56
+ * that tells the operator what to go and do. `detail` is appended where the
57
+ * service sent one — `org-key-route-ungated` is the case where no grant change
58
+ * will help, and saying so stops the reader hunting for a permission to add.
59
+ */
60
+ function describeFailure(cause: unknown): string {
61
+ const error = cause as
62
+ | { code?: string; status?: number; message?: string; details?: { detail?: string } }
63
+ | null
64
+ const status = [error?.status ? String(error.status) : null, error?.code ?? null]
65
+ .filter(Boolean)
66
+ .join(' ')
67
+ const parts = [
68
+ status || null,
69
+ error?.message ?? null,
70
+ error?.details?.detail ?? null,
71
+ ].filter(Boolean)
72
+ return parts.length > 0 ? parts.join(' — ') : String(cause)
73
+ }
74
+
75
+ async function readCatalog<T>(fetch: () => Promise<T[]>): Promise<CatalogRead<T>> {
76
+ try {
77
+ return { ok: true, rows: await fetch() }
78
+ } catch (cause) {
79
+ return { ok: false, unavailable: describeFailure(cause) }
80
+ }
81
+ }
82
+
32
83
  /**
33
84
  * Resolve what the caller typed to a real api name.
34
85
  *
@@ -99,29 +150,70 @@ export const blueprintGet: Command = {
99
150
  // Both key on object-type UUIDs, not api names, so the catalog is fetched
100
151
  // to resolve them. Matching on names silently produced "Relations (0)" for
101
152
  // a type with eight of them.
153
+ //
154
+ // Degrade rather than crash — one unreadable catalog route should not stop
155
+ // the command printing the properties it already has. But a failure is
156
+ // reported as a failure: an empty list and an unreadable one are different
157
+ // facts, and rendering both as "(none)" told the caller a type had no
158
+ // relations when the truth was that nothing could check.
102
159
  const [links, metrics, types] = await Promise.all([
103
- api.blueprintLinkTypes().catch(() => [] as unknown[]),
104
- api.blueprintMetrics().catch(() => [] as unknown[]),
105
- api.blueprintObjectTypes().catch(() => [] as unknown[]),
160
+ readCatalog(() => api.blueprintLinkTypes()),
161
+ readCatalog(() => api.blueprintMetrics()),
162
+ readCatalog(() => api.blueprintObjectTypes()),
106
163
  ])
107
164
 
165
+ // Relations and metrics key on object-type UUIDs, so both depend on the
166
+ // object-type list to resolve an id to a name. If THAT read failed there is
167
+ // no id to match on, and every link would silently drop — so its failure
168
+ // makes both sections unavailable, not empty.
169
+ const typeRows = types.ok
170
+ ? (types.rows as Array<{ id?: string; apiName?: string }>)
171
+ : []
108
172
  const nameById = new Map<string, string>()
109
- for (const t of types as Array<{ id?: string; apiName?: string }>) {
110
- if (t.id && t.apiName) nameById.set(t.id, t.apiName)
173
+ for (const row of typeRows) {
174
+ if (row.id && row.apiName) nameById.set(row.id, row.apiName)
111
175
  }
112
- const selfId = (types as Array<{ id?: string; apiName?: string }>)
113
- .find((candidate) => candidate.apiName === apiName)?.id ?? ''
114
-
115
- const related = (links as Link[]).flatMap((link) => {
116
- if (link.fromObjectTypeId !== selfId && link.toObjectTypeId !== selfId) return []
117
- const outbound = link.fromObjectTypeId === selfId
118
- const other = nameById.get((outbound ? link.toObjectTypeId : link.fromObjectTypeId) ?? '')
119
- if (!other || !grantedNames.has(other)) return []
120
- return [{ apiName: link.apiName, direction: outbound ? 'outbound' as const : 'inbound' as const, otherObjectType: other, cardinality: link.cardinality }]
121
- })
122
- const own = (metrics as Metric[])
123
- .filter((metric) => metric.objectTypeId === selfId)
124
- .map((metric) => ({ apiName: metric.apiName, displayName: metric.displayName }))
176
+ // `apiName` was resolved against `/schema` (the workspace-granted slice) while
177
+ // the id comes from `/object-types` (the organization catalog) — two sources,
178
+ // so a type can be present in the first and missing from the second even when
179
+ // BOTH reads succeeded. Without this, `selfId` fell back to `''`, matched no
180
+ // link, and printed the same confident `Relations (0)` this command exists to
181
+ // stop. A read that worked but cannot answer is still "cannot answer".
182
+ const selfRow = typeRows.find((candidate) => candidate.apiName === apiName)
183
+ const selfId = selfRow?.id ?? ''
184
+ const unresolvedSelf = types.ok && !selfId
185
+ ? `could not resolve ${apiName} in the object-type catalog — it is in this workspace's schema but absent from the catalog list`
186
+ : null
187
+
188
+ const relations: CatalogRead<Relation> = !types.ok
189
+ ? { ok: false, unavailable: `could not read object types: ${types.unavailable}` }
190
+ : unresolvedSelf
191
+ ? { ok: false, unavailable: unresolvedSelf }
192
+ : !links.ok
193
+ ? { ok: false, unavailable: `could not read link types: ${links.unavailable}` }
194
+ : {
195
+ ok: true,
196
+ rows: (links.rows as Link[]).flatMap((link) => {
197
+ if (link.fromObjectTypeId !== selfId && link.toObjectTypeId !== selfId) return []
198
+ const outbound = link.fromObjectTypeId === selfId
199
+ const other = nameById.get((outbound ? link.toObjectTypeId : link.fromObjectTypeId) ?? '')
200
+ if (!other || !grantedNames.has(other)) return []
201
+ return [{ apiName: link.apiName, direction: outbound ? 'outbound' as const : 'inbound' as const, otherObjectType: other, cardinality: link.cardinality }]
202
+ }),
203
+ }
204
+
205
+ const own: CatalogRead<{ apiName?: string; displayName?: string }> = !types.ok
206
+ ? { ok: false, unavailable: `could not read object types: ${types.unavailable}` }
207
+ : unresolvedSelf
208
+ ? { ok: false, unavailable: unresolvedSelf }
209
+ : !metrics.ok
210
+ ? { ok: false, unavailable: `could not read metrics: ${metrics.unavailable}` }
211
+ : {
212
+ ok: true,
213
+ rows: (metrics.rows as Metric[])
214
+ .filter((metric) => metric.objectTypeId === selfId)
215
+ .map((metric) => ({ apiName: metric.apiName, displayName: metric.displayName })),
216
+ }
125
217
 
126
218
  const props = type.properties as Property[]
127
219
  const heading = [apiName, type.displayName].filter(Boolean)
@@ -137,21 +229,39 @@ export const blueprintGet: Command = {
137
229
  ` ${(p.apiName ?? '?').padEnd(26)}${(p.dataType ?? '').padEnd(12)}${p.propertyType ?? ''}`,
138
230
  )),
139
231
  '',
140
- `Relations (${related.length})`,
141
- ...(related.length === 0
142
- ? [' (none)']
143
- : related.map((relation) => {
144
- return ` ${(relation.apiName ?? '?').padEnd(26)}${relation.direction === 'outbound' ? '→' : '←'} ${relation.otherObjectType.padEnd(18)}${relation.cardinality ?? ''}`
145
- })),
232
+ ...(relations.ok
233
+ ? [
234
+ `Relations (${relations.rows.length})`,
235
+ ...(relations.rows.length === 0
236
+ ? [' (none)']
237
+ : relations.rows.map((relation) => {
238
+ return ` ${(relation.apiName ?? '?').padEnd(26)}${relation.direction === 'outbound' ? '→' : '←'} ${relation.otherObjectType.padEnd(18)}${relation.cardinality ?? ''}`
239
+ })),
240
+ ]
241
+ : ['Relations (unavailable)', ` ${relations.unavailable}`]),
146
242
  '',
147
- `Metrics (${own.length})`,
148
- ...(own.length === 0
149
- ? [' (none)']
150
- : own.map((m) => ` ${(m.apiName ?? '?').padEnd(26)}${m.displayName ?? ''}`)),
243
+ ...(own.ok
244
+ ? [
245
+ `Metrics (${own.rows.length})`,
246
+ ...(own.rows.length === 0
247
+ ? [' (none)']
248
+ : own.rows.map((m) => ` ${(m.apiName ?? '?').padEnd(26)}${m.displayName ?? ''}`)),
249
+ ]
250
+ : ['Metrics (unavailable)', ` ${own.unavailable}`]),
151
251
  ]
152
252
 
153
253
  return {
154
- data: { objectType: type, properties: props, relations: related, metrics: own },
254
+ data: {
255
+ objectType: type,
256
+ properties: props,
257
+ // `null` rather than `[]` for an unreadable section, and a sibling
258
+ // field saying why. A consumer that treats absence as emptiness now has
259
+ // to opt into it; the old `[]` gave it no choice.
260
+ relations: relations.ok ? relations.rows : null,
261
+ ...(relations.ok ? {} : { relationsUnavailable: relations.unavailable }),
262
+ metrics: own.ok ? own.rows : null,
263
+ ...(own.ok ? {} : { metricsUnavailable: own.unavailable }),
264
+ },
155
265
  text: lines.join('\n'),
156
266
  }
157
267
  },