@finebi/finebi-cli 1.6.1 → 1.6.2

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 (152) hide show
  1. package/README.md +8 -8
  2. package/package.json +1 -1
  3. package/skills/finebi-cli-agent/SKILL.md +43 -123
  4. package/skills/finebi-cli-agent/agents/openai.yaml +1 -1
  5. package/skills/finebi-cli-agent/references/artifact-ordering.md +3 -3
  6. package/skills/finebi-cli-agent/references/command-workflows.md +30 -1
  7. package/skills/finebi-cli-agent/references/dashboard-data-result.md +191 -191
  8. package/skills/finebi-cli-agent/references/dashboard-dsl-api.md +44 -44
  9. package/skills/finebi-cli-agent/references/dsl-handoff-contract.md +1 -1
  10. package/skills/finebi-cli-agent/references/handoff-contract.md +6 -4
  11. package/skills/finebi-cli-agent/references/mutation-confirmation-contract.md +2 -0
  12. package/skills/finebi-cli-agent/references/scenario-workflows.md +10 -8
  13. package/skills/finebi-cli-agent/references/web-app.md +4 -2
  14. package/skills/finebi-cli-agent/references/widget-dsl-core.md +6 -0
  15. package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
  16. package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +157 -148
  17. package/skills/finebi-cli-agent/scripts/finebi-cli/src/browser-auth.js +3 -4
  18. package/skills/finebi-cli-agent/scripts/finebi-cli/src/config.js +28 -28
  19. package/skills/finebi-cli-agent/scripts/finebi-cli/src/error-catalog.js +2 -2
  20. package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +63 -60
  21. package/skills/finebi-cli-agent/scripts/finebi-cli/src/install-skill.js +89 -89
  22. package/skills/finebi-cli-agent/scripts/finebi-cli/src/profiles.js +158 -158
  23. package/skills/finebi-cli-agent/scripts/finebi-cli/src/skill-files.js +94 -94
  24. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +18 -5
  25. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/README.md +12 -5
  26. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/acceptance.md +80 -67
  27. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/auth-and-deploy.md +17 -2
  28. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/data-contract.md +66 -38
  29. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/filters.md +2 -2
  30. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/cockpit.config.json +22 -22
  31. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/retail.config.json +185 -185
  32. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/index.html +2 -2
  33. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/public/app-config.json +11 -11
  34. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/browser-session.mjs +59 -0
  35. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-proxy.mjs +25 -24
  36. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-session.mjs +57 -0
  37. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/session-file.mjs +45 -0
  38. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +63 -56
  39. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/cockpit.css +3 -3
  40. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +40 -40
  41. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx +1 -1
  42. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Insights.tsx +17 -17
  43. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/MonthPicker.tsx +32 -32
  44. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/OptionFilter.tsx +7 -7
  45. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/RangeFilter.tsx +9 -9
  46. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +40 -39
  47. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar-locales.ts +3 -3
  48. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts +14 -14
  49. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Bar.tsx +6 -6
  50. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Line.tsx +6 -6
  51. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Pie.tsx +6 -6
  52. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/month-picker.css +1 -1
  53. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/client.ts +20 -4
  54. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.d.ts +3 -1
  55. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +58 -44
  56. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.d.ts +1 -1
  57. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.js +5 -5
  58. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.d.ts +4 -4
  59. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.js +106 -106
  60. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +235 -77
  61. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +20 -18
  62. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/main.tsx +5 -5
  63. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +12 -12
  64. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/presentation.ts +36 -36
  65. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/table-data.ts +47 -47
  66. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts +18 -18
  67. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/widget-queries.ts +18 -18
  68. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/fixtures.mjs +28 -28
  69. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/index.html +16 -16
  70. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/main.tsx +149 -149
  71. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/test-page.css +4 -4
  72. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/vite.config.mjs +13 -13
  73. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/bundle.test.mjs +19 -19
  74. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/cockpit.test.mjs +73 -73
  75. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/field-selection.test.mjs +70 -0
  76. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/proxy.test.mjs +20 -5
  77. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/retail.test.mjs +31 -31
  78. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/runtime.test.mjs +7 -0
  79. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/session.test.mjs +91 -0
  80. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/table-normalize.test.mjs +189 -0
  81. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/vite-auth.test.mjs +41 -0
  82. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tsconfig.json +4 -4
  83. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +9 -4
  84. package/skills/finebi-cli-agent/scripts/finebi-cli-runner.mjs +4 -4
  85. package/src/app.js +157 -148
  86. package/src/browser-auth.js +3 -4
  87. package/src/config.js +28 -28
  88. package/src/error-catalog.js +2 -2
  89. package/src/help.js +63 -60
  90. package/src/install-skill.js +89 -89
  91. package/src/profiles.js +158 -158
  92. package/src/skill-files.js +94 -94
  93. package/templates/web-app/AGENTS.md +18 -5
  94. package/templates/web-app/README.md +12 -5
  95. package/templates/web-app/docs/acceptance.md +80 -67
  96. package/templates/web-app/docs/auth-and-deploy.md +17 -2
  97. package/templates/web-app/docs/data-contract.md +66 -38
  98. package/templates/web-app/docs/filters.md +2 -2
  99. package/templates/web-app/examples/cockpit.config.json +22 -22
  100. package/templates/web-app/examples/retail.config.json +185 -185
  101. package/templates/web-app/index.html +2 -2
  102. package/templates/web-app/public/app-config.json +11 -11
  103. package/templates/web-app/scripts/browser-session.mjs +59 -0
  104. package/templates/web-app/scripts/dev-proxy.mjs +25 -24
  105. package/templates/web-app/scripts/dev-session.mjs +57 -0
  106. package/templates/web-app/scripts/session-file.mjs +45 -0
  107. package/templates/web-app/src/App.tsx +63 -56
  108. package/templates/web-app/src/cockpit.css +3 -3
  109. package/templates/web-app/src/components/Chart.tsx +40 -40
  110. package/templates/web-app/src/components/DataTable.tsx +1 -1
  111. package/templates/web-app/src/components/Insights.tsx +17 -17
  112. package/templates/web-app/src/components/MonthPicker.tsx +32 -32
  113. package/templates/web-app/src/components/OptionFilter.tsx +7 -7
  114. package/templates/web-app/src/components/RangeFilter.tsx +9 -9
  115. package/templates/web-app/src/components/Widget.tsx +40 -39
  116. package/templates/web-app/src/components/calendar-locales.ts +3 -3
  117. package/templates/web-app/src/components/calendar.ts +14 -14
  118. package/templates/web-app/src/components/charts/Bar.tsx +6 -6
  119. package/templates/web-app/src/components/charts/Line.tsx +6 -6
  120. package/templates/web-app/src/components/charts/Pie.tsx +6 -6
  121. package/templates/web-app/src/components/month-picker.css +1 -1
  122. package/templates/web-app/src/finebi/client.ts +20 -4
  123. package/templates/web-app/src/finebi/config.d.ts +3 -1
  124. package/templates/web-app/src/finebi/config.js +58 -44
  125. package/templates/web-app/src/finebi/date.d.ts +1 -1
  126. package/templates/web-app/src/finebi/date.js +5 -5
  127. package/templates/web-app/src/finebi/filters.d.ts +4 -4
  128. package/templates/web-app/src/finebi/filters.js +106 -106
  129. package/templates/web-app/src/finebi/normalize.ts +235 -77
  130. package/templates/web-app/src/finebi/types.ts +20 -18
  131. package/templates/web-app/src/main.tsx +5 -5
  132. package/templates/web-app/src/retail.css +12 -12
  133. package/templates/web-app/src/services/presentation.ts +36 -36
  134. package/templates/web-app/src/services/table-data.ts +47 -47
  135. package/templates/web-app/src/services/view-data.ts +18 -18
  136. package/templates/web-app/src/services/widget-queries.ts +18 -18
  137. package/templates/web-app/tests/browser/fixtures.mjs +28 -28
  138. package/templates/web-app/tests/browser/index.html +16 -16
  139. package/templates/web-app/tests/browser/main.tsx +149 -149
  140. package/templates/web-app/tests/browser/test-page.css +4 -4
  141. package/templates/web-app/tests/browser/vite.config.mjs +13 -13
  142. package/templates/web-app/tests/bundle.test.mjs +19 -19
  143. package/templates/web-app/tests/cockpit.test.mjs +73 -73
  144. package/templates/web-app/tests/field-selection.test.mjs +70 -0
  145. package/templates/web-app/tests/proxy.test.mjs +20 -5
  146. package/templates/web-app/tests/retail.test.mjs +31 -31
  147. package/templates/web-app/tests/runtime.test.mjs +7 -0
  148. package/templates/web-app/tests/session.test.mjs +91 -0
  149. package/templates/web-app/tests/table-normalize.test.mjs +189 -0
  150. package/templates/web-app/tests/vite-auth.test.mjs +41 -0
  151. package/templates/web-app/tsconfig.json +4 -4
  152. package/templates/web-app/vite.config.ts +9 -4
