@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 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. Then keep the daemon
17
- running:
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 can request and display
30
- folders, but it cannot grant itself access. Read, Write, Edit, Glob and Grep only
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tensor.chat/companion",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Lets your tensor.chat AI use this machine: files, shell and internet through your own connection.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://tensor.chat",
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
- function systemInfo(shell, localAuthority = false) {
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 (approved.some((allow) => inside(real, allow))) {
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': emitter.emit('local_approval_request', msg); break;
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 };