@finebi/finebi-cli 1.6.0 → 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 (160) hide show
  1. package/README.md +16 -12
  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 +31 -2
  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 +12 -6
  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 -189
  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 +65 -61
  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 +23 -7
  25. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/README.md +19 -6
  26. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/acceptance.md +80 -0
  27. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/auth-and-deploy.md +19 -4
  28. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/data-contract.md +48 -3
  29. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/design.md +43 -2
  30. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/filters.md +6 -2
  31. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/cockpit.config.json +22 -0
  32. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/retail.config.json +185 -0
  33. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/index.html +2 -2
  34. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/package-lock.json +2 -2
  35. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/package.json +3 -2
  36. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/public/app-config.json +11 -11
  37. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/browser-session.mjs +59 -0
  38. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-proxy.mjs +26 -24
  39. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-session.mjs +57 -0
  40. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/session-file.mjs +45 -0
  41. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +63 -31
  42. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/cockpit.css +3 -0
  43. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +40 -31
  44. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx +12 -5
  45. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/FilterBar.tsx +16 -10
  46. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Insights.tsx +17 -0
  47. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/MonthPicker.tsx +32 -0
  48. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/OptionFilter.tsx +7 -0
  49. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/RangeFilter.tsx +9 -0
  50. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +40 -28
  51. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar-locales.ts +3 -0
  52. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts +14 -14
  53. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Bar.tsx +6 -0
  54. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Line.tsx +6 -0
  55. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Pie.tsx +6 -0
  56. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/month-picker.css +1 -0
  57. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/client.ts +32 -11
  58. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.d.ts +3 -0
  59. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +58 -0
  60. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.d.ts +1 -1
  61. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.js +5 -5
  62. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.d.ts +4 -4
  63. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.js +106 -106
  64. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +235 -77
  65. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +20 -18
  66. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/main.tsx +5 -5
  67. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +13 -0
  68. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/presentation.ts +36 -0
  69. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/table-data.ts +47 -0
  70. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts +18 -18
  71. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/widget-queries.ts +18 -0
  72. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/fixtures.mjs +28 -0
  73. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/index.html +16 -0
  74. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/main.tsx +149 -0
  75. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/test-page.css +4 -0
  76. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/vite.config.mjs +13 -0
  77. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/bundle.test.mjs +19 -0
  78. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/cockpit.test.mjs +73 -0
  79. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/field-selection.test.mjs +70 -0
  80. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/proxy.test.mjs +31 -4
  81. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/retail.test.mjs +31 -0
  82. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/runtime.test.mjs +8 -1
  83. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/session.test.mjs +91 -0
  84. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/table-normalize.test.mjs +189 -0
  85. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/vite-auth.test.mjs +41 -0
  86. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tsconfig.json +4 -4
  87. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +11 -4
  88. package/skills/finebi-cli-agent/scripts/finebi-cli-runner.mjs +4 -4
  89. package/src/app.js +157 -189
  90. package/src/browser-auth.js +3 -4
  91. package/src/config.js +28 -28
  92. package/src/error-catalog.js +2 -2
  93. package/src/help.js +65 -61
  94. package/src/install-skill.js +89 -89
  95. package/src/profiles.js +158 -158
  96. package/src/skill-files.js +94 -94
  97. package/templates/web-app/AGENTS.md +23 -7
  98. package/templates/web-app/README.md +19 -6
  99. package/templates/web-app/docs/acceptance.md +80 -0
  100. package/templates/web-app/docs/auth-and-deploy.md +19 -4
  101. package/templates/web-app/docs/data-contract.md +48 -3
  102. package/templates/web-app/docs/design.md +43 -2
  103. package/templates/web-app/docs/filters.md +6 -2
  104. package/templates/web-app/examples/cockpit.config.json +22 -0
  105. package/templates/web-app/examples/retail.config.json +185 -0
  106. package/templates/web-app/index.html +2 -2
  107. package/templates/web-app/package-lock.json +2 -2
  108. package/templates/web-app/package.json +3 -2
  109. package/templates/web-app/public/app-config.json +11 -11
  110. package/templates/web-app/scripts/browser-session.mjs +59 -0
  111. package/templates/web-app/scripts/dev-proxy.mjs +26 -24
  112. package/templates/web-app/scripts/dev-session.mjs +57 -0
  113. package/templates/web-app/scripts/session-file.mjs +45 -0
  114. package/templates/web-app/src/App.tsx +63 -31
  115. package/templates/web-app/src/cockpit.css +3 -0
  116. package/templates/web-app/src/components/Chart.tsx +40 -31
  117. package/templates/web-app/src/components/DataTable.tsx +12 -5
  118. package/templates/web-app/src/components/FilterBar.tsx +16 -10
  119. package/templates/web-app/src/components/Insights.tsx +17 -0
  120. package/templates/web-app/src/components/MonthPicker.tsx +32 -0
  121. package/templates/web-app/src/components/OptionFilter.tsx +7 -0
  122. package/templates/web-app/src/components/RangeFilter.tsx +9 -0
  123. package/templates/web-app/src/components/Widget.tsx +40 -28
  124. package/templates/web-app/src/components/calendar-locales.ts +3 -0
  125. package/templates/web-app/src/components/calendar.ts +14 -14
  126. package/templates/web-app/src/components/charts/Bar.tsx +6 -0
  127. package/templates/web-app/src/components/charts/Line.tsx +6 -0
  128. package/templates/web-app/src/components/charts/Pie.tsx +6 -0
  129. package/templates/web-app/src/components/month-picker.css +1 -0
  130. package/templates/web-app/src/finebi/client.ts +32 -11
  131. package/templates/web-app/src/finebi/config.d.ts +3 -0
  132. package/templates/web-app/src/finebi/config.js +58 -0
  133. package/templates/web-app/src/finebi/date.d.ts +1 -1
  134. package/templates/web-app/src/finebi/date.js +5 -5
  135. package/templates/web-app/src/finebi/filters.d.ts +4 -4
  136. package/templates/web-app/src/finebi/filters.js +106 -106
  137. package/templates/web-app/src/finebi/normalize.ts +235 -77
  138. package/templates/web-app/src/finebi/types.ts +20 -18
  139. package/templates/web-app/src/main.tsx +5 -5
  140. package/templates/web-app/src/retail.css +13 -0
  141. package/templates/web-app/src/services/presentation.ts +36 -0
  142. package/templates/web-app/src/services/table-data.ts +47 -0
  143. package/templates/web-app/src/services/view-data.ts +18 -18
  144. package/templates/web-app/src/services/widget-queries.ts +18 -0
  145. package/templates/web-app/tests/browser/fixtures.mjs +28 -0
  146. package/templates/web-app/tests/browser/index.html +16 -0
  147. package/templates/web-app/tests/browser/main.tsx +149 -0
  148. package/templates/web-app/tests/browser/test-page.css +4 -0
  149. package/templates/web-app/tests/browser/vite.config.mjs +13 -0
  150. package/templates/web-app/tests/bundle.test.mjs +19 -0
  151. package/templates/web-app/tests/cockpit.test.mjs +73 -0
  152. package/templates/web-app/tests/field-selection.test.mjs +70 -0
  153. package/templates/web-app/tests/proxy.test.mjs +31 -4
  154. package/templates/web-app/tests/retail.test.mjs +31 -0
  155. package/templates/web-app/tests/runtime.test.mjs +8 -1
  156. package/templates/web-app/tests/session.test.mjs +91 -0
  157. package/templates/web-app/tests/table-normalize.test.mjs +189 -0
  158. package/templates/web-app/tests/vite-auth.test.mjs +41 -0
  159. package/templates/web-app/tsconfig.json +4 -4
  160. package/templates/web-app/vite.config.ts +11 -4
