zerogterm 0.9.0-alpha.1 → 0.9.2-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -6
- package/dist/main/main/agent-hook-server.js +166 -0
- package/dist/main/main/editable.js +31 -0
- package/dist/main/main/host-label-store.js +110 -0
- package/dist/main/main/local-fs.js +57 -6
- package/dist/main/main/main.js +122 -104
- package/dist/main/main/pane-link.js +335 -0
- package/dist/main/main/preload.cjs +17 -0
- package/dist/main/main/remote-file.js +97 -0
- package/dist/main/main/sftp-service.js +12 -0
- package/dist/main/main/workspace-store.js +7 -0
- package/dist/main/shared/editing.js +11 -0
- package/dist/main/shared/files.js +11 -0
- package/dist/renderer/assets/index-B7wWHnX0.js +149 -0
- package/dist/renderer/assets/index-DJfpGOKA.css +1 -0
- package/dist/renderer/index.html +2 -2
- package/package.json +25 -1
- package/dist/renderer/assets/index-BovKjPi0.css +0 -1
- package/dist/renderer/assets/index-DYB0NFWF.js +0 -142
package/README.md
CHANGED
|
@@ -38,9 +38,9 @@ See the project walkthrough on [YouTube](https://youtu.be/4aJZCxLHD14).
|
|
|
38
38
|
|
|
39
39
|
## Release status
|
|
40
40
|
|
|
41
|
-
ZeroG Terminal is currently a public alpha. The current release is `0.9.
|
|
41
|
+
ZeroG Terminal is currently a public alpha. The current release is `0.9.2-alpha.1`; the version history is tracked in [versions.txt](versions.txt). The running version is shown beside the wordmark in the title bar, read from the app itself rather than written into the interface, so it is accurate in a packaged build too.
|
|
42
42
|
|
|
43
|
-
The GitHub Releases page provides a Windows x64 installer and portable executable for each desktop release. These alpha builds are intended for early adopters and testing rather than production use. The npm package remains available for developers who prefer to launch ZeroG from Node.js.
|
|
43
|
+
The GitHub Releases page provides a Windows x64 installer and portable executable plus a Linux x64 AppImage and Debian package for each desktop release. These alpha builds are intended for early adopters and testing rather than production use. The npm package remains available for developers who prefer to launch ZeroG from Node.js.
|
|
44
44
|
|
|
45
45
|
## Terminal shortcuts
|
|
46
46
|
|
|
@@ -396,6 +396,59 @@ npm install --global zerogterm
|
|
|
396
396
|
zerogterm
|
|
397
397
|
```
|
|
398
398
|
|
|
399
|
+
## Connecting an AI agent through MCP
|
|
400
|
+
|
|
401
|
+
ZeroG Terminal can expose the running desktop instance as a local MCP server. This
|
|
402
|
+
lets an MCP-capable AI agent inspect workspaces and sessions, restore or create
|
|
403
|
+
project workspaces, open SSH sessions, and request commands for your approval.
|
|
404
|
+
|
|
405
|
+
1. Start ZeroG Terminal with MCP enabled:
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
ZEROG_MCP_ENABLED=1 npx zerogterm
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
On Windows PowerShell, use:
|
|
412
|
+
|
|
413
|
+
```powershell
|
|
414
|
+
$env:ZEROG_MCP_ENABLED = "1"
|
|
415
|
+
npx zerogterm
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
2. Open **Settings → AI & voice → MCP** in ZeroG Terminal. Copy the displayed
|
|
419
|
+
endpoint and bearer token into your MCP client. The default endpoint is
|
|
420
|
+
`http://127.0.0.1:60056/mcp`.
|
|
421
|
+
|
|
422
|
+
MCP clients that support Streamable HTTP generally need the equivalent of:
|
|
423
|
+
|
|
424
|
+
```json
|
|
425
|
+
{
|
|
426
|
+
"url": "http://127.0.0.1:60056/mcp",
|
|
427
|
+
"headers": {
|
|
428
|
+
"Authorization": "Bearer <token-from-zero-g-settings>"
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
The exact configuration key differs between clients; use its Streamable HTTP
|
|
434
|
+
or remote MCP server setting rather than a stdio command. The token belongs to
|
|
435
|
+
the current ZeroG process, is held only in memory, and must be copied again
|
|
436
|
+
after ZeroG restarts. Never put it in source control, prompts, issue reports,
|
|
437
|
+
or shared configuration.
|
|
438
|
+
|
|
439
|
+
3. Ask the agent to call `zerog_get_connection_status`, then
|
|
440
|
+
`zerog_acquire_control` with a client ID and the capabilities it needs. Read
|
|
441
|
+
capabilities require no write access; workspace changes and command requests
|
|
442
|
+
require the corresponding lease capabilities. The lease is single-client and
|
|
443
|
+
expires unless renewed.
|
|
444
|
+
|
|
445
|
+
Every command request is shown in ZeroG Terminal for exact user approval before
|
|
446
|
+
anything is written to a shell. Rejection, cancellation, takeover, credential
|
|
447
|
+
prompts, uncertain prompts, and unsupported command shapes fail closed. MCP does
|
|
448
|
+
not receive terminal output, credentials, or keystroke-level control. See the
|
|
449
|
+
[full MCP protocol and security guide](docs/mcp.md) for tools, limits, and
|
|
450
|
+
troubleshooting.
|
|
451
|
+
|
|
399
452
|
## Development
|
|
400
453
|
|
|
401
454
|
```bash
|
|
@@ -414,12 +467,21 @@ required whichever shell you start them from.
|
|
|
414
467
|
for Windows and macOS, so a C/C++ toolchain is a Linux requirement rather than a
|
|
415
468
|
general one — there, `npm install` compiles it.
|
|
416
469
|
|
|
417
|
-
###
|
|
470
|
+
### Desktop releases
|
|
471
|
+
|
|
472
|
+
Download the latest release for your platform from [GitHub Releases](https://github.com/HappyMonkeyAI/ZeroGTerm/releases). These alpha builds are intended for early adopters and testing rather than production use. Review the release notes before installing; SSH, WSL, PowerShell, Git Bash, and any remote `screen` sessions are provided by the host system rather than bundled with the app.
|
|
473
|
+
|
|
474
|
+
#### Windows
|
|
475
|
+
|
|
476
|
+
The Windows x64 installer adds ZeroG Terminal to the current user's applications. The portable `.exe` can be run without installation.
|
|
477
|
+
|
|
478
|
+
These binaries are unsigned, so Windows SmartScreen may show a warning on first launch. If you downloaded the release from the official repository, choose **More info → Run anyway**.
|
|
479
|
+
|
|
480
|
+
#### Linux
|
|
418
481
|
|
|
419
|
-
|
|
482
|
+
The Linux release provides an x64 AppImage and Debian package built for Ubuntu 22.04 or newer. Make the AppImage executable after downloading it, then launch it from the same directory. If the AppImage reports that FUSE2 is unavailable, install `libfuse2` on Ubuntu 22.04 or `libfuse2t64` on Ubuntu 24.04 and newer. The `.deb` package can be installed through Ubuntu's software tools or APT and does not require FUSE2.
|
|
420
483
|
|
|
421
|
-
|
|
422
|
-
### Linux
|
|
484
|
+
The host still needs a C/C++ toolchain when developing from source:
|
|
423
485
|
|
|
424
486
|
```bash
|
|
425
487
|
sudo dnf install screen make gcc-c++ python3
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Loopback listener for the hooks of agents that ZeroG launched itself.
|
|
2
|
+
//
|
|
3
|
+
// Each launched agent gets its own settings file (passed with --settings, so no user or
|
|
4
|
+
// project settings are edited) pointing four hook events at a URL that carries a random
|
|
5
|
+
// per-agent token. The token is the only credential: the listener binds to 127.0.0.1
|
|
6
|
+
// only, and a request for an unknown token is a 404 that reveals nothing. The token also
|
|
7
|
+
// identifies the session, so a hook cannot claim to be a different pane.
|
|
8
|
+
//
|
|
9
|
+
// The hook response is an empty JSON object: ZeroG observes the agent, it never steers it
|
|
10
|
+
// (no blocking, no injected context).
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { randomBytes } from 'node:crypto';
|
|
13
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
export const HOOK_EVENTS = ['UserPromptSubmit', 'Stop', 'Notification', 'SessionEnd'];
|
|
16
|
+
/** A Stop payload carries the whole final message, so allow generously but not without bound. */
|
|
17
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
18
|
+
/** Settings JSON that points each observed hook event at `url`. Exported for tests. */
|
|
19
|
+
export function buildHookSettings(url) {
|
|
20
|
+
const hooks = {};
|
|
21
|
+
for (const event of HOOK_EVENTS)
|
|
22
|
+
hooks[event] = [{ hooks: [{ type: 'http', url }] }];
|
|
23
|
+
return { hooks };
|
|
24
|
+
}
|
|
25
|
+
/** Turn a raw hook payload into the small event the controller understands, or undefined to ignore it. */
|
|
26
|
+
export function parseHookPayload(sessionId, payload) {
|
|
27
|
+
if (typeof payload !== 'object' || payload === null)
|
|
28
|
+
return undefined;
|
|
29
|
+
const record = payload;
|
|
30
|
+
const name = record.hook_event_name;
|
|
31
|
+
if (typeof name !== 'string' || !HOOK_EVENTS.includes(name))
|
|
32
|
+
return undefined;
|
|
33
|
+
const text = (key) => (typeof record[key] === 'string' ? record[key] : undefined);
|
|
34
|
+
return {
|
|
35
|
+
sessionId,
|
|
36
|
+
event: name,
|
|
37
|
+
message: text('last_assistant_message'),
|
|
38
|
+
notificationType: text('notification_type'),
|
|
39
|
+
reason: text('reason')
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export class AgentHookServer {
|
|
43
|
+
options;
|
|
44
|
+
byToken = new Map();
|
|
45
|
+
bySession = new Map();
|
|
46
|
+
http;
|
|
47
|
+
port = 0;
|
|
48
|
+
constructor(options) {
|
|
49
|
+
this.options = options;
|
|
50
|
+
}
|
|
51
|
+
/** Start listening on an ephemeral loopback port. Safe to call twice. */
|
|
52
|
+
async start() {
|
|
53
|
+
if (this.http)
|
|
54
|
+
return;
|
|
55
|
+
const server = createServer((req, res) => this.handle(req, res));
|
|
56
|
+
await new Promise((resolve, reject) => {
|
|
57
|
+
server.once('error', reject);
|
|
58
|
+
server.listen(0, '127.0.0.1', () => {
|
|
59
|
+
server.off('error', reject);
|
|
60
|
+
resolve();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
const address = server.address();
|
|
64
|
+
this.port = typeof address === 'object' && address ? address.port : 0;
|
|
65
|
+
this.http = server;
|
|
66
|
+
}
|
|
67
|
+
async stop() {
|
|
68
|
+
const server = this.http;
|
|
69
|
+
this.http = undefined;
|
|
70
|
+
const sessions = Array.from(this.bySession.keys());
|
|
71
|
+
await Promise.all(sessions.map((sessionId) => this.unregister(sessionId)));
|
|
72
|
+
if (!server)
|
|
73
|
+
return;
|
|
74
|
+
await new Promise((resolve) => {
|
|
75
|
+
server.close(() => resolve());
|
|
76
|
+
server.closeAllConnections?.();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Issue a token and a settings file for one launched agent. Replaces any earlier registration for the session. */
|
|
80
|
+
async register(sessionId) {
|
|
81
|
+
if (!this.http)
|
|
82
|
+
await this.start();
|
|
83
|
+
await this.unregister(sessionId);
|
|
84
|
+
const token = randomBytes(24).toString('hex');
|
|
85
|
+
const settingsPath = join(this.options.settingsDir, `agent-${token.slice(0, 12)}.json`);
|
|
86
|
+
const url = `http://127.0.0.1:${this.port}/hook/${token}`;
|
|
87
|
+
await mkdir(this.options.settingsDir, { recursive: true });
|
|
88
|
+
// The file holds the token, so keep it to the owner where the platform supports modes.
|
|
89
|
+
await writeFile(settingsPath, JSON.stringify(buildHookSettings(url)), { mode: 0o600 });
|
|
90
|
+
this.byToken.set(token, { sessionId, settingsPath });
|
|
91
|
+
this.bySession.set(sessionId, token);
|
|
92
|
+
return { sessionId, settingsPath };
|
|
93
|
+
}
|
|
94
|
+
async unregister(sessionId) {
|
|
95
|
+
const token = this.bySession.get(sessionId);
|
|
96
|
+
if (!token)
|
|
97
|
+
return;
|
|
98
|
+
this.bySession.delete(sessionId);
|
|
99
|
+
const registration = this.byToken.get(token);
|
|
100
|
+
this.byToken.delete(token);
|
|
101
|
+
if (registration)
|
|
102
|
+
await rm(registration.settingsPath, { force: true });
|
|
103
|
+
}
|
|
104
|
+
handle(req, res) {
|
|
105
|
+
const reply = (status, body = '{}') => {
|
|
106
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
107
|
+
res.end(body);
|
|
108
|
+
};
|
|
109
|
+
const match = /^\/hook\/([0-9a-f]{48})$/.exec((req.url ?? '').split('?')[0]);
|
|
110
|
+
const registration = match ? this.byToken.get(match[1]) : undefined;
|
|
111
|
+
if (!registration)
|
|
112
|
+
return reply(404, '{"error":"not found"}');
|
|
113
|
+
if (req.method !== 'POST')
|
|
114
|
+
return reply(405, '{"error":"method not allowed"}');
|
|
115
|
+
const chunks = [];
|
|
116
|
+
let size = 0;
|
|
117
|
+
let tooLarge = false;
|
|
118
|
+
req.on('data', (chunk) => {
|
|
119
|
+
size += chunk.length;
|
|
120
|
+
if (size > MAX_BODY_BYTES) {
|
|
121
|
+
// Past the limit a chunk is counted and dropped, never kept, so a larger
|
|
122
|
+
// body costs no more memory than the limit. The request is read to its end
|
|
123
|
+
// rather than torn down so the 413 below actually reaches the sender, and
|
|
124
|
+
// Node's own requestTimeout (five minutes) bounds a sender that never
|
|
125
|
+
// finishes — one that also needs this agent's token to get this far.
|
|
126
|
+
tooLarge = true;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
chunks.push(chunk);
|
|
130
|
+
});
|
|
131
|
+
req.on('end', () => {
|
|
132
|
+
if (tooLarge)
|
|
133
|
+
return reply(413, '{"error":"payload too large"}');
|
|
134
|
+
try {
|
|
135
|
+
const event = parseHookPayload(registration.sessionId, JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
136
|
+
if (event)
|
|
137
|
+
this.options.onEvent(event);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return reply(400, '{"error":"bad request"}');
|
|
141
|
+
}
|
|
142
|
+
reply(200);
|
|
143
|
+
});
|
|
144
|
+
req.on('error', () => reply(400, '{"error":"bad request"}'));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The command typed into the pane: the user's agent command plus the settings file.
|
|
149
|
+
*
|
|
150
|
+
* Forward slashes keep the path safe inside double quotes in PowerShell, cmd and bash alike.
|
|
151
|
+
* The command comes from a user setting, so it must be a single plain line.
|
|
152
|
+
*/
|
|
153
|
+
export function buildLaunchCommand(agentCommand, settingsPath) {
|
|
154
|
+
const command = agentCommand.trim();
|
|
155
|
+
if (!command || command.length > 200)
|
|
156
|
+
throw new Error('The AI command must be 1 to 200 characters.');
|
|
157
|
+
for (const ch of command) {
|
|
158
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
159
|
+
if (code < 0x20 || code === 0x7f)
|
|
160
|
+
throw new Error('The AI command must be a single line with no control characters.');
|
|
161
|
+
}
|
|
162
|
+
const path = settingsPath.replaceAll('\\', '/');
|
|
163
|
+
if (path.includes('"'))
|
|
164
|
+
throw new Error('The settings path cannot contain a quote.');
|
|
165
|
+
return `${command} --settings "${path}"`;
|
|
166
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// What makes a file safe to put in the editor's text box, shared by the local and
|
|
2
|
+
// the remote read so the two cannot disagree about it.
|
|
3
|
+
import { MAX_EDIT_BYTES } from '../shared/editing.js';
|
|
4
|
+
export function megabytes(bytes) {
|
|
5
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
6
|
+
}
|
|
7
|
+
/** Throws unless a file of this size may be opened. */
|
|
8
|
+
export function checkEditableSize(size) {
|
|
9
|
+
if (size > MAX_EDIT_BYTES) {
|
|
10
|
+
throw new Error(`That file is ${megabytes(size)}; the editor opens files up to ${megabytes(MAX_EDIT_BYTES)}.`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The file's bytes as text, or the reason it cannot be edited.
|
|
15
|
+
*
|
|
16
|
+
* Refused rather than coaxed: a NUL byte (the usual mark of a binary) and bytes
|
|
17
|
+
* that are not valid UTF-8 would each be damaged by a round trip through a text
|
|
18
|
+
* box, and a refused open costs nothing where a corrupted save does. The BOM is
|
|
19
|
+
* kept as a character so a save writes it back.
|
|
20
|
+
*/
|
|
21
|
+
export function decodeEditable(bytes) {
|
|
22
|
+
checkEditableSize(bytes.length);
|
|
23
|
+
if (bytes.includes(0))
|
|
24
|
+
throw new Error('That looks like a binary file, so it was not opened.');
|
|
25
|
+
try {
|
|
26
|
+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error('That file is not valid UTF-8, so it was not opened.');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
const SCHEMA_VERSION = 1;
|
|
5
|
+
const MAX_HOSTS = 256;
|
|
6
|
+
// Session names cap at 48, so a longer remembered label could never be reused.
|
|
7
|
+
const MAX_LABEL = 48;
|
|
8
|
+
/**
|
|
9
|
+
* Main-process-only, best-effort memory of the session label last given to each
|
|
10
|
+
* host, so the SSH dialog can offer it again instead of the user retyping
|
|
11
|
+
* "dev server" every time.
|
|
12
|
+
*
|
|
13
|
+
* Stores a host and a label — never a user name, a credential, or a command —
|
|
14
|
+
* the same guarantee the other stores make, and for the same reason: this is
|
|
15
|
+
* plain JSON in the user's profile. The renderer decides what a host is called
|
|
16
|
+
* for this purpose; the file only guarantees what comes back is well formed.
|
|
17
|
+
*/
|
|
18
|
+
export class HostLabelStore {
|
|
19
|
+
filePath;
|
|
20
|
+
file = { version: SCHEMA_VERSION, hosts: [] };
|
|
21
|
+
loaded = false;
|
|
22
|
+
writeQueue = Promise.resolve();
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.filePath = options.filePath;
|
|
25
|
+
}
|
|
26
|
+
async load() {
|
|
27
|
+
await this.ensureLoaded();
|
|
28
|
+
return normalizeFile(this.file) ?? { version: SCHEMA_VERSION, hosts: [] };
|
|
29
|
+
}
|
|
30
|
+
async save(input) {
|
|
31
|
+
await this.ensureLoaded();
|
|
32
|
+
// Validated on the way in as well as out: this is an IPC boundary, and a
|
|
33
|
+
// stored bad value would come back on every launch from then on.
|
|
34
|
+
const normalized = normalizeFile(input);
|
|
35
|
+
if (!normalized)
|
|
36
|
+
throw new Error('Invalid host label list.');
|
|
37
|
+
this.file = normalized;
|
|
38
|
+
await this.persist();
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
async ensureLoaded() {
|
|
42
|
+
if (this.loaded)
|
|
43
|
+
return;
|
|
44
|
+
this.loaded = true;
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(await readFile(this.filePath, 'utf8'));
|
|
47
|
+
const normalized = normalizeFile(parsed);
|
|
48
|
+
if (normalized)
|
|
49
|
+
this.file = normalized;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
this.file = { version: SCHEMA_VERSION, hosts: [] };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async persist() {
|
|
56
|
+
const snapshot = this.file;
|
|
57
|
+
this.writeQueue = this.writeQueue.then(async () => {
|
|
58
|
+
try {
|
|
59
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
60
|
+
const temp = join(dirname(this.filePath), `.host-labels.tmp-${process.pid}-${randomUUID()}`);
|
|
61
|
+
await writeFile(temp, JSON.stringify(snapshot, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
62
|
+
await rename(temp, this.filePath);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Remembering a label must never affect terminal operation.
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
await this.writeQueue;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Strip control characters and cap length; the file is user-editable. */
|
|
72
|
+
function safeText(value, limit) {
|
|
73
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, limit);
|
|
74
|
+
}
|
|
75
|
+
function normalizeEntry(value) {
|
|
76
|
+
if (!value || typeof value !== 'object')
|
|
77
|
+
return undefined;
|
|
78
|
+
const item = value;
|
|
79
|
+
if (typeof item.host !== 'string' || typeof item.label !== 'string')
|
|
80
|
+
return undefined;
|
|
81
|
+
const host = safeText(item.host, 256).toLowerCase();
|
|
82
|
+
const label = safeText(item.label, MAX_LABEL);
|
|
83
|
+
if (!host || !label)
|
|
84
|
+
return undefined;
|
|
85
|
+
return { host, label };
|
|
86
|
+
}
|
|
87
|
+
export function normalizeFile(value) {
|
|
88
|
+
if (!value || typeof value !== 'object')
|
|
89
|
+
return undefined;
|
|
90
|
+
const item = value;
|
|
91
|
+
if (item.version !== SCHEMA_VERSION)
|
|
92
|
+
return undefined;
|
|
93
|
+
if (!Array.isArray(item.hosts))
|
|
94
|
+
return undefined;
|
|
95
|
+
// Later entries win and an entry is only kept once, so a hand-edited file
|
|
96
|
+
// that repeats a host settles on its last word rather than its first.
|
|
97
|
+
const byHost = new Map();
|
|
98
|
+
for (const raw of item.hosts) {
|
|
99
|
+
const entry = normalizeEntry(raw);
|
|
100
|
+
if (!entry)
|
|
101
|
+
continue;
|
|
102
|
+
byHost.delete(entry.host);
|
|
103
|
+
byHost.set(entry.host, entry);
|
|
104
|
+
}
|
|
105
|
+
// Oldest first, so the cap drops the host not touched for longest.
|
|
106
|
+
return { version: SCHEMA_VERSION, hosts: [...byHost.values()].slice(-MAX_HOSTS) };
|
|
107
|
+
}
|
|
108
|
+
export function defaultHostLabelPath(userDataPath) {
|
|
109
|
+
return join(userDataPath, 'host-labels.json');
|
|
110
|
+
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
// The local half of the transfer panel: directory listings and the three edits
|
|
2
|
-
// the panel can make locally
|
|
2
|
+
// the panel can make locally, plus reading and writing one text file for the
|
|
3
|
+
// built-in editor.
|
|
3
4
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// The renderer is sandboxed and has no filesystem access of its own, and this
|
|
6
|
+
// module is deliberately the narrowest widening of that boundary that a file
|
|
7
|
+
// browser needs: names, sizes, kinds, and explicit create/rename/delete on a path
|
|
8
|
+
// the user pointed at. File *contents* are only ever read or written by the
|
|
9
|
+
// editor's two functions at the bottom, one file at a time, with limits.
|
|
8
10
|
import { constants } from 'node:fs';
|
|
9
|
-
import { access, lstat, mkdir, readdir, rename, rm, rmdir } from 'node:fs/promises';
|
|
11
|
+
import { access, lstat, mkdir, readFile, readdir, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
|
|
10
12
|
import { homedir } from 'node:os';
|
|
11
13
|
import { isAbsolute, join, normalize, resolve } from 'node:path';
|
|
14
|
+
import { EDIT_CONFLICT_MESSAGE, MAX_EDIT_BYTES } from '../shared/editing.js';
|
|
15
|
+
import { checkEditableSize, decodeEditable, megabytes } from './editable.js';
|
|
12
16
|
import { sortEntries } from '../shared/files.js';
|
|
13
17
|
/**
|
|
14
18
|
* A path this process will act on.
|
|
@@ -113,3 +117,50 @@ export async function removeLocalEntry(path, kind) {
|
|
|
113
117
|
else
|
|
114
118
|
await rm(target, { force: false });
|
|
115
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Read one file as text for the editor, or say why it cannot be edited (see
|
|
122
|
+
* decodeEditable for what is refused).
|
|
123
|
+
*
|
|
124
|
+
* Follows a symlink, as opening it in any editor would.
|
|
125
|
+
*/
|
|
126
|
+
export async function readLocalFile(path) {
|
|
127
|
+
const target = resolveLocalPath(path);
|
|
128
|
+
const info = await stat(target);
|
|
129
|
+
if (!info.isFile())
|
|
130
|
+
throw new Error('That is not a file.');
|
|
131
|
+
// Checked before reading, so a huge file is refused without being loaded.
|
|
132
|
+
checkEditableSize(info.size);
|
|
133
|
+
const bytes = await readFile(target);
|
|
134
|
+
return { path: target, text: decodeEditable(bytes), size: bytes.length, mtimeMs: info.mtimeMs };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Save the editor's text back over the file it came from.
|
|
138
|
+
*
|
|
139
|
+
* Refused when the file's modified time is not the one it was opened with, so a
|
|
140
|
+
* change made meanwhile — an agent writing the same `.env` in the pane behind —
|
|
141
|
+
* is flagged instead of silently lost. `overwrite` is the user saying they have
|
|
142
|
+
* seen that and mean it.
|
|
143
|
+
*
|
|
144
|
+
* Written in place rather than through a temp file and a rename, which would
|
|
145
|
+
* replace a symlink with a plain file and reset the file's owner and mode. The
|
|
146
|
+
* price is that a write interrupted halfway leaves a short file, which for the
|
|
147
|
+
* small text files this opens is the lesser harm.
|
|
148
|
+
*/
|
|
149
|
+
export async function writeLocalFile(path, text, expectedMtimeMs, overwrite = false) {
|
|
150
|
+
const target = resolveLocalPath(path);
|
|
151
|
+
if (typeof text !== 'string')
|
|
152
|
+
throw new Error('There is no text to save.');
|
|
153
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_EDIT_BYTES) {
|
|
154
|
+
throw new Error(`That is more than ${megabytes(MAX_EDIT_BYTES)}, which is more than the editor saves.`);
|
|
155
|
+
}
|
|
156
|
+
const current = await stat(target).then((info) => info, () => undefined);
|
|
157
|
+
if (!current)
|
|
158
|
+
throw new Error('That file no longer exists, so it was not saved.');
|
|
159
|
+
if (!current.isFile())
|
|
160
|
+
throw new Error('That is not a file.');
|
|
161
|
+
if (!overwrite && current.mtimeMs !== expectedMtimeMs)
|
|
162
|
+
throw new Error(EDIT_CONFLICT_MESSAGE);
|
|
163
|
+
await writeFile(target, text, 'utf8');
|
|
164
|
+
const saved = await stat(target);
|
|
165
|
+
return { size: saved.size, mtimeMs: saved.mtimeMs };
|
|
166
|
+
}
|