@terros-inc/cli 1.0.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/cli.js +2 -0
- package/dist/api/auth.js +13 -0
- package/dist/api/query.js +13 -0
- package/dist/auth/auth0.js +68 -0
- package/dist/auth/constants.js +2 -0
- package/dist/auth/tokens.js +50 -0
- package/dist/auth/types.js +1 -0
- package/dist/commands/auth.js +12 -0
- package/dist/commands/index.js +22 -0
- package/dist/commands/types.js +1 -0
- package/dist/crud/endpoint.js +1 -0
- package/dist/crud/index.js +21 -0
- package/dist/crud/input.js +189 -0
- package/dist/crud/parameters.js +83 -0
- package/dist/crud/types.js +1 -0
- package/dist/crud/util.js +9 -0
- package/dist/index.js +99 -0
- package/dist/messages.js +51 -0
- package/package.json +42 -0
package/cli.js
ADDED
package/dist/api/auth.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { getTokens } from "../auth/tokens.js";
|
|
2
|
+
const API_KEY_ENV = 'TERROS_API_KEY';
|
|
3
|
+
export async function getAuthorizationHeader() {
|
|
4
|
+
const apiKeyEnv = process.env[API_KEY_ENV];
|
|
5
|
+
if (apiKeyEnv) {
|
|
6
|
+
return `ApiKey ${apiKeyEnv}`;
|
|
7
|
+
}
|
|
8
|
+
const tokens = await getTokens();
|
|
9
|
+
if (tokens !== null) {
|
|
10
|
+
return `Bearer ${tokens.access_token}`;
|
|
11
|
+
}
|
|
12
|
+
throw new Error(`CLI not authorized. Run \`terros auth login\` to authenticate or provide an API key in the \`${API_KEY_ENV}\` environment variable.`);
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { getAuthorizationHeader } from "./auth.js";
|
|
2
|
+
export async function queryTerrosAPI(path, input) {
|
|
3
|
+
const authorization = await getAuthorizationHeader();
|
|
4
|
+
const res = await fetch(`https://api.terros.com${path}`, {
|
|
5
|
+
method: 'POST',
|
|
6
|
+
headers: {
|
|
7
|
+
Authorization: authorization,
|
|
8
|
+
'Content-Type': 'application/json',
|
|
9
|
+
},
|
|
10
|
+
body: JSON.stringify(input)
|
|
11
|
+
});
|
|
12
|
+
return await res.json();
|
|
13
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { AUTH0_CLIENT_ID, AUTH0_DOMAIN } from "./constants.js";
|
|
2
|
+
import open from "open";
|
|
3
|
+
import { DateTime } from "luxon";
|
|
4
|
+
export async function signInToAuth0() {
|
|
5
|
+
const res = await fetch(`${AUTH0_DOMAIN}/oauth/device/code`, {
|
|
6
|
+
method: 'POST',
|
|
7
|
+
headers: {
|
|
8
|
+
"Content-Type": 'application/json'
|
|
9
|
+
},
|
|
10
|
+
body: JSON.stringify({
|
|
11
|
+
client_id: AUTH0_CLIENT_ID,
|
|
12
|
+
scope: 'offline_access'
|
|
13
|
+
})
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok)
|
|
16
|
+
throw new Error('Failed to get OAuth device code');
|
|
17
|
+
const body = await res.json();
|
|
18
|
+
const deadline = DateTime.now().plus({ seconds: body.expires_in });
|
|
19
|
+
await open(body.verification_uri_complete);
|
|
20
|
+
console.log(`Confirm that the code in your browser matches: ${body.user_code}`);
|
|
21
|
+
console.log(`If your browser did not open, visit ${body.verification_uri} and enter the code`);
|
|
22
|
+
return await pollForToken(deadline, body.interval, body.device_code);
|
|
23
|
+
}
|
|
24
|
+
function sleep(seconds) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
setTimeout(resolve, seconds * 1000);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async function pollForToken(deadline, interval, deviceCode) {
|
|
30
|
+
await sleep(interval);
|
|
31
|
+
if (deadline < DateTime.now())
|
|
32
|
+
throw new Error('Token was not approved in time');
|
|
33
|
+
const res = await fetch(`${AUTH0_DOMAIN}/oauth/token`, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: {
|
|
36
|
+
"Content-Type": 'application/json',
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
client_id: AUTH0_CLIENT_ID,
|
|
40
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
41
|
+
device_code: deviceCode
|
|
42
|
+
})
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const body = await res.json();
|
|
46
|
+
if (body.error === 'access_denied')
|
|
47
|
+
throw new Error('Authorization request denied');
|
|
48
|
+
return pollForToken(deadline, interval, deviceCode);
|
|
49
|
+
}
|
|
50
|
+
return await res.json();
|
|
51
|
+
}
|
|
52
|
+
export async function refreshTokens(refreshToken) {
|
|
53
|
+
const res = await fetch(`${AUTH0_DOMAIN}/oauth/token`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: {
|
|
56
|
+
'Content-Type': 'application/json'
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
client_id: AUTH0_CLIENT_ID,
|
|
60
|
+
grant_type: 'refresh_token',
|
|
61
|
+
refresh_token: refreshToken
|
|
62
|
+
})
|
|
63
|
+
});
|
|
64
|
+
if (res.ok) {
|
|
65
|
+
return await res.json();
|
|
66
|
+
}
|
|
67
|
+
throw new Error('Unable to refresh token, it may be expired. Run `terros auth login` to sign in again');
|
|
68
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { mkdir, writeFile, readFile } from 'node:fs/promises';
|
|
6
|
+
import { refreshTokens } from "./auth0.js";
|
|
7
|
+
export async function getTokens() {
|
|
8
|
+
const tokens = await readTokens();
|
|
9
|
+
if (tokens === null) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
if (areTokensValid(tokens))
|
|
13
|
+
return tokens;
|
|
14
|
+
const updated = await refreshTokens(tokens.refresh_token);
|
|
15
|
+
return await saveTokens(updated);
|
|
16
|
+
}
|
|
17
|
+
function areTokensValid(tokens) {
|
|
18
|
+
return tokens.expires_at > DateTime.now().minus({ minute: 5 }).toMillis();
|
|
19
|
+
}
|
|
20
|
+
async function readTokens() {
|
|
21
|
+
try {
|
|
22
|
+
const path = await getAuthFilePath();
|
|
23
|
+
const file = await readFile(path, 'utf-8');
|
|
24
|
+
return JSON.parse(file);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function saveTokens(tokenResponse) {
|
|
31
|
+
const expiresAt = DateTime.now().plus({ second: tokenResponse.expires_in }).toMillis();
|
|
32
|
+
const savedTokens = {
|
|
33
|
+
access_token: tokenResponse.access_token,
|
|
34
|
+
refresh_token: tokenResponse.refresh_token,
|
|
35
|
+
id_token: tokenResponse.id_token,
|
|
36
|
+
token_type: tokenResponse.token_type,
|
|
37
|
+
expires_at: expiresAt,
|
|
38
|
+
};
|
|
39
|
+
const authFile = await getAuthFilePath();
|
|
40
|
+
await writeFile(authFile, JSON.stringify(savedTokens));
|
|
41
|
+
return savedTokens;
|
|
42
|
+
}
|
|
43
|
+
async function getAuthFilePath() {
|
|
44
|
+
const home = homedir();
|
|
45
|
+
const configPath = join(home, '.config', 'terros');
|
|
46
|
+
if (!existsSync(configPath)) {
|
|
47
|
+
await mkdir(configPath, { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
return join(configPath, 'auth.json');
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { signInToAuth0 } from "../auth/auth0.js";
|
|
2
|
+
import { saveTokens } from "../auth/tokens.js";
|
|
3
|
+
export const authCommands = {
|
|
4
|
+
login: {
|
|
5
|
+
description: 'Sign in to Terros',
|
|
6
|
+
async run() {
|
|
7
|
+
const tokens = await signInToAuth0();
|
|
8
|
+
await saveTokens(tokens);
|
|
9
|
+
console.log('Signed in successfully');
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { authCommands } from "./auth.js";
|
|
2
|
+
export const commandRegistry = {
|
|
3
|
+
auth: {
|
|
4
|
+
description: 'Manage authentication',
|
|
5
|
+
subcommands: authCommands,
|
|
6
|
+
},
|
|
7
|
+
};
|
|
8
|
+
export function getCommandGroup(command) {
|
|
9
|
+
return commandRegistry[command];
|
|
10
|
+
}
|
|
11
|
+
export function getSubcommand(command, subcommand) {
|
|
12
|
+
return getCommandGroup(command)?.subcommands[subcommand];
|
|
13
|
+
}
|
|
14
|
+
export function getCommandNames() {
|
|
15
|
+
return Object.keys(commandRegistry).sort();
|
|
16
|
+
}
|
|
17
|
+
export function getSubcommandNames(command) {
|
|
18
|
+
const commandGroup = getCommandGroup(command);
|
|
19
|
+
if (!commandGroup)
|
|
20
|
+
return [];
|
|
21
|
+
return Object.keys(commandGroup.subcommands).sort();
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { parse } from 'yaml';
|
|
3
|
+
import { getPathParts } from "./util.js";
|
|
4
|
+
import { getEndpointParameters } from "./parameters.js";
|
|
5
|
+
export function loadEndpoints() {
|
|
6
|
+
const file = readFileSync('./terros.yml', 'utf-8');
|
|
7
|
+
const data = parse(file);
|
|
8
|
+
const entries = Object.entries(data.paths);
|
|
9
|
+
const endpoints = {};
|
|
10
|
+
entries.forEach(([path, config]) => {
|
|
11
|
+
const { group, alias } = getPathParts(path);
|
|
12
|
+
endpoints[group] ??= {};
|
|
13
|
+
const schema = config.post.requestBody.content['application/json'].schema;
|
|
14
|
+
endpoints[group][alias] = {
|
|
15
|
+
path,
|
|
16
|
+
properties: schema,
|
|
17
|
+
parameters: getEndpointParameters(schema, data.components),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
return endpoints;
|
|
21
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
export function buildEndpointInput(endpoint, params) {
|
|
2
|
+
const parsedParams = flattenParsedArgs(params);
|
|
3
|
+
const providedParameterNames = Object.keys(parsedParams);
|
|
4
|
+
const knownParameterNames = new Set(endpoint.parameters.map((parameter) => parameter.name));
|
|
5
|
+
const unknownParameterNames = providedParameterNames.filter((name) => !knownParameterNames.has(name));
|
|
6
|
+
if (unknownParameterNames.length > 0) {
|
|
7
|
+
throw new Error(`Unknown parameter(s): ${unknownParameterNames.map((name) => `--${name}`).join(', ')}`);
|
|
8
|
+
}
|
|
9
|
+
const missingParameters = endpoint.parameters.filter((parameter) => (parameter.required && !Object.hasOwn(parsedParams, parameter.name)));
|
|
10
|
+
if (missingParameters.length > 0) {
|
|
11
|
+
throw new Error(`Missing required parameter(s): ${missingParameters.map((parameter) => `--${parameter.name}`).join(', ')}`);
|
|
12
|
+
}
|
|
13
|
+
const input = {};
|
|
14
|
+
const prefix = getHiddenWrapperPrefix(endpoint);
|
|
15
|
+
for (const parameter of endpoint.parameters) {
|
|
16
|
+
if (!Object.hasOwn(parsedParams, parameter.name))
|
|
17
|
+
continue;
|
|
18
|
+
const value = parseParameterValue(parsedParams[parameter.name], parameter);
|
|
19
|
+
const path = [...prefix, ...parameter.name.split('.').filter(Boolean)];
|
|
20
|
+
setNestedValue(input, path, value);
|
|
21
|
+
}
|
|
22
|
+
return input;
|
|
23
|
+
}
|
|
24
|
+
function flattenParsedArgs(params) {
|
|
25
|
+
const flattened = {};
|
|
26
|
+
for (const [key, value] of Object.entries(params)) {
|
|
27
|
+
if (key === '_')
|
|
28
|
+
continue;
|
|
29
|
+
flattenValue(flattened, key, value);
|
|
30
|
+
}
|
|
31
|
+
return flattened;
|
|
32
|
+
}
|
|
33
|
+
function flattenValue(flattened, prefix, value) {
|
|
34
|
+
if (isRecord(value)) {
|
|
35
|
+
for (const [key, childValue] of Object.entries(value)) {
|
|
36
|
+
flattenValue(flattened, `${prefix}.${key}`, childValue);
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
flattened[prefix] = value;
|
|
41
|
+
}
|
|
42
|
+
function getHiddenWrapperPrefix(endpoint) {
|
|
43
|
+
const schema = endpoint.properties;
|
|
44
|
+
if (!('type' in schema) || schema.type !== 'object')
|
|
45
|
+
return [];
|
|
46
|
+
const propertyNames = Object.keys(schema.properties);
|
|
47
|
+
if (propertyNames.length !== 1)
|
|
48
|
+
return [];
|
|
49
|
+
const wrapperName = propertyNames[0];
|
|
50
|
+
return wrapperName ? [wrapperName] : [];
|
|
51
|
+
}
|
|
52
|
+
function setNestedValue(input, path, value) {
|
|
53
|
+
if (path.length === 0) {
|
|
54
|
+
throw new Error('Cannot set a parameter without a name');
|
|
55
|
+
}
|
|
56
|
+
let current = input;
|
|
57
|
+
for (const key of path.slice(0, -1)) {
|
|
58
|
+
const existing = current[key];
|
|
59
|
+
if (existing === undefined) {
|
|
60
|
+
const next = {};
|
|
61
|
+
current[key] = next;
|
|
62
|
+
current = next;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!isRecord(existing)) {
|
|
66
|
+
throw new Error(`Parameter path conflict at "${key}"`);
|
|
67
|
+
}
|
|
68
|
+
current = existing;
|
|
69
|
+
}
|
|
70
|
+
const leaf = path.at(-1);
|
|
71
|
+
if (!leaf)
|
|
72
|
+
throw new Error('Cannot set a parameter without a name');
|
|
73
|
+
current[leaf] = value;
|
|
74
|
+
}
|
|
75
|
+
function parseParameterValue(value, parameter) {
|
|
76
|
+
return parseValue(value, parameter.type, parameter.name);
|
|
77
|
+
}
|
|
78
|
+
function parseValue(value, type, parameterName) {
|
|
79
|
+
if (type.endsWith('[]')) {
|
|
80
|
+
return parseArrayValue(value, type.slice(0, -2), parameterName);
|
|
81
|
+
}
|
|
82
|
+
if (type.includes('|')) {
|
|
83
|
+
return parseUnionValue(value, type, parameterName);
|
|
84
|
+
}
|
|
85
|
+
switch (type) {
|
|
86
|
+
case 'string':
|
|
87
|
+
return parseStringValue(value, parameterName);
|
|
88
|
+
case 'integer':
|
|
89
|
+
return parseIntegerValue(value, parameterName);
|
|
90
|
+
case 'number':
|
|
91
|
+
return parseNumberValue(value, parameterName);
|
|
92
|
+
case 'boolean':
|
|
93
|
+
return parseBooleanValue(value, parameterName);
|
|
94
|
+
case 'object':
|
|
95
|
+
case 'unknown':
|
|
96
|
+
return parseJsonValue(value, parameterName);
|
|
97
|
+
default:
|
|
98
|
+
return parseJsonValue(value, parameterName);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function parseStringValue(value, parameterName) {
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
throw new Error(`Parameter --${parameterName} expects a single string value`);
|
|
104
|
+
}
|
|
105
|
+
return String(value);
|
|
106
|
+
}
|
|
107
|
+
function parseIntegerValue(value, parameterName) {
|
|
108
|
+
const numberValue = parseNumberValue(value, parameterName);
|
|
109
|
+
if (!Number.isInteger(numberValue)) {
|
|
110
|
+
throw new Error(`Parameter --${parameterName} expects an integer`);
|
|
111
|
+
}
|
|
112
|
+
return numberValue;
|
|
113
|
+
}
|
|
114
|
+
function parseNumberValue(value, parameterName) {
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
throw new Error(`Parameter --${parameterName} expects a single number value`);
|
|
117
|
+
}
|
|
118
|
+
const numberValue = typeof value === 'number' ? value : Number(value);
|
|
119
|
+
if (!Number.isFinite(numberValue)) {
|
|
120
|
+
throw new Error(`Parameter --${parameterName} expects a number`);
|
|
121
|
+
}
|
|
122
|
+
return numberValue;
|
|
123
|
+
}
|
|
124
|
+
function parseBooleanValue(value, parameterName) {
|
|
125
|
+
if (Array.isArray(value)) {
|
|
126
|
+
throw new Error(`Parameter --${parameterName} expects a single boolean value`);
|
|
127
|
+
}
|
|
128
|
+
if (typeof value === 'boolean')
|
|
129
|
+
return value;
|
|
130
|
+
if (typeof value === 'number') {
|
|
131
|
+
if (value === 1)
|
|
132
|
+
return true;
|
|
133
|
+
if (value === 0)
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
if (typeof value === 'string') {
|
|
137
|
+
const normalized = value.toLowerCase();
|
|
138
|
+
if (['true', '1', 'yes'].includes(normalized))
|
|
139
|
+
return true;
|
|
140
|
+
if (['false', '0', 'no'].includes(normalized))
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`Parameter --${parameterName} expects a boolean`);
|
|
144
|
+
}
|
|
145
|
+
function parseArrayValue(value, itemType, parameterName) {
|
|
146
|
+
if (Array.isArray(value)) {
|
|
147
|
+
return value.map((item) => parseValue(item, itemType, parameterName));
|
|
148
|
+
}
|
|
149
|
+
if (typeof value === 'string') {
|
|
150
|
+
const trimmed = value.trim();
|
|
151
|
+
if (trimmed.startsWith('[')) {
|
|
152
|
+
const parsed = parseJsonValue(trimmed, parameterName);
|
|
153
|
+
if (!Array.isArray(parsed)) {
|
|
154
|
+
throw new Error(`Parameter --${parameterName} expects an array`);
|
|
155
|
+
}
|
|
156
|
+
return parsed.map((item) => parseValue(item, itemType, parameterName));
|
|
157
|
+
}
|
|
158
|
+
if (trimmed === '')
|
|
159
|
+
return [];
|
|
160
|
+
return trimmed.split(',').map((item) => parseValue(item, itemType, parameterName));
|
|
161
|
+
}
|
|
162
|
+
return [parseValue(value, itemType, parameterName)];
|
|
163
|
+
}
|
|
164
|
+
function parseUnionValue(value, type, parameterName) {
|
|
165
|
+
const types = type.split('|').map((item) => item.trim());
|
|
166
|
+
const errors = [];
|
|
167
|
+
for (const itemType of types) {
|
|
168
|
+
try {
|
|
169
|
+
return parseValue(value, itemType, parameterName);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`Parameter --${parameterName} does not match any supported type: ${types.join(', ')}. ${errors.at(-1)}`);
|
|
176
|
+
}
|
|
177
|
+
function parseJsonValue(value, parameterName) {
|
|
178
|
+
if (typeof value !== 'string')
|
|
179
|
+
return value;
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(value);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
throw new Error(`Parameter --${parameterName} expects valid JSON`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function isRecord(value) {
|
|
188
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
189
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
function isRefSchema(schema) {
|
|
2
|
+
return '$ref' in schema;
|
|
3
|
+
}
|
|
4
|
+
function isAnyOfSchema(schema) {
|
|
5
|
+
return 'anyOf' in schema;
|
|
6
|
+
}
|
|
7
|
+
function getSchemaType(schema, components) {
|
|
8
|
+
if (isRefSchema(schema))
|
|
9
|
+
return getSchemaType(resolveSchema(schema, components), components);
|
|
10
|
+
if ('oneOf' in schema)
|
|
11
|
+
return schema.oneOf.map((item) => getSchemaType(item, components)).join(' | ');
|
|
12
|
+
if (isAnyOfSchema(schema))
|
|
13
|
+
return schema.anyOf.map((item) => getSchemaType(item, components)).join(' | ');
|
|
14
|
+
if ('type' in schema) {
|
|
15
|
+
if (schema.type === 'array')
|
|
16
|
+
return `${getSchemaType(schema.items, components)}[]`;
|
|
17
|
+
return schema.type;
|
|
18
|
+
}
|
|
19
|
+
return 'unknown';
|
|
20
|
+
}
|
|
21
|
+
function resolveSchema(schema, components, seen = new Set()) {
|
|
22
|
+
if (!isRefSchema(schema))
|
|
23
|
+
return schema;
|
|
24
|
+
const match = schema.$ref.match(/^#\/components\/schemas\/(.+)$/);
|
|
25
|
+
if (!match)
|
|
26
|
+
throw new Error(`Unsupported schema ref: ${schema.$ref}`);
|
|
27
|
+
const schemaName = match[1];
|
|
28
|
+
if (!schemaName)
|
|
29
|
+
throw new Error(`Unsupported schema ref: ${schema.$ref}`);
|
|
30
|
+
if (seen.has(schemaName))
|
|
31
|
+
throw new Error(`Circular schema ref: ${schema.$ref}`);
|
|
32
|
+
const resolved = components.schemas[schemaName];
|
|
33
|
+
if (!resolved)
|
|
34
|
+
throw new Error(`Unknown schema ref: ${schema.$ref}`);
|
|
35
|
+
seen.add(schemaName);
|
|
36
|
+
return resolveSchema(resolved, components, seen);
|
|
37
|
+
}
|
|
38
|
+
function flattenSchema(schema, context) {
|
|
39
|
+
const resolved = resolveSchema(schema, context.components);
|
|
40
|
+
if ('type' in resolved
|
|
41
|
+
&& resolved.type === 'object'
|
|
42
|
+
&& resolved.properties) {
|
|
43
|
+
return Object.entries(resolved.properties).flatMap(([name, childSchema]) => {
|
|
44
|
+
const required = resolved.required?.includes(name) ?? false;
|
|
45
|
+
return flattenSchema(childSchema, {
|
|
46
|
+
components: context.components,
|
|
47
|
+
path: [...context.path, name],
|
|
48
|
+
required: context.required && required,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
name: context.path.join('.'),
|
|
55
|
+
type: getSchemaType(resolved, context.components),
|
|
56
|
+
required: context.required,
|
|
57
|
+
...(schema.description ?? resolved.description
|
|
58
|
+
? { description: schema.description ?? resolved.description }
|
|
59
|
+
: {}),
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
function hideSingleObjectWrapper(parameters) {
|
|
64
|
+
const wrapperNames = new Set(parameters.map((parameter) => parameter.name.split('.')[0]));
|
|
65
|
+
if (wrapperNames.size !== 1)
|
|
66
|
+
return parameters;
|
|
67
|
+
const wrapperName = [...wrapperNames][0];
|
|
68
|
+
if (!wrapperName)
|
|
69
|
+
return parameters;
|
|
70
|
+
return parameters.map((parameter) => ({
|
|
71
|
+
...parameter,
|
|
72
|
+
name: parameter.name.startsWith(`${wrapperName}.`)
|
|
73
|
+
? parameter.name.slice(wrapperName.length + 1)
|
|
74
|
+
: parameter.name,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
export function getEndpointParameters(schema, components) {
|
|
78
|
+
return hideSingleObjectWrapper(flattenSchema(schema, {
|
|
79
|
+
components,
|
|
80
|
+
path: [],
|
|
81
|
+
required: true,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import minimist from 'minimist';
|
|
2
|
+
import { formatCommandsHelp, formatSubcommandParametersHelp, formatSubcommandsHelp, HELP_PARENT_MESSAGE, } from "./messages.js";
|
|
3
|
+
import { loadEndpoints } from "./crud/index.js";
|
|
4
|
+
import { buildEndpointInput } from "./crud/input.js";
|
|
5
|
+
import { queryTerrosAPI } from "./api/query.js";
|
|
6
|
+
import { getCommandGroup, getCommandNames, getSubcommand, getSubcommandNames, } from "./commands/index.js";
|
|
7
|
+
async function main() {
|
|
8
|
+
const params = minimist(process.argv.slice(2));
|
|
9
|
+
const commands = params._;
|
|
10
|
+
if (commands.length === 0) {
|
|
11
|
+
console.log(HELP_PARENT_MESSAGE);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const requestedAlias = commands.at(0);
|
|
15
|
+
if (!requestedAlias) {
|
|
16
|
+
console.log(HELP_PARENT_MESSAGE);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (commands.at(-1) === 'help') {
|
|
20
|
+
showHelp(commands, requestedAlias);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const commandGroup = getCommandGroup(requestedAlias);
|
|
24
|
+
if (commandGroup) {
|
|
25
|
+
const subcommand = commands.at(1);
|
|
26
|
+
if (!subcommand) {
|
|
27
|
+
console.log(formatSubcommandsHelp(requestedAlias, getSubcommandNames(requestedAlias)));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const command = getSubcommand(requestedAlias, subcommand);
|
|
31
|
+
if (!command) {
|
|
32
|
+
console.error(`Unknown subcommand: ${requestedAlias} ${subcommand}`);
|
|
33
|
+
console.log(formatSubcommandsHelp(requestedAlias, getSubcommandNames(requestedAlias)));
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
await command.run({
|
|
38
|
+
params,
|
|
39
|
+
args: commands.slice(2),
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const endpoints = loadEndpoints();
|
|
44
|
+
const endpointGroup = endpoints[requestedAlias];
|
|
45
|
+
if (!endpointGroup) {
|
|
46
|
+
console.error(`Unknown command: ${requestedAlias}`);
|
|
47
|
+
const commandList = [...getCommandNames(), ...Object.keys(endpoints)].sort();
|
|
48
|
+
console.log(formatCommandsHelp(commandList));
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const subcommand = commands.at(1);
|
|
53
|
+
if (!subcommand) {
|
|
54
|
+
console.log(formatSubcommandsHelp(requestedAlias, Object.keys(endpointGroup).sort()));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const endpoint = endpointGroup[subcommand];
|
|
58
|
+
if (!endpoint) {
|
|
59
|
+
console.error(`Unknown subcommand: ${requestedAlias} ${subcommand}`);
|
|
60
|
+
console.log(formatSubcommandsHelp(requestedAlias, Object.keys(endpointGroup).sort()));
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const input = buildEndpointInput(endpoint, params);
|
|
65
|
+
const response = await queryTerrosAPI(endpoint.path, input);
|
|
66
|
+
console.log(JSON.stringify(response, null, 2));
|
|
67
|
+
}
|
|
68
|
+
function showHelp(commands, requestedAlias) {
|
|
69
|
+
const commandGroup = getCommandGroup(requestedAlias);
|
|
70
|
+
if (commandGroup) {
|
|
71
|
+
const subcommand = commands.at(1);
|
|
72
|
+
if (subcommand && commands.length >= 3 && getSubcommand(requestedAlias, subcommand)) {
|
|
73
|
+
console.log(formatSubcommandParametersHelp(requestedAlias, subcommand, []));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
console.log(formatSubcommandsHelp(requestedAlias, getSubcommandNames(requestedAlias)));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const endpoints = loadEndpoints();
|
|
80
|
+
const endpoint = endpoints[requestedAlias];
|
|
81
|
+
if (endpoint) {
|
|
82
|
+
const subcommand = commands.at(1);
|
|
83
|
+
if (subcommand && commands.length >= 3) {
|
|
84
|
+
const requestedSubcommand = endpoint[subcommand];
|
|
85
|
+
if (requestedSubcommand) {
|
|
86
|
+
console.log(formatSubcommandParametersHelp(requestedAlias, subcommand, requestedSubcommand.parameters));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
console.log(formatSubcommandsHelp(requestedAlias, Object.keys(endpoint).sort()));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const commandList = [...getCommandNames(), ...Object.keys(endpoints)].sort();
|
|
94
|
+
console.log(formatCommandsHelp(commandList));
|
|
95
|
+
}
|
|
96
|
+
main().catch((error) => {
|
|
97
|
+
console.error(error instanceof Error ? error.message : error);
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
});
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const HELP_PARENT_MESSAGE = `
|
|
2
|
+
usage: terros <command> <subcommand> [parameters]
|
|
3
|
+
To see help text, you can run:
|
|
4
|
+
|
|
5
|
+
terros help
|
|
6
|
+
terros <command> help
|
|
7
|
+
terros <command> <subcommand> help
|
|
8
|
+
`.trim();
|
|
9
|
+
export function formatCommandsHelp(commands) {
|
|
10
|
+
const lines = [
|
|
11
|
+
`usage: terros <command> <subcommand> [parameters]`,
|
|
12
|
+
'',
|
|
13
|
+
'Commands:',
|
|
14
|
+
...commands.map((command) => ` ${command}`),
|
|
15
|
+
'',
|
|
16
|
+
`Run "terros <command> help" to see subcommands.`,
|
|
17
|
+
];
|
|
18
|
+
return lines.join('\n');
|
|
19
|
+
}
|
|
20
|
+
export function formatSubcommandsHelp(command, subcommands) {
|
|
21
|
+
const lines = [
|
|
22
|
+
`usage: terros ${command} <subcommand> [parameters]`,
|
|
23
|
+
'',
|
|
24
|
+
'Subcommands:',
|
|
25
|
+
...subcommands.map((subcommand) => ` ${subcommand}`),
|
|
26
|
+
'',
|
|
27
|
+
`Run "terros ${command} <subcommand> help" to see subcommand parameters.`,
|
|
28
|
+
];
|
|
29
|
+
return lines.join('\n');
|
|
30
|
+
}
|
|
31
|
+
export function formatSubcommandParametersHelp(command, subcommand, parameters) {
|
|
32
|
+
const lines = [
|
|
33
|
+
`usage: terros ${command} ${subcommand} [parameters]`,
|
|
34
|
+
'',
|
|
35
|
+
'Parameters:',
|
|
36
|
+
];
|
|
37
|
+
if (parameters.length === 0) {
|
|
38
|
+
lines.push(' none');
|
|
39
|
+
return lines.join('\n');
|
|
40
|
+
}
|
|
41
|
+
const nameWidth = Math.max(...parameters.map((parameter) => parameter.name.length));
|
|
42
|
+
const typeWidth = Math.max(...parameters.map((parameter) => parameter.type.length));
|
|
43
|
+
lines.push(...parameters.map((parameter) => {
|
|
44
|
+
const name = parameter.name.padEnd(nameWidth);
|
|
45
|
+
const type = parameter.type.padEnd(typeWidth);
|
|
46
|
+
const required = parameter.required ? 'required' : 'optional';
|
|
47
|
+
const description = parameter.description ? ` ${parameter.description}` : '';
|
|
48
|
+
return ` --${name} ${type} ${required}${description}`;
|
|
49
|
+
}));
|
|
50
|
+
return lines.join('\n');
|
|
51
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"author": "Terros Inc.",
|
|
3
|
+
"bin": {
|
|
4
|
+
"terros": "./cli.js"
|
|
5
|
+
},
|
|
6
|
+
"description": "Command-line interface for Terros Sales platform",
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@types/minimist": "^1.2.5",
|
|
9
|
+
"@types/node": "^24.13.1",
|
|
10
|
+
"typescript": "^6.0.3",
|
|
11
|
+
"@types/luxon": "^3.7.1"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"terros",
|
|
15
|
+
"cli"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"name": "@terros-inc/cli",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=24"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"cli.js",
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"version": "1.0.0",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"luxon": "^3.7.2",
|
|
31
|
+
"minimist": "^1.2.8",
|
|
32
|
+
"open": "^11.0.0",
|
|
33
|
+
"yaml": "^2.9.0"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc",
|
|
40
|
+
"start": "node src/index.ts"
|
|
41
|
+
}
|
|
42
|
+
}
|