@carthooks/arcubase-cli 0.1.12 → 0.1.15

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 (46) hide show
  1. package/bundle/arcubase-admin.mjs +15019 -3802
  2. package/bundle/arcubase.mjs +15019 -3802
  3. package/dist/generated/command_registry.generated.d.ts +108 -0
  4. package/dist/generated/command_registry.generated.d.ts.map +1 -1
  5. package/dist/generated/command_registry.generated.js +145 -0
  6. package/dist/generated/zod_registry.generated.d.ts +23 -23
  7. package/dist/runtime/entity_import_mapping.d.ts +12 -0
  8. package/dist/runtime/entity_import_mapping.d.ts.map +1 -0
  9. package/dist/runtime/entity_import_mapping.js +133 -0
  10. package/dist/runtime/entity_save_schema.d.ts.map +1 -1
  11. package/dist/runtime/entity_save_schema.js +23 -2
  12. package/dist/runtime/errors.d.ts +1 -0
  13. package/dist/runtime/errors.d.ts.map +1 -1
  14. package/dist/runtime/execute.d.ts.map +1 -1
  15. package/dist/runtime/execute.js +414 -8
  16. package/dist/runtime/local_upload.d.ts +10 -0
  17. package/dist/runtime/local_upload.d.ts.map +1 -0
  18. package/dist/runtime/local_upload.js +73 -0
  19. package/dist/tests/entity_save_schema.test.js +38 -0
  20. package/dist/tests/execute_validation.test.js +64 -2
  21. package/dist/tests/help.test.js +2 -0
  22. package/package.json +7 -7
  23. package/sdk-dist/docs/runtime-reference/README.md +4 -0
  24. package/sdk-dist/docs/runtime-reference/access-rule.md +19 -0
  25. package/sdk-dist/docs/runtime-reference/entity-schema/textarea.md +2 -2
  26. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.query.json +1 -1
  27. package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.query.json +1 -1
  28. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.query.json +1 -1
  29. package/sdk-dist/docs/runtime-reference/row-crud.md +68 -0
  30. package/sdk-dist/docs/runtime-reference/search-and-bulk-actions.md +26 -1
  31. package/sdk-dist/docs/runtime-reference/table-lifecycle.md +1 -1
  32. package/sdk-dist/generated/command_registry.generated.ts +145 -0
  33. package/sdk-dist/types/common.ts +1 -0
  34. package/src/generated/command_registry.generated.ts +145 -0
  35. package/src/runtime/entity_import_mapping.ts +172 -0
  36. package/src/runtime/entity_save_schema.ts +27 -3
  37. package/src/runtime/errors.ts +1 -0
  38. package/src/runtime/execute.ts +434 -10
  39. package/src/runtime/local_upload.ts +84 -0
  40. package/src/tests/command_registry.test.ts +4 -0
  41. package/src/tests/entity_import_mapping.test.ts +87 -0
  42. package/src/tests/entity_save_schema.test.ts +40 -0
  43. package/src/tests/execute_validation.test.ts +299 -5
  44. package/src/tests/help.test.ts +16 -0
  45. package/src/tests/local_upload.test.ts +47 -0
  46. package/src/tests/zod_registry.test.ts +27 -0