@@ -0,0 +1,189 @@
1
+ import assert from 'node:assert/strict';
2
+ import {after, test} from 'node:test';
3
+ import {createServer} from 'vite';
4
+ import {createElement} from 'react';
5
+ import {renderToStaticMarkup} from 'react-dom/server';
6
+ const server = await createServer({configFile: false, cacheDir: '../../../temp/vite-table-tests', server: {middlewareMode: true, hmr: false}});
7
+ after(() => server.close());
8
+ const {normalize} = await server.ssrLoadModule('/src/finebi/normalize.ts');
9
+ const {DataTable} = await server.ssrLoadModule('/src/components/DataTable.tsx');
10
+ const {tableCsv} = await server.ssrLoadModule('/src/services/table-data.ts');
11
+ const metric = (text, rowSumInfo = {totalSum: false, innerSum: false}, extra = {}) => ({dId: 'amount', text, authority: true, rowSumInfo, ...extra});
12
+ const group = items => ({previewResult: {resultType: 'BIGROUP', header: [{dId: 'region', text: '地区'}, {dId: 'city', text: '城市'}, {dId: 'amount', text: '金额'}], items, hasNext: true, hasPrev: false}, widgetBean: {dimensions: {region: {type: 1}, city: {type: 1}}, settings: {tableStyle: {rowCounts: 10}}}});
13
+ const detailInfo = {totalSum: false, innerSum: false};
14
+ const totalInfo = {totalSum: true, innerSum: false};
15
+ const node = (dId, text, children = null, values = null) => ({dId, text, formatText: text, authority: true, children, values});
16
+ const crossMetric = (text, colSumInfo = detailInfo, rowSumInfo = detailInfo, dId = 'amount') => ({dId, text, authority: true, colSumInfo, rowSumInfo});
17
+ const cross = (top, left) => ({previewResult: {resultType: 'BICROSS', top: {header: [{dId: 'year', text: '年'}], items: [node(null, null, top)]}, left: {header: [{dId: 'region', text: '地区'}], items: [node(null, null, left)]}, hasXNext: true, hasXPrev: false, hasYNext: false, hasYPrev: false}});
18
+
19
+ test('cross display removes only repeated metric captions and DATA summary carriers', () => {
20
+ const source = cross([node('region', '金额', [node('target-name', '金额', null, [node('amount', '金额')])])], [
21
+ node('region', '合计', [node('__summary_dim_id__', '合计', null, [crossMetric('1')])]),
22
+ node('__summary_dim_id__', '合计', null, [crossMetric('2', detailInfo, totalInfo)]),
23
+ node('__summary_dim_id__', '合计', null, [crossMetric('3', detailInfo, {totalSum: false, innerSum: true})]),
24
+ node('__summary_dim_id__', '合计', null, [{...crossMetric('4'), rowSumInfo: null}]),
25
+ ]);
26
+ const data = normalize(source).datasets[0];
27
+ assert.equal(data.columns[1].name, '金额 / 金额', 'real repeated grouping labels must remain');
28
+ assert.equal(data.columns[1].path.length, 3, 'source path metadata must remain intact');
29
+ assert.equal(data.rows[0].cells[0].display, '合计', 'business dimension named 合计 must remain');
30
+ assert.equal(data.rows[0].path.length, 2);
31
+ assert.deepEqual(data.rows.slice(1).map(row => [row.kind, row.cells[0].display]), [['GRAND_TOTAL', '合计'], ['SUBTOTAL', '合计'], ['UNKNOWN', '合计']]);
32
+ });
33
+
34
+ test('cross metric rows deduplicate only the verified trailing metric caption', () => {
35
+ const rows = [
36
+ node('region', '金额', [node('target-name', '金额', [node('amount', '金额', null, [crossMetric('7')])])]),
37
+ node('region', '金额', [node('other-dimension', '金额', null, [crossMetric('8')])]),
38
+ node('target-name', '金额', [node('amount', '别名', null, [crossMetric('9')])]),
39
+ node('target-name', '金额', [node('amount', '金额', null, [crossMetric('10', detailInfo, detailInfo, 'count')])]),
40
+ ];
41
+ const data = normalize(cross([node('year', '2024', null, [{}])], rows)).datasets[0];
42
+ assert.deepEqual(data.rows.map(r => r.cells[0].display), ['金额 / 金额', '金额 / 金额', '金额 / 别名', '金额 / 金额']);
43
+ assert.equal(data.rows[0].path.length, 3);
44
+ assert.equal(data.rows[0].cells[1].sourceFieldId, 'amount');
45
+ const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
46
+ assert.doesNotMatch(html, /金额 \/ 金额 \/ 金额/);
47
+ assert.doesNotMatch(tableCsv(data, data.rows), /金额 \/ 金额 \/ 金额/);
48
+ });
49
+
50
+ test('cross real null renders as dash while placeholders, missing payload and permissions stay distinct', () => {
51
+ const empty = {...crossMetric(null), value: null, formatText: null};
52
+ const cells = [empty, {...empty, dId: null}, {...empty, authority: false}, {...empty, authority: undefined}, {dId: 'amount', authority: true}, {...empty, formatText: '仅展示'}];
53
+ const data = normalize(cross([node('year', '2024', null, cells.map(() => ({})))], [node('region', '东部', null, cells)])).datasets[0];
54
+ assert.deepEqual(data.rows[0].cells.slice(1).map(c => c.state), ['NULL', 'UNAVAILABLE', 'RESTRICTED', 'UNAVAILABLE', 'UNAVAILABLE', 'UNAVAILABLE']);
55
+ assert.equal(data.rows[0].cells[1].reason, null);
56
+ const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
57
+ assert.match(html, /<td>—<\/td><td>不可用<\/td><td>受限<\/td>/);
58
+ assert.match(tableCsv(data, data.rows), /"—","不可用","受限"/);
59
+ });
60
+
61
+ test('cross placeholders without valid metric ids never become available numeric values', () => {
62
+ const cells = [null, undefined, '', ' ', 12].map(dId => ({...crossMetric('12'), dId, value: 12}));
63
+ const data = normalize(cross([node('year', '2024', null, cells.map(() => ({})))], [node('region', '东部', null, cells)])).datasets[0];
64
+ for (const cell of data.rows[0].cells.slice(1)) {
65
+ assert.equal(cell.state, 'UNAVAILABLE');
66
+ assert.equal(cell.value, null);
67
+ assert.equal(cell.sourceFieldId, null);
68
+ }
69
+ });
70
+
71
+ test('cross table aligns repeated metric ids by column slots and preserves both axes and totals', () => {
72
+ for (const totalFirst of [false, true]) {
73
+ const columns = [node('year', '2024', null, [node('amount', '金额')]), node('year', '2025', null, [node('amount', '金额')])];
74
+ const summary = node('__summary_dim_id__', '合计', [node('amount', '金额', null, [{}])]);
75
+ columns.splice(totalFirst ? 0 : columns.length, 0, summary);
76
+ const values = [crossMetric('9007199254740993.12'), crossMetric('2.50')];
77
+ values.splice(totalFirst ? 0 : values.length, 0, crossMetric('9007199254740995.62', totalInfo));
78
+ const source = cross(columns, [node('region', '东部', [node('__summary_dim_id__', null, null, values)]), node('__summary_dim_id__', '合计', [node('amount', '金额', null, values.map(v => ({...v, rowSumInfo: totalInfo})))])]);
79
+ const result = normalize(source), data = result.datasets[0];
80
+ assert.equal(result.resultType, 'CROSS_TABLE'); assert.equal(result.normalization, 'COMPLETE');
81
+ assert.deepEqual(data.rows[0].cells.slice(1).map(c => c.value), values.map(v => v.text));
82
+ assert.equal(new Set(data.columns.map(c => c.fieldId)).size, 4);
83
+ assert.equal(data.rows[0].cells[1].sourceFieldId, 'amount');
84
+ assert.deepEqual(data.rows.map(r => r.kind), ['DATA', 'GRAND_TOTAL']);
85
+ assert.equal(data.columns[totalFirst ? 1 : 3].kind, 'GRAND_TOTAL');
86
+ assert.equal(result.range.mode, 'INITIAL_VIEW'); assert.equal(result.range.page, null);
87
+ assert.deepEqual(result.range.axes, {horizontal: {hasNext: true, hasPrevious: false}, vertical: {hasNext: false, hasPrevious: false}});
88
+ assert.equal(result.range.hasNext, true); assert.ok(result.warnings.includes('NEXT_PAGE_UNSUPPORTED'));
89
+ const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
90
+ assert.match(html, /2024/); assert.match(html, /2025/); assert.match(html, /总计/);
91
+ assert.match(tableCsv(data, data.rows), /9007199254740993.12/);
92
+ assert.equal(source.previewResult.top.items[0].children.length, 3);
93
+ }
94
+ });
95
+
96
+ test('cross table supports metrics on rows and multi-level columns without id joins', () => {
97
+ const source = cross([node('year', '2024', [node('quarter', 'Q1', null, []), node('quarter', 'Q2', null, [])])], [
98
+ node('region', '东部', [node('target-name', '金额', [node('amount', '金额', null, [crossMetric('12'), crossMetric('18')])]), node('target-name', '数量', [node('count', '数量', null, [crossMetric('3', detailInfo, detailInfo, 'count'), crossMetric('4', detailInfo, detailInfo, 'count')])])]),
99
+ ]);
100
+ const data = normalize(source).datasets[0];
101
+ assert.equal(data.columns.length, 3); assert.equal(data.rows.length, 2);
102
+ assert.match(data.columns[1].name, /2024.*Q1/);
103
+ assert.match(data.rows[1].cells[0].display, /东部.*数量/);
104
+ assert.deepEqual(data.rows[1].cells.slice(1).map(c => c.sourceFieldId), ['count', 'count']);
105
+ });
106
+
107
+ test('cross table never exposes restricted axis labels and retains missing/ambiguous value states', () => {
108
+ const secret = node('year', 'secret column', null, [{}]); secret.authority = false;
109
+ const row = node('region', 'secret row', null, [crossMetric('123')]); row.authority = false;
110
+ const result = normalize(cross([secret], [row]));
111
+ assert.doesNotMatch(JSON.stringify(result), /secret column|secret row/);
112
+ assert.equal(result.datasets[0].rows[0].cells[0].state, 'RESTRICTED');
113
+ assert.equal(result.datasets[0].rows[0].cells[1].state, 'RESTRICTED');
114
+ const source = cross([node('year', 'A', null, [{}, {}])], [node('region', 'X', null, [{...crossMetric('1万'), formatText: '1万'}])]);
115
+ const incomplete = normalize(source);
116
+ assert.equal(incomplete.normalization, 'PARTIAL');
117
+ assert.equal(incomplete.datasets[0].rows[0].cells[1].state, 'UNAVAILABLE');
118
+ assert.equal(incomplete.datasets[0].rows[0].cells[2].reason, 'MISSING_CELL');
119
+ source.previewResult.left.items[0].children[0].values.push(crossMetric('2'), crossMetric('3'));
120
+ assert.equal(normalize(source).normalization, 'UNSUPPORTED', 'extra values must not be silently dropped');
121
+ });
122
+
123
+ test('cross table distinguishes empty views, missing axes and unsupported mixed node topology', () => {
124
+ assert.equal(normalize(cross([], [])).normalization, 'COMPLETE');
125
+ assert.equal(normalize(cross([], [])).datasets[0].returnedRowCount, 0);
126
+ assert.equal(normalize({previewResult: {resultType: 'BICROSS', left: {header: [], items: []}}}).normalization, 'UNSUPPORTED');
127
+ assert.equal(normalize(cross([node('x', 'A', [node('x', 'B')], [{}])], [])).normalization, 'UNSUPPORTED');
128
+ });
129
+
130
+ test('cross table handles no column dimensions and preserves metric-name intersection placeholders', () => {
131
+ const source = cross([], [node('target', '金额', null, [crossMetric('7'), {dId: null, text: null, value: null, authority: true}])]);
132
+ source.previewResult.top = {header: [], items: []};
133
+ const result = normalize(source);
134
+ assert.equal(result.normalization, 'COMPLETE');
135
+ assert.deepEqual(result.datasets[0].rows[0].cells.slice(1).map(c => c.state), ['AVAILABLE', 'UNAVAILABLE']);
136
+ assert.equal(result.datasets[0].rows[0].cells[2].value, null);
137
+ const swapped = cross([node('year', '2024', null, [node('amount', '金额')])], [node('region', '东部', null, [crossMetric('1', detailInfo, detailInfo, 'count')])]);
138
+ assert.equal(normalize(swapped).normalization, 'PARTIAL');
139
+ assert.equal(normalize(swapped).datasets[0].rows[0].cells[1].reason, 'AMBIGUOUS_FIELD');
140
+ });
141
+
142
+ test('group preserves parent keys, totals, metric precision and the first group page', () => {
143
+ const source = group([{dId: 'region', text: '东部', authority: true, values: [metric('9007199254740993.12', {totalSum: false, innerSum: true})], children: [
144
+ {dId: 'city', text: '甲城', authority: true, values: [metric('12.50', undefined, {formatText: '12.50元'})]},
145
+ ]}, {values: [metric('9007199254741005.62', {totalSum: true, innerSum: false})]}]);
146
+ const result = normalize(source), data = result.datasets[0];
147
+ assert.equal(result.resultType, 'GROUP_TABLE');
148
+ assert.equal(result.normalization, 'COMPLETE');
149
+ assert.deepEqual(data.rows.map(r => r.kind), ['SUBTOTAL', 'DATA', 'GRAND_TOTAL']);
150
+ assert.deepEqual(data.rows[1].cells.map(c => c.value), ['东部', '甲城', '12.50']);
151
+ assert.equal(data.rows[0].cells[2].value, '9007199254740993.12');
152
+ assert.equal(data.returnedRowCount, 3);
153
+ assert.deepEqual(result.range, {mode: 'PAGE', page: 1, pageSize: 10, pageUnit: 'GROUP', hasNext: true, hasPrevious: false, total: {value: null, unit: 'GROUP'}});
154
+ assert.ok(result.warnings.includes('NEXT_PAGE_UNSUPPORTED'));
155
+ const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
156
+ assert.match(html, /小计/); assert.match(html, /总计/);
157
+ assert.match(tableCsv(data, data.rows), /GRAND_TOTAL/);
158
+ });
159
+
160
+ test('group keeps restricted and ambiguous cells unavailable and does not infer numeric group keys', () => {
161
+ const source = group([{dId: 'region', text: '42', authority: true, values: [metric('12万', undefined, {formatText: '12万'})], children: [
162
+ {dId: 'city', text: '秘密', authority: false, values: [metric('999', undefined, {authority: false})]},
163
+ {dId: 'city', text: '乙城', authority: true, values: [metric('1'), metric('2')]},
164
+ {dId: 'city', text: '丙城', authority: true, values: [metric(null, null)]},
165
+ ]}]);
166
+ source.widgetBean.dimensions.region.type = 2;
167
+ const result = normalize(source), rows = result.datasets[0].rows;
168
+ assert.equal(rows[0].cells[0].reason, 'GROUP_KEY_SEMANTICS_UNKNOWN');
169
+ assert.equal(rows[0].cells[2].state, 'UNAVAILABLE');
170
+ assert.equal(rows[1].cells[1].state, 'RESTRICTED');
171
+ assert.equal(rows[1].cells[2].state, 'RESTRICTED');
172
+ assert.equal(rows[2].cells[2].reason, 'AMBIGUOUS_FIELD');
173
+ assert.equal(rows[3].cells[2].state, 'UNAVAILABLE');
174
+ assert.equal(rows[3].kind, 'UNKNOWN');
175
+ assert.equal(result.normalization, 'PARTIAL');
176
+ });
177
+
178
+ test('group header expansion reports an initial view and malformed groups are not empty successes', () => {
179
+ const source = group([]);
180
+ source.widgetBean.settings.tableStyle.showRowHeaderIcon = true;
181
+ const result = normalize(source);
182
+ assert.equal(result.range.mode, 'INITIAL_VIEW');
183
+ assert.equal(result.range.page, null);
184
+ assert.equal(result.range.hasNext, null);
185
+ assert.equal(result.datasets[0].returnedRowCount, 0);
186
+ assert.equal(normalize({previewResult: {resultType: 'BIGROUP'}}).normalization, 'UNSUPPORTED');
187
+ source.previewResult.items = [null, {dId: 'unknown', values: [metric('1')]}];
188
+ assert.equal(normalize(source).normalization, 'PARTIAL');
189
+ });
@@ -0,0 +1,41 @@
1
+ import assert from 'node:assert/strict';
2
+ import {test} from 'node:test';
3
+ import {createServer as httpServer} from 'node:http';
4
+ import {mkdir,mkdtemp,cp,writeFile,readFile} from 'node:fs/promises';
5
+ import {resolve} from 'node:path';
6
+ import {createServer} from 'vite';
7
+
8
+ test('real Vite uses project env, persists sessions, blocks credential files and survives token updates',async()=>{
9
+ await mkdir('temp',{recursive:true});
10
+ const root=await mkdtemp(resolve('temp','vite-auth-'));
11
+ for(const name of ['vite.config.ts','scripts','src','public','index.html','package.json'])await cp(resolve(name),resolve(root,name),{recursive:true});
12
+ await mkdir(resolve(root,'temp'));
13
+ await writeFile(resolve(root,'temp','staged-session'),'staged-secret');
14
+ let logins=0,queries=0;
15
+ const upstream=httpServer(async(req,res)=>{
16
+ for await(const _ of req){};
17
+ res.setHeader('content-type','application/json');
18
+ if(req.url==='/decision/login'){logins++;res.end(JSON.stringify({data:{accessToken:'persisted-session'}}));return;}
19
+ assert.equal(req.headers.cookie,'fine_auth_token=persisted-session');queries++;
20
+ res.end(JSON.stringify({success:true,data:{}}));
21
+ });
22
+ await new Promise(r=>upstream.listen(0,'127.0.0.1',r));
23
+ const baseUrl=`http://127.0.0.1:${upstream.address().port}/decision`;
24
+ await writeFile(resolve(root,'.env.local'),`FINEBI_DEV_BASE_URL=${baseUrl}\nFINEBI_DEV_USERNAME=alice\nFINEBI_DEV_PASSWORD=password\n`);
25
+ await writeFile(resolve(root,'public','app-config.json'),JSON.stringify({basePath:'/decision',dashboardId:'r',widgets:[{entryId:'w'}],maxRows:10,filters:[]}));
26
+ const server=await createServer({root,configFile:resolve(root,'vite.config.ts'),cacheDir:resolve(root,'temp','cache'),server:{port:0},logLevel:'silent'});
27
+ try {
28
+ await server.listen();
29
+ const url=`http://127.0.0.1:${server.httpServer.address().port}`;
30
+ assert.equal((await fetch(url+'/')).status,200,'project under a temp parent must still serve its index');
31
+ assert.equal((await fetch(url+'/src/main.tsx')).status,200,'project source must remain loadable');
32
+ const send=()=>fetch(url+'/decision/v5/api/dashboard/report/r/widgets/query',{method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({widgetIds:['w'],pageLimit:10})});
33
+ const result=await send();assert.equal(result.status,200);assert.doesNotMatch(await result.text(),/persisted-session|password/);
34
+ assert.match(await readFile(resolve(root,'.env.local'),'utf8'),/FINEBI_DEV_TOKEN="persisted-session"/);
35
+ assert.equal((await fetch(url+'/.env.local')).status,403);
36
+ assert.equal((await fetch(url+'/temp/staged-session')).status,403);
37
+ // Allow the file watcher to process the save; it must not restart the session manager.
38
+ await new Promise(r=>setTimeout(r,400));
39
+ assert.equal((await send()).status,200);assert.equal(logins,1);assert.equal(queries,2);
40
+ } finally {await server.close();await new Promise(r=>upstream.close(r));}
41
+ });
@@ -1,4 +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
- }
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
+ }
@@ -1,15 +1,20 @@
1
1
  import {readFileSync} from 'node:fs';
