@finebi/finebi-cli 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -142
- package/package.json +38 -35
- package/skills/finebi-cli-agent/SKILL.md +133 -0
- package/skills/finebi-cli-agent/agents/openai.yaml +7 -0
- package/skills/finebi-cli-agent/references/analysis-dsl-api.md +318 -0
- package/skills/finebi-cli-agent/references/artifact-ordering.md +29 -0
- package/skills/finebi-cli-agent/references/command-workflows.md +194 -0
- package/skills/finebi-cli-agent/references/dashboard-dsl-api.md +1025 -0
- package/skills/finebi-cli-agent/references/dsl-handoff-contract.md +195 -0
- package/skills/finebi-cli-agent/references/file-to-dashboard.md +65 -0
- package/skills/finebi-cli-agent/references/formula-basics.md +102 -0
- package/skills/finebi-cli-agent/references/formula-contexts.md +66 -0
- package/skills/finebi-cli-agent/references/formula-functions.md +247 -0
- package/skills/finebi-cli-agent/references/formula-generation.md +121 -0
- package/skills/finebi-cli-agent/references/formula-glossary.md +17 -0
- package/skills/finebi-cli-agent/references/formula-help-boundaries.md +87 -0
- package/skills/finebi-cli-agent/references/formula-help-index.md +264 -0
- package/skills/finebi-cli-agent/references/formula-parameters-aggregate.md +143 -0
- package/skills/finebi-cli-agent/references/formula-parameters-analysis.md +223 -0
- package/skills/finebi-cli-agent/references/formula-parameters-date.md +236 -0
- package/skills/finebi-cli-agent/references/formula-parameters-math-logic.md +271 -0
- package/skills/finebi-cli-agent/references/formula-parameters-text.md +217 -0
- package/skills/finebi-cli-agent/references/formula-recipes-analysis.md +124 -0
- package/skills/finebi-cli-agent/references/formula-recipes-text-date.md +86 -0
- package/skills/finebi-cli-agent/references/formula-semantics.md +39 -0
- package/skills/finebi-cli-agent/references/formula-syntax.md +70 -0
- package/skills/finebi-cli-agent/references/formula-troubleshooting.md +63 -0
- package/skills/finebi-cli-agent/references/formula-validation.md +36 -0
- package/skills/finebi-cli-agent/references/formula.md +37 -0
- package/skills/finebi-cli-agent/references/handoff-contract.md +210 -0
- package/skills/finebi-cli-agent/references/mapping-rules.md +423 -0
- package/skills/finebi-cli-agent/references/mutation-confirmation-contract.md +150 -0
- package/skills/finebi-cli-agent/references/scenario-workflows.md +75 -0
- package/skills/finebi-cli-agent/references/widget-dsl-api-examples.md +457 -0
- package/skills/finebi-cli-agent/references/widget-dsl-api.md +1340 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +11 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/access-key-contract.js +2 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/access-key.js +174 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/auth-failure.js +18 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/auth-headers.js +21 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/auth.js +116 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/branding.js +31 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/browser-auth.js +331 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/capability.js +122 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/config-permissions.js +87 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/config.js +189 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/dynamic-command.js +324 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/error-catalog.js +482 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/errors.js +22 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +467 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/index.js +1343 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/install-skill.js +87 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/output-limits.js +188 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/output.js +33 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/profiles.js +158 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/schema-options.js +265 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/schema-validator.js +194 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/skill-files.js +94 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli-runner-core.mjs +399 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli-runner.mjs +29 -0
- package/src/error-catalog.js +12 -0
- package/src/help.js +15 -0
- package/src/index.js +6 -1
- package/src/install-skill.js +87 -0
- package/src/skill-files.js +94 -0
|
@@ -0,0 +1,87 @@
|
|
|
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 npmSource = fileURLToPath(new URL("../skills/finebi-cli-agent/", import.meta.url));
|
|
59
|
+
const source = context.skillSource || (existsSync(join(npmSource, "SKILL.md"))
|
|
60
|
+
? npmSource : fileURLToPath(new URL("../../../", import.meta.url)));
|
|
61
|
+
await readFile(join(source, "SKILL.md"));
|
|
62
|
+
await readFile(join(source, "scripts/finebi-cli/src/index.js"));
|
|
63
|
+
await mkdir(directory, {recursive: true});
|
|
64
|
+
try { lock = await open(lockPath, "wx"); }
|
|
65
|
+
catch (error) { if (error.code === "EEXIST") throw new Error(`Skill installation lock exists: ${lockPath}`); throw error; }
|
|
66
|
+
let state = {};
|
|
67
|
+
try { state = JSON.parse(await readFile(statePath, "utf8")); }
|
|
68
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
69
|
+
const results = await installSkills({source, home, env, agents: options.agents, replace: options.replace, previous: state.skills || []});
|
|
70
|
+
const skills = new Map((state.skills || []).map(skill => [skill.agent, skill]));
|
|
71
|
+
for (const skill of results) skills.set(skill.agent, {...skills.get(skill.agent), ...skill});
|
|
72
|
+
const stagedState = join(directory, "installation.json.stage");
|
|
73
|
+
await writeFile(stagedState, `${JSON.stringify({...state, skills: [...skills.values()], updatedAt: new Date().toISOString()}, null, 2)}\n`);
|
|
74
|
+
await rename(stagedState, statePath);
|
|
75
|
+
const data = {skills: results.map(({hashes, ...skill}) => skill)};
|
|
76
|
+
if (results.some(skill => !["installed", "unchanged"].includes(skill.status))) {
|
|
77
|
+
return failure("install-skill", cliError("SKILL_INSTALL_FAILED", {
|
|
78
|
+
message: "Some Skills were kept or failed. Review the results; --replace backs up and replaces local changes.", partialResult: data,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
return success("install-skill", data);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return failure("install-skill", cliError("SKILL_INSTALL_FAILED", {message: error.message}));
|
|
84
|
+
} finally {
|
|
85
|
+
if (lock) { await lock.close(); await unlink(lockPath); }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {mkdirSync, writeFileSync} from "node:fs";
|
|
2
|
+
import {dirname} from "node:path";
|
|
3
|
+
|
|
4
|
+
import {cliError} from "./errors.js";
|
|
5
|
+
|
|
6
|
+
// 全局输出参数:把大回包挡在上下文之外(投影 / 截断 / 落盘)。
|
|
7
|
+
// 三个参数不参与命令契约,也不转发给服务端——在入口解析后即从 argv 剥离。
|
|
8
|
+
export const SELECT_FLAG = "--select";
|
|
9
|
+
export const MAX_CHARS_FLAG = "--max-chars";
|
|
10
|
+
export const SAVE_FLAG = "--save";
|
|
11
|
+
|
|
12
|
+
const FORBIDDEN_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
|
|
13
|
+
const MIN_MAX_CHARS = 200;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 解析并剥离输出参数。
|
|
17
|
+
*
|
|
18
|
+
* @returns {{ok: true, args: string[], options: {select: string[]|null, maxChars: number|null, save: string|null}}}
|
|
19
|
+
* 或 {{ok: false, error: object}}(参数缺失/非法,调用方按失败信封渲染)
|
|
20
|
+
*/
|
|
21
|
+
export function parseOutputArgs(args) {
|
|
22
|
+
const rest = [];
|
|
23
|
+
const options = {select: null, maxChars: null, save: null};
|
|
24
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
25
|
+
const current = args[index];
|
|
26
|
+
if (current !== SELECT_FLAG && current !== MAX_CHARS_FLAG && current !== SAVE_FLAG) {
|
|
27
|
+
rest.push(current);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const value = args[index + 1];
|
|
31
|
+
if (value === undefined || value.startsWith("-")) {
|
|
32
|
+
return {ok: false, error: argumentError(current, `Missing value for ${current}.`)};
|
|
33
|
+
}
|
|
34
|
+
index += 1;
|
|
35
|
+
if (current === SELECT_FLAG) {
|
|
36
|
+
const paths = value.split(",").map((path) => path.trim()).filter((path) => path.length > 0);
|
|
37
|
+
if (paths.length === 0 || paths.some((path) => invalidPath(path))) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
error: argumentError(current,
|
|
41
|
+
`${current} needs comma-separated dotted paths (e.g. data.dsl,warnings).`),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
options.select = paths;
|
|
45
|
+
} else if (current === MAX_CHARS_FLAG) {
|
|
46
|
+
const parsed = Number(value);
|
|
47
|
+
if (!Number.isInteger(parsed) || parsed < MIN_MAX_CHARS) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: argumentError(current,
|
|
51
|
+
`${current} must be an integer >= ${MIN_MAX_CHARS}, got: ${value}.`),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
options.maxChars = parsed;
|
|
55
|
+
} else {
|
|
56
|
+
options.save = value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return {ok: true, args: rest, options};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 把响应投影到点号路径(支持数组下标,如 errors.0.message)。
|
|
64
|
+
* 投影结果恒带 ok / command 两个自描述字段,失败时给出可用键与修正指引,不做静默忽略。
|
|
65
|
+
*
|
|
66
|
+
* @returns {{ok: true, value: object}|{ok: false, error: object}}
|
|
67
|
+
*/
|
|
68
|
+
export function selectPaths(envelope, paths) {
|
|
69
|
+
const value = {ok: envelope.ok, command: envelope.command};
|
|
70
|
+
const missing = [];
|
|
71
|
+
for (const path of paths) {
|
|
72
|
+
const resolved = resolvePath(envelope, path);
|
|
73
|
+
if (!resolved.found) {
|
|
74
|
+
missing.push(path);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
assignPath(value, path, envelope, resolved.value);
|
|
78
|
+
}
|
|
79
|
+
if (missing.length > 0) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: argumentError(SELECT_FLAG,
|
|
83
|
+
`Unknown path(s): ${missing.join(", ")}.`,
|
|
84
|
+
`Available top-level keys: ${Object.keys(envelope).join(", ")}.`,
|
|
85
|
+
["finebi-cli <resource> <command> --json --save <path>"]),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {ok: true, value};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 超限保头尾截断:首行给出结构化标记与续读指引,中间省略段单独标记。标记开销计入预算——
|
|
92
|
+
* 截断后总长(标记 + 头 + 尾)恒不超过 maxChars,消费方可按字符数做上下文预算。 */
|
|
93
|
+
export function truncateMiddle(text, maxChars) {
|
|
94
|
+
if (text.length <= maxChars) {
|
|
95
|
+
return text;
|
|
96
|
+
}
|
|
97
|
+
const header = `[finebi-cli] output truncated: ${text.length} chars total. `
|
|
98
|
+
+ `Use ${SAVE_FLAG} <path> for the full output, or raise ${MAX_CHARS_FLAG}.\n`;
|
|
99
|
+
const marker = (omitted) => `\n[... ${omitted} chars omitted ...]\n`;
|
|
100
|
+
// 按总数的位数预留省略标记(省略数 ≤ 总数,位数只会更少),保证总长不越界
|
|
101
|
+
const budget = Math.max(0, maxChars - header.length - marker(text.length).length);
|
|
102
|
+
const head = Math.floor(budget * 0.6);
|
|
103
|
+
const tail = budget - head;
|
|
104
|
+
return header + text.slice(0, head) + marker(text.length - head - tail) + text.slice(text.length - tail);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 全量文本落盘(自动建父目录),返回字符数(与摘要 `chars` 字段同口径,非字节数)。 */
|
|
108
|
+
export function saveOutput(text, path) {
|
|
109
|
+
mkdirSync(dirname(path), {recursive: true});
|
|
110
|
+
writeFileSync(path, text, "utf8");
|
|
111
|
+
return text.length;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 落盘后的 stdout 摘要:只回路径与计量,正文留在文件里按需读。 */
|
|
115
|
+
export function savedSummary(envelope, path, chars) {
|
|
116
|
+
return `${JSON.stringify({
|
|
117
|
+
ok: envelope.ok,
|
|
118
|
+
command: envelope.command,
|
|
119
|
+
saved: path,
|
|
120
|
+
chars,
|
|
121
|
+
warnings: Array.isArray(envelope.warnings) ? envelope.warnings.length : 0,
|
|
122
|
+
}, null, 2)}\n`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolvePath(root, path) {
|
|
126
|
+
let current = root;
|
|
127
|
+
for (const segment of path.split(".")) {
|
|
128
|
+
if (current === null || current === undefined) {
|
|
129
|
+
return {found: false};
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(current)) {
|
|
132
|
+
if (!/^\d+$/.test(segment) || Number(segment) >= current.length) {
|
|
133
|
+
return {found: false};
|
|
134
|
+
}
|
|
135
|
+
current = current[Number(segment)];
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (typeof current !== "object" || !Object.prototype.hasOwnProperty.call(current, segment)) {
|
|
139
|
+
return {found: false};
|
|
140
|
+
}
|
|
141
|
+
current = current[segment];
|
|
142
|
+
}
|
|
143
|
+
return {found: true, value: current};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 逐段沿源结构赋值:容器形态跟随源(源是数组则投影结果也是数组,下标位置保留)。 */
|
|
147
|
+
function assignPath(target, path, source, value) {
|
|
148
|
+
const segments = path.split(".");
|
|
149
|
+
let currentTarget = target;
|
|
150
|
+
let currentSource = source;
|
|
151
|
+
for (let index = 0; index < segments.length - 1; index += 1) {
|
|
152
|
+
const segment = segments[index];
|
|
153
|
+
const nextSource = Array.isArray(currentSource)
|
|
154
|
+
? currentSource[Number(segment)]
|
|
155
|
+
: currentSource[segment];
|
|
156
|
+
if (typeof currentTarget[segment] !== "object" || currentTarget[segment] === null) {
|
|
157
|
+
currentTarget[segment] = Array.isArray(nextSource) ? [] : {};
|
|
158
|
+
}
|
|
159
|
+
currentTarget = currentTarget[segment];
|
|
160
|
+
currentSource = nextSource;
|
|
161
|
+
}
|
|
162
|
+
currentTarget[segments[segments.length - 1]] = value;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 落盘失败的参数错误:给出路径与可行动提示(不抛裸异常栈)。 */
|
|
166
|
+
export function saveFailure(path, cause) {
|
|
167
|
+
return cliError("INVALID_ARGUMENT", {
|
|
168
|
+
path: SAVE_FLAG,
|
|
169
|
+
message: `Cannot write ${path}: ${cause && cause.message ? cause.message : cause}.`,
|
|
170
|
+
hint: "Check that the directory exists and is writable, or pick another path.",
|
|
171
|
+
retryable: false,
|
|
172
|
+
nextActions: [`${SAVE_FLAG} <writable-path>`],
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function invalidPath(path) {
|
|
177
|
+
return path.split(".").some((segment) => segment.length === 0 || FORBIDDEN_SEGMENTS.has(segment));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function argumentError(path, message, hint, nextActions) {
|
|
181
|
+
return cliError("INVALID_ARGUMENT", {
|
|
182
|
+
path,
|
|
183
|
+
message,
|
|
184
|
+
hint: hint ?? `Run finebi-cli --help for output options.`,
|
|
185
|
+
retryable: false,
|
|
186
|
+
nextActions: nextActions ?? ["finebi-cli --help"],
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function success(command, data, meta = {}, warnings = [], resolved = {}) {
|
|
2
|
+
return {
|
|
3
|
+
ok: true,
|
|
4
|
+
command,
|
|
5
|
+
data,
|
|
6
|
+
errors: [],
|
|
7
|
+
warnings,
|
|
8
|
+
resolved,
|
|
9
|
+
meta,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function failure(command, error, meta = {}, resolved = {}) {
|
|
14
|
+
return {
|
|
15
|
+
ok: false,
|
|
16
|
+
command,
|
|
17
|
+
data: null,
|
|
18
|
+
errors: [error],
|
|
19
|
+
warnings: [],
|
|
20
|
+
resolved,
|
|
21
|
+
meta,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function maskSecret(value) {
|
|
26
|
+
if (!value) {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
if (value.length <= 8) {
|
|
30
|
+
return "****";
|
|
31
|
+
}
|
|
32
|
+
return `${value.slice(0, 4)}****${value.slice(-4)}`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import {closeSync, existsSync, mkdirSync, openSync, rmSync} from "node:fs";
|
|
2
|
+
import {dirname, join} from "node:path";
|
|
3
|
+
|
|
4
|
+
import {normalizeConfig, readConfig, writeConfigAtomically} from "./config.js";
|
|
5
|
+
|
|
6
|
+
export class ProfileError extends Error {}
|
|
7
|
+
|
|
8
|
+
export function validateProfileName(name) {
|
|
9
|
+
if (typeof name !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name)) {
|
|
10
|
+
throw new ProfileError("Profile name must contain 1-64 letters, digits, underscores or hyphens and start with a letter or digit.");
|
|
11
|
+
}
|
|
12
|
+
return name;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readProfiles(path) {
|
|
16
|
+
const config = readConfig(path);
|
|
17
|
+
if (config.schemaVersion === 2) {
|
|
18
|
+
if (!config.profiles || typeof config.profiles !== "object" || Array.isArray(config.profiles)) {
|
|
19
|
+
throw new ProfileError("Invalid profiles configuration.");
|
|
20
|
+
}
|
|
21
|
+
return {multi: true, currentProfile: config.currentProfile ?? null, profiles: config.profiles};
|
|
22
|
+
}
|
|
23
|
+
// Preserve legacy session tokens when a single-environment file is migrated.
|
|
24
|
+
if (config.token && !config.auth?.token) {
|
|
25
|
+
config.auth = {...config.auth, token: config.token};
|
|
26
|
+
}
|
|
27
|
+
const present = existsSync(path);
|
|
28
|
+
return {multi: false, currentProfile: present ? "default" : null, profiles: present ? {default: config} : {}};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function selectProfile(path, requested, {allowCreate = false, optional = false} = {}) {
|
|
32
|
+
const store = readProfiles(path);
|
|
33
|
+
const name = requested ?? store.currentProfile ?? (!store.multi ? "default" : null);
|
|
34
|
+
if (!name) {
|
|
35
|
+
if (optional) return null;
|
|
36
|
+
throw new ProfileError("No default environment is selected. Run finebi-cli auth use <name> or pass --profile <name>.");
|
|
37
|
+
}
|
|
38
|
+
validateProfileName(name);
|
|
39
|
+
const implicitInitial = !requested && !store.multi && !existsSync(path);
|
|
40
|
+
if (!Object.hasOwn(store.profiles, name) && !allowCreate && !implicitInitial && !optional) {
|
|
41
|
+
throw new ProfileError(`Environment '${name}' does not exist. Create it with finebi-cli auth login --profile ${name} --base-url <url>.`);
|
|
42
|
+
}
|
|
43
|
+
if (requested && !Object.hasOwn(store.profiles, name) && !allowCreate) {
|
|
44
|
+
throw new ProfileError(`Environment '${name}' does not exist.`);
|
|
45
|
+
}
|
|
46
|
+
return name;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readProfile(path, name) {
|
|
50
|
+
const store = readProfiles(path);
|
|
51
|
+
return normalizeConfig(name && Object.hasOwn(store.profiles, name) ? store.profiles[name] : {});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function profileCapabilityPath(path, name) {
|
|
55
|
+
if (!name || name === "default") return path;
|
|
56
|
+
// Hex encoding preserves case-sensitive names on case-insensitive filesystems.
|
|
57
|
+
return join(dirname(path), `capabilities-profile-${Buffer.from(validateProfileName(name)).toString("hex")}.json`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function saveProfile(path, name, config, {makeCurrent = false, forceMulti = false, writer = writeConfigAtomically} = {}) {
|
|
61
|
+
validateProfileName(name);
|
|
62
|
+
return withProfileLock(path, () => {
|
|
63
|
+
const store = readProfiles(path);
|
|
64
|
+
if (!store.multi && name === "default" && !forceMulti) {
|
|
65
|
+
writer(path, config);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const profiles = {...store.profiles, [name]: config};
|
|
69
|
+
writer(path, {schemaVersion: 2, currentProfile: makeCurrent ? name : store.currentProfile, profiles});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useProfile(path, name) {
|
|
74
|
+
validateProfileName(name);
|
|
75
|
+
return withProfileLock(path, () => {
|
|
76
|
+
const store = readProfiles(path);
|
|
77
|
+
if (!Object.hasOwn(store.profiles, name)) throw new ProfileError(`Environment '${name}' does not exist.`);
|
|
78
|
+
writeConfigAtomically(path, {schemaVersion: 2, currentProfile: name, profiles: store.profiles});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function removeProfile(path, name) {
|
|
83
|
+
validateProfileName(name);
|
|
84
|
+
return withProfileLock(path, () => {
|
|
85
|
+
const store = readProfiles(path);
|
|
86
|
+
if (!Object.hasOwn(store.profiles, name)) {
|
|
87
|
+
if (!store.multi && name === "default") return;
|
|
88
|
+
throw new ProfileError(`Environment '${name}' does not exist.`);
|
|
89
|
+
}
|
|
90
|
+
if (!store.multi && name === "default") {
|
|
91
|
+
rmSync(path);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const profiles = {...store.profiles};
|
|
95
|
+
delete profiles[name];
|
|
96
|
+
writeConfigAtomically(path, {schemaVersion: 2, currentProfile: store.currentProfile === name ? null : store.currentProfile, profiles});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function withProfileLoginLock(path, name, operation) {
|
|
101
|
+
validateProfileName(name);
|
|
102
|
+
mkdirSync(dirname(path), {recursive: true, mode: 0o700});
|
|
103
|
+
const lock = `${path}.login-${Buffer.from(name).toString("hex")}.lock`;
|
|
104
|
+
let fd;
|
|
105
|
+
try {
|
|
106
|
+
fd = openSync(lock, "wx", 0o600);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error.code === "EEXIST") throw new ProfileError(`An operation for environment '${name}' is already in progress. Retry after it finishes.`);
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
return await operation();
|
|
113
|
+
} finally {
|
|
114
|
+
closeSync(fd);
|
|
115
|
+
rmSync(lock, {force: true});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function withProfileLock(path, operation) {
|
|
120
|
+
mkdirSync(dirname(path), {recursive: true, mode: 0o700});
|
|
121
|
+
const lock = `${path}.lock`;
|
|
122
|
+
const deadline = Date.now() + 3000;
|
|
123
|
+
let fd;
|
|
124
|
+
while (fd === undefined) {
|
|
125
|
+
try {
|
|
126
|
+
fd = openSync(lock, "wx", 0o600);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error.code !== "EEXIST") throw error;
|
|
129
|
+
if (Date.now() >= deadline) throw new ProfileError("Configuration is locked by another process. Retry after it finishes.");
|
|
130
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return operation();
|
|
135
|
+
} finally {
|
|
136
|
+
closeSync(fd);
|
|
137
|
+
rmSync(lock, {force: true});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function parseConnectionArgs(argv) {
|
|
142
|
+
const args = [];
|
|
143
|
+
const flags = {};
|
|
144
|
+
const names = {"--profile": "profile", "--base-url": "baseUrl", "--token": "token"};
|
|
145
|
+
for (let index = 0; index < argv.length; index++) {
|
|
146
|
+
const [option, ...inline] = argv[index].split("=");
|
|
147
|
+
if (!Object.hasOwn(names, option)) {
|
|
148
|
+
args.push(argv[index]);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const value = inline.length ? inline.join("=") : argv[++index];
|
|
152
|
+
if (!value?.trim() || value.startsWith("--")) throw new ProfileError(`${option} requires a value.`);
|
|
153
|
+
const key = names[option];
|
|
154
|
+
if (Object.hasOwn(flags, key)) throw new ProfileError(`${option} must only be supplied once.`);
|
|
155
|
+
flags[key] = value.trim();
|
|
156
|
+
}
|
|
157
|
+
return {args, flags};
|
|
158
|
+
}
|