@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/src/runtime/execute.ts
CHANGED
|
@@ -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
|
|
125
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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: {
|
|
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(' ')};
|
|
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
|
+
}
|