@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.
- package/README.md +8 -8
- package/package.json +1 -1
- package/skills/finebi-cli-agent/SKILL.md +43 -123
- package/skills/finebi-cli-agent/agents/openai.yaml +1 -1
- package/skills/finebi-cli-agent/references/artifact-ordering.md +3 -3
- package/skills/finebi-cli-agent/references/command-workflows.md +30 -1
- package/skills/finebi-cli-agent/references/dashboard-data-result.md +191 -191
- package/skills/finebi-cli-agent/references/dashboard-dsl-api.md +44 -44
- package/skills/finebi-cli-agent/references/dsl-handoff-contract.md +1 -1
- package/skills/finebi-cli-agent/references/handoff-contract.md +6 -4
- package/skills/finebi-cli-agent/references/mutation-confirmation-contract.md +2 -0
- package/skills/finebi-cli-agent/references/scenario-workflows.md +10 -8
- package/skills/finebi-cli-agent/references/web-app.md +4 -2
- package/skills/finebi-cli-agent/references/widget-dsl-core.md +6 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +157 -148
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/browser-auth.js +3 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/config.js +28 -28
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/error-catalog.js +2 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +63 -60
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/install-skill.js +89 -89
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/profiles.js +158 -158
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/skill-files.js +94 -94
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +18 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/README.md +12 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/acceptance.md +80 -67
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/auth-and-deploy.md +17 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/data-contract.md +66 -38
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/filters.md +2 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/cockpit.config.json +22 -22
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/retail.config.json +185 -185
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/index.html +2 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/public/app-config.json +11 -11
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/browser-session.mjs +59 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-proxy.mjs +25 -24
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-session.mjs +57 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/session-file.mjs +45 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +63 -56
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/cockpit.css +3 -3
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +40 -40
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Insights.tsx +17 -17
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/MonthPicker.tsx +32 -32
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/OptionFilter.tsx +7 -7
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/RangeFilter.tsx +9 -9
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +40 -39
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar-locales.ts +3 -3
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts +14 -14
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Bar.tsx +6 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Line.tsx +6 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Pie.tsx +6 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/month-picker.css +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/client.ts +20 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.d.ts +3 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +58 -44
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.d.ts +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.js +5 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.d.ts +4 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.js +106 -106
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +235 -77
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +20 -18
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/main.tsx +5 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +12 -12
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/presentation.ts +36 -36
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/table-data.ts +47 -47
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts +18 -18
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/widget-queries.ts +18 -18
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/fixtures.mjs +28 -28
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/index.html +16 -16
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/main.tsx +149 -149
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/test-page.css +4 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/vite.config.mjs +13 -13
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/bundle.test.mjs +19 -19
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/cockpit.test.mjs +73 -73
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/field-selection.test.mjs +70 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/proxy.test.mjs +20 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/retail.test.mjs +31 -31
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/runtime.test.mjs +7 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/session.test.mjs +91 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/table-normalize.test.mjs +189 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/vite-auth.test.mjs +41 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tsconfig.json +4 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +9 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli-runner.mjs +4 -4
- package/src/app.js +157 -148
- package/src/browser-auth.js +3 -4
- package/src/config.js +28 -28
- package/src/error-catalog.js +2 -2
- package/src/help.js +63 -60
- package/src/install-skill.js +89 -89
- package/src/profiles.js +158 -158
- package/src/skill-files.js +94 -94
- package/templates/web-app/AGENTS.md +18 -5
- package/templates/web-app/README.md +12 -5
- package/templates/web-app/docs/acceptance.md +80 -67
- package/templates/web-app/docs/auth-and-deploy.md +17 -2
- package/templates/web-app/docs/data-contract.md +66 -38
- package/templates/web-app/docs/filters.md +2 -2
- package/templates/web-app/examples/cockpit.config.json +22 -22
- package/templates/web-app/examples/retail.config.json +185 -185
- package/templates/web-app/index.html +2 -2
- package/templates/web-app/public/app-config.json +11 -11
- package/templates/web-app/scripts/browser-session.mjs +59 -0
- package/templates/web-app/scripts/dev-proxy.mjs +25 -24
- package/templates/web-app/scripts/dev-session.mjs +57 -0
- package/templates/web-app/scripts/session-file.mjs +45 -0
- package/templates/web-app/src/App.tsx +63 -56
- package/templates/web-app/src/cockpit.css +3 -3
- package/templates/web-app/src/components/Chart.tsx +40 -40
- package/templates/web-app/src/components/DataTable.tsx +1 -1
- package/templates/web-app/src/components/Insights.tsx +17 -17
- package/templates/web-app/src/components/MonthPicker.tsx +32 -32
- package/templates/web-app/src/components/OptionFilter.tsx +7 -7
- package/templates/web-app/src/components/RangeFilter.tsx +9 -9
- package/templates/web-app/src/components/Widget.tsx +40 -39
- package/templates/web-app/src/components/calendar-locales.ts +3 -3
- package/templates/web-app/src/components/calendar.ts +14 -14
- package/templates/web-app/src/components/charts/Bar.tsx +6 -6
- package/templates/web-app/src/components/charts/Line.tsx +6 -6
- package/templates/web-app/src/components/charts/Pie.tsx +6 -6
- package/templates/web-app/src/components/month-picker.css +1 -1
- package/templates/web-app/src/finebi/client.ts +20 -4
- package/templates/web-app/src/finebi/config.d.ts +3 -1
- package/templates/web-app/src/finebi/config.js +58 -44
- package/templates/web-app/src/finebi/date.d.ts +1 -1
- package/templates/web-app/src/finebi/date.js +5 -5
- package/templates/web-app/src/finebi/filters.d.ts +4 -4
- package/templates/web-app/src/finebi/filters.js +106 -106
- package/templates/web-app/src/finebi/normalize.ts +235 -77
- package/templates/web-app/src/finebi/types.ts +20 -18
- package/templates/web-app/src/main.tsx +5 -5
- package/templates/web-app/src/retail.css +12 -12
- package/templates/web-app/src/services/presentation.ts +36 -36
- package/templates/web-app/src/services/table-data.ts +47 -47
- package/templates/web-app/src/services/view-data.ts +18 -18
- package/templates/web-app/src/services/widget-queries.ts +18 -18
- package/templates/web-app/tests/browser/fixtures.mjs +28 -28
- package/templates/web-app/tests/browser/index.html +16 -16
- package/templates/web-app/tests/browser/main.tsx +149 -149
- package/templates/web-app/tests/browser/test-page.css +4 -4
- package/templates/web-app/tests/browser/vite.config.mjs +13 -13
- package/templates/web-app/tests/bundle.test.mjs +19 -19
- package/templates/web-app/tests/cockpit.test.mjs +73 -73
- package/templates/web-app/tests/field-selection.test.mjs +70 -0
- package/templates/web-app/tests/proxy.test.mjs +20 -5
- package/templates/web-app/tests/retail.test.mjs +31 -31
- package/templates/web-app/tests/runtime.test.mjs +7 -0
- package/templates/web-app/tests/session.test.mjs +91 -0
- package/templates/web-app/tests/table-normalize.test.mjs +189 -0
- package/templates/web-app/tests/vite-auth.test.mjs +41 -0
- package/templates/web-app/tsconfig.json +4 -4
- package/templates/web-app/vite.config.ts +9 -4
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 =
|
|
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();
|
package/src/error-catalog.js
CHANGED
|
@@ -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
|
|
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
|
{
|
package/src/help.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import {brandCommandValue, CLI_COMMAND_NAME, CLI_DISPLAY_NAME, commandLine} from "./branding.js";
|
|
2
2
|
import {cliError} from "./errors.js";
|
|
3
|
-
import {failure, success} from "./output.js";
|
|
4
|
-
|
|
5
|
-
const GLOBAL_OPTIONS = [
|
|
6
|
-
{name: "--profile", type: "string", description: "Environment name. Overrides FINEBI_PROFILE and the saved default; ordinary commands do not create environments or switch the default."},
|
|
7
|
-
{name: "--base-url", type: "string", description: "Connection URL override. A different server never inherits saved credentials or capabilities."},
|
|
8
|
-
{name: "--token", type: "string", description: "Temporary session token override; not persisted."},
|
|
9
|
-
];
|
|
3
|
+
import {failure, success} from "./output.js";
|
|
4
|
+
|
|
5
|
+
const GLOBAL_OPTIONS = [
|
|
6
|
+
{name: "--profile", type: "string", description: "Environment name. Overrides FINEBI_PROFILE and the saved default; ordinary commands do not create environments or switch the default."},
|
|
7
|
+
{name: "--base-url", type: "string", description: "Connection URL override. A different server never inherits saved credentials or capabilities."},
|
|
8
|
+
{name: "--token", type: "string", description: "Temporary session token override; not persisted."},
|
|
9
|
+
];
|
|
10
10
|
|
|
11
11
|
export function renderTopLevelHelp(manifest) {
|
|
12
12
|
return success("help", {
|
|
@@ -14,8 +14,8 @@ export function renderTopLevelHelp(manifest) {
|
|
|
14
14
|
name: resource.name,
|
|
15
15
|
summary: resource.summary,
|
|
16
16
|
})),
|
|
17
|
-
dynamicCapabilitiesLoaded: Boolean(manifest),
|
|
18
|
-
globalOptions: GLOBAL_OPTIONS,
|
|
17
|
+
dynamicCapabilitiesLoaded: Boolean(manifest),
|
|
18
|
+
globalOptions: GLOBAL_OPTIONS,
|
|
19
19
|
}, { schemaVersion: manifest?.schemaVersion });
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -24,7 +24,7 @@ export function renderBuiltInHelp(resourceName) {
|
|
|
24
24
|
if (!resource) {
|
|
25
25
|
return capabilityNotFound(resourceName);
|
|
26
26
|
}
|
|
27
|
-
return success("help", brandCommandValue({...resource, globalOptions: GLOBAL_OPTIONS}));
|
|
27
|
+
return success("help", brandCommandValue({...resource, globalOptions: GLOBAL_OPTIONS}));
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export function renderBuiltInCommandHelp(resourceName, commandName) {
|
|
@@ -38,8 +38,8 @@ export function renderBuiltInCommandHelp(resourceName, commandName) {
|
|
|
38
38
|
command: command.name,
|
|
39
39
|
summary: command.summary,
|
|
40
40
|
usage: command.usage,
|
|
41
|
-
options: command.options || [],
|
|
42
|
-
globalOptions: GLOBAL_OPTIONS,
|
|
41
|
+
options: command.options || [],
|
|
42
|
+
globalOptions: GLOBAL_OPTIONS,
|
|
43
43
|
details: command.details || [],
|
|
44
44
|
examples: command.examples || [],
|
|
45
45
|
}));
|
|
@@ -52,8 +52,8 @@ export function renderResourceHelp(manifest, resourceName) {
|
|
|
52
52
|
}
|
|
53
53
|
return success("help", {
|
|
54
54
|
resource: resource.name,
|
|
55
|
-
summary: resource.summary,
|
|
56
|
-
globalOptions: GLOBAL_OPTIONS,
|
|
55
|
+
summary: resource.summary,
|
|
56
|
+
globalOptions: GLOBAL_OPTIONS,
|
|
57
57
|
commands: (resource.commands || []).map((command) => ({
|
|
58
58
|
name: command.name,
|
|
59
59
|
summary: command.summary,
|
|
@@ -72,8 +72,8 @@ export function renderCommandHelp(manifest, resourceName, commandName) {
|
|
|
72
72
|
command: command.name,
|
|
73
73
|
summary: command.summary,
|
|
74
74
|
usage: usageFor(resource.name, command),
|
|
75
|
-
options: commandOptionsFor(command),
|
|
76
|
-
globalOptions: GLOBAL_OPTIONS,
|
|
75
|
+
options: commandOptionsFor(command),
|
|
76
|
+
globalOptions: GLOBAL_OPTIONS,
|
|
77
77
|
display: command.display,
|
|
78
78
|
requestSchema: command.requestSchema,
|
|
79
79
|
responseSchema: command.responseSchema,
|
|
@@ -116,10 +116,13 @@ const BUILT_IN_RESOURCES = {
|
|
|
116
116
|
},
|
|
117
117
|
{
|
|
118
118
|
name: "inspect",
|
|
119
|
-
summary: "
|
|
120
|
-
usage: "finebi-cli app inspect --path <project>",
|
|
121
|
-
options: [
|
|
122
|
-
|
|
119
|
+
summary: "Summarize a local web app configuration; optionally validate against the bundled template.",
|
|
120
|
+
usage: "finebi-cli app inspect --path <project> [--strict]",
|
|
121
|
+
options: [
|
|
122
|
+
{name: "--path", type: "string", description: "Existing project directory; relative paths resolve from the current directory."},
|
|
123
|
+
{name: "--strict", type: "boolean", description: "Validate against this CLI's bundled template rules; custom app extensions may not match."},
|
|
124
|
+
],
|
|
125
|
+
details: ["By default, reads app-config.json as a JSON object and reports a summary with configValidated: false. --strict enables local template validation. Neither mode validates remote resources or permissions; inspection is not an application acceptance gate."],
|
|
123
126
|
},
|
|
124
127
|
],
|
|
125
128
|
},
|
|
@@ -171,12 +174,12 @@ const BUILT_IN_RESOURCES = {
|
|
|
171
174
|
examples: [
|
|
172
175
|
"finebi-cli config set base-url http://localhost:37799/webroot/decision",
|
|
173
176
|
"finebi-cli config set timeout-ms 30000",
|
|
174
|
-
"finebi-cli config set capability-ttl-ms
|
|
177
|
+
"finebi-cli config set capability-ttl-ms 72000000",
|
|
175
178
|
],
|
|
176
179
|
},
|
|
177
180
|
{
|
|
178
181
|
name: "clear",
|
|
179
|
-
summary: "Remove the selected environment's local configuration, credentials and capability cache; does not revoke server keys.",
|
|
182
|
+
summary: "Remove the selected environment's local configuration, credentials and capability cache; does not revoke server keys.",
|
|
180
183
|
usage: "finebi-cli config clear",
|
|
181
184
|
},
|
|
182
185
|
],
|
|
@@ -188,18 +191,18 @@ const BUILT_IN_RESOURCES = {
|
|
|
188
191
|
{
|
|
189
192
|
name: "login",
|
|
190
193
|
summary: "Login with FineBI credentials or browser authorization and persist an Access Key.",
|
|
191
|
-
usage: "finebi-cli auth login [--profile <name>] [--base-url <url>] [--browser] [--username <username>] [--from-env] [--password-stdin] [--no-use]",
|
|
192
|
-
options: [
|
|
193
|
-
{ name: "--no-use", type: "boolean", description: "Save successful login without changing the default environment." },
|
|
194
|
+
usage: "finebi-cli auth login [--profile <name>] [--base-url <url>] [--browser] [--username <username>] [--from-env] [--password-stdin] [--no-use]",
|
|
195
|
+
options: [
|
|
196
|
+
{ name: "--no-use", type: "boolean", description: "Save successful login without changing the default environment." },
|
|
194
197
|
{ name: "--username", type: "string", description: "FineBI username." },
|
|
195
198
|
{ name: "--from-env", type: "boolean", description: "Read base URL, username, and password from environment variables." },
|
|
196
199
|
{ name: "--password-stdin", type: "boolean", description: "Read password from standard input." },
|
|
197
200
|
{ name: "--browser", type: "boolean", description: "Authorize this CLI from a signed-in FineBI browser without sending a password." },
|
|
198
201
|
],
|
|
199
|
-
details: [
|
|
200
|
-
"Login creates the named environment if missing and selects it after successful authentication, unless --no-use is supplied.",
|
|
201
|
-
"A new environment requires --base-url or FINEBI_BASE_URL; it never inherits another environment's address or identity.",
|
|
202
|
-
"Failed authentication preserves saved environments and the default selection.",
|
|
202
|
+
details: [
|
|
203
|
+
"Login creates the named environment if missing and selects it after successful authentication, unless --no-use is supplied.",
|
|
204
|
+
"A new environment requires --base-url or FINEBI_BASE_URL; it never inherits another environment's address or identity.",
|
|
205
|
+
"Failed authentication preserves saved environments and the default selection.",
|
|
203
206
|
"Environment variables for --from-env:",
|
|
204
207
|
" FINEBI_BASE_URL",
|
|
205
208
|
" FINEBI_USERNAME",
|
|
@@ -211,28 +214,28 @@ const BUILT_IN_RESOURCES = {
|
|
|
211
214
|
"finebi-cli auth login --username <username>",
|
|
212
215
|
"finebi-cli auth login --from-env",
|
|
213
216
|
"echo <password> | finebi-cli auth login --username <username> --password-stdin",
|
|
214
|
-
"finebi-cli auth login --browser",
|
|
215
|
-
"finebi-cli auth login --profile test --base-url https://test.example.com/webroot/decision --browser",
|
|
217
|
+
"finebi-cli auth login --browser",
|
|
218
|
+
"finebi-cli auth login --profile test --base-url https://test.example.com/webroot/decision --browser",
|
|
216
219
|
],
|
|
217
220
|
},
|
|
218
|
-
{
|
|
219
|
-
name: "list",
|
|
220
|
-
summary: "List saved environments and mark the default; local metadata only, no credential validation or secret output.",
|
|
221
|
-
usage: "finebi-cli auth list",
|
|
222
|
-
},
|
|
223
|
-
{
|
|
224
|
-
name: "use",
|
|
225
|
-
summary: "Select an existing environment as default without logging in.",
|
|
226
|
-
usage: "finebi-cli auth use <name>",
|
|
227
|
-
},
|
|
228
|
-
{
|
|
229
|
-
name: "remove",
|
|
230
|
-
summary: "Delete one local environment and its capability cache; does not revoke server keys.",
|
|
231
|
-
usage: "finebi-cli auth remove <name>",
|
|
232
|
-
details: ["Use auth logout --profile <name> first to revoke its Access Key.", "Removing the default environment does not select another environment."],
|
|
233
|
-
},
|
|
234
|
-
{
|
|
235
|
-
name: "info",
|
|
221
|
+
{
|
|
222
|
+
name: "list",
|
|
223
|
+
summary: "List saved environments and mark the default; local metadata only, no credential validation or secret output.",
|
|
224
|
+
usage: "finebi-cli auth list",
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: "use",
|
|
228
|
+
summary: "Select an existing environment as default without logging in.",
|
|
229
|
+
usage: "finebi-cli auth use <name>",
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: "remove",
|
|
233
|
+
summary: "Delete one local environment and its capability cache; does not revoke server keys.",
|
|
234
|
+
usage: "finebi-cli auth remove <name>",
|
|
235
|
+
details: ["Use auth logout --profile <name> first to revoke its Access Key.", "Removing the default environment does not select another environment."],
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "info",
|
|
236
239
|
summary: "Show stored authentication metadata with secrets masked.",
|
|
237
240
|
usage: "finebi-cli auth info",
|
|
238
241
|
},
|
|
@@ -400,10 +403,10 @@ function renderTopLevelHelpText(data) {
|
|
|
400
403
|
"",
|
|
401
404
|
"Use --help or -h for command details.",
|
|
402
405
|
"Use --json with --help for structured output.",
|
|
403
|
-
"Output options: --select <paths> / --max-chars <n> / --save <path> for large responses.",
|
|
404
|
-
"Environment: --profile <name> > FINEBI_PROFILE > saved default. Global options may precede or follow the command.",
|
|
405
|
-
);
|
|
406
|
-
lines.push("", "Global Options:", ...formatOptionRows(data.globalOptions || []));
|
|
406
|
+
"Output options: --select <paths> / --max-chars <n> / --save <path> for large responses.",
|
|
407
|
+
"Environment: --profile <name> > FINEBI_PROFILE > saved default. Global options may precede or follow the command.",
|
|
408
|
+
);
|
|
409
|
+
lines.push("", "Global Options:", ...formatOptionRows(data.globalOptions || []));
|
|
407
410
|
return `${lines.join("\n")}\n`;
|
|
408
411
|
}
|
|
409
412
|
|
|
@@ -419,8 +422,8 @@ function renderResourceHelpText(data) {
|
|
|
419
422
|
"",
|
|
420
423
|
"Commands:",
|
|
421
424
|
];
|
|
422
|
-
lines.push(...formatRows(data.commands || [], "name", "summary"));
|
|
423
|
-
lines.push("", "Global Options:", ...formatOptionRows(data.globalOptions || []));
|
|
425
|
+
lines.push(...formatRows(data.commands || [], "name", "summary"));
|
|
426
|
+
lines.push("", "Global Options:", ...formatOptionRows(data.globalOptions || []));
|
|
424
427
|
lines.push("", `Use ${commandLine(data.resource, "<command>", "--help")} for command details.`);
|
|
425
428
|
return `${lines.join("\n")}\n`;
|
|
426
429
|
}
|
|
@@ -435,13 +438,13 @@ function renderCommandHelpText(data) {
|
|
|
435
438
|
"Usage:",
|
|
436
439
|
` ${data.usage}`,
|
|
437
440
|
];
|
|
438
|
-
if ((data.options || []).length > 0) {
|
|
441
|
+
if ((data.options || []).length > 0) {
|
|
439
442
|
lines.push("", "Options:");
|
|
440
443
|
lines.push(...formatOptionRows(data.options));
|
|
441
|
-
}
|
|
442
|
-
if ((data.globalOptions || []).length > 0) {
|
|
443
|
-
lines.push("", "Global Options:", ...formatOptionRows(data.globalOptions));
|
|
444
|
-
}
|
|
444
|
+
}
|
|
445
|
+
if ((data.globalOptions || []).length > 0) {
|
|
446
|
+
lines.push("", "Global Options:", ...formatOptionRows(data.globalOptions));
|
|
447
|
+
}
|
|
445
448
|
if ((data.details || []).length > 0) {
|
|
446
449
|
lines.push("", ...data.details);
|
|
447
450
|
}
|
package/src/install-skill.js
CHANGED
|
@@ -1,89 +1,89 @@
|
|
|
1
|
-
import {mkdir, readFile, writeFile, rename, open, unlink} from "node:fs/promises";
|
|
2
|
-
import {existsSync} from "node:fs";
|
|
3
|
-
import {homedir} from "node:os";
|
|
4
|
-
import {join, resolve} from "node:path";
|
|
5
|
-
import {fileURLToPath} from "node:url";
|
|
6
|
-
import {createInterface} from "node:readline/promises";
|
|
7
|
-
import {agentCatalog, installSkills} from "./skill-files.js";
|
|
8
|
-
import {cliError} from "./errors.js";
|
|
9
|
-
import {failure, success} from "./output.js";
|
|
10
|
-
|
|
11
|
-
function parseOptions(args) {
|
|
12
|
-
const options = {};
|
|
13
|
-
for (let i = 0; i < args.length; i++) {
|
|
14
|
-
const arg = args[i];
|
|
15
|
-
if (arg === "--json") continue;
|
|
16
|
-
if (arg === "--list") options.list = true;
|
|
17
|
-
else if (arg === "--replace") options.replace = true;
|
|
18
|
-
else if (arg === "--agents") {
|
|
19
|
-
if (!args[i + 1] || args[i + 1].startsWith("--")) throw new Error("--agents requires comma-separated Agent IDs.");
|
|
20
|
-
options.agents = [...new Set(args[++i].split(",").map(id => id.trim()).filter(Boolean))];
|
|
21
|
-
if (!options.agents.length) throw new Error("--agents requires at least one Agent ID.");
|
|
22
|
-
} else throw new Error(`Unknown install-skill option: ${arg}`);
|
|
23
|
-
}
|
|
24
|
-
if (options.list && (options.agents || options.replace)) throw new Error("--list cannot be combined with installation options.");
|
|
25
|
-
return options;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export async function handleInstallSkill(args, context = {}) {
|
|
29
|
-
const env = context.env || process.env;
|
|
30
|
-
const home = resolve(env.USERPROFILE || env.HOME || homedir());
|
|
31
|
-
const catalog = agentCatalog({home, env});
|
|
32
|
-
let options;
|
|
33
|
-
try {
|
|
34
|
-
options = parseOptions(args);
|
|
35
|
-
if (options.list) return success("install-skill", {agents: catalog});
|
|
36
|
-
if (!options.agents) {
|
|
37
|
-
const interactive = context.isTTY ?? (process.stdin.isTTY && process.stderr.isTTY);
|
|
38
|
-
if (!interactive) throw new Error("Specify --agents <ids>; use --list to see supported Agents.");
|
|
39
|
-
const prompt = createInterface({input: process.stdin, output: process.stderr});
|
|
40
|
-
try {
|
|
41
|
-
process.stderr.write(catalog.map(a => `${a.id}: ${a.name}${a.detected ? " (detected)" : ""}`).join("\n") + "\n");
|
|
42
|
-
const answer = await prompt.question("Select Agent IDs (comma-separated): ");
|
|
43
|
-
options.agents = parseOptions(["--agents", answer]).agents;
|
|
44
|
-
} finally { prompt.close(); }
|
|
45
|
-
}
|
|
46
|
-
for (const id of options.agents) if (!catalog.some(agent => agent.id === id)) throw new Error(`Unknown Agent: ${id}`);
|
|
47
|
-
} catch (error) {
|
|
48
|
-
return failure("install-skill", cliError("INVALID_ARGUMENT", {
|
|
49
|
-
message: error.message, nextActions: ["finebi-cli install-skill --help"],
|
|
50
|
-
}));
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const directory = join(home, ".finebi-cli");
|
|
54
|
-
const statePath = join(directory, "installation.json");
|
|
55
|
-
const lockPath = join(directory, "skill-install.lock");
|
|
56
|
-
let lock;
|
|
57
|
-
try {
|
|
58
|
-
const source = context.skillSource || [
|
|
59
|
-
"../skills/finebi-cli-agent/", "../../skills/finebi-cli-agent/", "../../../",
|
|
60
|
-
].map(path => fileURLToPath(new URL(path, import.meta.url)))
|
|
61
|
-
.find(path => existsSync(join(path, "SKILL.md")) && existsSync(join(path, "scripts/finebi-cli/src/index.js")));
|
|
62
|
-
if (!source) throw new Error("FineBI Skill resources were not found. Reinstall the CLI package or build the Skill in the source checkout.");
|
|
63
|
-
await readFile(join(source, "SKILL.md"));
|
|
64
|
-
await readFile(join(source, "scripts/finebi-cli/src/index.js"));
|
|
65
|
-
await mkdir(directory, {recursive: true});
|
|
66
|
-
try { lock = await open(lockPath, "wx"); }
|
|
67
|
-
catch (error) { if (error.code === "EEXIST") throw new Error(`Skill installation lock exists: ${lockPath}`); throw error; }
|
|
68
|
-
let state = {};
|
|
69
|
-
try { state = JSON.parse(await readFile(statePath, "utf8")); }
|
|
70
|
-
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
71
|
-
const results = await installSkills({source, home, env, agents: options.agents, replace: options.replace, previous: state.skills || []});
|
|
72
|
-
const skills = new Map((state.skills || []).map(skill => [skill.agent, skill]));
|
|
73
|
-
for (const skill of results) skills.set(skill.agent, {...skills.get(skill.agent), ...skill});
|
|
74
|
-
const stagedState = join(directory, "installation.json.stage");
|
|
75
|
-
await writeFile(stagedState, `${JSON.stringify({...state, skills: [...skills.values()], updatedAt: new Date().toISOString()}, null, 2)}\n`);
|
|
76
|
-
await rename(stagedState, statePath);
|
|
77
|
-
const data = {skills: results.map(({hashes, ...skill}) => skill)};
|
|
78
|
-
if (results.some(skill => !["installed", "unchanged"].includes(skill.status))) {
|
|
79
|
-
return failure("install-skill", cliError("SKILL_INSTALL_FAILED", {
|
|
80
|
-
message: "Some Skills were kept or failed. Review the results; --replace backs up and replaces local changes.", partialResult: data,
|
|
81
|
-
}));
|
|
82
|
-
}
|
|
83
|
-
return success("install-skill", data);
|
|
84
|
-
} catch (error) {
|
|
85
|
-
return failure("install-skill", cliError("SKILL_INSTALL_FAILED", {message: error.message}));
|
|
86
|
-
} finally {
|
|
87
|
-
if (lock) { await lock.close(); await unlink(lockPath); }
|
|
88
|
-
}
|
|
89
|
-
}
|
|
1
|
+
import {mkdir, readFile, writeFile, rename, open, unlink} from "node:fs/promises";
|
|
2
|
+
import {existsSync} from "node:fs";
|
|
3
|
+
import {homedir} from "node:os";
|
|
4
|
+
import {join, resolve} from "node:path";
|
|
5
|
+
import {fileURLToPath} from "node:url";
|
|
6
|
+
import {createInterface} from "node:readline/promises";
|
|
7
|
+
import {agentCatalog, installSkills} from "./skill-files.js";
|
|
8
|
+
import {cliError} from "./errors.js";
|
|
9
|
+
import {failure, success} from "./output.js";
|
|
10
|
+
|
|
11
|
+
function parseOptions(args) {
|
|
12
|
+
const options = {};
|
|
13
|
+
for (let i = 0; i < args.length; i++) {
|
|
14
|
+
const arg = args[i];
|
|
15
|
+
if (arg === "--json") continue;
|
|
16
|
+
if (arg === "--list") options.list = true;
|
|
17
|
+
else if (arg === "--replace") options.replace = true;
|
|
18
|
+
else if (arg === "--agents") {
|
|
19
|
+
if (!args[i + 1] || args[i + 1].startsWith("--")) throw new Error("--agents requires comma-separated Agent IDs.");
|
|
20
|
+
options.agents = [...new Set(args[++i].split(",").map(id => id.trim()).filter(Boolean))];
|
|
21
|
+
if (!options.agents.length) throw new Error("--agents requires at least one Agent ID.");
|
|
22
|
+
} else throw new Error(`Unknown install-skill option: ${arg}`);
|
|
23
|
+
}
|
|
24
|
+
if (options.list && (options.agents || options.replace)) throw new Error("--list cannot be combined with installation options.");
|
|
25
|
+
return options;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function handleInstallSkill(args, context = {}) {
|
|
29
|
+
const env = context.env || process.env;
|
|
30
|
+
const home = resolve(env.USERPROFILE || env.HOME || homedir());
|
|
31
|
+
const catalog = agentCatalog({home, env});
|
|
32
|
+
let options;
|
|
33
|
+
try {
|
|
34
|
+
options = parseOptions(args);
|
|
35
|
+
if (options.list) return success("install-skill", {agents: catalog});
|
|
36
|
+
if (!options.agents) {
|
|
37
|
+
const interactive = context.isTTY ?? (process.stdin.isTTY && process.stderr.isTTY);
|
|
38
|
+
if (!interactive) throw new Error("Specify --agents <ids>; use --list to see supported Agents.");
|
|
39
|
+
const prompt = createInterface({input: process.stdin, output: process.stderr});
|
|
40
|
+
try {
|
|
41
|
+
process.stderr.write(catalog.map(a => `${a.id}: ${a.name}${a.detected ? " (detected)" : ""}`).join("\n") + "\n");
|
|
42
|
+
const answer = await prompt.question("Select Agent IDs (comma-separated): ");
|
|
43
|
+
options.agents = parseOptions(["--agents", answer]).agents;
|
|
44
|
+
} finally { prompt.close(); }
|
|
45
|
+
}
|
|
46
|
+
for (const id of options.agents) if (!catalog.some(agent => agent.id === id)) throw new Error(`Unknown Agent: ${id}`);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return failure("install-skill", cliError("INVALID_ARGUMENT", {
|
|
49
|
+
message: error.message, nextActions: ["finebi-cli install-skill --help"],
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const directory = join(home, ".finebi-cli");
|
|
54
|
+
const statePath = join(directory, "installation.json");
|
|
55
|
+
const lockPath = join(directory, "skill-install.lock");
|
|
56
|
+
let lock;
|
|
57
|
+
try {
|
|
58
|
+
const source = context.skillSource || [
|
|
59
|
+
"../skills/finebi-cli-agent/", "../../skills/finebi-cli-agent/", "../../../",
|
|
60
|
+
].map(path => fileURLToPath(new URL(path, import.meta.url)))
|
|
61
|
+
.find(path => existsSync(join(path, "SKILL.md")) && existsSync(join(path, "scripts/finebi-cli/src/index.js")));
|
|
62
|
+
if (!source) throw new Error("FineBI Skill resources were not found. Reinstall the CLI package or build the Skill in the source checkout.");
|
|
63
|
+
await readFile(join(source, "SKILL.md"));
|
|
64
|
+
await readFile(join(source, "scripts/finebi-cli/src/index.js"));
|
|
65
|
+
await mkdir(directory, {recursive: true});
|
|
66
|
+
try { lock = await open(lockPath, "wx"); }
|
|
67
|
+
catch (error) { if (error.code === "EEXIST") throw new Error(`Skill installation lock exists: ${lockPath}`); throw error; }
|
|
68
|
+
let state = {};
|
|
69
|
+
try { state = JSON.parse(await readFile(statePath, "utf8")); }
|
|
70
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
71
|
+
const results = await installSkills({source, home, env, agents: options.agents, replace: options.replace, previous: state.skills || []});
|
|
72
|
+
const skills = new Map((state.skills || []).map(skill => [skill.agent, skill]));
|
|
73
|
+
for (const skill of results) skills.set(skill.agent, {...skills.get(skill.agent), ...skill});
|
|
74
|
+
const stagedState = join(directory, "installation.json.stage");
|
|
75
|
+
await writeFile(stagedState, `${JSON.stringify({...state, skills: [...skills.values()], updatedAt: new Date().toISOString()}, null, 2)}\n`);
|
|
76
|
+
await rename(stagedState, statePath);
|
|
77
|
+
const data = {skills: results.map(({hashes, ...skill}) => skill)};
|
|
78
|
+
if (results.some(skill => !["installed", "unchanged"].includes(skill.status))) {
|
|
79
|
+
return failure("install-skill", cliError("SKILL_INSTALL_FAILED", {
|
|
80
|
+
message: "Some Skills were kept or failed. Review the results; --replace backs up and replaces local changes.", partialResult: data,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
return success("install-skill", data);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return failure("install-skill", cliError("SKILL_INSTALL_FAILED", {message: error.message}));
|
|
86
|
+
} finally {
|
|
87
|
+
if (lock) { await lock.close(); await unlink(lockPath); }
|
|
88
|
+
}
|
|
89
|
+
}
|