@carthooks/arcubase-cli 0.1.18 → 0.1.20

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.
Files changed (31) hide show
  1. package/bundle/arcubase-admin.mjs +423 -34
  2. package/bundle/arcubase.mjs +423 -34
  3. package/dist/generated/help_examples.generated.d.ts +205 -0
  4. package/dist/generated/help_examples.generated.d.ts.map +1 -0
  5. package/dist/generated/help_examples.generated.js +282 -0
  6. package/dist/generated/zod_registry.generated.d.ts.map +1 -1
  7. package/dist/generated/zod_registry.generated.js +1 -1
  8. package/dist/runtime/execute.d.ts.map +1 -1
  9. package/dist/runtime/execute.js +84 -17
  10. package/dist/runtime/help_examples.d.ts +10 -0
  11. package/dist/runtime/help_examples.d.ts.map +1 -0
  12. package/dist/runtime/help_examples.js +48 -0
  13. package/package.json +4 -3
  14. package/sdk-dist/docs/runtime-reference/access-rule.md +12 -10
  15. package/sdk-dist/docs/runtime-reference/help-examples/admin/access-rule/assign-users.json +23 -0
  16. package/sdk-dist/docs/runtime-reference/help-examples/admin/access-rule/create.json +95 -0
  17. package/sdk-dist/docs/runtime-reference/help-examples/user/row/bulk-update.json +23 -0
  18. package/sdk-dist/docs/runtime-reference/help-examples/user/row/selection-action.json +25 -0
  19. package/sdk-dist/generated/help_examples.generated.ts +289 -0
  20. package/sdk-dist/generated/zod_registry.generated.ts +1 -1
  21. package/sdk-dist/types/common.ts +1 -0
  22. package/sdk-dist/types/entity.ts +1 -1
  23. package/sdk-dist/types/kiosk-ui.ts +1 -1
  24. package/sdk-dist/types/tenant.ts +1 -1
  25. package/src/generated/help_examples.generated.ts +289 -0
  26. package/src/generated/zod_registry.generated.ts +1 -1
  27. package/src/runtime/execute.ts +98 -25
  28. package/src/runtime/help_examples.ts +61 -0
  29. package/src/tests/execute_validation.test.ts +88 -10
  30. package/src/tests/help.test.ts +35 -1
  31. package/src/tests/zod_registry.test.ts +38 -0
@@ -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:',
@@ -130,19 +159,19 @@ export function renderCommandHelp(scope: CommandScope, moduleName: string, comma
130
159
  ` - hide field: ${JSON.stringify(buildHiddenFieldAccessRuleBody())}`,
131
160
  'success requires:',
132
161
  ' - result.enabled must be true',
133
- ' - options.user_scope[].type is "user" for tenant users',
162
+ ' - options.user_scope[].type is "user", "department", or "actor"',
134
163
  ' - hidden fields use colon field keys such as ":1002"',
135
164
  ]
136
165
  : []),
137
166
  ...(scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users'
138
167
  ? [
139
168
  'body-json example:',
140
- ' - {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}',
169
+ ' - {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901},{"type":"department","id":2188881201},{"type":"actor","id":2188883301}]}}',
141
170
  'success requires:',
142
171
  ' - result.enabled must be true',
143
- ' - result.options.user_scope must contain the assigned numeric arcubaseUserId values',
144
- ' - PermitUserScope.id must be the numeric arcubaseUserId',
145
- ' - Do not copy a non-numeric arcubaseUserId into PermitUserScope.id',
172
+ ' - result.options.user_scope must contain every assigned user, department, or actor scope',
173
+ ' - PermitUserScope.id must be the numeric arcubaseUserId, department ID, or actor ID',
174
+ ' - Do not copy a non-numeric BotWorks userId into PermitUserScope.id',
146
175
  ' - If result.enabled is false, enable the rule first and retry assign-users',
147
176
  ]
148
177
  : []),
@@ -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)
@@ -697,14 +726,18 @@ function buildAssignUsersValidationDetails(
697
726
  shapeHint: {
698
727
  update: ['user_scope'],
699
728
  options: {
700
- user_scope: [{ type: 'user', id: 2188889901 }],
729
+ user_scope: [
730
+ { type: 'user', id: 2188889901 },
731
+ { type: 'department', id: 2188881201 },
732
+ { type: 'actor', id: 2188883301 },
733
+ ],
701
734
  },
702
735
  },
703
736
  commonMistakes: [
704
737
  'do not use user_scope at top level',
705
738
  'do not use users or allowedUsers',
706
- 'do not use non-numeric arcubaseUserId as id',
707
- 'id must be the numeric arcubaseUserId',
739
+ 'do not use non-numeric BotWorks userId as id',
740
+ 'id must be the numeric arcubaseUserId, department ID, or actor ID',
708
741
  ],
709
742
  }