package/src/app.js CHANGED
@@ -1,189 +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
- 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
- }
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
  }
package/src/config.js CHANGED
@@ -8,7 +8,7 @@ import {CLI_CONFIG_DIRECTORY} from "./branding.js";
8
8
  import {maskSecret} from "./output.js";
9
9
 
10
10
  export const DEFAULT_TIMEOUT_MS = 30000;
11
- export const DEFAULT_CAPABILITY_TTL_MS = 600000;
11
+ export const DEFAULT_CAPABILITY_TTL_MS = 20 * 60 * 60 * 1000;
12
12
 
13
13
  export function defaultConfigPath(env = process.env) {
14
14
  const home = env.USERPROFILE || env.HOME || homedir();
@@ -56,23 +56,23 @@ export function writeConfigAtomically(path, config, options = {}) {
56
56
  }
57
57
  }
58
58
 
59
- export function resolveConfig(flags = {}, env = process.env, fileConfig = readConfig()) {
60
- const baseUrl = firstNonEmpty(flags.baseUrl, env.FINEBI_BASE_URL, fileConfig.baseUrl);
61
- const sameServer = sameBaseUrl(baseUrl, fileConfig.baseUrl);
62
- const explicitToken = firstNonEmpty(flags.token, env.FINEBI_AUTH_TOKEN);
63
- const savedAuth = sameServer ? fileConfig.auth : undefined;
64
- const token = firstNonEmpty(explicitToken, savedAuth?.token, sameServer ? fileConfig.token : undefined);
59
+ export function resolveConfig(flags = {}, env = process.env, fileConfig = readConfig()) {
60
+ const baseUrl = firstNonEmpty(flags.baseUrl, env.FINEBI_BASE_URL, fileConfig.baseUrl);
61
+ const sameServer = sameBaseUrl(baseUrl, fileConfig.baseUrl);
62
+ const explicitToken = firstNonEmpty(flags.token, env.FINEBI_AUTH_TOKEN);
63
+ const savedAuth = sameServer ? fileConfig.auth : undefined;
64
+ const token = firstNonEmpty(explicitToken, savedAuth?.token, sameServer ? fileConfig.token : undefined);
65
65
  return normalizeConfig({
66
66
  ...fileConfig,
67
- baseUrl,
67
+ baseUrl,
68
68
  timeoutMs: parsePositiveInt(firstNonEmpty(flags.timeoutMs, env.FINEBI_TIMEOUT_MS), fileConfig.timeoutMs),
69
69
  capabilityTtlMs: parsePositiveInt(
70
70
  firstNonEmpty(flags.capabilityTtlMs, env.FINEBI_CAPABILITY_TTL_MS),
71
71
  fileConfig.capabilityTtlMs,
72
72
  ),
73
73
  auth: {
74
- ...(explicitToken ? {} : savedAuth),
75
- token,
74
+ ...(explicitToken ? {} : savedAuth),
75
+ token,
76
76
  },
77
77
  });
78
78
  }
@@ -81,10 +81,10 @@ export function updateConfigValue(config, key, value, now = () => new Date().toI
81
81
  const next = normalizeConfig(config);
82
82
  switch (key) {
83
83
  case "base-url":
84
- if (next.baseUrl !== value) {
85
- next.capabilityCacheInvalidatedAt = now();
86
- delete next.auth;
87
- delete next.token;
84
+ if (next.baseUrl !== value) {
85
+ next.capabilityCacheInvalidatedAt = now();
86
+ delete next.auth;
87
+ delete next.token;
88
88
  }
89
89
  next.baseUrl = value;
90
90
  return next;
@@ -128,7 +128,7 @@ function defaultConfig() {
128
128
  };
129
129
  }
130
130
 
131
- export function normalizeConfig(config) {
131
+ export function normalizeConfig(config) {
132
132
  return {
133
133
  ...config,
134
134
  timeoutMs: parsePositiveInt(config.timeoutMs, DEFAULT_TIMEOUT_MS),
@@ -136,14 +136,14 @@ export function normalizeConfig(config) {
136
136
  };
137
137
  }
138
138
 
139
- function sanitizeConfig(config) {
140
- if (config.schemaVersion === 2) {
141
- return {
142
- schemaVersion: 2,
143
- currentProfile: config.currentProfile,
144
- profiles: Object.fromEntries(Object.entries(config.profiles).map(([name, profile]) => [name, sanitizeConfig(normalizeConfig(profile))])),
145
- };
146
- }
139
+ function sanitizeConfig(config) {
140
+ if (config.schemaVersion === 2) {
141
+ return {
142
+ schemaVersion: 2,
143
+ currentProfile: config.currentProfile,
144
+ profiles: Object.fromEntries(Object.entries(config.profiles).map(([name, profile]) => [name, sanitizeConfig(normalizeConfig(profile))])),
145
+ };
146
+ }
147
147
  const { token: _legacyToken, ...rest } = config;
148
148
  const auth = config.auth
149
149
  ? {
@@ -166,11 +166,11 @@ function sanitizeConfig(config) {
166
166
  ...rest,
167
167
  auth,
168
168
  };
169
- }
170
-
171
- export function sameBaseUrl(left, right) {
172
- return (left || "").replace(/\/+$/, "") === (right || "").replace(/\/+$/, "");
173
- }
169
+ }
170
+
171
+ export function sameBaseUrl(left, right) {
172
+ return (left || "").replace(/\/+$/, "") === (right || "").replace(/\/+$/, "");
173
+ }
174
174
 
175
175
  function firstNonEmpty(...values) {
176
176
  return values.find((value) => typeof value === "string" && value.trim().length > 0)?.trim();
@@ -19,8 +19,8 @@ export const ERROR_CATALOG = [
19
19
  recoverableByAgent: true,
20
20
  defaultRetryable: false,
21
21
  pathSemantics: "Browser authorization base URL",
22
- canonicalNextActions: ["finebi-cli config set base-url <https-url>"],
23
- expectedAgentBehaviors: ["configure an HTTPS URL or localhost HTTP URL", "rerun browser login"],
22
+ canonicalNextActions: ["finebi-cli config set base-url <http-or-https-url>"],
23
+ expectedAgentBehaviors: ["configure an HTTP or HTTPS URL", "rerun browser login"],
24
24
  benchmarkTags: ["auth", "browser", "url-validation"],
25
25
  },
26
26
  {