@fnndsc/chili 3.3.0 → 3.4.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.
@@ -13,13 +13,28 @@ export interface DownloadSummary {
13
13
  duration: number;
14
14
  speed: number;
15
15
  }
16
+ /** Download progress fact emitted to callers that want to render progress. */
17
+ export interface DownloadProgressEvent {
18
+ operation: "download";
19
+ kind: "transfer";
20
+ phase: "scanning" | "transferring" | "complete" | "failed";
21
+ label?: string;
22
+ current?: number;
23
+ total?: number;
24
+ percent?: number;
25
+ unit?: "files" | "bytes";
26
+ status?: "running" | "done" | "error";
27
+ }
28
+ /** Options for download execution. */
29
+ export interface DownloadOptions {
30
+ force?: boolean;
31
+ onProgress?: (event: DownloadProgressEvent) => void;
32
+ }
16
33
  /**
17
- * Downloads files from ChRIS with a progress bar display.
34
+ * Downloads files from ChRIS and optionally emits structured progress events.
18
35
  * @param chrisPath - Remote ChRIS path (file or directory).
19
36
  * @param localPath - Local filesystem path.
20
37
  * @param options - Download options.
21
38
  * @returns Promise<DownloadSummary> with download statistics.
22
39
  */
23
- export declare function files_downloadWithProgress(chrisPath: string, localPath: string, options?: {
24
- force?: boolean;
25
- }): Promise<DownloadSummary>;
40
+ export declare function files_downloadWithProgress(chrisPath: string, localPath: string, options?: DownloadOptions): Promise<DownloadSummary>;
@@ -6,10 +6,9 @@ import { fileContent_getBinaryStream, files_listRecursive } from "@fnndsc/salsa"
6
6
  import { path_resolveChrisFs } from "../../utils/cli.js";
7
7
  import fs from "fs";
8
8
  import path from "path";
9
- import cliProgress from "cli-progress";
10
9
  import chalk from "chalk";
11
10
  import { pipeline } from "stream/promises";
12
- import { bytes_format, eta_format, rate_format } from "./upload.js";
11
+ import { bytes_format } from "./upload.js";
13
12
  import { prompt_confirmOrThrow } from "../../utils/input_format.js";
14
13
  export { bytes_format };
