@tensor.chat/companion 0.3.1 → 0.4.1
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/package.json +1 -1
- package/src/daemon.js +47 -4
- package/src/stalls.js +53 -0
- package/src/tools/files.js +13 -3
- package/src/tools/outside.js +101 -0
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -13,7 +13,9 @@ const bash = require('./tools/bash');
|
|
|
13
13
|
const files = require('./tools/files');
|
|
14
14
|
const { fetchUrl } = require('./tools/fetch');
|
|
15
15
|
const { extractTouched } = require('./tools/touched');
|
|
16
|
+
const { outsideFolders } = require('./tools/outside');
|
|
16
17
|
const { Jobs } = require('./tools/jobs');
|
|
18
|
+
const { createStallWatch } = require('./stalls');
|
|
17
19
|
|
|
18
20
|
const VERSION = require('../package.json').version;
|
|
19
21
|
const RECONNECT_MIN_MS = 1000;
|
|
@@ -21,6 +23,17 @@ const RECONNECT_MAX_MS = 60000;
|
|
|
21
23
|
const MAX_CONCURRENT_CALLS = 4;
|
|
22
24
|
const BACKGROUND_FIRST_LOOK_MS = 1500; // a background start still reports the first lines
|
|
23
25
|
const FOREGROUND_DEFAULT_MS = 600000;
|
|
26
|
+
const SLOW_SPAWN_MS = 2000;
|
|
27
|
+
|
|
28
|
+
// What a call works on, for the log: the command, the path or the address
|
|
29
|
+
function callSubject(tool, args) {
|
|
30
|
+
const a = args && typeof args === 'object' ? args : {};
|
|
31
|
+
if (tool === 'bash' || tool === 'grep') return a.command;
|
|
32
|
+
if (tool === 'glob') return a.pattern;
|
|
33
|
+
if (tool === 'fetch') return a.url;
|
|
34
|
+
if (tool === 'renamepath') return a.from;
|
|
35
|
+
return a.path;
|
|
36
|
+
}
|
|
24
37
|
|
|
25
38
|
function wsUrlFor(serverUrl) {
|
|
26
39
|
const u = new URL(serverUrl);
|
|
@@ -82,7 +95,7 @@ function authorizeRequestedRoots(requestedRoots, labels, approvedRoots) {
|
|
|
82
95
|
return { roots, labels: keptLabels, rejected };
|
|
83
96
|
}
|
|
84
97
|
|
|
85
|
-
function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell } = {}) {
|
|
98
|
+
function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell, slowSpawnMs = SLOW_SPAWN_MS } = {}) {
|
|
86
99
|
if (!serverUrl || !token) throw new Error('serverUrl and token are required');
|
|
87
100
|
const emitter = new EventEmitter();
|
|
88
101
|
const say = typeof log === 'function' ? log : () => {};
|
|
@@ -92,7 +105,8 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
92
105
|
const running = new Map(); // call id → AbortController
|
|
93
106
|
const jobs = new Jobs();
|
|
94
107
|
const unsentExits = []; // job endings the server has not heard yet (offline at the time)
|
|
95
|
-
const state = { connected: false, deviceId: null, name: null, mode: null, roots: [], labels: [], reconnectMs: RECONNECT_MIN_MS, stopped: false, ws: null, timer: null };
|
|
108
|
+
const state = { connected: false, deviceId: null, name: null, mode: null, roots: [], labels: [], reconnectMs: RECONNECT_MIN_MS, stopped: false, ws: null, timer: null, connectedAt: null, lastPingAt: null };
|
|
109
|
+
const stalls = createStallWatch({ say });
|
|
96
110
|
|
|
97
111
|
function send(msg) {
|
|
98
112
|
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false;
|
|
@@ -121,7 +135,13 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
121
135
|
if (cwdRes && cwdRes.error) return { ok: false, text: `(cannot run there — ${cwdRes.error})` };
|
|
122
136
|
const cwd = cwdRes ? cwdRes.abs : callRoots.primary();
|
|
123
137
|
if (!cwd) return { ok: false, text: '(no shared folder is available on this device — the user has to add one in the device settings)' };
|
|
138
|
+
// Starting a process holds this whole process until Windows has it running. A
|
|
139
|
+
// scanner that inspects the program first can make that last long enough for
|
|
140
|
+
// the server to stop hearing from this computer.
|
|
141
|
+
const spawnAt = Date.now();
|
|
124
142
|
const job = jobs.start(String(a.command || ''), { cwd, shell });
|
|
143
|
+
const spawnMs = Date.now() - spawnAt;
|
|
144
|
+
if (spawnMs >= slowSpawnMs) say(`starting the shell took ${Math.round(spawnMs / 1000)} s`);
|
|
125
145
|
// A cancelled call (turn aborted, server timeout) takes its foreground job with
|
|
126
146
|
// it; a background job belongs to the chat, not to the call, and lives on.
|
|
127
147
|
const onAbort = () => { if (!a.background) jobs.kill(job.id); };
|
|
@@ -158,6 +178,12 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
158
178
|
case 'write': return files.write(callRoots, a.path, a.content, { overwrite: a.overwrite === true });
|
|
159
179
|
case 'edit': return files.edit(callRoots, a.path, a.edits);
|
|
160
180
|
case 'glob': return files.glob(callRoots, a.pattern);
|
|
181
|
+
// The server asks before it judges a shell command: what of it lies outside
|
|
182
|
+
// the shared folders, read with this machine's own variables. Nothing runs.
|
|
183
|
+
case 'outside': {
|
|
184
|
+
const at = a.cwd ? callRoots.resolve(a.cwd) : null;
|
|
185
|
+
return outsideFolders(callRoots, a.command, { cwd: at && !at.error ? at.abs : callRoots.primary() || os.homedir() });
|
|
186
|
+
}
|
|
161
187
|
case 'grep': return files.grep(callRoots, a.command);
|
|
162
188
|
case 'ls': return files.ls(callRoots, a.path, { max: a.max });
|
|
163
189
|
case 'fetchfile': return files.fetchfile(callRoots, a.path, { maxBytes: a.maxBytes });
|
|
@@ -189,6 +215,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
189
215
|
}
|
|
190
216
|
const ctrl = new AbortController();
|
|
191
217
|
running.set(id, ctrl);
|
|
218
|
+
const finished = stalls.started(tool, callSubject(tool, msg.args));
|
|
192
219
|
emitter.emit('call', { id, tool: msg.tool, args: msg.args });
|
|
193
220
|
let r;
|
|
194
221
|
try {
|
|
@@ -202,6 +229,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
202
229
|
} catch (e) {
|
|
203
230
|
r = { ok: false, text: `(tool error on the companion device: ${e && e.message ? e.message : e})` };
|
|
204
231
|
}
|
|
232
|
+
finished();
|
|
205
233
|
running.delete(id);
|
|
206
234
|
send({ t: 'result', id, ...r });
|
|
207
235
|
emitter.emit('result', { id, tool: msg.tool, ok: r.ok });
|
|
@@ -241,6 +269,16 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
241
269
|
}
|
|
242
270
|
}
|
|
243
271
|
|
|
272
|
+
// 1006 without a reason is a connection that just ended: the server stopped hearing
|
|
273
|
+
// pings, or the network went. The last ping says which side went quiet first.
|
|
274
|
+
function closeLine(code, reason, at = Date.now()) {
|
|
275
|
+
const why = String(reason).trim();
|
|
276
|
+
const secs = (ms) => `${Math.round(ms / 1000)} s`;
|
|
277
|
+
return `disconnected: code ${code}${why ? ` "${why}"` : ''}, connected for ${secs(at - state.connectedAt)}`
|
|
278
|
+
+ `, last ping from the server ${state.lastPingAt ? `${secs(at - state.lastPingAt)} ago` : 'never'}`
|
|
279
|
+
+ (running.size ? `, ${running.size} call(s) running` : '');
|
|
280
|
+
}
|
|
281
|
+
|
|
244
282
|
function scheduleReconnect() {
|
|
245
283
|
if (state.stopped || state.timer) return;
|
|
246
284
|
const wait = state.reconnectMs;
|
|
@@ -263,6 +301,8 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
263
301
|
state.ws = ws;
|
|
264
302
|
ws.on('open', () => {
|
|
265
303
|
state.connected = true;
|
|
304
|
+
state.connectedAt = Date.now();
|
|
305
|
+
state.lastPingAt = null;
|
|
266
306
|
state.reconnectMs = RECONNECT_MIN_MS;
|
|
267
307
|
send(systemInfo(shell, !!localApprovals && !!localShellApproval));
|
|
268
308
|
flushExits();
|
|
@@ -270,6 +310,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
270
310
|
emitter.emit('connected');
|
|
271
311
|
});
|
|
272
312
|
ws.on('message', onMessage);
|
|
313
|
+
ws.on('ping', () => { state.lastPingAt = Date.now(); });
|
|
273
314
|
ws.on('unexpected-response', (_req, res) => {
|
|
274
315
|
const code = res && res.statusCode;
|
|
275
316
|
say(`server refused the connection (HTTP ${code})`);
|
|
@@ -283,6 +324,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
283
324
|
ws.on('error', (e) => { say(`socket error: ${e.message}`); });
|
|
284
325
|
ws.on('close', (code, reason) => {
|
|
285
326
|
const was = state.connected;
|
|
327
|
+
if (was) say(closeLine(code, reason));
|
|
286
328
|
state.connected = false;
|
|
287
329
|
state.ws = null;
|
|
288
330
|
for (const c of running.values()) c.abort();
|
|
@@ -295,9 +337,10 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
295
337
|
return {
|
|
296
338
|
events: emitter,
|
|
297
339
|
state,
|
|
298
|
-
start() { state.stopped = false; connect(); return this; },
|
|
340
|
+
start() { state.stopped = false; stalls.start(); connect(); return this; },
|
|
299
341
|
stop() {
|
|
300
342
|
state.stopped = true;
|
|
343
|
+
stalls.stop();
|
|
301
344
|
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
302
345
|
for (const c of running.values()) c.abort();
|
|
303
346
|
jobs.killAll();
|
|
@@ -308,4 +351,4 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
308
351
|
};
|
|
309
352
|
}
|
|
310
353
|
|
|
311
|
-
module.exports = { createDaemon, wsUrlFor, systemInfo, authorizeRequestedRoots, VERSION };
|
|
354
|
+
module.exports = { createDaemon, wsUrlFor, systemInfo, authorizeRequestedRoots, callSubject, VERSION };
|
package/src/stalls.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// A process that is stuck answers no ping, and the server drops the computer as gone.
|
|
3
|
+
// Nothing in the process can say so while it is stuck, so this says it afterwards: how
|
|
4
|
+
// long, and which calls were under way in that time. A computer asleep stops the process
|
|
5
|
+
// the same way; the words leave both open (the desktop app logs the sleep itself).
|
|
6
|
+
|
|
7
|
+
const TICK_MS = 1000;
|
|
8
|
+
const STUCK_MS = 5000;
|
|
9
|
+
const KEEP = 30;
|
|
10
|
+
|
|
11
|
+
function createStallWatch({ say, tickMs = TICK_MS, stuckMs = STUCK_MS, now = Date.now } = {}) {
|
|
12
|
+
const calls = []; // the last KEEP calls, finished or not
|
|
13
|
+
let last = now();
|
|
14
|
+
let timer = null;
|
|
15
|
+
|
|
16
|
+
function started(tool, what) {
|
|
17
|
+
const call = { tool: String(tool), what: String(what || '').replace(/\s+/g, ' ').trim().slice(0, 80), start: now(), end: null };
|
|
18
|
+
calls.push(call);
|
|
19
|
+
if (calls.length > KEEP) calls.shift();
|
|
20
|
+
return () => { call.end = now(); };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A call that finished during the stall counts too: the work that held the process
|
|
24
|
+
// usually is that call, and it is done by the time the timer can run again.
|
|
25
|
+
function during(from, to) {
|
|
26
|
+
return calls
|
|
27
|
+
.filter((c) => c.start <= to && (c.end == null || c.end >= from))
|
|
28
|
+
.map((c) => `${c.tool}${c.what ? ` "${c.what}"` : ''} (${c.end == null ? 'still running' : `took ${Math.round((c.end - c.start) / 1000)} s`})`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function tick() {
|
|
32
|
+
const t = now();
|
|
33
|
+
const gap = t - last;
|
|
34
|
+
last = t;
|
|
35
|
+
if (gap - tickMs < stuckMs) return;
|
|
36
|
+
const what = during(t - gap, t);
|
|
37
|
+
say(`this process did not run for ${Math.round(gap / 1000)} s (busy, or the computer slept)${what.length ? ` - calls in that time: ${what.join('; ')}` : ''}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
started,
|
|
42
|
+
tick,
|
|
43
|
+
start() {
|
|
44
|
+
if (timer) return;
|
|
45
|
+
last = now();
|
|
46
|
+
timer = setInterval(tick, tickMs);
|
|
47
|
+
if (timer.unref) timer.unref();
|
|
48
|
+
},
|
|
49
|
+
stop() { if (timer) { clearInterval(timer); timer = null; } },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { createStallWatch, TICK_MS, STUCK_MS };
|
package/src/tools/files.js
CHANGED
|
@@ -114,20 +114,30 @@ function edit(roots, p, edits) {
|
|
|
114
114
|
const original = fs.readFileSync(r.abs, 'utf8');
|
|
115
115
|
if (original.includes('\u0000')) return err(`(cannot edit ${roots.display(r.abs)} — binary content)`);
|
|
116
116
|
const eol = original.includes('\r\n') ? '\r\n' : '\n';
|
|
117
|
+
// All edits or none: every edit is checked, and every failing one is named, so
|
|
118
|
+
// the model can fix them in one retry and knows nothing was written.
|
|
119
|
+
const problems = [];
|
|
120
|
+
let notFound = false;
|
|
117
121
|
let next = original;
|
|
118
122
|
let i = 0;
|
|
119
123
|
for (const e of edits) {
|
|
120
124
|
i++;
|
|
125
|
+
const label = edits.length === 1 ? '' : `Edit ${i}: `;
|
|
121
126
|
const search = String(e && e.search != null ? e.search : '');
|
|
122
127
|
const replace = String(e && e.replace != null ? e.replace : '');
|
|
123
128
|
const searchN = eol === '\r\n' ? search.replace(/\r?\n/g, '\r\n') : search;
|
|
124
129
|
const replaceN = eol === '\r\n' ? replace.replace(/\r?\n/g, '\r\n') : replace;
|
|
125
|
-
if (!searchN)
|
|
130
|
+
if (!searchN) { problems.push(`${label}search text is empty`); continue; }
|
|
126
131
|
const n = countOccurrences(next, searchN);
|
|
127
|
-
if (n === 0)
|
|
128
|
-
if (n > 1)
|
|
132
|
+
if (n === 0) { problems.push(`${label}search text not found`); notFound = true; continue; }
|
|
133
|
+
if (n > 1) { problems.push(`${label}search text occurs ${n} times — include more context so it is unique`); continue; }
|
|
129
134
|
next = next.replace(searchN, () => replaceN);
|
|
130
135
|
}
|
|
136
|
+
if (problems.length) {
|
|
137
|
+
const shown = roots.display(r.abs);
|
|
138
|
+
const head = edits.length === 1 ? `The edit was not applied to ${shown}: ` : `None of the ${edits.length} edits were applied to ${shown}. `;
|
|
139
|
+
return err(`(${head}${problems.join('; ')}.${notFound ? ' Read the file and copy the current text exactly.' : ''})`);
|
|
140
|
+
}
|
|
131
141
|
fs.writeFileSync(r.abs, next, 'utf8');
|
|
132
142
|
return ok(`(edited ${roots.display(r.abs)}: ${edits.length} replacement${edits.length === 1 ? '' : 's'})`, { touched: [r.abs] });
|
|
133
143
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Which folders outside the shared ones does a shell command name? The safety check on
|
|
3
|
+
// the server can only guess what "$env:USERPROFILE\.voicetype" means - this computer
|
|
4
|
+
// knows (03.10.2026: four commands like that left it "not sure"). Every path-looking
|
|
5
|
+
// word of the command, with this machine's variables put in, that names an existing
|
|
6
|
+
// file or folder - or a new file in an existing folder - and lies outside every shared
|
|
7
|
+
// folder counts, as the folder that holds it. A command can reach further than its
|
|
8
|
+
// words (a script reads what it likes): this helps the question, it is no fence.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { isInside, realExisting } = require('../paths');
|
|
14
|
+
|
|
15
|
+
const MAX_FOLDERS = 10;
|
|
16
|
+
const MAX_WORDS = 2000;
|
|
17
|
+
// What ends a path written without quotes
|
|
18
|
+
const STOP = '\\s"\'<>|*?;,`()';
|
|
19
|
+
const WIN_ABS = new RegExp(`(?<![\\w\\\\/])[A-Za-z]:[\\\\/][^${STOP}]*`, 'g');
|
|
20
|
+
const UNC = new RegExp(`(?<![\\w\\\\])\\\\\\\\[^${STOP}\\\\]+\\\\[^${STOP}]+`, 'g');
|
|
21
|
+
const DOTDOT = new RegExp(`(?<![\\w.\\\\/])\\.\\.(?:[\\\\/][^${STOP}]*)?(?![\\w.])`, 'g');
|
|
22
|
+
const POSIX_ABS = new RegExp(`(?<![\\w.~\\\\/:])/[^${STOP}]+`, 'g');
|
|
23
|
+
const WHOLE_PATH = /^(?:[A-Za-z]:[\\/]|\\\\|\.\.(?:[\\/]|$)|\/)/;
|
|
24
|
+
|
|
25
|
+
// The command as this machine reads it: PowerShell's $env:NAME, cmd's %NAME%, the
|
|
26
|
+
// POSIX $NAME and ${NAME}, and ~ for the home folder. A name it does not know stays.
|
|
27
|
+
function expand(command, { env, home, platform }) {
|
|
28
|
+
const keys = Object.keys(env);
|
|
29
|
+
const get = (name) => {
|
|
30
|
+
const key = platform === 'win32' ? keys.find((k) => k.toLowerCase() === name.toLowerCase()) : (name in env ? name : null);
|
|
31
|
+
if (key != null && env[key] != null) return String(env[key]);
|
|
32
|
+
return /^(home|userprofile)$/i.test(name) ? home : null;
|
|
33
|
+
};
|
|
34
|
+
return String(command == null ? '' : command)
|
|
35
|
+
.replace(/\$\{env:([A-Za-z_][\w()]*)\}|\$env:([A-Za-z_]\w*)/gi, (m, a, b) => get(a || b) ?? m)
|
|
36
|
+
.replace(/%([A-Za-z_][\w()]*)%/g, (m, n) => get(n) ?? m)
|
|
37
|
+
.replace(/\$\{([A-Za-z_]\w*)\}|\$([A-Za-z_]\w*)/g, (m, a, b) => get(a || b) ?? m)
|
|
38
|
+
.replace(/(^|[\s"'=(])~(?=[\\/]|$|[\s"')])/g, (m, pre) => pre + home);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The words that may be paths: a quoted one that starts like a path is one path,
|
|
42
|
+
// spaces and all - cut at a space, "R:\Projekte\Open Chat Pal\x" named a stray file
|
|
43
|
+
// "R:\Projekte\Open". Other quoted text (a -Command "...") and the rest are searched.
|
|
44
|
+
function words(text, platform) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const scan = (s) => {
|
|
47
|
+
for (const re of platform === 'win32' ? [WIN_ABS, UNC, DOTDOT] : [WIN_ABS, UNC, DOTDOT, POSIX_ABS]) {
|
|
48
|
+
re.lastIndex = 0;
|
|
49
|
+
let m;
|
|
50
|
+
while ((m = re.exec(s)) && out.length < MAX_WORDS) out.push(m[0]);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const rest = text.replace(/"([^"]*)"|'([^']*)'/g, (m, a, b) => {
|
|
54
|
+
const inner = a != null ? a : b;
|
|
55
|
+
if (WHOLE_PATH.test(inner)) out.push(inner); else scan(inner);
|
|
56
|
+
return ' ';
|
|
57
|
+
});
|
|
58
|
+
scan(rest);
|
|
59
|
+
// A full stop or comma after a path belongs to the sentence; ".." stays itself
|
|
60
|
+
return out.map((w) => w.replace(/[,;:\]}]+$/, '').replace(/([^.])\.$/, '$1')).filter(Boolean);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function statOf(p) {
|
|
64
|
+
try { return fs.statSync(p); } catch (_) { return null; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The folder a word stands for: the folder itself, the folder of a file, or the folder
|
|
68
|
+
// a new file would go into. Anything else is not a path on this machine.
|
|
69
|
+
function folderOf(word, cwd) {
|
|
70
|
+
const abs = path.resolve(cwd, word);
|
|
71
|
+
const st = statOf(abs);
|
|
72
|
+
if (st) return st.isDirectory() ? abs : path.dirname(abs);
|
|
73
|
+
if (!/\.[A-Za-z0-9]{1,8}$/.test(path.basename(abs))) return null;
|
|
74
|
+
const parent = path.dirname(abs);
|
|
75
|
+
const ps = statOf(parent);
|
|
76
|
+
return ps && ps.isDirectory() && path.parse(parent).root !== parent ? parent : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function outsideFolders(roots, command, { cwd, env = process.env, home = os.homedir(), platform = process.platform } = {}) {
|
|
80
|
+
const shared = roots.okRoots().map((r) => r.real);
|
|
81
|
+
const found = [];
|
|
82
|
+
for (const word of words(expand(command, { env, home, platform }), platform)) {
|
|
83
|
+
const folder = folderOf(word, cwd || home);
|
|
84
|
+
if (!folder) continue;
|
|
85
|
+
const real = realExisting(folder) || folder;
|
|
86
|
+
if (shared.some((r) => isInside(real, r))) continue;
|
|
87
|
+
if (!found.some((f) => isInside(folder, f) && isInside(f, folder))) found.push(folder);
|
|
88
|
+
}
|
|
89
|
+
// A folder inside another one found is covered by it
|
|
90
|
+
const folders = found
|
|
91
|
+
.filter((f) => !found.some((g) => g !== f && isInside(f, g) && !isInside(g, f)))
|
|
92
|
+
.slice(0, MAX_FOLDERS)
|
|
93
|
+
// A whole drive, the home folder or what holds it is too much to share in one click
|
|
94
|
+
.map((f) => ({ path: f, broad: path.parse(f).root === f || isInside(home, f) }));
|
|
95
|
+
const text = folders.length
|
|
96
|
+
? `(outside the shared folders: ${folders.map((f) => f.path).join(', ')})`
|
|
97
|
+
: '(nothing outside the shared folders)';
|
|
98
|
+
return { ok: true, text, folders };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { outsideFolders, expand, words };
|