bridgerator 1.0.8 → 1.1.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 +76 -14
- package/assets/bridgerator.ico +0 -0
- package/assets/icon-16.png +0 -0
- package/assets/icon-20.png +0 -0
- package/assets/icon-24.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon-32.png +0 -0
- package/assets/icon-48.png +0 -0
- package/assets/icon-64.png +0 -0
- package/assets/logo.svg +1 -0
- package/install.ps1 +455 -56
- package/npm-shrinkwrap.json +8621 -0
- package/package.json +55 -49
- package/recipes/backup-folder.json +31 -0
- package/recipes/clean-downloads.json +48 -0
- package/recipes/disk-report.json +66 -0
- package/recipes/sort-folder.json +49 -0
- package/recipes/update-apps.json +25 -0
- package/src/ask-lib.ps1 +230 -0
- package/src/ask.mjs +51 -0
- package/src/ask.ps1 +17 -0
- package/src/automations.mjs +673 -0
- package/src/batch.mjs +221 -0
- package/src/bridge.mjs +720 -29
- package/src/cli.mjs +529 -74
- package/src/confirm.mjs +418 -0
- package/src/dc-config-hooks.mjs +18 -8
- package/src/dc-isolate.mjs +5 -0
- package/src/delegate.mjs +555 -0
- package/src/docs-tool.mjs +494 -0
- package/src/doctor.mjs +200 -0
- package/src/events.mjs +21 -0
- package/src/extra-tools.mjs +122 -0
- package/src/guard.mjs +34 -0
- package/src/notice.mjs +84 -23
- package/src/pair.mjs +27 -4
- package/src/pc-check.mjs +112 -0
- package/src/pc-check.ps1 +103 -0
- package/src/pc-text.mjs +94 -0
- package/src/pdf-font.mjs +245 -0
- package/src/photos-tool.mjs +187 -0
- package/src/photos.ps1 +145 -0
- package/src/plan.mjs +90 -0
- package/src/policy.mjs +293 -124
- package/src/queue.mjs +194 -0
- package/src/recent.mjs +48 -0
- package/src/recipes.mjs +108 -0
- package/src/recycle.mjs +185 -0
- package/src/recycle.ps1 +110 -0
- package/src/screen-tool.mjs +185 -0
- package/src/screen.ps1 +285 -0
- package/src/search-tool.mjs +189 -0
- package/src/search.ps1 +129 -0
- package/src/settings-tool.mjs +186 -0
- package/src/setup-tool.mjs +129 -0
- package/src/software-tool.mjs +185 -0
- package/src/telemetry.mjs +86 -0
- package/src/tool-lib.mjs +177 -0
- package/src/tool-text.mjs +45 -0
- package/src/tray.mjs +97 -0
- package/src/tray.ps1 +359 -0
- package/src/undo.mjs +313 -0
- package/src/win.mjs +17 -0
- package/ui/app.css +388 -0
- package/ui/app.js +868 -0
- package/ui/i18n/en.json +598 -0
- package/ui/i18n/ru.json +598 -0
- package/ui/index.html +26 -0
- package/vendor/webview2/LICENSE.txt +27 -0
- package/vendor/webview2/Microsoft.Web.WebView2.Core.dll +0 -0
- package/vendor/webview2/Microsoft.Web.WebView2.WinForms.dll +0 -0
- package/vendor/webview2/NOTICE.txt +85 -0
- package/vendor/webview2/SHA256SUMS.txt +4 -0
- package/vendor/webview2/SOURCE.txt +1 -0
- package/vendor/webview2/arm64/WebView2Loader.dll +0 -0
- package/vendor/webview2/x64/WebView2Loader.dll +0 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Extra tools" / fast_search). Instant search: Everything (if
|
|
2
|
+
// installed and running) → the Windows Search index (SystemIndex over OLE DB) → the engine's own
|
|
3
|
+
// search (start_search, already under the policy). Every hit goes through the same folder rules as the
|
|
4
|
+
// file tools (visiblePath): excluded folders and folders outside the allowed list never show up.
|
|
5
|
+
// Queries are built here (escaped); search.ps1 only runs them.
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { psJson, guard, visiblePath, fail, json, needAction, str, int, ToolError, errorText } from './tool-lib.mjs';
|
|
9
|
+
|
|
10
|
+
const PS = fileURLToPath(new URL('./search.ps1', import.meta.url));
|
|
11
|
+
const ACTIONS = ['files', 'text', 'status'];
|
|
12
|
+
const MAX = 500;
|
|
13
|
+
const POLL_MS = 400, BASIC_WAIT_MS = 20000;
|
|
14
|
+
|
|
15
|
+
export const defs = [{
|
|
16
|
+
name: 'fast_search',
|
|
17
|
+
title: 'Fast search',
|
|
18
|
+
description: 'Fast file search on this PC. action "files": names or masks like *.pdf; "text": words inside documents (Windows Search index); "status": which search sources this PC has. Uses Everything when it is installed, then the Windows Search index, then a plain folder scan. Results follow the folder rules of this PC.',
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: 'object', additionalProperties: false, required: ['action'],
|
|
21
|
+
properties: {
|
|
22
|
+
action: { type: 'string', enum: ACTIONS },
|
|
23
|
+
query: { type: 'string', maxLength: 300, description: 'Name, mask (* ?) or words' },
|
|
24
|
+
folder: { type: 'string', description: 'Search only inside this folder (full path)' },
|
|
25
|
+
kind: { type: 'string', maxLength: 12, description: 'File extension, e.g. pdf or .docx' },
|
|
26
|
+
limit: { type: 'integer', minimum: 1, maximum: MAX, description: 'Default 100' },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
|
30
|
+
}];
|
|
31
|
+
|
|
32
|
+
const b64 = (s) => Buffer.from(String(s), 'utf8').toString('base64');
|
|
33
|
+
const sq = (s) => String(s).replace(/'/g, "''"); // SQL string literal
|
|
34
|
+
const ctl = (s) => String(s).replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
|
|
35
|
+
export function extOf(kind) {
|
|
36
|
+
if (kind === undefined) return undefined;
|
|
37
|
+
const k = String(kind).trim().replace(/^\*?\./, '');
|
|
38
|
+
if (!/^[\p{L}\p{N}_-]{1,10}$/u.test(k)) fail('BAD_ARGS', 'kind must be a file extension like pdf or .docx.');
|
|
39
|
+
return k.toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
/** Name / mask → LIKE pattern: LIKE's own specials are literal, * ? are the wildcards. */
|
|
42
|
+
export function likePattern(q) {
|
|
43
|
+
const lit = ctl(q).replace(/[[%_]/g, (c) => `[${c}]`);
|
|
44
|
+
const w = lit.replace(/\*/g, '%').replace(/\?/g, '_');
|
|
45
|
+
return /[*?]/.test(q) ? w : `%${w}%`;
|
|
46
|
+
}
|
|
47
|
+
const scopeUrl = (d) => `file:${d.replace(/\\/g, '/')}`;
|
|
48
|
+
/** The SystemIndex query for files / text, scoped to folders (none = whole index). */
|
|
49
|
+
export function indexSql({ action, query, folders = [], ext, limit }) {
|
|
50
|
+
const where = [];
|
|
51
|
+
if (folders.length) where.push(`(${folders.map((d) => `SCOPE='${sq(scopeUrl(d))}'`).join(' OR ')})`);
|
|
52
|
+
if (action === 'text') { where.push(`FREETEXT('${sq(ctl(query))}')`); where.push("System.ItemType <> 'Directory'"); }
|
|
53
|
+
else if (query) where.push(`System.FileName LIKE '${sq(likePattern(query))}'`);
|
|
54
|
+
if (ext) where.push(`System.FileExtension = '.${sq(ext)}'`);
|
|
55
|
+
return `SELECT TOP ${limit} System.ItemUrl, System.Size, System.DateModified FROM SystemIndex${where.length ? ` WHERE ${where.join(' AND ')}` : ''} ORDER BY System.DateModified DESC`;
|
|
56
|
+
}
|
|
57
|
+
export const coverSql = (folder) => `SELECT TOP 1 System.ItemUrl FROM SystemIndex WHERE SCOPE='${sq(scopeUrl(folder))}'`;
|
|
58
|
+
/** es.exe arguments: words only — nothing that starts like an option (-export-…, /a) reaches it. Every
|
|
59
|
+
* double quote goes first (a quoted "-export-txt" or a"b must never turn into a switch), then a leading - or /. */
|
|
60
|
+
export function esArgs({ query, folder, ext, limit }) {
|
|
61
|
+
const words = ctl(query || '').replace(/["\u201c\u201d]/g, '').split(/\s+/).map((w) => w.replace(/^[-/]+/, '')).filter(Boolean);
|
|
62
|
+
return ['-n', String(limit), ...(folder ? ['-path', folder] : []), ...(ext ? [`ext:${ext}`] : []), ...words];
|
|
63
|
+
}
|
|
64
|
+
export const urlToPath = (u) => String(u).replace(/^file:/i, '').replace(/\//g, '\\');
|
|
65
|
+
|
|
66
|
+
const run = (ctx, env, timeout = 30000) => psJson(ctx, { files: [PS], env, timeout });
|
|
67
|
+
async function status(ctx, folder) {
|
|
68
|
+
return run(ctx, { BRX_MODE: 'status', ...(folder ? { BRX_SCOPE_SQL: b64(coverSql(folder)) } : {}) }, 20000);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function keep(ctx, items, limit) {
|
|
72
|
+
const out = [];
|
|
73
|
+
let hidden = 0;
|
|
74
|
+
for (const it of items) {
|
|
75
|
+
if (!it?.path || typeof it.path !== 'string') continue;
|
|
76
|
+
if (!visiblePath(ctx, it.path)) { hidden++; continue; }
|
|
77
|
+
out.push({ path: it.path, ...(Number.isFinite(it.size) ? { size: it.size } : {}), ...(it.modified ? { modified: it.modified } : {}), ...(it.dir ? { folder: true } : {}) });
|
|
78
|
+
if (out.length >= limit) break;
|
|
79
|
+
}
|
|
80
|
+
return { items: out, hidden };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The engine's own search: "📁 path" (files) / "📄 path:line - text" (content) lines.
|
|
84
|
+
export function parseBasic(text) {
|
|
85
|
+
const items = [];
|
|
86
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
87
|
+
let m = /^📁 (.+)$/u.exec(line);
|
|
88
|
+
if (m) { items.push({ path: m[1].trim() }); continue; }
|
|
89
|
+
m = /^📄 (.+?):(\d+) - (.*)$/u.exec(line);
|
|
90
|
+
if (m && !items.some((x) => x.path === m[1])) items.push({ path: m[1], line: Number(m[2]), match: m[3].slice(0, 200) });
|
|
91
|
+
}
|
|
92
|
+
return items;
|
|
93
|
+
}
|
|
94
|
+
const textOf = (r) => (r?.content || []).filter((c) => c?.type === 'text').map((c) => c.text).join('\n');
|
|
95
|
+
async function basic(ctx, { action, query, folder, ext, limit }, wait = BASIC_WAIT_MS) {
|
|
96
|
+
if (!ctx.engine) throw new ToolError('NO_BASIC', 'The plain folder search is not available here.');
|
|
97
|
+
const root = folder || ctx.cfg?.allowedDirectories?.[0] || process.env.USERPROFILE;
|
|
98
|
+
if (!root) throw new ToolError('NO_FOLDER', 'Say which folder to search.', 'Pass folder.');
|
|
99
|
+
// Names: the engine takes masks (* ?) as globs and plain text as "contains"; content: literal text.
|
|
100
|
+
const started = await ctx.engine('start_search', { path: root, pattern: query, searchType: action === 'text' ? 'content' : 'files', ...(ext ? { filePattern: `*.${ext}` } : {}), maxResults: limit, literalSearch: true });
|
|
101
|
+
const t0 = textOf(started);
|
|
102
|
+
if (started?.isError) throw new ToolError('BASIC_FAILED', t0.slice(0, 300) || 'The folder search did not start.');
|
|
103
|
+
const id = /session:\s*(\S+)/i.exec(t0)?.[1];
|
|
104
|
+
if (!id) return parseBasic(t0);
|
|
105
|
+
let all = [], done = /COMPLETED/.test(t0);
|
|
106
|
+
const until = Date.now() + wait;
|
|
107
|
+
try {
|
|
108
|
+
for (;;) {
|
|
109
|
+
const r = await ctx.engine('get_more_search_results', { sessionId: id, offset: 0, length: limit });
|
|
110
|
+
const t = textOf(r);
|
|
111
|
+
all = parseBasic(t);
|
|
112
|
+
done = /Status:\s*COMPLETED/i.test(t);
|
|
113
|
+
if (done || r?.isError || Date.now() > until) break;
|
|
114
|
+
await new Promise((res) => setTimeout(res, POLL_MS));
|
|
115
|
+
}
|
|
116
|
+
} finally {
|
|
117
|
+
// Also when a poll throws (engine timeout / restart): the search must not keep running.
|
|
118
|
+
if (!done) await Promise.resolve().then(() => ctx.engine('stop_search', { sessionId: id })).catch(() => null);
|
|
119
|
+
}
|
|
120
|
+
return all;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function handle(name, args, ctx) {
|
|
124
|
+
const action = needAction(args, ACTIONS);
|
|
125
|
+
const folder = args.folder !== undefined ? guard(ctx, str(args, 'folder', { max: 1000 })) : undefined;
|
|
126
|
+
if (action === 'status') {
|
|
127
|
+
const s = await status(ctx, folder);
|
|
128
|
+
return json({
|
|
129
|
+
everything: s.everything?.path ? (s.everything.running ? 'ready' : 'installed, not running') : s.everything?.untrusted ? 'not used (es.exe is outside Program Files and not signed by voidtools)' : 'not installed',
|
|
130
|
+
windows_index: s.index?.service === 'Running' ? 'ready' : `not available (service ${String(s.index?.service || 'unknown')})`,
|
|
131
|
+
...(folder ? { folder, folder_indexed: s.index?.covered === true ? 'yes' : s.index?.covered === false ? 'no (not in the index — the plain scan is used)' : 'unknown' } : {}),
|
|
132
|
+
plain_scan: 'always available',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const query = str(args, 'query', { required: true, max: 300 });
|
|
136
|
+
const limit = int(args, 'limit', { min: 1, max: MAX, def: 100 });
|
|
137
|
+
const ext = extOf(args.kind);
|
|
138
|
+
const allowed = ctx.cfg?.allowedDirectories || [];
|
|
139
|
+
const folders = folder ? [folder] : allowed.map((d) => path.resolve(d));
|
|
140
|
+
const want = Math.min(MAX, limit * 2); // room for hits the folder rules hide
|
|
141
|
+
const tried = [];
|
|
142
|
+
let firstError = null;
|
|
143
|
+
const note = (src, e) => { tried.push(`${src}: ${e instanceof ToolError ? e.code : e?.code || 'error'}`); firstError ||= e; };
|
|
144
|
+
// null = every hit was hidden by the folder rules → the next source is tried.
|
|
145
|
+
const answer = (source, items, extra = {}) => {
|
|
146
|
+
const k = keep(ctx, items, limit);
|
|
147
|
+
if (!k.items.length && k.hidden) { tried.push(`${source}: ${k.hidden} result(s) hidden by this PC's folder rules`); return null; }
|
|
148
|
+
return json({ source, count: k.items.length, results: k.items, ...(k.hidden ? { hidden: `${k.hidden} result(s) hidden by this PC's folder rules` } : {}), ...(tried.length ? { skipped_sources: tried } : {}), ...extra });
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
let st = null;
|
|
152
|
+
try { st = await status(ctx, folder); } catch (e) { note('status', e); }
|
|
153
|
+
|
|
154
|
+
if (action === 'files' && st?.everything?.path && st.everything.running) {
|
|
155
|
+
try {
|
|
156
|
+
// Scoped like the index: the folder, or the one allowed folder (several: hits are filtered below).
|
|
157
|
+
const esFolder = folder || (folders.length === 1 ? folders[0] : undefined);
|
|
158
|
+
const r = await run(ctx, { BRX_MODE: 'everything', BRX_ES: st.everything.path, BRX_ARGS: b64(JSON.stringify(esArgs({ query, folder: esFolder, ext, limit: want }))) });
|
|
159
|
+
if (r.error) throw new ToolError(r.error, r.error === 'ES_START_FAILED' ? 'Everything\'s es.exe could not start (an antivirus may block it).' : 'Everything did not answer.');
|
|
160
|
+
const a = r.items?.length ? answer('everything', r.items) : null;
|
|
161
|
+
if (a) return a;
|
|
162
|
+
if (!r.items?.length) tried.push('everything: nothing found');
|
|
163
|
+
} catch (e) { note('everything', e); }
|
|
164
|
+
} else if (action === 'files') tried.push(`everything: ${st?.everything?.path ? 'not running' : 'not installed'}`);
|
|
165
|
+
|
|
166
|
+
let indexNote;
|
|
167
|
+
if (st?.index?.service === 'Running') {
|
|
168
|
+
try {
|
|
169
|
+
const r = await run(ctx, { BRX_MODE: 'index', BRX_SQL: b64(indexSql({ action, query, folders, ext, limit: want })) });
|
|
170
|
+
if (r.error) throw new ToolError(r.error, 'The Windows Search index did not answer.');
|
|
171
|
+
const items = (r.items || []).map((x) => ({ ...x, path: urlToPath(x.url) }));
|
|
172
|
+
const a = items.length ? answer('windows_index', items, folder && st.index.covered === false ? { note: 'This folder is not in the Windows Search index; results may be incomplete.' } : {}) : null;
|
|
173
|
+
if (a) return a;
|
|
174
|
+
if (!items.length) {
|
|
175
|
+
indexNote = folder && st.index.covered === false ? 'This folder is not in the Windows Search index' : 'Nothing found in the Windows Search index';
|
|
176
|
+
tried.push(`windows_index: ${indexNote.toLowerCase()}`);
|
|
177
|
+
}
|
|
178
|
+
} catch (e) { note('windows_index', e); }
|
|
179
|
+
} else tried.push(`windows_index: service ${String(st?.index?.service || 'unknown')}`);
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const items = await basic(ctx, { action, query, folder, ext, limit: want });
|
|
183
|
+
return answer('basic', items, { note: `Plain folder scan${indexNote ? ` (${indexNote.toLowerCase()})` : ''}.` })
|
|
184
|
+
|| json({ source: 'basic', count: 0, results: [], skipped_sources: tried });
|
|
185
|
+
} catch (e) {
|
|
186
|
+
note('basic', e);
|
|
187
|
+
throw new ToolError('SEARCH_FAILED', `Search did not work: ${errorText(firstError).replace(/\s*\(code [A-Z_0-9]+\)$/, '')}`, 'Pass a folder, or check the PC with bridgerator_doctor.');
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/search.ps1
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# LOGIC: see critical-logic/bridge.md ("Extra tools" / fast_search). Instant search sources for the
|
|
2
|
+
# bridge (search-tool.mjs builds every query; this script only runs it).
|
|
3
|
+
# In (env): BRX_MODE = status | everything | index; BRX_ES (es.exe path), BRX_ARGS (base64 UTF-8 JSON
|
|
4
|
+
# array of es.exe arguments), BRX_SQL (base64 UTF-8 SystemIndex query), BRX_SCOPE_SQL (status: the
|
|
5
|
+
# coverage query, optional), BRX_ES_WAIT (ms es.exe may run, default 20000). Out: ONE JSON line. Errors are codes, never localized message text.
|
|
6
|
+
function Out-Json($o) { [Console]::Out.WriteLine(($o | ConvertTo-Json -Compress -Depth 5)) }
|
|
7
|
+
function From-B64($s) { if (-not $s) { return '' }; [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($s)) }
|
|
8
|
+
|
|
9
|
+
# es.exe runs only from a place a standard user cannot write (Program Files, Windows) or when it is
|
|
10
|
+
# signed by voidtools: a planted es.exe on the PATH / in a user folder never runs.
|
|
11
|
+
function Test-TrustedEs([string]$p) {
|
|
12
|
+
if (-not $p -or -not (Test-Path -LiteralPath $p -PathType Leaf)) { return $false }
|
|
13
|
+
$full = [IO.Path]::GetFullPath($p)
|
|
14
|
+
foreach ($d in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) {
|
|
15
|
+
if ($d -and $full.StartsWith(([IO.Path]::GetFullPath($d).TrimEnd('\') + '\'), [StringComparison]::OrdinalIgnoreCase)) { return $true }
|
|
16
|
+
}
|
|
17
|
+
# Windows itself: only the folder and System32 (subfolders like Temp are writable by users).
|
|
18
|
+
$parent = [IO.Path]::GetDirectoryName($full)
|
|
19
|
+
if ($env:SystemRoot -and @($env:SystemRoot, (Join-Path $env:SystemRoot 'System32')) -contains $parent) { return $true }
|
|
20
|
+
try {
|
|
21
|
+
$sig = Get-AuthenticodeSignature -LiteralPath $full
|
|
22
|
+
return ($sig.Status -eq 'Valid' -and [string]$sig.SignerCertificate.Subject -match '(^|,\s*)(CN|O)=voidtools(,|$)')
|
|
23
|
+
} catch { return $false }
|
|
24
|
+
}
|
|
25
|
+
function Find-Es {
|
|
26
|
+
$found = $null
|
|
27
|
+
$cands = @(Get-Command es.exe -CommandType Application -All -ErrorAction SilentlyContinue | ForEach-Object { $_.Source })
|
|
28
|
+
foreach ($d in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, (Join-Path $env:LOCALAPPDATA 'Programs'))) { if ($d) { $cands += (Join-Path $d 'Everything\es.exe') } }
|
|
29
|
+
foreach ($p in $cands) {
|
|
30
|
+
if (-not $p -or -not (Test-Path -LiteralPath $p -PathType Leaf)) { continue }
|
|
31
|
+
if (Test-TrustedEs $p) { return @{ path = $p } }
|
|
32
|
+
if (-not $found) { $found = $p }
|
|
33
|
+
}
|
|
34
|
+
return @{ path = $null; untrusted = $found }
|
|
35
|
+
}
|
|
36
|
+
# One Windows command-line argument (words carry no quotes: search-tool.mjs strips them). Quoted, a
|
|
37
|
+
# trailing \ is doubled: "C:\My Files\" would end in \" (a literal quote) and swallow the next arguments.
|
|
38
|
+
function Quote-Arg([string]$a) { if ($a -match '[\s"]' -or -not $a) { return '"' + (($a -replace '"', '') -replace '(\\+)$', '$1$1') + '"' }; return $a }
|
|
39
|
+
|
|
40
|
+
function Invoke-Index($sql) {
|
|
41
|
+
$conn = $null; $rs = $null
|
|
42
|
+
try {
|
|
43
|
+
$conn = New-Object -ComObject ADODB.Connection
|
|
44
|
+
$conn.Open("Provider=Search.CollatorDSO;Extended Properties='Application=Windows';")
|
|
45
|
+
$rs = New-Object -ComObject ADODB.Recordset
|
|
46
|
+
$rs.Open($sql, $conn)
|
|
47
|
+
$items = New-Object System.Collections.ArrayList
|
|
48
|
+
while (-not $rs.EOF) {
|
|
49
|
+
$url = [string]$rs.Fields.Item('System.ItemUrl').Value
|
|
50
|
+
$size = $rs.Fields.Item('System.Size').Value
|
|
51
|
+
$mod = $rs.Fields.Item('System.DateModified').Value
|
|
52
|
+
$o = @{ url = $url }
|
|
53
|
+
if ($size -ne $null -and $size -isnot [DBNull]) { $o.size = [int64]$size }
|
|
54
|
+
if ($mod -is [DateTime]) { $o.modified = $mod.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') }
|
|
55
|
+
[void]$items.Add($o)
|
|
56
|
+
$rs.MoveNext()
|
|
57
|
+
}
|
|
58
|
+
return @{ items = @($items) }
|
|
59
|
+
} catch {
|
|
60
|
+
return @{ error = 'INDEX_UNAVAILABLE'; hresult = ('0x{0:X8}' -f $_.Exception.HResult) }
|
|
61
|
+
} finally {
|
|
62
|
+
if ($rs) { try { $rs.Close() } catch { } }
|
|
63
|
+
if ($conn) { try { $conn.Close() } catch { } }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
$mode = $env:BRX_MODE
|
|
68
|
+
if ($mode -eq 'status') {
|
|
69
|
+
$es = Find-Es
|
|
70
|
+
$running = [bool](Get-Process -Name 'Everything', 'Everything64' -ErrorAction SilentlyContinue)
|
|
71
|
+
$svc = Get-Service -Name WSearch -ErrorAction SilentlyContinue
|
|
72
|
+
$state = if ($svc) { [string]$svc.Status } else { 'Missing' }
|
|
73
|
+
$covered = $null
|
|
74
|
+
$scopeSql = From-B64 $env:BRX_SCOPE_SQL
|
|
75
|
+
if ($scopeSql -and $state -eq 'Running') {
|
|
76
|
+
$r = Invoke-Index $scopeSql
|
|
77
|
+
$covered = if ($r.error) { $null } else { $r.items.Count -gt 0 }
|
|
78
|
+
}
|
|
79
|
+
Out-Json @{ everything = @{ path = $es.path; untrusted = [bool]$es.untrusted; running = $running }; index = @{ service = $state; covered = $covered } }
|
|
80
|
+
exit 0
|
|
81
|
+
}
|
|
82
|
+
if ($mode -eq 'everything') {
|
|
83
|
+
$es = $env:BRX_ES
|
|
84
|
+
if (-not $es -or -not (Test-Path -LiteralPath $es)) { Out-Json @{ error = 'ES_MISSING' }; exit 0 }
|
|
85
|
+
# Held open (others may only read) from the trust check until it has started: it can't be swapped in between.
|
|
86
|
+
$hold = $null; try { $hold = [IO.File]::Open($es, 'Open', 'Read', 'Read') } catch { Out-Json @{ error = 'ES_START_FAILED' }; exit 0 }
|
|
87
|
+
if (-not (Test-TrustedEs $es)) { $hold.Dispose(); Out-Json @{ error = 'ES_UNTRUSTED' }; exit 0 }
|
|
88
|
+
# PowerShell 5.1 ConvertFrom-Json emits an array as ONE object: enumerate it into separate arguments.
|
|
89
|
+
$argv = @((From-B64 $env:BRX_ARGS) | ConvertFrom-Json | ForEach-Object { $_ })
|
|
90
|
+
# The export file lives in our own cache folder (not TEMP); es.exe is stopped when it takes too long.
|
|
91
|
+
$dir = Join-Path $env:LOCALAPPDATA 'Bridgerator\cache'
|
|
92
|
+
[void][IO.Directory]::CreateDirectory($dir)
|
|
93
|
+
$tmp = Join-Path $dir ('es-' + [guid]::NewGuid().ToString('N') + '.efu')
|
|
94
|
+
$wait = 20000; $w = 0; if ([int]::TryParse([string]$env:BRX_ES_WAIT, [ref]$w) -and $w -gt 0) { $wait = $w }
|
|
95
|
+
$proc = $null
|
|
96
|
+
try {
|
|
97
|
+
$psi = New-Object Diagnostics.ProcessStartInfo
|
|
98
|
+
$psi.FileName = $es
|
|
99
|
+
$psi.Arguments = ((@($argv) + @('-export-efu', $tmp)) | ForEach-Object { Quote-Arg ([string]$_) }) -join ' '
|
|
100
|
+
# Its own pipes (our stdout stays the one JSON line for the bridge), both drained at once: a full pipe
|
|
101
|
+
# (usage / error text) would block es.exe until the kill.
|
|
102
|
+
$psi.UseShellExecute = $false; $psi.CreateNoWindow = $true; $psi.RedirectStandardOutput = $true; $psi.RedirectStandardError = $true
|
|
103
|
+
try { $proc = [Diagnostics.Process]::Start($psi) } catch { Out-Json @{ error = 'ES_START_FAILED' }; exit 0 } finally { $hold.Dispose() }
|
|
104
|
+
[void]$proc.StandardOutput.ReadToEndAsync(); [void]$proc.StandardError.ReadToEndAsync()
|
|
105
|
+
if (-not $proc.WaitForExit($wait)) { try { $proc.Kill() } catch { }; [void]$proc.WaitForExit(5000); Out-Json @{ error = 'ES_TIMEOUT' }; exit 0 }
|
|
106
|
+
$code = $proc.ExitCode
|
|
107
|
+
if ($code -ne 0) { Out-Json @{ error = ('ES_EXIT_' + $code) }; exit 0 }
|
|
108
|
+
$items = New-Object System.Collections.ArrayList
|
|
109
|
+
if (Test-Path -LiteralPath $tmp) {
|
|
110
|
+
foreach ($row in (Import-Csv -LiteralPath $tmp -Encoding UTF8)) {
|
|
111
|
+
$o = @{ path = [string]$row.Filename }
|
|
112
|
+
$s = 0L; if ([int64]::TryParse([string]$row.Size, [ref]$s)) { $o.size = $s }
|
|
113
|
+
$ft = 0L; if ([int64]::TryParse([string]$row.'Date Modified', [ref]$ft) -and $ft -gt 0) { $o.modified = [DateTime]::FromFileTimeUtc($ft).ToString('yyyy-MM-ddTHH:mm:ssZ') }
|
|
114
|
+
$a = 0; if ([int]::TryParse([string]$row.Attributes, [ref]$a)) { $o.dir = [bool]($a -band 16) }
|
|
115
|
+
[void]$items.Add($o)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
Out-Json @{ items = @($items) }
|
|
119
|
+
} finally {
|
|
120
|
+
if ($proc) { try { if (-not $proc.HasExited) { $proc.Kill() } } catch { }; $proc.Dispose() }
|
|
121
|
+
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
|
|
122
|
+
}
|
|
123
|
+
exit 0
|
|
124
|
+
}
|
|
125
|
+
if ($mode -eq 'index') {
|
|
126
|
+
Out-Json (Invoke-Index (From-B64 $env:BRX_SQL))
|
|
127
|
+
exit 0
|
|
128
|
+
}
|
|
129
|
+
Out-Json @{ error = 'BAD_MODE' }
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// bridgerator_settings: ChatGPT can read every setting of the Bridgerator window and change them.
|
|
2
|
+
// Changes apply at once (owner 21.10). With "Ask before AI changes settings" (askSettings, off by default)
|
|
3
|
+
// anything that lowers protection or widens access needs Apply on the PC.
|
|
4
|
+
// LOGIC: see critical-logic/bridge.md ("Settings", "Access presets").
|
|
5
|
+
import { CATEGORIES, LABEL, readSettings } from './confirm.mjs';
|
|
6
|
+
import { validateSetup } from './setup-tool.mjs';
|
|
7
|
+
import { SNAP_DEFAULTS } from './undo.mjs';
|
|
8
|
+
import { FUSE_DEFAULTS } from './guard.mjs';
|
|
9
|
+
import { extraRegistry } from './extra-tools.mjs';
|
|
10
|
+
|
|
11
|
+
// Settings of the extra tools (screen capture…): declared by their modules (extra-tools.mjs).
|
|
12
|
+
const EXTRA = extraRegistry();
|
|
13
|
+
|
|
14
|
+
const BOOL = { type: 'boolean' };
|
|
15
|
+
const b = (description) => ({ type: 'boolean', description });
|
|
16
|
+
const int = (minimum, maximum, description) => ({ type: 'integer', minimum, maximum, description });
|
|
17
|
+
export const PRESETS = ['read_only', 'files', 'full'];
|
|
18
|
+
export const TIMEOUTS = [60, 120, 300];
|
|
19
|
+
/** What a fresh install has (owner 21.10: nothing restricted, like the engine on its own). */
|
|
20
|
+
export const DEFAULTS = {
|
|
21
|
+
preset: 'full', terminal: true, allowedDirectories: [], readOnlyDirectories: [], protectSensitive: false,
|
|
22
|
+
confirm: { enabled: false, ...readSettings('').ask, timeoutSeconds: 120 }, // no confirm.ini = the defaults per category
|
|
23
|
+
recycleBin: true, snapshots: { ...SNAP_DEFAULTS }, fuse: { ...FUSE_DEFAULTS }, keepAwake: true, askSettings: false,
|
|
24
|
+
theme: 'system', language: 'en', popupStyle: 'toast', notify: { sound: true, offline: true, queueDone: true, limit: true },
|
|
25
|
+
deviceName: '', autostart: true, localHistory: false, telemetryEnabled: true,
|
|
26
|
+
...EXTRA.defaults(),
|
|
27
|
+
};
|
|
28
|
+
const NOTIFY = ['sound', 'offline', 'queueDone', 'limit'];
|
|
29
|
+
const dirs = { type: 'array', maxItems: 20, items: { type: 'string' } };
|
|
30
|
+
export const SETTINGS_TOOL_DEF = {
|
|
31
|
+
name: 'bridgerator_settings',
|
|
32
|
+
description: 'Reads ("get": current values and defaults) or changes ("set") every Bridgerator setting of this PC, the same as its window. Changes apply at once (the PC asks first only if the person turned on askSettings). Bridgerator\'s own folders and blocked commands are not settings.',
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: 'object', additionalProperties: false, required: ['action'],
|
|
35
|
+
properties: {
|
|
36
|
+
action: { type: 'string', enum: ['get', 'set'] },
|
|
37
|
+
settings: {
|
|
38
|
+
type: 'object', additionalProperties: false,
|
|
39
|
+
properties: {
|
|
40
|
+
preset: { type: 'string', enum: PRESETS, description: 'Access level: read_only = terminal off and every folder read-only; files = terminal off; full = files and terminal (default).' },
|
|
41
|
+
terminal: b('Running commands and programs (start_process, interact_with_process, kill_process).'),
|
|
42
|
+
allowedDirectories: { ...dirs, description: 'Folders ChatGPT may use; empty = all folders (default).' },
|
|
43
|
+
readOnlyDirectories: { ...dirs, description: 'Folders that can be read but not changed by the file tools. Terminal commands are only checked as a courtesy (a script can still write there): for a hard limit turn the terminal off.' },
|
|
44
|
+
protectSensitive: b('Block startup programs, PowerShell profiles, .ssh/.aws/cloud logins and AppData (default off).'),
|
|
45
|
+
confirm: { type: 'object', additionalProperties: false, description: 'Ask the person on this PC before risky actions (default off).', properties: { ...Object.fromEntries(['enabled', ...CATEGORIES].map((k) => [k, BOOL])), timeoutSeconds: { type: 'integer', enum: TIMEOUTS, description: 'No answer in this time = Deny.' } } },
|
|
46
|
+
recycleBin: b('Simple delete commands move files to the Recycle Bin instead of deleting them (default on).'),
|
|
47
|
+
snapshots: { type: 'object', additionalProperties: false, description: 'Copies of files before ChatGPT changes them, for bridgerator_undo (default on, 7 days, 500 MB, files up to 50 MB).', properties: { enabled: BOOL, days: int(1, 30), maxMb: int(50, 5000), maxFileMb: int(1, 500) } },
|
|
48
|
+
fuse: { type: 'object', additionalProperties: false, description: 'Pause Bridgerator and ask the person when more than limit files are deleted or moved within a minute (default off, 20).', properties: { enabled: BOOL, limit: int(3, 1000) } },
|
|
49
|
+
keepAwake: b('Keep the PC from sleeping while ChatGPT works or the queue has tasks, on AC power only (default on).'),
|
|
50
|
+
askSettings: b('Ask the person on the PC before an AI change to these settings that lowers protection (default off: every change applies at once).'),
|
|
51
|
+
theme: { type: 'string', enum: ['system', 'light', 'dark'] }, language: { type: 'string', enum: ['en', 'ru'] },
|
|
52
|
+
popupStyle: { type: 'string', enum: ['toast', 'popup'] },
|
|
53
|
+
notify: { type: 'object', additionalProperties: false, properties: Object.fromEntries(NOTIFY.map((k) => [k, BOOL])) },
|
|
54
|
+
deviceName: { type: 'string', maxLength: 40 }, autostart: BOOL, localHistory: b('Keep the Recent list on disk for 7 days.'), telemetryEnabled: b('Anonymous usage counts.'),
|
|
55
|
+
...EXTRA.schema(),
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const TOP = ['theme', 'language', 'popupStyle', 'notify', 'deviceName', 'autostart', 'localHistory', 'telemetryEnabled', 'confirm', 'allowedDirectories', 'readOnlyDirectories',
|
|
63
|
+
'preset', 'terminal', 'protectSensitive', 'recycleBin', 'snapshots', 'fuse', 'keepAwake', 'askSettings', ...EXTRA.groups()];
|
|
64
|
+
const RANGES = { 'snapshots.days': [1, 30], 'snapshots.maxMb': [50, 5000], 'snapshots.maxFileMb': [1, 500], 'fuse.limit': [3, 1000] };
|
|
65
|
+
const ENUM = { theme: ['system', 'light', 'dark'], language: ['en', 'ru'], popupStyle: ['toast', 'popup'] };
|
|
66
|
+
|
|
67
|
+
/** → { changes: [{key, value}] } (setSetting keys of the window) or { error }. */
|
|
68
|
+
export function validateSettings(input, opts) {
|
|
69
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return { error: 'settings must be an object' };
|
|
70
|
+
const bad = Object.keys(input).filter((k) => !TOP.includes(k));
|
|
71
|
+
if (bad.length) return { error: `unknown setting(s): ${bad.join(', ')}` };
|
|
72
|
+
const changes = [];
|
|
73
|
+
for (const k of ['theme', 'language', 'popupStyle']) if (input[k] !== undefined) { if (!ENUM[k].includes(input[k])) return { error: `${k} must be one of ${ENUM[k].join(', ')}` }; changes.push({ key: k, value: input[k] }); }
|
|
74
|
+
if (input.preset !== undefined) { if (!PRESETS.includes(input.preset)) return { error: `preset must be one of ${PRESETS.join(', ')}` }; changes.push({ key: 'preset', value: input.preset }); }
|
|
75
|
+
for (const k of ['autostart', 'localHistory', 'telemetryEnabled', 'terminal', 'protectSensitive', 'recycleBin', 'keepAwake', 'askSettings']) if (input[k] !== undefined) { if (typeof input[k] !== 'boolean') return { error: `${k} must be true or false` }; changes.push({ key: k, value: input[k] }); }
|
|
76
|
+
if (input.deviceName !== undefined) { if (typeof input.deviceName !== 'string' || input.deviceName.trim().length > 40 || /[\u0000-\u001f]/.test(input.deviceName)) return { error: 'deviceName must be text, up to 40 characters' }; changes.push({ key: 'deviceName', value: input.deviceName.trim() }); }
|
|
77
|
+
for (const [group, keys] of [['notify', NOTIFY], ['confirm', ['enabled', ...CATEGORIES, 'timeoutSeconds']], ['snapshots', ['enabled', 'days', 'maxMb', 'maxFileMb']], ['fuse', ['enabled', 'limit']]]) {
|
|
78
|
+
const g = input[group];
|
|
79
|
+
if (g === undefined) continue;
|
|
80
|
+
if (!g || typeof g !== 'object' || Array.isArray(g)) return { error: `${group} must be an object` };
|
|
81
|
+
for (const [k, v] of Object.entries(g)) {
|
|
82
|
+
const key = `${group}.${k}`;
|
|
83
|
+
if (key === 'confirm.timeoutSeconds') { if (!TIMEOUTS.includes(v)) return { error: `confirm.timeoutSeconds must be one of ${TIMEOUTS.join(', ')}` }; changes.push({ key: 'confirm.timeout', value: v }); continue; }
|
|
84
|
+
if (RANGES[key]) { const [lo, hi] = RANGES[key]; if (!Number.isInteger(v) || v < lo || v > hi) return { error: `${key} must be a whole number from ${lo} to ${hi}` }; changes.push({ key, value: v }); continue; }
|
|
85
|
+
if (!keys.includes(k) || typeof v !== 'boolean') return { error: `${key}: unknown or not true/false` };
|
|
86
|
+
changes.push({ key, value: v });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const ex = EXTRA.parse(input);
|
|
90
|
+
if (ex.error) return { error: ex.error };
|
|
91
|
+
changes.push(...ex.changes);
|
|
92
|
+
for (const k of ['allowedDirectories', 'readOnlyDirectories']) if (input[k] !== undefined) {
|
|
93
|
+
const v = validateSetup({ [k]: input[k] }, opts);
|
|
94
|
+
if (v.error) return { error: v.error };
|
|
95
|
+
changes.push({ key: k, value: v.value[k] });
|
|
96
|
+
}
|
|
97
|
+
if (!changes.length) return { error: 'nothing to change' };
|
|
98
|
+
return { changes };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const low = (a) => a.map((x) => x.toLowerCase());
|
|
102
|
+
/** Does this change lower protection or widen access (→ the person's OK on the PC)? */
|
|
103
|
+
export function widens(cur, { key, value }) {
|
|
104
|
+
if (EXTRA.owns(key)) return EXTRA.widens(cur, { key, value });
|
|
105
|
+
if (key === 'confirm.enabled') return cur.confirm.enabled && !value;
|
|
106
|
+
if (key === 'confirm.timeout') return false;
|
|
107
|
+
if (key.startsWith('confirm.')) return cur.confirm.ask[key.slice(8)] && !value;
|
|
108
|
+
const was = (k, d) => (cur[k] === undefined ? d : cur[k]);
|
|
109
|
+
if (key === 'terminal') return value && !was('terminal', DEFAULTS.terminal);
|
|
110
|
+
if (key === 'protectSensitive' || key === 'recycleBin') return !value && was(key, DEFAULTS[key]);
|
|
111
|
+
const snap = { ...SNAP_DEFAULTS, ...(cur.snapshots || {}) }, fuse = { ...FUSE_DEFAULTS, ...(cur.fuse || {}) };
|
|
112
|
+
if (key === 'snapshots.enabled') return !value && snap.enabled;
|
|
113
|
+
if (key.startsWith('snapshots.')) return value < snap[key.slice(10)]; // less undo history
|
|
114
|
+
if (key === 'fuse.enabled') return !value && fuse.enabled;
|
|
115
|
+
if (key === 'fuse.limit') return fuse.enabled && value > fuse.limit;
|
|
116
|
+
if (key === 'askSettings') return !value && cur.askSettings === true;
|
|
117
|
+
if (key === 'keepAwake' || key === 'preset') return false; // preset: judged by its parts (presetChanges)
|
|
118
|
+
if (key === 'autostart') return value !== cur.autostart;
|
|
119
|
+
if (key === 'telemetryEnabled') return value && !cur.telemetry;
|
|
120
|
+
if (key === 'localHistory') return value && !cur.localHistory;
|
|
121
|
+
if (key === 'readOnlyDirectories') { const now = low(value); return low(cur.readOnlyDirectories).some((x) => !now.includes(x)); }
|
|
122
|
+
if (key === 'allowedDirectories') {
|
|
123
|
+
const before = low(cur.allowedDirectories), after = low(value);
|
|
124
|
+
if (!after.length) return before.length > 0; // a list → all folders
|
|
125
|
+
if (!before.length) return false; // all folders → a list: narrower
|
|
126
|
+
return after.some((x) => !before.includes(x)); // a folder added to the list
|
|
127
|
+
}
|
|
128
|
+
return false; // theme, language, notifications, name…
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One human line per change, for the notification / the window. */
|
|
132
|
+
export function describe(cur, { key, value }) {
|
|
133
|
+
const onoff = (v) => (v ? 'on' : 'off');
|
|
134
|
+
if (EXTRA.owns(key)) return EXTRA.describe({ key, value });
|
|
135
|
+
if (key === 'confirm.enabled') return `Confirmations: ${onoff(value)}`;
|
|
136
|
+
if (key === 'confirm.timeout') return `No answer = Deny after: ${value} s`;
|
|
137
|
+
if (key.startsWith('confirm.')) return `Confirm ${LABEL[key.slice(8)]}: ${onoff(value)}`;
|
|
138
|
+
if (key === 'allowedDirectories') {
|
|
139
|
+
if (!value.length) return 'Folders: all (except always-blocked)';
|
|
140
|
+
const before = low(cur.allowedDirectories);
|
|
141
|
+
const add = value.filter((x) => !before.includes(x.toLowerCase())), rem = cur.allowedDirectories.filter((x) => !low(value).includes(x.toLowerCase()));
|
|
142
|
+
return `Folders: ${[...add.map((x) => `+${x}`), ...rem.map((x) => `−${x}`)].join(', ') || 'unchanged'}`;
|
|
143
|
+
}
|
|
144
|
+
if (key === 'readOnlyDirectories') {
|
|
145
|
+
const before = low(cur.readOnlyDirectories);
|
|
146
|
+
const add = value.filter((x) => !before.includes(x.toLowerCase())), rem = cur.readOnlyDirectories.filter((x) => !low(value).includes(x.toLowerCase()));
|
|
147
|
+
return `Read-only: ${[...add.map((x) => `+${x}`), ...rem.map((x) => `−${x}`)].join(', ') || 'unchanged'}`;
|
|
148
|
+
}
|
|
149
|
+
const names = { autostart: 'Start with Windows', telemetryEnabled: 'Usage stats', localHistory: 'Local history', theme: 'Theme', language: 'Language', popupStyle: 'Pop-up style', deviceName: 'Computer name',
|
|
150
|
+
terminal: 'Terminal (commands)', protectSensitive: 'Protect sensitive folders', recycleBin: 'Recycle Bin instead of deleting', keepAwake: 'Keep the PC awake while ChatGPT works', askSettings: 'Ask before AI changes settings', preset: 'Access',
|
|
151
|
+
'snapshots.enabled': 'Snapshots and Undo', 'snapshots.days': 'Keep snapshots (days)', 'snapshots.maxMb': 'Snapshots up to (MB)', 'snapshots.maxFileMb': 'Snapshot files up to (MB)',
|
|
152
|
+
'fuse.enabled': 'Pause on mass deletes / moves', 'fuse.limit': 'Mass-action limit per minute' };
|
|
153
|
+
return `${names[key] || key}: ${typeof value === 'boolean' ? onoff(value) : value}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const covered = (dirs, ro) => { const r = low(ro).map((x) => x.replace(/[\\/]+$/, '')); return dirs.length > 0 && dirs.every((d) => { const n = d.toLowerCase().replace(/[\\/]+$/, ''); return r.some((x) => n === x || n.startsWith(`${x}\\`)); }); };
|
|
157
|
+
/** Folders the Read only preset makes read-only: the folder list, or every drive when there is none. */
|
|
158
|
+
const presetTargets = (s, drives) => (s.allowedDirectories.length ? s.allowedDirectories : drives);
|
|
159
|
+
/** The access preset the settings amount to: full (terminal on), read_only (terminal off, all read-only), files. */
|
|
160
|
+
export function presetOf(s, drives = []) {
|
|
161
|
+
if (s.terminal !== false) return 'full';
|
|
162
|
+
return covered(presetTargets(s, drives), s.readOnlyDirectories || []) ? 'read_only' : 'files';
|
|
163
|
+
}
|
|
164
|
+
/** A preset → the setting changes it stands for (terminal, readOnlyDirectories). */
|
|
165
|
+
export function presetChanges(name, s, drives = []) {
|
|
166
|
+
const t = presetTargets(s, drives), ro = s.readOnlyDirectories || [];
|
|
167
|
+
const out = [{ key: 'terminal', value: name === 'full' }];
|
|
168
|
+
if (name === 'read_only') {
|
|
169
|
+
const add = t.filter((d) => !low(ro).includes(d.toLowerCase()));
|
|
170
|
+
if (add.length) out.push({ key: 'readOnlyDirectories', value: [...ro, ...add] });
|
|
171
|
+
} else if (presetOf(s, drives) === 'read_only') {
|
|
172
|
+
// Leaving Read only: the folders it made read-only go back to writable.
|
|
173
|
+
const keep = ro.filter((d) => !low(t).includes(d.toLowerCase()));
|
|
174
|
+
if (keep.length !== ro.length) out.push({ key: 'readOnlyDirectories', value: keep });
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
/** Change list with every preset replaced by what it stands for (the widen check looks at the parts). */
|
|
179
|
+
// The preset is judged on the folder lists of the SAME request (they are applied first, the preset after).
|
|
180
|
+
export function expandPresets(changes, s, drives) {
|
|
181
|
+
const rest = changes.filter((c) => c.key !== 'preset'), presets = changes.filter((c) => c.key === 'preset');
|
|
182
|
+
if (!presets.length) return changes;
|
|
183
|
+
const s2 = { ...s };
|
|
184
|
+
for (const c of rest) if (c.key === 'allowedDirectories' || c.key === 'readOnlyDirectories' || c.key === 'terminal') s2[c.key] = c.value;
|
|
185
|
+
return [...rest, ...presets.flatMap((c) => presetChanges(c.value, s2, drives))];
|
|
186
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// bridgerator_setup: the assistant SUGGESTS folders / read-only folders / confirmation checks after
|
|
2
|
+
// a conversation with the person; nothing changes until the person clicks Apply in a window on the
|
|
3
|
+
// PC (no answer = Cancel). It can't touch the built-in exclusions, blockedCommands, telemetry or
|
|
4
|
+
// anything else.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { CATEGORIES, readSettings, setIni, restoreIni, writeAtomic, LABEL } from './confirm.mjs';
|
|
8
|
+
import { TERMINAL_TOOLS } from './policy.mjs';
|
|
9
|
+
|
|
10
|
+
const MAX = 20;
|
|
11
|
+
const CONFIRM_KEYS = ['enabled', ...CATEGORIES];
|
|
12
|
+
const dirList = { type: 'array', maxItems: MAX, items: { type: 'string' } };
|
|
13
|
+
export const SETUP_TOOL_DEF = {
|
|
14
|
+
name: 'bridgerator_setup',
|
|
15
|
+
description: 'Limits access on this PC, and only when the person explicitly asks for limits ("only this folder", "read only", "ask me before deleting"). A folder named for a task is not a request to limit: just work there. "Set up Bridgerator" needs no call: the defaults (every folder, terminal on, no confirmations, deletions to the Recycle Bin, Undo on) are the setup. Fields: preset (read_only | files | full), allowedDirectories (empty = every folder), readOnlyDirectories, confirm. Applied at once (the PC asks first only if the person turned on "Ask before AI changes settings"). Absolute local paths only.',
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
properties: {
|
|
20
|
+
preset: { type: 'string', enum: ['read_only', 'files', 'full'], description: 'read_only = look only (terminal off, everything read-only); files = files but no terminal; full = files and terminal (default).' },
|
|
21
|
+
allowedDirectories: dirList,
|
|
22
|
+
readOnlyDirectories: dirList,
|
|
23
|
+
confirm: { type: 'object', additionalProperties: false, properties: Object.fromEntries(CONFIRM_KEYS.map((k) => [k, { type: 'boolean' }])) },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** → { value } (normalized) or { error } (text for the assistant). */
|
|
29
|
+
export function validateSetup(input, { isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } } } = {}) {
|
|
30
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return { error: 'expected an object' };
|
|
31
|
+
const extra = Object.keys(input).filter((k) => !['preset', 'allowedDirectories', 'readOnlyDirectories', 'confirm'].includes(k));
|
|
32
|
+
if (extra.length) return { error: `unknown setting(s): ${extra.join(', ')} — only preset, allowedDirectories, readOnlyDirectories and confirm can be suggested` };
|
|
33
|
+
const value = {};
|
|
34
|
+
if (input.preset !== undefined) {
|
|
35
|
+
if (!['read_only', 'files', 'full'].includes(input.preset)) return { error: 'preset must be read_only, files or full' };
|
|
36
|
+
value.preset = input.preset;
|
|
37
|
+
}
|
|
38
|
+
for (const k of ['allowedDirectories', 'readOnlyDirectories']) {
|
|
39
|
+
if (input[k] === undefined) continue;
|
|
40
|
+
const list = input[k];
|
|
41
|
+
if (!Array.isArray(list) || list.length > MAX) return { error: `${k} must be a list of at most ${MAX} folders` };
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const p of list) {
|
|
44
|
+
if (typeof p !== 'string' || !p.trim()) return { error: `${k}: every entry must be a folder path` };
|
|
45
|
+
const s = p.trim();
|
|
46
|
+
if (/^[\\/]{2}/.test(s) || /^[a-z][a-z0-9+.-]*:\/\//i.test(s)) return { error: `${k}: "${s}" — network paths and URLs are not allowed` };
|
|
47
|
+
if (!/^[a-z]:[\\/]/i.test(s)) return { error: `${k}: "${s}" is not an absolute local path like C:\\Users\\Name\\Documents` };
|
|
48
|
+
if (!isDir(s)) return { error: `${k}: "${s}" is not an existing folder` };
|
|
49
|
+
const n = path.resolve(s);
|
|
50
|
+
if (!out.some((x) => x.toLowerCase() === n.toLowerCase())) out.push(n);
|
|
51
|
+
}
|
|
52
|
+
value[k] = out;
|
|
53
|
+
}
|
|
54
|
+
if (input.confirm !== undefined) {
|
|
55
|
+
const c = input.confirm;
|
|
56
|
+
if (!c || typeof c !== 'object' || Array.isArray(c)) return { error: 'confirm must be an object' };
|
|
57
|
+
const bad = Object.keys(c).filter((k) => !CONFIRM_KEYS.includes(k) || typeof c[k] !== 'boolean');
|
|
58
|
+
if (bad.length) return { error: `confirm: unknown or non-boolean key(s): ${bad.join(', ')} (allowed: ${CONFIRM_KEYS.join(', ')})` };
|
|
59
|
+
value.confirm = { ...c };
|
|
60
|
+
}
|
|
61
|
+
if (!Object.keys(value).length) return { error: 'nothing to change' };
|
|
62
|
+
return { value };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One line for the notification, e.g. "Folders: D:\\Work, D:\\Photos (read-only) · confirm deletions: on". */
|
|
66
|
+
export function summarizeSetup(cfg, ini, v) {
|
|
67
|
+
const parts = [];
|
|
68
|
+
const ro = v.readOnlyDirectories || cfg.readOnlyDirectories || [];
|
|
69
|
+
const folders = [...(v.allowedDirectories || []).map((d) => (ro.some((r) => r.toLowerCase() === d.toLowerCase()) ? `${d} (read-only)` : d)),
|
|
70
|
+
...(v.readOnlyDirectories || []).filter((r) => !(v.allowedDirectories || []).some((d) => d.toLowerCase() === r.toLowerCase())).map((r) => `${r} (read-only)`)];
|
|
71
|
+
if (folders.length) parts.push(`Folders: ${folders.join(', ')}`);
|
|
72
|
+
else if (v.readOnlyDirectories) parts.push('Read-only folders: none');
|
|
73
|
+
if (v.confirm) {
|
|
74
|
+
const s = readSettings(ini);
|
|
75
|
+
const on = v.confirm.enabled ?? s.enabled;
|
|
76
|
+
if (!on) parts.push('confirmations: off');
|
|
77
|
+
else parts.push(`confirm ${CATEGORIES.filter((k) => (k in v.confirm ? v.confirm[k] : s.ask[k])).map((k) => LABEL[k]).join(', ') || 'nothing'}: on`);
|
|
78
|
+
}
|
|
79
|
+
const line = parts.join(' · ');
|
|
80
|
+
return line.length > 180 ? `${line.slice(0, 179)}…` : line;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Before → after as a diff: lines for the window, one short line for the notification. */
|
|
84
|
+
export function diffSetup(cfg, ini, v) {
|
|
85
|
+
const lines = [], parts = [];
|
|
86
|
+
const same = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
87
|
+
const d = (label, before, after) => {
|
|
88
|
+
const add = after.filter((x) => !before.some((b) => same(b, x))), rem = before.filter((x) => !after.some((a) => same(a, x)));
|
|
89
|
+
if (!add.length && !rem.length) return;
|
|
90
|
+
lines.push(`${label}: ${before.length} → ${after.length}`, ...add.map((x) => ` + ${x}`), ...rem.map((x) => ` − ${x}`));
|
|
91
|
+
parts.push(`${label}: ${[...add.map((x) => `+${x}`), ...rem.map((x) => `−${x}`)].join(', ')}`);
|
|
92
|
+
};
|
|
93
|
+
if (v.allowedDirectories) {
|
|
94
|
+
const before = cfg.allowedDirectories || [], after = v.allowedDirectories;
|
|
95
|
+
if (!after.length && before.length) { lines.push('Folders: all folders (except always-blocked)'); parts.push('Folders: all (except always-blocked)'); }
|
|
96
|
+
else if (after.length && !before.length) { lines.push(`Folders: only these ${after.length}`, ...after.map((x) => ` + ${x}`)); parts.push(`Folders: only ${after.join(', ')}`); }
|
|
97
|
+
else d('Folders', before, after);
|
|
98
|
+
}
|
|
99
|
+
if (v.readOnlyDirectories) d('Read-only', cfg.readOnlyDirectories || [], v.readOnlyDirectories);
|
|
100
|
+
if (typeof v.terminal === 'boolean' && v.terminal !== !(cfg.disabledTools || []).includes('start_process')) { const t = `Terminal: ${v.terminal ? 'on' : 'off'}`; lines.push(t); parts.push(t); }
|
|
101
|
+
if (v.confirm) {
|
|
102
|
+
const s = readSettings(ini);
|
|
103
|
+
const cur = { enabled: s.enabled, ...s.ask };
|
|
104
|
+
for (const [k, x] of Object.entries(v.confirm)) if (cur[k] !== x) {
|
|
105
|
+
const t = `${k === 'enabled' ? 'Confirmations' : `Confirm ${LABEL[k]}`}: ${x ? 'on' : 'off'}`;
|
|
106
|
+
lines.push(t); parts.push(t);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { lines: lines.length ? lines : ['No changes'], short: parts.join(' · ') || 'No changes' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Writes the approved settings: config.json (folders, setupDone) and confirm.ini. → the new cfg. */
|
|
113
|
+
export function applySetup(configFile, ini, v) {
|
|
114
|
+
const cfg = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
115
|
+
const next = { ...cfg, ...(v.allowedDirectories ? { allowedDirectories: v.allowedDirectories } : {}), ...(v.readOnlyDirectories ? { readOnlyDirectories: v.readOnlyDirectories } : {}), setupDone: true };
|
|
116
|
+
if (typeof v.terminal === 'boolean') next.disabledTools = terminalTools(cfg.disabledTools, v.terminal);
|
|
117
|
+
writeAtomic(configFile, JSON.stringify(next, null, 2));
|
|
118
|
+
if (v.confirm) {
|
|
119
|
+
restoreIni(ini);
|
|
120
|
+
for (const [k, x] of Object.entries(v.confirm)) setIni(ini, k === 'enabled' ? 'confirm' : 'ask', k, x ? 'true' : 'false');
|
|
121
|
+
}
|
|
122
|
+
return next;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** disabledTools with the terminal tools switched on (removed) or off (added). */
|
|
126
|
+
export function terminalTools(list = [], on) {
|
|
127
|
+
const rest = (list || []).filter((x) => !TERMINAL_TOOLS.includes(x));
|
|
128
|
+
return on ? rest : [...rest, ...TERMINAL_TOOLS];
|
|
129
|
+
}
|