@carthooks/arcubase-cli 0.1.3 → 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 (33) hide show
  1. package/bundle/arcubase-admin.mjs +366 -41
  2. package/bundle/arcubase.mjs +366 -41
  3. package/dist/bin/arcubase-admin.js +0 -0
  4. package/dist/bin/arcubase.js +0 -0
  5. package/dist/runtime/errors.d.ts +1 -0
  6. package/dist/runtime/errors.d.ts.map +1 -1
  7. package/dist/runtime/execute.d.ts.map +1 -1
  8. package/dist/runtime/execute.js +209 -6
  9. package/dist/runtime/upload.d.ts +13 -0
  10. package/dist/runtime/upload.d.ts.map +1 -0
  11. package/dist/runtime/upload.js +148 -0
  12. package/dist/runtime/zod_registry.d.ts.map +1 -1
  13. package/dist/runtime/zod_registry.js +27 -24
  14. package/dist/tests/execute_validation.test.js +153 -0
  15. package/dist/tests/help.test.js +18 -2
  16. package/dist/tests/upload.test.d.ts +2 -0
  17. package/dist/tests/upload.test.d.ts.map +1 -0
  18. package/dist/tests/upload.test.js +107 -0
  19. package/package.json +1 -1
  20. package/sdk-dist/docs/runtime-reference/README.md +11 -1
  21. package/sdk-dist/docs/runtime-reference/entity-schema/file.md +9 -0
  22. package/sdk-dist/docs/runtime-reference/entity-schema/image.md +9 -0
  23. package/sdk-dist/docs/runtime-reference/examples/oms-01/README.md +19 -0
  24. package/sdk-dist/docs/runtime-reference/row-crud.md +2 -0
  25. package/sdk-dist/docs/runtime-reference/table-lifecycle.md +12 -0
  26. package/sdk-dist/docs/runtime-reference/uploads.md +106 -0
  27. package/src/runtime/errors.ts +1 -0
  28. package/src/runtime/execute.ts +248 -6
  29. package/src/runtime/upload.ts +196 -0
  30. package/src/runtime/zod_registry.ts +27 -25
  31. package/src/tests/execute_validation.test.ts +183 -0
  32. package/src/tests/help.test.ts +23 -2
  33. package/src/tests/upload.test.ts +133 -0
@@ -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,28 @@ 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
 
@@ -35,16 +51,30 @@ export function renderModuleHelp(scope: CommandScope, moduleName: string): strin
35
51
  return [
36
52
  `${scope === 'admin' ? 'arcubase-admin' : 'arcubase'} ${moduleName} <command> [flags]`,
37
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', '']),
38
57
  'commands:',
39
58
  ...items.map((item) => ` - ${item.commandPath[1]}`),
40
59
  ].join('\n')
41
60
  }
42
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
+
43
74
  export function renderCommandHelp(scope: CommandScope, moduleName: string, commandName: string): string {
44
75
  const command = findCommand(scope, moduleName, commandName)
45
76
  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)
77
+ throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName} ${commandName}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName))
48
78
  }
49
79
  const docHints = [
50
80
  ...getCommandDocHints(`${scope}.${command.module}.${command.functionName}`),
@@ -58,6 +88,14 @@ export function renderCommandHelp(scope: CommandScope, moduleName: string, comma
58
88
  'query flags: --query-file',
59
89
  command.endpointParams.length ? `path flags: ${command.endpointParams.map((item) => `--${item.replace(/_/g, '-')}`).join(', ')}` : 'path flags: none',
60
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
+ : []),
61
99
  ...(docHints.length > 0
62
100
  ? ['docs:', ...docHints.map((item) => ` - ${item.title}: ${item.file}`)]
63
101
  : []),
@@ -112,6 +150,171 @@ function isRecord(value: unknown): value is Record<string, any> {
112
150
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
113
151
  }
114
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
+
115
318
  function throwBodyValidationFailure(message: string, requestType: string, path: string, scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>) {
116
319
  throw new CLIError('BODY_VALIDATION_FAILED', message, 2, {
117
320
  operation: `${scope}.${command.module}.${command.functionName}`,
@@ -186,8 +389,7 @@ function validateActionSpecificBody(
186
389
  export function summarizeCommand(scope: CommandScope, moduleName: string, commandName: string): CommandExecutionSummary {
187
390
  const command = findCommand(scope, moduleName, commandName)
188
391
  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)
392
+ throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName} ${commandName}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName))
191
393
  }
192
394
  return {
193
395
  scope,
@@ -202,6 +404,31 @@ export function summarizeCommand(scope: CommandScope, moduleName: string, comman
202
404
 
203
405
  export async function executeCLI(scope: CommandScope, argv: string[], env?: RuntimeEnv, fetchImpl: typeof fetch = fetch): Promise<any> {
204
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
+ }
205
432
  if (parsed.commandTokens.length === 0) {
206
433
  return { kind: 'help', text: renderRootHelp(scope) }
207
434
  }
@@ -220,7 +447,7 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
220
447
 
221
448
  const command = findCommand(scope, moduleName, commandName)
222
449
  if (!command) {
223
- 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))
224
451
  }
225
452
 
226
453
  const endpoint = normalizeExternalEndpoint(resolveEndpoint(command.endpoint, parsed.flags))
@@ -270,6 +497,10 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
270
497
  }
