@carthooks/arcubase-cli 0.1.19 → 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.
@@ -0,0 +1,289 @@
1
+ // Generated by scripts/generate-help-examples.mjs. Do not edit by hand.
2
+
3
+ export type GeneratedHelpExample = {
4
+ title: string
5
+ body?: unknown
6
+ notes?: readonly string[]
7
+ }
8
+
9
+ export const helpExamplesIndex = {
10
+ "admin.access-rule.assign-users": {
11
+ "command": [
12
+ "access-rule",
13
+ "assign-users"
14
+ ],
15
+ "examples": [
16
+ {
17
+ "title": "replace user_scope with user, department, and actor scopes",
18
+ "body": {
19
+ "update": [
20
+ "user_scope"
21
+ ],
22
+ "options": {
23
+ "user_scope": [
24
+ {
25
+ "type": "user",
26
+ "id": 2188889901
27
+ },
28
+ {
29
+ "type": "department",
30
+ "id": 2188881201
31
+ },
32
+ {
33
+ "type": "actor",
34
+ "id": 2188883301
35
+ }
36
+ ]
37
+ }
38
+ },
39
+ "notes": [
40
+ "id must be numeric arcubaseUserId, department ID, or actor ID",
41
+ "assign-users updates user_scope only and preserves the existing policy"
42
+ ]
43
+ }
44
+ ]
45
+ },
46
+ "admin.access-rule.create": {
47
+ "command": [
48
+ "access-rule",
49
+ "create"
50
+ ],
51
+ "examples": [
52
+ {
53
+ "title": "full access for a specific arcubaseUserId",
54
+ "body": {
55
+ "name": "Admin full access",
56
+ "enabled": true,
57
+ "type": "form",
58
+ "options": {
59
+ "user_scope": [
60
+ {
61
+ "type": "user",
62
+ "id": 2188889977
63
+ }
64
+ ],
65
+ "policy": {
66
+ "key": "custom",
67
+ "actions": [
68
+ "view",
69
+ "add",
70
+ "edit",
71
+ "delete"
72
+ ],
73
+ "all_fields_read": true,
74
+ "all_fields_write": true
75
+ },
76
+ "list_options": {}
77
+ }
78
+ }
79
+ },
80
+ {
81
+ "title": "department can view/add/edit only rows created by themselves",
82
+ "body": {
83
+ "name": "Sales own rows",
84
+ "enabled": true,
85
+ "type": "form",
86
+ "options": {
87
+ "user_scope": [
88
+ {
89
+ "type": "department",
90
+ "id": 2188881201
91
+ }
92
+ ],
93
+ "policy": {
94
+ "key": "custom",
95
+ "actions": [
96
+ "view",
97
+ "add",
98
+ "edit"
99
+ ],
100
+ "all_fields_read": true,
101
+ "all_fields_write": true,
102
+ "condition": {
103
+ "mode": "and",
104
+ "conditions": [
105
+ {
106
+ "column": "creator",
107
+ "operator": "$eq",
108
+ "value": "[[current_user]]",
109
+ "value_from": 0,
110
+ "value_relation": ""
111
+ }
112
+ ]
113
+ }
114
+ },
115
+ "list_options": {}
116
+ }
117
+ },
118
+ "notes": [
119
+ "department scope includes child departments",
120
+ "row-owner isolation uses [[current_user]] on creator"
121
+ ]
122
+ },
123
+ {
124
+ "title": "role actor can view rows matching a complex AND/OR condition",
125
+ "body": {
126
+ "name": "Sales manager review queue",
127
+ "enabled": true,
128
+ "type": "form",
129
+ "options": {
130
+ "user_scope": [
131
+ {
132
+ "type": "actor",
133
+ "id": 2188883301
134
+ }
135
+ ],
136
+ "policy": {
137
+ "key": "custom",
138
+ "actions": [
139
+ "view",
140
+ "edit"
141
+ ],
142
+ "all_fields_read": true,
143
+ "all_fields_write": true,
144
+ "condition": {
145
+ "mode": "and",
146
+ "conditions": [
147
+ {
148
+ "column": ":1004",
149
+ "operator": "$eq",
150
+ "value": "pending",
151
+ "value_from": 0,
152
+ "value_relation": ""
153
+ },
154
+ {
155
+ "column": "",
156
+ "operator": "",
157
+ "value": null,
158
+ "value_from": 0,
159
+ "value_relation": "",
160
+ "subset": {
161
+ "mode": "or",
162
+ "conditions": [
163
+ {
164
+ "column": "creator",
165
+ "operator": "$eq",
166
+ "value": "[[current_user]]",
167
+ "value_from": 0,
168
+ "value_relation": ""
169
+ },
170
+ {
171
+ "column": ":1005",
172
+ "operator": "$eq",
173
+ "value": "high",
174
+ "value_from": 0,
175
+ "value_relation": ""
176
+ }
177
+ ]
178
+ }
179
+ }
180
+ ]
181
+ }
182
+ },
183
+ "list_options": {}
184
+ }
185
+ },
186
+ "notes": [
187
+ "actor means Arcubase role id",
188
+ "condition mode \"and\" and \"or\" are canonical; legacy \"all\" also reads as AND",
189
+ "nested OR uses subset and leaves the wrapper column/operator empty"
190
+ ]
191
+ }
192
+ ]
193
+ },
194
+ "user.row.bulk-update": {
195
+ "command": [
196
+ "row",
197
+ "bulk-update"
198
+ ],
199
+ "examples": [
200
+ {
201
+ "title": "bulk update explicit row ids",
202
+ "body": {
203
+ "selection": {
204
+ "type": "ids",
205
+ "ids": [
206
+ 2188890411,
207
+ 2188890412
208
+ ],
209
+ "length": 2
210
+ },
211
+ "fields": [
212
+ {
213
+ "id": 1003,
214
+ "action": {
215
+ "type": "set",
216
+ "value": 2
217
+ }
218
+ }
219
+ ]
220
+ }
221
+ },
222
+ {
223
+ "title": "bulk update rows selected by a search condition",
224
+ "body": {
225
+ "selection": {
226
+ "type": "condition",
227
+ "condition": {
228
+ "selectAll": true,
229
+ "q": "SO-1001"
230
+ },
231
+ "length": 0
232
+ },
233
+ "fields": [
234
+ {
235
+ "id": 1003,
236
+ "action": {
237
+ "type": "set",
238
+ "value": 2
239
+ }
240
+ }
241
+ ]
242
+ },
243
+ "notes": [
244
+ "condition selection uses row-query style keys such as q, tab, and filter_:1002"
245
+ ]
246
+ }
247
+ ]
248
+ },
249
+ "user.row.selection-action": {
250
+ "command": [
251
+ "row",
252
+ "selection-action"
253
+ ],
254
+ "examples": [
255
+ {
256
+ "title": "run a custom action against explicit row ids",
257
+ "body": {
258
+ "selection": {
259
+ "type": "ids",
260
+ "ids": [
261
+ 2188890411
262
+ ],
263
+ "length": 1
264
+ }
265
+ },
266
+ "notes": [
267
+ "pass the backend action code with --action",
268
+ "for query action, use ids or all selection instead of condition selection"
269
+ ]
270
+ },
271
+ {
272
+ "title": "run a custom action against a filtered selection",
273
+ "body": {
274
+ "selection": {
275
+ "type": "condition",
276
+ "condition": {
277
+ "selectAll": true,
278
+ "q": "SO-1001"
279
+ },
280
+ "length": 0
281
+ }
282
+ },
283
+ "notes": [
284
+ "condition selection uses row-query style keys such as q, tab, and filter_:1002"
285
+ ]
286
+ }
287
+ ]
288
+ }
289
+ } as const satisfies Record<string, { command: readonly string[]; examples: readonly GeneratedHelpExample[] }>
@@ -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)
@@ -1051,6 +1080,38 @@ function validateAccessRuleUserScope(
1051
1080
  }
