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