@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.
Files changed (235) 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 +49 -5
  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/draft-changes.ts +1 -1
  33. package/src/commands/agent/index-commands.ts +69 -7
  34. package/src/commands/agent/resolve.ts +2 -0
  35. package/src/commands/app/checks.ts +44 -0
  36. package/src/commands/app/deploy.ts +243 -60
  37. package/src/commands/app/deployments.ts +97 -0
  38. package/src/commands/app/dev.ts +103 -11
  39. package/src/commands/app/init.ts +36 -3
  40. package/src/commands/app/promote.ts +45 -19
  41. package/src/commands/app/pull.ts +3 -1
  42. package/src/commands/app/rollback.ts +46 -0
  43. package/src/commands/app/sdk.ts +54 -8
  44. package/src/commands/app/unpin.ts +27 -0
  45. package/src/commands/app/versions.ts +1 -1
  46. package/src/commands/automation/bindings.ts +4 -2
  47. package/src/commands/automation/build-entry.ts +4 -1
  48. package/src/commands/automation/index-commands.ts +3 -1
  49. package/src/commands/automation/pull.ts +6 -1
  50. package/src/commands/automation/run.ts +34 -9
  51. package/src/commands/blueprint/bind.ts +2 -1
  52. package/src/commands/blueprint/declarative.ts +332 -32
  53. package/src/commands/blueprint/editable.ts +3 -3
  54. package/src/commands/blueprint/get.ts +6 -2
  55. package/src/commands/blueprint/grants.ts +64 -10
  56. package/src/commands/blueprint/metric.ts +108 -0
  57. package/src/commands/blueprint/object-sets.ts +71 -0
  58. package/src/commands/blueprint/query.ts +155 -31
  59. package/src/commands/dataset/index-commands.ts +156 -7
  60. package/src/commands/knowledge/index-commands.ts +122 -20
  61. package/src/commands/local/check.ts +657 -44
  62. package/src/commands/local/data.ts +33 -12
  63. package/src/commands/local/dev.ts +876 -89
  64. package/src/commands/local/docs-mcp.ts +10 -18
  65. package/src/commands/local/index-commands.ts +300 -63
  66. package/src/commands/local/init.ts +18 -5
  67. package/src/commands/local/key.ts +0 -3
  68. package/src/commands/local/providers.ts +0 -7
  69. package/src/commands/local/remove.ts +116 -0
  70. package/src/commands/local/schema.ts +3 -4
  71. package/src/commands/local/sdk-refresh.ts +59 -0
  72. package/src/commands/local/snapshot.ts +100 -0
  73. package/src/commands/local/sync-request.ts +321 -14
  74. package/src/commands/local/sync.ts +103 -6
  75. package/src/commands/mcp/serve.ts +34 -0
  76. package/src/commands/registry.ts +54 -10
  77. package/src/commands/types.ts +29 -0
  78. package/src/commands/workflow/archive.ts +46 -0
  79. package/src/commands/workflow/get.ts +2 -1
  80. package/src/commands/workflow/index-commands.ts +5 -0
  81. package/src/commands/workflow/push.ts +49 -2
  82. package/src/commands/workflow/run.ts +65 -7
  83. package/src/commands/workflow/runs.ts +9 -6
  84. package/src/commands/workflow/webhook-test.ts +166 -0
  85. package/src/config.ts +43 -6
  86. package/src/dev-broker.ts +52 -0
  87. package/src/exit.ts +103 -4
  88. package/src/flag-help.ts +77 -17
  89. package/src/forge/claude-commands.ts +11 -6
  90. package/src/forge/claude-skills.ts +4 -4
  91. package/src/forge/touches.ts +15 -12
  92. package/src/function-outside-imports.ts +89 -0
  93. package/src/harness.ts +42 -24
  94. package/src/heal.ts +113 -2
  95. package/src/kit.ts +1 -1
  96. package/src/local/fix-words.ts +12 -0
  97. package/src/local/function-bundle-recovery.ts +775 -0
  98. package/src/local/local-actions.ts +103 -10
  99. package/src/local/local-agent-action-grants.ts +210 -0
  100. package/src/local/local-agent-checks.ts +232 -0
  101. package/src/local/local-agent-grants.ts +29 -7
  102. package/src/local/local-agent-lean-plugins.ts +68 -0
  103. package/src/local/local-agent-missing-tools.ts +68 -0
  104. package/src/local/local-agent-name.ts +27 -0
  105. package/src/local/local-app-code.ts +191 -0
  106. package/src/local/local-app-dev-files.ts +186 -0
  107. package/src/local/local-app-dev-session.ts +88 -0
  108. package/src/local/local-app-lock-install.ts +114 -0
  109. package/src/local/local-app-pulled.ts +167 -0
  110. package/src/local/local-app-restore.ts +111 -0
  111. package/src/local/local-app-runtime.ts +68 -0
  112. package/src/local/local-app-sdk-paths.ts +149 -0
  113. package/src/local/local-app-sdk.ts +509 -0
  114. package/src/local/local-applied.ts +212 -22
  115. package/src/local/local-apply-history.ts +84 -0
  116. package/src/local/local-apply-summary.ts +86 -0
  117. package/src/local/local-apply-watchdog.ts +95 -0
  118. package/src/local/local-apps.ts +181 -8
  119. package/src/local/local-blueprint-errors.ts +198 -0
  120. package/src/local/local-blueprint-outline.ts +31 -12
  121. package/src/local/local-blueprint-schemas.ts +582 -0
  122. package/src/local/local-build-inputs.ts +48 -0
  123. package/src/local/local-build-summary.ts +495 -28
  124. package/src/local/local-case-evidence.ts +120 -0
  125. package/src/local/local-case-not-run.ts +153 -0
  126. package/src/local/local-chat-apps.ts +93 -16
  127. package/src/local/local-check-inherited.ts +83 -0
  128. package/src/local/local-cities.ts +89 -0
  129. package/src/local/local-component-health.ts +119 -0
  130. package/src/local/local-control.ts +34 -7
  131. package/src/local/local-db-snapshot.ts +348 -0
  132. package/src/local/local-dev-references.ts +347 -36
  133. package/src/local/local-dev-sync.ts +141 -12
  134. package/src/local/local-docker.ts +35 -1
  135. package/src/local/local-docs-search.ts +1 -1
  136. package/src/local/local-docs.ts +281 -2
  137. package/src/local/local-empty-workflows.ts +92 -0
  138. package/src/local/local-env.ts +16 -1
  139. package/src/local/local-file-checks.ts +104 -0
  140. package/src/local/local-function-ids.ts +141 -0
  141. package/src/local/local-function-recovery.ts +141 -0
  142. package/src/local/local-function-state.ts +87 -0
  143. package/src/local/local-keys.ts +7 -5
  144. package/src/local/local-knowledge-documents.ts +1 -1
  145. package/src/local/local-knowledge-ingestion.ts +225 -0
  146. package/src/local/local-knowledge-uploads.ts +217 -0
  147. package/src/local/local-lessons.ts +1 -1
  148. package/src/local/local-library.ts +1 -1
  149. package/src/local/local-media-placeholders.ts +182 -0
  150. package/src/local/local-media-sets.ts +90 -10
  151. package/src/local/local-model-parameters.ts +89 -0
  152. package/src/local/local-object-sets.ts +18 -6
  153. package/src/local/local-operating.ts +35 -1
  154. package/src/local/local-org-settings.ts +206 -8
  155. package/src/local/local-orphans.ts +321 -0
  156. package/src/local/local-personas.ts +296 -0
  157. package/src/local/local-portability.ts +143 -18
  158. package/src/local/local-ports.ts +99 -7
  159. package/src/local/local-project-notices.ts +234 -0
  160. package/src/local/local-project-secrets.ts +133 -0
  161. package/src/local/local-project.ts +12 -0
  162. package/src/local/local-provider-refusal.ts +66 -0
  163. package/src/local/local-request-evidence.ts +112 -0
  164. package/src/local/local-runtime-health.ts +123 -0
  165. package/src/local/local-runtime-history.ts +50 -0
  166. package/src/local/local-runtime-target.ts +42 -0
  167. package/src/local/local-sample-data.ts +141 -0
  168. package/src/local/local-sample-remake.ts +138 -0
  169. package/src/local/local-scaffold.ts +336 -53
  170. package/src/local/local-schema-check.ts +148 -0
  171. package/src/local/local-schemas.ts +742 -25
  172. package/src/local/local-secret-carry.ts +107 -0
  173. package/src/local/local-secrets.ts +41 -11
  174. package/src/local/local-seed.ts +102 -26
  175. package/src/local/local-stand-ins.ts +3 -3
  176. package/src/local/local-state.ts +43 -2
  177. package/src/local/local-studio-server.ts +170 -7
  178. package/src/local/local-supervisor.ts +44 -6
  179. package/src/local/local-sync-merge.ts +1 -1
  180. package/src/local/local-sync-names.ts +2 -0
  181. package/src/local/local-sync-plan.ts +19 -8
  182. package/src/local/local-sync-push.ts +15 -12
  183. package/src/local/local-sync-refs.ts +49 -3
  184. package/src/local/local-sync-remote.ts +349 -38
  185. package/src/local/local-sync-snapshot.ts +18 -13
  186. package/src/local/local-synthetic-data.ts +2116 -99
  187. package/src/local/local-test-baseline.ts +113 -0
  188. package/src/local/local-test-cases.ts +76 -1
  189. package/src/local/local-test-runner.ts +582 -91
  190. package/src/local/local-test-runs.ts +13 -5
  191. package/src/local/local-vendored-sdk.ts +183 -0
  192. package/src/local/local-workflow-state.ts +16 -3
  193. package/src/local/local-workflow-tests.ts +482 -0
  194. package/src/local/local-workspace-grants.ts +81 -0
  195. package/src/main.ts +50 -9
  196. package/src/mcp/a11y-scan.ts +55 -0
  197. package/src/mcp/browser/ax-snapshot.ts +181 -0
  198. package/src/mcp/browser/credentials.ts +78 -0
  199. package/src/mcp/browser/frames.ts +336 -0
  200. package/src/mcp/browser/headless-session.ts +413 -0
  201. package/src/mcp/browser/headless-tab.ts +575 -0
  202. package/src/mcp/browser/keys.ts +70 -0
  203. package/src/mcp/browser/page-scripts.ts +129 -0
  204. package/src/mcp/browser/wait.ts +73 -0
  205. package/src/mcp/forge-link.ts +126 -0
  206. package/src/mcp/headless.ts +199 -0
  207. package/src/mcp/mcp-apps.ts +435 -0
  208. package/src/mcp/mcp-chat.ts +226 -0
  209. package/src/mcp/mcp-cli.ts +53 -0
  210. package/src/mcp/mcp-protocol.ts +122 -0
  211. package/src/mcp/mcp-runtime.ts +113 -0
  212. package/src/mcp/mcp-server.ts +108 -0
  213. package/src/mcp/mcp-traces.ts +409 -0
  214. package/src/mcp/page-observation.ts +193 -0
  215. package/src/mcp/tools/agent-tools.ts +700 -0
  216. package/src/mcp/tools/app-tools.ts +241 -0
  217. package/src/mcp/tools/browser-tools.ts +310 -0
  218. package/src/mcp/tools/docs-tools.ts +32 -0
  219. package/src/mcp/tools/project-tools.ts +205 -0
  220. package/src/mcp/tools/test-tools.ts +339 -0
  221. package/src/mcp/tools/workflow-tools.ts +611 -0
  222. package/src/output.ts +1 -1
  223. package/src/pack.ts +8 -3
  224. package/src/packaging.ts +1 -1
  225. package/src/project-bootstrap.ts +107 -4
  226. package/src/project-context.ts +18 -1
  227. package/src/project.ts +27 -2
  228. package/src/provenance.ts +25 -0
  229. package/src/render-evidence.ts +65 -7
  230. package/src/scopes.ts +1 -1
  231. package/src/template.ts +14 -8
  232. package/src/templates/next-app-files.ts +35 -6
  233. package/src/templates/next-skills.ts +61 -27
  234. package/src/vendor/kit-assets.json +6 -6
  235. 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
- /** `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
  }
@@ -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: new Set(['path', 'includedColumns']),
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
- created = await api.createFromFile(
573
- resolve(dirname(path), csv),
574
- { ...common, includedColumns: names(document.includedColumns, 'includedColumns', filePath) },
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
- export const datasetCommands: Command[] = [list, get, rows, sources, preview, testSource, create, upload]
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]