@finebi/finebi-cli 1.4.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.
Files changed (93) hide show
  1. package/README.md +35 -1
  2. package/package.json +5 -4
  3. package/skills/finebi-cli-agent/SKILL.md +8 -4
  4. package/skills/finebi-cli-agent/references/command-workflows.md +61 -7
  5. package/skills/finebi-cli-agent/references/dashboard-data-result.md +219 -0
  6. package/skills/finebi-cli-agent/references/dashboard-dsl-api.md +12 -1005
  7. package/skills/finebi-cli-agent/references/dashboard-dsl-core.md +194 -0
  8. package/skills/finebi-cli-agent/references/dashboard-dsl-examples.md +378 -0
  9. package/skills/finebi-cli-agent/references/dashboard-dsl-items.md +456 -0
  10. package/skills/finebi-cli-agent/references/file-to-dashboard.md +1 -1
  11. package/skills/finebi-cli-agent/references/handoff-contract.md +2 -0
  12. package/skills/finebi-cli-agent/references/mapping-rules.md +15 -15
  13. package/skills/finebi-cli-agent/references/scenario-workflows.md +1 -1
  14. package/skills/finebi-cli-agent/references/web-app.md +13 -0
  15. package/skills/finebi-cli-agent/references/widget-dsl-api-examples.md +21 -4
  16. package/skills/finebi-cli-agent/references/widget-dsl-api.md +10 -1336
  17. package/skills/finebi-cli-agent/references/widget-dsl-contexts.md +142 -0
  18. package/skills/finebi-cli-agent/references/widget-dsl-core.md +374 -0
  19. package/skills/finebi-cli-agent/references/widget-dsl-encodings.md +457 -0
  20. package/skills/finebi-cli-agent/references/widget-dsl-styles.md +413 -0
  21. package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
  22. package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +189 -0
  23. package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +33 -2
  24. package/skills/finebi-cli-agent/scripts/finebi-cli/src/index.js +5 -0
  25. package/skills/finebi-cli-agent/scripts/finebi-cli/src/schema-options.js +44 -8
  26. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +27 -0
  27. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/README.md +30 -0
  28. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/auth-and-deploy.md +17 -0
  29. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/data-contract.md +21 -0
  30. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/design.md +13 -0
  31. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/filters.md +89 -0
  32. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/index.html +2 -0
  33. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/package-lock.json +2191 -0
  34. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/package.json +15 -0
  35. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/public/app-config.json +11 -0
  36. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-proxy.mjs +56 -0
  37. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +31 -0
  38. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +31 -0
  39. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx +14 -0
  40. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/FilterBar.tsx +42 -0
  41. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +28 -0
  42. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts +14 -0
  43. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/client.ts +37 -0
  44. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.d.ts +1 -0
  45. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.js +5 -0
  46. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.d.ts +4 -0
  47. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.js +106 -0
  48. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +77 -0
  49. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +18 -0
  50. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/main.tsx +5 -0
  51. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts +18 -0
  52. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/styles.css +8 -0
  53. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/calendar.test.mjs +25 -0
  54. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/proxy.test.mjs +84 -0
  55. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/runtime.test.mjs +136 -0
  56. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tsconfig.json +4 -0
  57. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +13 -0
  58. package/src/app.js +189 -0
  59. package/src/help.js +33 -2
  60. package/src/index.js +5 -0
  61. package/src/schema-options.js +44 -8
  62. package/templates/web-app/AGENTS.md +27 -0
  63. package/templates/web-app/README.md +30 -0
  64. package/templates/web-app/docs/auth-and-deploy.md +17 -0
  65. package/templates/web-app/docs/data-contract.md +21 -0
  66. package/templates/web-app/docs/design.md +13 -0
  67. package/templates/web-app/docs/filters.md +89 -0
  68. package/templates/web-app/index.html +2 -0
  69. package/templates/web-app/package-lock.json +2191 -0
  70. package/templates/web-app/package.json +15 -0
  71. package/templates/web-app/public/app-config.json +11 -0
  72. package/templates/web-app/scripts/dev-proxy.mjs +56 -0
  73. package/templates/web-app/src/App.tsx +31 -0
  74. package/templates/web-app/src/components/Chart.tsx +31 -0
  75. package/templates/web-app/src/components/DataTable.tsx +14 -0
  76. package/templates/web-app/src/components/FilterBar.tsx +42 -0
  77. package/templates/web-app/src/components/Widget.tsx +28 -0
  78. package/templates/web-app/src/components/calendar.ts +14 -0
  79. package/templates/web-app/src/finebi/client.ts +37 -0
  80. package/templates/web-app/src/finebi/date.d.ts +1 -0
  81. package/templates/web-app/src/finebi/date.js +5 -0
  82. package/templates/web-app/src/finebi/filters.d.ts +4 -0
  83. package/templates/web-app/src/finebi/filters.js +106 -0
  84. package/templates/web-app/src/finebi/normalize.ts +77 -0
  85. package/templates/web-app/src/finebi/types.ts +18 -0
  86. package/templates/web-app/src/main.tsx +5 -0
  87. package/templates/web-app/src/services/view-data.ts +18 -0
  88. package/templates/web-app/src/styles.css +8 -0
  89. package/templates/web-app/tests/calendar.test.mjs +25 -0
  90. package/templates/web-app/tests/proxy.test.mjs +84 -0
  91. package/templates/web-app/tests/runtime.test.mjs +136 -0
  92. package/templates/web-app/tsconfig.json +4 -0
  93. 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
