@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.
- package/bundle/arcubase-admin.mjs +15019 -3802
- package/bundle/arcubase.mjs +15019 -3802
- package/dist/generated/command_registry.generated.d.ts +108 -0
- package/dist/generated/command_registry.generated.d.ts.map +1 -1
- package/dist/generated/command_registry.generated.js +145 -0
- package/dist/generated/zod_registry.generated.d.ts +23 -23
- package/dist/runtime/entity_import_mapping.d.ts +12 -0
- package/dist/runtime/entity_import_mapping.d.ts.map +1 -0
- package/dist/runtime/entity_import_mapping.js +133 -0
- package/dist/runtime/entity_save_schema.d.ts.map +1 -1
- package/dist/runtime/entity_save_schema.js +23 -2
- package/dist/runtime/errors.d.ts +1 -0
- package/dist/runtime/errors.d.ts.map +1 -1
- package/dist/runtime/execute.d.ts.map +1 -1
- package/dist/runtime/execute.js +414 -8
- package/dist/runtime/local_upload.d.ts +10 -0
- package/dist/runtime/local_upload.d.ts.map +1 -0
- package/dist/runtime/local_upload.js +73 -0
- package/dist/tests/entity_save_schema.test.js +38 -0
- package/dist/tests/execute_validation.test.js +64 -2
- package/dist/tests/help.test.js +2 -0
- package/package.json +7 -7
- package/sdk-dist/docs/runtime-reference/README.md +4 -0
- package/sdk-dist/docs/runtime-reference/access-rule.md +19 -0
- package/sdk-dist/docs/runtime-reference/entity-schema/textarea.md +2 -2
- package/sdk-dist/docs/runtime-reference/examples/crm-01/lead.query.json +1 -1
- package/sdk-dist/docs/runtime-reference/examples/oms-01/sales-order.query.json +1 -1
- package/sdk-dist/docs/runtime-reference/examples/wms-01/inventory-snapshot.query.json +1 -1
- package/sdk-dist/docs/runtime-reference/row-crud.md +68 -0
- package/sdk-dist/docs/runtime-reference/search-and-bulk-actions.md +26 -1
- package/sdk-dist/docs/runtime-reference/table-lifecycle.md +1 -1
- package/sdk-dist/generated/command_registry.generated.ts +145 -0
- package/sdk-dist/types/common.ts +1 -0
- package/src/generated/command_registry.generated.ts +145 -0
- package/src/runtime/entity_import_mapping.ts +172 -0
- package/src/runtime/entity_save_schema.ts +27 -3
- package/src/runtime/errors.ts +1 -0
- package/src/runtime/execute.ts +434 -10
- package/src/runtime/local_upload.ts +84 -0
- package/src/tests/command_registry.test.ts +4 -0
- package/src/tests/entity_import_mapping.test.ts +87 -0
- package/src/tests/entity_save_schema.test.ts +40 -0
- package/src/tests/execute_validation.test.ts +299 -5
- package/src/tests/help.test.ts +16 -0
- package/src/tests/local_upload.test.ts +47 -0
- package/src/tests/zod_registry.test.ts +27 -0
package/dist/runtime/execute.js
CHANGED
|
@@ -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}`);
|
|
@@ -104,8 +108,9 @@ export function renderCommandHelp(scope, moduleName, commandName, env = process.
|
|
|
104
108
|
: []),
|
|
105
109
|
...(scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'create'
|
|
106
110
|
? [
|
|
107
|
-
'body-json
|
|
108
|
-
|
|
111
|
+
'body-json examples:',
|
|
112
|
+
` - full access: ${JSON.stringify(buildFullAccessRuleBody())}`,
|
|
113
|
+
` - hide field: ${JSON.stringify(buildHiddenFieldAccessRuleBody())}`,
|
|
109
114
|
'success requires:',
|
|
110
115
|
' - result.enabled must be true',
|
|
111
116
|
' - options.user_scope[].type is "user" for tenant users',
|
|
@@ -124,6 +129,48 @@ export function renderCommandHelp(scope, moduleName, commandName, env = process.
|
|
|
124
129
|
' - If result.enabled is false, enable the rule first and retry assign-users',
|
|
125
130
|
]
|
|
126
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 optionally overrides the imported app name',
|
|
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 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
|
+
: []),
|
|
127
174
|
...(docHints.length > 0
|
|
128
175
|
? ['docs:', ...docHints.map((item) => ` - ${item.title}: ${item.file}`)]
|
|
129
176
|
: []),
|
|
@@ -207,6 +254,18 @@ function buildUnknownFlagDetails(command, flag, env = process.env) {
|
|
|
207
254
|
operation,
|
|
208
255
|
hint: `run ${operation} --help for the supported flags`,
|
|
209
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
|
+
}
|
|
210
269
|
if (command.requestType) {
|
|
211
270
|
details.requestType = command.requestType;
|
|
212
271
|
details.tsHints = getTypeHints(command.requestType, env);
|
|
@@ -218,8 +277,7 @@ function buildUnknownFlagDetails(command, flag, env = process.env) {
|
|
|
218
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()];
|
|
219
278
|
}
|
|
220
279
|
if (command.commandPath[0] === 'row' && command.commandPath[1] === 'query' && ['limit', 'offset', 'search', 'sorts', 'view-mode'].includes(flag)) {
|
|
221
|
-
details
|
|
222
|
-
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'));
|
|
223
281
|
}
|
|
224
282
|
if (command.commandPath[0] === 'table' && command.commandPath[1] === 'dataset' && ['app-id', 'table-id'].includes(flag)) {
|
|
225
283
|
details.hint = 'table dataset uses --dataset-id and --node-id; use table get for app/table metadata';
|
|
@@ -485,7 +543,7 @@ function buildTableCreateExampleBody() {
|
|
|
485
543
|
depends: [],
|
|
486
544
|
key: 'vote_description',
|
|
487
545
|
code_index: false,
|
|
488
|
-
options: { placeholder: '', html: false, size:
|
|
546
|
+
options: { placeholder: '', html: false, size: 6, lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
|
|
489
547
|
transfers: null,
|
|
490
548
|
children: null,
|
|
491
549
|
},
|
|
@@ -500,6 +558,9 @@ function isAssignUsersCommand(scope, command) {
|
|
|
500
558
|
function isTableCreateCommand(scope, command) {
|
|
501
559
|
return scope === 'admin' && command.commandPath[0] === 'table' && command.commandPath[1] === 'create';
|
|
502
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
|
+
}
|
|
503
564
|
function isTableSchemaCommand(scope, command) {
|
|
504
565
|
return scope === 'admin' && command.commandPath[0] === 'table' && (command.commandPath[1] === 'create' || command.commandPath[1] === 'update-schema');
|
|
505
566
|
}
|
|
@@ -510,6 +571,26 @@ function isAccessRuleUpdateCommand(scope, command) {
|
|
|
510
571
|
return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'update';
|
|
511
572
|
}
|
|
512
573
|
function buildAccessRuleShapeHint(userId = 2188889901) {
|
|
574
|
+
return buildHiddenFieldAccessRuleBody(userId);
|
|
575
|
+
}
|
|
576
|
+
function buildFullAccessRuleBody(userId = 2188889977) {
|
|
577
|
+
return {
|
|
578
|
+
name: 'Admin full access',
|
|
579
|
+
enabled: true,
|
|
580
|
+
type: 'form',
|
|
581
|
+
options: {
|
|
582
|
+
user_scope: [{ type: 'user', id: userId }],
|
|
583
|
+
policy: {
|
|
584
|
+
key: 'custom',
|
|
585
|
+
actions: ['view', 'add', 'edit', 'delete'],
|
|
586
|
+
all_fields_read: true,
|
|
587
|
+
all_fields_write: true,
|
|
588
|
+
},
|
|
589
|
+
list_options: {},
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
function buildHiddenFieldAccessRuleBody(userId = 2188889901) {
|
|
513
594
|
return {
|
|
514
595
|
name: 'Sales read write',
|
|
515
596
|
enabled: true,
|
|
@@ -527,6 +608,12 @@ function buildAccessRuleShapeHint(userId = 2188889901) {
|
|
|
527
608
|
},
|
|
528
609
|
};
|
|
529
610
|
}
|
|
611
|
+
function buildAccessRuleCreateToolCall(body) {
|
|
612
|
+
return {
|
|
613
|
+
command: 'access-rule create',
|
|
614
|
+
args: ['--app-id', '<app_id>', '--table-id', '<table_id>', '--body-json', JSON.stringify(body)],
|
|
615
|
+
};
|
|
616
|
+
}
|
|
530
617
|
function buildAssignUsersValidationDetails(scope, command, requestType, path, message, env = process.env) {
|
|
531
618
|
return {
|
|
532
619
|
operation: `${scope}.${command.module}.${command.functionName}`,
|
|
@@ -601,7 +688,7 @@ function buildBodyGuidance(requestType) {
|
|
|
601
688
|
depends: [],
|
|
602
689
|
key: 'description',
|
|
603
690
|
code_index: false,
|
|
604
|
-
options: { placeholder: '', html: false, size:
|
|
691
|
+
options: { placeholder: '', html: false, size: 6, lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
|
|
605
692
|
transfers: null,
|
|
606
693
|
children: null,
|
|
607
694
|
},
|
|
@@ -664,7 +751,15 @@ function buildBodyGuidance(requestType) {
|
|
|
664
751
|
};
|
|
665
752
|
}
|
|
666
753
|
if (requestType === 'AppIngressCreateReqVO' || requestType === 'AppIngressUpdateReqVO') {
|
|
754
|
+
const retryToolCalls = [
|
|
755
|
+
buildAccessRuleCreateToolCall(buildFullAccessRuleBody()),
|
|
756
|
+
buildAccessRuleCreateToolCall(buildHiddenFieldAccessRuleBody()),
|
|
757
|
+
];
|
|
667
758
|
return {
|
|
759
|
+
retryToolCalls,
|
|
760
|
+
suggestions: [
|
|
761
|
+
'next arcubase-admin-cli input: retryToolCalls[0] for full access or retryToolCalls[1] for hidden-field access',
|
|
762
|
+
],
|
|
668
763
|
shapeHint: buildAccessRuleShapeHint(),
|
|
669
764
|
commonMistakes: [
|
|
670
765
|
'use options.policy, not top-level permissions',
|
|
@@ -703,15 +798,21 @@ function buildBodyGuidance(requestType) {
|
|
|
703
798
|
shapeHint: {
|
|
704
799
|
limit: 20,
|
|
705
800
|
offset: 0,
|
|
706
|
-
search: {
|
|
801
|
+
search: { q: 'SO-1001' },
|
|
802
|
+
sorts: [{ column: ':1001', order: 'asc' }],
|
|
707
803
|
},
|
|
708
804
|
commonMistakes: [
|
|
709
805
|
'limit, offset, search, sorts, and view_mode belong inside --body-json',
|
|
710
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',
|
|
711
810
|
'do not add access policy fields; the CLI resolves access policy internally',
|
|
712
811
|
],
|
|
713
812
|
suggestions: [
|
|
714
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"}}\'',
|
|
715
816
|
],
|
|
716
817
|
};
|
|
717
818
|
}
|
|
@@ -938,6 +1039,71 @@ function unwrapData(value) {
|
|
|
938
1039
|
}
|
|
939
1040
|
return current;
|
|
940
1041
|
}
|
|
1042
|
+
function numericFieldKeys(fields) {
|
|
1043
|
+
if (!isRecord(fields))
|
|
1044
|
+
return [];
|
|
1045
|
+
return Object.keys(fields).filter((key) => /^\d+$/.test(key)).sort((left, right) => Number(left) - Number(right));
|
|
1046
|
+
}
|
|
1047
|
+
function permitFieldId(permit) {
|
|
1048
|
+
if (!isRecord(permit) || typeof permit.key !== 'string')
|
|
1049
|
+
return undefined;
|
|
1050
|
+
const match = /^:(\d+)$/.exec(permit.key);
|
|
1051
|
+
return match?.[1];
|
|
1052
|
+
}
|
|
1053
|
+
function rowFieldPermits(value) {
|
|
1054
|
+
const data = unwrapData(value);
|
|
1055
|
+
const fieldPermits = isRecord(data) && isRecord(data.fieldPermits) ? data.fieldPermits : undefined;
|
|
1056
|
+
return fieldPermits && Array.isArray(fieldPermits.fields)
|
|
1057
|
+
? fieldPermits.fields.filter((item) => isRecord(item))
|
|
1058
|
+
: [];
|
|
1059
|
+
}
|
|
1060
|
+
function buildRowValueEvidence(command, responseBody) {
|
|
1061
|
+
if (command.scope !== 'user' || command.commandPath[0] !== 'row')
|
|
1062
|
+
return undefined;
|
|
1063
|
+
const action = command.commandPath[1];
|
|
1064
|
+
const data = unwrapData(responseBody);
|
|
1065
|
+
const rules = [
|
|
1066
|
+
'current row values come only from row query fields or row get record.fields',
|
|
1067
|
+
'table schema fields[].value and default_value_mode are schema defaults, not returned row values',
|
|
1068
|
+
'a field id absent from returned row fields has no row-value evidence',
|
|
1069
|
+
'read visibility status is fieldPermits read:false; FIELD_PERMISSION_REQUIRED is only a write preflight error',
|
|
1070
|
+
];
|
|
1071
|
+
if (action === 'get') {
|
|
1072
|
+
const record = isRecord(data) && isRecord(data.record) ? data.record : undefined;
|
|
1073
|
+
const returnedFieldIds = numericFieldKeys(record?.fields);
|
|
1074
|
+
const permits = rowFieldPermits(responseBody);
|
|
1075
|
+
const permitFieldIds = permits.map(permitFieldId).filter((id) => Boolean(id));
|
|
1076
|
+
const deniedReadFieldIds = permits
|
|
1077
|
+
.filter((permit) => permit.read === false)
|
|
1078
|
+
.map(permitFieldId)
|
|
1079
|
+
.filter((id) => Boolean(id))
|
|
1080
|
+
.sort((left, right) => Number(left) - Number(right));
|
|
1081
|
+
const notReturnedFieldIds = permitFieldIds
|
|
1082
|
+
.filter((id) => !returnedFieldIds.includes(id))
|
|
1083
|
+
.sort((left, right) => Number(left) - Number(right));
|
|
1084
|
+
return {
|
|
1085
|
+
source: 'row get record.fields',
|
|
1086
|
+
returnedFieldIds,
|
|
1087
|
+
notReturnedFieldIds,
|
|
1088
|
+
deniedReadFieldIds,
|
|
1089
|
+
absentFieldMeaning: 'not returned to current user; no row-value evidence',
|
|
1090
|
+
rules,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
if (action === 'query') {
|
|
1094
|
+
const items = isRecord(data) && Array.isArray(data.items) ? data.items : [];
|
|
1095
|
+
return {
|
|
1096
|
+
source: 'row query items[].fields',
|
|
1097
|
+
rows: items.filter((item) => isRecord(item)).map((item) => ({
|
|
1098
|
+
rowId: item.id,
|
|
1099
|
+
returnedFieldIds: numericFieldKeys(item.fields),
|
|
1100
|
+
})),
|
|
1101
|
+
absentFieldMeaning: 'not returned to current user; no row-value evidence',
|
|
1102
|
+
rules,
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
return undefined;
|
|
1106
|
+
}
|
|
941
1107
|
function requiredRowAction(command, flags) {
|
|
942
1108
|
if (command.commandPath[0] !== 'row')
|
|
943
1109
|
return undefined;
|
|
@@ -1155,6 +1321,235 @@ async function requestJSON(runtimeEnv, method, endpoint, body, fetchImpl) {
|
|
|
1155
1321
|
}
|
|
1156
1322
|
return { status: response.status, data: parsedResponse };
|
|
1157
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
|
+
}
|
|
1158
1553
|
function extractCreatedTableID(value) {
|
|
1159
1554
|
const candidates = [
|
|
1160
1555
|
value,
|
|
@@ -1262,6 +1657,15 @@ export async function executeCLI(scope, argv, env, fetchImpl = fetch) {
|
|
|
1262
1657
|
}
|
|
1263
1658
|
validateKnownFlags(command, parsed.flags, envInput);
|
|
1264
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
|
+
}
|
|
1265
1669
|
const endpoint = resolveEndpoint(command, parsed.flags);
|
|
1266
1670
|
const scalarQuery = buildScalarQuery(command, parsed.flags);
|
|
1267
1671
|
const fileQuery = loadQueryFromFlags(parsed.flags);
|
|
@@ -1272,7 +1676,7 @@ export async function executeCLI(scope, argv, env, fetchImpl = fetch) {
|
|
|
1272
1676
|
}
|
|
1273
1677
|
catch (error) {
|
|
1274
1678
|
if (error instanceof CLIError && error.code === 'INVALID_BODY_JSON' && command.requestType) {
|
|
1275
|
-
throw new CLIError('INVALID_BODY_JSON', `body json is invalid for ${scope}.${command.commandPath.join(' ')};
|
|
1679
|
+
throw new CLIError('INVALID_BODY_JSON', `body json is invalid for ${scope}.${command.commandPath.join(' ')}; retry with retryToolCalls[0] or retryToolCalls[1] when present`, 2, buildBodyValidationDetails(scope, command, command.requestType, [{ path: 'body', message: 'invalid JSON syntax' }], runtimeEnv));
|
|
1276
1680
|
}
|
|
1277
1681
|
throw error;
|
|
1278
1682
|
}
|
|
@@ -1381,10 +1785,12 @@ export async function executeCLI(scope, argv, env, fetchImpl = fetch) {
|
|
|
1381
1785
|
: {}),
|
|
1382
1786
|
});
|
|
1383
1787
|
}
|
|
1788
|
+
const rowValueEvidence = buildRowValueEvidence(command, parsedResponse);
|
|
1384
1789
|
return {
|
|
1385
1790
|
kind: 'result',
|
|
1386
1791
|
commandPath: command.commandPath,
|
|
1387
1792
|
status: response.status,
|
|
1388
1793
|
data: parsedResponse,
|
|
1794
|
+
...(rowValueEvidence ? { rowValueEvidence } : {}),
|
|
1389
1795
|
};
|
|
1390
1796
|
}
|
|
@@ -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
|
+
}
|