@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7
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/CHANGELOG.md +1309 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +24 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- package/src/util/cli.mjs +70 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct argv execution for the W2M Localside agent.
|
|
3
|
+
*
|
|
4
|
+
* Two invariants matter more than anything else in this file:
|
|
5
|
+
*
|
|
6
|
+
* 1. **No shell, ever.** `spawn(file, args, {shell: false})` is the only way
|
|
7
|
+
* a command is started. A string is not accepted as `argv`, so a caller
|
|
8
|
+
* cannot smuggle a command line in by accident; the type check throws.
|
|
9
|
+
* This is what keeps `node --test && rm -rf /` from being two commands.
|
|
10
|
+
*
|
|
11
|
+
* 2. **Raw byte streams are the evidence.** `stdout_sha256` / `stdout_bytes`
|
|
12
|
+
* describe the *entire* stream, not the retained sample, so a result stays
|
|
13
|
+
* comparable between machines even when a chatty command prints more than
|
|
14
|
+
* we are willing to keep in memory. Truncation only limits what we retain
|
|
15
|
+
* for `stdout_head` / `stdout_tail`, and it is reported as a warning.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { resolveCommand } from './resolve.mjs';
|
|
21
|
+
|
|
22
|
+
/** Per-stream default retention cap (task-8: 2 MiB each). */
|
|
23
|
+
export const DEFAULT_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/** §5.2: `stdout_head` / `stdout_tail` carry 4 KiB each. */
|
|
26
|
+
export const SAMPLE_BYTES = 4 * 1024;
|
|
27
|
+
|
|
28
|
+
/** Warning code emitted when we retained less than the command produced. */
|
|
29
|
+
export const WARN_OUTPUT_TRUNCATED = 'OUTPUT_TRUNCATED';
|
|
30
|
+
|
|
31
|
+
/** Warning code emitted when the executable could not be started at all. */
|
|
32
|
+
export const WARN_PATH_INVALID = 'PATH_INVALID';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Lowercase hex SHA-256 of a Buffer/string.
|
|
36
|
+
*
|
|
37
|
+
* @param {Buffer|Uint8Array|string} data
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function sha256Hex(data) {
|
|
41
|
+
return createHash('sha256').update(data).digest('hex');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** SHA-256 of the empty stream; used by refusal envelopes. */
|
|
45
|
+
export const EMPTY_SHA256 = sha256Hex(Buffer.alloc(0));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {object} ExecResult
|
|
49
|
+
* @property {string[]} argv
|
|
50
|
+
* @property {string} cwd
|
|
51
|
+
* @property {number|null} exit_code Exit status, `null` when killed by a signal.
|
|
52
|
+
* @property {string|null} signal Signal name, `null` on a normal exit.
|
|
53
|
+
* @property {Buffer} stdout Retained prefix of stdout (<= maxOutputBytes).
|
|
54
|
+
* @property {Buffer} stderr Retained prefix of stderr (<= maxOutputBytes).
|
|
55
|
+
* @property {number} stdout_bytes Total bytes seen on stdout.
|
|
56
|
+
* @property {number} stderr_bytes Total bytes seen on stderr.
|
|
57
|
+
* @property {string} stdout_sha256 SHA-256 of the *entire* stdout stream.
|
|
58
|
+
* @property {string} stderr_sha256 SHA-256 of the *entire* stderr stream.
|
|
59
|
+
* @property {string} stdout_head First 4 KiB of stdout, UTF-8 (lossy).
|
|
60
|
+
* @property {string} stdout_tail Last 4 KiB of stdout, UTF-8 (lossy).
|
|
61
|
+
* @property {string} stderr_head First 4 KiB of stderr, UTF-8 (lossy).
|
|
62
|
+
* @property {string} stderr_tail Last 4 KiB of stderr, UTF-8 (lossy).
|
|
63
|
+
* @property {boolean} truncated_stdout True when stdout exceeded the retention cap.
|
|
64
|
+
* @property {boolean} truncated_stderr True when stderr exceeded the retention cap.
|
|
65
|
+
* @property {string} started_at RFC3339 UTC.
|
|
66
|
+
* @property {string} ended_at RFC3339 UTC.
|
|
67
|
+
* @property {number} duration_ms Wall-clock duration.
|
|
68
|
+
* @property {boolean} timed_out We killed it because `timeoutMs` elapsed.
|
|
69
|
+
* @property {boolean} cancelled We killed it because the signal aborted.
|
|
70
|
+
* @property {{code: string, message: string}|null} spawn_error
|
|
71
|
+
* @property {string[]} warnings
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Streaming collector: hashes and counts everything, retains a capped prefix
|
|
76
|
+
* plus a rolling 4 KiB tail.
|
|
77
|
+
*
|
|
78
|
+
* @param {number} maxOutputBytes
|
|
79
|
+
*/
|
|
80
|
+
function createCollector(maxOutputBytes) {
|
|
81
|
+
/** @type {Buffer[]} */
|
|
82
|
+
const heads = [];
|
|
83
|
+
/** @type {Buffer[]} */
|
|
84
|
+
const tails = [];
|
|
85
|
+
let kept = 0;
|
|
86
|
+
let total = 0;
|
|
87
|
+
let tailLen = 0;
|
|
88
|
+
const hasher = createHash('sha256');
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
/** @param {Buffer} chunk */
|
|
92
|
+
push(chunk) {
|
|
93
|
+
total += chunk.length;
|
|
94
|
+
hasher.update(chunk);
|
|
95
|
+
if (kept < maxOutputBytes) {
|
|
96
|
+
const room = maxOutputBytes - kept;
|
|
97
|
+
const piece = chunk.length <= room ? chunk : chunk.subarray(0, room);
|
|
98
|
+
heads.push(piece);
|
|
99
|
+
kept += piece.length;
|
|
100
|
+
}
|
|
101
|
+
tails.push(chunk);
|
|
102
|
+
tailLen += chunk.length;
|
|
103
|
+
// Drop whole chunks from the front while the rest still covers 4 KiB.
|
|
104
|
+
while (tails.length > 1 && tailLen - tails[0].length >= SAMPLE_BYTES) {
|
|
105
|
+
tailLen -= tails.shift().length;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
finish() {
|
|
109
|
+
const all = Buffer.concat(tails, tailLen);
|
|
110
|
+
return {
|
|
111
|
+
out: Buffer.concat(heads, kept),
|
|
112
|
+
head: all.subarray(0, Math.min(SAMPLE_BYTES, all.length)),
|
|
113
|
+
tail: all.length > SAMPLE_BYTES ? all.subarray(all.length - SAMPLE_BYTES) : all,
|
|
114
|
+
bytes: total,
|
|
115
|
+
sha: hasher.digest('hex'),
|
|
116
|
+
truncated: total > kept,
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Start `argv` directly, capture both streams, enforce a timeout.
|
|
124
|
+
*
|
|
125
|
+
* Never rejects: a failure to even start the process is part of the result,
|
|
126
|
+
* because "could not spawn" is a result the Rabbit side has to see.
|
|
127
|
+
*
|
|
128
|
+
* @param {string[]} argv Command and arguments; `argv[0]` is the executable.
|
|
129
|
+
* @param {object} [options]
|
|
130
|
+
* @param {string} [options.cwd]
|
|
131
|
+
* @param {Record<string,string|undefined>} [options.env] Extra environment entries.
|
|
132
|
+
* @param {number} [options.timeoutMs] 0/undefined means "no timeout".
|
|
133
|
+
* @param {number} [options.maxOutputBytes] Per-stream retention cap.
|
|
134
|
+
* @param {AbortSignal} [options.signal] Cancellation (task.cancel / shutdown).
|
|
135
|
+
* @param {string} [options.killSignal]
|
|
136
|
+
* @param {number} [options.killGraceMs] Delay before escalating to SIGKILL.
|
|
137
|
+
* @param {boolean} [options.resolveExecutable] Look `argv[0]` up the way the
|
|
138
|
+
* capability probe does — PATH first, DSH's bundled runtime second —
|
|
139
|
+
* instead of handing the bare name to `spawn`. Defaults to true, so a
|
|
140
|
+
* capability the probe advertised is one a task can actually use. Set
|
|
141
|
+
* false to spawn `argv[0]` verbatim.
|
|
142
|
+
* @returns {Promise<ExecResult>}
|
|
143
|
+
*/
|
|
144
|
+
export function runArgv(argv, options = {}) {
|
|
145
|
+
if (!Array.isArray(argv) || argv.length === 0) {
|
|
146
|
+
throw new TypeError('runArgv: argv must be a non-empty array of strings');
|
|
147
|
+
}
|
|
148
|
+
for (const part of argv) {
|
|
149
|
+
if (typeof part !== 'string') {
|
|
150
|
+
throw new TypeError(
|
|
151
|
+
`runArgv: argv entries must be strings, got ${part === null ? 'null' : typeof part}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (argv[0] === '') throw new TypeError('runArgv: argv[0] must not be empty');
|
|
156
|
+
|
|
157
|
+
const {
|
|
158
|
+
cwd = process.cwd(),
|
|
159
|
+
env: extraEnv,
|
|
160
|
+
timeoutMs = 0,
|
|
161
|
+
maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
|
|
162
|
+
signal,
|
|
163
|
+
killSignal = 'SIGTERM',
|
|
164
|
+
killGraceMs = 2000,
|
|
165
|
+
resolveExecutable = true,
|
|
166
|
+
} = options;
|
|
167
|
+
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
const outCollector = createCollector(maxOutputBytes);
|
|
170
|
+
const errCollector = createCollector(maxOutputBytes);
|
|
171
|
+
|
|
172
|
+
const startedAtWall = new Date();
|
|
173
|
+
const startedHr = process.hrtime.bigint();
|
|
174
|
+
|
|
175
|
+
let settled = false;
|
|
176
|
+
let timedOut = false;
|
|
177
|
+
let cancelled = false;
|
|
178
|
+
let timeoutTimer = null;
|
|
179
|
+
let graceTimer = null;
|
|
180
|
+
/** @type {import('node:child_process').ChildProcess} */
|
|
181
|
+
let child;
|
|
182
|
+
|
|
183
|
+
const finish = (exitCode, signalName, spawnError) => {
|
|
184
|
+
if (settled) return;
|
|
185
|
+
settled = true;
|
|
186
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
187
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
188
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
189
|
+
|
|
190
|
+
const out = outCollector.finish();
|
|
191
|
+
const err = errCollector.finish();
|
|
192
|
+
const durationMs = Number(process.hrtime.bigint() - startedHr) / 1e6;
|
|
193
|
+
|
|
194
|
+
const warnings = [];
|
|
195
|
+
if (out.truncated || err.truncated) warnings.push(WARN_OUTPUT_TRUNCATED);
|
|
196
|
+
if (spawnError) warnings.push(WARN_PATH_INVALID);
|
|
197
|
+
|
|
198
|
+
resolve({
|
|
199
|
+
argv: [...argv],
|
|
200
|
+
cwd,
|
|
201
|
+
exit_code: exitCode,
|
|
202
|
+
signal: signalName,
|
|
203
|
+
stdout: out.out,
|
|
204
|
+
stderr: err.out,
|
|
205
|
+
stdout_bytes: out.bytes,
|
|
206
|
+
stderr_bytes: err.bytes,
|
|
207
|
+
stdout_sha256: out.sha,
|
|
208
|
+
stderr_sha256: err.sha,
|
|
209
|
+
stdout_head: out.head.toString('utf8'),
|
|
210
|
+
stdout_tail: out.tail.toString('utf8'),
|
|
211
|
+
stderr_head: err.head.toString('utf8'),
|
|
212
|
+
stderr_tail: err.tail.toString('utf8'),
|
|
213
|
+
truncated_stdout: out.truncated,
|
|
214
|
+
truncated_stderr: err.truncated,
|
|
215
|
+
started_at: startedAtWall.toISOString(),
|
|
216
|
+
ended_at: new Date().toISOString(),
|
|
217
|
+
duration_ms: Math.round(durationMs),
|
|
218
|
+
timed_out: timedOut,
|
|
219
|
+
cancelled,
|
|
220
|
+
spawn_error: spawnError,
|
|
221
|
+
warnings,
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/** Kill, then escalate if the process ignores SIGTERM. */
|
|
226
|
+
const killChild = () => {
|
|
227
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
228
|
+
try {
|
|
229
|
+
child.kill(killSignal);
|
|
230
|
+
} catch {
|
|
231
|
+
/* already gone */
|
|
232
|
+
}
|
|
233
|
+
if (killGraceMs > 0) {
|
|
234
|
+
graceTimer = setTimeout(() => {
|
|
235
|
+
try {
|
|
236
|
+
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
237
|
+
} catch {
|
|
238
|
+
/* already gone */
|
|
239
|
+
}
|
|
240
|
+
}, killGraceMs);
|
|
241
|
+
if (typeof graceTimer.unref === 'function') graceTimer.unref();
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const onAbort = () => {
|
|
246
|
+
cancelled = true;
|
|
247
|
+
killChild();
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
// The probe resolves a tool by PATH first, then in DSH's bundled runtime, so a machine with no
|
|
252
|
+
// system `node` still reports a real `caps.node`. Execution has to use the same order, or the
|
|
253
|
+
// capability is one the machine advertises and cannot use: `spawn('node', …, {shell:false})`
|
|
254
|
+
// only ever searches PATH.
|
|
255
|
+
const childEnv = extraEnv ? { ...process.env, ...extraEnv } : process.env;
|
|
256
|
+
const executable = resolveExecutable ? resolveCommand(argv[0], { env: childEnv }) : argv[0];
|
|
257
|
+
child = spawn(executable, argv.slice(1), {
|
|
258
|
+
cwd,
|
|
259
|
+
env: childEnv,
|
|
260
|
+
shell: false, // non-negotiable
|
|
261
|
+
windowsHide: true,
|
|
262
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
263
|
+
detached: false,
|
|
264
|
+
});
|
|
265
|
+
} catch (error) {
|
|
266
|
+
finish(null, null, {
|
|
267
|
+
code: error?.code ?? 'SPAWN_FAILED',
|
|
268
|
+
message: String(error?.message ?? error),
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
child.stdout?.on('data', (chunk) => outCollector.push(chunk));
|
|
274
|
+
child.stderr?.on('data', (chunk) => errCollector.push(chunk));
|
|
275
|
+
|
|
276
|
+
child.on('error', (error) => {
|
|
277
|
+
finish(null, null, {
|
|
278
|
+
code: error?.code ?? 'SPAWN_FAILED',
|
|
279
|
+
message: String(error?.message ?? error),
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
child.on('close', (code, signalName) => {
|
|
284
|
+
const signalled = signalName !== null && signalName !== undefined;
|
|
285
|
+
// The contract wants exit_code null whenever a signal killed the process.
|
|
286
|
+
finish(signalled ? null : code, signalled ? String(signalName) : null, null);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (timeoutMs > 0) {
|
|
290
|
+
timeoutTimer = setTimeout(() => {
|
|
291
|
+
timedOut = true;
|
|
292
|
+
killChild();
|
|
293
|
+
}, timeoutMs);
|
|
294
|
+
if (typeof timeoutTimer.unref === 'function') timeoutTimer.unref();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (signal) {
|
|
298
|
+
if (signal.aborted) {
|
|
299
|
+
cancelled = true;
|
|
300
|
+
killChild();
|
|
301
|
+
} else {
|
|
302
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Map an execution result onto the contract's `status` enum.
|
|
310
|
+
*
|
|
311
|
+
* `refused` / `unverifiable` are decided by the caller, not here.
|
|
312
|
+
*
|
|
313
|
+
* @param {ExecResult} result
|
|
314
|
+
* @returns {'ok'|'nonzero_exit'|'timeout'|'crashed'}
|
|
315
|
+
*/
|
|
316
|
+
export function classifyExit(result) {
|
|
317
|
+
if (result.timed_out) return 'timeout';
|
|
318
|
+
if (result.spawn_error) return 'crashed';
|
|
319
|
+
if (result.cancelled) return 'crashed';
|
|
320
|
+
if (result.exit_code === null) return 'crashed'; // killed by a signal
|
|
321
|
+
return result.exit_code === 0 ? 'ok' : 'nonzero_exit';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Control characters are the point of these patterns: they match the ANSI escape sequences a
|
|
325
|
+
// terminal emits. `no-control-regex` exists to catch control characters that slipped in by accident,
|
|
326
|
+
// which is the opposite of what is happening here, so the rule is disabled per line rather than
|
|
327
|
+
// globally -- a genuinely accidental control character elsewhere must still be reported.
|
|
328
|
+
// eslint-disable-next-line no-control-regex -- matches ANSI OSC terminators by definition
|
|
329
|
+
const ANSI_OSC = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g;
|
|
330
|
+
// eslint-disable-next-line no-control-regex -- matches ANSI CSI introducers by definition
|
|
331
|
+
const ANSI_CSI = /\u001B\[[0-9;?]*[ -/]*[@-~]/g;
|
|
332
|
+
// eslint-disable-next-line no-control-regex -- matches ANSI single-character escapes by definition
|
|
333
|
+
const ANSI_OTHER = /\u001B[@-Z\\-_]/g;
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Apply `compare_policy` to a captured stream so that two machines can agree
|
|
337
|
+
* on `stdout_normalized_sha256` despite ANSI colour and CRLF differences.
|
|
338
|
+
*
|
|
339
|
+
* The transform is lossy on purpose and intentionally *not* used for
|
|
340
|
+
* `stdout_sha256`, which stays the raw byte stream.
|
|
341
|
+
*
|
|
342
|
+
* @param {Buffer|string} data
|
|
343
|
+
* @param {{strip_ansi?: boolean, normalize_crlf?: boolean, strip_trailing_blank_lines?: boolean}} [policy]
|
|
344
|
+
* @returns {Buffer}
|
|
345
|
+
*/
|
|
346
|
+
export function normalizeOutput(data, policy = {}) {
|
|
347
|
+
const stripAnsi = policy.strip_ansi !== false;
|
|
348
|
+
const normalizeCrlf = policy.normalize_crlf !== false;
|
|
349
|
+
const stripTrailingBlank = policy.strip_trailing_blank_lines !== false;
|
|
350
|
+
|
|
351
|
+
let text = Buffer.isBuffer(data) ? data.toString('utf8') : String(data);
|
|
352
|
+
if (stripAnsi) text = text.replace(ANSI_OSC, '').replace(ANSI_CSI, '').replace(ANSI_OTHER, '');
|
|
353
|
+
if (normalizeCrlf) text = text.replace(/\r\n/g, '\n');
|
|
354
|
+
if (stripTrailingBlank) text = text.replace(/(?:\n[ \t]*)+$/, '');
|
|
355
|
+
return Buffer.from(text, 'utf8');
|
|
356
|
+
}
|