@carthooks/arcubase-cli 0.1.3 → 0.1.5

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 (64) hide show
  1. package/bundle/arcubase-admin.mjs +546 -243
  2. package/bundle/arcubase.mjs +546 -243
  3. package/dist/bin/arcubase-admin.js +0 -0
  4. package/dist/bin/arcubase.js +0 -0
  5. package/dist/generated/command_registry.generated.d.ts +36 -112
  6. package/dist/generated/command_registry.generated.d.ts.map +1 -1
  7. package/dist/generated/command_registry.generated.js +40 -163
  8. package/dist/generated/type_index.generated.d.ts +0 -12
  9. package/dist/generated/type_index.generated.d.ts.map +1 -1
  10. package/dist/generated/type_index.generated.js +0 -12
  11. package/dist/generated/zod_registry.generated.d.ts +1 -19
  12. package/dist/generated/zod_registry.generated.d.ts.map +1 -1
  13. package/dist/generated/zod_registry.generated.js +0 -21
  14. package/dist/runtime/command_registry.d.ts +19 -1
  15. package/dist/runtime/command_registry.d.ts.map +1 -1
  16. package/dist/runtime/command_registry.js +19 -1
  17. package/dist/runtime/errors.d.ts +1 -0
  18. package/dist/runtime/errors.d.ts.map +1 -1
  19. package/dist/runtime/execute.d.ts.map +1 -1
  20. package/dist/runtime/execute.js +321 -8
  21. package/dist/runtime/upload.d.ts +13 -0
  22. package/dist/runtime/upload.d.ts.map +1 -0
  23. package/dist/runtime/upload.js +148 -0
  24. package/dist/runtime/zod_registry.d.ts.map +1 -1
  25. package/dist/runtime/zod_registry.js +50 -30
  26. package/dist/tests/command_registry.test.js +11 -4
  27. package/dist/tests/execute_validation.test.js +221 -13
  28. package/dist/tests/help.test.js +29 -6
  29. package/dist/tests/upload.test.d.ts +2 -0
  30. package/dist/tests/upload.test.d.ts.map +1 -0
  31. package/dist/tests/upload.test.js +107 -0
  32. package/package.json +1 -1
  33. package/sdk-dist/api/admin/app.ts +8 -1
  34. package/sdk-dist/docs/runtime-reference/README.md +33 -15
  35. package/sdk-dist/docs/runtime-reference/app-discovery.md +68 -0
  36. package/sdk-dist/docs/runtime-reference/entity-schema/README.md +2 -2
  37. package/sdk-dist/docs/runtime-reference/entity-schema/file.md +9 -0
  38. package/sdk-dist/docs/runtime-reference/entity-schema/image.md +9 -0
  39. package/sdk-dist/docs/runtime-reference/entity-schema/subform.md +2 -2
  40. package/sdk-dist/docs/runtime-reference/entity-schema.md +2 -2
  41. package/sdk-dist/docs/runtime-reference/examples/README.md +1 -1
  42. package/sdk-dist/docs/runtime-reference/examples/oms-01/README.md +23 -4
  43. package/sdk-dist/docs/runtime-reference/examples/oms-01/app-overview.md +1 -1
  44. package/sdk-dist/docs/runtime-reference/row-crud.md +8 -6
  45. package/sdk-dist/docs/runtime-reference/search-and-bulk-actions.md +5 -5
  46. package/sdk-dist/docs/runtime-reference/table-lifecycle.md +34 -6
  47. package/sdk-dist/docs/runtime-reference/uploads.md +106 -0
  48. package/sdk-dist/generated/command_registry.generated.ts +40 -163
  49. package/sdk-dist/generated/type_index.generated.ts +0 -12
  50. package/sdk-dist/generated/zod_registry.generated.ts +0 -36
  51. package/sdk-dist/types/app.ts +7 -0
  52. package/src/generated/command_registry.generated.ts +40 -163
  53. package/src/generated/type_index.generated.ts +0 -12
  54. package/src/generated/zod_registry.generated.ts +0 -36
  55. package/src/runtime/command_registry.ts +38 -2
  56. package/src/runtime/errors.ts +1 -0
  57. package/src/runtime/execute.ts +368 -8
  58. package/src/runtime/upload.ts +196 -0
  59. package/src/runtime/zod_registry.ts +50 -30
  60. package/src/tests/command_registry.test.ts +13 -4
  61. package/src/tests/execute_validation.test.ts +257 -13
  62. package/src/tests/help.test.ts +35 -6
  63. package/src/tests/upload.test.ts +133 -0
  64. package/sdk-dist/docs/runtime-reference/workflow/README.md +0 -19
