@carthooks/arcubase-cli 0.1.10 → 0.1.12
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 +1274 -375
- package/bundle/arcubase.mjs +1274 -375
- package/dist/generated/command_registry.generated.d.ts +36 -35
- package/dist/generated/command_registry.generated.d.ts.map +1 -1
- package/dist/generated/command_registry.generated.js +37 -36
- package/dist/generated/type_index.generated.d.ts +23 -23
- package/dist/generated/type_index.generated.js +23 -23
- package/dist/runtime/entity_save_schema.d.ts.map +1 -1
- package/dist/runtime/entity_save_schema.js +30 -0
- package/dist/runtime/env.d.ts +2 -1
- package/dist/runtime/env.d.ts.map +1 -1
- package/dist/runtime/env.js +10 -1
- package/dist/runtime/errors.d.ts +2 -0
- package/dist/runtime/errors.d.ts.map +1 -1
- package/dist/runtime/execute.d.ts +4 -2
- package/dist/runtime/execute.d.ts.map +1 -1
- package/dist/runtime/execute.js +944 -134
- package/dist/runtime/paths.d.ts +1 -1
- package/dist/runtime/paths.d.ts.map +1 -1
- package/dist/runtime/paths.js +9 -2
- package/dist/runtime/upload.d.ts +1 -11
- package/dist/runtime/upload.d.ts.map +1 -1
- package/dist/runtime/upload.js +19 -133
- package/dist/runtime/zod_registry.d.ts +5 -3
- package/dist/runtime/zod_registry.d.ts.map +1 -1
- package/dist/runtime/zod_registry.js +108 -48
- package/dist/tests/bootstrap.test.js +12 -0
- package/dist/tests/command_registry.test.js +8 -0
- package/dist/tests/docs_readability.test.d.ts +2 -0
- package/dist/tests/docs_readability.test.d.ts.map +1 -0
- package/dist/tests/docs_readability.test.js +87 -0
- package/dist/tests/entity_save_schema.test.js +22 -0
- package/dist/tests/execute_validation.test.js +381 -44
- package/dist/tests/help.test.js +45 -8
- package/dist/tests/upload.test.js +30 -77
- package/package.json +1 -1
- package/sdk-dist/docs/runtime-reference/README.md +43 -29
- package/sdk-dist/docs/runtime-reference/access-rule.md +109 -0
- package/sdk-dist/docs/runtime-reference/condition.md +182 -0
- package/sdk-dist/docs/runtime-reference/entity-schema/README.md +6 -5
- package/sdk-dist/docs/runtime-reference/entity-schema/checkbox.md +1 -1
- package/sdk-dist/docs/runtime-reference/entity-schema/file.md +2 -2
- package/sdk-dist/docs/runtime-reference/entity-schema/image.md +2 -2
- package/sdk-dist/docs/runtime-reference/entity-schema/linkto.md +3 -3
- package/sdk-dist/docs/runtime-reference/entity-schema/number.md +1 -1
- package/sdk-dist/docs/runtime-reference/entity-schema/query.md +3 -3
- package/sdk-dist/docs/runtime-reference/entity-schema/relation.md +3 -3
- package/sdk-dist/docs/runtime-reference/entity-schema/relationfield.md +3 -3
- package/sdk-dist/docs/runtime-reference/entity-schema/status.md +1 -1
- package/sdk-dist/docs/runtime-reference/entity-schema/text.md +1 -1
- package/sdk-dist/docs/runtime-reference/entity-schema.md +28 -21
- package/sdk-dist/docs/runtime-reference/examples/README.md +1 -1
- package/sdk-dist/docs/runtime-reference/examples/crm-01/README.md +30 -11
- package/sdk-dist/docs/runtime-reference/examples/crm-01/contact.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/contact.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/create-app.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/customer.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/customer.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/follow-up.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/follow-up.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.bulk-update.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.query.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.row.insert.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.selection.condition.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/oms-01/README.md +26 -26
- package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.row.insert.json +1 -1
- package/sdk-dist/docs/runtime-reference/examples/wms-01/README.md +26 -10
- package/sdk-dist/docs/runtime-reference/examples/wms-01/create-app.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/goods-receipt.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/goods-receipt.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.bulk-update.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.query.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.selection.condition.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/item-master.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/item-master.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/stock-issue.create-entity.json +1 -0
- package/sdk-dist/docs/runtime-reference/examples/wms-01/stock-issue.schema.json +1 -0
- package/sdk-dist/docs/runtime-reference/row-crud.md +35 -8
- package/sdk-dist/docs/runtime-reference/search-and-bulk-actions.md +31 -95
- package/sdk-dist/docs/runtime-reference/selection.md +128 -0
- package/sdk-dist/docs/runtime-reference/table-lifecycle.md +113 -30
- package/sdk-dist/docs/runtime-reference/uploads.md +30 -59
- package/sdk-dist/docs/runtime-reference/workflow/README.md +129 -13
- package/sdk-dist/generated/command_registry.generated.ts +37 -36
- package/sdk-dist/generated/type_index.generated.ts +23 -23
- package/src/generated/command_registry.generated.ts +37 -36
- package/src/generated/type_index.generated.ts +23 -23
- package/src/runtime/entity_save_schema.ts +30 -0
- package/src/runtime/env.ts +14 -2
- package/src/runtime/errors.ts +2 -0
- package/src/runtime/execute.ts +1166 -134
- package/src/runtime/paths.ts +11 -2
- package/src/runtime/upload.ts +19 -178
- package/src/runtime/zod_registry.ts +113 -48
- package/src/tests/bootstrap.test.ts +16 -0
- package/src/tests/command_registry.test.ts +9 -0
- package/src/tests/docs_readability.test.ts +96 -0
- package/src/tests/entity_save_schema.test.ts +22 -0
- package/src/tests/execute_validation.test.ts +508 -47
- package/src/tests/help.test.ts +49 -8
- package/src/tests/upload.test.ts +34 -91
package/src/runtime/execute.ts
CHANGED
|
@@ -5,7 +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 {
|
|
8
|
+
import { resolveArcubaseSDKPath } from './paths.js'
|
|
9
9
|
|
|
10
10
|
export type CommandExecutionSummary = {
|
|
11
11
|
scope: CommandScope
|
|
@@ -17,16 +17,6 @@ export type CommandExecutionSummary = {
|
|
|
17
17
|
queryFlags: readonly string[]
|
|
18
18
|
}
|
|
19
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
|
-
|
|
30
20
|
export function renderRootHelp(scope: CommandScope): string {
|
|
31
21
|
const binary = scope === 'admin' ? 'arcubase-admin' : 'arcubase'
|
|
32
22
|
const items = listModules(scope).map((item) => ` - ${item}`)
|
|
@@ -36,20 +26,21 @@ export function renderRootHelp(scope: CommandScope): string {
|
|
|
36
26
|
...(scope === 'admin'
|
|
37
27
|
? ['use arcubase-admin for app, table, access-rule, and workflow management', '']
|
|
38
28
|
: ['use arcubase for entry, table, row, upload, profile, and workflow actions', '']),
|
|
29
|
+
'prefer --body-json <json-string> for normal payloads, including table schema; use --body-file only when JSON is too large for one tool call',
|
|
30
|
+
'',
|
|
39
31
|
'modules:',
|
|
40
32
|
...items,
|
|
41
|
-
...(scope === 'user' ? ['','special commands:',' - upload <local-file>'] : []),
|
|
42
33
|
].join('\n')
|
|
43
34
|
}
|
|
44
35
|
|
|
45
|
-
export function renderModuleHelp(scope: CommandScope, moduleName: string): string {
|
|
36
|
+
export function renderModuleHelp(scope: CommandScope, moduleName: string, env: EnvInput = process.env): string {
|
|
46
37
|
const items = listModuleCommands(scope, moduleName)
|
|
47
38
|
if (items.length === 0) {
|
|
48
39
|
const suggestions = findCommandSuggestions(moduleName, '').map((item) => `${item.binary} ${item.moduleName}${item.commandName ? ` ${item.commandName}` : ''}`)
|
|
49
40
|
throw new CLIError('UNKNOWN_MODULE', `unknown module: ${moduleName}`, 2, suggestions.length > 0 ? { suggestions } : undefined)
|
|
50
41
|
}
|
|
51
42
|
if (items.length === 1 && items[0].commandPath.length === 1) {
|
|
52
|
-
return renderCommandHelp(scope, moduleName)
|
|
43
|
+
return renderCommandHelp(scope, moduleName, undefined, env)
|
|
53
44
|
}
|
|
54
45
|
return [
|
|
55
46
|
`${scope === 'admin' ? 'arcubase-admin' : 'arcubase'} ${moduleName} <command> [flags]`,
|
|
@@ -57,6 +48,8 @@ export function renderModuleHelp(scope: CommandScope, moduleName: string): strin
|
|
|
57
48
|
...(scope === 'admin'
|
|
58
49
|
? ['use arcubase-admin for app, table, access-rule, and workflow management', '']
|
|
59
50
|
: ['use arcubase for entry, table, row, upload, profile, and workflow actions', '']),
|
|
51
|
+
'prefer --body-json <json-string> for normal payloads, including table schema; use --body-file only when JSON is too large for one tool call',
|
|
52
|
+
'',
|
|
60
53
|
'commands:',
|
|
61
54
|
...items.filter((item) => item.commandPath.length > 1).map((item) => ` - ${item.commandPath[1]}`),
|
|
62
55
|
].join('\n')
|
|
@@ -67,30 +60,55 @@ function buildUnknownCommandDetails(scope: CommandScope, moduleName: string, com
|
|
|
67
60
|
return suggestions.length > 0 ? { suggestions } : undefined
|
|
68
61
|
}
|
|
69
62
|
|
|
70
|
-
|
|
63
|
+
type EnvInput = Record<string, string | undefined>
|
|
64
|
+
|
|
65
|
+
function sdkPath(file: string, env: EnvInput = process.env): string {
|
|
66
|
+
return resolveArcubaseSDKPath(file, env)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function renderCommandHelp(scope: CommandScope, moduleName: string, commandName?: string, env: EnvInput = process.env): string {
|
|
71
70
|
const command = findCommand(scope, moduleName, commandName)
|
|
72
71
|
if (!command) {
|
|
73
72
|
throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName}${commandName ? ` ${commandName}` : ''}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName ?? ''))
|
|
74
73
|
}
|
|
75
74
|
const docHints = [
|
|
76
|
-
...getCommandDocHints(`${scope}.${command.module}.${command.functionName}
|
|
77
|
-
...(command.requestType ? getDocHints(command.requestType) : []),
|
|
75
|
+
...getCommandDocHints(`${scope}.${command.module}.${command.functionName}`, env),
|
|
76
|
+
...(command.requestType ? getDocHints(command.requestType, env) : []),
|
|
78
77
|
].filter((item, index, list) => list.findIndex((candidate) => candidate.file === item.file) === index)
|
|
78
|
+
const typeHints = command.requestType ? getTypeHints(command.requestType, env) : []
|
|
79
79
|
const pathFlags = command.pathParams.filter((item) => !getFixedValue(item)).map((item) => `--${item.flag}`)
|
|
80
|
-
const queryFlags = command
|
|
80
|
+
const queryFlags = visibleQueryParams(command).map((item) => `--${item.flag}`)
|
|
81
|
+
const isTableCreate = scope === 'admin' && command.commandPath[0] === 'table' && command.commandPath[1] === 'create'
|
|
82
|
+
const bodyTypeText = isTableCreate ? 'TableCreateWithSchema' : command.requestType
|
|
83
|
+
const tableCreateExampleBody = JSON.stringify(buildTableCreateExampleBody())
|
|
81
84
|
return [
|
|
82
85
|
`${scope === 'admin' ? 'arcubase-admin' : 'arcubase'} ${command.commandPath.join(' ')}`,
|
|
83
86
|
`method: ${command.method}`,
|
|
84
87
|
`endpoint: ${normalizeExternalEndpoint(command.endpoint)}`,
|
|
85
|
-
command.requestType ? `body: ${
|
|
88
|
+
command.requestType ? `body: ${bodyTypeText} via --body-json <json-string> | --body-file <file>.json` : 'body: none',
|
|
86
89
|
queryFlags.length ? `query flags: ${queryFlags.join(', ')}, --query-file` : 'query flags: --query-file',
|
|
87
90
|
pathFlags.length ? `path flags: ${pathFlags.join(', ')}` : 'path flags: none',
|
|
91
|
+
...(isTableCreate
|
|
92
|
+
? [
|
|
93
|
+
'body-json example:',
|
|
94
|
+
` - ${tableCreateExampleBody}`,
|
|
95
|
+
'body rules:',
|
|
96
|
+
' - fields must be non-empty; empty table shell creation is not allowed',
|
|
97
|
+
' - field_id_seq must be greater than every field.id',
|
|
98
|
+
' - use text, not string; use member, not user',
|
|
99
|
+
' - for select/radio/checkbox/status, use options.options.items',
|
|
100
|
+
'schema refs:',
|
|
101
|
+
` - Table schema cheatsheet: ${sdkPath('docs/runtime-reference/entity-schema.md', env)}`,
|
|
102
|
+
` - Field type index: ${sdkPath('docs/runtime-reference/entity-schema/README.md', env)}`,
|
|
103
|
+
]
|
|
104
|
+
: []),
|
|
88
105
|
...(scope === 'admin' && command.commandPath[0] === 'table' && command.commandPath[1] === 'update-schema'
|
|
89
106
|
? [
|
|
90
107
|
'next step:',
|
|
108
|
+
' - prefer --body-json for schema save to avoid file creation and JSON file corruption',
|
|
91
109
|
' - switch to arcubase for row operations',
|
|
92
|
-
' - row create: arcubase row create --app-id <app_id> --table-id <table_id> --body-file insert.json',
|
|
93
|
-
' - row query: arcubase row query --app-id <app_id> --table-id <table_id> --body-file query.json',
|
|
110
|
+
' - row create: arcubase row create --app-id <app_id> --table-id <table_id> --body-json <json-string> | --body-file insert.json',
|
|
111
|
+
' - row query: arcubase row query --app-id <app_id> --table-id <table_id> --body-json <json-string> | --body-file query.json',
|
|
94
112
|
]
|
|
95
113
|
: []),
|
|
96
114
|
...(scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'update'
|
|
@@ -98,13 +116,23 @@ export function renderCommandHelp(scope: CommandScope, moduleName: string, comma
|
|
|
98
116
|
'body-json examples:',
|
|
99
117
|
' - enable: {"update":["enabled"],"enabled":true}',
|
|
100
118
|
' - rename: {"update":["name"],"name":"Sales read only"}',
|
|
101
|
-
' - update
|
|
119
|
+
' - update policy: {"update":["options"],"options":{"policy":{"key":"custom","actions":["view","add","edit"],"all_fields_read":true,"all_fields_write":true,"fields":[{"key":":1002","read":false,"write":false,"report":false}]},"list_options":{}}}',
|
|
120
|
+
]
|
|
121
|
+
: []),
|
|
122
|
+
...(scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'create'
|
|
123
|
+
? [
|
|
124
|
+
'body-json example:',
|
|
125
|
+
' - {"name":"Sales read write","enabled":true,"type":"form","options":{"user_scope":[{"type":"user","id":2188889901}],"policy":{"key":"custom","actions":["view","add","edit"],"all_fields_read":false,"all_fields_write":false,"fields":[{"key":":1001","read":true,"write":true,"report":false},{"key":":1002","read":false,"write":false,"report":false}]},"list_options":{}}}',
|
|
126
|
+
'success requires:',
|
|
127
|
+
' - result.enabled must be true',
|
|
128
|
+
' - options.user_scope[].type is "user" for tenant users',
|
|
129
|
+
' - hidden fields use colon field keys such as ":1002"',
|
|
102
130
|
]
|
|
103
131
|
: []),
|
|
104
132
|
...(scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users'
|
|
105
133
|
? [
|
|
106
134
|
'body-json example:',
|
|
107
|
-
' - {"update":["user_scope"],"options":{"user_scope":[{"type":"
|
|
135
|
+
' - {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}',
|
|
108
136
|
'success requires:',
|
|
109
137
|
' - result.enabled must be true',
|
|
110
138
|
' - result.options.user_scope must contain the assigned numeric TenantUser.ID values',
|
|
@@ -116,6 +144,9 @@ export function renderCommandHelp(scope: CommandScope, moduleName: string, comma
|
|
|
116
144
|
...(docHints.length > 0
|
|
117
145
|
? ['docs:', ...docHints.map((item) => ` - ${item.title}: ${item.file}`)]
|
|
118
146
|
: []),
|
|
147
|
+
...(typeHints.length > 0
|
|
148
|
+
? ['types:', ...typeHints.map((item) => ` - ${item.symbol}: ${item.file}`)]
|
|
149
|
+
: []),
|
|
119
150
|
].join('\n')
|
|
120
151
|
}
|
|
121
152
|
|
|
@@ -161,7 +192,7 @@ function buildScalarQuery(command: ReturnType<typeof findCommand>, flags: Record
|
|
|
161
192
|
return undefined
|
|
162
193
|
}
|
|
163
194
|
const out: Record<string, any> = {}
|
|
164
|
-
for (const scalar of command
|
|
195
|
+
for (const scalar of effectiveQueryParams(command)) {
|
|
165
196
|
const value = getFixedValue(scalar) ?? flagValue(flags, scalar.flag)
|
|
166
197
|
if (value === undefined) continue
|
|
167
198
|
out[scalar.key] = coerceScalar(value, scalar.type)
|
|
@@ -169,10 +200,133 @@ function buildScalarQuery(command: ReturnType<typeof findCommand>, flags: Record
|
|
|
169
200
|
return Object.keys(out).length > 0 ? out : undefined
|
|
170
201
|
}
|
|
171
202
|
|
|
203
|
+
function validateKnownFlags(command: NonNullable<ReturnType<typeof findCommand>>, flags: Record<string, string | boolean>, env: EnvInput = process.env) {
|
|
204
|
+
const allowed = new Set<string>(['help', 'body-file', 'body-json', 'query-file'])
|
|
205
|
+
for (const mapping of command.pathParams) {
|
|
206
|
+
if (!getFixedValue(mapping)) {
|
|
207
|
+
allowed.add(mapping.flag)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const query of visibleQueryParams(command)) {
|
|
211
|
+
if (!getFixedValue(query)) {
|
|
212
|
+
allowed.add(query.flag)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const flag of Object.keys(flags)) {
|
|
216
|
+
if (!allowed.has(flag)) {
|
|
217
|
+
throw new CLIError('UNKNOWN_FLAG', `unknown flag --${flag} for ${command.commandPath.join(' ')}`, 2, {
|
|
218
|
+
...buildUnknownFlagDetails(command, flag, env),
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function buildUnknownFlagDetails(
|
|
225
|
+
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
226
|
+
flag: string,
|
|
227
|
+
env: EnvInput = process.env,
|
|
228
|
+
): CLIErrorDetails {
|
|
229
|
+
const operation = command.commandPath.join(' ')
|
|
230
|
+
const details: CLIErrorDetails = {
|
|
231
|
+
operation,
|
|
232
|
+
hint: `run ${operation} --help for the supported flags`,
|
|
233
|
+
}
|
|
234
|
+
if (command.requestType) {
|
|
235
|
+
details.requestType = command.requestType
|
|
236
|
+
details.tsHints = getTypeHints(command.requestType, env)
|
|
237
|
+
details.docHints = getDocHints(command.requestType, env)
|
|
238
|
+
details.commonMistakes = [
|
|
239
|
+
`do not pass request body fields as --${flag}`,
|
|
240
|
+
'put request body fields inside --body-json <json-string>',
|
|
241
|
+
]
|
|
242
|
+
details.suggestions = [`retry with: ${command.commandPath.join(' ')} ${command.pathParams.filter((item) => !getFixedValue(item)).map((item) => `--${item.flag} <${item.flag.replace(/-/g, '_')}>`).join(' ')} --body-json '<json>'`.replace(/\s+/g, ' ').trim()]
|
|
243
|
+
}
|
|
244
|
+
if (command.commandPath[0] === 'row' && command.commandPath[1] === 'query' && ['limit', 'offset', 'search', 'sorts', 'view-mode'].includes(flag)) {
|
|
245
|
+
details.shapeHint = { limit: 20, offset: 0, search: { text_search: 'SO-1001' } }
|
|
246
|
+
details.suggestions = ['retry with: row query --app-id <app_id> --table-id <table_id> --body-json \'{"limit":20,"offset":0}\'']
|
|
247
|
+
}
|
|
248
|
+
if (command.commandPath[0] === 'table' && command.commandPath[1] === 'dataset' && ['app-id', 'table-id'].includes(flag)) {
|
|
249
|
+
details.hint = 'table dataset uses --dataset-id and --node-id; use table get for app/table metadata'
|
|
250
|
+
details.suggestions = [
|
|
251
|
+
'retry metadata read with: table get --app-id <app_id> --table-id <table_id>',
|
|
252
|
+
'retry dataset read with: table dataset --dataset-id <dataset_id> --node-id <node_id>',
|
|
253
|
+
]
|
|
254
|
+
}
|
|
255
|
+
return details
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function visibleQueryParams(command: ReturnType<typeof findCommand>) {
|
|
259
|
+
if (!command) return []
|
|
260
|
+
return effectiveQueryParams(command).filter((item) => !getFixedValue(item))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function effectiveQueryParams(command: ReturnType<typeof findCommand>) {
|
|
264
|
+
if (!command) return []
|
|
265
|
+
return command.queryParams.filter((item) => {
|
|
266
|
+
if (rowCommandNeedsPolicy(command) && item.key === 'policy_id') {
|
|
267
|
+
return false
|
|
268
|
+
}
|
|
269
|
+
return true
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
172
273
|
function isRecord(value: unknown): value is Record<string, any> {
|
|
173
274
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
174
275
|
}
|
|
175
276
|
|
|
277
|
+
function stringifyUpstreamBody(value: unknown): string {
|
|
278
|
+
if (typeof value === 'string') return value
|
|
279
|
+
if (value == null) return ''
|
|
280
|
+
try {
|
|
281
|
+
return JSON.stringify(value)
|
|
282
|
+
} catch {
|
|
283
|
+
return String(value)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function extractTraceID(value: unknown): string | undefined {
|
|
288
|
+
if (!isRecord(value)) return undefined
|
|
289
|
+
if (typeof value.trace_id === 'string') return value.trace_id
|
|
290
|
+
if (typeof value.request_id === 'string') return value.request_id
|
|
291
|
+
return undefined
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function upstreamErrorMessage(value: unknown): string {
|
|
295
|
+
if (isRecord(value) && typeof value.message === 'string') return value.message
|
|
296
|
+
if (typeof value === 'string') return value
|
|
297
|
+
return stringifyUpstreamBody(value)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function throwUpstreamHTTPError(endpoint: string, method: string, response: Response, parsedResponse: unknown): never {
|
|
301
|
+
const upstreamError = isRecord(parsedResponse) && parsedResponse.error ? parsedResponse.error : undefined
|
|
302
|
+
const body = stringifyUpstreamBody(parsedResponse)
|
|
303
|
+
throw new CLIError(
|
|
304
|
+
'UPSTREAM_REQUEST_FAILED',
|
|
305
|
+
upstreamError ? upstreamErrorMessage(upstreamError) : body || `HTTP ${response.status}`,
|
|
306
|
+
1,
|
|
307
|
+
{
|
|
308
|
+
endpoint,
|
|
309
|
+
method,
|
|
310
|
+
status: response.status,
|
|
311
|
+
traceId: extractTraceID(parsedResponse),
|
|
312
|
+
responseBody: body,
|
|
313
|
+
...(upstreamError ? { upstreamError } : {}),
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function fetchUpstream(url: string, init: RequestInit, endpoint: string, method: string, fetchImpl: typeof fetch): Promise<Response> {
|
|
319
|
+
try {
|
|
320
|
+
return await fetchImpl(url, init)
|
|
321
|
+
} catch (error) {
|
|
322
|
+
throw new CLIError('UPSTREAM_NETWORK_ERROR', error instanceof Error ? error.message : String(error), 1, {
|
|
323
|
+
endpoint,
|
|
324
|
+
method,
|
|
325
|
+
reason: 'network or DNS failure before Arcubase returned an HTTP response',
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
176
330
|
type EntityFieldInfo = {
|
|
177
331
|
id: number
|
|
178
332
|
type: string
|
|
@@ -200,10 +354,12 @@ async function fetchEntityFields(
|
|
|
200
354
|
entityId: string,
|
|
201
355
|
fetchImpl: typeof fetch,
|
|
202
356
|
): Promise<Map<number, EntityFieldInfo>> {
|
|
203
|
-
const
|
|
357
|
+
const endpoint = `/api/entity/${appId}/${entityId}`
|
|
358
|
+
const encodedEndpoint = `/api/entity/${encodeURIComponent(appId)}/${encodeURIComponent(entityId)}`
|
|
359
|
+
const response = await fetchUpstream(buildURL(runtimeEnv.ARCUBASE_BASE_URL, encodedEndpoint), {
|
|
204
360
|
method: 'GET',
|
|
205
361
|
headers: buildRequestHeaders(runtimeEnv),
|
|
206
|
-
})
|
|
362
|
+
}, endpoint, 'GET', fetchImpl)
|
|
207
363
|
const raw = await response.text()
|
|
208
364
|
let parsedResponse: unknown = raw
|
|
209
365
|
try {
|
|
@@ -212,19 +368,15 @@ async function fetchEntityFields(
|
|
|
212
368
|
parsedResponse = raw
|
|
213
369
|
}
|
|
214
370
|
if (!response.ok) {
|
|
215
|
-
|
|
371
|
+
throwUpstreamHTTPError(endpoint, 'GET', response, parsedResponse)
|
|
216
372
|
}
|
|
217
373
|
if (isRecord(parsedResponse) && parsedResponse.error) {
|
|
218
374
|
const upstreamError = parsedResponse.error
|
|
219
|
-
const upstreamMessage =
|
|
220
|
-
isRecord(upstreamError) && typeof upstreamError.message === 'string'
|
|
221
|
-
? upstreamError.message
|
|
222
|
-
: typeof upstreamError === 'string'
|
|
223
|
-
? upstreamError
|
|
224
|
-
: JSON.stringify(upstreamError)
|
|
375
|
+
const upstreamMessage = upstreamErrorMessage(upstreamError)
|
|
225
376
|
throw new CLIError('UPSTREAM_BODY_ERROR', upstreamMessage, 1, {
|
|
226
|
-
endpoint
|
|
377
|
+
endpoint,
|
|
227
378
|
method: 'GET',
|
|
379
|
+
traceId: extractTraceID(parsedResponse),
|
|
228
380
|
upstreamError,
|
|
229
381
|
})
|
|
230
382
|
}
|
|
@@ -285,52 +437,52 @@ async function validateFileFieldPayloads(
|
|
|
285
437
|
const fieldName = field.label ? `${field.label} (${field.type})` : `${field.type} field ${fieldId}`
|
|
286
438
|
if (typeof value === 'string') {
|
|
287
439
|
const guidance = looksLikeLocalFilePath(value)
|
|
288
|
-
? `do not put a local file path directly into ${fieldName};
|
|
289
|
-
: `${fieldName} must be an
|
|
440
|
+
? `do not put a local file path directly into ${fieldName}; use arcubase upload token flow and pass the returned upload value array`
|
|
441
|
+
: `${fieldName} must be an upload value array, not a string`
|
|
290
442
|
throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
|
|
291
443
|
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
292
444
|
requestType: command.requestType ?? undefined,
|
|
293
445
|
issues: [{ path: fieldPath, message: guidance }],
|
|
294
446
|
docHints: [
|
|
295
|
-
{ title: 'Uploads', file: '
|
|
296
|
-
{ title: 'Row CRUD', file: '
|
|
447
|
+
{ title: 'Uploads', file: sdkPath('docs/runtime-reference/uploads.md', runtimeEnv) },
|
|
448
|
+
{ title: 'Row CRUD', file: sdkPath('docs/runtime-reference/row-crud.md', runtimeEnv) },
|
|
297
449
|
],
|
|
298
450
|
})
|
|
299
451
|
}
|
|
300
452
|
if (isRecord(value)) {
|
|
301
|
-
const guidance = `${fieldName} must be an array.
|
|
453
|
+
const guidance = `${fieldName} must be an upload value array. Use the upload token flow and pass the returned array directly`
|
|
302
454
|
throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
|
|
303
455
|
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
304
456
|
requestType: command.requestType ?? undefined,
|
|
305
457
|
issues: [{ path: fieldPath, message: guidance }],
|
|
306
458
|
docHints: [
|
|
307
|
-
{ title: 'Uploads', file: '
|
|
308
|
-
{ title: 'Row CRUD', file: '
|
|
459
|
+
{ title: 'Uploads', file: sdkPath('docs/runtime-reference/uploads.md', runtimeEnv) },
|
|
460
|
+
{ title: 'Row CRUD', file: sdkPath('docs/runtime-reference/row-crud.md', runtimeEnv) },
|
|
309
461
|
],
|
|
310
462
|
})
|
|
311
463
|
}
|
|
312
464
|
if (!Array.isArray(value)) {
|
|
313
|
-
const guidance = `${fieldName} must be an
|
|
465
|
+
const guidance = `${fieldName} must be an upload value array`
|
|
314
466
|
throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
|
|
315
467
|
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
316
468
|
requestType: command.requestType ?? undefined,
|
|
317
469
|
issues: [{ path: fieldPath, message: guidance }],
|
|
318
470
|
docHints: [
|
|
319
|
-
{ title: 'Uploads', file: '
|
|
320
|
-
{ title: 'Row CRUD', file: '
|
|
471
|
+
{ title: 'Uploads', file: sdkPath('docs/runtime-reference/uploads.md', runtimeEnv) },
|
|
472
|
+
{ title: 'Row CRUD', file: sdkPath('docs/runtime-reference/row-crud.md', runtimeEnv) },
|
|
321
473
|
],
|
|
322
474
|
})
|
|
323
475
|
}
|
|
324
476
|
for (const [index, item] of value.entries()) {
|
|
325
477
|
if (!isValidUploadEntry(item)) {
|
|
326
|
-
const guidance = `${fieldName} entries must include upload_id or assets_id plus file and file_name. Use
|
|
478
|
+
const guidance = `${fieldName} entries must include upload_id or assets_id plus file and file_name. Use the upload token flow and copy the returned array item as-is`
|
|
327
479
|
throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
|
|
328
480
|
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
329
481
|
requestType: command.requestType ?? undefined,
|
|
330
482
|
issues: [{ path: `${fieldPath}.${index}`, message: guidance }],
|
|
331
483
|
docHints: [
|
|
332
|
-
{ title: 'Uploads', file: '
|
|
333
|
-
{ title: 'Row CRUD', file: '
|
|
484
|
+
{ title: 'Uploads', file: sdkPath('docs/runtime-reference/uploads.md', runtimeEnv) },
|
|
485
|
+
{ title: 'Row CRUD', file: sdkPath('docs/runtime-reference/row-crud.md', runtimeEnv) },
|
|
334
486
|
],
|
|
335
487
|
})
|
|
336
488
|
}
|
|
@@ -338,31 +490,123 @@ async function validateFileFieldPayloads(
|
|
|
338
490
|
}
|
|
339
491
|
}
|
|
340
492
|
|
|
493
|
+
function buildTableCreateExampleBody() {
|
|
494
|
+
return {
|
|
495
|
+
name: '投票项目',
|
|
496
|
+
parent: 0,
|
|
497
|
+
schema_version: 0,
|
|
498
|
+
field_id_seq: 1003,
|
|
499
|
+
layout: [[1001], [1002]],
|
|
500
|
+
fields: [
|
|
501
|
+
{
|
|
502
|
+
id: 1001,
|
|
503
|
+
label: '投票名称',
|
|
504
|
+
type: 'text',
|
|
505
|
+
required: true,
|
|
506
|
+
unique: false,
|
|
507
|
+
editable: true,
|
|
508
|
+
visible: true,
|
|
509
|
+
show_label: true,
|
|
510
|
+
scannable: false,
|
|
511
|
+
default_value_mode: 0,
|
|
512
|
+
description: '',
|
|
513
|
+
value: null,
|
|
514
|
+
number_decimal: 0,
|
|
515
|
+
depends: [],
|
|
516
|
+
key: 'vote_name',
|
|
517
|
+
code_index: false,
|
|
518
|
+
options: { type: 'text', placeholder: '', lengthLimit: false, lengthMin: 0, lengthMax: 255 },
|
|
519
|
+
transfers: null,
|
|
520
|
+
children: null,
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
id: 1002,
|
|
524
|
+
label: '投票描述',
|
|
525
|
+
type: 'textarea',
|
|
526
|
+
required: false,
|
|
527
|
+
unique: false,
|
|
528
|
+
editable: true,
|
|
529
|
+
visible: true,
|
|
530
|
+
show_label: true,
|
|
531
|
+
scannable: false,
|
|
532
|
+
default_value_mode: 0,
|
|
533
|
+
description: '',
|
|
534
|
+
value: null,
|
|
535
|
+
number_decimal: 0,
|
|
536
|
+
depends: [],
|
|
537
|
+
key: 'vote_description',
|
|
538
|
+
code_index: false,
|
|
539
|
+
options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
|
|
540
|
+
transfers: null,
|
|
541
|
+
children: null,
|
|
542
|
+
},
|
|
543
|
+
],
|
|
544
|
+
options: { Buttons: [], CustomActions: [], FrontendEvents: [], TitleFormat: { Fields: [], Parts: [], Body: '' }, misc: {} },
|
|
545
|
+
workflow_enabled: false,
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
341
549
|
function isAssignUsersCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
|
|
342
550
|
return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users'
|
|
343
551
|
}
|
|
344
552
|
|
|
553
|
+
function isTableCreateCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
|
|
554
|
+
return scope === 'admin' && command.commandPath[0] === 'table' && command.commandPath[1] === 'create'
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function isTableSchemaCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
|
|
558
|
+
return scope === 'admin' && command.commandPath[0] === 'table' && (command.commandPath[1] === 'create' || command.commandPath[1] === 'update-schema')
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function isAccessRuleCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
|
|
562
|
+
return scope === 'admin' && command.commandPath[0] === 'access-rule'
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function isAccessRuleUpdateCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
|
|
566
|
+
return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'update'
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function buildAccessRuleShapeHint(userId = 2188889901) {
|
|
570
|
+
return {
|
|
571
|
+
name: 'Sales read write',
|
|
572
|
+
enabled: true,
|
|
573
|
+
type: 'form',
|
|
574
|
+
options: {
|
|
575
|
+
user_scope: [{ type: 'user', id: userId }],
|
|
576
|
+
policy: {
|
|
577
|
+
key: 'custom',
|
|
578
|
+
actions: ['view', 'add', 'edit'],
|
|
579
|
+
all_fields_read: true,
|
|
580
|
+
all_fields_write: true,
|
|
581
|
+
fields: [{ key: ':1002', read: false, write: false, report: false }],
|
|
582
|
+
},
|
|
583
|
+
list_options: {},
|
|
584
|
+
},
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
345
588
|
function buildAssignUsersValidationDetails(
|
|
346
589
|
scope: CommandScope,
|
|
347
590
|
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
348
591
|
requestType: string,
|
|
349
592
|
path: string,
|
|
350
593
|
message: string,
|
|
594
|
+
env: EnvInput = process.env,
|
|
351
595
|
): CLIErrorDetails {
|
|
352
596
|
return {
|
|
353
597
|
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
354
598
|
requestType,
|
|
355
599
|
issues: [{ path, message }],
|
|
356
600
|
tsHints: [
|
|
357
|
-
{ symbol: 'AppIngressUpdateReqVO', file: '
|
|
358
|
-
{ symbol: 'EntityIngressOptions', file: '
|
|
359
|
-
{ symbol: 'PermitUserScope', file: '
|
|
601
|
+
{ symbol: 'AppIngressUpdateReqVO', file: sdkPath('types/ingress.ts', env) },
|
|
602
|
+
{ symbol: 'EntityIngressOptions', file: sdkPath('types/ingress.ts', env) },
|
|
603
|
+
{ symbol: 'PermitUserScope', file: sdkPath('types/common.ts', env) },
|
|
360
604
|
],
|
|
361
|
-
docHints: getDocHints(requestType),
|
|
605
|
+
docHints: getDocHints(requestType, env),
|
|
362
606
|
shapeHint: {
|
|
363
607
|
update: ['user_scope'],
|
|
364
608
|
options: {
|
|
365
|
-
user_scope: [{ type: '
|
|
609
|
+
user_scope: [{ type: 'user', id: 2188889901 }],
|
|
366
610
|
},
|
|
367
611
|
},
|
|
368
612
|
commonMistakes: [
|
|
@@ -374,33 +618,336 @@ function buildAssignUsersValidationDetails(
|
|
|
374
618
|
}
|
|
375
619
|
}
|
|
376
620
|
|
|
377
|
-
function
|
|
378
|
-
if (
|
|
379
|
-
|
|
621
|
+
function buildBodyGuidance(requestType: string): Pick<CLIErrorDetails, 'shapeHint' | 'commonMistakes' | 'suggestions'> {
|
|
622
|
+
if (requestType === 'EntitySaveReqVO' || requestType === 'AppCreateEntityReqVO') {
|
|
623
|
+
return {
|
|
624
|
+
shapeHint: {
|
|
625
|
+
id: 2188889845,
|
|
626
|
+
app_id: 2188889844,
|
|
627
|
+
name: '订单',
|
|
628
|
+
schema_version: 0,
|
|
629
|
+
field_id_seq: 1004,
|
|
630
|
+
layout: [[1001], [1002], [1003]],
|
|
631
|
+
fields: [
|
|
632
|
+
{
|
|
633
|
+
id: 1001,
|
|
634
|
+
label: '订单号',
|
|
635
|
+
type: 'text',
|
|
636
|
+
required: true,
|
|
637
|
+
unique: false,
|
|
638
|
+
editable: true,
|
|
639
|
+
visible: true,
|
|
640
|
+
show_label: true,
|
|
641
|
+
scannable: false,
|
|
642
|
+
default_value_mode: 0,
|
|
643
|
+
description: '',
|
|
644
|
+
value: null,
|
|
645
|
+
number_decimal: 0,
|
|
646
|
+
depends: [],
|
|
647
|
+
key: 'order_no',
|
|
648
|
+
code_index: false,
|
|
649
|
+
options: { type: 'text', placeholder: '', lengthLimit: false, lengthMin: 0, lengthMax: 255 },
|
|
650
|
+
transfers: null,
|
|
651
|
+
children: null,
|
|
652
|
+
},
|
|
653
|
+
{
|
|
654
|
+
id: 1002,
|
|
655
|
+
label: '描述',
|
|
656
|
+
type: 'textarea',
|
|
657
|
+
required: false,
|
|
658
|
+
unique: false,
|
|
659
|
+
editable: true,
|
|
660
|
+
visible: true,
|
|
661
|
+
show_label: true,
|
|
662
|
+
scannable: false,
|
|
663
|
+
default_value_mode: 0,
|
|
664
|
+
description: '',
|
|
665
|
+
value: null,
|
|
666
|
+
number_decimal: 0,
|
|
667
|
+
depends: [],
|
|
668
|
+
key: 'description',
|
|
669
|
+
code_index: false,
|
|
670
|
+
options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
|
|
671
|
+
transfers: null,
|
|
672
|
+
children: null,
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
id: 1003,
|
|
676
|
+
label: '状态',
|
|
677
|
+
type: 'select',
|
|
678
|
+
required: true,
|
|
679
|
+
unique: false,
|
|
680
|
+
editable: true,
|
|
681
|
+
visible: true,
|
|
682
|
+
show_label: true,
|
|
683
|
+
scannable: false,
|
|
684
|
+
default_value_mode: 1,
|
|
685
|
+
description: '',
|
|
686
|
+
value: 'draft',
|
|
687
|
+
number_decimal: 0,
|
|
688
|
+
depends: [],
|
|
689
|
+
key: 'status',
|
|
690
|
+
code_index: false,
|
|
691
|
+
options: {
|
|
692
|
+
horizontal: false,
|
|
693
|
+
numberMax: '',
|
|
694
|
+
numberMin: '',
|
|
695
|
+
numberRange: false,
|
|
696
|
+
repeatRemind: 'This value already exists and duplicate submission is not allowed',
|
|
697
|
+
options: {
|
|
698
|
+
mode: 'custom',
|
|
699
|
+
colorful: false,
|
|
700
|
+
items: [
|
|
701
|
+
{ key: 1, value: 'draft' },
|
|
702
|
+
{ key: 2, value: 'active' },
|
|
703
|
+
],
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
transfers: null,
|
|
707
|
+
children: null,
|
|
708
|
+
},
|
|
709
|
+
],
|
|
710
|
+
options: { Buttons: [], CustomActions: [], FrontendEvents: [], TitleFormat: { Fields: [], Parts: [], Body: '' }, misc: {} },
|
|
711
|
+
workflow_enabled: false,
|
|
712
|
+
},
|
|
713
|
+
commonMistakes: [
|
|
714
|
+
'table create must include fields and field_id_seq; do not create an empty table shell',
|
|
715
|
+
'prefer --body-json for table create/update-schema; do not create a body file unless JSON is too large for one tool call',
|
|
716
|
+
'fields must be an array of full FieldVO objects, not an object map',
|
|
717
|
+
'get table shell first: arcubase-admin table get --app-id <app_id> --table-id <table_id>',
|
|
718
|
+
'save the full returned schema after editing; do not submit only changed fields',
|
|
719
|
+
'field_id_seq must be greater than every field.id',
|
|
720
|
+
'layout must reference existing field ids',
|
|
721
|
+
'use type "text", not "string"',
|
|
722
|
+
'use type "member", not "user"',
|
|
723
|
+
'for select/radio/checkbox/status, options must contain options.items',
|
|
724
|
+
'for linkto/relation/query, options must contain relation.app_id, relation.entity_id, relation.type',
|
|
725
|
+
'do not put per-user permissions in FieldVO; use access-rule for read/write and hidden fields',
|
|
726
|
+
],
|
|
727
|
+
suggestions: [
|
|
728
|
+
`retry with: arcubase-admin table create --app-id <app_id> --body-json '${JSON.stringify(buildTableCreateExampleBody())}'`,
|
|
729
|
+
],
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (requestType === 'AppIngressCreateReqVO' || requestType === 'AppIngressUpdateReqVO') {
|
|
733
|
+
return {
|
|
734
|
+
shapeHint: buildAccessRuleShapeHint(),
|
|
735
|
+
commonMistakes: [
|
|
736
|
+
'use options.policy, not top-level permissions',
|
|
737
|
+
'use options.policy.fields, not field_visibility',
|
|
738
|
+
'actions must use Arcubase action codes: view, add, edit; do not use read, write, or insert',
|
|
739
|
+
'field permission keys must be strings prefixed with colon, for example ":1002"; do not use FieldVO.key values such as "vote_name"',
|
|
740
|
+
'for access-rule update policy changes, use {"update":["options"],"options":{"policy":...,"list_options":{}}}; do not use update:["options.policy"]',
|
|
741
|
+
'use options.user_scope with type user/department/actor and numeric id; do not use FieldVO type "member" here',
|
|
742
|
+
'use assign-users only for updating user_scope later',
|
|
743
|
+
'assign-users preserves existing policy when body.update=["user_scope"]',
|
|
744
|
+
],
|
|
745
|
+
}
|
|
380
746
|
}
|
|
381
|
-
|
|
747
|
+
if (requestType === 'InsertReqVO') {
|
|
748
|
+
return {
|
|
749
|
+
shapeHint: {
|
|
750
|
+
fields: {
|
|
751
|
+
'1001': 'SO-1001',
|
|
752
|
+
'1002': 199,
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
commonMistakes: [
|
|
756
|
+
'row fields must use numeric field ids as JSON object keys',
|
|
757
|
+
'do not use field labels such as "标题" or "投票标题" as keys',
|
|
758
|
+
'run table get first when field ids are unclear',
|
|
759
|
+
'do not add access policy fields; the CLI resolves access policy internally',
|
|
760
|
+
],
|
|
761
|
+
suggestions: [
|
|
762
|
+
'run: arcubase table get --app-id <app_id> --table-id <table_id>',
|
|
763
|
+
'retry with: arcubase row create --app-id <app_id> --table-id <table_id> --body-json \'{"fields":{"1001":"今天吃什么"}}\'',
|
|
764
|
+
],
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (requestType === 'EntityQueryReqVO') {
|
|
768
|
+
return {
|
|
769
|
+
shapeHint: {
|
|
770
|
+
limit: 20,
|
|
771
|
+
offset: 0,
|
|
772
|
+
search: { text_search: 'SO-1001' },
|
|
773
|
+
},
|
|
774
|
+
commonMistakes: [
|
|
775
|
+
'limit, offset, search, sorts, and view_mode belong inside --body-json',
|
|
776
|
+
'do not pass --limit or --offset flags',
|
|
777
|
+
'do not add access policy fields; the CLI resolves access policy internally',
|
|
778
|
+
],
|
|
779
|
+
suggestions: [
|
|
780
|
+
'retry with: arcubase row query --app-id <app_id> --table-id <table_id> --body-json \'{"limit":20,"offset":0}\'',
|
|
781
|
+
],
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (requestType === 'EntityUpdateReqVO') {
|
|
785
|
+
return {
|
|
786
|
+
shapeHint: {
|
|
787
|
+
data: { '1002': 299 },
|
|
788
|
+
changed_fields: [1002],
|
|
789
|
+
},
|
|
790
|
+
commonMistakes: [
|
|
791
|
+
'use data, not fields',
|
|
792
|
+
'changed_fields is required and must match data keys',
|
|
793
|
+
'do not add access policy fields; the CLI resolves access policy internally',
|
|
794
|
+
],
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (requestType === 'EntityBulkUpdateReqVO') {
|
|
798
|
+
return {
|
|
799
|
+
shapeHint: {
|
|
800
|
+
selection: {
|
|
801
|
+
type: 'ids',
|
|
802
|
+
ids: [2188890411, 2188890412],
|
|
803
|
+
length: 2,
|
|
804
|
+
},
|
|
805
|
+
fields: [
|
|
806
|
+
{
|
|
807
|
+
id: 1003,
|
|
808
|
+
action: {
|
|
809
|
+
type: 'set',
|
|
810
|
+
value: 2,
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
],
|
|
814
|
+
},
|
|
815
|
+
commonMistakes: [
|
|
816
|
+
'bulk update fields[].action must be an object with type',
|
|
817
|
+
'use {"type":"set","value":2} to assign a scalar value',
|
|
818
|
+
'do not use value_set, setValue, or a top-level value field',
|
|
819
|
+
'use {"type":"number_add","number_add":10} only for numeric increments',
|
|
820
|
+
'selection.type=ids requires ids and length',
|
|
821
|
+
],
|
|
822
|
+
suggestions: [
|
|
823
|
+
'retry with: arcubase row bulk-update --app-id <app_id> --table-id <table_id> --body-json \'{"selection":{"type":"ids","ids":[2188890411],"length":1},"fields":[{"id":1003,"action":{"type":"set","value":2}}]}\'',
|
|
824
|
+
],
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return {}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function buildBodyValidationDetails(
|
|
831
|
+
scope: CommandScope,
|
|
832
|
+
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
833
|
+
requestType: string,
|
|
834
|
+
issues: CLIErrorDetails['issues'],
|
|
835
|
+
env: EnvInput = process.env,
|
|
836
|
+
): CLIErrorDetails {
|
|
837
|
+
return {
|
|
382
838
|
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
383
839
|
requestType,
|
|
384
|
-
issues
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
840
|
+
issues,
|
|
841
|
+
tsHints: getTypeHints(requestType, env),
|
|
842
|
+
docHints: getDocHints(requestType, env),
|
|
843
|
+
...buildBodyGuidance(requestType),
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function throwBodyValidationFailure(message: string, requestType: string, path: string, scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>, env: EnvInput = process.env): never {
|
|
848
|
+
if (isAssignUsersCommand(scope, command)) {
|
|
849
|
+
throw new CLIError('BODY_VALIDATION_FAILED', message, 2, buildAssignUsersValidationDetails(scope, command, requestType, path, message, env))
|
|
850
|
+
}
|
|
851
|
+
throw new CLIError('BODY_VALIDATION_FAILED', message, 2, buildBodyValidationDetails(scope, command, requestType, [{ path, message }], env))
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function requireTableCreateSchemaBody(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>, body: unknown, env: EnvInput) {
|
|
855
|
+
if (!isRecord(body)) {
|
|
856
|
+
throwBodyValidationFailure(
|
|
857
|
+
'table create requires a JSON object with name, fields, field_id_seq, layout, options, and workflow_enabled',
|
|
858
|
+
'AppCreateEntityReqVO',
|
|
859
|
+
'body',
|
|
860
|
+
scope,
|
|
861
|
+
command,
|
|
862
|
+
env,
|
|
863
|
+
)
|
|
864
|
+
}
|
|
865
|
+
if (!Array.isArray(body.fields) || body.fields.length === 0) {
|
|
866
|
+
throwBodyValidationFailure(
|
|
867
|
+
'table create must include non-empty body.fields; empty table shell creation is not allowed',
|
|
868
|
+
'AppCreateEntityReqVO',
|
|
869
|
+
'body.fields',
|
|
870
|
+
scope,
|
|
871
|
+
command,
|
|
872
|
+
env,
|
|
873
|
+
)
|
|
874
|
+
}
|
|
875
|
+
if (typeof body.field_id_seq !== 'number') {
|
|
876
|
+
throwBodyValidationFailure(
|
|
877
|
+
'table create must include body.field_id_seq greater than every field.id',
|
|
878
|
+
'AppCreateEntityReqVO',
|
|
879
|
+
'body.field_id_seq',
|
|
880
|
+
scope,
|
|
881
|
+
command,
|
|
882
|
+
env,
|
|
883
|
+
)
|
|
884
|
+
}
|
|
393
885
|
}
|
|
394
886
|
|
|
395
887
|
function stringArray(value: unknown): string[] {
|
|
396
888
|
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
|
397
889
|
}
|
|
398
890
|
|
|
891
|
+
function validateAccessRuleUserScope(
|
|
892
|
+
scope: CommandScope,
|
|
893
|
+
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
894
|
+
requestType: string,
|
|
895
|
+
userScope: unknown,
|
|
896
|
+
env: EnvInput,
|
|
897
|
+
pathPrefix = 'body.options.user_scope',
|
|
898
|
+
) {
|
|
899
|
+
if (userScope === undefined) return
|
|
900
|
+
if (!Array.isArray(userScope)) {
|
|
901
|
+
throwBodyValidationFailure(
|
|
902
|
+
'access-rule user_scope must be an array of {"type":"user","id":2188889901}',
|
|
903
|
+
requestType,
|
|
904
|
+
pathPrefix,
|
|
905
|
+
scope,
|
|
906
|
+
command,
|
|
907
|
+
env,
|
|
908
|
+
)
|
|
909
|
+
}
|
|
910
|
+
const allowedTypes = new Set(['user', 'department', 'actor'])
|
|
911
|
+
for (const [index, item] of userScope.entries()) {
|
|
912
|
+
if (!isRecord(item)) {
|
|
913
|
+
throwBodyValidationFailure(
|
|
914
|
+
'access-rule user_scope item must be an object',
|
|
915
|
+
requestType,
|
|
916
|
+
`${pathPrefix}.${index}`,
|
|
917
|
+
scope,
|
|
918
|
+
command,
|
|
919
|
+
env,
|
|
920
|
+
)
|
|
921
|
+
}
|
|
922
|
+
if (typeof item.type !== 'string' || !allowedTypes.has(item.type)) {
|
|
923
|
+
throwBodyValidationFailure(
|
|
924
|
+
'access-rule user_scope[].type must be "user", "department", or "actor"; do not use FieldVO type "member" here',
|
|
925
|
+
requestType,
|
|
926
|
+
`${pathPrefix}.${index}.type`,
|
|
927
|
+
scope,
|
|
928
|
+
command,
|
|
929
|
+
env,
|
|
930
|
+
)
|
|
931
|
+
}
|
|
932
|
+
if (typeof item.id !== 'number' || !Number.isInteger(item.id) || item.id <= 0) {
|
|
933
|
+
throwBodyValidationFailure(
|
|
934
|
+
'access-rule user_scope[].id must be numeric TenantUser.ID, department ID, or actor ID',
|
|
935
|
+
requestType,
|
|
936
|
+
`${pathPrefix}.${index}.id`,
|
|
937
|
+
scope,
|
|
938
|
+
command,
|
|
939
|
+
env,
|
|
940
|
+
)
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
399
945
|
function requireUpdateContains(
|
|
400
946
|
scope: CommandScope,
|
|
401
947
|
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
402
948
|
body: Record<string, unknown>,
|
|
403
949
|
field: string,
|
|
950
|
+
env: EnvInput,
|
|
404
951
|
) {
|
|
405
952
|
if (!(field in body)) return
|
|
406
953
|
const update = stringArray(body.update)
|
|
@@ -411,6 +958,7 @@ function requireUpdateContains(
|
|
|
411
958
|
'body.update',
|
|
412
959
|
scope,
|
|
413
960
|
command,
|
|
961
|
+
env,
|
|
414
962
|
)
|
|
415
963
|
}
|
|
416
964
|
}
|
|
@@ -419,6 +967,7 @@ function validateActionSpecificRawBody(
|
|
|
419
967
|
scope: CommandScope,
|
|
420
968
|
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
421
969
|
body: unknown,
|
|
970
|
+
env: EnvInput,
|
|
422
971
|
) {
|
|
423
972
|
if (!isRecord(body) || !command.requestType) {
|
|
424
973
|
return
|
|
@@ -427,13 +976,112 @@ function validateActionSpecificRawBody(
|
|
|
427
976
|
if (isAssignUsersCommand(scope, command)) {
|
|
428
977
|
if ('users' in body || 'allowedUsers' in body || 'user_scope' in body) {
|
|
429
978
|
throwBodyValidationFailure(
|
|
430
|
-
'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"
|
|
979
|
+
'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}',
|
|
431
980
|
command.requestType,
|
|
432
981
|
'body.options.user_scope',
|
|
433
982
|
scope,
|
|
434
983
|
command,
|
|
984
|
+
env,
|
|
985
|
+
)
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (isTableSchemaCommand(scope, command)) {
|
|
990
|
+
const fields = Array.isArray(body.fields) ? body.fields : []
|
|
991
|
+
const hasPermissionPatch = fields.some((field) => isRecord(field) && ('permission' in field || 'permissions' in field || 'field_visibility' in field))
|
|
992
|
+
if (hasPermissionPatch) {
|
|
993
|
+
throwBodyValidationFailure(
|
|
994
|
+
'table schema cannot configure per-user permissions. Use access-rule create/update with options.policy.fields for hidden fields',
|
|
995
|
+
command.requestType,
|
|
996
|
+
'body.fields',
|
|
997
|
+
scope,
|
|
998
|
+
command,
|
|
999
|
+
env,
|
|
1000
|
+
)
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
if (isAccessRuleCommand(scope, command)) {
|
|
1005
|
+
const update = stringArray(body.update)
|
|
1006
|
+
if (isAccessRuleUpdateCommand(scope, command) && update.some((item) => item.startsWith('options.'))) {
|
|
1007
|
+
throwBodyValidationFailure(
|
|
1008
|
+
'access-rule update cannot patch nested options paths. Use {"update":["options"],"options":{"policy":{"key":"custom","actions":["view","add","edit"],"all_fields_read":true,"all_fields_write":true,"fields":[{"key":":1002","read":false,"write":false,"report":false}]},"list_options":{}}}',
|
|
1009
|
+
command.requestType,
|
|
1010
|
+
'body.update',
|
|
1011
|
+
scope,
|
|
1012
|
+
command,
|
|
1013
|
+
env,
|
|
435
1014
|
)
|
|
436
1015
|
}
|
|
1016
|
+
if ('permissions' in body || 'field_visibility' in body || 'description' in body) {
|
|
1017
|
+
throwBodyValidationFailure(
|
|
1018
|
+
'access-rule body only supports name, enabled, type, and options; use options.policy.actions and options.policy.fields',
|
|
1019
|
+
command.requestType,
|
|
1020
|
+
'body',
|
|
1021
|
+
scope,
|
|
1022
|
+
command,
|
|
1023
|
+
env,
|
|
1024
|
+
)
|
|
1025
|
+
}
|
|
1026
|
+
const options = isRecord(body.options) ? body.options : undefined
|
|
1027
|
+
if (options && ('permissions' in options || 'field_visibility' in options)) {
|
|
1028
|
+
throwBodyValidationFailure(
|
|
1029
|
+
'access-rule options only supports user_scope, policy, list_options, i18n_name; use options.policy.actions and options.policy.fields',
|
|
1030
|
+
command.requestType,
|
|
1031
|
+
'body.options',
|
|
1032
|
+
scope,
|
|
1033
|
+
command,
|
|
1034
|
+
env,
|
|
1035
|
+
)
|
|
1036
|
+
}
|
|
1037
|
+
const policy = options && isRecord(options.policy) ? options.policy : undefined
|
|
1038
|
+
if (isAccessRuleUpdateCommand(scope, command) && update.includes('options') && options?.user_scope !== undefined && !policy) {
|
|
1039
|
+
throwBodyValidationFailure(
|
|
1040
|
+
'access-rule update with update:["options"] cannot change only options.user_scope because it overwrites policy. Use access-rule assign-users for user assignment, or include the full options.policy and options.list_options.',
|
|
1041
|
+
command.requestType,
|
|
1042
|
+
'body.options.policy',
|
|
1043
|
+
scope,
|
|
1044
|
+
command,
|
|
1045
|
+
env,
|
|
1046
|
+
)
|
|
1047
|
+
}
|
|
1048
|
+
validateAccessRuleUserScope(scope, command, command.requestType, options?.user_scope, env)
|
|
1049
|
+
const actions = policy && Array.isArray(policy.actions) ? policy.actions : []
|
|
1050
|
+
if (actions.includes('read') || actions.includes('write')) {
|
|
1051
|
+
throwBodyValidationFailure(
|
|
1052
|
+
'access-rule policy.actions must use Arcubase action codes: use "view" for read and "add","edit" for write; do not use "read", "write", or "insert"',
|
|
1053
|
+
command.requestType,
|
|
1054
|
+
'body.options.policy.actions',
|
|
1055
|
+
scope,
|
|
1056
|
+
command,
|
|
1057
|
+
env,
|
|
1058
|
+
)
|
|
1059
|
+
}
|
|
1060
|
+
const fields = policy && Array.isArray(policy.fields) ? policy.fields : []
|
|
1061
|
+
const allowedSystemFieldKeys = new Set(['created', 'updated', 'creator'])
|
|
1062
|
+
for (const [index, field] of fields.entries()) {
|
|
1063
|
+
if (!isRecord(field)) continue
|
|
1064
|
+
if (typeof field.key === 'number' || (typeof field.key === 'string' && /^\d+$/.test(field.key))) {
|
|
1065
|
+
throwBodyValidationFailure(
|
|
1066
|
+
'access-rule policy.fields[].key must be a string prefixed with colon, for example ":1002"; do not use raw numbers',
|
|
1067
|
+
command.requestType,
|
|
1068
|
+
`body.options.policy.fields.${index}.key`,
|
|
1069
|
+
scope,
|
|
1070
|
+
command,
|
|
1071
|
+
env,
|
|
1072
|
+
)
|
|
1073
|
+
}
|
|
1074
|
+
if (typeof field.key === 'string' && !field.key.startsWith(':') && !allowedSystemFieldKeys.has(field.key)) {
|
|
1075
|
+
throwBodyValidationFailure(
|
|
1076
|
+
'access-rule policy.fields[].key must use Arcubase permission keys such as ":1002"; do not use FieldVO.key values such as "vote_name"',
|
|
1077
|
+
command.requestType,
|
|
1078
|
+
`body.options.policy.fields.${index}.key`,
|
|
1079
|
+
scope,
|
|
1080
|
+
command,
|
|
1081
|
+
env,
|
|
1082
|
+
)
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
437
1085
|
}
|
|
438
1086
|
}
|
|
439
1087
|
|
|
@@ -442,15 +1090,16 @@ function validateActionSpecificBody(
|
|
|
442
1090
|
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
443
1091
|
flags: Record<string, string | boolean>,
|
|
444
1092
|
body: unknown,
|
|
1093
|
+
env: EnvInput,
|
|
445
1094
|
) {
|
|
446
1095
|
if (!isRecord(body) || !command.requestType) {
|
|
447
1096
|
return
|
|
448
1097
|
}
|
|
449
1098
|
|
|
450
1099
|
if (scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'update') {
|
|
451
|
-
requireUpdateContains(scope, command, body, 'enabled')
|
|
452
|
-
requireUpdateContains(scope, command, body, 'name')
|
|
453
|
-
requireUpdateContains(scope, command, body, 'options')
|
|
1100
|
+
requireUpdateContains(scope, command, body, 'enabled', env)
|
|
1101
|
+
requireUpdateContains(scope, command, body, 'name', env)
|
|
1102
|
+
requireUpdateContains(scope, command, body, 'options', env)
|
|
454
1103
|
}
|
|
455
1104
|
|
|
456
1105
|
if (isAssignUsersCommand(scope, command)) {
|
|
@@ -459,11 +1108,12 @@ function validateActionSpecificBody(
|
|
|
459
1108
|
const userScope = options && Array.isArray(options.user_scope) ? options.user_scope : undefined
|
|
460
1109
|
if ('users' in body || 'allowedUsers' in body) {
|
|
461
1110
|
throwBodyValidationFailure(
|
|
462
|
-
'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"
|
|
1111
|
+
'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}',
|
|
463
1112
|
command.requestType,
|
|
464
1113
|
'body.options.user_scope',
|
|
465
1114
|
scope,
|
|
466
1115
|
command,
|
|
1116
|
+
env,
|
|
467
1117
|
)
|
|
468
1118
|
}
|
|
469
1119
|
if (!update.includes('user_scope')) {
|
|
@@ -473,6 +1123,7 @@ function validateActionSpecificBody(
|
|
|
473
1123
|
'body.update',
|
|
474
1124
|
scope,
|
|
475
1125
|
command,
|
|
1126
|
+
env,
|
|
476
1127
|
)
|
|
477
1128
|
}
|
|
478
1129
|
if (!userScope || userScope.length === 0) {
|
|
@@ -482,8 +1133,10 @@ function validateActionSpecificBody(
|
|
|
482
1133
|
'body.options.user_scope',
|
|
483
1134
|
scope,
|
|
484
1135
|
command,
|
|
1136
|
+
env,
|
|
485
1137
|
)
|
|
486
1138
|
}
|
|
1139
|
+
validateAccessRuleUserScope(scope, command, command.requestType, userScope, env)
|
|
487
1140
|
const assignedUserScope = userScope
|
|
488
1141
|
for (const [index, item] of assignedUserScope.entries()) {
|
|
489
1142
|
if (!isRecord(item)) {
|
|
@@ -493,15 +1146,17 @@ function validateActionSpecificBody(
|
|
|
493
1146
|
`body.options.user_scope.${index}`,
|
|
494
1147
|
scope,
|
|
495
1148
|
command,
|
|
1149
|
+
env,
|
|
496
1150
|
)
|
|
497
1151
|
}
|
|
498
|
-
if (item.type !== '
|
|
1152
|
+
if (item.type !== 'user') {
|
|
499
1153
|
throwBodyValidationFailure(
|
|
500
|
-
'access-rule assign-users requires body.options.user_scope[].type to be "
|
|
1154
|
+
'access-rule assign-users requires body.options.user_scope[].type to be "user"',
|
|
501
1155
|
command.requestType,
|
|
502
1156
|
`body.options.user_scope.${index}.type`,
|
|
503
1157
|
scope,
|
|
504
1158
|
command,
|
|
1159
|
+
env,
|
|
505
1160
|
)
|
|
506
1161
|
}
|
|
507
1162
|
if (typeof item.id !== 'number' || !Number.isInteger(item.id) || item.id <= 0) {
|
|
@@ -511,6 +1166,7 @@ function validateActionSpecificBody(
|
|
|
511
1166
|
`body.options.user_scope.${index}.id`,
|
|
512
1167
|
scope,
|
|
513
1168
|
command,
|
|
1169
|
+
env,
|
|
514
1170
|
)
|
|
515
1171
|
}
|
|
516
1172
|
}
|
|
@@ -535,6 +1191,7 @@ function validateActionSpecificBody(
|
|
|
535
1191
|
'body.selection.type',
|
|
536
1192
|
scope,
|
|
537
1193
|
command,
|
|
1194
|
+
env,
|
|
538
1195
|
)
|
|
539
1196
|
}
|
|
540
1197
|
return
|
|
@@ -547,6 +1204,7 @@ function validateActionSpecificBody(
|
|
|
547
1204
|
'body.selection.type',
|
|
548
1205
|
scope,
|
|
549
1206
|
command,
|
|
1207
|
+
env,
|
|
550
1208
|
)
|
|
551
1209
|
}
|
|
552
1210
|
|
|
@@ -557,11 +1215,370 @@ function validateActionSpecificBody(
|
|
|
557
1215
|
'body.selection.condition',
|
|
558
1216
|
scope,
|
|
559
1217
|
command,
|
|
1218
|
+
env,
|
|
560
1219
|
)
|
|
561
1220
|
}
|
|
562
1221
|
}
|
|
563
1222
|
}
|
|
564
1223
|
|
|
1224
|
+
function unwrapData(value: unknown): unknown {
|
|
1225
|
+
let current = value
|
|
1226
|
+
for (let i = 0; i < 3; i += 1) {
|
|
1227
|
+
if (isRecord(current) && isRecord(current.data)) {
|
|
1228
|
+
current = current.data
|
|
1229
|
+
continue
|
|
1230
|
+
}
|
|
1231
|
+
break
|
|
1232
|
+
}
|
|
1233
|
+
return current
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function requiredRowAction(command: NonNullable<ReturnType<typeof findCommand>>, flags: Record<string, string | boolean>): string | undefined {
|
|
1237
|
+
if (command.commandPath[0] !== 'row') return undefined
|
|
1238
|
+
switch (command.commandPath[1]) {
|
|
1239
|
+
case 'query':
|
|
1240
|
+
case 'get':
|
|
1241
|
+
return 'view'
|
|
1242
|
+
case 'create':
|
|
1243
|
+
return 'add'
|
|
1244
|
+
case 'update':
|
|
1245
|
+
return 'edit'
|
|
1246
|
+
case 'delete':
|
|
1247
|
+
return 'delete'
|
|
1248
|
+
case 'bulk-update':
|
|
1249
|
+
return 'bulk_update'
|
|
1250
|
+
case 'selection-action':
|
|
1251
|
+
return flagValue(flags, 'action') || undefined
|
|
1252
|
+
default:
|
|
1253
|
+
return undefined
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function rowCommandNeedsPolicy(command: NonNullable<ReturnType<typeof findCommand>>): boolean {
|
|
1258
|
+
return command.scope === 'user' && command.commandPath[0] === 'row'
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function extractIngressItems(appDetail: unknown): Array<{ id: string }> {
|
|
1262
|
+
const appData = unwrapData(appDetail)
|
|
1263
|
+
const ingress = isRecord(appData) ? appData.ingress : undefined
|
|
1264
|
+
const groups = isRecord(ingress) && Array.isArray(ingress.groups) ? ingress.groups : []
|
|
1265
|
+
const items: Array<{ id: string }> = []
|
|
1266
|
+
for (const group of groups) {
|
|
1267
|
+
const groupItems = isRecord(group) && Array.isArray(group.items) ? group.items : []
|
|
1268
|
+
for (const item of groupItems) {
|
|
1269
|
+
if (!isRecord(item)) continue
|
|
1270
|
+
const id = typeof item.id === 'string' ? item.id : typeof item.ID === 'string' ? item.ID : ''
|
|
1271
|
+
if (id) {
|
|
1272
|
+
items.push({ id })
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return items
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
type ResolvedRowPolicy = {
|
|
1280
|
+
policyID: string
|
|
1281
|
+
policy?: Record<string, unknown>
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function ingressMatchesTableAndAction(ingressDetail: unknown, tableId: string, action: string | undefined): ResolvedRowPolicy | undefined {
|
|
1285
|
+
const ingressData = unwrapData(ingressDetail)
|
|
1286
|
+
if (!isRecord(ingressData)) return undefined
|
|
1287
|
+
const id = typeof ingressData.id === 'number' || typeof ingressData.id === 'string'
|
|
1288
|
+
? ingressData.id
|
|
1289
|
+
: typeof ingressData.ID === 'number' || typeof ingressData.ID === 'string'
|
|
1290
|
+
? ingressData.ID
|
|
1291
|
+
: undefined
|
|
1292
|
+
if (String(id ?? '') !== tableId) return undefined
|
|
1293
|
+
const policyID = typeof ingressData.hash_id === 'string'
|
|
1294
|
+
? ingressData.hash_id
|
|
1295
|
+
: typeof ingressData.hashId === 'string'
|
|
1296
|
+
? ingressData.hashId
|
|
1297
|
+
: undefined
|
|
1298
|
+
const policy = isRecord(ingressData.policy) ? ingressData.policy : undefined
|
|
1299
|
+
if (!action) return { policyID: policyID || '', policy }
|
|
1300
|
+
const actions = policy && Array.isArray(policy.actions) ? policy.actions.filter((item): item is string => typeof item === 'string') : []
|
|
1301
|
+
if (!actions.includes(action)) return undefined
|
|
1302
|
+
return { policyID: policyID || '', policy }
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function writtenRowFieldIds(command: NonNullable<ReturnType<typeof findCommand>>, body: unknown): number[] {
|
|
1306
|
+
if (!isRecord(body)) return []
|
|
1307
|
+
const source = command.commandPath[1] === 'create'
|
|
1308
|
+
? body.fields
|
|
1309
|
+
: command.commandPath[1] === 'update'
|
|
1310
|
+
? body.data
|
|
1311
|
+
: undefined
|
|
1312
|
+
if (!isRecord(source)) return []
|
|
1313
|
+
return Object.keys(source)
|
|
1314
|
+
.filter((key) => /^\d+$/.test(key))
|
|
1315
|
+
.map((key) => Number(key))
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function fieldWriteAllowedByPolicy(policy: Record<string, unknown> | undefined, fieldId: number): boolean {
|
|
1319
|
+
if (!policy) return true
|
|
1320
|
+
const fields = Array.isArray(policy.fields) ? policy.fields : []
|
|
1321
|
+
const explicit = fields.find((item) => isRecord(item) && item.key === `:${fieldId}`)
|
|
1322
|
+
if (isRecord(explicit) && explicit.write === false) return false
|
|
1323
|
+
if (isRecord(explicit) && explicit.write === true) return true
|
|
1324
|
+
if (policy.all_fields_write === false) return false
|
|
1325
|
+
return true
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function assertWritableFieldsForRowCommand(
|
|
1329
|
+
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
1330
|
+
runtimeEnv: RuntimeEnv,
|
|
1331
|
+
body: unknown,
|
|
1332
|
+
policy: Record<string, unknown> | undefined,
|
|
1333
|
+
) {
|
|
1334
|
+
if (command.commandPath[1] !== 'create' && command.commandPath[1] !== 'update') {
|
|
1335
|
+
return
|
|
1336
|
+
}
|
|
1337
|
+
const denied = writtenRowFieldIds(command, body).filter((fieldId) => !fieldWriteAllowedByPolicy(policy, fieldId))
|
|
1338
|
+
if (denied.length === 0) {
|
|
1339
|
+
return
|
|
1340
|
+
}
|
|
1341
|
+
const payloadKey = command.commandPath[1] === 'create' ? 'fields' : 'data'
|
|
1342
|
+
throw new CLIError('FIELD_PERMISSION_REQUIRED', `row ${command.commandPath[1]} includes field ids without write permission: ${denied.join(', ')}`, 2, {
|
|
1343
|
+
operation: `user.${command.module}.${command.functionName}`,
|
|
1344
|
+
requestType: command.requestType ?? undefined,
|
|
1345
|
+
issues: denied.map((fieldId) => ({
|
|
1346
|
+
path: `body.${payloadKey}.${fieldId}`,
|
|
1347
|
+
message: 'field is not writable for the resolved Arcubase access rule',
|
|
1348
|
+
})),
|
|
1349
|
+
docHints: [
|
|
1350
|
+
{ title: 'Row CRUD', file: sdkPath('docs/runtime-reference/row-crud.md', runtimeEnv) },
|
|
1351
|
+
{ title: 'Runtime reference', file: sdkPath('docs/runtime-reference/README.md', runtimeEnv) },
|
|
1352
|
+
],
|
|
1353
|
+
suggestions: [
|
|
1354
|
+
'remove non-writable field ids from the row body and retry',
|
|
1355
|
+
'run arcubase table get to map visible business fields to numeric field ids',
|
|
1356
|
+
'ask an admin to update the access-rule if this field should be writable',
|
|
1357
|
+
],
|
|
1358
|
+
})
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
async function resolvePolicyIDForRowCommand(
|
|
1362
|
+
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
1363
|
+
runtimeEnv: RuntimeEnv,
|
|
1364
|
+
flags: Record<string, string | boolean>,
|
|
1365
|
+
fetchImpl: typeof fetch,
|
|
1366
|
+
): Promise<ResolvedRowPolicy> {
|
|
1367
|
+
const appId = flagValue(flags, 'app-id')
|
|
1368
|
+
const tableId = flagValue(flags, 'table-id')
|
|
1369
|
+
const action = requiredRowAction(command, flags)
|
|
1370
|
+
if (!appId || !tableId) {
|
|
1371
|
+
throw new CLIError('POLICY_ID_RESOLUTION_FAILED', 'row command requires --app-id and --table-id to resolve Arcubase policy_id', 2, {
|
|
1372
|
+
operation: `user.${command.module}.${command.functionName}`,
|
|
1373
|
+
docHints: getDocHints(command.requestType ?? '', runtimeEnv),
|
|
1374
|
+
})
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const appDetail = await requestJSON(runtimeEnv, 'GET', `/api/apps/${encodeURIComponent(appId)}`, undefined, fetchImpl)
|
|
1378
|
+
const ingressItems = extractIngressItems(appDetail.data)
|
|
1379
|
+
for (const item of ingressItems) {
|
|
1380
|
+
const ingressDetail = await requestJSON(runtimeEnv, 'GET', `/api/ingress/${encodeURIComponent(appId)}/${encodeURIComponent(item.id)}`, undefined, fetchImpl)
|
|
1381
|
+
const match = ingressMatchesTableAndAction(ingressDetail.data, tableId, action)
|
|
1382
|
+
if (match) {
|
|
1383
|
+
return { ...match, policyID: match.policyID || item.id }
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
throw new CLIError('POLICY_ID_RESOLUTION_FAILED', `no accessible ingress policy found for app ${appId}, table ${tableId}${action ? `, action ${action}` : ''}`, 2, {
|
|
1388
|
+
operation: `user.${command.module}.${command.functionName}`,
|
|
1389
|
+
requestType: command.requestType ?? undefined,
|
|
1390
|
+
issues: [{ path: 'policy_id', message: 'CLI could not resolve policy_id from accessible ingress' }],
|
|
1391
|
+
docHints: [
|
|
1392
|
+
{ title: 'Row CRUD', file: sdkPath('docs/runtime-reference/row-crud.md', runtimeEnv) },
|
|
1393
|
+
{ title: 'Runtime reference', file: sdkPath('docs/runtime-reference/README.md', runtimeEnv) },
|
|
1394
|
+
],
|
|
1395
|
+
suggestions: [
|
|
1396
|
+
'run arcubase entry and confirm the app/table is visible',
|
|
1397
|
+
'ask an admin to create an enabled access-rule with the required action for this table',
|
|
1398
|
+
],
|
|
1399
|
+
})
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
async function injectResolvedPolicyID(
|
|
1403
|
+
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
1404
|
+
runtimeEnv: RuntimeEnv,
|
|
1405
|
+
flags: Record<string, string | boolean>,
|
|
1406
|
+
query: Record<string, unknown>,
|
|
1407
|
+
body: unknown,
|
|
1408
|
+
fetchImpl: typeof fetch,
|
|
1409
|
+
): Promise<{ query: Record<string, unknown>; body: unknown }> {
|
|
1410
|
+
if (!rowCommandNeedsPolicy(command)) {
|
|
1411
|
+
return { query, body }
|
|
1412
|
+
}
|
|
1413
|
+
if (typeof query.policy_id === 'string' && query.policy_id) {
|
|
1414
|
+
return { query, body }
|
|
1415
|
+
}
|
|
1416
|
+
if (isRecord(body) && typeof body.policy_id === 'string' && body.policy_id) {
|
|
1417
|
+
return { query, body }
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const resolvedPolicy = await resolvePolicyIDForRowCommand(command, runtimeEnv, flags, fetchImpl)
|
|
1421
|
+
const policyID = resolvedPolicy.policyID
|
|
1422
|
+
assertWritableFieldsForRowCommand(command, runtimeEnv, body, resolvedPolicy.policy)
|
|
1423
|
+
if (command.commandPath[1] === 'get') {
|
|
1424
|
+
return { query: { ...query, policy_id: policyID }, body }
|
|
1425
|
+
}
|
|
1426
|
+
if (isRecord(body)) {
|
|
1427
|
+
return { query, body: { ...body, policy_id: policyID } }
|
|
1428
|
+
}
|
|
1429
|
+
return { query, body }
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function assertNoUserPolicyIDInput(
|
|
1433
|
+
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
1434
|
+
runtimeEnv: RuntimeEnv,
|
|
1435
|
+
query: Record<string, unknown>,
|
|
1436
|
+
body: unknown,
|
|
1437
|
+
) {
|
|
1438
|
+
if (!rowCommandNeedsPolicy(command)) {
|
|
1439
|
+
return
|
|
1440
|
+
}
|
|
1441
|
+
if ('policy_id' in query || (isRecord(body) && 'policy_id' in body)) {
|
|
1442
|
+
throw new CLIError('INTERNAL_POLICY_ID_NOT_ALLOWED', 'policy_id is an internal Arcubase ingress parameter; use app/table/row ids only', 2, {
|
|
1443
|
+
operation: `user.${command.module}.${command.functionName}`,
|
|
1444
|
+
issues: [{ path: 'policy_id', message: 'do not provide policy_id in flags, query file, or body JSON' }],
|
|
1445
|
+
docHints: [
|
|
1446
|
+
{ title: 'Row CRUD', file: sdkPath('docs/runtime-reference/row-crud.md', runtimeEnv) },
|
|
1447
|
+
{ title: 'Runtime reference', file: sdkPath('docs/runtime-reference/README.md', runtimeEnv) },
|
|
1448
|
+
],
|
|
1449
|
+
suggestions: ['run arcubase entry to find the app/table ids, then retry without policy_id'],
|
|
1450
|
+
})
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
async function requestJSON(
|
|
1455
|
+
runtimeEnv: RuntimeEnv,
|
|
1456
|
+
method: string,
|
|
1457
|
+
endpoint: string,
|
|
1458
|
+
body: unknown,
|
|
1459
|
+
fetchImpl: typeof fetch,
|
|
1460
|
+
): Promise<{ status: number; data: any }> {
|
|
1461
|
+
const response = await fetchUpstream(buildURL(runtimeEnv.ARCUBASE_BASE_URL, endpoint), {
|
|
1462
|
+
method,
|
|
1463
|
+
headers: {
|
|
1464
|
+
...buildRequestHeaders(runtimeEnv),
|
|
1465
|
+
'Content-Type': 'application/json',
|
|
1466
|
+
},
|
|
1467
|
+
body: body === undefined || method === 'GET' ? undefined : JSON.stringify(body),
|
|
1468
|
+
}, endpoint, method, fetchImpl)
|
|
1469
|
+
const raw = await response.text()
|
|
1470
|
+
let parsedResponse: any = raw
|
|
1471
|
+
try {
|
|
1472
|
+
parsedResponse = raw ? JSON.parse(raw) : null
|
|
1473
|
+
} catch {
|
|
1474
|
+
// keep raw text
|
|
1475
|
+
}
|
|
1476
|
+
if (!response.ok) {
|
|
1477
|
+
throwUpstreamHTTPError(endpoint, method, response, parsedResponse)
|
|
1478
|
+
}
|
|
1479
|
+
if (parsedResponse && typeof parsedResponse === 'object' && 'error' in parsedResponse && parsedResponse.error) {
|
|
1480
|
+
const upstreamError = parsedResponse.error
|
|
1481
|
+
const upstreamMessage = upstreamErrorMessage(upstreamError)
|
|
1482
|
+
throw new CLIError('UPSTREAM_BODY_ERROR', upstreamMessage, 1, { upstreamError })
|
|
1483
|
+
}
|
|
1484
|
+
return { status: response.status, data: parsedResponse }
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function extractCreatedTableID(value: unknown): number {
|
|
1488
|
+
const candidates = [
|
|
1489
|
+
value,
|
|
1490
|
+
isRecord(value) ? value.data : undefined,
|
|
1491
|
+
isRecord(value) && isRecord(value.data) ? value.data.data : undefined,
|
|
1492
|
+
]
|
|
1493
|
+
for (const candidate of candidates) {
|
|
1494
|
+
if (isRecord(candidate) && typeof candidate.id === 'number') {
|
|
1495
|
+
return candidate.id
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
throw new CLIError('UPSTREAM_RESPONSE_INVALID', 'table create response did not include numeric table id', 1)
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function normalizeTableCreateSchemaBody(body: Record<string, any>, appId: string, tableId: number): Record<string, any> {
|
|
1502
|
+
const { parent: _parent, ...schemaBody } = body
|
|
1503
|
+
return {
|
|
1504
|
+
...schemaBody,
|
|
1505
|
+
id: tableId,
|
|
1506
|
+
app_id: Number.isFinite(Number(appId)) ? Number(appId) : body.app_id,
|
|
1507
|
+
schema_version: typeof body.schema_version === 'number' ? body.schema_version : 0,
|
|
1508
|
+
layout: Array.isArray(body.layout) ? body.layout : [],
|
|
1509
|
+
options: isRecord(body.options)
|
|
1510
|
+
? body.options
|
|
1511
|
+
: { Buttons: [], CustomActions: [], FrontendEvents: [], TitleFormat: { Fields: [], Parts: [], Body: '' }, misc: {} },
|
|
1512
|
+
workflow_enabled: typeof body.workflow_enabled === 'boolean' ? body.workflow_enabled : false,
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
async function executeTableCreateWithSchema(
|
|
1517
|
+
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
1518
|
+
runtimeEnv: RuntimeEnv,
|
|
1519
|
+
flags: Record<string, string | boolean>,
|
|
1520
|
+
body: unknown,
|
|
1521
|
+
fetchImpl: typeof fetch,
|
|
1522
|
+
): Promise<any> {
|
|
1523
|
+
const appId = flagValue(flags, 'app-id')
|
|
1524
|
+
if (!appId) {
|
|
1525
|
+
throw new CLIError('MISSING_PATH_FLAG', 'missing required flag --app-id', 2)
|
|
1526
|
+
}
|
|
1527
|
+
if (!isRecord(body)) {
|
|
1528
|
+
throw new CLIError('BODY_VALIDATION_FAILED', 'table create body must be an object', 2)
|
|
1529
|
+
}
|
|
1530
|
+
const createBody = {
|
|
1531
|
+
name: typeof body.name === 'string' ? body.name : undefined,
|
|
1532
|
+
parent: typeof body.parent === 'number' ? body.parent : 0,
|
|
1533
|
+
}
|
|
1534
|
+
if (!createBody.name) {
|
|
1535
|
+
throw new CLIError('BODY_VALIDATION_FAILED', 'table create requires body.name', 2)
|
|
1536
|
+
}
|
|
1537
|
+
const createResult = await requestJSON(runtimeEnv, command.method, resolveEndpoint(command, flags), createBody, fetchImpl)
|
|
1538
|
+
const tableId = extractCreatedTableID(createResult.data)
|
|
1539
|
+
const saveBody = normalizeTableCreateSchemaBody(body, appId, tableId)
|
|
1540
|
+
const saveSchema = getBodySchema('EntitySaveReqVO')
|
|
1541
|
+
if (!saveSchema) {
|
|
1542
|
+
throw new CLIError('BODY_VALIDATION_FAILED', 'EntitySaveReqVO schema is not available', 2)
|
|
1543
|
+
}
|
|
1544
|
+
const parsedSaveBody = saveSchema.safeParse(saveBody)
|
|
1545
|
+
if (!parsedSaveBody.success) {
|
|
1546
|
+
const message = parsedSaveBody.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ')
|
|
1547
|
+
throw new CLIError(
|
|
1548
|
+
'BODY_VALIDATION_FAILED',
|
|
1549
|
+
message,
|
|
1550
|
+
2,
|
|
1551
|
+
buildBodyValidationDetails(
|
|
1552
|
+
'admin',
|
|
1553
|
+
command,
|
|
1554
|
+
'EntitySaveReqVO',
|
|
1555
|
+
parsedSaveBody.error.issues.map((issue) => ({
|
|
1556
|
+
path: issue.path.length > 0 ? `body.${issue.path.join('.')}` : 'body',
|
|
1557
|
+
message: issue.message,
|
|
1558
|
+
})),
|
|
1559
|
+
runtimeEnv,
|
|
1560
|
+
),
|
|
1561
|
+
)
|
|
1562
|
+
}
|
|
1563
|
+
const saveResult = await requestJSON(
|
|
1564
|
+
runtimeEnv,
|
|
1565
|
+
'PUT',
|
|
1566
|
+
`/api/apps/${encodeURIComponent(appId)}/entity/${encodeURIComponent(String(tableId))}`,
|
|
1567
|
+
parsedSaveBody.data,
|
|
1568
|
+
fetchImpl,
|
|
1569
|
+
)
|
|
1570
|
+
return {
|
|
1571
|
+
kind: 'result',
|
|
1572
|
+
commandPath: command.commandPath,
|
|
1573
|
+
status: saveResult.status,
|
|
1574
|
+
data: {
|
|
1575
|
+
id: tableId,
|
|
1576
|
+
created: createResult.data,
|
|
1577
|
+
schema: saveResult.data,
|
|
1578
|
+
},
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
565
1582
|
export function summarizeCommand(scope: CommandScope, moduleName: string, commandName: string): CommandExecutionSummary {
|
|
566
1583
|
const command = findCommand(scope, moduleName, commandName)
|
|
567
1584
|
if (!command) {
|
|
@@ -574,37 +1591,13 @@ export function summarizeCommand(scope: CommandScope, moduleName: string, comman
|
|
|
574
1591
|
endpoint: command.endpoint,
|
|
575
1592
|
requestType: command.requestType,
|
|
576
1593
|
pathFlags: command.pathParams.filter((item) => !getFixedValue(item)).map((item) => item.flag),
|
|
577
|
-
queryFlags: command
|
|
1594
|
+
queryFlags: visibleQueryParams(command).map((item) => item.flag),
|
|
578
1595
|
}
|
|
579
1596
|
}
|
|
580
1597
|
|
|
581
1598
|
export async function executeCLI(scope: CommandScope, argv: string[], env?: RuntimeEnv, fetchImpl: typeof fetch = fetch): Promise<any> {
|
|
582
1599
|
const parsed = parseCLIArgs(argv)
|
|
583
|
-
|
|
584
|
-
if (hasFlag(parsed.flags, 'help') || parsed.commandTokens.length === 1) {
|
|
585
|
-
return { kind: 'help', text: renderUploadHelp() }
|
|
586
|
-
}
|
|
587
|
-
const sourcePath = parsed.commandTokens[1]
|
|
588
|
-
if (!sourcePath) {
|
|
589
|
-
throw new CLIError('UPLOAD_SOURCE_REQUIRED', 'upload requires a local file path', 2)
|
|
590
|
-
}
|
|
591
|
-
const runtimeEnv = env ?? loadRuntimeEnv(scope)
|
|
592
|
-
const data = await uploadLocalFile(
|
|
593
|
-
runtimeEnv,
|
|
594
|
-
sourcePath,
|
|
595
|
-
{
|
|
596
|
-
filename: flagValue(parsed.flags, 'filename'),
|
|
597
|
-
global: hasFlag(parsed.flags, 'global'),
|
|
598
|
-
},
|
|
599
|
-
fetchImpl,
|
|
600
|
-
)
|
|
601
|
-
return {
|
|
602
|
-
kind: 'result',
|
|
603
|
-
commandPath: ['upload', sourcePath],
|
|
604
|
-
status: 200,
|
|
605
|
-
data,
|
|
606
|
-
}
|
|
607
|
-
}
|
|
1600
|
+
const envInput = env ?? process.env
|
|
608
1601
|
if (parsed.commandTokens.length === 0) {
|
|
609
1602
|
return { kind: 'help', text: renderRootHelp(scope) }
|
|
610
1603
|
}
|
|
@@ -614,24 +1607,38 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
614
1607
|
}
|
|
615
1608
|
const singleTokenCommand = !commandName ? findCommand(scope, moduleName) : null
|
|
616
1609
|
if (!commandName && !singleTokenCommand) {
|
|
617
|
-
return { kind: 'help', text: renderModuleHelp(scope, moduleName) }
|
|
1610
|
+
return { kind: 'help', text: renderModuleHelp(scope, moduleName, envInput) }
|
|
618
1611
|
}
|
|
619
1612
|
if (hasFlag(parsed.flags, 'help')) {
|
|
620
|
-
return { kind: 'help', text: renderCommandHelp(scope, moduleName, commandName) }
|
|
1613
|
+
return { kind: 'help', text: renderCommandHelp(scope, moduleName, commandName, envInput) }
|
|
621
1614
|
}
|
|
622
1615
|
|
|
623
|
-
const runtimeEnv = env ?? loadRuntimeEnv(scope)
|
|
624
|
-
|
|
625
1616
|
const command = findCommand(scope, moduleName, commandName)
|
|
626
1617
|
if (!command) {
|
|
627
1618
|
throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName}${commandName ? ` ${commandName}` : ''}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName ?? ''))
|
|
628
1619
|
}
|
|
1620
|
+
validateKnownFlags(command, parsed.flags, envInput)
|
|
1621
|
+
|
|
1622
|
+
const runtimeEnv = env ?? loadRuntimeEnv(scope)
|
|
629
1623
|
|
|
630
1624
|
const endpoint = resolveEndpoint(command, parsed.flags)
|
|
631
1625
|
const scalarQuery = buildScalarQuery(command, parsed.flags)
|
|
632
1626
|
const fileQuery = loadQueryFromFlags(parsed.flags)
|
|
633
|
-
|
|
634
|
-
|
|
1627
|
+
let query = { ...(fileQuery ?? {}), ...(scalarQuery ?? {}) }
|
|
1628
|
+
let body: unknown
|
|
1629
|
+
try {
|
|
1630
|
+
body = loadBodyFromFlags(parsed.flags)
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
if (error instanceof CLIError && error.code === 'INVALID_BODY_JSON' && command.requestType) {
|
|
1633
|
+
throw new CLIError(
|
|
1634
|
+
'INVALID_BODY_JSON',
|
|
1635
|
+
`body json is invalid for ${scope}.${command.commandPath.join(' ')}; fix JSON syntax, then use the canonical ${command.requestType} shape from docs/types`,
|
|
1636
|
+
2,
|
|
1637
|
+
buildBodyValidationDetails(scope, command, command.requestType, [{ path: 'body', message: 'invalid JSON syntax' }], runtimeEnv),
|
|
1638
|
+
)
|
|
1639
|
+
}
|
|
1640
|
+
throw error
|
|
1641
|
+
}
|
|
635
1642
|
let validatedBody = body
|
|
636
1643
|
|
|
637
1644
|
if (!command.requestType && body !== undefined) {
|
|
@@ -640,9 +1647,38 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
640
1647
|
|
|
641
1648
|
if (command.requestType) {
|
|
642
1649
|
if (body === undefined) {
|
|
643
|
-
throw new CLIError('MISSING_BODY_FILE', `command requires --body-
|
|
1650
|
+
throw new CLIError('MISSING_BODY_FILE', `command requires --body-json or --body-file for ${command.requestType}; prefer --body-json to avoid file creation`, 2)
|
|
644
1651
|
}
|
|
645
|
-
|
|
1652
|
+
if (isTableCreateCommand(scope, command)) {
|
|
1653
|
+
requireTableCreateSchemaBody(scope, command, body, runtimeEnv)
|
|
1654
|
+
validateActionSpecificRawBody(scope, command, body, runtimeEnv)
|
|
1655
|
+
const { parent: _parent, ...schemaBody } = isRecord(body) ? body : {}
|
|
1656
|
+
const parsedBody = getBodySchema('EntitySaveReqVO')?.safeParse(schemaBody)
|
|
1657
|
+
if (!parsedBody?.success) {
|
|
1658
|
+
const message = parsedBody
|
|
1659
|
+
? parsedBody.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ')
|
|
1660
|
+
: 'EntitySaveReqVO schema is not available'
|
|
1661
|
+
throw new CLIError(
|
|
1662
|
+
'BODY_VALIDATION_FAILED',
|
|
1663
|
+
message,
|
|
1664
|
+
2,
|
|
1665
|
+
buildBodyValidationDetails(
|
|
1666
|
+
scope,
|
|
1667
|
+
command,
|
|
1668
|
+
'EntitySaveReqVO',
|
|
1669
|
+
parsedBody && !parsedBody.success
|
|
1670
|
+
? parsedBody.error.issues.map((issue) => ({
|
|
1671
|
+
path: issue.path.length > 0 ? `body.${issue.path.join('.')}` : 'body',
|
|
1672
|
+
message: issue.message,
|
|
1673
|
+
}))
|
|
1674
|
+
: [{ path: 'body', message }],
|
|
1675
|
+
runtimeEnv,
|
|
1676
|
+
),
|
|
1677
|
+
)
|
|
1678
|
+
}
|
|
1679
|
+
return executeTableCreateWithSchema(command, runtimeEnv, parsed.flags, parsedBody.data, fetchImpl)
|
|
1680
|
+
}
|
|
1681
|
+
validateActionSpecificRawBody(scope, command, body, runtimeEnv)
|
|
646
1682
|
const schema = getBodySchema(command.requestType)
|
|
647
1683
|
if (schema) {
|
|
648
1684
|
const parsedBody = schema.safeParse(body)
|
|
@@ -660,6 +1696,7 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
660
1696
|
command.requestType,
|
|
661
1697
|
hasUserScopeIDIssue ? 'body.options.user_scope' : 'body',
|
|
662
1698
|
hasUserScopeIDIssue ? 'access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID' : message,
|
|
1699
|
+
runtimeEnv,
|
|
663
1700
|
)
|
|
664
1701
|
)
|
|
665
1702
|
}
|
|
@@ -667,20 +1704,20 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
667
1704
|
'BODY_VALIDATION_FAILED',
|
|
668
1705
|
message,
|
|
669
1706
|
2,
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
1707
|
+
buildBodyValidationDetails(
|
|
1708
|
+
scope,
|
|
1709
|
+
command,
|
|
1710
|
+
command.requestType,
|
|
1711
|
+
parsedBody.error.issues.map((issue) => ({
|
|
674
1712
|
path: issue.path.length > 0 ? `body.${issue.path.join('.')}` : 'body',
|
|
675
1713
|
message: issue.message,
|
|
676
1714
|
})),
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
}
|
|
1715
|
+
runtimeEnv,
|
|
1716
|
+
)
|
|
680
1717
|
)
|
|
681
1718
|
}
|
|
682
1719
|
validatedBody = parsedBody.data
|
|
683
|
-
validateActionSpecificBody(scope, command, parsed.flags, validatedBody)
|
|
1720
|
+
validateActionSpecificBody(scope, command, parsed.flags, validatedBody, runtimeEnv)
|
|
684
1721
|
} else {
|
|
685
1722
|
const unsupportedReason = getUnsupportedBodySchemaReason(command.requestType)
|
|
686
1723
|
if (unsupportedReason) {
|
|
@@ -688,25 +1725,30 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
688
1725
|
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
689
1726
|
requestType: command.requestType,
|
|
690
1727
|
reason: unsupportedReason,
|
|
691
|
-
tsHints: getTypeHints(command.requestType),
|
|
692
|
-
docHints: getDocHints(command.requestType),
|
|
1728
|
+
tsHints: getTypeHints(command.requestType, runtimeEnv),
|
|
1729
|
+
docHints: getDocHints(command.requestType, runtimeEnv),
|
|
693
1730
|
})
|
|
694
1731
|
}
|
|
695
1732
|
}
|
|
696
1733
|
}
|
|
697
1734
|
|
|
1735
|
+
assertNoUserPolicyIDInput(command, runtimeEnv, query, validatedBody)
|
|
1736
|
+
|
|
698
1737
|
if (scope === 'user' && (command.functionName === 'insertEntity' || command.functionName === 'updateEntityRow')) {
|
|
699
1738
|
await validateFileFieldPayloads(scope, command, runtimeEnv, parsed.flags, validatedBody, fetchImpl)
|
|
700
1739
|
}
|
|
1740
|
+
const policyInjected = await injectResolvedPolicyID(command, runtimeEnv, parsed.flags, query, validatedBody, fetchImpl)
|
|
1741
|
+
query = policyInjected.query
|
|
1742
|
+
validatedBody = policyInjected.body
|
|
701
1743
|
|
|
702
|
-
const response = await
|
|
1744
|
+
const response = await fetchUpstream(buildURL(runtimeEnv.ARCUBASE_BASE_URL, endpoint, Object.keys(query).length > 0 ? query : undefined), {
|
|
703
1745
|
method: command.method,
|
|
704
1746
|
headers: {
|
|
705
1747
|
...buildRequestHeaders(runtimeEnv),
|
|
706
1748
|
'Content-Type': 'application/json',
|
|
707
1749
|
},
|
|
708
1750
|
body: validatedBody === undefined || command.method === 'GET' ? undefined : JSON.stringify(validatedBody),
|
|
709
|
-
})
|
|
1751
|
+
}, endpoint, command.method, fetchImpl)
|
|
710
1752
|
|
|
711
1753
|
const raw = await response.text()
|
|
712
1754
|
let parsedResponse: any = raw
|
|
@@ -716,22 +1758,12 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
716
1758
|
// keep raw text
|
|
717
1759
|
}
|
|
718
1760
|
if (!response.ok) {
|
|
719
|
-
|
|
1761
|
+
throwUpstreamHTTPError(endpoint, command.method, response, parsedResponse)
|
|
720
1762
|
}
|
|
721
1763
|
if (parsedResponse && typeof parsedResponse === 'object' && 'error' in parsedResponse && parsedResponse.error) {
|
|
722
1764
|
const upstreamError = parsedResponse.error
|
|
723
|
-
const upstreamMessage =
|
|
724
|
-
|
|
725
|
-
? upstreamError.message
|
|
726
|
-
: typeof upstreamError === 'string'
|
|
727
|
-
? upstreamError
|
|
728
|
-
: JSON.stringify(upstreamError)
|
|
729
|
-
const traceId =
|
|
730
|
-
typeof parsedResponse.trace_id === 'string'
|
|
731
|
-
? parsedResponse.trace_id
|
|
732
|
-
: typeof parsedResponse.request_id === 'string'
|
|
733
|
-
? parsedResponse.request_id
|
|
734
|
-
: undefined
|
|
1765
|
+
const upstreamMessage = upstreamErrorMessage(upstreamError)
|
|
1766
|
+
const traceId = extractTraceID(parsedResponse)
|
|
735
1767
|
const uploadBindFailure = upstreamMessage.includes('failed to bind upload to assets')
|
|
736
1768
|
throw new CLIError('UPSTREAM_BODY_ERROR', upstreamMessage, 1, {
|
|
737
1769
|
endpoint,
|
|
@@ -741,10 +1773,10 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
741
1773
|
...(uploadBindFailure
|
|
742
1774
|
? {
|
|
743
1775
|
reason: 'the row payload used a file/image upload entry, but Arcubase could not bind that upload into an asset record',
|
|
744
|
-
hint: 'verify the upload storage host is reachable from the Arcubase server, then
|
|
1776
|
+
hint: 'verify the upload storage host is reachable from the Arcubase server, then use the upload token flow again and retry the row insert/update',
|
|
745
1777
|
docHints: [
|
|
746
|
-
{ title: 'Uploads', file: '
|
|
747
|
-
{ title: 'Row CRUD', file: '
|
|
1778
|
+
{ title: 'Uploads', file: 'docs/runtime-reference/uploads.md' },
|
|
1779
|
+
{ title: 'Row CRUD', file: 'docs/runtime-reference/row-crud.md' },
|
|
748
1780
|
],
|
|
749
1781
|
}
|
|
750
1782
|
: {}),
|