@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.
- package/bundle/arcubase-admin.mjs +546 -243
- package/bundle/arcubase.mjs +546 -243
- package/dist/bin/arcubase-admin.js +0 -0
- package/dist/bin/arcubase.js +0 -0
- package/dist/generated/command_registry.generated.d.ts +36 -112
- package/dist/generated/command_registry.generated.d.ts.map +1 -1
- package/dist/generated/command_registry.generated.js +40 -163
- package/dist/generated/type_index.generated.d.ts +0 -12
- package/dist/generated/type_index.generated.d.ts.map +1 -1
- package/dist/generated/type_index.generated.js +0 -12
- package/dist/generated/zod_registry.generated.d.ts +1 -19
- package/dist/generated/zod_registry.generated.d.ts.map +1 -1
- package/dist/generated/zod_registry.generated.js +0 -21
- package/dist/runtime/command_registry.d.ts +19 -1
- package/dist/runtime/command_registry.d.ts.map +1 -1
- package/dist/runtime/command_registry.js +19 -1
- package/dist/runtime/errors.d.ts +1 -0
- package/dist/runtime/errors.d.ts.map +1 -1
- package/dist/runtime/execute.d.ts.map +1 -1
- package/dist/runtime/execute.js +321 -8
- package/dist/runtime/upload.d.ts +13 -0
- package/dist/runtime/upload.d.ts.map +1 -0
- package/dist/runtime/upload.js +148 -0
- package/dist/runtime/zod_registry.d.ts.map +1 -1
- package/dist/runtime/zod_registry.js +50 -30
- package/dist/tests/command_registry.test.js +11 -4
- package/dist/tests/execute_validation.test.js +221 -13
- package/dist/tests/help.test.js +29 -6
- package/dist/tests/upload.test.d.ts +2 -0
- package/dist/tests/upload.test.d.ts.map +1 -0
- package/dist/tests/upload.test.js +107 -0
- package/package.json +1 -1
- package/sdk-dist/api/admin/app.ts +8 -1
- package/sdk-dist/docs/runtime-reference/README.md +33 -15
- package/sdk-dist/docs/runtime-reference/app-discovery.md +68 -0
- package/sdk-dist/docs/runtime-reference/entity-schema/README.md +2 -2
- package/sdk-dist/docs/runtime-reference/entity-schema/file.md +9 -0
- package/sdk-dist/docs/runtime-reference/entity-schema/image.md +9 -0
- package/sdk-dist/docs/runtime-reference/entity-schema/subform.md +2 -2
- package/sdk-dist/docs/runtime-reference/entity-schema.md +2 -2
- package/sdk-dist/docs/runtime-reference/examples/README.md +1 -1
- package/sdk-dist/docs/runtime-reference/examples/oms-01/README.md +23 -4
- package/sdk-dist/docs/runtime-reference/examples/oms-01/app-overview.md +1 -1
- package/sdk-dist/docs/runtime-reference/row-crud.md +8 -6
- package/sdk-dist/docs/runtime-reference/search-and-bulk-actions.md +5 -5
- package/sdk-dist/docs/runtime-reference/table-lifecycle.md +34 -6
- package/sdk-dist/docs/runtime-reference/uploads.md +106 -0
- package/sdk-dist/generated/command_registry.generated.ts +40 -163
- package/sdk-dist/generated/type_index.generated.ts +0 -12
- package/sdk-dist/generated/zod_registry.generated.ts +0 -36
- package/sdk-dist/types/app.ts +7 -0
- package/src/generated/command_registry.generated.ts +40 -163
- package/src/generated/type_index.generated.ts +0 -12
- package/src/generated/zod_registry.generated.ts +0 -36
- package/src/runtime/command_registry.ts +38 -2
- package/src/runtime/errors.ts +1 -0
- package/src/runtime/execute.ts +368 -8
- package/src/runtime/upload.ts +196 -0
- package/src/runtime/zod_registry.ts +50 -30
- package/src/tests/command_registry.test.ts +13 -4
- package/src/tests/execute_validation.test.ts +257 -13
- package/src/tests/help.test.ts +35 -6
- package/src/tests/upload.test.ts +133 -0
- package/sdk-dist/docs/runtime-reference/workflow/README.md +0 -19
package/src/runtime/execute.ts
CHANGED
|
@@ -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
|
-
...
|
|
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
|
-
|
|
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 === '
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|