@tensor.chat/companion 0.4.1 → 0.4.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 +10 -5
- package/bin/tensor-companion.js +25 -2
- package/package.json +1 -1
- package/src/daemon.js +44 -11
- package/src/setup/i18n.js +157 -0
- package/src/setup/layout.js +49 -0
- package/src/setup/places.js +113 -0
- package/src/setup/term.js +94 -0
- package/src/setup/tree.js +174 -0
- package/src/setup/wizard.js +457 -0
- package/src/tools/outside.js +44 -15
package/README.md
CHANGED
|
@@ -13,8 +13,11 @@ npx @tensor.chat/companion pair
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
The command prints a code such as `AB3D-7KQ2`. In tensor.chat open
|
|
16
|
-
**Settings → Companion devices → Pair a device** and enter it.
|
|
17
|
-
|
|
16
|
+
**Settings → Companion devices → Pair a device** and enter it. Once paired, a short
|
|
17
|
+
setup asks in the terminal what the AI may use on this computer: folders from a list of
|
|
18
|
+
suggestions or a folder browser, everything (not recommended), or nothing yet, and
|
|
19
|
+
whether it may run commands. Run it again any time with
|
|
20
|
+
`npx @tensor.chat/companion setup`. Then keep the daemon running:
|
|
18
21
|
|
|
19
22
|
```
|
|
20
23
|
npx @tensor.chat/companion run
|
|
@@ -25,9 +28,11 @@ It then appears as the folder `Devices/<name>/` in that chat's file tree.
|
|
|
25
28
|
|
|
26
29
|
## What the AI can do, and where
|
|
27
30
|
|
|
28
|
-
- **Shared folders** must be approved locally on the computer first with
|
|
29
|
-
`tensor-companion approve-folder <full-path>`. The website
|
|
30
|
-
folders, but it cannot grant itself access.
|
|
31
|
+
- **Shared folders** must be approved locally on the computer first, with
|
|
32
|
+
`tensor-companion setup` or `tensor-companion approve-folder <full-path>`. The website
|
|
33
|
+
can request and display folders, but it cannot grant itself access. When a chat asks
|
|
34
|
+
for a folder the computer already approved (or one inside it), the daemon confirms
|
|
35
|
+
that by itself; anything else waits for `tensor-companion allow <code>`. Read, Write, Edit, Glob and Grep only
|
|
31
36
|
work inside locally approved folders. Paths outside, including symlinks that point
|
|
32
37
|
outside, are refused on this machine even if the server asks for them.
|
|
33
38
|
- **Shell commands** stay in tensor.chat's server sandbox by default. Approve shell locally
|
package/bin/tensor-companion.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
// tensor-companion — pair this machine with your tensor.chat account and keep it
|
|
4
|
-
// available for the AI. Commands: pair, run, status, shell, unpair.
|
|
4
|
+
// available for the AI. Commands: pair, setup, run, status, shell, unpair.
|
|
5
5
|
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const config = require('../src/config');
|
|
@@ -33,14 +33,35 @@ async function pair() {
|
|
|
33
33
|
const cfg = await pairing.waitAndSave(server, start, { name });
|
|
34
34
|
if (!cfg) throw new Error('the pairing code expired — run "npx @tensor.chat/companion pair" again');
|
|
35
35
|
out(` Paired as "${cfg.name}". Config saved to ${config.configPath()}`);
|
|
36
|
+
if (process.stdin.isTTY && process.stdout.isTTY) await setup();
|
|
37
|
+
else out(' Choose what the AI may use here with: npx @tensor.chat/companion setup');
|
|
36
38
|
out(' Start the daemon with: npx @tensor.chat/companion run');
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
// What this computer lets the AI use, chosen in a guided menu
|
|
42
|
+
async function setup() {
|
|
43
|
+
const { runSetup } = require('../src/setup/wizard');
|
|
44
|
+
const { translator, pickLanguage } = require('../src/setup/i18n');
|
|
45
|
+
const t = translator(pickLanguage(process.env, opt('--lang')));
|
|
46
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error(t('done.noTty'));
|
|
47
|
+
let user = '';
|
|
48
|
+
try { user = os.userInfo().username; } catch (_) {}
|
|
49
|
+
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
50
|
+
const r = await runSetup({ user, device: config.load().name || os.hostname(), isRoot, lang: opt('--lang') });
|
|
51
|
+
out('');
|
|
52
|
+
if (!r.saved) { out(` ${t('done.unchanged')}`); return; }
|
|
53
|
+
out(` ✔ ${t('done.saved')}`);
|
|
54
|
+
out(` ${t('review.folders')}: ${r.everything ? t('everything.summary') : (r.roots.length ? r.roots.join(', ') : t('review.noFolders'))}`);
|
|
55
|
+
out(` ${t('review.commands')}: ${r.shellUntil ? t('review.cmdUntil', { date: t.date(r.shellUntil) }) : t('review.cmdNo')}`);
|
|
56
|
+
if (r.roots.length) out(` ${t('done.web')}`);
|
|
57
|
+
out('');
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
function run() {
|
|
40
61
|
const cfg = config.load();
|
|
41
62
|
if (!cfg.token || !cfg.serverUrl) throw new Error('not paired yet — run "npx @tensor.chat/companion pair" first');
|
|
42
63
|
const stamp = () => new Date().toISOString().slice(11, 19);
|
|
43
|
-
const daemon = createDaemon({ serverUrl: cfg.serverUrl, token: cfg.token, shell: cfg.shell, approvedRoots: () => config.approvedRoots(), approvedShell: () => config.shellApproved(), log: (m) => out(`[${stamp()}] ${m}`) });
|
|
64
|
+
const daemon = createDaemon({ serverUrl: cfg.serverUrl, token: cfg.token, shell: cfg.shell, approvedRoots: () => config.approvedRoots(), approvedShell: () => config.shellApproved(), approver: 'terminal', log: (m) => out(`[${stamp()}] ${m}`) });
|
|
44
65
|
daemon.events.on('welcome', (w) => out(`[${stamp()}] ready as "${w.name || cfg.name}" (device ${w.deviceId})`));
|
|
45
66
|
daemon.events.on('config', (c) => {
|
|
46
67
|
const bad = c.roots.filter((r) => !r.ok);
|
|
@@ -144,6 +165,7 @@ function help() {
|
|
|
144
165
|
out(`tensor-companion ${VERSION}
|
|
145
166
|
|
|
146
167
|
tensor-companion pair [--server URL] [--name NAME] pair this machine with your account
|
|
168
|
+
tensor-companion setup [--lang en|de] choose what the AI may use on this computer
|
|
147
169
|
tensor-companion run stay connected and run tools for the AI
|
|
148
170
|
tensor-companion status show the current pairing
|
|
149
171
|
tensor-companion shell <path|auto> choose the shell Bash commands run in
|
|
@@ -159,6 +181,7 @@ function help() {
|
|
|
159
181
|
(async () => {
|
|
160
182
|
try {
|
|
161
183
|
if (cmd === 'pair') await pair();
|
|
184
|
+
else if (cmd === 'setup') await setup();
|
|
162
185
|
else if (cmd === 'run') run();
|
|
163
186
|
else if (cmd === 'status') status();
|
|
164
187
|
else if (cmd === 'shell') shell();
|
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -48,7 +48,9 @@ function isRoot() {
|
|
|
48
48
|
try { return typeof process.getuid === 'function' && process.getuid() === 0; } catch (_) { return false; }
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// approver: where this computer confirms what the website asks for - 'app' (a dialog of
|
|
52
|
+
// the desktop app) or 'terminal' (a command run on it, often over SSH)
|
|
53
|
+
function systemInfo(shell, localAuthority = false, approver = null) {
|
|
52
54
|
let username = null;
|
|
53
55
|
try { username = os.userInfo().username; } catch (_) {}
|
|
54
56
|
return {
|
|
@@ -65,18 +67,29 @@ function systemInfo(shell, localAuthority = false) {
|
|
|
65
67
|
shellKind: bash.shellFor(shell).kind,
|
|
66
68
|
cwd: process.cwd(),
|
|
67
69
|
localAuthority: localAuthority ? 1 : null,
|
|
70
|
+
approver: approver === 'app' || approver === 'terminal' ? approver : null,
|
|
68
71
|
};
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
// Whether a real path lies in (or is) one of the folders this computer approved
|
|
75
|
+
function insideApproved(real, approvedRoots) {
|
|
76
|
+
const norm = (p) => process.platform === 'win32' ? p.toLowerCase() : p;
|
|
77
|
+
return (Array.isArray(approvedRoots) ? approvedRoots : []).some((r) => {
|
|
78
|
+
const rel = path.relative(norm(path.resolve(String(r))), norm(real));
|
|
79
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A request the computer already allows: a folder inside one it approved, or the
|
|
84
|
+
// shell while its approval runs
|
|
85
|
+
function coveredLocally(request, approvedRoots, shellApproved) {
|
|
86
|
+
if (request.kind === 'shell') return !!shellApproved;
|
|
87
|
+
try { return insideApproved(fs.realpathSync.native(path.resolve(String(request.path || ''))), approvedRoots); } catch (_) { return false; }
|
|
88
|
+
}
|
|
89
|
+
|
|
71
90
|
function authorizeRequestedRoots(requestedRoots, labels, approvedRoots) {
|
|
72
91
|
const requested = Array.isArray(requestedRoots) ? requestedRoots.map(String) : [];
|
|
73
92
|
const names = Array.isArray(labels) ? labels.map(String) : [];
|
|
74
|
-
const approved = (Array.isArray(approvedRoots) ? approvedRoots : []).map((r) => path.resolve(String(r)));
|
|
75
|
-
const norm = (p) => process.platform === 'win32' ? p.toLowerCase() : p;
|
|
76
|
-
const inside = (candidate, allowed) => {
|
|
77
|
-
const rel = path.relative(norm(allowed), norm(candidate));
|
|
78
|
-
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
79
|
-
};
|
|
80
93
|
const roots = [];
|
|
81
94
|
const keptLabels = [];
|
|
82
95
|
const rejected = [];
|
|
@@ -87,7 +100,7 @@ function authorizeRequestedRoots(requestedRoots, labels, approvedRoots) {
|
|
|
87
100
|
rejected.push({ path: abs, ok: false, error: 'folder does not exist on this computer' });
|
|
88
101
|
return;
|
|
89
102
|
}
|
|
90
|
-
if (
|
|
103
|
+
if (insideApproved(real, approvedRoots)) {
|
|
91
104
|
roots.push(real);
|
|
92
105
|
keptLabels.push(names[i] || '');
|
|
93
106
|
} else rejected.push({ path: abs, ok: false, error: 'not approved on this computer' });
|
|
@@ -95,7 +108,7 @@ function authorizeRequestedRoots(requestedRoots, labels, approvedRoots) {
|
|
|
95
108
|
return { roots, labels: keptLabels, rejected };
|
|
96
109
|
}
|
|
97
110
|
|
|
98
|
-
function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell, slowSpawnMs = SLOW_SPAWN_MS } = {}) {
|
|
111
|
+
function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShell, approver = null, slowSpawnMs = SLOW_SPAWN_MS } = {}) {
|
|
99
112
|
if (!serverUrl || !token) throw new Error('serverUrl and token are required');
|
|
100
113
|
const emitter = new EventEmitter();
|
|
101
114
|
const say = typeof log === 'function' ? log : () => {};
|
|
@@ -113,6 +126,26 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
113
126
|
try { state.ws.send(JSON.stringify(msg)); return true; } catch (_) { return false; }
|
|
114
127
|
}
|
|
115
128
|
|
|
129
|
+
// What this computer already allows needs nobody at it: the daemon says yes itself.
|
|
130
|
+
// Everything else waits for a person there (the desktop app's dialog, or "allow").
|
|
131
|
+
async function confirmIfCovered(msg) {
|
|
132
|
+
const what = msg.kind === 'shell' ? 'shell access' : `folder ${msg.path}`;
|
|
133
|
+
if (!localApprovals || !coveredLocally(msg, localApprovals(), localShellApproval && localShellApproval())) {
|
|
134
|
+
emitter.emit('local_approval_request', msg);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const url = `${serverUrl.replace(/\/+$/, '')}/api/companion/local-approvals/${encodeURIComponent(String(msg.code))}/complete`;
|
|
139
|
+
const res = await fetch(url, { method: 'POST', headers: { authorization: `Bearer ${token}` } });
|
|
140
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
141
|
+
say(`${what} confirmed: already approved on this computer`);
|
|
142
|
+
emitter.emit('local_approval_confirmed', msg);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
say(`${what} is approved on this computer, but confirming it failed (${e.message})`);
|
|
145
|
+
emitter.emit('local_approval_request', msg);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
116
149
|
function applyConfig(msg) {
|
|
117
150
|
const requested = Array.isArray(msg.roots) ? msg.roots.map(String) : [];
|
|
118
151
|
const labels = Array.isArray(msg.labels) ? msg.labels.map(String) : [];
|
|
@@ -264,7 +297,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
264
297
|
case 'cancel': { const c = running.get(String(msg.id)); if (c) c.abort(); break; }
|
|
265
298
|
case 'approval': emitter.emit('approval', msg); break;
|
|
266
299
|
case 'approval_resolved': emitter.emit('approval_resolved', msg); break;
|
|
267
|
-
case 'local_approval_request':
|
|
300
|
+
case 'local_approval_request': confirmIfCovered(msg); break;
|
|
268
301
|
default: break;
|
|
269
302
|
}
|
|
270
303
|
}
|
|
@@ -304,7 +337,7 @@ function createDaemon({ serverUrl, token, shell, log, approvedRoots, approvedShe
|
|
|
304
337
|
state.connectedAt = Date.now();
|
|
305
338
|
state.lastPingAt = null;
|
|
306
339
|
state.reconnectMs = RECONNECT_MIN_MS;
|
|
307
|
-
send(systemInfo(shell, !!localApprovals && !!localShellApproval));
|
|
340
|
+
send(systemInfo(shell, !!localApprovals && !!localShellApproval, approver));
|
|
308
341
|
flushExits();
|
|
309
342
|
say(`connected to ${serverUrl}`);
|
|
310
343
|
emitter.emit('connected');
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The setup's words, in the language the computer is set to (LC_ALL, LC_MESSAGES,
|
|
3
|
+
// LANG - an SSH session usually brings the caller's), English otherwise.
|
|
4
|
+
|
|
5
|
+
const STRINGS = {
|
|
6
|
+
en: {
|
|
7
|
+
setup: 'setup',
|
|
8
|
+
steps: ['What', 'Folders', 'Commands', 'Save'],
|
|
9
|
+
'start.title': 'What may the AI use on this computer?',
|
|
10
|
+
'start.folders': 'Choose folders', 'start.foldersNote': 'recommended',
|
|
11
|
+
'start.nothing': 'Nothing yet', 'start.nothingNote': 'a chat asks when it needs a folder',
|
|
12
|
+
'start.everything': 'Everything', 'start.everythingNote': 'not recommended',
|
|
13
|
+
'start.help': 'The AI reads, changes and deletes files only where you allow it here.',
|
|
14
|
+
'start.now': 'Approved now:',
|
|
15
|
+
'start.later': 'Change it any time:',
|
|
16
|
+
'places.title': 'Which folders may the AI use?',
|
|
17
|
+
'places.loading': 'Looking for folders…',
|
|
18
|
+
'places.none': 'No suggestions on this computer.',
|
|
19
|
+
'places.browse': 'Browse all folders…', 'places.type': 'Type a path…', 'places.continue': 'Continue',
|
|
20
|
+
selected: '{n} selected',
|
|
21
|
+
'note.git': '{n} Git projects', 'note.git1': '1 Git project', 'note.project': 'Git project',
|
|
22
|
+
'note.web': 'websites', 'note.srv': 'server data', 'note.opt': 'installed programs',
|
|
23
|
+
'note.etc': 'system settings', 'note.log': 'logs', 'note.home': 'your whole home folder',
|
|
24
|
+
'note.readOnly': 'read-only for you', 'note.added': 'added by you', 'note.earlier': 'approved earlier',
|
|
25
|
+
'note.inside': 'inside {path}',
|
|
26
|
+
'help.home': 'Everything in {home}, also hidden settings and keys such as ~/.ssh.',
|
|
27
|
+
'help.etc': 'Settings of the whole system. A wrong change there can stop the server.',
|
|
28
|
+
'help.log': 'Log files of the system and its services. Good for finding errors.',
|
|
29
|
+
'help.web': 'Files of the websites this server delivers.',
|
|
30
|
+
'help.srv': 'Data of the services on this server.',
|
|
31
|
+
'help.opt': 'Programs installed outside the package manager.',
|
|
32
|
+
'help.code': 'Your code projects.',
|
|
33
|
+
'help.readOnly': 'You can read here but not write. The AI can do the same, no more.',
|
|
34
|
+
'help.browse': 'Pick any folder on this computer, like in a file manager.',
|
|
35
|
+
'help.type': 'Enter a full path. Tab completes it.',
|
|
36
|
+
'help.continue': 'Next: commands.',
|
|
37
|
+
'browse.title': 'Browse', 'browse.thisPc': 'This PC',
|
|
38
|
+
'browse.loading': 'loading…', 'browse.empty': 'no folders', 'browse.noAccess': 'no access', 'browse.link': 'link',
|
|
39
|
+
'browse.blocked': '{path} is inside {parent}, which is already selected.',
|
|
40
|
+
'browse.hidden': 'Hidden folders are shown.', 'browse.hiddenOff': 'Hidden folders are hidden.',
|
|
41
|
+
'type.title': 'Type a folder path', 'type.label': 'Path',
|
|
42
|
+
'type.notFound': 'There is no folder {path}.',
|
|
43
|
+
'everything.title': 'Share everything?',
|
|
44
|
+
'everything.body': 'The AI can read, change and delete every file that {user} can reach on this computer.',
|
|
45
|
+
'everything.root': 'You are root: that is every file on the system, the system itself included.',
|
|
46
|
+
'everything.free': 'It lets the AI work freely on this server. Not recommended.',
|
|
47
|
+
'everything.no': 'No, go back', 'everything.yes': 'Yes, share everything',
|
|
48
|
+
'everything.summary': 'Everything on this computer',
|
|
49
|
+
'shell.title': 'May the AI run commands here?',
|
|
50
|
+
'shell.body': 'Commands run as {user}, with full internet access. They are not limited to the shared folders.',
|
|
51
|
+
'shell.keep': 'Keep as it is', 'shell.keepNote': 'until {date}',
|
|
52
|
+
'shell.no': 'Not now', 'shell.noNote': 'a chat asks first when it needs them',
|
|
53
|
+
'shell.off': 'Turn off', 'shell.offNote': 'a chat asks first when it needs them',
|
|
54
|
+
'shell.d7': 'Yes, for 7 days', 'shell.d30': 'Yes, for 30 days', 'shell.d365': 'Yes, for a year',
|
|
55
|
+
'review.title': 'Check and save',
|
|
56
|
+
'review.folders': 'Folders', 'review.commands': 'Commands',
|
|
57
|
+
'review.noFolders': 'none — a chat asks when it needs one',
|
|
58
|
+
'review.removed': 'approval removed',
|
|
59
|
+
'review.cmdNo': 'not approved — a chat asks first',
|
|
60
|
+
'review.cmdUntil': 'approved until {date}',
|
|
61
|
+
'review.save': 'Save', 'review.back': 'Back', 'review.quit': 'Quit without saving',
|
|
62
|
+
'review.where': 'Saved only on this computer, in {file}',
|
|
63
|
+
keyNames: { space: 'space', enter: 'enter', esc: 'esc', tab: 'tab', backspace: 'backspace' },
|
|
64
|
+
keys: { move: 'move', select: 'select', toggle: 'select', back: 'back', quit: 'quit', open: 'open', close: 'close', openClose: 'open/close', up: 'folder up', hidden: 'hidden', done: 'done', tab: 'complete', add: 'add' },
|
|
65
|
+
'done.saved': 'Saved on this computer.',
|
|
66
|
+
'done.unchanged': 'Nothing changed.',
|
|
67
|
+
'done.web': 'When a chat on tensor.chat shares one of these folders, this computer confirms it by itself.',
|
|
68
|
+
'done.run': 'Start the companion with:',
|
|
69
|
+
'done.noTty': 'The setup needs an interactive terminal. Without one, use approve-folder and approve-shell.',
|
|
70
|
+
},
|
|
71
|
+
de: {
|
|
72
|
+
setup: 'Einrichtung',
|
|
73
|
+
steps: ['Was', 'Ordner', 'Befehle', 'Speichern'],
|
|
74
|
+
'start.title': 'Was darf die KI auf diesem Computer nutzen?',
|
|
75
|
+
'start.folders': 'Ordner auswählen', 'start.foldersNote': 'empfohlen',
|
|
76
|
+
'start.nothing': 'Noch nichts', 'start.nothingNote': 'ein Chat fragt, wenn er einen Ordner braucht',
|
|
77
|
+
'start.everything': 'Alles', 'start.everythingNote': 'nicht empfohlen',
|
|
78
|
+
'start.help': 'Die KI liest, ändert und löscht Dateien nur dort, wo du es hier erlaubst.',
|
|
79
|
+
'start.now': 'Jetzt freigegeben:',
|
|
80
|
+
'start.later': 'Jederzeit änderbar:',
|
|
81
|
+
'places.title': 'Welche Ordner darf die KI nutzen?',
|
|
82
|
+
'places.loading': 'Suche Ordner…',
|
|
83
|
+
'places.none': 'Keine Vorschläge auf diesem Computer.',
|
|
84
|
+
'places.browse': 'Alle Ordner durchsuchen…', 'places.type': 'Pfad eingeben…', 'places.continue': 'Weiter',
|
|
85
|
+
selected: '{n} ausgewählt',
|
|
86
|
+
'note.git': '{n} Git-Projekte', 'note.git1': '1 Git-Projekt', 'note.project': 'Git-Projekt',
|
|
87
|
+
'note.web': 'Webseiten', 'note.srv': 'Serverdaten', 'note.opt': 'installierte Programme',
|
|
88
|
+
'note.etc': 'Systemeinstellungen', 'note.log': 'Protokolle', 'note.home': 'dein ganzer Home-Ordner',
|
|
89
|
+
'note.readOnly': 'für dich nur lesbar', 'note.added': 'von dir hinzugefügt', 'note.earlier': 'schon freigegeben',
|
|
90
|
+
'note.inside': 'liegt in {path}',
|
|
91
|
+
'help.home': 'Alles in {home}, auch versteckte Einstellungen und Schlüssel wie ~/.ssh.',
|
|
92
|
+
'help.etc': 'Einstellungen des ganzen Systems. Eine falsche Änderung dort kann den Server lahmlegen.',
|
|
93
|
+
'help.log': 'Protokolle des Systems und seiner Dienste. Gut für die Fehlersuche.',
|
|
94
|
+
'help.web': 'Dateien der Webseiten, die dieser Server ausliefert.',
|
|
95
|
+
'help.srv': 'Daten der Dienste auf diesem Server.',
|
|
96
|
+
'help.opt': 'Programme, die neben der Paketverwaltung installiert sind.',
|
|
97
|
+
'help.code': 'Deine Code-Projekte.',
|
|
98
|
+
'help.readOnly': 'Du kannst hier lesen, aber nicht schreiben. Die KI kann dasselbe, nicht mehr.',
|
|
99
|
+
'help.browse': 'Wähle einen beliebigen Ordner auf diesem Computer, wie in einem Dateimanager.',
|
|
100
|
+
'help.type': 'Gib einen vollständigen Pfad ein. Tab ergänzt ihn.',
|
|
101
|
+
'help.continue': 'Weiter zu den Befehlen.',
|
|
102
|
+
'browse.title': 'Durchsuchen', 'browse.thisPc': 'Dieser PC',
|
|
103
|
+
'browse.loading': 'lädt…', 'browse.empty': 'keine Ordner', 'browse.noAccess': 'kein Zugriff', 'browse.link': 'Verknüpfung',
|
|
104
|
+
'browse.blocked': '{path} liegt in {parent}, und der ist schon ausgewählt.',
|
|
105
|
+
'browse.hidden': 'Versteckte Ordner werden angezeigt.', 'browse.hiddenOff': 'Versteckte Ordner sind ausgeblendet.',
|
|
106
|
+
'type.title': 'Ordnerpfad eingeben', 'type.label': 'Pfad',
|
|
107
|
+
'type.notFound': 'Den Ordner {path} gibt es nicht.',
|
|
108
|
+
'everything.title': 'Alles freigeben?',
|
|
109
|
+
'everything.body': 'Die KI kann jede Datei lesen, ändern und löschen, an die {user} auf diesem Computer herankommt.',
|
|
110
|
+
'everything.root': 'Du bist root: Das ist jede Datei des Systems, das System selbst eingeschlossen.',
|
|
111
|
+
'everything.free': 'So kann die KI frei auf diesem Server arbeiten. Nicht empfohlen.',
|
|
112
|
+
'everything.no': 'Nein, zurück', 'everything.yes': 'Ja, alles freigeben',
|
|
113
|
+
'everything.summary': 'Alles auf diesem Computer',
|
|
114
|
+
'shell.title': 'Darf die KI hier Befehle ausführen?',
|
|
115
|
+
'shell.body': 'Befehle laufen als {user}, mit vollem Internetzugang. Sie sind nicht auf die freigegebenen Ordner beschränkt.',
|
|
116
|
+
'shell.keep': 'So lassen', 'shell.keepNote': 'bis {date}',
|
|
117
|
+
'shell.no': 'Jetzt nicht', 'shell.noNote': 'ein Chat fragt vorher, wenn er sie braucht',
|
|
118
|
+
'shell.off': 'Ausschalten', 'shell.offNote': 'ein Chat fragt vorher, wenn er sie braucht',
|
|
119
|
+
'shell.d7': 'Ja, für 7 Tage', 'shell.d30': 'Ja, für 30 Tage', 'shell.d365': 'Ja, für ein Jahr',
|
|
120
|
+
'review.title': 'Prüfen und speichern',
|
|
121
|
+
'review.folders': 'Ordner', 'review.commands': 'Befehle',
|
|
122
|
+
'review.noFolders': 'keine – ein Chat fragt, wenn er einen braucht',
|
|
123
|
+
'review.removed': 'Freigabe entfernt',
|
|
124
|
+
'review.cmdNo': 'nicht freigegeben – ein Chat fragt vorher',
|
|
125
|
+
'review.cmdUntil': 'freigegeben bis {date}',
|
|
126
|
+
'review.save': 'Speichern', 'review.back': 'Zurück', 'review.quit': 'Beenden ohne zu speichern',
|
|
127
|
+
'review.where': 'Wird nur auf diesem Computer gespeichert, in {file}',
|
|
128
|
+
keyNames: { space: 'Leertaste', enter: 'Enter', esc: 'Esc', tab: 'Tab', backspace: 'Rücktaste' },
|
|
129
|
+
keys: { move: 'wählen', select: 'bestätigen', toggle: 'auswählen', back: 'zurück', quit: 'beenden', open: 'öffnen', close: 'schließen', openClose: 'öffnen/schließen', up: 'Ordner hoch', hidden: 'versteckte', done: 'fertig', tab: 'ergänzen', add: 'hinzufügen' },
|
|
130
|
+
'done.saved': 'Auf diesem Computer gespeichert.',
|
|
131
|
+
'done.unchanged': 'Nichts geändert.',
|
|
132
|
+
'done.web': 'Gibt ein Chat auf tensor.chat einen dieser Ordner frei, bestätigt dieser Computer das selbst.',
|
|
133
|
+
'done.run': 'Den Companion starten mit:',
|
|
134
|
+
'done.noTty': 'Die Einrichtung braucht ein interaktives Terminal. Ohne eines gehen approve-folder und approve-shell.',
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
function pickLanguage(env = process.env, override) {
|
|
139
|
+
let raw = override || env.TENSOR_COMPANION_LANG || env.LC_ALL || env.LC_MESSAGES || env.LANG;
|
|
140
|
+
if (!raw) { try { raw = Intl.DateTimeFormat().resolvedOptions().locale; } catch (_) { raw = ''; } }
|
|
141
|
+
const code = String(raw || '').slice(0, 2).toLowerCase();
|
|
142
|
+
return Object.prototype.hasOwnProperty.call(STRINGS, code) ? code : 'en';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function translator(lang) {
|
|
146
|
+
const table = STRINGS[lang] || STRINGS.en;
|
|
147
|
+
const t = (key, vars) => {
|
|
148
|
+
const s = Object.prototype.hasOwnProperty.call(table, key) ? table[key] : STRINGS.en[key];
|
|
149
|
+
if (typeof s !== 'string') return s;
|
|
150
|
+
return s.replace(/\{(\w+)\}/g, (m, k) => (vars && vars[k] != null ? String(vars[k]) : m));
|
|
151
|
+
};
|
|
152
|
+
t.lang = STRINGS[lang] ? lang : 'en';
|
|
153
|
+
t.date = (iso) => { try { return new Intl.DateTimeFormat(t.lang, { dateStyle: 'medium' }).format(new Date(iso)); } catch (_) { return String(iso).slice(0, 10); } };
|
|
154
|
+
return t;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { pickLanguage, translator, STRINGS };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// How the setup's lines are laid out: wrapped text, columns, the key hints and framed
|
|
3
|
+
// blocks. Widths count what the terminal shows, not the escape codes.
|
|
4
|
+
|
|
5
|
+
const { width, pad } = require('./term');
|
|
6
|
+
|
|
7
|
+
function wrap(text, cols) {
|
|
8
|
+
const lines = [];
|
|
9
|
+
let line = '';
|
|
10
|
+
for (const word of String(text).split(/\s+/)) {
|
|
11
|
+
if (line && width(line) + 1 + width(word) > cols) { lines.push(line); line = word; } else line = line ? `${line} ${word}` : word;
|
|
12
|
+
}
|
|
13
|
+
if (line) lines.push(line);
|
|
14
|
+
return lines;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A label in its column, always followed by a gap
|
|
18
|
+
function cell(label, col) {
|
|
19
|
+
return pad(label, col - 2) + ' ';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function spread(left, right, cols) {
|
|
23
|
+
return left + ' '.repeat(Math.max(1, cols - width(left) - width(right))) + right;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The key hints, on as many lines as the width needs
|
|
27
|
+
function keyLines(S, keys, w) {
|
|
28
|
+
const lines = [''];
|
|
29
|
+
for (const [k, label] of keys) {
|
|
30
|
+
const part = `${S.bold(k)} ${S.dim(label)}`;
|
|
31
|
+
const last = lines[lines.length - 1];
|
|
32
|
+
if (last && width(last) + 3 + width(part) > w) lines.push(part); else lines[lines.length - 1] = last ? `${last} ${part}` : part;
|
|
33
|
+
}
|
|
34
|
+
return lines.map((l) => ' ' + l);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A framed block: a warning in yellow, a note in grey
|
|
38
|
+
function panel(S, lines, tone, w) {
|
|
39
|
+
const edge = (s) => (tone ? S[tone](s) : S.dim(s));
|
|
40
|
+
const inner = [];
|
|
41
|
+
for (const l of lines) inner.push(...(width(l) <= w - 4 ? [l] : wrap(l, w - 4)));
|
|
42
|
+
return [
|
|
43
|
+
' ' + edge('╭' + '─'.repeat(w - 2) + '╮'),
|
|
44
|
+
...inner.map((l) => ' ' + edge('│') + ' ' + pad(l, w - 4) + ' ' + edge('│')),
|
|
45
|
+
' ' + edge('╰' + '─'.repeat(w - 2) + '╯'),
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { wrap, cell, spread, keyLines, panel };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Folders worth offering on this computer: where code, documents and server data
|
|
3
|
+
// usually live. Only folders that exist are offered, each once.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const fsp = fs.promises;
|
|
9
|
+
const CODE_DIRS = ['projects', 'Projects', 'src', 'code', 'Code', 'dev', 'Developer', 'git', 'repos', 'workspace', 'work', path.join('source', 'repos')];
|
|
10
|
+
// Where a server keeps its sites, data, programs, settings and logs
|
|
11
|
+
const SYSTEM = {
|
|
12
|
+
linux: [['/var/www', 'web'], ['/srv', 'srv'], ['/opt', 'opt'], ['/etc', 'etc'], ['/var/log', 'log']],
|
|
13
|
+
};
|
|
14
|
+
const GIT_LOOK_LIMIT = 300;
|
|
15
|
+
const PROJECTS_IN_HOME = 8;
|
|
16
|
+
|
|
17
|
+
async function isDir(p) {
|
|
18
|
+
try { return (await fsp.stat(p)).isDirectory(); } catch (_) { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function writable(p) {
|
|
22
|
+
try { await fsp.access(p, fs.constants.W_OK); return true; } catch (_) { return false; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function hasEntries(p) {
|
|
26
|
+
try { return (await fsp.readdir(p)).length > 0; } catch (_) { return false; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function subfolders(dir) {
|
|
30
|
+
try {
|
|
31
|
+
return (await fsp.readdir(dir, { withFileTypes: true }))
|
|
32
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.') && d.name !== 'node_modules')
|
|
33
|
+
.map((d) => path.join(dir, d.name));
|
|
34
|
+
} catch (_) { return []; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Git projects in a folder and one level below; looks at no more than `limit` folders
|
|
38
|
+
async function countGit(dir, limit = GIT_LOOK_LIMIT) {
|
|
39
|
+
if (await isDir(path.join(dir, '.git'))) return 1;
|
|
40
|
+
const first = (await subfolders(dir)).slice(0, limit);
|
|
41
|
+
let n = 0;
|
|
42
|
+
let looked = first.length;
|
|
43
|
+
for (const sub of first) {
|
|
44
|
+
if (await isDir(path.join(sub, '.git'))) { n++; continue; }
|
|
45
|
+
if (looked >= limit) continue;
|
|
46
|
+
const second = (await subfolders(sub)).slice(0, limit - looked);
|
|
47
|
+
looked += second.length;
|
|
48
|
+
for (const deeper of second) if (await isDir(path.join(deeper, '.git'))) n++;
|
|
49
|
+
}
|
|
50
|
+
return n;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ~/.config/user-dirs.dirs names the desktop, documents and downloads folders, in the
|
|
54
|
+
// computer's language (XDG_DOCUMENTS_DIR="$HOME/Dokumente")
|
|
55
|
+
async function xdgUserDirs(home) {
|
|
56
|
+
const found = {};
|
|
57
|
+
try {
|
|
58
|
+
const text = await fsp.readFile(path.join(home, '.config', 'user-dirs.dirs'), 'utf8');
|
|
59
|
+
for (const m of text.matchAll(/^XDG_(DESKTOP|DOCUMENTS|DOWNLOAD)_DIR="([^"]+)"/gm)) {
|
|
60
|
+
found[m[1]] = m[2].startsWith('$HOME/') ? path.join(home, ...m[2].slice(6).split('/')) : m[2];
|
|
61
|
+
}
|
|
62
|
+
} catch (_) {}
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function candidates({ platform, home, env, system }) {
|
|
67
|
+
const list = [];
|
|
68
|
+
const add = (p, kind) => { if (p) list.push({ path: p, kind }); };
|
|
69
|
+
for (const d of CODE_DIRS) add(path.join(home, d), 'code');
|
|
70
|
+
if (platform === 'linux') {
|
|
71
|
+
const xdg = await xdgUserDirs(home);
|
|
72
|
+
add(xdg.DOCUMENTS || path.join(home, 'Documents'), 'docs');
|
|
73
|
+
add(xdg.DESKTOP || path.join(home, 'Desktop'), 'docs');
|
|
74
|
+
add(xdg.DOWNLOAD || path.join(home, 'Downloads'), 'docs');
|
|
75
|
+
} else {
|
|
76
|
+
if (platform === 'win32' && env.OneDrive) add(path.join(env.OneDrive, 'Documents'), 'docs');
|
|
77
|
+
for (const d of ['Documents', 'Desktop', 'Downloads']) add(path.join(home, d), 'docs');
|
|
78
|
+
}
|
|
79
|
+
add(home, 'home');
|
|
80
|
+
if (platform === 'win32') add(path.join(env.SystemDrive || 'C:', '\\inetpub', 'wwwroot'), 'web');
|
|
81
|
+
for (const [p, kind] of system) add(p, kind);
|
|
82
|
+
return list;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// [{ path, kind, git, readOnly }] - kind: code, project, docs, web, srv, opt, home, etc, log
|
|
86
|
+
async function suggestPlaces({ platform = process.platform, home, env = process.env, system = SYSTEM[platform] || [] } = {}) {
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
const key = (p) => (platform === 'win32' || platform === 'darwin' ? p.toLowerCase() : p);
|
|
89
|
+
const out = [];
|
|
90
|
+
const keep = async (p, kind) => {
|
|
91
|
+
let real;
|
|
92
|
+
try { real = await fsp.realpath(p); } catch (_) { return; }
|
|
93
|
+
if (seen.has(key(real)) || !(await isDir(real))) return;
|
|
94
|
+
// A server folder that is there but empty helps nobody
|
|
95
|
+
if ((kind === 'srv' || kind === 'opt') && !(await hasEntries(real))) return;
|
|
96
|
+
seen.add(key(real));
|
|
97
|
+
const item = { path: real, kind, git: 0, readOnly: !(await writable(real)) };
|
|
98
|
+
if (kind === 'code') item.git = await countGit(real);
|
|
99
|
+
out.push(item);
|
|
100
|
+
};
|
|
101
|
+
const list = await candidates({ platform, home, env, system });
|
|
102
|
+
for (const c of list.filter((x) => x.kind === 'code')) await keep(c.path, c.kind);
|
|
103
|
+
// Git projects right in the home folder, as on many servers (~/shop, ~/api)
|
|
104
|
+
let projects = 0;
|
|
105
|
+
for (const sub of await subfolders(home)) {
|
|
106
|
+
if (projects >= PROJECTS_IN_HOME) break;
|
|
107
|
+
if (await isDir(path.join(sub, '.git'))) { await keep(sub, 'project'); projects++; }
|
|
108
|
+
}
|
|
109
|
+
for (const c of list.filter((x) => x.kind !== 'code')) await keep(c.path, c.kind);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { suggestPlaces, countGit, xdgUserDirs, SYSTEM };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The parts of a terminal the setup screens need: keys in, whole frames out. The
|
|
3
|
+
// screens draw on the alternate screen, so the shell's history stays as it was.
|
|
4
|
+
|
|
5
|
+
const readline = require('readline');
|
|
6
|
+
|
|
7
|
+
const CSI = '\x1b[';
|
|
8
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
9
|
+
|
|
10
|
+
// Colour only where the terminal wants it (https://no-color.org); weight and
|
|
11
|
+
// dimming stay, they carry the layout.
|
|
12
|
+
function styles(color) {
|
|
13
|
+
const sgr = (open, close, isColor) => (s) => ((isColor && !color) ? String(s) : `${CSI}${open}m${s}${CSI}${close}m`);
|
|
14
|
+
return {
|
|
15
|
+
bold: sgr(1, 22), dim: sgr(2, 22), inverse: sgr(7, 27), underline: sgr(4, 24),
|
|
16
|
+
cyan: sgr(36, 39, true), green: sgr(32, 39, true), yellow: sgr(33, 39, true), red: sgr(31, 39, true), gray: sgr(90, 39, true),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function charWidth(cp) {
|
|
21
|
+
// East Asian wide and fullwidth forms take two cells
|
|
22
|
+
if ((cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3)
|
|
23
|
+
|| (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60)
|
|
24
|
+
|| (cp >= 0xffe0 && cp <= 0xffe6)) return 2;
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function width(s) {
|
|
29
|
+
let n = 0;
|
|
30
|
+
for (const ch of String(s).replace(ANSI_RE, '')) n += charWidth(ch.codePointAt(0));
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Cut a line to the screen width, keeping its escape codes; a cut line ends in "…"
|
|
35
|
+
function fit(line, cols) {
|
|
36
|
+
if (width(line) <= cols) return line;
|
|
37
|
+
let out = '';
|
|
38
|
+
let n = 0;
|
|
39
|
+
let full = false;
|
|
40
|
+
for (const part of String(line).split(/(\x1b\[[0-9;?]*[A-Za-z])/)) {
|
|
41
|
+
if (part.startsWith('\x1b[')) { if (!full) out += part; continue; }
|
|
42
|
+
for (const ch of part) {
|
|
43
|
+
const w = charWidth(ch.codePointAt(0));
|
|
44
|
+
if (full || n + w > cols - 1) { full = true; break; }
|
|
45
|
+
out += ch;
|
|
46
|
+
n += w;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out + '…' + `${CSI}0m`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function pad(s, cols) {
|
|
53
|
+
return s + ' '.repeat(Math.max(0, cols - width(s)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Opens the screen: raw keys from `input`, frames to `output`. stop() puts the
|
|
57
|
+
// terminal back the way it was, also after an error.
|
|
58
|
+
function openScreen({ input, output }) {
|
|
59
|
+
const keyHandlers = [];
|
|
60
|
+
const onKey = (str, key) => { for (const h of keyHandlers) h(str, key || {}); };
|
|
61
|
+
const onResize = () => { for (const h of resizeHandlers) h(); };
|
|
62
|
+
const resizeHandlers = [];
|
|
63
|
+
let open = false;
|
|
64
|
+
readline.emitKeypressEvents(input);
|
|
65
|
+
return {
|
|
66
|
+
start() {
|
|
67
|
+
if (input.isTTY && typeof input.setRawMode === 'function') input.setRawMode(true);
|
|
68
|
+
input.on('keypress', onKey);
|
|
69
|
+
output.on('resize', onResize);
|
|
70
|
+
input.resume();
|
|
71
|
+
output.write(`${CSI}?1049h${CSI}?25l`);
|
|
72
|
+
open = true;
|
|
73
|
+
},
|
|
74
|
+
size() { return { cols: Math.max(40, output.columns || 80), rows: Math.max(12, output.rows || 24) }; },
|
|
75
|
+
draw(lines) {
|
|
76
|
+
const { cols, rows } = this.size();
|
|
77
|
+
const shown = lines.slice(0, rows).map((l) => fit(l, cols) + `${CSI}K`);
|
|
78
|
+
output.write(`${CSI}H` + shown.join('\r\n') + `${CSI}0m${CSI}J`);
|
|
79
|
+
},
|
|
80
|
+
onKey(h) { keyHandlers.push(h); },
|
|
81
|
+
onResize(h) { resizeHandlers.push(h); },
|
|
82
|
+
stop() {
|
|
83
|
+
if (!open) return;
|
|
84
|
+
open = false;
|
|
85
|
+
input.removeListener('keypress', onKey);
|
|
86
|
+
output.removeListener('resize', onResize);
|
|
87
|
+
if (input.isTTY && typeof input.setRawMode === 'function') input.setRawMode(false);
|
|
88
|
+
input.pause();
|
|
89
|
+
output.write(`${CSI}0m${CSI}?25h${CSI}?1049l`);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { styles, width, fit, pad, openScreen };
|