@carthooks/arcubase-cli 0.1.13 → 0.1.16

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 (41) hide show
  1. package/bundle/arcubase-admin.mjs +14934 -3808
  2. package/bundle/arcubase.mjs +14934 -3808
  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/execute.d.ts.map +1 -1
  13. package/dist/runtime/execute.js +309 -5
  14. package/dist/runtime/local_upload.d.ts +10 -0
  15. package/dist/runtime/local_upload.d.ts.map +1 -0
  16. package/dist/runtime/local_upload.js +73 -0
  17. package/dist/tests/entity_save_schema.test.js +38 -0
  18. package/dist/tests/execute_validation.test.js +2 -2
  19. package/package.json +7 -7
  20. package/sdk-dist/docs/runtime-reference/README.md +6 -0
  21. package/sdk-dist/docs/runtime-reference/entity-schema/textarea.md +2 -2
  22. package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.query.json +1 -1
  23. package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.query.json +1 -1
  24. package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.query.json +1 -1
  25. package/sdk-dist/docs/runtime-reference/row-crud.md +55 -0
  26. package/sdk-dist/docs/runtime-reference/search-and-bulk-actions.md +26 -1
  27. package/sdk-dist/docs/runtime-reference/table-lifecycle.md +1 -1
  28. package/sdk-dist/generated/command_registry.generated.ts +145 -0
  29. package/sdk-dist/types/common.ts +1 -0
  30. package/src/generated/command_registry.generated.ts +145 -0
  31. package/src/runtime/entity_import_mapping.ts +172 -0
  32. package/src/runtime/entity_save_schema.ts +27 -3
  33. package/src/runtime/execute.ts +324 -6
  34. package/src/runtime/local_upload.ts +84 -0
  35. package/src/tests/command_registry.test.ts +4 -0
  36. package/src/tests/entity_import_mapping.test.ts +87 -0
  37. package/src/tests/entity_save_schema.test.ts +40 -0
  38. package/src/tests/execute_validation.test.ts +231 -5
  39. package/src/tests/help.test.ts +14 -0
  40. package/src/tests/local_upload.test.ts +47 -0
  41. package/src/tests/zod_registry.test.ts +27 -0
@@ -146,15 +146,197 @@ 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, name: '企业基础业务数据应用' } },
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
+ assert.equal((out as any).data.data.name, '企业基础业务数据应用')
247
+ })
248
+
249
+ test('app watch-import-task rejects finished status without app id', async () => {
250
+ await assert.rejects(async () => {
251
+ await executeCLI(
252
+ 'admin',
253
+ ['app', 'watch-import-task', '--task-id', 'task-1', '--ttl-seconds', '1'],
254
+ { ...env, ARCUBASE_IMPORT_TASK_WATCH_INTERVAL_MS: '1' } as any,
255
+ async () => new Response(JSON.stringify({ data: { task_id: 'task-1', status: 'finished' } }), { status: 200 }),
256
+ )
257
+ }, (error: unknown) => {
258
+ assert.ok(error instanceof CLIError)
259
+ assert.equal(error.code, 'IMPORT_TASK_APP_ID_MISSING')
260
+ return true
261
+ })
262
+ })
263
+
264
+ test('row import-csv validates mapping, uploads local file, starts import, and watches task', async () => {
265
+ const csvFile = tempJSONFile('ID,客户名称\n12,Alice\n').replace(/payload\.json$/, 'records.csv')
266
+ fs.writeFileSync(csvFile, 'ID,客户名称\n12,Alice\n')
267
+ const mappingFile = tempJSONFile({
268
+ mode: 'addorupdate',
269
+ header: true,
270
+ dataStartRow: 2,
271
+ recordId: { header: 'ID' },
272
+ fields: [{ fieldId: 1001, header: '客户名称' }],
157
273
  })
274
+ const calls: Array<{ url: string; method?: string; body?: string; isForm?: boolean }> = []
275
+ const out = await executeCLI(
276
+ 'user',
277
+ ['row', 'import-csv', '--app-id', 'app_1', '--table-id', 'table_1', '--file', csvFile, '--mapping-file', mappingFile, '--ttl-seconds', '1'],
278
+ { ...env, ARCUBASE_IMPORT_TASK_WATCH_INTERVAL_MS: '1' } as any,
279
+ async (url, init) => {
280
+ calls.push({
281
+ url: String(url),
282
+ method: init?.method,
283
+ body: typeof init?.body === 'string' ? init.body : undefined,
284
+ isForm: init?.body instanceof FormData,
285
+ })
286
+ if (String(url).endsWith('/api/entity/app_1/table_1/import') && init?.method === 'POST') {
287
+ const body = JSON.parse(String(init.body))
288
+ if (body._import_action === 'init-token') {
289
+ 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 })
290
+ }
291
+ if (body._import_action === 'uploaded') {
292
+ return new Response(JSON.stringify({ data: 'task-1' }), { status: 200 })
293
+ }
294
+ if (body._import_action === 'do-process') {
295
+ assert.deepEqual(body.config, {
296
+ fileFormat: 'csv',
297
+ importMode: 'addorupdate',
298
+ titleRowIndex: 0,
299
+ csvDelimiter: ',',
300
+ mapper: { '$id': 0, '1001': 1 },
301
+ })
302
+ return new Response(JSON.stringify({ data: { taskId: 'task-1', status: 'processing', state: {} } }), { status: 200 })
303
+ }
304
+ if (body._import_action === 'get-state') {
305
+ return new Response(JSON.stringify({ data: { taskId: 'task-1', status: 'finished', state: { state: { SuccessNum: 1 } } } }), { status: 200 })
306
+ }
307
+ }
308
+ if (String(url) === 'https://uploads.example.com') {
309
+ return new Response(null, { status: 204 })
310
+ }
311
+ throw new Error(`unexpected fetch ${url}`)
312
+ },
313
+ )
314
+ assert.equal(out.kind, 'result')
315
+ assert.equal(out.data.data.status, 'finished')
316
+ assert.ok(calls.some((call) => call.isForm), 'expected local file upload form request')
317
+ })
318
+
319
+ test('row import-csv missing header fails before fetch', async () => {
320
+ const csvFile = tempJSONFile('Name\nAlice\n').replace(/payload\.json$/, 'records.csv')
321
+ fs.writeFileSync(csvFile, 'Name\nAlice\n')
322
+ const mappingFile = tempJSONFile({
323
+ mode: 'addonly',
324
+ header: true,
325
+ dataStartRow: 2,
326
+ fields: [{ fieldId: 1001, header: 'Missing' }],
327
+ })
328
+ let called = false
329
+ await assert.rejects(async () => {
330
+ await executeCLI('user', ['row', 'import-csv', '--app-id', 'app_1', '--table-id', 'table_1', '--file', csvFile, '--mapping-file', mappingFile], env as any, async () => {
331
+ called = true
332
+ return new Response('{}')
333
+ })
334
+ }, (error: unknown) => {
335
+ assert.ok(error instanceof CLIError)
336
+ assert.equal(error.code, 'INVALID_IMPORT_MAPPING')
337
+ return true
338
+ })
339
+ assert.equal(called, false)
158
340
  })
