Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

All notable changes to this project will be documented in this file.

## [1.57.0] (14/07/2026)
Added a new `silverfin run-sampler` command to run the Liquid Sampler for partner templates. Specify a partner with `-p` and one or more reconciliation text handles (`-h`), account detail template names (`-at`), and/or shared part names (`-s`), optionally scoping the run to specific firms with `--firm-ids`. Pass `--id <sampler-id>` to fetch and display the results of an existing sampler run instead.

## [1.56.3] (03/07/2026)
Improve `run-test --status` output for CI: surface the underlying error message when a run ends in `test_error`/`internal_error` (previously reported as a bare `FAILED` with no reason), and suppress the progress spinner when stdout is not a TTY (it flooded CI logs with hundreds of "Running tests.." frames).

Expand Down
50 changes: 50 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const toolkit = require("../index");
const liquidTestGenerator = require("../lib/liquidTestGenerator");
const liquidTestRunner = require("../lib/liquidTestRunner");
const { ExportFileInstanceGenerator } = require("../lib/exportFileInstanceGenerator");
const { LiquidSamplerRunner } = require("../lib/liquidSamplerRunner");
const stats = require("../lib/cli/stats");
const { Command, Option } = require("commander");
const pkg = require("../package.json");
Expand Down Expand Up @@ -514,6 +515,55 @@ program
);
});

// Run Liquid Sampler
program
.command("run-sampler", { hidden: true})
.description("Run Liquid Sampler for partner templates (reconciliation texts, account detail templates, and/or shared parts)")
.requiredOption("-p, --partner <partner-id>", "Specify the partner to be used")
.option("-h, --handle <handles...>", "Specify reconciliation text handle(s) - can specify multiple")
.option("-at, --account-template <names...>", "Specify account detail template name(s) - can specify multiple")
.option("-s, --shared-part <names...>", "Specify shared part name(s) - can specify multiple")
.option("--firm-ids <firm-ids...>", "Specify firm ID(s) to run the sampler against - can specify multiple (optional)")
.addOption(
new Option("--id <sampler-id>", "Specify an existing sampler ID to fetch results for (optional)").conflicts([
"handle",
"accountTemplate",
"sharedPart",
"firmIds",
])
)
.action(async (options) => {
// If an existing sampler ID is provided, fetch and display results
if (options.id) {
if (!/^\d+$/.test(options.id)) {
consola.error("Invalid sampler ID: must be a numeric value");
process.exit(1);
}
await new LiquidSamplerRunner(options.partner).checkStatus(options.id);
return;
Comment thread
Toby-Masters-SF marked this conversation as resolved.
}

// Validate: at least one template specified
const handles = options.handle || [];
const accountTemplates = options.accountTemplate || [];
const sharedParts = options.sharedPart || [];

if (handles.length === 0 && accountTemplates.length === 0 && sharedParts.length === 0) {
consola.error("You need to specify at least one template using -h, -at, or -s");
process.exit(1);
}

const templateHandles = {
reconciliationTexts: handles,
accountTemplates: accountTemplates,
sharedParts: sharedParts,
};

const firmIds = options.firmIds || [];
Comment thread
Toby-Masters-SF marked this conversation as resolved.

await new LiquidSamplerRunner(options.partner).run(templateHandles, firmIds);
});
Comment thread
Toby-Masters-SF marked this conversation as resolved.

// Create Liquid Test
program
.command("create-test")
Expand Down
26 changes: 26 additions & 0 deletions lib/api/sfApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,30 @@ async function getExportFileInstance(firmId, companyId, periodId, exportFileInst
}
}

async function createSamplerRun(partnerId, attributes) {
const instance = AxiosFactory.createInstance("partner", partnerId);
try {
const response = await instance.post("liquid_sampler/run", attributes);
apiUtils.responseSuccessHandler(response);
return response;
Comment thread
Toby-Masters-SF marked this conversation as resolved.
} catch (error) {
const response = await apiUtils.responseErrorHandler(error);
return response;
}
}
Comment thread
Toby-Masters-SF marked this conversation as resolved.

async function readSamplerRun(partnerId, samplerId) {
const instance = AxiosFactory.createInstance("partner", partnerId);
try {
const response = await instance.get(`liquid_sampler/${samplerId}`);
apiUtils.responseSuccessHandler(response);
return response;
} catch (error) {
const response = await apiUtils.responseErrorHandler(error);
return response;
}
}