15
14
  /**
@@ -36,7 +35,7 @@ async function chrisPath_isDirectory(chrisPath) {
36
35
  }
37
36
  }
38
37
  /**
39
- * Downloads files from ChRIS with a progress bar display.
38
+ * Downloads files from ChRIS and optionally emits structured progress events.
40
39
  * @param chrisPath - Remote ChRIS path (file or directory).
41
40
  * @param localPath - Local filesystem path.
42
41
  * @param options - Download options.
@@ -93,61 +92,66 @@ export async function files_downloadWithProgress(chrisPath, localPath, options =
93
92
  // Ensure parent directory exists
94
93
  const parentDir = path.dirname(finalLocalPath);
95
94
  await directory_ensureExists(parentDir);
96
- const showProgress = !!process.stdout.isTTY;
97
- const progressBar = typeof size === "number" && showProgress
98
- ? new cliProgress.SingleBar({
99
- format: "Downloading [{bar}] {percentage}% | ETA: {etaHuman} | Rate: {rate} | {bytes}/{totalBytes}",
100
- }, cliProgress.Presets.shades_classic)
101
- : null;
102
- if (progressBar) {
103
- progressBar.start(size, 0, {
104
- etaHuman: "--",
105
- rate: "--",
106
- bytes: bytes_format(0),
107
- totalBytes: bytes_format(size),
108
- });
109
- }
110
95
  let transferred = 0;
111
96
  const writeStream = fs.createWriteStream(finalLocalPath);
112
97
  stream.on("data", (chunk) => {
113
98
  transferred += chunk.length;
114
- if (progressBar) {
115
- const elapsedSeconds = Math.max(1e-6, (Date.now() - summary.startTime) / 1000);
116
- const rateCurrent = transferred / elapsedSeconds;
117
- const remainingBytes = Math.max(0, (size ?? transferred) - transferred);
118
- const etaSeconds = rateCurrent > 0 ? remainingBytes / rateCurrent : null;
119
- progressBar.update(Math.min(transferred, size || transferred), {
120
- etaHuman: eta_format(etaSeconds),
121
- rate: rate_format(rateCurrent),
122
- bytes: bytes_format(transferred),
123
- totalBytes: bytes_format(size ?? transferred),
124
- });
125
- }
99
+ options.onProgress?.({
100
+ operation: "download",
101
+ kind: "transfer",
102
+ phase: "transferring",
103
+ label: `Downloading ${path.basename(finalLocalPath)}`,
104
+ current: transferred,
105
+ total: size,
106
+ percent: typeof size === "number" && size > 0 ? (Math.min(transferred, size) / size) * 100 : undefined,
107
+ unit: "bytes",
108
+ status: "running",
109
+ });
126
110
  });
127
111
  await pipeline(stream, writeStream);
128
- if (progressBar) {
129
- progressBar.update(size || transferred);
130
- progressBar.stop();
131
- }
132
112
  summary.totalFiles = 1;
133
113
  summary.transferredCount = 1;
134
114
  summary.transferSize = transferred;
135
115
  summary.endTime = Date.now();
136
116
  summary.duration = (summary.endTime - summary.startTime) / 1000;
137
117
  summary.speed = summary.duration > 0 ? summary.transferSize / summary.duration : 0;
118
+ options.onProgress?.({
119
+ operation: "download",
120
+ kind: "transfer",
121
+ phase: "complete",
122
+ label: "Download complete",
123
+ current: transferred,
124
+ total: size,
125
+ percent: typeof size === "number" && size > 0 ? 100 : undefined,
126
+ unit: "bytes",
127
+ status: "done",
128
+ });
138
129
  return summary;
139
130
  }
140
131
  catch (error) {
141
132
  summary.failedCount = 1;
133
+ options.onProgress?.({
134
+ operation: "download",
135
+ kind: "transfer",
136
+ phase: "failed",
137
+ label: "Download failed",
138
+ status: "error",
139
+ });
142
140
  throw error;
143
141
  }
144
142
  }
145
143
  // Download directory
146
144
  console.log(chalk.cyan("Scanning files to download..."));
145
+ options.onProgress?.({
146
+ operation: "download",
147
+ kind: "transfer",
148
+ phase: "scanning",
149
+ label: "Scanning files to download",
150
+ status: "running",
151
+ });
147
152
  const items = await files_listRecursive(resolvedChris);
148
153
  const files = items.filter((item) => item.type === 'file');
149
154
  summary.totalFiles = files.length;
150
- const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
151
155
  // Apply Unix semantics: trailing slash determines merge behavior
152
156
  const targetDir = resolvedChris.endsWith('/')
153
157
  ? localPath // Merge contents
@@ -169,20 +173,17 @@ export async function files_downloadWithProgress(chrisPath, localPath, options =
169
173
  }
170
174
  // Ensure target directory exists
171
175
  await directory_ensureExists(targetDir);
172
- const showProgress = !!process.stdout.isTTY;
173
- const progressBar = showProgress
174
- ? new cliProgress.SingleBar({
175
- format: "Downloading [{bar}] {percentage}% | ETA: {etaHuman} | Rate: {rate} | {value}/{total} files | {bytes}/{totalBytes}",
176
- }, cliProgress.Presets.shades_classic)
177
- : null;
178
- if (progressBar) {
179
- progressBar.start(files.length, 0, {
180
- bytes: bytes_format(0),
181
- totalBytes: bytes_format(totalSize),
182
- etaHuman: "--",
183
- rate: "--",
184
- });
185
- }
176
+ options.onProgress?.({
177
+ operation: "download",
178
+ kind: "transfer",
179
+ phase: "transferring",
180
+ label: "Downloading files",
181
+ current: 0,
182
+ total: files.length,
183
+ percent: files.length === 0 ? 100 : 0,
184
+ unit: "files",
185
+ status: "running",
186
+ });
186
187
  // Download each file
187
188
  for (const [index, file] of files.entries()) {
188
189
  try {
@@ -213,23 +214,31 @@ export async function files_downloadWithProgress(chrisPath, localPath, options =
213
214
  const msg = error instanceof Error ? error.message : String(error);
214
215
  console.log(chalk.red(`\nError downloading ${file.path}: ${msg}`));
215
216
  }
216
- const elapsedSeconds = Math.max(1e-6, (Date.now() - summary.startTime) / 1000);
217
- const rateCurrent = summary.transferSize > 0 ? summary.transferSize / elapsedSeconds : 0;
218
- const remainingBytes = Math.max(0, totalSize - summary.transferSize);
219
- const etaSeconds = rateCurrent > 0 ? remainingBytes / rateCurrent : null;
220
- if (progressBar) {
221
- progressBar.update(index + 1, {
222
- bytes: bytes_format(summary.transferSize),
223
- etaHuman: eta_format(etaSeconds),
224
- rate: rate_format(rateCurrent),
225
- });
226
- }
227
- }
228
- if (progressBar) {
229
- progressBar.stop();
217
+ options.onProgress?.({
218
+ operation: "download",
219
+ kind: "transfer",
220
+ phase: "transferring",
221
+ label: "Downloading files",
222
+ current: index + 1,
223
+ total: files.length,
224
+ percent: files.length === 0 ? 100 : ((index + 1) / files.length) * 100,
225
+ unit: "files",
226
+ status: "running",
227
+ });
230
228
  }
231
229
  summary.endTime = Date.now();
232
230
  summary.duration = (summary.endTime - summary.startTime) / 1000;
233
231
  summary.speed = summary.duration > 0 ? summary.transferSize / summary.duration : 0;
232
+ options.onProgress?.({
233
+ operation: "download",
234
+ kind: "transfer",
235
+ phase: summary.failedCount === 0 ? "complete" : "failed",
236
+ label: "Download complete",
237
+ current: summary.transferredCount,
238
+ total: summary.totalFiles,
239
+ percent: summary.totalFiles === 0 ? 100 : (summary.transferredCount / summary.totalFiles) * 100,
240
+ unit: "files",
241
+ status: summary.failedCount === 0 ? "done" : "error",
242
+ });
234
243
  return summary;
235
244
  }
@@ -20,6 +20,23 @@ export interface UploadSummary {
20
20
  speed: number;
21
21
  actualTargetPath: string;
22
22
  }
23
+ /** Upload progress fact emitted to callers that want to render progress. */
24
+ export interface UploadProgressEvent {
25
+ operation: "upload";
26
+ kind: "transfer";
27
+ phase: "scanning" | "transferring" | "complete" | "failed";
28
+ label?: string;
29
+ current?: number;
30
+ total?: number;
31
+ percent?: number;
32
+ unit?: "files";
33
+ status?: "running" | "done" | "error";
34
+ }
35
+ /** Options for upload execution. */
36
+ export interface UploadOptions {
37
+ force?: boolean;
38
+ onProgress?: (event: UploadProgressEvent) => void;
39
+ }
23
40
  /**
24
41
  * Formats bytes into human-readable format.
25
42
  * @param bytes - Number of bytes.
@@ -37,14 +54,13 @@ export declare function eta_format(seconds: number | null): string;
37
54
  */
