@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
@@ -1,3 +1,5 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
1
3
  import { parseCLIArgs, hasFlag, flagValue } from './argv.js';
2
4
  import { loadRuntimeEnv } from './env.js';
3
5
  import { CLIError } from './errors.js';
@@ -6,6 +8,8 @@ import { listModules, listModuleCommands, findCommand, findCommandSuggestions }
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 } from './entity_import_mapping.js';
12
+ import { uploadLocalFileWithToken } from './local_upload.js';
9
13
  export function renderRootHelp(scope) {
10
14
  const binary = scope === 'admin' ? 'arcubase-admin' : 'arcubase';
11
15
  const items = listModules(scope).map((item) => ` - ${item}`);
@@ -125,6 +129,48 @@ export function renderCommandHelp(scope, moduleName, commandName, env = process.
125
129
  ' - If result.enabled is false, enable the rule first and retry assign-users',
126
130
  ]
127
131
  : []),
132
+ ...(scope === 'admin' && command.commandPath[0] === 'app' && command.commandPath[1] === 'create-from-bundle-url'
133
+ ? [
134
+ 'flags:',
135
+ ' - --url must be a public http/https app bundle zip URL',
136
+ ' - --name is accepted for compatibility; imported app name comes from the bundle source app',
137
+ 'success:',
138
+ ' - returns task_id; use app watch-import-task to wait for completion',
139
+ ]
140
+ : []),
141
+ ...(scope === 'admin' && command.commandPath[0] === 'app' && command.commandPath[1] === 'watch-import-task'
142
+ ? [
143
+ 'flags:',
144
+ ' - --task-id is returned by app create-from-bundle-url',
145
+ ' - --ttl-seconds is a local wait limit and does not cancel the backend task',
146
+ 'success:',
147
+ ' - returns finished task status with app_id, name, and warnings',
148
+ ]
149
+ : []),
150
+ ...(scope === 'user' && command.commandPath[0] === 'row' && command.commandPath[1] === 'import-excel'
151
+ ? [
152
+ 'mapping-file example:',
153
+ ' - {"sheet":"Sheet1","mode":"addonly","headerRow":1,"dataStartRow":2,"fields":[{"fieldId":1001,"column":"A"}]}',
154
+ 'rules:',
155
+ ' - import supports local file paths only',
156
+ ' - --file must be a local Excel file path',
157
+ ' - Excel columns use letters such as A, B, AA',
158
+ ' - row numbers are 1-based',
159
+ ' - fieldId must be numeric; use arcubase-admin table get --app-id <app_id> --table-id <table_id>',
160
+ ]
161
+ : []),
162
+ ...(scope === 'user' && command.commandPath[0] === 'row' && command.commandPath[1] === 'import-csv'
163
+ ? [
164
+ 'mapping-file example:',
165
+ ' - {"mode":"addonly","header":true,"dataStartRow":2,"fields":[{"fieldId":1001,"header":"客户名称"}]}',
166
+ 'rules:',
167
+ ' - import supports local file paths only',
168
+ ' - --file must be a local CSV file path',
169
+ ' - CSV mapping uses headers, not column indexes',
170
+ ' - CSV import does not support embedded images',
171
+ ' - fieldId must be numeric; use arcubase-admin table get --app-id <app_id> --table-id <table_id>',
172
+ ]
173
+ : []),
128
174
  ...(docHints.length > 0
129
175
  ? ['docs:', ...docHints.map((item) => ` - ${item.title}: ${item.file}`)]
130
176
  : []),
@@ -208,6 +254,18 @@ function buildUnknownFlagDetails(command, flag, env = process.env) {
208
254
  operation,
209
255
  hint: `run ${operation} --help for the supported flags`,
210
256
  };