2
+ import {resolve} from 'node:path';
2
3
  import {fileURLToPath} from 'node:url';
3
- import {defineConfig, loadEnv} from 'vite';
4
+ import {defineConfig, loadEnv, normalizePath} from 'vite';
4
5
  // @ts-expect-error The development-only Node module is tested directly with node:test.
5
6
  import {createDevMiddleware} from './scripts/dev-proxy.mjs';
7
+ const projectTemp=normalizePath(fileURLToPath(new URL('./temp',import.meta.url)))+'/**';
6
8
  export default defineConfig(({mode}) => ({
7
9
  base: './',
8
10
  resolve:{alias:[{find:/^date-fns\/locale$/,replacement:fileURLToPath(new URL('./src/components/calendar-locales.ts',import.meta.url))}]},
9
- server: {host:'127.0.0.1',port:4173,strictPort:true},
11
+ server: {host:'127.0.0.1',port:4173,strictPort:true,watch:{ignored:['**/.env.local',projectTemp]},
12
+ fs:{deny:['.env','.env.*','*.{crt,pem}','**/.git/**',projectTemp]}},
10
13
  plugins:[{name:'finebi-development-session',apply:'serve',configureServer(server) {
11
14
  const config=JSON.parse(readFileSync(new URL('./public/app-config.json',import.meta.url),'utf8'));
12
- const env={...loadEnv(mode,process.cwd(),'FINEBI_DEV_'),...process.env};
13
- server.middlewares.use(createDevMiddleware(config,env));
15
+ const env={...loadEnv(mode,server.config.root,'FINEBI_DEV_'),...process.env};
16
+ const middleware=createDevMiddleware(config,env,fetch,{file:resolve(server.config.root,'.env.local')});
17
+ server.middlewares.use(middleware);
18
+ server.httpServer?.once('close',()=>middleware.dispose());
14
19
  }}],
15
20
  }));