271
498
  }
272
499
 
500
+ if (scope === 'user' && (command.functionName === 'insertEntity' || command.functionName === 'updateEntityRow')) {
501
+ await validateFileFieldPayloads(scope, command, runtimeEnv, parsed.flags, validatedBody, fetchImpl)
502
+ }
503
+
273
504
  const response = await fetchImpl(buildURL(runtimeEnv.ARCUBASE_BASE_URL, endpoint, Object.keys(query).length > 0 ? query : undefined), {
274
505
  method: command.method,
275
506
  headers: {
@@ -303,11 +534,22 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
303
534
  : typeof parsedResponse.request_id === 'string'
304
535
  ? parsedResponse.request_id
305
536
  : undefined
537
+ const uploadBindFailure = upstreamMessage.includes('failed to bind upload to assets')
306
538
  throw new CLIError('UPSTREAM_BODY_ERROR', upstreamMessage, 1, {
307
539
  endpoint,
308
540
  method: command.method,
309
541
  traceId,
310
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
+ : {}),
311
553
  })
312
554
  }
313
555
  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
+ }
@@ -67,55 +67,53 @@ const examplesIndexDocHint: RuntimeDocHint = {
67
67
  file: '/opt/arcubase-sdk/docs/runtime-reference/examples/README.md',
68
68
  }
69
69
 
