feat(idpcli): Add idp CLI (IdpCli) with commands, file-based credential storage, typed request APIs; bump deps and update config
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export interface IIdpCliConfig {
|
||||
idpBaseUrl: string;
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
export interface IStoredCredentials {
|
||||
refreshToken?: string;
|
||||
jwt?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* IdpCli - A Node.js CLI client for idp.global
|
||||
* Uses file-based storage instead of browser webstore
|
||||
*/
|
||||
export class IdpCli {
|
||||
public config: IIdpCliConfig;
|
||||
public configDir: string;
|
||||
public credentialsPath: string;
|
||||
|
||||
public typedsocket: plugins.typedsocket.TypedSocket;
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
private typedsocketDeferred = plugins.smartpromise.defer<plugins.typedsocket.TypedSocket>();
|
||||
|
||||
constructor(configArg: IIdpCliConfig) {
|
||||
this.config = configArg;
|
||||
this.configDir = configArg.configDir || plugins.path.join(plugins.os.homedir(), '.idp-global');
|
||||
this.credentialsPath = plugins.path.join(this.configDir, 'credentials.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure config directory exists
|
||||
*/
|
||||
private ensureConfigDir(): void {
|
||||
if (!plugins.fs.existsSync(this.configDir)) {
|
||||
plugins.fs.mkdirSync(this.configDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store credentials to file
|
||||
*/
|
||||
public storeCredentials(credentials: IStoredCredentials): void {
|
||||
this.ensureConfigDir();
|
||||
plugins.fs.writeFileSync(this.credentialsPath, JSON.stringify(credentials, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load stored credentials
|
||||
*/
|
||||
public loadCredentials(): IStoredCredentials | null {
|
||||
try {
|
||||
if (!plugins.fs.existsSync(this.credentialsPath)) {
|
||||
return null;
|
||||
}
|
||||
const content = plugins.fs.readFileSync(this.credentialsPath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stored credentials (logout)
|
||||
*/
|
||||
public deleteCredentials(): void {
|
||||
try {
|
||||
if (plugins.fs.existsSync(this.credentialsPath)) {
|
||||
plugins.fs.unlinkSync(this.credentialsPath);
|
||||
}
|
||||
} catch {
|
||||
// ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to IDP server via WebSocket
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (this.typedsocketDeferred.status === 'fulfilled') {
|
||||
return;
|
||||
}
|
||||
|
||||
let baseUrl = this.config.idpBaseUrl;
|
||||
if (baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.slice(0, -1);
|
||||
}
|
||||
if (!baseUrl.endsWith('/typedrequest')) {
|
||||
baseUrl = `${baseUrl}/typedrequest`;
|
||||
}
|
||||
|
||||
console.log(`Connecting to ${baseUrl}...`);
|
||||
this.typedsocket = await plugins.typedsocket.TypedSocket.createClient(
|
||||
this.typedrouter,
|
||||
baseUrl
|
||||
);
|
||||
this.typedsocketDeferred.resolve(this.typedsocket);
|
||||
console.log('Connected!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from IDP server
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
if (this.typedsocket) {
|
||||
await this.typedsocket.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Authentication Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Login with email and password
|
||||
*/
|
||||
public async loginWithPassword(email: string, password: string): Promise<boolean> {
|
||||
await this.connect();
|
||||
|
||||
const loginRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||
'loginWithEmailOrUsernameAndPassword'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
username: email,
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (response.refreshToken) {
|
||||
this.storeCredentials({
|
||||
refreshToken: response.refreshToken,
|
||||
});
|
||||
console.log('Login successful!');
|
||||
return true;
|
||||
} else if (response.twoFaNeeded) {
|
||||
console.log('Two-factor authentication required.');
|
||||
return false;
|
||||
} else {
|
||||
console.log('Login failed.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with API token
|
||||
*/
|
||||
public async loginWithApiToken(apiToken: string): Promise<boolean> {
|
||||
await this.connect();
|
||||
|
||||
const loginRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithApiToken>(
|
||||
'loginWithApiToken'
|
||||
);
|
||||
|
||||
const response = await loginRequest.fire({
|
||||
apiToken,
|
||||
});
|
||||
|
||||
if (response.jwt) {
|
||||
this.storeCredentials({
|
||||
jwt: response.jwt,
|
||||
});
|
||||
console.log('Login successful!');
|
||||
return true;
|
||||
} else {
|
||||
console.log('Login failed.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh JWT from stored refresh token
|
||||
*/
|
||||
public async refreshJwt(): Promise<string | null> {
|
||||
const credentials = this.loadCredentials();
|
||||
if (!credentials?.refreshToken) {
|
||||
console.error('No refresh token stored. Please login first.');
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.connect();
|
||||
|
||||
const refreshRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RefreshJwt>(
|
||||
'refreshJwt'
|
||||
);
|
||||
|
||||
const response = await refreshRequest.fire({
|
||||
refreshToken: credentials.refreshToken,
|
||||
});
|
||||
|
||||
if (response.jwt) {
|
||||
this.storeCredentials({
|
||||
...credentials,
|
||||
jwt: response.jwt,
|
||||
});
|
||||
return response.jwt;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout - clear stored credentials
|
||||
*/
|
||||
public async logout(): Promise<void> {
|
||||
const credentials = this.loadCredentials();
|
||||
|
||||
if (credentials?.refreshToken) {
|
||||
try {
|
||||
await this.connect();
|
||||
const logoutRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.ILogoutRequest>(
|
||||
'logout'
|
||||
);
|
||||
await logoutRequest.fire({
|
||||
refreshToken: credentials.refreshToken,
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore errors during server-side logout
|
||||
}
|
||||
}
|
||||
|
||||
this.deleteCredentials();
|
||||
console.log('Logged out successfully.');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get current user info
|
||||
*/
|
||||
public async whoami(): Promise<plugins.idpInterfaces.data.IUser | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const whoIsRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_WhoIs>(
|
||||
'whoIs'
|
||||
);
|
||||
|
||||
const response = await whoIsRequest.fire({ jwt });
|
||||
return response.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user sessions
|
||||
*/
|
||||
public async getSessions(): Promise<plugins.idpInterfaces.request.IReq_GetUserSessions['response']['sessions'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const sessionsRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>(
|
||||
'getUserSessions'
|
||||
);
|
||||
|
||||
const response = await sessionsRequest.fire({ jwt });
|
||||
return response.sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a session
|
||||
*/
|
||||
public async revokeSession(sessionId: string): Promise<boolean> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return false;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const revokeRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>(
|
||||
'revokeSession'
|
||||
);
|
||||
|
||||
const response = await revokeRequest.fire({ jwt, sessionId });
|
||||
return response.success;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Organization Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get organizations for current user
|
||||
*/
|
||||
public async getOrganizations(): Promise<{
|
||||
roles: plugins.idpInterfaces.data.IRole[];
|
||||
organizations: plugins.idpInterfaces.data.IOrganization[];
|
||||
} | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
const user = await this.whoami();
|
||||
if (!user) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const orgsRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetRolesAndOrganizationsForUserId>(
|
||||
'getRolesAndOrganizationsForUserId'
|
||||
);
|
||||
|
||||
const response = await orgsRequest.fire({
|
||||
jwt,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new organization
|
||||
*/
|
||||
public async createOrganization(
|
||||
name: string,
|
||||
slug: string,
|
||||
mode: 'checkAvailability' | 'manifest' = 'manifest'
|
||||
): Promise<plugins.idpInterfaces.request.IReq_CreateOrganization['response'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
const user = await this.whoami();
|
||||
if (!user) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const createRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateOrganization>(
|
||||
'createOrganization'
|
||||
);
|
||||
|
||||
const response = await createRequest.fire({
|
||||
jwt,
|
||||
userId: user.id,
|
||||
organizationName: name,
|
||||
organizationSlug: slug,
|
||||
action: mode,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get organization members
|
||||
*/
|
||||
public async getOrgMembers(
|
||||
organizationId: string
|
||||
): Promise<plugins.idpInterfaces.request.IReq_GetOrgMembers['response']['members'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const membersRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgMembers>(
|
||||
'getOrgMembers'
|
||||
);
|
||||
|
||||
const response = await membersRequest.fire({
|
||||
jwt,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
return response.members;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a user to organization
|
||||
*/
|
||||
public async inviteMember(
|
||||
organizationId: string,
|
||||
email: string,
|
||||
roles: string[] = ['member']
|
||||
): Promise<plugins.idpInterfaces.request.IReq_CreateInvitation['response'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const inviteRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CreateInvitation>(
|
||||
'createInvitation'
|
||||
);
|
||||
|
||||
const response = await inviteRequest.fire({
|
||||
jwt,
|
||||
organizationId,
|
||||
email,
|
||||
roles,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Admin Commands
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check if current user is global admin
|
||||
*/
|
||||
public async checkGlobalAdmin(): Promise<boolean> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return false;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const adminRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CheckGlobalAdmin>(
|
||||
'checkGlobalAdmin'
|
||||
);
|
||||
|
||||
const response = await adminRequest.fire({ jwt });
|
||||
return response.isGlobalAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global app statistics (admin only)
|
||||
*/
|
||||
public async getGlobalAppStats(): Promise<plugins.idpInterfaces.request.IReq_GetGlobalAppStats['response']['apps'] | null> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return null;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const statsRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
||||
'getGlobalAppStats'
|
||||
);
|
||||
|
||||
const response = await statsRequest.fire({ jwt });
|
||||
return response.apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend a user (admin only)
|
||||
*/
|
||||
public async suspendUser(userId: string): Promise<boolean> {
|
||||
const jwt = await this.ensureAuthenticated();
|
||||
if (!jwt) return false;
|
||||
|
||||
await this.connect();
|
||||
|
||||
const suspendRequest = this.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_SuspendUser>(
|
||||
'suspendUser'
|
||||
);
|
||||
|
||||
await suspendRequest.fire({ jwt, userId });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helpers
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Ensure user is authenticated, refresh JWT if needed
|
||||
*/
|
||||
private async ensureAuthenticated(): Promise<string | null> {
|
||||
let credentials = this.loadCredentials();
|
||||
|
||||
if (!credentials) {
|
||||
console.error('Not logged in. Please run: idp login');
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we have a JWT, return it
|
||||
if (credentials.jwt) {
|
||||
return credentials.jwt;
|
||||
}
|
||||
|
||||
// Otherwise, try to get a new JWT from refresh token
|
||||
if (credentials.refreshToken) {
|
||||
const jwt = await this.refreshJwt();
|
||||
return jwt;
|
||||
}
|
||||
|
||||
console.error('No valid credentials. Please run: idp login');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user