@oddessentials/agent-guild 0.26.0 → 0.28.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.
- package/README.md +3 -3
- package/bin/agent-guild.mjs +2 -2
- package/node_modules/qrcode-generator/README.md +101 -0
- package/node_modules/qrcode-generator/dist/qrcode.d.ts +57 -0
- package/node_modules/qrcode-generator/dist/qrcode.js +2297 -0
- package/node_modules/qrcode-generator/dist/qrcode.mjs +2237 -0
- package/node_modules/qrcode-generator/dist/qrcode_SJIS.js +30 -0
- package/node_modules/qrcode-generator/dist/qrcode_SJIS.mjs +23 -0
- package/node_modules/qrcode-generator/dist/qrcode_UTF8.js +26 -0
- package/node_modules/qrcode-generator/dist/qrcode_UTF8.mjs +50 -0
- package/node_modules/qrcode-generator/experiment/vt/package-lock.json +6184 -0
- package/node_modules/qrcode-generator/experiment/vt/package.json +19 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode.ts +2311 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_SJIS.ts +25 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_UTF8.ts +50 -0
- package/node_modules/qrcode-generator/experiment/vt/src/main/types.ts +80 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode-test-impl.js +353 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode.test.mjs +5 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_SJIS.test.mjs +10 -0
- package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_UTF8.test.mjs +9 -0
- package/node_modules/qrcode-generator/experiment/vt/tsconfig.json +25 -0
- package/node_modules/qrcode-generator/experiment/vt/vite.config.js +31 -0
- package/node_modules/qrcode-generator/experiment/vt/web-test-runner.config.mjs +7 -0
- package/node_modules/qrcode-generator/package.json +37 -0
- package/node_modules/qrcode-generator/test/qrcode-test-impl.js +353 -0
- package/node_modules/qrcode-generator/test/qrcode.test.html +18 -0
- package/node_modules/qrcode-generator/test/qrcode.test.mjs +5 -0
- package/node_modules/qrcode-generator/test/qrcode_SJIS.test.html +21 -0
- package/node_modules/qrcode-generator/test/qrcode_SJIS.test.mjs +10 -0
- package/node_modules/qrcode-generator/test/qrcode_UTF8.test.html +19 -0
- package/node_modules/qrcode-generator/test/qrcode_UTF8.test.mjs +9 -0
- package/node_modules/qrcode-generator/web-test-runner.config.mjs +8 -0
- package/package.json +2 -1
- package/src/manager/access-policy.mjs +39 -0
- package/src/manager/config.mjs +1 -0
- package/src/manager/folder-browser.mjs +85 -0
- package/src/manager/main.mjs +11 -9
- package/src/manager/remote-access.mjs +286 -0
- package/src/manager/server.mjs +59 -38
- package/src/manager/tailscale.mjs +201 -0
- package/web/app.js +229 -33
- package/web/folders.js +17 -0
- package/web/index.html +107 -2
- package/web/remote-access.js +236 -0
- package/web/styles.css +57 -0
- package/src/manager/folder-picker.mjs +0 -116
- package/src/manager/folder-picker.ps1 +0 -84
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { resolveCommand } from './command-resolver.mjs';
|
|
5
|
+
import { parseAllowedHosts } from './access-policy.mjs';
|
|
6
|
+
|
|
7
|
+
const object = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
8
|
+
export const remoteError = (code, message, status = 409) => Object.assign(new Error(message), { code, status });
|
|
9
|
+
|
|
10
|
+
export function findTailscale(env = process.env, platform = process.platform, exists = fs.existsSync) {
|
|
11
|
+
const onPath = resolveCommand(platform === 'win32' ? 'tailscale.exe' : 'tailscale', env, platform);
|
|
12
|
+
const candidates = [onPath];
|
|
13
|
+
if (platform === 'win32') {
|
|
14
|
+
for (const dir of [env.ProgramFiles, env['ProgramFiles(x86)'], 'C:\\Program Files']) {
|
|
15
|
+
if (dir) candidates.push(path.win32.join(dir, 'Tailscale', 'tailscale.exe'));
|
|
16
|
+
}
|
|
17
|
+
} else if (platform === 'darwin') {
|
|
18
|
+
candidates.push('/Applications/Tailscale.app/Contents/MacOS/Tailscale', '/usr/local/bin/tailscale', '/opt/homebrew/bin/tailscale');
|
|
19
|
+
} else candidates.push('/usr/bin/tailscale', '/usr/local/bin/tailscale');
|
|
20
|
+
return candidates.find((file) => file && (platform !== 'win32' || /\.exe$/i.test(file)) && exists(file)) || null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function approvalLink(output) {
|
|
24
|
+
for (const match of output.matchAll(/https:\/\/[^\s<>"']+/g)) {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(match[0]);
|
|
27
|
+
if (url.hostname === 'login.tailscale.com' && !url.username && !url.password && !url.port) return url.href;
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function runTailscale(file, args, { env = process.env, signal, onApproval, timeout = 15000 } = {}) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
let output = '';
|
|
36
|
+
const child = execFile(file, args, {
|
|
37
|
+
env: { ...env, TAILSCALE_BE_CLI: '1' }, windowsHide: true, timeout, maxBuffer: 1024 * 1024, encoding: 'utf8', signal,
|
|
38
|
+
}, (error, stdout, stderr) => {
|
|
39
|
+
if (!error) return resolve(stdout);
|
|
40
|
+
const detail = `${stderr || ''}\n${stdout || ''}`.trim().slice(-2000);
|
|
41
|
+
const url = approvalLink(detail || output);
|
|
42
|
+
if (url) return reject(Object.assign(remoteError('approval_required', 'Approve HTTPS in Tailscale, then continue setup.'), { approvalUrl: url }));
|
|
43
|
+
if (/access denied|permission denied|must be.*(?:root|admin)|sudo tailscale|not permitted|serve config denied/i.test(detail)) {
|
|
44
|
+
return reject(remoteError('permission_required', 'Tailscale needs permission to configure remote access.'));
|
|
45
|
+
}
|
|
46
|
+
if (error.killed || error.code === 'ABORT_ERR') return reject(remoteError('tailscale_timeout', 'Tailscale did not finish. Check its connection and try again.'));
|
|
47
|
+
reject(remoteError('tailscale_error', detail || 'Could not communicate with Tailscale. Open Tailscale and check its connection.'));
|
|
48
|
+
});
|
|
49
|
+
const collect = (chunk) => {
|
|
50
|
+
output = (output + chunk).slice(-8192);
|
|
51
|
+
const url = approvalLink(output);
|
|
52
|
+
if (url) onApproval?.(url);
|
|
53
|
+
};
|
|
54
|
+
child.stdout?.on('data', collect);
|
|
55
|
+
child.stderr?.on('data', collect);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseJson(raw) {
|
|
60
|
+
let value;
|
|
61
|
+
try { value = JSON.parse(raw); } catch {}
|
|
62
|
+
if (value === null) return {};
|
|
63
|
+
if (!object(value)) throw remoteError('tailscale_response', 'Tailscale returned an unreadable status. Update Tailscale and try again.');
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function validateServeConfig(config) {
|
|
68
|
+
if (!object(config)) throw remoteError('tailscale_response', 'Tailscale returned an unreadable Serve configuration.');
|
|
69
|
+
for (const key of ['TCP', 'Web', 'AllowFunnel', 'Foreground', 'Services']) {
|
|
70
|
+
if (config[key] !== undefined && config[key] !== null && !object(config[key])) {
|
|
71
|
+
throw remoteError('tailscale_response', 'Tailscale returned an unsupported Serve configuration.');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const [port, value] of Object.entries(config.TCP || {})) {
|
|
75
|
+
if (!/^[1-9]\d{0,4}$/.test(port) || Number(port) > 65535 || !object(value)) {
|
|
76
|
+
throw remoteError('tailscale_response', 'Tailscale returned an unsupported TCP configuration.');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const value of Object.values(config.Web || {})) {
|
|
80
|
+
if (!object(value) || !object(value.Handlers) || Object.values(value.Handlers).some((handler) => !object(handler))) {
|
|
81
|
+
throw remoteError('tailscale_response', 'Tailscale returned an unsupported web configuration.');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (Object.values(config.AllowFunnel || {}).some((value) => typeof value !== 'boolean')) {
|
|
85
|
+
throw remoteError('tailscale_response', 'Tailscale returned an unsupported Funnel configuration.');
|
|
86
|
+
}
|
|
87
|
+
for (const value of Object.values(config.Foreground || {})) {
|
|
88
|
+
if (!object(value) || value.Foreground) throw remoteError('tailscale_response', 'Tailscale returned an unsupported foreground configuration.');
|
|
89
|
+
validateServeConfig(value);
|
|
90
|
+
}
|
|
91
|
+
return config;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ordered = (value) => Array.isArray(value) ? value.map(ordered)
|
|
95
|
+
: object(value) ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, ordered(value[key])])) : value;
|
|
96
|
+
export const sameConfig = (a, b) => JSON.stringify(ordered(a)) === JSON.stringify(ordered(b));
|
|
97
|
+
export const routeAuthority = (route) => `${route.host}:${route.port}`;
|
|
98
|
+
export const routeUrl = (route) => `https://${route.host}${route.port === 443 ? '' : `:${route.port}`}`;
|
|
99
|
+
|
|
100
|
+
function foregroundPort(config, port) {
|
|
101
|
+
return Object.values(config.Foreground || {}).some((entry) => entry.TCP?.[port] || Object.keys(entry.Web || {}).some((key) => key.endsWith(`:${port}`)));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function portOccupied(config, port) {
|
|
105
|
+
return Boolean(config.TCP?.[port] || Object.keys(config.Web || {}).some((key) => key.endsWith(`:${port}`))
|
|
106
|
+
|| Object.keys(config.AllowFunnel || {}).some((key) => key.endsWith(`:${port}`)) || foregroundPort(config, port));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function routeMatches(config, route) {
|
|
110
|
+
const authority = routeAuthority(route);
|
|
111
|
+
const web = config.Web?.[authority];
|
|
112
|
+
const handler = web?.Handlers?.['/'];
|
|
113
|
+
if (!sameConfig(config.TCP?.[route.port], { HTTPS: true }) || !object(web) || !object(handler)
|
|
114
|
+
|| Object.entries(config.AllowFunnel || {}).some(([key, value]) => value && key.endsWith(`:${route.port}`)) || foregroundPort(config, route.port)
|
|
115
|
+
|| Object.keys(web.Handlers).length !== 1 || Object.keys(web).some((key) => key !== 'Handlers')
|
|
116
|
+
|| Object.keys(handler).some((key) => key !== 'Proxy')
|
|
117
|
+
|| Object.keys(config.Web || {}).some((key) => key !== authority && key.endsWith(`:${route.port}`))) return false;
|
|
118
|
+
try {
|
|
119
|
+
const target = new URL(handler.Proxy);
|
|
120
|
+
return target.origin === new URL(route.target).origin && target.pathname === '/' && !target.search && !target.hash && !target.username && !target.password;
|
|
121
|
+
} catch { return false; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function chooseRoute(info, target, previous = null) {
|
|
125
|
+
if (previous) {
|
|
126
|
+
if (previous.nodeId !== info.nodeId || previous.host !== info.host) {
|
|
127
|
+
throw remoteError('network_changed', 'This computer’s Tailscale identity or address changed. Disable the saved connection before setting up the new address.');
|
|
128
|
+
}
|
|
129
|
+
if (portOccupied(info.config, previous.port) && !routeMatches(info.config, previous)) {
|
|
130
|
+
throw remoteError('route_changed', 'The saved Tailscale route was changed outside Agent Guild. Review it in Tailscale before continuing.');
|
|
131
|
+
}
|
|
132
|
+
return { ...previous, target };
|
|
133
|
+
}
|
|
134
|
+
for (const key of Object.keys(info.config.TCP || {})) {
|
|
135
|
+
const port = Number(key);
|
|
136
|
+
const route = { nodeId: info.nodeId, host: info.host, port, target };
|
|
137
|
+
if (Number.isInteger(port) && port > 0 && port <= 65535 && routeMatches(info.config, route)) return route;
|
|
138
|
+
}
|
|
139
|
+
const port = [443, ...Array.from({ length: 32 }, (_, i) => 8443 + i)].find((candidate) => !portOccupied(info.config, candidate));
|
|
140
|
+
if (!port) throw remoteError('no_port', 'No available HTTPS port was found. Free a Serve port in Tailscale and try again.');
|
|
141
|
+
return { nodeId: info.nodeId, host: info.host, port, target };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export class Tailscale {
|
|
145
|
+
constructor({ env = process.env, platform = process.platform, find = findTailscale, run = runTailscale } = {}) {
|
|
146
|
+
Object.assign(this, { env, platform, find, run });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async command(args, options = {}) {
|
|
150
|
+
const file = this.find(this.env, this.platform);
|
|
151
|
+
if (!file) throw remoteError('not_installed', 'Install Tailscale on this computer to connect it to your network.');
|
|
152
|
+
return this.run(file, args, { env: this.env, ...options });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async inspect(options = {}) {
|
|
156
|
+
const version = (await this.command(['version'], options)).trim().split(/\s/)[0];
|
|
157
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
158
|
+
if (!match || Number(match[1]) < 1 || (Number(match[1]) === 1 && Number(match[2]) < 52)) {
|
|
159
|
+
throw remoteError('update_required', 'Update Tailscale to version 1.52 or later to set up remote access.');
|
|
160
|
+
}
|
|
161
|
+
const status = parseJson(await this.command(['status', '--json', '--peers=false'], options));
|
|
162
|
+
if (!['Running', 'Starting'].includes(status.BackendState)) {
|
|
163
|
+
throw remoteError('not_connected', 'Open Tailscale and connect this computer to your network, then continue.');
|
|
164
|
+
}
|
|
165
|
+
const host = typeof status.Self?.DNSName === 'string' ? status.Self.DNSName.replace(/\.$/, '').toLowerCase() : null;
|
|
166
|
+
const nodeId = status.Self?.ID;
|
|
167
|
+
if (!host || typeof nodeId !== 'string' || !nodeId || !host.endsWith('.ts.net')) {
|
|
168
|
+
throw remoteError('address_unavailable', 'Tailscale has not assigned this computer an HTTPS address yet. Check its connection and MagicDNS settings.');
|
|
169
|
+
}
|
|
170
|
+
try { parseAllowedHosts(host); } catch { throw remoteError('address_unavailable', 'Tailscale returned an invalid HTTPS address.'); }
|
|
171
|
+
const config = validateServeConfig(parseJson(await this.command(['serve', 'status', '--json'], options)));
|
|
172
|
+
return { version, host, nodeId, config, httpsReady: Array.isArray(status.CertDomains) && status.CertDomains.includes(host) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async enable(route, previous, options = {}) {
|
|
176
|
+
const before = await this.inspect(options);
|
|
177
|
+
if (before.nodeId !== route.nodeId || before.host !== route.host) throw remoteError('network_changed', 'Tailscale changed networks during setup. Check the connection and try again.');
|
|
178
|
+
if (routeMatches(before.config, route)) return;
|
|
179
|
+
if (portOccupied(before.config, route.port) && !(previous && routeMatches(before.config, previous))) {
|
|
180
|
+
throw remoteError('route_changed', 'The selected HTTPS port is now in use. Check the connection and try again.');
|
|
181
|
+
}
|
|
182
|
+
await this.command(['serve', '--bg', `--https=${route.port}`, '--set-path=/', route.target], { ...options, timeout: 30000 });
|
|
183
|
+
const after = await this.inspect(options);
|
|
184
|
+
if (after.nodeId !== route.nodeId || after.host !== route.host || !routeMatches(after.config, route)) {
|
|
185
|
+
throw remoteError('setup_incomplete', 'Tailscale has not finished setting up the private HTTPS route. Complete any approval, then continue.');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async remove(route, options = {}) {
|
|
190
|
+
const info = await this.inspect(options);
|
|
191
|
+
if (info.nodeId !== route.nodeId || info.host !== route.host) throw remoteError('network_changed', 'Connect Tailscale to the original network to remove its saved route.');
|
|
192
|
+
if (!portOccupied(info.config, route.port)) return info;
|
|
193
|
+
if (!routeMatches(info.config, route)) throw remoteError('route_changed', 'This Serve route was changed outside Agent Guild. It has been left in place; review it in Tailscale.');
|
|
194
|
+
await this.command(['serve', '--bg', `--https=${route.port}`, '--set-path=/', 'off'], options);
|
|
195
|
+
const after = await this.inspect(options);
|
|
196
|
+
if (after.nodeId !== route.nodeId || after.host !== route.host || portOccupied(after.config, route.port)) {
|
|
197
|
+
throw remoteError('cleanup_incomplete', 'The Serve route is still present. Check Tailscale and retry cleanup.');
|
|
198
|
+
}
|
|
199
|
+
return after;
|
|
200
|
+
}
|
|
201
|
+
}
|
package/web/app.js
CHANGED
|
@@ -6,6 +6,8 @@ import { TerminalCopy } from './terminal-copy.js';
|
|
|
6
6
|
import { topbarInline, dockMode, clampDockWidth, stageBesideDock, splitMode, clampRatio, bindSplitter, DOCK_MIN, SPLIT_RATIO_MIN } from './layout.js';
|
|
7
7
|
import { highlightParts, rankRepos, recentFirst, remember, repoForOrigin, repoKey } from './repo-search.js';
|
|
8
8
|
import { createActivityFavicon, isSessionWorking } from './activity-favicon.js';
|
|
9
|
+
import { createRemoteAccessUI } from './remote-access.js';
|
|
10
|
+
import { matchFolders, readRecentFolders, rememberFolder } from './folders.js';
|
|
9
11
|
|
|
10
12
|
const TOKEN_KEY = 'agentGuild.token';
|
|
11
13
|
const CWD_KEY = 'agentGuild.cwd';
|
|
@@ -18,6 +20,8 @@ const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
|
|
|
18
20
|
const CHANGELOG_SEEN_KEY = 'agentGuild.changelogSeen';
|
|
19
21
|
const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
|
|
20
22
|
const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
|
|
23
|
+
const HIDDEN_FOLDERS_KEY = 'agentGuild.showHiddenFolders';
|
|
24
|
+
const RECENT_CWDS_KEY = 'agentGuild.recentCwds';
|
|
21
25
|
const GITHUB_REPO_KEY = 'agentGuild.githubRepo';
|
|
22
26
|
const GITHUB_RECENT_KEY = 'agentGuild.githubRecent';
|
|
23
27
|
const GITHUB_VIEW_KEY = 'agentGuild.githubView';
|
|
@@ -61,8 +65,8 @@ const state = {
|
|
|
61
65
|
connected: false,
|
|
62
66
|
folderOpener: null,
|
|
63
67
|
folderOpening: false,
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
/** The manager's operating system, from `hello`. */
|
|
69
|
+
platform: null,
|
|
66
70
|
/** The manager's own version check, from `hello` and `manager.upgrade`. */
|
|
67
71
|
upgrade: null,
|
|
68
72
|
/** The running manager's version and pid, from `hello`. */
|
|
@@ -120,6 +124,7 @@ function setConnection(kind, label) {
|
|
|
120
124
|
el.title = label;
|
|
121
125
|
// The manager can only be stopped, restarted or upgraded while the page can reach it.
|
|
122
126
|
state.connected = kind === 'ok';
|
|
127
|
+
state.remoteAccessUI?.connectionChanged();
|
|
123
128
|
$('manager').hidden = !state.connected;
|
|
124
129
|
if (!state.connected) closeMenu($('manager-menu'));
|
|
125
130
|
renderFolderTools();
|
|
@@ -735,11 +740,9 @@ function renderFolderTools() {
|
|
|
735
740
|
: !feature?.available ? feature?.reason || text.missing
|
|
736
741
|
: label;
|
|
737
742
|
};
|
|
738
|
-
|
|
739
|
-
offline: 'Connect to the session manager to choose folders'
|
|
740
|
-
|
|
741
|
-
missing: 'Choosing folders is unavailable with this manager',
|
|
742
|
-
});
|
|
743
|
+
for (const id of ['cwd-pick', 'github-parent-pick']) {
|
|
744
|
+
paint(id, { available: true }, false, 'Choose a folder…', { offline: 'Connect to the session manager to choose folders' });
|
|
745
|
+
}
|
|
743
746
|
paint('cwd-open', state.folderOpener, state.folderOpening, `Open working folder in ${state.folderOpener?.label || 'file manager'}`, {
|
|
744
747
|
offline: 'Connect to the session manager to open folders',
|
|
745
748
|
busy: 'Opening working folder…',
|
|
@@ -747,22 +750,6 @@ function renderFolderTools() {
|
|
|
747
750
|
});
|
|
748
751
|
}
|
|
749
752
|
|
|
750
|
-
async function pickWorkingFolder() {
|
|
751
|
-
if (!state.connected || !state.folderPicker?.available || state.folderPicking) return;
|
|
752
|
-
state.folderPicking = true;
|
|
753
|
-
renderFolderTools();
|
|
754
|
-
try {
|
|
755
|
-
const { path } = await api('POST', '/pick-folder', { cwd: $('cwd').value.trim() });
|
|
756
|
-
if (path) useFolder(path);
|
|
757
|
-
} catch (err) {
|
|
758
|
-
if (err instanceof AuthError) showAuth(err.message);
|
|
759
|
-
else toast(err.message || 'Could not choose a folder.');
|
|
760
|
-
} finally {
|
|
761
|
-
state.folderPicking = false;
|
|
762
|
-
renderFolderTools();
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
|
|
766
753
|
async function openWorkingFolder() {
|
|
767
754
|
if (!state.connected || !state.folderOpener?.available || state.folderOpening) return;
|
|
768
755
|
state.folderOpening = true;
|
|
@@ -778,6 +765,186 @@ async function openWorkingFolder() {
|
|
|
778
765
|
}
|
|
779
766
|
}
|
|
780
767
|
|
|
768
|
+
// ---- recent working folders -----------------------------------------------
|
|
769
|
+
|
|
770
|
+
const recentView = { open: false, typed: false, active: -1 };
|
|
771
|
+
|
|
772
|
+
function recentCwds() {
|
|
773
|
+
return readRecentFolders(load(RECENT_CWDS_KEY));
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function rememberCwd(dir) {
|
|
777
|
+
if (dir) save(RECENT_CWDS_KEY, JSON.stringify(rememberFolder(recentCwds(), dir, { caseless: state.platform === 'win32' })));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function renderRecentCwds() {
|
|
781
|
+
const input = $('cwd'), list = $('cwd-recent');
|
|
782
|
+
const shown = recentView.open ? matchFolders(recentCwds(), recentView.typed ? input.value : '') : [];
|
|
783
|
+
recentView.active = Math.min(recentView.active, shown.length - 1);
|
|
784
|
+
list.replaceChildren(...shown.map((dir, i) => {
|
|
785
|
+
const option = el('li', 'cwd-recent-option', dir);
|
|
786
|
+
option.id = `cwd-recent-${i}`;
|
|
787
|
+
option.setAttribute('role', 'option');
|
|
788
|
+
option.setAttribute('aria-selected', String(i === recentView.active));
|
|
789
|
+
option.addEventListener('pointerdown', (e) => e.preventDefault());
|
|
790
|
+
option.addEventListener('click', () => pickRecentCwd(dir));
|
|
791
|
+
return option;
|
|
792
|
+
}));
|
|
793
|
+
list.hidden = !shown.length;
|
|
794
|
+
input.setAttribute('aria-expanded', String(!list.hidden));
|
|
795
|
+
if (recentView.active >= 0) input.setAttribute('aria-activedescendant', `cwd-recent-${recentView.active}`);
|
|
796
|
+
else input.removeAttribute('aria-activedescendant');
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function showRecentCwds(typed = false) {
|
|
800
|
+
Object.assign(recentView, { open: true, typed, active: -1 });
|
|
801
|
+
renderRecentCwds();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function hideRecentCwds() {
|
|
805
|
+
Object.assign(recentView, { open: false, active: -1 });
|
|
806
|
+
renderRecentCwds();
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function pickRecentCwd(dir) {
|
|
810
|
+
hideRecentCwds();
|
|
811
|
+
useFolder(dir);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function recentCwdKeys(e) {
|
|
815
|
+
const options = $('cwd-recent').hidden ? [] : [...$('cwd-recent').children];
|
|
816
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
817
|
+
e.preventDefault();
|
|
818
|
+
if (!recentView.open) showRecentCwds();
|
|
819
|
+
const count = $('cwd-recent').children.length;
|
|
820
|
+
if (!count) return;
|
|
821
|
+
recentView.active = e.key === 'ArrowDown' ? (recentView.active + 1) % count : recentView.active <= 0 ? count - 1 : recentView.active - 1;
|
|
822
|
+
renderRecentCwds();
|
|
823
|
+
$(`cwd-recent-${recentView.active}`).scrollIntoView?.({ block: 'nearest' });
|
|
824
|
+
} else if (e.key === 'Enter' && recentView.active >= 0 && options[recentView.active]) {
|
|
825
|
+
e.preventDefault();
|
|
826
|
+
pickRecentCwd(options[recentView.active].textContent);
|
|
827
|
+
} else if (e.key === 'Escape' && options.length) {
|
|
828
|
+
e.preventDefault();
|
|
829
|
+
hideRecentCwds();
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ---- folder browser -------------------------------------------------------
|
|
834
|
+
|
|
835
|
+
const FOLDER_FIELDS = {
|
|
836
|
+
cwd: { title: 'Choose working folder', input: 'cwd' },
|
|
837
|
+
clone: { title: 'Choose clone folder', input: 'github-parent' },
|
|
838
|
+
};
|
|
839
|
+
const folderView = { field: null, listing: null, loading: false, error: null, request: 0, opener: null };
|
|
840
|
+
|
|
841
|
+
function chooseFolder(field) {
|
|
842
|
+
if (!state.connected) return;
|
|
843
|
+
const dialog = $('folder-browser');
|
|
844
|
+
Object.assign(folderView, { field, listing: null, loading: false, error: null });
|
|
845
|
+
$('folder-title').textContent = FOLDER_FIELDS[field].title;
|
|
846
|
+
$('folder-filter').value = '';
|
|
847
|
+
$('folder-hidden').checked = load(HIDDEN_FOLDERS_KEY) === '1';
|
|
848
|
+
if (!dialog.open) {
|
|
849
|
+
folderView.opener = document.activeElement;
|
|
850
|
+
dialog.showModal();
|
|
851
|
+
}
|
|
852
|
+
browseTo($(FOLDER_FIELDS[field].input).value.trim());
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async function browseTo(dir) {
|
|
856
|
+
const request = ++folderView.request;
|
|
857
|
+
folderView.loading = true;
|
|
858
|
+
folderView.error = null;
|
|
859
|
+
renderFolderBrowser();
|
|
860
|
+
try {
|
|
861
|
+
const listing = await api('GET', `/folders?path=${encodeURIComponent(dir)}`);
|
|
862
|
+
if (request !== folderView.request) return;
|
|
863
|
+
folderView.listing = listing;
|
|
864
|
+
$('folder-filter').value = '';
|
|
865
|
+
} catch (err) {
|
|
866
|
+
if (request !== folderView.request) return;
|
|
867
|
+
if (err instanceof AuthError) {
|
|
868
|
+
$('folder-browser').close();
|
|
869
|
+
return showAuth(err.message);
|
|
870
|
+
}
|
|
871
|
+
folderView.error = err.message || 'Could not list this folder.';
|
|
872
|
+
}
|
|
873
|
+
folderView.loading = false;
|
|
874
|
+
renderFolderBrowser();
|
|
875
|
+
if ($('folder-browser').open) ($('folder-list').querySelector('.folder-row') ?? $('folder-use')).focus();
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function renderFolderBrowser() {
|
|
879
|
+
const { listing, loading, error } = folderView;
|
|
880
|
+
const query = $('folder-filter').value.trim().toLowerCase();
|
|
881
|
+
const showHidden = $('folder-hidden').checked;
|
|
882
|
+
$('folder-path').replaceChildren(...(listing?.segments ?? []).map((segment, i, all) => {
|
|
883
|
+
const crumb = button(segment.name, () => browseTo(segment.path), 'folder-crumb');
|
|
884
|
+
if (i === all.length - 1) crumb.setAttribute('aria-current', 'location');
|
|
885
|
+
return crumb;
|
|
886
|
+
}));
|
|
887
|
+
$('folder-roots').replaceChildren(...(listing?.roots ?? []).map((root) => button(root.name, () => browseTo(root.path), 'btn folder-chip')));
|
|
888
|
+
$('folder-up').disabled = loading || !listing?.parent;
|
|
889
|
+
$('folder-home').disabled = loading;
|
|
890
|
+
const note = $('folder-note');
|
|
891
|
+
note.hidden = !listing?.note;
|
|
892
|
+
note.textContent = listing?.note ? `${listing.note} was not found, so this shows the nearest folder that exists.` : '';
|
|
893
|
+
const entries = (listing?.entries ?? []).filter((entry) => (showHidden || !entry.hidden) && (!query || entry.name.toLowerCase().includes(query)));
|
|
894
|
+
$('folder-list').replaceChildren(...entries.map((entry) => {
|
|
895
|
+
const row = button(entry.name, () => browseTo(entry.path), 'folder-row');
|
|
896
|
+
row.classList.toggle('hidden-folder', entry.hidden);
|
|
897
|
+
row.title = entry.path;
|
|
898
|
+
return row;
|
|
899
|
+
}));
|
|
900
|
+
const status = $('folder-status');
|
|
901
|
+
status.textContent = loading ? 'Loading folders…'
|
|
902
|
+
: error ? error
|
|
903
|
+
: !listing ? ''
|
|
904
|
+
: !entries.length ? (listing.entries.length ? 'No folders match.' : 'No folders here.')
|
|
905
|
+
: listing.truncated ? `Showing the first ${listing.entries.length.toLocaleString()} folders.` : '';
|
|
906
|
+
status.classList.toggle('error', Boolean(error));
|
|
907
|
+
$('folder-current').textContent = listing?.path ?? '';
|
|
908
|
+
$('folder-use').disabled = loading || !listing;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function folderBrowserClosed() {
|
|
912
|
+
if (!$('folder-browser').open && folderView.opener?.isConnected) folderView.opener.focus();
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function setCloneParent(dir) {
|
|
916
|
+
$('github-parent').value = dir;
|
|
917
|
+
save(CLONE_PARENT_KEY, dir);
|
|
918
|
+
loadRepos();
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function useBrowsedFolder() {
|
|
922
|
+
const dir = folderView.listing?.path;
|
|
923
|
+
if (!dir || folderView.loading) return;
|
|
924
|
+
const field = folderView.field;
|
|
925
|
+
$('folder-browser').close();
|
|
926
|
+
if (field === 'clone') setCloneParent(dir);
|
|
927
|
+
else {
|
|
928
|
+
rememberCwd(dir);
|
|
929
|
+
useFolder(dir);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function moveInFolders(e) {
|
|
934
|
+
const rows = [...$('folder-list').querySelectorAll('.folder-row')];
|
|
935
|
+
const at = rows.indexOf(document.activeElement);
|
|
936
|
+
const next = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: rows.length - 1 }[e.key];
|
|
937
|
+
if (at === -1 || next === undefined) return;
|
|
938
|
+
e.preventDefault();
|
|
939
|
+
rows[Math.max(0, Math.min(rows.length - 1, next))].focus();
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function folderBrowserKeys(e) {
|
|
943
|
+
if (e.key !== 'Backspace' || e.target.matches('input') || !folderView.listing?.parent || folderView.loading) return;
|
|
944
|
+
e.preventDefault();
|
|
945
|
+
browseTo(folderView.listing.parent);
|
|
946
|
+
}
|
|
947
|
+
|
|
781
948
|
function wsUrl(path) {
|
|
782
949
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
783
950
|
return `${proto}://${location.host}/api/v1${path}?token=${encodeURIComponent(state.token)}`;
|
|
@@ -2249,6 +2416,7 @@ async function startSession(provider, card, { resume, cwd, account = selectedAcc
|
|
|
2249
2416
|
({ session } = await api('POST', '/sessions', { ...body, cwd: working || undefined }));
|
|
2250
2417
|
}
|
|
2251
2418
|
upsertSession(session);
|
|
2419
|
+
rememberCwd(session.cwd);
|
|
2252
2420
|
closeHistory({ focusOpener: false });
|
|
2253
2421
|
openPanel(session.id);
|
|
2254
2422
|
} catch (err) {
|
|
@@ -4257,13 +4425,13 @@ class TerminalView {
|
|
|
4257
4425
|
}
|
|
4258
4426
|
|
|
4259
4427
|
connect() {
|
|
4260
|
-
if (this.disposed) return;
|
|
4428
|
+
if (this.disposed || state.remoteRevoked) return;
|
|
4261
4429
|
const ws = new WebSocket(wsUrl(`/sessions/${this.id}/terminal`));
|
|
4262
4430
|
this.ws = ws;
|
|
4263
4431
|
ws.onopen = () => { this.retry = 0; this.sent = { cols: 0, rows: 0 }; this.sendSize(); };
|
|
4264
4432
|
ws.onmessage = (event) => this.onMessage(JSON.parse(event.data));
|
|
4265
4433
|
ws.onclose = (event) => {
|
|
4266
|
-
if (this.disposed || event.code === 4404 || event.code === 4410) return;
|
|
4434
|
+
if (this.disposed || event.code === 4403 || event.code === 4404 || event.code === 4410) return;
|
|
4267
4435
|
const delay = Math.min(5000, 300 * 2 ** this.retry++);
|
|
4268
4436
|
setTimeout(() => this.connect(), delay);
|
|
4269
4437
|
};
|
|
@@ -4860,7 +5028,7 @@ function showManagerUnavailable() {
|
|
|
4860
5028
|
}
|
|
4861
5029
|
|
|
4862
5030
|
function connectEvents() {
|
|
4863
|
-
if (state.pageAway) return;
|
|
5031
|
+
if (state.pageAway || state.remoteRevoked) return;
|
|
4864
5032
|
const ws = new WebSocket(wsUrl('/events'));
|
|
4865
5033
|
state.eventsSocket = ws;
|
|
4866
5034
|
ws.onopen = () => {
|
|
@@ -4880,7 +5048,8 @@ function connectEvents() {
|
|
|
4880
5048
|
state.restartable = typeof msg.pid === 'number';
|
|
4881
5049
|
state.launcher = typeof msg.launcher === 'string' ? msg.launcher : null;
|
|
4882
5050
|
state.folderOpener = msg.folderOpener || null;
|
|
4883
|
-
state.
|
|
5051
|
+
state.platform = msg.platform || null;
|
|
5052
|
+
state.remoteAccessUI?.setAvailable(msg.remoteAccess);
|
|
4884
5053
|
renderVersion();
|
|
4885
5054
|
setConnection('ok', 'Connected to session manager');
|
|
4886
5055
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
@@ -4895,6 +5064,8 @@ function connectEvents() {
|
|
|
4895
5064
|
// A changelog.updated sent while the socket was down is lost; catch up the open panel.
|
|
4896
5065
|
if ($('changelog').open) loadChangelog();
|
|
4897
5066
|
if (dockShows('github')) loadGitHub();
|
|
5067
|
+
} else if (msg.type === 'remote-access.updated') {
|
|
5068
|
+
state.remoteAccessUI?.updated();
|
|
4898
5069
|
} else if (msg.type === 'news.updated') {
|
|
4899
5070
|
loadNews();
|
|
4900
5071
|
} else if (msg.type === 'github.updated') {
|
|
@@ -4921,8 +5092,15 @@ function connectEvents() {
|
|
|
4921
5092
|
scheduleStats();
|
|
4922
5093
|
}
|
|
4923
5094
|
};
|
|
4924
|
-
ws.onclose = () => {
|
|
5095
|
+
ws.onclose = (event) => {
|
|
4925
5096
|
if (state.pageAway || state.eventsSocket !== ws) return;
|
|
5097
|
+
if (event?.code === 4403) {
|
|
5098
|
+
state.remoteRevoked = true;
|
|
5099
|
+
managerLoss.cancel();
|
|
5100
|
+
state.remoteAccessUI?.close();
|
|
5101
|
+
setConnection('down', 'Remote access changed. Your terminals are still running. Reopen an enabled address to reconnect.');
|
|
5102
|
+
return;
|
|
5103
|
+
}
|
|
4926
5104
|
if (state.managerUnavailable) {
|
|
4927
5105
|
showManagerUnavailable();
|
|
4928
5106
|
} else if (state.stopping && state.stopRemaining !== null) {
|
|
@@ -4964,6 +5142,7 @@ let statsInterval;
|
|
|
4964
5142
|
let newsTimer;
|
|
4965
5143
|
|
|
4966
5144
|
function showAuth(message = '') {
|
|
5145
|
+
state.remoteAccessUI?.setAvailable(null);
|
|
4967
5146
|
managerLoss.cancel();
|
|
4968
5147
|
closePanel();
|
|
4969
5148
|
closeModels();
|
|
@@ -5132,10 +5311,8 @@ $('github-views').addEventListener('click', (e) => {
|
|
|
5132
5311
|
if (view) switchGitHubView(view);
|
|
5133
5312
|
});
|
|
5134
5313
|
$('github-views').addEventListener('keydown', moveGitHubView);
|
|
5135
|
-
$('github-parent').addEventListener('change', () =>
|
|
5136
|
-
|
|
5137
|
-
loadRepos();
|
|
5138
|
-
});
|
|
5314
|
+
$('github-parent').addEventListener('change', () => setCloneParent(cloneParent()));
|
|
5315
|
+
$('github-parent-pick').addEventListener('click', firstClick(() => chooseFolder('clone')));
|
|
5139
5316
|
$('history-here').addEventListener('change', renderHistory);
|
|
5140
5317
|
$('history-form').addEventListener('submit', resumeById);
|
|
5141
5318
|
$('models').addEventListener('click', (e) => { if (e.target === $('models')) closeModels(); });
|
|
@@ -5358,7 +5535,25 @@ $('panel-stop').addEventListener('click', () => {
|
|
|
5358
5535
|
else removeSession(s.id);
|
|
5359
5536
|
});
|
|
5360
5537
|
$('cwd').value = load(CWD_KEY) || '';
|
|
5361
|
-
$('cwd-pick').addEventListener('click', firstClick(
|
|
5538
|
+
$('cwd-pick').addEventListener('click', firstClick(() => chooseFolder('cwd')));
|
|
5539
|
+
$('cwd').addEventListener('focus', () => showRecentCwds());
|
|
5540
|
+
$('cwd').addEventListener('click', () => { if (!recentView.open) showRecentCwds(); });
|
|
5541
|
+
$('cwd').addEventListener('input', () => showRecentCwds(true));
|
|
5542
|
+
$('cwd').addEventListener('blur', hideRecentCwds);
|
|
5543
|
+
$('cwd').addEventListener('keydown', recentCwdKeys);
|
|
5544
|
+
$('folder-cancel').addEventListener('click', () => $('folder-browser').close());
|
|
5545
|
+
$('folder-browser').addEventListener('click', (e) => { if (e.target === $('folder-browser')) $('folder-browser').close(); });
|
|
5546
|
+
$('folder-browser').addEventListener('close', folderBrowserClosed);
|
|
5547
|
+
$('folder-browser').addEventListener('keydown', folderBrowserKeys);
|
|
5548
|
+
$('folder-list').addEventListener('keydown', moveInFolders);
|
|
5549
|
+
$('folder-up').addEventListener('click', () => browseTo(folderView.listing.parent));
|
|
5550
|
+
$('folder-home').addEventListener('click', () => browseTo('~'));
|
|
5551
|
+
$('folder-filter').addEventListener('input', renderFolderBrowser);
|
|
5552
|
+
$('folder-hidden').addEventListener('change', (e) => {
|
|
5553
|
+
save(HIDDEN_FOLDERS_KEY, e.target.checked ? '1' : null);
|
|
5554
|
+
renderFolderBrowser();
|
|
5555
|
+
});
|
|
5556
|
+
$('folder-use').addEventListener('click', useBrowsedFolder);
|
|
5362
5557
|
$('cwd-open').addEventListener('click', firstClick(openWorkingFolder));
|
|
5363
5558
|
try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
|
|
5364
5559
|
try { state.shellPicks = JSON.parse(load(SHELLS_KEY)) || {}; } catch { state.shellPicks = {}; }
|
|
@@ -5380,5 +5575,6 @@ addEventListener('scroll', publishTopbarHeight, { passive: true });
|
|
|
5380
5575
|
publishTopbarHeight();
|
|
5381
5576
|
applyDockLayout();
|
|
5382
5577
|
|
|
5578
|
+
state.remoteAccessUI = createRemoteAccessUI({ api, getToken: () => state.token, isConnected: () => state.connected, onAuthError: showAuth });
|
|
5383
5579
|
state.token = readTokenFromHash() || load(TOKEN_KEY);
|
|
5384
5580
|
boot();
|
package/web/folders.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const RECENT_FOLDERS_MAX = 5;
|
|
2
|
+
|
|
3
|
+
export function readRecentFolders(text) {
|
|
4
|
+
let list;
|
|
5
|
+
try { list = JSON.parse(text); } catch { return []; }
|
|
6
|
+
return Array.isArray(list) ? list.filter((dir) => typeof dir === 'string' && dir.trim()).slice(0, RECENT_FOLDERS_MAX) : [];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function rememberFolder(list, dir, { caseless = false } = {}) {
|
|
10
|
+
const key = (value) => (caseless ? value.toLowerCase() : value);
|
|
11
|
+
return [dir, ...list.filter((known) => key(known) !== key(dir))].slice(0, RECENT_FOLDERS_MAX);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function matchFolders(list, query) {
|
|
15
|
+
const wanted = query.trim().toLowerCase();
|
|
16
|
+
return wanted ? list.filter((dir) => dir.toLowerCase().includes(wanted)) : list;
|
|
17
|
+
}
|