@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.
Files changed (44) hide show
  1. package/bundle/arcubase-admin.mjs +727 -45
  2. package/bundle/arcubase.mjs +727 -45
  3. package/dist/generated/command_registry.generated.d.ts +15 -0
  4. package/dist/generated/command_registry.generated.d.ts.map +1 -1
  5. package/dist/generated/command_registry.generated.js +21 -0
  6. package/dist/generated/help_examples.generated.d.ts +319 -0
  7. package/dist/generated/help_examples.generated.d.ts.map +1 -0
  8. package/dist/generated/help_examples.generated.js +437 -0
  9. package/dist/generated/type_index.generated.d.ts +4 -0
  10. package/dist/generated/type_index.generated.d.ts.map +1 -1
  11. package/dist/generated/type_index.generated.js +4 -0
  12. package/dist/generated/zod_registry.generated.d.ts +4 -1
  13. package/dist/generated/zod_registry.generated.d.ts.map +1 -1
  14. package/dist/generated/zod_registry.generated.js +4 -0
  15. package/dist/runtime/execute.d.ts.map +1 -1
  16. package/dist/runtime/execute.js +155 -17
  17. package/dist/runtime/help_examples.d.ts +10 -0
  18. package/dist/runtime/help_examples.d.ts.map +1 -0
  19. package/dist/runtime/help_examples.js +48 -0
  20. package/dist/runtime/zod_registry.d.ts.map +1 -1
  21. package/dist/runtime/zod_registry.js +8 -0
  22. package/package.json +4 -3
  23. package/sdk-dist/api/admin/ingress.ts +11 -0
  24. package/sdk-dist/docs/runtime-reference/access-rule.md +19 -0
  25. package/sdk-dist/docs/runtime-reference/help-examples/admin/access-rule/assign-users.json +23 -0
  26. package/sdk-dist/docs/runtime-reference/help-examples/admin/access-rule/bulk-apply.json +105 -0
  27. package/sdk-dist/docs/runtime-reference/help-examples/admin/access-rule/create.json +95 -0
  28. package/sdk-dist/docs/runtime-reference/help-examples/user/row/bulk-update.json +23 -0
  29. package/sdk-dist/docs/runtime-reference/help-examples/user/row/selection-action.json +25 -0
  30. package/sdk-dist/generated/command_registry.generated.ts +21 -0
  31. package/sdk-dist/generated/help_examples.generated.ts +444 -0
  32. package/sdk-dist/generated/type_index.generated.ts +4 -0
  33. package/sdk-dist/generated/zod_registry.generated.ts +6 -0
  34. package/sdk-dist/types/ingress.ts +31 -0
  35. package/src/generated/command_registry.generated.ts +21 -0
  36. package/src/generated/help_examples.generated.ts +444 -0
  37. package/src/generated/type_index.generated.ts +4 -0
  38. package/src/generated/zod_registry.generated.ts +6 -0
  39. package/src/runtime/execute.ts +223 -37
  40. package/src/runtime/help_examples.ts +61 -0
  41. package/src/runtime/zod_registry.ts +8 -0
  42. package/src/tests/command_registry.test.ts +1 -0
  43. package/src/tests/execute_validation.test.ts +113 -0
  44. package/src/tests/help.test.ts +45 -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:',
@@ -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
- const actions = policy && Array.isArray(policy.actions) ? policy.actions : []
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' },
@@ -24,6 +24,7 @@ test('admin surface is restricted to reset white list', () => {
24
24
  'table rename',
25
25
  'table update-schema',
26
26
  'access-rule assign-users',
27
+ 'access-rule bulk-apply',
27
28
  'access-rule create',
28
29
  'access-rule delete',
29
30
  'access-rule get',
@@ -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',
@@ -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')