@myagentroam/marctl 0.11.2 → 0.11.4

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/src/scp.mjs CHANGED
@@ -1,336 +1,7 @@
1
- import { spawn } from 'node:child_process';
2
- import { readFileSync } from 'node:fs';
3
- import path from 'node:path';
4
- import { gzipSync } from 'node:zlib';
5
- import { MarctlError } from './config.mjs';
6
- import { parseFrame, runTerminalSession } from './terminal.mjs';
7
-
8
- const CHUNK = 32 * 1024;
9
- const WINDOW = 8;
10
- const MAX_BUFFERED_BYTES = WINDOW * (CHUNK + 4) + 64;
11
- const script = gzipSync(readFileSync(new URL('./scp-worker.cjs', import.meta.url))).toString(
12
- 'base64'
13
- );
14
- const expression = `eval(require('zlib').gunzipSync(Buffer.from('${script}','base64')).toString())`;
15
-
16
- export function parseScpEndpoint(value) {
17
- if (typeof value !== 'string' || !value || value.includes('\0'))
18
- throw new MarctlError('Invalid copy endpoint.');
19
- const explicit = value.indexOf('://');
20
- const separator = explicit >= 0 ? explicit : value.indexOf(':');
21
- if (separator < 0 || (separator === 1 && /^[A-Za-z]:[\\/]/u.test(value))) {
22
- // Let the filesystem resolve dot components and symlink parents.
23
- return { path: path.isAbsolute(value) ? value : `${process.cwd()}${path.sep}${value}` };
24
- }
25
- const host = value.slice(0, separator);
26
- const file = value.slice(separator + (explicit >= 0 ? 3 : 1));
27
- if (!host || !file || (!file.startsWith('/') && !/^[A-Za-z]:[\\/]/u.test(file)))
28
- throw new MarctlError('Invalid Node file path.');
29
- return { host, path: file };
30
- }
31
-
32
- export function scpSourceBasename(value) {
33
- const windowsPath = /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('\\\\');
34
- if (value.endsWith('/') || (windowsPath && value.endsWith('\\')))
35
- throw new MarctlError('Invalid source filename.');
36
- const sourcePath = windowsPath ? path.win32 : path.posix;
37
- const basename = sourcePath.basename(value);
38
- if (!basename || basename === '.' || basename === '..')
39
- throw new MarctlError('Invalid source filename.');
40
- return basename;
41
- }
42
-
43
- function workerArguments(mode, file, size, metadata, preserve, sourceBasename) {
44
- const encoded = Buffer.from(file, 'utf8').toString('base64url');
45
- return [
46
- mode,
47
- encoded,
48
- ...(size === undefined
49
- ? []
50
- : [
51
- String(size),
52
- metadata.toString('base64url'),
53
- preserve ? '1' : '0',
54
- Buffer.from(sourceBasename, 'utf8').toString('base64url')
55
- ])
56
- ];
57
- }
58
-
59
- function workerCommand(mode, file, size, metadata, preserve, sourceBasename) {
60
- return `node -e "${expression}" ${workerArguments(mode, file, size, metadata, preserve, sourceBasename).join(' ')}`;
61
- }
62
-
63
- class ByteChannel {
64
- #buffer = Buffer.alloc(0);
65
- #waiting = [];
66
- #sending = new Map();
67
- #ended;
68
- #failure;
69
- #controller;
70
-
71
- constructor(send, controller) {
72
- this.#controller = controller;
73
- const signal = controller.signal;
74
- this.send = (bytes) => {
75
- if (this.#failure) throw this.#failure;
76
- if (this.#ended !== undefined) throw new MarctlError('Copy stream ended unexpectedly.');
77
- return new Promise((resolve, reject) => {
78
- this.#sending.set(reject, resolve);
79
- const settle = (complete, value) => {
80
- this.#sending.delete(reject);
81
- complete(value);
82
- };
83
- try {
84
- Promise.resolve(send(bytes)).then(
85
- (value) => settle(resolve, value),
86
- (error) => settle(reject, error)
87
- );
88
- } catch (error) {
89
- settle(reject, error);
90
- }
91
- });
92
- };
93
- if (signal) {
94
- if (signal.aborted) this.fail(signal.reason);
95
- else
96
- signal.addEventListener('abort', () => this.fail(signal.reason), {
97
- once: true
98
- });
99
- }
100
- }
101
-
102
- feed(bytes) {
103
- if (this.#failure) return;
104
- this.#buffer = Buffer.concat([this.#buffer, bytes]);
105
- if (this.#buffer.length > MAX_BUFFERED_BYTES)
106
- return this.fail(new MarctlError('Copy output exceeded the bounded window.'));
107
- this.#wake();
108
- }
109
-
110
- finish(code) {
111
- this.#ended = code;
112
- if (code !== 0) return this.fail(new MarctlError('Remote copy command failed.'));
113
- // A successful exit confirms the worker consumed its final protocol input.
114
- for (const resolve of this.#sending.values()) resolve();
115
- this.#sending.clear();
116
- this.#wake();
117
- }
118
-
119
- connectionEnded(error) {
120
- if (this.#ended === undefined) this.fail(error);
121
- }
122
-
123
- fail(error) {
124
- if (this.#failure !== undefined) return;
125
- this.#failure = error;
126
- for (const reject of this.#sending.keys()) reject(this.#failure);
127
- this.#sending.clear();
128
- this.#wake();
129
- // Any endpoint failure must wake reads and writes across the entire copy.
130
- this.#controller.abort(error);
131
- }
132
-
133
- #wake() {
134
- for (const resolve of this.#waiting.splice(0)) resolve();
135
- }
136
-
137
- async read(length) {
138
- if (this.#failure) throw this.#failure;
139
- while (this.#buffer.length < length) {
140
- if (this.#failure) throw this.#failure;
141
- if (this.#ended !== undefined) throw new MarctlError('Copy stream ended unexpectedly.');
142
- await new Promise((resolve) => this.#waiting.push(resolve));
143
- }
144
- const bytes = this.#buffer.subarray(0, length);
145
- this.#buffer = this.#buffer.subarray(length);
146
- return bytes;
147
- }
148
-
149
- async done() {
150
- if (this.#failure) throw this.#failure;
151
- while (this.#ended === undefined && this.#failure === undefined)
152
- await new Promise((resolve) => this.#waiting.push(resolve));
153
- if (this.#failure) throw this.#failure;
154
- if (this.#ended !== 0) throw new MarctlError('Remote copy command failed.');
155
- }
156
- }
157
-
158
- async function copyBytes(source, destination) {
159
- const sizeBytes = await source.read(8);
160
- const size = sizeBytes.readBigUInt64BE();
161
- if (size > BigInt(Number.MAX_SAFE_INTEGER)) throw new MarctlError('Copy source is too large.');
162
- const metadata = await source.read(20);
163
- await destination.open(Number(size), metadata, async (target) => {
164
- if ((await target.read(1))[0] !== 1)
165
- throw new MarctlError('Copy target did not accept the file.');
166
- let remaining = Number(size);
167
- let acknowledged = Promise.resolve();
168
- const pending = [];
169
- for (;;) {
170
- const header = await source.read(4);
171
- const length = header.readUInt32BE();
172
- if (length > CHUNK || length > remaining)
173
- throw new MarctlError('Copy source sent an invalid chunk.');
174
- await target.send(header);
175
- if (length === 0) {
176
- await acknowledged;
177
- await target.send(await source.read(32));
178
- if (remaining !== 0 || (await target.read(1))[0] !== 2)
179
- throw new MarctlError('Copy target did not verify the file.');
180
- break;
181
- }
182
- await target.send(await source.read(length));
183
- // Keep one ordered ACK reader while sending up to a bounded window ahead.
184
- acknowledged = acknowledged.then(async () => {
185
- if ((await target.read(1))[0] !== 1)
186
- throw new MarctlError('Copy target rejected the chunk.');
187
- await source.send(Buffer.from([1]));
188
- });
189
- acknowledged.catch((error) => {
190
- // ACK failures use the same copy-wide cancellation as transport failures.
191
- source.fail(error);
192
- });
193
- pending.push(acknowledged);
194
- remaining -= length;
195
- if (pending.length >= WINDOW) await pending.shift();
196
- }
197
- await target.done();
198
- });
199
- await source.done();
200
- }
201
-
202
- async function withLocalWorker(
203
- mode,
204
- file,
205
- size,
206
- metadata,
207
- preserve,
208
- sourceBasename,
209
- use,
210
- controller
211
- ) {
212
- const child = spawn(
213
- process.execPath,
214
- ['-e', expression, ...workerArguments(mode, file, size, metadata, preserve, sourceBasename)],
215
- {
216
- stdio: ['pipe', 'pipe', 'pipe'],
217
- windowsHide: true
218
- }
219
- );
220
- child.stdin.on('error', () => undefined);
221
- child.stderr.resume();
222
- const channel = new ByteChannel(
223
- (bytes) =>
224
- new Promise((resolve, reject) => {
225
- child.stdin.write(bytes, (error) => (error ? reject(error) : resolve()));
226
- }),
227
- controller
228
- );
229
- child.stdout.on('data', (bytes) => channel.feed(bytes));
230
- child.once('error', (error) => channel.fail(error));
231
- child.once('close', (code) => channel.finish(code ?? 1));
232
- try {
233
- return await use(channel);
234
- } finally {
235
- if (child.exitCode === null) child.kill();
236
- }
237
- }
238
-
239
- async function withRemoteWorker(
240
- {
241
- client,
242
- WebSocketClass,
243
- nodeId,
244
- file,
245
- mode,
246
- size,
247
- metadata,
248
- preserve,
249
- sourceBasename,
250
- translate,
251
- controller
252
- },
253
- use
254
- ) {
255
- return runTerminalSession({
256
- client,
257
- WebSocketClass,
258
- nodeId,
259
- command: workerCommand(mode, file, size, metadata, preserve, sourceBasename),
260
- binary: true,
261
- raceConnections: true,
262
- translate,
263
- attached: async ({ transport, terminalId }) => {
264
- const channel = new ByteChannel(
265
- (bytes) =>
266
- transport.send(
267
- JSON.stringify({ type: 'input', terminalId, data: bytes.toString('base64') })
268
- ),
269
- controller
270
- );
271
- let sequence = 0;
272
- const onMessage = (raw, isBinary) => {
273
- try {
274
- const frame = parseFrame(raw, isBinary, terminalId, translate);
275
- if (frame.type === 'output') {
276
- if (frame.sequence !== sequence + 1)
277
- throw new MarctlError('Copy output sequence is invalid.');
278
- sequence = frame.sequence;
279
- const bytes = Buffer.from(frame.data, 'base64');
280
- if (bytes.toString('base64') !== frame.data)
281
- throw new MarctlError('Copy output is not valid base64.');
282
- channel.feed(bytes);
283
- } else if (frame.type === 'exit') channel.finish(frame.exitCode ?? 1);
284
- else if (frame.type === 'error')
285
- channel.fail(new MarctlError(translate('errors.terminalRemoteError')));
286
- else throw new MarctlError(translate('errors.terminalUnexpectedFrame'));
287
- } catch (error) {
288
- channel.fail(error);
289
- }
290
- };
291
- const onError = () =>
292
- channel.connectionEnded(new MarctlError(translate('errors.terminalConnectionFailed')));
293
- const onClose = () =>
294
- channel.connectionEnded(new MarctlError(translate('errors.terminalConnectionLost')));
295
- transport.on('message', onMessage);
296
- transport.on('error', onError);
297
- transport.on('close', onClose);
298
- try {
299
- transport.activate?.();
300
- return await use(channel);
301
- } finally {
302
- transport.removeListener?.('message', onMessage);
303
- transport.removeListener?.('error', onError);
304
- transport.removeListener?.('close', onClose);
305
- }
306
- }
307
- });
308
- }
309
-
310
- async function withWorker(endpoint, mode, size, metadata, context, use) {
311
- if (endpoint.nodeId === undefined)
312
- return withLocalWorker(
313
- mode,
314
- endpoint.path,
315
- size,
316
- metadata,
317
- context.preserve,
318
- context.sourceBasename,
319
- use,
320
- context.controller
321
- );
322
- return withRemoteWorker(
323
- {
324
- ...context,
325
- nodeId: endpoint.nodeId,
326
- file: endpoint.path,
327
- mode,
328
- size,
329
- metadata
330
- },
331
- use
332
- );
333
- }
1
+ import { MarctlError } from './errors.mjs';
2
+ import { parseScpEndpoint, scpSourceBasename } from './scp-endpoint.mjs';
3
+ import { copyBytes } from './scp-copy.mjs';
4
+ import { withWorker } from './scp-workers.mjs';
334
5
 
335
6
  export async function runScp({
336
7
  source,
@@ -340,23 +11,33 @@ export async function runScp({
340
11
  WebSocketClass,
341
12
  resolveNodeId,
342
13
  translate,
343
- signals = process
14
+ signal
344
15
  }) {
345
16
  const from = parseScpEndpoint(source);
346
17
  const to = parseScpEndpoint(destination);
347
18
  const sourceBasename = scpSourceBasename(from.path);
348
19
  if (!from.host && !to.host) throw new MarctlError('At least one copy endpoint must be a Node.');
349
20
  const references = [...new Set([from.host, to.host].filter(Boolean))];
350
- const ids = await Promise.all(references.map((host) => resolveNodeId(host)));
351
- const resolved = (endpoint) => ({
352
- path: endpoint.path,
353
- ...(endpoint.host ? { nodeId: ids[references.indexOf(endpoint.host)] } : {})
354
- });
355
21
  const controller = new AbortController();
356
- const interrupt = () => controller.abort(new MarctlError('Copy was interrupted.'));
357
- signals.on?.('SIGINT', interrupt);
358
- signals.on?.('SIGTERM', interrupt);
22
+ let rejectResolution;
23
+ const interrupt = () => {
24
+ controller.abort(new MarctlError('Copy was interrupted.'));
25
+ rejectResolution?.(controller.signal.reason);
26
+ };
27
+ signal?.addEventListener('abort', interrupt, { once: true });
28
+ if (signal?.aborted) interrupt();
359
29
  try {
30
+ controller.signal.throwIfAborted();
31
+ const ids = await new Promise((resolve, reject) => {
32
+ rejectResolution = reject;
33
+ Promise.all(references.map((host) => resolveNodeId(host))).then(resolve, reject);
34
+ });
35
+ rejectResolution = undefined;
36
+ controller.signal.throwIfAborted();
37
+ const resolved = (endpoint) => ({
38
+ path: endpoint.path,
39
+ ...(endpoint.host ? { nodeId: ids[references.indexOf(endpoint.host)] } : {})
40
+ });
360
41
  const context = {
361
42
  client,
362
43
  WebSocketClass,
@@ -371,12 +52,13 @@ export async function runScp({
371
52
  withWorker(resolved(to), 'write', size, metadata, context, use)
372
53
  })
373
54
  );
55
+ controller.signal.throwIfAborted();
374
56
  return 0;
375
57
  } catch (error) {
376
58
  controller.abort(error);
377
59
  throw error;
378
60
  } finally {
379
- signals.removeListener?.('SIGINT', interrupt);
380
- signals.removeListener?.('SIGTERM', interrupt);
61
+ rejectResolution = undefined;
62
+ signal?.removeEventListener('abort', interrupt);
381
63
  }
382
64
  }
@@ -0,0 +1,176 @@
1
+ import { MarctlError } from './errors.mjs';
2
+ import { createTranslator } from './i18n/index.mjs';
3
+ import { sessionTarget } from './session-target.mjs';
4
+
5
+ const commands = {
6
+ current: [],
7
+ list: ['cursor', 'limit', 'query', 'parent'],
8
+ graph: ['limit'],
9
+ show: [],
10
+ history: ['cursor', 'limit'],
11
+ start: [
12
+ 'runner',
13
+ 'model',
14
+ 'effort',
15
+ 'title',
16
+ 'message',
17
+ 'message-file',
18
+ 'image',
19
+ 'client-message-id'
20
+ ],
21
+ config: ['model', 'effort'],
22
+ rename: ['title'],
23
+ 'prompt.get': [],
24
+ 'prompt.set': ['message', 'message-file'],
25
+ 'prompt.clear': [],
26
+ 'approval.decide': ['run', 'approval', 'decision'],
27
+ 'schedule.list': ['cursor', 'limit'],
28
+ 'schedule.get': [],
29
+ 'schedule.create': ['cron', 'message', 'message-file'],
30
+ 'schedule.update': ['cron', 'message', 'message-file', 'expected-revision'],
31
+ send: ['message', 'message-file', 'image', 'steer', 'queue', 'client-message-id'],
32
+ stop: ['run'],
33
+ 'goal.get': [],
34
+ 'goal.set': ['message', 'message-file'],
35
+ 'goal.clear': [],
36
+ wait: ['run', 'timeout', 'until']
37
+ };
38
+ const fieldNames = {
39
+ parent: 'parentSessionId',
40
+ 'expected-revision': 'expectedRevision',
41
+ 'message-file': 'messageFile',
42
+ 'client-message-id': 'clientMessageId',
43
+ run: 'runId',
44
+ approval: 'approvalId',
45
+ timeout: 'timeoutMs',
46
+ image: 'images'
47
+ };
48
+
49
+ export function parseSessionArgs(argv, translate = createTranslator()) {
50
+ const invalid = (key = 'errors.invalidArguments', details) =>
51
+ new MarctlError(translate(key), {
52
+ code: 'MARCTL_SESSION_INPUT_INVALID',
53
+ details:
54
+ details ??
55
+ (argv[0] === 'prompt'
56
+ ? 'Use prompt get|clear [session-id], or prompt set [session-id] with exactly one of -m/--message or --message-file.'
57
+ : argv[0] === 'approval'
58
+ ? 'Use approval decide <session-id> --run <run-id> --approval <approval-id> --decision approve|reject.'
59
+ : undefined)
60
+ });
61
+ if (
62
+ argv.length === 0 ||
63
+ argv.some(
64
+ (value, index) =>
65
+ (value === '--help' || value === '-h') && !['-m', '--message'].includes(argv[index - 1])
66
+ )
67
+ )
68
+ return { command: 'sessions', help: true };
69
+ const input = [...argv];
70
+ let action = input.shift();
71
+ if (['goal', 'schedule', 'prompt', 'approval'].includes(action)) action += '.' + input.shift();
72
+ if (!Object.hasOwn(commands, action)) throw invalid();
73
+ const args = { command: 'sessions', action };
74
+ if (sessionTarget[action] !== 'workspace') {
75
+ if (
76
+ (input[0] === undefined || input[0].startsWith('-')) &&
77
+ sessionTarget[action] === 'explicit'
78
+ )
79
+ throw invalid();
80
+ if (input[0] !== undefined && !input[0].startsWith('-')) {
81
+ const sessionId = input.shift();
82
+ if (!sessionId || sessionId.length > 512) throw invalid();
83
+ args.sessionId = sessionId;
84
+ }
85
+ }
86
+ while (input.length > 0) {
87
+ const rawFlag = input.shift();
88
+ const flag = rawFlag === '-m' ? '--message' : rawFlag;
89
+ if (flag === '--json' && args.json === undefined) {
90
+ args.json = true;
91
+ continue;
92
+ }
93
+ if (action === 'send' && (flag === '--steer' || flag === '--queue')) {
94
+ const key = flag.slice(2);
95
+ if (Object.hasOwn(args, key)) throw invalid();
96
+ args[key] = true;
97
+ continue;
98
+ }
99
+ const name = flag.startsWith('--') ? flag.slice(2) : '';
100
+ const key = fieldNames[name] ?? name;
101
+ if (!commands[action].includes(name) || (name !== 'image' && Object.hasOwn(args, key)))
102
+ throw invalid();
103
+ const value = input.shift();
104
+ if (
105
+ value === undefined ||
106
+ (value === '' && name !== 'effort') ||
107
+ (name !== 'message' && value.startsWith('--'))
108
+ )
109
+ throw invalid();
110
+ if (name === 'image') {
111
+ (args.images ??= []).push(value);
112
+ if (args.images.length > 5) throw invalid();
113
+ } else if (name === 'limit' || name === 'expected-revision') {
114
+ if (
115
+ !/^[1-9][0-9]*$/u.test(value) ||
116
+ !Number.isSafeInteger(Number(value)) ||
117
+ (name === 'limit' && Number(value) > 100)
118
+ )
119
+ throw invalid();
120
+ args[key] = Number(value);
121
+ } else if (name === 'timeout') {
122
+ const match = /^([1-9][0-9]*)(ms|s|m)$/u.exec(value);
123
+ const ms = match === null ? 0 : Number(match[1]) * { ms: 1, s: 1000, m: 60000 }[match[2]];
124
+ if (!Number.isSafeInteger(ms) || ms < 1 || ms > 3600000) throw invalid();
125
+ args[key] = ms;
126
+ } else args[key] = value;
127
+ }
128
+ if (action === 'send' && (args.steer === true) === (args.queue === true))
129
+ throw invalid('errors.sessionDeliveryRequired');
130
+ if (args.message !== undefined && args.messageFile !== undefined) throw invalid();
131
+ if (
132
+ ['start', 'send', 'goal.set', 'schedule.create', 'prompt.set'].includes(action) &&
133
+ args.messageFile === undefined &&
134
+ args.message === undefined &&
135
+ !args.images?.length
136
+ )
137
+ throw invalid();
138
+ if (
139
+ action === 'start' &&
140
+ !['mar-agent', 'codex', 'claude-code', 'opencode'].includes(args.runner)
141
+ )
142
+ throw invalid();
143
+ if (action === 'wait' && (args.runId === undefined || args.timeoutMs === undefined))
144
+ throw invalid();
145
+ if (args.until !== undefined && args.until !== 'approval')
146
+ throw invalid(undefined, '--until: expected approval.');
147
+ if (
148
+ action === 'approval.decide' &&
149
+ (!args.runId || !args.approvalId || !['approve', 'reject'].includes(args.decision))
150
+ )
151
+ throw invalid(
152
+ undefined,
153
+ 'Approval decisions require --run, --approval and --decision approve|reject.'
154
+ );
155
+ if (action === 'rename' && args.title !== undefined) args.title = args.title.trim();
156
+ if (args.title !== undefined && args.title.length > 160) throw invalid();
157
+ if (args.model !== undefined && (args.model.trim().length === 0 || args.model.length > 120))
158
+ throw invalid();
159
+ if (args.effort !== undefined && args.effort.length > 120) throw invalid();
160
+ if (action === 'config' && args.model === undefined && args.effort === undefined) throw invalid();
161
+ if (action === 'rename') {
162
+ if (args.title === undefined || args.title.trim().length === 0) throw invalid();
163
+ }
164
+ if (action === 'schedule.create' && args.cron === undefined) throw invalid();
165
+ if (args.cron !== undefined && (args.cron.trim().length === 0 || args.cron.length > 512))
166
+ throw invalid();
167
+ if (
168
+ action === 'schedule.update' &&
169
+ (args.expectedRevision === undefined ||
170
+ (args.cron === undefined && args.message === undefined && args.messageFile === undefined))
171
+ )
172
+ throw invalid();
173
+ if (args.query !== undefined && args.query.length > 160) throw invalid();
174
+ if (args.parentSessionId !== undefined && args.parentSessionId.length > 512) throw invalid();
175
+ return args;
176
+ }
@@ -0,0 +1,88 @@
1
+ import { open } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ import { MarctlError } from './errors.mjs';
4
+
5
+ export async function messageContent(
6
+ { message, messageFile: path },
7
+ stdin,
8
+ limit,
9
+ translate,
10
+ diagnostic = false
11
+ ) {
12
+ let file;
13
+ try {
14
+ let content = message;
15
+ if (content === undefined) {
16
+ const input =
17
+ path === '-'
18
+ ? stdin
19
+ : (file = await open(path, 'r')).createReadStream({ autoClose: false });
20
+ const parts = [];
21
+ let bytes = 0;
22
+ for await (const part of input) {
23
+ const chunk = Buffer.from(part);
24
+ bytes += chunk.length;
25
+ if (bytes > limit * 4) throw new Error('MESSAGE_TOO_LONG');
26
+ parts.push(chunk);
27
+ }
28
+ content = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(parts));
29
+ }
30
+ if (content.trim().length === 0 || content.length > limit) throw new Error('MESSAGE_INVALID');
31
+ return content;
32
+ } catch (error) {
33
+ throw new MarctlError(translate('errors.sessionMessageInvalid'), {
34
+ code: 'MARCTL_SESSION_INPUT_INVALID',
35
+ ...(diagnostic
36
+ ? {
37
+ details: `${path === undefined ? 'message' : path === '-' ? 'stdin' : 'message-file'}: provide non-blank UTF-8 text of at most ${limit} characters.${typeof error.code === 'string' && /^[A-Z_]+$/u.test(error.code) ? ' File error: ' + error.code + '.' : ''}`
38
+ }
39
+ : {})
40
+ });
41
+ } finally {
42
+ await file?.close();
43
+ }
44
+ }
45
+
46
+ export async function imageAttachments(paths, translate) {
47
+ const attachments = [];
48
+ let total = 0;
49
+ for (const path of paths) {
50
+ let file;
51
+ try {
52
+ file = await open(path, 'r');
53
+ const info = await file.stat();
54
+ if (!info.isFile() || info.size === 0 || info.size > 5 * 1024 * 1024)
55
+ throw new Error('IMAGE_INVALID');
56
+ const parts = [];
57
+ let size = 0;
58
+ for await (const part of file.createReadStream({ autoClose: false })) {
59
+ size += part.length;
60
+ total += part.length;
61
+ if (size > 5 * 1024 * 1024 || total > 10 * 1024 * 1024) throw new Error('IMAGE_TOO_LARGE');
62
+ parts.push(part);
63
+ }
64
+ const data = Buffer.concat(parts);
65
+ const mime = imageMime(data);
66
+ if (mime === undefined) throw new Error('IMAGE_INVALID');
67
+ attachments.push({ name: basename(path), mime, dataBase64: data.toString('base64') });
68
+ } catch {
69
+ throw new MarctlError(translate('errors.sessionImageInvalid'), {
70
+ code: 'MARCTL_SESSION_INPUT_INVALID'
71
+ });
72
+ } finally {
73
+ await file?.close();
74
+ }
75
+ }
76
+ return attachments;
77
+ }
78
+
79
+ function imageMime(data) {
80
+ if (data.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) return 'image/png';
81
+ if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return 'image/jpeg';
82
+ if (['GIF87a', 'GIF89a'].includes(data.subarray(0, 6).toString('ascii'))) return 'image/gif';
83
+ if (
84
+ data.subarray(0, 4).toString('ascii') === 'RIFF' &&
85
+ data.subarray(8, 12).toString('ascii') === 'WEBP'
86
+ )
87
+ return 'image/webp';
88
+ }