@limecloud/cli 0.3.0 → 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/README.md +66 -4
- package/bin/limecloud-cli.mjs +2 -0
- package/dist/identity.json +1 -1
- package/dist/locales.json +17 -0
- package/dist/management.json +1757 -184
- package/package.json +4 -1
- package/src/cli.mjs +110 -106
- package/src/commands.mjs +5 -4
- package/src/contract.mjs +11 -15
- package/src/i18n.mjs +68 -0
- package/src/input.mjs +20 -19
- 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 +29 -28
- package/src/output.mjs +12 -6
- package/src/protocol.mjs +18 -17
- package/src/resources.mjs +11 -0
- package/src/storage.mjs +8 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@limecloud/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Lime Cloud CLI for public identity and delegated platform APIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -31,5 +31,8 @@
|
|
|
31
31
|
"publishConfig": {
|
|
32
32
|
"access": "public",
|
|
33
33
|
"registry": "https://registry.npmjs.org"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"i18next": "25.8.0"
|
|
34
37
|
}
|
|
35
38
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createLocalizer, currentLocalizer, localizeContract, locales, message, normalizeLocale, requestedLocale, translate, withLocalizer } from "./i18n.mjs";
|
|
1
2
|
import { setTimeout } from "node:timers/promises";
|
|
2
3
|
import { publicContract, resolveSchema } from "./contract.mjs";
|
|
3
4
|
import { commandOperations, inspectSchema } from "./commands.mjs";
|
|
@@ -6,56 +7,14 @@ import { CLIError, issuerOrigin, pollDevice, protocolRequest, startDevice, token
|
|
|
6
7
|
import { parametersFor, scopeList, validateParameters } from "./input.mjs";
|
|
7
8
|
import { errorEnvelope, outputOptions, success } from "./output.mjs";
|
|
8
9
|
import { flagName, matchHTTP, prepareManagement } from "./management.mjs";
|
|
10
|
+
import { configureInitialSettings, discoverCLISettings, initialSettings, installationPlan, installRuntime, withSetupPrompt } from "./install.mjs";
|
|
9
11
|
|
|
10
12
|
export { validateParameters } from "./input.mjs";
|
|
11
13
|
|
|
12
|
-
const help =
|
|
14
|
+
const help = () => ["help.overview", "help.apiFlags", "help.connectionFlags"].map(key => translate(key)).join("");
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
limecloud-cli identity +login | +whoami
|
|
17
|
-
limecloud-cli identity token exchange --data <json>
|
|
18
|
-
limecloud-cli api <METHOD> <contract-path> [--params <json>] [--data <json>]
|
|
19
|
-
limecloud-cli schema [service.resource.method | service resource method]
|
|
20
|
-
|
|
21
|
-
Identity:
|
|
22
|
-
identity Public OAuth / OpenID Connect operations and +shortcuts
|
|
23
|
-
|
|
24
|
-
CLI management:
|
|
25
|
-
config init | show | set: local public-client configuration
|
|
26
|
-
auth login | status | whoami | refresh | logout | scopes | check
|
|
27
|
-
whoami Show the local effective identity and token state
|
|
28
|
-
doctor Check local configuration and contract (--offline; no network)
|
|
29
|
-
docs Print a documentation URL
|
|
30
|
-
|
|
31
|
-
API tools:
|
|
32
|
-
api Call a contract HTTP path; list/schema/call also accept operationId
|
|
33
|
-
schema Inspect public methods, parameters, responses and authentication
|
|
34
|
-
|
|
35
|
-
API flags:
|
|
36
|
-
--params JSON Query JSON; supports @file or - for stdin
|
|
37
|
-
--data JSON OAuth form input as JSON; supports @file or - for stdin
|
|
38
|
-
--dry-run Print a redacted request without executing or changing state
|
|
39
|
-
--format FORMAT json (default), ndjson, table or csv
|
|
40
|
-
--fields PATHS Comma-separated data field paths
|
|
41
|
-
--json Shorthand for --format json
|
|
42
|
-
--param name=value Repeat a non-sensitive protocol parameter
|
|
43
|
-
--param-env name=ENV Read a sensitive protocol parameter from an environment variable
|
|
44
|
-
--header name=value Set a contract-declared HTTP header
|
|
45
|
-
--file PATH Binary body for image/png methods; - reads stdin
|
|
46
|
-
--show-secrets Explicitly include API response credentials in stdout
|
|
47
|
-
|
|
48
|
-
Connection flags:
|
|
49
|
-
--issuer ORIGIN Auth origin (HTTPS; HTTP only on loopback)
|
|
50
|
-
--api-origin ORIGIN Explicit business API origin, bound to the saved login
|
|
51
|
-
--client-id ID Already registered sign-in client ID
|
|
52
|
-
--auth-client-id ID Override the OAuth client context for a business API call
|
|
53
|
-
|
|
54
|
-
Run limecloud-cli <command> --help or schema identity before calling a method.
|
|
55
|
-
`;
|
|
56
|
-
|
|
57
|
-
const booleanFlags = new Set(["help", "version", "json", "show-secrets", "local-only", "dry-run", "no-wait", "resume", "offline"]);
|
|
58
|
-
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"]);
|
|
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"]);
|
|
59
18
|
const fail = (message) => { throw new CLIError("invalid_arguments", message); };
|
|
60
19
|
|
|
61
20
|
function fieldNames(contract, operation) {
|
|
@@ -71,31 +30,31 @@ export function parseArguments(args, protocolFields = []) {
|
|
|
71
30
|
const item = args[index] === "-h" ? "--help" : args[index] === "-v" ? "--version" : args[index];
|
|
72
31
|
if (!item.startsWith("--")) { positionals.push(item); continue; }
|
|
73
32
|
const [name, ...rest] = item.slice(2).split("=");
|
|
74
|
-
if (!booleanFlags.has(name) && !valueFlags.has(name) && !dynamic.has(name)) fail(
|
|
33
|
+
if (!booleanFlags.has(name) && !valueFlags.has(name) && !dynamic.has(name)) fail(message("arguments.unknownOption", { name }));
|
|
75
34
|
let value = true;
|
|
76
|
-
if (booleanFlags.has(name) && rest.length) fail(
|
|
35
|
+
if (booleanFlags.has(name) && rest.length) fail(message("arguments.booleanOptionValue", { name }));
|
|
77
36
|
if (!booleanFlags.has(name)) {
|
|
78
37
|
value = rest.length ? rest.join("=") : args[++index];
|
|
79
|
-
if (typeof value !== "string" || !value || value.startsWith("--")) fail(
|
|
38
|
+
if (typeof value !== "string" || !value || value.startsWith("--")) fail(message("arguments.optionValueRequired", { name }));
|
|
80
39
|
}
|
|
81
|
-
if (flags.has(name) && !["param", "param-env", "audience", "header"].includes(name)) fail(
|
|
40
|
+
if (flags.has(name) && !["param", "param-env", "audience", "header"].includes(name)) fail(message("arguments.duplicateOption", { name }));
|
|
82
41
|
flags.set(name, [...(flags.get(name) ?? []), value]);
|
|
83
42
|
}
|
|
84
43
|
return { positionals, flags, get: (name) => flags.get(name)?.[0] };
|
|
85
44
|
}
|
|
86
45
|
|
|
87
46
|
function allowedFlags(parsed, names) {
|
|
88
|
-
for (const key of parsed.flags.keys()) if (!["help", "json", ...names].includes(key)) fail(
|
|
47
|
+
for (const key of parsed.flags.keys()) if (!["help", "json", "locale", ...names].includes(key)) fail(message("arguments.optionUnsupported", { name: key }));
|
|
89
48
|
}
|
|
90
49
|
|
|
91
50
|
function commandHelp(command, contract) {
|
|
92
|
-
const serviceMethods = commandOperations(contract).filter(
|
|
93
|
-
if (serviceMethods.length) return
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
if (command === "api") return
|
|
98
|
-
return
|
|
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(", ") });
|
|
99
58
|
}
|
|
100
59
|
|
|
101
60
|
async function contextFor(parsed, env, settings, saved, management = false) {
|
|
@@ -108,8 +67,8 @@ async function contextFor(parsed, env, settings, saved, management = false) {
|
|
|
108
67
|
}
|
|
109
68
|
|
|
110
69
|
function usableAccess(context, env, now) {
|
|
111
|
-
if (!context.accessToken) throw new CLIError("login_required", "
|
|
112
|
-
if (!env.LIMECLOUD_ACCESS_TOKEN && Date.parse(context.saved.expires_at) <= now()) throw new CLIError("token_expired", "
|
|
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"));
|
|
113
72
|
return context.accessToken;
|
|
114
73
|
}
|
|
115
74
|
|
|
@@ -126,20 +85,29 @@ function preview(operation, context, form, authentication) {
|
|
|
126
85
|
const body = {};
|
|
127
86
|
for (const key of new Set(form.keys())) body[key] = form.getAll(key).length > 1 ? form.getAll(key) : form.get(key);
|
|
128
87
|
return { dry_run: true, command: operation.command, operationId: operation.operationId, request: { method: operation.method,
|
|
129
|
-
url: `${context.issuer}${operation.path}`, headers: { Accept: "application/json", ...(authentication === "user" ? { Authorization: "Bearer [REDACTED]" } :
|
|
88
|
+
url: `${context.issuer}${operation.path}`, headers: { Accept: "application/json", "Accept-Language": currentLocalizer().locale, ...(authentication === "user" ? { Authorization: "Bearer [REDACTED]" } :
|
|
130
89
|
authentication === "client" ? { Authorization: "Basic [REDACTED]" } : {}), ...(operation.method === "POST" ? { "Content-Type": "application/x-www-form-urlencoded" } : {}) },
|
|
131
90
|
...(operation.method === "GET" ? { params: body } : { data: body }) } };
|
|
132
91
|
}
|
|
133
92
|
|
|
134
|
-
export async function execute(args,
|
|
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)),
|
|
135
102
|
settingsStore = new SettingsStore(settingsPath(env)), pendingStore = new PrivateJSONStore(`${store.path ?? credentialsPath(env)}.pending.json`),
|
|
136
|
-
request: requestImpl = protocolRequest, wait = setTimeout, now = Date.now, signal, readInput, readBinary, contract: suppliedContract
|
|
103
|
+
request: requestImpl = protocolRequest, wait = setTimeout, now = Date.now, signal, readInput, readBinary, contract: suppliedContract,
|
|
104
|
+
installer = installRuntime, prompt } = {}) {
|
|
137
105
|
const contract = suppliedContract ?? await publicContract();
|
|
138
106
|
const catalogue = commandOperations(contract);
|
|
139
107
|
const parsed = parseArguments(args, catalogue.flatMap((operation) => fieldNames(contract, operation)));
|
|
140
108
|
let [command, action, target, ...extra] = parsed.positionals;
|
|
141
109
|
const get = parsed.get;
|
|
142
|
-
const request = (input) => requestImpl({ ...input, signal });
|
|
110
|
+
const request = (input) => requestImpl({ ...input, extraHeaders: { ...input.extraHeaders, "Accept-Language": currentLocalizer().locale }, signal });
|
|
143
111
|
const pause = (ms) => wait(ms, undefined, signal ? { signal } : undefined);
|
|
144
112
|
const output = (value, options = {}) => success(io, value, { ...outputOptions(parsed), ...options });
|
|
145
113
|
if (get("help") || !args.length) { io.stdout.write(commandHelp(command, contract)); return; }
|
|
@@ -153,21 +121,57 @@ export async function execute(args, { env = process.env, io = process, store = n
|
|
|
153
121
|
}
|
|
154
122
|
if (command === "docs") {
|
|
155
123
|
allowedFlags(parsed, []);
|
|
156
|
-
if (target || extra.length || action && !/^[a-z0-9-]{1,80}$/.test(action)) fail("
|
|
124
|
+
if (target || extra.length || action && !/^[a-z0-9-]{1,80}$/.test(action)) fail(message("arguments.docsUsage"));
|
|
157
125
|
output({ url: `https://open.limeai.run/docs${action ? `/${action}` : ""}` }); return;
|
|
158
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
|
+
}
|
|
159
169
|
if (command === "config") {
|
|
160
170
|
allowedFlags(parsed, action === "init" ? ["issuer", "api-origin", "client-id", "scope"] : []);
|
|
161
171
|
if (action === "show" && !target) { output({ settings: await settingsStore.read(), path: settingsStore.path ?? null }, { identity: "local" }); return; }
|
|
162
172
|
if (action === "init" && !target) {
|
|
163
|
-
await
|
|
164
|
-
const
|
|
165
|
-
const apiOrigin = get("api-origin") ?? env.LIMECLOUD_API_ORIGIN ?? old.api_origin;
|
|
166
|
-
const settings = validateSettings({ issuer: get("issuer") ?? env.LIMECLOUD_ISSUER ?? old.issuer ?? "https://auth.limeai.run",
|
|
167
|
-
...(apiOrigin ? { api_origin: apiOrigin } : {}), client_id: get("client-id") ?? env.LIMECLOUD_CLIENT_ID ?? old.client_id,
|
|
168
|
-
scope: get("scope") ?? old.scope ?? "openid profile email offline_access" });
|
|
169
|
-
if (!settings.client_id) fail("Provide the already registered public client with --client-id");
|
|
170
|
-
await settingsStore.write(settings);
|
|
173
|
+
await withSetupPrompt({ io, prompt, signal, interactive: !get("json") }, async ask => {
|
|
174
|
+
const settings = await configureInitialSettings(settingsStore, parsed, env, ask);
|
|
171
175
|
output({ settings, path: settingsStore.path ?? null }, { identity: "local" });
|
|
172
176
|
}); return;
|
|
173
177
|
}
|
|
@@ -177,9 +181,9 @@ export async function execute(args, { env = process.env, io = process, store = n
|
|
|
177
181
|
await settingsStore.write(settings); output({ settings }, { identity: "local" });
|
|
178
182
|
}); return;
|
|
179
183
|
}
|
|
180
|
-
fail("
|
|
184
|
+
fail(message("arguments.configUsage"));
|
|
181
185
|
}
|
|
182
|
-
if (extra.length) fail("
|
|
186
|
+
if (extra.length) fail(message("arguments.tooMany"));
|
|
183
187
|
let operation = catalogue.find((item) => item.command === [command, action, target].join("."));
|
|
184
188
|
if (command === "api" && action === "call") operation = catalogue.find((item) => item.operationId === target);
|
|
185
189
|
const raw = command === "api" ? matchHTTP(catalogue, action, target) : null;
|
|
@@ -189,7 +193,7 @@ export async function execute(args, { env = process.env, io = process, store = n
|
|
|
189
193
|
const context = await contextFor(parsed, env, settings, await store.read(), management);
|
|
190
194
|
if (["whoami", "doctor"].includes(command)) {
|
|
191
195
|
allowedFlags(parsed, ["issuer", "api-origin", "client-id", "auth-client-id", ...(command === "doctor" ? ["offline"] : [])]);
|
|
192
|
-
if (action) fail(
|
|
196
|
+
if (action) fail(message("arguments.noPositionals", { command }));
|
|
193
197
|
const state = localIdentity(context, env, now);
|
|
194
198
|
output(command === "whoami" ? state : { offline: true, checks: { public_contract: true, version: contract.info.version,
|
|
195
199
|
issuer: context.issuer, configured_client: Boolean(context.clientID), credentials_readable: true, token_unexpired: state.authenticated },
|
|
@@ -197,16 +201,15 @@ export async function execute(args, { env = process.env, io = process, store = n
|
|
|
197
201
|
}
|
|
198
202
|
if (command === "api" && action === "list") {
|
|
199
203
|
allowedFlags(parsed, ["locale", "format", "fields"]);
|
|
200
|
-
const locale =
|
|
201
|
-
if (!["en", "zh-CN"].includes(locale)) fail("--locale must be en or zh-CN");
|
|
204
|
+
const locale = currentLocalizer().locale;
|
|
202
205
|
const needle = target?.toLocaleLowerCase(locale) ?? "";
|
|
203
|
-
output(catalogue.filter((operation) => `${operation.operationId} ${operation.path} ${operation.summary} ${operation
|
|
206
|
+
output(catalogue.filter((operation) => `${operation.operationId} ${operation.path} ${operation.summary} ${localizeContract(operation)}`.toLocaleLowerCase(locale).includes(needle))
|
|
204
207
|
.map((operation) => ({ command: operation.command, operationId: operation.operationId, method: operation.method, path: operation.path,
|
|
205
|
-
summary:
|
|
208
|
+
summary: localizeContract(operation) }))); return;
|
|
206
209
|
}
|
|
207
210
|
if (command === "api" && action === "schema") {
|
|
208
211
|
allowedFlags(parsed, []);
|
|
209
|
-
if (!catalogue.some((operation) => operation.operationId === target)) fail("
|
|
212
|
+
if (!catalogue.some((operation) => operation.operationId === target)) fail(message("arguments.operationRequired"));
|
|
210
213
|
output(inspectSchema(contract, [target])); return;
|
|
211
214
|
}
|
|
212
215
|
if (operation) {
|
|
@@ -217,15 +220,15 @@ export async function execute(args, { env = process.env, io = process, store = n
|
|
|
217
220
|
if (get("dry-run")) {
|
|
218
221
|
output({ dry_run: true, command: operation.command, operationId: operation.operationId, request: { method: operation.method,
|
|
219
222
|
url: `${context.apiOrigin ?? "<api-origin>"}${input.path}`, params: Object.fromEntries(input.query),
|
|
220
|
-
headers: { ...input.extraHeaders, Authorization: "Bearer [REDACTED]", ...(input.binaryType ? { "Content-Type": input.binaryType } : input.jsonBody === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
223
|
+
headers: { ...input.extraHeaders, "Accept-Language": currentLocalizer().locale, Authorization: "Bearer [REDACTED]", ...(input.binaryType ? { "Content-Type": input.binaryType } : input.jsonBody === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
221
224
|
...(input.binarySummary ? { file: input.binarySummary } : input.jsonBody === undefined ? {} : { data: input.jsonBody }) } }, { identity: "user" }); return;
|
|
222
225
|
}
|
|
223
|
-
if (!context.apiOrigin) throw new CLIError("api_origin_required", "
|
|
226
|
+
if (!context.apiOrigin) throw new CLIError("api_origin_required", message("auth.apiOriginRequired"));
|
|
224
227
|
const token = usableAccess(context, env, now);
|
|
225
|
-
if (!env.LIMECLOUD_ACCESS_TOKEN && context.saved.api_origin !== context.apiOrigin) throw new CLIError("login_required", "
|
|
228
|
+
if (!env.LIMECLOUD_ACCESS_TOKEN && context.saved.api_origin !== context.apiOrigin) throw new CLIError("login_required", message("auth.apiBindingChanged"));
|
|
226
229
|
const granted = new Set(context.saved?.scope.split(/\s+/) ?? []);
|
|
227
230
|
const required = operation["x-lime-required-scopes"] ?? [];
|
|
228
|
-
if (!env.LIMECLOUD_ACCESS_TOKEN && required.some((scope) => !granted.has(scope))) throw new CLIError("missing_scopes",
|
|
231
|
+
if (!env.LIMECLOUD_ACCESS_TOKEN && required.some((scope) => !granted.has(scope))) throw new CLIError("missing_scopes", message("auth.requiredScopes", { scopes: required.join(" ") }));
|
|
229
232
|
const result = await request({ issuer: context.issuer, origin: context.apiOrigin, method: operation.method, ...input, accessToken: token });
|
|
230
233
|
const response = resolveSchema(contract, operation.responses["200"]?.content?.["application/json"]?.schema ?? operation.responses["201"]?.content?.["application/json"]?.schema);
|
|
231
234
|
output(response?.properties?.data && result && Object.hasOwn(result, "data") ? result.data : result ?? { accepted: true }, { identity: "user", raw: Boolean(get("show-secrets")) }); return;
|
|
@@ -241,7 +244,7 @@ export async function execute(args, { env = process.env, io = process, store = n
|
|
|
241
244
|
const basic = Boolean(operation.security?.some((item) => Object.hasOwn(item, "ClientSecretBasic")) && env.LIMECLOUD_CLIENT_SECRET);
|
|
242
245
|
const bearer = operation.security?.some((item) => Object.hasOwn(item, "Bearer"));
|
|
243
246
|
const requiresBasic = operation.path === "/oauth/introspect" || operation.path === "/oauth/token" && form.get("grant_type") === "client_credentials";
|
|
244
|
-
if (!get("dry-run") && (requiresBasic && !basic || basic && !context.clientID)) fail("
|
|
247
|
+
if (!get("dry-run") && (requiresBasic && !basic || basic && !context.clientID)) fail(message("arguments.basicCredentialsRequired"));
|
|
245
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);
|
|
246
249
|
validateParameters(contract, operation, form, basic || requiresBasic, Boolean(get("dry-run")));
|
|
247
250
|
const identity = basic || requiresBasic ? "client" : bearer ? "user" : "public";
|
|
@@ -251,50 +254,50 @@ export async function execute(args, { env = process.env, io = process, store = n
|
|
|
251
254
|
accessToken: bearer ? usableAccess(context, env, now) : undefined });
|
|
252
255
|
output(result ?? { accepted: true }, { identity, raw: Boolean(get("show-secrets")) }); return;
|
|
253
256
|
}
|
|
254
|
-
if (command !== "auth" || !["login", "status", "whoami", "refresh", "logout", "scopes", "check"].includes(action) || target) fail("
|
|
257
|
+
if (command !== "auth" || !["login", "status", "whoami", "refresh", "logout", "scopes", "check"].includes(action) || target) fail(message("arguments.unknownCommand"));
|
|
255
258
|
allowedFlags(parsed, ["issuer", "api-origin", "client-id", "auth-client-id", ...(action === "login" ? ["scope", "installation-id", "space-id", "no-wait", "resume", "dry-run"] : []),
|
|
256
259
|
...(["refresh", "logout", "scopes"].includes(action) ? ["dry-run"] : []), ...(action === "logout" ? ["local-only"] : []), ...(action === "check" ? ["scope"] : [])]);
|
|
257
260
|
if (action === "status") { output(localIdentity(context, env, now), { identity: "user" }); return; }
|
|
258
261
|
if (action === "whoami") { output(await request({ issuer: context.issuer, path: "/oauth/userinfo", accessToken: usableAccess(context, env, now) }), { identity: "user" }); return; }
|
|
259
262
|
if (action === "check") {
|
|
260
|
-
if (!get("scope")) fail("
|
|
263
|
+
if (!get("scope")) fail(message("arguments.scopeCheckUsage"));
|
|
261
264
|
const required = scopeList(get("scope"));
|
|
262
265
|
const granted = new Set((context.saved?.scope ?? "").split(/\s+/));
|
|
263
266
|
const missing = required.filter((scope) => !granted.has(scope));
|
|
264
267
|
usableAccess(context, env, now);
|
|
265
|
-
if (env.LIMECLOUD_ACCESS_TOKEN || missing.length) throw new CLIError("missing_scopes", env.LIMECLOUD_ACCESS_TOKEN ? "
|
|
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(", ") }));
|
|
266
269
|
output({ checked: true, source: "local_grant", scopes: required }, { identity: "user" }); return;
|
|
267
270
|
}
|
|
268
271
|
if (action === "scopes") {
|
|
269
272
|
const method = catalogue.find((item) => item.operationId === "identity_discovery");
|
|
270
273
|
if (get("dry-run")) { output(preview(method, context, new URLSearchParams(), "public")); return; }
|
|
271
274
|
const metadata = await request({ issuer: context.issuer, path: method.path });
|
|
272
|
-
if (metadata?.issuer !== context.issuer || !Array.isArray(metadata.scopes_supported) || metadata.scopes_supported.some((scope) => typeof scope !== "string")) throw new CLIError("invalid_response", "
|
|
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"));
|
|
273
276
|
output({ supported_scopes: metadata.scopes_supported, granted_scopes: context.saved?.scope.split(/\s+/).filter(Boolean) ?? [], source: "oidc_discovery",
|
|
274
277
|
client_enabled_scopes: null }); return;
|
|
275
278
|
}
|
|
276
|
-
if (["login", "refresh", "logout"].includes(action) && env.LIMECLOUD_CLIENT_SECRET) fail("
|
|
277
|
-
if (["login", "refresh", "logout"].includes(action) && env.LIMECLOUD_ACCESS_TOKEN) fail("
|
|
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"));
|
|
278
281
|
async function auth() {
|
|
279
282
|
const current = await contextFor(parsed, env, settings, await store.read());
|
|
280
283
|
if (action === "login") {
|
|
281
|
-
if (get("no-wait") && get("resume")) fail("
|
|
282
|
-
if (get("resume") && ["scope", "installation-id", "space-id"].some((name) => get(name))) fail("
|
|
283
|
-
if (Boolean(get("installation-id")) !== Boolean(get("space-id"))) fail("
|
|
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"));
|
|
284
287
|
let pending;
|
|
285
288
|
if (get("resume")) {
|
|
286
289
|
pending = await pendingStore.read();
|
|
287
|
-
if (!pending?.device_code) throw new CLIError("login_required", "
|
|
290
|
+
if (!pending?.device_code) throw new CLIError("login_required", message("auth.pendingMissing"));
|
|
288
291
|
issuerOrigin(pending.issuer);
|
|
289
292
|
verificationURI(pending.verification_uri, pending.issuer);
|
|
290
|
-
if (!/^[A-Z2-7]{4}-[A-Z2-7]{4}$/.test(pending.user_code)) throw new CLIError("invalid_credentials", "
|
|
293
|
+
if (!/^[A-Z2-7]{4}-[A-Z2-7]{4}$/.test(pending.user_code)) throw new CLIError("invalid_credentials", message("auth.pendingCodeInvalid"));
|
|
291
294
|
if ((get("issuer") ?? env.LIMECLOUD_ISSUER ?? pending.issuer) !== pending.issuer ||
|
|
292
|
-
(get("client-id") ?? env.LIMECLOUD_CLIENT_ID ?? pending.client_id) !== pending.client_id) fail("
|
|
293
|
-
if ((get("api-origin") ?? env.LIMECLOUD_API_ORIGIN ?? settings.api_origin ?? pending.api_origin) !== pending.api_origin) fail("
|
|
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"));
|
|
294
297
|
} else {
|
|
295
|
-
if (!current.clientID && !get("dry-run")) fail("
|
|
298
|
+
if (!current.clientID && !get("dry-run")) fail(message("arguments.registeredClientRequired"));
|
|
296
299
|
const scopes = scopeList(get("scope") ?? settings.scope ?? "openid profile email offline_access");
|
|
297
|
-
if (!scopes.includes("openid")) fail("
|
|
300
|
+
if (!scopes.includes("openid")) fail(message("arguments.openidRequired"));
|
|
298
301
|
const form = new URLSearchParams({ scope: scopes.join(" ") });
|
|
299
302
|
if (get("installation-id")) { form.set("installation_id", get("installation-id")); form.set("space_id", get("space-id")); }
|
|
300
303
|
if (get("dry-run")) {
|
|
@@ -312,7 +315,7 @@ export async function execute(args, { env = process.env, io = process, store = n
|
|
|
312
315
|
const notice = { authorization_pending: true, verification_uri: pending.verification_uri, user_code: pending.user_code,
|
|
313
316
|
expires_at: pending.expires_at, resume_command: "limecloud-cli auth login --resume" };
|
|
314
317
|
if (get("no-wait")) { output(notice); return; }
|
|
315
|
-
io.stderr.write(
|
|
318
|
+
io.stderr.write(translate("auth.deviceNotice", { uri: notice.verification_uri, code: notice.user_code }));
|
|
316
319
|
const saved = await pollDevice({ pending, request, wait: pause, now, persist: (value) => pendingStore.write(value) });
|
|
317
320
|
await store.write(saved);
|
|
318
321
|
await pendingStore.write({});
|
|
@@ -323,9 +326,9 @@ export async function execute(args, { env = process.env, io = process, store = n
|
|
|
323
326
|
output({ authenticated: false }, { identity: "user" }); return;
|
|
324
327
|
}
|
|
325
328
|
if (action === "refresh") {
|
|
326
|
-
if (current.saved.refresh_pending) throw new CLIError("login_required", "
|
|
327
|
-
if (!current.saved.refresh_token) throw new CLIError("login_required", "
|
|
328
|
-
if (current.apiOrigin && current.saved.api_origin !== current.apiOrigin) throw new CLIError("login_required", "
|
|
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"));
|
|
329
332
|
const form = new URLSearchParams({ client_id: current.clientID, grant_type: "refresh_token", refresh_token: current.saved.refresh_token });
|
|
330
333
|
if (get("dry-run")) { output(preview(catalogue.find((item) => item.operationId === "identity_token"), current, form, "public")); return; }
|
|
331
334
|
await store.write({ ...current.saved, refresh_pending: true });
|
|
@@ -356,7 +359,8 @@ export async function run(args, options = {}) {
|
|
|
356
359
|
process.once("SIGINT", interrupt); process.once("SIGTERM", terminate);
|
|
357
360
|
try { await execute(args, { ...options, signal: controller.signal }); }
|
|
358
361
|
catch (error) {
|
|
359
|
-
(options.
|
|
362
|
+
const localizer = createLocalizer(requestedLocale(args, options.env ?? process.env).locale);
|
|
363
|
+
(options.io ?? process).stderr.write(`${JSON.stringify(errorEnvelope(error, cancelled, localizer))}\n`);
|
|
360
364
|
process.exitCode = cancelled === "SIGINT" ? 130 : cancelled === "SIGTERM" ? 143 : error.code === "invalid_arguments" || error.code === "invalid_issuer" ? 2 : 1;
|
|
361
365
|
} finally { process.removeListener("SIGINT", interrupt); process.removeListener("SIGTERM", terminate); }
|
|
362
366
|
}
|
package/src/commands.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { localizeContract, message } from "./i18n.mjs";
|
|
1
2
|
import { operations, resolveSchema } from "./contract.mjs";
|
|
2
3
|
import { CLIError } from "./protocol.mjs";
|
|
3
4
|
|
|
@@ -17,7 +18,7 @@ const identityCommands = {
|
|
|
17
18
|
export function commandOperations(contract) {
|
|
18
19
|
return operations(contract).map((operation) => {
|
|
19
20
|
const command = operation["x-lime-cli"] ?? identityCommands[operation.operationId];
|
|
20
|
-
if (!command || !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){2}$/.test(command)) throw new CLIError("invalid_contract", "
|
|
21
|
+
if (!command || !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){2}$/.test(command)) throw new CLIError("invalid_contract", message("contract.commandMappingRequired"));
|
|
21
22
|
return { ...operation, command };
|
|
22
23
|
});
|
|
23
24
|
}
|
|
@@ -39,10 +40,10 @@ function methodSchema(contract, operation) {
|
|
|
39
40
|
const encoding = content && Object.keys(content)[0];
|
|
40
41
|
const response = operation.responses["200"]?.content?.["application/json"]?.schema ?? operation.responses["201"]?.content?.["application/json"]?.schema;
|
|
41
42
|
const access = operation.security?.flatMap((item) => Object.keys(item)) ?? [];
|
|
42
|
-
return { name: operation.command.replaceAll(".", " "), description: operation
|
|
43
|
+
return { name: operation.command.replaceAll(".", " "), description: localizeContract(operation, "description") ?? localizeContract(operation),
|
|
43
44
|
inputSchema: { type: "object", properties: {
|
|
44
45
|
...(params.length ? { params: { type: "object", carrier: "--params", properties: Object.fromEntries(params.map((parameter) => [parameter.name,
|
|
45
|
-
{ ...expanded(contract, parameter.schema), description: parameter
|
|
46
|
+
{ ...expanded(contract, parameter.schema), description: localizeContract(parameter, "description"), in: parameter.in, flag: parameterFlag(parameter.name) }])),
|
|
46
47
|
required: params.filter((parameter) => parameter.required).map((parameter) => parameter.name) } } : {}),
|
|
47
48
|
...(headers.length ? { headers: { type: "object", carrier: "--header", properties: Object.fromEntries(headers.map((parameter) => [parameter.name,
|
|
48
49
|
{ ...expanded(contract, parameter.schema), flag: parameterFlag(parameter.name) }])), required: headers.filter((parameter) => parameter.required).map((parameter) => parameter.name) } } : {}),
|
|
@@ -65,5 +66,5 @@ export function inspectSchema(contract, segments = []) {
|
|
|
65
66
|
if (operation) return methodSchema(contract, operation);
|
|
66
67
|
const matching = methods.filter((item) => item.command.startsWith(`${target}.`));
|
|
67
68
|
if (matching.length) return matching.map((item) => methodSchema(contract, item));
|
|
68
|
-
throw new CLIError("invalid_arguments",
|
|
69
|
+
throw new CLIError("invalid_arguments", message("arguments.unknownSchema", { target }));
|
|
69
70
|
}
|
package/src/contract.mjs
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
|
+
import { message } from "./i18n.mjs";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
3
|
import { CLIError } from "./protocol.mjs";
|
|
4
|
+
import { contractURL } from "./resources.mjs";
|
|
3
5
|
|
|
4
6
|
let contract;
|
|
5
|
-
let workspaceSource;
|
|
6
7
|
async function load(name) {
|
|
7
|
-
|
|
8
|
-
try { workspaceSource = JSON.parse(await readFile(new URL("../../../package.json", import.meta.url), "utf8")).name === "lime-cloud"; }
|
|
9
|
-
catch (error) { if (error.code !== "ENOENT") throw error; workspaceSource = false; }
|
|
10
|
-
}
|
|
11
|
-
const path = workspaceSource ? `../../../contracts/openapi/${name}.json` : `../dist/${name}.json`;
|
|
12
|
-
return JSON.parse(await readFile(new URL(path, import.meta.url), "utf8"));
|
|
8
|
+
return JSON.parse(await readFile(contractURL(name), "utf8"));
|
|
13
9
|
}
|
|
14
10
|
function namespace(value, prefix) {
|
|
15
11
|
if (Array.isArray(value)) return value.map((item) => namespace(item, prefix));
|
|
@@ -27,22 +23,22 @@ export async function publicContract() {
|
|
|
27
23
|
contract.paths[path] = Object.fromEntries(Object.entries(methods).map(([method, operation]) => [method, { ...operation, "x-lime-cli-origin": "auth" }]));
|
|
28
24
|
}
|
|
29
25
|
if (management) {
|
|
30
|
-
if (management.info.version !== identity.info.version) throw new CLIError("invalid_contract", "
|
|
26
|
+
if (management.info.version !== identity.info.version) throw new CLIError("invalid_contract", message("contract.versionMismatch"));
|
|
31
27
|
for (const [name, schema] of Object.entries(management.components?.schemas ?? {})) contract.components.schemas[`Management_${name}`] = namespace(schema, "Management_");
|
|
32
28
|
for (const [name, scheme] of Object.entries(management.components?.securitySchemes ?? {})) {
|
|
33
29
|
const existing = contract.components.securitySchemes[name];
|
|
34
|
-
if (existing && (existing.type !== scheme.type || existing.scheme !== scheme.scheme)) throw new CLIError("invalid_contract", "
|
|
30
|
+
if (existing && (existing.type !== scheme.type || existing.scheme !== scheme.scheme)) throw new CLIError("invalid_contract", message("contract.securityCollision"));
|
|
35
31
|
contract.components.securitySchemes[name] = scheme;
|
|
36
32
|
}
|
|
37
33
|
for (const [path, methods] of Object.entries(management.paths)) {
|
|
38
|
-
if (!/^\/api\/v1\/(open|account|operations|admin)(\/|$)/.test(path)) throw new CLIError("invalid_contract", "
|
|
39
|
-
if (contract.paths[path]) throw new CLIError("invalid_contract", "
|
|
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"));
|
|
40
36
|
contract.paths[path] = Object.fromEntries(Object.entries(methods).map(([method, operation]) => [method,
|
|
41
37
|
{ ...namespace(operation, "Management_"), "x-lime-cli-origin": "api" }]));
|
|
42
38
|
}
|
|
43
39
|
}
|
|
44
40
|
const ids = operations(contract).map((operation) => operation.operationId);
|
|
45
|
-
if (new Set(ids).size !== ids.length) throw new CLIError("invalid_contract", "
|
|
41
|
+
if (new Set(ids).size !== ids.length) throw new CLIError("invalid_contract", message("contract.duplicateOperation"));
|
|
46
42
|
return contract;
|
|
47
43
|
}
|
|
48
44
|
|
|
@@ -55,9 +51,9 @@ export function operations(contract) {
|
|
|
55
51
|
export function resolveSchema(contract, schema) {
|
|
56
52
|
if (!schema?.$ref) return schema;
|
|
57
53
|
const prefix = "#/components/schemas/";
|
|
58
|
-
if (!schema.$ref.startsWith(prefix)) throw new CLIError("invalid_contract", "
|
|
54
|
+
if (!schema.$ref.startsWith(prefix)) throw new CLIError("invalid_contract", message("contract.referenceUnsupported"));
|
|
59
55
|
const found = contract.components.schemas[schema.$ref.slice(prefix.length)];
|
|
60
|
-
if (!found) throw new CLIError("invalid_contract", "
|
|
56
|
+
if (!found) throw new CLIError("invalid_contract", message("contract.referenceUnknown"));
|
|
61
57
|
return found;
|
|
62
58
|
}
|
|
63
59
|
|
|
@@ -67,6 +63,6 @@ export function requestSchema(contract, operation, parameters) {
|
|
|
67
63
|
if (!schema?.oneOf) return schema;
|
|
68
64
|
const candidates = schema.oneOf.map((item) => resolveSchema(contract, item));
|
|
69
65
|
const matching = candidates.find((item) => item.properties?.grant_type?.const === parameters.get("grant_type"));
|
|
70
|
-
if (!matching) throw new CLIError("invalid_arguments", "
|
|
66
|
+
if (!matching) throw new CLIError("invalid_arguments", message("arguments.grantTypeRequired"));
|
|
71
67
|
return matching;
|
|
72
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
|
+
}
|