@@ -5,6 +5,7 @@ import { buildRequestHeaders, buildURL, normalizeExternalEndpoint } from './http
5
5
  import { listModules, listModuleCommands, findCommand, findCommandSuggestions, type CommandScope } from './command_registry.js'
6
6
  import { loadBodyFromFlags, loadQueryFromFlags } from './body_loader.js'
7
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,13 +17,136 @@ 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
+ type AppInventoryItem = {
27
+ id: string
28
+ name: string
29
+ entities: Array<{ id: string; name: string }>
30
+ }
31
+
32
+ function summarizeUploadResult(value: Awaited<ReturnType<typeof uploadLocalFile>>) {
33
+ return value
34
+ }
35
+
36
+ function parseJSONResponse(raw: string): unknown {
37
+ if (!raw) return null
38
+ try {
39
+ return JSON.parse(raw)
40
+ } catch {
41
+ return raw
42
+ }
43
+ }
44
+
45
+ function unwrapResponseData(payload: unknown): unknown {
46
+ return isRecord(payload) && 'data' in payload ? payload.data : payload
47
+ }
48
+
49
+ function formatUpstreamError(payload: unknown): string {
50
+ if (typeof payload === 'string') {
51
+ return payload
52
+ }
53
+ if (isRecord(payload) && isRecord(payload.error) && typeof payload.error.message === 'string') {
54
+ return payload.error.message
55
+ }
56
+ return JSON.stringify(payload)
57
+ }
58
+
59
+ function extractApps(payload: unknown): Array<{ id: string; name: string }> {
60
+ const data = unwrapResponseData(payload)
61
+ if (!Array.isArray(data)) return []
62
+ return data
63
+ .filter((item): item is Record<string, unknown> => isRecord(item))
64
+ .filter((item) => item.id !== undefined && typeof item.name === 'string')
65
+ .map((item) => ({
66
+ id: String(item.id),
67
+ name: item.name as string,
68
+ }))
69
+ }
70
+
71
+ function extractEntities(payload: unknown): Array<{ id: string; name: string }> {
72
+ const data = unwrapResponseData(payload)
73
+ if (!Array.isArray(data)) return []
74
+ return data
75
+ .filter((item): item is Record<string, unknown> => isRecord(item))
76
+ .filter((item) => item.id !== undefined && typeof item.name === 'string')
77
+ .map((item) => ({
78
+ id: String(item.id),
79
+ name: item.name as string,
80
+ }))
81
+ }
82
+
83
+ async function executeAppInventory(runtimeEnv: RuntimeEnv, fetchImpl: typeof fetch) {
84
+ const appsResponse = await fetchImpl(buildURL(runtimeEnv.ARCUBASE_BASE_URL, '/apps'), {
85
+ method: 'GET',
86
+ headers: buildRequestHeaders(runtimeEnv),
87
+ })
88
+ const appsRaw = await appsResponse.text()
89
+ const appsPayload = parseJSONResponse(appsRaw)
90
+ if (!appsResponse.ok) {
91
+ throw new CLIError('UPSTREAM_REQUEST_FAILED', formatUpstreamError(appsPayload), 1)
92
+ }
93
+ if (isRecord(appsPayload) && appsPayload.error) {
94
+ throw new CLIError('UPSTREAM_BODY_ERROR', formatUpstreamError(appsPayload), 1, {
95
+ endpoint: '/apps',
96
+ method: 'GET',
97
+ upstreamError: appsPayload.error,
98
+ })
99
+ }
100
+ const apps = extractApps(appsPayload)
101
+ const items: AppInventoryItem[] = []
102
+ for (const app of apps) {
103
+ const entitiesResponse = await fetchImpl(buildURL(runtimeEnv.ARCUBASE_BASE_URL, `/apps/${encodeURIComponent(app.id)}/entities`), {
104
+ method: 'POST',
105
+ headers: {
106
+ ...buildRequestHeaders(runtimeEnv),
107
+ 'Content-Type': 'application/json',
108
+ },
109
+ body: JSON.stringify({}),
110
+ })
111
+ const entitiesRaw = await entitiesResponse.text()
112
+ const entitiesPayload = parseJSONResponse(entitiesRaw)
113
+ if (!entitiesResponse.ok) {
114
+ throw new CLIError('UPSTREAM_REQUEST_FAILED', formatUpstreamError(entitiesPayload), 1)
115
+ }
116
+ if (isRecord(entitiesPayload) && entitiesPayload.error) {
117
+ throw new CLIError('UPSTREAM_BODY_ERROR', formatUpstreamError(entitiesPayload), 1, {
118
+ endpoint: `/apps/${app.id}/entities`,
119
+ method: 'POST',
120
+ upstreamError: entitiesPayload.error,
121
+ })
122
+ }
123
+ items.push({
124
+ ...app,
125
+ entities: extractEntities(entitiesPayload),
126
+ })
127
+ }
128
+ return {
129
+ kind: 'result',
130
+ commandPath: ['app', 'inventory'] as const,
131
+ status: 200,
132
+ data: {
133
+ apps: items,
134
+ },
135
+ }
136
+ }
137
+
19
138
  export function renderRootHelp(scope: CommandScope): string {
20
139
  const binary = scope === 'admin' ? 'arcubase-admin' : 'arcubase'
140
+ const items = listModules(scope).map((item) => ` - ${item}`)
21
141
  return [
22
142
  `${binary} <module> <command> [flags]`,
23
143
  '',
144
+ ...(scope === 'admin'
145
+ ? ['use arcubase-admin for app, entity shell, and schema management', '']
146
+ : ['use arcubase for rows, search, selection actions, and bulk actions', '']),
24
147
  'modules:',
25
- ...listModules(scope).map((item) => ` - ${item}`),
148
+ ...items,
149
+ ...(scope === 'user' ? ['','special commands:',' - upload <local-file>'] : []),
26
150
  ].join('\n')
27
151
  }
