@carthooks/arcubase-cli 0.1.9 → 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 +1407 -345
- package/bundle/arcubase.mjs +1407 -345
- 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 +4 -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 +1025 -106
- 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 +446 -32
- package/dist/tests/help.test.js +56 -4
- 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 +4 -0
- package/src/runtime/execute.ts +1249 -129
- 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 +564 -45
- package/src/tests/help.test.ts +51 -7
- package/src/tests/upload.test.ts +34 -91
package/src/runtime/execute.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { parseCLIArgs, hasFlag, flagValue } from './argv.js'
|
|
2
2
|
import { loadRuntimeEnv, type RuntimeEnv } from './env.js'
|
|
3
|
-
import { CLIError } from './errors.js'
|
|
3
|
+
import { CLIError, type CLIErrorDetails } from './errors.js'
|
|
4
4
|
import { buildRequestHeaders, buildURL, normalizeExternalEndpoint } from './http.js'
|
|
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,21 +116,37 @@ 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":"user","id":
|
|
135
|
+
' - {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}',
|
|
108
136
|
'success requires:',
|
|
109
137
|
' - result.enabled must be true',
|
|
110
|
-
' - result.options.user_scope must contain the assigned
|
|
138
|
+
' - result.options.user_scope must contain the assigned numeric TenantUser.ID values',
|
|
139
|
+
' - PermitUserScope.id must be numeric TenantUser.ID',
|
|
140
|
+
' - Do not copy hash arcubaseTenantUserId into PermitUserScope.id',
|
|
141
|
+
' - If result.enabled is false, enable the rule first and retry assign-users',
|
|
111
142
|
]
|
|
112
143
|
: []),
|
|
113
144
|
...(docHints.length > 0
|
|
114
145
|
? ['docs:', ...docHints.map((item) => ` - ${item.title}: ${item.file}`)]
|
|
115
146
|
: []),
|
|
147
|
+
...(typeHints.length > 0
|
|
148
|
+
? ['types:', ...typeHints.map((item) => ` - ${item.symbol}: ${item.file}`)]
|
|
149
|
+
: []),
|
|
116
150
|
].join('\n')
|
|
117
151
|
}
|
|
118
152
|
|
|
@@ -158,7 +192,7 @@ function buildScalarQuery(command: ReturnType<typeof findCommand>, flags: Record
|
|
|
158
192
|
return undefined
|
|
159
193
|
}
|
|
160
194
|
const out: Record<string, any> = {}
|
|
161
|
-
for (const scalar of command
|
|
195
|
+
for (const scalar of effectiveQueryParams(command)) {
|
|
162
196
|
const value = getFixedValue(scalar) ?? flagValue(flags, scalar.flag)
|
|
163
197
|
if (value === undefined) continue
|
|
164
198
|
out[scalar.key] = coerceScalar(value, scalar.type)
|
|
@@ -166,10 +200,133 @@ function buildScalarQuery(command: ReturnType<typeof findCommand>, flags: Record
|
|
|
166
200
|
return Object.keys(out).length > 0 ? out : undefined
|
|
167
201
|
}
|
|
168
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
|
+
|
|
169
273
|
function isRecord(value: unknown): value is Record<string, any> {
|
|
170
274
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
171
275
|
}
|
|
172
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
|
+
|
|
173
330
|
type EntityFieldInfo = {
|
|
174
331
|
id: number
|
|
175
332
|
type: string
|
|
@@ -197,10 +354,12 @@ async function fetchEntityFields(
|
|
|
197
354
|
entityId: string,
|
|
198
355
|
fetchImpl: typeof fetch,
|
|
199
356
|
): Promise<Map<number, EntityFieldInfo>> {
|
|
200
|
-
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), {
|
|
201
360
|
method: 'GET',
|
|
202
361
|
headers: buildRequestHeaders(runtimeEnv),
|
|
203
|
-
})
|
|
362
|
+
}, endpoint, 'GET', fetchImpl)
|
|
204
363
|
const raw = await response.text()
|
|
205
364
|
let parsedResponse: unknown = raw
|
|
206
365
|
try {
|
|
@@ -209,19 +368,15 @@ async function fetchEntityFields(
|
|
|
209
368
|
parsedResponse = raw
|
|
210
369
|
}
|
|
211
370
|
if (!response.ok) {
|
|
212
|
-
|
|
371
|
+
throwUpstreamHTTPError(endpoint, 'GET', response, parsedResponse)
|
|
213
372
|
}
|
|
214
373
|
if (isRecord(parsedResponse) && parsedResponse.error) {
|
|
215
374
|
const upstreamError = parsedResponse.error
|
|
216
|
-
const upstreamMessage =
|
|
217
|
-
isRecord(upstreamError) && typeof upstreamError.message === 'string'
|
|
218
|
-
? upstreamError.message
|
|
219
|
-
: typeof upstreamError === 'string'
|
|
220
|
-
? upstreamError
|
|
221
|
-
: JSON.stringify(upstreamError)
|
|
375
|
+
const upstreamMessage = upstreamErrorMessage(upstreamError)
|
|
222
376
|
throw new CLIError('UPSTREAM_BODY_ERROR', upstreamMessage, 1, {
|
|
223
|
-
endpoint
|
|
377
|
+
endpoint,
|
|
224
378
|
method: 'GET',
|
|
379
|
+
traceId: extractTraceID(parsedResponse),
|
|
225
380
|
upstreamError,
|
|
226
381
|
})
|
|
227
382
|
}
|
|
@@ -282,52 +437,52 @@ async function validateFileFieldPayloads(
|
|
|
282
437
|
const fieldName = field.label ? `${field.label} (${field.type})` : `${field.type} field ${fieldId}`
|
|
283
438
|
if (typeof value === 'string') {
|
|
284
439
|
const guidance = looksLikeLocalFilePath(value)
|
|
285
|
-
? `do not put a local file path directly into ${fieldName};
|
|
286
|
-
: `${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`
|
|
287
442
|
throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
|
|
288
443
|
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
289
444
|
requestType: command.requestType ?? undefined,
|
|
290
445
|
issues: [{ path: fieldPath, message: guidance }],
|
|
291
446
|
docHints: [
|
|
292
|
-
{ title: 'Uploads', file: '
|
|
293
|
-
{ 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) },
|
|
294
449
|
],
|
|
295
450
|
})
|
|
296
451
|
}
|
|
297
452
|
if (isRecord(value)) {
|
|
298
|
-
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`
|
|
299
454
|
throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
|
|
300
455
|
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
301
456
|
requestType: command.requestType ?? undefined,
|
|
302
457
|
issues: [{ path: fieldPath, message: guidance }],
|
|
303
458
|
docHints: [
|
|
304
|
-
{ title: 'Uploads', file: '
|
|
305
|
-
{ 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) },
|
|
306
461
|
],
|
|
307
462
|
})
|
|
308
463
|
}
|
|
309
464
|
if (!Array.isArray(value)) {
|
|
310
|
-
const guidance = `${fieldName} must be an
|
|
465
|
+
const guidance = `${fieldName} must be an upload value array`
|
|
311
466
|
throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
|
|
312
467
|
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
313
468
|
requestType: command.requestType ?? undefined,
|
|
314
469
|
issues: [{ path: fieldPath, message: guidance }],
|
|
315
470
|
docHints: [
|
|
316
|
-
{ title: 'Uploads', file: '
|
|
317
|
-
{ 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) },
|
|
318
473
|
],
|
|
319
474
|
})
|
|
320
475
|
}
|
|
321
476
|
for (const [index, item] of value.entries()) {
|
|
322
477
|
if (!isValidUploadEntry(item)) {
|
|
323
|
-
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`
|
|
324
479
|
throw new CLIError('BODY_VALIDATION_FAILED', guidance, 2, {
|
|
325
480
|
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
326
481
|
requestType: command.requestType ?? undefined,
|
|
327
482
|
issues: [{ path: `${fieldPath}.${index}`, message: guidance }],
|
|
328
483
|
docHints: [
|
|
329
|
-
{ title: 'Uploads', file: '
|
|
330
|
-
{ 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) },
|
|
331
486
|
],
|
|
332
487
|
})
|
|
333
488
|
}
|
|
@@ -335,30 +490,464 @@ async function validateFileFieldPayloads(
|
|
|
335
490
|
}
|
|
336
491
|
}
|
|
337
492
|
|
|
338
|
-
function
|
|
339
|
-
|
|
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
|
+
|
|
549
|
+
function isAssignUsersCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
|
|
550
|
+
return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users'
|
|
551
|
+
}
|
|
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
|
+
|
|
588
|
+
function buildAssignUsersValidationDetails(
|
|
589
|
+
scope: CommandScope,
|
|
590
|
+
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
591
|
+
requestType: string,
|
|
592
|
+
path: string,
|
|
593
|
+
message: string,
|
|
594
|
+
env: EnvInput = process.env,
|
|
595
|
+
): CLIErrorDetails {
|
|
596
|
+
return {
|
|
340
597
|
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
341
598
|
requestType,
|
|
342
|
-
issues: [
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
599
|
+
issues: [{ path, message }],
|
|
600
|
+
tsHints: [
|
|
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) },
|
|
604
|
+
],
|
|
605
|
+
docHints: getDocHints(requestType, env),
|
|
606
|
+
shapeHint: {
|
|
607
|
+
update: ['user_scope'],
|
|
608
|
+
options: {
|
|
609
|
+
user_scope: [{ type: 'user', id: 2188889901 }],
|
|
346
610
|
},
|
|
611
|
+
},
|
|
612
|
+
commonMistakes: [
|
|
613
|
+
'do not use user_scope at top level',
|
|
614
|
+
'do not use users or allowedUsers',
|
|
615
|
+
'do not use hash tenant user id as id',
|
|
616
|
+
'id must be numeric TenantUser.ID',
|
|
347
617
|
],
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
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
|
+
}
|
|
746
|
+
}
|
|
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 {
|
|
838
|
+
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
839
|
+
requestType,
|
|
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
|
+
}
|
|
351
885
|
}
|
|
352
886
|
|
|
353
887
|
function stringArray(value: unknown): string[] {
|
|
354
888
|
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
|
355
889
|
}
|
|
356
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
|
+
|
|
357
945
|
function requireUpdateContains(
|
|
358
946
|
scope: CommandScope,
|
|
359
947
|
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
360
948
|
body: Record<string, unknown>,
|
|
361
949
|
field: string,
|
|
950
|
+
env: EnvInput,
|
|
362
951
|
) {
|
|
363
952
|
if (!(field in body)) return
|
|
364
953
|
const update = stringArray(body.update)
|
|
@@ -369,6 +958,7 @@ function requireUpdateContains(
|
|
|
369
958
|
'body.update',
|
|
370
959
|
scope,
|
|
371
960
|
command,
|
|
961
|
+
env,
|
|
372
962
|
)
|
|
373
963
|
}
|
|
374
964
|
}
|
|
@@ -377,22 +967,122 @@ function validateActionSpecificRawBody(
|
|
|
377
967
|
scope: CommandScope,
|
|
378
968
|
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
379
969
|
body: unknown,
|
|
970
|
+
env: EnvInput,
|
|
380
971
|
) {
|
|
381
972
|
if (!isRecord(body) || !command.requestType) {
|
|
382
973
|
return
|
|
383
974
|
}
|
|
384
975
|
|
|
385
|
-
if (scope
|
|
386
|
-
if ('users' in body || 'allowedUsers' in body) {
|
|
976
|
+
if (isAssignUsersCommand(scope, command)) {
|
|
977
|
+
if ('users' in body || 'allowedUsers' in body || 'user_scope' in body) {
|
|
387
978
|
throwBodyValidationFailure(
|
|
388
|
-
'access-rule assign-users
|
|
979
|
+
'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}',
|
|
389
980
|
command.requestType,
|
|
390
981
|
'body.options.user_scope',
|
|
391
982
|
scope,
|
|
392
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,
|
|
393
1000
|
)
|
|
394
1001
|
}
|
|
395
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,
|
|
1014
|
+
)
|
|
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
|
+
}
|
|
1085
|
+
}
|
|
396
1086
|
}
|
|
397
1087
|
|
|
398
1088
|
function validateActionSpecificBody(
|
|
@@ -400,28 +1090,30 @@ function validateActionSpecificBody(
|
|
|
400
1090
|
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
401
1091
|
flags: Record<string, string | boolean>,
|
|
402
1092
|
body: unknown,
|
|
1093
|
+
env: EnvInput,
|
|
403
1094
|
) {
|
|
404
1095
|
if (!isRecord(body) || !command.requestType) {
|
|
405
1096
|
return
|
|
406
1097
|
}
|
|
407
1098
|
|
|
408
1099
|
if (scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'update') {
|
|
409
|
-
requireUpdateContains(scope, command, body, 'enabled')
|
|
410
|
-
requireUpdateContains(scope, command, body, 'name')
|
|
411
|
-
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)
|
|
412
1103
|
}
|
|
413
1104
|
|
|
414
|
-
if (scope
|
|
1105
|
+
if (isAssignUsersCommand(scope, command)) {
|
|
415
1106
|
const update = stringArray(body.update)
|
|
416
1107
|
const options = isRecord(body.options) ? body.options : undefined
|
|
417
1108
|
const userScope = options && Array.isArray(options.user_scope) ? options.user_scope : undefined
|
|
418
1109
|
if ('users' in body || 'allowedUsers' in body) {
|
|
419
1110
|
throwBodyValidationFailure(
|
|
420
|
-
'access-rule assign-users
|
|
1111
|
+
'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}',
|
|
421
1112
|
command.requestType,
|
|
422
1113
|
'body.options.user_scope',
|
|
423
1114
|
scope,
|
|
424
1115
|
command,
|
|
1116
|
+
env,
|
|
425
1117
|
)
|
|
426
1118
|
}
|
|
427
1119
|
if (!update.includes('user_scope')) {
|
|
@@ -431,6 +1123,7 @@ function validateActionSpecificBody(
|
|
|
431
1123
|
'body.update',
|
|
432
1124
|
scope,
|
|
433
1125
|
command,
|
|
1126
|
+
env,
|
|
434
1127
|
)
|
|
435
1128
|
}
|
|
436
1129
|
if (!userScope || userScope.length === 0) {
|
|
@@ -440,8 +1133,43 @@ function validateActionSpecificBody(
|
|
|
440
1133
|
'body.options.user_scope',
|
|
441
1134
|
scope,
|
|
442
1135
|
command,
|
|
1136
|
+
env,
|
|
443
1137
|
)
|
|
444
1138
|
}
|
|
1139
|
+
validateAccessRuleUserScope(scope, command, command.requestType, userScope, env)
|
|
1140
|
+
const assignedUserScope = userScope
|
|
1141
|
+
for (const [index, item] of assignedUserScope.entries()) {
|
|
1142
|
+
if (!isRecord(item)) {
|
|
1143
|
+
throwBodyValidationFailure(
|
|
1144
|
+
'access-rule assign-users requires each body.options.user_scope item to be an object',
|
|
1145
|
+
command.requestType,
|
|
1146
|
+
`body.options.user_scope.${index}`,
|
|
1147
|
+
scope,
|
|
1148
|
+
command,
|
|
1149
|
+
env,
|
|
1150
|
+
)
|
|
1151
|
+
}
|
|
1152
|
+
if (item.type !== 'user') {
|
|
1153
|
+
throwBodyValidationFailure(
|
|
1154
|
+
'access-rule assign-users requires body.options.user_scope[].type to be "user"',
|
|
1155
|
+
command.requestType,
|
|
1156
|
+
`body.options.user_scope.${index}.type`,
|
|
1157
|
+
scope,
|
|
1158
|
+
command,
|
|
1159
|
+
env,
|
|
1160
|
+
)
|
|
1161
|
+
}
|
|
1162
|
+
if (typeof item.id !== 'number' || !Number.isInteger(item.id) || item.id <= 0) {
|
|
1163
|
+
throwBodyValidationFailure(
|
|
1164
|
+
'access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID',
|
|
1165
|
+
command.requestType,
|
|
1166
|
+
`body.options.user_scope.${index}.id`,
|
|
1167
|
+
scope,
|
|
1168
|
+
command,
|
|
1169
|
+
env,
|
|
1170
|
+
)
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
445
1173
|
}
|
|
446
1174
|
|
|
447
1175
|
if (command.commandPath[0] === 'row' && command.commandPath[1] === 'selection-action') {
|
|
@@ -463,6 +1191,7 @@ function validateActionSpecificBody(
|
|
|
463
1191
|
'body.selection.type',
|
|
464
1192
|
scope,
|
|
465
1193
|
command,
|
|
1194
|
+
env,
|
|
466
1195
|
)
|
|
467
1196
|
}
|
|
468
1197
|
return
|
|
@@ -475,6 +1204,7 @@ function validateActionSpecificBody(
|
|
|
475
1204
|
'body.selection.type',
|
|
476
1205
|
scope,
|
|
477
1206
|
command,
|
|
1207
|
+
env,
|
|
478
1208
|
)
|
|
479
1209
|
}
|
|
480
1210
|
|
|
@@ -485,11 +1215,370 @@ function validateActionSpecificBody(
|
|
|
485
1215
|
'body.selection.condition',
|
|
486
1216
|
scope,
|
|
487
1217
|
command,
|
|
1218
|
+
env,
|
|
488
1219
|
)
|
|
489
1220
|
}
|
|
490
1221
|
}
|
|
491
1222
|
}
|
|
492
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
|
+
|
|
493
1582
|
export function summarizeCommand(scope: CommandScope, moduleName: string, commandName: string): CommandExecutionSummary {
|
|
494
1583
|
const command = findCommand(scope, moduleName, commandName)
|
|
495
1584
|
if (!command) {
|
|
@@ -502,37 +1591,13 @@ export function summarizeCommand(scope: CommandScope, moduleName: string, comman
|
|
|
502
1591
|
endpoint: command.endpoint,
|
|
503
1592
|
requestType: command.requestType,
|
|
504
1593
|
pathFlags: command.pathParams.filter((item) => !getFixedValue(item)).map((item) => item.flag),
|
|
505
|
-
queryFlags: command
|
|
1594
|
+
queryFlags: visibleQueryParams(command).map((item) => item.flag),
|
|
506
1595
|
}
|
|
507
1596
|
}
|
|
508
1597
|
|
|
509
1598
|
export async function executeCLI(scope: CommandScope, argv: string[], env?: RuntimeEnv, fetchImpl: typeof fetch = fetch): Promise<any> {
|
|
510
1599
|
const parsed = parseCLIArgs(argv)
|
|
511
|
-
|
|
512
|
-
if (hasFlag(parsed.flags, 'help') || parsed.commandTokens.length === 1) {
|
|
513
|
-
return { kind: 'help', text: renderUploadHelp() }
|
|
514
|
-
}
|
|
515
|
-
const sourcePath = parsed.commandTokens[1]
|
|
516
|
-
if (!sourcePath) {
|
|
517
|
-
throw new CLIError('UPLOAD_SOURCE_REQUIRED', 'upload requires a local file path', 2)
|
|
518
|
-
}
|
|
519
|
-
const runtimeEnv = env ?? loadRuntimeEnv(scope)
|
|
520
|
-
const data = await uploadLocalFile(
|
|
521
|
-
runtimeEnv,
|
|
522
|
-
sourcePath,
|
|
523
|
-
{
|
|
524
|
-
filename: flagValue(parsed.flags, 'filename'),
|
|
525
|
-
global: hasFlag(parsed.flags, 'global'),
|
|
526
|
-
},
|
|
527
|
-
fetchImpl,
|
|
528
|
-
)
|
|
529
|
-
return {
|
|
530
|
-
kind: 'result',
|
|
531
|
-
commandPath: ['upload', sourcePath],
|
|
532
|
-
status: 200,
|
|
533
|
-
data,
|
|
534
|
-
}
|
|
535
|
-
}
|
|
1600
|
+
const envInput = env ?? process.env
|
|
536
1601
|
if (parsed.commandTokens.length === 0) {
|
|
537
1602
|
return { kind: 'help', text: renderRootHelp(scope) }
|
|
538
1603
|
}
|
|
@@ -542,24 +1607,38 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
542
1607
|
}
|
|
543
1608
|
const singleTokenCommand = !commandName ? findCommand(scope, moduleName) : null
|
|
544
1609
|
if (!commandName && !singleTokenCommand) {
|
|
545
|
-
return { kind: 'help', text: renderModuleHelp(scope, moduleName) }
|
|
1610
|
+
return { kind: 'help', text: renderModuleHelp(scope, moduleName, envInput) }
|
|
546
1611
|
}
|
|
547
1612
|
if (hasFlag(parsed.flags, 'help')) {
|
|
548
|
-
return { kind: 'help', text: renderCommandHelp(scope, moduleName, commandName) }
|
|
1613
|
+
return { kind: 'help', text: renderCommandHelp(scope, moduleName, commandName, envInput) }
|
|
549
1614
|
}
|
|
550
1615
|
|
|
551
|
-
const runtimeEnv = env ?? loadRuntimeEnv(scope)
|
|
552
|
-
|
|
553
1616
|
const command = findCommand(scope, moduleName, commandName)
|
|
554
1617
|
if (!command) {
|
|
555
1618
|
throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName}${commandName ? ` ${commandName}` : ''}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName ?? ''))
|
|
556
1619
|
}
|
|
1620
|
+
validateKnownFlags(command, parsed.flags, envInput)
|
|
1621
|
+
|
|
1622
|
+
const runtimeEnv = env ?? loadRuntimeEnv(scope)
|
|
557
1623
|
|
|
558
1624
|
const endpoint = resolveEndpoint(command, parsed.flags)
|
|
559
1625
|
const scalarQuery = buildScalarQuery(command, parsed.flags)
|
|
560
1626
|
const fileQuery = loadQueryFromFlags(parsed.flags)
|
|
561
|
-
|
|
562
|
-
|
|
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
|
+
}
|
|
563
1642
|
let validatedBody = body
|
|
564
1643
|
|
|
565
1644
|
if (!command.requestType && body !== undefined) {
|
|
@@ -568,31 +1647,77 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
568
1647
|
|
|
569
1648
|
if (command.requestType) {
|
|
570
1649
|
if (body === undefined) {
|
|
571
|
-
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)
|
|
1651
|
+
}
|
|
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)
|
|
572
1680
|
}
|
|
573
|
-
validateActionSpecificRawBody(scope, command, body)
|
|
1681
|
+
validateActionSpecificRawBody(scope, command, body, runtimeEnv)
|
|
574
1682
|
const schema = getBodySchema(command.requestType)
|
|
575
1683
|
if (schema) {
|
|
576
1684
|
const parsedBody = schema.safeParse(body)
|
|
577
1685
|
if (!parsedBody.success) {
|
|
1686
|
+
const message = parsedBody.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ')
|
|
1687
|
+
if (isAssignUsersCommand(scope, command)) {
|
|
1688
|
+
const hasUserScopeIDIssue = parsedBody.error.issues.some((issue) => issue.path.join('.') === 'options.user_scope.0.id' || issue.path.join('.').startsWith('options.user_scope.'))
|
|
1689
|
+
throw new CLIError(
|
|
1690
|
+
'BODY_VALIDATION_FAILED',
|
|
1691
|
+
hasUserScopeIDIssue ? 'access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID' : message,
|
|
1692
|
+
2,
|
|
1693
|
+
buildAssignUsersValidationDetails(
|
|
1694
|
+
scope,
|
|
1695
|
+
command,
|
|
1696
|
+
command.requestType,
|
|
1697
|
+
hasUserScopeIDIssue ? 'body.options.user_scope' : 'body',
|
|
1698
|
+
hasUserScopeIDIssue ? 'access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID' : message,
|
|
1699
|
+
runtimeEnv,
|
|
1700
|
+
)
|
|
1701
|
+
)
|
|
1702
|
+
}
|
|
578
1703
|
throw new CLIError(
|
|
579
1704
|
'BODY_VALIDATION_FAILED',
|
|
580
|
-
|
|
1705
|
+
message,
|
|
581
1706
|
2,
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
1707
|
+
buildBodyValidationDetails(
|
|
1708
|
+
scope,
|
|
1709
|
+
command,
|
|
1710
|
+
command.requestType,
|
|
1711
|
+
parsedBody.error.issues.map((issue) => ({
|
|
586
1712
|
path: issue.path.length > 0 ? `body.${issue.path.join('.')}` : 'body',
|
|
587
1713
|
message: issue.message,
|
|
588
1714
|
})),
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
}
|
|
1715
|
+
runtimeEnv,
|
|
1716
|
+
)
|
|
592
1717
|
)
|
|
593
1718
|
}
|
|
594
1719
|
validatedBody = parsedBody.data
|
|
595
|
-
validateActionSpecificBody(scope, command, parsed.flags, validatedBody)
|
|
1720
|
+
validateActionSpecificBody(scope, command, parsed.flags, validatedBody, runtimeEnv)
|
|
596
1721
|
} else {
|
|
597
1722
|
const unsupportedReason = getUnsupportedBodySchemaReason(command.requestType)
|
|
598
1723
|
if (unsupportedReason) {
|
|
@@ -600,25 +1725,30 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
600
1725
|
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
601
1726
|
requestType: command.requestType,
|
|
602
1727
|
reason: unsupportedReason,
|
|
603
|
-
tsHints: getTypeHints(command.requestType),
|
|
604
|
-
docHints: getDocHints(command.requestType),
|
|
1728
|
+
tsHints: getTypeHints(command.requestType, runtimeEnv),
|
|
1729
|
+
docHints: getDocHints(command.requestType, runtimeEnv),
|
|
605
1730
|
})
|
|
606
1731
|
}
|
|
607
1732
|
}
|
|
608
1733
|
}
|
|
609
1734
|
|
|
1735
|
+
assertNoUserPolicyIDInput(command, runtimeEnv, query, validatedBody)
|
|
1736
|
+
|
|
610
1737
|
if (scope === 'user' && (command.functionName === 'insertEntity' || command.functionName === 'updateEntityRow')) {
|
|
611
1738
|
await validateFileFieldPayloads(scope, command, runtimeEnv, parsed.flags, validatedBody, fetchImpl)
|
|
612
1739
|
}
|
|
1740
|
+
const policyInjected = await injectResolvedPolicyID(command, runtimeEnv, parsed.flags, query, validatedBody, fetchImpl)
|
|
1741
|
+
query = policyInjected.query
|
|
1742
|
+
validatedBody = policyInjected.body
|
|
613
1743
|
|
|
614
|
-
const response = await
|
|
1744
|
+
const response = await fetchUpstream(buildURL(runtimeEnv.ARCUBASE_BASE_URL, endpoint, Object.keys(query).length > 0 ? query : undefined), {
|
|
615
1745
|
method: command.method,
|
|
616
1746
|
headers: {
|
|
617
1747
|
...buildRequestHeaders(runtimeEnv),
|
|
618
1748
|
'Content-Type': 'application/json',
|
|
619
1749
|
},
|
|
620
1750
|
body: validatedBody === undefined || command.method === 'GET' ? undefined : JSON.stringify(validatedBody),
|
|
621
|
-
})
|
|
1751
|
+
}, endpoint, command.method, fetchImpl)
|
|
622
1752
|
|
|
623
1753
|
const raw = await response.text()
|
|
624
1754
|
let parsedResponse: any = raw
|
|
@@ -628,22 +1758,12 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
628
1758
|
// keep raw text
|
|
629
1759
|
}
|
|
630
1760
|
if (!response.ok) {
|
|
631
|
-
|
|
1761
|
+
throwUpstreamHTTPError(endpoint, command.method, response, parsedResponse)
|
|
632
1762
|
}
|
|
633
1763
|
if (parsedResponse && typeof parsedResponse === 'object' && 'error' in parsedResponse && parsedResponse.error) {
|
|
634
1764
|
const upstreamError = parsedResponse.error
|
|
635
|
-
const upstreamMessage =
|
|
636
|
-
|
|
637
|
-
? upstreamError.message
|
|
638
|
-
: typeof upstreamError === 'string'
|
|
639
|
-
? upstreamError
|
|
640
|
-
: JSON.stringify(upstreamError)
|
|
641
|
-
const traceId =
|
|
642
|
-
typeof parsedResponse.trace_id === 'string'
|
|
643
|
-
? parsedResponse.trace_id
|
|
644
|
-
: typeof parsedResponse.request_id === 'string'
|
|
645
|
-
? parsedResponse.request_id
|
|
646
|
-
: undefined
|
|
1765
|
+
const upstreamMessage = upstreamErrorMessage(upstreamError)
|
|
1766
|
+
const traceId = extractTraceID(parsedResponse)
|
|
647
1767
|
const uploadBindFailure = upstreamMessage.includes('failed to bind upload to assets')
|
|
648
1768
|
throw new CLIError('UPSTREAM_BODY_ERROR', upstreamMessage, 1, {
|
|
649
1769
|
endpoint,
|
|
@@ -653,10 +1773,10 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
653
1773
|
...(uploadBindFailure
|
|
654
1774
|
? {
|
|
655
1775
|
reason: 'the row payload used a file/image upload entry, but Arcubase could not bind that upload into an asset record',
|
|
656
|
-
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',
|
|
657
1777
|
docHints: [
|
|
658
|
-
{ title: 'Uploads', file: '
|
|
659
|
-
{ title: 'Row CRUD', file: '
|
|
1778
|
+
{ title: 'Uploads', file: 'docs/runtime-reference/uploads.md' },
|
|
1779
|
+
{ title: 'Row CRUD', file: 'docs/runtime-reference/row-crud.md' },
|
|
660
1780
|
],
|
|
661
1781
|
}
|
|
662
1782
|
: {}),
|