@frontera-sdk/cli 1.52.39 → 1.52.41
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 +49 -5
- 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/draft-changes.ts +1 -1
- 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 +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- 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 +876 -89
- 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 +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- 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/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- 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 +113 -2
- package/src/kit.ts +1 -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 +212 -22
- 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 +181 -8
- 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 +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- 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-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- 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 +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- 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 +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- 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-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- 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/scopes.ts +1 -1
- 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 +17 -14
|
@@ -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
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { basename, dirname, resolve } from 'node:path'
|
|
3
3
|
|
|
4
|
-
import { DatasetApi, type DatasetColumn } from '../../api/dataset-api'
|
|
4
|
+
import { DatasetApi, type DatasetColumn, type DatasetSummary } from '../../api/dataset-api'
|
|
5
5
|
import { CliError } from '../../errors'
|
|
6
6
|
import { table } from '../../table'
|
|
7
7
|
import { flagString, type Command } from '../types'
|
|
@@ -103,6 +103,76 @@ function readColumns(raw: unknown, path: string): DatasetColumn[] {
|
|
|
103
103
|
})
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The column contract of a file dataset that declares `columns`: each included
|
|
108
|
+
* column with the type and nullability the file says, the CSV's own where it
|
|
109
|
+
* says nothing (`upload-preview`). The CSV must hold exactly the included
|
|
110
|
+
* columns, since a contract built this way records none as left out.
|
|
111
|
+
*/
|
|
112
|
+
async function fileContract(api: DatasetApi, csvPath: string, included: string[], declared: Map<string, { nullable?: boolean; databaseType?: string }>, filePath: string): Promise<DatasetColumn[]> {
|
|
113
|
+
const preview = await api.uploadPreview(csvPath)
|
|
114
|
+
const inferred = new Map((preview.columns ?? []).flatMap((c) => (c.name ? [[c.name, c] as const] : [])))
|
|
115
|
+
const extra = [...inferred.keys()].filter((name) => !included.includes(name))
|
|
116
|
+
if (extra.length) {
|
|
117
|
+
throw new CliError(`${filePath} declares \`columns\`, and its CSV carries ${extra.join(', ')}, which \`includedColumns\` leaves out.`, {
|
|
118
|
+
code: 'USAGE',
|
|
119
|
+
hint: 'With `columns`, the CSV holds exactly the included columns: add them to includedColumns, or drop them from the CSV.',
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
return included.map((name): DatasetColumn => {
|
|
123
|
+
const said = declared.get(name)
|
|
124
|
+
const seen = inferred.get(name)
|
|
125
|
+
const databaseType = (said?.databaseType ?? seen?.databaseType) as DatasetColumn['databaseType'] | undefined
|
|
126
|
+
if (!databaseType || !COLUMN_TYPES.includes(databaseType as never)) {
|
|
127
|
+
throw new CliError(`${filePath}: the CSV has no column "${name}" to read its type from.`, {
|
|
128
|
+
code: 'USAGE',
|
|
129
|
+
hint: `Add it to the CSV, or declare it: columns: [{ name: ${name}, databaseType: text, nullable: true }]`,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
return { name, databaseType, nullable: said?.nullable ?? seen?.nullable ?? true }
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A file dataset's `columns`: what it declares of each column instead of
|
|
138
|
+
* leaving it to the first CSV (`[{ name, nullable, databaseType? }]`). Each
|
|
139
|
+
* names an included column and says its nullability, its type, or both;
|
|
140
|
+
* the rest come from the CSV.
|
|
141
|
+
*/
|
|
142
|
+
function readFileColumns(raw: unknown, included: string[], path: string): Map<string, { nullable?: boolean; databaseType?: string }> {
|
|
143
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
144
|
+
throw new CliError(`${path} has \`columns\` that is not a list.`, {
|
|
145
|
+
code: 'USAGE',
|
|
146
|
+
hint: 'columns: [{ name: closed_at, nullable: true }] — the columns whose nullability (or type) the file declares.',
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
const out = new Map<string, { nullable?: boolean; databaseType?: string }>()
|
|
150
|
+
raw.forEach((entry, position) => {
|
|
151
|
+
const where = `${path}: columns[${position}]`
|
|
152
|
+
const column = (entry && typeof entry === 'object' ? entry : {}) as Record<string, unknown>
|
|
153
|
+
const name = column.name
|
|
154
|
+
if (typeof name !== 'string' || !included.includes(name)) {
|
|
155
|
+
throw new CliError(`${where} names ${typeof name === 'string' ? `"${name}", which is not in includedColumns` : 'no column'}.`, {
|
|
156
|
+
code: 'USAGE',
|
|
157
|
+
hint: `Included: ${included.join(', ')}`,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
const strays = Object.keys(column).filter((k) => !['name', 'nullable', 'databaseType'].includes(k))
|
|
161
|
+
if (strays.length) throw new CliError(`${where} carries ${strays.join(', ')}.`, { code: 'USAGE', hint: 'A file dataset’s column is { name, nullable, databaseType }; databaseType is optional.' })
|
|
162
|
+
if (column.nullable !== undefined && typeof column.nullable !== 'boolean') {
|
|
163
|
+
throw new CliError(`${where}: \`nullable\` is true or false.`, { code: 'USAGE', hint: 'nullable: true lets the column hold empty values in any later CSV.' })
|
|
164
|
+
}
|
|
165
|
+
if (column.databaseType !== undefined && (typeof column.databaseType !== 'string' || !COLUMN_TYPES.includes(column.databaseType as never))) {
|
|
166
|
+
throw new CliError(`${where} has an unknown \`databaseType\`.`, { code: 'USAGE', hint: `One of: ${COLUMN_TYPES.join(', ')}` })
|
|
167
|
+
}
|
|
168
|
+
if (column.nullable === undefined && column.databaseType === undefined) {
|
|
169
|
+
throw new CliError(`${where} declares nothing about "${name}".`, { code: 'USAGE', hint: 'Say `nullable`, `databaseType`, or both; leave a column out to take both from the CSV.' })
|
|
170
|
+
}
|
|
171
|
+
out.set(name, { ...(column.nullable !== undefined ? { nullable: column.nullable as boolean } : {}), ...(column.databaseType !== undefined ? { databaseType: column.databaseType as string } : {}) })
|
|
172
|
+
})
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
106
176
|
const list: Command = {
|
|
107
177
|
meta: {
|
|
108
178
|
noun: 'dataset',
|
|
@@ -476,7 +546,9 @@ const create: Command = {
|
|
|
476
546
|
const KIND_KEYS: Record<string, Set<string>> = {
|
|
477
547
|
blank: new Set(['columns']),
|
|
478
548
|
source: new Set(['source', 'schema', 'relation', 'includedColumns']),
|
|
479
|
-
file
|
|
549
|
+
// `columns`: what the file declares of each column (its nullability, its type) instead of
|
|
550
|
+
// leaving both to the first CSV, whose contract every later upload must then match.
|
|
551
|
+
file: new Set(['path', 'includedColumns', 'columns']),
|
|
480
552
|
}
|
|
481
553
|
const COMMON = new Set([
|
|
482
554
|
'apiName', 'displayName', 'description', 'kind', 'keyColumns', 'deterministicKeyConfirmed',
|
|
@@ -569,10 +641,21 @@ const create: Command = {
|
|
|
569
641
|
}
|
|
570
642
|
// Relative to the DEFINITION, not the shell's cwd: the two travel together in a
|
|
571
643
|
// repo, and resolving against cwd broke the moment anyone ran it from elsewhere.
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
)
|
|
644
|
+
const csvPath = resolve(dirname(path), csv)
|
|
645
|
+
const included = names(document.includedColumns, 'includedColumns', filePath)
|
|
646
|
+
const declared = document.columns === undefined ? null : readFileColumns(document.columns, included, filePath)
|
|
647
|
+
if (!declared) {
|
|
648
|
+
created = await api.createFromFile(csvPath, { ...common, includedColumns: included })
|
|
649
|
+
} else {
|
|
650
|
+
// The contract as the file declares it, then the CSV loaded into it: a column declared
|
|
651
|
+
// nullable stays so, whatever this CSV holds.
|
|
652
|
+
const columns = await fileContract(api, csvPath, included, declared, filePath)
|
|
653
|
+
const blank = await api.createBlank({ ...common, columns })
|
|
654
|
+
if (!blank.id) {
|
|
655
|
+
throw new CliError(`"${apiName}" was created without an id, so its rows were not loaded.`, { code: 'FAILURE', hint: `frontera dataset upload ${apiName} ${csv}` })
|
|
656
|
+
}
|
|
657
|
+
created = await api.uploadSnapshot(blank.id, csvPath)
|
|
658
|
+
}
|
|
576
659
|
} else {
|
|
577
660
|
throw new CliError(`${filePath} has an unknown \`kind\`: ${String(kind)}.`, {
|
|
578
661
|
code: 'USAGE',
|
|
@@ -664,4 +747,70 @@ const upload: Command = {
|
|
|
664
747
|
},
|
|
665
748
|
}
|
|
666
749
|
|
|
667
|
-
|
|
750
|
+
const revise: Command = {
|
|
751
|
+
meta: {
|
|
752
|
+
noun: 'dataset',
|
|
753
|
+
verb: 'revise',
|
|
754
|
+
args: [],
|
|
755
|
+
flags: { file: 'string' },
|
|
756
|
+
aliases: { f: 'file' },
|
|
757
|
+
summary: 'Cut a new revision of a file dataset from its CSV, when the CSV holds empty values the current revision refuses',
|
|
758
|
+
examples: ['frontera dataset revise -f datasets/work_orders.json'],
|
|
759
|
+
},
|
|
760
|
+
/**
|
|
761
|
+
* A file dataset's first load fixes which columns may be empty. A later CSV
|
|
762
|
+
* with empty values in a column that had none is refused by `upload`; this
|
|
763
|
+
* makes that CSV the dataset's next revision instead, which only widens what
|
|
764
|
+
* may be empty. A column added, dropped or retyped, or a key column that
|
|
765
|
+
* became nullable, is a different dataset: the service refuses it, naming
|
|
766
|
+
* the column. Object types still read the old revision until they are bound
|
|
767
|
+
* to the new one (`blueprint bind`, which `local dev` does).
|
|
768
|
+
*/
|
|
769
|
+
async run(ctx) {
|
|
770
|
+
const filePath = flagString(ctx, 'file')
|
|
771
|
+
if (!filePath) throw new CliError('A definition file is required.', { code: 'USAGE', hint: 'frontera dataset revise -f datasets/<apiName>.json' })
|
|
772
|
+
const path = resolve(ctx.cwd, filePath)
|
|
773
|
+
let document: Record<string, unknown>
|
|
774
|
+
try {
|
|
775
|
+
document = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
|
|
776
|
+
} catch (err) {
|
|
777
|
+
throw new CliError(`${filePath} could not be read as JSON: ${(err as Error).message}`, { code: 'USAGE', hint: 'A dataset definition is JSON: `frontera local schema dataset`.' })
|
|
778
|
+
}
|
|
779
|
+
if (document.kind !== 'file' || typeof document.path !== 'string' || typeof document.apiName !== 'string') {
|
|
780
|
+
throw new CliError(`${filePath} is not a file dataset.`, { code: 'USAGE', hint: 'revise reads kind: "file" with `apiName` and `path`; a Source dataset is revised from its Source.' })
|
|
781
|
+
}
|
|
782
|
+
const apiName = document.apiName
|
|
783
|
+
const csvPath = resolve(dirname(path), document.path)
|
|
784
|
+
const api = new DatasetApi(ctx.apiUrl, ctx.token)
|
|
785
|
+
const dataset = await resolveDataset(api, apiName)
|
|
786
|
+
const revisions = await api.revisions(dataset.id!)
|
|
787
|
+
const expectedRevision = (revisions.find((r) => r.id === dataset.currentRevisionId) ?? revisions.at(-1))?.revision
|
|
788
|
+
if (typeof expectedRevision !== 'number') {
|
|
789
|
+
throw new CliError(`"${apiName}" has no current revision to follow.`, { code: 'CONFLICT', hint: `frontera dataset get ${apiName}` })
|
|
790
|
+
}
|
|
791
|
+
let result: DatasetSummary
|
|
792
|
+
try {
|
|
793
|
+
result = await api.reviseFromFile(dataset.id!, csvPath, expectedRevision)
|
|
794
|
+
} catch (err) {
|
|
795
|
+
if (err instanceof CliError && (err.code === 'DATASET_REVISION_UNCHANGED' || /DATASET_REVISION_UNCHANGED/.test(err.message))) {
|
|
796
|
+
return { data: { unchanged: true, revision: expectedRevision }, text: `"${apiName}" already fits ${basename(csvPath)}: revision ${expectedRevision} stays.` }
|
|
797
|
+
}
|
|
798
|
+
if (err instanceof CliError && err.code === 'CONFLICT') {
|
|
799
|
+
throw new CliError(err.message, {
|
|
800
|
+
code: 'CONFLICT',
|
|
801
|
+
hint: 'A new revision may only let more columns be empty. A column added, dropped or retyped (or a key column that became nullable) is a new dataset: give it another apiName, or on a local runtime run `frontera local reset`, which creates every dataset again from its definition.',
|
|
802
|
+
})
|
|
803
|
+
}
|
|
804
|
+
throw err
|
|
805
|
+
}
|
|
806
|
+
const revision = (result as { currentRevision?: { revision?: number } }).currentRevision?.revision
|
|
807
|
+
return {
|
|
808
|
+
data: result,
|
|
809
|
+
text: `Cut revision ${revision ?? expectedRevision + 1} of "${apiName}" from ${basename(csvPath)}.\n`
|
|
810
|
+
+ ' Object types read the old revision until bound to this one: `frontera blueprint bind <Type> --dataset '
|
|
811
|
+
+ `${apiName} --file <mapping> --accept-contract-change\`.`,
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
export const datasetCommands: Command[] = [list, get, rows, sources, preview, testSource, create, upload, revise]
|