@carthooks/arcubase-cli 0.1.19 → 0.1.21
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 +727 -45
- package/bundle/arcubase.mjs +727 -45
- package/dist/generated/command_registry.generated.d.ts +15 -0
- package/dist/generated/command_registry.generated.d.ts.map +1 -1
- package/dist/generated/command_registry.generated.js +21 -0
- package/dist/generated/help_examples.generated.d.ts +319 -0
- package/dist/generated/help_examples.generated.d.ts.map +1 -0
- package/dist/generated/help_examples.generated.js +437 -0
- package/dist/generated/type_index.generated.d.ts +4 -0
- package/dist/generated/type_index.generated.d.ts.map +1 -1
- package/dist/generated/type_index.generated.js +4 -0
- package/dist/generated/zod_registry.generated.d.ts +4 -1
- package/dist/generated/zod_registry.generated.d.ts.map +1 -1
- package/dist/generated/zod_registry.generated.js +4 -0
- package/dist/runtime/execute.d.ts.map +1 -1
- package/dist/runtime/execute.js +155 -17
- package/dist/runtime/help_examples.d.ts +10 -0
- package/dist/runtime/help_examples.d.ts.map +1 -0
- package/dist/runtime/help_examples.js +48 -0
- package/dist/runtime/zod_registry.d.ts.map +1 -1
- package/dist/runtime/zod_registry.js +8 -0
- package/package.json +4 -3
- package/sdk-dist/api/admin/ingress.ts +11 -0
- package/sdk-dist/docs/runtime-reference/access-rule.md +19 -0
- package/sdk-dist/docs/runtime-reference/help-examples/admin/access-rule/assign-users.json +23 -0
- package/sdk-dist/docs/runtime-reference/help-examples/admin/access-rule/bulk-apply.json +105 -0
- package/sdk-dist/docs/runtime-reference/help-examples/admin/access-rule/create.json +95 -0
- package/sdk-dist/docs/runtime-reference/help-examples/user/row/bulk-update.json +23 -0
- package/sdk-dist/docs/runtime-reference/help-examples/user/row/selection-action.json +25 -0
- package/sdk-dist/generated/command_registry.generated.ts +21 -0
- package/sdk-dist/generated/help_examples.generated.ts +444 -0
- package/sdk-dist/generated/type_index.generated.ts +4 -0
- package/sdk-dist/generated/zod_registry.generated.ts +6 -0
- package/sdk-dist/types/ingress.ts +31 -0
- package/src/generated/command_registry.generated.ts +21 -0
- package/src/generated/help_examples.generated.ts +444 -0
- package/src/generated/type_index.generated.ts +4 -0
- package/src/generated/zod_registry.generated.ts +6 -0
- package/src/runtime/execute.ts +223 -37
- package/src/runtime/help_examples.ts +61 -0
- package/src/runtime/zod_registry.ts +8 -0
- package/src/tests/command_registry.test.ts +1 -0
- package/src/tests/execute_validation.test.ts +113 -0
- package/src/tests/help.test.ts +45 -0
package/src/runtime/execute.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { getBodySchema, getCommandDocHints, getDocHints, getTypeHints, getUnsupp
|
|
|
10
10
|
import { resolveArcubaseSDKPath } from './paths.js'
|
|
11
11
|
import { compileCSVImportMapping, compileExcelImportMapping, type CompiledEntityImportConfig } from './entity_import_mapping.js'
|
|
12
12
|
import { uploadLocalFileWithToken } from './local_upload.js'
|
|
13
|
+
import { renderCommandHelpExamples } from './help_examples.js'
|
|
13
14
|
|
|
14
15
|
export type CommandExecutionSummary = {
|
|
15
16
|
scope: CommandScope
|
|
@@ -64,12 +65,39 @@ function buildUnknownCommandDetails(scope: CommandScope, moduleName: string, com
|
|
|
64
65
|
return suggestions.length > 0 ? { suggestions } : undefined
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
function throwHelpExamplesRequiresCommand(scope: CommandScope, moduleName?: string): never {
|
|
69
|
+
const binary = binaryForScope(scope)
|
|
70
|
+
const suggestions = scope === 'admin'
|
|
71
|
+
? [
|
|
72
|
+
`${binary} access-rule create --help-examples`,
|
|
73
|
+
`${binary} access-rule assign-users --help-examples`,
|
|
74
|
+
]
|
|
75
|
+
: [
|
|
76
|
+
`${binary} row bulk-update --help-examples`,
|
|
77
|
+
`${binary} row selection-action --help-examples`,
|
|
78
|
+
]
|
|
79
|
+
throw new CLIError(
|
|
80
|
+
'HELP_EXAMPLES_REQUIRES_COMMAND',
|
|
81
|
+
'--help-examples must follow a final command',
|
|
82
|
+
2,
|
|
83
|
+
{
|
|
84
|
+
operation: moduleName ? `${binary} ${moduleName} --help-examples` : `${binary} --help-examples`,
|
|
85
|
+
hint: `retry with a final command, for example: ${suggestions[0]}`,
|
|
86
|
+
suggestions,
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
67
91
|
type EnvInput = Record<string, string | undefined>
|
|
68
92
|
|
|
69
93
|
function sdkPath(file: string, env: EnvInput = process.env): string {
|
|
70
94
|
return resolveArcubaseSDKPath(file, env)
|
|
71
95
|
}
|
|
72
96
|
|
|
97
|
+
function binaryForScope(scope: CommandScope): string {
|
|
98
|
+
return scope === 'admin' ? 'arcubase-admin' : 'arcubase'
|
|
99
|
+
}
|
|
100
|
+
|
|
73
101
|
export function renderCommandHelp(scope: CommandScope, moduleName: string, commandName?: string, env: EnvInput = process.env): string {
|
|
74
102
|
const command = findCommand(scope, moduleName, commandName)
|
|
75
103
|
if (!command) {
|
|
@@ -92,6 +120,7 @@ export function renderCommandHelp(scope: CommandScope, moduleName: string, comma
|
|
|
92
120
|
command.requestType ? `body: ${bodyTypeText} via --body-json <json-string> | --body-file <file>.json` : 'body: none',
|
|
93
121
|
queryFlags.length ? `query flags: ${queryFlags.join(', ')}, --query-file` : 'query flags: --query-file',
|
|
94
122
|
pathFlags.length ? `path flags: ${pathFlags.join(', ')}` : 'path flags: none',
|
|
123
|
+
`copyable examples: ${scope === 'admin' ? 'arcubase-admin' : 'arcubase'} ${command.commandPath.join(' ')} --help-examples`,
|
|
95
124
|
...(isTableCreate
|
|
96
125
|
? [
|
|
97
126
|
'body-json example:',
|
|
@@ -248,7 +277,7 @@ function buildScalarQuery(command: ReturnType<typeof findCommand>, flags: Record
|
|
|
248
277
|
}
|
|
249
278
|
|
|
250
279
|
function validateKnownFlags(command: NonNullable<ReturnType<typeof findCommand>>, flags: Record<string, string | boolean>, env: EnvInput = process.env) {
|
|
251
|
-
const allowed = new Set<string>(['help', 'body-file', 'body-json', 'query-file'])
|
|
280
|
+
const allowed = new Set<string>(['help', 'help-examples', 'body-file', 'body-json', 'query-file'])
|
|
252
281
|
for (const mapping of command.pathParams) {
|
|
253
282
|
if (!getFixedValue(mapping)) {
|
|
254
283
|
allowed.add(mapping.flag)
|
|
@@ -608,6 +637,10 @@ function isAssignUsersCommand(scope: CommandScope, command: NonNullable<ReturnTy
|
|
|
608
637
|
return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users'
|
|
609
638
|
}
|
|
610
639
|
|
|
640
|
+
function isAccessRuleBulkApplyCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
|
|
641
|
+
return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'bulk-apply'
|
|
642
|
+
}
|
|
643
|
+
|
|
611
644
|
function isTableCreateCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
|
|
612
645
|
return scope === 'admin' && command.commandPath[0] === 'table' && command.commandPath[1] === 'create'
|
|
613
646
|
}
|
|
@@ -1051,6 +1084,129 @@ function validateAccessRuleUserScope(
|
|
|
1051
1084
|
}
|
|
1052
1085
|
}
|
|
1053
1086
|
|
|
1087
|
+
function normalizeNumericAccessRuleUserScopeIDs(
|
|
1088
|
+
scope: CommandScope,
|
|
1089
|
+
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
1090
|
+
body: unknown,
|
|
1091
|
+
): unknown {
|
|
1092
|
+
if (!isAccessRuleCommand(scope, command) || !isRecord(body)) {
|
|
1093
|
+
return body
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const normalizeUserScope = (userScope: unknown): { value: unknown; changed: boolean } => {
|
|
1097
|
+
if (!Array.isArray(userScope)) return { value: userScope, changed: false }
|
|
1098
|
+
let changed = false
|
|
1099
|
+
const value = userScope.map((item) => {
|
|
1100
|
+
if (!isRecord(item) || typeof item.id !== 'string' || !/^\d+$/.test(item.id)) {
|
|
1101
|
+
return item
|
|
1102
|
+
}
|
|
1103
|
+
const id = Number(item.id)
|
|
1104
|
+
if (!Number.isFinite(id) || !Number.isInteger(id)) {
|
|
1105
|
+
return item
|
|
1106
|
+
}
|
|
1107
|
+
changed = true
|
|
1108
|
+
return { ...item, id }
|
|
1109
|
+
})
|
|
1110
|
+
return { value, changed }
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const normalizeNumericID = (value: unknown): { value: unknown; changed: boolean } => {
|
|
1114
|
+
if (typeof value !== 'string' || !/^\d+$/.test(value)) return { value, changed: false }
|
|
1115
|
+
const id = Number(value)
|
|
1116
|
+
if (!Number.isFinite(id) || !Number.isInteger(id)) return { value, changed: false }
|
|
1117
|
+
return { value: id, changed: true }
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (isAccessRuleBulkApplyCommand(scope, command) && Array.isArray(body.items)) {
|
|
1121
|
+
let changed = false
|
|
1122
|
+
const items = body.items.map((item) => {
|
|
1123
|
+
if (!isRecord(item)) return item
|
|
1124
|
+
let nextItem: Record<string, unknown> = item
|
|
1125
|
+
for (const key of ['table_id', 'rule_id']) {
|
|
1126
|
+
const normalized = normalizeNumericID(nextItem[key])
|
|
1127
|
+
if (normalized.changed) {
|
|
1128
|
+
nextItem = { ...nextItem, [key]: normalized.value }
|
|
1129
|
+
changed = true
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
if (isRecord(nextItem.options)) {
|
|
1133
|
+
const normalizedUserScope = normalizeUserScope(nextItem.options.user_scope)
|
|
1134
|
+
if (normalizedUserScope.changed) {
|
|
1135
|
+
nextItem = {
|
|
1136
|
+
...nextItem,
|
|
1137
|
+
options: {
|
|
1138
|
+
...nextItem.options,
|
|
1139
|
+
user_scope: normalizedUserScope.value,
|
|
1140
|
+
},
|
|
1141
|
+
}
|
|
1142
|
+
changed = true
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return nextItem
|
|
1146
|
+
})
|
|
1147
|
+
return changed ? { ...body, items } : body
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (!isRecord(body.options)) {
|
|
1151
|
+
return body
|
|
1152
|
+
}
|
|
1153
|
+
const normalizedUserScope = normalizeUserScope(body.options.user_scope)
|
|
1154
|
+
if (!normalizedUserScope.changed) return body
|
|
1155
|
+
return {
|
|
1156
|
+
...body,
|
|
1157
|
+
options: {
|
|
1158
|
+
...body.options,
|
|
1159
|
+
user_scope: normalizedUserScope.value,
|
|
1160
|
+
},
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function validateAccessRulePolicyShape(
|
|
1165
|
+
scope: CommandScope,
|
|
1166
|
+
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
1167
|
+
requestType: string,
|
|
1168
|
+
policy: Record<string, unknown> | undefined,
|
|
1169
|
+
env: EnvInput,
|
|
1170
|
+
pathPrefix: string,
|
|
1171
|
+
) {
|
|
1172
|
+
const actions = policy && Array.isArray(policy.actions) ? policy.actions : []
|
|
1173
|
+
if (actions.includes('read') || actions.includes('write')) {
|
|
1174
|
+
throwBodyValidationFailure(
|
|
1175
|
+
'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"',
|
|
1176
|
+
requestType,
|
|
1177
|
+
`${pathPrefix}.actions`,
|
|
1178
|
+
scope,
|
|
1179
|
+
command,
|
|
1180
|
+
env,
|
|
1181
|
+
)
|
|
1182
|
+
}
|
|
1183
|
+
const fields = policy && Array.isArray(policy.fields) ? policy.fields : []
|
|
1184
|
+
const allowedSystemFieldKeys = new Set(['created', 'updated', 'creator'])
|
|
1185
|
+
for (const [index, field] of fields.entries()) {
|
|
1186
|
+
if (!isRecord(field)) continue
|
|
1187
|
+
if (typeof field.key === 'number' || (typeof field.key === 'string' && /^\d+$/.test(field.key))) {
|
|
1188
|
+
throwBodyValidationFailure(
|
|
1189
|
+
'access-rule policy.fields[].key must be a string prefixed with colon, for example ":1002"; do not use raw numbers',
|
|
1190
|
+
requestType,
|
|
1191
|
+
`${pathPrefix}.fields.${index}.key`,
|
|
1192
|
+
scope,
|
|
1193
|
+
command,
|
|
1194
|
+
env,
|
|
1195
|
+
)
|
|
1196
|
+
}
|
|
1197
|
+
if (typeof field.key === 'string' && !field.key.startsWith(':') && !allowedSystemFieldKeys.has(field.key)) {
|
|
1198
|
+
throwBodyValidationFailure(
|
|
1199
|
+
'access-rule policy.fields[].key must use Arcubase permission keys such as ":1002"; do not use FieldVO.key values such as "vote_name"',
|
|
1200
|
+
requestType,
|
|
1201
|
+
`${pathPrefix}.fields.${index}.key`,
|
|
1202
|
+
scope,
|
|
1203
|
+
command,
|
|
1204
|
+
env,
|
|
1205
|
+
)
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1054
1210
|
function requireUpdateContains(
|
|
1055
1211
|
scope: CommandScope,
|
|
1056
1212
|
command: NonNullable<ReturnType<typeof findCommand>>,
|
|
@@ -1111,6 +1267,54 @@ function validateActionSpecificRawBody(
|
|
|
1111
1267
|
}
|
|
1112
1268
|
|
|
1113
1269
|
if (isAccessRuleCommand(scope, command)) {
|
|
1270
|
+
if (isAccessRuleBulkApplyCommand(scope, command)) {
|
|
1271
|
+
if (!Array.isArray(body.items) || body.items.length === 0) {
|
|
1272
|
+
throwBodyValidationFailure(
|
|
1273
|
+
'access-rule bulk-apply requires non-empty body.items; put every table/rule assignment in body.items',
|
|
1274
|
+
command.requestType,
|
|
1275
|
+
'body.items',
|
|
1276
|
+
scope,
|
|
1277
|
+
command,
|
|
1278
|
+
env,
|
|
1279
|
+
)
|
|
1280
|
+
}
|
|
1281
|
+
if (body.mode !== undefined && body.mode !== 'update_existing' && body.mode !== 'upsert_by_name') {
|
|
1282
|
+
throwBodyValidationFailure(
|
|
1283
|
+
'access-rule bulk-apply body.mode must be "update_existing" or "upsert_by_name"',
|
|
1284
|
+
command.requestType,
|
|
1285
|
+
'body.mode',
|
|
1286
|
+
scope,
|
|
1287
|
+
command,
|
|
1288
|
+
env,
|
|
1289
|
+
)
|
|
1290
|
+
}
|
|
1291
|
+
for (const [index, item] of body.items.entries()) {
|
|
1292
|
+
if (!isRecord(item)) {
|
|
1293
|
+
throwBodyValidationFailure(
|
|
1294
|
+
'access-rule bulk-apply body.items[] must be an object',
|
|
1295
|
+
command.requestType,
|
|
1296
|
+
`body.items.${index}`,
|
|
1297
|
+
scope,
|
|
1298
|
+
command,
|
|
1299
|
+
env,
|
|
1300
|
+
)
|
|
1301
|
+
}
|
|
1302
|
+
const options = isRecord(item.options) ? item.options : undefined
|
|
1303
|
+
if (!options) {
|
|
1304
|
+
throwBodyValidationFailure(
|
|
1305
|
+
'access-rule bulk-apply body.items[].options is required and must include the full canonical options object',
|
|
1306
|
+
command.requestType,
|
|
1307
|
+
`body.items.${index}.options`,
|
|
1308
|
+
scope,
|
|
1309
|
+
command,
|
|
1310
|
+
env,
|
|
1311
|
+
)
|
|
1312
|
+
}
|
|
1313
|
+
validateAccessRuleUserScope(scope, command, command.requestType, options.user_scope, env, `body.items.${index}.options.user_scope`)
|
|
1314
|
+
validateAccessRulePolicyShape(scope, command, command.requestType, isRecord(options.policy) ? options.policy : undefined, env, `body.items.${index}.options.policy`)
|
|
1315
|
+
}
|
|
1316
|
+
return
|
|
1317
|
+
}
|
|
1114
1318
|
const update = stringArray(body.update)
|
|
1115
1319
|
if (isAccessRuleUpdateCommand(scope, command) && update.some((item) => item.startsWith('options.'))) {
|
|
1116
1320
|
throwBodyValidationFailure(
|
|
@@ -1155,42 +1359,7 @@ function validateActionSpecificRawBody(
|
|
|
1155
1359
|
)
|
|
1156
1360
|
}
|
|
1157
1361
|
validateAccessRuleUserScope(scope, command, command.requestType, options?.user_scope, env)
|
|
1158
|
-
|
|
1159
|
-
if (actions.includes('read') || actions.includes('write')) {
|
|
1160
|
-
throwBodyValidationFailure(
|
|
1161
|
-
'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"',
|
|
1162
|
-
command.requestType,
|
|
1163
|
-
'body.options.policy.actions',
|
|
1164
|
-
scope,
|
|
1165
|
-
command,
|
|
1166
|
-
env,
|
|
1167
|
-
)
|
|
1168
|
-
}
|
|
1169
|
-
const fields = policy && Array.isArray(policy.fields) ? policy.fields : []
|
|
1170
|
-
const allowedSystemFieldKeys = new Set(['created', 'updated', 'creator'])
|
|
1171
|
-
for (const [index, field] of fields.entries()) {
|
|
1172
|
-
if (!isRecord(field)) continue
|
|
1173
|
-
if (typeof field.key === 'number' || (typeof field.key === 'string' && /^\d+$/.test(field.key))) {
|
|
1174
|
-
throwBodyValidationFailure(
|
|
1175
|
-
'access-rule policy.fields[].key must be a string prefixed with colon, for example ":1002"; do not use raw numbers',
|
|
1176
|
-
command.requestType,
|
|
1177
|
-
`body.options.policy.fields.${index}.key`,
|
|
1178
|
-
scope,
|
|
1179
|
-
command,
|
|
1180
|
-
env,
|
|
1181
|
-
)
|
|
1182
|
-
}
|
|
1183
|
-
if (typeof field.key === 'string' && !field.key.startsWith(':') && !allowedSystemFieldKeys.has(field.key)) {
|
|
1184
|
-
throwBodyValidationFailure(
|
|
1185
|
-
'access-rule policy.fields[].key must use Arcubase permission keys such as ":1002"; do not use FieldVO.key values such as "vote_name"',
|
|
1186
|
-
command.requestType,
|
|
1187
|
-
`body.options.policy.fields.${index}.key`,
|
|
1188
|
-
scope,
|
|
1189
|
-
command,
|
|
1190
|
-
env,
|
|
1191
|
-
)
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1362
|
+
validateAccessRulePolicyShape(scope, command, command.requestType, policy, env, 'body.options.policy')
|
|
1194
1363
|
}
|
|
1195
1364
|
}
|
|
1196
1365
|
|
|
@@ -2005,16 +2174,32 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
2005
2174
|
const parsed = parseCLIArgs(argv)
|
|
2006
2175
|
const envInput = env ?? process.env
|
|
2007
2176
|
if (parsed.commandTokens.length === 0) {
|
|
2177
|
+
if (hasFlag(parsed.flags, 'help-examples')) {
|
|
2178
|
+
throwHelpExamplesRequiresCommand(scope)
|
|
2179
|
+
}
|
|
2008
2180
|
return { kind: 'help', text: renderRootHelp(scope) }
|
|
2009
2181
|
}
|
|
2010
2182
|
const [moduleName, commandName] = parsed.commandTokens
|
|
2011
2183
|
if (!moduleName) {
|
|
2184
|
+
if (hasFlag(parsed.flags, 'help-examples')) {
|
|
2185
|
+
throwHelpExamplesRequiresCommand(scope)
|
|
2186
|
+
}
|
|
2012
2187
|
return { kind: 'help', text: renderRootHelp(scope) }
|
|
2013
2188
|
}
|
|
2014
2189
|
const singleTokenCommand = !commandName ? findCommand(scope, moduleName) : null
|
|
2015
2190
|
if (!commandName && !singleTokenCommand) {
|
|
2191
|
+
if (hasFlag(parsed.flags, 'help-examples')) {
|
|
2192
|
+
throwHelpExamplesRequiresCommand(scope, moduleName)
|
|
2193
|
+
}
|
|
2016
2194
|
return { kind: 'help', text: renderModuleHelp(scope, moduleName, envInput) }
|
|
2017
2195
|
}
|
|
2196
|
+
if (hasFlag(parsed.flags, 'help-examples')) {
|
|
2197
|
+
const command = commandName ? findCommand(scope, moduleName, commandName) : singleTokenCommand
|
|
2198
|
+
if (!command) {
|
|
2199
|
+
throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName}${commandName ? ` ${commandName}` : ''}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName ?? ''))
|
|
2200
|
+
}
|
|
2201
|
+
return { kind: 'help', text: renderCommandHelpExamples(scope, command) }
|
|
2202
|
+
}
|
|
2018
2203
|
if (hasFlag(parsed.flags, 'help')) {
|
|
2019
2204
|
return { kind: 'help', text: renderCommandHelp(scope, moduleName, commandName, envInput) }
|
|
2020
2205
|
}
|
|
@@ -2065,6 +2250,7 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
2065
2250
|
if (body === undefined) {
|
|
2066
2251
|
throw new CLIError('MISSING_BODY_FILE', `command requires --body-json or --body-file for ${command.requestType}; prefer --body-json to avoid file creation`, 2)
|
|
2067
2252
|
}
|
|
2253
|
+
body = normalizeNumericAccessRuleUserScopeIDs(scope, command, body)
|
|
2068
2254
|
if (isTableCreateCommand(scope, command)) {
|
|
2069
2255
|
requireTableCreateSchemaBody(scope, command, body, runtimeEnv)
|
|
2070
2256
|
validateActionSpecificRawBody(scope, command, body, runtimeEnv)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { helpExamplesIndex } from '../generated/help_examples.generated.js'
|
|
2
|
+
import type { CommandDef, CommandScope } from './command_registry.js'
|
|
3
|
+
|
|
4
|
+
type HelpExample = {
|
|
5
|
+
title: string
|
|
6
|
+
body?: unknown
|
|
7
|
+
notes?: readonly string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function binaryForScope(scope: CommandScope): string {
|
|
11
|
+
return scope === 'admin' ? 'arcubase-admin' : 'arcubase'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getFixedValue(value: object): string | undefined {
|
|
15
|
+
if (!('fixedValue' in value)) {
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
const fixedValue = (value as { fixedValue?: unknown }).fixedValue
|
|
19
|
+
return typeof fixedValue === 'string' ? fixedValue : undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function pathFlagPlaceholders(command: CommandDef): string {
|
|
23
|
+
const parts = command.pathParams
|
|
24
|
+
.filter((item) => !getFixedValue(item))
|
|
25
|
+
.map((item) => `--${item.flag} <${item.flag.replace(/-/g, '_')}>`)
|
|
26
|
+
return parts.length > 0 ? `${parts.join(' ')} ` : ''
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function helpExampleKey(scope: CommandScope, command: CommandDef): string {
|
|
30
|
+
return `${scope}.${command.commandPath.join('.')}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getCommandHelpExamples(scope: CommandScope, command: CommandDef): readonly HelpExample[] {
|
|
34
|
+
return (helpExamplesIndex as Record<string, { examples?: readonly HelpExample[] } | undefined>)[helpExampleKey(scope, command)]?.examples ?? []
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function renderCommandHelpExamples(scope: CommandScope, command: CommandDef): string {
|
|
38
|
+
const binary = binaryForScope(scope)
|
|
39
|
+
const examples = getCommandHelpExamples(scope, command)
|
|
40
|
+
const header = `${binary} ${command.commandPath.join(' ')} --help-examples`
|
|
41
|
+
if (examples.length === 0) {
|
|
42
|
+
return [
|
|
43
|
+
header,
|
|
44
|
+
'',
|
|
45
|
+
'No dedicated examples for this final command yet.',
|
|
46
|
+
`Use ${binary} ${command.commandPath.join(' ')} --help for flags, docs, and type paths.`,
|
|
47
|
+
].join('\n')
|
|
48
|
+
}
|
|
49
|
+
return [
|
|
50
|
+
header,
|
|
51
|
+
'',
|
|
52
|
+
...examples.flatMap((example) => [
|
|
53
|
+
`${example.title}:`,
|
|
54
|
+
...(example.body === undefined
|
|
55
|
+
? []
|
|
56
|
+
: [` ${binary} ${command.commandPath.join(' ')} ${pathFlagPlaceholders(command)}--body-json '${JSON.stringify(example.body)}'`.replace(/\s+/g, ' ').trim()]),
|
|
57
|
+
...(example.notes ?? []).map((note) => ` - ${note}`),
|
|
58
|
+
'',
|
|
59
|
+
]),
|
|
60
|
+
].join('\n').trimEnd()
|
|
61
|
+
}
|
|
@@ -109,6 +109,10 @@ const docHintIndex: Record<string, RuntimeDocHint[]> = {
|
|
|
109
109
|
{ title: 'Access rule', file: 'docs/runtime-reference/access-rule.md' },
|
|
110
110
|
examplesIndexDocHint,
|
|
111
111
|
],
|
|
112
|
+
AppIngressBulkApplyReqVO: [
|
|
113
|
+
{ title: 'Access rule', file: 'docs/runtime-reference/access-rule.md' },
|
|
114
|
+
examplesIndexDocHint,
|
|
115
|
+
],
|
|
112
116
|
EntitySaveReqVO: [
|
|
113
117
|
{ title: 'Table schema cheatsheet', file: 'docs/runtime-reference/entity-schema.md' },
|
|
114
118
|
{ title: 'Field type index', file: 'docs/runtime-reference/entity-schema/README.md' },
|
|
@@ -225,6 +229,10 @@ const commandDocHintIndex: Record<string, RuntimeDocHint[]> = {
|
|
|
225
229
|
{ title: 'Access rule', file: 'docs/runtime-reference/access-rule.md' },
|
|
226
230
|
examplesIndexDocHint,
|
|
227
231
|
],
|
|
232
|
+
'admin.access-rule.bulkApplyEntityIngress': [
|
|
233
|
+
{ title: 'Access rule', file: 'docs/runtime-reference/access-rule.md' },
|
|
234
|
+
examplesIndexDocHint,
|
|
235
|
+
],
|
|
228
236
|
'user.row.insertEntity': [
|
|
229
237
|
{ title: 'Row CRUD', file: 'docs/runtime-reference/row-crud.md' },
|
|
230
238
|
{ title: 'Uploads', file: 'docs/runtime-reference/uploads.md' },
|
|
@@ -1344,6 +1344,33 @@ test('access-rule assign-users rejects non-numeric user scope id', async () => {
|
|
|
1344
1344
|
}, (error: unknown) => assertAssignUsersGuidance(error, /numeric arcubaseUserId, department ID, or actor ID/))
|
|
1345
1345
|
})
|
|
1346
1346
|
|
|
1347
|
+
test('access-rule create normalizes numeric string user_scope ids before validation and request', async () => {
|
|
1348
|
+
const out = await executeCLI(
|
|
1349
|
+
'admin',
|
|
1350
|
+
['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', '{"name":"Role read","enabled":true,"type":"form","options":{"user_scope":[{"type":"actor","id":"2188883301"}],"policy":{"key":"custom","actions":["view"],"all_fields_read":true,"all_fields_write":false},"list_options":{}}}'],
|
|
1351
|
+
env as any,
|
|
1352
|
+
async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
|
|
1353
|
+
)
|
|
1354
|
+
assert.equal(out.kind, 'result')
|
|
1355
|
+
assert.deepEqual(out.data.options.user_scope, [{ type: 'actor', id: 2188883301 }])
|
|
1356
|
+
})
|
|
1357
|
+
|
|
1358
|
+
test('access-rule assign-users normalizes numeric string user_scope ids before validation and request', async () => {
|
|
1359
|
+
const out = await executeCLI(
|
|
1360
|
+
'admin',
|
|
1361
|
+
['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":"2188889901"},{"type":"department","id":"2188881201"},{"type":"actor","id":"2188883301"}]}}'],
|
|
1362
|
+
env as any,
|
|
1363
|
+
async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
|
|
1364
|
+
)
|
|
1365
|
+
assert.equal(out.kind, 'result')
|
|
1366
|
+
assert.deepEqual(out.data.update, ['user_scope'])
|
|
1367
|
+
assert.deepEqual(out.data.options.user_scope, [
|
|
1368
|
+
{ type: 'user', id: 2188889901 },
|
|
1369
|
+
{ type: 'department', id: 2188881201 },
|
|
1370
|
+
{ type: 'actor', id: 2188883301 },
|
|
1371
|
+
])
|
|
1372
|
+
})
|
|
1373
|
+
|
|
1347
1374
|
test('access-rule assign-users accepts canonical mixed user_scope body', async () => {
|
|
1348
1375
|
const out = await executeCLI(
|
|
1349
1376
|
'admin',
|
|
@@ -1360,6 +1387,92 @@ test('access-rule assign-users accepts canonical mixed user_scope body', async (
|
|
|
1360
1387
|
])
|
|
1361
1388
|
})
|
|
1362
1389
|
|
|
1390
|
+
test('access-rule bulk-apply normalizes numeric string ids before validation and request', async () => {
|
|
1391
|
+
const body = JSON.stringify({
|
|
1392
|
+
mode: 'update_existing',
|
|
1393
|
+
items: [
|
|
1394
|
+
{
|
|
1395
|
+
table_id: '2188893443',
|
|
1396
|
+
rule_id: '2188893557',
|
|
1397
|
+
enabled: true,
|
|
1398
|
+
options: {
|
|
1399
|
+
user_scope: [{ type: 'department', id: '2188881201' }],
|
|
1400
|
+
policy: { key: 'custom', actions: ['view'], all_fields_read: true, all_fields_write: false },
|
|
1401
|
+
list_options: {},
|
|
1402
|
+
},
|
|
1403
|
+
},
|
|
1404
|
+
],
|
|
1405
|
+
})
|
|
1406
|
+
let requestedURL = ''
|
|
1407
|
+
const out = await executeCLI(
|
|
1408
|
+
'admin',
|
|
1409
|
+
['access-rule', 'bulk-apply', '--app-id', 'app_1', '--body-json', body],
|
|
1410
|
+
env as any,
|
|
1411
|
+
async (url, init) => {
|
|
1412
|
+
requestedURL = String(url)
|
|
1413
|
+
return new Response(String(init?.body ?? '{}'), { status: 200 })
|
|
1414
|
+
},
|
|
1415
|
+
)
|
|
1416
|
+
assert.equal(out.kind, 'result')
|
|
1417
|
+
assert.match(requestedURL, /\/api\/apps\/app_1\/access-rules\/bulk-apply$/)
|
|
1418
|
+
assert.equal(out.data.items[0].table_id, 2188893443)
|
|
1419
|
+
assert.equal(out.data.items[0].rule_id, 2188893557)
|
|
1420
|
+
assert.deepEqual(out.data.items[0].options.user_scope, [{ type: 'department', id: 2188881201 }])
|
|
1421
|
+
})
|
|
1422
|
+
|
|
1423
|
+
test('access-rule bulk-apply rejects empty items before request', async () => {
|
|
1424
|
+
await assert.rejects(async () => {
|
|
1425
|
+
await executeCLI(
|
|
1426
|
+
'admin',
|
|
1427
|
+
['access-rule', 'bulk-apply', '--app-id', 'app_1', '--body-json', '{"mode":"upsert_by_name","items":[]}'],
|
|
1428
|
+
env as any,
|
|
1429
|
+
async () => new Response('{}', { status: 200 }),
|
|
1430
|
+
)
|
|
1431
|
+
}, (error: unknown) => {
|
|
1432
|
+
assert.ok(error instanceof CLIError)
|
|
1433
|
+
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
1434
|
+
assert.equal(error.details?.requestType, 'AppIngressBulkApplyReqVO')
|
|
1435
|
+
assert.match(error.message, /non-empty body.items/)
|
|
1436
|
+
return true
|
|
1437
|
+
})
|
|
1438
|
+
})
|
|
1439
|
+
|
|
1440
|
+
test('access-rule bulk-apply rejects non-canonical policy actions before request', async () => {
|
|
1441
|
+
const body = JSON.stringify({
|
|
1442
|
+
mode: 'upsert_by_name',
|
|
1443
|
+
items: [
|
|
1444
|
+
{
|
|
1445
|
+
table_id: 2188893443,
|
|
1446
|
+
name: 'Bad read alias',
|
|
1447
|
+
enabled: true,
|
|
1448
|
+
options: {
|
|
1449
|
+
user_scope: [{ type: 'user', id: 2188889901 }],
|
|
1450
|
+
policy: { key: 'custom', actions: ['read'], all_fields_read: true, all_fields_write: false },
|
|
1451
|
+
list_options: {},
|
|
1452
|
+
},
|
|
1453
|
+
},
|
|
1454
|
+
],
|
|
1455
|
+
})
|
|
1456
|
+
await assert.rejects(async () => {
|
|
1457
|
+
await executeCLI(
|
|
1458
|
+
'admin',
|
|
1459
|
+
['access-rule', 'bulk-apply', '--app-id', 'app_1', '--body-json', body],
|
|
1460
|
+
env as any,
|
|
1461
|
+
async () => new Response('{}', { status: 200 }),
|
|
1462
|
+
)
|
|
1463
|
+
}, (error: unknown) => {
|
|
1464
|
+
assert.ok(error instanceof CLIError)
|
|
1465
|
+
assert.equal(error.code, 'BODY_VALIDATION_FAILED')
|
|
1466
|
+
assert.equal(error.details?.requestType, 'AppIngressBulkApplyReqVO')
|
|
1467
|
+
assert.match(error.message, /actions must use Arcubase action codes/)
|
|
1468
|
+
assert.deepEqual(error.details?.issues?.[0], {
|
|
1469
|
+
path: 'body.items.0.options.policy.actions',
|
|
1470
|
+
message: '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"',
|
|
1471
|
+
})
|
|
1472
|
+
return true
|
|
1473
|
+
})
|
|
1474
|
+
})
|
|
1475
|
+
|
|
1363
1476
|
test('access-rule create accepts nested custom policy condition with field columns', async () => {
|
|
1364
1477
|
const body = JSON.stringify({
|
|
1365
1478
|
name: 'Sales own or west region',
|
package/src/tests/help.test.ts
CHANGED
|
@@ -130,6 +130,51 @@ test('admin access-rule help gives update whitelist and assign-users body-json e
|
|
|
130
130
|
assert.doesNotMatch(assign.text, /"allowedUsers"/)
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
+
test('help-examples requires a final command', async () => {
|
|
134
|
+
await assert.rejects(
|
|
135
|
+
() => executeCLI('admin', ['--help-examples'], env as any, async () => new Response('{}')),
|
|
136
|
+
{ code: 'HELP_EXAMPLES_REQUIRES_COMMAND' },
|
|
137
|
+
)
|
|
138
|
+
await assert.rejects(
|
|
139
|
+
() => executeCLI('admin', ['access-rule', '--help-examples'], env as any, async () => new Response('{}')),
|
|
140
|
+
{ code: 'HELP_EXAMPLES_REQUIRES_COMMAND' },
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('admin access-rule create help-examples show canonical ingress scopes and conditions', async () => {
|
|
145
|
+
const out = await executeCLI('admin', ['access-rule', 'create', '--help-examples'], env as any, async () => new Response('{}'))
|
|
146
|
+
assert.equal(out.kind, 'help')
|
|
147
|
+
assert.match(out.text, /arcubase-admin access-rule create --help-examples/)
|
|
148
|
+
assert.match(out.text, /"type":"department"/)
|
|
149
|
+
assert.match(out.text, /"type":"actor"/)
|
|
150
|
+
assert.match(out.text, /"column":"creator","operator":"\$eq","value":"\[\[current_user\]\]"/)
|
|
151
|
+
assert.match(out.text, /"subset":\{"mode":"or"/)
|
|
152
|
+
assert.doesNotMatch(out.text, forbiddenArcubaseUserTerms())
|
|
153
|
+
assert.doesNotMatch(out.text, /"type":"member"/)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('admin access-rule bulk-apply help-examples show transactional matrix body', async () => {
|
|
157
|
+
const out = await executeCLI('admin', ['access-rule', 'bulk-apply', '--help-examples'], env as any, async () => new Response('{}'))
|
|
158
|
+
assert.equal(out.kind, 'help')
|
|
159
|
+
assert.match(out.text, /arcubase-admin access-rule bulk-apply --help-examples/)
|
|
160
|
+
assert.match(out.text, /"mode":"upsert_by_name"/)
|
|
161
|
+
assert.match(out.text, /"table_id":2188893443/)
|
|
162
|
+
assert.match(out.text, /"type":"department"/)
|
|
163
|
+
assert.match(out.text, /"type":"actor"/)
|
|
164
|
+
assert.match(out.text, /"value":"\[\[current_user\]\]"/)
|
|
165
|
+
assert.doesNotMatch(out.text, forbiddenArcubaseUserTerms())
|
|
166
|
+
assert.doesNotMatch(out.text, /"type":"member"/)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('user row bulk-update help-examples show ids and condition selection bodies', async () => {
|
|
170
|
+
const out = await executeCLI('user', ['row', 'bulk-update', '--help-examples'], env as any, async () => new Response('{}'))
|
|
171
|
+
assert.equal(out.kind, 'help')
|
|
172
|
+
assert.match(out.text, /arcubase row bulk-update --help-examples/)
|
|
173
|
+
assert.match(out.text, /"selection":\{"type":"ids"/)
|
|
174
|
+
assert.match(out.text, /"selection":\{"type":"condition"/)
|
|
175
|
+
assert.match(out.text, /"action":\{"type":"set"/)
|
|
176
|
+
})
|
|
177
|
+
|
|
133
178
|
test('admin assign-users help uses arcubaseUserId terminology', async () => {
|
|
134
179
|
const help = await executeCLI('admin', ['access-rule', 'assign-users', '--help'], env as any, async () => new Response('{}'))
|
|
135
180
|
assert.equal(help.kind, 'help')
|