@limecloud/cli 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +230 -2
- package/bin/limecloud-cli.mjs +3 -0
- package/dist/identity.json +1453 -0
- package/dist/management.json +23355 -0
- package/package.json +33 -4
- package/src/cli.mjs +362 -0
- package/src/commands.mjs +69 -0
- package/src/contract.mjs +72 -0
- package/src/input.mjs +91 -0
- package/src/management.mjs +160 -0
- package/src/output.mjs +55 -0
- package/src/protocol.mjs +147 -0
- package/src/storage.mjs +95 -0
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@limecloud/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Lime Cloud CLI for public identity and delegated platform APIs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.12.0"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"limecloud-cli": "./bin/limecloud-cli.mjs",
|
|
11
|
+
"limecloud": "./bin/limecloud-cli.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"src",
|
|
16
|
+
"dist/*.json",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "node scripts/build.mjs",
|
|
22
|
+
"prepack": "node scripts/build.mjs",
|
|
23
|
+
"test": "node --test test/*.test.mjs"
|
|
24
|
+
},
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/limecloud/cloud.git",
|
|
29
|
+
"directory": "packages/cli"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"registry": "https://registry.npmjs.org"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { setTimeout } from "node:timers/promises";
|
|
2
|
+
import { publicContract, resolveSchema } from "./contract.mjs";
|
|
3
|
+
import { commandOperations, inspectSchema } from "./commands.mjs";
|
|
4
|
+
import { CredentialStore, PrivateJSONStore, SettingsStore, credentialsPath, settingsPath, validateSettings } from "./storage.mjs";
|
|
5
|
+
import { CLIError, issuerOrigin, pollDevice, protocolRequest, startDevice, tokenRecord, verificationURI } from "./protocol.mjs";
|
|
6
|
+
import { parametersFor, scopeList, validateParameters } from "./input.mjs";
|
|
7
|
+
import { errorEnvelope, outputOptions, success } from "./output.mjs";
|
|
8
|
+
import { flagName, matchHTTP, prepareManagement } from "./management.mjs";
|
|
9
|
+
|
|
10
|
+
export { validateParameters } from "./input.mjs";
|
|
11
|
+
|
|
12
|
+
const help = `limecloud-cli — Lime Cloud CLI (@limecloud/cli)
|
|
13
|
+
|
|
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"]);
|
|
59
|
+
const fail = (message) => { throw new CLIError("invalid_arguments", message); };
|
|
60
|
+
|
|
61
|
+
function fieldNames(contract, operation) {
|
|
62
|
+
const schema = resolveSchema(contract, operation.requestBody?.content["application/x-www-form-urlencoded"]?.schema ?? operation.requestBody?.content["application/json"]?.schema);
|
|
63
|
+
const variants = schema?.oneOf?.map((item) => resolveSchema(contract, item)) ?? (schema ? [schema] : []);
|
|
64
|
+
return [...new Set([...variants.flatMap((item) => Object.keys(item.properties ?? {})), ...(operation.parameters ?? []).map((parameter) => parameter.name)])];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseArguments(args, protocolFields = []) {
|
|
68
|
+
const positionals = [], flags = new Map();
|
|
69
|
+
const dynamic = new Set(protocolFields.map(flagName));
|
|
70
|
+
for (let index = 0; index < args.length; index++) {
|
|
71
|
+
const item = args[index] === "-h" ? "--help" : args[index] === "-v" ? "--version" : args[index];
|
|
72
|
+
if (!item.startsWith("--")) { positionals.push(item); continue; }
|
|
73
|
+
const [name, ...rest] = item.slice(2).split("=");
|
|
74
|
+
if (!booleanFlags.has(name) && !valueFlags.has(name) && !dynamic.has(name)) fail(`Unknown option --${name}`);
|
|
75
|
+
let value = true;
|
|
76
|
+
if (booleanFlags.has(name) && rest.length) fail(`--${name} does not take a value`);
|
|
77
|
+
if (!booleanFlags.has(name)) {
|
|
78
|
+
value = rest.length ? rest.join("=") : args[++index];
|
|
79
|
+
if (typeof value !== "string" || !value || value.startsWith("--")) fail(`--${name} needs a value`);
|
|
80
|
+
}
|
|
81
|
+
if (flags.has(name) && !["param", "param-env", "audience", "header"].includes(name)) fail(`Duplicate option --${name}`);
|
|
82
|
+
flags.set(name, [...(flags.get(name) ?? []), value]);
|
|
83
|
+
}
|
|
84
|
+
return { positionals, flags, get: (name) => flags.get(name)?.[0] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
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`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
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`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function contextFor(parsed, env, settings, saved, management = false) {
|
|
102
|
+
const issuer = issuerOrigin(parsed.get("issuer") ?? env.LIMECLOUD_ISSUER ?? settings.issuer ?? saved?.issuer ?? "https://auth.limeai.run");
|
|
103
|
+
const apiValue = parsed.get("api-origin") ?? env.LIMECLOUD_API_ORIGIN ?? settings.api_origin;
|
|
104
|
+
const apiOrigin = apiValue ? issuerOrigin(apiValue) : undefined;
|
|
105
|
+
const clientID = parsed.get("auth-client-id") ?? (management ? undefined : parsed.get("client-id")) ?? env.LIMECLOUD_CLIENT_ID ?? settings.client_id ?? (saved?.issuer === issuer ? saved.client_id : undefined);
|
|
106
|
+
const matching = saved && saved.issuer === issuer && saved.client_id === clientID ? saved : null;
|
|
107
|
+
return { issuer, apiOrigin, clientID, saved: matching, accessToken: env.LIMECLOUD_ACCESS_TOKEN || matching?.access_token };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
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");
|
|
113
|
+
return context.accessToken;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function localIdentity(context, env, now) {
|
|
117
|
+
const external = Boolean(env.LIMECLOUD_ACCESS_TOKEN);
|
|
118
|
+
return { identity: context.accessToken ? "user" : "public", authenticated: Boolean(context.accessToken) && (external || Date.parse(context.saved.expires_at) > now()),
|
|
119
|
+
source: external ? "environment" : context.saved ? "credentials" : null, issuer: context.issuer, api_origin: context.apiOrigin ?? null, client_id: context.clientID ?? null,
|
|
120
|
+
bound_api_origin: external ? null : context.saved?.api_origin ?? null,
|
|
121
|
+
expires_at: external ? null : context.saved?.expires_at ?? null, expired: external ? null : context.saved ? Date.parse(context.saved.expires_at) <= now() : null,
|
|
122
|
+
refresh_requires_login: context.saved?.refresh_pending === true, scope: external ? null : context.saved?.scope ?? null };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function preview(operation, context, form, authentication) {
|
|
126
|
+
const body = {};
|
|
127
|
+
for (const key of new Set(form.keys())) body[key] = form.getAll(key).length > 1 ? form.getAll(key) : form.get(key);
|
|
128
|
+
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]" } :
|
|
130
|
+
authentication === "client" ? { Authorization: "Basic [REDACTED]" } : {}), ...(operation.method === "POST" ? { "Content-Type": "application/x-www-form-urlencoded" } : {}) },
|
|
131
|
+
...(operation.method === "GET" ? { params: body } : { data: body }) } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function execute(args, { env = process.env, io = process, store = new CredentialStore(credentialsPath(env)),
|
|
135
|
+
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 } = {}) {
|
|
137
|
+
const contract = suppliedContract ?? await publicContract();
|
|
138
|
+
const catalogue = commandOperations(contract);
|
|
139
|
+
const parsed = parseArguments(args, catalogue.flatMap((operation) => fieldNames(contract, operation)));
|
|
140
|
+
let [command, action, target, ...extra] = parsed.positionals;
|
|
141
|
+
const get = parsed.get;
|
|
142
|
+
const request = (input) => requestImpl({ ...input, signal });
|
|
143
|
+
const pause = (ms) => wait(ms, undefined, signal ? { signal } : undefined);
|
|
144
|
+
const output = (value, options = {}) => success(io, value, { ...outputOptions(parsed), ...options });
|
|
145
|
+
if (get("help") || !args.length) { io.stdout.write(commandHelp(command, contract)); return; }
|
|
146
|
+
if (get("version")) { io.stdout.write(`${contract.info.version}\n`); return; }
|
|
147
|
+
// 在任何读取凭据或发请求前验证输出选项。
|
|
148
|
+
outputOptions(parsed);
|
|
149
|
+
if (command === "identity" && ["+login", "+whoami"].includes(action) && !target) { command = "auth"; action = action.slice(1); }
|
|
150
|
+
if (command === "schema") {
|
|
151
|
+
allowedFlags(parsed, []);
|
|
152
|
+
output(inspectSchema(contract, parsed.positionals.slice(1))); return;
|
|
153
|
+
}
|
|
154
|
+
if (command === "docs") {
|
|
155
|
+
allowedFlags(parsed, []);
|
|
156
|
+
if (target || extra.length || action && !/^[a-z0-9-]{1,80}$/.test(action)) fail("Use docs [article-slug]");
|
|
157
|
+
output({ url: `https://open.limeai.run/docs${action ? `/${action}` : ""}` }); return;
|
|
158
|
+
}
|
|
159
|
+
if (command === "config") {
|
|
160
|
+
allowedFlags(parsed, action === "init" ? ["issuer", "api-origin", "client-id", "scope"] : []);
|
|
161
|
+
if (action === "show" && !target) { output({ settings: await settingsStore.read(), path: settingsStore.path ?? null }, { identity: "local" }); return; }
|
|
162
|
+
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);
|
|
171
|
+
output({ settings, path: settingsStore.path ?? null }, { identity: "local" });
|
|
172
|
+
}); return;
|
|
173
|
+
}
|
|
174
|
+
if (action === "set" && target && extra.length === 1) {
|
|
175
|
+
await settingsStore.withLock(async () => {
|
|
176
|
+
const settings = validateSettings({ ...await settingsStore.read(), [target]: extra[0] });
|
|
177
|
+
await settingsStore.write(settings); output({ settings }, { identity: "local" });
|
|
178
|
+
}); return;
|
|
179
|
+
}
|
|
180
|
+
fail("Use config init, config show or config set <issuer|api_origin|client_id|scope> <value>");
|
|
181
|
+
}
|
|
182
|
+
if (extra.length) fail("Too many arguments; use --help");
|
|
183
|
+
let operation = catalogue.find((item) => item.command === [command, action, target].join("."));
|
|
184
|
+
if (command === "api" && action === "call") operation = catalogue.find((item) => item.operationId === target);
|
|
185
|
+
const raw = command === "api" ? matchHTTP(catalogue, action, target) : null;
|
|
186
|
+
if (raw) operation = raw.operation;
|
|
187
|
+
const settings = await settingsStore.read() ?? {};
|
|
188
|
+
const management = operation?.["x-lime-cli-origin"] === "api";
|
|
189
|
+
const context = await contextFor(parsed, env, settings, await store.read(), management);
|
|
190
|
+
if (["whoami", "doctor"].includes(command)) {
|
|
191
|
+
allowedFlags(parsed, ["issuer", "api-origin", "client-id", "auth-client-id", ...(command === "doctor" ? ["offline"] : [])]);
|
|
192
|
+
if (action) fail(`Use ${command} without positional arguments`);
|
|
193
|
+
const state = localIdentity(context, env, now);
|
|
194
|
+
output(command === "whoami" ? state : { offline: true, checks: { public_contract: true, version: contract.info.version,
|
|
195
|
+
issuer: context.issuer, configured_client: Boolean(context.clientID), credentials_readable: true, token_unexpired: state.authenticated },
|
|
196
|
+
configuration: { settings: settingsStore.path ?? null, credentials: store.path ?? null } }, { identity: "local" }); return;
|
|
197
|
+
}
|
|
198
|
+
if (command === "api" && action === "list") {
|
|
199
|
+
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");
|
|
202
|
+
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))
|
|
204
|
+
.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;
|
|
206
|
+
}
|
|
207
|
+
if (command === "api" && action === "schema") {
|
|
208
|
+
allowedFlags(parsed, []);
|
|
209
|
+
if (!catalogue.some((operation) => operation.operationId === target)) fail("Choose a public operationId from api list");
|
|
210
|
+
output(inspectSchema(contract, [target])); return;
|
|
211
|
+
}
|
|
212
|
+
if (operation) {
|
|
213
|
+
const fields = fieldNames(contract, operation);
|
|
214
|
+
allowedFlags(parsed, ["issuer", "api-origin", ...(management ? [] : ["client-id"]), "auth-client-id", "param", "param-env", "params", "data", ...(management ? ["header", "file"] : []), "show-secrets", "dry-run", "format", "fields", ...fields.map(flagName)]);
|
|
215
|
+
if (management) {
|
|
216
|
+
const input = await prepareManagement(contract, operation, parsed, env, readInput, raw?.bindings, readBinary);
|
|
217
|
+
if (get("dry-run")) {
|
|
218
|
+
output({ dry_run: true, command: operation.command, operationId: operation.operationId, request: { method: operation.method,
|
|
219
|
+
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" }) },
|
|
221
|
+
...(input.binarySummary ? { file: input.binarySummary } : input.jsonBody === undefined ? {} : { data: input.jsonBody }) } }, { identity: "user" }); return;
|
|
222
|
+
}
|
|
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");
|
|
224
|
+
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");
|
|
226
|
+
const granted = new Set(context.saved?.scope.split(/\s+/) ?? []);
|
|
227
|
+
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(" ")}`);
|
|
229
|
+
const result = await request({ issuer: context.issuer, origin: context.apiOrigin, method: operation.method, ...input, accessToken: token });
|
|
230
|
+
const response = resolveSchema(contract, operation.responses["200"]?.content?.["application/json"]?.schema ?? operation.responses["201"]?.content?.["application/json"]?.schema);
|
|
231
|
+
output(response?.properties?.data && result && Object.hasOwn(result, "data") ? result.data : result ?? { accepted: true }, { identity: "user", raw: Boolean(get("show-secrets")) }); return;
|
|
232
|
+
}
|
|
233
|
+
const form = await parametersFor(parsed, env, operation.method, readInput);
|
|
234
|
+
// 与参考 typed commands 一致:显式字段参数覆盖同名 JSON 参数。
|
|
235
|
+
for (const name of fields) if (get(flagName(name)) !== undefined) {
|
|
236
|
+
const single = { flags: new Map([["param", (parsed.flags.get(flagName(name)) ?? []).map((value) => `${name}=${value}`)]]), get: () => undefined };
|
|
237
|
+
const override = await parametersFor(single, env, operation.method, readInput);
|
|
238
|
+
form.delete(name);
|
|
239
|
+
for (const value of override.getAll(name)) form.append(name, value);
|
|
240
|
+
}
|
|
241
|
+
const basic = Boolean(operation.security?.some((item) => Object.hasOwn(item, "ClientSecretBasic")) && env.LIMECLOUD_CLIENT_SECRET);
|
|
242
|
+
const bearer = operation.security?.some((item) => Object.hasOwn(item, "Bearer"));
|
|
243
|
+
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");
|
|
245
|
+
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
|
+
validateParameters(contract, operation, form, basic || requiresBasic, Boolean(get("dry-run")));
|
|
247
|
+
const identity = basic || requiresBasic ? "client" : bearer ? "user" : "public";
|
|
248
|
+
if (get("dry-run")) { output(preview(operation, context, form, identity), { identity }); return; }
|
|
249
|
+
const result = await request({ issuer: context.issuer, path: operation.path, method: operation.method, parameters: form,
|
|
250
|
+
clientID: context.clientID, clientSecret: basic ? env.LIMECLOUD_CLIENT_SECRET : undefined,
|
|
251
|
+
accessToken: bearer ? usableAccess(context, env, now) : undefined });
|
|
252
|
+
output(result ?? { accepted: true }, { identity, raw: Boolean(get("show-secrets")) }); return;
|
|
253
|
+
}
|
|
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");
|
|
255
|
+
allowedFlags(parsed, ["issuer", "api-origin", "client-id", "auth-client-id", ...(action === "login" ? ["scope", "installation-id", "space-id", "no-wait", "resume", "dry-run"] : []),
|
|
256
|
+
...(["refresh", "logout", "scopes"].includes(action) ? ["dry-run"] : []), ...(action === "logout" ? ["local-only"] : []), ...(action === "check" ? ["scope"] : [])]);
|
|
257
|
+
if (action === "status") { output(localIdentity(context, env, now), { identity: "user" }); return; }
|
|
258
|
+
if (action === "whoami") { output(await request({ issuer: context.issuer, path: "/oauth/userinfo", accessToken: usableAccess(context, env, now) }), { identity: "user" }); return; }
|
|
259
|
+
if (action === "check") {
|
|
260
|
+
if (!get("scope")) fail("Use auth check --scope SCOPES");
|
|
261
|
+
const required = scopeList(get("scope"));
|
|
262
|
+
const granted = new Set((context.saved?.scope ?? "").split(/\s+/));
|
|
263
|
+
const missing = required.filter((scope) => !granted.has(scope));
|
|
264
|
+
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(", ")}`);
|
|
266
|
+
output({ checked: true, source: "local_grant", scopes: required }, { identity: "user" }); return;
|
|
267
|
+
}
|
|
268
|
+
if (action === "scopes") {
|
|
269
|
+
const method = catalogue.find((item) => item.operationId === "identity_discovery");
|
|
270
|
+
if (get("dry-run")) { output(preview(method, context, new URLSearchParams(), "public")); return; }
|
|
271
|
+
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");
|
|
273
|
+
output({ supported_scopes: metadata.scopes_supported, granted_scopes: context.saved?.scope.split(/\s+/).filter(Boolean) ?? [], source: "oidc_discovery",
|
|
274
|
+
client_enabled_scopes: null }); return;
|
|
275
|
+
}
|
|
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");
|
|
278
|
+
async function auth() {
|
|
279
|
+
const current = await contextFor(parsed, env, settings, await store.read());
|
|
280
|
+
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
|
+
let pending;
|
|
285
|
+
if (get("resume")) {
|
|
286
|
+
pending = await pendingStore.read();
|
|
287
|
+
if (!pending?.device_code) throw new CLIError("login_required", "No pending device authorization; start auth login --no-wait");
|
|
288
|
+
issuerOrigin(pending.issuer);
|
|
289
|
+
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");
|
|
291
|
+
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");
|
|
294
|
+
} else {
|
|
295
|
+
if (!current.clientID && !get("dry-run")) fail("Provide a registered public client with --client-id or config init");
|
|
296
|
+
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");
|
|
298
|
+
const form = new URLSearchParams({ scope: scopes.join(" ") });
|
|
299
|
+
if (get("installation-id")) { form.set("installation_id", get("installation-id")); form.set("space_id", get("space-id")); }
|
|
300
|
+
if (get("dry-run")) {
|
|
301
|
+
if (current.clientID) form.set("client_id", current.clientID);
|
|
302
|
+
output(preview(catalogue.find((item) => item.operationId === "identity_device_authorization"), current, form, "public")); return;
|
|
303
|
+
}
|
|
304
|
+
pending = await startDevice({ context: { issuer: current.issuer, apiOrigin: current.apiOrigin, clientID: current.clientID }, parameters: form, request, now });
|
|
305
|
+
await pendingStore.write(pending);
|
|
306
|
+
}
|
|
307
|
+
if (get("dry-run")) {
|
|
308
|
+
output(preview(catalogue.find((item) => item.operationId === "identity_token"), { issuer: pending.issuer }, new URLSearchParams({
|
|
309
|
+
client_id: pending.client_id, grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: pending.device_code,
|
|
310
|
+
}), "public")); return;
|
|
311
|
+
}
|
|
312
|
+
const notice = { authorization_pending: true, verification_uri: pending.verification_uri, user_code: pending.user_code,
|
|
313
|
+
expires_at: pending.expires_at, resume_command: "limecloud-cli auth login --resume" };
|
|
314
|
+
if (get("no-wait")) { output(notice); return; }
|
|
315
|
+
io.stderr.write(`Open ${notice.verification_uri}\nConfirm code: ${notice.user_code}\n`);
|
|
316
|
+
const saved = await pollDevice({ pending, request, wait: pause, now, persist: (value) => pendingStore.write(value) });
|
|
317
|
+
await store.write(saved);
|
|
318
|
+
await pendingStore.write({});
|
|
319
|
+
output({ authenticated: true, issuer: saved.issuer, client_id: saved.client_id, expires_at: saved.expires_at, scope: saved.scope }, { identity: "user" }); return;
|
|
320
|
+
}
|
|
321
|
+
if (!current.saved) {
|
|
322
|
+
if (action === "logout" && !get("dry-run")) await pendingStore.write({});
|
|
323
|
+
output({ authenticated: false }, { identity: "user" }); return;
|
|
324
|
+
}
|
|
325
|
+
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
|
+
const form = new URLSearchParams({ client_id: current.clientID, grant_type: "refresh_token", refresh_token: current.saved.refresh_token });
|
|
330
|
+
if (get("dry-run")) { output(preview(catalogue.find((item) => item.operationId === "identity_token"), current, form, "public")); return; }
|
|
331
|
+
await store.write({ ...current.saved, refresh_pending: true });
|
|
332
|
+
const tokens = await request({ issuer: current.issuer, path: "/oauth/token", method: "POST", parameters: form });
|
|
333
|
+
const saved = tokenRecord(tokens, { ...current, apiOrigin: current.saved.api_origin }, now());
|
|
334
|
+
await store.write(saved);
|
|
335
|
+
output({ authenticated: true, expires_at: saved.expires_at, scope: saved.scope }, { identity: "user" }); return;
|
|
336
|
+
}
|
|
337
|
+
const form = new URLSearchParams({ client_id: current.clientID, token: current.saved.refresh_token ?? current.saved.access_token,
|
|
338
|
+
token_type_hint: current.saved.refresh_token ? "refresh_token" : "access_token" });
|
|
339
|
+
if (get("dry-run")) {
|
|
340
|
+
output(get("local-only") ? { dry_run: true, local_credentials: "would_forget", remote_revocation: false } :
|
|
341
|
+
preview(catalogue.find((item) => item.operationId === "identity_revoke"), current, form, "public")); return;
|
|
342
|
+
}
|
|
343
|
+
if (!get("local-only")) await request({ issuer: current.issuer, path: "/oauth/revoke", method: "POST", parameters: form });
|
|
344
|
+
await store.write({}); await pendingStore.write({});
|
|
345
|
+
output({ authenticated: false, revoked: !get("local-only") }, { identity: "user" });
|
|
346
|
+
}
|
|
347
|
+
if (get("dry-run")) await auth();
|
|
348
|
+
else await store.withLock(auth);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function run(args, options = {}) {
|
|
352
|
+
const controller = new AbortController();
|
|
353
|
+
let cancelled;
|
|
354
|
+
const interrupt = () => { cancelled = "SIGINT"; controller.abort(); };
|
|
355
|
+
const terminate = () => { cancelled = "SIGTERM"; controller.abort(); };
|
|
356
|
+
process.once("SIGINT", interrupt); process.once("SIGTERM", terminate);
|
|
357
|
+
try { await execute(args, { ...options, signal: controller.signal }); }
|
|
358
|
+
catch (error) {
|
|
359
|
+
(options.io ?? process).stderr.write(`${JSON.stringify(errorEnvelope(error, cancelled))}\n`);
|
|
360
|
+
process.exitCode = cancelled === "SIGINT" ? 130 : cancelled === "SIGTERM" ? 143 : error.code === "invalid_arguments" || error.code === "invalid_issuer" ? 2 : 1;
|
|
361
|
+
} finally { process.removeListener("SIGINT", interrupt); process.removeListener("SIGTERM", terminate); }
|
|
362
|
+
}
|
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { operations, resolveSchema } from "./contract.mjs";
|
|
2
|
+
import { CLIError } from "./protocol.mjs";
|
|
3
|
+
|
|
4
|
+
// 缺省映射兼容现有身份合同。新增公共领域可在合同中显式声明 x-lime-cli。
|
|
5
|
+
const identityCommands = {
|
|
6
|
+
identity_discovery: "identity.discovery.get",
|
|
7
|
+
identity_authorize: "identity.authorization.authorize",
|
|
8
|
+
identity_token: "identity.token.exchange",
|
|
9
|
+
identity_device_authorization: "identity.device.authorize",
|
|
10
|
+
identity_userinfo: "identity.userinfo.get",
|
|
11
|
+
identity_userinfo_post: "identity.userinfo.post",
|
|
12
|
+
identity_jwks: "identity.keys.list",
|
|
13
|
+
identity_revoke: "identity.token.revoke",
|
|
14
|
+
identity_introspect: "identity.token.introspect",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function commandOperations(contract) {
|
|
18
|
+
return operations(contract).map((operation) => {
|
|
19
|
+
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
|
+
return { ...operation, command };
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function expanded(contract, input, depth = 0) {
|
|
26
|
+
if (!input || depth > 20) return input;
|
|
27
|
+
const schema = resolveSchema(contract, input);
|
|
28
|
+
if (Array.isArray(schema)) return schema.map((item) => expanded(contract, item, depth + 1));
|
|
29
|
+
if (typeof schema !== "object") return schema;
|
|
30
|
+
return Object.fromEntries(Object.entries(schema).map(([key, value]) => [key,
|
|
31
|
+
key === "properties" ? Object.fromEntries(Object.entries(value).map(([name, field]) => [name, expanded(contract, field, depth + 1)])) :
|
|
32
|
+
["items", "oneOf", "anyOf", "allOf", "additionalProperties", "not"].includes(key) && typeof value === "object" ? expanded(contract, value, depth + 1) : value]));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function methodSchema(contract, operation) {
|
|
36
|
+
const params = operation.parameters?.filter((parameter) => ["query", "path"].includes(parameter.in)) ?? [];
|
|
37
|
+
const headers = operation.parameters?.filter((parameter) => parameter.in === "header") ?? [];
|
|
38
|
+
const content = operation.requestBody?.content;
|
|
39
|
+
const encoding = content && Object.keys(content)[0];
|
|
40
|
+
const response = operation.responses["200"]?.content?.["application/json"]?.schema ?? operation.responses["201"]?.content?.["application/json"]?.schema;
|
|
41
|
+
const access = operation.security?.flatMap((item) => Object.keys(item)) ?? [];
|
|
42
|
+
return { name: operation.command.replaceAll(".", " "), description: operation.description ?? operation.summary,
|
|
43
|
+
inputSchema: { type: "object", properties: {
|
|
44
|
+
...(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
|
+
required: params.filter((parameter) => parameter.required).map((parameter) => parameter.name) } } : {}),
|
|
47
|
+
...(headers.length ? { headers: { type: "object", carrier: "--header", properties: Object.fromEntries(headers.map((parameter) => [parameter.name,
|
|
48
|
+
{ ...expanded(contract, parameter.schema), flag: parameterFlag(parameter.name) }])), required: headers.filter((parameter) => parameter.required).map((parameter) => parameter.name) } } : {}),
|
|
49
|
+
...(encoding ? encoding === "image/png" ? { file: { ...expanded(contract, content[encoding].schema), carrier: "--file", encoding } } :
|
|
50
|
+
{ data: { ...expanded(contract, content[encoding].schema), carrier: "--data", encoding } } : {}),
|
|
51
|
+
} }, outputSchema: response ? expanded(contract, response) : { type: "null" },
|
|
52
|
+
_meta: { envelope_version: "1.0", command: operation.command, operationId: operation.operationId, method: operation.method, path: operation.path,
|
|
53
|
+
access_tokens: access.includes("Bearer") ? ["user"] : access.includes("ClientSecretBasic") ? ["client"] : [],
|
|
54
|
+
required_scopes: operation["x-lime-required-scopes"] ?? (operation.path === "/oauth/userinfo" ? ["openid"] : []),
|
|
55
|
+
origin: operation["x-lime-cli-origin"], risk: operation.method === "GET" ? "read" : "write", doc_url: `https://open.limeai.run/docs/api?operation=${operation.operationId}` } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parameterFlag = (name) => `--${name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").replaceAll("_", "-").toLowerCase()}`;
|
|
59
|
+
|
|
60
|
+
export function inspectSchema(contract, segments = []) {
|
|
61
|
+
const target = segments.join(".");
|
|
62
|
+
const methods = commandOperations(contract);
|
|
63
|
+
if (!target) return methods.map((operation) => methodSchema(contract, operation));
|
|
64
|
+
const operation = methods.find((item) => item.command === target || item.operationId === target);
|
|
65
|
+
if (operation) return methodSchema(contract, operation);
|
|
66
|
+
const matching = methods.filter((item) => item.command.startsWith(`${target}.`));
|
|
67
|
+
if (matching.length) return matching.map((item) => methodSchema(contract, item));
|
|
68
|
+
throw new CLIError("invalid_arguments", `Unknown schema path: ${target}; start with schema`);
|
|
69
|
+
}
|
package/src/contract.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { CLIError } from "./protocol.mjs";
|
|
3
|
+
|
|
4
|
+
let contract;
|
|
5
|
+
let workspaceSource;
|
|
6
|
+
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"));
|
|
13
|
+
}
|
|
14
|
+
function namespace(value, prefix) {
|
|
15
|
+
if (Array.isArray(value)) return value.map((item) => namespace(item, prefix));
|
|
16
|
+
if (!value || typeof value !== "object") return value;
|
|
17
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key,
|
|
18
|
+
key === "$ref" && typeof item === "string" && item.startsWith("#/components/schemas/") ? item.replace("#/components/schemas/", `#/components/schemas/${prefix}`) : namespace(item, prefix)]));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function publicContract() {
|
|
22
|
+
if (contract) return contract;
|
|
23
|
+
const identity = await load("identity");
|
|
24
|
+
const management = await load("management");
|
|
25
|
+
contract = { ...identity, paths: {}, components: { ...identity.components, schemas: { ...identity.components.schemas }, securitySchemes: { ...identity.components.securitySchemes } } };
|
|
26
|
+
for (const [path, methods] of Object.entries(identity.paths)) {
|
|
27
|
+
contract.paths[path] = Object.fromEntries(Object.entries(methods).map(([method, operation]) => [method, { ...operation, "x-lime-cli-origin": "auth" }]));
|
|
28
|
+
}
|
|
29
|
+
if (management) {
|
|
30
|
+
if (management.info.version !== identity.info.version) throw new CLIError("invalid_contract", "Public API contract versions must match");
|
|
31
|
+
for (const [name, schema] of Object.entries(management.components?.schemas ?? {})) contract.components.schemas[`Management_${name}`] = namespace(schema, "Management_");
|
|
32
|
+
for (const [name, scheme] of Object.entries(management.components?.securitySchemes ?? {})) {
|
|
33
|
+
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");
|
|
35
|
+
contract.components.securitySchemes[name] = scheme;
|
|
36
|
+
}
|
|
37
|
+
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");
|
|
40
|
+
contract.paths[path] = Object.fromEntries(Object.entries(methods).map(([method, operation]) => [method,
|
|
41
|
+
{ ...namespace(operation, "Management_"), "x-lime-cli-origin": "api" }]));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const ids = operations(contract).map((operation) => operation.operationId);
|
|
45
|
+
if (new Set(ids).size !== ids.length) throw new CLIError("invalid_contract", "Duplicate public operationId");
|
|
46
|
+
return contract;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function operations(contract) {
|
|
50
|
+
return Object.entries(contract.paths).flatMap(([path, methods]) => Object.entries(methods)
|
|
51
|
+
.filter(([method]) => ["get", "post", "put", "patch", "delete", "head", "options"].includes(method))
|
|
52
|
+
.map(([method, definition]) => ({ path, method: method.toUpperCase(), ...definition })));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveSchema(contract, schema) {
|
|
56
|
+
if (!schema?.$ref) return schema;
|
|
57
|
+
const prefix = "#/components/schemas/";
|
|
58
|
+
if (!schema.$ref.startsWith(prefix)) throw new CLIError("invalid_contract", "Unsupported public schema reference");
|
|
59
|
+
const found = contract.components.schemas[schema.$ref.slice(prefix.length)];
|
|
60
|
+
if (!found) throw new CLIError("invalid_contract", "Unknown public schema reference");
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function requestSchema(contract, operation, parameters) {
|
|
65
|
+
const input = operation.requestBody?.content["application/x-www-form-urlencoded"]?.schema;
|
|
66
|
+
const schema = resolveSchema(contract, input);
|
|
67
|
+
if (!schema?.oneOf) return schema;
|
|
68
|
+
const candidates = schema.oneOf.map((item) => resolveSchema(contract, item));
|
|
69
|
+
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");
|
|
71
|
+
return matching;
|
|
72
|
+
}
|