@carthooks/arcubase-cli 0.1.2 → 0.1.4

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 (106) hide show
  1. package/bundle/arcubase-admin.mjs +1238 -16
  2. package/bundle/arcubase.mjs +1238 -16
  3. package/dist/bin/arcubase-admin.js +0 -0
  4. package/dist/bin/arcubase.js +0 -0
  5. package/dist/runtime/command_registry.d.ts +8 -0
  6. package/dist/runtime/command_registry.d.ts.map +1 -1
  7. package/dist/runtime/command_registry.js +35 -0
  8. package/dist/runtime/entity_save_schema.d.ts +15 -0
  9. package/dist/runtime/entity_save_schema.d.ts.map +1 -0
  10. package/dist/runtime/entity_save_schema.js +641 -0
  11. package/dist/runtime/errors.d.ts +11 -0
  12. package/dist/runtime/errors.d.ts.map +1 -1
  13. package/dist/runtime/execute.d.ts.map +1 -1
  14. package/dist/runtime/execute.js +294 -12
  15. package/dist/runtime/upload.d.ts +13 -0
  16. package/dist/runtime/upload.d.ts.map +1 -0
  17. package/dist/runtime/upload.js +148 -0
  18. package/dist/runtime/zod_registry.d.ts +6 -0
  19. package/dist/runtime/zod_registry.d.ts.map +1 -1
  20. package/dist/runtime/zod_registry.js +136 -0
  21. package/dist/tests/command_registry.test.js +5 -0
  22. package/dist/tests/entity_save_schema.test.d.ts +2 -0
  23. package/dist/tests/entity_save_schema.test.d.ts.map +1 -0
  24. package/dist/tests/entity_save_schema.test.js +221 -0
  25. package/dist/tests/execute_validation.test.js +603 -0
  26. package/dist/tests/help.test.js +49 -0
  27. package/dist/tests/upload.test.d.ts +2 -0
  28. package/dist/tests/upload.test.d.ts.map +1 -0
  29. package/dist/tests/upload.test.js +107 -0
  30. package/package.json +1 -1
  31. package/sdk-dist/docs/runtime-reference/README.md +187 -0
  32. package/sdk-dist/docs/runtime-reference/entity-schema/README.md +120 -0
  33. package/sdk-dist/docs/runtime-reference/entity-schema/address.md +34 -0
  34. package/sdk-dist/docs/runtime-reference/entity-schema/boolean.md +37 -0
  35. package/sdk-dist/docs/runtime-reference/entity-schema/button.md +47 -0
  36. package/sdk-dist/docs/runtime-reference/entity-schema/checkbox.md +61 -0
  37. package/sdk-dist/docs/runtime-reference/entity-schema/datasets.md +35 -0
  38. package/sdk-dist/docs/runtime-reference/entity-schema/datetime.md +40 -0
  39. package/sdk-dist/docs/runtime-reference/entity-schema/department.md +33 -0
  40. package/sdk-dist/docs/runtime-reference/entity-schema/departments.md +34 -0
  41. package/sdk-dist/docs/runtime-reference/entity-schema/divider.md +39 -0
  42. package/sdk-dist/docs/runtime-reference/entity-schema/file.md +53 -0
  43. package/sdk-dist/docs/runtime-reference/entity-schema/formula.md +44 -0
  44. package/sdk-dist/docs/runtime-reference/entity-schema/image.md +52 -0
  45. package/sdk-dist/docs/runtime-reference/entity-schema/linkto.md +67 -0
  46. package/sdk-dist/docs/runtime-reference/entity-schema/location.md +32 -0
  47. package/sdk-dist/docs/runtime-reference/entity-schema/lookup.md +41 -0
  48. package/sdk-dist/docs/runtime-reference/entity-schema/member.md +37 -0
  49. package/sdk-dist/docs/runtime-reference/entity-schema/members.md +31 -0
  50. package/sdk-dist/docs/runtime-reference/entity-schema/number.md +70 -0
  51. package/sdk-dist/docs/runtime-reference/entity-schema/query.md +50 -0
  52. package/sdk-dist/docs/runtime-reference/entity-schema/radio.md +53 -0
  53. package/sdk-dist/docs/runtime-reference/entity-schema/relation.md +55 -0
  54. package/sdk-dist/docs/runtime-reference/entity-schema/relationfield.md +38 -0
  55. package/sdk-dist/docs/runtime-reference/entity-schema/rollup.md +54 -0
  56. package/sdk-dist/docs/runtime-reference/entity-schema/select.md +63 -0
  57. package/sdk-dist/docs/runtime-reference/entity-schema/selects.md +51 -0
  58. package/sdk-dist/docs/runtime-reference/entity-schema/serialnumber.md +48 -0
  59. package/sdk-dist/docs/runtime-reference/entity-schema/signature.md +31 -0
  60. package/sdk-dist/docs/runtime-reference/entity-schema/status.md +70 -0
  61. package/sdk-dist/docs/runtime-reference/entity-schema/subform.md +74 -0
  62. package/sdk-dist/docs/runtime-reference/entity-schema/tags.md +19 -0
  63. package/sdk-dist/docs/runtime-reference/entity-schema/text.md +76 -0
  64. package/sdk-dist/docs/runtime-reference/entity-schema/textarea.md +47 -0
  65. package/sdk-dist/docs/runtime-reference/entity-schema/workflow_status.md +17 -0
  66. package/sdk-dist/docs/runtime-reference/entity-schema.md +197 -0
  67. package/sdk-dist/docs/runtime-reference/examples/README.md +51 -0
  68. package/sdk-dist/docs/runtime-reference/examples/crm-01/README.md +20 -0
  69. package/sdk-dist/docs/runtime-reference/examples/mes-01/README.md +21 -0
  70. package/sdk-dist/docs/runtime-reference/examples/oms-01/README.md +183 -0
  71. package/sdk-dist/docs/runtime-reference/examples/oms-01/app-overview.md +79 -0
  72. package/sdk-dist/docs/runtime-reference/examples/oms-01/create-app.json +3 -0
  73. package/sdk-dist/docs/runtime-reference/examples/oms-01/goods-receipt.create-entity.json +3 -0
  74. package/sdk-dist/docs/runtime-reference/examples/oms-01/goods-receipt.schema.json +159 -0
  75. package/sdk-dist/docs/runtime-reference/examples/oms-01/item-master.create-entity.json +3 -0
  76. package/sdk-dist/docs/runtime-reference/examples/oms-01/item-master.schema.json +179 -0
  77. package/sdk-dist/docs/runtime-reference/examples/oms-01/picking-list.create-entity.json +3 -0
  78. package/sdk-dist/docs/runtime-reference/examples/oms-01/picking-list.schema.json +168 -0
  79. package/sdk-dist/docs/runtime-reference/examples/oms-01/purchase-order.create-entity.json +3 -0
  80. package/sdk-dist/docs/runtime-reference/examples/oms-01/purchase-order.schema.json +417 -0
  81. package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.bulk-update.json +22 -0
  82. package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.create-entity.json +3 -0
  83. package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.query.json +10 -0
  84. package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.row.insert.json +28 -0
  85. package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.schema.json +398 -0
  86. package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.selection.condition.json +13 -0
  87. package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.selection.ids.json +7 -0
  88. package/sdk-dist/docs/runtime-reference/examples/oms-01/stock-issue.create-entity.json +3 -0
  89. package/sdk-dist/docs/runtime-reference/examples/oms-01/stock-issue.schema.json +137 -0
  90. package/sdk-dist/docs/runtime-reference/examples/wms-01/README.md +21 -0
  91. package/sdk-dist/docs/runtime-reference/row-crud.md +188 -0
  92. package/sdk-dist/docs/runtime-reference/search-and-bulk-actions.md +494 -0
  93. package/sdk-dist/docs/runtime-reference/table-lifecycle.md +135 -0
  94. package/sdk-dist/docs/runtime-reference/uploads.md +106 -0
  95. package/sdk-dist/docs/runtime-reference/workflow/README.md +19 -0
  96. package/src/runtime/command_registry.ts +47 -0
  97. package/src/runtime/entity_save_schema.ts +708 -0
  98. package/src/runtime/errors.ts +12 -0
  99. package/src/runtime/execute.ts +367 -12
  100. package/src/runtime/upload.ts +196 -0
  101. package/src/runtime/zod_registry.ts +156 -0
  102. package/src/tests/command_registry.test.ts +6 -0
  103. package/src/tests/entity_save_schema.test.ts +236 -0
  104. package/src/tests/execute_validation.test.ts +781 -0
  105. package/src/tests/help.test.ts +60 -0
  106. package/src/tests/upload.test.ts +133 -0