1052
1081
  }
1053
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
+
1054
1115
  function requireUpdateContains(
1055
1116
  scope: CommandScope,
1056
1117
  command: NonNullable<ReturnType<typeof findCommand>>,
@@ -2005,16 +2066,32 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
2005
2066
  const parsed = parseCLIArgs(argv)
2006
2067
  const envInput = env ?? process.env
2007
2068
  if (parsed.commandTokens.length === 0) {
2069
+ if (hasFlag(parsed.flags, 'help-examples')) {
2070
+ throwHelpExamplesRequiresCommand(scope)
2071
+ }
2008
2072
  return { kind: 'help', text: renderRootHelp(scope) }
2009
2073
  }
2010
2074
  const [moduleName, commandName] = parsed.commandTokens
2011
2075
  if (!moduleName) {
2076
+ if (hasFlag(parsed.flags, 'help-examples')) {
2077
+ throwHelpExamplesRequiresCommand(scope)
2078
+ }
2012
2079
  return { kind: 'help', text: renderRootHelp(scope) }
2013
2080
  }
2014
2081
  const singleTokenCommand = !commandName ? findCommand(scope, moduleName) : null
2015
2082
  if (!commandName && !singleTokenCommand) {
2083
+ if (hasFlag(parsed.flags, 'help-examples')) {
2084
+ throwHelpExamplesRequiresCommand(scope, moduleName)
2085
+ }
2016
2086
  return { kind: 'help', text: renderModuleHelp(scope, moduleName, envInput) }
2017
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
+ }
2018
2095
  if (hasFlag(parsed.flags, 'help')) {
2019
2096
  return { kind: 'help', text: renderCommandHelp(scope, moduleName, commandName, envInput) }
2020
2097
  }
@@ -2065,6 +2142,7 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
2065
2142
  if (body === undefined) {
2066
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)
2067
2144
  }
2145
+ body = normalizeNumericAccessRuleUserScopeIDs(scope, command, body)
2068
2146
  if (isTableCreateCommand(scope, command)) {
2069
2147
  requireTableCreateSchemaBody(scope, command, body, runtimeEnv)
2070
2148
  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
+ }
@@ -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',
@@ -130,6 +130,38 @@ 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('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
+
133
165
  test('admin assign-users help uses arcubaseUserId terminology', async () => {
134
166
  const help = await executeCLI('admin', ['access-rule', 'assign-users', '--help'], env as any, async () => new Response('{}'))
135
167
  assert.equal(help.kind, 'help')