module.exports = {
authorizeFirm,
refreshFirmTokens,
Expand Down Expand Up @@ -771,4 +795,6 @@ module.exports = {
getFirmDetails,
createExportFileInstance,
getExportFileInstance,
createSamplerRun,
readSamplerRun,
};
263 changes: 263 additions & 0 deletions lib/liquidSamplerRunner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
const { UrlHandler } = require("./utils/urlHandler");
const errorUtils = require("./utils/errorUtils");
const { spinner } = require("./cli/spinner");
const SF = require("./api/sfApi");
const fsUtils = require("./utils/fsUtils");
const { consola } = require("consola");

const { ReconciliationText } = require("./templates/reconciliationText");
const { AccountTemplate } = require("./templates/accountTemplate");
const { SharedPart } = require("./templates/sharedPart");

/**
* Class to run liquid samplers for partner templates
*/
class LiquidSamplerRunner {
constructor(partnerId) {
this.partnerId = partnerId;
}

/**
* Run liquid sampler for partner templates
* @param {Object} templateHandles - Object containing arrays of template identifiers
* @param {Array<string>} templateHandles.reconciliationTexts - Array of reconciliation text handles
* @param {Array<string>} templateHandles.accountTemplates - Array of account template names
* @param {Array<string>} templateHandles.sharedParts - Array of shared part names
* @param {Array<number>} firmIds - Array of firm IDs to use in the sampler
* @returns {Promise<void>}
*/
async run(templateHandles = {}, firmIds = []) {
try {
// Build payload
const samplerParams = await this.#buildSamplerParams(templateHandles, firmIds);

Comment thread
Toby-Masters-SF marked this conversation as resolved.
consola.info(`Starting sampler run with ${samplerParams.templates.length} template(s)...`);

// Start sampler run
const samplerResponse = await SF.createSamplerRun(this.partnerId, samplerParams);

if (!samplerResponse?.data?.id) {
consola.error("Failed to start sampler run - no ID returned");
if (samplerResponse?.data) {
consola.error(`Response data: ${JSON.stringify(samplerResponse.data)}`);
Comment thread
michieldegezelle marked this conversation as resolved.
}
process.exit(1);
}

const samplerId = samplerResponse.data.id;

consola.info(`Sampler run started with ID: ${samplerId}`);

// Poll for completion
const samplerRun = await this.#fetchAndWaitSamplerResult(samplerId);

// Process results
await this.#handleSamplerResponse(samplerRun);
} catch (error) {
errorUtils.errorHandler(error);
}
}

/**
* Fetch the status of an existing sampler run
* @param {string} samplerId - The sampler run ID
* @returns {Promise<void>}
*/
async checkStatus(samplerId) {
try {
consola.info(`Fetching status for sampler run ID: ${samplerId}`);

const response = await SF.readSamplerRun(this.partnerId, samplerId);

if (!response?.data?.status) {
consola.error("Failed to fetch sampler run status. Is staging running?");
process.exit(1);
}

await this.#handleSamplerResponse(response.data);
} catch (error) {
errorUtils.errorHandler(error);
}
}

/**
* Build sampler parameters from local template files
* @param {Object} templateHandles - Object containing arrays of template identifiers
* @param {Array<string>} templateHandles.reconciliationTexts - Array of reconciliation text handles
* @param {Array<string>} templateHandles.accountTemplates - Array of account template names
* @param {Array<string>} templateHandles.sharedParts - Array of shared part names
* @param {Array<number>} firmIds - Array of firm IDs to use in the sampler
* @returns {Object} Sampler payload with templates array
*/
async #buildSamplerParams(templateHandles = {}, firmIds = []) {
const templates = [];
const { reconciliationTexts = [], accountTemplates = [], sharedParts = [] } = templateHandles;

// Process reconciliation texts
for (const handle of reconciliationTexts) {
const templateId = this.#resolveTemplateId("reconciliationText", handle, "reconciliation text");
const { text, text_parts } = await this.#readTemplateContent(ReconciliationText, handle, "reconciliation text");

templates.push({
type: "reconciliation_text",
id: templateId,
text,
text_parts,
});
}

// Process account templates
for (const name of accountTemplates) {
const templateId = this.#resolveTemplateId("accountTemplate", name, "account template");
const { text, text_parts } = await this.#readTemplateContent(AccountTemplate, name, "account template");

templates.push({
type: "account_detail_template",
id: templateId,
text,
text_parts,
});
}

// Process shared parts
for (const name of sharedParts) {
const templateId = this.#resolveTemplateId("sharedPart", name, "shared part");
const { text } = await this.#readTemplateContent(SharedPart, name, "shared part");

templates.push({
type: "shared_part",
id: templateId,
text,
});
}

return { templates, firm_ids: firmIds };
}

/**
* Resolve a template's partner-specific ID from its local config.
* Exits the process with a helpful message if the config is missing or has
* no partner_id entry for the current partner.
* @param {string} templateType - Config type ("reconciliationText" | "accountTemplate" | "sharedPart")
* @param {string} handle - Template handle/name
* @param {string} label - Human-readable label used in error messages
* @returns {string} The partner template ID
*/
#resolveTemplateId(templateType, handle, label) {
if (!fsUtils.configExists(templateType, handle)) {
Comment thread
Toby-Masters-SF marked this conversation as resolved.
consola.error(`Config file for ${label} "${handle}" not found`);
process.exit(1);
}

const config = fsUtils.readConfig(templateType, handle);

if (!config.partner_id || !config.partner_id[this.partnerId]) {
Comment thread
michieldegezelle marked this conversation as resolved.
consola.error(`${label} '${handle}' has no partner_id entry for partner ${this.partnerId}. Import it to this partner first.`);
process.exit(1);
}

return String(config.partner_id[this.partnerId]);
}

