@jipsoft/factusync-cli 0.1.2 → 0.1.3
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 +27 -7
- package/dist/commands.js +9 -3
- package/dist/config.js +9 -2
- package/dist/help.js +1 -1
- package/dist/mcp-session.js +22 -2
- package/dist/run.js +103 -16
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -30,11 +30,12 @@ Requires Node.js 22 or newer.
|
|
|
30
30
|
| Variable | Required | Meaning |
|
|
31
31
|
|---|---|---|
|
|
32
32
|
| `FACTUSYNC_API_KEY` | yes | A FactuSync API key. Sent as the `X-API-Key` header. |
|
|
33
|
-
| `FACTUSYNC_MCP_URL` | no | The MCP endpoint. Default `https://factusync-api.jipsoft.com/mcp`. |
|
|
33
|
+
| `FACTUSYNC_MCP_URL` | no | The MCP endpoint. Default `https://factusync-api.jipsoft.com/mcp`. Must be `https:`; `http:` is accepted only for a loopback host (`localhost`, `127.x.x.x`, `[::1]`). |
|
|
34
34
|
|
|
35
35
|
The key is **never** accepted as a flag (`--api-key`, `--key` are rejected as unknown options), so it
|
|
36
36
|
does not end up in shell history or in the process list. Never print the key or paste it into a
|
|
37
|
-
conversation.
|
|
37
|
+
conversation. Redirects are never followed on a request that carries the key (`REDIRECT_REFUSED`,
|
|
38
|
+
`RIDE_REDIRECT_REFUSED`): set `FACTUSYNC_MCP_URL` to the final URL of the endpoint.
|
|
38
39
|
|
|
39
40
|
A key's **SRI environment** (testing or production) is fixed when the key is created: every document
|
|
40
41
|
created through it goes to that environment. `factusync context` tells you which one it is.
|
|
@@ -162,17 +163,23 @@ taxableBase, taxAmount }], totals, payments }`.
|
|
|
162
163
|
Only after the user approved the draft. Prints `{ documentId, jobId, status, next }`; then follow the
|
|
163
164
|
document with `documents get`. Only `DRAFT` documents can be emitted.
|
|
164
165
|
|
|
165
|
-
### `factusync ride <id> [--out file.pdf] [--xml file.xml]`
|
|
166
|
+
### `factusync ride <id> [--out file.pdf|dir/] [--xml file.xml|dir/]`
|
|
166
167
|
|
|
167
168
|
Only for `AUTHORIZED` documents. Prints `{ documentId, status, number, accessKey, rideUrl, rideDownload,
|
|
168
169
|
xml?, xmlTruncated? }`.
|
|
169
170
|
|
|
170
171
|
- `--out file.pdf` downloads the PDF from `rideUrl` with the same key and adds `rideFile` to the output.
|
|
171
172
|
The key is only sent when `rideUrl` is on the same origin as `FACTUSYNC_MCP_URL`
|
|
172
|
-
(otherwise `RIDE_URL_UNTRUSTED`).
|
|
173
|
+
(otherwise `RIDE_URL_UNTRUSTED`). The response must be `application/pdf` (otherwise `RIDE_NOT_PDF`)
|
|
174
|
+
and at most 20 MiB (otherwise `RIDE_TOO_LARGE`, checked while it streams).
|
|
173
175
|
- `--xml file.xml` writes the authorized XML, adds `xmlFile` and leaves `xml` out of the output. The
|
|
174
176
|
XML is only returned when the key also has `documents:read` (otherwise `XML_NOT_AVAILABLE`); an XML
|
|
175
177
|
the server truncated is not written (`XML_TRUNCATED`).
|
|
178
|
+
- `--out` and `--xml` take a file or a directory. A file path is written exactly as given, replacing
|
|
179
|
+
what is there. A directory (one that exists, or any path ending in `/`) gets a generated name,
|
|
180
|
+
`ride-<accessKey>-<UTC time>.pdf` / `.xml` (for example `ride-2409…811-20261007T153012Z.pdf`; the
|
|
181
|
+
`documentId` when there is no access key), created only if it does not exist yet: an existing
|
|
182
|
+
file is never replaced (`OUTPUT_FILE_EXISTS`). `rideFile` / `xmlFile` give the path written.
|
|
176
183
|
- If any check fails, no file is written.
|
|
177
184
|
|
|
178
185
|
### `factusync tools`
|
|
@@ -191,9 +198,9 @@ command for each.
|
|
|
191
198
|
| Exit | Meaning | Codes |
|
|
192
199
|
|---|---|---|
|
|
193
200
|
| 0 | OK | |
|
|
194
|
-
| 1 | The tool answered with an error | the API code, `TOOL_ERROR`, `TOOL_NOT_AVAILABLE`, `INVALID_INPUT`, `MCP_ERROR`, `XML_NOT_AVAILABLE`, `XML_TRUNCATED`, `RIDE_URL_UNTRUSTED`, `RIDE_DOWNLOAD_FAILED` |
|
|
195
|
-
| 2 | Usage error: nothing was sent | `USAGE`, `CONFIRMATION_REQUIRED`, `INVALID_INPUT_FILE`, `OUTPUT_FILE_NOT_WRITABLE` |
|
|
196
|
-
| 3 | Authentication or network | `MISSING_API_KEY`, `UNAUTHORIZED`, `FORBIDDEN`, `HTTP_ERROR`, `NETWORK_ERROR`, `TIMEOUT` |
|
|
201
|
+
| 1 | The tool answered with an error | the API code, `TOOL_ERROR`, `TOOL_NOT_AVAILABLE`, `INVALID_INPUT`, `MCP_ERROR`, `XML_NOT_AVAILABLE`, `XML_TRUNCATED`, `RIDE_URL_UNTRUSTED`, `RIDE_DOWNLOAD_FAILED`, `RIDE_NOT_PDF`, `RIDE_TOO_LARGE` |
|
|
202
|
+
| 2 | Usage error: nothing was sent | `USAGE`, `CONFIRMATION_REQUIRED`, `INVALID_INPUT_FILE`, `OUTPUT_FILE_NOT_WRITABLE`, `OUTPUT_FILE_EXISTS` |
|
|
203
|
+
| 3 | Authentication or network | `MISSING_API_KEY`, `UNAUTHORIZED`, `FORBIDDEN`, `HTTP_ERROR`, `NETWORK_ERROR`, `TIMEOUT`, `REDIRECT_REFUSED`, `RIDE_REDIRECT_REFUSED` |
|
|
197
204
|
|
|
198
205
|
A missing `FACTUSYNC_API_KEY` fails with exit 3 before any network request.
|
|
199
206
|
|
|
@@ -210,6 +217,19 @@ factusync documents get "$DOCUMENT_ID" # until AUTHORIZED or REJECTED
|
|
|
210
217
|
factusync ride "$DOCUMENT_ID" --out invoice.pdf --xml invoice.xml
|
|
211
218
|
```
|
|
212
219
|
|
|
220
|
+
## Security
|
|
221
|
+
|
|
222
|
+
What the CLI guarantees about the API key and the files it writes:
|
|
223
|
+
|
|
224
|
+
- The key is read from the environment only, sent as `X-API-Key` only to `FACTUSYNC_MCP_URL`'s origin,
|
|
225
|
+
and never over plain `http:` except to a loopback host.
|
|
226
|
+
- No request that carries the key follows a redirect (`REDIRECT_REFUSED`, `RIDE_REDIRECT_REFUSED`).
|
|
227
|
+
- A RIDE download must be `application/pdf` and at most 20 MiB; a directory `--out`/`--xml` never
|
|
228
|
+
replaces an existing file.
|
|
229
|
+
|
|
230
|
+
0.1.3 added the https-only rule, the redirect refusal, the RIDE checks and the directory naming, and moved to `@modelcontextprotocol/sdk` 1.32.1, after an external
|
|
231
|
+
review of 0.1.2 ([issue #1](https://github.com/jipsoft-labs/factusync-cli/issues/1)).
|
|
232
|
+
|
|
213
233
|
## Source and issues
|
|
214
234
|
|
|
215
235
|
This package is developed inside JipSoft's private monorepo, next to the FactuSync server it talks
|
package/dist/commands.js
CHANGED
|
@@ -84,12 +84,18 @@ export const COMMANDS = [
|
|
|
84
84
|
{
|
|
85
85
|
words: ['ride'],
|
|
86
86
|
tool: 'factusync_get_ride',
|
|
87
|
-
usage: 'factusync ride <id> [--out <file.pdf
|
|
87
|
+
usage: 'factusync ride <id> [--out <file.pdf|dir/>] [--xml <file.xml|dir/>]',
|
|
88
88
|
summary: 'For an AUTHORIZED document: the RIDE (PDF) URL, and the authorized XML when the key may read documents.',
|
|
89
89
|
positionals: [{ name: 'id', argument: 'id' }],
|
|
90
90
|
flags: {
|
|
91
|
-
out: {
|
|
92
|
-
|
|
91
|
+
out: {
|
|
92
|
+
type: 'string',
|
|
93
|
+
description: 'Download the RIDE PDF to this file, replacing it, or into this directory under a new timestamped name that never replaces a file (sent with the same API key)',
|
|
94
|
+
},
|
|
95
|
+
xml: {
|
|
96
|
+
type: 'string',
|
|
97
|
+
description: 'Write the authorized XML to this file, or into this directory under a new timestamped name (needs documents:read too)',
|
|
98
|
+
},
|
|
93
99
|
},
|
|
94
100
|
},
|
|
95
101
|
{
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { CliFailure, EXIT_AUTH_OR_NETWORK, usageFailure } from './failure.js';
|
|
2
2
|
export const DEFAULT_MCP_URL = 'https://factusync-api.jipsoft.com/mcp';
|
|
3
|
+
/**
|
|
4
|
+
* Plain http would carry the API key in clear text: it is accepted only for a server on this
|
|
5
|
+
* machine (a local FactuSync, tests). `URL` already normalizes the host, so `127.1` is `127.0.0.1`.
|
|
6
|
+
*/
|
|
7
|
+
function isLoopback(hostname) {
|
|
8
|
+
return hostname === 'localhost' || hostname === '[::1]' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
|
|
9
|
+
}
|
|
3
10
|
/**
|
|
4
11
|
* Configuration comes from the environment only. The key in particular is never a flag: a flag
|
|
5
12
|
* lands in shell history and in the process list, where other users on the machine can read it.
|
|
@@ -13,8 +20,8 @@ export function readConfig(env) {
|
|
|
13
20
|
catch {
|
|
14
21
|
throw usageFailure(`FACTUSYNC_MCP_URL is not a valid URL: ${rawUrl}`);
|
|
15
22
|
}
|
|
16
|
-
if (mcpUrl.protocol !== 'https:' && mcpUrl.protocol
|
|
17
|
-
throw usageFailure(`FACTUSYNC_MCP_URL must be an http
|
|
23
|
+
if (mcpUrl.protocol !== 'https:' && !(mcpUrl.protocol === 'http:' && isLoopback(mcpUrl.hostname))) {
|
|
24
|
+
throw usageFailure(`FACTUSYNC_MCP_URL must be an https URL (http only for localhost): ${rawUrl}`);
|
|
18
25
|
}
|
|
19
26
|
const apiKey = env['FACTUSYNC_API_KEY']?.trim();
|
|
20
27
|
if (!apiKey) {
|
package/dist/help.js
CHANGED
|
@@ -3,7 +3,7 @@ import { DEFAULT_MCP_URL } from './config.js';
|
|
|
3
3
|
const COMMON = [
|
|
4
4
|
'Environment:',
|
|
5
5
|
' FACTUSYNC_API_KEY Required. Your FactuSync API key. Never accepted as a flag.',
|
|
6
|
-
` FACTUSYNC_MCP_URL Optional. Default ${DEFAULT_MCP_URL}
|
|
6
|
+
` FACTUSYNC_MCP_URL Optional. Default ${DEFAULT_MCP_URL}. https only (http for localhost).`,
|
|
7
7
|
'',
|
|
8
8
|
'Output: the tool result as JSON on stdout (--pretty indents it).',
|
|
9
9
|
'Errors: JSON {code, message, details?} on stderr.',
|
package/dist/mcp-session.js
CHANGED
|
@@ -12,7 +12,7 @@ import { CLI_VERSION } from './version.js';
|
|
|
12
12
|
export async function withMcpSession(config, action) {
|
|
13
13
|
const transport = new StreamableHTTPClientTransport(config.mcpUrl, {
|
|
14
14
|
requestInit: { headers: { 'X-API-Key': config.apiKey } },
|
|
15
|
-
fetch: config.fetch,
|
|
15
|
+
fetch: refusingRedirects(config.fetch),
|
|
16
16
|
});
|
|
17
17
|
const client = new Client({ name: 'factusync-cli', version: CLI_VERSION });
|
|
18
18
|
const options = config.timeoutMs !== undefined ? { timeout: config.timeoutMs } : undefined;
|
|
@@ -50,6 +50,15 @@ export async function withMcpSession(config, action) {
|
|
|
50
50
|
await client.close().catch(() => undefined);
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Every request here carries `X-API-Key`, and on a cross-origin redirect `fetch` strips
|
|
55
|
+
* `Authorization` but forwards every other header: following one would hand the key to whatever
|
|
56
|
+
* host the `Location` names. FactuSync's `POST /mcp` never redirects, so none is ever followed,
|
|
57
|
+
* whatever `redirect` the SDK passes.
|
|
58
|
+
*/
|
|
59
|
+
function refusingRedirects(fetchFn) {
|
|
60
|
+
return (input, init) => fetchFn(input, { ...init, redirect: 'error' });
|
|
61
|
+
}
|
|
53
62
|
/** A successful result without `structuredContent`: its first text block, as JSON when it is JSON. */
|
|
54
63
|
function parseTextContent(result) {
|
|
55
64
|
const text = textBlocks(result)[0] ?? '';
|
|
@@ -131,10 +140,21 @@ export function transportFailure(error) {
|
|
|
131
140
|
}
|
|
132
141
|
return networkFailure(error);
|
|
133
142
|
}
|
|
134
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* `fetch` with `redirect: 'error'` rejecting on a 3xx. Undici reports it only as a `TypeError`
|
|
145
|
+
* whose cause says so; nothing more structured exists to test against.
|
|
146
|
+
*/
|
|
147
|
+
export function isRefusedRedirect(error) {
|
|
148
|
+
const cause = error?.cause;
|
|
149
|
+
return error instanceof TypeError && cause instanceof Error && /redirect/i.test(cause.message);
|
|
150
|
+
}
|
|
151
|
+
/** `fetch` rejections: refused, unresolvable, reset or aborted connections, refused redirects. */
|
|
135
152
|
export function networkFailure(error) {
|
|
136
153
|
if (error instanceof CliFailure)
|
|
137
154
|
return error;
|
|
155
|
+
if (isRefusedRedirect(error)) {
|
|
156
|
+
return new CliFailure(EXIT_AUTH_OR_NETWORK, 'REDIRECT_REFUSED', 'FactuSync answered with a redirect, which is never followed: the API key goes only to FACTUSYNC_MCP_URL. Check that it is the final URL of the MCP endpoint.');
|
|
157
|
+
}
|
|
138
158
|
if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
|
|
139
159
|
return new CliFailure(EXIT_AUTH_OR_NETWORK, 'TIMEOUT', 'FactuSync did not answer in time.');
|
|
140
160
|
}
|
package/dist/run.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join, sep } from 'node:path';
|
|
2
3
|
import { parseArgs } from 'node:util';
|
|
3
4
|
import { commandForTool, findCommand } from './commands.js';
|
|
4
5
|
import { readConfig } from './config.js';
|
|
5
6
|
import { CliFailure, EXIT_AUTH_OR_NETWORK, EXIT_OK, EXIT_TOOL_ERROR, EXIT_USAGE, usageFailure } from './failure.js';
|
|
6
7
|
import { commandHelp, rootHelp } from './help.js';
|
|
7
|
-
import { networkFailure, withMcpSession } from './mcp-session.js';
|
|
8
|
+
import { isRefusedRedirect, networkFailure, withMcpSession } from './mcp-session.js';
|
|
8
9
|
import { CLI_VERSION } from './version.js';
|
|
9
10
|
/** Same default as the SDK's MCP requests, for the RIDE download. */
|
|
10
11
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
12
|
+
/** A RIDE is a one- or two-page PDF of tens of KiB: anything near this is not one. */
|
|
13
|
+
export const MAX_RIDE_BYTES = 20 * 1024 * 1024;
|
|
11
14
|
/** Runs one CLI invocation and returns its exit code. Never throws. */
|
|
12
15
|
export async function run(argv, env, io) {
|
|
13
16
|
try {
|
|
@@ -164,8 +167,9 @@ async function listTools(session) {
|
|
|
164
167
|
}
|
|
165
168
|
/**
|
|
166
169
|
* `ride --out/--xml`: every check runs before anything is written, so a failure leaves no file.
|
|
167
|
-
* The PDF is fetched with the key only from the MCP endpoint's own origin
|
|
168
|
-
* to a host the user did not configure, whatever
|
|
170
|
+
* The PDF is fetched with the key only from the MCP endpoint's own origin, and a redirect is
|
|
171
|
+
* refused rather than followed: the key never goes to a host the user did not configure, whatever
|
|
172
|
+
* URL a response or its `Location` carries.
|
|
169
173
|
*/
|
|
170
174
|
async function saveRide(result, parsed, config, io) {
|
|
171
175
|
const out = parsed.values['out'];
|
|
@@ -179,25 +183,55 @@ async function saveRide(result, parsed, config, io) {
|
|
|
179
183
|
throw new CliFailure(EXIT_TOOL_ERROR, 'XML_TRUNCATED', 'The server truncated this XML; it was not written. Download it from the FactuSync API instead.');
|
|
180
184
|
}
|
|
181
185
|
}
|
|
186
|
+
const stem = outputStem(result, (io.now ?? (() => new Date()))());
|
|
187
|
+
const pdfTarget = typeof out === 'string' ? await outputTarget(out, stem, 'pdf') : undefined;
|
|
188
|
+
const xmlTarget = typeof xmlPath === 'string' ? await outputTarget(xmlPath, stem, 'xml') : undefined;
|
|
182
189
|
let pdf;
|
|
183
|
-
if (
|
|
190
|
+
if (pdfTarget)
|
|
184
191
|
pdf = await downloadRide(result['rideUrl'], config, io);
|
|
185
192
|
const saved = { ...result };
|
|
193
|
+
if (pdf && pdfTarget) {
|
|
194
|
+
await writeOutput(pdfTarget, pdf);
|
|
195
|
+
saved['rideFile'] = pdfTarget.path;
|
|
196
|
+
}
|
|
197
|
+
if (xmlTarget) {
|
|
198
|
+
await writeOutput(xmlTarget, result['xml']);
|
|
199
|
+
delete saved['xml'];
|
|
200
|
+
saved['xmlFile'] = xmlTarget.path;
|
|
201
|
+
}
|
|
202
|
+
return saved;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* `--out`/`--xml` naming a directory (an existing one, or any path ending in a separator) get a
|
|
206
|
+
* generated file name inside it, so repeated downloads never replace each other. A file path is
|
|
207
|
+
* the integrator's exact choice and is written as given.
|
|
208
|
+
*/
|
|
209
|
+
async function outputTarget(value, stem, extension) {
|
|
210
|
+
const isDirectory = value.endsWith('/') ||
|
|
211
|
+
value.endsWith(sep) ||
|
|
212
|
+
(await stat(value).then((stats) => stats.isDirectory(), () => false));
|
|
213
|
+
return isDirectory ? { path: join(value, `${stem}.${extension}`), exclusive: true } : { path: value, exclusive: false };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* `ride-<accessKey or documentId>-<UTC time>`, e.g. `ride-2409…811-20261007T153012Z`. Both come
|
|
217
|
+
* from the server, so anything outside `[A-Za-z0-9._-]` becomes `_`: no separator can climb out of
|
|
218
|
+
* the directory the user chose.
|
|
219
|
+
*/
|
|
220
|
+
function outputStem(result, now) {
|
|
221
|
+
const id = [result['accessKey'], result['documentId']].find((value) => typeof value === 'string' && value !== '');
|
|
222
|
+
const timestamp = now.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '');
|
|
223
|
+
return `ride-${(id ?? 'document').replace(/[^A-Za-z0-9._-]/g, '_')}-${timestamp}`;
|
|
224
|
+
}
|
|
225
|
+
async function writeOutput(target, data) {
|
|
186
226
|
try {
|
|
187
|
-
|
|
188
|
-
await writeFile(out, pdf);
|
|
189
|
-
saved['rideFile'] = out;
|
|
190
|
-
}
|
|
191
|
-
if (typeof xmlPath === 'string') {
|
|
192
|
-
await writeFile(xmlPath, result['xml'], 'utf8');
|
|
193
|
-
delete saved['xml'];
|
|
194
|
-
saved['xmlFile'] = xmlPath;
|
|
195
|
-
}
|
|
227
|
+
await writeFile(target.path, data, target.exclusive ? { flag: 'wx' } : {});
|
|
196
228
|
}
|
|
197
229
|
catch (error) {
|
|
230
|
+
if (target.exclusive && error.code === 'EEXIST') {
|
|
231
|
+
throw new CliFailure(EXIT_USAGE, 'OUTPUT_FILE_EXISTS', `${target.path} already exists; it was not replaced. Run it again in a moment.`);
|
|
232
|
+
}
|
|
198
233
|
throw new CliFailure(EXIT_USAGE, 'OUTPUT_FILE_NOT_WRITABLE', `Cannot write the output file: ${error.message}`);
|
|
199
234
|
}
|
|
200
|
-
return saved;
|
|
201
235
|
}
|
|
202
236
|
async function downloadRide(rideUrl, config, io) {
|
|
203
237
|
let url;
|
|
@@ -212,12 +246,17 @@ async function downloadRide(rideUrl, config, io) {
|
|
|
212
246
|
}
|
|
213
247
|
let response;
|
|
214
248
|
try {
|
|
249
|
+
// The API serves the PDF itself and never redirects; following one would forward X-API-Key.
|
|
215
250
|
response = await io.fetch(url, {
|
|
216
251
|
headers: { 'X-API-Key': config.apiKey },
|
|
252
|
+
redirect: 'error',
|
|
217
253
|
signal: AbortSignal.timeout(io.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
218
254
|
});
|
|
219
255
|
}
|
|
220
256
|
catch (error) {
|
|
257
|
+
if (isRefusedRedirect(error)) {
|
|
258
|
+
throw new CliFailure(EXIT_AUTH_OR_NETWORK, 'RIDE_REDIRECT_REFUSED', 'The RIDE download answered with a redirect, which is never followed: the API key is not sent to another URL.');
|
|
259
|
+
}
|
|
221
260
|
throw networkFailure(error);
|
|
222
261
|
}
|
|
223
262
|
if (response.status === 401 || response.status === 403) {
|
|
@@ -228,5 +267,53 @@ async function downloadRide(rideUrl, config, io) {
|
|
|
228
267
|
status: response.status,
|
|
229
268
|
});
|
|
230
269
|
}
|
|
231
|
-
|
|
270
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
271
|
+
if (contentType.split(';')[0].trim().toLowerCase() !== 'application/pdf') {
|
|
272
|
+
await response.body?.cancel().catch(() => undefined);
|
|
273
|
+
throw new CliFailure(EXIT_TOOL_ERROR, 'RIDE_NOT_PDF', `The RIDE download is not a PDF (Content-Type '${contentType}'); nothing was written.`);
|
|
274
|
+
}
|
|
275
|
+
return readCapped(response, io.maxRideBytes ?? MAX_RIDE_BYTES);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The body, refused as soon as it passes `maxBytes`: up front when `Content-Length` says so, and
|
|
279
|
+
* while streaming otherwise, so a server that lies about it or streams without one cannot fill
|
|
280
|
+
* the memory or the disk.
|
|
281
|
+
*/
|
|
282
|
+
async function readCapped(response, maxBytes) {
|
|
283
|
+
const tooLarge = () => new CliFailure(EXIT_TOOL_ERROR, 'RIDE_TOO_LARGE', `The RIDE download is larger than ${maxBytes} bytes; nothing was written.`, {
|
|
284
|
+
maxBytes,
|
|
285
|
+
});
|
|
286
|
+
const declared = Number(response.headers.get('content-length'));
|
|
287
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
288
|
+
await response.body?.cancel().catch(() => undefined);
|
|
289
|
+
throw tooLarge();
|
|
290
|
+
}
|
|
291
|
+
if (!response.body)
|
|
292
|
+
return new Uint8Array();
|
|
293
|
+
const reader = response.body.getReader();
|
|
294
|
+
const chunks = [];
|
|
295
|
+
let total = 0;
|
|
296
|
+
try {
|
|
297
|
+
for (;;) {
|
|
298
|
+
const { done, value } = await reader.read();
|
|
299
|
+
if (done)
|
|
300
|
+
break;
|
|
301
|
+
total += value.byteLength;
|
|
302
|
+
if (total > maxBytes) {
|
|
303
|
+
await reader.cancel().catch(() => undefined);
|
|
304
|
+
throw tooLarge();
|
|
305
|
+
}
|
|
306
|
+
chunks.push(value);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
throw networkFailure(error);
|
|
311
|
+
}
|
|
312
|
+
const pdf = new Uint8Array(total);
|
|
313
|
+
let offset = 0;
|
|
314
|
+
for (const chunk of chunks) {
|
|
315
|
+
pdf.set(chunk, offset);
|
|
316
|
+
offset += chunk.byteLength;
|
|
317
|
+
}
|
|
318
|
+
return pdf;
|
|
232
319
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jipsoft/factusync-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "FactuSync command line for AI agents (Pi) and shell scripts: Ecuadorian SRI e-invoicing through the FactuSync MCP endpoint",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"prepublishOnly": "npm test"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@modelcontextprotocol/sdk": "1.
|
|
44
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
45
45
|
"zod": "^3.25.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|