@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.
- package/bundle/arcubase-admin.mjs +14934 -3808
- package/bundle/arcubase.mjs +14934 -3808
- 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/execute.d.ts.map +1 -1
- package/dist/runtime/execute.js +309 -5
- 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 +2 -2
- package/package.json +7 -7
- package/sdk-dist/docs/runtime-reference/README.md +6 -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 +55 -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/execute.ts +324 -6
- 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 +231 -5
- package/src/tests/help.test.ts +14 -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
|
|
@@ -142,6 +146,48 @@ export function renderCommandHelp(scope: CommandScope, moduleName: string, comma
|
|
|
142
146
|
' - If result.enabled is false, enable the rule first and retry assign-users',
|
|
143
147
|
]
|
|
144
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 is accepted for compatibility; imported app name comes from the bundle source app',
|
|
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, name, 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
|
+
: []),
|
|
145
191
|
...(docHints.length > 0
|
|
146
192
|
? ['docs:', ...docHints.map((item) => ` - ${item.title}: ${item.file}`)]
|
|
147
193
|
: []),
|
|
@@ -232,6 +278,18 @@ function buildUnknownFlagDetails(
|
|
|
232
278
|
operation,
|
|
233
279
|
hint: `run ${operation} --help for the supported flags`,
|
|
234
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
|
+
}
|
|
235
293
|
if (command.requestType) {
|
|
236
294
|
details.requestType = command.requestType
|
|
237
295
|
details.tsHints = getTypeHints(command.requestType, env)
|
|
@@ -243,8 +301,7 @@ function buildUnknownFlagDetails(
|
|
|
243
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()]
|
|
244
302
|
}
|
|
245
303
|
if (command.commandPath[0] === 'row' && command.commandPath[1] === 'query' && ['limit', 'offset', 'search', 'sorts', 'view-mode'].includes(flag)) {
|
|
246
|
-
details
|
|
247
|
-
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'))
|
|
248
305
|
}
|
|
249
306
|
if (command.commandPath[0] === 'table' && command.commandPath[1] === 'dataset' && ['app-id', 'table-id'].includes(flag)) {
|
|
250
307
|
details.hint = 'table dataset uses --dataset-id and --node-id; use table get for app/table metadata'
|
|
@@ -537,7 +594,7 @@ function buildTableCreateExampleBody() {
|
|
|
537
594
|
depends: [],
|
|
538
595
|
key: 'vote_description',
|
|
539
596
|
code_index: false,
|
|
540
|
-
options: { placeholder: '', html: false, size:
|
|
597
|
+
options: { placeholder: '', html: false, size: 6, lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
|
|
541
598
|
transfers: null,
|
|
542
599
|
children: null,
|
|
543
600
|
},
|
|
@@ -555,6 +612,10 @@ function isTableCreateCommand(scope: CommandScope, command: NonNullable<ReturnTy
|
|
|
555
612
|
return scope === 'admin' && command.commandPath[0] === 'table' && command.commandPath[1] === 'create'
|
|
556
613
|
}
|
|
557
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
|
+
|
|
558
619
|
function isTableSchemaCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
|
|
559
620
|
return scope === 'admin' && command.commandPath[0] === 'table' && (command.commandPath[1] === 'create' || command.commandPath[1] === 'update-schema')
|
|
560
621
|
}
|
|
@@ -697,7 +758,7 @@ function buildBodyGuidance(requestType: string): Pick<CLIErrorDetails, 'retryToo
|
|
|
697
758
|
depends: [],
|
|
698
759
|
key: 'description',
|
|
699
760
|
code_index: false,
|
|
700
|
-
options: { placeholder: '', html: false, size:
|
|
761
|
+
options: { placeholder: '', html: false, size: 6, lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
|
|
701
762
|
transfers: null,
|
|
702
763
|
children: null,
|
|
703
764
|
},
|
|
@@ -807,15 +868,21 @@ function buildBodyGuidance(requestType: string): Pick<CLIErrorDetails, 'retryToo
|
|
|
807
868
|
shapeHint: {
|
|
808
869
|
limit: 20,
|
|
809
870
|
offset: 0,
|
|
810
|
-
search: {
|
|
871
|
+
search: { q: 'SO-1001' },
|
|
872
|
+
sorts: [{ column: ':1001', order: 'asc' }],
|
|
811
873
|
},
|
|
812
874
|
commonMistakes: [
|
|
813
875
|
'limit, offset, search, sorts, and view_mode belong inside --body-json',
|
|
814
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',
|
|
815
880
|
'do not add access policy fields; the CLI resolves access policy internally',
|
|
816
881
|
],
|
|
817
882
|
suggestions: [
|
|
818
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"}}\'',
|
|
819
886
|
],
|
|
820
887
|
}
|
|
821
888
|
}
|
|
@@ -1588,6 +1655,247 @@ async function requestJSON(
|
|
|
1588
1655
|
return { status: response.status, data: parsedResponse }
|
|
1589
1656
|
}
|
|
1590
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
|
+
|
|
1591
1899
|
function extractCreatedTableID(value: unknown): number {
|
|
1592
1900
|
const candidates = [
|
|
1593
1901
|
value,
|
|
@@ -1725,6 +2033,16 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
|
|
|
1725
2033
|
|
|
1726
2034
|
const runtimeEnv = env ?? loadRuntimeEnv(scope)
|
|
1727
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
|
+
|
|
1728
2046
|
const endpoint = resolveEndpoint(command, parsed.flags)
|
|
1729
2047
|
const scalarQuery = buildScalarQuery(command, parsed.flags)
|
|
1730
2048
|
const fileQuery = loadQueryFromFlags(parsed.flags)
|
|
@@ -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
|
+
}
|
|
@@ -11,10 +11,12 @@ test('admin surface is restricted to reset white list', () => {
|
|
|
11
11
|
const actual = commandPaths(adminCommands)
|
|
12
12
|
const expected = [
|
|
13
13
|
'app create',
|
|
14
|
+
'app create-from-bundle-url',
|
|
14
15
|
'app delete',
|
|
15
16
|
'app get',
|
|
16
17
|
'app list',
|
|
17
18
|
'app update',
|
|
19
|
+
'app watch-import-task',
|
|
18
20
|
'table create',
|
|
19
21
|
'table delete',
|
|
20
22
|
'table get',
|
|
@@ -45,6 +47,8 @@ test('user surface is restricted to reset white list', () => {
|
|
|
45
47
|
'row create',
|
|
46
48
|
'row delete',
|
|
47
49
|
'row get',
|
|
50
|
+
'row import-csv',
|
|
51
|
+
'row import-excel',
|
|
48
52
|
'row query',
|
|
49
53
|
'row selection-action',
|
|
50
54
|
'row update',
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { compileCSVImportMapping, compileExcelImportMapping } from '../runtime/entity_import_mapping.js'
|
|
4
|
+
import { CLIError } from '../runtime/errors.js'
|
|
5
|
+
|
|
6
|
+
test('excel mapping compiles column letters to backend mapper', () => {
|
|
7
|
+
const result = compileExcelImportMapping({
|
|
8
|
+
sheet: 'Sheet1',
|
|
9
|
+
mode: 'addorupdate',
|
|
10
|
+
headerRow: 1,
|
|
11
|
+
dataStartRow: 2,
|
|
12
|
+
recordId: { column: 'A' },
|
|
13
|
+
fields: [
|
|
14
|
+
{ fieldId: 1001, column: 'B' },
|
|
15
|
+
{ fieldId: 1002, column: 'AA' },
|
|
16
|
+
],
|
|
17
|
+
})
|
|
18
|
+
assert.deepEqual(result, {
|
|
19
|
+
fileFormat: 'excel',
|
|
20
|
+
importMode: 'addorupdate',
|
|
21
|
+
workingSheet: 'Sheet1',
|
|
22
|
+
titleRowIndex: 0,
|
|
23
|
+
mapper: {
|
|
24
|
+
'$id': 0,
|
|
25
|
+
'1001': 1,
|
|
26
|
+
'1002': 26,
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('excel mapping rejects string field id', () => {
|
|
32
|
+
assert.throws(() => {
|
|
33
|
+
compileExcelImportMapping({
|
|
34
|
+
sheet: 'Sheet1',
|
|
35
|
+
mode: 'addonly',
|
|
36
|
+
dataStartRow: 2,
|
|
37
|
+
fields: [{ fieldId: '1001', column: 'A' }],
|
|
38
|
+
} as any)
|
|
39
|
+
}, (error: unknown) => {
|
|
40
|
+
assert.ok(error instanceof CLIError)
|
|
41
|
+
assert.equal(error.code, 'INVALID_IMPORT_MAPPING')
|
|
42
|
+
assert.match(error.message, /fieldId must be a number/)
|
|
43
|
+
return true
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('csv mapping resolves headers to backend mapper', () => {
|
|
48
|
+
const result = compileCSVImportMapping({
|
|
49
|
+
mode: 'updateonly',
|
|
50
|
+
header: true,
|
|
51
|
+
dataStartRow: 2,
|
|
52
|
+
delimiter: ',',
|
|
53
|
+
recordId: { header: 'ID' },
|
|
54
|
+
fields: [
|
|
55
|
+
{ fieldId: 1001, header: '客户名称' },
|
|
56
|
+
{ fieldId: 1002, header: '手机号' },
|
|
57
|
+
],
|
|
58
|
+
}, ['ID', '客户名称', '手机号'])
|
|
59
|
+
assert.deepEqual(result, {
|
|
60
|
+
fileFormat: 'csv',
|
|
61
|
+
importMode: 'updateonly',
|
|
62
|
+
titleRowIndex: 0,
|
|
63
|
+
csvDelimiter: ',',
|
|
64
|
+
mapper: {
|
|
65
|
+
'$id': 0,
|
|
66
|
+
'1001': 1,
|
|
67
|
+
'1002': 2,
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('csv mapping missing header lists available headers', () => {
|
|
73
|
+
assert.throws(() => {
|
|
74
|
+
compileCSVImportMapping({
|
|
75
|
+
mode: 'addonly',
|
|
76
|
+
header: true,
|
|
77
|
+
dataStartRow: 2,
|
|
78
|
+
fields: [{ fieldId: 1001, header: 'missing' }],
|
|
79
|
+
}, ['客户名称', '手机号'])
|
|
80
|
+
}, (error: unknown) => {
|
|
81
|
+
assert.ok(error instanceof CLIError)
|
|
82
|
+
assert.equal(error.code, 'INVALID_IMPORT_MAPPING')
|
|
83
|
+
assert.match(error.message, /missing/)
|
|
84
|
+
assert.ok(error.details?.suggestions?.some((item) => item.includes('客户名称')))
|
|
85
|
+
return true
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -65,6 +65,46 @@ test('EntitySaveReqVO normalizes mechanical FieldVO defaults', () => {
|
|
|
65
65
|
assert.deepEqual(parsed.data.fields[0].depends, [])
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
+
test('EntitySaveReqVO rejects textarea size as string', () => {
|
|
69
|
+
assert.ok(schema)
|
|
70
|
+
const parsed = schema!.safeParse({
|
|
71
|
+
name: '订单',
|
|
72
|
+
field_id_seq: 1003,
|
|
73
|
+
layout: [[1001], [1002]],
|
|
74
|
+
fields: [
|
|
75
|
+
buildMinimalField(1001),
|
|
76
|
+
{
|
|
77
|
+
...buildMinimalField(1002),
|
|
78
|
+
label: '描述',
|
|
79
|
+
type: 'textarea',
|
|
80
|
+
key: 'description',
|
|
81
|
+
options: { placeholder: '', html: false, size: 'default', lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
})
|
|
85
|
+
assert.equal(parsed.success, false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('EntitySaveReqVO accepts textarea size as number', () => {
|
|
89
|
+
assert.ok(schema)
|
|
90
|
+
const parsed = schema!.safeParse({
|
|
91
|
+
name: '订单',
|
|
92
|
+
field_id_seq: 1003,
|
|
93
|
+
layout: [[1001], [1002]],
|
|
94
|
+
fields: [
|
|
95
|
+
buildMinimalField(1001),
|
|
96
|
+
{
|
|
97
|
+
...buildMinimalField(1002),
|
|
98
|
+
label: '描述',
|
|
99
|
+
type: 'textarea',
|
|
100
|
+
key: 'description',
|
|
101
|
+
options: { placeholder: '', html: false, size: 6, lengthLimit: false, lengthMin: 0, lengthMax: 2000 },
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
})
|
|
105
|
+
assert.equal(parsed.success, true)
|
|
106
|
+
})
|
|
107
|
+
|
|
68
108
|
test('EntitySaveReqVO rejects fields as object', () => {
|
|
69
109
|
assert.ok(schema)
|
|
70
110
|
const parsed = schema!.safeParse({
|