@limecloud/cli 0.3.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limecloud/cli",
3
- "version": "0.3.0",
3
+ "version": "0.8.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 = `limecloud-cli — Lime Cloud CLI (@limecloud/cli)
14
+ const help = () => ["help.overview", "help.apiFlags", "help.connectionFlags"].map(key => translate(key)).join("");
13
15
 
14
- Usage:
15
- limecloud-cli <command> [resource] [method] [flags]
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(`Unknown option --${name}`);
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(`--${name} does not take a value`);
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(`--${name} needs a value`);
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(`Duplicate option --${name}`);
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(`Option --${key} is not supported by this command`);
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((operation) => operation.command.startsWith(`${command}.`));
93
- if (serviceMethods.length) return `${command === "identity" ? "Identity shortcuts:\n identity +login [--scope SCOPES] [--no-wait | --resume]\n identity +whoami\n\n" : ""}Typed API commands:\n${serviceMethods.map((operation) => ` ${operation.command.replaceAll(".", " ")} (${operation.method} ${operation.path})`).join("\n")}\n\nUse schema ${command}.<resource>.<method> for parameters. --dry-run executes nothing.\n`;
94
- if (command === "auth") return `Usage: auth <login|status|whoami|refresh|logout|scopes|check> [flags]\n\nlogin --client-id ID [--scope "openid profile"] [--installation-id ID --space-id ID]\n --no-wait: return a verification URL and save private pending state\n --resume: complete the saved authorization without exposing its device code\nstatus: local state; whoami: remote UserInfo; refresh: rotate stored credentials\nlogout [--local-only]: revoke, then forget; --local-only forgets without revocation\nscopes: read supported OIDC scopes and report locally granted scopes\ncheck --scope SCOPES: check unexpired local granted scopes (no network)\n\nAuth commands use an already registered public client. --dry-run previews login, refresh, logout or scopes without execution.\n`;
95
- if (command === "config") return `Usage:\n config init --issuer ORIGIN --client-id ID [--api-origin ORIGIN] [--scope SCOPES]\n config show\n config set <issuer|api_origin|client_id|scope> <value>\n\nConfiguration stores public client settings only. Business API origin must be explicit; changing it requires a new login binding.\n`;
96
- if (command === "schema") return "Usage: schema [identity | identity.resource | identity.resource.method | identity resource method]\nReturns public inputSchema, outputSchema and _meta derived from the OpenAPI contract. Risk: read.\n";
97
- if (command === "api") return `${help.slice(help.indexOf("API flags:"), help.indexOf("Connection flags:"))}\nUsage: api GET <path> --params JSON\n api POST <path> --data JSON\n api list [query] [--locale en|zh-CN]\n api schema <operationId>\n api call <operationId> [flags]\n\nOnly exact public contract paths are callable. Arbitrary URLs and management BFF paths are rejected.\n`;
98
- return `${help}\nAvailable services: ${[...new Set(commandOperations(contract).map((operation) => operation.command.split(".")[0]))].join(", ")}\n`;
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", "Run auth login or set LIMECLOUD_ACCESS_TOKEN");
112
- if (!env.LIMECLOUD_ACCESS_TOKEN && Date.parse(context.saved.expires_at) <= now()) throw new CLIError("token_expired", "Run auth refresh or start auth login again");
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, { env = process.env, io = process, store = new CredentialStore(credentialsPath(env)),
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("Use docs [article-slug]");
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 settingsStore.withLock(async () => {
164
- const old = await settingsStore.read() ?? {};
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("Use config init, config show or config set <issuer|api_origin|client_id|scope> <value>");
184
+ fail(message("arguments.configUsage"));
181
185
  }
182
- if (extra.length) fail("Too many arguments; use --help");
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(`Use ${command} without positional arguments`);
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 = get("locale") ?? "en";
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["x-lime-summary-zh-CN"]}`.toLocaleLowerCase(locale).includes(needle))
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: locale === "zh-CN" ? operation["x-lime-summary-zh-CN"] ?? operation.summary : operation.summary }))); return;
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("Choose a public operationId from api list");
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", "Set --api-origin, LIMECLOUD_API_ORIGIN or config api_origin before calling a business API");
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", "API origin changed or was not bound to this login; run auth login with the intended --api-origin");
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", `This method requires explicit login scopes: ${required.join(" ")}`);
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("This request requires LIMECLOUD_CLIENT_ID and LIMECLOUD_CLIENT_SECRET");
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("Unknown command or public method; use --help or schema identity");
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("Use auth check --scope SCOPES");
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 ? "Environment-token scopes are unknown; use a saved login or verify remotely in your application" : `Missing locally granted scopes: ${missing.join(", ")}`);
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", "Discovery did not return this issuer and its supported scopes");
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("Auth commands require a public client; unset LIMECLOUD_CLIENT_SECRET");
277
- if (["login", "refresh", "logout"].includes(action) && env.LIMECLOUD_ACCESS_TOKEN) fail("Unset LIMECLOUD_ACCESS_TOKEN before managing saved credentials; revoke environment tokens explicitly with api");
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("--no-wait and --resume cannot be combined");
282
- if (get("resume") && ["scope", "installation-id", "space-id"].some((name) => get(name))) fail("A resumed authorization keeps its original scope and installation context");
283
- if (Boolean(get("installation-id")) !== Boolean(get("space-id"))) fail("--installation-id and --space-id must be supplied together");
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", "No pending device authorization; start auth login --no-wait");
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", "Invalid pending user code");
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("Resume issuer and client ID must match the saved device authorization");
293
- if ((get("api-origin") ?? env.LIMECLOUD_API_ORIGIN ?? settings.api_origin ?? pending.api_origin) !== pending.api_origin) fail("Resume API origin must match the original device authorization");
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("Provide a registered public client with --client-id or config init");
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("Device sign-in requires the openid scope");
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(`Open ${notice.verification_uri}\nConfirm code: ${notice.user_code}\n`);
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", "A previous refresh has an unknown result; start auth login again");
327
- if (!current.saved.refresh_token) throw new CLIError("login_required", "No refresh token is saved; start auth login again");
328
- if (current.apiOrigin && current.saved.api_origin !== current.apiOrigin) throw new CLIError("login_required", "API origin changed; refreshing cannot bind an existing grant to another API. Start auth login again");
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.io ?? process).stderr.write(`${JSON.stringify(errorEnvelope(error, cancelled))}\n`);
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", "Every public CLI operation needs an explicit service.resource.method mapping");
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.description ?? operation.summary,
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.description, in: parameter.in, flag: parameterFlag(parameter.name) }])),
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", `Unknown schema path: ${target}; start with schema`);
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
- if (workspaceSource === undefined) {
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", "Public API contract versions must match");
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", "Public API security scheme collision");
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", "Management contract contains an unexpected public path");
39
- if (contract.paths[path]) throw new CLIError("invalid_contract", "Duplicate public API path");
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", "Duplicate public operationId");
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", "Unsupported public schema reference");
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", "Unknown public schema reference");
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", "Choose a supported grant_type; use schema identity.token.exchange");
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
+ }