@fnndsc/chili 3.2.5 → 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.
package/README.md CHANGED
@@ -42,27 +42,34 @@ Designed for developers and power-users who want to script and control a ChRIS i
42
42
  2. **[`salsa`](https://github.com/FNNDSC/salsa) (Logic)**: Shared Application Logic and Service Assets — high-level business intents.
43
43
  3. **[`cumin`](https://github.com/FNNDSC/cumin) (Infrastructure)**: State and connection layer — authentication, context persistence, low-level API.
44
44
 
45
- ## Installation & Development
45
+ ## Development
46
46
 
47
- ### Full build (all layers)
47
+ `chili` lives in the [`mise` monorepo](https://github.com/FNNDSC/mise) alongside
48
+ `cumin`, `salsa`, and `chell`. Build the whole stack from the repository root —
49
+ npm workspaces links the four packages together, so there is nothing to clone or
50
+ hand-link:
48
51
 
49
52
  ```bash
50
- cd chili
51
- make meal
53
+ git clone https://github.com/FNNDSC/mise
54
+ cd mise
55
+ make taco # scrub → prep → cook → taste → serve (the full course)
52
56
  ```
53
57
 
54
- ### Individual steps
58
+ ### Individual steps (run from the repo root)
55
59
 
56
60
  | Target | Action |
57
61
  |--------|--------|
58
- | `make shop` | Clone `cumin` and `salsa` if missing |
59
- | `make prep` | `npm install` across all packages |
60
- | `make cook` | Build (compile TypeScript) all packages |
61
- | `make taste` | Run tests |
62
- | `make serve` | Link packages globally |
62
+ | `make prep` | `npm install` — install deps and link all four workspaces |
63
+ | `make cook` | Build (compile TypeScript) all packages in dependency order |
64
+ | `make taste` | Run the full test suite |
65
+ | `make serve` | Link `chell` globally |
63
66
  | `make scrub` | Clean build artifacts and `node_modules` |
64
67
 
65
- > Use [NVM](https://github.com/nvm-sh/nvm) to avoid needing `sudo` for global links.
68
+ To build or test just this package: `npm run build -w @fnndsc/chili` /
69
+ `npm test -w @fnndsc/chili`.
70
+
71
+ > Use [NVM](https://github.com/nvm-sh/nvm) and Node 22.x to avoid needing `sudo`
72
+ > for the global link in `make serve`.
66
73
 
67
74
  ## Core Features
68
75
 
@@ -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
  /**
@@ -23,7 +23,7 @@ export async function manPage_display(options) {
23
23
  return;
24
24
  }
25
25
  if (options.browser) {
26
- browser_open(docPath);
26
+ await browser_open(docPath);
27
27
  }
28
28
  else {
29
29
  const content = fs.readFileSync(docPath, "utf-8");
@@ -11,6 +11,9 @@ import { marked } from "marked";
11
11
  import TerminalRenderer from "marked-terminal";
12
12
  import chalk from "chalk";
13
13
  marked.setOptions({
14
+ // @ts-expect-error marked-terminal@6 ships .d.ts that drift from marked@9's
15
+ // Renderer type at this runtime-compatible version pair. (Tracked: align the
16
+ // marked / marked-terminal versions across chili & chell.)
14
17
  renderer: new TerminalRenderer({
15
18
  code: chalk.yellow,
16
19
  blockquote: chalk.gray.italic,
@@ -25,11 +25,6 @@ export declare class FileGroupHandler {
25
25
  * @returns A Promise resolving to a new FileGroupHandler instance.
26
26
  */
27
27
  static handler_create(assetName: string, path?: string): Promise<FileGroupHandler>;
28
- /**
29
- * Removes duplicate column headers from FilteredResourceData results.
30
- * Copied from BaseGroupHandler to support local listing logic.
31
- */
32
- private columns_removeDuplicates;
33
28
  /**
34
29
  * Lists files, links, or directories using the new command logic.
35
30
  */
@@ -4,6 +4,7 @@ import { path_resolveChrisFs } from "../utils/cli.js";
4
4
  import { FileController } from "../controllers/fileController.js";
5
5
  import { files_create } from "../commands/fs/create.js";
6
6
  import { errorStack } from "@fnndsc/cumin";
7
+ import { resourceColumns_removeDuplicates } from "../utils/resourceData.js";
7
8
  import { table_display } from "../screen/screen.js";
8
9
  import { files_fetchList } from "../commands/files/list.js";
9
10
  import { fileFields_fetch } from "../commands/files/fields.js";
@@ -41,24 +42,6 @@ export class FileGroupHandler {
41
42
  throw new Error(`Failed to initialize FileGroupHandler for ${assetName}: ${errorMessage}`);
42
43
  }
43
44
  }
44
- /**
45
- * Removes duplicate column headers from FilteredResourceData results.
46
- * Copied from BaseGroupHandler to support local listing logic.
47
- */
48
- columns_removeDuplicates(results) {
49
- const uniqueHeaders = Array.from(new Set(results.selectedFields));
50
- const uniqueTableData = results.tableData.map((row) => uniqueHeaders.reduce((acc, header) => {
51
- if (typeof header === "string" && header in row) {
52
- acc[header] = row[header];
53
- }
54
- return acc;
55
- }, {}));
56
- return {
57
- ...results,
58
- selectedFields: uniqueHeaders,
59
- tableData: uniqueTableData,
60
- };
61
- }
62
45
  /**
63
46
  * Lists files, links, or directories using the new command logic.
64
47
  */
@@ -73,7 +56,7 @@ export class FileGroupHandler {
73
56
  console.log(`No ${this.assetName} found matching the criteria.`);
74
57
  }
75
58
  else {
76
- const uniqueResults = this.columns_removeDuplicates(results);
59
+ const uniqueResults = resourceColumns_removeDuplicates(results);
77
60
  // cumin returns dynamic table rows (Record<string, unknown>[]);
78
61
  // narrow to the FileResource view model at this boundary.
79
62
  console.log(fileList_render(uniqueResults.tableData, uniqueResults.selectedFields, { table: options.table, csv: options.csv }));
@@ -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
  /**
@@ -16,13 +16,6 @@ export declare class BaseGroupHandler {
16
16
  displayOptions: TableOptions;
17
17
  chrisObject: ChRISPluginGroup | ChRISFeedGroup | ChRISEmbeddedResourceGroup<unknown> | ChRISPACSGroup;
18
18
  constructor(assetName: string, chrisObject: ChRISPluginGroup | ChRISFeedGroup | ChRISEmbeddedResourceGroup<unknown> | ChRISPACSGroup);
19
- /**
20
- * Removes duplicate column headers from FilteredResourceData results.
21
- *
22
- * @param results - The FilteredResourceData to process.
23
- * @returns FilteredResourceData with unique headers and corresponding data.
24
- */
25
- private columns_removeDuplicates;
26
19
  /**
27
20
  * Lists ChRIS resources based on provided CLI options.
28
21
  *
@@ -33,6 +26,15 @@ export declare class BaseGroupHandler {
33
26
  * Lists available fields for the current ChRIS resource type.
34
27
  */
35
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>;
36
38
  /**
37
39
  * Prompts the user for confirmation before performing an operation.
38
40
  *
@@ -4,9 +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
- import { table_display, border_draw } from "../screen/screen.js";
9
+ import { resourceColumns_removeDuplicates } from "../utils/resourceData.js";
10
+ import { table_display, table_render, border_draw } from "../screen/screen.js";
10
11
  import * as readline from "readline";
11
12
  /**
12
13
  * Base handler for groups of ChRIS resources.
@@ -21,26 +22,6 @@ export class BaseGroupHandler {
21
22
  title: { title: this.assetName, justification: "center" },
22
23
  };
23
24
  }
24
- /**
25
- * Removes duplicate column headers from FilteredResourceData results.
26
- *
27
- * @param results - The FilteredResourceData to process.
28
- * @returns FilteredResourceData with unique headers and corresponding data.
29
- */
30
- columns_removeDuplicates(results) {
31
- const uniqueHeaders = Array.from(new Set(results.selectedFields));
32
- const uniqueTableData = results.tableData.map((row) => uniqueHeaders.reduce((acc, header) => {
33
- if (typeof header === "string" && header in row) {
34
- acc[header] = row[header];
35
- }
36
- return acc;
37
- }, {}));
38
- return {
39
- ...results,
40
- selectedFields: uniqueHeaders,
41
- tableData: uniqueTableData,
42
- };
43
- }
44
25
  /**
45
26
  * Lists ChRIS resources based on provided CLI options.
46
27
  *
@@ -77,7 +58,7 @@ export class BaseGroupHandler {
77
58
  console.log(`No ${this.assetName} found matching the criteria.`);
78
59
  }
79
60
  else {
80
- const uniqueResults = this.columns_removeDuplicates(results);
61
+ const uniqueResults = resourceColumns_removeDuplicates(results);
81
62
  // Construct column options based on parsed widths
82
63
  const columns = uniqueResults.selectedFields.map(field => {
83
64
  const width = columnWidths[field];
@@ -147,6 +128,30 @@ export class BaseGroupHandler {
147
128
  console.log(errorStack.stack_search(this.assetName)[0]);
148
129
  }
149
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
+ }
150
155
  /**
151
156
  * Prompts the user for confirmation before performing an operation.
152
157
  *
package/dist/index.d.ts CHANGED
@@ -2,6 +2,11 @@
2
2
  /**
3
3
  * @file Command-line entry point for the chili (ChILI) CLI.
4
4
  *
5
+ * This is a thin wrapper: all command wiring and execution live in {@link run}
6
+ * (./run.js) so the same logic can be driven in-process by a host such as chell
7
+ * without spawning a separate `node` subprocess. This file only adds the
8
+ * bin-only concerns — shell completion and fatal-exit handling.
9
+ *
5
10
  * @module
6
11
  */
7
- export { logical_toPhysical } from './utils/cli.js';
12
+ export { logical_toPhysical } from "./utils/cli.js";