159
341
 
160
342
  test('table create rejects empty schema body before request', async () => {
@@ -387,7 +569,7 @@ test('table update-schema rejects per-user permission patches', async () => {
387
569
  layout: [[1001], [1002]],
388
570
  fields: [
389
571
  { 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 },
390
- { 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 } } },
572
+ { 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 } } },
391
573
  ],
392
574
  options: { Buttons: [], CustomActions: [], FrontendEvents: [], TitleFormat: { Fields: [], Parts: [], Body: '' }, misc: {} },
393
575
  workflow_enabled: false,
@@ -770,6 +952,45 @@ test('row query rejects camelCase viewMode and points to row crud doc', async ()
770
952
  })
771
953
  })
772
954
 
955
+ test('row query rejects invalid search shape through EntityQueryReqVO zod schema', async () => {
956
+ for (const search of [
957
+ { conditions: [{ field_id: 1002, op: 'eq', value: 'member_1' }] },
958
+ { field_filters: [{ field_id: 1002, operator: 'eq', value: 'member_1' }] },
959
+ { q: ['SO-1001'] },
960
+ ]) {
961
+ const bodyFile = tempJSONFile({ limit: 20, offset: 0, search })
962
+ let dataQueryFetchCount = 0
963
+ await assert.rejects(async () => {
964
+ await executeCLI(
965
+ 'user',
966
+ ['row', 'query', '--app-id', 'app_1', '--table-id', 'table_1', '--body-file', bodyFile],
967
+ env as any,
968
+ withRowPolicyResolver('table_1', ['view'], async () => {
969
+ dataQueryFetchCount += 1
970
+ return new Response('{}', { status: 200 })
971
+ }),
972
+ )
973
+ }, (error: unknown) => {
974
+ assert.ok(error instanceof CLIError)
975
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
976
+ assert.equal(error.details?.requestType, 'EntityQueryReqVO')
977
+ assert.ok((error.details?.issues ?? []).some((item) => item.path.startsWith('body.search.')))
978
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntityQueryReqVO' && item.file === '/runtime/arcubase-sdk/types/user-action.ts'))
979
+ assert.deepEqual(error.details?.shapeHint, {
980
+ limit: 20,
981
+ offset: 0,
982
+ search: { q: 'SO-1001' },
983
+ sorts: [{ column: ':1001', order: 'asc' }],
984
+ })
985
+ assert.ok(error.details?.commonMistakes?.some((item) => item.includes('string route-query keys')))
986
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('"search":{"q":"SO-1001"}')))
987
+ assert.ok(error.details?.suggestions?.some((item) => item.includes('"filter_:1002"')))
988
+ return true
989
+ })
990
+ assert.equal(dataQueryFetchCount, 0)
991
+ }
992
+ })
993
+
773
994
  test('row query unknown limit flag gives body-json retry path', async () => {
774
995
  await assert.rejects(async () => {
775
996
  await executeCLI('user', ['row', 'query', '--app-id', '2188900691', '--table-id', '2188900694', '--limit', '1', '--offset', '0'], env as any, async () => new Response('{}', { status: 200 }))
@@ -777,7 +998,12 @@ test('row query unknown limit flag gives body-json retry path', async () => {
777
998
  assert.ok(error instanceof CLIError)
778
999
  assert.equal(error.code, 'UNKNOWN_FLAG')
779
1000
  assert.equal(error.details?.requestType, 'EntityQueryReqVO')
780
- assert.deepEqual(error.details?.shapeHint, { limit: 20, offset: 0, search: { text_search: 'SO-1001' } })
1001
+ assert.deepEqual(error.details?.shapeHint, {
1002
+ limit: 20,
1003
+ offset: 0,
1004
+ search: { q: 'SO-1001' },
1005
+ sorts: [{ column: ':1001', order: 'asc' }],
1006
+ })
781
1007
  assert.ok(error.details?.suggestions?.some((item) => item.includes('--body-json')))
782
1008
  return true
783
1009
  })
@@ -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')
@@ -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)