@frontera-sdk/cli 1.52.39 → 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.
- package/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +48 -4
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +331 -31
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +875 -88
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +320 -13
- package/src/commands/local/sync.ts +102 -5
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +52 -8
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +112 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +211 -21
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +180 -7
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +346 -35
- package/src/local/local-dev-sync.ts +136 -7
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +205 -7
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +141 -16
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +38 -8
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +1 -1
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +11 -0
- package/src/local/local-sync-push.ts +10 -7
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +343 -32
- package/src/local/local-sync-snapshot.ts +15 -10
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- 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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
/** `
|
|
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 `
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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 (
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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 &&
|
|
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 =
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
}
|