tassello 0.2.0 → 0.4.0

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +175 -8
  3. package/dist/server/behavior_tree_files.d.ts +0 -1
  4. package/dist/server/behavior_tree_files.js +2 -25
  5. package/dist/server/diagnostics.d.ts +16 -0
  6. package/dist/server/diagnostics.js +200 -0
  7. package/dist/server/diagnostics_state.d.ts +23 -0
  8. package/dist/server/diagnostics_state.js +146 -0
  9. package/dist/server/disks.d.ts +14 -0
  10. package/dist/server/disks.js +152 -0
  11. package/dist/server/file_manager.d.ts +25 -0
  12. package/dist/server/file_manager.js +717 -0
  13. package/dist/server/http.js +248 -5
  14. package/dist/server/netpbm.d.ts +3 -0
  15. package/dist/server/netpbm.js +69 -0
  16. package/dist/server/networking.d.ts +41 -0
  17. package/dist/server/networking.js +508 -0
  18. package/dist/server/networking_format.d.ts +38 -0
  19. package/dist/server/networking_format.js +651 -0
  20. package/dist/server/pty.d.ts +26 -0
  21. package/dist/server/pty.js +150 -0
  22. package/dist/server/server.js +13 -2
  23. package/dist/server/session.d.ts +10 -1
  24. package/dist/server/session.js +88 -1
  25. package/dist/server/terminal_output.d.ts +25 -0
  26. package/dist/server/terminal_output.js +108 -0
  27. package/dist/server/terminals.d.ts +32 -0
  28. package/dist/server/terminals.js +366 -0
  29. package/dist/server/util.d.ts +4 -0
  30. package/dist/server/util.js +45 -1
  31. package/dist/shared/file_manager.d.ts +130 -0
  32. package/dist/shared/file_manager.js +7 -0
  33. package/dist/shared/networking.d.ts +178 -0
  34. package/dist/shared/networking.js +248 -0
  35. package/dist/shared/protocol.d.ts +103 -2
  36. package/dist/shared/protocol.js +8 -0
  37. package/dist/types/client/api.d.ts +22 -0
  38. package/dist/types/client/connection.d.ts +30 -1
  39. package/dist/types/client/icons.d.ts +33 -0
  40. package/dist/types/client/sdk/index.d.ts +5 -3
  41. package/dist/types/shared/file_manager.d.ts +130 -0
  42. package/dist/types/shared/networking.d.ts +178 -0
  43. package/dist/types/shared/protocol.d.ts +103 -2
  44. package/dist/www/assets/Joystick.js +1 -1
  45. package/dist/www/assets/RobotModelView.js +2 -2
  46. package/dist/www/assets/TerminalView.css +1 -0
  47. package/dist/www/assets/TerminalView.js +17 -0
  48. package/dist/www/assets/dialogs.js +1 -0
  49. package/dist/www/assets/icons.js +1 -1
  50. package/dist/www/assets/index.css +1 -1
  51. package/dist/www/assets/index.js +59 -19
  52. package/dist/www/assets/keys.js +3 -0
  53. package/dist/www/assets/shared/tassello-sdk.js +1 -1
  54. package/dist/www/index.html +2 -0
  55. package/docs/plugins.md +22 -0
  56. package/package.json +6 -2