@@ -11,10 +11,12 @@ test('admin surface is restricted to reset white list', () => {
11
11
  const actual = commandPaths(adminCommands)
12
12
  const expected = [
13
13
  'app create',
14
+ 'app create-from-bundle-url',
14
15
  'app delete',
15
16
  'app get',
16
17
  'app list',
17
18
  'app update',
19
+ 'app watch-import-task',
18
20
  'table create',
19
21
  'table delete',
20
22
  'table get',
@@ -45,6 +47,8 @@ test('user surface is restricted to reset white list', () => {
45
47
  'row create',
46
48
  'row delete',
47
49
  'row get',
50
+ 'row import-csv',
51
+ 'row import-excel',
48
52
  'row query',
49
53
  'row selection-action',
50
54
  'row update',
@@ -0,0 +1,87 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { compileCSVImportMapping, compileExcelImportMapping } from '../runtime/entity_import_mapping.js'
4
+ import { CLIError } from '../runtime/errors.js'
5
+
6
+ test('excel mapping compiles column letters to backend mapper', () => {
7
+ const result = compileExcelImportMapping({
8
+ sheet: 'Sheet1',
9
+ mode: 'addorupdate',
10
+ headerRow: 1,
11
+ dataStartRow: 2,
12
+ recordId: { column: 'A' },
13
+ fields: [
14
+ { fieldId: 1001, column: 'B' },
15
+ { fieldId: 1002, column: 'AA' },
16
+ ],
17
+ })
18
+ assert.deepEqual(result, {
19
+ fileFormat: 'excel',
20
+ importMode: 'addorupdate',
21
+ workingSheet: 'Sheet1',
22
+ titleRowIndex: 0,
23
+ mapper: {
24
+ '$id': 0,
25
+ '1001': 1,
26
+ '1002': 26,
27
+ },
28
+ })
29
+ })
30
+
31
+ test('excel mapping rejects string field id', () => {
32
+ assert.throws(() => {
33
+ compileExcelImportMapping({
34
+ sheet: 'Sheet1',
35
+ mode: 'addonly',
36
+ dataStartRow: 2,
37
+ fields: [{ fieldId: '1001', column: 'A' }],
38
+ } as any)
39
+ }, (error: unknown) => {
40
+ assert.ok(error instanceof CLIError)
41
+ assert.equal(error.code, 'INVALID_IMPORT_MAPPING')
42
+ assert.match(error.message, /fieldId must be a number/)
43
+ return true
44
+ })
45
+ })
46
+
47
+ test('csv mapping resolves headers to backend mapper', () => {
48
+ const result = compileCSVImportMapping({
49
+ mode: 'updateonly',
50
+ header: true,
51
+ dataStartRow: 2,
52
+ delimiter: ',',
53
+ recordId: { header: 'ID' },
54
+ fields: [
55
+ { fieldId: 1001, header: '客户名称' },
56
+ { fieldId: 1002, header: '手机号' },
57
+ ],
58
+ }, ['ID', '客户名称', '手机号'])
59
+ assert.deepEqual(result, {
60
+ fileFormat: 'csv',
61
+ importMode: 'updateonly',
62
+ titleRowIndex: 0,
63
+ csvDelimiter: ',',
64
+ mapper: {
65
+ '$id': 0,
66
+ '1001': 1,
67
+ '1002': 2,
68
+ },
69
+ })
70
+ })
71
+
72
+ test('csv mapping missing header lists available headers', () => {
73
+ assert.throws(() => {
74
+ compileCSVImportMapping({
75
+ mode: 'addonly',
76
+ header: true,
77
+ dataStartRow: 2,
78
+ fields: [{ fieldId: 1001, header: 'missing' }],
79
+ }, ['客户名称', '手机号'])
80
+ }, (error: unknown) => {
81
+ assert.ok(error instanceof CLIError)
82
+ assert.equal(error.code, 'INVALID_IMPORT_MAPPING')
83
+ assert.match(error.message, /missing/)
84
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('客户名称')))
85
+ return true
86
+ })
87
+ })
@@ -65,6 +65,46 @@ test('EntitySaveReqVO normalizes mechanical FieldVO defaults', () => {
65
65
  assert.deepEqual(parsed.data.fields[0].depends, [])
66
66
  })
67
67
 
68
+ test('EntitySaveReqVO rejects textarea size as string', () => {
69
+ assert.ok(schema)
70
+ const parsed = schema!.safeParse({
71
+ name: '订单',
72
+ field_id_seq: 1003,
73
+ layout: [[1001], [1002]],
74
+ fields: [
75
+ buildMinimalField(1001),
76
+ {
77
+ ...buildMinimalField(1002),
78
+ label: '描述',
79
+ type: 'textarea',
80
+ key: 'description',
81
+ options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
82
+ },
83
+ ],
84
+ })
85
+ assert.equal(parsed.success, false)
86
+ })
87
+
88
+ test('EntitySaveReqVO accepts textarea size as number', () => {
89
+ assert.ok(schema)
90
+ const parsed = schema!.safeParse({
91
+ name: '订单',
92
+ field_id_seq: 1003,
93
+ layout: [[1001], [1002]],
94
+ fields: [
95
+ buildMinimalField(1001),
96
+ {
97
+ ...buildMinimalField(1002),
98
+ label: '描述',
99
+ type: 'textarea',
100
+ key: 'description',
101
+ options: { placeholder: '', html: false, size: 6, lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
102
+ },
103
+ ],
104
+ })
105
+ assert.equal(parsed.success, true)
106
+ })
107
+
68
108
  test('EntitySaveReqVO rejects fields as object', () => {
69
109
  assert.ok(schema)
70
110
  const parsed = schema!.safeParse({
@@ -146,15 +146,196 @@ test('table update-schema rejects invalid body file', async () => {
146
146
  })
147
147
 
148
148
  test('app create rejects name longer than 20 chars', async () => {
149
- const bodyFile = tempJSONFile({ name: 'CLI Verify Happy Path' })
150
- await assert.rejects(async () => {
151
- await executeCLI('admin', ['app', 'create', '--body-file', bodyFile], env as any, async () => new Response('{}', { status: 200 }))
149
+ const bodyFile = tempJSONFile({ name: 'CLI Verify Happy Path' })
150
+ await assert.rejects(async () => {
151
+ await executeCLI('admin', ['app', 'create', '--body-file', bodyFile], env as any, async () => new Response('{}', { status: 200 }))
152
152
  }, (error: unknown) => {
153
153
  assert.ok(error instanceof CLIError)
154
154
  assert.equal(error.code, 'BODY_VALIDATION_FAILED')
155
155
  assert.ok((error.details?.issues ?? []).some((item) => item.path === 'body.name'))
156
156
  return true
157
+ })
158
+ })
159
+
160
+ test('app create-from-bundle-url posts url body and returns task id', async () => {
161
+ const calls: Array<{ url: string; method?: string; body?: string }> = []
162
+ const out = await executeCLI(
163
+ 'admin',
164
+ ['app', 'create-from-bundle-url', '--url', 'https://cdn.example.com/app-bundles/2026/06/17/a.zip', '--name', 'Demo'],
165
+ env as any,
166
+ async (url, init) => {
167
+ calls.push({ url: String(url), method: init?.method, body: String(init?.body ?? '') })
168
+ return new Response(JSON.stringify({ data: { task_id: 'task-1' } }), { status: 200 })
169
+ },
170
+ )
171
+ assert.equal(calls[0].method, 'POST')
172
+ assert.match(calls[0].url, /\/api\/apps\/import-task$/)
173
+ assert.deepEqual(JSON.parse(calls[0].body ?? '{}'), {
174
+ url: 'https://cdn.example.com/app-bundles/2026/06/17/a.zip',
175
+ name: 'Demo',
176
+ })
177
+ assert.equal((out as any).data.data.task_id, 'task-1')
178
+ assert.deepEqual((out as any).nextAction, {
179
+ command: 'app watch-import-task',
180
+ args: ['--task-id', 'task-1', '--ttl-seconds', '300'],
181
+ hint: 'run this command until the task returns status=finished and app_id > 0; task_id alone is not success',
182
+ })
183
+ })
184
+
185
+ test('app create --url points to bundle import flow', async () => {
186
+ await assert.rejects(async () => {
187
+ await executeCLI(
188
+ 'admin',
189
+ ['app', 'create', '--url', 'https://cdn.example.com/app-bundles/2026/06/17/a.zip', '--name', 'Demo'],
190
+ env as any,
191
+ async () => new Response('{}', { status: 200 }),
192
+ )
193
+ }, (error: unknown) => {
194
+ assert.ok(error instanceof CLIError)
195
+ assert.equal(error.code, 'UNKNOWN_FLAG')
196
+ assert.equal(error.details?.hint, 'app bundle URLs must use app create-from-bundle-url; do not retry app create')
197
+ assert.deepEqual(error.details?.commonMistakes, [
198
+ 'do not use app create for app bundle URLs',
199
+ 'do not put url inside app create --body-json',
200
+ ])
201
+ assert.deepEqual(error.details?.suggestions, [
202
+ 'retry with: app create-from-bundle-url --url <public_http_zip_url> --name <app_name>',
203
+ 'then watch with: app watch-import-task --task-id <task_id> --ttl-seconds 300',
204
+ ])
205
+ return true
206
+ })
207
+ })
208
+
209
+ test('app create-from-bundle-url rejects unsupported url scheme before request', async () => {
210
+ let called = false
211
+ await assert.rejects(async () => {
212
+ await executeCLI(
213
+ 'admin',
214
+ ['app', 'create-from-bundle-url', '--url', 'file:///tmp/app.zip', '--name', 'Demo'],
215
+ env as any,
216
+ async () => {
217
+ called = true
218
+ return new Response('{}', { status: 200 })
219
+ },
220
+ )
221
+ }, (error: unknown) => {
222
+ assert.ok(error instanceof CLIError)
223
+ assert.equal(error.code, 'INVALID_IMPORT_URL')
224
+ assert.equal(called, false)
225
+ return true
226
+ })
227
+ })
228
+
229
+ test('app watch-import-task polls until finished', async () => {
230
+ const statuses = [
231
+ { data: { task_id: 'task-1', status: 'processing' } },
232
+ { data: { task_id: 'task-1', status: 'finished', app_id: 2188889999 } },
233
+ ]
234
+ const calls: string[] = []
235
+ const out = await executeCLI(
236
+ 'admin',
237
+ ['app', 'watch-import-task', '--task-id', 'task-1', '--ttl-seconds', '1'],
238
+ { ...env, ARCUBASE_IMPORT_TASK_WATCH_INTERVAL_MS: '1' } as any,
239
+ async (url) => {
240
+ calls.push(String(url))
241
+ return new Response(JSON.stringify(statuses.shift()), { status: 200 })
242
+ },
243
+ )
244
+ assert.equal(calls.length, 2)
245
+ assert.equal((out as any).data.data.status, 'finished')
246
+ })
247
+
248
+ test('app watch-import-task rejects finished status without app id', async () => {
249
+ await assert.rejects(async () => {
250
+ await executeCLI(
251
+ 'admin',
252
+ ['app', 'watch-import-task', '--task-id', 'task-1', '--ttl-seconds', '1'],
253
+ { ...env, ARCUBASE_IMPORT_TASK_WATCH_INTERVAL_MS: '1' } as any,
254
+ async () => new Response(JSON.stringify({ data: { task_id: 'task-1', status: 'finished' } }), { status: 200 }),
255
+ )
256
+ }, (error: unknown) => {
257
+ assert.ok(error instanceof CLIError)
258
+ assert.equal(error.code, 'IMPORT_TASK_APP_ID_MISSING')
259
+ return true
260
+ })
261
+ })
262
+
263
+ test('row import-csv validates mapping, uploads local file, starts import, and watches task', async () => {
264
+ const csvFile = tempJSONFile('ID,客户名称\n12,Alice\n').replace(/payload\.json$/, 'records.csv')
265
+ fs.writeFileSync(csvFile, 'ID,客户名称\n12,Alice\n')
266
+ const mappingFile = tempJSONFile({
267
+ mode: 'addorupdate',
268
+ header: true,
269
+ dataStartRow: 2,
270
+ recordId: { header: 'ID' },
271
+ fields: [{ fieldId: 1001, header: '客户名称' }],
157
272
  })
273
+ const calls: Array<{ url: string; method?: string; body?: string; isForm?: boolean }> = []
274
+ const out = await executeCLI(
275
+ 'user',
276
+ ['row', 'import-csv', '--app-id', 'app_1', '--table-id', 'table_1', '--file', csvFile, '--mapping-file', mappingFile, '--ttl-seconds', '1'],
277
+ { ...env, ARCUBASE_IMPORT_TASK_WATCH_INTERVAL_MS: '1' } as any,
278
+ async (url, init) => {
279
+ calls.push({
280
+ url: String(url),
281
+ method: init?.method,
282
+ body: typeof init?.body === 'string' ? init.body : undefined,
283
+ isForm: init?.body instanceof FormData,
284
+ })
285
+ if (String(url).endsWith('/api/entity/app_1/table_1/import') && init?.method === 'POST') {
286
+ const body = JSON.parse(String(init.body))
287
+ if (body._import_action === 'init-token') {
288
+ return new Response(JSON.stringify({ data: { taskId: 'task-1', token: { id: 123, mode: 's3', token_data: { host: 'https://uploads.example.com', dir: 'upload/123/', policy: 'p', signature: 's', accessid: 'a' } } } }), { status: 200 })
289
+ }
290
+ if (body._import_action === 'uploaded') {
291
+ return new Response(JSON.stringify({ data: 'task-1' }), { status: 200 })
292
+ }
293
+ if (body._import_action === 'do-process') {
294
+ assert.deepEqual(body.config, {
295
+ fileFormat: 'csv',
296
+ importMode: 'addorupdate',
297
+ titleRowIndex: 0,
298
+ csvDelimiter: ',',
299
+ mapper: { '$id': 0, '1001': 1 },
300
+ })
301
+ return new Response(JSON.stringify({ data: { taskId: 'task-1', status: 'processing', state: {} } }), { status: 200 })
302
+ }
303
+ if (body._import_action === 'get-state') {
304
+ return new Response(JSON.stringify({ data: { taskId: 'task-1', status: 'finished', state: { state: { SuccessNum: 1 } } } }), { status: 200 })
305
+ }
306
+ }
307
+ if (String(url) === 'https://uploads.example.com') {
308
+ return new Response(null, { status: 204 })
309
+ }
310
+ throw new Error(`unexpected fetch ${url}`)
311
+ },
312
+ )
313
+ assert.equal(out.kind, 'result')
314
+ assert.equal(out.data.data.status, 'finished')
315
+ assert.ok(calls.some((call) => call.isForm), 'expected local file upload form request')
316
+ })
317
+
318
+ test('row import-csv missing header fails before fetch', async () => {
319
+ const csvFile = tempJSONFile('Name\nAlice\n').replace(/payload\.json$/, 'records.csv')
320
+ fs.writeFileSync(csvFile, 'Name\nAlice\n')
321
+ const mappingFile = tempJSONFile({
322
+ mode: 'addonly',
323
+ header: true,
324
+ dataStartRow: 2,
325
+ fields: [{ fieldId: 1001, header: 'Missing' }],
326
+ })
327
+ let called = false
328
+ await assert.rejects(async () => {
329
+ await executeCLI('user', ['row', 'import-csv', '--app-id', 'app_1', '--table-id', 'table_1', '--file', csvFile, '--mapping-file', mappingFile], env as any, async () => {
330
+ called = true
331
+ return new Response('{}')
332
+ })
333
+ }, (error: unknown) => {
334
+ assert.ok(error instanceof CLIError)
335
+ assert.equal(error.code, 'INVALID_IMPORT_MAPPING')
336
+ return true
337
+ })
338
+ assert.equal(called, false)
158
339
  })
159
340
 
160
341
  test('table create rejects empty schema body before request', async () => {
@@ -281,6 +462,38 @@ test('body json parse errors include command docs and type hints', async () => {
281
462
  })
282
463
  })
283
464
 
465
+ test('access-rule invalid json returns copyable create retry examples', async () => {
466
+ await assert.rejects(async () => {
467
+ await executeCLI('admin', ['access-rule', 'create', '--app-id', 'app_1', '--table-id', 'table_1', '--body-json', '{"name":"Admin full access","enabled":true,"type":"form","options":{"user_scope":[{"type":"user","id":2188889977}],"policy":{"key":"custom","actions":["view","add","edit","delete"],"all_fields_read":true,"all_fields_write":true},"list_options":{}}'], env as any, async () => new Response('{}', { status: 200 }))
468
+ }, (error: unknown) => {
469
+ assert.ok(error instanceof CLIError)
470
+ assert.equal(error.code, 'INVALID_BODY_JSON')
471
+ assert.equal(error.details?.requestType, 'AppIngressCreateReqVO')
472
+ assert.match(error.message, /retryToolCalls/)
473
+ const toolCall = (error.details?.retryToolCalls ?? [])[0] as any
474
+ assert.ok(toolCall)
475
+ assert.equal(toolCall.command, 'access-rule create')
476
+ assert.deepEqual(toolCall.args.slice(0, 5), ['--app-id', '<app_id>', '--table-id', '<table_id>', '--body-json'])
477
+ const body = JSON.parse(toolCall.args[5])
478
+ assert.deepEqual(body, {
479
+ name: 'Admin full access',
480
+ enabled: true,
481
+ type: 'form',
482
+ options: {
483
+ user_scope: [{ type: 'user', id: 2188889977 }],
484
+ policy: {
485
+ key: 'custom',
486
+ actions: ['view', 'add', 'edit', 'delete'],
487
+ all_fields_read: true,
488
+ all_fields_write: true,
489
+ },
490
+ list_options: {},
491
+ },
492
+ })
493
+ return true
494
+ })
495
+ })
496
+
284
497
  test('access-rule list executes against external root path', async () => {
285
498
  const out = await executeCLI(
286
499
  'admin',
@@ -355,7 +568,7 @@ test('table update-schema rejects per-user permission patches', async () => {
355
568
  layout: [[1001], [1002]],
356
569
  fields: [
357
570
  { id: 1001, label: 'Title', type: 'text', required: true, unique: false, editable: true, visible: true, show_label: true, scannable: false, default_value_mode: 0, description: '', value: null, number_decimal: 0, depends: [], key: 'title', code_index: false, options: { type: 'text', placeholder: '', lengthLimit: false, lengthMin: 0, lengthMax: 255 }, transfers: null, children: null },
358
- { id: 1002, label: 'Description', type: 'textarea', required: false, unique: false, editable: true, visible: true, show_label: true, scannable: false, default_value_mode: 0, description: '', value: null, number_decimal: 0, depends: [], key: 'description', code_index: false, options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 }, transfers: null, children: null, permission: { user: { read: false } } },
571
+ { id: 1002, label: 'Description', type: 'textarea', required: false, unique: false, editable: true, visible: true, show_label: true, scannable: false, default_value_mode: 0, description: '', value: null, number_decimal: 0, depends: [], key: 'description', code_index: false, options: { placeholder: '', html: false, size: 6, lengthLimit: false, lengthMin: 0, lengthMax: 2000 }, transfers: null, children: null, permission: { user: { read: false } } },
359
572
  ],
360
573
  options: { Buttons: [], CustomActions: [], FrontendEvents: [], TitleFormat: { Fields: [], Parts: [], Body: '' }, misc: {} },
361
574
  workflow_enabled: false,
@@ -477,6 +690,43 @@ test('row get resolves internal policy_id as query parameter', async () => {
477
690
  assert.equal(calls[0], 'https://arcubase.example.com/api/entity/2188900691/2188900694/row/4000000046?policy_id=policy_hash_1')
478
691
  })
479
692
 
693
+ test('row get returns machine-readable row value evidence', async () => {
694
+ const out = await executeCLI(
695
+ 'user',
696
+ ['row', 'get', '--app-id', '2188900691', '--table-id', '2188900694', '--row-id', '4000000046'],
697
+ env as any,
698
+ withRowPolicyResolver('2188900694', ['view'], async () => new Response(JSON.stringify({
699
+ data: {
700
+ record: {
701
+ id: 4000000046,
702
+ fields: { 1001: '复测投票' },
703
+ },
704
+ fieldPermits: {
705
+ all_fields_read: false,
706
+ fields: [
707
+ { key: ':1001', read: true, write: true, report: false },
708
+ { key: ':1002', read: false, write: false, report: false },
709
+ { key: ':1003', read: true, write: true, report: false },
710
+ ],
711
+ },
712
+ },
713
+ }), { status: 200 })),
714
+ )
715
+ assert.deepEqual(out.rowValueEvidence, {
716
+ source: 'row get record.fields',
717
+ returnedFieldIds: ['1001'],
718
+ notReturnedFieldIds: ['1002', '1003'],
719
+ deniedReadFieldIds: ['1002'],
720
+ absentFieldMeaning: 'not returned to current user; no row-value evidence',
721
+ rules: [
722
+ 'current row values come only from row query fields or row get record.fields',
723
+ 'table schema fields[].value and default_value_mode are schema defaults, not returned row values',
724
+ 'a field id absent from returned row fields has no row-value evidence',
725
+ 'read visibility status is fieldPermits read:false; FIELD_PERMISSION_REQUIRED is only a write preflight error',
726
+ ],
727
+ })
728
+ })
729
+
480
730
  test('row command rejects user-provided policy_id', async () => {
481
731
  const bodyFile = tempJSONFile({ fields: { 1001: '今天吃什么' }, policy_id: 'manual_policy' })
482
732
  await assert.rejects(async () => {
@@ -701,6 +951,45 @@ test('row query rejects camelCase viewMode and points to row crud doc', async ()
701
951
  })
702
952
  })
703
953
 
954
+ test('row query rejects invalid search shape through EntityQueryReqVO zod schema', async () => {
955
+ for (const search of [
956
+ { conditions: [{ field_id: 1002, op: 'eq', value: 'member_1' }] },
957
+ { field_filters: [{ field_id: 1002, operator: 'eq', value: 'member_1' }] },
958
+ { q: ['SO-1001'] },
959
+ ]) {
960
+ const bodyFile = tempJSONFile({ limit: 20, offset: 0, search })
961
+ let dataQueryFetchCount = 0
962
+ await assert.rejects(async () => {
963
+ await executeCLI(
964
+ 'user',
965
+ ['row', 'query', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
966
+ env as any,
967
+ withRowPolicyResolver('table_1', ['view'], async () => {
968
+ dataQueryFetchCount += 1
969
+ return new Response('{}', { status: 200 })
970
+ }),
971
+ )
972
+ }, (error: unknown) => {
973
+ assert.ok(error instanceof CLIError)
974
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
975
+ assert.equal(error.details?.requestType, 'EntityQueryReqVO')
976
+ assert.ok((error.details?.issues ?? []).some((item) => item.path.startsWith('body.search.')))
977
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntityQueryReqVO' && item.file === '/runtime/arcubase-sdk/types/user-action.ts'))
978
+ assert.deepEqual(error.details?.shapeHint, {
979
+ limit: 20,
980
+ offset: 0,
981
+ search: { q: 'SO-1001' },
982
+ sorts: [{ column: ':1001', order: 'asc' }],
983
+ })
984
+ assert.ok(error.details?.commonMistakes?.some((item) => item.includes('string route-query keys')))
985
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('"search":{"q":"SO-1001"}')))
986
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('"filter_:1002"')))
987
+ return true
988
+ })
989
+ assert.equal(dataQueryFetchCount, 0)
990
+ }
991
+ })
992
+
704
993
  test('row query unknown limit flag gives body-json retry path', async () => {
705
994
  await assert.rejects(async () => {
706
995
  await executeCLI('user', ['row', 'query', '--app-id', '2188900691', '--table-id', '2188900694', '--limit', '1', '--offset', '0'], env as any, async () => new Response('{}', { status: 200 }))
@@ -708,7 +997,12 @@ test('row query unknown limit flag gives body-json retry path', async () => {
708
997
  assert.ok(error instanceof CLIError)
709
998
  assert.equal(error.code, 'UNKNOWN_FLAG')
710
999
  assert.equal(error.details?.requestType, 'EntityQueryReqVO')
711
- assert.deepEqual(error.details?.shapeHint, { limit: 20, offset: 0, search: { text_search: 'SO-1001' } })
1000
+ assert.deepEqual(error.details?.shapeHint, {
1001
+ limit: 20,
1002
+ offset: 0,
1003
+ search: { q: 'SO-1001' },
1004
+ sorts: [{ column: ':1001', order: 'asc' }],
1005
+ })
712
1006
  assert.ok(error.details?.suggestions?.some((item) => item.includes('--body-json')))
713
1007
  return true
714
1008
  })
@@ -66,6 +66,20 @@ test('user row help does not expose internal policy id input', async () => {
66
66
  assert.doesNotMatch(out.text, /policy_id/)
67
67
  })
68
68
 
69
+ test('row import help explains mapping formats and local files', async () => {
70
+ const excel = await executeCLI('user', ['row', 'import-excel', '--help'], env as any, async () => new Response('{}'))
71
+ assert.equal(excel.kind, 'help')
72
+ assert.match(excel.text, /mapping-file/)
73
+ assert.match(excel.text, /Excel columns use letters/)
74
+ assert.match(excel.text, /local file/)
75
+
76
+ const csv = await executeCLI('user', ['row', 'import-csv', '--help'], env as any, async () => new Response('{}'))
77
+ assert.equal(csv.kind, 'help')
78
+ assert.match(csv.text, /CSV mapping uses headers/)
79
+ assert.match(csv.text, /does not support embedded images/)
80
+ assert.match(csv.text, /arcubase-admin table get/)
81
+ })
82
+
69
83
  test('admin table update-schema help uses table nouns and body-json', async () => {
70
84
  const out = await executeCLI('admin', ['table', 'update-schema', '--help'], env as any, async () => new Response('{}'))
71
85
  assert.equal(out.kind, 'help')
@@ -90,6 +104,8 @@ test('admin access-rule help gives update whitelist and assign-users body-json e
90
104
  assert.equal(create.kind, 'help')
91
105
  assert.match(create.text, /Access rule/)
92
106
  assert.match(create.text, /access-rule\.md/)
107
+ assert.match(create.text, /full access/)
108
+ assert.match(create.text, /"actions":\["view","add","edit","delete"\]/)
93
109
  assert.match(create.text, /"user_scope":\[\{"type":"user","id":2188889901\}\]/)
94
110
  assert.doesNotMatch(create.text, /"user_scope":\[\{"type":"member"/)
95
111
 
@@ -0,0 +1,47 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'fs'
4
+ import os from 'os'
5
+ import path from 'path'
6
+ import { uploadLocalFileWithToken } from '../runtime/local_upload.js'
7
+ import { CLIError } from '../runtime/errors.js'
8
+
9
+ function tempFile(name: string, content = 'hello'): string {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'arcubase-upload-'))
11
+ const file = path.join(dir, name)
12
+ fs.writeFileSync(file, content)
13
+ return file
14
+ }
15
+
16
+ test('uploadLocalFileWithToken uploads aliyun-compatible post policy', async () => {
17
+ const file = tempFile('records.csv')
18
+ const calls: Array<{ url: string; method?: string; body: any }> = []
19
+ await uploadLocalFileWithToken(file, {
20
+ mode: 's3',
21
+ token_data: {
22
+ host: 'https://uploads.example.com',
23
+ dir: 'upload/123/',
24
+ policy: 'policy',
25
+ signature: 'signature',
26
+ accessid: 'access',
27
+ expire: 123,
28
+ },
29
+ }, async (url, init) => {
30
+ calls.push({ url: String(url), method: init?.method, body: init?.body })
31
+ return new Response(null, { status: 204 })
32
+ })
33
+ assert.equal(calls[0].url, 'https://uploads.example.com')
34
+ assert.equal(calls[0].method, 'POST')
35
+ assert.ok(calls[0].body instanceof FormData)
36
+ })
37
+
38
+ test('uploadLocalFileWithToken rejects unsupported token shape', async () => {
39
+ const file = tempFile('records.csv')
40
+ await assert.rejects(async () => {
41
+ await uploadLocalFileWithToken(file, { mode: 'unknown', token_data: {} }, async () => new Response(''))
42
+ }, (error: unknown) => {
43
+ assert.ok(error instanceof CLIError)
44
+ assert.equal(error.code, 'UPLOAD_TOKEN_UNSUPPORTED')
45
+ return true
46
+ })
47
+ })
@@ -14,6 +14,33 @@ test('EntityQueryReqVO schema exists', () => {
14
14
  assert.ok(getBodySchema('EntityQueryReqVO'))
15
15
  })
16
16
 
17
+ test('EntityQueryReqVO search only accepts route-query string keys', () => {
18
+ const schema = getBodySchema('EntityQueryReqVO')
19
+ assert.ok(schema)
20
+
21
+ assert.equal(schema.safeParse({
22
+ limit: 100,
23
+ offset: 0,
24
+ search: {
25
+ q: 'SO-1001',
26
+ tab: 'all',
27
+ 'filter_:1002': 'eq:member_1',
28
+ },
29
+ }).success, true)
30
+
31
+ for (const search of [
32
+ { conditions: [{ field_id: 1002, op: 'eq', value: 'member_1' }] },
33
+ { condition: { field_id: 1002, op: 'eq', value: 'member_1' } },
34
+ { field_filters: [{ field_id: 1002, operator: 'eq', value: 'member_1' }] },
35
+ { filter: { field_id: 1002, value: 'member_1' } },
36
+ { order: [{ column: ':1001', order: 'asc' }] },
37
+ { q: ['SO-1001'] },
38
+ { 'filter_:1002': { op: 'eq', value: 'member_1' } },
39
+ ]) {
40
+ assert.equal(schema.safeParse({ limit: 100, offset: 0, search }).success, false)
41
+ }
42
+ })
43
+
17
44
  test('unknown request type has no schema and no unsupported reason', () => {
18
45
  assert.equal(getBodySchema('DefinitelyMissingReqVO'), null)
19
46
  assert.equal(getUnsupportedBodySchemaReason('DefinitelyMissingReqVO'), null)