@limecloud/cli 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ import { message } from "./i18n.mjs";
2
+ import { createHash } from "node:crypto";
3
+ import { resolveSchema } from "./contract.mjs";
4
+ import { jsonInput, readSource } from "./input.mjs";
5
+ import { CLIError } from "./protocol.mjs";
6
+
7
+ export const flagName = (field) => field.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").replaceAll("_", "-").toLowerCase();
8
+ const fail = (message) => { throw new CLIError("invalid_arguments", message); };
9
+ const sensitive = new Set(["password", "secret", "client_secret", "signing_key", "access_token", "refresh_token", "id_token", "device_code", "code_verifier", "token", "totp_secret", "recovery_codes"]);
10
+
11
+ function noInlineSecrets(value) {
12
+ if (!value || typeof value !== "object") return;
13
+ for (const [key, item] of Object.entries(value)) {
14
+ if (sensitive.has(key) && item != null && item !== "") fail(message("arguments.privateJSONRequired"));
15
+ noInlineSecrets(item);
16
+ }
17
+ }
18
+
19
+ export function validateJSON(contract, input, value, label = "data", depth = 0) {
20
+ if (!input || depth > 32) return;
21
+ const schema = resolveSchema(contract, input);
22
+ for (const kind of ["oneOf", "anyOf"]) if (schema[kind]) {
23
+ const accepted = schema[kind].filter((variant) => { try { validateJSON(contract, variant, value, label, depth + 1); return true; } catch { return false; } });
24
+ if (!accepted.length || kind === "oneOf" && accepted.length !== 1) fail(message("arguments.schemaMismatch", { label }));
25
+ return;
26
+ }
27
+ for (const variant of schema.allOf ?? []) validateJSON(contract, variant, value, label, depth + 1);
28
+ if (schema.const !== undefined && value !== schema.const || schema.enum && !schema.enum.includes(value)) fail(message("arguments.unsupportedValue", { label }));
29
+ const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
30
+ if (types.length && !types.some((type) => type === "null" ? value === null : type === "array" ? Array.isArray(value) : type === "object" ? value && typeof value === "object" && !Array.isArray(value) :
31
+ type === "integer" ? Number.isInteger(value) : type === "number" ? typeof value === "number" && Number.isFinite(value) : typeof value === type)) fail(message("arguments.wrongType", { label }));
32
+ if (typeof value === "string" && (schema.minLength !== undefined && value.length < schema.minLength || schema.maxLength !== undefined && value.length > schema.maxLength || schema.pattern && !new RegExp(schema.pattern).test(value))) fail(message("arguments.invalidString", { label }));
33
+ if (typeof value === "number" && (schema.minimum !== undefined && value < schema.minimum || schema.maximum !== undefined && value > schema.maximum)) fail(message("arguments.outsideRange", { label }));
34
+ if (Array.isArray(value)) {
35
+ if (schema.minItems !== undefined && value.length < schema.minItems || schema.maxItems !== undefined && value.length > schema.maxItems) fail(message("arguments.invalidItemCount", { label }));
36
+ value.forEach((item, index) => validateJSON(contract, schema.items, item, `${label}[${index}]`, depth + 1));
37
+ } else if (value && typeof value === "object") {
38
+ for (const required of schema.required ?? []) if (!Object.hasOwn(value, required)) fail(message("arguments.requiredField", { label, name: required }));
39
+ for (const [name, item] of Object.entries(value)) {
40
+ if (schema.additionalProperties === false && !Object.hasOwn(schema.properties ?? {}, name)) fail(message("arguments.undeclaredField", { label, name }));
41
+ validateJSON(contract, schema.properties?.[name], item, `${label}.${name}`, depth + 1);
42
+ }
43
+ }
44
+ }
45
+
46
+ export function matchHTTP(catalogue, method, path) {
47
+ if (!path?.startsWith("/") || path.startsWith("//") || /[?#\\\x00-\x1f]/.test(path)) return null;
48
+ for (const operation of catalogue.filter((item) => item.method === method)) {
49
+ const pattern = operation.path.split("/"), actual = path.split("/");
50
+ if (pattern.length !== actual.length) continue;
51
+ const bindings = {};
52
+ let matches = true;
53
+ for (let index = 0; index < pattern.length; index++) {
54
+ const name = pattern[index].match(/^\{([^}]+)\}$/)?.[1];
55
+ if (!name && pattern[index] !== actual[index]) { matches = false; break; }
56
+ if (name) {
57
+ try { bindings[name] = decodeURIComponent(actual[index]); } catch { matches = false; break; }
58
+ if (!bindings[name] || /[/\\?#\x00-\x1f]/.test(bindings[name])) { matches = false; break; }
59
+ }
60
+ }
61
+ if (matches) return { operation, bindings };
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function cast(raw, schema) {
67
+ if (schema?.type === "integer" || schema?.type === "number") {
68
+ if (String(raw).trim() === "" || !Number.isFinite(Number(raw))) fail(message("arguments.numericRequired"));
69
+ return Number(raw);
70
+ }
71
+ if (schema?.type === "boolean") {
72
+ if (raw !== "true" && raw !== "false" && typeof raw !== "boolean") fail(message("arguments.booleanRequired"));
73
+ return raw === true || raw === "true";
74
+ }
75
+ if (["object", "array"].includes(schema?.type) && typeof raw === "string") {
76
+ try { return JSON.parse(raw); } catch { fail(message("arguments.structuredJSONRequired")); }
77
+ }
78
+ return raw;
79
+ }
80
+
81
+ export async function prepareManagement(contract, operation, parsed, env, readInput, rawBindings = {}, readBinary = readSource) {
82
+ const declarations = operation.parameters ?? [];
83
+ const inputSchema = resolveSchema(contract, operation.requestBody?.content?.["application/json"]?.schema);
84
+ const values = {}, body = {};
85
+ let bodyProvided = false;
86
+ const binaryType = Object.keys(operation.requestBody?.content ?? {}).find((type) => type === "image/png");
87
+ let binaryBody;
88
+ if (parsed.get("file")) {
89
+ if (!binaryType) fail(message("arguments.fileUnsupported"));
90
+ if (parsed.get("data")) fail(message("arguments.binaryBodyConflict"));
91
+ binaryBody = Buffer.from(await readBinary(parsed.get("file") === "-" ? 0 : parsed.get("file"), 320 << 10));
92
+ if (binaryBody.length > 320 << 10 || binaryBody.length < 33 || !binaryBody.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
93
+ binaryBody.toString("ascii", 12, 16) !== "IHDR" || binaryBody.readUInt32BE(16) !== 256 || binaryBody.readUInt32BE(20) !== 256) fail(message("arguments.invalidPNG"));
94
+ bodyProvided = true;
95
+ }
96
+ if (parsed.get("params")) {
97
+ const { value, indirect } = await jsonInput(parsed.get("params"), readInput);
98
+ if (!indirect) noInlineSecrets(value);
99
+ Object.assign(values, value);
100
+ }
101
+ if (parsed.get("data")) {
102
+ if (!inputSchema) fail(message("arguments.dataUnsupported"));
103
+ const input = await jsonInput(parsed.get("data"), readInput);
104
+ if (!input.indirect) noInlineSecrets(input.value);
105
+ Object.assign(body, input.value); bodyProvided = true;
106
+ }
107
+ const declaration = (name) => declarations.find((item) => item.name === name || item.in === "header" && item.name.toLowerCase() === name.toLowerCase());
108
+ function apply(name, raw, privateInput) {
109
+ if (!privateInput && sensitive.has(name)) fail(message("arguments.privateFieldRequired"));
110
+ const parameter = declaration(name);
111
+ const property = inputSchema?.properties?.[name];
112
+ if (!parameter && !property) fail(message("arguments.unknownParameter", { name }));
113
+ if (parameter) values[parameter.name] = cast(raw, parameter.schema);
114
+ else { body[name] = cast(raw, resolveSchema(contract, property)); bodyProvided = true; }
115
+ }
116
+ for (const kind of ["param", "param-env", "header"]) for (const pair of parsed.flags.get(kind) ?? []) {
117
+ const index = pair.indexOf("=");
118
+ if (index < 1) fail(message("arguments.nameValueRequired", { flag: kind }));
119
+ const name = pair.slice(0, index), raw = pair.slice(index + 1);
120
+ if (kind === "header" && !declarations.some((item) => item.in === "header" && item.name.toLowerCase() === name.toLowerCase())) fail(message("arguments.headerUndeclared"));
121
+ if (kind === "param-env") {
122
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw) || !env[raw]) fail(message("arguments.environmentValueRequired", { name }));
123
+ apply(name, env[raw], true);
124
+ } else apply(name, raw, false);
125
+ }
126
+ for (const name of [...declarations.map((item) => item.name), ...Object.keys(inputSchema?.properties ?? {})]) {
127
+ if (parsed.get(flagName(name)) !== undefined) {
128
+ const field = declaration(name)?.schema ?? resolveSchema(contract, inputSchema.properties[name]);
129
+ const items = parsed.flags.get(flagName(name));
130
+ apply(name, field?.type === "array" && items.length > 1 ? items : items[0], false);
131
+ }
132
+ }
133
+ for (const key of Object.keys(values)) if (!declaration(key)) fail(message("arguments.unknownRequestParameter", { name: key }));
134
+ const query = new URLSearchParams(), headers = {}, bindings = { ...rawBindings };
135
+ for (const parameter of declarations) {
136
+ let value = values[parameter.name];
137
+ if (parameter.in === "path" && rawBindings[parameter.name] !== undefined) {
138
+ if (value !== undefined && String(value) !== rawBindings[parameter.name]) fail(message("arguments.pathConflict", { name: parameter.name }));
139
+ value = rawBindings[parameter.name];
140
+ }
141
+ if (value === undefined) { if (parameter.required) fail(message("arguments.requiredRequestParameter", { location: parameter.in, name: parameter.name })); continue; }
142
+ value = cast(value, parameter.schema);
143
+ validateJSON(contract, parameter.schema, value, parameter.name);
144
+ if (parameter.in === "path") {
145
+ if (typeof value !== "string" || !value || value === "." || value === ".." || /[/\\?#\x00-\x1f]/.test(value)) fail(message("arguments.invalidPathParameter", { name: parameter.name }));
146
+ bindings[parameter.name] = value;
147
+ } else if (parameter.in === "header") {
148
+ if (["authorization", "cookie", "host", "content-type"].includes(parameter.name.toLowerCase()) || /[\r\n\0]/.test(String(value))) fail(message("arguments.unsafeHeader"));
149
+ headers[parameter.name] = String(value);
150
+ } else if (parameter.in === "query") for (const item of Array.isArray(value) ? value : [value]) query.append(parameter.name, String(item));
151
+ }
152
+ const path = operation.path.replace(/\{([^}]+)\}/g, (_match, key) => {
153
+ if (bindings[key] === undefined) fail(message("arguments.requiredPathParameter", { name: key }));
154
+ return encodeURIComponent(bindings[key]);
155
+ });
156
+ if (operation.requestBody?.required && !bodyProvided) fail(binaryType ? message("arguments.fileRequired") : message("arguments.dataRequired"));
157
+ if (bodyProvided && !binaryBody) validateJSON(contract, inputSchema, body);
158
+ return { path, query, extraHeaders: headers, ...(binaryBody ? { binaryBody, binaryType,
159
+ binarySummary: { media_type: binaryType, bytes: binaryBody.length, width: 256, height: 256, sha256: createHash("sha256").update(binaryBody).digest("hex") } } :
160
+ bodyProvided ? { jsonBody: body } : {}) };
161
+ }
package/src/output.mjs ADDED
@@ -0,0 +1,61 @@
1
+ import { currentLocalizer, message } from "./i18n.mjs";
2
+ import { CLIError, redact } from "./protocol.mjs";
3
+
4
+ export const formats = ["json", "ndjson", "table", "csv"];
5
+ const columnLabels = {
6
+ command: "columns.command", operationId: "columns.operationId", method: "columns.method",
7
+ path: "columns.path", summary: "columns.summary", description: "columns.description", value: "columns.value",
8
+ };
9
+
10
+ export function outputOptions(parsed) {
11
+ const format = parsed.get("json") ? "json" : parsed.get("format") ?? "json";
12
+ if (!formats.includes(format)) throw new CLIError("invalid_arguments", message("arguments.invalidFormat"));
13
+ if (parsed.get("json") && parsed.get("format") && parsed.get("format") !== "json") throw new CLIError("invalid_arguments", message("arguments.formatConflict"));
14
+ const fields = parsed.get("fields")?.split(",").map((value) => value.trim());
15
+ if (fields?.some((value) => !/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(value) || value.split(".").some((part) => ["__proto__", "prototype", "constructor"].includes(part)))) {
16
+ throw new CLIError("invalid_arguments", message("arguments.invalidFields"));
17
+ }
18
+ return { format, fields };
19
+ }
20
+
21
+ function select(value, fields) {
22
+ if (!fields) return value;
23
+ if (Array.isArray(value)) return value.map((row) => select(row, fields));
24
+ return Object.fromEntries(fields.map((field) => [field, field.split(".").reduce((current, key) =>
25
+ current && typeof current === "object" && Object.hasOwn(current, key) ? current[key] : null, value)]));
26
+ }
27
+
28
+ function text(value) {
29
+ const result = value == null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
30
+ return result.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
31
+ }
32
+
33
+ export function success(io, value, { format = "json", fields, identity = "public", raw = false, meta = {} } = {}) {
34
+ const data = select(raw ? value : redact(value), fields);
35
+ const rows = (Array.isArray(data) ? data : [data]).map((row) => row && typeof row === "object" && !Array.isArray(row) ? row : { value: row });
36
+ if (format === "json") {
37
+ io.stdout.write(`${JSON.stringify({ ok: true, identity, data, meta: { ...meta, ...(Array.isArray(data) ? { count: data.length } : {}) } }, null, 2)}\n`);
38
+ } else if (format === "ndjson") {
39
+ for (const row of rows) io.stdout.write(`${JSON.stringify({ ok: true, identity, data: row })}\n`);
40
+ } else {
41
+ const keys = [...new Set(rows.flatMap((row) => Object.keys(row)))];
42
+ if (format === "csv") {
43
+ const quote = (item) => `"${String(item ?? "").replaceAll('"', '""')}"`;
44
+ io.stdout.write(`${[keys, ...rows.map((row) => keys.map((key) => typeof row[key] === "object" && row[key] !== null ? JSON.stringify(row[key]) : row[key]))].map((row) => row.map(quote).join(",")).join("\n")}\n`);
45
+ } else {
46
+ const headings = keys.map(key => Object.hasOwn(columnLabels, key) ? currentLocalizer().t(columnLabels[key]) : key);
47
+ const cells = [headings, ...rows.map((row) => keys.map((key) => text(row[key])))];
48
+ const widths = keys.map((_key, index) => Math.min(80, Math.max(...cells.map((row) => row[index].length))));
49
+ io.stdout.write(`${cells.map((row) => row.map((cell, index) => text(cell).slice(0, widths[index]).padEnd(widths[index])).join(" ").trimEnd()).join("\n")}\n`);
50
+ }
51
+ }
52
+ }
53
+
54
+ export function errorEnvelope(error, cancelled, localizer = currentLocalizer()) {
55
+ const known = error instanceof CLIError;
56
+ const code = cancelled ? "cancelled" : known ? error.code : "cli_error";
57
+ const type = error?.status ? "api" : ["login_required", "token_expired", "missing_scopes"].includes(code) ? "auth" :
58
+ code === "request_failed" ? "transport" : code === "invalid_arguments" || code === "invalid_issuer" ? "validation" : "cli";
59
+ return { ok: false, error: { type, ...(type === "validation" ? { subtype: "invalid_argument" } : {}), code, message: cancelled ? localizer.t("errors.cancelled") : known ? error.message : localizer.t("errors.failed"),
60
+ ...(known && error.status ? { status: error.status } : {}), ...(known && error.retryAfter ? { retry_after: error.retryAfter } : {}) } };
61
+ }
@@ -0,0 +1,148 @@
1
+ import { currentLocalizer, message, translate } from "./i18n.mjs";
2
+ export class CLIError extends Error {
3
+ constructor(code, message, status, retryAfter) {
4
+ super(typeof message === "object" && message?.key ? translate(message.key, message.values) : message);
5
+ this.code = code;
6
+ this.status = status;
7
+ this.retryAfter = retryAfter;
8
+ }
9
+ }
10
+
11
+ export function issuerOrigin(value) {
12
+ let url;
13
+ try { url = new URL(value); } catch { throw new CLIError("invalid_issuer", message("protocol.invalidIssuer")); }
14
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
15
+ if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) || url.username || url.password ||
16
+ url.pathname !== "/" || url.search || url.hash) {
17
+ throw new CLIError("invalid_issuer", message("protocol.insecureIssuer"));
18
+ }
19
+ return url.origin;
20
+ }
21
+
22
+ export function verificationURI(value, issuer) {
23
+ let url;
24
+ try { url = new URL(value); } catch { throw new CLIError("invalid_response", message("protocol.invalidVerificationURI")); }
25
+ if (url.origin !== issuer || url.username || url.password || url.pathname !== "/auth/device") {
26
+ throw new CLIError("invalid_response", message("protocol.verificationIssuerMismatch"));
27
+ }
28
+ return url.href;
29
+ }
30
+
31
+ const secretKeys = new Set(["access_token", "refresh_token", "id_token", "device_code", "client_secret", "secret", "password", "signing_key", "totp_secret", "recovery_codes", "code", "code_verifier", "token"]);
32
+ export function redact(value) {
33
+ if (Array.isArray(value)) return value.map(redact);
34
+ if (!value || typeof value !== "object") return value;
35
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, secretKeys.has(key) ? "[REDACTED]" : redact(item)]));
36
+ }
37
+
38
+ export async function protocolRequest({ issuer, origin = issuer, path, method = "GET", parameters, query, jsonBody, binaryBody, binaryType, extraHeaders = {}, accessToken, clientID, clientSecret, fetcher = fetch, signal }) {
39
+ const targetOrigin = issuerOrigin(origin);
40
+ const url = new URL(path, targetOrigin);
41
+ if (url.origin !== targetOrigin || !path.startsWith("/") || path.startsWith("//") || url.pathname !== path || url.hash || url.search) {
42
+ throw new CLIError("invalid_endpoint", message("protocol.invalidEndpoint"));
43
+ }
44
+ const headers = { ...extraHeaders, Accept: "application/json", "Accept-Language": currentLocalizer().locale };
45
+ const form = new URLSearchParams(parameters);
46
+ if (clientSecret) {
47
+ if (!clientID) throw new CLIError("client_id_required", message("protocol.basicClientRequired"));
48
+ // OAuth Basic credentials use form encoding before Base64 encoding.
49
+ const encode = (value) => new URLSearchParams({ v: value }).toString().slice(2);
50
+ headers.Authorization = `Basic ${Buffer.from(`${encode(clientID)}:${encode(clientSecret)}`).toString("base64")}`;
51
+ form.delete("client_id");
52
+ } else if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
53
+ const management = path.startsWith("/api/v1/");
54
+ if (management) {
55
+ url.search = new URLSearchParams(query).toString();
56
+ if (binaryBody !== undefined) headers["Content-Type"] = binaryType;
57
+ else if (jsonBody !== undefined) headers["Content-Type"] = "application/json";
58
+ } else if (method === "GET") url.search = form.toString();
59
+ else headers["Content-Type"] = "application/x-www-form-urlencoded";
60
+ let response;
61
+ try {
62
+ response = await fetcher(url, {
63
+ method, headers, body: management ? binaryBody ?? (jsonBody === undefined ? undefined : JSON.stringify(jsonBody)) : method === "GET" ? undefined : form,
64
+ redirect: "manual", signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
65
+ });
66
+ } catch {
67
+ throw new CLIError("request_failed", message("protocol.requestUncertain"));
68
+ }
69
+ if (response.status >= 300 && response.status < 400) {
70
+ throw new CLIError("redirect_refused", message("protocol.redirectRefused"), response.status);
71
+ }
72
+ let data;
73
+ const body = await response.text();
74
+ if (!body) data = null;
75
+ else {
76
+ try { data = JSON.parse(body); } catch {
77
+ throw new CLIError("invalid_response", message("protocol.nonJSONResponse"), response.status);
78
+ }
79
+ }
80
+ if (!response.ok) {
81
+ const errorCode = typeof data?.error === "string" ? data.error : data?.error?.code;
82
+ const code = typeof errorCode === "string" && /^[A-Za-z0-9_.:-]{1,80}$/.test(errorCode) ? errorCode : "http_error";
83
+ // Do not echo error descriptions: a server may include submitted secrets in them.
84
+ const retry = Number(response.headers.get("Retry-After"));
85
+ throw new CLIError(code, message("protocol.apiFailed", { code, status: response.status }), response.status,
86
+ Number.isFinite(retry) && retry > 0 ? retry : undefined);
87
+ }
88
+ return data;
89
+ }
90
+
91
+ export function tokenRecord(tokens, context, now = Date.now()) {
92
+ if (!tokens || typeof tokens.access_token !== "string" || !tokens.access_token ||
93
+ !/^bearer$/i.test(tokens.token_type) || !Number.isFinite(tokens.expires_in) || tokens.expires_in <= 0 || tokens.expires_in > 31_536_000 ||
94
+ (tokens.scope !== undefined && typeof tokens.scope !== "string") ||
95
+ (tokens.refresh_token !== undefined && (typeof tokens.refresh_token !== "string" || !tokens.refresh_token))) {
96
+ throw new CLIError("invalid_response", message("protocol.invalidTokenResponse"));
97
+ }
98
+ return { issuer: context.issuer, ...(context.apiOrigin ? { api_origin: context.apiOrigin } : {}), client_id: context.clientID, access_token: tokens.access_token,
99
+ refresh_token: tokens.refresh_token, scope: tokens.scope ?? "", expires_at: new Date(now + tokens.expires_in * 1000).toISOString() };
100
+ }
101
+
102
+ export async function startDevice({ context, request = protocolRequest, now = Date.now, parameters = new URLSearchParams() }) {
103
+ const input = new URLSearchParams(parameters);
104
+ input.set("client_id", context.clientID);
105
+ const device = await request({ ...context, path: "/oauth/device/authorization", method: "POST", parameters: input });
106
+ if (!device || typeof device.device_code !== "string" || !device.device_code || typeof device.user_code !== "string" ||
107
+ !/^[A-Z2-7]{4}-[A-Z2-7]{4}$/.test(device.user_code) || !Number.isInteger(device.interval) || device.interval <= 0 ||
108
+ !Number.isInteger(device.expires_in) || device.expires_in <= 0 || device.expires_in > 3600) {
109
+ throw new CLIError("invalid_response", message("protocol.invalidDeviceResponse"));
110
+ }
111
+ return { issuer: context.issuer, ...(context.apiOrigin ? { api_origin: context.apiOrigin } : {}), client_id: context.clientID, device_code: device.device_code, user_code: device.user_code,
112
+ verification_uri: verificationURI(device.verification_uri_complete ?? device.verification_uri, context.issuer),
113
+ expires_at: new Date(now() + device.expires_in * 1000).toISOString(), interval: device.interval, next_poll_at: now() + device.interval * 1000 };
114
+ }
115
+
116
+ export async function pollDevice({ pending, request = protocolRequest, wait, now = Date.now, persist = async () => {} }) {
117
+ if (pending.exchange_pending) throw new CLIError("login_required", message("protocol.deviceExchangeUncertain"));
118
+ const deadline = Date.parse(pending.expires_at);
119
+ const context = { issuer: pending.issuer, apiOrigin: pending.api_origin, clientID: pending.client_id };
120
+ if (!Number.isFinite(deadline) || !Number.isFinite(pending.next_poll_at) || !Number.isInteger(pending.interval) || pending.interval <= 0 ||
121
+ typeof pending.device_code !== "string" || !pending.device_code || !context.clientID) throw new CLIError("invalid_credentials", message("protocol.invalidPendingDevice"));
122
+ const state = { ...pending };
123
+ const form = new URLSearchParams({ client_id: context.clientID, grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: state.device_code });
124
+ while (now() < deadline) {
125
+ if (state.next_poll_at >= deadline) break;
126
+ await wait(Math.max(0, state.next_poll_at - now()));
127
+ if (now() >= deadline) break;
128
+ state.exchange_pending = true;
129
+ await persist(state);
130
+ try {
131
+ const tokens = await request({ ...context, path: "/oauth/token", method: "POST", parameters: form });
132
+ return tokenRecord(tokens, context, now());
133
+ } catch (error) {
134
+ if (error.code === "slow_down") state.interval += 5;
135
+ else if (error.code !== "authorization_pending") throw error;
136
+ state.exchange_pending = false;
137
+ state.next_poll_at = now() + state.interval * 1000;
138
+ await persist(state);
139
+ }
140
+ }
141
+ throw new CLIError("expired_token", message("protocol.deviceExpired"));
142
+ }
143
+
144
+ export async function deviceLogin({ context, request = protocolRequest, notify, wait, now = Date.now, parameters = new URLSearchParams() }) {
145
+ const pending = await startDevice({ context, request, now, parameters });
146
+ notify({ verification_uri: pending.verification_uri, user_code: pending.user_code, expires_in: Math.ceil((Date.parse(pending.expires_at) - now()) / 1000) });
147
+ return pollDevice({ pending, request, wait, now });
148
+ }
@@ -0,0 +1,11 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ let workspace = false;
4
+ try {
5
+ workspace = JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")).name === "lime-cloud";
6
+ } catch (error) {
7
+ if (error.code !== "ENOENT") throw error;
8
+ }
9
+
10
+ export const contractURL = (name) => new URL(workspace ? `../../../contracts/openapi/${name}.json` : `../dist/${name}.json`, import.meta.url);
11
+ export const localeRegistryURL = new URL(workspace ? "../../../contracts/i18n/locales.json" : "../dist/locales.json", import.meta.url);
@@ -0,0 +1,96 @@
1
+ import { message } from "./i18n.mjs";
2
+ import { constants } from "node:fs";
3
+ import { mkdir, open, rename, unlink } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { randomUUID } from "node:crypto";
7
+ import { CLIError, issuerOrigin } from "./protocol.mjs";
8
+ import { scopeList } from "./input.mjs";
9
+
10
+ export function credentialsPath(env = process.env) {
11
+ return env.LIMECLOUD_CONFIG || join(homedir(), ".config", "limecloud", "credentials.json");
12
+ }
13
+ export function settingsPath(env = process.env) {
14
+ return env.LIMECLOUD_SETTINGS || join(dirname(credentialsPath(env)), "config.json");
15
+ }
16
+
17
+ function checkPrivate(stat) {
18
+ if (!stat.isFile() || (process.platform !== "win32" && ((stat.mode & 0o077) || stat.uid !== process.getuid()))) {
19
+ throw new CLIError("unsafe_credentials", message("storage.unsafeFile"));
20
+ }
21
+ }
22
+
23
+ export class PrivateJSONStore {
24
+ constructor(path) { this.path = path; }
25
+ async read() {
26
+ let file;
27
+ try {
28
+ file = await open(this.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
29
+ checkPrivate(await file.stat());
30
+ const data = JSON.parse(await file.readFile("utf8"));
31
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("Invalid record");
32
+ return data;
33
+ } catch (error) {
34
+ if (error.code === "ENOENT") return null;
35
+ if (error instanceof CLIError) throw error;
36
+ throw new CLIError("invalid_credentials", message("storage.unreadable"));
37
+ } finally { await file?.close(); }
38
+ }
39
+ async write(record) {
40
+ await this.read();
41
+ const temp = `${this.path}.${randomUUID()}.new`;
42
+ const file = await open(temp, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
43
+ try { await file.writeFile(`${JSON.stringify(record)}\n`); await file.sync(); }
44
+ finally { await file.close(); }
45
+ await rename(temp, this.path);
46
+ }
47
+ async withLock(callback) {
48
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
49
+ const lockPath = `${this.path}.lock`;
50
+ let lock;
51
+ try { lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); }
52
+ catch (error) {
53
+ if (error.code === "EEXIST") throw new CLIError("credentials_busy", message("storage.busy"));
54
+ throw error;
55
+ }
56
+ try { await lock.writeFile(`${process.pid}\n`); return await callback(); }
57
+ finally { await lock.close(); await unlink(lockPath); }
58
+ }
59
+ }
60
+
61
+ export class CredentialStore extends PrivateJSONStore {
62
+ async read() {
63
+ const data = await super.read();
64
+ if (!data?.access_token) return null;
65
+ issuerOrigin(data.issuer);
66
+ if (data.api_origin !== undefined) issuerOrigin(data.api_origin);
67
+ if (typeof data.client_id !== "string" || !data.client_id || typeof data.access_token !== "string" ||
68
+ !Number.isFinite(Date.parse(data.expires_at)) ||
69
+ (data.refresh_token !== undefined && typeof data.refresh_token !== "string")) {
70
+ throw new CLIError("invalid_credentials", message("storage.invalidCredentials"));
71
+ }
72
+ return data;
73
+ }
74
+ }
75
+
76
+ export function validateSettings(value) {
77
+ if (Object.keys(value).some((key) => !["issuer", "api_origin", "client_id", "scope"].includes(key))) throw new CLIError("invalid_arguments", message("arguments.configKeys"));
78
+ const result = {};
79
+ if (value.issuer !== undefined) result.issuer = issuerOrigin(value.issuer);
80
+ if (value.api_origin !== undefined) result.api_origin = issuerOrigin(value.api_origin);
81
+ if (value.client_id !== undefined) {
82
+ if (typeof value.client_id !== "string" || !value.client_id || value.client_id.length > 200 || /[\x00-\x20\x7f]/.test(value.client_id)) {
83
+ throw new CLIError("invalid_arguments", message("arguments.invalidClientID"));
84
+ }
85
+ result.client_id = value.client_id;
86
+ }
87
+ if (value.scope !== undefined) {
88
+ if (typeof value.scope !== "string") throw new CLIError("invalid_arguments", message("arguments.invalidScope"));
89
+ result.scope = scopeList(value.scope).join(" ");
90
+ }
91
+ return result;
92
+ }
93
+
94
+ export class SettingsStore extends PrivateJSONStore {
95
+ async read() { const value = await super.read(); return value ? validateSettings(value) : {}; }
96
+ }