fluidcad 0.0.35 → 0.0.36

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.
@@ -1,7 +1,9 @@
1
1
  import http from 'http';
2
+ import os from 'os';
2
3
  import { randomBytes, createHash } from 'crypto';
3
4
  import { getHubUrl, writeCredentials } from '../lib/config.js';
4
5
  import { HubClient } from '../lib/api-client.js';
6
+ import { readPackageVersion } from '../lib/workspace.js';
5
7
  import { openBrowser } from '../lib/browser.js';
6
8
 
7
9
  const CLIENT_ID = 'fluidcad-cli';
@@ -11,6 +13,26 @@ function base64url(buf) {
11
13
  return buf.toString('base64url');
12
14
  }
13
15
 
16
+ const PLATFORM_NAMES = { darwin: 'macOS', win32: 'Windows', linux: 'Linux' };
17
+
18
+ /**
19
+ * Human-readable device hints shown on the hub's authorize page so the user can
20
+ * confirm the request came from this machine. Display-only — the hub never uses
21
+ * them for the authorization decision (PKCE + loopback do that). The browser
22
+ * request already reveals OS/arch via its User-Agent, so this leaks nothing new.
23
+ */
24
+ function deviceHints() {
25
+ const platform = PLATFORM_NAMES[process.platform] ?? process.platform;
26
+ // os.release() is the kernel/Darwin/NT version; the leading x.y.z is the
27
+ // useful part (e.g. "7.0.10-201.fc44.x86_64" → "7.0.10").
28
+ const release = os.release().split('-')[0];
29
+ return {
30
+ os: `${platform} ${release}`.trim(),
31
+ arch: process.arch,
32
+ version: readPackageVersion(),
33
+ };
34
+ }
35
+
14
36
  /**
15
37
  * Loopback + PKCE login (RFC 8252): spin up a one-shot 127.0.0.1 server, send
16
38
  * the browser to the hub's /cli/authorize, receive the code on /callback,
@@ -42,15 +64,16 @@ function runLogin(opts) {
42
64
  res.end();
43
65
  return;
44
66
  }
45
- res.writeHead(200, { 'content-type': 'text/html' });
46
- res.end(
47
- '<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;text-align:center;padding-top:3rem">' +
48
- '<h2>FluidCAD CLI</h2><p>You can close this tab and return to your terminal.</p>',
49
- );
50
67
  const error = url.searchParams.get('error');
51
68
  const code = url.searchParams.get('code');
52
69
  const returnedState = url.searchParams.get('state');
53
70
 
71
+ // The code has now been received here on the loopback; hand the browser
72
+ // to the hub's branded result screen (nav + design system) to finish.
73
+ const doneStatus = error ? 'denied' : code ? 'ok' : 'error';
74
+ res.writeHead(302, { location: `${hubUrl}/cli/done?status=${doneStatus}` });
75
+ res.end();
76
+
54
77
  (async () => {
55
78
  if (error) {
56
79
  throw new Error(`authorization ${error}`);
@@ -87,6 +110,7 @@ function runLogin(opts) {
87
110
  server.listen(0, '127.0.0.1', () => {
88
111
  const { port } = server.address();
89
112
  redirectUri = `http://127.0.0.1:${port}/callback`;
113
+ const device = deviceHints();
90
114
  const authorizeUrl =
91
115
  `${hubUrl}/cli/authorize?` +
92
116
  new URLSearchParams({
@@ -96,6 +120,10 @@ function runLogin(opts) {
96
120
  code_challenge: challenge,
97
121
  code_challenge_method: 'S256',
98
122
  state,
123
+ // Display-only context for the approval screen (not part of PKCE).
124
+ client_version: device.version,
125
+ os: device.os,
126
+ arch: device.arch,
99
127
  });
100
128
  console.log('Opening your browser to authorize the FluidCAD CLI…');
101
129
  console.log(`\n ${authorizeUrl}\n`);
@@ -1,5 +1,5 @@
1
1
  import { resolve, dirname } from 'path';
2
- import { fileURLToPath } from 'url';
2
+ import { fileURLToPath, pathToFileURL } from 'url';
3
3
 
4
4
  const __dirname = dirname(fileURLToPath(import.meta.url));
5
5
  const mcpEntry = resolve(__dirname, '..', '..', 'mcp', 'dist', 'server.js');
@@ -10,7 +10,8 @@ async function runMcp() {
10
10
  console.log = (...args) => console.error(...args);
11
11
  console.info = (...args) => console.error(...args);
12
12
 
13
- const mod = await import(mcpEntry);
13
+ // file:// URL required: Windows ESM loader rejects bare drive paths (D:\...).
14
+ const mod = await import(pathToFileURL(mcpEntry).href);
14
15
  if (typeof mod.runStdio !== 'function') {
15
16
  console.error('mcp/dist/server.js does not export runStdio.');
16
17
  process.exit(1);
@@ -2,7 +2,8 @@ import { resolve } from 'path';
2
2
  import { getHubUrl, readCredentials } from '../lib/config.js';
3
3
  import { HubClient } from '../lib/api-client.js';
4
4
  import { findEntry, readPackageVersion, readWorkspacePackage } from '../lib/workspace.js';
5
- import { readModelId, writeModelId } from '../lib/model-config.js';
5
+ import { readModelIdentity, writeModelConfig } from '../lib/model-config.js';
6
+ import { isInteractive, select } from '../lib/prompt.js';
6
7
  import { openBrowser } from '../lib/browser.js';
7
8
 
8
9
  async function runPublish(opts) {
@@ -20,7 +21,31 @@ async function runPublish(opts) {
20
21
  const pkg = readWorkspacePackage(workspace);
21
22
  const name = opts.name ?? pkg.name;
22
23
  const description = opts.description ?? pkg.description;
23
- const modelId = readModelId(workspace);
24
+ const { modelId: priorModelId, name: priorName } = readModelIdentity(workspace);
25
+
26
+ // Surface who we are and where this is going *before* any bytes leave the
27
+ // machine, so a wrong account or hub is caught before the upload starts.
28
+ // (The model's own page URL is minted by the hub and printed afterwards.)
29
+ console.log('Publishing to the FluidCAD hub:');
30
+ console.log(` account: ${creds.email || '(unknown account)'}`);
31
+ console.log(` url: ${hubUrl}`);
32
+ console.log('');
33
+
34
+ // Decide new-model vs new-version BEFORE the heavy build, so the user makes
35
+ // the call (and we do any model-list lookup) without first waiting ~110ms for
36
+ // the engine to load. A null target ⇒ the hub mints a fresh model.
37
+ const targetModelId = await resolveTargetModel({
38
+ opts,
39
+ hubUrl,
40
+ token: creds.token,
41
+ priorModelId,
42
+ priorName,
43
+ });
44
+ console.log(
45
+ targetModelId
46
+ ? `Publishing a new version${priorName ? ` of ${priorName}` : ''}.\n`
47
+ : 'Publishing as a new model.\n',
48
+ );
24
49
 
25
50
  // Render once to capture the full param schema for the manifest. This also
26
51
  // acts as a build gate — a compile/runtime error fails the publish here,
@@ -66,8 +91,8 @@ async function runPublish(opts) {
66
91
 
67
92
  const form = new FormData();
68
93
  form.append('fluidpkg', new Blob([zip], { type: 'application/zip' }), 'model.fluidpkg');
69
- if (modelId) {
70
- form.append('modelId', modelId);
94
+ if (targetModelId) {
95
+ form.append('modelId', targetModelId);
71
96
  }
72
97
  if (name) {
73
98
  form.append('name', name);
@@ -90,10 +115,12 @@ async function runPublish(opts) {
90
115
  throw new Error(body.error || `Publish failed (HTTP ${status})`);
91
116
  }
92
117
 
93
- // First publish for this workspace persist the hub-minted id so the next
94
- // publish lands as a new version of the same model.
95
- if (!modelId && body.modelId) {
96
- writeModelId(workspace, body.modelId);
118
+ // Persist the hub-authoritative id (and the name we used) whenever it changed
119
+ // or the workspace had no config covers the first publish, a deliberate new
120
+ // model, and re-attaching a deleted fluidcad.json (the user picked an existing
121
+ // model from the list). An owned-match with an unchanged name is a no-op.
122
+ if (body.modelId && (body.modelId !== priorModelId || (name && name !== priorName))) {
123
+ writeModelConfig(workspace, { modelId: body.modelId, name });
97
124
  }
98
125
 
99
126
  console.log('');
@@ -113,6 +140,72 @@ async function runPublish(opts) {
113
140
  }
114
141
  }
115
142
 
143
+ /**
144
+ * Decide which model this publish targets: an existing model id (→ a new
145
+ * version) or null (→ the hub mints a new model). Honors --new-model /
146
+ * --new-version; otherwise asks when interactive; and with no TTY falls back to
147
+ * today's behavior (a saved fluidcad.json id ⇒ a version, else a new model).
148
+ */
149
+ async function resolveTargetModel({ opts, hubUrl, token, priorModelId, priorName }) {
150
+ if (opts.newModel && opts.newVersion) {
151
+ throw new Error('Pass only one of --new-model / --new-version.');
152
+ }
153
+ if (opts.newModel) return null;
154
+ if (opts.newVersion) {
155
+ if (priorModelId) return priorModelId;
156
+ if (isInteractive()) return pickExistingModel(hubUrl, token);
157
+ throw new Error(
158
+ 'No fluidcad.json here, so there is no model to version. Drop --new-version to ' +
159
+ 'publish a new model, or run interactively to pick an existing one.',
160
+ );
161
+ }
162
+
163
+ // No explicit flag.
164
+ if (!isInteractive()) {
165
+ // Non-interactive (CI, piped input): keep the historical default.
166
+ return priorModelId;
167
+ }
168
+ if (priorModelId) {
169
+ const label = priorName ? `${priorName} (${priorModelId})` : priorModelId;
170
+ return select('How should this publish go up?', [
171
+ { label: `Publish a new version of ${label}`, value: priorModelId },
172
+ { label: 'Publish as a new model', value: null },
173
+ ]);
174
+ }
175
+ const choice = await select('How should this publish go up?', [
176
+ { label: 'Publish a new version of an existing model', value: '__existing__' },
177
+ { label: 'Publish as a new model', value: null },
178
+ ]);
179
+ return choice === '__existing__' ? pickExistingModel(hubUrl, token) : null;
180
+ }
181
+
182
+ /**
183
+ * Fetch the user's own models from the hub and let them pick which one this is a
184
+ * new version of. Returns the chosen model id, or null when they have none yet
185
+ * (the caller then mints a new model).
186
+ */
187
+ async function pickExistingModel(hubUrl, token) {
188
+ const { status, body } = await new HubClient(hubUrl, token).getJson('/api/cli/models');
189
+ if (status === 401) {
190
+ throw new Error('Your session has expired. Run `fluidcad login` again.');
191
+ }
192
+ if (status !== 200) {
193
+ throw new Error(body.error || `Could not list your models (HTTP ${status})`);
194
+ }
195
+ const models = Array.isArray(body.models) ? body.models : [];
196
+ if (models.length === 0) {
197
+ console.log('\nYou have no models on the hub yet — publishing this as a new model.\n');
198
+ return null;
199
+ }
200
+ return select(
201
+ 'Which model is this a new version of?',
202
+ models.map((m) => ({
203
+ label: `${m.name} · ${m.latestVersion ? 'v' + m.latestVersion : 'no versions yet'}`,
204
+ value: m.id,
205
+ })),
206
+ );
207
+ }
208
+
116
209
  export function registerPublishCommand(program) {
117
210
  program
118
211
  .command('publish')
@@ -121,6 +214,8 @@ export function registerPublishCommand(program) {
121
214
  .option('-e, --entry <file>', 'Entry .fluid.js file (auto-detected if only one exists)')
122
215
  .option('-n, --name <name>', 'Model name (defaults to the package name)')
123
216
  .option('-d, --description <text>', 'Optional human description')
217
+ .option('--new-model', 'Publish as a new model, ignoring any saved model id')
218
+ .option('--new-version', 'Publish a new version of the saved (or chosen) model')
124
219
  .option('--visibility <visibility>', 'public | unlisted | private (default: unlisted)')
125
220
  .option('--hub <url>', 'Hub base URL (default: the hub you logged into)')
126
221
  .action((opts) => {
@@ -20,6 +20,14 @@ export class HubClient {
20
20
  return { status: res.status, body };
21
21
  }
22
22
 
23
+ async getJson(path) {
24
+ const res = await fetch(this.base + path, {
25
+ method: 'GET',
26
+ headers: this.#authHeaders(),
27
+ });
28
+ return this.#result(res);
29
+ }
30
+
23
31
  async postJson(path, body) {
24
32
  const res = await fetch(this.base + path, {
25
33
  method: 'POST',
@@ -28,11 +28,34 @@ export function readModelId(workspace) {
28
28
  }
29
29
 
30
30
  /**
31
- * Persist the hub-minted model id, preserving any other fields already in
32
- * `fluidcad.json`. Called after the first publish writes back the new id.
31
+ * The persisted identity for this workspace: the hub model `id` and its
32
+ * last-known `name`. Both come back null when absent first publish, or a
33
+ * `fluidcad.json` the user deleted. The name is shown in the publish prompt so
34
+ * we can name the model offline without a round-trip.
33
35
  */
34
- export function writeModelId(workspace, modelId) {
36
+ export function readModelIdentity(workspace) {
37
+ const cfg = readModelConfig(workspace);
38
+ return {
39
+ modelId: typeof cfg.modelId === 'string' && cfg.modelId ? cfg.modelId : null,
40
+ name: typeof cfg.name === 'string' && cfg.name ? cfg.name : null,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Shallow-merge `patch` into `fluidcad.json`, preserving any other fields, and
46
+ * write it back (creating the file if needed). `undefined`/`null` values in the
47
+ * patch are skipped, so callers can pass a partial identity without clobbering
48
+ * what's already there.
49
+ */
50
+ export function writeModelConfig(workspace, patch) {
35
51
  const cfg = readModelConfig(workspace);
36
- cfg.modelId = modelId;
52
+ for (const [key, value] of Object.entries(patch)) {
53
+ if (value !== undefined && value !== null) cfg[key] = value;
54
+ }
37
55
  writeFileSync(modelConfigPath(workspace), JSON.stringify(cfg, null, 2) + '\n');
38
56
  }
57
+
58
+ /** Persist just the hub-minted model id (thin wrapper over `writeModelConfig`). */
59
+ export function writeModelId(workspace, modelId) {
60
+ writeModelConfig(workspace, { modelId });
61
+ }
@@ -0,0 +1,97 @@
1
+ import { emitKeypressEvents } from 'readline';
2
+
3
+ /**
4
+ * Minimal interactive prompts for the CLI, built on Node's built-in `readline`
5
+ * keypress events — no dependency (the CLI only ships `commander`). Used by
6
+ * `publish` to choose between a new model and a new version.
7
+ */
8
+
9
+ /**
10
+ * Whether we can run an interactive prompt — both stdin and stdout must be a
11
+ * TTY. CI and piped input are not, so callers fall back to flags/defaults there.
12
+ */
13
+ export function isInteractive() {
14
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
15
+ }
16
+
17
+ const HIDE_CURSOR = '\x1b[?25l';
18
+ const SHOW_CURSOR = '\x1b[?25h';
19
+ const CLEAR_LINE = '\x1b[2K';
20
+ const CYAN = '\x1b[36m';
21
+ const DIM = '\x1b[2m';
22
+ const RESET = '\x1b[0m';
23
+
24
+ /**
25
+ * Arrow-key (or j/k) single-select. Renders a menu with one highlighted row,
26
+ * moves the highlight on ↑/↓/k/j, confirms on Enter, and aborts on Ctrl-C.
27
+ * Resolves to the chosen entry's `value`. Assumes `isInteractive()` — it puts
28
+ * stdin in raw mode, so don't call it without a TTY.
29
+ *
30
+ * `choices`: `[{ label, value }]`.
31
+ */
32
+ export function select(message, choices) {
33
+ return new Promise((resolveChoice) => {
34
+ const input = process.stdin;
35
+ const output = process.stdout;
36
+ let active = 0;
37
+
38
+ // Truncate labels so a long one never wraps — a wrapped row would occupy two
39
+ // terminal lines and throw off the cursor-up redraw math below.
40
+ const width = Math.max(8, (output.columns || 80) - 2);
41
+ const fit = (s) => (s.length > width ? s.slice(0, width - 1) + '…' : s);
42
+
43
+ output.write(`${message}\n`);
44
+ output.write(`${DIM} ↑/↓ or j/k to move · Enter to confirm${RESET}\n`);
45
+ output.write(HIDE_CURSOR);
46
+
47
+ const draw = (initial) => {
48
+ if (!initial) output.write(`\x1b[${choices.length}A`); // back up to the first row
49
+ choices.forEach((c, i) => {
50
+ const on = i === active;
51
+ const row = `${on ? '❯' : ' '} ${fit(c.label)}`;
52
+ output.write(`${CLEAR_LINE}${on ? `${CYAN}${row}${RESET}` : row}\n`);
53
+ });
54
+ };
55
+ draw(true);
56
+
57
+ emitKeypressEvents(input);
58
+ const wasRaw = Boolean(input.isRaw);
59
+ input.setRawMode(true);
60
+ input.resume();
61
+
62
+ const restore = () => {
63
+ input.removeListener('keypress', onKey);
64
+ input.setRawMode(wasRaw);
65
+ input.pause();
66
+ output.write(SHOW_CURSOR);
67
+ };
68
+
69
+ const onKey = (_str, key) => {
70
+ if (!key) return;
71
+ if (key.ctrl && key.name === 'c') {
72
+ restore();
73
+ output.write('\n');
74
+ process.exit(130); // 128 + SIGINT, the shell convention for Ctrl-C
75
+ }
76
+ switch (key.name) {
77
+ case 'up':
78
+ case 'k':
79
+ active = (active - 1 + choices.length) % choices.length;
80
+ draw(false);
81
+ break;
82
+ case 'down':
83
+ case 'j':
84
+ active = (active + 1) % choices.length;
85
+ draw(false);
86
+ break;
87
+ case 'return':
88
+ case 'enter':
89
+ restore();
90
+ resolveChoice(choices[active].value);
91
+ break;
92
+ }
93
+ };
94
+
95
+ input.on('keypress', onKey);
96
+ });
97
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-05-29T18:50:46.977Z",
3
+ "generatedAt": "2026-06-06T08:18:55.590Z",
4
4
  "symbols": {
5
5
  "arc": "api/arc",
6
6
  "axis": "api/axis",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-05-29T18:50:46.977Z",
3
+ "generatedAt": "2026-06-06T08:18:55.590Z",
4
4
  "docs": [
5
5
  {
6
6
  "id": "api/arc",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fluidcad",
3
- "version": "0.0.35",
3
+ "version": "0.0.36",
4
4
  "description": "Parametric CAD modeling library using javascript",
5
5
  "author": "Marwan Aouida <contact@marwan.dev>",
6
6
  "homepage": "https://fluidcad.io",
@@ -125,6 +125,25 @@ export declare class FluidCadServer {
125
125
  data: string | Uint8Array;
126
126
  fileName: string;
127
127
  } | null;
128
+ /**
129
+ * Export every solid of a hub session's latest render. The session-keyed twin
130
+ * of `exportShapes` (which reads the desktop `currentFileName`): hub mode keys
131
+ * each render's scene by `sessionId`, so exporting/downloading from a hub
132
+ * session must look it up the same way — exactly why `hitTestForSession`
133
+ * exists. Gathers all solids itself ("download the whole model"); returns null
134
+ * when the session has no rendered scene or it holds no solids (the caller maps
135
+ * that to a "nothing to export" response).
136
+ */
137
+ exportShapesForSession(sessionId: string, options: {
138
+ format: 'step' | 'stl';
139
+ includeColors?: boolean;
140
+ resolution?: string;
141
+ customLinearDeflection?: number;
142
+ customAngularDeflectionDeg?: number;
143
+ }): {
144
+ data: string | Uint8Array;
145
+ fileName: string;
146
+ } | null;
128
147
  hitTest(shapeId: string, rayOrigin: [number, number, number], rayDir: [number, number, number], edgeThreshold: number): any;
129
148
  hitTestForSession(sessionId: string, shapeId: string, rayOrigin: [number, number, number], rayDir: [number, number, number], edgeThreshold: number): any;
130
149
  setCompileError(err: CompileError | null): void;
@@ -295,6 +295,36 @@ export class FluidCadServer {
295
295
  }
296
296
  return this.sceneManager.exportShapes(scene, shapeIds, options);
297
297
  }
298
+ /**
299
+ * Export every solid of a hub session's latest render. The session-keyed twin
300
+ * of `exportShapes` (which reads the desktop `currentFileName`): hub mode keys
301
+ * each render's scene by `sessionId`, so exporting/downloading from a hub
302
+ * session must look it up the same way — exactly why `hitTestForSession`
303
+ * exists. Gathers all solids itself ("download the whole model"); returns null
304
+ * when the session has no rendered scene or it holds no solids (the caller maps
305
+ * that to a "nothing to export" response).
306
+ */
307
+ exportShapesForSession(sessionId, options) {
308
+ if (!this.sceneManager) {
309
+ return null;
310
+ }
311
+ const scene = this.previousScenes.get(sessionId);
312
+ if (!scene) {
313
+ return null;
314
+ }
315
+ const shapeIds = [];
316
+ for (const obj of scene.getAllSceneObjects()) {
317
+ for (const shape of obj.getAddedShapes()) {
318
+ if (shape.isSolid()) {
319
+ shapeIds.push(shape.id);
320
+ }
321
+ }
322
+ }
323
+ if (shapeIds.length === 0) {
324
+ return null;
325
+ }
326
+ return this.sceneManager.exportShapes(scene, shapeIds, options);
327
+ }
298
328
  hitTest(shapeId, rayOrigin, rayDir, edgeThreshold) {
299
329
  if (!this.sceneManager) {
300
330
  return null;
@@ -116,12 +116,13 @@ async function collectImportAssetPaths(workspacePath) {
116
116
  await walk(workspacePath);
117
117
  return out.sort();
118
118
  }
119
- // Enforced on top of any `.gitignore`: dependency trees and prior pack outputs
120
- // (the latter would otherwise recurse into the next pack). `node_modules` is
121
- // also pruned during the walk for speed. Hidden dot-entries are excluded by the
122
- // walk directly (see below), so VCS metadata (`.git`) and secrets (`.env`) need
123
- // no pattern here.
124
- const ALWAYS_EXCLUDE = ['node_modules', '*.fluidpkg'];
119
+ // Enforced on top of any `.gitignore`: dependency trees, prior pack outputs
120
+ // (the latter would otherwise recurse into the next pack), and `fluidcad.json`
121
+ // (the local hub binding model id + name which the hub already owns and
122
+ // should never ship as model source). `node_modules` is also pruned during the
123
+ // walk for speed. Hidden dot-entries are excluded by the walk directly (see
124
+ // below), so VCS metadata (`.git`) and secrets (`.env`) need no pattern here.
125
+ const ALWAYS_EXCLUDE = ['node_modules', '*.fluidpkg', 'fluidcad.json'];
125
126
  // `ignore` ships a CJS `module.exports = factory`, but its bundled types use
126
127
  // `export default`, which loses the call signature under `module: nodenext`.
127
128
  // Pin the factory's real signature; the runtime value is the callable factory.
@@ -43,9 +43,10 @@ export interface ModelPackageManifest {
43
43
  * brep/STEP) are NOT repeated here; `files` + `assets` is the whole package.
44
44
  *
45
45
  * Selection respects a root `.gitignore` (via the `ignore` package) and
46
- * always excludes `node_modules`, prior `*.fluidpkg` outputs, and every
47
- * hidden dot-entry (`.git`, `.env`, `.claude`, `.vscode`, … — never model
48
- * content, may hold secrets), whether or not they're gitignored.
46
+ * always excludes `node_modules`, prior `*.fluidpkg` outputs, `fluidcad.json`
47
+ * (the local hub binding), and every hidden dot-entry (`.git`, `.env`,
48
+ * `.claude`, `.vscode`, … — never model content, may hold secrets), whether or
49
+ * not they're gitignored.
49
50
  */
50
51
  files: string[];
51
52
  params?: Record<string, ParamValue>;