+ }));
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/help.js CHANGED
@@ -99,6 +99,29 @@ function findResource(manifest, resourceName) {
99
99
  }
100
100
 
101
101
  const BUILT_IN_RESOURCES = {
102
+ app: {
103
+ resource: "app",
104
+ summary: "Create and inspect local FineBI dynamic web app projects without a server connection.",
105
+ commands: [
106
+ {
107
+ name: "create",
108
+ summary: "Copy the bundled web app template into a new project directory.",
109
+ usage: "finebi-cli app create --name <name> --path <parent>",
110
+ options: [
111
+ {name: "--name", type: "string", description: "Simple lowercase npm project name."},
112
+ {name: "--path", type: "string", description: "Existing parent directory; relative paths resolve from the current directory."},
113
+ ],
114
+ details: ["Creates a local project only; it does not connect to FineBI or run install/build scripts."],
115
+ },
116
+ {
117
+ name: "inspect",
118
+ summary: "Check a local web app project configuration without remote validation.",
119
+ usage: "finebi-cli app inspect --path <project>",
120
+ options: [{name: "--path", type: "string", description: "Existing project directory; relative paths resolve from the current directory."}],
121
+ details: ["Checks app-config.json structure and widget bindings locally. Remote dashboard and permission checks are not performed."],
122
+ },
123
+ ],
124
+ },
102
125
  "install-skill": {
103
126
  command: "install-skill",
104
127
  resource: "install-skill",
@@ -288,7 +311,7 @@ function commandOptionsFor(command) {
288
311
 
289
312
  function optionsFor(schema) {
290
313
  const required = schema.required || [];
291
- return Object.keys(schema.properties || {}).filter((fieldName) => schema.properties[fieldName].input !== "argument").map((fieldName) => {
314
+ return Object.keys(schema.properties || {}).filter((fieldName) => schema.properties[fieldName].input !== "argument").flatMap((fieldName) => {
292
315
  const property = schema.properties[fieldName];
293
316
  const optionName = optionNameFor(fieldName, property);
294
317
  const option = {
@@ -305,7 +328,15 @@ function optionsFor(schema) {
305
328
  if (defaultValue !== undefined && defaultValue !== "") {
306
329
  option.default = defaultValue;
307
330
  }
308
- return option;
331
+ if (property.keyValueOption) {
332
+ return [option, {
333
+ name: `--${property.keyValueOption}`,
334
+ required: false,
335
+ type: "name=value",
336
+ description: `Repeatable; maps each name to a one-element string array; mutually exclusive with --${optionName}. Split at the first =; empty names/values and duplicate names are rejected.`,
337
+ }];
338
+ }
339
+ return [option];
309
340
  });
310
341
  }
311
342
 
package/src/index.js CHANGED
@@ -19,6 +19,7 @@ import {parseConnectionArgs, ProfileError, profileCapabilityPath, readProfile, r
19
19
  import {loginWithPassword, resolveLoginInputFromEnv} from "./auth.js";
20
20
  import {executeDynamicCommand} from "./dynamic-command.js";
21
21
  import {handleInstallSkill} from "./install-skill.js";
22
+ import {handleApp} from "./app.js";
22
23
  import {cliError} from "./errors.js";
23
24
  import {
24
25
  renderBuiltInCommandHelp,
@@ -48,6 +49,9 @@ export async function runCli(argv, context = {}) {
48
49
  if (args[0] === "install-skill") {
49
50
  return isHelpFlagInvocation(args) ? renderBuiltInHelp("install-skill") : handleInstallSkill(args.slice(1), context);
50
51
  }
52
+ if (args[0] === "app") {
53
+ return isHelpFlagInvocation(args) ? renderBuiltInHelp("app") : handleApp(args.slice(1), context);
54
+ }
51
55
  const management = args[0] === "auth" && ["list", "use", "remove"].includes(args[1]);
52
56
  const builtInHelp = isHelpFlagInvocation(args) && (args.length === 1 || isBuiltInResource(args[0]));
53
57
  const optional = management || builtInHelp || args.length === 0 || args[0] === "version";
@@ -684,6 +688,7 @@ async function handleHelp(args, context) {
684
688
 
685
689
  function isBuiltInResource(resourceName) {
686
690
  return resourceName === "install-skill"
691
+ || resourceName === "app"
687
692
  || resourceName === "config"
688
693
  || resourceName === "auth"
689
694
  || resourceName === "capability"
@@ -4,11 +4,12 @@ import {cliError} from "./errors.js";
4
4
  import {validateSchemaValue} from "./schema-validator.js";
5
5
 
6
6
  export function buildRequestBody(schema, args) {
7
- const parsed = parseArgs(args);
7
+ const properties = schema.properties || {};
8
+ const repeatable = new Set(Object.values(properties).map(property => property.keyValueOption).filter(Boolean));
9
+ const parsed = parseArgs(args, repeatable);
8
10
  if (!parsed.ok) {
9
11
  return parsed;
10
12
  }
11
- const properties = schema.properties || {};
12
13
  const argumentFields = Object.keys(properties).filter((fieldName) => properties[fieldName].input === "argument");
13
14
  if (parsed.positionals.length > argumentFields.length) {
14
15
  return invalidArgument(parsed.positionals[argumentFields.length], `Unexpected argument: ${parsed.positionals[argumentFields.length]}.`);
@@ -33,10 +34,20 @@ export function buildRequestBody(schema, args) {
33
34
  for (const fieldName of Object.keys(properties)) {
34
35
  const property = properties[fieldName];
35
36
  const optionName = optionNameFor(fieldName, property);
37
+ const inlineValues = property.keyValueOption && parsed.options[property.keyValueOption];
38
+ if (inlineValues && Object.prototype.hasOwnProperty.call(parsed.options, optionName)) {
39
+ return invalidArgument(fieldName, `--${property.keyValueOption} and --${optionName} are mutually exclusive.`);
40
+ }
36
41
  if (property.input === "argument" && body[fieldName] !== undefined) {
37
42
  continue;
38
43
  }
39
- if (Object.prototype.hasOwnProperty.call(parsed.options, optionName)) {
44
+ if (inlineValues) {
45
+ const coerced = coerceKeyValues(fieldName, inlineValues, property);
46
+ if (!coerced.ok) {
47
+ return coerced;
48
+ }
49
+ body[fieldName] = coerced.value;
50
+ } else if (Object.prototype.hasOwnProperty.call(parsed.options, optionName)) {
40
51
  const coerced = coerceValue(fieldName, parsed.options[optionName], property);
41
52
  if (!coerced.ok) {
42
53
  return coerced;
@@ -60,7 +71,8 @@ export function buildRequestBody(schema, args) {
60
71
  }
61
72
  }
62
73
  for (const optionName of Object.keys(parsed.options)) {
63
- if (!Object.keys(properties).some((fieldName) => optionNameFor(fieldName, properties[fieldName]) === optionName)) {
74
+ if (!Object.keys(properties).some((fieldName) => optionNameFor(fieldName, properties[fieldName]) === optionName
75
+ || properties[fieldName].keyValueOption === optionName)) {
64
76
  return invalidArgument(`--${optionName}`, `Unknown option: --${optionName}.`);
65
77
  }
66
78
  }
@@ -75,7 +87,7 @@ export function buildRequestBody(schema, args) {
75
87
  };
76
88
  }
77
89
 
78
- function parseArgs(args) {
90
+ function parseArgs(args, repeatable) {
79
91
  const options = {};
80
92
  const positionals = [];
81
93
  for (let index = 0; index < args.length; index += 1) {
@@ -86,10 +98,13 @@ function parseArgs(args) {
86
98
  }
87
99
  const optionName = current.slice(2);
88
100
  const next = args[index + 1];
89
- if (next === undefined || next.startsWith("--")) {
90
- options[optionName] = true;
101
+ const value = next === undefined || next.startsWith("--") ? true : next;
102
+ if (repeatable.has(optionName)) {
103
+ (options[optionName] ||= []).push(value);
91
104
  } else {
92
- options[optionName] = next;
105
+ options[optionName] = value;
106
+ }
107
+ if (value !== true) {
93
108
  index += 1;
94
109
  }
95
110
  }
@@ -100,6 +115,27 @@ function parseArgs(args) {
100
115
  };
101
116
  }
102
117
 
118
+ function coerceKeyValues(fieldName, values, property) {
119
+ const entries = new Map();
120
+ for (const value of values) {
121
+ const separator = typeof value === "string" ? value.indexOf("=") : -1;
122
+ if (separator < 1 || !value.slice(0, separator).trim() || !value.slice(separator + 1).trim()) {
123
+ return invalidArgument(fieldName, `--${property.keyValueOption} requires name=value with a non-empty name and value.`);
124
+ }
125
+ const name = value.slice(0, separator);
126
+ if (entries.has(name)) {
127
+ return invalidArgument(fieldName, `Duplicate name in --${property.keyValueOption}: ${name}.`);
128
+ }
129
+ entries.set(name, [value.slice(separator + 1)]);
130
+ }
131
+ const value = Object.fromEntries(entries);
132
+ if (property.schema) {
133
+ const validation = validateSchemaValue(value, property.schema, fieldName);
134
+ if (!validation.ok) return validation;
135
+ }
136
+ return {ok: true, value};
137
+ }
138
+
103
139
  function coerceValue(fieldName, value, property) {
104
140
  if (property.input === "file" || property.file) {
105
141
  if (typeof value !== "string" || value.trim() === "") {