import type { DataHandler } from "@olenbetong/appframe-data"; import { FileUploader, type ProcedureBase, SortOrder } from "@olenbetong/appframe-data"; import { createNamespaceDataHandler, createTransactionsDataHandler, getNamespace as fetchNamespace, type NamespaceRecord, type TransactionFilter, type TransactionsRecord, apply as updaterApply, checkForUpdates as updaterCheckForUpdates, deploy as updaterDeploy, download as updaterDownload, generate as updaterGenerate, getTransactions as updaterGetTransactions, listTransactions as updaterListTransactions, } from "@olenbetong/appframe-updater"; import chalk from "chalk"; import { config } from "dotenv"; import { fileFromPath } from "formdata-node/file-from-path"; import fuzzy from "fuzzy"; import inquirer from "inquirer"; import autocomplete from "inquirer-autocomplete-standalone"; import type { Store } from "tough-cookie"; import { getBundlesDataObject, getBundlesProjectsDataObject, getCreateFirstBundleVersionProcedure, getDataResourcesDataObject, getPublishBundleProcedure, getPublishWebAssetProcedure, getSiteScriptsDataObject, getSiteStylesDataObject, getTemplatesDataObject, } from "../data/index.js"; import { getCookieStore } from "./auth.js"; import { readNamespacesCache, upsertNamespaceCacheRecord, writeNamespacesCache } from "./namespacesCache.js"; import { PersistentClient } from "./PersistentClient.js"; config({ path: `${process.cwd()}/.env`, quiet: true }); let { APPFRAME_LOGIN: envUsername, APPFRAME_PWD: envPassword } = process.env; const isInteractive = process.stdout.isTTY; export class Server { private hostname: string; private username: string; private password: string; readonly client: PersistentClient; readonly dsBundles: DataHandler; readonly dsBundlesProjects: DataHandler; readonly dsDataResources: DataHandler; readonly dsNamespaces: DataHandler; readonly dsSiteScripts: DataHandler; readonly dsSiteStyles: DataHandler; readonly dsTemplates: DataHandler; readonly dsTransactions: DataHandler; readonly procCreateFirstBundleVersion: ProcedureBase; readonly procPublishBundle: ProcedureBase; readonly procPublishWebAsset: ProcedureBase; /** * Creates a server instance. If username or password is not provided, will attempt to look for * environment variables APPFRAME_LOGIN and APPFRAME_PWD. If username or password is still missing, * the login method will prompt the user if running in a TTY environment. * * @param {string} [hostname] Hostname of the server (default: dev.obet.no) * @param {string} [username] Appframe username * @param {string} [password] Appframe password */ constructor(hostname?: string, username?: string, password?: string, cookieStore?: Store) { this.hostname = hostname ?? "dev.obet.no"; this.username = username ?? envUsername ?? ""; this.password = password ?? envPassword ?? ""; this.client = new PersistentClient( this.hostname, cookieStore ?? getCookieStore(this.hostname), this.username, this.password, ); this.dsBundles = getBundlesDataObject(this.client); this.dsBundlesProjects = getBundlesProjectsDataObject(this.client); this.dsDataResources = getDataResourcesDataObject(this.client); this.dsNamespaces = createNamespaceDataHandler(this.client); this.dsSiteScripts = getSiteScriptsDataObject(this.client); this.dsSiteStyles = getSiteStylesDataObject(this.client); this.dsTemplates = getTemplatesDataObject(this.client); this.dsTransactions = createTransactionsDataHandler(this.client); this.procCreateFirstBundleVersion = getCreateFirstBundleVersionProcedure(this.client); this.procPublishBundle = getPublishBundleProcedure(this.client); this.procPublishWebAsset = getPublishWebAssetProcedure(this.client); } private getHostPrefix() { let colorFn = chalk.bgRed; if (this.hostname.startsWith("dev")) { colorFn = chalk.bgGreen; } else if (this.hostname.startsWith("stage")) { colorFn = chalk.bgYellow; } let hostname = this.hostname.replace(/(\.obet\.no|\.olenbetong\.no)/g, ""); return colorFn(`[${hostname}]`); } async logAsyncTask( task: Promise, runningMessage: string, completedMessage?: string | ((param: Awaited) => string | undefined), ): Promise { if (isInteractive) { let start = performance.now(); process.stdout.clearLine(0); process.stdout.cursorTo(0); process.stdout.write(`${this.getHostPrefix()} ${runningMessage}`); let counter = 0; let interval = setInterval(() => { if (counter === 5) { process.stdout.clearLine(0); process.stdout.cursorTo(0); process.stdout.write( `${this.getHostPrefix()} ${runningMessage} (${Math.floor(performance.now() - start)}ms)`, ); counter = 0; } process.stdout.write("."); counter++; }, 250); try { let result = await task; process.stdout.clearLine(0); process.stdout.cursorTo(0); process.stdout.write( `${this.getHostPrefix()} ${ (typeof completedMessage === "function" ? completedMessage(result) : completedMessage) ?? runningMessage } (${Math.floor(performance.now() - start)}ms)\n`, ); return result; } catch (error) { process.stdout.write("\n"); throw error; } finally { clearInterval(interval); } } return await task; } logServerMessage(message: string) { if (isInteractive) { console.log(`${this.getHostPrefix()} ${message}`); } } async login() { if (isInteractive) { if (!this.username || !this.password) { let { username, password } = await inquirer.prompt([ { type: "input", name: "username", message: "What is your Appframe username?", default: this.username, }, { type: "password", name: "password", message: "What is you Appframe password?", }, ]); this.username = username; this.password = password; } } if (!this.username) { throw Error("No username provided."); } if (!this.password) { throw Error("No password provided."); } let task = this.client.login(this.username, this.password); return await this.logAsyncTask(task, "Logging in", "Logged in"); } async addDataResource(id: string, name?: string) { await this.logAsyncTask( this.dsDataResources.create({ DBObjectID: id, Name: name, }), `Adding data resource (${id})`, `Data resource added (${id})`, ); } /** * Applies updates matching the namespace, or all updates if no namespace is given * * @param {string | number | undefined} namespace limit apply updates to a namespace (ID or name) */ async apply(namespace?: string | number) { let message: string; if (namespace) { let n = await this.getNamespace(namespace); message = `Applying updates (${n.Name})`; } else { message = "Applying updates"; } let task = updaterApply(this.client, namespace); return await this.logAsyncTask(task, message, "Updates applied"); } /** * Checks that there are no transactions with a different name than `name` that matches `filter`. * Log transactions (type = 98) are not counted. If there are other transactions, an error is thrown. * * @param {string} filter - Filter to use on transactions * @param {string} name - Name of the expected transaction. * @param {string | number} namespace - Limit check to a single namespace */ async assertOnlyOneTransaction(filter: string, name: string, namespace: string | number) { let transactions = await this.getTransactions(filter, namespace); if (transactions.length > 1) { // Allow multiple transactions if they are all for the current article for (let record of transactions) { if (record.Name !== name) { updaterListTransactions(transactions); console.table( transactions.map((r) => ({ Namespace: r.Namespace, Name: r.Name, CreatedBy: r.CreatedBy, LocalCreatedBy: r.LocalCreatedBy, })), ); throw Error("Found more than 1 transaction. Deploy have to be done manually."); } } } else if (transactions.length === 0) { throw Error("No transactions found. Check if there is a versioning problem with the article."); } } async deleteDataResource(id: string) { let { dsDataResources } = this; let resource = await this.getResourceDefinition(id); let resourceRecord = await dsDataResources.retrieve({ whereClause: `[DBObjectID] = '${resource.DBObjectID}'`, maxRecords: -1, }); if (resourceRecord.length === 1) { await this.logAsyncTask( dsDataResources.destroy({ PrimKey: resourceRecord[0].PrimKey, }), `Deleting '${resourceRecord[0].DBObjectID}'`, `'${resourceRecord[0].DBObjectID}' deleted`, ); } else { this.logServerMessage(chalk.red(`Expected 1 resource to match '${id}'. Found ${resourceRecord.length}`)); process.exit(1); } } /** * Deploys the selected namespace, or all namespaces if none is * provided. * @param {number | string} [namespace] ID of the namespace to deploy */ async deploy(namespace?: string | number) { let message: string; if (namespace) { let n = await this.getNamespace(namespace); message = `Deploying (${n.Name})`; } else { message = "Deploying"; } await this.logAsyncTask(updaterDeploy(this.client, namespace), message); } /** * Check for updates to download */ async checkForUpdates() { this.logServerMessage(`Checking for updates...`); return await updaterCheckForUpdates(this.client); } /** * Download updates for the given namespace * @param {string} namespace Name of the namespace to download */ async download(namespace: string) { let message: string; if (namespace) { let n = await this.getNamespace(namespace); message = `Downloading updates (${n.Name})`; } else { message = `Downloading updates`; } let task = updaterDownload(this.client, namespace); await this.logAsyncTask(task, message); } /** * Generates transactions for the given namespace, or all namespaces if no ID is given. * * @param {number | string} [namespace] ID of the namespace to generate transactions in */ async generate(namespace?: number | string) { let message: string; let n: any; if (namespace) { n = await this.getNamespace(namespace); message = `Generating transactions (${n.Name})`; } else { message = "Generating transactions"; } let task = updaterGenerate(this.client, namespace); await this.logAsyncTask(task, message, namespace ? `Transactions generated (${n.Name})` : "Transactions generated"); } /** * Gets the bundle version for the given bundle with version 0 * * @param {string} bundleName Name of the bundle */ async getBundle(bundleName: string) { let { dsBundles } = this; let bundle = await this.logAsyncTask( dsBundles.retrieve({ maxRecords: 1, whereClause: `[Name] = '${bundleName}' AND [Version] = 0`, }), `Getting bundle (${bundleName})`, ); return bundle[0]; } /** * * @param {string | number} namespace Name or ID of the namespace * @returns */ async getNamespaces(options: { forceRefresh?: boolean } = {}): Promise { let { forceRefresh = false } = options; let fetchPromise = this.dsNamespaces .retrieve({ maxRecords: -1, sortOrder: [{ GroupName: SortOrder.Asc }, { Name: SortOrder.Asc }], }) .then((namespaces) => { writeNamespacesCache(this.hostname, namespaces); return namespaces; }); if (!forceRefresh) { let cached = readNamespacesCache(this.hostname); if (cached) { fetchPromise.catch((error) => { this.logServerMessage( chalk.yellow( `Failed to refresh namespaces cache: ${error instanceof Error ? error.message : String(error)}`, ), ); }); return cached; } } try { return await fetchPromise; } catch (error) { if (!forceRefresh) { let cached = readNamespacesCache(this.hostname); if (cached) { return cached; } } throw error; } } async getNamespace(namespace: string | number) { let cached = readNamespacesCache(this.hostname); if (cached) { let record = cached.find((n) => (typeof namespace === "number" ? n.ID === namespace : n.Name === namespace)); if (record) { fetchNamespace(this.client, namespace) .then((fresh) => { upsertNamespaceCacheRecord(this.hostname, fresh); }) .catch((error) => { this.logServerMessage( chalk.yellow( `Failed to refresh namespace ${namespace}: ${error instanceof Error ? error.message : String(error)}`, ), ); }); return record; } } let namespaceRecord = await fetchNamespace(this.client, namespace); upsertNamespaceCacheRecord(this.hostname, namespaceRecord); return namespaceRecord; } /** * Get namespace argument from the command line, or let the user select from a list * if they didn't provide one. * * @param {string | undefined} namespace Namespace argument from the command line * @param {any} options Command options, potentially including an `all` property * @returns {Promise} Namespace name */ async getNamespaceArgument(namespace?: string, options: any = {}): Promise { if (!namespace && !options.all) { if (process.stdout.isTTY) { let namespaces = await this.getNamespaces(); let namespace = await autocomplete({ message: "Select namespace", source: (input) => { let choices = namespaces.map((r) => ({ name: r.GroupName ? `${r.Name} (${r.GroupName})` : (r.Name as string), value: r.Name as string, })); return Promise.resolve( fuzzy .filter(input ?? "", choices, { extract: (el) => el.name, }) .map((el) => el.original), ); }, }); return namespace; } else { console.log(chalk.red("Namespace must be provided if --all is not set in a non-interactive environment")); process.exit(0); } } return namespace ?? ""; } /** * Get resource name argument for commands. If a name is not provided by * the user, create a prompt with autocomplete to let them select it. * @param {string | undefined} resourceArg Resource argument from command line * @returns {Promise} Resource name */ async getResourceArgument(resourceArg?: string): Promise { if (resourceArg) return resourceArg; if (isInteractive) { let resources = await this.getResources(); let resource = await autocomplete({ message: "Select the resource you want to view", source: async (input) => { let choices: { name: string; value: string }[] = resources.map( (resource: { Name: string; DBObjectID: string }) => ({ name: resource.Name, value: resource.DBObjectID, }), ); return Promise.resolve( fuzzy .filter(input ?? "", choices, { extract: (el) => (el.name !== el.value ? `${el.value} / ${el.name}` : el.name), }) .map((el) => el.original), ); }, }); return resource; } throw Error("Resource must be provided if not in interactive mode"); } async getResources() { let response = await this.client.afFetch("/api/data", { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ operation: "listapi", }), }); let data = await response.json(); if (data.success) { data.success.sort((a: any, z: any) => { if (a.ObjectType === z.ObjectType) { return a.Name >= z.Name ? 1 : -1; } else if (a.ObjectType === "V") { return -1; } else { return 1; } }); return data.success; } else { throw Error(data.error); } } /** * Get the definition of a data resource. * * @param {string} name Name of the data resource */ async getResourceDefinition(name: string) { let response = await this.logAsyncTask( this.client.afFetch("/api/data", { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify({ operation: "resource-definition", resourceName: name, }), }), `Getting resource definition (${name})`, ); let data = await response.json(); if (data.success) { return data.success; } else { throw Error(data.error); } } /** * Returns a list of transactions matching the filter or type. If a namespace * is given, will also limit to that namespace. * * @param {string} filterOrType SQL filter or type (apply/deploy) to get * @param {string | number} [namespace] Name or ID of namespace to limit to * @returns List of transactions matching filter and namespace */ async getTransactions(filterOrType: TransactionFilter = "apply", namespace?: string | number) { let message: string; let n: any; if (namespace) { n = await this.getNamespace(namespace); message = `Getting transactions (${n.Name})`; } else { message = `Getting transactions`; } let task = updaterGetTransactions(this.client, filterOrType, namespace); return await this.logAsyncTask(task, message); } /** * Creates a bundle project * * @param {string} name Name of the bundle * @param {number} namespaceId ID of the namespace to add the bundle to */ async createBundle(name: string, namespaceId: number) { let newBundle = await this.logAsyncTask( this.dsBundlesProjects.create({ Name: name, Namespace_ID: namespaceId, }), `Creating bundle (${name})`, (newBundle) => { if (newBundle?.ID) { return `Bundle '${name}' created with ID ${newBundle.ID}.`; } }, ); if (newBundle?.ID) { await this.logAsyncTask( this.procCreateFirstBundleVersion.execute({ project_id: newBundle.ID, }), `Creating bundle version`, ); } return newBundle; } /** * Publishes a bundle * * @param {number} projectId Project ID for the bundle * @param {number} versionId ID for the project version to publish * @param {string} description Description to set on the new version */ async publishBundle(projectId: number, versionId: number, description: string) { await this.logAsyncTask( this.procPublishBundle.execute({ description, issueid: null, project_id: projectId, ver_id: versionId, }), "Publishing bundle", "Bundle published", ); } /** * Publishes an article. If there is a mismatch between the latest transaction version * and the latest article version, the article will be published multiple times until * a transaction can be generated. * * @param {string} hostname Hostname of the article to publish * @param {string} articleId ID of the article to publish * @param {string} version Version comments to set as description */ /** * Publishes a site script * * @param {string} hostname Hostname where the site script is * @param {string} scriptId ID of the site script to publish * @param {string} version Version comments to set as description */ async publishSiteScript(hostname: string, scriptId: string, version: string) { await this.publishWebAsset(hostname, scriptId, "Script", version); } /** * Publishes a site style * * @param {string} hostname Hostname where the site style is * @param {string} styleId ID of the site style to publish * @param {string} version Version comments to set as description */ async publishSiteStyle(hostname: string, styleId: string, version: string) { await this.publishWebAsset(hostname, styleId, "Style", version); } /** * Publish a web asset (template, script or style) * * @param {string} hostname Hostname where the asset is located * @param {string} name Name of the asset * @param {"Template" | "Script" | "Style"} type Type of asset to publish * @param {string} description Description of the update */ async publishWebAsset(hostname: string, name: string, type: "Template" | "Script" | "Style", description: string) { await this.logAsyncTask( this.procPublishWebAsset.execute({ Hostname: hostname, Name: name, Type: type, Description: description, IssueID: null, }), `Publishing ${type.toLocaleLowerCase()} '${hostname}/${name}'`, `Published ${type.toLocaleLowerCase()} '${hostname}/${name}'`, ); } /** * Uploads a zip file to an AF bundle project * * @param {number} projectId ID for the bundle project * @param {number} versionId Version ID for version 0 of the bundle * @param {string} file Path to the zip file */ async uploadBundle(projectId: string, versionId: number, file: string) { let run = async () => { let uploader = new FileUploader({ route: `/api/ob/bundle-push/${versionId}`, client: this.client, }); let zipFile = await fileFromPath(file); // @ts-expect-error await uploader.upload(zipFile, { Project_ID: projectId }); }; await this.logAsyncTask(run(), `Uploading bundle file '${file}'`, `Uploaded bundle file '${file}'`); } /** * Uploads a template * * @param {string} hostname Hostname where the template is * @param {string} templateId ID of the template to upload * @param {string | undefined} content Content to upload for production use (undefined will not modify existing template) * @param {string | undefined} contentTest Content to upload for test mode use (undefined will not modify existing template) */ async uploadTemplate(hostname: string, templateId: string, content?: string, contentTest?: string) { let template = await this.logAsyncTask( this.dsTemplates.retrieve({ whereClause: `[HostName] = '${hostname}' AND [Name] = '${templateId}'`, maxRecords: 1, }), `Getting template '${hostname}/${templateId}'`, ); let updates: any = {}; if (content !== undefined) { updates.HTMLContent = content; } if (contentTest !== undefined) { updates.HTMLContentTest = contentTest; } if (template.length === 0) { await this.logAsyncTask( this.dsTemplates.create({ HostName: hostname, Name: templateId, ...updates, }), `Creating template '${hostname}/${templateId}'`, `Template '${hostname}/${templateId}' created`, ); } else { await this.logAsyncTask( this.dsTemplates.update({ PrimKey: template[0].PrimKey, ...updates, }), `Updating template '${hostname}/${templateId}'`, `Template '${hostname}/${templateId}' updated`, ); } } /** * Uploads a site script * * @param {string} hostname Hostname where the site script is * @param {string} scriptId ID of the site script to upload * @param {string | undefined} content Content to upload for production use (undefined will not modify existing script) * @param {string | undefined} contentTest Content to upload for test mode use (undefined will not modify existing script) */ async uploadSiteScript(hostname: string, scriptId: string, content?: string, contentTest?: string) { this.logServerMessage(`Getting site script '${hostname}/${scriptId}'...`); let siteScripts = await this.dsSiteScripts.retrieve({ whereClause: `[HostName] = '${hostname}' AND [Name] = '${scriptId}'`, maxRecords: 1, }); let updates: any = {}; if (content !== undefined) { updates.ScriptContent = content; } if (contentTest !== undefined) { updates.ScriptContentTest = contentTest; } if (siteScripts.length === 0) { await this.logAsyncTask( this.dsSiteScripts.create({ HostName: hostname, Name: scriptId, ...updates, }), `Site script '${hostname}/${scriptId}' not found. Creating..`, `Site script '${hostname}/${scriptId}' created`, ); } else { await this.logAsyncTask( this.dsSiteScripts.update({ PrimKey: siteScripts[0].PrimKey, ...updates, }), `Updating site script '${hostname}/${scriptId}'`, `Site script '${hostname}/${scriptId}' updated`, ); } } /** * Uploads a site style * * @param {string} hostname Hostname where the site style is * @param {string} styleId ID of the site style to upload * @param {string | undefined} content Content to upload for production use (undefined will not modify existing stylesheet) * @param {string | undefined} contentTest Content to upload for test mode use (undefined will not modify existing stylesheet) */ async uploadSiteStyle(hostname: string, styleId: string, content?: string, contentTest?: string) { let siteStyle = await this.logAsyncTask( this.dsSiteStyles.retrieve({ whereClause: `[HostName] = '${hostname}' AND [Name] = '${styleId}'`, maxRecords: 1, }), `Getting site style '${hostname}/${styleId}'`, ); let updates: any = {}; if (content !== undefined) { updates.StyleContent = content; } if (contentTest !== undefined) { updates.StyleContentTest = contentTest; } if (siteStyle.length === 0) { await this.logAsyncTask( this.dsSiteStyles.create({ HostName: hostname, Name: styleId, ...updates, }), `Creating site style '${hostname}/${styleId}'`, `Site style '${hostname}/${styleId}' created`, ); } else { await this.logAsyncTask( this.dsSiteStyles.update({ PrimKey: siteStyle[0].PrimKey, ...updates, }), `Updating site style '${hostname}/${styleId}'`, `Site style '${hostname}/${styleId}' updated`, ); } } }