@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
@@ -1,11 +1,15 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
1
3
  import { parseCLIArgs, hasFlag, flagValue } from './argv.js'
2
4
  import { loadRuntimeEnv, type RuntimeEnv } from './env.js'
3
5
  import { CLIError, type CLIErrorDetails } from './errors.js'
4
6
  import { buildRequestHeaders, buildURL, normalizeExternalEndpoint } from './http.js'
5
- import { listModules, listModuleCommands, findCommand, findCommandSuggestions, type CommandScope } from './command_registry.js'
7
+ import { listModules, listModuleCommands, findCommand, findCommandSuggestions, type CommandDef, type CommandScope } from './command_registry.js'
6
8
  import { loadBodyFromFlags, loadQueryFromFlags } from './body_loader.js'
7
9
  import { getBodySchema, getCommandDocHints, getDocHints, getTypeHints, getUnsupportedBodySchemaReason } from './zod_registry.js'
8
10
  import { resolveArcubaseSDKPath } from './paths.js'
11
+ import { compileCSVImportMapping, compileExcelImportMapping, type CompiledEntityImportConfig } from './entity_import_mapping.js'
12
+ import { uploadLocalFileWithToken } from './local_upload.js'
9
13
 
10
14
  export type CommandExecutionSummary = {
11
15
  scope: CommandScope
@@ -121,8 +125,9 @@ export function renderCommandHelp(scope: CommandScope, moduleName: string, comma
121
125
  : []),
122
126
  ...(scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'create'
123
127
  ? [
124
- 'body-json example:',
125
- ' - {"name":"Sales read write","enabled":true,"type":"form","options":{"user_scope":[{"type":"user","id":2188889901}],"policy":{"key":"custom","actions":["view","add","edit"],"all_fields_read":false,"all_fields_write":false,"fields":[{"key":":1001","read":true,"write":true,"report":false},{"key":":1002","read":false,"write":false,"report":false}]},"list_options":{}}}',
128
+ 'body-json examples:',
129
+ ` - full access: ${JSON.stringify(buildFullAccessRuleBody())}`,
130
+ ` - hide field: ${JSON.stringify(buildHiddenFieldAccessRuleBody())}`,
126
131
  'success requires:',
127
132
  ' - result.enabled must be true',
128
133
  ' - options.user_scope[].type is "user" for tenant users',
@@ -141,6 +146,48 @@ export function renderCommandHelp(scope: CommandScope, moduleName: string, comma
141
146
  ' - If result.enabled is false, enable the rule first and retry assign-users',
142
147
  ]
143
148
  : []),
149
+ ...(scope === 'admin' && command.commandPath[0] === 'app' && command.commandPath[1] === 'create-from-bundle-url'
150
+ ? [
151
+ 'flags:',
152
+ ' - --url must be a public http/https app bundle zip URL',
153
+ ' - --name optionally overrides the imported app name',
154
+ 'success:',
155
+ ' - returns task_id; use app watch-import-task to wait for completion',
156
+ ]
157
+ : []),
158
+ ...(scope === 'admin' && command.commandPath[0] === 'app' && command.commandPath[1] === 'watch-import-task'
159
+ ? [
160
+ 'flags:',
161
+ ' - --task-id is returned by app create-from-bundle-url',
162
+ ' - --ttl-seconds is a local wait limit and does not cancel the backend task',
163
+ 'success:',
164
+ ' - returns finished task status with app_id and warnings',
165
+ ]
166
+ : []),
167
+ ...(scope === 'user' && command.commandPath[0] === 'row' && command.commandPath[1] === 'import-excel'
168
+ ? [
169
+ 'mapping-file example:',
170
+ ' - {"sheet":"Sheet1","mode":"addonly","headerRow":1,"dataStartRow":2,"fields":[{"fieldId":1001,"column":"A"}]}',
171
+ 'rules:',
172
+ ' - import supports local file paths only',
173
+ ' - --file must be a local Excel file path',
174
+ ' - Excel columns use letters such as A, B, AA',
175
+ ' - row numbers are 1-based',
176
+ ' - fieldId must be numeric; use arcubase-admin table get --app-id <app_id> --table-id <table_id>',
177
+ ]
178
+ : []),
179
+ ...(scope === 'user' && command.commandPath[0] === 'row' && command.commandPath[1] === 'import-csv'
180
+ ? [
181
+ 'mapping-file example:',
182
+ ' - {"mode":"addonly","header":true,"dataStartRow":2,"fields":[{"fieldId":1001,"header":"客户名称"}]}',
183
+ 'rules:',
184
+ ' - import supports local file paths only',
185
+ ' - --file must be a local CSV file path',
186
+ ' - CSV mapping uses headers, not column indexes',
187
+ ' - CSV import does not support embedded images',
188
+ ' - fieldId must be numeric; use arcubase-admin table get --app-id <app_id> --table-id <table_id>',
189
+ ]
190
+ : []),
144
191
  ...(docHints.length > 0
145
192
  ? ['docs:', ...docHints.map((item) => ` - ${item.title}: ${item.file}`)]
146
193
  : []),
@@ -231,6 +278,18 @@ function buildUnknownFlagDetails(
231
278
  operation,
232
279
  hint: `run ${operation} --help for the supported flags`,
233
280
  }
281
+ if (command.commandPath[0] === 'app' && command.commandPath[1] === 'create' && flag === 'url') {
282
+ details.hint = 'app bundle URLs must use app create-from-bundle-url; do not retry app create'
283
+ details.commonMistakes = [
284
+ 'do not use app create for app bundle URLs',
285
+ 'do not put url inside app create --body-json',
286
+ ]
287
+ details.suggestions = [
288
+ 'retry with: app create-from-bundle-url --url <public_http_zip_url> --name <app_name>',
289
+ 'then watch with: app watch-import-task --task-id <task_id> --ttl-seconds 300',
290
+ ]
291
+ return details
292
+ }
234
293
  if (command.requestType) {
235
294
  details.requestType = command.requestType
236
295
  details.tsHints = getTypeHints(command.requestType, env)
@@ -242,8 +301,7 @@ function buildUnknownFlagDetails(
242
301
  details.suggestions = [`retry with: ${command.commandPath.join(' ')} ${command.pathParams.filter((item) => !getFixedValue(item)).map((item) => `--${item.flag} <${item.flag.replace(/-/g, '_')}>`).join(' ')} --body-json '<json>'`.replace(/\s+/g, ' ').trim()]
243
302
  }
244
303
  if (command.commandPath[0] === 'row' && command.commandPath[1] === 'query' && ['limit', 'offset', 'search', 'sorts', 'view-mode'].includes(flag)) {
245
- details.shapeHint = { limit: 20, offset: 0, search: { text_search: 'SO-1001' } }
246
- details.suggestions = ['retry with: row query --app-id <app_id> --table-id <table_id> --body-json \'{"limit":20,"offset":0}\'']
304
+ Object.assign(details, buildBodyGuidance('EntityQueryReqVO'))
247
305
  }
248
306
  if (command.commandPath[0] === 'table' && command.commandPath[1] === 'dataset' && ['app-id', 'table-id'].includes(flag)) {
249
307
  details.hint = 'table dataset uses --dataset-id and --node-id; use table get for app/table metadata'
@@ -536,7 +594,7 @@ function buildTableCreateExampleBody() {
536
594
  depends: [],
537
595
  key: 'vote_description',
538
596
  code_index: false,
539
- options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
597
+ options: { placeholder: '', html: false, size: 6, lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
540
598
  transfers: null,
541
599
  children: null,
542
600
  },
@@ -554,6 +612,10 @@ function isTableCreateCommand(scope: CommandScope, command: NonNullable<ReturnTy
554
612
  return scope === 'admin' && command.commandPath[0] === 'table' && command.commandPath[1] === 'create'
555
613
  }
556
614
 
615
+ function isEntityRecordImportCommand(scope: CommandScope, command: CommandDef): boolean {
616
+ return scope === 'user' && command.commandPath[0] === 'row' && (command.commandPath[1] === 'import-excel' || command.commandPath[1] === 'import-csv')
617
+ }
618
+
557
619
  function isTableSchemaCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
558
620
  return scope === 'admin' && command.commandPath[0] === 'table' && (command.commandPath[1] === 'create' || command.commandPath[1] === 'update-schema')
559
621
  }
@@ -567,6 +629,28 @@ function isAccessRuleUpdateCommand(scope: CommandScope, command: NonNullable<Ret
567
629
  }
568
630
 
569
631
  function buildAccessRuleShapeHint(userId = 2188889901) {
632
+ return buildHiddenFieldAccessRuleBody(userId)
633
+ }
634
+
635
+ function buildFullAccessRuleBody(userId = 2188889977) {
636
+ return {
637
+ name: 'Admin full access',
638
+ enabled: true,
639
+ type: 'form',
640
+ options: {
641
+ user_scope: [{ type: 'user', id: userId }],
642
+ policy: {
643
+ key: 'custom',
644
+ actions: ['view', 'add', 'edit', 'delete'],
645
+ all_fields_read: true,
646
+ all_fields_write: true,
647
+ },
648
+ list_options: {},
649
+ },
650
+ }
651
+ }
652
+
653
+ function buildHiddenFieldAccessRuleBody(userId = 2188889901) {
570
654
  return {
571
655
  name: 'Sales read write',
572
656
  enabled: true,
@@ -585,6 +669,13 @@ function buildAccessRuleShapeHint(userId = 2188889901) {
585
669
  }
586
670
  }
587
671
 
672
+ function buildAccessRuleCreateToolCall(body: unknown) {
673
+ return {
674
+ command: 'access-rule create',
675
+ args: ['--app-id', '<app_id>', '--table-id', '<table_id>', '--body-json', JSON.stringify(body)],
676
+ }
677
+ }
678
+
588
679
  function buildAssignUsersValidationDetails(
589
680
  scope: CommandScope,
590
681
  command: NonNullable<ReturnType<typeof findCommand>>,
@@ -618,7 +709,7 @@ function buildAssignUsersValidationDetails(
618
709
  }
619
710
  }
620
711
 
621
- function buildBodyGuidance(requestType: string): Pick<CLIErrorDetails, 'shapeHint' | 'commonMistakes' | 'suggestions'> {
712
+ function buildBodyGuidance(requestType: string): Pick<CLIErrorDetails, 'retryToolCalls' | 'shapeHint' | 'commonMistakes' | 'suggestions'> {
622
713
  if (requestType === 'EntitySaveReqVO' || requestType === 'AppCreateEntityReqVO') {
623
714
  return {
624
715
  shapeHint: {
@@ -667,7 +758,7 @@ function buildBodyGuidance(requestType: string): Pick<CLIErrorDetails, 'shapeHin
667
758
  depends: [],
668
759
  key: 'description',
669
760
  code_index: false,
670
- options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
761
+ options: { placeholder: '', html: false, size: 6, lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
671
762
  transfers: null,
672
763
  children: null,
673
764
  },
@@ -730,7 +821,15 @@ function buildBodyGuidance(requestType: string): Pick<CLIErrorDetails, 'shapeHin
730
821
  }
731
822
  }
732
823
  if (requestType === 'AppIngressCreateReqVO' || requestType === 'AppIngressUpdateReqVO') {
824
+ const retryToolCalls = [
825
+ buildAccessRuleCreateToolCall(buildFullAccessRuleBody()),
826
+ buildAccessRuleCreateToolCall(buildHiddenFieldAccessRuleBody()),
827
+ ]
733
828
  return {
829
+ retryToolCalls,
830
+ suggestions: [
831
+ 'next arcubase-admin-cli input: retryToolCalls[0] for full access or retryToolCalls[1] for hidden-field access',
832
+ ],
734
833
  shapeHint: buildAccessRuleShapeHint(),
735
834
  commonMistakes: [
736
835
  'use options.policy, not top-level permissions',
@@ -769,15 +868,21 @@ function buildBodyGuidance(requestType: string): Pick<CLIErrorDetails, 'shapeHin
769
868
  shapeHint: {
770
869
  limit: 20,
771
870
  offset: 0,
772
- search: { text_search: 'SO-1001' },
871
+ search: { q: 'SO-1001' },
872
+ sorts: [{ column: ':1001', order: 'asc' }],
773
873
  },
774
874
  commonMistakes: [
775
875
  'limit, offset, search, sorts, and view_mode belong inside --body-json',
776
876
  'do not pass --limit or --offset flags',
877
+ 'do not use search.conditions, search.condition, search.field_filters, filter, filters, where, or order',
878
+ 'search only accepts string route-query keys such as q, tab, and filter_:1002',
879
+ 'sorts belongs at top level and uses column values like ":1001"; do not use order/orderBy',
777
880
  'do not add access policy fields; the CLI resolves access policy internally',
778
881
  ],
779
882
  suggestions: [
780
883
  'retry with: arcubase row query --app-id <app_id> --table-id <table_id> --body-json \'{"limit":20,"offset":0}\'',
884
+ 'retry text search with: arcubase row query --app-id <app_id> --table-id <table_id> --body-json \'{"limit":20,"offset":0,"search":{"q":"SO-1001"}}\'',
885
+ 'retry field filter with: arcubase row query --app-id <app_id> --table-id <table_id> --body-json \'{"limit":20,"offset":0,"search":{"filter_:1002":"%7B%22is%22%3A%22member_1%22%7D"}}\'',
781
886
  ],
782
887
  }
783
888
  }
@@ -1233,6 +1338,72 @@ function unwrapData(value: unknown): unknown {
1233
1338
  return current
1234
1339
  }
1235
1340
 
1341
+ function numericFieldKeys(fields: unknown): string[] {
1342
+ if (!isRecord(fields)) return []
1343
+ return Object.keys(fields).filter((key) => /^\d+$/.test(key)).sort((left, right) => Number(left) - Number(right))
1344
+ }
1345
+
1346
+ function permitFieldId(permit: unknown): string | undefined {
1347
+ if (!isRecord(permit) || typeof permit.key !== 'string') return undefined
1348
+ const match = /^:(\d+)$/.exec(permit.key)
1349
+ return match?.[1]
1350
+ }
1351
+
1352
+ function rowFieldPermits(value: unknown): Array<Record<string, unknown>> {
1353
+ const data = unwrapData(value)
1354
+ const fieldPermits = isRecord(data) && isRecord(data.fieldPermits) ? data.fieldPermits : undefined
1355
+ return fieldPermits && Array.isArray(fieldPermits.fields)
1356
+ ? fieldPermits.fields.filter((item): item is Record<string, unknown> => isRecord(item))
1357
+ : []
1358
+ }
1359
+
1360
+ function buildRowValueEvidence(command: NonNullable<ReturnType<typeof findCommand>>, responseBody: unknown): Record<string, unknown> | undefined {
1361
+ if (command.scope !== 'user' || command.commandPath[0] !== 'row') return undefined
1362
+ const action = command.commandPath[1]
1363
+ const data = unwrapData(responseBody)
1364
+ const rules = [
1365
+ 'current row values come only from row query fields or row get record.fields',
1366
+ 'table schema fields[].value and default_value_mode are schema defaults, not returned row values',
1367
+ 'a field id absent from returned row fields has no row-value evidence',
1368
+ 'read visibility status is fieldPermits read:false; FIELD_PERMISSION_REQUIRED is only a write preflight error',
1369
+ ]
1370
+ if (action === 'get') {
1371
+ const record = isRecord(data) && isRecord(data.record) ? data.record : undefined
1372
+ const returnedFieldIds = numericFieldKeys(record?.fields)
1373
+ const permits = rowFieldPermits(responseBody)
1374
+ const permitFieldIds = permits.map(permitFieldId).filter((id): id is string => Boolean(id))
1375
+ const deniedReadFieldIds = permits
1376
+ .filter((permit) => permit.read === false)
1377
+ .map(permitFieldId)
1378
+ .filter((id): id is string => Boolean(id))
1379
+ .sort((left, right) => Number(left) - Number(right))
1380
+ const notReturnedFieldIds = permitFieldIds
1381
+ .filter((id) => !returnedFieldIds.includes(id))
1382
+ .sort((left, right) => Number(left) - Number(right))
1383
+ return {
1384
+ source: 'row get record.fields',
1385
+ returnedFieldIds,
1386
+ notReturnedFieldIds,
1387
+ deniedReadFieldIds,
1388
+ absentFieldMeaning: 'not returned to current user; no row-value evidence',
1389
+ rules,
1390
+ }
1391
+ }
1392
+ if (action === 'query') {
1393
+ const items = isRecord(data) && Array.isArray(data.items) ? data.items : []
1394
+ return {
1395
+ source: 'row query items[].fields',
1396
+ rows: items.filter((item): item is Record<string, unknown> => isRecord(item)).map((item) => ({
1397
+ rowId: item.id,
1398
+ returnedFieldIds: numericFieldKeys(item.fields),
1399
+ })),
1400
+ absentFieldMeaning: 'not returned to current user; no row-value evidence',
1401
+ rules,
1402
+ }
1403
+ }
1404
+ return undefined
1405
+ }
1406
+
1236
1407
  function requiredRowAction(command: NonNullable<ReturnType<typeof findCommand>>, flags: Record<string, string | boolean>): string | undefined {
1237
1408
  if (command.commandPath[0] !== 'row') return undefined
1238
1409
  switch (command.commandPath[1]) {
@@ -1484,6 +1655,247 @@ async function requestJSON(
1484
1655
  return { status: response.status, data: parsedResponse }
1485
1656
  }
1486
1657
 
1658
+ function requiredStringFlag(flags: Record<string, string | boolean>, flag: string): string {
1659
+ const value = flagValue(flags, flag)
1660
+ if (!value) {
1661
+ throw new CLIError('MISSING_PATH_FLAG', `missing required flag --${flag}`, 2)
1662
+ }
1663
+ return value
1664
+ }
1665
+
1666
+ function validateAppBundleImportURL(rawURL: string): string {
1667
+ const value = rawURL.trim()
1668
+ let parsed: URL
1669
+ try {
1670
+ parsed = new URL(value)
1671
+ } catch {
1672
+ throw new CLIError('INVALID_IMPORT_URL', '--url must be a public http/https app bundle zip URL', 2, {
1673
+ hint: 'retry with --url https://.../app-bundles/...zip',
1674
+ })
1675
+ }
1676
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
1677
+ throw new CLIError('INVALID_IMPORT_URL', '--url must be a public http/https app bundle zip URL', 2, {
1678
+ hint: 'local files and upload ids are not supported; provide a public app bundle URL',
1679
+ })
1680
+ }
1681
+ return value
1682
+ }
1683
+
1684
+ function watchIntervalMs(env: EnvInput): number {
1685
+ const raw = env.ARCUBASE_IMPORT_TASK_WATCH_INTERVAL_MS
1686
+ const parsed = raw ? Number(raw) : 2000
1687
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 2000
1688
+ }
1689
+
1690
+ function sleep(ms: number): Promise<void> {
1691
+ return new Promise((resolve) => setTimeout(resolve, ms))
1692
+ }
1693
+
1694
+ async function executeCreateAppFromBundleURL(runtimeEnv: RuntimeEnv, flags: Record<string, string | boolean>, fetchImpl: typeof fetch) {
1695
+ const url = validateAppBundleImportURL(requiredStringFlag(flags, 'url'))
1696
+ const name = flagValue(flags, 'name')
1697
+ const result = await requestJSON(runtimeEnv, 'POST', '/api/apps/import-task', {
1698
+ url,
1699
+ ...(name ? { name } : {}),
1700
+ }, fetchImpl)
1701
+ const taskID = extractImportTaskID(result.data)
1702
+ return {
1703
+ kind: 'result',
1704
+ commandPath: ['app', 'create-from-bundle-url'],
1705
+ status: result.status,
1706
+ data: result.data,
1707
+ nextAction: {
1708
+ command: 'app watch-import-task',
1709
+ args: ['--task-id', taskID, '--ttl-seconds', '300'],
1710
+ hint: 'run this command until the task returns status=finished and app_id > 0; task_id alone is not success',
1711
+ },
1712
+ }
1713
+ }
1714
+
1715
+ async function executeWatchAppBundleImportTask(runtimeEnv: RuntimeEnv, flags: Record<string, string | boolean>, fetchImpl: typeof fetch) {
1716
+ const taskID = requiredStringFlag(flags, 'task-id')
1717
+ const ttlRaw = requiredStringFlag(flags, 'ttl-seconds')
1718
+ const ttlSeconds = Number(ttlRaw)
1719
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
1720
+ throw new CLIError('INVALID_FLAG_VALUE', `expected positive ttl seconds, got ${ttlRaw}`, 2)
1721
+ }
1722
+ const deadline = Date.now() + ttlSeconds * 1000
1723
+ const endpoint = `/api/apps/import-task/${encodeURIComponent(taskID)}`
1724
+ let latest: any = null
1725
+ while (Date.now() <= deadline) {
1726
+ const result = await requestJSON(runtimeEnv, 'GET', endpoint, undefined, fetchImpl)
1727
+ latest = result.data
1728
+ const payload = isRecord(latest) && isRecord(latest.data) ? latest.data : latest
1729
+ const status = isRecord(payload) ? payload.status : undefined
1730
+ if (status === 'finished') {
1731
+ const appID = isRecord(payload) ? Number(payload.app_id) : 0
1732
+ if (!Number.isFinite(appID) || appID <= 0) {
1733
+ throw new CLIError('IMPORT_TASK_APP_ID_MISSING', 'app bundle import task finished without app_id', 1, {
1734
+ endpoint,
1735
+ responseBody: stringifyUpstreamBody(latest),
1736
+ hint: 'treat this as an import failure; do not claim the app was imported',
1737
+ })
1738
+ }
1739
+ return {
1740
+ kind: 'result',
1741
+ commandPath: ['app', 'watch-import-task'],
1742
+ status: result.status,
1743
+ data: latest,
1744
+ }
1745
+ }
1746
+ if (status === 'failed') {
1747
+ throw new CLIError('IMPORT_TASK_FAILED', isRecord(payload) && typeof payload.error === 'string' ? payload.error : 'app bundle import task failed', 1, {
1748
+ endpoint,
1749
+ responseBody: stringifyUpstreamBody(latest),
1750
+ })
1751
+ }
1752
+ await sleep(Math.min(watchIntervalMs(runtimeEnv), Math.max(0, deadline - Date.now())))
1753
+ }
1754
+ throw new CLIError('IMPORT_TASK_WATCH_TIMEOUT', `import task did not finish within ${ttlSeconds} seconds`, 1, {
1755
+ endpoint,
1756
+ responseBody: stringifyUpstreamBody(latest),
1757
+ })
1758
+ }
1759
+
1760
+ function readJSONFile(filePath: string, label: string): unknown {
1761
+ try {
1762
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
1763
+ } catch {
1764
+ throw new CLIError('INVALID_JSON_FILE', `${label} file is invalid: ${filePath}`, 2)
1765
+ }
1766
+ }
1767
+
1768
+ function assertLocalImportFile(filePath: string, format: 'excel' | 'csv') {
1769
+ let stat
1770
+ try {
1771
+ stat = fs.statSync(filePath)
1772
+ } catch {
1773
+ throw new CLIError('LOCAL_FILE_NOT_FOUND', `local file not found: ${filePath}`, 2)
1774
+ }
1775
+ if (!stat.isFile()) {
1776
+ throw new CLIError('LOCAL_FILE_INVALID', `path is not a regular file: ${filePath}`, 2)
1777
+ }
1778
+ const ext = path.extname(filePath).toLowerCase()
1779
+ if (format === 'csv' && ext !== '.csv') {
1780
+ throw new CLIError('LOCAL_FILE_INVALID', 'row import-csv requires a .csv file', 2)
1781
+ }
1782
+ if (format === 'excel' && !['.xlsx', '.xlsm', '.xls'].includes(ext)) {
1783
+ throw new CLIError('LOCAL_FILE_INVALID', 'row import-excel requires an Excel file', 2)
1784
+ }
1785
+ }
1786
+
1787
+ function csvHeadersFromLocalFile(filePath: string, delimiter = ','): string[] {
1788
+ const firstLine = fs.readFileSync(filePath, 'utf8').split(/\r?\n/, 1)[0] ?? ''
1789
+ const headers: string[] = []
1790
+ let current = ''
1791
+ let quoted = false
1792
+ for (let i = 0; i < firstLine.length; i += 1) {
1793
+ const char = firstLine[i]
1794
+ if (char === '"') {
1795
+ if (quoted && firstLine[i + 1] === '"') {
1796
+ current += '"'
1797
+ i += 1
1798
+ } else {
1799
+ quoted = !quoted
1800
+ }
1801
+ } else if (char === delimiter && !quoted) {
1802
+ headers.push(current)
1803
+ current = ''
1804
+ } else {
1805
+ current += char
1806
+ }
1807
+ }
1808
+ headers.push(current)
1809
+ return headers
1810
+ }
1811
+
1812
+ async function postEntityImportAction(runtimeEnv: RuntimeEnv, appId: string, entityId: string, body: unknown, fetchImpl: typeof fetch): Promise<any> {
1813
+ const endpoint = `/api/entity/${encodeURIComponent(appId)}/${encodeURIComponent(entityId)}/import`
1814
+ const response = await requestJSON(runtimeEnv, 'POST', endpoint, body, fetchImpl)
1815
+ return response.data
1816
+ }
1817
+
1818
+ function extractImportTaskID(value: unknown): string {
1819
+ const candidates = [
1820
+ value,
1821
+ isRecord(value) ? value.data : undefined,
1822
+ ]
1823
+ for (const candidate of candidates) {
1824
+ if (isRecord(candidate) && typeof candidate.taskId === 'string') return candidate.taskId
1825
+ if (isRecord(candidate) && typeof candidate.task_id === 'string') return candidate.task_id
1826
+ if (typeof candidate === 'string') return candidate
1827
+ }
1828
+ throw new CLIError('UPSTREAM_RESPONSE_INVALID', 'import response did not include taskId', 1)
1829
+ }
1830
+
1831
+ function extractUploadToken(value: unknown): unknown {
1832
+ const payload = isRecord(value) && isRecord(value.data) ? value.data : value
1833
+ if (isRecord(payload) && isRecord(payload.token)) return payload.token
1834
+ throw new CLIError('UPSTREAM_RESPONSE_INVALID', 'import init-token response did not include upload token', 1)
1835
+ }
1836
+
1837
+ function compileEntityImportConfig(format: 'excel' | 'csv', mapping: unknown, filePath: string): CompiledEntityImportConfig {
1838
+ if (format === 'excel') {
1839
+ return compileExcelImportMapping(mapping)
1840
+ }
1841
+ const delimiter = isRecord(mapping) && typeof mapping.delimiter === 'string' ? mapping.delimiter : ','
1842
+ return compileCSVImportMapping(mapping, csvHeadersFromLocalFile(filePath, delimiter))
1843
+ }
1844
+
1845
+ async function executeEntityRecordImport(
1846
+ command: CommandDef,
1847
+ runtimeEnv: RuntimeEnv,
1848
+ flags: Record<string, string | boolean>,
1849
+ fetchImpl: typeof fetch,
1850
+ ) {
1851
+ const appId = requiredStringFlag(flags, 'app-id')
1852
+ const entityId = requiredStringFlag(flags, 'table-id')
1853
+ const filePath = requiredStringFlag(flags, 'file')
1854
+ const mappingPath = requiredStringFlag(flags, 'mapping-file')
1855
+ const ttlRaw = flagValue(flags, 'ttl-seconds') ?? '300'
1856
+ const ttlSeconds = Number(ttlRaw)
1857
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
1858
+ throw new CLIError('INVALID_FLAG_VALUE', `expected positive ttl seconds, got ${ttlRaw}`, 2)
1859
+ }
1860
+
1861
+ const format: 'excel' | 'csv' = command.commandPath[1] === 'import-csv' ? 'csv' : 'excel'
1862
+ assertLocalImportFile(filePath, format)
1863
+ const mapping = readJSONFile(mappingPath, 'mapping')
1864
+ const config = compileEntityImportConfig(format, mapping, filePath)
1865
+
1866
+ const init = await postEntityImportAction(runtimeEnv, appId, entityId, { _import_action: 'init-token' }, fetchImpl)
1867
+ const taskId = extractImportTaskID(init)
1868
+ const token = extractUploadToken(init)
1869
+ await uploadLocalFileWithToken(filePath, token as any, fetchImpl)
1870
+ await postEntityImportAction(runtimeEnv, appId, entityId, { _import_action: 'uploaded', task_id: taskId }, fetchImpl)
1871
+ await postEntityImportAction(runtimeEnv, appId, entityId, { _import_action: 'do-process', task_id: taskId, config }, fetchImpl)
1872
+
1873
+ const deadline = Date.now() + ttlSeconds * 1000
1874
+ let latest: any = null
1875
+ while (Date.now() <= deadline) {
1876
+ latest = await postEntityImportAction(runtimeEnv, appId, entityId, { _import_action: 'get-state', task_id: taskId }, fetchImpl)
1877
+ const payload = isRecord(latest) && isRecord(latest.data) ? latest.data : latest
1878
+ const status = isRecord(payload) ? payload.status : undefined
1879
+ if (status === 'finished') {
1880
+ return {
1881
+ kind: 'result',
1882
+ commandPath: command.commandPath,
1883
+ status: 200,
1884
+ data: latest,
1885
+ }
1886
+ }
1887
+ if (status === 'failed') {
1888
+ throw new CLIError('IMPORT_TASK_FAILED', 'entity record import task failed', 1, {
1889
+ responseBody: stringifyUpstreamBody(latest),
1890
+ })
1891
+ }
1892
+ await sleep(Math.min(watchIntervalMs(runtimeEnv), Math.max(0, deadline - Date.now())))
1893
+ }
1894
+ throw new CLIError('IMPORT_TASK_WATCH_TIMEOUT', `import task did not finish within ${ttlSeconds} seconds`, 1, {
1895
+ responseBody: stringifyUpstreamBody(latest),
1896
+ })
1897
+ }
1898
+
1487
1899
  function extractCreatedTableID(value: unknown): number {
1488
1900
  const candidates = [
1489
1901
  value,
@@ -1621,6 +2033,16 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
1621
2033
 
1622
2034
  const runtimeEnv = env ?? loadRuntimeEnv(scope)
1623
2035
 
2036
+ if (scope === 'admin' && command.commandPath[0] === 'app' && command.commandPath[1] === 'create-from-bundle-url') {
2037
+ return executeCreateAppFromBundleURL(runtimeEnv, parsed.flags, fetchImpl)
2038
+ }
2039
+ if (scope === 'admin' && command.commandPath[0] === 'app' && command.commandPath[1] === 'watch-import-task') {
2040
+ return executeWatchAppBundleImportTask(runtimeEnv, parsed.flags, fetchImpl)
2041
+ }
2042
+ if (isEntityRecordImportCommand(scope, command)) {
2043
+ return executeEntityRecordImport(command, runtimeEnv, parsed.flags, fetchImpl)
2044
+ }
2045
+
1624
2046
  const endpoint = resolveEndpoint(command, parsed.flags)
1625
2047
  const scalarQuery = buildScalarQuery(command, parsed.flags)
1626
2048
  const fileQuery = loadQueryFromFlags(parsed.flags)
@@ -1632,7 +2054,7 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
1632
2054
  if (error instanceof CLIError && error.code === 'INVALID_BODY_JSON' && command.requestType) {
1633
2055
  throw new CLIError(
1634
2056
  'INVALID_BODY_JSON',
1635
- `body json is invalid for ${scope}.${command.commandPath.join(' ')}; fix JSON syntax, then use the canonical ${command.requestType} shape from docs/types`,
2057
+ `body json is invalid for ${scope}.${command.commandPath.join(' ')}; retry with retryToolCalls[0] or retryToolCalls[1] when present`,
1636
2058
  2,
1637
2059
  buildBodyValidationDetails(scope, command, command.requestType, [{ path: 'body', message: 'invalid JSON syntax' }], runtimeEnv),
1638
2060
  )
@@ -1782,10 +2204,12 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
1782
2204
  : {}),
1783
2205
  })
1784
2206
  }
2207
+ const rowValueEvidence = buildRowValueEvidence(command, parsedResponse)
1785
2208
  return {
1786
2209
  kind: 'result',
1787
2210
  commandPath: command.commandPath,
1788
2211
  status: response.status,
1789
2212
  data: parsedResponse,
2213
+ ...(rowValueEvidence ? { rowValueEvidence } : {}),
1790
2214
  }
1791
2215
  }
@@ -0,0 +1,84 @@
1
+ import fs from 'fs/promises'
2
+ import path from 'path'
3
+ import { CLIError } from './errors.js'
4
+
5
+ type UploadTokenPayload = {
6
+ mode?: string
7
+ token_data?: unknown
8
+ tokenData?: unknown
9
+ }
10
+
11
+ function isRecord(value: unknown): value is Record<string, any> {
12
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
13
+ }
14
+
15
+ async function ensureLocalFile(filePath: string) {
16
+ let stat
17
+ try {
18
+ stat = await fs.stat(filePath)
19
+ } catch {
20
+ throw new CLIError('LOCAL_FILE_NOT_FOUND', `local file not found: ${filePath}`, 2)
21
+ }
22
+ if (!stat.isFile()) {
23
+ throw new CLIError('LOCAL_FILE_INVALID', `path is not a regular file: ${filePath}`, 2)
24
+ }
25
+ }
26
+
27
+ function tokenData(token: UploadTokenPayload): Record<string, any> {
28
+ const data = token.token_data ?? token.tokenData
29
+ if (!isRecord(data)) {
30
+ throw new CLIError('UPLOAD_TOKEN_UNSUPPORTED', 'upload token data is not an object', 1)
31
+ }
32
+ return data
33
+ }
34
+
35
+ function appendAliyunCompatFields(form: FormData, data: Record<string, any>, objectKey: string) {
36
+ form.set('key', objectKey)
37
+ form.set('policy', String(data.policy))
38
+ form.set('OSSAccessKeyId', String(data.accessid))
39
+ form.set('Signature', String(data.signature))
40
+ form.set('success_action_status', '200')
41
+ if (typeof data.callback === 'string' && data.callback !== '') {
42
+ form.set('callback', data.callback)
43
+ }
44
+ }
45
+
46
+ function appendS3PostPolicyFields(form: FormData, data: Record<string, any>, objectKey: string) {
47
+ form.set('key', objectKey)
48
+ form.set('Policy', String(data.policy))
49
+ form.set('X-Amz-Algorithm', String(data.x_amz_algorithm))
50
+ form.set('X-Amz-Credential', String(data.x_amz_credential))
51
+ form.set('X-Amz-Date', String(data.x_amz_date))
52
+ form.set('X-Amz-Signature', String(data.x_amz_signature))
53
+ }
54
+
55
+ export async function uploadLocalFileWithToken(filePath: string, token: UploadTokenPayload, fetchImpl: typeof fetch = fetch): Promise<{ objectKey: string }> {
56
+ await ensureLocalFile(filePath)
57
+ const data = tokenData(token)
58
+ if (typeof data.host !== 'string' || data.host === '' || typeof data.dir !== 'string') {
59
+ throw new CLIError('UPLOAD_TOKEN_UNSUPPORTED', `unsupported upload token mode: ${token.mode ?? 'unknown'}`, 1)
60
+ }
61
+
62
+ const fileName = path.basename(filePath)
63
+ const objectKey = `${data.dir}${fileName}`
64
+ const form = new FormData()
65
+ if (typeof data.x_amz_signature === 'string') {
66
+ appendS3PostPolicyFields(form, data, objectKey)
67
+ } else if (typeof data.accessid === 'string' && typeof data.signature === 'string') {
68
+ appendAliyunCompatFields(form, data, objectKey)
69
+ } else {
70
+ throw new CLIError('UPLOAD_TOKEN_UNSUPPORTED', `unsupported upload token mode: ${token.mode ?? 'unknown'}`, 1)
71
+ }
72
+
73
+ const raw = await fs.readFile(filePath)
74
+ form.set('file', new Blob([raw]), fileName)
75
+ const response = await fetchImpl(data.host, { method: 'POST', body: form })
76
+ if (!response.ok) {
77
+ const body = await response.text().catch(() => '')
78
+ throw new CLIError('LOCAL_UPLOAD_FAILED', body || `upload failed with HTTP ${response.status}`, 1, {
79
+ status: response.status,
80
+ responseBody: body,
81
+ })
82
+ }
83
+ return { objectKey }
84
+ }