@limecloud/cli 0.0.0-stage → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +292 -2
- package/bin/limecloud-cli.mjs +5 -0
- package/dist/identity.json +1453 -0
- package/dist/locales.json +17 -0
- package/dist/management.json +24928 -0
- package/package.json +36 -4
- package/src/cli.mjs +366 -0
- package/src/commands.mjs +70 -0
- package/src/contract.mjs +68 -0
- package/src/i18n.mjs +68 -0
- package/src/input.mjs +92 -0
- package/src/install.mjs +91 -0
- package/src/locales/en.json +153 -0
- package/src/locales/zh-CN.json +153 -0
- package/src/management.mjs +161 -0
- package/src/output.mjs +61 -0
- package/src/protocol.mjs +148 -0
- package/src/resources.mjs +11 -0
- package/src/storage.mjs +96 -0
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@limecloud/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Lime Cloud CLI for public identity and delegated platform APIs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.12.0"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"limecloud-cli": "./bin/limecloud-cli.mjs",
|
|
11
|
+
"limecloud": "./bin/limecloud-cli.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"src",
|
|
16
|
+
"dist/*.json",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "node scripts/build.mjs",
|
|
22
|
+
"prepack": "node scripts/build.mjs",
|
|
23
|
+
"test": "node --test test/*.test.mjs"
|
|
24
|
+
},
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/limecloud/cloud.git",
|
|
29
|
+
"directory": "packages/cli"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"registry": "https://registry.npmjs.org"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"i18next": "25.8.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { createLocalizer, currentLocalizer, localizeContract, locales, message, normalizeLocale, requestedLocale, translate, withLocalizer } from "./i18n.mjs";
|
|
2
|
+
import { setTimeout } from "node:timers/promises";
|
|
3
|
+
import { publicContract, resolveSchema } from "./contract.mjs";
|
|
4
|
+
import { commandOperations, inspectSchema } from "./commands.mjs";
|
|
5
|
+
import { CredentialStore, PrivateJSONStore, SettingsStore, credentialsPath, settingsPath, validateSettings } from "./storage.mjs";
|
|
6
|
+
import { CLIError, issuerOrigin, pollDevice, protocolRequest, startDevice, tokenRecord, verificationURI } from "./protocol.mjs";
|
|
7
|
+
import { parametersFor, scopeList, validateParameters } from "./input.mjs";
|
|
8
|
+
import { errorEnvelope, outputOptions, success } from "./output.mjs";
|
|
9
|
+
import { flagName, matchHTTP, prepareManagement } from "./management.mjs";
|
|
10
|
+
import { configureInitialSettings, discoverCLISettings, initialSettings, installationPlan, installRuntime, withSetupPrompt } from "./install.mjs";
|
|
11
|
+
|
|
12
|
+
export { validateParameters } from "./input.mjs";
|
|
13
|
+
|
|
14
|
+
const help = () => ["help.overview", "help.apiFlags", "help.connectionFlags"].map(key => translate(key)).join("");
|
|
15
|
+
|
|
16
|
+
const booleanFlags = new Set(["help", "version", "json", "show-secrets", "local-only", "dry-run", "no-wait", "resume", "offline", "no-config", "skip-login", "login"]);
|
|
17
|
+
const valueFlags = new Set(["issuer", "api-origin", "client-id", "auth-client-id", "scope", "installation-id", "space-id", "locale", "param", "param-env", "params", "data", "format", "fields", "header", "file", "prefix"]);
|
|
18
|
+
const fail = (message) => { throw new CLIError("invalid_arguments", message); };
|
|
19
|
+
|
|
20
|
+
function fieldNames(contract, operation) {
|
|
21
|
+
const schema = resolveSchema(contract, operation.requestBody?.content["application/x-www-form-urlencoded"]?.schema ?? operation.requestBody?.content["application/json"]?.schema);
|
|
22
|
+
const variants = schema?.oneOf?.map((item) => resolveSchema(contract, item)) ?? (schema ? [schema] : []);
|
|
23
|
+
return [...new Set([...variants.flatMap((item) => Object.keys(item.properties ?? {})), ...(operation.parameters ?? []).map((parameter) => parameter.name)])];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseArguments(args, protocolFields = []) {
|
|
27
|
+
const positionals = [], flags = new Map();
|
|
28
|
+
const dynamic = new Set(protocolFields.map(flagName));
|
|
29
|
+
for (let index = 0; index < args.length; index++) {
|
|
30
|
+
const item = args[index] === "-h" ? "--help" : args[index] === "-v" ? "--version" : args[index];
|
|
31
|
+
if (!item.startsWith("--")) { positionals.push(item); continue; }
|
|
32
|
+
const [name, ...rest] = item.slice(2).split("=");
|
|
33
|
+
if (!booleanFlags.has(name) && !valueFlags.has(name) && !dynamic.has(name)) fail(message("arguments.unknownOption", { name }));
|
|
34
|
+
let value = true;
|
|
35
|
+
if (booleanFlags.has(name) && rest.length) fail(message("arguments.booleanOptionValue", { name }));
|
|
36
|
+
if (!booleanFlags.has(name)) {
|
|
37
|
+
value = rest.length ? rest.join("=") : args[++index];
|
|
38
|
+
if (typeof value !== "string" || !value || value.startsWith("--")) fail(message("arguments.optionValueRequired", { name }));
|
|
39
|
+
}
|
|
40
|
+
if (flags.has(name) && !["param", "param-env", "audience", "header"].includes(name)) fail(message("arguments.duplicateOption", { name }));
|
|
41
|
+
flags.set(name, [...(flags.get(name) ?? []), value]);
|
|
42
|
+
}
|
|
43
|
+
return { positionals, flags, get: (name) => flags.get(name)?.[0] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function allowedFlags(parsed, names) {
|
|
47
|
+
for (const key of parsed.flags.keys()) if (!["help", "json", "locale", ...names].includes(key)) fail(message("arguments.optionUnsupported", { name: key }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function commandHelp(command, contract) {
|
|
51
|
+
const serviceMethods = commandOperations(contract).filter(operation => operation.command.startsWith(command + "."));
|
|
52
|
+
if (serviceMethods.length) return (command === "identity" ? translate("help.identityShortcuts") : "") +
|
|
53
|
+
translate("help.typedMethods", { service: command, methods: serviceMethods.map(operation =>
|
|
54
|
+
" " + operation.command.replaceAll(".", " ") + " (" + operation.method + " " + operation.path + ")").join("\n") });
|
|
55
|
+
if (["auth", "config", "schema", "install"].includes(command)) return translate("help." + command);
|
|
56
|
+
if (command === "api") return translate("help.apiFlags") + translate("help.api");
|
|
57
|
+
return help() + "\n" + translate("help.services", { services: [...new Set(commandOperations(contract).map(operation => operation.command.split(".")[0]))].join(", ") });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function contextFor(parsed, env, settings, saved, management = false) {
|
|
61
|
+
const issuer = issuerOrigin(parsed.get("issuer") ?? env.LIMECLOUD_ISSUER ?? settings.issuer ?? saved?.issuer ?? "https://auth.limeai.run");
|
|
62
|
+
const apiValue = parsed.get("api-origin") ?? env.LIMECLOUD_API_ORIGIN ?? settings.api_origin;
|
|
63
|
+
const apiOrigin = apiValue ? issuerOrigin(apiValue) : undefined;
|
|
64
|
+
const clientID = parsed.get("auth-client-id") ?? (management ? undefined : parsed.get("client-id")) ?? env.LIMECLOUD_CLIENT_ID ?? settings.client_id ?? (saved?.issuer === issuer ? saved.client_id : undefined);
|
|
65
|
+
const matching = saved && saved.issuer === issuer && saved.client_id === clientID ? saved : null;
|
|
66
|
+
return { issuer, apiOrigin, clientID, saved: matching, accessToken: env.LIMECLOUD_ACCESS_TOKEN || matching?.access_token };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function usableAccess(context, env, now) {
|
|
70
|
+
if (!context.accessToken) throw new CLIError("login_required", message("auth.loginRequired"));
|
|
71
|
+
if (!env.LIMECLOUD_ACCESS_TOKEN && Date.parse(context.saved.expires_at) <= now()) throw new CLIError("token_expired", message("auth.tokenExpired"));
|
|
72
|
+
return context.accessToken;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function localIdentity(context, env, now) {
|
|
76
|
+
const external = Boolean(env.LIMECLOUD_ACCESS_TOKEN);
|
|
77
|
+
return { identity: context.accessToken ? "user" : "public", authenticated: Boolean(context.accessToken) && (external || Date.parse(context.saved.expires_at) > now()),
|
|
78
|
+
source: external ? "environment" : context.saved ? "credentials" : null, issuer: context.issuer, api_origin: context.apiOrigin ?? null, client_id: context.clientID ?? null,
|
|
79
|
+
bound_api_origin: external ? null : context.saved?.api_origin ?? null,
|
|
80
|
+
expires_at: external ? null : context.saved?.expires_at ?? null, expired: external ? null : context.saved ? Date.parse(context.saved.expires_at) <= now() : null,
|
|
81
|
+
refresh_requires_login: context.saved?.refresh_pending === true, scope: external ? null : context.saved?.scope ?? null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function preview(operation, context, form, authentication) {
|
|
85
|
+
const body = {};
|
|
86
|
+
for (const key of new Set(form.keys())) body[key] = form.getAll(key).length > 1 ? form.getAll(key) : form.get(key);
|
|
87
|
+
return { dry_run: true, command: operation.command, operationId: operation.operationId, request: { method: operation.method,
|
|
88
|
+
url: `${context.issuer}${operation.path}`, headers: { Accept: "application/json", "Accept-Language": currentLocalizer().locale, ...(authentication === "user" ? { Authorization: "Bearer [REDACTED]" } :
|
|
89
|
+
authentication === "client" ? { Authorization: "Basic [REDACTED]" } : {}), ...(operation.method === "POST" ? { "Content-Type": "application/x-www-form-urlencoded" } : {}) },
|
|
90
|
+
...(operation.method === "GET" ? { params: body } : { data: body }) } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function execute(args, options = {}) {
|
|
94
|
+
const requested = requestedLocale(args, options.env ?? process.env);
|
|
95
|
+
return withLocalizer(createLocalizer(requested.locale), async () => {
|
|
96
|
+
if (requested.explicit !== undefined && !normalizeLocale(requested.explicit)) fail(message("arguments.unsupportedLocale", { locales: locales.join(", ") }));
|
|
97
|
+
return executeCommand(args, options);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function executeCommand(args, { env = process.env, io = process, store = new CredentialStore(credentialsPath(env)),
|
|
102
|
+
settingsStore = new SettingsStore(settingsPath(env)), pendingStore = new PrivateJSONStore(`${store.path ?? credentialsPath(env)}.pending.json`),
|
|
103
|
+
request: requestImpl = protocolRequest, wait = setTimeout, now = Date.now, signal, readInput, readBinary, contract: suppliedContract,
|
|
104
|
+
installer = installRuntime, prompt } = {}) {
|
|
105
|
+
const contract = suppliedContract ?? await publicContract();
|
|
106
|
+
const catalogue = commandOperations(contract);
|
|
107
|
+
const parsed = parseArguments(args, catalogue.flatMap((operation) => fieldNames(contract, operation)));
|
|
108
|
+
let [command, action, target, ...extra] = parsed.positionals;
|
|
109
|
+
const get = parsed.get;
|
|
110
|
+
const request = (input) => requestImpl({ ...input, extraHeaders: { ...input.extraHeaders, "Accept-Language": currentLocalizer().locale }, signal });
|
|
111
|
+
const pause = (ms) => wait(ms, undefined, signal ? { signal } : undefined);
|
|
112
|
+
const output = (value, options = {}) => success(io, value, { ...outputOptions(parsed), ...options });
|
|
113
|
+
if (get("help") || !args.length) { io.stdout.write(commandHelp(command, contract)); return; }
|
|
114
|
+
if (get("version")) { io.stdout.write(`${contract.info.version}\n`); return; }
|
|
115
|
+
// 在任何读取凭据或发请求前验证输出选项。
|
|
116
|
+
outputOptions(parsed);
|
|
117
|
+
if (command === "identity" && ["+login", "+whoami"].includes(action) && !target) { command = "auth"; action = action.slice(1); }
|
|
118
|
+
if (command === "schema") {
|
|
119
|
+
allowedFlags(parsed, []);
|
|
120
|
+
output(inspectSchema(contract, parsed.positionals.slice(1))); return;
|
|
121
|
+
}
|
|
122
|
+
if (command === "docs") {
|
|
123
|
+
allowedFlags(parsed, []);
|
|
124
|
+
if (target || extra.length || action && !/^[a-z0-9-]{1,80}$/.test(action)) fail(message("arguments.docsUsage"));
|
|
125
|
+
output({ url: `https://open.limeai.run/docs${action ? `/${action}` : ""}` }); return;
|
|
126
|
+
}
|
|
127
|
+
if (command === "install") {
|
|
128
|
+
allowedFlags(parsed, ["prefix", "issuer", "api-origin", "client-id", "scope", "no-config", "skip-login", "login", "dry-run"]);
|
|
129
|
+
if (action || get("login") && (get("skip-login") || get("no-config"))) fail(message("install.usage"));
|
|
130
|
+
const plan = installationPlan(contract.info.version, get("prefix"), env);
|
|
131
|
+
let prepared = get("no-config") ? null : await initialSettings(parsed, env, await settingsStore.read() ?? {});
|
|
132
|
+
let discovery = { status: "not_needed" };
|
|
133
|
+
if (get("dry-run")) {
|
|
134
|
+
output({ dry_run: true, installation: plan, configuration: { status: get("no-config") ? "skipped" : prepared.client_id ? "planned" : "pending", settings: prepared },
|
|
135
|
+
discovery: { status: "not_started" }, authorization: { status: "not_started" } }, { identity: "local" }); return;
|
|
136
|
+
}
|
|
137
|
+
if (prepared && !prepared.client_id) {
|
|
138
|
+
try {
|
|
139
|
+
prepared = await discoverCLISettings(prepared, request);
|
|
140
|
+
discovery = { status: "available", url: `${prepared.issuer}/runtime/v1/cli` };
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (signal?.aborted) throw error;
|
|
143
|
+
discovery = { status: "unavailable", error_code: "cli_metadata_unavailable", message: translate("install.discoveryUnavailable") };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (get("login") && !prepared.client_id && !prompt && !io.stdin?.isTTY) fail(message("arguments.publicClientRequired"));
|
|
147
|
+
if (get("login") && env.LIMECLOUD_CLIENT_SECRET) fail(message("arguments.publicClientOnly"));
|
|
148
|
+
if (get("login") && env.LIMECLOUD_ACCESS_TOKEN) fail(message("arguments.externalTokenActive"));
|
|
149
|
+
await installer(plan, { env, io, signal });
|
|
150
|
+
await withSetupPrompt({ io, prompt, signal, interactive: !get("json") }, async ask => {
|
|
151
|
+
const configured = get("no-config") ? null : await configureInitialSettings(settingsStore, parsed, env, ask, true, prepared);
|
|
152
|
+
let authorization = { status: "not_started" };
|
|
153
|
+
const beginLogin = configured && (get("login") || ask && !get("skip-login") && /^(y|yes)$/i.test(await ask("install.promptLogin", "n")));
|
|
154
|
+
if (beginLogin) {
|
|
155
|
+
const chunks = [];
|
|
156
|
+
await execute(["auth", "login", "--no-wait", "--json", "--locale", currentLocalizer().locale], {
|
|
157
|
+
env, io: { stdin: io.stdin, stderr: io.stderr, stdout: { write: value => chunks.push(value) } }, store, settingsStore, pendingStore,
|
|
158
|
+
request: requestImpl, wait, now, signal, contract,
|
|
159
|
+
});
|
|
160
|
+
authorization = { status: "pending", ...JSON.parse(chunks.join("")).data };
|
|
161
|
+
}
|
|
162
|
+
output({ installation: { ...plan, installed: true }, configuration: { status: get("no-config") ? "skipped" : configured ? "configured" : "pending", path: settingsStore.path ?? null, settings: configured },
|
|
163
|
+
discovery, authorization, next_steps: [translate("install.pathHint", { directory: plan.bin_directory }),
|
|
164
|
+
...(!configured ? ["limecloud-cli config init"] : []),
|
|
165
|
+
authorization.status === "pending" ? "limecloud-cli auth login --resume" : "limecloud-cli auth login",
|
|
166
|
+
"limecloud-cli doctor --offline", "limecloud-cli schema"] }, { identity: "local" });
|
|
167
|
+
}); return;
|
|
168
|
+
}
|
|
169
|
+
if (command === "config") {
|
|
170
|
+
allowedFlags(parsed, action === "init" ? ["issuer", "api-origin", "client-id", "scope"] : []);
|
|
171
|
+
if (action === "show" && !target) { output({ settings: await settingsStore.read(), path: settingsStore.path ?? null }, { identity: "local" }); return; }
|
|
172
|
+
if (action === "init" && !target) {
|
|
173
|
+
await withSetupPrompt({ io, prompt, signal, interactive: !get("json") }, async ask => {
|
|
174
|
+
const settings = await configureInitialSettings(settingsStore, parsed, env, ask);
|
|
175
|
+
output({ settings, path: settingsStore.path ?? null }, { identity: "local" });
|
|
176
|
+
}); return;
|
|
177
|
+
}
|
|
178
|
+
if (action === "set" && target && extra.length === 1) {
|
|
179
|
+
await settingsStore.withLock(async () => {
|
|
180
|
+
const settings = validateSettings({ ...await settingsStore.read(), [target]: extra[0] });
|
|
181
|
+
await settingsStore.write(settings); output({ settings }, { identity: "local" });
|
|
182
|
+
}); return;
|
|
183
|
+
}
|
|
184
|
+
fail(message("arguments.configUsage"));
|
|
185
|
+
}
|
|
186
|
+
if (extra.length) fail(message("arguments.tooMany"));
|
|
187
|
+
let operation = catalogue.find((item) => item.command === [command, action, target].join("."));
|
|
188
|
+
if (command === "api" && action === "call") operation = catalogue.find((item) => item.operationId === target);
|
|
189
|
+
const raw = command === "api" ? matchHTTP(catalogue, action, target) : null;
|
|
190
|
+
if (raw) operation = raw.operation;
|
|
191
|
+
const settings = await settingsStore.read() ?? {};
|
|
192
|
+
const management = operation?.["x-lime-cli-origin"] === "api";
|
|
193
|
+
const context = await contextFor(parsed, env, settings, await store.read(), management);
|
|
194
|
+
if (["whoami", "doctor"].includes(command)) {
|
|
195
|
+
allowedFlags(parsed, ["issuer", "api-origin", "client-id", "auth-client-id", ...(command === "doctor" ? ["offline"] : [])]);
|
|
196
|
+
if (action) fail(message("arguments.noPositionals", { command }));
|
|
197
|
+
const state = localIdentity(context, env, now);
|
|
198
|
+
output(command === "whoami" ? state : { offline: true, checks: { public_contract: true, version: contract.info.version,
|
|
199
|
+
issuer: context.issuer, configured_client: Boolean(context.clientID), credentials_readable: true, token_unexpired: state.authenticated },
|
|
200
|
+
configuration: { settings: settingsStore.path ?? null, credentials: store.path ?? null } }, { identity: "local" }); return;
|
|
201
|
+
}
|
|
202
|
+
if (command === "api" && action === "list") {
|
|
203
|
+
allowedFlags(parsed, ["locale", "format", "fields"]);
|
|
204
|
+
const locale = currentLocalizer().locale;
|
|
205
|
+
const needle = target?.toLocaleLowerCase(locale) ?? "";
|
|
206
|
+
output(catalogue.filter((operation) => `${operation.operationId} ${operation.path} ${operation.summary} ${localizeContract(operation)}`.toLocaleLowerCase(locale).includes(needle))
|
|
207
|
+
.map((operation) => ({ command: operation.command, operationId: operation.operationId, method: operation.method, path: operation.path,
|
|
208
|
+
summary: localizeContract(operation) }))); return;
|
|
209
|
+
}
|
|
210
|
+
if (command === "api" && action === "schema") {
|
|
211
|
+
allowedFlags(parsed, []);
|
|
212
|
+
if (!catalogue.some((operation) => operation.operationId === target)) fail(message("arguments.operationRequired"));
|
|
213
|
+
output(inspectSchema(contract, [target])); return;
|
|
214
|
+
}
|
|
215
|
+
if (operation) {
|
|
216
|
+
const fields = fieldNames(contract, operation);
|
|
217
|
+
allowedFlags(parsed, ["issuer", "api-origin", ...(management ? [] : ["client-id"]), "auth-client-id", "param", "param-env", "params", "data", ...(management ? ["header", "file"] : []), "show-secrets", "dry-run", "format", "fields", ...fields.map(flagName)]);
|
|
218
|
+
if (management) {
|
|
219
|
+
const input = await prepareManagement(contract, operation, parsed, env, readInput, raw?.bindings, readBinary);
|
|
220
|
+
if (get("dry-run")) {
|
|
221
|
+
output({ dry_run: true, command: operation.command, operationId: operation.operationId, request: { method: operation.method,
|
|
222
|
+
url: `${context.apiOrigin ?? "<api-origin>"}${input.path}`, params: Object.fromEntries(input.query),
|
|
223
|
+
headers: { ...input.extraHeaders, "Accept-Language": currentLocalizer().locale, Authorization: "Bearer [REDACTED]", ...(input.binaryType ? { "Content-Type": input.binaryType } : input.jsonBody === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
224
|
+
...(input.binarySummary ? { file: input.binarySummary } : input.jsonBody === undefined ? {} : { data: input.jsonBody }) } }, { identity: "user" }); return;
|
|
225
|
+
}
|
|
226
|
+
if (!context.apiOrigin) throw new CLIError("api_origin_required", message("auth.apiOriginRequired"));
|
|
227
|
+
const token = usableAccess(context, env, now);
|
|
228
|
+
if (!env.LIMECLOUD_ACCESS_TOKEN && context.saved.api_origin !== context.apiOrigin) throw new CLIError("login_required", message("auth.apiBindingChanged"));
|
|
229
|
+
const granted = new Set(context.saved?.scope.split(/\s+/) ?? []);
|
|
230
|
+
const required = operation["x-lime-required-scopes"] ?? [];
|
|
231
|
+
if (!env.LIMECLOUD_ACCESS_TOKEN && required.some((scope) => !granted.has(scope))) throw new CLIError("missing_scopes", message("auth.requiredScopes", { scopes: required.join(" ") }));
|
|
232
|
+
const result = await request({ issuer: context.issuer, origin: context.apiOrigin, method: operation.method, ...input, accessToken: token });
|
|
233
|
+
const response = resolveSchema(contract, operation.responses["200"]?.content?.["application/json"]?.schema ?? operation.responses["201"]?.content?.["application/json"]?.schema);
|
|
234
|
+
output(response?.properties?.data && result && Object.hasOwn(result, "data") ? result.data : result ?? { accepted: true }, { identity: "user", raw: Boolean(get("show-secrets")) }); return;
|
|
235
|
+
}
|
|
236
|
+
const form = await parametersFor(parsed, env, operation.method, readInput);
|
|
237
|
+
// 与参考 typed commands 一致:显式字段参数覆盖同名 JSON 参数。
|
|
238
|
+
for (const name of fields) if (get(flagName(name)) !== undefined) {
|
|
239
|
+
const single = { flags: new Map([["param", (parsed.flags.get(flagName(name)) ?? []).map((value) => `${name}=${value}`)]]), get: () => undefined };
|
|
240
|
+
const override = await parametersFor(single, env, operation.method, readInput);
|
|
241
|
+
form.delete(name);
|
|
242
|
+
for (const value of override.getAll(name)) form.append(name, value);
|
|
243
|
+
}
|
|
244
|
+
const basic = Boolean(operation.security?.some((item) => Object.hasOwn(item, "ClientSecretBasic")) && env.LIMECLOUD_CLIENT_SECRET);
|
|
245
|
+
const bearer = operation.security?.some((item) => Object.hasOwn(item, "Bearer"));
|
|
246
|
+
const requiresBasic = operation.path === "/oauth/introspect" || operation.path === "/oauth/token" && form.get("grant_type") === "client_credentials";
|
|
247
|
+
if (!get("dry-run") && (requiresBasic && !basic || basic && !context.clientID)) fail(message("arguments.basicCredentialsRequired"));
|
|
248
|
+
if (!basic && !requiresBasic && context.clientID && ["/oauth/token", "/oauth/device/authorization", "/oauth/revoke"].includes(operation.path) && !form.has("client_id")) form.set("client_id", context.clientID);
|
|
249
|
+
validateParameters(contract, operation, form, basic || requiresBasic, Boolean(get("dry-run")));
|
|
250
|
+
const identity = basic || requiresBasic ? "client" : bearer ? "user" : "public";
|
|
251
|
+
if (get("dry-run")) { output(preview(operation, context, form, identity), { identity }); return; }
|
|
252
|
+
const result = await request({ issuer: context.issuer, path: operation.path, method: operation.method, parameters: form,
|
|
253
|
+
clientID: context.clientID, clientSecret: basic ? env.LIMECLOUD_CLIENT_SECRET : undefined,
|
|
254
|
+
accessToken: bearer ? usableAccess(context, env, now) : undefined });
|
|
255
|
+
output(result ?? { accepted: true }, { identity, raw: Boolean(get("show-secrets")) }); return;
|
|
256
|
+
}
|
|
257
|
+
if (command !== "auth" || !["login", "status", "whoami", "refresh", "logout", "scopes", "check"].includes(action) || target) fail(message("arguments.unknownCommand"));
|
|
258
|
+
allowedFlags(parsed, ["issuer", "api-origin", "client-id", "auth-client-id", ...(action === "login" ? ["scope", "installation-id", "space-id", "no-wait", "resume", "dry-run"] : []),
|
|
259
|
+
...(["refresh", "logout", "scopes"].includes(action) ? ["dry-run"] : []), ...(action === "logout" ? ["local-only"] : []), ...(action === "check" ? ["scope"] : [])]);
|
|
260
|
+
if (action === "status") { output(localIdentity(context, env, now), { identity: "user" }); return; }
|
|
261
|
+
if (action === "whoami") { output(await request({ issuer: context.issuer, path: "/oauth/userinfo", accessToken: usableAccess(context, env, now) }), { identity: "user" }); return; }
|
|
262
|
+
if (action === "check") {
|
|
263
|
+
if (!get("scope")) fail(message("arguments.scopeCheckUsage"));
|
|
264
|
+
const required = scopeList(get("scope"));
|
|
265
|
+
const granted = new Set((context.saved?.scope ?? "").split(/\s+/));
|
|
266
|
+
const missing = required.filter((scope) => !granted.has(scope));
|
|
267
|
+
usableAccess(context, env, now);
|
|
268
|
+
if (env.LIMECLOUD_ACCESS_TOKEN || missing.length) throw new CLIError("missing_scopes", env.LIMECLOUD_ACCESS_TOKEN ? message("auth.externalScopesUnknown") : message("auth.missingScopes", { scopes: missing.join(", ") }));
|
|
269
|
+
output({ checked: true, source: "local_grant", scopes: required }, { identity: "user" }); return;
|
|
270
|
+
}
|
|
271
|
+
if (action === "scopes") {
|
|
272
|
+
const method = catalogue.find((item) => item.operationId === "identity_discovery");
|
|
273
|
+
if (get("dry-run")) { output(preview(method, context, new URLSearchParams(), "public")); return; }
|
|
274
|
+
const metadata = await request({ issuer: context.issuer, path: method.path });
|
|
275
|
+
if (metadata?.issuer !== context.issuer || !Array.isArray(metadata.scopes_supported) || metadata.scopes_supported.some((scope) => typeof scope !== "string")) throw new CLIError("invalid_response", message("auth.discoveryInvalid"));
|
|
276
|
+
output({ supported_scopes: metadata.scopes_supported, granted_scopes: context.saved?.scope.split(/\s+/).filter(Boolean) ?? [], source: "oidc_discovery",
|
|
277
|
+
client_enabled_scopes: null }); return;
|
|
278
|
+
}
|
|
279
|
+
if (["login", "refresh", "logout"].includes(action) && env.LIMECLOUD_CLIENT_SECRET) fail(message("arguments.publicClientOnly"));
|
|
280
|
+
if (["login", "refresh", "logout"].includes(action) && env.LIMECLOUD_ACCESS_TOKEN) fail(message("arguments.externalTokenActive"));
|
|
281
|
+
async function auth() {
|
|
282
|
+
const current = await contextFor(parsed, env, settings, await store.read());
|
|
283
|
+
if (action === "login") {
|
|
284
|
+
if (get("no-wait") && get("resume")) fail(message("arguments.deviceModeConflict"));
|
|
285
|
+
if (get("resume") && ["scope", "installation-id", "space-id"].some((name) => get(name))) fail(message("arguments.resumeContextImmutable"));
|
|
286
|
+
if (Boolean(get("installation-id")) !== Boolean(get("space-id"))) fail(message("arguments.installationContextPair"));
|
|
287
|
+
let pending;
|
|
288
|
+
if (get("resume")) {
|
|
289
|
+
pending = await pendingStore.read();
|
|
290
|
+
if (!pending?.device_code) throw new CLIError("login_required", message("auth.pendingMissing"));
|
|
291
|
+
issuerOrigin(pending.issuer);
|
|
292
|
+
verificationURI(pending.verification_uri, pending.issuer);
|
|
293
|
+
if (!/^[A-Z2-7]{4}-[A-Z2-7]{4}$/.test(pending.user_code)) throw new CLIError("invalid_credentials", message("auth.pendingCodeInvalid"));
|
|
294
|
+
if ((get("issuer") ?? env.LIMECLOUD_ISSUER ?? pending.issuer) !== pending.issuer ||
|
|
295
|
+
(get("client-id") ?? env.LIMECLOUD_CLIENT_ID ?? pending.client_id) !== pending.client_id) fail(message("arguments.resumeIdentityMismatch"));
|
|
296
|
+
if ((get("api-origin") ?? env.LIMECLOUD_API_ORIGIN ?? settings.api_origin ?? pending.api_origin) !== pending.api_origin) fail(message("arguments.resumeOriginMismatch"));
|
|
297
|
+
} else {
|
|
298
|
+
if (!current.clientID && !get("dry-run")) fail(message("arguments.registeredClientRequired"));
|
|
299
|
+
const scopes = scopeList(get("scope") ?? settings.scope ?? "openid profile email offline_access");
|
|
300
|
+
if (!scopes.includes("openid")) fail(message("arguments.openidRequired"));
|
|
301
|
+
const form = new URLSearchParams({ scope: scopes.join(" ") });
|
|
302
|
+
if (get("installation-id")) { form.set("installation_id", get("installation-id")); form.set("space_id", get("space-id")); }
|
|
303
|
+
if (get("dry-run")) {
|
|
304
|
+
if (current.clientID) form.set("client_id", current.clientID);
|
|
305
|
+
output(preview(catalogue.find((item) => item.operationId === "identity_device_authorization"), current, form, "public")); return;
|
|
306
|
+
}
|
|
307
|
+
pending = await startDevice({ context: { issuer: current.issuer, apiOrigin: current.apiOrigin, clientID: current.clientID }, parameters: form, request, now });
|
|
308
|
+
await pendingStore.write(pending);
|
|
309
|
+
}
|
|
310
|
+
if (get("dry-run")) {
|
|
311
|
+
output(preview(catalogue.find((item) => item.operationId === "identity_token"), { issuer: pending.issuer }, new URLSearchParams({
|
|
312
|
+
client_id: pending.client_id, grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: pending.device_code,
|
|
313
|
+
}), "public")); return;
|
|
314
|
+
}
|
|
315
|
+
const notice = { authorization_pending: true, verification_uri: pending.verification_uri, user_code: pending.user_code,
|
|
316
|
+
expires_at: pending.expires_at, resume_command: "limecloud-cli auth login --resume" };
|
|
317
|
+
if (get("no-wait")) { output(notice); return; }
|
|
318
|
+
io.stderr.write(translate("auth.deviceNotice", { uri: notice.verification_uri, code: notice.user_code }));
|
|
319
|
+
const saved = await pollDevice({ pending, request, wait: pause, now, persist: (value) => pendingStore.write(value) });
|
|
320
|
+
await store.write(saved);
|
|
321
|
+
await pendingStore.write({});
|
|
322
|
+
output({ authenticated: true, issuer: saved.issuer, client_id: saved.client_id, expires_at: saved.expires_at, scope: saved.scope }, { identity: "user" }); return;
|
|
323
|
+
}
|
|
324
|
+
if (!current.saved) {
|
|
325
|
+
if (action === "logout" && !get("dry-run")) await pendingStore.write({});
|
|
326
|
+
output({ authenticated: false }, { identity: "user" }); return;
|
|
327
|
+
}
|
|
328
|
+
if (action === "refresh") {
|
|
329
|
+
if (current.saved.refresh_pending) throw new CLIError("login_required", message("auth.refreshUncertain"));
|
|
330
|
+
if (!current.saved.refresh_token) throw new CLIError("login_required", message("auth.refreshMissing"));
|
|
331
|
+
if (current.apiOrigin && current.saved.api_origin !== current.apiOrigin) throw new CLIError("login_required", message("auth.refreshBindingChanged"));
|
|
332
|
+
const form = new URLSearchParams({ client_id: current.clientID, grant_type: "refresh_token", refresh_token: current.saved.refresh_token });
|
|
333
|
+
if (get("dry-run")) { output(preview(catalogue.find((item) => item.operationId === "identity_token"), current, form, "public")); return; }
|
|
334
|
+
await store.write({ ...current.saved, refresh_pending: true });
|
|
335
|
+
const tokens = await request({ issuer: current.issuer, path: "/oauth/token", method: "POST", parameters: form });
|
|
336
|
+
const saved = tokenRecord(tokens, { ...current, apiOrigin: current.saved.api_origin }, now());
|
|
337
|
+
await store.write(saved);
|
|
338
|
+
output({ authenticated: true, expires_at: saved.expires_at, scope: saved.scope }, { identity: "user" }); return;
|
|
339
|
+
}
|
|
340
|
+
const form = new URLSearchParams({ client_id: current.clientID, token: current.saved.refresh_token ?? current.saved.access_token,
|
|
341
|
+
token_type_hint: current.saved.refresh_token ? "refresh_token" : "access_token" });
|
|
342
|
+
if (get("dry-run")) {
|
|
343
|
+
output(get("local-only") ? { dry_run: true, local_credentials: "would_forget", remote_revocation: false } :
|
|
344
|
+
preview(catalogue.find((item) => item.operationId === "identity_revoke"), current, form, "public")); return;
|
|
345
|
+
}
|
|
346
|
+
if (!get("local-only")) await request({ issuer: current.issuer, path: "/oauth/revoke", method: "POST", parameters: form });
|
|
347
|
+
await store.write({}); await pendingStore.write({});
|
|
348
|
+
output({ authenticated: false, revoked: !get("local-only") }, { identity: "user" });
|
|
349
|
+
}
|
|
350
|
+
if (get("dry-run")) await auth();
|
|
351
|
+
else await store.withLock(auth);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function run(args, options = {}) {
|
|
355
|
+
const controller = new AbortController();
|
|
356
|
+
let cancelled;
|
|
357
|
+
const interrupt = () => { cancelled = "SIGINT"; controller.abort(); };
|
|
358
|
+
const terminate = () => { cancelled = "SIGTERM"; controller.abort(); };
|
|
359
|
+
process.once("SIGINT", interrupt); process.once("SIGTERM", terminate);
|
|
360
|
+
try { await execute(args, { ...options, signal: controller.signal }); }
|
|
361
|
+
catch (error) {
|
|
362
|
+
const localizer = createLocalizer(requestedLocale(args, options.env ?? process.env).locale);
|
|
363
|
+
(options.io ?? process).stderr.write(`${JSON.stringify(errorEnvelope(error, cancelled, localizer))}\n`);
|
|
364
|
+
process.exitCode = cancelled === "SIGINT" ? 130 : cancelled === "SIGTERM" ? 143 : error.code === "invalid_arguments" || error.code === "invalid_issuer" ? 2 : 1;
|
|
365
|
+
} finally { process.removeListener("SIGINT", interrupt); process.removeListener("SIGTERM", terminate); }
|
|
366
|
+
}
|
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { localizeContract, message } from "./i18n.mjs";
|
|
2
|
+
import { operations, resolveSchema } from "./contract.mjs";
|
|
3
|
+
import { CLIError } from "./protocol.mjs";
|
|
4
|
+
|
|
5
|
+
// 缺省映射兼容现有身份合同。新增公共领域可在合同中显式声明 x-lime-cli。
|
|
6
|
+
const identityCommands = {
|
|
7
|
+
identity_discovery: "identity.discovery.get",
|
|
8
|
+
identity_authorize: "identity.authorization.authorize",
|
|
9
|
+
identity_token: "identity.token.exchange",
|
|
10
|
+
identity_device_authorization: "identity.device.authorize",
|
|
11
|
+
identity_userinfo: "identity.userinfo.get",
|
|
12
|
+
identity_userinfo_post: "identity.userinfo.post",
|
|
13
|
+
identity_jwks: "identity.keys.list",
|
|
14
|
+
identity_revoke: "identity.token.revoke",
|
|
15
|
+
identity_introspect: "identity.token.introspect",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function commandOperations(contract) {
|
|
19
|
+
return operations(contract).map((operation) => {
|
|
20
|
+
const command = operation["x-lime-cli"] ?? identityCommands[operation.operationId];
|
|
21
|
+
if (!command || !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){2}$/.test(command)) throw new CLIError("invalid_contract", message("contract.commandMappingRequired"));
|
|
22
|
+
return { ...operation, command };
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function expanded(contract, input, depth = 0) {
|
|
27
|
+
if (!input || depth > 20) return input;
|
|
28
|
+
const schema = resolveSchema(contract, input);
|
|
29
|
+
if (Array.isArray(schema)) return schema.map((item) => expanded(contract, item, depth + 1));
|
|
30
|
+
if (typeof schema !== "object") return schema;
|
|
31
|
+
return Object.fromEntries(Object.entries(schema).map(([key, value]) => [key,
|
|
32
|
+
key === "properties" ? Object.fromEntries(Object.entries(value).map(([name, field]) => [name, expanded(contract, field, depth + 1)])) :
|
|
33
|
+
["items", "oneOf", "anyOf", "allOf", "additionalProperties", "not"].includes(key) && typeof value === "object" ? expanded(contract, value, depth + 1) : value]));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function methodSchema(contract, operation) {
|
|
37
|
+
const params = operation.parameters?.filter((parameter) => ["query", "path"].includes(parameter.in)) ?? [];
|
|
38
|
+
const headers = operation.parameters?.filter((parameter) => parameter.in === "header") ?? [];
|
|
39
|
+
const content = operation.requestBody?.content;
|
|
40
|
+
const encoding = content && Object.keys(content)[0];
|
|
41
|
+
const response = operation.responses["200"]?.content?.["application/json"]?.schema ?? operation.responses["201"]?.content?.["application/json"]?.schema;
|
|
42
|
+
const access = operation.security?.flatMap((item) => Object.keys(item)) ?? [];
|
|
43
|
+
return { name: operation.command.replaceAll(".", " "), description: localizeContract(operation, "description") ?? localizeContract(operation),
|
|
44
|
+
inputSchema: { type: "object", properties: {
|
|
45
|
+
...(params.length ? { params: { type: "object", carrier: "--params", properties: Object.fromEntries(params.map((parameter) => [parameter.name,
|
|
46
|
+
{ ...expanded(contract, parameter.schema), description: localizeContract(parameter, "description"), in: parameter.in, flag: parameterFlag(parameter.name) }])),
|
|
47
|
+
required: params.filter((parameter) => parameter.required).map((parameter) => parameter.name) } } : {}),
|
|
48
|
+
...(headers.length ? { headers: { type: "object", carrier: "--header", properties: Object.fromEntries(headers.map((parameter) => [parameter.name,
|
|
49
|
+
{ ...expanded(contract, parameter.schema), flag: parameterFlag(parameter.name) }])), required: headers.filter((parameter) => parameter.required).map((parameter) => parameter.name) } } : {}),
|
|
50
|
+
...(encoding ? encoding === "image/png" ? { file: { ...expanded(contract, content[encoding].schema), carrier: "--file", encoding } } :
|
|
51
|
+
{ data: { ...expanded(contract, content[encoding].schema), carrier: "--data", encoding } } : {}),
|
|
52
|
+
} }, outputSchema: response ? expanded(contract, response) : { type: "null" },
|
|
53
|
+
_meta: { envelope_version: "1.0", command: operation.command, operationId: operation.operationId, method: operation.method, path: operation.path,
|
|
54
|
+
access_tokens: access.includes("Bearer") ? ["user"] : access.includes("ClientSecretBasic") ? ["client"] : [],
|
|
55
|
+
required_scopes: operation["x-lime-required-scopes"] ?? (operation.path === "/oauth/userinfo" ? ["openid"] : []),
|
|
56
|
+
origin: operation["x-lime-cli-origin"], risk: operation.method === "GET" ? "read" : "write", doc_url: `https://open.limeai.run/docs/api?operation=${operation.operationId}` } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const parameterFlag = (name) => `--${name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").replaceAll("_", "-").toLowerCase()}`;
|
|
60
|
+
|
|
61
|
+
export function inspectSchema(contract, segments = []) {
|
|
62
|
+
const target = segments.join(".");
|
|
63
|
+
const methods = commandOperations(contract);
|
|
64
|
+
if (!target) return methods.map((operation) => methodSchema(contract, operation));
|
|
65
|
+
const operation = methods.find((item) => item.command === target || item.operationId === target);
|
|
66
|
+
if (operation) return methodSchema(contract, operation);
|
|
67
|
+
const matching = methods.filter((item) => item.command.startsWith(`${target}.`));
|
|
68
|
+
if (matching.length) return matching.map((item) => methodSchema(contract, item));
|
|
69
|
+
throw new CLIError("invalid_arguments", message("arguments.unknownSchema", { target }));
|
|
70
|
+
}
|
package/src/contract.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { message } from "./i18n.mjs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { CLIError } from "./protocol.mjs";
|
|
4
|
+
import { contractURL } from "./resources.mjs";
|
|
5
|
+
|
|
6
|
+
let contract;
|
|
7
|
+
async function load(name) {
|
|
8
|
+
return JSON.parse(await readFile(contractURL(name), "utf8"));
|
|
9
|
+
}
|
|
10
|
+
function namespace(value, prefix) {
|
|
11
|
+
if (Array.isArray(value)) return value.map((item) => namespace(item, prefix));
|
|
12
|
+
if (!value || typeof value !== "object") return value;
|
|
13
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key,
|
|
14
|
+
key === "$ref" && typeof item === "string" && item.startsWith("#/components/schemas/") ? item.replace("#/components/schemas/", `#/components/schemas/${prefix}`) : namespace(item, prefix)]));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function publicContract() {
|
|
18
|
+
if (contract) return contract;
|
|
19
|
+
const identity = await load("identity");
|
|
20
|
+
const management = await load("management");
|
|
21
|
+
contract = { ...identity, paths: {}, components: { ...identity.components, schemas: { ...identity.components.schemas }, securitySchemes: { ...identity.components.securitySchemes } } };
|
|
22
|
+
for (const [path, methods] of Object.entries(identity.paths)) {
|
|
23
|
+
contract.paths[path] = Object.fromEntries(Object.entries(methods).map(([method, operation]) => [method, { ...operation, "x-lime-cli-origin": "auth" }]));
|
|
24
|
+
}
|
|
25
|
+
if (management) {
|
|
26
|
+
if (management.info.version !== identity.info.version) throw new CLIError("invalid_contract", message("contract.versionMismatch"));
|
|
27
|
+
for (const [name, schema] of Object.entries(management.components?.schemas ?? {})) contract.components.schemas[`Management_${name}`] = namespace(schema, "Management_");
|
|
28
|
+
for (const [name, scheme] of Object.entries(management.components?.securitySchemes ?? {})) {
|
|
29
|
+
const existing = contract.components.securitySchemes[name];
|
|
30
|
+
if (existing && (existing.type !== scheme.type || existing.scheme !== scheme.scheme)) throw new CLIError("invalid_contract", message("contract.securityCollision"));
|
|
31
|
+
contract.components.securitySchemes[name] = scheme;
|
|
32
|
+
}
|
|
33
|
+
for (const [path, methods] of Object.entries(management.paths)) {
|
|
34
|
+
if (!/^\/api\/v1\/(open|account|operations|admin)(\/|$)/.test(path)) throw new CLIError("invalid_contract", message("contract.unexpectedPath"));
|
|
35
|
+
if (contract.paths[path]) throw new CLIError("invalid_contract", message("contract.duplicatePath"));
|
|
36
|
+
contract.paths[path] = Object.fromEntries(Object.entries(methods).map(([method, operation]) => [method,
|
|
37
|
+
{ ...namespace(operation, "Management_"), "x-lime-cli-origin": "api" }]));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const ids = operations(contract).map((operation) => operation.operationId);
|
|
41
|
+
if (new Set(ids).size !== ids.length) throw new CLIError("invalid_contract", message("contract.duplicateOperation"));
|
|
42
|
+
return contract;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function operations(contract) {
|
|
46
|
+
return Object.entries(contract.paths).flatMap(([path, methods]) => Object.entries(methods)
|
|
47
|
+
.filter(([method]) => ["get", "post", "put", "patch", "delete", "head", "options"].includes(method))
|
|
48
|
+
.map(([method, definition]) => ({ path, method: method.toUpperCase(), ...definition })));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveSchema(contract, schema) {
|
|
52
|
+
if (!schema?.$ref) return schema;
|
|
53
|
+
const prefix = "#/components/schemas/";
|
|
54
|
+
if (!schema.$ref.startsWith(prefix)) throw new CLIError("invalid_contract", message("contract.referenceUnsupported"));
|
|
55
|
+
const found = contract.components.schemas[schema.$ref.slice(prefix.length)];
|
|
56
|
+
if (!found) throw new CLIError("invalid_contract", message("contract.referenceUnknown"));
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function requestSchema(contract, operation, parameters) {
|
|
61
|
+
const input = operation.requestBody?.content["application/x-www-form-urlencoded"]?.schema;
|
|
62
|
+
const schema = resolveSchema(contract, input);
|
|
63
|
+
if (!schema?.oneOf) return schema;
|
|
64
|
+
const candidates = schema.oneOf.map((item) => resolveSchema(contract, item));
|
|
65
|
+
const matching = candidates.find((item) => item.properties?.grant_type?.const === parameters.get("grant_type"));
|
|
66
|
+
if (!matching) throw new CLIError("invalid_arguments", message("arguments.grantTypeRequired"));
|
|
67
|
+
return matching;
|
|
68
|
+
}
|
package/src/i18n.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createInstance } from "i18next";
|
|
4
|
+
import { localeRegistryURL } from "./resources.mjs";
|
|
5
|
+
|
|
6
|
+
// 构建时从唯一登记复制;外部语言输入从不参与资源文件路径。
|
|
7
|
+
export const localeRegistry = JSON.parse(readFileSync(localeRegistryURL, "utf8"));
|
|
8
|
+
export const defaultLocale = localeRegistry.defaultLocale;
|
|
9
|
+
export const localeOptions = localeRegistry.locales;
|
|
10
|
+
export const locales = localeOptions.map(({ code }) => code);
|
|
11
|
+
const resources = Object.fromEntries(locales.map(code => [code, {
|
|
12
|
+
cli: JSON.parse(readFileSync(new URL(`./locales/${code}.json`, import.meta.url), "utf8")),
|
|
13
|
+
}]));
|
|
14
|
+
const contexts = new AsyncLocalStorage();
|
|
15
|
+
|
|
16
|
+
export function normalizeLocale(input) {
|
|
17
|
+
if (typeof input !== "string" || input.length > 64) return undefined;
|
|
18
|
+
try {
|
|
19
|
+
const tag = Intl.getCanonicalLocales(input)[0];
|
|
20
|
+
return localeOptions.find(item => item.code === tag)?.code ??
|
|
21
|
+
localeOptions.find(item => item.aliases.includes(tag))?.code ??
|
|
22
|
+
localeOptions.find(item => item.prefixes.some(prefix => tag?.startsWith(prefix)))?.code;
|
|
23
|
+
} catch { return undefined; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function environmentLocale(env) {
|
|
27
|
+
// POSIX 的编码/修饰后缀不属于语言标签;只在环境变量入口转换。
|
|
28
|
+
for (const input of [env.LIMECLOUD_LOCALE, env.LC_ALL, env.LC_MESSAGES, env.LANG]) {
|
|
29
|
+
const locale = normalizeLocale(input?.split(/[.@]/)[0].replaceAll("_", "-"));
|
|
30
|
+
if (locale) return locale;
|
|
31
|
+
if (input) return defaultLocale;
|
|
32
|
+
}
|
|
33
|
+
return defaultLocale;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function requestedLocale(args, env = process.env) {
|
|
37
|
+
for (let index = 0; index < args.length; index++) {
|
|
38
|
+
if (args[index] === "--locale") return { explicit: args[index + 1], locale: normalizeLocale(args[index + 1]) ?? environmentLocale(env) };
|
|
39
|
+
if (args[index].startsWith("--locale=")) {
|
|
40
|
+
const explicit = args[index].slice(9);
|
|
41
|
+
return { explicit, locale: normalizeLocale(explicit) ?? environmentLocale(env) };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { locale: environmentLocale(env) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createLocalizer(locale = defaultLocale) {
|
|
48
|
+
const instance = createInstance();
|
|
49
|
+
void instance.init({ lng: normalizeLocale(locale) ?? defaultLocale, fallbackLng: defaultLocale,
|
|
50
|
+
supportedLngs: locales, load: "currentOnly", resources, ns: ["cli"], defaultNS: "cli",
|
|
51
|
+
interpolation: { escapeValue: false }, initImmediate: false, keySeparator: false });
|
|
52
|
+
return { locale: instance.resolvedLanguage ?? defaultLocale, t: (key, values = {}) => {
|
|
53
|
+
// 缺失消息安全回退,不能把原始翻译键展示给用户。
|
|
54
|
+
return instance.exists(key) ? instance.t(key, values) : instance.t("errors.failed");
|
|
55
|
+
} };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fallback = createLocalizer();
|
|
59
|
+
export const currentLocalizer = () => contexts.getStore() ?? fallback;
|
|
60
|
+
export const withLocalizer = (localizer, operation) => contexts.run(localizer, operation);
|
|
61
|
+
export const translate = (key, values) => currentLocalizer().t(key, values);
|
|
62
|
+
export const message = (key, values = {}) => ({ key, values });
|
|
63
|
+
|
|
64
|
+
export function localizeContract(value, field = "summary") {
|
|
65
|
+
const locale = currentLocalizer().locale;
|
|
66
|
+
// 新合同使用语言映射,旧合同扩展只作读取兼容。
|
|
67
|
+
return value?.["x-lime-localizations"]?.[locale]?.[field] ?? value?.[`x-lime-${field}-${locale}`] ?? value?.[field];
|
|
68
|
+
}
|