bridgerator 1.0.7 → 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 +78 -15
- 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 +465 -43
- 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 +743 -29
- package/src/cli.mjs +561 -53
- 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 +86 -23
- package/src/pair.mjs +41 -6
- 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 -109
- 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,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
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Software (winget)"). Programs through the Windows Package
|
|
2
|
+
// Manager (winget, part of Windows 10/11 "App Installer"): search, installed list, available updates,
|
|
3
|
+
// install, upgrade. winget is started by its absolute path (a planted winget.exe on the PATH never
|
|
4
|
+
// runs), never through a shell, with --disable-interactivity; install / upgrade run --silent.
|
|
5
|
+
// Agreements are accepted ONLY when the call says accept_agreements: true (the AI asks the person
|
|
6
|
+
// first). Install / upgrade = confirmation category "install" (asks only when the person turned
|
|
7
|
+
// confirmations on) and are refused while the terminal is off (access presets Read only / Files).
|
|
8
|
+
// Tables are read by column positions from the dashes line under the header (any language), the
|
|
9
|
+
// result by winget's exit code (HRESULT), never by message text.
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { stripOurs } from './ask.mjs';
|
|
13
|
+
import { WINGET } from './win.mjs';
|
|
14
|
+
import { ToolError, fail, json, needAction, str } from './tool-lib.mjs';
|
|
15
|
+
|
|
16
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,127}$/; // winget ids: Vendor.App, 9NBLGGH4NNS1 (Store)
|
|
17
|
+
const SOURCES = ['winget', 'msstore'];
|
|
18
|
+
const MAX_ROWS = 200;
|
|
19
|
+
|
|
20
|
+
// winget runs in the background, so Windows often shows its administrator prompt only as a flashing
|
|
21
|
+
// taskbar button (a background program can't bring it to the front): the person must be told to look.
|
|
22
|
+
export const UAC_NOTE = 'Windows may show its administrator prompt only as a flashing button in the taskbar — ask the person to click it and choose Yes.';
|
|
23
|
+
// HRESULT exit codes (winget returnCodes.md) → plain words + what to do.
|
|
24
|
+
const CODES = {
|
|
25
|
+
0x8A150014: ['No program matches.', 'Search with other words (action "search").'],
|
|
26
|
+
0x8A150016: ['Several programs match.', 'Use the exact id from action "search".'],
|
|
27
|
+
0x8A150010: ['No installer of this program fits this PC.', ''],
|
|
28
|
+
0x8A150011: ['The downloaded installer does not match its published checksum, so it was not run.', 'Try again later.'],
|
|
29
|
+
0x8A150008: ['The download failed.', 'Check the internet connection and try again.'],
|
|
30
|
+
0x8A150019: ['This needs administrator rights.', 'Ask the person to run it from an administrator terminal.'],
|
|
31
|
+
0x8A15002B: ['No update is available for it.', ''],
|
|
32
|
+
0x8A15002C: ['Some updates failed.', 'Check the list with action "updates" and update them one by one.'],
|
|
33
|
+
0x8A15003A: ['A company policy blocks winget on this PC.', 'Ask the PC administrator.'],
|
|
34
|
+
0x8A150041: ['The program\'s license terms were not accepted.', 'Ask the person; if they agree, call again with accept_agreements: true.'],
|
|
35
|
+
0x8A150046: ['The program source\'s terms were not accepted yet.', 'Ask the person; if they agree, call again with accept_agreements: true.'],
|
|
36
|
+
0x8A15004F: ['The available version is not newer than the installed one.', ''],
|
|
37
|
+
0x8A150101: ['The program is running.', 'Close it and try again.'],
|
|
38
|
+
0x8A150102: ['Another installation is in progress.', 'Try again in a few minutes.'],
|
|
39
|
+
0x8A150103: ['A file of the program is in use.', 'Close the program and try again.'],
|
|
40
|
+
0x8A150105: ['The disk is full.', 'Free some space and try again.'],
|
|
41
|
+
0x8A150107: ['The installer needs the internet.', 'Check the connection.'],
|
|
42
|
+
0x8A150109: ['Installed — restart the PC to finish.', ''],
|
|
43
|
+
0x8A15010A: ['Installation failed: restart the PC, then try again.', ''],
|
|
44
|
+
0x8A15010B: ['The PC will restart to finish the installation.', ''],
|
|
45
|
+
0x8A15010C: ['The installation was cancelled (for example "No" in the Windows administrator prompt).', `Ask the person to allow it when Windows asks. ${UAC_NOTE}`],
|
|
46
|
+
0x8A15010D: ['It is already installed.', 'Use action "upgrade" to update it.'],
|
|
47
|
+
0x8A15010E: ['A newer version is already installed.', ''],
|
|
48
|
+
// Seen on a lab PC after "No" in the administrator prompt (installer exit 1625): said as not approved.
|
|
49
|
+
0x8A15010F: ['The installation was cancelled or not approved (for example "No" in the Windows administrator prompt, or a policy of this PC).', `Ask the person to try again and allow it when Windows asks. ${UAC_NOTE}`],
|
|
50
|
+
0x8A150113: ['This program does not support this Windows version.', ''],
|
|
51
|
+
0x8A150115: ['The program\'s installer reported an error.', 'Try again, or install it from the vendor\'s site.'],
|
|
52
|
+
};
|
|
53
|
+
const OK_CODES = new Set([0, 0x8A150109]);
|
|
54
|
+
|
|
55
|
+
/** Runs winget (win.WINGET) → { code (unsigned HRESULT), out }. ctx.runWinget (tests) replaces it. */
|
|
56
|
+
// winget.exe in WindowsApps is an App Execution Alias: stat() fails (EACCES) so fs.existsSync says false; lstat sees it.
|
|
57
|
+
export const aliasExists = (p) => { try { fs.lstatSync(p); return true; } catch { return false; } };
|
|
58
|
+
export function runWinget(args, { timeout = 120000, exists = aliasExists } = {}) {
|
|
59
|
+
if (process.platform !== 'win32') return Promise.reject(new ToolError('NOT_WINDOWS', 'This works only on Windows.'));
|
|
60
|
+
if (!exists(WINGET)) return Promise.reject(new ToolError('NO_WINGET', 'winget (Windows Package Manager) is not available on this PC.', 'It comes with "App Installer" from the Microsoft Store; Windows 10 1809+ and Windows 11 have it after updates.'));
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const child = spawn(WINGET, args, { windowsHide: true, env: stripOurs(process.env), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
63
|
+
const chunks = [];
|
|
64
|
+
let size = 0;
|
|
65
|
+
const timer = setTimeout(() => { child.kill(); reject(new ToolError('TIMEOUT', 'winget did not finish in time.', `An installer may be waiting for an answer on the PC screen; ask the person to look. ${UAC_NOTE}`)); }, timeout);
|
|
66
|
+
const take = (b) => { size += b.length; if (size < 8 * 1024 * 1024) chunks.push(b); };
|
|
67
|
+
child.stdout.on('data', take);
|
|
68
|
+
child.stderr.on('data', take);
|
|
69
|
+
child.on('error', (e) => { clearTimeout(timer); reject(new ToolError('NO_WINGET', `winget did not start (${e.code || e.message}).`, 'Update "App Installer" from the Microsoft Store.')); });
|
|
70
|
+
child.on('close', (code) => { clearTimeout(timer); resolve({ code: code >>> 0, out: Buffer.concat(chunks).toString('utf8') }); });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Output lines without progress spinners / bars (text after the last bare \r of a line). */
|
|
75
|
+
export function cleanLines(out, { blank = false } = {}) {
|
|
76
|
+
return String(out).replace(/\r\n/g, '\n').split('\n')
|
|
77
|
+
.map((l) => l.slice(l.lastIndexOf('\r') + 1).replace(/[▀-▟]+/g, '').replace(/\s+$/, ''))
|
|
78
|
+
.filter((l) => (blank && !l) || (l && !/^\s*[-\\|/]\s*$/.test(l)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The first table of winget output → rows of cells. Columns start where the header words start;
|
|
83
|
+
* the dashes line marks the header. Header labels are localized and only give positions.
|
|
84
|
+
* → { cols: number, rows: [[cell…]] }
|
|
85
|
+
*/
|
|
86
|
+
export function parseTable(out) {
|
|
87
|
+
const lines = cleanLines(out, { blank: true });
|
|
88
|
+
const dash = lines.findIndex((l) => /^-{10,}$/.test(l.trim()));
|
|
89
|
+
if (dash < 1) return { cols: 0, rows: [] };
|
|
90
|
+
const header = lines[dash - 1];
|
|
91
|
+
const starts = [];
|
|
92
|
+
for (const m of header.matchAll(/\S+/g)) starts.push(m.index);
|
|
93
|
+
const rows = [];
|
|
94
|
+
for (let i = dash + 1; i < lines.length && rows.length < MAX_ROWS; i++) {
|
|
95
|
+
const l = lines[i];
|
|
96
|
+
// The table ends at a blank line, a dashes line, or the header of a second table (its dashes follow).
|
|
97
|
+
if (!l.trim() || /^-{10,}$/.test(l.trim()) || /^-{10,}$/.test((lines[i + 1] || '').trim())) break;
|
|
98
|
+
rows.push(starts.map((s, j) => l.slice(s, starts[j + 1] ?? undefined).trim()));
|
|
99
|
+
}
|
|
100
|
+
return { cols: starts.length, rows };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Column order is fixed by winget: search Name Id Version [Match] Source; list Name Id Version
|
|
104
|
+
// [Available] Source; upgrade Name Id Version Available Source.
|
|
105
|
+
function rowsOf(kind, { cols, rows }) {
|
|
106
|
+
return rows.map((r) => {
|
|
107
|
+
const o = { name: r[0], id: r[1], version: r[2] };
|
|
108
|
+
if (cols >= 5) { if (kind === 'search') { if (r[3]) o.match = r[3]; } else if (r[3]) o.available = r[3]; o.source = r[4] || undefined; }
|
|
109
|
+
else if (cols === 4) { if (kind === 'updates') o.available = r[3]; else o.source = r[3] || undefined; }
|
|
110
|
+
return o;
|
|
111
|
+
}).filter((o) => /^\S+$/.test(o.id || '') && o.version); // footer lines ("2 upgrades available.") have no id
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function explain(code, out) {
|
|
115
|
+
if (OK_CODES.has(code)) return null;
|
|
116
|
+
const known = CODES[code];
|
|
117
|
+
const hex = `0x${code.toString(16).toUpperCase()}`;
|
|
118
|
+
const tail = cleanLines(out).filter((l) => !/^-{3,}/.test(l)).slice(-3).join(' | ').slice(0, 300);
|
|
119
|
+
return known ? new ToolError(`WINGET_${hex}`, known[0], known[1]) : new ToolError(`WINGET_${hex}`, `winget stopped with code ${hex}.${tail ? ` Last output: ${tail}` : ''}`, 'Try again; if it repeats, the person can run the same winget command in a terminal to see more.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const base = (args) => ['--disable-interactivity', ...(args.accept_agreements === true ? ['--accept-source-agreements'] : [])];
|
|
123
|
+
const sourceArg = (args) => { const s = str(args, 'source', { max: 20 }); if (s && !SOURCES.includes(s)) fail('BAD_ARGS', `source must be ${SOURCES.join(' or ')}.`); return s ? ['--source', s] : []; };
|
|
124
|
+
const idArg = (args, required) => {
|
|
125
|
+
const id = str(args, 'id', { required, max: 128 });
|
|
126
|
+
if (id && !ID_RE.test(id)) fail('BAD_ARGS', 'id must be a winget package id like "7zip.7zip" (from action "search").');
|
|
127
|
+
return id;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const defs = [{
|
|
131
|
+
name: 'software',
|
|
132
|
+
title: 'Programs (winget)',
|
|
133
|
+
description: 'Programs on this PC through the Windows Package Manager (winget). search: find programs by name; list: installed programs; updates: programs with a newer version; install / upgrade: install or update one program by its id (silent; upgrade with all: true updates everything). Windows may still show its administrator prompt on the PC for some installers (often only as a flashing taskbar button). Program and source license terms are accepted only with accept_agreements: true, after the person agreed.',
|
|
134
|
+
inputSchema: {
|
|
135
|
+
type: 'object', additionalProperties: false, required: ['action'],
|
|
136
|
+
properties: {
|
|
137
|
+
action: { type: 'string', enum: ['search', 'list', 'updates', 'install', 'upgrade'] },
|
|
138
|
+
query: { type: 'string', maxLength: 100, description: 'search / list: words to look for' },
|
|
139
|
+
id: { type: 'string', maxLength: 128, description: 'install / upgrade: exact package id, e.g. "7zip.7zip"' },
|
|
140
|
+
all: { type: 'boolean', description: 'upgrade: update every program that has an update' },
|
|
141
|
+
version: { type: 'string', maxLength: 40, description: 'install: a specific version' },
|
|
142
|
+
scope: { type: 'string', enum: ['user', 'machine'], description: 'install: for this user only, or for all users (needs administrator rights)' },
|
|
143
|
+
source: { type: 'string', enum: SOURCES },
|
|
144
|
+
accept_agreements: { type: 'boolean', description: 'Accept the program\'s and the source\'s license terms (only after the person agreed). Default false.' },
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
148
|
+
}];
|
|
149
|
+
|
|
150
|
+
export async function handle(name, args, ctx) {
|
|
151
|
+
const action = needAction(args, defs[0].inputSchema.properties.action.enum);
|
|
152
|
+
const run = ctx.runWinget || runWinget;
|
|
153
|
+
if (action === 'search' || action === 'list' || action === 'updates') {
|
|
154
|
+
const q = str(args, 'query', { required: action === 'search', max: 100 });
|
|
155
|
+
if (q && /[\u0000-\u001f"]/.test(q)) fail('BAD_ARGS', 'query must be plain text.');
|
|
156
|
+
const argv = action === 'search' ? ['search', '--query', q, '--count', '50']
|
|
157
|
+
: action === 'list' ? ['list', ...(q ? ['--query', q] : [])]
|
|
158
|
+
: ['upgrade'];
|
|
159
|
+
const r = await run([...argv, ...sourceArg(args), ...base(args)], { timeout: 120000 });
|
|
160
|
+
if (r.code === 0x8A150014) return json({ action, found: 0, programs: [] });
|
|
161
|
+
const err = explain(r.code, r.out);
|
|
162
|
+
if (err) throw err;
|
|
163
|
+
const programs = rowsOf(action, parseTable(r.out));
|
|
164
|
+
return json({ action, found: programs.length, programs, ...(programs.length >= MAX_ROWS ? { note: `first ${MAX_ROWS} shown` } : {}) });
|
|
165
|
+
}
|
|
166
|
+
// install / upgrade: like a terminal command — off with the terminal; asks when confirmations are on.
|
|
167
|
+
if ((ctx.cfg?.disabledTools || []).includes('start_process')) fail('TERMINAL_OFF', 'Installing programs is off on this PC (access preset Read only or Files).', 'The person can switch to Full in Bridgerator → Safety, or you can ask with bridgerator_settings.');
|
|
168
|
+
const id = idArg(args, !(action === 'upgrade' && args.all === true));
|
|
169
|
+
if (action === 'upgrade' && args.all === true && id) fail('BAD_ARGS', 'Give either id or all: true, not both.');
|
|
170
|
+
const version = str(args, 'version', { max: 40 });
|
|
171
|
+
if (version && !/^[A-Za-z0-9.+_-]+$/.test(version)) fail('BAD_ARGS', 'version must look like 1.2.3.');
|
|
172
|
+
// Fails closed: without the confirmation gate nothing is installed.
|
|
173
|
+
if (typeof ctx.gate?.gate !== 'function') fail('NO_GATE', 'Installing programs is not available here.');
|
|
174
|
+
const stop = await ctx.gate.gate('software', { ...args, action });
|
|
175
|
+
if (stop) return stop;
|
|
176
|
+
const argv = [action, ...(id ? ['--id', id, '--exact'] : ['--all']),
|
|
177
|
+
...(version && action === 'install' ? ['--version', version] : []),
|
|
178
|
+
...(args.scope && action === 'install' ? ['--scope', args.scope === 'machine' ? 'machine' : 'user'] : []),
|
|
179
|
+
'--silent', ...sourceArg(args), ...base(args), ...(args.accept_agreements === true ? ['--accept-package-agreements'] : [])];
|
|
180
|
+
ctx.log?.(`[bridgerator] software ${action} ${id || '--all'}`);
|
|
181
|
+
const r = await run(argv, { timeout: 30 * 60000 });
|
|
182
|
+
const err = explain(r.code, r.out);
|
|
183
|
+
if (err) throw err;
|
|
184
|
+
return json({ action, id: id || 'all', done: true, ...(r.code === 0x8A150109 ? { restartNeeded: true } : {}), output: cleanLines(r.out).filter((l) => !/^-{3,}/.test(l)).slice(-6) });
|
|
185
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Anonymous usage counts to Bridgerator (disclosed on the privacy page; off with
|
|
2
|
+
// `bridgerator.cmd telemetry off`, "telemetryEnabled": false in config.json, or install -NoTelemetry).
|
|
3
|
+
// Only these fields: random install id, bridge version, Windows version, OS language, Node.js
|
|
4
|
+
// version, and in the daily heartbeat per-tool call / error counts. Never paths, file names,
|
|
5
|
+
// commands, arguments, outputs, e-mail. HTTPS only; any failure is silent.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { writeAtomic } from './confirm.mjs';
|
|
10
|
+
|
|
11
|
+
export const TELEMETRY_URL = 'https://bridgerator.recondeck.com/t';
|
|
12
|
+
export const VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
13
|
+
const DAY = 24 * 3600 * 1000;
|
|
14
|
+
const READ_FAILS_MAX = 3; // hourly ticks
|
|
15
|
+
|
|
16
|
+
export const enabled = (cfg) => !!cfg?.installId && cfg.telemetryEnabled !== false;
|
|
17
|
+
|
|
18
|
+
export function payload(cfg, event, extra = {}) {
|
|
19
|
+
let lang = '';
|
|
20
|
+
try { lang = Intl.DateTimeFormat().resolvedOptions().locale; } catch { /* none */ }
|
|
21
|
+
return { event, id: cfg.installId, version: VERSION, os: `${os.platform()} ${os.release()}`, lang, node: process.version, ...extra };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function send(cfg, event, extra, { fetch: f = globalThis.fetch } = {}) {
|
|
25
|
+
if (!enabled(cfg)) return;
|
|
26
|
+
try {
|
|
27
|
+
await f(TELEMETRY_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload(cfg, event, extra)), signal: AbortSignal.timeout(10000) });
|
|
28
|
+
} catch { /* offline: nothing */ }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Per-tool counters for the heartbeat. `known` = the engine's tool names: a name the relay made up
|
|
32
|
+
// is never counted (only our own fixed tool names leave the PC). State survives restarts in `file`.
|
|
33
|
+
export function usage(cfg, known, file, { now = Date.now, sendFn = send } = {}) {
|
|
34
|
+
const fresh = () => ({ since: now(), calls: {}, errors: {} });
|
|
35
|
+
// Only a missing file (ENOENT) or an unparsable / wrong-shape one = fresh (rewritten at the next save),
|
|
36
|
+
// so one bad usage.json never stops counting for good. Any other read error (a lock of antivirus /
|
|
37
|
+
// backup, EIO, EMFILE, EISDIR...) throws — the caller keeps its in-memory state and retries next tick;
|
|
38
|
+
// after READ_FAILS_MAX failed ticks in a row it goes on from memory (rewrites the file, the heartbeat
|
|
39
|
+
// goes), so a permanently unreadable file never stops telemetry. Only since / calls / errors are kept;
|
|
40
|
+
// a counter that is not a non-negative safe integer is dropped.
|
|
41
|
+
const load = () => {
|
|
42
|
+
let txt;
|
|
43
|
+
try { txt = fs.readFileSync(file, 'utf8'); } catch (e) { if (e?.code === 'ENOENT') return fresh(); throw e; }
|
|
44
|
+
let j; try { j = JSON.parse(txt); } catch { return fresh(); }
|
|
45
|
+
if (!j || typeof j !== 'object' || Array.isArray(j)) return fresh();
|
|
46
|
+
const obj = (o) => Object.fromEntries(Object.entries(o && typeof o === 'object' && !Array.isArray(o) ? o : {}).filter(([, n]) => Number.isSafeInteger(n) && n >= 0));
|
|
47
|
+
return { since: Number.isFinite(j.since) ? j.since : now(), calls: obj(j.calls), errors: obj(j.errors) };
|
|
48
|
+
};
|
|
49
|
+
let st; try { st = load(); } catch { st = fresh(); }
|
|
50
|
+
// Counted since the last save: added to what is in the file then (another bridge process may have
|
|
51
|
+
// saved meanwhile — no last-writer-wins), written atomically (temp + rename).
|
|
52
|
+
let delta = { calls: {}, errors: {} };
|
|
53
|
+
const add = (to, from) => { for (const [k, n] of Object.entries(from)) to[k] = (to[k] || 0) + n; };
|
|
54
|
+
const write = () => { try { writeAtomic(file, JSON.stringify(st)); } catch { /* read-only */ } };
|
|
55
|
+
let fails = 0;
|
|
56
|
+
const save = () => {
|
|
57
|
+
let disk;
|
|
58
|
+
try { disk = load(); fails = 0; } catch { // locked now: keep st + delta, retry next tick
|
|
59
|
+
if (++fails < READ_FAILS_MAX) return false;
|
|
60
|
+
fails = 0; delta = { calls: {}, errors: {} }; write(); return true; // still unreadable: go on from memory
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
add(disk.calls, delta.calls); add(disk.errors, delta.errors);
|
|
64
|
+
delta = { calls: {}, errors: {} };
|
|
65
|
+
st = disk;
|
|
66
|
+
write();
|
|
67
|
+
return true;
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
count(name, isError) {
|
|
71
|
+
if (!known.has(name)) return;
|
|
72
|
+
st.calls[name] = (st.calls[name] || 0) + 1;
|
|
73
|
+
delta.calls[name] = (delta.calls[name] || 0) + 1;
|
|
74
|
+
if (isError) { st.errors[name] = (st.errors[name] || 0) + 1; delta.errors[name] = (delta.errors[name] || 0) + 1; }
|
|
75
|
+
},
|
|
76
|
+
async tick() {
|
|
77
|
+
if (!save()) return;
|
|
78
|
+
if (now() - st.since < DAY) return;
|
|
79
|
+
const { calls, errors } = st;
|
|
80
|
+
st = fresh();
|
|
81
|
+
write();
|
|
82
|
+
await sendFn(cfg, 'heartbeat', { calls, errors });
|
|
83
|
+
},
|
|
84
|
+
state: () => st,
|
|
85
|
+
};
|
|
86
|
+
}
|