@aderis-erp/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/README.md +244 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +5 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +5 -0
- package/dist/commands/access-groups.d.ts +39 -0
- package/dist/commands/access-groups.js +1004 -0
- package/dist/commands/account-statement-runs.d.ts +40 -0
- package/dist/commands/account-statement-runs.js +1004 -0
- package/dist/commands/account-transactions.d.ts +41 -0
- package/dist/commands/account-transactions.js +1020 -0
- package/dist/commands/accounts.d.ts +40 -0
- package/dist/commands/accounts.js +1012 -0
- package/dist/commands/agent-runs.d.ts +39 -0
- package/dist/commands/agent-runs.js +1001 -0
- package/dist/commands/audit-log.d.ts +39 -0
- package/dist/commands/audit-log.js +1000 -0
- package/dist/commands/auth/login.d.ts +14 -0
- package/dist/commands/auth/login.js +116 -0
- package/dist/commands/auth/logout.d.ts +10 -0
- package/dist/commands/auth/logout.js +58 -0
- package/dist/commands/auth/status.d.ts +12 -0
- package/dist/commands/auth/status.js +101 -0
- package/dist/commands/auth/tenant.d.ts +18 -0
- package/dist/commands/auth/tenant.js +168 -0
- package/dist/commands/auth.d.ts +37 -0
- package/dist/commands/auth.js +871 -0
- package/dist/commands/bank-accounts.d.ts +41 -0
- package/dist/commands/bank-accounts.js +1020 -0
- package/dist/commands/bank-integrations.d.ts +39 -0
- package/dist/commands/bank-integrations.js +1011 -0
- package/dist/commands/bank-statements.d.ts +40 -0
- package/dist/commands/bank-statements.js +998 -0
- package/dist/commands/business-partners---contacts.d.ts +41 -0
- package/dist/commands/business-partners---contacts.js +1006 -0
- package/dist/commands/business-partners.d.ts +40 -0
- package/dist/commands/business-partners.js +1012 -0
- package/dist/commands/config/add.d.ts +13 -0
- package/dist/commands/config/add.js +120 -0
- package/dist/commands/config/current.d.ts +9 -0
- package/dist/commands/config/current.js +43 -0
- package/dist/commands/config/delete.d.ts +12 -0
- package/dist/commands/config/delete.js +67 -0
- package/dist/commands/config/list.d.ts +9 -0
- package/dist/commands/config/list.js +50 -0
- package/dist/commands/config/use.d.ts +9 -0
- package/dist/commands/config/use.js +40 -0
- package/dist/commands/credit-card-accounts.d.ts +41 -0
- package/dist/commands/credit-card-accounts.js +1018 -0
- package/dist/commands/documents.d.ts +39 -0
- package/dist/commands/documents.js +1005 -0
- package/dist/commands/dunning-configs.d.ts +39 -0
- package/dist/commands/dunning-configs.js +1006 -0
- package/dist/commands/dunning-reminders.d.ts +39 -0
- package/dist/commands/dunning-reminders.js +1007 -0
- package/dist/commands/dunning-runs.d.ts +39 -0
- package/dist/commands/dunning-runs.js +995 -0
- package/dist/commands/employees.d.ts +43 -0
- package/dist/commands/employees.js +1037 -0
- package/dist/commands/event-processor-mappings.d.ts +39 -0
- package/dist/commands/event-processor-mappings.js +1005 -0
- package/dist/commands/files-internal.d.ts +39 -0
- package/dist/commands/files-internal.js +1007 -0
- package/dist/commands/health.d.ts +39 -0
- package/dist/commands/health.js +1001 -0
- package/dist/commands/inbox.d.ts +39 -0
- package/dist/commands/inbox.js +997 -0
- package/dist/commands/integrations.d.ts +40 -0
- package/dist/commands/integrations.js +1001 -0
- package/dist/commands/item-categories.d.ts +39 -0
- package/dist/commands/item-categories.js +1006 -0
- package/dist/commands/items.d.ts +39 -0
- package/dist/commands/items.js +1006 -0
- package/dist/commands/journal-lines.d.ts +39 -0
- package/dist/commands/journal-lines.js +998 -0
- package/dist/commands/journals.d.ts +41 -0
- package/dist/commands/journals.js +1022 -0
- package/dist/commands/ledgers.d.ts +41 -0
- package/dist/commands/ledgers.js +1019 -0
- package/dist/commands/office-locations.d.ts +39 -0
- package/dist/commands/office-locations.js +1006 -0
- package/dist/commands/payment-method-ledger-mappings.d.ts +40 -0
- package/dist/commands/payment-method-ledger-mappings.js +1000 -0
- package/dist/commands/payment-methods.d.ts +39 -0
- package/dist/commands/payment-methods.js +1006 -0
- package/dist/commands/payment-runs.d.ts +41 -0
- package/dist/commands/payment-runs.js +1021 -0
- package/dist/commands/payment-terms.d.ts +39 -0
- package/dist/commands/payment-terms.js +1007 -0
- package/dist/commands/price-lists.d.ts +40 -0
- package/dist/commands/price-lists.js +1012 -0
- package/dist/commands/processing-logs.d.ts +39 -0
- package/dist/commands/processing-logs.js +997 -0
- package/dist/commands/processor-mappings.d.ts +39 -0
- package/dist/commands/processor-mappings.js +1004 -0
- package/dist/commands/processors.d.ts +39 -0
- package/dist/commands/processors.js +1010 -0
- package/dist/commands/reconciliation.d.ts +39 -0
- package/dist/commands/reconciliation.js +1003 -0
- package/dist/commands/reports.d.ts +39 -0
- package/dist/commands/reports.js +996 -0
- package/dist/commands/salary-types.d.ts +39 -0
- package/dist/commands/salary-types.js +1006 -0
- package/dist/commands/sales-invoices.d.ts +42 -0
- package/dist/commands/sales-invoices.js +1033 -0
- package/dist/commands/sales-orders.d.ts +42 -0
- package/dist/commands/sales-orders.js +1030 -0
- package/dist/commands/switch.d.ts +6 -0
- package/dist/commands/switch.js +41 -0
- package/dist/commands/tags.d.ts +39 -0
- package/dist/commands/tags.js +1006 -0
- package/dist/commands/tax-codes.d.ts +39 -0
- package/dist/commands/tax-codes.js +1009 -0
- package/dist/commands/tax-matrix.d.ts +39 -0
- package/dist/commands/tax-matrix.js +1006 -0
- package/dist/commands/tenants.d.ts +39 -0
- package/dist/commands/tenants.js +1006 -0
- package/dist/commands/text-templates.d.ts +39 -0
- package/dist/commands/text-templates.js +1006 -0
- package/dist/commands/tools.d.ts +39 -0
- package/dist/commands/tools.js +999 -0
- package/dist/commands/vendor-invoices.d.ts +43 -0
- package/dist/commands/vendor-invoices.js +1037 -0
- package/dist/commands/webhooks.d.ts +39 -0
- package/dist/commands/webhooks.js +1008 -0
- package/dist/commands/workflows.d.ts +39 -0
- package/dist/commands/workflows.js +1000 -0
- package/dist/hooks/command-not-found.d.ts +3 -0
- package/dist/hooks/command-not-found.js +57 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/api-client.d.ts +40 -0
- package/dist/lib/api-client.js +120 -0
- package/dist/lib/auth.d.ts +20 -0
- package/dist/lib/auth.js +431 -0
- package/dist/lib/base-api-command.d.ts +19 -0
- package/dist/lib/base-api-command.js +173 -0
- package/dist/lib/config.d.ts +40 -0
- package/dist/lib/config.js +113 -0
- package/dist/lib/openapi.d.ts +82 -0
- package/dist/lib/openapi.js +100 -0
- package/oclif.manifest.json +9040 -0
- package/package.json +91 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { toConfiguredId } from '@oclif/core';
|
|
2
|
+
import { cyan, yellow } from 'ansis';
|
|
3
|
+
// Simple Levenshtein distance for finding closest match
|
|
4
|
+
function levenshtein(a, b) {
|
|
5
|
+
const matrix = [];
|
|
6
|
+
for (let i = 0; i <= b.length; i++)
|
|
7
|
+
matrix[i] = [i];
|
|
8
|
+
for (let j = 0; j <= a.length; j++)
|
|
9
|
+
matrix[0][j] = j;
|
|
10
|
+
for (let i = 1; i <= b.length; i++) {
|
|
11
|
+
for (let j = 1; j <= a.length; j++) {
|
|
12
|
+
matrix[i][j] =
|
|
13
|
+
b.charAt(i - 1) === a.charAt(j - 1)
|
|
14
|
+
? matrix[i - 1][j - 1]
|
|
15
|
+
: Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return matrix[b.length][a.length];
|
|
19
|
+
}
|
|
20
|
+
function closest(input, candidates) {
|
|
21
|
+
let best;
|
|
22
|
+
let bestDistance = Infinity;
|
|
23
|
+
for (const candidate of candidates) {
|
|
24
|
+
const distance = levenshtein(input.toLowerCase(), candidate.toLowerCase());
|
|
25
|
+
// Only suggest if reasonably close (less than half the length difference)
|
|
26
|
+
if (distance < bestDistance && distance <= Math.max(input.length, candidate.length) / 2) {
|
|
27
|
+
bestDistance = distance;
|
|
28
|
+
best = candidate;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return best;
|
|
32
|
+
}
|
|
33
|
+
const hook = async function (opts) {
|
|
34
|
+
const id = opts.id;
|
|
35
|
+
const hiddenCommandIds = new Set(opts.config.commands.filter((c) => c.hidden).map((c) => c.id));
|
|
36
|
+
const commandIDs = [...opts.config.commandIDs, ...opts.config.commands.flatMap((c) => c.aliases)].filter((c) => !hiddenCommandIds.has(c));
|
|
37
|
+
if (commandIDs.length === 0)
|
|
38
|
+
return;
|
|
39
|
+
let binHelp = `${opts.config.bin} help`;
|
|
40
|
+
const idSplit = id.split(':');
|
|
41
|
+
if (opts.config.findTopic(idSplit[0])) {
|
|
42
|
+
binHelp = `${binHelp} ${idSplit[0]}`;
|
|
43
|
+
}
|
|
44
|
+
// Try to find suggestion based on first part of command (topic)
|
|
45
|
+
// This handles cases like "svendor-invoices:get:abc" -> "vendor-invoices"
|
|
46
|
+
const firstPart = idSplit[0];
|
|
47
|
+
const topics = [...new Set(commandIDs.map((c) => c.split(':')[0]))];
|
|
48
|
+
const suggestion = closest(firstPart, topics) || closest(id, commandIDs);
|
|
49
|
+
const originalCmd = toConfiguredId(id, this.config);
|
|
50
|
+
this.warn(`${yellow(originalCmd)} is not a ${opts.config.bin} command.`);
|
|
51
|
+
if (suggestion) {
|
|
52
|
+
const readableSuggestion = toConfiguredId(suggestion, this.config);
|
|
53
|
+
this.log(`Did you mean ${cyan.bold(readableSuggestion)}?`);
|
|
54
|
+
}
|
|
55
|
+
this.error(`Run ${cyan.bold(binHelp)} for a list of available commands.`, { exit: 127 });
|
|
56
|
+
};
|
|
57
|
+
export default hook;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { run } from '@oclif/core';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { run } from '@oclif/core';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type AxiosInstance } from 'axios';
|
|
2
|
+
export interface ApiResponse<T = unknown> {
|
|
3
|
+
data: T;
|
|
4
|
+
headers: Record<string, string>;
|
|
5
|
+
status: number;
|
|
6
|
+
}
|
|
7
|
+
export interface PaginatedResponse<T> {
|
|
8
|
+
data: T[];
|
|
9
|
+
links: {
|
|
10
|
+
first?: string;
|
|
11
|
+
last?: string;
|
|
12
|
+
next?: string;
|
|
13
|
+
previous?: string;
|
|
14
|
+
};
|
|
15
|
+
meta: {
|
|
16
|
+
currentPage: number;
|
|
17
|
+
itemCount: number;
|
|
18
|
+
itemsPerPage: number;
|
|
19
|
+
totalItems: number;
|
|
20
|
+
totalPages: number;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export declare class ApiError extends Error {
|
|
24
|
+
status: number;
|
|
25
|
+
data?: unknown | undefined;
|
|
26
|
+
constructor(message: string, status: number, data?: unknown | undefined);
|
|
27
|
+
}
|
|
28
|
+
export declare function createApiClient(contextName?: string, acceptAny?: boolean): Promise<AxiosInstance>;
|
|
29
|
+
export declare function apiRequest<T = unknown>(method: string, path: string, options?: {
|
|
30
|
+
acceptAny?: boolean;
|
|
31
|
+
contentType?: string;
|
|
32
|
+
contextName?: string;
|
|
33
|
+
data?: unknown;
|
|
34
|
+
params?: Record<string, unknown>;
|
|
35
|
+
}): Promise<ApiResponse<T>>;
|
|
36
|
+
export declare function apiGet<T = unknown>(path: string, params?: Record<string, unknown>, contextName?: string): Promise<ApiResponse<T>>;
|
|
37
|
+
export declare function apiPost<T = unknown>(path: string, data?: unknown, contextName?: string): Promise<ApiResponse<T>>;
|
|
38
|
+
export declare function apiPut<T = unknown>(path: string, data?: unknown, contextName?: string): Promise<ApiResponse<T>>;
|
|
39
|
+
export declare function apiPatch<T = unknown>(path: string, data?: unknown, contextName?: string): Promise<ApiResponse<T>>;
|
|
40
|
+
export declare function apiDelete<T = unknown>(path: string, contextName?: string): Promise<ApiResponse<T>>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { getValidToken } from './auth.js';
|
|
3
|
+
import { getBaseUrls, getCurrentContext } from './config.js';
|
|
4
|
+
export class ApiError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
data;
|
|
7
|
+
constructor(message, status, data) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.data = data;
|
|
11
|
+
this.name = 'ApiError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function createApiClient(contextName, acceptAny = false) {
|
|
15
|
+
const context = contextName ? (await import('./config.js')).getContext(contextName) : getCurrentContext();
|
|
16
|
+
if (!context) {
|
|
17
|
+
throw new Error('No context configured. Run: aderis auth login --new');
|
|
18
|
+
}
|
|
19
|
+
const token = await getValidToken(context.name);
|
|
20
|
+
const { api: baseURL } = getBaseUrls(context.environment);
|
|
21
|
+
const client = axios.create({
|
|
22
|
+
baseURL,
|
|
23
|
+
headers: {
|
|
24
|
+
Accept: acceptAny ? '*/*' : 'application/json',
|
|
25
|
+
Authorization: `Bearer ${token}`,
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
},
|
|
28
|
+
// Serialize arrays as repeated params (key=v1&key=v2) instead of bracket format (key[]=v1)
|
|
29
|
+
paramsSerializer: { indexes: null },
|
|
30
|
+
// Allow any response type when acceptAny is true
|
|
31
|
+
responseType: acceptAny ? 'text' : 'json',
|
|
32
|
+
});
|
|
33
|
+
// Response interceptor for error handling
|
|
34
|
+
client.interceptors.response.use((response) => response, (error) => {
|
|
35
|
+
if (error.response) {
|
|
36
|
+
// Handle 401 with a clear message
|
|
37
|
+
if (error.response.status === 401) {
|
|
38
|
+
throw new ApiError('Session expired. Please run: aderis auth login', 401, error.response.data);
|
|
39
|
+
}
|
|
40
|
+
const message = extractErrorMessage(error.response.data);
|
|
41
|
+
throw new ApiError(message, error.response.status, error.response.data);
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
});
|
|
45
|
+
return client;
|
|
46
|
+
}
|
|
47
|
+
function extractErrorMessage(data) {
|
|
48
|
+
// If data is a JSON string, try to parse it first
|
|
49
|
+
if (typeof data === 'string') {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(data);
|
|
52
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
53
|
+
if (typeof parsed.message === 'string')
|
|
54
|
+
return parsed.message;
|
|
55
|
+
if (Array.isArray(parsed.message))
|
|
56
|
+
return parsed.message.join(', ');
|
|
57
|
+
if (typeof parsed.error === 'string')
|
|
58
|
+
return parsed.error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Not JSON, return the string as-is
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (typeof data === 'object' && data !== null) {
|
|
67
|
+
const obj = data;
|
|
68
|
+
if (typeof obj.message === 'string')
|
|
69
|
+
return obj.message;
|
|
70
|
+
if (Array.isArray(obj.message))
|
|
71
|
+
return obj.message.join(', ');
|
|
72
|
+
if (typeof obj.error === 'string')
|
|
73
|
+
return obj.error;
|
|
74
|
+
}
|
|
75
|
+
return 'Unknown error';
|
|
76
|
+
}
|
|
77
|
+
export async function apiRequest(method, path, options = {}) {
|
|
78
|
+
const client = await createApiClient(options.contextName, options.acceptAny);
|
|
79
|
+
const config = {
|
|
80
|
+
method,
|
|
81
|
+
url: path,
|
|
82
|
+
};
|
|
83
|
+
if (options.params) {
|
|
84
|
+
config.params = options.params;
|
|
85
|
+
}
|
|
86
|
+
if (options.data) {
|
|
87
|
+
config.data = options.data;
|
|
88
|
+
// Auto-detect FormData and set appropriate headers
|
|
89
|
+
if (options.data && typeof options.data === 'object' && 'getHeaders' in options.data) {
|
|
90
|
+
const formHeaders = options.data.getHeaders();
|
|
91
|
+
config.headers = { ...config.headers, ...formHeaders };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Override Content-Type if specified
|
|
95
|
+
if (options.contentType) {
|
|
96
|
+
config.headers = { ...config.headers, 'Content-Type': options.contentType };
|
|
97
|
+
}
|
|
98
|
+
const response = await client.request(config);
|
|
99
|
+
return {
|
|
100
|
+
data: response.data,
|
|
101
|
+
headers: response.headers,
|
|
102
|
+
status: response.status,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// Convenience methods
|
|
106
|
+
export async function apiGet(path, params, contextName) {
|
|
107
|
+
return apiRequest('GET', path, { contextName, params });
|
|
108
|
+
}
|
|
109
|
+
export async function apiPost(path, data, contextName) {
|
|
110
|
+
return apiRequest('POST', path, { contextName, data });
|
|
111
|
+
}
|
|
112
|
+
export async function apiPut(path, data, contextName) {
|
|
113
|
+
return apiRequest('PUT', path, { contextName, data });
|
|
114
|
+
}
|
|
115
|
+
export async function apiPatch(path, data, contextName) {
|
|
116
|
+
return apiRequest('PATCH', path, { contextName, data });
|
|
117
|
+
}
|
|
118
|
+
export async function apiDelete(path, contextName) {
|
|
119
|
+
return apiRequest('DELETE', path, { contextName });
|
|
120
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Context, type TokenData } from './config.js';
|
|
2
|
+
export declare function login(context: Context): Promise<TokenData>;
|
|
3
|
+
export declare function refreshTokens(contextName: string): Promise<TokenData>;
|
|
4
|
+
export declare function getValidToken(contextName: string): Promise<string>;
|
|
5
|
+
export declare function testAuth(contextName: string): Promise<{
|
|
6
|
+
email: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
}>;
|
|
9
|
+
export interface Tenant {
|
|
10
|
+
createdAt?: string;
|
|
11
|
+
isDefault?: boolean;
|
|
12
|
+
name: string;
|
|
13
|
+
tenantId: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function listTenants(contextName: string): Promise<Tenant[]>;
|
|
16
|
+
export declare function getCurrentTenant(contextName: string): Promise<null | {
|
|
17
|
+
name?: string;
|
|
18
|
+
tenantId: string;
|
|
19
|
+
}>;
|
|
20
|
+
export declare function switchTenant(contextName: string, tenantId: string): Promise<TokenData>;
|
package/dist/lib/auth.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import jwt from 'jsonwebtoken';
|
|
3
|
+
import * as crypto from 'node:crypto';
|
|
4
|
+
import * as http from 'node:http';
|
|
5
|
+
import { URL, URLSearchParams } from 'node:url';
|
|
6
|
+
import open from 'open';
|
|
7
|
+
import { getBaseUrls, getContext, saveTokens } from './config.js';
|
|
8
|
+
function buildValidatedUrl(baseUrl) {
|
|
9
|
+
try {
|
|
10
|
+
// Minimal path validation
|
|
11
|
+
if (baseUrl.includes('/../') || /\/%2e%2e\//i.test(baseUrl)) {
|
|
12
|
+
throw new Error('Invalid path');
|
|
13
|
+
}
|
|
14
|
+
const url = new URL(baseUrl);
|
|
15
|
+
// Protocol + host checks
|
|
16
|
+
const allowedDomains = [
|
|
17
|
+
'api.aderis.com', 'api-test.aderis.com', 'api-dev.aderis.com',
|
|
18
|
+
'auth.aderis.com', 'auth-test.aderis.com', 'auth-dev.aderis.com',
|
|
19
|
+
'localhost',
|
|
20
|
+
];
|
|
21
|
+
if (!allowedDomains.includes(url.hostname)) {
|
|
22
|
+
throw new Error(`Invalid host: ${url.hostname}`);
|
|
23
|
+
}
|
|
24
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
25
|
+
throw new Error('Invalid protocol');
|
|
26
|
+
}
|
|
27
|
+
return url.href;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error('Invalid URL');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const CALLBACK_PORT = 8085;
|
|
34
|
+
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/oauth/callback`;
|
|
35
|
+
const ADERIS_LOGO_URL = 'https://public.aderis.com/69da47c9-274a-4103-bc3e-d9123fd13fac.png';
|
|
36
|
+
const ADERIS_ACCENT_COLOR = '#9687f5';
|
|
37
|
+
function renderAuthPage(type, title, message) {
|
|
38
|
+
const isSuccess = type === 'success';
|
|
39
|
+
const iconColor = isSuccess ? ADERIS_ACCENT_COLOR : '#dc2626';
|
|
40
|
+
const icon = isSuccess
|
|
41
|
+
? `<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="${iconColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>`
|
|
42
|
+
: `<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="${iconColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`;
|
|
43
|
+
return `<!DOCTYPE html>
|
|
44
|
+
<html lang="en">
|
|
45
|
+
<head>
|
|
46
|
+
<meta charset="UTF-8">
|
|
47
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
48
|
+
<title>${title} - Aderis CLI</title>
|
|
49
|
+
<style>
|
|
50
|
+
* {
|
|
51
|
+
margin: 0;
|
|
52
|
+
padding: 0;
|
|
53
|
+
box-sizing: border-box;
|
|
54
|
+
}
|
|
55
|
+
body {
|
|
56
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
57
|
+
min-height: 100vh;
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
justify-content: center;
|
|
61
|
+
background: linear-gradient(135deg, #f8f7ff 0%, #f0eeff 100%);
|
|
62
|
+
padding: 20px;
|
|
63
|
+
}
|
|
64
|
+
.container {
|
|
65
|
+
background: white;
|
|
66
|
+
border-radius: 16px;
|
|
67
|
+
box-shadow: 0 4px 24px rgba(150, 135, 245, 0.15);
|
|
68
|
+
padding: 48px;
|
|
69
|
+
text-align: center;
|
|
70
|
+
max-width: 440px;
|
|
71
|
+
width: 100%;
|
|
72
|
+
}
|
|
73
|
+
.logo {
|
|
74
|
+
margin-bottom: 32px;
|
|
75
|
+
}
|
|
76
|
+
.logo img {
|
|
77
|
+
height: 40px;
|
|
78
|
+
width: auto;
|
|
79
|
+
}
|
|
80
|
+
.icon {
|
|
81
|
+
margin-bottom: 24px;
|
|
82
|
+
}
|
|
83
|
+
h1 {
|
|
84
|
+
font-size: 24px;
|
|
85
|
+
font-weight: 600;
|
|
86
|
+
color: #1a1a2e;
|
|
87
|
+
margin-bottom: 12px;
|
|
88
|
+
}
|
|
89
|
+
.message {
|
|
90
|
+
font-size: 16px;
|
|
91
|
+
color: #6b7280;
|
|
92
|
+
line-height: 1.6;
|
|
93
|
+
margin-bottom: 8px;
|
|
94
|
+
}
|
|
95
|
+
.message strong {
|
|
96
|
+
color: #1a1a2e;
|
|
97
|
+
}
|
|
98
|
+
.hint {
|
|
99
|
+
font-size: 14px;
|
|
100
|
+
color: #9ca3af;
|
|
101
|
+
margin-top: 24px;
|
|
102
|
+
}
|
|
103
|
+
.divider {
|
|
104
|
+
height: 1px;
|
|
105
|
+
background: linear-gradient(90deg, transparent, #e5e7eb, transparent);
|
|
106
|
+
margin: 24px 0;
|
|
107
|
+
}
|
|
108
|
+
</style>
|
|
109
|
+
</head>
|
|
110
|
+
<body>
|
|
111
|
+
<div class="container">
|
|
112
|
+
<div class="logo">
|
|
113
|
+
<img src="${ADERIS_LOGO_URL}" alt="Aderis" />
|
|
114
|
+
</div>
|
|
115
|
+
<div class="icon">
|
|
116
|
+
${icon}
|
|
117
|
+
</div>
|
|
118
|
+
<h1>${title}</h1>
|
|
119
|
+
<p class="message">${message}</p>
|
|
120
|
+
<div class="divider"></div>
|
|
121
|
+
<p class="hint">You can close this window and return to the CLI.</p>
|
|
122
|
+
</div>
|
|
123
|
+
</body>
|
|
124
|
+
</html>`;
|
|
125
|
+
}
|
|
126
|
+
function generatePKCE() {
|
|
127
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
128
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
129
|
+
return { challenge, verifier };
|
|
130
|
+
}
|
|
131
|
+
function generateState() {
|
|
132
|
+
return crypto.randomBytes(16).toString('hex');
|
|
133
|
+
}
|
|
134
|
+
export async function login(context) {
|
|
135
|
+
const { auth: authUrl } = getBaseUrls(context.environment);
|
|
136
|
+
const pkce = generatePKCE();
|
|
137
|
+
const state = generateState();
|
|
138
|
+
// Build authorization URL
|
|
139
|
+
const authorizationUrl = new URL(`${authUrl}/oauth/authorize`);
|
|
140
|
+
authorizationUrl.searchParams.set('client_id', context.client_id);
|
|
141
|
+
authorizationUrl.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
142
|
+
authorizationUrl.searchParams.set('response_type', 'code');
|
|
143
|
+
authorizationUrl.searchParams.set('state', state);
|
|
144
|
+
authorizationUrl.searchParams.set('code_challenge', pkce.challenge);
|
|
145
|
+
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
|
|
146
|
+
authorizationUrl.searchParams.set('scope', 'openid profile email offline_access');
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const connections = new Set();
|
|
149
|
+
let timeoutId;
|
|
150
|
+
const server = http.createServer(async (req, res) => {
|
|
151
|
+
const url = new URL(req.url || '', `http://localhost:${CALLBACK_PORT}`);
|
|
152
|
+
if (url.pathname !== '/oauth/callback') {
|
|
153
|
+
res.writeHead(404);
|
|
154
|
+
res.end('Not Found');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const code = url.searchParams.get('code');
|
|
158
|
+
const returnedState = url.searchParams.get('state');
|
|
159
|
+
const error = url.searchParams.get('error');
|
|
160
|
+
const errorDescription = url.searchParams.get('error_description');
|
|
161
|
+
if (error) {
|
|
162
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
163
|
+
res.end(renderAuthPage('error', 'Authentication Failed', errorDescription || error));
|
|
164
|
+
closeServer();
|
|
165
|
+
reject(new Error(errorDescription || error));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!code || returnedState !== state) {
|
|
169
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
170
|
+
res.end(renderAuthPage('error', 'Authentication Failed', 'Invalid response from authorization server.'));
|
|
171
|
+
closeServer();
|
|
172
|
+
reject(new Error('Invalid authorization response'));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
// Exchange code for tokens
|
|
177
|
+
const tokens = await exchangeCodeForTokens(context, code, pkce.verifier);
|
|
178
|
+
res.writeHead(200, { Connection: 'close', 'Content-Type': 'text/html' });
|
|
179
|
+
res.end(renderAuthPage('success', 'Authentication Successful', `You have been logged in to <strong>${context.name}</strong> (${context.environment}).`));
|
|
180
|
+
// Save tokens immediately
|
|
181
|
+
saveTokens(context.name, tokens);
|
|
182
|
+
// Use setImmediate to let the response flush, then close
|
|
183
|
+
setImmediate(() => {
|
|
184
|
+
closeServer();
|
|
185
|
+
resolve(tokens);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (error_) {
|
|
189
|
+
res.writeHead(500, { 'Content-Type': 'text/html' });
|
|
190
|
+
res.end(renderAuthPage('error', 'Authentication Failed', error_ instanceof Error ? error_.message : 'Unknown error'));
|
|
191
|
+
closeServer();
|
|
192
|
+
reject(error_);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
// Track connections so we can force-close them
|
|
196
|
+
server.on('connection', (socket) => {
|
|
197
|
+
connections.add(socket);
|
|
198
|
+
socket.on('close', () => connections.delete(socket));
|
|
199
|
+
});
|
|
200
|
+
const closeServer = () => {
|
|
201
|
+
// Clear the timeout to allow process to exit
|
|
202
|
+
clearTimeout(timeoutId);
|
|
203
|
+
// Destroy all open connections
|
|
204
|
+
for (const socket of connections) {
|
|
205
|
+
socket.destroy();
|
|
206
|
+
}
|
|
207
|
+
connections.clear();
|
|
208
|
+
server.close();
|
|
209
|
+
};
|
|
210
|
+
server.listen(CALLBACK_PORT, () => {
|
|
211
|
+
console.log(`Opening browser for authentication...`);
|
|
212
|
+
console.log(`If the browser doesn't open, visit: ${authorizationUrl.toString()}`);
|
|
213
|
+
open(authorizationUrl.toString());
|
|
214
|
+
});
|
|
215
|
+
server.on('error', (err) => {
|
|
216
|
+
closeServer();
|
|
217
|
+
reject(new Error(`Failed to start local server: ${err.message}`));
|
|
218
|
+
});
|
|
219
|
+
// Timeout after 5 minutes
|
|
220
|
+
timeoutId = setTimeout(() => {
|
|
221
|
+
closeServer();
|
|
222
|
+
reject(new Error('Authentication timed out'));
|
|
223
|
+
}, 5 * 60 * 1000);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
async function exchangeCodeForTokens(context, code, codeVerifier) {
|
|
227
|
+
const { auth: authUrl } = getBaseUrls(context.environment);
|
|
228
|
+
const params = new URLSearchParams();
|
|
229
|
+
params.set('grant_type', 'authorization_code');
|
|
230
|
+
params.set('client_id', context.client_id);
|
|
231
|
+
params.set('code', code);
|
|
232
|
+
params.set('redirect_uri', REDIRECT_URI);
|
|
233
|
+
params.set('code_verifier', codeVerifier);
|
|
234
|
+
if (context.client_secret) {
|
|
235
|
+
params.set('client_secret', context.client_secret);
|
|
236
|
+
}
|
|
237
|
+
const response = await axios.post(buildValidatedUrl(`${authUrl}/oauth/token`), params.toString(), {
|
|
238
|
+
headers: {
|
|
239
|
+
Accept: 'application/json',
|
|
240
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
const tokens = response.data;
|
|
244
|
+
const decoded = jwt.decode(tokens.access_token);
|
|
245
|
+
return {
|
|
246
|
+
access_token: tokens.access_token,
|
|
247
|
+
expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
|
|
248
|
+
refresh_token: tokens.refresh_token,
|
|
249
|
+
tenant_id: decoded?.tenantId || decoded?.tenant_id,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
export async function refreshTokens(contextName) {
|
|
253
|
+
const context = getContext(contextName);
|
|
254
|
+
if (!context) {
|
|
255
|
+
throw new Error(`Context "${contextName}" not found`);
|
|
256
|
+
}
|
|
257
|
+
if (!context.tokens?.refresh_token) {
|
|
258
|
+
throw new Error('No refresh token available. Please login again.');
|
|
259
|
+
}
|
|
260
|
+
const { auth: authUrl } = getBaseUrls(context.environment);
|
|
261
|
+
const params = new URLSearchParams();
|
|
262
|
+
params.set('grant_type', 'refresh_token');
|
|
263
|
+
params.set('client_id', context.client_id);
|
|
264
|
+
params.set('refresh_token', context.tokens.refresh_token);
|
|
265
|
+
if (context.client_secret) {
|
|
266
|
+
params.set('client_secret', context.client_secret);
|
|
267
|
+
}
|
|
268
|
+
let response;
|
|
269
|
+
try {
|
|
270
|
+
response = await axios.post(buildValidatedUrl(`${authUrl}/oauth/token`), params.toString(), {
|
|
271
|
+
headers: {
|
|
272
|
+
Accept: 'application/json',
|
|
273
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (axios.isAxiosError(error) && (error.response?.status === 401 || error.response?.status === 400)) {
|
|
279
|
+
throw new Error('Session expired. Please run: aderis auth login');
|
|
280
|
+
}
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
const tokens = response.data;
|
|
284
|
+
const decoded = jwt.decode(tokens.access_token);
|
|
285
|
+
const tokenData = {
|
|
286
|
+
access_token: tokens.access_token,
|
|
287
|
+
expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
|
|
288
|
+
refresh_token: tokens.refresh_token || context.tokens.refresh_token,
|
|
289
|
+
tenant_id: decoded?.tenantId || decoded?.tenant_id,
|
|
290
|
+
};
|
|
291
|
+
saveTokens(contextName, tokenData);
|
|
292
|
+
return tokenData;
|
|
293
|
+
}
|
|
294
|
+
export async function getValidToken(contextName) {
|
|
295
|
+
const context = getContext(contextName);
|
|
296
|
+
if (!context) {
|
|
297
|
+
throw new Error(`Context "${contextName}" not found`);
|
|
298
|
+
}
|
|
299
|
+
if (!context.tokens?.access_token) {
|
|
300
|
+
throw new Error('Not logged in. Please run: aderis auth login');
|
|
301
|
+
}
|
|
302
|
+
// Check if token is expired (with 60 second buffer)
|
|
303
|
+
if (context.tokens.expires_at && context.tokens.expires_at < Date.now() + 60_000) {
|
|
304
|
+
if (context.tokens.refresh_token) {
|
|
305
|
+
const newTokens = await refreshTokens(contextName);
|
|
306
|
+
return newTokens.access_token;
|
|
307
|
+
}
|
|
308
|
+
throw new Error('Token expired. Please run: aderis auth login');
|
|
309
|
+
}
|
|
310
|
+
return context.tokens.access_token;
|
|
311
|
+
}
|
|
312
|
+
export async function testAuth(contextName) {
|
|
313
|
+
const context = getContext(contextName);
|
|
314
|
+
if (!context) {
|
|
315
|
+
throw new Error(`Context "${contextName}" not found`);
|
|
316
|
+
}
|
|
317
|
+
const token = await getValidToken(contextName);
|
|
318
|
+
const { api: apiUrl } = getBaseUrls(context.environment);
|
|
319
|
+
const response = await axios.get(buildValidatedUrl(`${apiUrl}/v1/auth/me`), {
|
|
320
|
+
headers: {
|
|
321
|
+
Accept: 'application/json',
|
|
322
|
+
Authorization: `Bearer ${token}`,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
return response.data;
|
|
326
|
+
}
|
|
327
|
+
export async function listTenants(contextName) {
|
|
328
|
+
const context = getContext(contextName);
|
|
329
|
+
if (!context) {
|
|
330
|
+
throw new Error(`Context "${contextName}" not found`);
|
|
331
|
+
}
|
|
332
|
+
const token = await getValidToken(contextName);
|
|
333
|
+
const { auth: authUrl } = getBaseUrls(context.environment);
|
|
334
|
+
let response;
|
|
335
|
+
try {
|
|
336
|
+
// Frontegg endpoint to list user's tenants
|
|
337
|
+
response = await axios.get(buildValidatedUrl(`${authUrl}/identity/resources/users/v2/me/tenants`), {
|
|
338
|
+
headers: {
|
|
339
|
+
Accept: 'application/json',
|
|
340
|
+
Authorization: `Bearer ${token}`,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
|
346
|
+
throw new Error('Session expired. Please run: aderis auth login');
|
|
347
|
+
}
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
// Normalize the response - Frontegg may use different field names
|
|
351
|
+
return response.data.map((t) => ({
|
|
352
|
+
createdAt: t.createdAt,
|
|
353
|
+
isDefault: t.isDefault,
|
|
354
|
+
name: t.name || t.tenantName || t.tenantId,
|
|
355
|
+
tenantId: t.tenantId,
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
export async function getCurrentTenant(contextName) {
|
|
359
|
+
const context = getContext(contextName);
|
|
360
|
+
if (!context) {
|
|
361
|
+
throw new Error(`Context "${contextName}" not found`);
|
|
362
|
+
}
|
|
363
|
+
if (!context.tokens?.access_token) {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
const decoded = jwt.decode(context.tokens.access_token);
|
|
367
|
+
const tenantId = decoded?.tenantId || decoded?.tenant_id;
|
|
368
|
+
if (!tenantId) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
// Try to get tenant name from the list
|
|
372
|
+
try {
|
|
373
|
+
const tenants = await listTenants(contextName);
|
|
374
|
+
const tenant = tenants.find((t) => t.tenantId === tenantId);
|
|
375
|
+
return {
|
|
376
|
+
name: tenant?.name,
|
|
377
|
+
tenantId,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return { tenantId };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
export async function switchTenant(contextName, tenantId) {
|
|
385
|
+
const context = getContext(contextName);
|
|
386
|
+
if (!context) {
|
|
387
|
+
throw new Error(`Context "${contextName}" not found`);
|
|
388
|
+
}
|
|
389
|
+
if (!context.tokens?.refresh_token) {
|
|
390
|
+
throw new Error('No refresh token available. Please login again.');
|
|
391
|
+
}
|
|
392
|
+
const { auth: authUrl } = getBaseUrls(context.environment);
|
|
393
|
+
const token = await getValidToken(contextName);
|
|
394
|
+
try {
|
|
395
|
+
// Step 1: PUT to switch the user's active tenant
|
|
396
|
+
await axios.put(buildValidatedUrl(`${authUrl}/identity/resources/users/v1/tenant`), { tenantId }, {
|
|
397
|
+
headers: {
|
|
398
|
+
Accept: 'application/json',
|
|
399
|
+
Authorization: `Bearer ${token}`,
|
|
400
|
+
'Content-Type': 'application/json',
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
// Step 2: Refresh tokens to get new tokens for the switched tenant
|
|
404
|
+
const response = await axios.post(buildValidatedUrl(`${authUrl}/oauth/token`), new URLSearchParams({
|
|
405
|
+
client_id: context.client_id,
|
|
406
|
+
grant_type: 'refresh_token',
|
|
407
|
+
refresh_token: context.tokens.refresh_token,
|
|
408
|
+
}).toString(), {
|
|
409
|
+
headers: {
|
|
410
|
+
Accept: 'application/json',
|
|
411
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
const tokens = response.data;
|
|
415
|
+
const decoded = jwt.decode(tokens.access_token);
|
|
416
|
+
const tokenData = {
|
|
417
|
+
access_token: tokens.access_token,
|
|
418
|
+
expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
|
|
419
|
+
refresh_token: tokens.refresh_token || context.tokens.refresh_token,
|
|
420
|
+
tenant_id: decoded?.tenantId || decoded?.tenant_id,
|
|
421
|
+
};
|
|
422
|
+
saveTokens(contextName, tokenData);
|
|
423
|
+
return tokenData;
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
if (axios.isAxiosError(error) && (error.response?.status === 401 || error.response?.status === 400)) {
|
|
427
|
+
throw new Error('Session expired. Please run: aderis auth login');
|
|
428
|
+
}
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
}
|