70
- const omsExampleDocHint: RuntimeDocHint = {
71
- title: 'OMS example 01',
72
- file: '/opt/arcubase-sdk/docs/runtime-reference/examples/oms-01/README.md',
73
- }
74
-
75
70
  const docHintIndex: Record<string, RuntimeDocHint[]> = {
76
71
  AppCreateByTenantsReqVO: [
77
72
  { title: 'Table lifecycle', file: '/opt/arcubase-sdk/docs/runtime-reference/table-lifecycle.md' },
78
73
  examplesIndexDocHint,
79
- omsExampleDocHint,
80
74
  ],
81
75
  AppCreateEntityReqVO: [
82
76
  { title: 'Table lifecycle', file: '/opt/arcubase-sdk/docs/runtime-reference/table-lifecycle.md' },
83
77
  examplesIndexDocHint,
84
- omsExampleDocHint,
85
78
  ],
86
79
  EntitySaveReqVO: [
87
80
  { title: 'Entity schema', file: '/opt/arcubase-sdk/docs/runtime-reference/entity-schema.md' },
88
- omsExampleDocHint,
81
+ examplesIndexDocHint,
89
82
  ],
90
83
  EntityQueryReqVO: [
91
84
  { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
92
85
  { title: 'Search and bulk actions', file: '/opt/arcubase-sdk/docs/runtime-reference/search-and-bulk-actions.md' },
93
- omsExampleDocHint,
86
+ examplesIndexDocHint,
94
87
  ],
95
88
  EntityUpdateReqVO: [
96
89
  { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
97
- omsExampleDocHint,
90
+ { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
91
+ examplesIndexDocHint,
98
92
  ],
99
93
  EntitySelectionActionReqVO: [
100
94
  { title: 'Search and bulk actions', file: '/opt/arcubase-sdk/docs/runtime-reference/search-and-bulk-actions.md' },
101
- omsExampleDocHint,
95
+ examplesIndexDocHint,
102
96
  ],
103
97
  InsertReqVO: [
104
98
  { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
105
- omsExampleDocHint,
99
+ { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
100
+ examplesIndexDocHint,
106
101
  ],
107
102
  EntityBulkUpdateReqVO: [
108
103
  { title: 'Search and bulk actions', file: '/opt/arcubase-sdk/docs/runtime-reference/search-and-bulk-actions.md' },
109
- omsExampleDocHint,
104
+ examplesIndexDocHint,
110
105
  ],
111
106
  'EntityInvokeBatchOperatorReqVO & InvokeRequestVO': [
112
107
  { title: 'Search and bulk actions', file: '/opt/arcubase-sdk/docs/runtime-reference/search-and-bulk-actions.md' },
108
+ examplesIndexDocHint,
113
109
  ],
114
110
  '{ policy_id: string selection: any }': [
115
111
  { title: 'Search and bulk actions', file: '/opt/arcubase-sdk/docs/runtime-reference/search-and-bulk-actions.md' },
112
+ examplesIndexDocHint,
116
113
  ],
117
114
  '{ policy_id: string selection: any tag_names: string[] }': [
118
115
  { title: 'Search and bulk actions', file: '/opt/arcubase-sdk/docs/runtime-reference/search-and-bulk-actions.md' },
116
+ examplesIndexDocHint,
119
117
  ],
120
118
  }
121
119
 
@@ -152,51 +150,55 @@ const commandDocHintIndex: Record<string, RuntimeDocHint[]> = {
152
150
  'admin.app.createAppByTenants': [
153
151
  { title: 'Table lifecycle', file: '/opt/arcubase-sdk/docs/runtime-reference/table-lifecycle.md' },
154
152
  examplesIndexDocHint,
155
- omsExampleDocHint,
156
153
  ],
157
154
  'admin.app.createEntity': [
158
155
  { title: 'Table lifecycle', file: '/opt/arcubase-sdk/docs/runtime-reference/table-lifecycle.md' },
159
- omsExampleDocHint,
156
+ examplesIndexDocHint,
160
157
  ],
161
158
  'admin.entity.adminGetEntityInfo': [
162
159
  { title: 'Table lifecycle', file: '/opt/arcubase-sdk/docs/runtime-reference/table-lifecycle.md' },
163
- omsExampleDocHint,
160
+ examplesIndexDocHint,
164
161
  ],
165
162
  'admin.entity.adminSaveEntity': [
166
163
  { title: 'Entity schema', file: '/opt/arcubase-sdk/docs/runtime-reference/entity-schema.md' },
167
- omsExampleDocHint,
164
+ { title: 'Table lifecycle', file: '/opt/arcubase-sdk/docs/runtime-reference/table-lifecycle.md' },
165
+ { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
166
+ examplesIndexDocHint,
168
167
  ],
169
168
  'user.workflow.insertEntity': [
170
169
  { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
171
- omsExampleDocHint,
170
+ { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
171
+ examplesIndexDocHint,
172
172
  ],
173
173
  'user.workflow.queryEntity': [
174
174
  { title: 'Search and bulk actions', file: '/opt/arcubase-sdk/docs/runtime-reference/search-and-bulk-actions.md' },
175
- omsExampleDocHint,
175
+ { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
176
+ examplesIndexDocHint,
176
177
  ],
177
178
  'user.workflow.getEntityRow': [
178
179
  { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
179
- omsExampleDocHint,
180
+ examplesIndexDocHint,
180
181
  ],
181
182
  'user.workflow.updateEntityRow': [
182
183
  { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
183
- omsExampleDocHint,
184
+ { title: 'Uploads', file: '/opt/arcubase-sdk/docs/runtime-reference/uploads.md' },
185
+ examplesIndexDocHint,
184
186
  ],
185
187
  'user.workflow.queryEntitySelection': [
186
188
  { title: 'Search and bulk actions', file: '/opt/arcubase-sdk/docs/runtime-reference/search-and-bulk-actions.md' },
187
- omsExampleDocHint,
189
+ examplesIndexDocHint,
188
190
  ],
189
191
  'user.workflow.deleteEntityRow': [
190
192
  { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
191
- omsExampleDocHint,
193
+ examplesIndexDocHint,
192
194
  ],
193
195
  'user.workflow.restoreEntityRow': [
194
196
  { title: 'Row CRUD', file: '/opt/arcubase-sdk/docs/runtime-reference/row-crud.md' },
195
- omsExampleDocHint,
197
+ examplesIndexDocHint,
196
198
  ],
197
199
  'user.entity.updateEntityBulk': [
198
200
  { title: 'Search and bulk actions', file: '/opt/arcubase-sdk/docs/runtime-reference/search-and-bulk-actions.md' },
199
- omsExampleDocHint,
201
+ examplesIndexDocHint,
200
202
  ],
201
203
  }
202
204