@nlxai/cli 1.2.2-alpha.2
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 +19 -0
- package/README.md +86 -0
- package/bin/nlx +6 -0
- package/lib/commands/auth/index.js +11 -0
- package/lib/commands/auth/login.js +145 -0
- package/lib/commands/auth/logout.js +32 -0
- package/lib/commands/auth/switch.js +30 -0
- package/lib/commands/auth/whoami.js +33 -0
- package/lib/commands/data-requests/index.js +5 -0
- package/lib/commands/data-requests/sync.js +328 -0
- package/lib/commands/http.js +60 -0
- package/lib/commands/modalities/check.js +99 -0
- package/lib/commands/modalities/generate.js +27 -0
- package/lib/commands/modalities/index.js +7 -0
- package/lib/commands/test.js +208 -0
- package/lib/index.js +13 -0
- package/lib/utils/index.js +19 -0
- package/package.json +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2020, NLX Inc.
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# `@nlxai/cli`
|
|
2
|
+
|
|
3
|
+
> Tools for integrating with NLX apps
|
|
4
|
+
|
|
5
|
+
**Warning:** This is an alpha package and will likely not work for you. Keep an eye on this space for a more feature complete release later.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
> npm -g i cli
|
|
11
|
+
|
|
12
|
+
> nlx
|
|
13
|
+
|
|
14
|
+
Usage: nlx [options] [command]
|
|
15
|
+
|
|
16
|
+
Intereact with NLX from the command line
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
-h, --help display help for command
|
|
20
|
+
|
|
21
|
+
Commands:
|
|
22
|
+
auth Authentication and user management
|
|
23
|
+
auth login Authenticate with NLX
|
|
24
|
+
auth logout Clear stored authentication tokens
|
|
25
|
+
auth switch Switch to the next saved NLX account
|
|
26
|
+
auth whoami Show current team, role, and available applications count (requires login)
|
|
27
|
+
modalities Work with NLX modalities
|
|
28
|
+
modalities generate [options] Fetch modalities and generate TypeScript interfaces
|
|
29
|
+
modalities check [file] Type check local TypeScript definitions against server schemas
|
|
30
|
+
data-requests Data Requests
|
|
31
|
+
data-requests sync [opts] <input> Sync Data Requests from an OpenAPI Specification or Swagger
|
|
32
|
+
test [options] <applicationId> Run conversation tests for a given application ID
|
|
33
|
+
http [options] <method> <path> [body] Perform an authenticated request to the management API
|
|
34
|
+
help [command] display help for command
|
|
35
|
+
|
|
36
|
+
> nlx auth login
|
|
37
|
+
Please visit https://nlxdev.us.auth0.com/activate?user_code=JCVM-MQHX and enter code: JCVM-MQHX
|
|
38
|
+
Login successful! Access token stored securely.
|
|
39
|
+
|
|
40
|
+
> nlx modalities generate
|
|
41
|
+
✔ TypeScript interfaces written to /Users/jakub.hampl/Programming/nlx/sdk/packages/cli/modalities-types.d.ts
|
|
42
|
+
|
|
43
|
+
> nlx modalities check
|
|
44
|
+
✔ Type check passed: all remote types are assignable to local types.
|
|
45
|
+
|
|
46
|
+
> echo "export interface Foo { bar: string; }" >> modalities-types.d.ts
|
|
47
|
+
|
|
48
|
+
> nlx modalities check
|
|
49
|
+
|
|
50
|
+
ERROR Type check failed:
|
|
51
|
+
- Type/interface 'Foo' does not correspond to any model on the server.
|
|
52
|
+
|
|
53
|
+
> bin/nlx data-requests sync __tests__/input-files/sample-openapi.yaml --dry-run --folder OpenAPI
|
|
54
|
+
ℹ Would create new data request CreateTest POST https://api.example.com/test, but skipping because --dry-run.
|
|
55
|
+
|
|
56
|
+
> bin/nlx data-requests sync __tests__/input-files/sample-openapi.yaml --interactive --folder OpenAPI
|
|
57
|
+
# Interactive session where individual requests can be created or updated starts
|
|
58
|
+
|
|
59
|
+
> bin/nlx test 9bf7404f-8636-4cc6-a33f-cb72ba6a062d --enterprise-region EU
|
|
60
|
+
Fetched 3 tests. Running...
|
|
61
|
+
|
|
62
|
+
ERROR BadTest failed: 0/1
|
|
63
|
+
|
|
64
|
+
✔ SimpleDataRequest 01
|
|
65
|
+
✔ SimpleCarousel 02
|
|
66
|
+
--------------------------------
|
|
67
|
+
1 tests failed
|
|
68
|
+
|
|
69
|
+
ERROR Test: BadTest
|
|
70
|
+
|
|
71
|
+
Assertions met: 0/1
|
|
72
|
+
Transcript:
|
|
73
|
+
1. User: SimpleCarousel
|
|
74
|
+
2. Bot:
|
|
75
|
+
┌─────────────────────────────────────┐
|
|
76
|
+
│Which invoice would you like to view?│
|
|
77
|
+
└─────────────────────────────────────┘
|
|
78
|
+
|
|
79
|
+
3. User: ➡️ Invoice 001
|
|
80
|
+
4. Bot:
|
|
81
|
+
┌───────┐
|
|
82
|
+
│Success│
|
|
83
|
+
└───────┘
|
|
84
|
+
|
|
85
|
+
Debug at: https://dev.platform.nlx.ai/flows/SimpleCarousel
|
|
86
|
+
```
|
package/bin/nlx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { loginCommand } from "./login.js";
|
|
3
|
+
import { logoutCommand } from "./logout.js";
|
|
4
|
+
import { authSwitchCommand } from "./switch.js";
|
|
5
|
+
import { whoamiCommand } from "./whoami.js";
|
|
6
|
+
export const authCommand = new Command("auth")
|
|
7
|
+
.description("Authentication and user management")
|
|
8
|
+
.addCommand(loginCommand)
|
|
9
|
+
.addCommand(logoutCommand)
|
|
10
|
+
.addCommand(authSwitchCommand)
|
|
11
|
+
.addCommand(whoamiCommand);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import open from "open";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import { consola } from "consola";
|
|
7
|
+
import keytar from "keytar";
|
|
8
|
+
export const ACCOUNTS_PATH = path.join(os.homedir(), ".nlx-cli-auth.json");
|
|
9
|
+
async function saveTokens(account, tokenData) {
|
|
10
|
+
await keytar.setPassword("nlx-cli", account, JSON.stringify(tokenData));
|
|
11
|
+
}
|
|
12
|
+
async function loadTokens() {
|
|
13
|
+
try {
|
|
14
|
+
const data = fs.readFileSync(ACCOUNTS_PATH, "utf8");
|
|
15
|
+
const accounts = JSON.parse(data);
|
|
16
|
+
if (accounts.currentAccount) {
|
|
17
|
+
const res = await keytar.getPassword("nlx-cli", accounts.currentAccount);
|
|
18
|
+
if (res)
|
|
19
|
+
return [accounts.currentAccount, JSON.parse(res)];
|
|
20
|
+
}
|
|
21
|
+
throw new Error("No tokens found for current account");
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error("Failed to load tokens");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function refreshTokenIfNeeded() {
|
|
28
|
+
let account, tokens;
|
|
29
|
+
try {
|
|
30
|
+
[account, tokens] = await loadTokens();
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
consola.error("Error loading tokens");
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (!tokens || !tokens.refresh_token)
|
|
37
|
+
return null;
|
|
38
|
+
// Check expiry
|
|
39
|
+
const now = Math.floor(Date.now() / 1000);
|
|
40
|
+
if (tokens.expires_in &&
|
|
41
|
+
tokens.obtained_at &&
|
|
42
|
+
now < tokens.obtained_at + tokens.expires_in - 60) {
|
|
43
|
+
consola.debug("Access token is still valid.");
|
|
44
|
+
return tokens.access_token;
|
|
45
|
+
}
|
|
46
|
+
consola.debug("Access token is expired or invalid. Refreshing...");
|
|
47
|
+
// Refresh
|
|
48
|
+
const res = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
51
|
+
body: new URLSearchParams({
|
|
52
|
+
grant_type: "refresh_token",
|
|
53
|
+
client_id: CLIENT_ID,
|
|
54
|
+
refresh_token: tokens.refresh_token,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
const newTokens = await res.json();
|
|
58
|
+
if (newTokens.access_token) {
|
|
59
|
+
newTokens.refresh_token = newTokens.refresh_token || tokens.refresh_token;
|
|
60
|
+
newTokens.obtained_at = now;
|
|
61
|
+
await saveTokens(account, newTokens);
|
|
62
|
+
return newTokens.access_token;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || "nlxdev.us.auth0.com"; // e.g. 'dev-xxxxxx.us.auth0.com'
|
|
67
|
+
const CLIENT_ID = process.env.AUTH0_CLIENT_ID || "A0qluq7wJQjFjMLle9pvrWWaVHM1QHE3";
|
|
68
|
+
const AUDIENCE = process.env.AUTH0_AUDIENCE || "https://nlxdev.us.auth0.com/api/v2/";
|
|
69
|
+
export const loginCommand = new Command("login")
|
|
70
|
+
.description("Authenticate with NLX")
|
|
71
|
+
.action(async () => {
|
|
72
|
+
// Step 1: Start device flow
|
|
73
|
+
const deviceCodeRes = await fetch(`https://${AUTH0_DOMAIN}/oauth/device/code`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
76
|
+
body: new URLSearchParams({
|
|
77
|
+
client_id: CLIENT_ID,
|
|
78
|
+
scope: "openid profile email offline_access",
|
|
79
|
+
audience: AUDIENCE,
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
const deviceCodeData = await deviceCodeRes.json();
|
|
83
|
+
open(deviceCodeData.verification_uri_complete);
|
|
84
|
+
consola.box(`Please visit ${deviceCodeData.verification_uri_complete} and enter code: ${deviceCodeData.user_code}`);
|
|
85
|
+
// Step 2: Poll for token
|
|
86
|
+
let tokenData;
|
|
87
|
+
while (!tokenData) {
|
|
88
|
+
await new Promise((r) => setTimeout(r, deviceCodeData.interval * 1000));
|
|
89
|
+
const tokenRes = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
92
|
+
body: new URLSearchParams({
|
|
93
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
94
|
+
device_code: deviceCodeData.device_code,
|
|
95
|
+
client_id: CLIENT_ID,
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
const resData = await tokenRes.json();
|
|
99
|
+
if (resData.access_token) {
|
|
100
|
+
tokenData = resData;
|
|
101
|
+
}
|
|
102
|
+
else if (resData.error !== "authorization_pending") {
|
|
103
|
+
consola.error("Error:", resData.error_description || resData.error);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Step 3: Fetch user object
|
|
108
|
+
let accounts = { currentAccount: null, accounts: [] };
|
|
109
|
+
if (fs.existsSync(ACCOUNTS_PATH)) {
|
|
110
|
+
const data = fs.readFileSync(ACCOUNTS_PATH, "utf8");
|
|
111
|
+
accounts = JSON.parse(data);
|
|
112
|
+
}
|
|
113
|
+
const userRes = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, {
|
|
114
|
+
headers: {
|
|
115
|
+
Authorization: `Bearer ${tokenData.access_token}`,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const userData = await userRes.json();
|
|
119
|
+
if (!accounts.currentAccount) {
|
|
120
|
+
await fs.promises.writeFile(ACCOUNTS_PATH, JSON.stringify({
|
|
121
|
+
currentAccount: userData.email,
|
|
122
|
+
accounts: [userData.email],
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
else if (accounts.currentAccount !== userData.email) {
|
|
126
|
+
accounts.currentAccount = userData.email;
|
|
127
|
+
await fs.promises.writeFile(ACCOUNTS_PATH, JSON.stringify({
|
|
128
|
+
currentAccount: userData.email,
|
|
129
|
+
accounts: [userData.email, ...accounts.accounts],
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
// Step 4: Store token securely
|
|
133
|
+
tokenData.obtained_at = Math.floor(Date.now() / 1000);
|
|
134
|
+
await saveTokens(userData.email, tokenData);
|
|
135
|
+
consola.success("Login successful! Access token stored securely.");
|
|
136
|
+
});
|
|
137
|
+
// Example usage: get a valid access token
|
|
138
|
+
export async function ensureToken() {
|
|
139
|
+
const accessToken = await refreshTokenIfNeeded();
|
|
140
|
+
if (!accessToken) {
|
|
141
|
+
consola.error("Not authenticated. Please run 'login' first.");
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
return accessToken;
|
|
145
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { consola } from "consola";
|
|
3
|
+
import { ACCOUNTS_PATH } from "./login.js";
|
|
4
|
+
import keytar from "keytar";
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
export const logoutCommand = new Command("logout")
|
|
7
|
+
.description("Clear stored authentication tokens")
|
|
8
|
+
.action(async () => {
|
|
9
|
+
try {
|
|
10
|
+
let accounts = { currentAccount: null, accounts: [] };
|
|
11
|
+
if (fs.existsSync(ACCOUNTS_PATH)) {
|
|
12
|
+
const data = fs.readFileSync(ACCOUNTS_PATH, "utf8");
|
|
13
|
+
accounts = JSON.parse(data);
|
|
14
|
+
}
|
|
15
|
+
if (accounts.currentAccount) {
|
|
16
|
+
await keytar.deletePassword("nlx-cli", accounts.currentAccount);
|
|
17
|
+
accounts.accounts = accounts.accounts.filter((acc) => acc !== accounts.currentAccount);
|
|
18
|
+
if (accounts.accounts.length > 0) {
|
|
19
|
+
accounts.currentAccount = accounts.accounts[0];
|
|
20
|
+
await fs.promises.writeFile(ACCOUNTS_PATH, JSON.stringify(accounts));
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
await fs.promises.unlink(ACCOUNTS_PATH);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
consola.success("Logged out. Tokens cleared from storage.");
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
consola.error(`Logout failed: ${err?.message || err}`);
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import { consola } from "consola";
|
|
6
|
+
const ACCOUNTS_PATH = path.join(os.homedir(), ".nlx-cli-auth.json");
|
|
7
|
+
export const authSwitchCommand = new Command("switch")
|
|
8
|
+
.description("Switch to the next saved NLX account")
|
|
9
|
+
.action(async () => {
|
|
10
|
+
if (!fs.existsSync(ACCOUNTS_PATH)) {
|
|
11
|
+
consola.error("No accounts file found. Please login first.");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const data = fs.readFileSync(ACCOUNTS_PATH, "utf8");
|
|
15
|
+
const accounts = JSON.parse(data);
|
|
16
|
+
if (!accounts.accounts || accounts.accounts.length === 0) {
|
|
17
|
+
consola.error("No saved accounts found.");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
if (!accounts.currentAccount) {
|
|
21
|
+
accounts.currentAccount = accounts.accounts[0];
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
const idx = accounts.accounts.indexOf(accounts.currentAccount);
|
|
25
|
+
const nextIdx = (idx + 1) % accounts.accounts.length;
|
|
26
|
+
accounts.currentAccount = accounts.accounts[nextIdx];
|
|
27
|
+
}
|
|
28
|
+
fs.writeFileSync(ACCOUNTS_PATH, JSON.stringify(accounts, null, 2));
|
|
29
|
+
consola.success(`Switched to account: ${accounts.currentAccount}`);
|
|
30
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { consola } from "consola";
|
|
3
|
+
import { fetchManagementApi } from "../../utils/index.js";
|
|
4
|
+
const WHOAMI_DESCRIPTION = "Show current team, role, and available applications count (requires login)";
|
|
5
|
+
function countBots(response) {
|
|
6
|
+
if (!response || !Array.isArray(response.bots))
|
|
7
|
+
return 0;
|
|
8
|
+
return response.bots.length;
|
|
9
|
+
}
|
|
10
|
+
export async function runWhoami() {
|
|
11
|
+
const [team, bots] = await Promise.all([
|
|
12
|
+
fetchManagementApi("team"),
|
|
13
|
+
fetchManagementApi("bots"),
|
|
14
|
+
]);
|
|
15
|
+
const teamName = team?.name ?? "Unknown";
|
|
16
|
+
const applicationsCount = countBots(bots);
|
|
17
|
+
consola.success("You are authenticated");
|
|
18
|
+
consola.info(`Workspace: ${teamName}`);
|
|
19
|
+
consola.info(`Role: ${team?.currentRole ?? "Unknown"}`);
|
|
20
|
+
consola.info(`Applications: ${applicationsCount}`);
|
|
21
|
+
}
|
|
22
|
+
export const whoamiCommand = new Command("whoami")
|
|
23
|
+
.description(WHOAMI_DESCRIPTION)
|
|
24
|
+
.action(async () => {
|
|
25
|
+
try {
|
|
26
|
+
await runWhoami();
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
const message = err?.response?.data?.message || err?.message || String(err);
|
|
30
|
+
consola.error(`whoami failed: ${message}`);
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { editor, expand, select } from "@inquirer/prompts";
|
|
2
|
+
import boxen from "boxen";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { fetchManagementApi } from "../../utils/index.js";
|
|
6
|
+
import OASNormalize from "oas-normalize";
|
|
7
|
+
import Oas from "oas";
|
|
8
|
+
import open from "open";
|
|
9
|
+
import { consola } from "consola";
|
|
10
|
+
const categorizeServers = async (spec, interactive) => {
|
|
11
|
+
const { servers } = spec.getDefinition();
|
|
12
|
+
if (servers == null)
|
|
13
|
+
throw new Error("No servers defined in the specification");
|
|
14
|
+
if (servers.length === 1)
|
|
15
|
+
return { production: 0 };
|
|
16
|
+
const guess = servers.reduce((acc, server, idx) => {
|
|
17
|
+
if (server.description != null &&
|
|
18
|
+
server.description.match(/prod(uction)?/i))
|
|
19
|
+
acc.production = idx;
|
|
20
|
+
else if (server.description != null &&
|
|
21
|
+
server.description.match(/dev(elopment)?|staging/i))
|
|
22
|
+
acc.development = idx;
|
|
23
|
+
return acc;
|
|
24
|
+
}, {});
|
|
25
|
+
if (interactive) {
|
|
26
|
+
const servers = spec.getDefinition().servers;
|
|
27
|
+
if (servers && servers.length > 1) {
|
|
28
|
+
const serverChoices = servers.map((srv, idx) => ({
|
|
29
|
+
name: `${srv.url}`,
|
|
30
|
+
value: idx,
|
|
31
|
+
description: srv.description,
|
|
32
|
+
}));
|
|
33
|
+
const prodIdx = await select({
|
|
34
|
+
message: "Select production server:",
|
|
35
|
+
choices: serverChoices,
|
|
36
|
+
default: guess.production,
|
|
37
|
+
});
|
|
38
|
+
const devIdx = await select({
|
|
39
|
+
message: "Select development server:",
|
|
40
|
+
choices: serverChoices.concat([
|
|
41
|
+
{
|
|
42
|
+
name: "No development server",
|
|
43
|
+
value: undefined,
|
|
44
|
+
description: undefined,
|
|
45
|
+
},
|
|
46
|
+
]),
|
|
47
|
+
default: guess.development,
|
|
48
|
+
});
|
|
49
|
+
return { production: prodIdx, development: devIdx };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return guess;
|
|
53
|
+
};
|
|
54
|
+
const resolveAmbiguity = (arr, interactive, id, label) => {
|
|
55
|
+
if (arr.length === 0)
|
|
56
|
+
return Promise.resolve(undefined);
|
|
57
|
+
if (arr.length === 1)
|
|
58
|
+
return Promise.resolve(arr[0]);
|
|
59
|
+
if (interactive) {
|
|
60
|
+
return select({
|
|
61
|
+
message: `Select ${label} in ${id}:`,
|
|
62
|
+
choices: arr.map((item, index) => ({
|
|
63
|
+
name: JSON.stringify(item, null, 2),
|
|
64
|
+
value: item,
|
|
65
|
+
})),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return Promise.resolve(arr[0]);
|
|
69
|
+
};
|
|
70
|
+
const capitalize = (str) => {
|
|
71
|
+
return str[0].toUpperCase() + str.slice(1);
|
|
72
|
+
};
|
|
73
|
+
export const syncCommand = new Command("sync")
|
|
74
|
+
.summary("Sync Data Requests from an OpenAPI Specification or Swagger")
|
|
75
|
+
.description(`Takes an OpenAPI Specification or Swagger file and creates or updates data requests in your NLX account to match the API definition.
|
|
76
|
+
|
|
77
|
+
Supports OpenAPI 2.0, 3.0, 3.1, Swagger in JSON and YAML formats. Also experimentally supports Postman collections.
|
|
78
|
+
|
|
79
|
+
NLX Data Requests only support a subset of HTTP functionality described in the OpenAPI Specification.
|
|
80
|
+
Operations that don't meet the following will be skipped:
|
|
81
|
+
|
|
82
|
+
- Request and response bodies must be in JSON format.
|
|
83
|
+
- Endpoints must either be unsecured or use API key or Bearer token authentication.
|
|
84
|
+
- Operations must not be marked deprecated.
|
|
85
|
+
|
|
86
|
+
The following features are not supported and will be silently ignored:
|
|
87
|
+
|
|
88
|
+
- Query parameters.
|
|
89
|
+
- Cookies.
|
|
90
|
+
- Fancier JSON Schema features outside the NLX data model.
|
|
91
|
+
- Multiple response schemas (only the first 2xx response is used).
|
|
92
|
+
`)
|
|
93
|
+
.argument("<input-spec>", "Path to the OpenAPI Specification or Swagger file")
|
|
94
|
+
.requiredOption("--folder <folder>", "Folder where the data requests will be organized")
|
|
95
|
+
.optionsGroup("Security mechanism:")
|
|
96
|
+
.option("--api-key-secret <secret>", "Name of the NLX Secret containing the secret to use for the API key security mechanism")
|
|
97
|
+
.option("--bearer-secret <secret>", "Name of the NLX Secret containing the secret to use for the Bearer token security mechanism")
|
|
98
|
+
.optionsGroup("Run modes:")
|
|
99
|
+
.option("--dry-run", "Simulate the sync process without making any changes")
|
|
100
|
+
.option("--interactive", "Prompt for each operation before syncing, allowing user to approve, skip, or resolve ambiguities")
|
|
101
|
+
.action(async (inputSpec, options) => {
|
|
102
|
+
const currentData = new Map((await fetchManagementApi("variables?size=1000")).variables.map((variable) => [variable.variableId, variable]));
|
|
103
|
+
let oas = new OASNormalize(inputSpec, { enablePaths: true });
|
|
104
|
+
try {
|
|
105
|
+
await oas.validate();
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
consola.error("Failed to validate OpenAPI Specification:", error);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
const spec = Oas.init((await oas.convert()));
|
|
112
|
+
await spec.dereference();
|
|
113
|
+
const serverIndices = await categorizeServers(spec, options.interactive ?? false);
|
|
114
|
+
const newData = await Promise.all(Object.entries(spec.getPaths()).flatMap(([path, methods]) => {
|
|
115
|
+
return Object.values(methods)
|
|
116
|
+
.filter((operation) => {
|
|
117
|
+
return (operation.getContentType() === "application/json" &&
|
|
118
|
+
!operation.isDeprecated() &&
|
|
119
|
+
!operation.isWebhook());
|
|
120
|
+
})
|
|
121
|
+
.filter((operation) => {
|
|
122
|
+
const keys = Object.keys(operation.prepareSecurity());
|
|
123
|
+
if (keys.length === 0 ||
|
|
124
|
+
keys.includes("Header") ||
|
|
125
|
+
keys.includes("Bearer")) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
consola.warn(`Skipping operation ${operation.getOperationId()} due to unsupported security schemes: ${keys.join(", ")}`);
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
.map(async (operation) => {
|
|
134
|
+
const variableId = capitalize(operation
|
|
135
|
+
.getOperationId()
|
|
136
|
+
.replace(/[^a-zA-Z0-9]+([a-zA-Z0-9]|$)/g, (_, v) => {
|
|
137
|
+
return v.toUpperCase();
|
|
138
|
+
}));
|
|
139
|
+
const codes = await resolveAmbiguity(operation
|
|
140
|
+
.getResponseStatusCodes()
|
|
141
|
+
.map(parseInt)
|
|
142
|
+
.filter((r) => r >= 200 && r < 300), options.interactive, variableId, "response status codes");
|
|
143
|
+
operation
|
|
144
|
+
.getParameters()
|
|
145
|
+
.filter((op) => ["cookie", "query"].includes(op.in))
|
|
146
|
+
.forEach((param) => {
|
|
147
|
+
consola.warn(`${param.in} param ${param.name} not supported, skipping.`);
|
|
148
|
+
});
|
|
149
|
+
let headers = operation.getHeaders().request.map((header) => ({
|
|
150
|
+
key: header,
|
|
151
|
+
value: "",
|
|
152
|
+
dynamic: true,
|
|
153
|
+
required: false,
|
|
154
|
+
}));
|
|
155
|
+
const security = operation.prepareSecurity();
|
|
156
|
+
if (security.Header &&
|
|
157
|
+
security.Header.length === 1 &&
|
|
158
|
+
options.apiKeySecret &&
|
|
159
|
+
security.Header[0].type === "apiKey") {
|
|
160
|
+
if (options.apiKeySecret) {
|
|
161
|
+
const apiSecretSecurity = security.Header.filter((h) => h.type === "apiKey");
|
|
162
|
+
if (apiSecretSecurity.length === 1 &&
|
|
163
|
+
apiSecretSecurity[0].type === "apiKey" &&
|
|
164
|
+
apiSecretSecurity[0].in === "header") {
|
|
165
|
+
const name = apiSecretSecurity[0].name;
|
|
166
|
+
headers = [
|
|
167
|
+
...headers.filter((h) => h.key !== name),
|
|
168
|
+
{
|
|
169
|
+
key: name,
|
|
170
|
+
value: `{${options.apiKeySecret}:NLX.Secret}`,
|
|
171
|
+
dynamic: false,
|
|
172
|
+
required: true,
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (options.bearerSecret) {
|
|
179
|
+
const bearerSecurity = security.Bearer?.filter((h) => h.type === "http" && h.scheme === "bearer") ?? [];
|
|
180
|
+
if (bearerSecurity.length === 1) {
|
|
181
|
+
headers = [
|
|
182
|
+
...headers.filter((h) => h.key !== "Authorization"),
|
|
183
|
+
{
|
|
184
|
+
key: "Authorization",
|
|
185
|
+
value: `Bearer {${options.bearerSecret}:NLX.Secret}`,
|
|
186
|
+
dynamic: false,
|
|
187
|
+
required: true,
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
variableId,
|
|
194
|
+
path: options.folder,
|
|
195
|
+
type: "text",
|
|
196
|
+
description: operation.getSummary() || operation.getDescription(),
|
|
197
|
+
requestSchema: await extractSchema(operation.getRequestBody("application/json") || undefined, options.interactive, variableId, "request schema"),
|
|
198
|
+
responseSchema: await extractSchema(codes ? operation.getResponseAsJSONSchema(codes) : undefined, options.interactive, variableId, "response schema"),
|
|
199
|
+
webhook: {
|
|
200
|
+
implementation: "external",
|
|
201
|
+
method: operation.method.toUpperCase(),
|
|
202
|
+
sendContext: false,
|
|
203
|
+
version: "v3",
|
|
204
|
+
environments: {
|
|
205
|
+
production: {
|
|
206
|
+
url: spec.url(serverIndices.production) + operation.path,
|
|
207
|
+
headers,
|
|
208
|
+
},
|
|
209
|
+
development: serverIndices.development == null
|
|
210
|
+
? undefined
|
|
211
|
+
: {
|
|
212
|
+
url: spec.url(serverIndices.development) +
|
|
213
|
+
operation.path,
|
|
214
|
+
headers,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
}));
|
|
221
|
+
for (const dataRequest of newData) {
|
|
222
|
+
let proceed = true;
|
|
223
|
+
let resolvedRequest = { ...dataRequest };
|
|
224
|
+
let action;
|
|
225
|
+
if (options.interactive) {
|
|
226
|
+
const preview = [
|
|
227
|
+
chalk.bold(dataRequest.webhook.method +
|
|
228
|
+
" " +
|
|
229
|
+
chalk.underline(dataRequest.webhook.environments.production.url)),
|
|
230
|
+
chalk.dim(dataRequest.description),
|
|
231
|
+
"",
|
|
232
|
+
chalk.bold.magenta(`Request Schema:`),
|
|
233
|
+
chalk.white(JSON.stringify(dataRequest.requestSchema, null, 2)),
|
|
234
|
+
"",
|
|
235
|
+
chalk.bold.magenta(`Response Schema:`),
|
|
236
|
+
chalk.white(JSON.stringify(dataRequest.responseSchema, null, 2)),
|
|
237
|
+
].join("\n");
|
|
238
|
+
consola.log(boxen(preview, {
|
|
239
|
+
padding: 1,
|
|
240
|
+
margin: 1,
|
|
241
|
+
borderStyle: "round",
|
|
242
|
+
borderColor: "cyan",
|
|
243
|
+
backgroundColor: "black",
|
|
244
|
+
title: dataRequest.variableId,
|
|
245
|
+
}));
|
|
246
|
+
action = await expand({
|
|
247
|
+
message: "Sync this operation?",
|
|
248
|
+
choices: [
|
|
249
|
+
{ key: "y", name: "Sync", value: "sync" },
|
|
250
|
+
{ key: "n", name: "Skip", value: "skip" },
|
|
251
|
+
{ key: "e", name: "Edit in your $EDITOR", value: "edit" },
|
|
252
|
+
{ key: "u", name: "Sync then edit in UI", value: "edit-ui" },
|
|
253
|
+
],
|
|
254
|
+
default: "y",
|
|
255
|
+
});
|
|
256
|
+
if (action === "skip") {
|
|
257
|
+
proceed = false;
|
|
258
|
+
}
|
|
259
|
+
else if (action === "edit") {
|
|
260
|
+
const res = await editor({
|
|
261
|
+
message: "Open JSON in your editor",
|
|
262
|
+
default: JSON.stringify(dataRequest, null, 2),
|
|
263
|
+
postfix: ".json",
|
|
264
|
+
waitForUseInput: false,
|
|
265
|
+
validate: (text) => {
|
|
266
|
+
try {
|
|
267
|
+
JSON.parse(text);
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return "Invalid JSON";
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
resolvedRequest = JSON.parse(res);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (!proceed)
|
|
279
|
+
continue;
|
|
280
|
+
const existing = currentData.get(resolvedRequest.variableId);
|
|
281
|
+
if (existing) {
|
|
282
|
+
if (!Object.entries(resolvedRequest).every(([key, value]) => {
|
|
283
|
+
return eq(value, existing[key]);
|
|
284
|
+
})) {
|
|
285
|
+
await wrapDataReqCU(false, resolvedRequest, options.dryRun, action);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
await wrapDataReqCU(true, resolvedRequest, options.dryRun, action);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
const wrapDataReqCU = async (create, dataRequest, dryRun, action) => {
|
|
294
|
+
if (dryRun) {
|
|
295
|
+
consola.info(`Would ${create ? "create new" : "update existing"} data request ${dataRequest.variableId} ${dataRequest.webhook.method} ${dataRequest.webhook.environments.production.url}, but skipping because --dry-run.`);
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
consola.start(`${create ? "Creating new" : "Updating existing"} data request ${dataRequest.variableId} ${dataRequest.webhook.method} ${dataRequest.webhook.environments.production.url}...`);
|
|
299
|
+
await fetchManagementApi(`variables/${dataRequest.variableId}`, create ? "PUT" : "POST", dataRequest);
|
|
300
|
+
consola.success(`Successfully ${create ? "Created new" : "Updated existing"} data request ${dataRequest.variableId} ${dataRequest.webhook.method} ${dataRequest.webhook.environments.production.url}.`);
|
|
301
|
+
if (action === "edit-ui") {
|
|
302
|
+
open(`https://dev.platform.nlx.ai/data-requests/${dataRequest.variableId}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const extractSchema = async (schema, interactive, id, label) => {
|
|
307
|
+
if (schema == null)
|
|
308
|
+
return undefined;
|
|
309
|
+
if (Array.isArray(schema) && schema.length > 0) {
|
|
310
|
+
schema = await resolveAmbiguity(schema, interactive, id, label);
|
|
311
|
+
}
|
|
312
|
+
if (schema != null && "schema" in schema) {
|
|
313
|
+
schema = schema.schema;
|
|
314
|
+
}
|
|
315
|
+
return schema;
|
|
316
|
+
};
|
|
317
|
+
const eq = (a, b) => {
|
|
318
|
+
if (a === b)
|
|
319
|
+
return true;
|
|
320
|
+
if (a == null && b == null)
|
|
321
|
+
return true;
|
|
322
|
+
if (typeof a == "object" && typeof b == "object") {
|
|
323
|
+
const aKeys = Object.keys(a);
|
|
324
|
+
const bKeys = Object.keys(b);
|
|
325
|
+
return aKeys.every((key) => eq(a[key], b[key]));
|
|
326
|
+
}
|
|
327
|
+
return false;
|
|
328
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import { fetchManagementApi } from "../utils/index.js";
|
|
4
|
+
import { consola } from "consola";
|
|
5
|
+
export const httpCommand = new Command("http")
|
|
6
|
+
.description("Perform an authenticated request to the management API")
|
|
7
|
+
.argument("<method>", "HTTP method (GET, POST, PUT, DELETE, etc.)")
|
|
8
|
+
.argument("<path>", "API path (e.g. /models)")
|
|
9
|
+
.argument("[body]", "Request body as JSON string, name of a JSON file or -- for Standard Input")
|
|
10
|
+
.option("-p, --paginate", "Enable pagination", false)
|
|
11
|
+
.action(async (method, apiPath, body, opts) => {
|
|
12
|
+
if (body === "--") {
|
|
13
|
+
// Read from stdin
|
|
14
|
+
body = "";
|
|
15
|
+
process.stdin.setEncoding("utf8");
|
|
16
|
+
for await (const chunk of process.stdin)
|
|
17
|
+
body += chunk;
|
|
18
|
+
try {
|
|
19
|
+
body = JSON.parse(body);
|
|
20
|
+
}
|
|
21
|
+
catch (ed) {
|
|
22
|
+
consola.error("Invalid JSON string from stdin: " + ed);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else if (body != null) {
|
|
27
|
+
// Try to parse as file path or JSON string
|
|
28
|
+
try {
|
|
29
|
+
if (fs.existsSync(body)) {
|
|
30
|
+
body = JSON.parse(fs.readFileSync(body, "utf8"));
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
body = JSON.parse(body);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
consola.error("Invalid JSON string or file path: " + body);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
let result = await fetchManagementApi(apiPath +
|
|
42
|
+
(opts.paginate
|
|
43
|
+
? apiPath.includes("?")
|
|
44
|
+
? "&size=1000"
|
|
45
|
+
: "?size=1000"
|
|
46
|
+
: ""), method.toUpperCase(), body);
|
|
47
|
+
let agg;
|
|
48
|
+
if (opts.paginate) {
|
|
49
|
+
const key = Object.keys(result).filter((k) => k !== "nextPageId")[0];
|
|
50
|
+
agg = result[key];
|
|
51
|
+
while (result.nextPageId) {
|
|
52
|
+
result = await fetchManagementApi(apiPath + `?pageId=${result.nextPageId}`, method.toUpperCase(), body);
|
|
53
|
+
agg.push(...result[key]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
agg = result;
|
|
58
|
+
}
|
|
59
|
+
console.log(JSON.stringify(agg, null, 2));
|
|
60
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { promises as fs, existsSync } from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import { fetchManagementApi } from "../../utils/index.js";
|
|
6
|
+
import ts from "typescript";
|
|
7
|
+
import { compile } from "json-schema-to-typescript";
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
import { consola } from "consola";
|
|
10
|
+
export const modalitiesCheckCommand = new Command("check")
|
|
11
|
+
.description("Type check local TypeScript definitions against server schemas")
|
|
12
|
+
.argument("[file]", "TypeScript file to check (e.g. one generated by 'modalities generate')", "modalities-types.d.ts")
|
|
13
|
+
.action(async (file) => {
|
|
14
|
+
// Fetch server models
|
|
15
|
+
const data = await fetchManagementApi(`models`);
|
|
16
|
+
const serverModels = {};
|
|
17
|
+
for (const item of data.items) {
|
|
18
|
+
serverModels[item.modelId] = item.schema;
|
|
19
|
+
}
|
|
20
|
+
// Read local TypeScript file
|
|
21
|
+
const filePath = path.resolve(process.cwd(), file);
|
|
22
|
+
if (!existsSync(filePath)) {
|
|
23
|
+
consola.error(`File not found: ${filePath}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
const tsSource = await fs.readFile(filePath, "utf8");
|
|
27
|
+
// Generate remote types from schemas
|
|
28
|
+
let remoteTypesSource = "";
|
|
29
|
+
for (const modelId in serverModels) {
|
|
30
|
+
const typeName = modelId.replace(/[^a-zA-Z0-9_]/g, "");
|
|
31
|
+
const tsType = await compile(serverModels[modelId], typeName, {
|
|
32
|
+
bannerComment: "",
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
});
|
|
35
|
+
remoteTypesSource += tsType + "\n";
|
|
36
|
+
}
|
|
37
|
+
// Create a virtual TypeScript program with both local and remote types
|
|
38
|
+
const tmpRemotePath = path.join(os.tmpdir(), `modalities-remote-${Date.now()}.d.ts`);
|
|
39
|
+
await fs.writeFile(tmpRemotePath, remoteTypesSource);
|
|
40
|
+
const program = ts.createProgram([filePath, tmpRemotePath], {});
|
|
41
|
+
const checker = program.getTypeChecker();
|
|
42
|
+
// Get local and remote type symbols
|
|
43
|
+
const localSourceFile = program.getSourceFile(filePath);
|
|
44
|
+
const remoteSourceFile = program.getSourceFile(tmpRemotePath);
|
|
45
|
+
if (!localSourceFile || !remoteSourceFile) {
|
|
46
|
+
consola.error("Could not load source files for type checking.");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
// Map type names to symbols
|
|
50
|
+
const getTypeSymbols = (sourceFile) => {
|
|
51
|
+
const symbols = {};
|
|
52
|
+
sourceFile.forEachChild((node) => {
|
|
53
|
+
if (ts.isInterfaceDeclaration(node) ||
|
|
54
|
+
ts.isTypeAliasDeclaration(node)) {
|
|
55
|
+
const name = node.name.text;
|
|
56
|
+
const symbol = checker.getSymbolAtLocation(node.name);
|
|
57
|
+
if (symbol)
|
|
58
|
+
symbols[name] = symbol;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
return symbols;
|
|
62
|
+
};
|
|
63
|
+
const localSymbols = getTypeSymbols(localSourceFile);
|
|
64
|
+
const remoteSymbols = getTypeSymbols(remoteSourceFile);
|
|
65
|
+
// Check assignability
|
|
66
|
+
let errors = [];
|
|
67
|
+
for (const typeName in localSymbols) {
|
|
68
|
+
if (!remoteSymbols[typeName]) {
|
|
69
|
+
errors.push(`Type/interface '${typeName}' does not correspond to any model on the server.`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const localType = checker.getDeclaredTypeOfSymbol(localSymbols[typeName]);
|
|
73
|
+
const remoteType = checker.getDeclaredTypeOfSymbol(remoteSymbols[typeName]);
|
|
74
|
+
if (!checker.isTypeAssignableTo(remoteType, localType)) {
|
|
75
|
+
errors.push(`NLX modality type for '${typeName}' is not assignable to local type.
|
|
76
|
+
|
|
77
|
+
${chalk.bold("NLX modality type definition:")}
|
|
78
|
+
|
|
79
|
+
${remoteSymbols[typeName].declarations?.[0]?.getText() ?? checker.typeToString(remoteType)}
|
|
80
|
+
|
|
81
|
+
${chalk.bold("Local type definition:")}
|
|
82
|
+
|
|
83
|
+
${localSymbols[typeName].declarations?.[0]?.getText() ?? checker.typeToString(localType)}
|
|
84
|
+
`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
await fs.unlink(tmpRemotePath);
|
|
88
|
+
if (errors.length) {
|
|
89
|
+
let errorMsg = "Type check failed:\n";
|
|
90
|
+
for (const err of errors)
|
|
91
|
+
errorMsg += ` - ${err}\n`;
|
|
92
|
+
consola.error(errorMsg.trim());
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
consola.success("Type check passed: all remote types are assignable to local types.");
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { compile } from "json-schema-to-typescript";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { fetchManagementApi } from "../../utils/index.js";
|
|
6
|
+
import { consola } from "consola";
|
|
7
|
+
export const modalitiesGenerateCommand = new Command("generate")
|
|
8
|
+
.description("Fetch modalities and generate TypeScript interfaces")
|
|
9
|
+
.option("-o, --out <file>", "Output TypeScript file", "modalities-types.d.ts")
|
|
10
|
+
.action(async (opts) => {
|
|
11
|
+
const data = await fetchManagementApi(`models`);
|
|
12
|
+
// Generate TypeScript interfaces for each modelId
|
|
13
|
+
let output = "// Auto-generated from NLX\n// Please do not edit manually\n\n";
|
|
14
|
+
for (const item of data.items) {
|
|
15
|
+
const name = item.modelId.replace(/[^a-zA-Z0-9_]/g, "");
|
|
16
|
+
const schema = item.schema;
|
|
17
|
+
const ts = await compile(schema, name, {
|
|
18
|
+
bannerComment: "",
|
|
19
|
+
additionalProperties: false,
|
|
20
|
+
});
|
|
21
|
+
output += ts + "\n";
|
|
22
|
+
}
|
|
23
|
+
// Write to file specified by flag or default
|
|
24
|
+
const outPath = path.resolve(process.cwd(), opts.out);
|
|
25
|
+
fs.writeFileSync(outPath, output);
|
|
26
|
+
consola.success(`TypeScript interfaces written to ${outPath}`);
|
|
27
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { modalitiesGenerateCommand } from "./generate.js";
|
|
3
|
+
import { modalitiesCheckCommand } from "./check.js";
|
|
4
|
+
export const modalitiesCommand = new Command("modalities")
|
|
5
|
+
.description("Work with NLX modalities")
|
|
6
|
+
.addCommand(modalitiesGenerateCommand)
|
|
7
|
+
.addCommand(modalitiesCheckCommand);
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { fetchManagementApi } from "../utils/index.js";
|
|
3
|
+
import { consola } from "consola";
|
|
4
|
+
import { createConversation, } from "@nlxai/core";
|
|
5
|
+
import { ensureToken } from "./auth/login.js";
|
|
6
|
+
import { flatten, uniq } from "ramda";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import boxen from "boxen";
|
|
9
|
+
export const testCommand = new Command("test")
|
|
10
|
+
.description("Run conversation tests for a given application ID")
|
|
11
|
+
.argument("<applicationId>", "Application ID to fetch tests for")
|
|
12
|
+
.option("--env <environment>", "Specify the environment", "production")
|
|
13
|
+
.option("--language <language>", "Specify the language code", "en-US")
|
|
14
|
+
.option("--channel <channel>", "Specify the channel type", "API")
|
|
15
|
+
.option("--applications-url-base-override <url>", "Override the base URL for applications")
|
|
16
|
+
.option("--enterprise-region <region>", "Specify the enterprise region")
|
|
17
|
+
.action(async (applicationId, opts) => {
|
|
18
|
+
try {
|
|
19
|
+
const { tests } = (await fetchManagementApi(`bots/${applicationId}/conversationTests`, "GET"));
|
|
20
|
+
consola.log("Fetched %i tests. Running...", tests.length);
|
|
21
|
+
const baseUrl = getBaseUrl(opts.enterpriseRegion == null, opts.applicationsUrlBaseOverride ?? "", opts.enterpriseRegion ?? "US");
|
|
22
|
+
const applicationUrl = `${baseUrl}/c/${applicationId}/sandbox`;
|
|
23
|
+
consola.debug("Application URL:", applicationUrl);
|
|
24
|
+
const accessToken = await ensureToken();
|
|
25
|
+
const handlerConfig = {
|
|
26
|
+
applicationUrl,
|
|
27
|
+
headers: {
|
|
28
|
+
"nlx-api-key": accessToken,
|
|
29
|
+
Authorization: `Bearer ${accessToken}`,
|
|
30
|
+
},
|
|
31
|
+
environment: opts.env,
|
|
32
|
+
languageCode: opts.language,
|
|
33
|
+
experimental: {
|
|
34
|
+
channelType: opts.channel,
|
|
35
|
+
completeApplicationUrl: true,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const failures = [];
|
|
39
|
+
for await (const test of tests) {
|
|
40
|
+
const result = await runTest(test, handlerConfig, applicationId);
|
|
41
|
+
if (result.met.length === result.total.length) {
|
|
42
|
+
consola.success(test.name);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
failures.push({
|
|
46
|
+
name: test.name,
|
|
47
|
+
met: result.met.length,
|
|
48
|
+
total: result.total.length,
|
|
49
|
+
responses: result.responses,
|
|
50
|
+
intentId: test.steps[0]?.raw?.intentId,
|
|
51
|
+
});
|
|
52
|
+
consola.error(`${test.name} failed: ${result.met.length}/${result.total.length}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (failures.length > 0) {
|
|
56
|
+
consola.log("--------------------------------");
|
|
57
|
+
consola.log("%i tests failed", failures.length);
|
|
58
|
+
failures.forEach((failure) => {
|
|
59
|
+
consola.error("Test: %s", failure.name);
|
|
60
|
+
consola.log("Assertions met: %i/%i", failure.met, failure.total);
|
|
61
|
+
console.log("Transcript:");
|
|
62
|
+
failure.responses.forEach((response, index) => {
|
|
63
|
+
if (response.type === "user") {
|
|
64
|
+
consola.log(` %i. ${chalk.blue("User")}: %s`, index + 1, response.payload.type === "structured"
|
|
65
|
+
? `${chalk.underline(response.payload.intentId)}`
|
|
66
|
+
: response.payload.type == "choice"
|
|
67
|
+
? `➡️ ${response.payload.choiceId}`
|
|
68
|
+
: response.payload.text);
|
|
69
|
+
}
|
|
70
|
+
else if (response.type === "bot") {
|
|
71
|
+
consola.log(` %i. ${chalk.yellow("Bot")}:`, index + 1);
|
|
72
|
+
consola.log(response.payload.messages
|
|
73
|
+
.map((msg) => boxen(msg.text, { margin: { left: 4, bottom: 1 } }))
|
|
74
|
+
.join("\n"));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
consola.log("Debug at: ", chalk.underline(`https://dev.platform.nlx.ai/flows/${failure.intentId}`));
|
|
78
|
+
});
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
consola.success("All tests passed!");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
consola.error("Failed to fetch tests:", err);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
const runTest = async (test, config, applicationId) => {
|
|
91
|
+
const handler = createConversation(config);
|
|
92
|
+
const responses = await replayConversation({
|
|
93
|
+
steps: test.steps,
|
|
94
|
+
conversationHandler: handler,
|
|
95
|
+
});
|
|
96
|
+
const { total, met } = assertionsSummary(responses, test.assertions);
|
|
97
|
+
handler.destroy();
|
|
98
|
+
await fetchReset(applicationId);
|
|
99
|
+
return { total, met, responses };
|
|
100
|
+
};
|
|
101
|
+
const fetchReset = async (applicationId) => {
|
|
102
|
+
return await fetchManagementApi(`bots/${applicationId}/sandbox/reset`, "POST");
|
|
103
|
+
};
|
|
104
|
+
const replayConversation = async ({ steps, conversationHandler, }) => {
|
|
105
|
+
if (steps.length === 0) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
const [firstStep, ...restSteps] = steps;
|
|
109
|
+
if (firstStep.raw.type === "structured" && (firstStep.raw.poll ?? false)) {
|
|
110
|
+
return replayConversation({
|
|
111
|
+
steps: restSteps,
|
|
112
|
+
conversationHandler,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return await new Promise((resolve) => {
|
|
116
|
+
const handleResponse = (responses, newResponse) => {
|
|
117
|
+
if (newResponse == null) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (newResponse.type === "bot" &&
|
|
121
|
+
!(newResponse.payload.metadata?.hasPendingDataRequest ?? false)) {
|
|
122
|
+
setTimeout(() => {
|
|
123
|
+
conversationHandler.unsubscribe(handleResponse);
|
|
124
|
+
if (restSteps.length === 0) {
|
|
125
|
+
resolve(responses);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
resolve(replayConversation({
|
|
129
|
+
steps: restSteps,
|
|
130
|
+
conversationHandler,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
}, 250);
|
|
134
|
+
}
|
|
135
|
+
else if (isTestCompleted(responses, steps)) {
|
|
136
|
+
resolve(responses);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
conversationHandler.subscribe(handleResponse);
|
|
140
|
+
if (firstStep.raw.type === "text") {
|
|
141
|
+
conversationHandler.sendText(firstStep.raw.text, firstStep.raw.context);
|
|
142
|
+
}
|
|
143
|
+
else if (firstStep.raw.type === "choice") {
|
|
144
|
+
// TODO: add response and message indices to fix button click states
|
|
145
|
+
conversationHandler.sendChoice(firstStep.raw.choiceId, firstStep.raw.context);
|
|
146
|
+
}
|
|
147
|
+
else if (firstStep.raw.type === "structured") {
|
|
148
|
+
conversationHandler.sendStructured({
|
|
149
|
+
// eslint-disable-next-line deprecation/deprecation
|
|
150
|
+
intentId: firstStep.raw.intentId,
|
|
151
|
+
choiceId: firstStep.raw.choiceId,
|
|
152
|
+
slots: firstStep.raw.slots,
|
|
153
|
+
}, firstStep.raw.context);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
const isTestCompleted = (responses, steps) => {
|
|
158
|
+
let pendingTestStepIndex = 0;
|
|
159
|
+
let index = 0;
|
|
160
|
+
for (const response of responses) {
|
|
161
|
+
if (response.type === "user") {
|
|
162
|
+
pendingTestStepIndex++;
|
|
163
|
+
}
|
|
164
|
+
// If all test steps have been triggered and there is a subsequent application response available, the test is considered complete
|
|
165
|
+
if (pendingTestStepIndex === steps.length + 1 && response.type === "bot") {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
index++;
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
};
|
|
172
|
+
export const assertionsSummary = (responses, assertions) => {
|
|
173
|
+
const assertionNodes = getAssertionNodes(assertions);
|
|
174
|
+
const applicationResponses = responses.filter((res) => res.type === "bot");
|
|
175
|
+
const payloads = applicationResponses.map((res) => res.payload);
|
|
176
|
+
const nodesHit = applicationResponseTraversedNodeIds(payloads);
|
|
177
|
+
return {
|
|
178
|
+
met: nodesHit.filter((nodeId) => assertionNodes.includes(nodeId)),
|
|
179
|
+
total: assertionNodes,
|
|
180
|
+
};
|
|
181
|
+
};
|
|
182
|
+
const applicationResponseTraversedNodeIds = (application) => uniq(flatten(application.map((payload) => traversalNodeIds(payload.debugEvents ?? []))));
|
|
183
|
+
const traversalNodeIds = (events) => {
|
|
184
|
+
const nodeIds = [];
|
|
185
|
+
events.forEach((event) => {
|
|
186
|
+
if (event.eventType === "NodeTraversal" &&
|
|
187
|
+
typeof event.nodeId === "string") {
|
|
188
|
+
nodeIds.push(event.nodeId);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return nodeIds;
|
|
192
|
+
};
|
|
193
|
+
const getAssertionNodes = (assertions) => {
|
|
194
|
+
return uniq(assertions
|
|
195
|
+
.map(({ nodeId }) => nodeId)
|
|
196
|
+
.filter((nodeId) => nodeId != null));
|
|
197
|
+
};
|
|
198
|
+
const getBaseUrl = (isGa, applicationsUrlBaseOverride, region) => {
|
|
199
|
+
const httpsBaseUrl = isGa
|
|
200
|
+
? `https://dev.apps.nlx.ai`
|
|
201
|
+
: `https://bots.dev.studio.nlx.ai`;
|
|
202
|
+
const baseUrl = applicationsUrlBaseOverride.length > 0
|
|
203
|
+
? applicationsUrlBaseOverride
|
|
204
|
+
: region === "EU"
|
|
205
|
+
? httpsBaseUrl.replace("//", "//eu-central-1.")
|
|
206
|
+
: httpsBaseUrl;
|
|
207
|
+
return baseUrl;
|
|
208
|
+
};
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { program } from "commander";
|
|
2
|
+
import { authCommand } from "./commands/auth/index.js";
|
|
3
|
+
import { modalitiesCommand } from "./commands/modalities/index.js";
|
|
4
|
+
import { dataRequestsCommand } from "./commands/data-requests/index.js";
|
|
5
|
+
import { httpCommand } from "./commands/http.js";
|
|
6
|
+
import { testCommand } from "./commands/test.js";
|
|
7
|
+
program.description("Intereact with NLX from the command line");
|
|
8
|
+
program.addCommand(authCommand);
|
|
9
|
+
program.addCommand(modalitiesCommand);
|
|
10
|
+
program.addCommand(dataRequestsCommand);
|
|
11
|
+
program.addCommand(testCommand);
|
|
12
|
+
program.addCommand(httpCommand);
|
|
13
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ensureToken } from "../commands/auth/login.js";
|
|
2
|
+
import { consola } from "consola";
|
|
3
|
+
const API_BASE_URL = process.env.NLX_API_BASE_URL || "https://api.dev.studio.nlx.ai/v1";
|
|
4
|
+
export const fetchManagementApi = async (path, method = "GET", body) => {
|
|
5
|
+
const accessToken = await ensureToken();
|
|
6
|
+
const res = await fetch(`${API_BASE_URL}/${path}`, {
|
|
7
|
+
headers: {
|
|
8
|
+
Authorization: `Bearer ${accessToken}`,
|
|
9
|
+
Accept: "application/json",
|
|
10
|
+
},
|
|
11
|
+
method,
|
|
12
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
13
|
+
});
|
|
14
|
+
if (!res.ok) {
|
|
15
|
+
consola.error("Failed to fetch:", res.status, await res.text());
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
return await res.json();
|
|
19
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nlxai/cli",
|
|
3
|
+
"version": "1.2.2-alpha.2",
|
|
4
|
+
"description": "Tools for integrating with NLX apps",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"NLX",
|
|
7
|
+
"AI",
|
|
8
|
+
"typescript"
|
|
9
|
+
],
|
|
10
|
+
"type": "module",
|
|
11
|
+
"author": "Jakub Hampl <jakub@nlx.ai>",
|
|
12
|
+
"homepage": "https://github.com/nlxai/sdk#readme",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"main": "lib/index.ts",
|
|
15
|
+
"bin": {
|
|
16
|
+
"npx": "bin/npx"
|
|
17
|
+
},
|
|
18
|
+
"directories": {
|
|
19
|
+
"lib": "lib"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"bin",
|
|
23
|
+
"lib"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/nlxai/sdk.git"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"dev": "vitest",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"build": "tsc",
|
|
33
|
+
"tsc": "tsc"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@inquirer/prompts": "^7.8.4",
|
|
37
|
+
"@nlxai/core": "^1.1.8-alpha.0",
|
|
38
|
+
"boxen": "^8.0.1",
|
|
39
|
+
"chalk": "^4.1.0",
|
|
40
|
+
"commander": "^14.0",
|
|
41
|
+
"consola": "^3.4.2",
|
|
42
|
+
"json-schema-to-typescript": "^15.0.4",
|
|
43
|
+
"keytar": "^7.9.0",
|
|
44
|
+
"oas": "^28.1.0",
|
|
45
|
+
"oas-normalize": "^15.0.1",
|
|
46
|
+
"open": "^8.4.2",
|
|
47
|
+
"typescript": "^5.4.5"
|
|
48
|
+
},
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/nlxai/sdk/issues"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@vitest/coverage-istanbul": "^3.2.4",
|
|
57
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
58
|
+
"@vitest/ui": "^3.2.4",
|
|
59
|
+
"vitest": "^3.2.4"
|
|
60
|
+
},
|
|
61
|
+
"gitHead": "326f4ca5d7362bf8f3c01acfd9f01844fdf53904"
|
|
62
|
+
}
|