38
55
  export declare function rate_format(rateBytesPerSec: number): string;
39
56
  /**
40
- * Uploads files with a progress bar display.
57
+ * Uploads files and optionally emits structured progress events.
41
58
  * @param localPath - Local path (file or directory).
42
59
  * @param remotePath - Remote ChRIS path.
60
+ * @param options - Upload options, including an optional progress callback.
43
61
  * @returns Promise<UploadSummary> with upload statistics.
44
62
  */
45
- export declare function files_uploadWithProgress(localPath: string, remotePath: string, options?: {
46
- force?: boolean;
47
- }): Promise<UploadSummary>;
63
+ export declare function files_uploadWithProgress(localPath: string, remotePath: string, options?: UploadOptions): Promise<UploadSummary>;
48
64
  /**
49
65
  * Uploads a local file or directory to ChRIS.
50
66
  *
@@ -7,7 +7,6 @@ import { chrisIO } from "@fnndsc/cumin";
7
7
  import { path_resolveChrisFs } from "../../utils/cli.js";
8
8
  import fs from "fs";
9
9
  import path from "path";
10
- import cliProgress from "cli-progress";
11
10
  import chalk from "chalk";
12
11
  import { prompt_confirmOrThrow } from "../../utils/input_format.js";
13
12
  /**
@@ -101,17 +100,24 @@ async function localFiles_scan(localPath, remotePath) {
101
100
  return files;
102
101
  }
103
102
  /**
104
- * Uploads files with a progress bar display.
103
+ * Uploads files and optionally emits structured progress events.
105
104
  * @param localPath - Local path (file or directory).
106
105
  * @param remotePath - Remote ChRIS path.
106
+ * @param options - Upload options, including an optional progress callback.
107
107
  * @returns Promise<UploadSummary> with upload statistics.
108
108
  */
