@frontera-sdk/cli 1.52.38 → 1.52.40

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 (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -124,7 +124,7 @@ export const blueprintGet: Command = {
124
124
  },
125
125
  ],
126
126
  flags: {},
127
- summary: 'Show one object type: its properties, relations and metrics',
127
+ summary: 'Show one object type: its id, properties, relations and metrics',
128
128
  examples: ['frontera blueprint get Shipment', 'frontera blueprint get shipment --json'],
129
129
  },
130
130
 
@@ -221,6 +221,8 @@ export const blueprintGet: Command = {
221
221
  const heading = [apiName, type.displayName].filter(Boolean)
222
222
  const lines = [
223
223
  heading[1] && heading[1] !== heading[0] ? `${heading[0]} — ${heading[1]}` : String(heading[0]),
224
+ // The runtime's id: what a hard-coded id in an App, a workflow or a test has to equal.
225
+ ` id ${selfId || (types.ok ? '(not in the object-type catalog)' : `(unavailable: ${types.unavailable})`)}`,
224
226
  ...(type.description ? ['', type.description] : []),
225
227
  '',
226
228
  `Properties (${props.length})`,
@@ -254,7 +256,9 @@ export const blueprintGet: Command = {
254
256
 
255
257
  return {
256
258
  data: {
257
- objectType: type,
259
+ // `id` from the organization catalog; null when it could not be read or does not list the type.
260
+ objectType: { ...type, id: selfId || null },
261
+ id: selfId || null,
258
262
  properties: props,
259
263
  // `null` rather than `[]` for an unreadable section, and a sibling
260
264
  // field saying why. A consumer that treats absence as emptiness now has
@@ -25,9 +25,11 @@ async function resolveSubject(
25
25
  const byId = candidates.find((candidate) => candidate.id === nameOrId)
26
26
  if (byId?.id) return { id: byId.id, name: byId.name ?? byId.id }
27
27
 
28
- const matches = candidates.filter((candidate) =>
29
- candidate.name === nameOrId
30
- || (subject === 'workspace' && (candidate as { slug?: string }).slug === nameOrId))
28
+ // An Agent by its slug too (`agentId`, `dispatcher`), as every other command names it; a
29
+ // workspace by its slug. A slug names one, so it wins over a display name that matches.
30
+ const slugOf = (candidate: { slug?: string; agentId?: string }) => (subject === 'workspace' ? candidate.slug : candidate.agentId ?? candidate.slug)
31
+ const bySlug = candidates.filter((candidate) => slugOf(candidate as { slug?: string; agentId?: string }) === nameOrId)
32
+ const matches = bySlug.length ? bySlug : candidates.filter((candidate) => candidate.name === nameOrId)
31
33
  const only = matches.length === 1 ? matches[0] : undefined
32
34
  if (only?.id) return { id: only.id, name: only.name ?? only.id }
33
35
  if (matches.length > 1) {
@@ -42,11 +44,47 @@ async function resolveSubject(
42
44
  throw new CliError(`No ${subject} named "${nameOrId}" in this organization.`, {
43
45
  code: 'NOT_FOUND',
44
46
  hint: candidates.length
45
- ? `Known ${subject}s: ${candidates.map((candidate) => candidate.name).filter(Boolean).join(', ')}`
47
+ ? `Known ${subject}s: ${candidates.map((candidate) => { const slug = slugOf(candidate as { slug?: string; agentId?: string }); return slug && slug !== candidate.name ? `${slug} (${candidate.name})` : candidate.name }).filter(Boolean).join(', ')}`
46
48
  : `No ${subject}s are visible to this credential.`,
47
49
  })
48
50
  }
49
51
 
52
+ /**
53
+ * `--permissions`, as the grant's whole list: `read` always, `read_sensitive`
54
+ * the only other one. A workspace's only: an Agent's clearance is its
55
+ * bindings.json, and a revoke takes the whole grant.
56
+ */
57
+ export function grantPermissions(raw: string | boolean | undefined, subject: string, revoke: boolean): string[] | undefined {
58
+ if (raw === undefined) return undefined
59
+ if (typeof raw !== 'string' || !raw.trim()) throw new CliError('--permissions takes a list: read,read_sensitive', { code: 'USAGE', hint: 'frontera blueprint grant workspace <name> <apiName…> --permissions read,read_sensitive' })
60
+ if (subject !== 'workspace') throw new CliError('--permissions is for a workspace grant', { code: 'USAGE', hint: 'an Agent\'s clearance over sensitive fields is `read_sensitive` in its bindings.json' })
61
+ if (revoke) throw new CliError('--revoke takes the whole grant; --permissions sets what a grant gives', { code: 'USAGE', hint: 'to keep only read, grant again with --permissions read' })
62
+ const given = raw.split(',').map((p) => p.trim()).filter(Boolean)
63
+ const unknown = given.filter((p) => p !== 'read' && p !== 'read_sensitive')
64
+ if (unknown.length) throw new CliError(`${unknown.join(', ')} ${unknown.length === 1 ? 'is not a permission' : 'are not permissions'} a workspace grant takes`, { code: 'USAGE', hint: 'the permissions are read and read_sensitive' })
65
+ return ['read', ...(given.includes('read_sensitive') ? ['read_sensitive'] : [])]
66
+ }
67
+
68
+ /**
69
+ * What a grant without `--permissions` sends, per object type: each grant
70
+ * replaces the whole permission list, so `read` plus whatever the grant
71
+ * already holds (`act`, `read_sensitive`), never `read` alone over a grant
72
+ * that had more. `kept` names what was kept, by object type.
73
+ */
74
+ export function keptPermissions(apiNames: string[], current: Array<{ objectTypeApiName?: string; permissions?: string[] }>): { send: Map<string, string[] | undefined>; kept: Record<string, string[]> } {
75
+ const send = new Map<string, string[] | undefined>()
76
+ const kept: Record<string, string[]> = {}
77
+ for (const apiName of apiNames) {
78
+ const held = current.find((g) => g.objectTypeApiName === apiName)?.permissions ?? []
79
+ const extras = [...new Set(held.filter((p) => p !== 'read'))].sort()
80
+ if (extras.length) {
81
+ send.set(apiName, ['read', ...extras])
82
+ kept[apiName] = extras
83
+ } else send.set(apiName, undefined)
84
+ }
85
+ return { send, kept }
86
+ }
87
+
50
88
  export const blueprintGrant: Command = {
51
89
  meta: {
52
90
  noun: 'blueprint',
@@ -56,10 +94,15 @@ export const blueprintGrant: Command = {
56
94
  { name: 'name', required: false, description: 'Workspace or agent, by name or id' },
57
95
  { name: 'apiName...', required: false, description: 'One or more object type API names' },
58
96
  ],
59
- flags: { revoke: 'boolean' },
97
+ flags: { revoke: 'boolean', permissions: 'string' },
60
98
  summary: 'Grant or revoke an object type for a workspace or an agent',
99
+ details: [
100
+ '`--permissions read,read_sensitive` gives a workspace clearance over the type\'s confidential and restricted fields, for the workflows, Functions and Apps that read through it (`read` is always included; `--permissions` sets the grant\'s whole list). An Agent\'s clearance is its bindings.json. In a local project, blueprint/workspace-grants.yaml declares it and `frontera local dev` applies it.',
101
+ 'Without `--permissions`, a grant keeps what it already holds: read, plus the grant\'s `read_sensitive` or an Agent\'s `act` (submit Actions), and the output says what it kept. To take one away: `--permissions read` for a workspace; for an Agent, revoke the grant and grant it again.',
102
+ ],
61
103
  examples: [
62
104
  'frontera blueprint grant workspace Ops Customer Policy',
105
+ 'frontera blueprint grant workspace Ops Customer --permissions read_sensitive',
63
106
  'frontera blueprint grant agent "Claims bot" Customer --revoke',
64
107
  'frontera blueprint grant list',
65
108
  ],
@@ -145,15 +188,20 @@ export const blueprintGrant: Command = {
145
188
  })
146
189
  }
147
190
 
148
- const resolved = await resolveSubject(client, subject, name)
149
191
  const revoke = ctx.flags.revoke === true
192
+ const permissions = grantPermissions(ctx.flags.permissions, subject, revoke)
193
+ const resolved = await resolveSubject(client, subject, name)
194
+ // Each grant replaces the whole list: without --permissions, what each grant holds is read first and kept.
195
+ const { send, kept } = revoke || permissions
196
+ ? { send: new Map<string, string[] | undefined>(apiNames.map((n) => [n, permissions])), kept: {} as Record<string, string[]> }
197
+ : keptPermissions(apiNames, subject === 'workspace' ? await client.workspaceGrants(resolved.id) : await client.agentGrants(resolved.id))
150
198
  for (const apiName of apiNames) {
151
199
  try {
152
200
  if (subject === 'workspace') {
153
201
  if (revoke) await client.revokeWorkspace(resolved.id, apiName)
154
- else await client.grantWorkspace(resolved.id, apiName)
202
+ else await client.grantWorkspace(resolved.id, apiName, send.get(apiName))
155
203
  } else if (revoke) await client.revokeAgent(resolved.id, apiName)
156
- else await client.grantAgent(resolved.id, apiName)
204
+ else await client.grantAgent(resolved.id, apiName, send.get(apiName))
157
205
  } catch (err) {
158
206
  /**
159
207
  * Grants address the PUBLISHED catalog, and the draft is a different
@@ -166,6 +214,10 @@ export const blueprintGrant: Command = {
166
214
  * which step — a comprehension audit read the bare 404 as the whole
167
215
  * pipeline being broken.
168
216
  */
217
+ // Grants are a Blueprint Steward's to give, `read_sensitive` above all: said as that.
218
+ if (err instanceof CliError && err.code === 'FORBIDDEN' && permissions?.includes('read_sensitive')) {
219
+ throw new CliError(`${err.message} Granting ${apiName} with read_sensitive needs a Blueprint Steward.`, { code: err.code, hint: 'run it with the credential of a Blueprint Steward of this organization', cause: err })
220
+ }
169
221
  const notFound = err instanceof CliError && err.code === 'NOT_FOUND'
170
222
  if (!notFound) throw err
171
223
  const onDraft = await client.getObjectType(apiName).then((t) => Boolean(t?.id)).catch(() => false)
@@ -182,10 +234,12 @@ export const blueprintGrant: Command = {
182
234
  }
183
235
  }
184
236
 
237
+ const keptText = Object.entries(kept).map(([apiName, extras]) => `${extras.join(' and ')} on ${apiName}`).join('; ')
185
238
  return {
186
- data: { subject, id: resolved.id, name: resolved.name, apiNames, revoked: revoke },
239
+ data: { subject, id: resolved.id, name: resolved.name, apiNames, revoked: revoke, ...(permissions ? { permissions } : {}), ...(keptText ? { kept } : {}) },
187
240
  text: `${revoke ? 'Revoked' : 'Granted'} ${apiNames.join(', ')} `
188
- + `${revoke ? 'from' : 'to'} ${subject} "${resolved.name}".`,
241
+ + `${revoke ? 'from' : 'to'} ${subject} "${resolved.name}"${permissions ? ` (${permissions.join(', ')})` : ''}.`
242
+ + (keptText ? ` Kept what the grant${Object.keys(kept).length === 1 ? '' : 's'} already held: ${keptText} (\`--permissions\` sets the whole list).` : ''),
189
243
  }
190
244
  },
191
245
  }
@@ -0,0 +1,108 @@
1
+ import { PlatformApi } from '../../api/platform-api'
2
+ import { METRIC_GRAINS, metricQueryBody, metricRows, resolveRangeWindow, timeseriesOf, type RangeWindow } from '../../blueprint/metric-query'
3
+ import { UsageError } from '../../errors'
4
+ import { table } from '../../table'
5
+ import { flagBool, flagString, type Command } from '../types'
6
+
7
+ /**
8
+ * A metric's numbers, through the same route an App's `useMetric` and an
9
+ * Agent's `blueprint_query_metric` read: so a metric just authored can be
10
+ * checked from the CLI, as `blueprint query` checks an object type's rows.
11
+ */
12
+
13
+ const list = (raw: string | undefined) => (raw === undefined ? undefined : raw.split(',').map((s) => s.trim()).filter(Boolean))
14
+
15
+ function cell(value: unknown): string {
16
+ if (value === null || value === undefined) return ''
17
+ if (typeof value === 'number') return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)))
18
+ if (typeof value === 'object') return JSON.stringify(value)
19
+ return String(value)
20
+ }
21
+
22
+ export const blueprintMetric: Command = {
23
+ meta: {
24
+ noun: 'blueprint',
25
+ verb: 'metric',
26
+ // Reads the slice granted to ONE workspace, as `blueprint query` does.
27
+ scope: 'workspace' as const,
28
+ args: [
29
+ { name: 'query', required: true, description: 'query (the one action)' },
30
+ { name: 'apiName', required: true, description: 'the metric, from `frontera blueprint list` or blueprint/metrics/<apiName>.yaml' },
31
+ ],
32
+ flags: { measure: 'string', 'group-by': 'string', grain: 'string', range: 'string', start: 'string', end: 'string', compare: 'boolean' },
33
+ summary: 'Query the numbers of a Blueprint metric: by grain, over a time window, grouped by its dimensions',
34
+ details: [
35
+ 'Every measure the metric defines, unless `--measure a,b` names some. `--group-by d1,d2` breaks the numbers down by the metric\'s dimensions.',
36
+ `\`--grain\` (${METRIC_GRAINS.join(', ')}) gives a series: the metric's timeseries dimension is added to the group-by. \`--range P7D\` is a window as an ISO-8601 duration, counted back from the newest value of the metric's timeseries in the data, not from now (so a load that runs behind does not empty it); the answer says the window it covered. \`--start\` and \`--end\` give a fixed one (a date reads as its midnight UTC; the end is exclusive). \`--compare\` adds the window before: <measure>_prev, <measure>_delta and <measure>_pct.`,
37
+ 'Reads what the workspace is granted, as `blueprint query` does: an unknown or ungranted metric is NOT_FOUND. A metric the runtime is still building says so; run it again shortly.',
38
+ ],
39
+ examples: [
40
+ 'frontera blueprint metric query workOrderBacklog',
41
+ 'frontera blueprint metric query workOrderBacklog --group-by state,priority',
42
+ 'frontera blueprint metric query workOrdersOpened --grain day --range P7D',
43
+ 'frontera blueprint metric query workOrdersOpened --range P7D --compare',
44
+ ],
45
+ },
46
+
47
+ async run(ctx) {
48
+ const [first, second, ...rest] = ctx.positional
49
+ // `metric <apiName>` reads as `metric query <apiName>`.
50
+ const apiName = first === 'query' ? second : first
51
+ const extra = first === 'query' ? rest : [second, ...rest].filter((x) => x !== undefined)
52
+ if (!apiName) throw new UsageError('missing <apiName>', 'frontera blueprint list — then `frontera blueprint metric query <apiName>`')
53
+ if (extra.length) throw new UsageError(`unexpected ${extra.join(' ')}`, 'name one metric: `frontera blueprint metric query <apiName> [--group-by …]`')
54
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
55
+ const grain = flagString(ctx, 'grain')
56
+ // The timeseries dimension, read only when a series is asked for.
57
+ let timeseries: string | null | undefined
58
+ let metricRow: unknown
59
+ if (grain !== undefined) {
60
+ metricRow = await api.blueprintMetric(apiName).catch(async () => {
61
+ const all = (await api.blueprintMetrics().catch(() => [])) as Array<{ apiName?: string }>
62
+ return all.find((m) => m?.apiName === apiName) ?? null
63
+ })
64
+ timeseries = metricRow ? timeseriesOf(metricRow) : undefined
65
+ }
66
+ let body
67
+ try {
68
+ body = metricQueryBody({
69
+ measures: list(flagString(ctx, 'measure')),
70
+ dimensions: list(flagString(ctx, 'group-by')),
71
+ grain,
72
+ range: flagString(ctx, 'range'),
73
+ from: flagString(ctx, 'start'),
74
+ to: flagString(ctx, 'end'),
75
+ compare: flagBool(ctx, 'compare') || undefined,
76
+ }, timeseries)
77
+ } catch (err) {
78
+ throw new UsageError((err as Error).message.replace(/^(grain|range|compare)\b/, '--$1').replace(/^from\b/, '--start').replace(/^to\b/, '--end').replace(/give a range \(P7D\) or from and to/, 'give --range (P7D) or --start and --end').replace(/as a range \(P7D\) or as from and to/, 'as --range (P7D) or as --start and --end'), 'e.g. `frontera blueprint metric query <apiName> --grain day --range P7D`')
79
+ }
80
+ const result = await api.blueprintMetricQuery(apiName, body)
81
+ const rows = metricRows(result)
82
+ // A `--range` window ends at the newest record, not at now: say which window it was.
83
+ const isoDuration = body.timeRange?.isoDuration
84
+ const window: RangeWindow | undefined = isoDuration
85
+ ? await resolveRangeWindow(isoDuration, {
86
+ metric: async () => metricRow ?? api.blueprintMetric(apiName),
87
+ objectTypes: () => api.blueprintObjectTypes(),
88
+ aggregate: (aggregate) => api.blueprintAggregate(aggregate),
89
+ })
90
+ : undefined
91
+ const data = window ? (Array.isArray(result) ? { data: result, window } : { ...result, window }) : result
92
+ const compared = !Array.isArray(result) ? (result?.compareWindow as { start?: string; end?: string } | undefined) : undefined
93
+ const windowLine = (window ? `\n Window: ${window.note}` : '')
94
+ + (compared?.start && compared.end ? `\n Compared with: ${compared.start} up to ${compared.end} (the _prev, _delta and _pct columns).` : '')
95
+ if (!rows.length) {
96
+ return {
97
+ data,
98
+ text: `No rows for ${apiName}${body.timeRange ? ' in that window' : ''}.${windowLine}\n The metric's object type may have no records yet; \`frontera blueprint query <ObjectType> --limit 5\` shows whether it has.`,
99
+ }
100
+ }
101
+ const columns = [...new Set(rows.flatMap((r) => Object.keys(r)))]
102
+ const truncated = !Array.isArray(result) && result?.truncated ? '\n Truncated: narrow the window or group by fewer dimensions.' : ''
103
+ return {
104
+ data,
105
+ text: `${table(columns, rows.map((r) => columns.map((c) => cell(r[c]))))}\n\n${rows.length} row${rows.length === 1 ? '' : 's'}.${windowLine}${truncated}`,
106
+ }
107
+ },
108
+ }
@@ -0,0 +1,71 @@
1
+ import { PlatformApi } from '../../api/platform-api'
2
+ import { findObjectSet, normalizeObjectSets, projectObjectSetNames, type SavedObjectSet } from '../../blueprint/object-sets'
3
+ import { CliError } from '../../errors'
4
+ import { findSolutionRoot } from '../../local/local-project'
5
+ import { table } from '../../table'
6
+ import type { Command, CommandContext } from '../types'
7
+
8
+ /**
9
+ * The saved object sets this credential can read: name, id, object type.
10
+ *
11
+ * A set is saved from a project file (`blueprint/object-sets/<name>.yaml`) by
12
+ * `local dev`, or in the Console, and a file or an App names it. An App's
13
+ * code keeps the id, a workflow file or a query the name: this is where both
14
+ * are read.
15
+ */
16
+ export async function savedObjectSets(ctx: Pick<CommandContext, 'apiUrl' | 'token' | 'workspaceId'> & { cwd?: string }): Promise<SavedObjectSet[]> {
17
+ // In a project, a set a file saves is referenced by that file's name, as `status` names it.
18
+ const files = ctx.cwd ? projectObjectSetNames(projectRoot(ctx.cwd)) : new Map<string, string>()
19
+ return normalizeObjectSets(await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).blueprintObjectSets(), files)
20
+ }
21
+
22
+ /** The project's root when the working directory is in one with object set files; else null. */
23
+ function projectRoot(cwd: string): string | null {
24
+ try {
25
+ return findSolutionRoot(cwd)
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ /** The set a flag names, by id, name or reference; a refusal that lists the sets otherwise. */
32
+ export async function resolveObjectSet(ctx: Pick<CommandContext, 'apiUrl' | 'token' | 'workspaceId' | 'cwd'>, wanted: string, sets?: SavedObjectSet[]): Promise<SavedObjectSet> {
33
+ const all = sets ?? (await savedObjectSets(ctx))
34
+ const found = findObjectSet(all, wanted, projectObjectSetNames(projectRoot(ctx.cwd)))
35
+ if ('set' in found) return found.set
36
+ const listed = (found.ambiguous ?? all).slice(0, 8).map((s) => `${s.name} (${s.ref}, ${s.id})`)
37
+ throw new CliError(found.problem, {
38
+ code: found.ambiguous ? 'AMBIGUOUS_OBJECT_SET' : 'UNKNOWN_OBJECT_SET',
39
+ hint: all.length
40
+ ? `${found.ambiguous ? 'pass its id; they are' : 'the sets here are'}: ${listed.join('; ')}${all.length > 8 && !found.ambiguous ? ` and ${all.length - 8} more` : ''} — \`frontera blueprint object-sets\` lists them`
41
+ : 'no saved object set is readable here: add blueprint/object-sets/<name>.yaml and run `frontera local dev`, or save one in the Console',
42
+ })
43
+ }
44
+
45
+ export const blueprintObjectSetsList: Command = {
46
+ meta: {
47
+ noun: 'blueprint',
48
+ verb: 'object-sets',
49
+ scope: 'workspace' as const,
50
+ args: [],
51
+ flags: {},
52
+ summary: 'List the saved object sets: name, id, object type and the reference a file names it by',
53
+ details: [
54
+ 'Pass a set to `frontera blueprint query --object-set <name|ref|id>` to read its rows. A workflow file names it as `objectSet/<ref>` or by its name; an App keeps its id.',
55
+ ],
56
+ examples: ['frontera blueprint object-sets', 'frontera blueprint object-sets --json'],
57
+ },
58
+
59
+ async run(ctx) {
60
+ const sets = await savedObjectSets(ctx)
61
+ return {
62
+ data: sets,
63
+ text: sets.length
64
+ ? table(
65
+ ['name', 'id', 'object type', 'kind', 'reference'],
66
+ sets.map((s) => [s.name, s.id, s.objectType, s.kind, s.ref]),
67
+ )
68
+ : 'No saved object sets are readable with this credential. Add blueprint/object-sets/<name>.yaml and run `frontera local dev`, or save one in the Console.',
69
+ }
70
+ },
71
+ }
@@ -1,9 +1,12 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
 
3
3
  import { PlatformApi } from '../../api/platform-api'
4
+ import { resolveObjectSetReferences, type SavedObjectSet } from '../../blueprint/object-sets'
4
5
  import { CliError, UsageError } from '../../errors'
6
+ import { isUuid } from '../../local/local-sync-refs'
7
+ import { resolveObjectSet, savedObjectSets } from './object-sets'
5
8
  import { table } from '../../table'
6
- import { flagString, type Command } from '../types'
9
+ import { flagBool, flagString, flagStrings, type Command } from '../types'
7
10
 
8
11
  /**
9
12
  * Read rows through the ontology.
@@ -23,11 +26,61 @@ function api(ctx: { apiUrl: string; token: string; workspaceId?: string }): Plat
23
26
  return new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
24
27
  }
25
28
 
26
- /** `status:open` and `amount:>:1000` — the two forms worth typing by hand. */
29
+ /** The operators the shorthand names, so `url:https://…` reads as an equality on `url`, not as operator `https`. */
30
+ const WHERE_OPS = new Set(['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn', 'between', 'contains', 'startsWith', 'isNull', 'isNotNull'])
31
+ /** Spellings a person types: `not_in` for `notIn`, `>` for `gt`. */
32
+ const OP_ALIASES: Record<string, string> = {
33
+ not_in: 'notIn', notin: 'notIn', nin: 'notIn',
34
+ '=': 'eq', '!=': 'ne', '>': 'gt', '>=': 'gte', '<': 'lt', '<=': 'lte',
35
+ }
36
+ /** Operators whose value is a list: `state:in:new,assigned`, `amount:between:10,20`. */
37
+ const LIST_OPS = new Set(['in', 'notIn', 'between'])
38
+
39
+ /**
40
+ * A comma list, as `in`, `notIn` and `between` take one: `new,assigned`.
41
+ * `\,` is a comma inside a value and `\\` a backslash; a value that starts
42
+ * with `[` is read as a JSON array instead (`["Smith, J", "Lee"]`), which also
43
+ * keeps numbers and `null` as written.
44
+ */
45
+ export function parseWhereList(raw: string, op: string): unknown[] {
46
+ const trimmed = raw.trim()
47
+ if (trimmed.startsWith('[')) {
48
+ let parsed: unknown
49
+ try {
50
+ parsed = JSON.parse(trimmed)
51
+ } catch (e) {
52
+ throw new UsageError(`the "${op}" list is not valid JSON: ${(e as Error).message}`, 'write it as a JSON array, like ["new","assigned"], or as new,assigned')
53
+ }
54
+ if (!Array.isArray(parsed)) throw new UsageError(`the "${op}" list is not a JSON array`, 'write it as ["new","assigned"]')
55
+ return parsed
56
+ }
57
+ const values: string[] = []
58
+ let current = ''
59
+ for (let i = 0; i < raw.length; i += 1) {
60
+ const ch = raw[i]!
61
+ if (ch === '\\' && i + 1 < raw.length) {
62
+ current += raw[i + 1]
63
+ i += 1
64
+ } else if (ch === ',') {
65
+ values.push(current)
66
+ current = ''
67
+ } else {
68
+ current += ch
69
+ }
70
+ }
71
+ values.push(current)
72
+ return values.map((v) => v.trim()).filter((v) => v !== '')
73
+ }
74
+
75
+ /**
76
+ * `status:open`, `amount:gt:1000` and `state:in:new,assigned` — the forms worth
77
+ * typing by hand. The value is everything after the operator, so a time
78
+ * (`openedAt:gt:2026-10-01T09:00:00Z`) or a URL keeps its colons.
79
+ */
27
80
  export function parseWhere(raw: string): unknown {
28
81
  const trimmed = raw.trim()
29
82
  // A JSON object is passed through untouched: the shorthand covers one
30
- // condition, and anything with an `and`/`or` in it needs the real grammar
83
+ // condition, and anything with an `or` in it needs the real grammar
31
84
  // rather than a punctuation language invented here to avoid it.
32
85
  if (trimmed.startsWith('{')) {
33
86
  try {
@@ -41,17 +94,54 @@ export function parseWhere(raw: string): unknown {
41
94
  }
42
95
  }
43
96
 
44
- const parts = trimmed.split(':')
45
- if (parts.length === 2) {
46
- return { property: parts[0], op: 'eq', value: parts[1] }
97
+ const first = trimmed.indexOf(':')
98
+ const property = first === -1 ? '' : trimmed.slice(0, first)
99
+ if (!property) {
100
+ throw new UsageError(
101
+ `cannot read --where "${raw}"`,
102
+ 'use <property>:<value>, <property>:<op>:<value>, <property>:in:<a>,<b>, or a JSON where-node',
103
+ )
104
+ }
105
+ const rest = trimmed.slice(first + 1)
106
+ const second = rest.indexOf(':')
107
+ const named = second === -1 ? rest : rest.slice(0, second)
108
+ const op = OP_ALIASES[named.toLowerCase()] ?? named
109
+ if (!WHERE_OPS.has(op)) {
110
+ // A word between two colons is an operator, and a misspelt one must not
111
+ // turn into an equality that quietly matches nothing.
112
+ if (second !== -1 && /^(?:[A-Za-z_]+|[<>=!]+)$/.test(named)) {
113
+ throw new UsageError(
114
+ `--where "${raw}": unknown operator "${named}"`,
115
+ `use one of ${[...WHERE_OPS].join(', ')}; for a value with a colon in it, write ${property}:eq:<value>`,
116
+ )
117
+ }
118
+ // No operator: the whole rest is the value of an equality (`openedAt:2026-10-01T09:00:00Z`).
119
+ return { property, op: 'eq', value: rest }
120
+ }
121
+ if (op === 'isNull' || op === 'isNotNull') {
122
+ if (second !== -1 && rest.slice(second + 1).trim() !== '') throw new UsageError(`"${op}" takes no value`, `use ${property}:${op}`)
123
+ return { property, op }
47
124
  }
48
- if (parts.length === 3) {
49
- return { property: parts[0], op: parts[1], value: parts[2] }
125
+ if (second === -1) {
126
+ // `status:in` — the operator word with nothing after it reads as the value "in"
127
+ // of an equality, which is what it said before operators were recognised.
128
+ return { property, op: 'eq', value: rest }
50
129
  }
51
- throw new UsageError(
52
- `cannot read --where "${raw}"`,
53
- 'use <property>:<value>, <property>:<op>:<value>, or a JSON where-node',
54
- )
130
+ const value = rest.slice(second + 1)
131
+ if (LIST_OPS.has(op)) {
132
+ const values = parseWhereList(value, op)
133
+ if (values.length === 0) throw new UsageError(`"${op}" on "${property}" names no values`, `use ${property}:${op}:<a>,<b>`)
134
+ if (op === 'between' && values.length !== 2) throw new UsageError(`"between" on "${property}" takes two values, a low and a high`, `use ${property}:between:<low>,<high>`)
135
+ return { property, op, values }
136
+ }
137
+ return { property, op, value }
138
+ }
139
+
140
+ /** Several `--where` flags are all true at once: one condition each, ANDed. */
141
+ export function parseWheres(raws: readonly string[]): unknown {
142
+ const nodes = raws.map(parseWhere)
143
+ if (nodes.length === 0) return null
144
+ return nodes.length === 1 ? nodes[0] : { and: nodes }
55
145
  }
56
146
 
57
147
  export function parseOrder(raw: string): Array<{ property: string; dir: 'asc' | 'desc' }> {
@@ -94,12 +184,16 @@ export function coerceWhereValue(value: unknown, dataType: string | undefined):
94
184
  return value
95
185
  }
96
186
 
97
- /** `{ property, op, value }` from the shorthand, retyped against the ontology. */
187
+ /** `{ property, op, value }` (or `values`) from the shorthand, retyped against the ontology; `and` of them too. */
98
188
  export function typeWhereNode(node: unknown, types: Map<string, string>): unknown {
99
- const condition = node as { property?: string; value?: unknown }
189
+ const and = (node as { and?: unknown })?.and
190
+ if (Array.isArray(and)) return { and: and.map((n) => typeWhereNode(n, types)) }
191
+ const condition = node as { property?: string; value?: unknown; values?: unknown }
100
192
  if (!condition || typeof condition !== 'object' || typeof condition.property !== 'string') return node
193
+ const dataType = types.get(condition.property)
194
+ if (Array.isArray(condition.values)) return { ...condition, values: condition.values.map((v) => coerceWhereValue(v, dataType)) }
101
195
  if (!('value' in condition)) return node
102
- return { ...condition, value: coerceWhereValue(condition.value, types.get(condition.property)) }
196
+ return { ...condition, value: coerceWhereValue(condition.value, dataType) }
103
197
  }
104
198
 
105
199
  export function parseLimit(raw: string): number {
@@ -140,25 +234,47 @@ export const blueprintQuery: Command = {
140
234
  limit: 'string',
141
235
  'page-token': 'string',
142
236
  file: 'string',
237
+ 'object-set': 'string',
143
238
  },
144
239
  aliases: { f: 'file' },
145
- summary: 'Read rows of an object type through the ontology',
240
+ summary: 'Read rows of an object type, or of a saved object set, through the ontology',
241
+ details: [
242
+ '`--object-set` reads a saved object set\'s rows instead of a whole object type: name it by its name (`"Open work orders"`), the reference a file names it by (`objectSet/open-work-orders`) or its id, from `frontera blueprint object-sets`. `--where` then narrows the set.',
243
+ 'In a `--file` query, a `{ "type": "reference", "objectSetId": … }` may name the set the same ways; the name is read as its id before the query is sent.',
244
+ '`--where` takes `<property>:<value>` (equality), `<property>:<op>:<value>` with op one of eq, ne, gt, gte, lt, lte, contains, startsWith, or `<property>:isNull` / `<property>:isNotNull`. `in`, `notIn` and `between` take a comma list: `--where state:in:new,assigned`, `--where amount:between:10,20`. Write `\\,` for a comma inside a value, or give the list as a JSON array: `--where \'owner:in:["Smith, J","Lee"]\'`. Repeat `--where` for conditions that must all hold; pass a JSON where-node for an `or` or a `not`.',
245
+ 'On a date or time property, a value can be the time the query runs: `$now`, or `$now` plus or minus an amount in m (or min), h, d or w: `--where \'dueAt:lt:$now\'` (overdue), `--where \'createdAt:gt:$now-7d\'` (the last week). Quote it, so the shell leaves `$now` alone; a `--file` query takes the same value.',
246
+ ],
146
247
  examples: [
147
248
  'frontera blueprint query Invoice --limit 5',
249
+ 'frontera blueprint query --object-set "Open work orders" --limit 10',
250
+ 'frontera blueprint query --object-set objectSet/open-orders --where tier:gold',
148
251
  'frontera blueprint query Invoice --where status:open --select id,amount',
149
252
  'frontera blueprint query Invoice --order amount:desc --limit 20',
253
+ "frontera blueprint query WorkOrder --where 'dueAt:lt:$now'",
254
+ 'frontera blueprint query WorkOrder --where state:in:new,assigned',
150
255
  'frontera blueprint query --file ./query.json --json',
151
256
  ],
152
257
  },
153
258
 
154
259
  async run(ctx) {
155
- const apiName = ctx.positional[0]
156
260
  const file = flagString(ctx, 'file')
261
+ const setFlag = flagString(ctx, 'object-set')
262
+ if (setFlag !== undefined && !setFlag.trim()) throw new UsageError('--object-set names no set', 'frontera blueprint object-sets — then pass a name, reference or id')
263
+ if (setFlag && file) throw new UsageError('--object-set and --file both say what to read', 'put the set in the file as { "type": "reference", "objectSetId": "<name or id>" }, or drop --file')
264
+ // The sets, read once, when a name has to become an id.
265
+ let listed: SavedObjectSet[] | undefined
266
+ const sets = async () => (listed ??= await savedObjectSets(ctx))
267
+ const set = setFlag ? await resolveObjectSet(ctx, setFlag, await sets()) : null
268
+ if (set && ctx.positional[0] && set.objectType && !isUuid(set.objectType) && ctx.positional[0] !== set.objectType) {
269
+ throw new UsageError(`${set.name} is a set of ${set.objectType}, not ${ctx.positional[0]}`, `drop <apiName>, or pass ${set.objectType}`)
270
+ }
271
+ // A set's object type is what its rows are: the shorthand's values are typed against it.
272
+ const apiName = ctx.positional[0] ?? (set && !isUuid(set.objectType) ? set.objectType : undefined)
157
273
 
158
- if (!apiName && !file) {
274
+ if (!apiName && !file && !set) {
159
275
  throw new UsageError(
160
276
  'missing <apiName>',
161
- 'frontera blueprint list — then `frontera blueprint query <apiName>`',
277
+ 'frontera blueprint list — then `frontera blueprint query <apiName>`; or `frontera blueprint object-sets`, then `--object-set <name>`',
162
278
  )
163
279
  }
164
280
 
@@ -184,17 +300,19 @@ export const blueprintQuery: Command = {
184
300
  'the minimum is { "objectSet": { "type": "base", "objectType": "Invoice" } }',
185
301
  )
186
302
  }
187
- body = doc as typeof body
303
+ // A set named in words (`objectSet/open-work-orders`, `Open work orders`) is sent as its id.
304
+ body = (await resolveObjectSetReferences(doc, async (name) => (await resolveObjectSet(ctx, name, await sets())).id)) as typeof body
188
305
  } else {
189
- const where = flagString(ctx, 'where')
190
- let node = where ? parseWhere(where) : null
306
+ // Every `--where` holds: repeated, they are ANDed (the last one alone used to win, silently).
307
+ const wheres = flagStrings(ctx, 'where')
308
+ let node = parseWheres(wheres)
191
309
 
192
310
  // One extra read, and only when the shorthand is used: the flag carries
193
311
  // a string and the compiler is typed, so the property's declared type is
194
312
  // what makes `--where salesIdr:gt:9000000` a number rather than a 400.
195
313
  // A JSON where-node is already typed by whoever wrote it, and is left
196
314
  // exactly as given.
197
- if (node && where && !where.trim().startsWith('{')) {
315
+ if (node && wheres.some((w) => !w.trim().startsWith('{')) && apiName) {
198
316
  const shape = (await api(ctx).blueprintObjectType(apiName!)) as {
199
317
  properties?: Array<{ apiName?: string; dataType?: string }>
200
318
  }
@@ -202,14 +320,13 @@ export const blueprintQuery: Command = {
202
320
  for (const p of shape?.properties ?? []) {
203
321
  if (p.apiName && p.dataType) types.set(p.apiName, p.dataType)
204
322
  }
205
- node = typeWhereNode(node, types)
323
+ node = wheres.length === 1
324
+ ? typeWhereNode(node, types)
325
+ : { and: wheres.map((w, i) => (w.trim().startsWith('{') ? (node as { and: unknown[] }).and[i] : typeWhereNode((node as { and: unknown[] }).and[i], types))) }
206
326
  }
207
327
 
208
- body = {
209
- objectSet: node
210
- ? { type: 'filter', objectSet: { type: 'base', objectType: apiName }, where: node }
211
- : { type: 'base', objectType: apiName },
212
- }
328
+ const from = set ? { type: 'reference', objectSetId: set.id } : { type: 'base', objectType: apiName }
329
+ body = { objectSet: node ? { type: 'filter', objectSet: from, where: node } : from }
213
330
  }
214
331
 
215
332
  const select = flagString(ctx, 'select')
@@ -221,7 +338,14 @@ export const blueprintQuery: Command = {
221
338
  const pageToken = flagString(ctx, 'page-token')
222
339
  if (pageToken) body.pageToken = pageToken
223
340
 
224
- const result = await api(ctx).blueprintQuery(body)
341
+ // JSON is read by code that may go on to submit an Action: each row says its record version
342
+ // (`_meta.recordVersion`). A runtime that predates the flag is asked again without it.
343
+ if (flagBool(ctx, 'json')) body.recordVersions = true
344
+ const result = await api(ctx).blueprintQuery(body).catch(async (err: unknown) => {
345
+ if (!body.recordVersions || !/recordVersions/.test(String((err as Error)?.message ?? ''))) throw err
346
+ delete body.recordVersions
347
+ return api(ctx).blueprintQuery(body)
348
+ })
225
349
  const rows = result.rows ?? []
226
350
 
227
351
  if (rows.length === 0) {
@@ -231,7 +355,7 @@ export const blueprintQuery: Command = {
231
355
  // names the two things that produce one rather than printing "0 rows".
232
356
  text:
233
357
  'No rows.\n'
234
- + ' The object type may have no dataset binding yet, or the filter matched nothing.\n'
358
+ + (set ? ` The set ${set.name} has no rows now, or the filter matched nothing.\n` : ' The object type may have no dataset binding yet, or the filter matched nothing.\n')
235
359
  + ` Check the binding with \`frontera blueprint get ${apiName ?? '<apiName>'}\`.`,
236
360
  }
237
361
  }