257
+ if (command.commandPath[0] === 'app' && command.commandPath[1] === 'create' && flag === 'url') {
258
+ details.hint = 'app bundle URLs must use app create-from-bundle-url; do not retry app create';
259
+ details.commonMistakes = [
260
+ 'do not use app create for app bundle URLs',
261
+ 'do not put url inside app create --body-json',
262
+ ];
263
+ details.suggestions = [
264
+ 'retry with: app create-from-bundle-url --url <public_http_zip_url> --name <app_name>',
265
+ 'then watch with: app watch-import-task --task-id <task_id> --ttl-seconds 300',
266
+ ];
267
+ return details;
268
+ }
211
269
  if (command.requestType) {
212
270
  details.requestType = command.requestType;
213
271
  details.tsHints = getTypeHints(command.requestType, env);
@@ -219,8 +277,7 @@ function buildUnknownFlagDetails(command, flag, env = process.env) {
219
277
  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()];
220
278
  }
221
279
  if (command.commandPath[0] === 'row' && command.commandPath[1] === 'query' && ['limit', 'offset', 'search', 'sorts', 'view-mode'].includes(flag)) {
222
- details.shapeHint = { limit: 20, offset: 0, search: { text_search: 'SO-1001' } };
223
- details.suggestions = ['retry with: row query --app-id <app_id> --table-id <table_id> --body-json \'{"limit":20,"offset":0}\''];
280
+ Object.assign(details, buildBodyGuidance('EntityQueryReqVO'));
224
281
  }