/**
* Read a template's local content, exiting with a clear message if it
* can't be read (e.g. an invalid handle makes `read` return false).
* Awaiting is safe for both the synchronous (ReconciliationText,
* AccountTemplate) and asynchronous (SharedPart) read implementations.
* @param {Object} TemplateClass - Template class exposing a static `read`
* @param {string} handle - Template handle/name
* @param {string} label - Human-readable label used in error messages
* @returns {Promise<Object>} The template content ({ text, text_parts? })
*/
async #readTemplateContent(TemplateClass, handle, label) {
const templateContent = await TemplateClass.read(handle);

if (!templateContent) {
consola.error(`Could not read ${label} "${handle}"`);
process.exit(1);
}

return templateContent;
}

/**
* Poll for sampler run completion
* @param {string} samplerId - The sampler run ID
* @returns {Promise<Object>} The completed sampler run
*/
Comment thread
Toby-Masters-SF marked this conversation as resolved.
async #fetchAndWaitSamplerResult(samplerId) {
let samplerRun = { status: "pending" };
const pollingDelay = 15000; // 15 seconds
const waitingLimit = 3600000; // 1 hour

spinner.spin("Running sampler...");
let waitingTime = 0;

try {
while (samplerRun.status === "pending" || samplerRun.status === "running") {
// Pause the loop before polling again (setTimeout wrapped in a Promise so it can be awaited)
await new Promise((resolve) => setTimeout(resolve, pollingDelay));

// Poll for the sampler run status
const response = await SF.readSamplerRun(this.partnerId, samplerId);
samplerRun = response?.data;

if (!samplerRun?.status) {
// process.exit() bypasses the finally block, so stop the spinner explicitly here.
spinner.stop();
consola.error("Failed to fetch sampler run status. Is staging running?");
process.exit(1);
}

waitingTime += pollingDelay;

if (waitingTime >= waitingLimit) {
// process.exit() bypasses the finally block, so stop the spinner explicitly here.
spinner.stop();
consola.error("Timeout. Try to fetch the status by using the --id flag, if not run your sampler again");
process.exit(1);
}
}

return samplerRun;
} finally {
spinner.stop();
}
}

/**
* Process and display sampler run results
* @param {Object} response - The sampler run response
*/
async #handleSamplerResponse(response) {
switch (response.status) {
case "failed":
consola.error(`Sampler run failed: ${response.error_message || "Unknown error"}`);
process.exit(1);
break; // eslint-disable-line no-unreachable

case "completed":
consola.success("Sampler run completed successfully");

if (response && response.result_url) {
await new UrlHandler(response.result_url).openFile();
} else {
consola.error("Sampler completed but no result URL was returned");
process.exit(1);
break; // eslint-disable-line no-unreachable
}
break;

case "pending":
case "running":
Comment thread
Toby-Masters-SF marked this conversation as resolved.
consola.info(`Sampler run is still in progress. Current status: "${response.status}". Please check again later.`);
break;

default:
consola.error(`Unexpected sampler status: ${response.status}`);
process.exit(1);
}
}
}

module.exports = { LiquidSamplerRunner };
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "silverfin-cli",
"version": "1.56.3",
"version": "1.57.0",
"description": "Command line tool for Silverfin template development",
"main": "index.js",
"license": "MIT",
Expand Down
Loading