@@ -8,12 +8,24 @@ export type CLITSTypeHint = {
8
8
  file: string
9
9
  }
10
10
 
11
+ export type CLIDocHint = {
12
+ title: string
13
+ file: string
14
+ }
15
+
11
16
  export type CLIErrorDetails = {
12
17
  operation?: string
13
18
  requestType?: string
14
19
  issues?: CLIValidationIssue[]
15
20
  tsHints?: CLITSTypeHint[]
21
+ docHints?: CLIDocHint[]
16
22
  reason?: string
23
+ hint?: string
24
+ endpoint?: string
25
+ method?: string
26
+ traceId?: string
27
+ upstreamError?: unknown
28
+ suggestions?: string[]
17
29
  }
18
30
 
19
31
  export class CLIError extends Error {
@@ -2,9 +2,10 @@ import { parseCLIArgs, hasFlag, flagValue } from './argv.js'
2
2
  import { loadRuntimeEnv, type RuntimeEnv } from './env.js'
3
3
  import { CLIError } from './errors.js'
4
4
  import { buildRequestHeaders, buildURL, normalizeExternalEndpoint } from './http.js'
5
- import { listModules, listModuleCommands, findCommand, type CommandScope } from './command_registry.js'
5
+ import { listModules, listModuleCommands, findCommand, findCommandSuggestions, type CommandScope } from './command_registry.js'
6
6
  import { loadBodyFromFlags, loadQueryFromFlags } from './body_loader.js'
7
- import { getBodySchema, getTypeHints, getUnsupportedBodySchemaReason } from './zod_registry.js'
7
+ import { getBodySchema, getCommandDocHints, getDocHints, getTypeHints, getUnsupportedBodySchemaReason } from './zod_registry.js'
8
+ import { renderUploadHelp, uploadLocalFile } from './upload.js'
8
9
 
9
10
  export type CommandExecutionSummary = {
10
11
  scope: CommandScope
@@ -16,41 +17,88 @@ export type CommandExecutionSummary = {
16
17
  scalarFlags: readonly string[]
17
18
  }
18
19
 
20
+ type CustomUploadSummary = {
21
+ kind: 'upload'
22
+ status: number
23
+ data: ReturnType<typeof summarizeUploadResult>
24
+ }
25
+
26
+ function summarizeUploadResult(value: Awaited<ReturnType<typeof uploadLocalFile>>) {
27
+ return value
28
+ }
29
+
19
30
  export function renderRootHelp(scope: CommandScope): string {
20
31
  const binary = scope === 'admin' ? 'arcubase-admin' : 'arcubase'
32
+ const items = listModules(scope).map((item) => ` - ${item}`)
21
33
  return [
22
34
  `${binary} <module> <command> [flags]`,
23
35
  '',
36
+ ...(scope === 'admin'
37
+ ? ['use arcubase-admin for app, entity shell, and schema management', '']
38
+ : ['use arcubase for row CRUD, search, selection actions, and bulk actions', '']),
24
39
  'modules:',
25
- ...listModules(scope).map((item) => ` - ${item}`),
40
+ ...items,
41
+ ...(scope === 'user' ? ['','special commands:',' - upload <local-file>'] : []),
26
42
  ].join('\n')
27
43
  }
28
44
 
29
45
  export function renderModuleHelp(scope: CommandScope, moduleName: string): string {
30
46
  const items = listModuleCommands(scope, moduleName)
31
47
  if (items.length === 0) {
32
- throw new CLIError('UNKNOWN_MODULE', `unknown module: ${moduleName}`, 2)
48
+ const suggestions = findCommandSuggestions(moduleName, '').map((item) => `${item.binary} ${item.moduleName} ${item.commandName}`)
49
+ throw new CLIError('UNKNOWN_MODULE', `unknown module: ${moduleName}`, 2, suggestions.length > 0 ? { suggestions } : undefined)
33
50
  }
34
51
  return [
35
52
  `${scope === 'admin' ? 'arcubase-admin' : 'arcubase'} ${moduleName} <command> [flags]`,
36
53
  '',
54
+ ...(scope === 'admin'
55
+ ? ['use arcubase-admin for app, entity shell, and schema management', '']
56
+ : ['use arcubase for row CRUD, search, selection actions, and bulk actions', '']),
37
57
  'commands:',
38
58
  ...items.map((item) => ` - ${item.commandPath[1]}`),
39
59
  ].join('\n')
40
60
  }
41
61
 
62
+ function buildUnknownCommandDetails(scope: CommandScope, moduleName: string, commandName: string): { suggestions?: string[]; reason?: string; hint?: string } | undefined {
63
+ const suggestions = findCommandSuggestions(moduleName, commandName).map((item) => `${item.binary} ${item.moduleName} ${item.commandName}`)
64
+ if (scope === 'admin' && moduleName === 'workflow' && suggestions.some((item) => item.startsWith('arcubase workflow '))) {
65
+ return {
66
+ suggestions,
67
+ reason: `${moduleName} ${commandName} is a row operation, not an arcubase-admin command`,
68
+ hint: `use: arcubase ${moduleName} ${commandName}`,
69
+ }
70
+ }
71
+ return suggestions.length > 0 ? { suggestions } : undefined
72
+ }
73
+
42
74
  export function renderCommandHelp(scope: CommandScope, moduleName: string, commandName: string): string {
43
75
  const command = findCommand(scope, moduleName, commandName)
44
76
  if (!command) {
45
- throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName} ${commandName}`, 2)
77
+ throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName} ${commandName}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName))
46
78
  }
79
+ const docHints = [
80
+ ...getCommandDocHints(`${scope}.${command.module}.${command.functionName}`),
81
+ ...(command.requestType ? getDocHints(command.requestType) : []),
82
+ ].filter((item, index, list) => list.findIndex((candidate) => candidate.file === item.file) === index)
47
83
  return [
48
84
  `${scope === 'admin' ? 'arcubase-admin' : 'arcubase'} ${moduleName} ${commandName}`,
49
85
  `method: ${command.method}`,
50
86
  `endpoint: ${normalizeExternalEndpoint(command.endpoint)}`,
51
87
  command.requestType ? `body: ${command.requestType} via --body-file` : 'body: none',
88
+ 'query flags: --query-file',
52
89
  command.endpointParams.length ? `path flags: ${command.endpointParams.map((item) => `--${item.replace(/_/g, '-')}`).join(', ')}` : 'path flags: none',
53
90
  command.scalarParams.length ? `scalar flags: ${command.scalarParams.map((item) => `--${item.name.replace(/([A-Z])/g, '-$1').toLowerCase()}`).join(', ')}` : 'scalar flags: none',
91
+ ...(scope === 'admin' && moduleName === 'entity' && commandName === 'admin-save-entity'
92
+ ? [
93
+ 'next step:',
94
+ ' - switch to arcubase for row operations',
95
+ ' - row insert: arcubase workflow insert-entity --app-id <app_id> --entity-id <entity_id> --body-file insert.json',
96
+ ' - row query: arcubase workflow query-entity --app-id <app_id> --entity-id <entity_id> --body-file query.json',
97
+ ]
98
+ : []),
99
+ ...(docHints.length > 0
100
+ ? ['docs:', ...docHints.map((item) => ` - ${item.title}: ${item.file}`)]
101
+ : []),
54
102
  ].join('\n')
55
103
  }
56
104
 
@@ -98,10 +146,250 @@ function buildScalarQuery(command: ReturnType<typeof findCommand>, flags: Record
98
146
  return Object.keys(out).length > 0 ? out : undefined
99
147
  }
100
148
 
149
+ function isRecord(value: unknown): value is Record<string, any> {
150
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
151
+ }
152
+
153
+ type EntityFieldInfo = {
154
+ id: number
155
+ type: string
156
+ label?: string
157
+ }
158
+
159
+ function extractEntityFields(payload: unknown): EntityFieldInfo[] {
160
+ const candidate = isRecord(payload) && isRecord(payload.data) ? payload.data : payload
161
+ if (!isRecord(candidate) || !Array.isArray(candidate.fields)) {
162
+ return []
163
+ }
164
+ return candidate.fields
165
+ .filter((item): item is Record<string, unknown> => isRecord(item))
166
+ .filter((item) => typeof item.id === 'number' && typeof item.type === 'string')
167
+ .map((item) => ({
168
+ id: item.id as number,
169
+ type: item.type as string,
170
+ label: typeof item.label === 'string' ? item.label : undefined,
171
+ }))
172
+ }
173
+
174
+ async function fetchEntityFields(
175
+ runtimeEnv: RuntimeEnv,
176
+ appId: string,
177
+ entityId: string,
178
+ fetchImpl: typeof fetch,
179
+ ): Promise<Map<number, EntityFieldInfo>> {
180
+ const response = await fetchImpl(buildURL(runtimeEnv.ARCUBASE_BASE_URL, `/entity/${encodeURIComponent(appId)}/${encodeURIComponent(entityId)}`), {
181
+ method: 'GET',
182
+ headers: buildRequestHeaders(runtimeEnv),
183
+ })
184
+ const raw = await response.text()
185
+ let parsedResponse: unknown = raw
186
+ try {
187
+ parsedResponse = raw ? JSON.parse(raw) : null
188
+ } catch {
189
+ parsedResponse = raw
190
+ }
191
+ if (!response.ok) {
192
+ throw new CLIError('UPSTREAM_REQUEST_FAILED', typeof parsedResponse === 'string' ? parsedResponse : JSON.stringify(parsedResponse), 1)
193
+ }
194
+ if (isRecord(parsedResponse) && parsedResponse.error) {
195
+ const upstreamError = parsedResponse.error
196
+ const upstreamMessage =
197
+ isRecord(upstreamError) && typeof upstreamError.message === 'string'
198
+ ? upstreamError.message
199
+ : typeof upstreamError === 'string'
200
+ ? upstreamError
201
+ : JSON.stringify(upstreamError)
202
+ throw new CLIError('UPSTREAM_BODY_ERROR', upstreamMessage, 1, {
203
+ endpoint: `/entity/${appId}/${entityId}`,
204
+ method: 'GET',
205
+ upstreamError,
206
+ })
207
+ }
208
+ const fields = extractEntityFields(parsedResponse)
209
+ return new Map(fields.map((field) => [field.id, field]))
210
+ }
211
+
212
+ function looksLikeLocalFilePath(value: string): boolean {
213
+ return (
214
+ value.startsWith('./') ||
215
+ value.startsWith('../') ||
216
+ value.startsWith('/') ||
217
+ value.startsWith('~/') ||
218
+ /\.(pdf|png|jpe?g|gif|webp|bmp|svg|heic|txt|csv|xlsx?|docx?|pptx?|zip)$/i.test(value)
219
+ )
220
+ }
221
+
222
+ function isValidUploadEntry(value: unknown): boolean {
223
+ if (!isRecord(value)) {
224
+ return false
225
+ }
226
+ const hasUploadId = typeof value.upload_id === 'number' || typeof value.upload_id === 'string'
227
+ const hasAssetsId = typeof value.assets_id === 'number' || typeof value.assets_id === 'string'
228
+ return (hasUploadId || hasAssetsId) && typeof value.file === 'string' && typeof value.file_name === 'string'
229
+ }
230
+
231
+ async function validateFileFieldPayloads(
232
+ scope: CommandScope,
233
+ command: NonNullable<ReturnType<typeof findCommand>>,
234
+ runtimeEnv: RuntimeEnv,
235
+ flags: Record<string, string | boolean>,
236
+ body: unknown,
237
+ fetchImpl: typeof fetch,
238
+ ) {
239
+ if (!isRecord(body)) {
240
+ return
241
+ }
242
+ const container = command.functionName === 'updateEntityRow' ? body.data : body.fields
243
+ if (!isRecord(container)) {
244
+ return
245
+ }
246
+ const appId = flagValue(flags, 'app-id')
247
+ const entityId = flagValue(flags, 'entity-id')
248
+ if (!appId || !entityId) {
249
+ return
250
+ }
251
+ const fieldsById = await fetchEntityFields(runtimeEnv, appId, entityId, fetchImpl)
252
+ for (const [fieldIdText, value] of Object.entries(container)) {
253
+ if (!/^\d+$/.test(fieldIdText)) {
254
+ continue
255
+ }
256
+ const fieldId = Number(fieldIdText)
257
+ const field = fieldsById.get(fieldId)
258
+ if (!field || (field.type !== 'file' && field.type !== 'image')) {
259
+ continue
260
+ }
261
+ const fieldPath = command.functionName === 'updateEntityRow' ? `body.data.${fieldId}` : `body.fields.${fieldId}`
262
+ const fieldName = field.label ? `${field.label} (${field.type})` : `${field.type} field ${fieldId}`
263
+ if (typeof value === 'string') {
264
+ const guidance = looksLikeLocalFilePath(value)
265
+ ? `do not put a local file path directly into ${fieldName}; run "arcubase upload ${value}" first and use the returned data array as the field value`
266
+ : `${fieldName} must be an array returned by "arcubase upload <local-file>", not a string`
267
+ throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
268
+ operation: `${scope}.${command.module}.${command.functionName}`,
269
+ requestType: command.requestType ?? undefined,
270
+ issues: [{ path: fieldPath, message: guidance }],
271
+ docHints: [
272
+ { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
273
+ { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
274
+ ],
275
+ })
276
+ }
277
+ if (isRecord(value)) {
278
+ const guidance = `${fieldName} must be an array. Run "arcubase upload <local-file>" and use the returned data array directly as the field value`
279
+ throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
280
+ operation: `${scope}.${command.module}.${command.functionName}`,
281
+ requestType: command.requestType ?? undefined,
282
+ issues: [{ path: fieldPath, message: guidance }],
283
+ docHints: [
284
+ { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
285
+ { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
286
+ ],
287
+ })
288
+ }
289
+ if (!Array.isArray(value)) {
290
+ const guidance = `${fieldName} must be an array returned by "arcubase upload <local-file>"`
291
+ throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
292
+ operation: `${scope}.${command.module}.${command.functionName}`,
293
+ requestType: command.requestType ?? undefined,
294
+ issues: [{ path: fieldPath, message: guidance }],
295
+ docHints: [
296
+ { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
297
+ { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
298
+ ],
299
+ })
300
+ }
301
+ for (const [index, item] of value.entries()) {
302
+ if (!isValidUploadEntry(item)) {
303
+ const guidance = `${fieldName} entries must include upload_id or assets_id plus file and file_name. Use "arcubase upload <local-file>" and copy the returned array item as-is`
304
+ throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
305
+ operation: `${scope}.${command.module}.${command.functionName}`,
306
+ requestType: command.requestType ?? undefined,
307
+ issues: [{ path: `${fieldPath}.${index}`, message: guidance }],
308
+ docHints: [
309
+ { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
310
+ { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
311
+ ],
312
+ })
313
+ }
314
+ }
315
+ }
316
+ }
317
+
318
+ function throwBodyValidationFailure(message: string, requestType: string, path: string, scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>) {
319
+ throw new CLIError('BODY_VALIDATION_FAILED', message, 2, {
320
+ operation: `${scope}.${command.module}.${command.functionName}`,
321
+ requestType,
322
+ issues: [
323
+ {
324
+ path,
325
+ message,
326
+ },
327
+ ],
328
+ tsHints: getTypeHints(requestType),
329
+ docHints: getDocHints(requestType),
330
+ })
331
+ }
332
+
333
+ function validateActionSpecificBody(
334
+ scope: CommandScope,
335
+ command: NonNullable<ReturnType<typeof findCommand>>,
336
+ flags: Record<string, string | boolean>,
337
+ body: unknown,
338
+ ) {
339
+ if (!isRecord(body) || !command.requestType) {
340
+ return
341
+ }
342
+
343
+ if (command.module === 'workflow' && command.functionName === 'queryEntitySelection') {
344
+ const action = flagValue(flags, 'action')
345
+ const selection = body.selection
346
+ if (!isRecord(selection) || typeof selection.type !== 'string') {
347
+ return
348
+ }
349
+
350
+ const type = selection.type
351
+ const condition = isRecord(selection.condition) ? selection.condition : undefined
352
+ const conditionKeys = condition ? Object.keys(condition).filter((key) => key !== 'selectAll') : []
353
+
354
+ if (action === 'query') {
355
+ if (type === 'condition') {
356
+ throwBodyValidationFailure(
357
+ 'selection.type=condition is not supported for action=query; use selection.type=ids or selection.type=all, or use workflow query-entity for search conditions',
358
+ command.requestType,
359
+ 'body.selection.type',
360
+ scope,
361
+ command,
362
+ )
363
+ }
364
+ return
365
+ }
366
+
367
+ if ((action === 'archive' || action === 'delete' || action === 'batch_print') && type === 'all') {
368
+ throwBodyValidationFailure(
369
+ `selection.type=all is not supported for action=${action}; use ids or condition`,
370
+ command.requestType,
371
+ 'body.selection.type',
372
+ scope,
373
+ command,
374
+ )
375
+ }
376
+
377
+ if ((action === 'archive' || action === 'delete') && type === 'condition' && conditionKeys.length === 0) {
378
+ throwBodyValidationFailure(
379
+ `selection.condition for action=${action} requires at least one q, tab, or filter_* key in addition to selectAll`,
380
+ command.requestType,
381
+ 'body.selection.condition',
382
+ scope,
383
+ command,
384
+ )
385
+ }
386
+ }
387
+ }
388
+
101
389
  export function summarizeCommand(scope: CommandScope, moduleName: string, commandName: string): CommandExecutionSummary {
102
390
  const command = findCommand(scope, moduleName, commandName)
103
391
  if (!command) {
104
- throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName} ${commandName}`, 2)
392
+ throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName} ${commandName}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName))
105
393
  }
