@finebi/finebi-cli 1.5.0 → 1.6.0
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/README.md +10 -0
- package/package.json +4 -3
- package/skills/finebi-cli-agent/SKILL.md +8 -4
- package/skills/finebi-cli-agent/references/command-workflows.md +7 -3
- package/skills/finebi-cli-agent/references/dashboard-data-result.md +191 -191
- package/skills/finebi-cli-agent/references/dashboard-dsl-api.md +8 -1018
- package/skills/finebi-cli-agent/references/dashboard-dsl-core.md +194 -0
- package/skills/finebi-cli-agent/references/dashboard-dsl-examples.md +378 -0
- package/skills/finebi-cli-agent/references/dashboard-dsl-items.md +456 -0
- package/skills/finebi-cli-agent/references/mapping-rules.md +14 -14
- package/skills/finebi-cli-agent/references/scenario-workflows.md +1 -1
- package/skills/finebi-cli-agent/references/web-app.md +13 -0
- package/skills/finebi-cli-agent/references/widget-dsl-api-examples.md +3 -4
- package/skills/finebi-cli-agent/references/widget-dsl-api.md +10 -1374
- package/skills/finebi-cli-agent/references/widget-dsl-contexts.md +142 -0
- package/skills/finebi-cli-agent/references/widget-dsl-core.md +374 -0
- package/skills/finebi-cli-agent/references/widget-dsl-encodings.md +457 -0
- package/skills/finebi-cli-agent/references/widget-dsl-styles.md +413 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +189 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/config.js +27 -27
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +78 -55
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/index.js +5 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/install-skill.js +89 -89
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/profiles.js +158 -158
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/skill-files.js +94 -94
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +27 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/README.md +30 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/auth-and-deploy.md +17 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/data-contract.md +21 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/design.md +13 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/filters.md +89 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/index.html +2 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/package-lock.json +2191 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/package.json +15 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/public/app-config.json +11 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-proxy.mjs +56 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +31 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +31 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx +14 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/FilterBar.tsx +42 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +28 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts +14 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/client.ts +37 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.d.ts +1 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.js +5 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.d.ts +4 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.js +106 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +77 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +18 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/main.tsx +5 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts +18 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/styles.css +8 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/calendar.test.mjs +25 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/proxy.test.mjs +84 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/runtime.test.mjs +136 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tsconfig.json +4 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +13 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli-runner.mjs +4 -4
- package/src/app.js +189 -0
- package/src/config.js +27 -27
- package/src/help.js +78 -55
- package/src/index.js +5 -0
- package/src/install-skill.js +89 -89
- package/src/profiles.js +158 -158
- package/src/skill-files.js +94 -94
- package/templates/web-app/AGENTS.md +27 -0
- package/templates/web-app/README.md +30 -0
- package/templates/web-app/docs/auth-and-deploy.md +17 -0
- package/templates/web-app/docs/data-contract.md +21 -0
- package/templates/web-app/docs/design.md +13 -0
- package/templates/web-app/docs/filters.md +89 -0
- package/templates/web-app/index.html +2 -0
- package/templates/web-app/package-lock.json +2191 -0
- package/templates/web-app/package.json +15 -0
- package/templates/web-app/public/app-config.json +11 -0
- package/templates/web-app/scripts/dev-proxy.mjs +56 -0
- package/templates/web-app/src/App.tsx +31 -0
- package/templates/web-app/src/components/Chart.tsx +31 -0
- package/templates/web-app/src/components/DataTable.tsx +14 -0
- package/templates/web-app/src/components/FilterBar.tsx +42 -0
- package/templates/web-app/src/components/Widget.tsx +28 -0
- package/templates/web-app/src/components/calendar.ts +14 -0
- package/templates/web-app/src/finebi/client.ts +37 -0
- package/templates/web-app/src/finebi/date.d.ts +1 -0
- package/templates/web-app/src/finebi/date.js +5 -0
- package/templates/web-app/src/finebi/filters.d.ts +4 -0
- package/templates/web-app/src/finebi/filters.js +106 -0
- package/templates/web-app/src/finebi/normalize.ts +77 -0
- package/templates/web-app/src/finebi/types.ts +18 -0
- package/templates/web-app/src/main.tsx +5 -0
- package/templates/web-app/src/services/view-data.ts +18 -0
- package/templates/web-app/src/styles.css +8 -0
- package/templates/web-app/tests/calendar.test.mjs +25 -0
- package/templates/web-app/tests/proxy.test.mjs +84 -0
- package/templates/web-app/tests/runtime.test.mjs +136 -0
- package/templates/web-app/tsconfig.json +4 -0
- package/templates/web-app/vite.config.ts +13 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import {createServer} from 'node:http';
|
|
4
|
+
import {createDevMiddleware} from '../scripts/dev-proxy.mjs';
|
|
5
|
+
|
|
6
|
+
test('development identity stays on server and cross-site or out-of-scope requests are rejected', async () => {
|
|
7
|
+
const upstream = [];
|
|
8
|
+
const middleware = createDevMiddleware({basePath:'/webroot/decision',dashboardId:'r',widgets:[{entryId:'w'}],maxRows:1000}, {FINEBI_DEV_BASE_URL:'http://bi.example/webroot/decision',FINEBI_DEV_USERNAME:'tester',FINEBI_DEV_PASSWORD:'dev-secret'}, async (url,init) => {
|
|
9
|
+
upstream.push({url,init});
|
|
10
|
+
return new Response(JSON.stringify(url.endsWith('/login') ? {data:{accessToken:'session-secret'}} : {success:true,data:{w:{previewResult:{resultType:'DETAIL'}}}}));
|
|
11
|
+
});
|
|
12
|
+
const server=createServer((req,res)=>middleware(req,res,()=>{res.writeHead(404);res.end();}));
|
|
13
|
+
await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
|
14
|
+
const base=`http://127.0.0.1:${server.address().port}`;
|
|
15
|
+
const path='/webroot/decision/v5/api/dashboard/report/r/widgets/query';
|
|
16
|
+
const request=(body,headers={})=>fetch(base+path,{method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest',...headers},body:JSON.stringify(body)});
|
|
17
|
+
try {
|
|
18
|
+
const valid={widgetIds:['w'],pageLimit:1000};
|
|
19
|
+
assert.equal((await request(valid,{Origin:'https://evil.example'})).status,403);
|
|
20
|
+
assert.equal((await request({...valid,widgetIds:['other']})).status,400);
|
|
21
|
+
assert.equal((await request({...valid,pageLimit:10001})).status,400);
|
|
22
|
+
assert.equal((await request({...valid,refer:{'未配置的控件':['x']}})).status,400);
|
|
23
|
+
assert.equal(upstream.length,0);
|
|
24
|
+
const result=await request(valid,{Origin:base});
|
|
25
|
+
assert.equal(result.status,200);
|
|
26
|
+
assert.doesNotMatch(await result.text(),/secret/);
|
|
27
|
+
assert.equal(upstream.length,2);
|
|
28
|
+
assert.equal(upstream[1].init.headers.Cookie,'fine_auth_token=session-secret');
|
|
29
|
+
assert.equal(upstream[1].init.redirect,'manual');
|
|
30
|
+
assert.equal((await fetch(base+'/webroot/decision/login',{method:'POST'})).status,403);
|
|
31
|
+
assert.equal((await request(valid)).status,200);
|
|
32
|
+
assert.equal(upstream.length,3,'reuse only the in-memory development session');
|
|
33
|
+
} finally {await new Promise(r=>server.close(r));}
|
|
34
|
+
});
|
|
35
|
+
test('HTML login response clears expired development session and explicit retry logs in again', async()=>{
|
|
36
|
+
let logins=0, queries=0;
|
|
37
|
+
const middleware=createDevMiddleware({basePath:'/webroot/decision',dashboardId:'r',widgets:[{entryId:'w'}],filters:[],maxRows:1000},{FINEBI_DEV_BASE_URL:'http://bi.example/webroot/decision',FINEBI_DEV_USERNAME:'tester',FINEBI_DEV_PASSWORD:'secret'},async url=>{
|
|
38
|
+
if(url.endsWith('/login')){logins++;return new Response(JSON.stringify({data:{accessToken:'token-'+logins}}));}
|
|
39
|
+
if(++queries===1)return new Response('<html>登录</html>',{headers:{'content-type':'text/html'}});
|
|
40
|
+
return new Response(JSON.stringify({success:true,data:{w:{previewResult:{resultType:'DETAIL'}}}}));
|
|
41
|
+
});
|
|
42
|
+
const server=createServer((req,res)=>middleware(req,res,()=>{}));await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
|
43
|
+
const send=()=>fetch(`http://127.0.0.1:${server.address().port}/webroot/decision/v5/api/dashboard/report/r/widgets/query`,{method:'POST',headers:{'X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({widgetIds:['w'],pageLimit:10})});
|
|
44
|
+
try{const expired=await send();assert.equal(expired.status,401);assert.equal((await expired.json()).errorCode,'AUTH_REQUIRED');assert.equal((await send()).status,200);assert.equal(logins,2);}finally{await new Promise(r=>server.close(r));}
|
|
45
|
+
});
|
|
46
|
+
test('missing credentials gives an explicit auth error without upstream access',async()=>{
|
|
47
|
+
const middleware=createDevMiddleware({basePath:'/webroot/decision',dashboardId:'r',widgets:[{entryId:'w'}],maxRows:1000},{},()=>{throw Error('must not fetch');});
|
|
48
|
+
const server=createServer((req,res)=>middleware(req,res,()=>{}));
|
|
49
|
+
await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
|
50
|
+
try {
|
|
51
|
+
const r=await fetch(`http://127.0.0.1:${server.address().port}/webroot/decision/v5/api/dashboard/report/r/widgets/query`,{method:'POST',headers:{'X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({widgetIds:['w'],pageLimit:10})});
|
|
52
|
+
assert.equal(r.status,401);assert.equal((await r.json()).errorCode,'AUTH_REQUIRED');
|
|
53
|
+
}finally{await new Promise(r=>server.close(r));}
|
|
54
|
+
});
|
|
55
|
+
test('date proxy accepts real dates without an option allowlist and rejects malformed overrides',async()=>{
|
|
56
|
+
const calls=[];
|
|
57
|
+
const middleware=createDevMiddleware({basePath:'/bi',dashboardId:'r',widgets:[{entryId:'w'}],maxRows:1000,filters:[{name:'日期',label:'日期',type:'date'},{name:'产品',label:'产品',options:[{label:'橡皮',value:'橡皮'}]}]},
|
|
58
|
+
{FINEBI_DEV_BASE_URL:'http://bi.example/bi',FINEBI_DEV_TOKEN:'secret'},async (_url,init)=>{calls.push(JSON.parse(init.body));return new Response(JSON.stringify({success:true,data:{}}));});
|
|
59
|
+
const server=createServer((req,res)=>middleware(req,res,()=>{}));await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
|
60
|
+
const send=refer=>fetch(`http://127.0.0.1:${server.address().port}/bi/v5/api/dashboard/report/r/widgets/query`,{method:'POST',headers:{'X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({widgetIds:['w'],pageLimit:1000,refer})});
|
|
61
|
+
try {
|
|
62
|
+
for(const refer of [{'日期':['2023-02-29']},{'日期':['2024-02-30']},{'日期':['0000-01-01']},{'日期':['2024-2-01']},{'日期':['']},{'日期':['2024-01-01','2024-02-01']},{'日期':[42]},{'未知':['2024-01-01']},{'产品':['未知']}]) assert.equal((await send(refer)).status,400);
|
|
63
|
+
assert.equal(calls.length,0);
|
|
64
|
+
const refer={'日期':['2024-02-29'],'产品':['橡皮']};assert.equal((await send(refer)).status,200);assert.deepEqual(calls[0].refer,refer);
|
|
65
|
+
assert.equal((await send({})).status,200);assert.deepEqual(calls[1].refer,{});
|
|
66
|
+
}finally{await new Promise(r=>server.close(r));}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('proxy applies the same typed contract to ranges, multi-select, null and unsupported raw values',async()=>{
|
|
70
|
+
const calls=[];
|
|
71
|
+
const middleware=createDevMiddleware({basePath:'/bi',dashboardId:'r',widgets:[{entryId:'w'}],maxRows:1000,filters:[
|
|
72
|
+
{name:'产品',label:'产品',type:'multi-select',allowNull:true,options:[{label:'A',value:'A'},{label:'B',value:'B'}]},
|
|
73
|
+
{name:'日期',label:'日期',type:'date-range'}, {name:'金额',label:'金额',type:'number'},
|
|
74
|
+
]},{FINEBI_DEV_BASE_URL:'http://bi.example/bi',FINEBI_DEV_TOKEN:'secret'},async (_url,init)=>{calls.push(JSON.parse(init.body));return new Response(JSON.stringify({success:true,data:{}}));});
|
|
75
|
+
const server=createServer((req,res)=>middleware(req,res,()=>{}));await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
|
76
|
+
const send=refer=>fetch(`http://127.0.0.1:${server.address().port}/bi/v5/api/dashboard/report/r/widgets/query`,{method:'POST',headers:{'X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({widgetIds:['w'],pageLimit:10,refer})});
|
|
77
|
+
try {
|
|
78
|
+
for(const refer of [{'产品':['A','B']},{'产品':['A,C']},{'产品':['A,A']},{'产品':['A,']},{'日期_s':['2024-03-01'],'日期_e':['2024-02-29']},{'日期':['2024-01-01']},{'金额':['10,20']},null]) assert.equal((await send(refer)).status,400);
|
|
79
|
+
assert.equal(calls.length,0);
|
|
80
|
+
for(const refer of [{'产品':['A,B'],'日期_s':['2024-02-29'],'金额':['-0.25']},{'产品':['null']},{}]) {
|
|
81
|
+
assert.equal((await send(refer)).status,200);assert.deepEqual(calls.at(-1).refer,refer);
|
|
82
|
+
}
|
|
83
|
+
}finally{await new Promise(r=>server.close(r));}
|
|
84
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {after, test} from 'node:test';
|
|
3
|
+
import {createServer} from 'vite';
|
|
4
|
+
const server = await createServer({configFile: false, cacheDir: 'temp/vite-tests', server: {middlewareMode: true, hmr: false}});
|
|
5
|
+
after(() => server.close());
|
|
6
|
+
const {normalize} = await server.ssrLoadModule('/src/finebi/normalize.ts');
|
|
7
|
+
const {queryWidgets, FineBIError} = await server.ssrLoadModule('/src/finebi/client.ts');
|
|
8
|
+
const {chartValues, selectDataset} = await server.ssrLoadModule('/src/services/view-data.ts');
|
|
9
|
+
const detail = () => ({previewResult: {resultType: 'DETAIL', header: [
|
|
10
|
+
{dId: 'd', text: '重复名'}, {dId: 'n', text: '重复名'}, {dId: 't', text: '日期'}
|
|
11
|
+
], items: [[{value: 'A', text: 'A', authority: true}, {value: '1234.50', formatText: '1,234.50元', authority: true}, {value: '1349020800000', formatText: '2012-10-01', authority: true}]], row: 1000, size: 1, hasNext: true}, widgetBean: {dimensions: {d: {type: 1}, n: {type: 2}, t: {type: 3}}}});
|
|
12
|
+
test('detail preserves field identity, display, decimal and date values with honest range', () => {
|
|
13
|
+
const r = normalize(detail());
|
|
14
|
+
assert.equal(r.schemaVersion, '1');
|
|
15
|
+
assert.equal(r.normalization, 'COMPLETE');
|
|
16
|
+
assert.deepEqual(r.datasets[0].columns.map(c => c.key), ['c0','c1','c2']);
|
|
17
|
+
assert.deepEqual(r.datasets[0].rows[0].cells.map(c => c.value), ['A','1234.50','1349020800000']);
|
|
18
|
+
assert.equal(r.datasets[0].rows[0].cells[1].display, '1,234.50元');
|
|
19
|
+
assert.equal(r.range.total.value, 1000);
|
|
20
|
+
assert.ok(r.warnings.includes('NEXT_PAGE_UNSUPPORTED'));
|
|
21
|
+
});
|
|
22
|
+
test('restricted, display-only, missing, unknown and invalid dates cannot become computable values', () => {
|
|
23
|
+
const source = detail();
|
|
24
|
+
source.previewResult.items = [[{value: 'secret', text: '***', authority: false}, {value: null, text: '20%', authority: true}, {value: '9223372036854775808', authority: true}], []];
|
|
25
|
+
const r = normalize(source);
|
|
26
|
+
assert.equal(r.normalization, 'PARTIAL');
|
|
27
|
+
const cells = r.datasets[0].rows[0].cells;
|
|
28
|
+
assert.equal(cells[0].state, 'RESTRICTED');
|
|
29
|
+
assert.equal(cells[0].value, null);
|
|
30
|
+
assert.equal(cells[1].state, 'UNAVAILABLE');
|
|
31
|
+
assert.equal(cells[2].reason, 'INVALID_TIMESTAMP_VALUE');
|
|
32
|
+
delete source.widgetBean;
|
|
33
|
+
assert.equal(normalize(source).datasets[0].rows[0].cells[1].value, null);
|
|
34
|
+
});
|
|
35
|
+
test('full-looking page does not discard native hasNext; empty is different from unsupported', () => {
|
|
36
|
+
const source = detail(); source.previewResult.row = 1;
|
|
37
|
+
assert.ok(normalize(source).warnings.includes('NEXT_PAGE_UNSUPPORTED'));
|
|
38
|
+
source.previewResult.items = []; source.previewResult.row = 0; source.previewResult.hasNext = false;
|
|
39
|
+
assert.equal(normalize(source).datasets[0].returnedRowCount, 0);
|
|
40
|
+
assert.equal(normalize({previewResult: {resultType: 'BICROSS'}}).normalization, 'UNSUPPORTED');
|
|
41
|
+
});
|
|
42
|
+
test('chart permission placeholders and all supported detail date metadata preserve their semantics', () => {
|
|
43
|
+
const r=normalize({previewResult:{resultType:'CHART',geoms:[{dataModel:{fields:[{id:'n',name:'指标',fieldType:'MEASURE'}],colData:[['***']],rowCount:1}}]}});
|
|
44
|
+
assert.equal(r.datasets[0].rows[0].cells[0].state,'RESTRICTED');
|
|
45
|
+
for(const type of [3,13,15,20]) {const s=detail();s.widgetBean.dimensions.t.type=type;assert.equal(normalize(s).datasets[0].columns[2].type,'DATE');}
|
|
46
|
+
});
|
|
47
|
+
test('chart preserves multiple models, rejects formatted numeric strings and mismatched columns', () => {
|
|
48
|
+
const model = {fields: [{id:'x',name:'地区',fieldType:'DIMENSION'}, {id:'y',name:'金额',fieldType:'MEASURE'}], colData: [['East','West'], [12,'1,234']],rowCount:2};
|
|
49
|
+
const r = normalize({previewResult:{resultType:'CHART',geoms:[{dataModel:model},{dataModel:{...model,colData:[['A'],[]],rowCount:1}}]}});
|
|
50
|
+
assert.equal(r.datasets.length,2);
|
|
51
|
+
assert.equal(r.datasets[0].rows[0].cells[1].value,'12');
|
|
52
|
+
assert.equal(r.datasets[0].rows[1].cells[1].state,'UNAVAILABLE');
|
|
53
|
+
assert.equal(r.normalization,'PARTIAL');
|
|
54
|
+
});
|
|
55
|
+
const config = {schemaVersion:'1',basePath:'/webroot/decision',dashboardId:'report',maxRows:1000,widgets:[{entryId:'table',type:'table',name:'明细'}],filters:[{name:'地区',label:'地区',options:[{label:'西部',value:'West'}]}]};
|
|
56
|
+
test('query sends scoped refer, cookie credentials and bounded pageLimit without browser token storage', async () => {
|
|
57
|
+
let sent;
|
|
58
|
+
const r = await queryWidgets(config, {'地区':'West'}, undefined, async (url, init) => {
|
|
59
|
+
sent = {url, ...init};
|
|
60
|
+
return new Response(JSON.stringify({success:true,data:{table:detail()}}));
|
|
61
|
+
});
|
|
62
|
+
assert.equal(sent.credentials,'include');
|
|
63
|
+
assert.equal(sent.headers['X-Requested-With'],'XMLHttpRequest');
|
|
64
|
+
assert.deepEqual(JSON.parse(sent.body).refer, {'地区':['West']});
|
|
65
|
+
assert.equal(JSON.parse(sent.body).pageLimit,1000);
|
|
66
|
+
assert.equal(sent.headers.Authorization,undefined);
|
|
67
|
+
assert.equal(r.table.datasets[0].returnedRowCount,1);
|
|
68
|
+
await assert.rejects(queryWidgets({...config,maxRows:10001},{},undefined,()=>{throw new Error('must not fetch');}), /maxRows/);
|
|
69
|
+
await assert.rejects(queryWidgets(config,{'未知':'x'}), /筛选/);
|
|
70
|
+
});
|
|
71
|
+
test('auth expiry, permission denial, login HTML and missing widget stay distinct from empty data', async () => {
|
|
72
|
+
for (const [response, code] of [[new Response('',{status:302,headers:{Location:'/login'}}),'AUTH_REQUIRED'],[new Response('',{status:401}),'AUTH_REQUIRED'],[new Response('',{status:403}),'PERMISSION_DENIED'],[new Response(JSON.stringify({errorCode:'TIME_OUT'})),'AUTH_REQUIRED'],[new Response('<html>login</html>',{headers:{'content-type':'text/html'}}),'AUTH_REQUIRED'],[new Response(JSON.stringify({success:true,data:{}})),'INVALID_RESPONSE']]) {
|
|
73
|
+
await assert.rejects(queryWidgets(config,{},undefined,async()=>response), e=>e instanceof FineBIError && e.code===code);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
test('view binding requires exact fields and an explicit model for multi-model results', () => {
|
|
77
|
+
const source = {previewResult:{resultType:'CHART',geoms:[{dataModel:{fields:[{id:'x',name:'地区',fieldType:'DIMENSION'},{id:'y',name:'金额',fieldType:'MEASURE'}],colData:[['East','West'],[12,5]],rowCount:2}}]}};
|
|
78
|
+
const r=normalize(source);
|
|
79
|
+
assert.deepEqual(chartValues(selectDataset(r),{categoryFieldId:'x',valueFieldId:'y'}),[{name:'East',value:12},{name:'West',value:5}]);
|
|
80
|
+
assert.throws(()=>chartValues(selectDataset(r),{categoryFieldId:'x',valueFieldId:'missing'}),/字段/);
|
|
81
|
+
r.datasets.push({...r.datasets[0],id:'model1'});
|
|
82
|
+
assert.throws(()=>selectDataset(r),/模型/);
|
|
83
|
+
assert.equal(selectDataset(r,'model1').id,'model1');
|
|
84
|
+
});
|
|
85
|
+
test('date filters accept calendar dates without options and omit cleared dates', async () => {
|
|
86
|
+
const dateConfig = {...config, filters:[{name:'日期',label:'日期',type:'date'},...config.filters]};
|
|
87
|
+
let sent;
|
|
88
|
+
const fetchImpl=async (_url,init)=>{sent=JSON.parse(init.body);return new Response(JSON.stringify({success:true,data:{table:detail()}}));};
|
|
89
|
+
for (const value of ['2012-10-02','2024-02-29','0001-01-01','9999-12-31']) {
|
|
90
|
+
await queryWidgets(dateConfig,{'日期':value},undefined,fetchImpl);
|
|
91
|
+
assert.deepEqual(sent.refer,{'日期':[value]});
|
|
92
|
+
}
|
|
93
|
+
await queryWidgets(dateConfig,{'日期':''},undefined,fetchImpl);
|
|
94
|
+
assert.deepEqual(sent.refer,{});
|
|
95
|
+
for (const value of ['2023-02-29','2024-02-30','2024-04-31','0000-01-01','10000-01-01','2024-13-01','2024-1-01','2024-01-01T00:00:00Z']) {
|
|
96
|
+
await assert.rejects(queryWidgets(dateConfig,{'日期':value},undefined,()=>{assert.fail('invalid date must not be sent');}),/筛选/);
|
|
97
|
+
}
|
|
98
|
+
for (const filter of [{name:'日期',label:'日期',type:'date',options:[]},{name:'日期',label:'日期',type:'unknown'}]) {
|
|
99
|
+
await assert.rejects(queryWidgets({...config,filters:[filter]},{},undefined,()=>{assert.fail('invalid configuration must not be sent');}),/筛选/);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('typed filters encode native multi-value, null and open date ranges without guessing', async () => {
|
|
104
|
+
const filters=[
|
|
105
|
+
{name:'文本',label:'文本',type:'text',allowNull:true},
|
|
106
|
+
{name:'多选',label:'多选',type:'multi-select',options:[{label:'东',value:'East'},{label:'西',value:'West'}]},
|
|
107
|
+
{name:'数值',label:'数值',type:'number'},
|
|
108
|
+
{name:'区间',label:'区间',type:'date-range'},
|
|
109
|
+
{name:'月份',label:'月份',type:'month-range'},
|
|
110
|
+
{name:'年',label:'年',type:'year'}, {name:'季',label:'季',type:'quarter'},
|
|
111
|
+
{name:'标签',label:'标签',type:'time-label',options:[{label:'本月',value:'本月,动态'}]},
|
|
112
|
+
];
|
|
113
|
+
let sent;
|
|
114
|
+
const send=values=>queryWidgets({...config,filters},values,undefined,async (_url,init)=>{
|
|
115
|
+
sent=JSON.parse(init.body);return new Response(JSON.stringify({success:true,data:{table:detail()}}));
|
|
116
|
+
});
|
|
117
|
+
await send({'文本':' North ','多选':['East','West'],'数值':'12345678901234567890.25','区间':{start:'2024-02-29',end:'2024-03-01'},'月份':{start:'2024-01',end:''},'年':'2024','季':'2024-3','标签':'本月,动态'});
|
|
118
|
+
assert.deepEqual(sent.refer,{'文本':[' North '],'多选':['East,West'],'数值':['12345678901234567890.25'],'区间_s':['2024-02-29'],'区间_e':['2024-03-01'],'月份_s':['2024-01'],'年':['2024'],'季':['2024-3'],'标签':['本月,动态']});
|
|
119
|
+
await send({'文本':null,'多选':[],'区间':{start:'',end:''}});
|
|
120
|
+
assert.deepEqual(sent.refer,{'文本':['null']});
|
|
121
|
+
await send({}); assert.deepEqual(sent.refer,{});
|
|
122
|
+
for(const values of [{'文本':'A,B'},{'文本':'null'},{'多选':['East,West']},{'多选':['East','East']},{'多选':[null]},{'数值':'1e3'},{'区间':{start:'2024-03-02',end:'2024-03-01'}},{'区间':{start:'2024-02-30',end:''}},{'月份':{start:'2024-13',end:''}},{'年':'0000'},{'季':'2024-5'},{'标签':'未知'},{'区间':null},{'未知':''}]) {
|
|
123
|
+
await assert.rejects(send(values),/筛选/);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('filter configuration rejects ambiguous native keys and unrepresentable choices before fetching',async()=>{
|
|
128
|
+
for(const filters of [
|
|
129
|
+
[{name:'范围',label:'范围',type:'date-range'},{name:'范围_s',label:'起始',type:'date'}],
|
|
130
|
+
[{name:'重复',label:'A',type:'text'},{name:'重复',label:'B',type:'date'}],
|
|
131
|
+
[{name:'x',label:'x',options:[{label:'逗号',value:'A,B'}]}],
|
|
132
|
+
[{name:'x',label:'x',options:[{label:'字面空值',value:'null'}]}],
|
|
133
|
+
[{name:'x',label:'x',type:'number-range'}],
|
|
134
|
+
[{name:'x',label:'x',type:'date',allowNull:true}],
|
|
135
|
+
]) await assert.rejects(queryWidgets({...config,filters},{},undefined,()=>assert.fail('invalid config must not fetch')),/筛选/);
|
|
136
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {"target":"ES2020","lib":["ES2020","DOM","DOM.Iterable"],"module":"ESNext","moduleResolution":"Bundler","jsx":"react-jsx","strict":true,"skipLibCheck":true,"esModuleInterop":true,"resolveJsonModule":true,"isolatedModules":true,"noEmit":true},
|
|
3
|
+
"include":["src","vite.config.ts"]
|
|
4
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs';
|
|
2
|
+
import {defineConfig, loadEnv} from 'vite';
|
|
3
|
+
// @ts-expect-error The development-only Node module is tested directly with node:test.
|
|
4
|
+
import {createDevMiddleware} from './scripts/dev-proxy.mjs';
|
|
5
|
+
export default defineConfig(({mode}) => ({
|
|
6
|
+
base: './',
|
|
7
|
+
server: {host:'127.0.0.1',port:4173,strictPort:true},
|
|
8
|
+
plugins:[{name:'finebi-development-session',apply:'serve',configureServer(server) {
|
|
9
|
+
const config=JSON.parse(readFileSync(new URL('./public/app-config.json',import.meta.url),'utf8'));
|
|
10
|
+
const env={...loadEnv(mode,process.cwd(),'FINEBI_DEV_'),...process.env};
|
|
11
|
+
server.middlewares.use(createDevMiddleware(config,env));
|
|
12
|
+
}}],
|
|
13
|
+
}));
|
|
@@ -10,10 +10,10 @@ const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
|
|
10
10
|
const bundledCliEntry = resolve(scriptDirectory, "finebi-cli", "src", "index.js");
|
|
11
11
|
|
|
12
12
|
export async function run(argv = process.argv.slice(2), options = {}) {
|
|
13
|
-
const env = {...(options.env ?? process.env)};
|
|
14
|
-
if (!env.FINEBI_CLI_PATH?.trim() && existsSync(bundledCliEntry)) {
|
|
15
|
-
env.FINEBI_CLI_PATH = bundledCliEntry;
|
|
16
|
-
}
|
|
13
|
+
const env = {...(options.env ?? process.env)};
|
|
14
|
+
if (!env.FINEBI_CLI_PATH?.trim() && existsSync(bundledCliEntry)) {
|
|
15
|
+
env.FINEBI_CLI_PATH = bundledCliEntry;
|
|
16
|
+
}
|
|
17
17
|
return runCore(argv, {...options, env});
|
|
18
18
|
}
|
|
19
19
|
|
package/src/app.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {cp, mkdir, readdir, readFile, stat, writeFile} from "node:fs/promises";
|
|
2
|
+
import {dirname, resolve} from "node:path";
|
|
3
|
+
import {fileURLToPath} from "node:url";
|
|
4
|
+
|
|
5
|
+
import {cliError} from "./errors.js";
|
|
6
|
+
import {failure, success} from "./output.js";
|
|
7
|
+
|
|
8
|
+
const TEMPLATE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates", "web-app");
|
|
9
|
+
const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
10
|
+
const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu;
|
|
11
|
+
|
|
12
|
+
export async function handleApp(args, context = {}) {
|
|
13
|
+
const action = args[0];
|
|
14
|
+
if (action === "create") return createApp(args.slice(1), context);
|
|
15
|
+
if (action === "inspect") return inspectApp(args.slice(1));
|
|
16
|
+
return invalid("app", "App command is required.", ["finebi-cli app create --name <name> --path <parent>", "finebi-cli app inspect --path <project>"]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function createApp(args, context = {}) {
|
|
20
|
+
const options = parseOptions(args, "app.create");
|
|
21
|
+
if (!options.ok) return options.error;
|
|
22
|
+
const {name, path: parentPath} = options.value;
|
|
23
|
+
if (!name || name.length > 214 || !NAME_PATTERN.test(name) || WINDOWS_DEVICE_NAME.test(name)) {
|
|
24
|
+
return invalid("app.create", "name must be a simple lowercase npm name (letters, digits, and single hyphens).", ["finebi-cli app create --name sales-dashboard --path <parent>"]);
|
|
25
|
+
}
|
|
26
|
+
if (!parentPath) {
|
|
27
|
+
return invalid("app.create", "path must be an existing parent directory.");
|
|
28
|
+
}
|
|
29
|
+
const parent = resolve(parentPath);
|
|
30
|
+
const destination = resolve(parent, name);
|
|
31
|
+
if (dirname(destination) !== parent) {
|
|
32
|
+
return invalid("app.create", "name must stay inside the selected parent directory.");
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
if (!(await stat(parent)).isDirectory()) return invalid("app.create", "path must point to a directory.");
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return invalid("app.create", error.code === "ENOENT" ? `Parent directory does not exist: ${parent}` : `Cannot inspect parent directory: ${error.message}`);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const templateRoot = context.templateRoot ? resolve(context.templateRoot) : TEMPLATE_ROOT;
|
|
41
|
+
const templatePackage = JSON.parse(await readFile(resolve(templateRoot, "package.json"), "utf8"));
|
|
42
|
+
const templateConfigPath = resolve(templateRoot, "public", "app-config.json");
|
|
43
|
+
let config = {schemaVersion: "1", title: name, basePath: "/webroot/decision", layout: "overview", dashboardId: "", maxRows: 1000, pageSize: 8, widgets: [], filters: []};
|
|
44
|
+
try {
|
|
45
|
+
config = JSON.parse(await readFile(templateConfigPath, "utf8"));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error.code !== "ENOENT") throw error;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
await mkdir(destination, {recursive: false});
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return invalid("app.create", error.code === "EEXIST" ? `Destination already exists: ${destination}` : `Cannot create destination: ${error.message}`);
|
|
53
|
+
}
|
|
54
|
+
await copyTemplateContents(templateRoot, destination);
|
|
55
|
+
await mkdir(resolve(destination, "public"), {recursive: true});
|
|
56
|
+
await writeFile(resolve(destination, "package.json"), `${JSON.stringify({...templatePackage, name}, null, 2)}\n`, "utf8");
|
|
57
|
+
await writeFile(resolve(destination, "public", "app-config.json"), `${JSON.stringify({...config, templateVersion: templatePackage.version}, null, 2)}\n`, "utf8");
|
|
58
|
+
const lockPath = resolve(destination, "package-lock.json");
|
|
59
|
+
try {
|
|
60
|
+
const lock = JSON.parse(await readFile(lockPath, "utf8"));
|
|
61
|
+
lock.name = name;
|
|
62
|
+
if (lock.packages?.[""]) lock.packages[""].name = name;
|
|
63
|
+
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`, "utf8");
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error.code !== "ENOENT") throw error;
|
|
66
|
+
}
|
|
67
|
+
return success("app.create", {name, path: destination, templateVersion: templatePackage.version});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return invalid("app.create", `Could not create project: ${error.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function copyTemplateContents(sourceRoot, destinationRoot, relativeDirectory = "") {
|
|
74
|
+
const sourceDirectory = resolve(sourceRoot, relativeDirectory);
|
|
75
|
+
const destinationDirectory = resolve(destinationRoot, relativeDirectory);
|
|
76
|
+
for (const entry of (await readdir(sourceDirectory, {withFileTypes: true})).sort((left, right) => left.name.localeCompare(right.name, "en"))) {
|
|
77
|
+
const relativePath = `${relativeDirectory}/${entry.name}`.replace(/^\//u, "");
|
|
78
|
+
if (!templatePathAllowed(relativePath)) continue;
|
|
79
|
+
const sourcePath = resolve(sourceDirectory, entry.name);
|
|
80
|
+
const destinationPath = resolve(destinationDirectory, entry.name);
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
await mkdir(destinationPath, {recursive: false});
|
|
83
|
+
await copyTemplateContents(sourceRoot, destinationRoot, relativePath);
|
|
84
|
+
} else if (entry.isFile()) {
|
|
85
|
+
await cp(sourcePath, destinationPath, {force: false, errorOnExist: true});
|
|
86
|
+
} else {
|
|
87
|
+
throw new Error(`Unsupported template entry: ${relativePath}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function templatePathAllowed(relative) {
|
|
93
|
+
const relativePath = relative.replaceAll("\\", "/");
|
|
94
|
+
const parts = relativePath.split("/").filter(Boolean);
|
|
95
|
+
const basename = parts.at(-1) || "";
|
|
96
|
+
return !parts.some(part => ["node_modules", "dist", "temp", ".git"].includes(part))
|
|
97
|
+
&& basename !== ".env"
|
|
98
|
+
&& (!basename.startsWith(".env.") || basename === ".env.example");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function inspectApp(args) {
|
|
102
|
+
const options = parseOptions(args, "app.inspect");
|
|
103
|
+
if (!options.ok) return options.error;
|
|
104
|
+
const projectPath = options.value.path;
|
|
105
|
+
if (!projectPath) return invalid("app.inspect", "path must be a project directory.");
|
|
106
|
+
const project = resolve(projectPath);
|
|
107
|
+
try {
|
|
108
|
+
if (!(await stat(project)).isDirectory()) return invalid("app.inspect", "path must point to a project directory.");
|
|
109
|
+
const configPath = resolve(project, "public", "app-config.json");
|
|
110
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
111
|
+
const errors = await validateConfig(config);
|
|
112
|
+
if (errors.length) return failure("app.inspect", cliError("INVALID_ARGUMENT", {path: "public/app-config.json", message: errors.join(" ")}));
|
|
113
|
+
return success("app.inspect", {
|
|
114
|
+
path: project,
|
|
115
|
+
dashboardId: config.dashboardId,
|
|
116
|
+
widgets: config.widgets.length,
|
|
117
|
+
templateVersion: config.templateVersion || null,
|
|
118
|
+
remoteValidated: false,
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return invalid("app.inspect", `Cannot read project configuration: ${error.message}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function validateConfig(config) {
|
|
126
|
+
const errors = [];
|
|
127
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) return ["configuration must be a JSON object."];
|
|
128
|
+
const allowedConfigFields = new Set(["schemaVersion", "title", "basePath", "layout", "dashboardId", "maxRows", "pageSize", "widgets", "filters", "templateVersion"]);
|
|
129
|
+
for (const field of Object.keys(config)) if (!allowedConfigFields.has(field)) errors.push(`unsupported config field: ${field}.`);
|
|
130
|
+
if (config.schemaVersion !== "1") errors.push("schemaVersion must be \"1\".");
|
|
131
|
+
if (!nonEmptyString(config.title)) errors.push("title is required.");
|
|
132
|
+
if (!nonEmptyString(config.basePath) || !/^\/(?!\/)[\w/-]+$/.test(config.basePath)) errors.push("basePath must be a same-origin path.");
|
|
133
|
+
if (!["overview", "workbench"].includes(config.layout)) errors.push("layout must be overview or workbench.");
|
|
134
|
+
if (!Number.isInteger(config.maxRows) || config.maxRows < 1 || config.maxRows > 10000) errors.push("maxRows must be an integer from 1 to 10000.");
|
|
135
|
+
if (!Number.isInteger(config.pageSize) || config.pageSize < 1 || config.pageSize > 100) errors.push("pageSize must be an integer from 1 to 100.");
|
|
136
|
+
if (typeof config.dashboardId !== "string" || !config.dashboardId.trim()) {
|
|
137
|
+
if (Array.isArray(config.widgets) && config.widgets.length === 0) return ["project is not bound: dashboardId and widgets must be configured."];
|
|
138
|
+
errors.push("dashboardId is required.");
|
|
139
|
+
}
|
|
140
|
+
if (!Array.isArray(config.widgets) || config.widgets.length === 0) {
|
|
141
|
+
errors.push("widgets must be a non-empty array.");
|
|
142
|
+
return errors;
|
|
143
|
+
}
|
|
144
|
+
for (const [index, widget] of config.widgets.entries()) {
|
|
145
|
+
if (!widget || typeof widget !== "object") {
|
|
146
|
+
errors.push(`widgets[${index}] must be an object.`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const allowedWidgetFields = new Set(["entryId", "name", "type", "datasetId", "categoryFieldId", "valueFieldId"]);
|
|
150
|
+
for (const field of Object.keys(widget)) if (!allowedWidgetFields.has(field)) errors.push(`widgets[${index}].${field} is unsupported.`);
|
|
151
|
+
for (const field of ["entryId", "name", "type"]) {
|
|
152
|
+
if (typeof widget[field] !== "string" || !widget[field].trim()) errors.push(`widgets[${index}].${field} is required.`);
|
|
153
|
+
}
|
|
154
|
+
if (!["table", "bar", "line", "pie", "kpi"].includes(widget.type)) errors.push(`widgets[${index}].type is unsupported.`);
|
|
155
|
+
if (widget.type && widget.type !== "table" && !nonEmptyString(widget.valueFieldId)) errors.push(`widgets[${index}].valueFieldId is required for ${widget.type}.`);
|
|
156
|
+
if (widget.type && !["table", "kpi"].includes(widget.type) && !nonEmptyString(widget.categoryFieldId)) errors.push(`widgets[${index}].categoryFieldId is required for ${widget.type}.`);
|
|
157
|
+
for (const field of ["datasetId", "categoryFieldId", "valueFieldId"]) {
|
|
158
|
+
if (widget[field] !== undefined && !nonEmptyString(widget[field])) errors.push(`widgets[${index}].${field} must be a non-empty string.`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const {validateFilterBindings} = await import("../templates/web-app/src/finebi/filters.js");
|
|
163
|
+
validateFilterBindings(config.filters);
|
|
164
|
+
} catch (error) {errors.push(error.message);}
|
|
165
|
+
return errors;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function nonEmptyString(value) {
|
|
169
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseOptions(args, command) {
|
|
173
|
+
const value = {};
|
|
174
|
+
for (let index = 0; index < args.length; index++) {
|
|
175
|
+
const option = args[index];
|
|
176
|
+
if (option === "--json") continue;
|
|
177
|
+
if (option !== "--name" && option !== "--path") return {ok: false, error: invalid(command, `Unsupported option: ${option}`)};
|
|
178
|
+
const key = option.slice(2);
|
|
179
|
+
const next = args[++index];
|
|
180
|
+
if (!next || next.startsWith("--")) return {ok: false, error: invalid(command, `${option} requires a value.`)};
|
|
181
|
+
if (value[key]) return {ok: false, error: invalid(command, `${option} must only be supplied once.`)};
|
|
182
|
+
value[key] = next;
|
|
183
|
+
}
|
|
184
|
+
return {ok: true, value};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function invalid(command, message, nextActions = []) {
|
|
188
|
+
return failure(command, cliError("INVALID_ARGUMENT", {path: command, message, nextActions}));
|
|
189
|
+
}
|
package/src/config.js
CHANGED
|
@@ -56,23 +56,23 @@ export function writeConfigAtomically(path, config, options = {}) {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export function resolveConfig(flags = {}, env = process.env, fileConfig = readConfig()) {
|
|
60
|
-
const baseUrl = firstNonEmpty(flags.baseUrl, env.FINEBI_BASE_URL, fileConfig.baseUrl);
|
|
61
|
-
const sameServer = sameBaseUrl(baseUrl, fileConfig.baseUrl);
|
|
62
|
-
const explicitToken = firstNonEmpty(flags.token, env.FINEBI_AUTH_TOKEN);
|
|
63
|
-
const savedAuth = sameServer ? fileConfig.auth : undefined;
|
|
64
|
-
const token = firstNonEmpty(explicitToken, savedAuth?.token, sameServer ? fileConfig.token : undefined);
|
|
59
|
+
export function resolveConfig(flags = {}, env = process.env, fileConfig = readConfig()) {
|
|
60
|
+
const baseUrl = firstNonEmpty(flags.baseUrl, env.FINEBI_BASE_URL, fileConfig.baseUrl);
|
|
61
|
+
const sameServer = sameBaseUrl(baseUrl, fileConfig.baseUrl);
|
|
62
|
+
const explicitToken = firstNonEmpty(flags.token, env.FINEBI_AUTH_TOKEN);
|
|
63
|
+
const savedAuth = sameServer ? fileConfig.auth : undefined;
|
|
64
|
+
const token = firstNonEmpty(explicitToken, savedAuth?.token, sameServer ? fileConfig.token : undefined);
|
|
65
65
|
return normalizeConfig({
|
|
66
66
|
...fileConfig,
|
|
67
|
-
baseUrl,
|
|
67
|
+
baseUrl,
|
|
68
68
|
timeoutMs: parsePositiveInt(firstNonEmpty(flags.timeoutMs, env.FINEBI_TIMEOUT_MS), fileConfig.timeoutMs),
|
|
69
69
|
capabilityTtlMs: parsePositiveInt(
|
|
70
70
|
firstNonEmpty(flags.capabilityTtlMs, env.FINEBI_CAPABILITY_TTL_MS),
|
|
71
71
|
fileConfig.capabilityTtlMs,
|
|
72
72
|
),
|
|
73
73
|
auth: {
|
|
74
|
-
...(explicitToken ? {} : savedAuth),
|
|
75
|
-
token,
|
|
74
|
+
...(explicitToken ? {} : savedAuth),
|
|
75
|
+
token,
|
|
76
76
|
},
|
|
77
77
|
});
|
|
78
78
|
}
|
|
@@ -81,10 +81,10 @@ export function updateConfigValue(config, key, value, now = () => new Date().toI
|
|
|
81
81
|
const next = normalizeConfig(config);
|
|
82
82
|
switch (key) {
|
|
83
83
|
case "base-url":
|
|
84
|
-
if (next.baseUrl !== value) {
|
|
85
|
-
next.capabilityCacheInvalidatedAt = now();
|
|
86
|
-
delete next.auth;
|
|
87
|
-
delete next.token;
|
|
84
|
+
if (next.baseUrl !== value) {
|
|
85
|
+
next.capabilityCacheInvalidatedAt = now();
|
|
86
|
+
delete next.auth;
|
|
87
|
+
delete next.token;
|
|
88
88
|
}
|
|
89
89
|
next.baseUrl = value;
|
|
90
90
|
return next;
|
|
@@ -128,7 +128,7 @@ function defaultConfig() {
|
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
export function normalizeConfig(config) {
|
|
131
|
+
export function normalizeConfig(config) {
|
|
132
132
|
return {
|
|
133
133
|
...config,
|
|
134
134
|
timeoutMs: parsePositiveInt(config.timeoutMs, DEFAULT_TIMEOUT_MS),
|
|
@@ -136,14 +136,14 @@ export function normalizeConfig(config) {
|
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
function sanitizeConfig(config) {
|
|
140
|
-
if (config.schemaVersion === 2) {
|
|
141
|
-
return {
|
|
142
|
-
schemaVersion: 2,
|
|
143
|
-
currentProfile: config.currentProfile,
|
|
144
|
-
profiles: Object.fromEntries(Object.entries(config.profiles).map(([name, profile]) => [name, sanitizeConfig(normalizeConfig(profile))])),
|
|
145
|
-
};
|
|
146
|
-
}
|
|
139
|
+
function sanitizeConfig(config) {
|
|
140
|
+
if (config.schemaVersion === 2) {
|
|
141
|
+
return {
|
|
142
|
+
schemaVersion: 2,
|
|
143
|
+
currentProfile: config.currentProfile,
|
|
144
|
+
profiles: Object.fromEntries(Object.entries(config.profiles).map(([name, profile]) => [name, sanitizeConfig(normalizeConfig(profile))])),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
147
|
const { token: _legacyToken, ...rest } = config;
|
|
148
148
|
const auth = config.auth
|
|
149
149
|
? {
|
|
@@ -166,11 +166,11 @@ function sanitizeConfig(config) {
|
|
|
166
166
|
...rest,
|
|
167
167
|
auth,
|
|
168
168
|
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export function sameBaseUrl(left, right) {
|
|
172
|
-
return (left || "").replace(/\/+$/, "") === (right || "").replace(/\/+$/, "");
|
|
173
|
-
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function sameBaseUrl(left, right) {
|
|
172
|
+
return (left || "").replace(/\/+$/, "") === (right || "").replace(/\/+$/, "");
|
|
173
|
+
}
|
|
174
174
|
|
|
175
175
|
function firstNonEmpty(...values) {
|
|
176
176
|
return values.find((value) => typeof value === "string" && value.trim().length > 0)?.trim();
|