572 lines
15 KiB
TypeScript
572 lines
15 KiB
TypeScript
// src/web-cli.ts
|
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
|
// All Rights Reserved
|
|
|
|
import { v4 as uuidv4 } from "uuid";
|
|
|
|
import "./lib/db.js";
|
|
|
|
import ApiClient, { ApiClientStatus } from "./models/api-client.js";
|
|
import User from "./models/user.js";
|
|
import AiProvider from "./models/ai-provider.js";
|
|
|
|
import ApiClientService from "./services/api-client.js";
|
|
import CryptoService from "./services/crypto.js";
|
|
import UserService from "./services/user.js";
|
|
|
|
import { DtpProcess } from "./lib/process.js";
|
|
import { createAiApi, type IAiLogger } from "@gadget/ai";
|
|
import {
|
|
type IAiModel,
|
|
type IAiModelCapabilities,
|
|
type IAiModelSettings,
|
|
} from "@gadget/api";
|
|
|
|
class DtpWebCli extends DtpProcess {
|
|
get name(): string {
|
|
return "DtpWebCli";
|
|
}
|
|
get slug(): string {
|
|
return "dtp-web-cli";
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
async run(argv: string[]): Promise<void> {
|
|
const cmd = argv.shift();
|
|
if (!cmd) {
|
|
throw new Error("must specify command");
|
|
}
|
|
switch (cmd) {
|
|
case "admin":
|
|
return this.onAdminCmd(argv);
|
|
|
|
case "api-client":
|
|
return this.onApiClientCmd(argv);
|
|
|
|
case "user":
|
|
return this.onUserCmd(argv);
|
|
|
|
case "provider":
|
|
return this.onProviderCmd(argv);
|
|
|
|
default:
|
|
break;
|
|
}
|
|
throw new Error(`unknown command: ${cmd}`);
|
|
}
|
|
|
|
async onAdminCmd(argv: string[]): Promise<void> {
|
|
const action = argv.shift();
|
|
if (!action) {
|
|
throw new Error("must specify user command action");
|
|
}
|
|
switch (action) {
|
|
case "grant":
|
|
return this.onAdminGrant(argv);
|
|
case "revoke":
|
|
return this.onAdminRevoke(argv);
|
|
default:
|
|
break;
|
|
}
|
|
throw new Error(`unknown user command action: ${action}`);
|
|
}
|
|
|
|
async onAdminGrant(argv: string[]): Promise<void> {
|
|
const email = argv.shift();
|
|
if (!email) {
|
|
throw new Error("must specify email for admin grant");
|
|
}
|
|
const user = await User.findOne({
|
|
email_lc: email.trim().toLowerCase(),
|
|
});
|
|
if (!user) {
|
|
throw new Error(`user ${email} not found`);
|
|
}
|
|
|
|
user.flags.isAdmin = true;
|
|
await user.save();
|
|
|
|
this.log.info(`admin rights granted to ${email}`);
|
|
}
|
|
|
|
async onAdminRevoke(argv: string[]): Promise<void> {
|
|
const email = argv.shift();
|
|
if (!email) {
|
|
throw new Error("must specify email for admin revoke");
|
|
}
|
|
|
|
const user = await User.findOne({
|
|
email_lc: email.trim().toLowerCase(),
|
|
});
|
|
if (!user) {
|
|
throw new Error(`user ${email} not found`);
|
|
}
|
|
|
|
user.flags.isAdmin = false;
|
|
await user.save();
|
|
|
|
this.log.info(`admin rights revoked from ${email}`);
|
|
}
|
|
|
|
async onApiClientCmd(argv: string[]): Promise<void> {
|
|
const action = argv.shift();
|
|
if (!action) {
|
|
throw new Error("must specify api client command action");
|
|
}
|
|
switch (action) {
|
|
case "add":
|
|
return this.onApiClientAdd(argv);
|
|
|
|
case "ls":
|
|
return this.onApiClientList(argv);
|
|
|
|
case "status":
|
|
return this.onApiClientSetStatus(argv);
|
|
|
|
case "remove":
|
|
return this.onApiClientRemove(argv);
|
|
|
|
default:
|
|
break;
|
|
}
|
|
throw new Error(`unknown api client command action: ${action}`);
|
|
}
|
|
|
|
async onApiClientAdd(argv: string[]): Promise<void> {
|
|
const name = argv.shift();
|
|
const description = argv.shift();
|
|
|
|
const client = await ApiClientService.create({
|
|
name,
|
|
description,
|
|
});
|
|
this.log.info("api client added", {
|
|
client: {
|
|
_id: client._id,
|
|
secret: client.secret,
|
|
name: client.name,
|
|
},
|
|
});
|
|
}
|
|
|
|
async onApiClientList(_argv: string[]): Promise<void> {
|
|
const clients = await ApiClient.find({ status: ApiClientStatus.Active })
|
|
.sort({ name: 1 })
|
|
.lean();
|
|
console.log("Name".padEnd(20), "Client ID".padEnd(24), "Secret");
|
|
console.log(
|
|
"--------------------------------------------------------------------------------",
|
|
);
|
|
for (const client of clients) {
|
|
console.log(client.name.padEnd(20), client._id.toString(), client.secret);
|
|
}
|
|
}
|
|
|
|
async onApiClientSetStatus(argv: string[]): Promise<void> {
|
|
const clientId = argv.shift();
|
|
if (!clientId) {
|
|
throw new Error("client ID is required");
|
|
}
|
|
const client = await ApiClientService.getById(clientId);
|
|
if (!client) {
|
|
throw new Error("Client not found");
|
|
}
|
|
|
|
const status = argv.shift() as ApiClientStatus;
|
|
if (!status) {
|
|
throw new Error("New client status is required");
|
|
}
|
|
|
|
await ApiClientService.setStatus(client, status);
|
|
this.log.info("API client status updated", { _id: clientId, status });
|
|
}
|
|
|
|
async onApiClientRemove(argv: string[]): Promise<void> {
|
|
const clientId = argv.shift();
|
|
if (!clientId) {
|
|
throw new Error("client ID is required");
|
|
}
|
|
const client = await ApiClientService.getById(clientId);
|
|
if (!client) {
|
|
throw new Error("Client not found");
|
|
}
|
|
await ApiClientService.remove(client);
|
|
this.log.info("API client removed", { _id: clientId });
|
|
}
|
|
|
|
async onUserCmd(argv: string[]): Promise<void> {
|
|
const action = argv.shift();
|
|
if (!action) {
|
|
throw new Error("must specify user command action");
|
|
}
|
|
switch (action) {
|
|
case "add":
|
|
return this.onUserAdd(argv);
|
|
case "password":
|
|
return this.onUserPassword(argv);
|
|
case "remove":
|
|
return this.onUserRemove(argv);
|
|
case "ban":
|
|
return this.onUserBan(argv);
|
|
default:
|
|
break;
|
|
}
|
|
throw new Error(`unknown user command action: ${action}`);
|
|
}
|
|
|
|
async onUserAdd(argv: string[]): Promise<void> {
|
|
const email = argv.shift();
|
|
if (!email) {
|
|
throw new Error("must specify email address");
|
|
}
|
|
|
|
const password = argv.shift();
|
|
if (!password) {
|
|
throw new Error("must specify password");
|
|
}
|
|
|
|
const displayName: string | undefined = argv.shift();
|
|
|
|
const user = await UserService.create(email, password, displayName);
|
|
|
|
this.log.info(`user created: id:${user._id}, email:${user.email}`);
|
|
}
|
|
|
|
async onUserRemove(argv: string[]): Promise<void> {
|
|
let email = argv.shift();
|
|
if (!email) {
|
|
throw new Error("must specify email address");
|
|
}
|
|
|
|
email = email.trim().toLowerCase();
|
|
const user = await User.findOne({ email }).lean();
|
|
if (!user) {
|
|
throw new Error(`user not found: ${email}`);
|
|
}
|
|
|
|
await User.deleteOne({ _id: user._id });
|
|
this.log.info(`user ${email} removed`);
|
|
}
|
|
|
|
async onUserBan(argv: string[]): Promise<void> {
|
|
const email = argv.shift();
|
|
if (!email) {
|
|
throw new Error("must specify email address");
|
|
}
|
|
|
|
const email_lc = email.trim().toLowerCase();
|
|
const user = await User.findOne({ email_lc }).lean();
|
|
if (!user) {
|
|
throw new Error(`user not found: ${email}`);
|
|
}
|
|
|
|
await User.updateOne(
|
|
{ _id: user._id },
|
|
{
|
|
$set: {
|
|
"flags.isBanned": true,
|
|
},
|
|
},
|
|
);
|
|
this.log.info(`user ${email} banned`);
|
|
}
|
|
|
|
async onUserUnban(argv: string[]): Promise<void> {
|
|
const email = argv.shift();
|
|
if (!email) {
|
|
throw new Error("must specify email address");
|
|
}
|
|
|
|
const email_lc = email.trim().toLowerCase();
|
|
const user = await User.findOne({ email_lc }).lean();
|
|
if (!user) {
|
|
throw new Error(`user not found: ${email}`);
|
|
}
|
|
|
|
await User.updateOne(
|
|
{ _id: user._id },
|
|
{
|
|
$set: {
|
|
"flags.isBanned": false,
|
|
},
|
|
},
|
|
);
|
|
this.log.info(`user ${email} unbanned`);
|
|
}
|
|
|
|
async onUserPassword(argv: string[]): Promise<void> {
|
|
let email = argv.shift();
|
|
if (!email) {
|
|
throw new Error("must specify email address");
|
|
}
|
|
|
|
const password = argv.shift();
|
|
if (!password) {
|
|
throw new Error("must specify password");
|
|
}
|
|
|
|
const email_lc = email.trim().toLowerCase();
|
|
const user = await User.findOne({ email_lc });
|
|
if (!user) {
|
|
throw new Error(`user not found: ${email}`);
|
|
}
|
|
|
|
user.passwordSalt = uuidv4();
|
|
user.password = await CryptoService.maskPassword(
|
|
user.passwordSalt,
|
|
password,
|
|
);
|
|
await user.save();
|
|
|
|
this.log.info(`user ${email} password changed`);
|
|
}
|
|
|
|
async onProviderCmd(argv: string[]): Promise<void> {
|
|
const action = argv.shift();
|
|
if (!action) {
|
|
throw new Error("must specify provider command action");
|
|
}
|
|
switch (action) {
|
|
case "add":
|
|
return this.onProviderAdd(argv);
|
|
case "ls":
|
|
return this.onProviderList(argv);
|
|
case "status":
|
|
return this.onProviderSetStatus(argv);
|
|
case "remove":
|
|
return this.onProviderRemove(argv);
|
|
case "probe":
|
|
return this.onProviderProbe(argv);
|
|
default:
|
|
break;
|
|
}
|
|
throw new Error(`unknown provider command action: ${action}`);
|
|
}
|
|
|
|
async onProviderAdd(argv: string[]): Promise<void> {
|
|
const name = argv.shift();
|
|
if (!name) {
|
|
throw new Error("must specify provider name");
|
|
}
|
|
|
|
const sdkType = argv.shift();
|
|
if (!sdkType) {
|
|
throw new Error("must specify SDK type (ollama or openai)");
|
|
}
|
|
if (sdkType !== "ollama" && sdkType !== "openai") {
|
|
throw new Error("SDK type must be 'ollama' or 'openai'");
|
|
}
|
|
|
|
const baseUrl = argv.shift();
|
|
if (!baseUrl) {
|
|
throw new Error("must specify base URL");
|
|
}
|
|
|
|
const apiKey = argv.shift();
|
|
if (sdkType === "openai" && !apiKey) {
|
|
throw new Error("API key required for OpenAI providers");
|
|
}
|
|
|
|
// Check if provider with this name already exists
|
|
const existing = await AiProvider.findOne({ baseUrl });
|
|
if (existing) {
|
|
throw new Error(`provider with name '${baseUrl}' already exists`);
|
|
}
|
|
|
|
const provider = new AiProvider({
|
|
name,
|
|
apiType: sdkType,
|
|
baseUrl,
|
|
apiKey: apiKey || "",
|
|
enabled: true,
|
|
models: [],
|
|
lastModelRefresh: new Date(),
|
|
});
|
|
await provider.save();
|
|
|
|
this.log.info("provider added", {
|
|
_id: provider._id,
|
|
name: provider.name,
|
|
apiType: provider.apiType,
|
|
baseUrl: provider.baseUrl,
|
|
});
|
|
|
|
// Auto-probe for models
|
|
this.log.info("probing provider for models...");
|
|
await this.onProviderProbe([provider._id]);
|
|
}
|
|
|
|
async onProviderList(_argv: string[]): Promise<void> {
|
|
const providers = await AiProvider.find({}).sort({ name: 1 }).lean();
|
|
|
|
console.log(
|
|
"Name".padEnd(20),
|
|
"ID".padEnd(24),
|
|
"Type".padEnd(8),
|
|
"URL".padEnd(30),
|
|
"Models",
|
|
"Enabled",
|
|
);
|
|
console.log(
|
|
"------------------------------------------------------------------------------------------------------------",
|
|
);
|
|
for (const provider of providers) {
|
|
console.log(
|
|
provider.name.padEnd(20),
|
|
provider._id.toString().padEnd(24),
|
|
provider.apiType.padEnd(8),
|
|
provider.baseUrl.padEnd(30),
|
|
String(provider.models.length).padEnd(6),
|
|
provider.enabled ? "Yes" : "No",
|
|
);
|
|
}
|
|
}
|
|
|
|
async onProviderSetStatus(argv: string[]): Promise<void> {
|
|
const providerId = argv.shift();
|
|
if (!providerId) {
|
|
throw new Error("provider ID is required");
|
|
}
|
|
const provider = await AiProvider.findById(providerId);
|
|
if (!provider) {
|
|
throw new Error("Provider not found");
|
|
}
|
|
|
|
const status = argv.shift();
|
|
if (!status) {
|
|
throw new Error("New provider status is required (active or inactive)");
|
|
}
|
|
if (status !== "active" && status !== "inactive") {
|
|
throw new Error("Status must be 'active' or 'inactive'");
|
|
}
|
|
|
|
provider.enabled = status === "active";
|
|
await provider.save();
|
|
|
|
this.log.info("Provider status updated", {
|
|
_id: providerId,
|
|
enabled: provider.enabled,
|
|
});
|
|
}
|
|
|
|
async onProviderRemove(argv: string[]): Promise<void> {
|
|
const providerId = argv.shift();
|
|
if (!providerId) {
|
|
throw new Error("provider ID is required");
|
|
}
|
|
const provider = await AiProvider.findById(providerId).select("+apiKey");
|
|
if (!provider) {
|
|
throw new Error("Provider not found");
|
|
}
|
|
this.log.info("Provider removed", {
|
|
_id: providerId,
|
|
name: provider.name,
|
|
});
|
|
}
|
|
|
|
async onProviderProbe(argv: string[]): Promise<void> {
|
|
const providerId = argv.shift();
|
|
if (!providerId) {
|
|
throw new Error("provider ID is required");
|
|
}
|
|
const provider = await AiProvider.findById(providerId).select("+apiKey");
|
|
if (!provider) {
|
|
throw new Error("Provider not found");
|
|
}
|
|
|
|
this.log.info("probing provider for models", {
|
|
name: provider.name,
|
|
apiType: provider.apiType,
|
|
});
|
|
|
|
const cliLogger: IAiLogger = {
|
|
debug: async (msg, meta) => this.log.debug(msg, meta),
|
|
info: async (msg, meta) => this.log.info(msg, meta),
|
|
warn: async (msg, meta) => this.log.warn(msg, meta),
|
|
error: async (msg, meta) => this.log.error(msg, meta),
|
|
};
|
|
|
|
const api = createAiApi(
|
|
{
|
|
_id: provider._id,
|
|
name: provider.name,
|
|
sdk: provider.apiType,
|
|
baseUrl: provider.baseUrl,
|
|
apiKey: provider.apiKey,
|
|
},
|
|
cliLogger,
|
|
);
|
|
|
|
this.log.info("fetching model list from provider...");
|
|
const listResult = await api.listModels();
|
|
this.log.info(`found ${listResult.models.length} models`, {
|
|
count: listResult.models.length,
|
|
});
|
|
|
|
const models: IAiModel[] = [];
|
|
for (const modelInfo of listResult.models) {
|
|
this.log.info(`probing model: ${modelInfo.id}`);
|
|
try {
|
|
const probeResult = await api.probeModel(modelInfo.id);
|
|
|
|
const model: IAiModel = {
|
|
id: modelInfo.id,
|
|
name: modelInfo.name,
|
|
parameterCount: modelInfo.parameterCount,
|
|
parameterLabel: modelInfo.parameterLabel,
|
|
contextWindow: modelInfo.contextWindow,
|
|
capabilities: probeResult.capabilities as IAiModelCapabilities,
|
|
settings: probeResult.settings as IAiModelSettings | undefined,
|
|
};
|
|
|
|
models.push(model);
|
|
this.log.info(`model probed successfully`, {
|
|
model: model.id,
|
|
capabilities: model.capabilities,
|
|
});
|
|
} catch (error) {
|
|
this.log.error(`failed to probe model ${modelInfo.id}`, {
|
|
error: (error as Error).message,
|
|
});
|
|
}
|
|
}
|
|
|
|
provider.models = models;
|
|
provider.lastModelRefresh = new Date();
|
|
await provider.save();
|
|
|
|
this.log.info(`model discovery complete`, {
|
|
totalModels: models.length,
|
|
models: models.map((m) => ({
|
|
id: m.id,
|
|
name: m.name,
|
|
capabilities: m.capabilities,
|
|
})),
|
|
});
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
await this.startServices();
|
|
}
|
|
|
|
async startServices(): Promise<void> {
|
|
await ApiClientService.start();
|
|
await (await import("./services/chat-session.js")).default.start();
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
try {
|
|
const cli = new DtpWebCli();
|
|
await cli.start();
|
|
await cli.run(process.argv.slice(2));
|
|
process.exit(0);
|
|
} catch (error) {
|
|
console.error((error as Error).message);
|
|
process.exit(-1);
|
|
}
|
|
})();
|