109
109
  export async function files_uploadWithProgress(localPath, remotePath, options = {}) {
110
110
  const resolvedRemote = await path_resolveChrisFs(remotePath, {});
111
111
  // Scan files
112
112
  console.log(chalk.cyan("Scanning files to upload..."));
113
+ options.onProgress?.({
114
+ operation: "upload",
115
+ kind: "transfer",
116
+ phase: "scanning",
117
+ label: "Scanning files to upload",
118
+ status: "running",
119
+ });
113
120
  const fileList = await localFiles_scan(localPath, resolvedRemote);
114
- const totalSize = fileList.reduce((sum, f) => sum + f.size, 0);
115
121
  // Determine actual target path (where files will be uploaded)
116
122
  const stats = await fs.promises.stat(localPath);
117
123
  let actualTarget = resolvedRemote;
@@ -140,20 +146,6 @@ export async function files_uploadWithProgress(localPath, remotePath, options =
140
146
  else {
141
147
  // Force mode: if target exists and is a directory, no prompt. If it's a file and we're uploading a single file, proceed.
142
148
  }
143
- const showProgress = !!process.stdout.isTTY;
144
- const progressBar = showProgress
145
- ? new cliProgress.SingleBar({
146
- format: "Transferring [{bar}] {percentage}% | ETA: {etaHuman} | Rate: {rate} | {value}/{total} files | {bytes}/{totalBytes}",
147
- }, cliProgress.Presets.shades_classic)
148
- : null;
149
- if (progressBar) {
150
- progressBar.start(fileList.length, 0, {
151
- bytes: "0 B",
152
- totalBytes: bytes_format(totalSize),
153
- etaHuman: "--",
154
- rate: "--",
155
- });
156
- }
157
149
  // Upload files
158
150
  const summary = {
159
151
  startTime: Date.now(),
@@ -166,6 +158,17 @@ export async function files_uploadWithProgress(localPath, remotePath, options =
166
158
  speed: 0,
167
159
  actualTargetPath: actualTarget,
168
160
  };
161
+ options.onProgress?.({
162
+ operation: "upload",
163
+ kind: "transfer",
164
+ phase: "transferring",
165
+ label: "Uploading files",
166
+ current: 0,
167
+ total: fileList.length,
168
+ percent: fileList.length === 0 ? 100 : 0,
169
+ unit: "files",
170
+ status: "running",
171
+ });
169
172
  for (const [index, file] of fileList.entries()) {
170
173
  try {
171
174
  const fileContent = await fs.promises.readFile(file.hostPath);
@@ -188,24 +191,32 @@ export async function files_uploadWithProgress(localPath, remotePath, options =
188
191
  summary.failedCount++;
189
192
  console.log(chalk.red(`Error uploading ${file.hostPath}: ${error instanceof Error ? error.message : String(error)}`));
190
193
  }
191
- const elapsedSeconds = Math.max(1e-6, (Date.now() - summary.startTime) / 1000);
192
- const rateCurrent = summary.transferSize > 0 ? summary.transferSize / elapsedSeconds : 0;
193
- const remainingBytes = Math.max(0, totalSize - summary.transferSize);
194
- const etaSeconds = rateCurrent > 0 ? remainingBytes / rateCurrent : null;
195
- if (progressBar) {
196
- progressBar.update(index + 1, {
197
- bytes: bytes_format(summary.transferSize),
198
- etaHuman: eta_format(etaSeconds),
199
- rate: rate_format(rateCurrent),
200
- });
201
- }
202
- }
203
- if (progressBar) {
204
- progressBar.stop();
194
+ options.onProgress?.({
195
+ operation: "upload",
196
+ kind: "transfer",
197
+ phase: "transferring",
198
+ label: "Uploading files",
199
+ current: index + 1,
200
+ total: fileList.length,
201
+ percent: fileList.length === 0 ? 100 : ((index + 1) / fileList.length) * 100,
202
+ unit: "files",
203
+ status: "running",
204
+ });
205
205
  }
206
206
  summary.endTime = Date.now();
207
207
  summary.duration = (summary.endTime - summary.startTime) / 1000; // seconds
208
208
  summary.speed = summary.transferSize / summary.duration; // bytes per second
209
+ options.onProgress?.({
210
+ operation: "upload",
211
+ kind: "transfer",
212
+ phase: summary.failedCount === 0 ? "complete" : "failed",
213
+ label: "Upload complete",
214
+ current: summary.transferredCount,
215
+ total: summary.totalFiles,
216
+ percent: summary.totalFiles === 0 ? 100 : (summary.transferredCount / summary.totalFiles) * 100,
217
+ unit: "files",
218
+ status: summary.failedCount === 0 ? "done" : "error",
219
+ });
209
220
  return summary;
210
221
  }
211
222
  /**
@@ -4,7 +4,7 @@
4
4
  * @module
5
5
  */
6
6
  import { Command } from "commander";
7
- import { ChRISPluginGroup, ChRISFeedGroup, ChRISEmbeddedResourceGroup, ChRISPACSGroup } from "@fnndsc/cumin";
7
+ import { ChRISPluginGroup, ChRISFeedGroup, ChRISEmbeddedResourceGroup, ChRISPACSGroup, type CommandEnvelope } from "@fnndsc/cumin";
8
8
  import { CLIoptions } from "../utils/cli.js";
9
9
  import { TableOptions } from "../screen/screen.js";
10
10
  /**
@@ -26,6 +26,15 @@ export declare class BaseGroupHandler {
26
26
  * Lists available fields for the current ChRIS resource type.
27
27
  */
28
28
  resourceFields_list(): Promise<void>;
29
+ /**
30
+ * Renders available fields for the current ChRIS resource type as an envelope.
31
+ *
32
+ * Sans-I/O counterpart to {@link resourceFields_list} — returns the rendered
33
+ * text instead of printing it, so hosted surfaces can carry it over the wire.
34
+ *
35
+ * @returns An envelope carrying the field listing or an error.
36
+ */
37
+ resourceFields_render(): Promise<CommandEnvelope>;
29
38
  /**
30
39
  * Prompts the user for confirmation before performing an operation.
31
40
  *
@@ -4,10 +4,10 @@
4
4
  * @module
5
5
  */
6
6
  import { Command } from "commander";
7
- import { ChRISFeedGroup, record_extract, errorStack, } from "@fnndsc/cumin";
7
+ import { ChRISFeedGroup, record_extract, errorStack, envelope_ok, envelope_error, } from "@fnndsc/cumin";
8
8
  import { options_toParams } from "../utils/cli.js";
9
9
  import { resourceColumns_removeDuplicates } from "../utils/resourceData.js";
10
- import { table_display, border_draw } from "../screen/screen.js";
10
+ import { table_display, table_render, border_draw } from "../screen/screen.js";
11
11
  import * as readline from "readline";
12
12
  /**
13
13
  * Base handler for groups of ChRIS resources.
@@ -128,6 +128,30 @@ export class BaseGroupHandler {
128
128
  console.log(errorStack.stack_search(this.assetName)[0]);
129
129
  }
130
130
  }
131
+ /**
132
+ * Renders available fields for the current ChRIS resource type as an envelope.
133
+ *
134
+ * Sans-I/O counterpart to {@link resourceFields_list} — returns the rendered
135
+ * text instead of printing it, so hosted surfaces can carry it over the wire.
136
+ *
137
+ * @returns An envelope carrying the field listing or an error.
138
+ */
139
+ async resourceFields_render() {
140
+ try {
141
+ const results = await this.chrisObject.asset.resourceFields_get();
142
+ if (!results) {
143
+ return envelope_error('', undefined, `An error occurred while fetching resource fields for ${this.assetName}.\n`);
144
+ }
145
+ if (results.fields.length === 0) {
146
+ return envelope_ok(`No resource fields found for ${this.assetName}.\n`);
147
+ }
148
+ return envelope_ok(table_render(results.fields, ["fields"]));
149
+ }
150
+ catch (error) {
151
+ const stacked = errorStack.stack_search(this.assetName)[0];
152
+ return envelope_error('', undefined, `${stacked ?? String(error)}\n`);
153
+ }
154
+ }
131
155
  /**
132
156
  * Prompts the user for confirmation before performing an operation.
133
157
  *
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { Command } from "commander";
7
7
  import { CLIoptions } from "../utils/cli.js";
8
+ import { type CommandEnvelope } from "@fnndsc/cumin";
8
9
  /**
9
10
  * Handles commands related to groups of plugin contexts (computes, instances, parameters).
10
11
  */
@@ -33,6 +34,23 @@ export declare class PluginContextGroupHandler {
33
34
  * Lists available fields for the current plugin context resource.
34
35
  */
35
36
  parameters_fieldsList(): Promise<void>;
37
+ /**
38
+ * Renders plugin parameters in "man page" style as an envelope.
39
+ *
40
+ * Sans-I/O counterpart to {@link parameters_listMan}.
41
+ *
42
+ * @param options - CLI options for filtering.
43
+ * @returns An envelope carrying the rendered parameters or an error.
44
+ */
45
+ parameters_listManRender(options: CLIoptions): Promise<CommandEnvelope>;
46
+ /**
47
+ * Renders available fields for the current plugin context resource as an envelope.
48
+ *
49
+ * Sans-I/O counterpart to {@link parameters_fieldsList}.
50
+ *
51
+ * @returns An envelope carrying the field listing.
52
+ */
53
+ parameters_fieldsRender(): Promise<CommandEnvelope>;
36
54
  /**
37
55
  * Sets up the Commander.js commands for plugin context group operations.
38
56
  *
@@ -6,7 +6,8 @@
6
6
  import { BaseGroupHandler } from "../handlers/baseGroupHandler.js";
7
7
  import { options_toParams } from "../utils/cli.js";
8
8
  import { PluginContextController } from "../controllers/pluginContextController.js";
9
- import { pluginParameters_renderMan } from "../views/pluginParameters.js";
9
+ import { pluginParameters_renderMan, pluginParameters_manRender } from "../views/pluginParameters.js";
10
+ import { envelope_ok, envelope_error } from "@fnndsc/cumin";
10
11
  class InitializationError extends Error {
11
12
  constructor(message) {
12
13
  super(message);
@@ -76,6 +77,44 @@ export class PluginContextGroupHandler {
76
77
  await this.baseGroupHandler.resourceFields_list();
77
78
  }
78
79
  }
80
+ /**
81
+ * Renders plugin parameters in "man page" style as an envelope.
82
+ *
83
+ * Sans-I/O counterpart to {@link parameters_listMan}.
84
+ *
85
+ * @param options - CLI options for filtering.
86
+ * @returns An envelope carrying the rendered parameters or an error.
87
+ */
88
+ async parameters_listManRender(options) {
89
+ try {
90
+ const asset = this.controller.chrisObject.asset;
91
+ if (!asset || typeof asset.resources_listAndFilterByOptions !== 'function') {
92
+ return envelope_error('', undefined, "Underlying resource does not support listing.\n");
93
+ }
94
+ const params = options_toParams(options);
95
+ const results = await asset.resources_listAndFilterByOptions(params);
96
+ if (results) {
97
+ return envelope_ok(`${pluginParameters_manRender(results)}\n`);
98
+ }
99
+ return envelope_ok("No parameters found.\n");
100
+ }
101
+ catch (error) {
102
+ return envelope_error('', undefined, `Error listing parameters: ${error}\n`);
103
+ }
104
+ }
105
+ /**
106
+ * Renders available fields for the current plugin context resource as an envelope.
107
+ *
108
+ * Sans-I/O counterpart to {@link parameters_fieldsList}.
109
+ *
110
+ * @returns An envelope carrying the field listing.
111
+ */
112
+ async parameters_fieldsRender() {
113
+ if (this.baseGroupHandler) {
114
+ return this.baseGroupHandler.resourceFields_render();
115
+ }
116
+ return envelope_ok('');
117
+ }
79
118
  /**
80
119
  * Sets up the Commander.js commands for plugin context group operations.
81
120
  *
@@ -75,6 +75,19 @@ export declare function border_draw(text: string, borders?: Borders): string;
75
75
  * @param options - Optional table display options.
76
76
  * @returns The `TableContent` object or null on error.
77
77
  */
78
+ /**
79
+ * Renders a table to a string, the string form of {@link table_display}.
80
+ *
81
+ * Envelope-returning callers use this to return their output rather than
82
+ * printing it; table_display remains the printing form for callers not yet
83
+ * converted. Returns an empty string when there is no table to render.
84
+ *
85
+ * @param tableData - The rows to render.
86
+ * @param headers - Column headers.
87
+ * @param options - Table formatting options.
88
+ * @returns The rendered table (with a trailing newline), or an empty string.
89
+ */
90
+ export declare function table_render(tableData: TableDataRow[] | string[][] | string[], headers: string[] | string, options?: TableOptions): string;
78
91
  export declare function table_display(tableData: TableDataRow[] | string[][] | string[], headers: string[] | string, options?: TableOptions): TableContent | null;
79
92
  /**
80
93
  * Provides screen utilities for logging and table output.
@@ -118,6 +118,50 @@ function firstColumnSettings_apply(columns) {
118
118
  * @param options - Optional table display options.
119
119
  * @returns The `TableContent` object or null on error.
120
120
  */
121
+ /**
122
+ * Renders a table to a string, the string form of {@link table_display}.
123
+ *
124
+ * Envelope-returning callers use this to return their output rather than
125
+ * printing it; table_display remains the printing form for callers not yet
126
+ * converted. Returns an empty string when there is no table to render.
127
+ *
128
+ * @param tableData - The rows to render.
129
+ * @param headers - Column headers.
130
+ * @param options - Table formatting options.
131
+ * @returns The rendered table (with a trailing newline), or an empty string.
132
+ */
133
+ export function table_render(tableData, headers, options = {}) {
134
+ const { processedTableData, processedHeaders } = tableInput_process(tableData, headers);
135
+ const tableObj = tableContent_pack(processedTableData, processedHeaders);
136
+ if (!tableObj) {
137
+ return "";
138
+ }
139
+ const columns = tableObj.headers.map((_, index) => {
140
+ const existingCol = options.columns?.[index] || {};
141
+ return {
142
+ justification: "left",
143
+ ...existingCol,
144
+ };
145
+ });
146
+ const updatedColumns = firstColumnSettings_apply(columns);
147
+ const tableOptions = {
148
+ ...options,
149
+ head: processedHeaders,
150
+ columns: updatedColumns,
151
+ typeColors: {
152
+ string: "green",
153
+ number: "yellow",
154
+ boolean: "cyan",
155
+ object: "magenta",
156
+ },
157
+ };
158
+ let out = `${screen.table_output(processedTableData, tableOptions)}\n`;
159
+ if (options.pagination && options.pagination.shown < options.pagination.total) {
160
+ const { shown, total } = options.pagination;
161
+ out += `${chalk.dim(` ↓ showing ${shown} of ${total} · --all to fetch all · --limit <n> for page size`)}\n`;
162
+ }
163
+ return out;
164
+ }
121
165
  export function table_display(tableData, headers, options = {}) {
122
166
  const { processedTableData, processedHeaders } = tableInput_process(tableData, headers);
123
167
  const tableObj = tableContent_pack(processedTableData, processedHeaders);
@@ -15,3 +15,13 @@ import { FilteredResourceData } from '@fnndsc/cumin';
15
15
  * @param data - The filtered resource data containing plugin parameters.
16
16
  */
17
17
  export declare function pluginParameters_renderMan(data: FilteredResourceData): void;
18
+ /**
19
+ * Builds the "man page" style rendering of plugin parameters as a string.
20
+ *
21
+ * Same format as {@link pluginParameters_renderMan} but returns the text
22
+ * instead of printing it, so hosted surfaces can carry it in an envelope.
23
+ *
24
+ * @param data - The filtered resource data containing plugin parameters.
25
+ * @returns The rendered parameter listing.
26
+ */
27
+ export declare function pluginParameters_manRender(data: FilteredResourceData): string;
@@ -15,10 +15,22 @@ import chalk from 'chalk';
15
15
  * @param data - The filtered resource data containing plugin parameters.
16
16
  */
17
17
  export function pluginParameters_renderMan(data) {
18
+ console.log(pluginParameters_manRender(data));
19
+ }
20
+ /**
21
+ * Builds the "man page" style rendering of plugin parameters as a string.
22
+ *
23
+ * Same format as {@link pluginParameters_renderMan} but returns the text
24
+ * instead of printing it, so hosted surfaces can carry it in an envelope.
25
+ *
26
+ * @param data - The filtered resource data containing plugin parameters.
27
+ * @returns The rendered parameter listing.
28
+ */
29
+ export function pluginParameters_manRender(data) {
18
30
  if (!data.tableData || data.tableData.length === 0) {
19
- console.log("No parameters found.");
20
- return;
31
+ return "No parameters found.";
21
32
  }
33
+ const lines = [];
22
34
  data.tableData.forEach((param) => {
23
35
  // 1. Construct the first line: Flags and Value
24
36
  let line1Parts = [];
@@ -52,28 +64,23 @@ export function pluginParameters_renderMan(data) {
52
64
  line1Parts.push(chalk.gray(`(default: ${param.default})`));
53
65
  }
54
66
  if (line1Parts.length > 0) {
55
- console.log(line1Parts.join(' '));
67
+ lines.push(line1Parts.join(' '));
56
68
  }
57
- else {
58
- // Fallback if both are suppressed (e.g. name 'v', no flag)?
59
- // In strict adherence, we show nothing on line 1, which is weird.
60
- // But assuming a valid plugin definition, one of them should appear.
61
- // If name is 'v', it implies it should probably have a flag '-v'.
62
- // If not, we might want to show '--v' anyway despite the rule, or '-v'.
63
- // For safety, if line1 is empty but we have a name, show it as long flag to avoid invisible params.
64
- if (param.name) {
65
- console.log(chalk.bold(`--${param.name}`) + ' ' + chalk.gray('(implicit)'));
66
- }
69
+ else if (param.name) {
70
+ // Fallback: if line1 is empty but we have a name, show it as a long flag
71
+ // to avoid invisible params.
72
+ lines.push(chalk.bold(`--${param.name}`) + ' ' + chalk.gray('(implicit)'));
67
73
  }
68
74
  // 2. Type line
69
75
  if (param.type) {
70
- console.log(`Type: ${chalk.yellow(param.type)}`);
76
+ lines.push(`Type: ${chalk.yellow(param.type)}`);
71
77
  }
72
78
  // 3. Help description
73
79
  if (param.help) {
74
- console.log(chalk.white(param.help));
80
+ lines.push(chalk.white(param.help));
75
81
  }
76
- // Separator (newline)
77
- console.log('');
82
+ // Separator (blank line)
83
+ lines.push('');
78
84
  });
85
+ return lines.join('\n');
79
86
  }
package/docs/pacs.adoc CHANGED
@@ -21,7 +21,7 @@
21
21
  == PACS Queries
22
22
 
23
23
  - **Create**: `chili pacsqueries create "<query>" [--title <title>] [--description <desc>] [--pacsserver <pacs>]`
24
- - `<query>` can be JSON (`{"PatientID":"12345"}`) or comma-separated `key:value` pairs (`PatientID:12345,StudyDate:20251003`).
24
+ - `<query>` can be JSON (`{"PatientID":"12345"}`) or comma-separated `key:value` pairs (`PatientID:12345,StudyDate:20240101`).
25
25
  - On success prints: `Created PACS query id=<ID> status=<status> pacs=<pacs> title="<title>"`.
26
26
  - **List**: `chili pacsqueries list [--pacsserver <pacs>] [--fields ...] [--table|--csv]`
27
27
  - Filters to current/override PACS server. Use `fieldslist` to discover columns.
@@ -64,7 +64,7 @@
64
64
  ```bash
65
65
  chili context set --pacsserver PACSDCM
66
66
  chili pacsservers list --table
67
- chili pacsqueries create "PatientID:4456554" --title "PID 4456554"
67
+ chili pacsqueries create "PatientID:7654321" --title "PID 7654321"
68
68
  chili pacsqueries list --fields id,title,status,pacs_id,query
69
69
  chili pacsqueries decode <id>
70
70
  ```
@@ -76,7 +76,7 @@ chili pacsqueries decode <id>
76
76
  chili context set --pacsserver PACSDCM
77
77
 
78
78
  # 2. Create query
79
- chili pacsqueries create "PatientID:4456554" --title "Patient scan"
79
+ chili pacsqueries create "PatientID:7654321" --title "Patient scan"
80
80
  # Returns: Created PACS query id=123
81
81
 
82
82
  # 3. See what's available on PACS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fnndsc/chili",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "ChILI handles Intelligent Line Interactions: A CLI and library for ChRIS, acting as the controller layer for business logic and user presentation.",
5
5
  "repository": {
6
6
  "type": "git",