106
394
  return {
107
395
  scope,
@@ -114,8 +402,33 @@ export function summarizeCommand(scope: CommandScope, moduleName: string, comman
114
402
  }
115
403
  }
116
404
 
117
- export async function executeCLI(scope: CommandScope, argv: string[], env: RuntimeEnv = loadRuntimeEnv(scope), fetchImpl: typeof fetch = fetch): Promise<any> {
405
+ export async function executeCLI(scope: CommandScope, argv: string[], env?: RuntimeEnv, fetchImpl: typeof fetch = fetch): Promise<any> {
118
406
  const parsed = parseCLIArgs(argv)
407
+ if (scope === 'user' && parsed.commandTokens[0] === 'upload') {
408
+ if (hasFlag(parsed.flags, 'help') || parsed.commandTokens.length === 1) {
409
+ return { kind: 'help', text: renderUploadHelp() }
410
+ }
411
+ const sourcePath = parsed.commandTokens[1]
412
+ if (!sourcePath) {
413
+ throw new CLIError('UPLOAD_SOURCE_REQUIRED', 'upload requires a local file path', 2)
414
+ }
415
+ const runtimeEnv = env ?? loadRuntimeEnv(scope)
416
+ const data = await uploadLocalFile(
417
+ runtimeEnv,
418
+ sourcePath,
419
+ {
420
+ filename: flagValue(parsed.flags, 'filename'),
421
+ global: hasFlag(parsed.flags, 'global'),
422
+ },
423
+ fetchImpl,
424
+ )
425
+ return {
426
+ kind: 'result',
427
+ commandPath: ['upload', sourcePath],
428
+ status: 200,
429
+ data,
430
+ }
431
+ }
119
432
  if (parsed.commandTokens.length === 0) {
120
433
  return { kind: 'help', text: renderRootHelp(scope) }
121
434
  }
@@ -130,9 +443,11 @@ export async function executeCLI(scope: CommandScope, argv: string[], env: Runti
130
443
  return { kind: 'help', text: renderCommandHelp(scope, moduleName, commandName) }
131
444
  }
132
445
 
446
+ const runtimeEnv = env ?? loadRuntimeEnv(scope)
447
+
133
448
  const command = findCommand(scope, moduleName, commandName)
134
449
  if (!command) {
135
- throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName} ${commandName}`, 2)
450
+ throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName} ${commandName}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName))
136
451
  }
137
452
 
138
453
  const endpoint = normalizeExternalEndpoint(resolveEndpoint(command.endpoint, parsed.flags))
@@ -140,6 +455,7 @@ export async function executeCLI(scope: CommandScope, argv: string[], env: Runti
140
455
  const fileQuery = loadQueryFromFlags(parsed.flags)
141
456
  const query = { ...(scalarQuery ?? {}), ...(fileQuery ?? {}) }
142
457
  const body = loadBodyFromFlags(parsed.flags)
458
+ let validatedBody = body
143
459
 
144
460
  if (command.requestType) {
145
461
  if (body === undefined) {
@@ -161,9 +477,12 @@ export async function executeCLI(scope: CommandScope, argv: string[], env: Runti
161
477
  message: issue.message,
162
478
  })),
163
479
  tsHints: getTypeHints(command.requestType),
480
+ docHints: getDocHints(command.requestType),
164
481
  }
165
482
  )
166
483
  }
484
+ validatedBody = parsedBody.data
485
+ validateActionSpecificBody(scope, command, parsed.flags, validatedBody)
167
486
  } else {
168
487
  const unsupportedReason = getUnsupportedBodySchemaReason(command.requestType)
169
488
  if (unsupportedReason) {
@@ -172,19 +491,23 @@ export async function executeCLI(scope: CommandScope, argv: string[], env: Runti
172
491
  requestType: command.requestType,
173
492
  reason: unsupportedReason,
174
493
  tsHints: getTypeHints(command.requestType),
494
+ docHints: getDocHints(command.requestType),
175
495
  })
176
496
  }
177
497
  }
178
498
  }
179
499
 
180
- const headers = buildRequestHeaders(env)
181
- const response = await fetchImpl(buildURL(env.ARCUBASE_BASE_URL, endpoint, Object.keys(query).length > 0 ? query : undefined), {
500
+ if (scope === 'user' && (command.functionName === 'insertEntity' || command.functionName === 'updateEntityRow')) {
501
+ await validateFileFieldPayloads(scope, command, runtimeEnv, parsed.flags, validatedBody, fetchImpl)
502
+ }
503
+
504
+ const response = await fetchImpl(buildURL(runtimeEnv.ARCUBASE_BASE_URL, endpoint, Object.keys(query).length > 0 ? query : undefined), {
182
505
  method: command.method,
183
506
  headers: {
184
- ...headers,
507
+ ...buildRequestHeaders(runtimeEnv),
185
508
  'Content-Type': 'application/json',
186
509
  },
187
- body: body === undefined || command.method === 'GET' ? undefined : JSON.stringify(body),
510
+ body: validatedBody === undefined || command.method === 'GET' ? undefined : JSON.stringify(validatedBody),
188
511
  })
189
512
 
190
513
  const raw = await response.text()
@@ -197,6 +520,38 @@ export async function executeCLI(scope: CommandScope, argv: string[], env: Runti
197
520
  if (!response.ok) {
198
521
  throw new CLIError('UPSTREAM_REQUEST_FAILED', typeof parsedResponse === 'string' ? parsedResponse : JSON.stringify(parsedResponse), 1)
199
522
  }
523
+ if (parsedResponse && typeof parsedResponse === 'object' && 'error' in parsedResponse && parsedResponse.error) {
524
+ const upstreamError = parsedResponse.error
525
+ const upstreamMessage =
526
+ typeof upstreamError === 'object' && upstreamError && 'message' in upstreamError && typeof upstreamError.message === 'string'
527
+ ? upstreamError.message
528
+ : typeof upstreamError === 'string'
529
+ ? upstreamError
530
+ : JSON.stringify(upstreamError)
531
+ const traceId =
532
+ typeof parsedResponse.trace_id === 'string'
533
+ ? parsedResponse.trace_id
534
+ : typeof parsedResponse.request_id === 'string'
535
+ ? parsedResponse.request_id
536
+ : undefined
537
+ const uploadBindFailure = upstreamMessage.includes('failed to bind upload to assets')
538
+ throw new CLIError('UPSTREAM_BODY_ERROR', upstreamMessage, 1, {
539
+ endpoint,
540
+ method: command.method,
541
+ traceId,
542
+ upstreamError,
543
+ ...(uploadBindFailure
544
+ ? {
545
+ reason: 'the row payload used a file/image upload entry, but Arcubase could not bind that upload into an asset record',
546
+ hint: 'verify the upload storage host is reachable from the Arcubase server, then rerun arcubase upload and retry the row insert/update',
547
+ docHints: [
548
+ { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
549
+ { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
550
+ ],
551
+ }
552
+ : {}),
553
+ })
554
+ }
200
555
  return {
201
556
  kind: 'result',
202
557
  commandPath: command.commandPath,