@@ -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 CHANGED
@@ -1,148 +1,157 @@
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
- if (options.value.layout && !['overview','workbench','cockpit','retail'].includes(options.value.layout)) return invalid('app.create','layout must be overview, workbench, cockpit or retail.');
30
- const parent = resolve(parentPath);
31
- const destination = resolve(parent, name);
32
- if (dirname(destination) !== parent) {
33
- return invalid("app.create", "name must stay inside the selected parent directory.");
34
- }
35
- try {
36
- if (!(await stat(parent)).isDirectory()) return invalid("app.create", "path must point to a directory.");
37
- } catch (error) {
38
- return invalid("app.create", error.code === "ENOENT" ? `Parent directory does not exist: ${parent}` : `Cannot inspect parent directory: ${error.message}`);
39
- }
40
- try {
41
- const templateRoot = context.templateRoot ? resolve(context.templateRoot) : TEMPLATE_ROOT;
42
- const templatePackage = JSON.parse(await readFile(resolve(templateRoot, "package.json"), "utf8"));
43
- const templateConfigPath = resolve(templateRoot, "public", "app-config.json");
44
- let config = {schemaVersion: "1", title: name, basePath: "/webroot/decision", layout: "overview", dashboardId: "", maxRows: 1000, pageSize: 8, widgets: [], filters: []};
45
- try {
46
- config = JSON.parse(await readFile(templateConfigPath, "utf8"));
47
- } catch (error) {
48
- if (error.code !== "ENOENT") throw error;
49
- }
50
- try {
51
- await mkdir(destination, {recursive: false});
52
- } catch (error) {
53
- return invalid("app.create", error.code === "EEXIST" ? `Destination already exists: ${destination}` : `Cannot create destination: ${error.message}`);
54
- }
55
- await copyTemplateContents(templateRoot, destination);
56
- if(options.value.layout) config.layout=options.value.layout;
57
- if(config.layout==='cockpit') config.insights=true;
58
- await mkdir(resolve(destination, "public"), {recursive: true});
59
- await writeFile(resolve(destination, "package.json"), `${JSON.stringify({...templatePackage, name}, null, 2)}\n`, "utf8");
60
- await writeFile(resolve(destination, "public", "app-config.json"), `${JSON.stringify({...config, templateVersion: templatePackage.version}, null, 2)}\n`, "utf8");
61
- const lockPath = resolve(destination, "package-lock.json");
62
- try {
63
- const lock = JSON.parse(await readFile(lockPath, "utf8"));
64
- lock.name = name;
65
- if (lock.packages?.[""]) lock.packages[""].name = name;
66
- await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`, "utf8");
67
- } catch (error) {
68
- if (error.code !== "ENOENT") throw error;
69
- }
70
- return success("app.create", {name, path: destination, templateVersion: templatePackage.version});
71
- } catch (error) {
72
- return invalid("app.create", `Could not create project: ${error.message}`);
73
- }
74
- }
75
-
76
- async function copyTemplateContents(sourceRoot, destinationRoot, relativeDirectory = "") {
77
- const sourceDirectory = resolve(sourceRoot, relativeDirectory);
78
- const destinationDirectory = resolve(destinationRoot, relativeDirectory);
79
- for (const entry of (await readdir(sourceDirectory, {withFileTypes: true})).sort((left, right) => left.name.localeCompare(right.name, "en"))) {
80
- const relativePath = `${relativeDirectory}/${entry.name}`.replace(/^\//u, "");
81
- if (!templatePathAllowed(relativePath)) continue;
82
- const sourcePath = resolve(sourceDirectory, entry.name);
83
- const destinationPath = resolve(destinationDirectory, entry.name);
84
- if (entry.isDirectory()) {
85
- await mkdir(destinationPath, {recursive: false});
86
- await copyTemplateContents(sourceRoot, destinationRoot, relativePath);
87
- } else if (entry.isFile()) {
88
- await cp(sourcePath, destinationPath, {force: false, errorOnExist: true});
89
- } else {
90
- throw new Error(`Unsupported template entry: ${relativePath}`);
91
- }
92
- }
93
- }
94
-
95
- function templatePathAllowed(relative) {
96
- const relativePath = relative.replaceAll("\\", "/");
97
- const parts = relativePath.split("/").filter(Boolean);
98
- const basename = parts.at(-1) || "";
99
- return !parts.some(part => ["node_modules", "dist", "temp", ".git"].includes(part))
100
- && basename !== ".env"
101
- && (!basename.startsWith(".env.") || basename === ".env.example");
102
- }
103
-
104
- async function inspectApp(args) {
105
- const options = parseOptions(args, "app.inspect");
106
- if (!options.ok) return options.error;
107
- const projectPath = options.value.path;
108
- if (!projectPath) return invalid("app.inspect", "path must be a project directory.");
109
- const project = resolve(projectPath);
110
- try {
111
- if (!(await stat(project)).isDirectory()) return invalid("app.inspect", "path must point to a project directory.");
112
- const configPath = resolve(project, "public", "app-config.json");
113
- const config = JSON.parse(await readFile(configPath, "utf8"));
114
- const {configErrors}=await import('../templates/web-app/src/finebi/config.js');
115
- const errors = configErrors(config);
116
- if (errors.length) return failure("app.inspect", cliError("INVALID_ARGUMENT", {path: "public/app-config.json", message: errors.join(" ")}));
117
- return success("app.inspect", {
118
- path: project,
119
- dashboardId: config.dashboardId,
120
- layout: config.layout,
121
- directoryEntryId: config.directoryEntryId || null,
122
- widgets: config.widgets.length,
123
- templateVersion: config.templateVersion || null,
124
- remoteValidated: false,
125
- });
126
- } catch (error) {
127
- return invalid("app.inspect", `Cannot read project configuration: ${error.message}`);
128
- }
129
- }
130
-
131
- function parseOptions(args, command) {
132
- const value = {};
133
- for (let index = 0; index < args.length; index++) {
134
- const option = args[index];
135
- if (option === "--json") continue;
136
- if (!["--name","--path",...(command==='app.create'?['--layout']:[])].includes(option)) return {ok: false, error: invalid(command, `Unsupported option: ${option}`)};
137
- const key = option.slice(2);
138
- const next = args[++index];
139
- if (!next || next.startsWith("--")) return {ok: false, error: invalid(command, `${option} requires a value.`)};
140
- if (value[key]) return {ok: false, error: invalid(command, `${option} must only be supplied once.`)};
141
- value[key] = next;
142
- }
143
- return {ok: true, value};
144
- }
145
-
146
- function invalid(command, message, nextActions = []) {
147
- return failure(command, cliError("INVALID_ARGUMENT", {path: command, message, nextActions}));
148
- }
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
+ if (options.value.layout && !['overview','workbench','cockpit','retail'].includes(options.value.layout)) return invalid('app.create','layout must be overview, workbench, cockpit or retail.');
30
+ const parent = resolve(parentPath);
31
+ const destination = resolve(parent, name);
32
+ if (dirname(destination) !== parent) {
33
+ return invalid("app.create", "name must stay inside the selected parent directory.");
34
+ }
35
+ try {
36
+ if (!(await stat(parent)).isDirectory()) return invalid("app.create", "path must point to a directory.");
37
+ } catch (error) {
38
+ return invalid("app.create", error.code === "ENOENT" ? `Parent directory does not exist: ${parent}` : `Cannot inspect parent directory: ${error.message}`);
39
+ }
40
+ try {
41
+ const templateRoot = context.templateRoot ? resolve(context.templateRoot) : TEMPLATE_ROOT;
42
+ const templatePackage = JSON.parse(await readFile(resolve(templateRoot, "package.json"), "utf8"));
43
+ const templateConfigPath = resolve(templateRoot, "public", "app-config.json");
44
+ let config = {schemaVersion: "1", title: name, basePath: "/webroot/decision", layout: "overview", dashboardId: "", maxRows: 1000, pageSize: 8, widgets: [], filters: []};
45
+ try {
46
+ config = JSON.parse(await readFile(templateConfigPath, "utf8"));
47
+ } catch (error) {
48
+ if (error.code !== "ENOENT") throw error;
49
+ }
50
+ try {
51
+ await mkdir(destination, {recursive: false});
52
+ } catch (error) {
53
+ return invalid("app.create", error.code === "EEXIST" ? `Destination already exists: ${destination}` : `Cannot create destination: ${error.message}`);
54
+ }
55
+ await copyTemplateContents(templateRoot, destination);
56
+ if(options.value.layout) config.layout=options.value.layout;
57
+ if(config.layout==='cockpit') config.insights=true;
58
+ await mkdir(resolve(destination, "public"), {recursive: true});
59
+ await writeFile(resolve(destination, "package.json"), `${JSON.stringify({...templatePackage, name}, null, 2)}\n`, "utf8");
60
+ await writeFile(resolve(destination, "public", "app-config.json"), `${JSON.stringify({...config, templateVersion: templatePackage.version}, null, 2)}\n`, "utf8");
61
+ const lockPath = resolve(destination, "package-lock.json");
62
+ try {
63
+ const lock = JSON.parse(await readFile(lockPath, "utf8"));
64
+ lock.name = name;
65
+ if (lock.packages?.[""]) lock.packages[""].name = name;
66
+ await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`, "utf8");
67
+ } catch (error) {
68
+ if (error.code !== "ENOENT") throw error;
69
+ }
70
+ return success("app.create", {name, path: destination, templateVersion: templatePackage.version});
71
+ } catch (error) {
72
+ return invalid("app.create", `Could not create project: ${error.message}`);
73
+ }
74
+ }
75
+
76
+ async function copyTemplateContents(sourceRoot, destinationRoot, relativeDirectory = "") {
77
+ const sourceDirectory = resolve(sourceRoot, relativeDirectory);
78
+ const destinationDirectory = resolve(destinationRoot, relativeDirectory);
79
+ for (const entry of (await readdir(sourceDirectory, {withFileTypes: true})).sort((left, right) => left.name.localeCompare(right.name, "en"))) {
80
+ const relativePath = `${relativeDirectory}/${entry.name}`.replace(/^\//u, "");
81
+ if (!templatePathAllowed(relativePath)) continue;
82
+ const sourcePath = resolve(sourceDirectory, entry.name);
83
+ const destinationPath = resolve(destinationDirectory, entry.name);
84
+ if (entry.isDirectory()) {
85
+ await mkdir(destinationPath, {recursive: false});
86
+ await copyTemplateContents(sourceRoot, destinationRoot, relativePath);
87
+ } else if (entry.isFile()) {
88
+ await cp(sourcePath, destinationPath, {force: false, errorOnExist: true});
89
+ } else {
90
+ throw new Error(`Unsupported template entry: ${relativePath}`);
91
+ }
92
+ }
93
+ }
94
+
95
+ function templatePathAllowed(relative) {
96
+ const relativePath = relative.replaceAll("\\", "/");
97
+ const parts = relativePath.split("/").filter(Boolean);
98
+ const basename = parts.at(-1) || "";
99
+ return !parts.some(part => ["node_modules", "dist", "temp", ".git"].includes(part))
100
+ && basename !== ".env"
101
+ && (!basename.startsWith(".env.") || basename === ".env.example");
102
+ }
103
+
104
+ async function inspectApp(args) {
105
+ const options = parseOptions(args, "app.inspect");
106
+ if (!options.ok) return options.error;
107
+ const projectPath = options.value.path;
108
+ if (!projectPath) return invalid("app.inspect", "path must be a project directory.");
109
+ const project = resolve(projectPath);
110
+ try {
111
+ if (!(await stat(project)).isDirectory()) return invalid("app.inspect", "path must point to a project directory.");
112
+ const configPath = resolve(project, "public", "app-config.json");
113
+ const config = JSON.parse(await readFile(configPath, "utf8"));
114
+ if (!config || typeof config !== "object" || Array.isArray(config)) return invalid("app.inspect", "app-config.json must contain a JSON object.");
115
+ if (options.value.strict) {
116
+ const {configErrors}=await import('../templates/web-app/src/finebi/config.js');
117
+ const errors = configErrors(config);
118
+ if (errors.length) return failure("app.inspect", cliError("INVALID_ARGUMENT", {path: "public/app-config.json", message: errors.join(" ")}));
119
+ }
120
+ return success("app.inspect", {
121
+ path: project,
122
+ dashboardId: config.dashboardId ?? null,
123
+ layout: config.layout ?? null,
124
+ directoryEntryId: config.directoryEntryId || null,
125
+ widgets: Array.isArray(config.widgets) ? config.widgets.length : null,
126
+ templateVersion: config.templateVersion || null,
127
+ configValidated: options.value.strict === true,
128
+ remoteValidated: false,
129
+ });
130
+ } catch (error) {
131
+ return invalid("app.inspect", `Cannot read project configuration: ${error.message}`);
132
+ }
133
+ }
134
+
135
+ function parseOptions(args, command) {
136
+ const value = {};
137
+ for (let index = 0; index < args.length; index++) {
138
+ const option = args[index];
139
+ if (option === "--json") continue;
140
+ if (option === "--strict" && command === "app.inspect") {
141
+ if (value.strict) return {ok: false, error: invalid(command, "--strict must only be supplied once.")};
142
+ value.strict = true;
143
+ continue;
144
+ }
145
+ if (!["--name","--path",...(command==='app.create'?['--layout']:[])].includes(option)) return {ok: false, error: invalid(command, `Unsupported option: ${option}`)};
146
+ const key = option.slice(2);
147
+ const next = args[++index];
148
+ if (!next || next.startsWith("--")) return {ok: false, error: invalid(command, `${option} requires a value.`)};
149
+ if (value[key]) return {ok: false, error: invalid(command, `${option} must only be supplied once.`)};
150
+ value[key] = next;
151
+ }
152
+ return {ok: true, value};
153
+ }
154
+
155
+ function invalid(command, message, nextActions = []) {
156
+ return failure(command, cliError("INVALID_ARGUMENT", {path: command, message, nextActions}));
157
+ }
@@ -160,9 +160,8 @@ function abortedAuthorization() {
160
160
  function validateBaseUrl(value) {
161
161
  try {
162
162
  const url = new URL(value);
163
- const localHttp = url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname.toLowerCase());
164
- if (url.protocol !== "https:" && !localHttp) {
165
- throw new Error("Browser authorization requires HTTPS, except for localhost development.");
163
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
164
+ throw new Error("Browser authorization requires an HTTP or HTTPS URL.");
166
165
  }
167
166
  if (url.username || url.password || url.search || url.hash) {
168
167
  throw new Error("Browser authorization base URL must not contain credentials, a query, or a fragment.");
@@ -172,7 +171,7 @@ function validateBaseUrl(value) {
172
171
  return failure("auth.login", cliError("BROWSER_AUTH_URL_INVALID", {
173
172
  path: "baseUrl",
174
173
  message: error instanceof Error ? error.message : String(error),
175
- nextActions: ["finebi-cli config set base-url <https-url>"],
174
+ nextActions: ["finebi-cli config set base-url <http-or-https-url>"],
176
175
  }));
177
176
  }
178
177
  }