710
743
  }
@@ -1004,7 +1037,7 @@ function validateAccessRuleUserScope(
1004
1037
  if (userScope === undefined) return
1005
1038
  if (!Array.isArray(userScope)) {
1006
1039
  throwBodyValidationFailure(
1007
- 'access-rule user_scope must be an array of {"type":"user","id":2188889901}',
1040
+ 'access-rule user_scope must be an array of {"type":"user","id":2188889901}, {"type":"department","id":2188881201}, or {"type":"actor","id":2188883301}',
1008
1041
  requestType,
1009
1042
  pathPrefix,
1010
1043
  scope,
@@ -1047,6 +1080,38 @@ function validateAccessRuleUserScope(
1047
1080
  }
1048
1081
  }
1049
1082
 
1083
+ function normalizeNumericAccessRuleUserScopeIDs(
1084
+ scope: CommandScope,
1085
+ command: NonNullable<ReturnType<typeof findCommand>>,
1086
+ body: unknown,
1087
+ ): unknown {
1088
+ if (!isAccessRuleCommand(scope, command) || !isRecord(body) || !isRecord(body.options) || !Array.isArray(body.options.user_scope)) {
1089
+ return body
1090
+ }
1091
+
1092
+ let changed = false
1093
+ const userScope = body.options.user_scope.map((item) => {
1094
+ if (!isRecord(item) || typeof item.id !== 'string' || !/^\d+$/.test(item.id)) {
1095
+ return item
1096
+ }
1097
+ const id = Number(item.id)
1098
+ if (!Number.isFinite(id) || !Number.isInteger(id)) {
1099
+ return item
1100
+ }
1101
+ changed = true
1102
+ return { ...item, id }
1103
+ })
1104
+
1105
+ if (!changed) return body
1106
+ return {
1107
+ ...body,
1108
+ options: {
1109
+ ...body.options,
1110
+ user_scope: userScope,
1111
+ },
1112
+ }
1113
+ }
1114
+
1050
1115
  function requireUpdateContains(
1051
1116
  scope: CommandScope,
1052
1117
  command: NonNullable<ReturnType<typeof findCommand>>,
@@ -1081,7 +1146,7 @@ function validateActionSpecificRawBody(
1081
1146
  if (isAssignUsersCommand(scope, command)) {
1082
1147
  if ('users' in body || 'allowedUsers' in body || 'user_scope' in body) {
1083
1148
  throwBodyValidationFailure(
1084
- 'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}',
1149
+ 'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901},{"type":"department","id":2188881201},{"type":"actor","id":2188883301}]}}',
1085
1150
  command.requestType,
1086
1151
  'body.options.user_scope',
1087
1152
  scope,
@@ -1213,7 +1278,7 @@ function validateActionSpecificBody(
1213
1278
  const userScope = options && Array.isArray(options.user_scope) ? options.user_scope : undefined
1214
1279
  if ('users' in body || 'allowedUsers' in body) {
1215
1280
  throwBodyValidationFailure(
1216
- 'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901}]}}',
1281
+ 'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":2188889901},{"type":"department","id":2188881201},{"type":"actor","id":2188883301}]}}',
1217
1282
  command.requestType,
1218
1283
  'body.options.user_scope',
1219
1284
  scope,
@@ -1254,19 +1319,9 @@ function validateActionSpecificBody(
1254
1319
  env,
1255
1320
  )
1256
1321
  }
1257
- if (item.type !== 'user') {
1258
- throwBodyValidationFailure(
1259
- 'access-rule assign-users requires body.options.user_scope[].type to be "user"',
1260
- command.requestType,
1261
- `body.options.user_scope.${index}.type`,
1262
- scope,
1263
- command,
1264
- env,
1265
- )
1266
- }
1267
1322
  if (typeof item.id !== 'number' || !Number.isInteger(item.id) || item.id <= 0) {
1268
1323
  throwBodyValidationFailure(
1269
- 'access-rule assign-users requires body.options.user_scope[].id to be numeric arcubaseUserId',
1324
+ 'access-rule assign-users requires body.options.user_scope[].id to be numeric arcubaseUserId, department ID, or actor ID',
1270
1325
  command.requestType,
1271
1326
  `body.options.user_scope.${index}.id`,
1272
1327
  scope,
@@ -2011,16 +2066,32 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
2011
2066
  const parsed = parseCLIArgs(argv)
2012
2067
  const envInput = env ?? process.env
2013
2068
  if (parsed.commandTokens.length === 0) {
2069
+ if (hasFlag(parsed.flags, 'help-examples')) {
2070
+ throwHelpExamplesRequiresCommand(scope)
2071
+ }
2014
2072
  return { kind: 'help', text: renderRootHelp(scope) }
2015
2073
  }
2016
2074
  const [moduleName, commandName] = parsed.commandTokens
2017
2075
  if (!moduleName) {
2076
+ if (hasFlag(parsed.flags, 'help-examples')) {
2077
+ throwHelpExamplesRequiresCommand(scope)
2078
+ }
2018
2079
  return { kind: 'help', text: renderRootHelp(scope) }
2019
2080
  }
2020
2081
  const singleTokenCommand = !commandName ? findCommand(scope, moduleName) : null
2021
2082
  if (!commandName && !singleTokenCommand) {
2083
+ if (hasFlag(parsed.flags, 'help-examples')) {
2084
+ throwHelpExamplesRequiresCommand(scope, moduleName)
2085
+ }
2022
2086
  return { kind: 'help', text: renderModuleHelp(scope, moduleName, envInput) }
2023
2087
  }
2088
+ if (hasFlag(parsed.flags, 'help-examples')) {
2089
+ const command = commandName ? findCommand(scope, moduleName, commandName) : singleTokenCommand
2090
+ if (!command) {
2091
+ throw new CLIError('UNKNOWN_COMMAND', `unknown command: ${moduleName}${commandName ? ` ${commandName}` : ''}`, 2, buildUnknownCommandDetails(scope, moduleName, commandName ?? ''))
2092
+ }
2093
+ return { kind: 'help', text: renderCommandHelpExamples(scope, command) }
2094
+ }
2024
2095
  if (hasFlag(parsed.flags, 'help')) {
2025
2096
  return { kind: 'help', text: renderCommandHelp(scope, moduleName, commandName, envInput) }
2026
2097
  }
@@ -2071,6 +2142,7 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
2071
2142
  if (body === undefined) {
2072
2143
  throw new CLIError('MISSING_BODY_FILE', `command requires --body-json or --body-file for ${command.requestType}; prefer --body-json to avoid file creation`, 2)
2073
2144
  }
2145
+ body = normalizeNumericAccessRuleUserScopeIDs(scope, command, body)
2074
2146
  if (isTableCreateCommand(scope, command)) {
2075
2147
  requireTableCreateSchemaBody(scope, command, body, runtimeEnv)
2076
2148
  validateActionSpecificRawBody(scope, command, body, runtimeEnv)
@@ -2108,16 +2180,17 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
2108
2180
  const message = parsedBody.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ')
2109
2181
  if (isAssignUsersCommand(scope, command)) {
2110
2182
  const hasUserScopeIDIssue = parsedBody.error.issues.some((issue) => issue.path.join('.') === 'options.user_scope.0.id' || issue.path.join('.').startsWith('options.user_scope.'))
2183
+ const userScopeIDMessage = 'access-rule assign-users requires body.options.user_scope[].id to be numeric arcubaseUserId, department ID, or actor ID'
2111
2184
  throw new CLIError(
2112
2185
  'BODY_VALIDATION_FAILED',
2113
- hasUserScopeIDIssue ? 'access-rule assign-users requires body.options.user_scope[].id to be numeric arcubaseUserId' : message,
2186
+ hasUserScopeIDIssue ? userScopeIDMessage : message,
2114
2187
  2,
2115
2188
  buildAssignUsersValidationDetails(
2116
2189
  scope,
2117
2190
  command,
2118
2191
  command.requestType,
2119
2192
  hasUserScopeIDIssue ? 'body.options.user_scope' : 'body',
2120
- hasUserScopeIDIssue ? 'access-rule assign-users requires body.options.user_scope[].id to be numeric arcubaseUserId' : message,
2193
+ hasUserScopeIDIssue ? userScopeIDMessage : message,
2121
2194
  runtimeEnv,
2122
2195
  )
2123
2196
  )
@@ -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
+ }
@@ -55,14 +55,18 @@ function assertAssignUsersGuidance(error: unknown, messagePattern?: RegExp): boo
55
55
  assert.deepEqual(error.details?.shapeHint, {
56
56
  update: ['user_scope'],
57
57
  options: {
58
- user_scope: [{ type: 'user', id: 2188889901 }],
58
+ user_scope: [
59
+ { type: 'user', id: 2188889901 },
60
+ { type: 'department', id: 2188881201 },
61
+ { type: 'actor', id: 2188883301 },
62
+ ],
59
63
  },
60
64
  })
61
65
  assert.deepEqual(error.details?.commonMistakes, [
62
66
  'do not use user_scope at top level',
63
67
  'do not use users or allowedUsers',
64
- 'do not use non-numeric arcubaseUserId as id',
65
- 'id must be the numeric arcubaseUserId',
68
+ 'do not use non-numeric BotWorks userId as id',
69
+ 'id must be the numeric arcubaseUserId, department ID, or actor ID',
66
70
  ])
67
71
  assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'AppIngressUpdateReqVO' && item.file === '/runtime/arcubase-sdk/types/ingress.ts'))
68
72
  assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntityIngressOptions' && item.file === '/runtime/arcubase-sdk/types/ingress.ts'))
@@ -1318,7 +1322,7 @@ test('access-rule assign-users rejects top-level user_scope', async () => {
1318
1322
  }, (error: unknown) => assertAssignUsersGuidance(error, /options.user_scope/))
1319
1323
  })
1320
1324
 
1321
- test('access-rule assign-users rejects non-user member scope type', async () => {
1325
+ test('access-rule assign-users rejects FieldVO member scope type', async () => {
1322
1326
  await assert.rejects(async () => {
1323
1327
  await executeCLI(
1324
1328
  'admin',
@@ -1326,10 +1330,10 @@ test('access-rule assign-users rejects non-user member scope type', async () =>
1326
1330
  env as any,
1327
1331
  async () => new Response('{}', { status: 200 }),
1328
1332
  )
1329
- }, (error: unknown) => assertAssignUsersGuidance(error, /type.*user/))
1333
+ }, (error: unknown) => assertAssignUsersGuidance(error, /user.*department.*actor|FieldVO type "member"/))
1330
1334
  })
1331
1335
 
1332
- test('access-rule assign-users rejects non-numeric arcubaseUserId', async () => {
1336
+ test('access-rule assign-users rejects non-numeric user scope id', async () => {
1333
1337
  await assert.rejects(async () => {
1334
1338
  await executeCLI(
1335
1339
  'admin',
@@ -1337,17 +1341,91 @@ test('access-rule assign-users rejects non-numeric arcubaseUserId', async () =>
1337
1341
  env as any,
1338
1342
  async () => new Response('{}', { status: 200 }),
1339
1343
  )
1340
- }, (error: unknown) => assertAssignUsersGuidance(error, /numeric arcubaseUserId/))
1344
+ }, (error: unknown) => assertAssignUsersGuidance(error, /numeric arcubaseUserId, department ID, or actor ID/))
1341
1345
  })
1342
1346
 
1343
- test('access-rule assign-users accepts canonical user_scope body', async () => {
1347
+ test('access-rule create normalizes numeric string user_scope ids before validation and request', async () => {
1344
1348
  const out = await executeCLI(
1345
1349
  'admin',
1346
- ['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}]}}'],
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
+
1374
+ test('access-rule assign-users accepts canonical mixed user_scope body', async () => {
1375
+ const out = await executeCLI(
1376
+ 'admin',
1377
+ ['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}]}}'],
1347
1378
  env as any,
1348
1379
  async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
1349
1380
  )
1350
1381
  assert.equal(out.kind, 'result')
1351
1382
  assert.deepEqual(out.data.update, ['user_scope'])
1352
- assert.deepEqual(out.data.options.user_scope, [{ type: 'user', id: 2188889901 }])
1383
+ assert.deepEqual(out.data.options.user_scope, [
1384
+ { type: 'user', id: 2188889901 },
1385
+ { type: 'department', id: 2188881201 },
1386
+ { type: 'actor', id: 2188883301 },
1387
+ ])
1388
+ })
1389
+
1390
+ test('access-rule create accepts nested custom policy condition with field columns', async () => {
1391
+ const body = JSON.stringify({
1392
+ name: 'Sales own or west region',
1393
+ enabled: true,
1394
+ type: 'form',
1395
+ options: {
1396
+ user_scope: [{ type: 'department', id: 2188881201 }],
1397
+ policy: {
1398
+ key: 'custom',
1399
+ actions: ['view', 'add', 'edit'],
1400
+ all_fields_read: true,
1401
+ all_fields_write: true,
1402
+ condition: {
1403
+ mode: 'and',
1404
+ conditions: [
1405
+ {
1406
+ subset: {
1407
+ mode: 'or',
1408
+ conditions: [
1409
+ { column: 'creator', operator: '$eq', value: '[[current_user]]' },
1410
+ { column: ':1002', operator: '$eq', value: '[[current_user]]' },
1411
+ ],
1412
+ },
1413
+ },
1414
+ { column: ':1003', operator: '$eq', value: '华东' },
1415
+ ],
1416
+ },
1417
+ },
1418
+ list_options: {},
1419
+ },
1420
+ })
1421
+ const out = await executeCLI(
1422
+ 'admin',
1423
+ ['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', body],
1424
+ env as any,
1425
+ async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
1426
+ )
1427
+ assert.equal(out.kind, 'result')
1428
+ assert.equal(out.data.options.policy.condition.mode, 'and')
1429
+ assert.equal(out.data.options.policy.condition.conditions[0].subset.mode, 'or')
1430
+ assert.equal(out.data.options.policy.condition.conditions[0].subset.conditions[1].column, ':1002')
1353
1431
  })
@@ -120,14 +120,48 @@ test('admin access-rule help gives update whitelist and assign-users body-json e
120
120
  const assign = await executeCLI('admin', ['access-rule', 'assign-users', '--help'], env as any, async () => new Response('{}'))
121
121
  assert.equal(assign.kind, 'help')
122
122
  assert.match(assign.text, /"update":\["user_scope"\]/)
123
- assert.match(assign.text, /\{"update":\["user_scope"\],"options":\{"user_scope":\[\{"type":"user","id":2188889901\}\]\}\}/)
123
+ assert.match(assign.text, /\{"update":\["user_scope"\],"options":\{"user_scope":\[\{"type":"user","id":2188889901\},\{"type":"department","id":2188881201\},\{"type":"actor","id":2188883301\}\]\}\}/)
124
124
  assert.match(assign.text, /arcubaseUserId/)
125
+ assert.match(assign.text, /department ID/)
126
+ assert.match(assign.text, /actor ID/)
125
127
  assert.doesNotMatch(assign.text, forbiddenArcubaseUserTerms())
126
128
  assert.doesNotMatch(assign.text, /"type":"member"/)
127
129
  assert.doesNotMatch(assign.text, /"users"/)
128
130
  assert.doesNotMatch(assign.text, /"allowedUsers"/)
129
131
  })
130
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('user row bulk-update help-examples show ids and condition selection bodies', async () => {
157
+ const out = await executeCLI('user', ['row', 'bulk-update', '--help-examples'], env as any, async () => new Response('{}'))
158
+ assert.equal(out.kind, 'help')
159
+ assert.match(out.text, /arcubase row bulk-update --help-examples/)
160
+ assert.match(out.text, /"selection":\{"type":"ids"/)
161
+ assert.match(out.text, /"selection":\{"type":"condition"/)
162
+ assert.match(out.text, /"action":\{"type":"set"/)
163
+ })
164
+
131
165
  test('admin assign-users help uses arcubaseUserId terminology', async () => {
132
166
  const help = await executeCLI('admin', ['access-rule', 'assign-users', '--help'], env as any, async () => new Response('{}'))
133
167
  assert.equal(help.kind, 'help')
@@ -41,6 +41,44 @@ test('EntityQueryReqVO search only accepts route-query string keys', () => {
41
41
  }
42
42
  })
43
43
 
44
+ test('AppIngressCreateReqVO accepts nested ConditionSet with field EntityProp columns', () => {
45
+ const schema = getBodySchema('AppIngressCreateReqVO')
46
+ assert.ok(schema)
47
+
48
+ const result = schema.safeParse({
49
+ name: 'Sales own or west region',
50
+ enabled: true,
51
+ type: 'form',
52
+ options: {
53
+ user_scope: [{ type: 'department', id: 2188881201 }],
54
+ policy: {
55
+ key: 'custom',
56
+ actions: ['view', 'add', 'edit'],
57
+ all_fields_read: true,
58
+ all_fields_write: true,
59
+ condition: {
60
+ mode: 'and',
61
+ conditions: [
62
+ {
63
+ subset: {
64
+ mode: 'or',
65
+ conditions: [
66
+ { column: 'creator', operator: '$eq', value: '[[current_user]]' },
67
+ { column: ':1002', operator: '$eq', value: '[[current_user]]' },
68
+ ],
69
+ },
70
+ },
71
+ { column: ':1003', operator: '$eq', value: '华东' },
72
+ ],
73
+ },
74
+ },
75
+ list_options: {},
76
+ },
77
+ })
78
+
79
+ assert.equal(result.success, true)
80
+ })
81
+
44
82
  test('unknown request type has no schema and no unsupported reason', () => {
45
83
  assert.equal(getBodySchema('DefinitelyMissingReqVO'), null)
46
84
  assert.equal(getUnsupportedBodySchemaReason('DefinitelyMissingReqVO'), null)