28
152
 
@@ -35,16 +159,30 @@ export function renderModuleHelp(scope: CommandScope, moduleName: string): strin
35
159
  return [
36
160
  `${scope === 'admin' ? 'arcubase-admin' : 'arcubase'} ${moduleName} <command> [flags]`,
37
161
  '',
162
+ ...(scope === 'admin'
163
+ ? ['use arcubase-admin for app, entity shell, and schema management', '']
164
+ : ['use arcubase for rows, search, selection actions, and bulk actions', '']),
38
165
  'commands:',
39
166
  ...items.map((item) => ` - ${item.commandPath[1]}`),
40
167
  ].join('\n')
41
168
  }
42
169
 
170
+ function buildUnknownCommandDetails(scope: CommandScope, moduleName: string, commandName: string): { suggestions?: string[]; reason?: string; hint?: string } | undefined {
171
+ const suggestions = findCommandSuggestions(moduleName, commandName).map((item) => `${item.binary} ${item.moduleName} ${item.commandName}`)
172
+ if (scope === 'admin' && moduleName === 'workflow' && suggestions.some((item) => item.startsWith('arcubase rows '))) {
173
+ return {
174
+ suggestions,
175
+ reason: `${moduleName} ${commandName} is not a valid command group; use arcubase rows for row operations`,
176
+ hint: `use: arcubase rows ${commandName}`,
177
+ }
178
+ }
179
+ return suggestions.length > 0 ? { suggestions } : undefined
180
+ }
181
+
43
182
  export function renderCommandHelp(scope: CommandScope, moduleName: string, commandName: string): string {
44
183
  const command = findCommand(scope, moduleName, commandName)
45
184
  if (!command) {
46
- const suggestions = findCommandSuggestions(moduleName, commandName).map((item) => `${item.binary} ${item.moduleName} ${item.commandName}`)
47
- throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName} ${commandName}`, 2, suggestions.length > 0 ? { suggestions } : undefined)
185
+ throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName} ${commandName}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName))
48
186
  }
49
187
  const docHints = [
50
188
  ...getCommandDocHints(`${scope}.${command.module}.${command.functionName}`),
@@ -58,6 +196,21 @@ export function renderCommandHelp(scope: CommandScope, moduleName: string, comma
58
196
  'query flags: --query-file',
59
197
  command.endpointParams.length ? `path flags: ${command.endpointParams.map((item) => `--${item.replace(/_/g, '-')}`).join(', ')}` : 'path flags: none',
60
198
  command.scalarParams.length ? `scalar flags: ${command.scalarParams.map((item) => `--${item.name.replace(/([A-Z])/g, '-$1').toLowerCase()}`).join(', ')}` : 'scalar flags: none',
199
+ ...(scope === 'admin' && moduleName === 'entity' && commandName === 'admin-save-entity'
200
+ ? [
201
+ 'next step:',
202
+ ' - switch to arcubase for row operations',
203
+ ' - row insert: arcubase rows insert-entity --app-id <app_id> --entity-id <entity_id> --body-file insert.json',
204
+ ' - row query: arcubase rows query-entity --app-id <app_id> --entity-id <entity_id> --body-file query.json',
205
+ ]
206
+ : []),
207
+ ...(scope === 'admin' && moduleName === 'app' && commandName === 'inventory'
208
+ ? [
209
+ 'purpose:',
210
+ ' - list all accessible apps and each app entity list in one call sequence',
211
+ ' - use this before asking the user for app_id',
212
+ ]
213
+ : []),
61
214
  ...(docHints.length > 0
62
215
  ? ['docs:', ...docHints.map((item) => ` - ${item.title}: ${item.file}`)]
63
216
  : []),
@@ -112,6 +265,171 @@ function isRecord(value: unknown): value is Record<string, any> {
112
265
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
113
266
  }
114
267
 
268
+ type EntityFieldInfo = {
269
+ id: number
270
+ type: string
271
+ label?: string
272
+ }
273
+
274
+ function extractEntityFields(payload: unknown): EntityFieldInfo[] {
275
+ const candidate = isRecord(payload) && isRecord(payload.data) ? payload.data : payload
276
+ if (!isRecord(candidate) || !Array.isArray(candidate.fields)) {
277
+ return []
278
+ }
279
+ return candidate.fields
280
+ .filter((item): item is Record<string, unknown> => isRecord(item))
281
+ .filter((item) => typeof item.id === 'number' && typeof item.type === 'string')
282
+ .map((item) => ({
283
+ id: item.id as number,
284
+ type: item.type as string,
285
+ label: typeof item.label === 'string' ? item.label : undefined,
286
+ }))
287
+ }
288
+
289
+ async function fetchEntityFields(
290
+ runtimeEnv: RuntimeEnv,
291
+ appId: string,
292
+ entityId: string,
293
+ fetchImpl: typeof fetch,
294
+ ): Promise<Map<number, EntityFieldInfo>> {
295
+ const response = await fetchImpl(buildURL(runtimeEnv.ARCUBASE_BASE_URL, `/entity/${encodeURIComponent(appId)}/${encodeURIComponent(entityId)}`), {
296
+ method: 'GET',
297
+ headers: buildRequestHeaders(runtimeEnv),
298
+ })
299
+ const raw = await response.text()
300
+ let parsedResponse: unknown = raw
301
+ try {
302
+ parsedResponse = raw ? JSON.parse(raw) : null
303
+ } catch {
304
+ parsedResponse = raw
305
+ }
306
+ if (!response.ok) {
307
+ throw new CLIError('UPSTREAM_REQUEST_FAILED', typeof parsedResponse === 'string' ? parsedResponse : JSON.stringify(parsedResponse), 1)
308
+ }
309
+ if (isRecord(parsedResponse) && parsedResponse.error) {
310
+ const upstreamError = parsedResponse.error
311
+ const upstreamMessage =
312
+ isRecord(upstreamError) && typeof upstreamError.message === 'string'
313
+ ? upstreamError.message
314
+ : typeof upstreamError === 'string'
315
+ ? upstreamError
316
+ : JSON.stringify(upstreamError)
317
+ throw new CLIError('UPSTREAM_BODY_ERROR', upstreamMessage, 1, {
318
+ endpoint: `/entity/${appId}/${entityId}`,
319
+ method: 'GET',
320
+ upstreamError,
321
+ })
322
+ }
323
+ const fields = extractEntityFields(parsedResponse)
324
+ return new Map(fields.map((field) => [field.id, field]))
325
+ }
326
+
327
+ function looksLikeLocalFilePath(value: string): boolean {
328
+ return (
329
+ value.startsWith('./') ||
330
+ value.startsWith('../') ||
331
+ value.startsWith('/') ||
332
+ value.startsWith('~/') ||
333
+ /\.(pdf|png|jpe?g|gif|webp|bmp|svg|heic|txt|csv|xlsx?|docx?|pptx?|zip)$/i.test(value)
334
+ )
335
+ }
336
+
337
+ function isValidUploadEntry(value: unknown): boolean {
338
+ if (!isRecord(value)) {
339
+ return false
340
+ }
341
+ const hasUploadId = typeof value.upload_id === 'number' || typeof value.upload_id === 'string'
342
+ const hasAssetsId = typeof value.assets_id === 'number' || typeof value.assets_id === 'string'
343
+ return (hasUploadId || hasAssetsId) && typeof value.file === 'string' && typeof value.file_name === 'string'
344
+ }
345
+
346
+ async function validateFileFieldPayloads(
347
+ scope: CommandScope,
348
+ command: NonNullable<ReturnType<typeof findCommand>>,
349
+ runtimeEnv: RuntimeEnv,
350
+ flags: Record<string, string | boolean>,
351
+ body: unknown,
352
+ fetchImpl: typeof fetch,
353
+ ) {
354
+ if (!isRecord(body)) {
355
+ return
356
+ }
357
+ const container = command.functionName === 'updateEntityRow' ? body.data : body.fields
358
+ if (!isRecord(container)) {
359
+ return
360
+ }
361
+ const appId = flagValue(flags, 'app-id')
362
+ const entityId = flagValue(flags, 'entity-id')
363
+ if (!appId || !entityId) {
364
+ return
365
+ }
366
+ const fieldsById = await fetchEntityFields(runtimeEnv, appId, entityId, fetchImpl)
367
+ for (const [fieldIdText, value] of Object.entries(container)) {
368
+ if (!/^\d+$/.test(fieldIdText)) {
369
+ continue
370
+ }
371
+ const fieldId = Number(fieldIdText)
372
+ const field = fieldsById.get(fieldId)
373
+ if (!field || (field.type !== 'file' && field.type !== 'image')) {
374
+ continue
375
+ }
376
+ const fieldPath = command.functionName === 'updateEntityRow' ? `body.data.${fieldId}` : `body.fields.${fieldId}`
377
+ const fieldName = field.label ? `${field.label} (${field.type})` : `${field.type} field ${fieldId}`
378
+ if (typeof value === 'string') {
379
+ const guidance = looksLikeLocalFilePath(value)
380
+ ? `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`
381
+ : `${fieldName} must be an array returned by "arcubase upload <local-file>", not a string`
382
+ throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
383
+ operation: `${scope}.${command.module}.${command.functionName}`,
384
+ requestType: command.requestType ?? undefined,
385
+ issues: [{ path: fieldPath, message: guidance }],
386
+ docHints: [
387
+ { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
388
+ { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
389
+ ],
390
+ })
391
+ }
392
+ if (isRecord(value)) {
393
+ const guidance = `${fieldName} must be an array. Run "arcubase upload <local-file>" and use the returned data array directly as the field value`
394
+ throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
395
+ operation: `${scope}.${command.module}.${command.functionName}`,
396
+ requestType: command.requestType ?? undefined,
397
+ issues: [{ path: fieldPath, message: guidance }],
398
+ docHints: [
399
+ { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
400
+ { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
401
+ ],
402
+ })
403
+ }
404
+ if (!Array.isArray(value)) {
405
+ const guidance = `${fieldName} must be an array returned by "arcubase upload <local-file>"`
406
+ throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
407
+ operation: `${scope}.${command.module}.${command.functionName}`,
408
+ requestType: command.requestType ?? undefined,
409
+ issues: [{ path: fieldPath, message: guidance }],
410
+ docHints: [
411
+ { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
412
+ { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
413
+ ],
414
+ })
415
+ }
416
+ for (const [index, item] of value.entries()) {
417
+ if (!isValidUploadEntry(item)) {
418
+ 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`
419
+ throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
420
+ operation: `${scope}.${command.module}.${command.functionName}`,
421
+ requestType: command.requestType ?? undefined,
422
+ issues: [{ path: `${fieldPath}.${index}`, message: guidance }],
423
+ docHints: [
424
+ { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
425
+ { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
426
+ ],
427
+ })
428
+ }
429
+ }
430
+ }
431
+ }
432
+
115
433
  function throwBodyValidationFailure(message: string, requestType: string, path: string, scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>) {
116
434
  throw new CLIError('BODY_VALIDATION_FAILED', message, 2, {
117
435
  operation: `${scope}.${command.module}.${command.functionName}`,
@@ -137,7 +455,7 @@ function validateActionSpecificBody(
137
455
  return
138
456
  }
139
457
 
140
- if (command.module === 'workflow' && command.functionName === 'queryEntitySelection') {
458
+ if (command.module === 'rows' && command.functionName === 'queryEntitySelection') {
141
459
  const action = flagValue(flags, 'action')
142
460
  const selection = body.selection
143
461
  if (!isRecord(selection) || typeof selection.type !== 'string') {
@@ -151,7 +469,7 @@ function validateActionSpecificBody(
151
469
  if (action === 'query') {
152
470
  if (type === 'condition') {
153
471
  throwBodyValidationFailure(
154
- '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',
472
+ 'selection.type=condition is not supported for action=query; use selection.type=ids or selection.type=all, or use rows query-entity for search conditions',
155
473
  command.requestType,
156
474
  'body.selection.type',
157
475
  scope,
@@ -186,8 +504,7 @@ function validateActionSpecificBody(
186
504
  export function summarizeCommand(scope: CommandScope, moduleName: string, commandName: string): CommandExecutionSummary {
187
505
  const command = findCommand(scope, moduleName, commandName)
188
506
  if (!command) {
189
- const suggestions = findCommandSuggestions(moduleName, commandName).map((item) => `${item.binary} ${item.moduleName} ${item.commandName}`)
190
- throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName} ${commandName}`, 2, suggestions.length > 0 ? { suggestions } : undefined)
507
+ throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName} ${commandName}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName))
191
508
  }
192
509
  return {
193
510
  scope,
@@ -202,6 +519,31 @@ export function summarizeCommand(scope: CommandScope, moduleName: string, comman
202
519
 
203
520
  export async function executeCLI(scope: CommandScope, argv: string[], env?: RuntimeEnv, fetchImpl: typeof fetch = fetch): Promise<any> {
204
521
  const parsed = parseCLIArgs(argv)
522
+ if (scope === 'user' && parsed.commandTokens[0] === 'upload') {
523
+ if (hasFlag(parsed.flags, 'help') || parsed.commandTokens.length === 1) {
524
+ return { kind: 'help', text: renderUploadHelp() }
525
+ }
526
+ const sourcePath = parsed.commandTokens[1]
527
+ if (!sourcePath) {
528
+ throw new CLIError('UPLOAD_SOURCE_REQUIRED', 'upload requires a local file path', 2)
529
+ }
530
+ const runtimeEnv = env ?? loadRuntimeEnv(scope)
531
+ const data = await uploadLocalFile(
532
+ runtimeEnv,
533
+ sourcePath,
534
+ {
535
+ filename: flagValue(parsed.flags, 'filename'),
536
+ global: hasFlag(parsed.flags, 'global'),
537
+ },
538
+ fetchImpl,
539
+ )
540
+ return {
541
+ kind: 'result',
542
+ commandPath: ['upload', sourcePath],
543
+ status: 200,
544
+ data,
545
+ }
546
+ }
205
547
  if (parsed.commandTokens.length === 0) {
206
548
  return { kind: 'help', text: renderRootHelp(scope) }
207
549
  }
@@ -220,7 +562,10 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
220
562
 
221
563
  const command = findCommand(scope, moduleName, commandName)
222
564
  if (!command) {
223
- throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName} ${commandName}`, 2)
565
+ throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName} ${commandName}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName))
566
+ }
567
+ if (scope === 'admin' && moduleName === 'app' && commandName === 'inventory') {
568
+ return executeAppInventory(runtimeEnv, fetchImpl)
224
569
  }
225
570
 
226
571
  const endpoint = normalizeExternalEndpoint(resolveEndpoint(command.endpoint, parsed.flags))
@@ -270,6 +615,10 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
270
615
  }
271
616
  }
272
617
 
618
+ if (scope === 'user' && (command.functionName === 'insertEntity' || command.functionName === 'updateEntityRow')) {
619
+ await validateFileFieldPayloads(scope, command, runtimeEnv, parsed.flags, validatedBody, fetchImpl)
620
+ }
621
+
273
622
  const response = await fetchImpl(buildURL(runtimeEnv.ARCUBASE_BASE_URL, endpoint, Object.keys(query).length > 0 ? query : undefined), {
274
623
  method: command.method,
275
624
  headers: {
@@ -303,11 +652,22 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
303
652
  : typeof parsedResponse.request_id === 'string'
304
653
  ? parsedResponse.request_id
305
654
  : undefined
655
+ const uploadBindFailure = upstreamMessage.includes('failed to bind upload to assets')
306
656
  throw new CLIError('UPSTREAM_BODY_ERROR', upstreamMessage, 1, {
307
657
  endpoint,
308
658
  method: command.method,
309
659
  traceId,
310
660
  upstreamError,
661
+ ...(uploadBindFailure
662
+ ? {
663
+ reason: 'the row payload used a file/image upload entry, but Arcubase could not bind that upload into an asset record',
664
+ hint: 'verify the upload storage host is reachable from the Arcubase server, then rerun arcubase upload and retry the row insert/update',
665
+ docHints: [
666
+ { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
667
+ { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
668
+ ],
669
+ }
670
+ : {}),
311
671
  })
312
672
  }
313
673
  return {
@@ -0,0 +1,196 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import { CLIError } from './errors.js'
4
+ import type { RuntimeEnv } from './env.js'
5
+ import { buildRequestHeaders, buildURL } from './http.js'
6
+
7
+ type UploadTokenResponse = {
8
+ data?: {
9
+ id?: number | string
10
+ mode?: string
11
+ token_data?: {
12
+ dir?: string
13
+ policy?: string
14
+ host?: string
15
+ accessid?: string
16
+ signature?: string
17
+ x_amz_algorithm?: string
18
+ x_amz_credential?: string
19
+ x_amz_date?: string
20
+ x_amz_signature?: string
21
+ }
22
+ }
23
+ error?: unknown
24
+ trace_id?: string
25
+ }
26
+
27
+ export type ArcubaseUploadResult = {
28
+ upload_id: number | string
29
+ file: string
30
+ file_name: string
31
+ meta: Record<string, never>
32
+ }
33
+
34
+ function readUploadSource(sourcePath: string): { absolutePath: string; bytes: Buffer } {
35
+ const absolutePath = path.resolve(sourcePath)
36
+ if (!fs.existsSync(absolutePath)) {
37
+ throw new CLIError('UPLOAD_SOURCE_NOT_FOUND', `upload source file does not exist: ${sourcePath}`, 2)
38
+ }
39
+ const stat = fs.statSync(absolutePath)
40
+ if (!stat.isFile()) {
41
+ throw new CLIError('UPLOAD_SOURCE_INVALID', `upload source path must be a file: ${sourcePath}`, 2)
42
+ }
43
+ return {
44
+ absolutePath,
45
+ bytes: fs.readFileSync(absolutePath),
46
+ }
47
+ }
48
+
49
+ function parseJSONResponse(raw: string): unknown {
50
+ try {
51
+ return raw ? JSON.parse(raw) : null
52
+ } catch {
53
+ return raw
54
+ }
55
+ }
56
+
57
+ function requireTokenPayload(payload: UploadTokenResponse): NonNullable<UploadTokenResponse['data']> {
58
+ if (payload && typeof payload === 'object' && payload.error) {
59
+ const upstreamError = payload.error
60
+ const message =
61
+ typeof upstreamError === 'object' && upstreamError && 'message' in upstreamError && typeof (upstreamError as any).message === 'string'
62
+ ? (upstreamError as any).message
63
+ : typeof upstreamError === 'string'
64
+ ? upstreamError
65
+ : JSON.stringify(upstreamError)
66
+ throw new CLIError('UPSTREAM_BODY_ERROR', message, 1, {
67
+ endpoint: '/upload/token',
68
+ method: 'POST',
69
+ traceId: typeof payload.trace_id === 'string' ? payload.trace_id : undefined,
70
+ upstreamError,
71
+ })
72
+ }
73
+ if (!payload?.data || typeof payload.data !== 'object') {
74
+ throw new CLIError('UPLOAD_TOKEN_INVALID', 'upload token response did not include data', 1)
75
+ }
76
+ return payload.data
77
+ }
78
+
79
+ export function renderUploadHelp(): string {
80
+ return [
81
+ 'arcubase upload <local-file> [flags]',
82
+ '',
83
+ 'purpose:',
84
+ ' - upload one local file through Arcubase',
85
+ ' - return a field value that can be used directly in file or image row payloads',
86
+ '',
87
+ 'flags:',
88
+ ' --filename <name> override the uploaded filename stored in Arcubase',
89
+ ' --global request a global upload token instead of a tenant-scoped token',
90
+ '',
91
+ 'result shape:',
92
+ ' - data is a JSON array',
93
+ ' - use that array directly as the value of a file or image field in insert/update payloads',
94
+ '',
95
+ 'example:',
96
+ ' arcubase upload ./contract.pdf',
97
+ ' arcubase upload ./photo.jpg --filename lead-photo.jpg',
98
+ '',
99
+ 'docs:',
100
+ ' - Uploads: /opt/arcubase-sdk/docs/runtime-reference/uploads.md',
101
+ ' - Row CRUD: /opt/arcubase-sdk/docs/runtime-reference/row-crud.md',
102
+ ' - File field: /opt/arcubase-sdk/docs/runtime-reference/entity-schema/file.md',
103
+ ' - Image field: /opt/arcubase-sdk/docs/runtime-reference/entity-schema/image.md',
104
+ ].join('\n')
105
+ }
106
+
107
+ export async function uploadLocalFile(
108
+ env: RuntimeEnv,
109
+ sourcePath: string,
110
+ options: {
111
+ filename?: string
112
+ global?: boolean
113
+ },
114
+ fetchImpl: typeof fetch = fetch,
115
+ ): Promise<ArcubaseUploadResult[]> {
116
+ const { absolutePath, bytes } = readUploadSource(sourcePath)
117
+ const filename = (options.filename && options.filename.trim()) || path.basename(absolutePath)
118
+
119
+ const tokenResponse = await fetchImpl(buildURL(env.ARCUBASE_BASE_URL, '/upload/token'), {
120
+ method: 'POST',
121
+ headers: {
122
+ ...buildRequestHeaders(env),
123
+ 'Content-Type': 'application/json',
124
+ },
125
+ body: JSON.stringify({
126
+ global: Boolean(options.global),
127
+ }),
128
+ })
129
+
130
+ const tokenRaw = await tokenResponse.text()
131
+ const tokenParsed = parseJSONResponse(tokenRaw)
132
+ if (!tokenResponse.ok) {
133
+ throw new CLIError('UPLOAD_TOKEN_REQUEST_FAILED', typeof tokenParsed === 'string' ? tokenParsed : JSON.stringify(tokenParsed), 1, {
134
+ endpoint: '/upload/token',
135
+ method: 'POST',
136
+ })
137
+ }
138
+
139
+ const tokenData = requireTokenPayload(tokenParsed as UploadTokenResponse)
140
+ if (tokenData.mode !== 'aliyun-oss' && tokenData.mode !== 's3-post-policy') {
141
+ throw new CLIError('UPLOAD_MODE_UNSUPPORTED', `unsupported upload mode: ${String(tokenData.mode || '')}`, 1)
142
+ }
143
+ if (!tokenData.token_data?.dir || !tokenData.token_data.policy || !tokenData.token_data.host) {
144
+ throw new CLIError('UPLOAD_TOKEN_INVALID', 'upload token response is missing required token_data fields', 1)
145
+ }
146
+ if (tokenData.id === undefined || tokenData.id === null || tokenData.id === '') {
147
+ throw new CLIError('UPLOAD_TOKEN_INVALID', 'upload token response is missing id', 1)
148
+ }
149
+
150
+ const form = new FormData()
151
+ form.append('key', `${tokenData.token_data.dir}${filename}`)
152
+ form.append('policy', tokenData.token_data.policy)
153
+ if (tokenData.mode === 'aliyun-oss') {
154
+ if (!tokenData.token_data.accessid || !tokenData.token_data.signature) {
155
+ throw new CLIError('UPLOAD_TOKEN_INVALID', 'upload token response is missing required token_data fields', 1)
156
+ }
157
+ form.append('OSSAccessKeyId', tokenData.token_data.accessid)
158
+ form.append('success_action_status', '200')
159
+ form.append('signature', tokenData.token_data.signature)
160
+ } else {
161
+ if (
162
+ !tokenData.token_data.x_amz_algorithm ||
163
+ !tokenData.token_data.x_amz_credential ||
164
+ !tokenData.token_data.x_amz_date ||
165
+ !tokenData.token_data.x_amz_signature
166
+ ) {
167
+ throw new CLIError('UPLOAD_TOKEN_INVALID', 'upload token response is missing required s3 token_data fields', 1)
168
+ }
169
+ form.append('x-amz-algorithm', tokenData.token_data.x_amz_algorithm)
170
+ form.append('x-amz-credential', tokenData.token_data.x_amz_credential)
171
+ form.append('x-amz-date', tokenData.token_data.x_amz_date)
172
+ form.append('x-amz-signature', tokenData.token_data.x_amz_signature)
173
+ }
174
+ form.append('file', new Blob([new Uint8Array(bytes)]), filename)
175
+
176
+ const uploadResponse = await fetchImpl(tokenData.token_data.host, {
177
+ method: 'POST',
178
+ body: form,
179
+ })
180
+ const uploadRaw = await uploadResponse.text()
181
+ if (!uploadResponse.ok) {
182
+ throw new CLIError('UPLOAD_TRANSFER_FAILED', uploadRaw || `upload failed with status ${uploadResponse.status}`, 1, {
183
+ endpoint: tokenData.token_data.host,
184
+ method: 'POST',
185
+ })
186
+ }
187
+
188
+ return [
189
+ {
190
+ upload_id: tokenData.id,
191
+ file: filename,
192
+ file_name: filename,
193
+ meta: {},
194
+ },
195
+ ]
196
+ }