225
282
  if (command.commandPath[0] === 'table' && command.commandPath[1] === 'dataset' && ['app-id', 'table-id'].includes(flag)) {
226
283
  details.hint = 'table dataset uses --dataset-id and --node-id; use table get for app/table metadata';
@@ -486,7 +543,7 @@ function buildTableCreateExampleBody() {
486
543
  depends: [],
487
544
  key: 'vote_description',
488
545
  code_index: false,
489
- options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
546
+ options: { placeholder: '', html: false, size: 6, lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
490
547
  transfers: null,
491
548
  children: null,
492
549
  },
@@ -501,6 +558,9 @@ function isAssignUsersCommand(scope, command) {
501
558
  function isTableCreateCommand(scope, command) {
502
559
  return scope === 'admin' && command.commandPath[0] === 'table' && command.commandPath[1] === 'create';
503
560
  }
561
+ function isEntityRecordImportCommand(scope, command) {
562
+ return scope === 'user' && command.commandPath[0] === 'row' && (command.commandPath[1] === 'import-excel' || command.commandPath[1] === 'import-csv');
563
+ }
504
564
  function isTableSchemaCommand(scope, command) {
505
565
  return scope === 'admin' && command.commandPath[0] === 'table' && (command.commandPath[1] === 'create' || command.commandPath[1] === 'update-schema');
506
566
  }
@@ -628,7 +688,7 @@ function buildBodyGuidance(requestType) {
628
688
  depends: [],
629
689
  key: 'description',
630
690
  code_index: false,
631
- options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
691
+ options: { placeholder: '', html: false, size: 6, lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
632
692
  transfers: null,
633
693
  children: null,
634
694
  },
@@ -738,15 +798,21 @@ function buildBodyGuidance(requestType) {
738
798
  shapeHint: {
739
799
  limit: 20,
740
800
  offset: 0,
741
- search: { text_search: 'SO-1001' },
801
+ search: { q: 'SO-1001' },
802
+ sorts: [{ column: ':1001', order: 'asc' }],
742
803
  },
743
804
  commonMistakes: [
744
805
  'limit, offset, search, sorts, and view_mode belong inside --body-json',
745
806
  'do not pass --limit or --offset flags',
807
+ 'do not use search.conditions, search.condition, search.field_filters, filter, filters, where, or order',
808
+ 'search only accepts string route-query keys such as q, tab, and filter_:1002',
809
+ 'sorts belongs at top level and uses column values like ":1001"; do not use order/orderBy',
746
810
  'do not add access policy fields; the CLI resolves access policy internally',
747
811
  ],
748
812
  suggestions: [
749
813
  'retry with: arcubase row query --app-id <app_id> --table-id <table_id> --body-json \'{"limit":20,"offset":0}\'',
814
+ '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"}}\'',
815
+ '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"}}\'',
750
816
  ],
751
817
  };
752
818
  }
@@ -1255,6 +1321,235 @@ async function requestJSON(runtimeEnv, method, endpoint, body, fetchImpl) {
1255
1321
  }
1256
1322
  return { status: response.status, data: parsedResponse };
1257
1323
  }
1324
+ function requiredStringFlag(flags, flag) {
1325
+ const value = flagValue(flags, flag);
1326
+ if (!value) {
1327
+ throw new CLIError('MISSING_PATH_FLAG', `missing required flag --${flag}`, 2);
1328
+ }
1329
+ return value;
1330
+ }
1331
+ function validateAppBundleImportURL(rawURL) {
1332
+ const value = rawURL.trim();
1333
+ let parsed;
1334
+ try {
1335
+ parsed = new URL(value);
1336
+ }
1337
+ catch {
1338
+ throw new CLIError('INVALID_IMPORT_URL', '--url must be a public http/https app bundle zip URL', 2, {
1339
+ hint: 'retry with --url https://.../app-bundles/...zip',
1340
+ });
1341
+ }
1342
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
1343
+ throw new CLIError('INVALID_IMPORT_URL', '--url must be a public http/https app bundle zip URL', 2, {
1344
+ hint: 'local files and upload ids are not supported; provide a public app bundle URL',
1345
+ });
1346
+ }
1347
+ return value;
1348
+ }
1349
+ function watchIntervalMs(env) {
1350
+ const raw = env.ARCUBASE_IMPORT_TASK_WATCH_INTERVAL_MS;
1351
+ const parsed = raw ? Number(raw) : 2000;
1352
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 2000;
1353
+ }
1354
+ function sleep(ms) {
1355
+ return new Promise((resolve) => setTimeout(resolve, ms));
1356
+ }
1357
+ async function executeCreateAppFromBundleURL(runtimeEnv, flags, fetchImpl) {
1358
+ const url = validateAppBundleImportURL(requiredStringFlag(flags, 'url'));
1359
+ const name = flagValue(flags, 'name');
1360
+ const result = await requestJSON(runtimeEnv, 'POST', '/api/apps/import-task', {
1361
+ url,
1362
+ ...(name ? { name } : {}),
1363
+ }, fetchImpl);
1364
+ const taskID = extractImportTaskID(result.data);
1365
+ return {
1366
+ kind: 'result',
1367
+ commandPath: ['app', 'create-from-bundle-url'],
1368
+ status: result.status,
1369
+ data: result.data,
1370
+ nextAction: {
1371
+ command: 'app watch-import-task',
1372
+ args: ['--task-id', taskID, '--ttl-seconds', '300'],
1373
+ hint: 'run this command until the task returns status=finished and app_id > 0; task_id alone is not success',
1374
+ },
1375
+ };
1376
+ }
1377
+ async function executeWatchAppBundleImportTask(runtimeEnv, flags, fetchImpl) {
1378
+ const taskID = requiredStringFlag(flags, 'task-id');
1379
+ const ttlRaw = requiredStringFlag(flags, 'ttl-seconds');
1380
+ const ttlSeconds = Number(ttlRaw);
1381
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
1382
+ throw new CLIError('INVALID_FLAG_VALUE', `expected positive ttl seconds, got ${ttlRaw}`, 2);
1383
+ }
1384
+ const deadline = Date.now() + ttlSeconds * 1000;
1385
+ const endpoint = `/api/apps/import-task/${encodeURIComponent(taskID)}`;
1386
+ let latest = null;
1387
+ while (Date.now() <= deadline) {
1388
+ const result = await requestJSON(runtimeEnv, 'GET', endpoint, undefined, fetchImpl);
1389
+ latest = result.data;
1390
+ const payload = isRecord(latest) && isRecord(latest.data) ? latest.data : latest;
1391
+ const status = isRecord(payload) ? payload.status : undefined;
1392
+ if (status === 'finished') {
1393
+ const appID = isRecord(payload) ? Number(payload.app_id) : 0;
1394
+ if (!Number.isFinite(appID) || appID <= 0) {
1395
+ throw new CLIError('IMPORT_TASK_APP_ID_MISSING', 'app bundle import task finished without app_id', 1, {
1396
+ endpoint,
1397
+ responseBody: stringifyUpstreamBody(latest),
1398
+ hint: 'treat this as an import failure; do not claim the app was imported',
1399
+ });
1400
+ }
1401
+ return {
1402
+ kind: 'result',
1403
+ commandPath: ['app', 'watch-import-task'],
1404
+ status: result.status,
1405
+ data: latest,
1406
+ };
1407
+ }
1408
+ if (status === 'failed') {
1409
+ throw new CLIError('IMPORT_TASK_FAILED', isRecord(payload) && typeof payload.error === 'string' ? payload.error : 'app bundle import task failed', 1, {
1410
+ endpoint,
1411
+ responseBody: stringifyUpstreamBody(latest),
1412
+ });
1413
+ }
1414
+ await sleep(Math.min(watchIntervalMs(runtimeEnv), Math.max(0, deadline - Date.now())));
1415
+ }
1416
+ throw new CLIError('IMPORT_TASK_WATCH_TIMEOUT', `import task did not finish within ${ttlSeconds} seconds`, 1, {
1417
+ endpoint,
1418
+ responseBody: stringifyUpstreamBody(latest),
1419
+ });
1420
+ }
1421
+ function readJSONFile(filePath, label) {
1422
+ try {
1423
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
1424
+ }
1425
+ catch {
1426
+ throw new CLIError('INVALID_JSON_FILE', `${label} file is invalid: ${filePath}`, 2);
1427
+ }
1428
+ }
1429
+ function assertLocalImportFile(filePath, format) {
1430
+ let stat;
1431
+ try {
1432
+ stat = fs.statSync(filePath);
1433
+ }
1434
+ catch {
1435
+ throw new CLIError('LOCAL_FILE_NOT_FOUND', `local file not found: ${filePath}`, 2);
1436
+ }
1437
+ if (!stat.isFile()) {
1438
+ throw new CLIError('LOCAL_FILE_INVALID', `path is not a regular file: ${filePath}`, 2);
1439
+ }
1440
+ const ext = path.extname(filePath).toLowerCase();
1441
+ if (format === 'csv' && ext !== '.csv') {
1442
+ throw new CLIError('LOCAL_FILE_INVALID', 'row import-csv requires a .csv file', 2);
1443
+ }
1444
+ if (format === 'excel' && !['.xlsx', '.xlsm', '.xls'].includes(ext)) {
1445
+ throw new CLIError('LOCAL_FILE_INVALID', 'row import-excel requires an Excel file', 2);
1446
+ }
1447
+ }
1448
+ function csvHeadersFromLocalFile(filePath, delimiter = ',') {
1449
+ const firstLine = fs.readFileSync(filePath, 'utf8').split(/\r?\n/, 1)[0] ?? '';
1450
+ const headers = [];
1451
+ let current = '';
1452
+ let quoted = false;
1453
+ for (let i = 0; i < firstLine.length; i += 1) {
1454
+ const char = firstLine[i];
1455
+ if (char === '"') {
1456
+ if (quoted && firstLine[i + 1] === '"') {
1457
+ current += '"';
1458
+ i += 1;
1459
+ }
1460
+ else {
1461
+ quoted = !quoted;
1462
+ }
1463
+ }
1464
+ else if (char === delimiter && !quoted) {
1465
+ headers.push(current);
1466
+ current = '';
1467
+ }
1468
+ else {
1469
+ current += char;
1470
+ }
1471
+ }
1472
+ headers.push(current);
1473
+ return headers;
1474
+ }
1475
+ async function postEntityImportAction(runtimeEnv, appId, entityId, body, fetchImpl) {
1476
+ const endpoint = `/api/entity/${encodeURIComponent(appId)}/${encodeURIComponent(entityId)}/import`;
1477
+ const response = await requestJSON(runtimeEnv, 'POST', endpoint, body, fetchImpl);
1478
+ return response.data;
1479
+ }
1480
+ function extractImportTaskID(value) {
1481
+ const candidates = [
1482
+ value,
1483
+ isRecord(value) ? value.data : undefined,
1484
+ ];
1485
+ for (const candidate of candidates) {
1486
+ if (isRecord(candidate) && typeof candidate.taskId === 'string')
1487
+ return candidate.taskId;
1488
+ if (isRecord(candidate) && typeof candidate.task_id === 'string')
1489
+ return candidate.task_id;
1490
+ if (typeof candidate === 'string')
1491
+ return candidate;
1492
+ }
1493
+ throw new CLIError('UPSTREAM_RESPONSE_INVALID', 'import response did not include taskId', 1);
1494
+ }
1495
+ function extractUploadToken(value) {
1496
+ const payload = isRecord(value) && isRecord(value.data) ? value.data : value;
1497
+ if (isRecord(payload) && isRecord(payload.token))
1498
+ return payload.token;
1499
+ throw new CLIError('UPSTREAM_RESPONSE_INVALID', 'import init-token response did not include upload token', 1);
1500
+ }
1501
+ function compileEntityImportConfig(format, mapping, filePath) {
1502
+ if (format === 'excel') {
1503
+ return compileExcelImportMapping(mapping);
1504
+ }
1505
+ const delimiter = isRecord(mapping) && typeof mapping.delimiter === 'string' ? mapping.delimiter : ',';
1506
+ return compileCSVImportMapping(mapping, csvHeadersFromLocalFile(filePath, delimiter));
1507
+ }
1508
+ async function executeEntityRecordImport(command, runtimeEnv, flags, fetchImpl) {
1509
+ const appId = requiredStringFlag(flags, 'app-id');
1510
+ const entityId = requiredStringFlag(flags, 'table-id');
1511
+ const filePath = requiredStringFlag(flags, 'file');
1512
+ const mappingPath = requiredStringFlag(flags, 'mapping-file');
1513
+ const ttlRaw = flagValue(flags, 'ttl-seconds') ?? '300';
1514
+ const ttlSeconds = Number(ttlRaw);
1515
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
1516
+ throw new CLIError('INVALID_FLAG_VALUE', `expected positive ttl seconds, got ${ttlRaw}`, 2);
1517
+ }
1518
+ const format = command.commandPath[1] === 'import-csv' ? 'csv' : 'excel';
1519
+ assertLocalImportFile(filePath, format);
1520
+ const mapping = readJSONFile(mappingPath, 'mapping');
1521
+ const config = compileEntityImportConfig(format, mapping, filePath);
1522
+ const init = await postEntityImportAction(runtimeEnv, appId, entityId, { _import_action: 'init-token' }, fetchImpl);
1523
+ const taskId = extractImportTaskID(init);
1524
+ const token = extractUploadToken(init);
1525
+ await uploadLocalFileWithToken(filePath, token, fetchImpl);
1526
+ await postEntityImportAction(runtimeEnv, appId, entityId, { _import_action: 'uploaded', task_id: taskId }, fetchImpl);
1527
+ await postEntityImportAction(runtimeEnv, appId, entityId, { _import_action: 'do-process', task_id: taskId, config }, fetchImpl);
1528
+ const deadline = Date.now() + ttlSeconds * 1000;
1529
+ let latest = null;
1530
+ while (Date.now() <= deadline) {
1531
+ latest = await postEntityImportAction(runtimeEnv, appId, entityId, { _import_action: 'get-state', task_id: taskId }, fetchImpl);
1532
+ const payload = isRecord(latest) && isRecord(latest.data) ? latest.data : latest;
1533
+ const status = isRecord(payload) ? payload.status : undefined;
1534
+ if (status === 'finished') {
1535
+ return {
1536
+ kind: 'result',
1537
+ commandPath: command.commandPath,
1538
+ status: 200,
1539
+ data: latest,
1540
+ };
1541
+ }
1542
+ if (status === 'failed') {
1543
+ throw new CLIError('IMPORT_TASK_FAILED', 'entity record import task failed', 1, {
1544
+ responseBody: stringifyUpstreamBody(latest),
1545
+ });
1546
+ }
1547
+ await sleep(Math.min(watchIntervalMs(runtimeEnv), Math.max(0, deadline - Date.now())));
1548
+ }
1549
+ throw new CLIError('IMPORT_TASK_WATCH_TIMEOUT', `import task did not finish within ${ttlSeconds} seconds`, 1, {
1550
+ responseBody: stringifyUpstreamBody(latest),
1551
+ });
1552
+ }
1258
1553
  function extractCreatedTableID(value) {
1259
1554
  const candidates = [
1260
1555
  value,
@@ -1362,6 +1657,15 @@ export async function executeCLI(scope, argv, env, fetchImpl = fetch) {
1362
1657
  }
1363
1658
  validateKnownFlags(command, parsed.flags, envInput);
1364
1659
  const runtimeEnv = env ?? loadRuntimeEnv(scope);
1660
+ if (scope === 'admin' && command.commandPath[0] === 'app' && command.commandPath[1] === 'create-from-bundle-url') {
1661
+ return executeCreateAppFromBundleURL(runtimeEnv, parsed.flags, fetchImpl);
1662
+ }
1663
+ if (scope === 'admin' && command.commandPath[0] === 'app' && command.commandPath[1] === 'watch-import-task') {
1664
+ return executeWatchAppBundleImportTask(runtimeEnv, parsed.flags, fetchImpl);
1665
+ }
1666
+ if (isEntityRecordImportCommand(scope, command)) {
1667
+ return executeEntityRecordImport(command, runtimeEnv, parsed.flags, fetchImpl);
1668
+ }
1365
1669
  const endpoint = resolveEndpoint(command, parsed.flags);
1366
1670
  const scalarQuery = buildScalarQuery(command, parsed.flags);
1367
1671
  const fileQuery = loadQueryFromFlags(parsed.flags);
@@ -0,0 +1,10 @@
1
+ type UploadTokenPayload = {
2
+ mode?: string;
3
+ token_data?: unknown;
4
+ tokenData?: unknown;
5
+ };
6
+ export declare function uploadLocalFileWithToken(filePath: string, token: UploadTokenPayload, fetchImpl?: typeof fetch): Promise<{
7
+ objectKey: string;
8
+ }>;
9
+ export {};
10
+ //# sourceMappingURL=local_upload.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local_upload.d.ts","sourceRoot":"","sources":["../../src/runtime/local_upload.ts"],"names":[],"mappings":"AAIA,KAAK,kBAAkB,GAAG;IACxB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB,CAAA;AA8CD,wBAAsB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,kBAAkB,EAAE,SAAS,GAAE,OAAO,KAAa,GAAG,OAAO,CAAC;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC,CA6B3J"}
@@ -0,0 +1,73 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { CLIError } from './errors.js';
4
+ function isRecord(value) {
5
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
6
+ }
7
+ async function ensureLocalFile(filePath) {
8
+ let stat;
9
+ try {
10
+ stat = await fs.stat(filePath);
11
+ }
12
+ catch {
13
+ throw new CLIError('LOCAL_FILE_NOT_FOUND', `local file not found: ${filePath}`, 2);
14
+ }
15
+ if (!stat.isFile()) {
16
+ throw new CLIError('LOCAL_FILE_INVALID', `path is not a regular file: ${filePath}`, 2);
17
+ }
18
+ }
19
+ function tokenData(token) {
20
+ const data = token.token_data ?? token.tokenData;
21
+ if (!isRecord(data)) {
22
+ throw new CLIError('UPLOAD_TOKEN_UNSUPPORTED', 'upload token data is not an object', 1);
23
+ }
24
+ return data;
25
+ }
26
+ function appendAliyunCompatFields(form, data, objectKey) {
27
+ form.set('key', objectKey);
28
+ form.set('policy', String(data.policy));
29
+ form.set('OSSAccessKeyId', String(data.accessid));
30
+ form.set('Signature', String(data.signature));
31
+ form.set('success_action_status', '200');
32
+ if (typeof data.callback === 'string' && data.callback !== '') {
33
+ form.set('callback', data.callback);
34
+ }
35
+ }
36
+ function appendS3PostPolicyFields(form, data, objectKey) {
37
+ form.set('key', objectKey);
38
+ form.set('Policy', String(data.policy));
39
+ form.set('X-Amz-Algorithm', String(data.x_amz_algorithm));
40
+ form.set('X-Amz-Credential', String(data.x_amz_credential));
41
+ form.set('X-Amz-Date', String(data.x_amz_date));
42
+ form.set('X-Amz-Signature', String(data.x_amz_signature));
43
+ }
44
+ export async function uploadLocalFileWithToken(filePath, token, fetchImpl = fetch) {
45
+ await ensureLocalFile(filePath);
46
+ const data = tokenData(token);
47
+ if (typeof data.host !== 'string' || data.host === '' || typeof data.dir !== 'string') {
48
+ throw new CLIError('UPLOAD_TOKEN_UNSUPPORTED', `unsupported upload token mode: ${token.mode ?? 'unknown'}`, 1);
49
+ }
50
+ const fileName = path.basename(filePath);
51
+ const objectKey = `${data.dir}${fileName}`;
52
+ const form = new FormData();
53
+ if (typeof data.x_amz_signature === 'string') {
54
+ appendS3PostPolicyFields(form, data, objectKey);
55
+ }
56
+ else if (typeof data.accessid === 'string' && typeof data.signature === 'string') {
57
+ appendAliyunCompatFields(form, data, objectKey);
58
+ }
59
+ else {
60
+ throw new CLIError('UPLOAD_TOKEN_UNSUPPORTED', `unsupported upload token mode: ${token.mode ?? 'unknown'}`, 1);
61
+ }
62
+ const raw = await fs.readFile(filePath);
63
+ form.set('file', new Blob([raw]), fileName);
64
+ const response = await fetchImpl(data.host, { method: 'POST', body: form });
65
+ if (!response.ok) {
66
+ const body = await response.text().catch(() => '');
67
+ throw new CLIError('LOCAL_UPLOAD_FAILED', body || `upload failed with HTTP ${response.status}`, 1, {
68
+ status: response.status,
69
+ responseBody: body,
70
+ });
71
+ }
72
+ return { objectKey };
73
+ }
@@ -61,6 +61,44 @@ test('EntitySaveReqVO normalizes mechanical FieldVO defaults', () => {
61
61
  assert.equal(parsed.data.fields[0].transfers, null);
62
62
  assert.deepEqual(parsed.data.fields[0].depends, []);
63
63
  });
64
+ test('EntitySaveReqVO rejects textarea size as string', () => {
65
+ assert.ok(schema);
66
+ const parsed = schema.safeParse({
67
+ name: '订单',
68
+ field_id_seq: 1003,
69
+ layout: [[1001], [1002]],
70
+ fields: [
71
+ buildMinimalField(1001),
72
+ {
73
+ ...buildMinimalField(1002),
74
+ label: '描述',
75
+ type: 'textarea',
76
+ key: 'description',
77
+ options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
78
+ },
79
+ ],
80
+ });
81
+ assert.equal(parsed.success, false);
82
+ });
83
+ test('EntitySaveReqVO accepts textarea size as number', () => {
84
+ assert.ok(schema);
85
+ const parsed = schema.safeParse({
86
+ name: '订单',
87
+ field_id_seq: 1003,
88
+ layout: [[1001], [1002]],
89
+ fields: [
90
+ buildMinimalField(1001),
91
+ {
92
+ ...buildMinimalField(1002),
93
+ label: '描述',
94
+ type: 'textarea',
95
+ key: 'description',
96
+ options: { placeholder: '', html: false, size: 6, lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
97
+ },
98
+ ],
99
+ });
100
+ assert.equal(parsed.success, true);
101
+ });
64
102
  test('EntitySaveReqVO rejects fields as object', () => {
65
103
  assert.ok(schema);
66
104
  const parsed = schema.safeParse({
@@ -337,7 +337,7 @@ test('table update-schema rejects per-user permission patches', async () => {
337
337
  layout: [[1001], [1002]],
338
338
  fields: [
339
339
  { 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 },
340
- { 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 } } },
340
+ { 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 } } },
341
341
  ],
342
342
  options: { Buttons: [], CustomActions: [], FrontendEvents: [], TitleFormat: { Fields: [], Parts: [], Body: '' }, misc: {} },
343
343
  workflow_enabled: false,
@@ -624,7 +624,7 @@ test('row query unknown limit flag gives body-json retry path', async () => {
624
624
  assert.ok(error instanceof CLIError);
625
625
  assert.equal(error.code, 'UNKNOWN_FLAG');
626
626
  assert.equal(error.details?.requestType, 'EntityQueryReqVO');
627
- assert.deepEqual(error.details?.shapeHint, { limit: 20, offset: 0, search: { text_search: 'SO-1001' } });
627
+ assert.deepEqual(error.details?.shapeHint, { limit: 20, offset: 0, search: { q: 'SO-1001' }, sorts: [{ column: ':1001', order: 'asc' }] });
628
628
  assert.ok(error.details?.suggestions?.some((item) => item.includes('--body-json')));
629
629
  return true;
630
630
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carthooks/arcubase-cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.16",
4
4
  "description": "Arcubase runtime CLI for admin and user command execution",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -31,13 +31,13 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@carthooks/client-ts": "0.8.0",
34
- "zod": "^3.25.76"
34
+ "zod": "^4.4.3"
35
35
  },
36
36
  "devDependencies": {
37
- "@types/node": "^20.19.0",
38
- "esbuild": "^0.25.11",
39
- "ts-morph": "^25.0.1",
40
- "tsx": "^4.20.6",
41
- "typescript": "^5.3.3"
37
+ "@types/node": "^25.9.2",
38
+ "esbuild": "^0.28.0",
39
+ "ts-morph": "^28.0.0",
40
+ "tsx": "^4.22.4",
41
+ "typescript": "^6.0.3"
42
42
  }
43
43
  }
@@ -35,12 +35,18 @@ Common commands:
35
35
 
36
36
  ```bash
37
37
  arcubase-admin app create --body-file create-app.json | --body-json <json-string>
38
+ arcubase-admin app create-from-bundle-url --url <bundle_url> [--name <app_name>]
39
+ arcubase-admin app watch-import-task --task-id <task_id> --ttl-seconds 300
38
40
  arcubase-admin table create --app-id <app_id> --body-json <json-string> | --body-file table-create.json
39
41
  arcubase-admin table get --app-id <app_id> --table-id <table_id>
40
42
  arcubase-admin table update-schema --app-id <app_id> --table-id <table_id> --body-json <json-string> | --body-file table-schema.json
41
43
  arcubase-admin access-rule assign-users --app-id <app_id> --table-id <table_id> --rule-id <rule_id> --body-json <json-string> | --body-file assign-users.json
42
44
  ```
43
45
 
46
+ `app create-from-bundle-url --name` is accepted for compatibility; imported app name comes from the bundle source app.
47
+
48
+ `--ttl-seconds` is a local wait limit. It does not cancel the backend import task.
49
+
44
50
  Use `arcubase` for:
45
51
 
46
52
  - row insert
@@ -23,7 +23,7 @@ Multi-line text field.
23
23
  "options": {
24
24
  "placeholder": "",
25
25
  "html": false,
26
- "size": "default",
26
+ "size": 6,
27
27
  "lengthLimit": false,
28
28
  "lengthMin": 0,
29
29
  "lengthMax": 2000
@@ -44,4 +44,4 @@ Multi-line text field.
44
44
 
45
45
  - `placeholder` matters only when `html=false`
46
46
  - when `lengthLimit=true`, it is recommended to provide both `lengthMin` and `lengthMax`
47
- - keep `size` within the values returned by the current editor; do not invent extra enum values
47
+ - `size` is numeric and maps to editor rows; use `6` for the default two-row textarea
@@ -1 +1 @@
1
- {"limit":20,"offset":0,"search":{"text_search":"lead"},"sorts":[{"column":":1001","order":"asc"}]}
1
+ {"limit":20,"offset":0,"search":{"q":"lead"},"sorts":[{"column":":1001","order":"asc"}]}
@@ -2,7 +2,7 @@
2
2
  "limit": 20,
3
3
  "offset": 0,
4
4
  "search": {
5
- "text_search": "SO-1001"
5
+ "q": "SO-1001"
6
6
  },
7
7
  "sorts": [
8
8
  { "column": ":1003", "order": "desc" }
@@ -1 +1 @@
1
- {"limit":20,"offset":0,"search":{"text_search":"SKU-1001"},"sorts":[{"column":":1001","order":"desc"}]}
1
+ {"limit":20,"offset":0,"search":{"q":"SKU-1001"},"sorts":[{"column":":1001","order":"desc"}]}