@@ -0,0 +1,366 @@
1
+ // The terminal widgets' terminals: the user's login shell, or a command it
2
+ // runs, on a pseudo-terminal (pty.ts), as the server's user.
3
+ //
4
+ // A terminal outlives its pages: a page that reloads or reconnects opens it
5
+ // again by its name and gets the output it missed. That output is also
6
+ // written to a terminal of the server's own (xterm.js, headless), whose
7
+ // screen and scrollback a page gets instead when it shows none of it yet, or
8
+ // lacks more than a window of it (TERMINAL_WINDOW). The server's terminal
9
+ // paces the program, which waits while it is behind; the pages do not: a slow
10
+ // page, or a hidden one, gets the screen later, never holding the program up.
11
+ //
12
+ // A terminal that no page shows is hung up after LINGER_MS, as is the one
13
+ // unseen the longest when MAX_TERMINALS are open and another is opened.
14
+ import { statSync } from 'node:fs';
15
+ import os from 'node:os';
16
+ import serializeModule from '@xterm/addon-serialize';
17
+ import headlessModule from '@xterm/headless';
18
+ import { TERMINAL_SCROLLBACK, TERMINAL_WINDOW, } from '../shared/protocol.js';
19
+ import { resolvePath } from './file_manager.js';
20
+ import { Pty } from './pty.js';
21
+ import { Delivery, OutputLog } from './terminal_output.js';
22
+ import { errorMessage } from './util.js';
23
+ // CommonJS modules, without named exports for Node
24
+ const { SerializeAddon } = serializeModule;
25
+ const { Terminal: Screen } = headlessModule;
26
+ const LINGER_MS = 60 * 60_000;
27
+ const MAX_TERMINALS = 16;
28
+ // Output kept for the pages that missed some
29
+ const LOG_LIMIT = 1024 * 1024;
30
+ // The program waits while the server's terminal has more than this to write,
31
+ // until it has less than RESUME_BELOW
32
+ const PAUSE_ABOVE = 128 * 1024;
33
+ const RESUME_BELOW = 32 * 1024;
34
+ // Output goes out at most this often, gathered
35
+ const FLUSH_MS = 5;
36
+ const MAX_COLS = 1000;
37
+ const MAX_ROWS = 500;
38
+ function isFile(file) {
39
+ try {
40
+ return statSync(file).isFile();
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ // The user's, unless it lets no one in (a service's user)
47
+ function loginShell() {
48
+ for (const shell of [process.env.SHELL, os.userInfo().shell]) {
49
+ if (shell && !/\/(nologin|false)$/.test(shell) && isFile(shell)) {
50
+ return shell;
51
+ }
52
+ }
53
+ return '/bin/bash';
54
+ }
55
+ // The folder to start in: the home folder if it cannot be, and why
56
+ function workingFolder(folder) {
57
+ const home = os.homedir();
58
+ try {
59
+ const cwd = resolvePath(folder || '~');
60
+ return statSync(cwd).isDirectory() ? { cwd, problem: null } : { cwd: home, problem: 'not a folder' };
61
+ }
62
+ catch (e) {
63
+ return { cwd: home, problem: e.code === 'ENOENT' ? 'not found' : errorMessage(e) };
64
+ }
65
+ }
66
+ function programEnvironment() {
67
+ const env = {
68
+ ...process.env,
69
+ TERM: 'xterm-256color',
70
+ COLORTERM: 'truecolor',
71
+ TERM_PROGRAM: 'tassello',
72
+ };
73
+ // The server's own, as a systemd service: not the terminal's
74
+ for (const name of ['NOTIFY_SOCKET', 'JOURNAL_STREAM', 'INVOCATION_ID', 'COLUMNS', 'LINES']) {
75
+ delete env[name];
76
+ }
77
+ return env;
78
+ }
79
+ // One run of a terminal's program: the program, its output and its screen
80
+ class Run {
81
+ started;
82
+ pty;
83
+ screen;
84
+ serializer = new SerializeAddon();
85
+ log = new OutputLog(LOG_LIMIT);
86
+ // Of the output: received, and written to the screen
87
+ written = 0;
88
+ parsed = 0;
89
+ paused = false;
90
+ title = '';
91
+ // undefined while it runs
92
+ exitCode = undefined;
93
+ // Why it could not start
94
+ error = null;
95
+ constructor(started, pty, cols, rows) {
96
+ this.started = started;
97
+ this.pty = pty;
98
+ this.screen = new Screen({ cols, rows, scrollback: TERMINAL_SCROLLBACK, allowProposedApi: true });
99
+ // The addon is typed for the page's terminal, which the headless one is
100
+ // made of
101
+ this.screen.loadAddon(this.serializer);
102
+ this.screen.onTitleChange((title) => {
103
+ this.title = title;
104
+ });
105
+ }
106
+ // The screen and its scrollback, as of the output parsed
107
+ snapshot() {
108
+ return this.serializer.serialize({ scrollback: TERMINAL_SCROLLBACK });
109
+ }
110
+ dispose() {
111
+ this.pty.hangUp();
112
+ this.screen.dispose();
113
+ }
114
+ }
115
+ class Terminal {
116
+ name;
117
+ command;
118
+ folder;
119
+ // While no page shows it: since when
120
+ hiddenSince = null;
121
+ // The pages showing it, and what each was sent
122
+ watchers = new Map();
123
+ logger;
124
+ onEnd;
125
+ run;
126
+ cols;
127
+ rows;
128
+ flushTimer = null;
129
+ lingerTimer = null;
130
+ constructor(request, logger, onEnd) {
131
+ this.name = request.terminal;
132
+ this.command = request.command;
133
+ this.folder = request.folder;
134
+ this.cols = request.cols;
135
+ this.rows = request.rows;
136
+ this.logger = logger;
137
+ this.onEnd = onEnd;
138
+ this.start();
139
+ }
140
+ attach(watcher, started, offset, cols, rows) {
141
+ if (this.lingerTimer) {
142
+ clearTimeout(this.lingerTimer);
143
+ this.lingerTimer = null;
144
+ }
145
+ this.hiddenSince = null;
146
+ this.resize(cols, rows);
147
+ const { run } = this;
148
+ const delivery = new Delivery(0);
149
+ this.watchers.set(watcher, delivery);
150
+ watcher.onStatus(run.error ? 'error' : 'streaming', run.error ?? '');
151
+ if (started === run.started && offset !== null && run.log.since(offset) !== null) {
152
+ // The page missed some: what follows
153
+ delivery.offset = offset;
154
+ }
155
+ else if (run.written === 0) {
156
+ // A new program: the page keeps what it shows of another
157
+ watcher.onOutput({ started: run.started, offset: 0, reset: false, data: '', title: null });
158
+ }
159
+ else {
160
+ this.sendSnapshot(watcher, delivery);
161
+ }
162
+ this.deliver(watcher, delivery);
163
+ if (run.exitCode !== undefined) {
164
+ watcher.onExit({ started: run.started, exitCode: run.exitCode });
165
+ }
166
+ }
167
+ detach(watcher) {
168
+ if (!this.watchers.delete(watcher) || this.watchers.size > 0) {
169
+ return;
170
+ }
171
+ this.hiddenSince = Date.now();
172
+ this.lingerTimer = setTimeout(() => {
173
+ this.logger.info(`Terminal ${this.name}: not shown for ${LINGER_MS / 60_000} minutes, hung up`);
174
+ this.end();
175
+ }, LINGER_MS);
176
+ }
177
+ input(data) {
178
+ if (this.run.exitCode === undefined) {
179
+ this.run.pty.write(data);
180
+ }
181
+ }
182
+ resize(cols, rows) {
183
+ cols = Math.min(MAX_COLS, Math.max(2, Math.round(cols)));
184
+ rows = Math.min(MAX_ROWS, Math.max(1, Math.round(rows)));
185
+ if (cols === this.cols && rows === this.rows) {
186
+ return;
187
+ }
188
+ this.cols = cols;
189
+ this.rows = rows;
190
+ this.run.pty.resize(cols, rows);
191
+ this.run.screen.resize(cols, rows);
192
+ }
193
+ ack(watcher, started, offset) {
194
+ const delivery = this.watchers.get(watcher);
195
+ if (delivery && started === this.run.started) {
196
+ delivery.ack(offset);
197
+ this.deliver(watcher, delivery);
198
+ }
199
+ }
200
+ // Its program anew, from a clean screen; with another command or folder
201
+ restart(command = this.command, folder = this.folder) {
202
+ this.command = command;
203
+ this.folder = folder;
204
+ this.run.dispose();
205
+ this.start();
206
+ for (const watcher of this.watchers.keys()) {
207
+ this.watchers.set(watcher, new Delivery(0));
208
+ watcher.onStatus(this.run.error ? 'error' : 'streaming', this.run.error ?? '');
209
+ watcher.onOutput({ started: this.run.started, offset: 0, reset: false, data: '', title: null });
210
+ }
211
+ }
212
+ end() {
213
+ if (this.flushTimer) {
214
+ clearTimeout(this.flushTimer);
215
+ }
216
+ if (this.lingerTimer) {
217
+ clearTimeout(this.lingerTimer);
218
+ }
219
+ this.run.dispose();
220
+ this.onEnd();
221
+ }
222
+ start() {
223
+ const { cwd, problem } = workingFolder(this.folder);
224
+ const shell = loginShell();
225
+ const args = this.command ? ['-l', '-c', this.command] : ['-l'];
226
+ const pty = new Pty({ file: shell, args, cwd, env: programEnvironment(), cols: this.cols, rows: this.rows });
227
+ // Unique to this terminal: what tells the pages it is another program
228
+ const started = Math.max(Date.now(), (this.run?.started ?? 0) + 1);
229
+ const run = new Run(started, pty, this.cols, this.rows);
230
+ this.run = run;
231
+ const what = this.command ? `\`${this.command}\`` : shell;
232
+ this.logger.info(`Terminal ${this.name}: ${what} in ${cwd} (pid ${pty.pid ?? '-'})`);
233
+ if (problem) {
234
+ this.output(run, `\x1b[33mtassello: cannot start in ${this.folder} (${problem}): starting in ${cwd}\x1b[0m\r\n`);
235
+ }
236
+ pty.on('data', (data) => this.output(run, data));
237
+ pty.on('exit', (exitCode) => {
238
+ if (run !== this.run) {
239
+ return;
240
+ }
241
+ run.exitCode = exitCode;
242
+ this.logger.info(`Terminal ${this.name}: ${what} ${exitCode === null ? 'hung up' : `exited with ${exitCode}`}`);
243
+ this.flush();
244
+ for (const watcher of this.watchers.keys()) {
245
+ watcher.onExit({ started: run.started, exitCode });
246
+ }
247
+ });
248
+ pty.on('error', (e) => {
249
+ if (run !== this.run) {
250
+ return;
251
+ }
252
+ run.exitCode = null;
253
+ run.error =
254
+ e.code === 'ENOENT'
255
+ ? 'Cannot open a terminal: python3 is not installed (ROS 2 comes with it)'
256
+ : `Cannot open a terminal: ${errorMessage(e)}`;
257
+ this.logger.error(`Terminal ${this.name}: ${run.error}`);
258
+ for (const watcher of this.watchers.keys()) {
259
+ watcher.onStatus('error', run.error);
260
+ watcher.onExit({ started: run.started, exitCode: null });
261
+ }
262
+ });
263
+ }
264
+ output(run, data) {
265
+ if (run !== this.run) {
266
+ return;
267
+ }
268
+ run.log.append(data);
269
+ run.written += data.length;
270
+ run.screen.write(data, () => {
271
+ run.parsed += data.length;
272
+ if (run.paused && run.written - run.parsed < RESUME_BELOW) {
273
+ run.paused = false;
274
+ run.pty.resume();
275
+ }
276
+ });
277
+ if (!run.paused && run.written - run.parsed > PAUSE_ABOVE) {
278
+ run.paused = true;
279
+ run.pty.pause();
280
+ }
281
+ this.flushTimer ??= setTimeout(() => {
282
+ this.flushTimer = null;
283
+ this.flush();
284
+ }, FLUSH_MS);
285
+ }
286
+ flush() {
287
+ for (const [watcher, delivery] of this.watchers) {
288
+ this.deliver(watcher, delivery);
289
+ }
290
+ }
291
+ deliver(watcher, delivery) {
292
+ const { run } = this;
293
+ const next = delivery.next(run.log, TERMINAL_WINDOW);
294
+ if (next === 'snapshot') {
295
+ this.sendSnapshot(watcher, delivery);
296
+ }
297
+ else if (next) {
298
+ delivery.record(next.offset, next.data.length);
299
+ watcher.onOutput({ started: run.started, offset: next.offset, reset: false, data: next.data, title: null });
300
+ }
301
+ }
302
+ // The screen as of the output it has written: the page gets the rest after
303
+ sendSnapshot(watcher, delivery) {
304
+ const { run } = this;
305
+ const data = run.snapshot();
306
+ delivery.record(run.parsed, data.length);
307
+ watcher.onOutput({ started: run.started, offset: run.parsed, reset: true, data, title: run.title });
308
+ }
309
+ }
310
+ const IDLE_VIEW = { input() { }, resize() { }, ack() { }, restart() { }, close() { } };
311
+ export class Terminals {
312
+ logger;
313
+ terminals = new Map();
314
+ constructor(logger) {
315
+ this.logger = logger;
316
+ }
317
+ open(request, watcher) {
318
+ let terminal = this.terminals.get(request.terminal);
319
+ if (terminal && (terminal.command !== request.command || terminal.folder !== request.folder)) {
320
+ // Its widget's properties changed
321
+ terminal.restart(request.command, request.folder);
322
+ }
323
+ if (!terminal) {
324
+ if (this.terminals.size >= MAX_TERMINALS && !this.hangUpUnseen()) {
325
+ watcher.onStatus('error', `${MAX_TERMINALS} terminals are open on the robot already: close one`);
326
+ return IDLE_VIEW;
327
+ }
328
+ const opened = new Terminal(request, this.logger, () => {
329
+ if (this.terminals.get(request.terminal) === opened) {
330
+ this.terminals.delete(request.terminal);
331
+ }
332
+ });
333
+ this.terminals.set(request.terminal, opened);
334
+ terminal = opened;
335
+ }
336
+ const shown = terminal;
337
+ shown.attach(watcher, request.started, request.offset, request.cols, request.rows);
338
+ return {
339
+ input: (data) => shown.input(data),
340
+ resize: (cols, rows) => shown.resize(cols, rows),
341
+ ack: (started, offset) => shown.ack(watcher, started, offset),
342
+ restart: () => shown.restart(),
343
+ close: () => shown.detach(watcher),
344
+ };
345
+ }
346
+ // The server is shutting down
347
+ stopAll() {
348
+ for (const terminal of [...this.terminals.values()]) {
349
+ terminal.end();
350
+ }
351
+ }
352
+ // The terminal unseen the longest, if any is: hung up to open another
353
+ hangUpUnseen() {
354
+ let oldest = null;
355
+ for (const terminal of this.terminals.values()) {
356
+ if (terminal.hiddenSince !== null && (oldest === null || terminal.hiddenSince < oldest.hiddenSince)) {
357
+ oldest = terminal;
358
+ }
359
+ }
360
+ if (oldest) {
361
+ this.logger.info(`Terminal ${oldest.name}: hung up, to open another`);
362
+ oldest.end();
363
+ }
364
+ return oldest !== null;
365
+ }
366
+ }
@@ -1,3 +1,4 @@
1
+ import type { IncomingHttpHeaders } from 'node:http';
1
2
  export interface Logger {
2
3
  debug(message: string): unknown;
3
4
  info(message: string): unknown;
@@ -8,6 +9,9 @@ export declare function packageShareDirectory(name: string): string | null;
8
9
  export declare function defaultPanelsDirectory(): string;
9
10
  export declare function tasselloRoot(): string;
10
11
  export declare function absoluteTopic(topic: string): string;
12
+ export declare function ownPage(headers: IncomingHttpHeaders): boolean;
11
13
  export declare function errorMessage(e: unknown): string;
12
14
  export declare function stripAnsi(text: string): string;
13
15
  export declare function toJson(value: unknown): unknown;
16
+ export declare function contentVersion(text: string): string;
17
+ export declare function replaceFile(file: string, text: string): Promise<void>;
@@ -1,4 +1,5 @@
1
- import { existsSync } from 'node:fs';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { existsSync, promises as fs } from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  // The share directory of a ROS package, from the ament index of the sourced
@@ -26,6 +27,27 @@ export function absoluteTopic(topic) {
26
27
  const trimmed = topic.trim().replace(/\/+$/, '');
27
28
  return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
28
29
  }
30
+ // Whether the page that opened a WebSocket is one of the dashboard's own.
31
+ // Browsers say which site a page is from (Origin), and another site's page
32
+ // cannot change it; the dashboard's is the host the page reached: the Host
33
+ // header, or the X-Forwarded-Host of a reverse proxy. Programs other than
34
+ // browsers send no Origin.
35
+ export function ownPage(headers) {
36
+ if (headers.origin === undefined) {
37
+ return true;
38
+ }
39
+ let host;
40
+ try {
41
+ host = new URL(headers.origin).host.toLowerCase();
42
+ }
43
+ catch {
44
+ // "null": a sandboxed frame, a file
45
+ return false;
46
+ }
47
+ const forwarded = headers['x-forwarded-host'];
48
+ const proxied = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(',')[0].trim();
49
+ return [headers.host, proxied].some((reached) => reached?.toLowerCase() === host);
50
+ }
29
51
  export function errorMessage(e) {
30
52
  return e instanceof Error ? e.message : String(e);
31
53
  }
@@ -52,3 +74,25 @@ export function toJson(value) {
52
74
  }
53
75
  return value;
54
76
  }
77
+ // Of a file's content
78
+ export function contentVersion(text) {
79
+ return createHash('sha256').update(text).digest('hex').slice(0, 16);
80
+ }
81
+ // Writes the file anew, at once: a temporary file renamed over it, keeping
82
+ // its mode; in place if that cannot be (a folder the server cannot write in,
83
+ // a file of another user's)
84
+ export async function replaceFile(file, text) {
85
+ const stat = await fs.stat(file);
86
+ if (stat.uid === process.getuid?.()) {
87
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomBytes(4).toString('hex')}.tmp`);
88
+ try {
89
+ await fs.writeFile(temporary, text, { mode: stat.mode & 0o7777, flag: 'wx' });
90
+ await fs.rename(temporary, file);
91
+ return;
92
+ }
93
+ catch {
94
+ await fs.rm(temporary, { force: true });
95
+ }
96
+ }
97
+ await fs.writeFile(file, text);
98
+ }
@@ -0,0 +1,130 @@
1
+ export interface FsEntry {
2
+ name: string;
3
+ kind: 'dir' | 'file' | 'other';
4
+ link: string | null;
5
+ size: number;
6
+ modified: number;
7
+ mode: number;
8
+ owner: string;
9
+ group: string;
10
+ readable: boolean;
11
+ writable: boolean;
12
+ bag: boolean;
13
+ }
14
+ export interface FsSpace {
15
+ mount: string;
16
+ total: number;
17
+ free: number;
18
+ }
19
+ export interface FsListing {
20
+ path: string;
21
+ parent: string | null;
22
+ entries: FsEntry[];
23
+ truncated: number;
24
+ writable: boolean;
25
+ version: string;
26
+ space: FsSpace | null;
27
+ }
28
+ export interface FsPlaces {
29
+ home: string;
30
+ user: string;
31
+ ros: {
32
+ name: string;
33
+ path: string;
34
+ }[];
35
+ }
36
+ export interface FsDetails extends FsEntry {
37
+ path: string;
38
+ realPath: string | null;
39
+ created: number | null;
40
+ accessed: number;
41
+ changed: number;
42
+ inode: number;
43
+ links: number;
44
+ items: number | null;
45
+ hidden: number | null;
46
+ space: FsSpace | null;
47
+ }
48
+ export interface FsDisk extends FsSpace {
49
+ device: string;
50
+ type: string;
51
+ readOnly: boolean;
52
+ used: number;
53
+ inodes: {
54
+ total: number;
55
+ free: number;
56
+ } | null;
57
+ }
58
+ export interface FsAnalysis {
59
+ path: string;
60
+ size: number;
61
+ disk: number;
62
+ files: number;
63
+ folders: number;
64
+ links: number;
65
+ children: {
66
+ name: string;
67
+ kind: FsEntry['kind'];
68
+ size: number;
69
+ files: number;
70
+ }[];
71
+ types: {
72
+ type: string;
73
+ files: number;
74
+ size: number;
75
+ }[];
76
+ largest: {
77
+ path: string;
78
+ size: number;
79
+ }[];
80
+ recent: {
81
+ path: string;
82
+ modified: number;
83
+ }[];
84
+ partial: string | null;
85
+ unreadable: number;
86
+ mounts: number;
87
+ elapsedMs: number;
88
+ }
89
+ export interface FsText {
90
+ path: string;
91
+ text: string;
92
+ version: string;
93
+ stamp: string;
94
+ readOnly: string | null;
95
+ }
96
+ export interface FsSaved {
97
+ version: string;
98
+ stamp: string;
99
+ conflict: boolean;
100
+ }
101
+ export type FsConflict = 'ask' | 'skip' | 'keep-both' | 'replace';
102
+ export type FsOperation = {
103
+ op: 'mkdir';
104
+ path: string;
105
+ } | {
106
+ op: 'create';
107
+ path: string;
108
+ } | {
109
+ op: 'rename';
110
+ path: string;
111
+ name: string;
112
+ } | {
113
+ op: 'move' | 'copy';
114
+ paths: string[];
115
+ to: string;
116
+ conflict: FsConflict;
117
+ } | {
118
+ op: 'delete';
119
+ paths: string[];
120
+ } | {
121
+ op: 'chmod';
122
+ path: string;
123
+ mode: number;
124
+ };
125
+ export interface FsResult {
126
+ paths: string[];
127
+ conflicts: string[];
128
+ }
129
+ export declare const FS_MAX_ENTRIES = 5000;
130
+ export declare const FS_MAX_TEXT_BYTES: number;
@@ -0,0 +1,7 @@
1
+ // The robot's files, as the file manager widgets see them: the REST API of
2
+ // /api/fs (see src/server/http.ts). The server acts as its user, anywhere that
3
+ // user can: see the README's Security.
4
+ // Of a folder's entries, at most, listed
5
+ export const FS_MAX_ENTRIES = 5000;
6
+ // Of the files edited as text
7
+ export const FS_MAX_TEXT_BYTES = 5 * 1024 * 1024;