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
package/src/queue.mjs
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Task queue (checklist) on this PC: the person adds steps in the Bridgerator window, ChatGPT works
|
|
2
|
+
// through them with the queue_* tools. Stored only here (%APPDATA%\Bridgerator\queue.json, inside
|
|
3
|
+
// the always-excluded folder); never sent to the server. Tools read/write only this list.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { writeAtomic } from './confirm.mjs';
|
|
7
|
+
|
|
8
|
+
export const STATUSES = ['todo', 'doing', 'done', 'failed', 'skipped'];
|
|
9
|
+
const MAX_ITEMS = 500, MAX_TITLE = 200, MAX_DETAILS = 4000, MAX_NOTE = 1000, MAX_PATHS = 20;
|
|
10
|
+
const RULE = 'The usual loop: queue_next, do the task, queue_update with done / failed / skipped and a short note, then the next one until the queue is empty or the person stops. resultPath set: write the result to that file. from set: another AI added it and reads queue_list.';
|
|
11
|
+
const MAX_FROM = 80, MAX_RESULT_PATH = 1000;
|
|
12
|
+
export const STALE_MS = 30 * 60 * 1000; // "doing" with no update for this long: another chat gave up
|
|
13
|
+
const ID = { type: 'string', description: 'item id' };
|
|
14
|
+
const STR = (n) => ({ type: 'string', maxLength: n });
|
|
15
|
+
const PATHS = { type: 'array', maxItems: MAX_PATHS, items: STR(1000) };
|
|
16
|
+
// AI ↔ AI hand-off (another AI adds tasks for ChatGPT): who added it, and the file for the result. Optional; old items have neither.
|
|
17
|
+
const HANDOFF = { from: { type: 'string', maxLength: MAX_FROM, description: 'Who adds the task when another AI hands work over, e.g. "Claude Code".' }, resultPath: { type: 'string', maxLength: MAX_RESULT_PATH, description: 'Full path of the file where the result should be written.' } };
|
|
18
|
+
|
|
19
|
+
export const QUEUE_TOOL_DEFS = [
|
|
20
|
+
{ name: 'queue_list', description: `List the task queue the person keeps on this PC (a checklist of things to do): counts and items (id, title, status, note, worker, updatedAt, from, resultPath), optionally only one status.`, inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: STATUSES } } } },
|
|
21
|
+
{ name: 'queue_next', description: `Take the next item of the task queue: returns the first "todo" item and marks it "doing" (nothing when the queue is empty). An item left "doing" for 30 minutes without an update (another chat stopped) is returned first, with resumed: true. Several chats can work through one queue at once; each call gets a different item. Pass worker = a short name for this chat (e.g. its title) so the person sees who is on it. ${RULE}`, inputSchema: { type: 'object', additionalProperties: false, properties: { worker: STR(80) } } },
|
|
22
|
+
{ name: 'queue_update', description: `Set an item's status (doing / done / failed / skipped / todo) with a short result note.`, inputSchema: { type: 'object', additionalProperties: false, required: ['id', 'status'], properties: { id: ID, status: { type: 'string', enum: STATUSES }, note: STR(MAX_NOTE) } } },
|
|
23
|
+
{ name: 'queue_add', description: `Add steps to the task queue (e.g. when planning a bigger job with the person): one { title, details?, paths?, from?, resultPath? } or items: [...] (1–100). Another AI handing work to ChatGPT sets from (its name) and resultPath (a file for the result), then reads the outcome with queue_list.`, inputSchema: { type: 'object', additionalProperties: false, properties: { title: STR(MAX_TITLE), details: STR(MAX_DETAILS), paths: PATHS, ...HANDOFF, items: { type: 'array', minItems: 1, maxItems: 100, items: { type: 'object', additionalProperties: false, required: ['title'], properties: { title: STR(MAX_TITLE), details: STR(MAX_DETAILS), paths: PATHS, ...HANDOFF } } } } } },
|
|
24
|
+
{ name: 'queue_clear', description: 'Remove finished items from the task queue: status done, failed or skipped. status "all" removes every item and needs the person\'s OK on the PC.', inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: ['done', 'failed', 'skipped', 'all'] } } } },
|
|
25
|
+
{ name: 'queue_edit', description: 'Change the title, details, paths or result file of a queue item.', inputSchema: { type: 'object', additionalProperties: false, required: ['id'], properties: { id: ID, title: STR(MAX_TITLE), details: STR(MAX_DETAILS), paths: PATHS, resultPath: HANDOFF.resultPath } } },
|
|
26
|
+
{ name: 'queue_reorder', description: 'Put queue items in a new order: ids first, in this order; the rest keep their order after them.', inputSchema: { type: 'object', additionalProperties: false, required: ['ids'], properties: { ids: { type: 'array', maxItems: MAX_ITEMS, items: ID } } } },
|
|
27
|
+
];
|
|
28
|
+
export const QUEUE_TOOL_NAMES = new Set(QUEUE_TOOL_DEFS.map((t) => t.name));
|
|
29
|
+
|
|
30
|
+
const str = (v, n, name, required) => {
|
|
31
|
+
if (v === undefined || v === null) { if (required) throw new Error(`${name} is required`); return undefined; }
|
|
32
|
+
if (typeof v !== 'string') throw new Error(`${name} must be text`);
|
|
33
|
+
const t = v.trim();
|
|
34
|
+
if (required && !t) throw new Error(`${name} is required`);
|
|
35
|
+
if (t.length > n) throw new Error(`${name} is longer than ${n} characters`);
|
|
36
|
+
return t;
|
|
37
|
+
};
|
|
38
|
+
const paths = (v) => {
|
|
39
|
+
if (v === undefined) return undefined;
|
|
40
|
+
if (!Array.isArray(v) || v.length > MAX_PATHS || v.some((p) => typeof p !== 'string' || p.length > 1000)) throw new Error(`paths must be a list of at most ${MAX_PATHS} paths`);
|
|
41
|
+
return v.map((p) => p.trim()).filter(Boolean);
|
|
42
|
+
};
|
|
43
|
+
const fromOf = (v) => { const t = str(v, MAX_FROM, 'from'); if (t && /[\u0000-\u001f]/.test(t)) throw new Error('from must be a short name on one line'); return t || undefined; };
|
|
44
|
+
const resultPathOf = (v) => { const t = str(v, MAX_RESULT_PATH, 'resultPath'); if (t && !/^[a-z]:[\\/]/i.test(t)) throw new Error('resultPath must be a full path like C:\\Users\\Name\\Documents\\result.md'); return t || undefined; };
|
|
45
|
+
const handoff = (x) => { const f = fromOf(x?.from), r = resultPathOf(x?.resultPath); return { ...(f ? { from: f } : {}), ...(r ? { resultPath: r } : {}) }; };
|
|
46
|
+
|
|
47
|
+
export function createQueue(file, { now = () => new Date().toISOString(), onChange = () => {} } = {}) {
|
|
48
|
+
let items = [], unreadable = null;
|
|
49
|
+
// Missing file = empty queue. A file that can't be READ (locked, no access) is never overwritten: saves
|
|
50
|
+
// fail until a restart reads it. A file that can't be PARSED — or has the wrong shape, or items that
|
|
51
|
+
// are dropped (no id / title, unknown status) — is kept as queue.json.broken-<content hash> before any
|
|
52
|
+
// save: once per distinct broken content (the same file at every start is not copied again).
|
|
53
|
+
let raw = null;
|
|
54
|
+
const keepCopy = () => {
|
|
55
|
+
const copy = `${file}.broken-${crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16)}`;
|
|
56
|
+
try { fs.copyFileSync(file, copy, fs.constants.COPYFILE_EXCL); } catch (e) { if (e.code !== 'EEXIST') unreadable = 'BROKEN'; }
|
|
57
|
+
};
|
|
58
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { if (e.code !== 'ENOENT') unreadable = e.code || e.message; }
|
|
59
|
+
if (raw !== null) {
|
|
60
|
+
try {
|
|
61
|
+
const j = JSON.parse(raw.replace(/^/, ''));
|
|
62
|
+
if (Array.isArray(j?.items)) {
|
|
63
|
+
items = j.items.filter((x) => x && typeof x === 'object' && typeof x.id === 'string' && x.id && typeof x.title === 'string' && STATUSES.includes(x.status))
|
|
64
|
+
.map((x, i) => ({ ...x, order: Number.isFinite(x.order) ? x.order : i + 1 }));
|
|
65
|
+
if (items.length !== j.items.length) keepCopy();
|
|
66
|
+
} else keepCopy();
|
|
67
|
+
} catch { keepCopy(); }
|
|
68
|
+
}
|
|
69
|
+
// Atomic: write a temp file, then rename over queue.json — a crash or a parallel write never leaves
|
|
70
|
+
// half a file. (Calls run in one process, so the read-modify-write steps never interleave.)
|
|
71
|
+
const save = () => {
|
|
72
|
+
if (unreadable) throw new Error(`queue.json could not be read (${unreadable}); restart Bridgerator to load it — nothing was changed`);
|
|
73
|
+
if (deferSave) return;
|
|
74
|
+
writeAtomic(file, JSON.stringify({ items }, null, 2)); onChange();
|
|
75
|
+
};
|
|
76
|
+
// Every change is all or nothing: a validation or save error leaves the queue as it was.
|
|
77
|
+
const commit = (fn) => {
|
|
78
|
+
const before = structuredClone(items);
|
|
79
|
+
try { return fn(); } catch (e) { items = before; throw e; }
|
|
80
|
+
};
|
|
81
|
+
// Several items in one change: ONE save at the end (a failure half-way leaves queue.json as it was).
|
|
82
|
+
let deferSave = false;
|
|
83
|
+
const many = (fn) => commit(() => { deferSave = true; let r; try { r = fn(); } finally { deferSave = false; } save(); return r; });
|
|
84
|
+
const sorted = () => [...items].sort((a, b) => a.order - b.order);
|
|
85
|
+
const get = (id) => { const it = items.find((x) => x.id === id); if (!it) throw new Error(`no queue item ${id}`); return it; };
|
|
86
|
+
const renumber = (list) => list.forEach((x, i) => { x.order = i + 1; });
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
list: (status) => {
|
|
90
|
+
if (status !== undefined && !STATUSES.includes(status)) throw new Error(`status must be one of ${STATUSES.join(', ')}`);
|
|
91
|
+
return sorted().filter((x) => !status || x.status === status);
|
|
92
|
+
},
|
|
93
|
+
counts: () => Object.fromEntries(STATUSES.map((s) => [s, items.filter((x) => x.status === s).length])),
|
|
94
|
+
add(x = {}) { return commit(() => this.add0(x)); },
|
|
95
|
+
add0({ title, details, paths: p, from, resultPath } = {}) {
|
|
96
|
+
if (items.length >= MAX_ITEMS) throw new Error(`the queue is full (${MAX_ITEMS} items)`);
|
|
97
|
+
const t = now();
|
|
98
|
+
const it = { id: crypto.randomUUID().slice(0, 8), title: str(title, MAX_TITLE, 'title', true), ...(details !== undefined ? { details: str(details, MAX_DETAILS, 'details') } : {}), ...(p !== undefined ? { paths: paths(p) } : {}), ...handoff({ from, resultPath }), status: 'todo', order: (Math.max(0, ...items.map((x) => x.order)) + 1), createdAt: t, updatedAt: t };
|
|
99
|
+
items.push(it); save(); return it;
|
|
100
|
+
},
|
|
101
|
+
// Synchronous take: parallel chats calling queue_next never get the same item.
|
|
102
|
+
next({ worker } = {}) { return commit(() => {
|
|
103
|
+
const w = str(worker, 80, 'worker');
|
|
104
|
+
const t = Date.parse(now());
|
|
105
|
+
const stale = sorted().find((x) => x.status === 'doing' && t - Date.parse(x.updatedAt) >= STALE_MS);
|
|
106
|
+
const it = stale || sorted().find((x) => x.status === 'todo');
|
|
107
|
+
if (!it) return null;
|
|
108
|
+
if (stale) it.resumedFrom = it.worker || 'another chat';
|
|
109
|
+
it.status = 'doing'; it.updatedAt = now();
|
|
110
|
+
if (w) it.worker = w; else delete it.worker;
|
|
111
|
+
save(); return stale ? { ...it, resumed: true } : it;
|
|
112
|
+
}); },
|
|
113
|
+
update({ id, status, note } = {}) { return commit(() => {
|
|
114
|
+
const it = get(str(id, 40, 'id', true));
|
|
115
|
+
if (!STATUSES.includes(status)) throw new Error(`status must be one of ${STATUSES.join(', ')}`);
|
|
116
|
+
const n = note !== undefined ? str(note, MAX_NOTE, 'note') : undefined;
|
|
117
|
+
it.status = status; it.updatedAt = now();
|
|
118
|
+
if (note !== undefined) it.note = n;
|
|
119
|
+
if (['done', 'failed', 'skipped'].includes(status)) it.doneAt = it.updatedAt; else delete it.doneAt;
|
|
120
|
+
if (status !== 'doing') delete it.worker;
|
|
121
|
+
save(); return it;
|
|
122
|
+
}); },
|
|
123
|
+
edit({ id, title, details, paths: p, resultPath } = {}) { return commit(() => {
|
|
124
|
+
const it = get(str(id, 40, 'id', true));
|
|
125
|
+
// Everything is checked first, then changed.
|
|
126
|
+
const t = title !== undefined ? str(title, MAX_TITLE, 'title', true) : undefined;
|
|
127
|
+
const d = details !== undefined ? str(details, MAX_DETAILS, 'details') : undefined;
|
|
128
|
+
const ps = p !== undefined ? paths(p) : undefined;
|
|
129
|
+
const r = resultPath !== undefined ? resultPathOf(resultPath) : undefined;
|
|
130
|
+
if (title !== undefined) it.title = t;
|
|
131
|
+
if (details !== undefined) it.details = d;
|
|
132
|
+
if (p !== undefined) it.paths = ps;
|
|
133
|
+
if (resultPath !== undefined) { if (r) it.resultPath = r; else delete it.resultPath; }
|
|
134
|
+
it.updatedAt = now(); save(); return it;
|
|
135
|
+
}); },
|
|
136
|
+
reorder({ ids } = {}) { return commit(() => {
|
|
137
|
+
if (!Array.isArray(ids)) throw new Error('ids must be a list');
|
|
138
|
+
const first = ids.map((id) => get(String(id)));
|
|
139
|
+
renumber([...new Set(first), ...sorted().filter((x) => !first.includes(x))]);
|
|
140
|
+
save(); return sorted();
|
|
141
|
+
}); },
|
|
142
|
+
remove({ id } = {}) { return commit(() => { const it = get(String(id)); items = items.filter((x) => x !== it); renumber(sorted()); save(); return it; }); },
|
|
143
|
+
// Undo of a delete in the window: the item goes back at its old place.
|
|
144
|
+
restore(x = {}) { return commit(() => this.restore0(x)); },
|
|
145
|
+
restore0({ item } = {}) {
|
|
146
|
+
if (!item || typeof item !== 'object' || typeof item.id !== 'string' || items.some((x) => x.id === item.id)) throw new Error('nothing to restore');
|
|
147
|
+
if (!STATUSES.includes(item.status)) throw new Error('bad item');
|
|
148
|
+
const it = { ...item, title: str(item.title, MAX_TITLE, 'title', true) };
|
|
149
|
+
const list = sorted(); list.splice(Math.max(0, Math.min(list.length, (Number(item.order) || 1) - 1)), 0, it);
|
|
150
|
+
items = list; renumber(items); save(); return it;
|
|
151
|
+
},
|
|
152
|
+
clearFinished() { return commit(() => { items = items.filter((x) => !['done', 'failed', 'skipped'].includes(x.status)); renumber(sorted()); save(); return true; }); },
|
|
153
|
+
// status: done | failed | skipped | finished (all three) | all → the removed items (for Undo).
|
|
154
|
+
clear({ status = 'finished' } = {}) {
|
|
155
|
+
const pick = { done: ['done'], failed: ['failed'], skipped: ['skipped'], finished: ['done', 'failed', 'skipped'], 'failed+skipped': ['failed', 'skipped'], all: STATUSES }[status];
|
|
156
|
+
if (!pick) throw new Error('status must be done, failed, skipped or all');
|
|
157
|
+
return commit(() => {
|
|
158
|
+
const gone = sorted().filter((x) => pick.includes(x.status));
|
|
159
|
+
items = items.filter((x) => !gone.includes(x)); renumber(sorted()); save();
|
|
160
|
+
return gone;
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
restoreMany({ items: list } = {}) {
|
|
164
|
+
if (!Array.isArray(list)) throw new Error('nothing to restore');
|
|
165
|
+
return many(() => { for (const it of [...list].sort((a, b) => a.order - b.order)) this.restore0({ item: it }); return sorted(); });
|
|
166
|
+
},
|
|
167
|
+
addMany(list) {
|
|
168
|
+
if (!Array.isArray(list) || !list.length || list.length > 100) throw new Error('items must be a list of 1–100 tasks');
|
|
169
|
+
if (items.length + list.length > MAX_ITEMS) throw new Error(`the queue would be over ${MAX_ITEMS} items`);
|
|
170
|
+
// All or nothing: a half-added list would be added twice when the chat retries.
|
|
171
|
+
list.forEach((x, i) => { str(x?.title, MAX_TITLE, `items[${i}].title`, true); str(x?.details, MAX_DETAILS, `items[${i}].details`); paths(x?.paths); try { handoff(x); } catch (e) { throw new Error(`items[${i}]: ${e.message}`); } });
|
|
172
|
+
return many(() => list.map((x) => this.add0(x)));
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** queue_* tool call → MCP result. */
|
|
178
|
+
export function queueTool(q, name, args = {}) {
|
|
179
|
+
const ok = (v) => ({ content: [{ type: 'text', text: JSON.stringify(v, null, 2) }] });
|
|
180
|
+
try {
|
|
181
|
+
switch (name) {
|
|
182
|
+
case 'queue_list': return ok({ counts: q.counts(), items: q.list(args.status).map(({ id, title, status, note, worker, updatedAt, details, paths, from, resultPath }) => ({ id, title, status, ...(note ? { note } : {}), ...(worker ? { worker } : {}), updatedAt, ...(details ? { details } : {}), ...(paths ? { paths } : {}), ...(from ? { from } : {}), ...(resultPath ? { resultPath } : {}) })) });
|
|
183
|
+
case 'queue_next': { const it = q.next(args); return ok(it ? { item: it } : { item: null, message: 'The queue is empty — tell the person and stop.' }); }
|
|
184
|
+
case 'queue_update': return ok({ item: q.update(args) });
|
|
185
|
+
case 'queue_add': return args.items ? ok({ items: q.addMany(args.items) }) : ok({ item: q.add(args) });
|
|
186
|
+
case 'queue_clear': { if (args.status === 'all') throw new Error('clearing every item needs the person\'s OK'); const gone = q.clear({ status: args.status || 'finished' }); return ok({ removed: gone.length, counts: q.counts() }); }
|
|
187
|
+
case 'queue_edit': return ok({ item: q.edit(args) });
|
|
188
|
+
case 'queue_reorder': return ok({ items: q.reorder(args) });
|
|
189
|
+
default: return { content: [{ type: 'text', text: `unknown queue tool ${name}` }], isError: true };
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
return { content: [{ type: 'text', text: `Queue: ${e.message}` }], isError: true };
|
|
193
|
+
}
|
|
194
|
+
}
|
package/src/recent.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// "Recent on this PC": the last 100 tool calls WITH their command / path, for the person at the PC.
|
|
2
|
+
// In memory; with "Keep a local history" on also in %APPDATA%\Bridgerator\recent.jsonl (inside the
|
|
3
|
+
// always-excluded folder), entries older than 7 days dropped. Never sent to the server.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export const KEEP = 100;
|
|
7
|
+
export const DAYS = 7;
|
|
8
|
+
const short = (s, n = 300) => { s = String(s ?? ''); return s.length > n ? `${s.slice(0, n - 1)}…` : s; };
|
|
9
|
+
|
|
10
|
+
export function target(a = {}) {
|
|
11
|
+
if (!a || typeof a !== 'object') return '';
|
|
12
|
+
if (a.source && a.destination) return short(`${a.source} → ${a.destination}`);
|
|
13
|
+
const t = a.command ?? a.input ?? a.path ?? a.file_path ?? (Array.isArray(a.paths) ? a.paths.join(', ') : undefined) ?? a.pattern ?? a.title ?? a.id;
|
|
14
|
+
return t === undefined ? '' : short(typeof t === 'string' ? t : JSON.stringify(t));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createRecent(file, { keepFile = false, now = Date.now } = {}) {
|
|
18
|
+
let list = [], keep = keepFile;
|
|
19
|
+
const fresh = (e) => now() - e.t < DAYS * 86400000;
|
|
20
|
+
const prune = () => {
|
|
21
|
+
if (!keep) return;
|
|
22
|
+
try {
|
|
23
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
24
|
+
const ok = lines.filter((l) => { try { return fresh(JSON.parse(l)); } catch { return false; } });
|
|
25
|
+
if (ok.length !== lines.length || ok.length > KEEP * 10) fs.writeFileSync(file, ok.slice(-KEEP * 10).join('\n') + (ok.length ? '\n' : ''));
|
|
26
|
+
} catch { /* no file */ }
|
|
27
|
+
};
|
|
28
|
+
if (keep) { prune(); try { list = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)).filter(fresh).slice(-KEEP); } catch { /* none */ } }
|
|
29
|
+
let lastPrune = now();
|
|
30
|
+
return {
|
|
31
|
+
record(name, args, outcome, extra) {
|
|
32
|
+
const e = { t: now(), tool: String(name), target: target(args), outcome, ...(extra && typeof extra === 'object' ? extra : {}) };
|
|
33
|
+
list.push(e); if (list.length > KEEP) list.shift();
|
|
34
|
+
if (keep) {
|
|
35
|
+
try { fs.appendFileSync(file, JSON.stringify(e) + '\n'); } catch { /* read-only */ }
|
|
36
|
+
if (now() - lastPrune > 86400000) { lastPrune = now(); prune(); }
|
|
37
|
+
}
|
|
38
|
+
return e;
|
|
39
|
+
},
|
|
40
|
+
list: () => [...list].reverse(),
|
|
41
|
+
clear() { list = []; try { fs.rmSync(file, { force: true }); } catch { /* none */ } },
|
|
42
|
+
setKeep(v) { keep = !!v; if (!keep) { try { fs.rmSync(file, { force: true }); } catch { /* none */ } } else for (const e of list) try { fs.appendFileSync(file, JSON.stringify(e) + '\n'); } catch { /* */ } },
|
|
43
|
+
get keep() { return keep; },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Result of a call → ok | error | refused | not confirmed | paused. */
|
|
48
|
+
export const outcomeOf = (r) => (r?.paused ? 'paused' : r?.notConfirmed ? 'not confirmed' : r?.refused ? 'refused' : r?.isError ? 'error' : 'ok');
|
package/src/recipes.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Automations and recipes"). Ready-made PowerShell scripts
|
|
2
|
+
// (recipes/*.json in the package): the AI runs one with parameters or saves it as an automation.
|
|
3
|
+
// A recipe becomes ONE plain script: a `$P = @{…}` block with the checked parameters (single-quoted
|
|
4
|
+
// literals, every quote character doubled), then the recipe's own lines. Paths go through the same
|
|
5
|
+
// folder rules as the file tools (tool-lib guard); the whole script then through policy.check like a
|
|
6
|
+
// terminal command (bridge.mjs / automations.mjs), so an excluded folder can't hide in a parameter.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { expandVars, norm } from './policy.mjs';
|
|
12
|
+
import { ToolError, fail, guard, excludedInside } from './tool-lib.mjs';
|
|
13
|
+
|
|
14
|
+
export const RECIPE_IDS = ['backup-folder', 'clean-downloads', 'sort-folder', 'disk-report', 'update-apps'];
|
|
15
|
+
const DIR = new URL('../recipes/', import.meta.url);
|
|
16
|
+
let cache = null;
|
|
17
|
+
export function loadRecipes() {
|
|
18
|
+
cache ||= RECIPE_IDS.map((id) => JSON.parse(fs.readFileSync(new URL(`${id}.json`, DIR), 'utf8')));
|
|
19
|
+
return cache;
|
|
20
|
+
}
|
|
21
|
+
const byId = (id) => loadRecipes().find((r) => r.id === id) || fail('NO_RECIPE', `There is no recipe "${id}".`, `Use one of: ${RECIPE_IDS.join(', ')} (action "recipes" lists them).`);
|
|
22
|
+
|
|
23
|
+
/** What the AI gets from action "recipes" (English, parameters with types and defaults). */
|
|
24
|
+
export function recipeList(lang = 'en') {
|
|
25
|
+
const l = (o) => (o && (o[lang] || o.en)) || '';
|
|
26
|
+
return loadRecipes().map((r) => ({
|
|
27
|
+
id: r.id, title: l(r.title), description: l(r.description), suggestedSchedule: r.schedule,
|
|
28
|
+
params: r.params.map((p) => ({ name: p.name, type: p.type, ...(p.required ? { required: true } : {}), ...(p.default !== undefined ? { default: p.default } : {}), ...(p.values ? { values: p.values } : {}), ...(p.min !== undefined ? { min: p.min, max: p.max } : {}), description: l(p.description) })),
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
export const recipeTitle = (id) => { const r = loadRecipes().find((x) => x.id === id); return r ? r.title : null; };
|
|
32
|
+
|
|
33
|
+
// PowerShell single-quoted literal. PowerShell treats ' ‘ ’ ‚ ‛ all as single quotes; doubling each
|
|
34
|
+
// one keeps it literal. Control characters are refused before this (no line breaks in a value).
|
|
35
|
+
export const psQuote = (s) => `'${String(s).replace(/['‘’‚‛]/g, (c) => c + c)}'`;
|
|
36
|
+
const CTRL = /[\u0000-\u001f\u007f\u2028\u2029]/;
|
|
37
|
+
|
|
38
|
+
// The Recycle Bin helper of the bridge itself (recycle.ps1, IFileOperation without UI; an item that
|
|
39
|
+
// would be deleted for good is left alone). Copied into the script: a scheduled run needs no package.
|
|
40
|
+
export function recycleLib() {
|
|
41
|
+
const t = fs.readFileSync(fileURLToPath(new URL('./recycle.ps1', import.meta.url)), 'utf8');
|
|
42
|
+
const m = /Add-CachedType @'\r?\n([\s\S]*?)\r?\n'@/.exec(t);
|
|
43
|
+
if (!m || !/class BrRecycle\b/.test(m[1])) throw new ToolError('INTERNAL', 'The Recycle Bin helper of this Bridgerator version could not be read.', 'Update Bridgerator.');
|
|
44
|
+
// Plain Add-Type (not ask-lib's Add-CachedType): its cache is in Bridgerator's own folder, which a
|
|
45
|
+
// recipe / scheduled script may never name (policy.check refuses such a script).
|
|
46
|
+
return `Add-Type -TypeDefinition @'\n${m[1]}\n'@`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Relative folder path → "<depth>:<first 16 hex of SHA-256 of it in lower case>" (the recipe's walk computes the same). */
|
|
50
|
+
export const skipId = (rel) => `${rel.split(/[\\/]/).length}:${crypto.createHash('sha256').update(rel.toLowerCase(), 'utf8').digest('hex').slice(0, 16)}`;
|
|
51
|
+
|
|
52
|
+
function readParam(ctx, spec, v, all, skips = {}) {
|
|
53
|
+
const bad = (why) => fail('BAD_ARGS', `${spec.name}: ${why}.`);
|
|
54
|
+
if (spec.type === 'bool') { if (typeof v !== 'boolean') bad('must be true or false'); return v; }
|
|
55
|
+
if (spec.type === 'int') { if (!Number.isInteger(v) || v < spec.min || v > spec.max) bad(`must be a whole number from ${spec.min} to ${spec.max}`); return v; }
|
|
56
|
+
if (spec.type === 'enum') { if (!spec.values.includes(v)) bad(`must be one of ${spec.values.join(', ')}`); return v; }
|
|
57
|
+
if (spec.type === 'path') {
|
|
58
|
+
if (typeof v !== 'string' || !v.trim() || v.length > 1000 || CTRL.test(v)) bad('must be a full folder or file path on one line');
|
|
59
|
+
// A folder parameter is used as a whole tree (copied, walked, recycled below it): it must not contain an
|
|
60
|
+
// excluded / Bridgerator folder (FOLDER_HAS_EXCLUDED), except tree "skip": the script skips them itself.
|
|
61
|
+
const write = spec.access === 'write', folder = !spec.ext;
|
|
62
|
+
const abs = guard(ctx, expandVars(v.trim()), { write, tree: folder && spec.tree !== 'skip' });
|
|
63
|
+
if (folder && spec.tree === 'skip') skips[spec.name] = excludedInside(ctx, abs, { write }).map(skipId);
|
|
64
|
+
if (spec.mustExist) { let ok = false; try { ok = fs.statSync(abs).isDirectory(); } catch { /* missing */ } if (!ok) fail('NOT_FOUND', `${spec.name}: the folder "${abs}" does not exist.`, 'Check the path (list the folder first).'); }
|
|
65
|
+
if (spec.ext && path.extname(abs).toLowerCase() !== spec.ext) bad(`must end with ${spec.ext}`);
|
|
66
|
+
if (spec.notInside && all[spec.notInside]) {
|
|
67
|
+
const a = norm(abs), b = norm(all[spec.notInside]);
|
|
68
|
+
if (a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep)) bad(`must not be inside ${spec.notInside} (or contain it)`);
|
|
69
|
+
}
|
|
70
|
+
return abs;
|
|
71
|
+
}
|
|
72
|
+
bad('unknown type');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* → { recipe: id, params: {checked values}, script }. ctx: { cfg, policy } (folder rules).
|
|
77
|
+
* Throws ToolError on a wrong / missing parameter or a path the folder rules refuse.
|
|
78
|
+
*/
|
|
79
|
+
export function buildRecipe(ctx, id, input = {}) {
|
|
80
|
+
const r = byId(id);
|
|
81
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) fail('BAD_ARGS', 'params must be an object.');
|
|
82
|
+
const unknown = Object.keys(input).filter((k) => !r.params.some((p) => p.name === k));
|
|
83
|
+
if (unknown.length) fail('BAD_ARGS', `Unknown parameter(s) for ${id}: ${unknown.join(', ')}.`, `Parameters: ${r.params.map((p) => p.name).join(', ')}.`);
|
|
84
|
+
const out = {}, skips = {};
|
|
85
|
+
for (const spec of r.params) {
|
|
86
|
+
let v = input[spec.name];
|
|
87
|
+
if (v === undefined || v === null || v === '') {
|
|
88
|
+
if (spec.required) fail('BAD_ARGS', `${spec.name} is required for ${id}.`);
|
|
89
|
+
if (spec.default === undefined) { out[spec.name] = null; continue; }
|
|
90
|
+
v = spec.default;
|
|
91
|
+
}
|
|
92
|
+
out[spec.name] = readParam(ctx, spec, v, out, skips);
|
|
93
|
+
}
|
|
94
|
+
const lit = (v) => (v === null ? '$null' : typeof v === 'boolean' ? (v ? '$true' : '$false') : typeof v === 'number' ? String(v) : psQuote(v));
|
|
95
|
+
const body = r.script.join('\n').replace('#RECYCLE#', () => recycleLib());
|
|
96
|
+
const script = [
|
|
97
|
+
`# Recipe "${r.id}" (${r.title.en}). Parameters:`,
|
|
98
|
+
"$ErrorActionPreference = 'Continue'",
|
|
99
|
+
'$P = @{',
|
|
100
|
+
...r.params.map((p) => ` ${p.name} = ${lit(out[p.name])}`),
|
|
101
|
+
// Excluded folders below a tree "skip" folder as "<depth>:<hash of the relative path>" (skipId): the
|
|
102
|
+
// script never names them (it may not mention an excluded folder or Bridgerator).
|
|
103
|
+
...Object.entries(skips).map(([k, l]) => ` ${k}Skip = @(${l.map(psQuote).join(', ')})`),
|
|
104
|
+
'}',
|
|
105
|
+
body,
|
|
106
|
+
].join('\n');
|
|
107
|
+
return { recipe: r.id, params: out, script };
|
|
108
|
+
}
|
package/src/recycle.mjs
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Recycle Bin"). A delete command the AI runs in the terminal
|
|
2
|
+
// goes to the Recycle Bin instead, when Bridgerator can read it for sure: ONE delete statement
|
|
3
|
+
// (Remove-Item / ri / rm / del / erase / rd / rmdir, optionally inside `cmd /c`) with plain paths or a
|
|
4
|
+
// wildcard in the last part. Anything else (pipes, ;, &&, variables, loops, scripts, other programs
|
|
5
|
+
// that delete) runs as written — a safety net, not a guarantee (said so in the window and on the site).
|
|
6
|
+
// The move itself: recycle.ps1 (IFileOperation; an item the shell would delete for good is left alone
|
|
7
|
+
// and reported 'nobin' → the bridge asks on the PC before a permanent delete).
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { POWERSHELL, psArgs } from './win.mjs';
|
|
14
|
+
import { runFilesPs, stripOurs } from './ask.mjs';
|
|
15
|
+
|
|
16
|
+
const VERBS = new Set(['remove-item', 'ri', 'rm', 'del', 'erase', 'rd', 'rmdir']);
|
|
17
|
+
const MAX_TARGETS = 1000;
|
|
18
|
+
const RECURSE = new Set(['-recurse', '-r', '-rf', '-fr', '--recursive', '/s']);
|
|
19
|
+
const QUIET = new Set(['-force', '-f', '--force', '-v', '-verbose', '/q', '/f', '/a', '-confirm:$false']);
|
|
20
|
+
|
|
21
|
+
function tokenize(s) {
|
|
22
|
+
const out = [];
|
|
23
|
+
let cur = '', q = null, quoted = false;
|
|
24
|
+
const push = () => { if (cur || quoted) out.push({ v: cur, quoted }); cur = ''; quoted = false; };
|
|
25
|
+
for (const ch of s) {
|
|
26
|
+
if (q) { if (ch === q) q = null; else cur += ch; continue; }
|
|
27
|
+
if (ch === '"' || ch === "'") { q = ch; quoted = true; continue; }
|
|
28
|
+
if (/\s/.test(ch)) { push(); continue; }
|
|
29
|
+
if (ch === ',') { push(); out.push({ v: ',', sep: true }); continue; }
|
|
30
|
+
cur += ch;
|
|
31
|
+
}
|
|
32
|
+
if (q) return null; // unbalanced quote
|
|
33
|
+
push();
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const hasWild = (s) => /[*?]/.test(s);
|
|
38
|
+
const wildRe = (seg) => new RegExp(`^${seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`, 'i');
|
|
39
|
+
const kindOf = (p) => { try { const s = fs.lstatSync(p); return s.isDirectory() ? 'dir' : 'file'; } catch { return null; } };
|
|
40
|
+
const isEmptyDir = (p) => { try { return fs.readdirSync(p).length === 0; } catch { return false; } };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* One delete command → { verb, targets: [absolute paths], recurse } when Bridgerator can do exactly
|
|
44
|
+
* what it asks (each target exists, folders only with a recurse flag or empty), else null (the
|
|
45
|
+
* command then runs as written).
|
|
46
|
+
*/
|
|
47
|
+
export function parseDelete(command, cwd = os.homedir(), home = os.homedir()) {
|
|
48
|
+
let s = String(command ?? '').trim();
|
|
49
|
+
if (!s || s.length > 4000) return null;
|
|
50
|
+
const wrapped = /^cmd(?:\.exe)?\s+\/[ck]\s+(.+)$/i.exec(s);
|
|
51
|
+
if (wrapped) { s = wrapped[1].trim(); if (/^"[^"]*"$/.test(s)) s = s.slice(1, -1); }
|
|
52
|
+
s = s.replace(/\s-Confirm:\$false\b/gi, ' -confirm:$false');
|
|
53
|
+
// Only plain text: no statements, pipes, redirects, variables, sub-expressions, escapes, line breaks.
|
|
54
|
+
if (/[;&|<>`\r\n{}()@%]/.test(s) || /\$(?!false\b)/i.test(s)) return null;
|
|
55
|
+
const tok = tokenize(s);
|
|
56
|
+
if (!tok || !tok.length) return null;
|
|
57
|
+
const verb = tok[0].v.toLowerCase().replace(/\.exe$/, '');
|
|
58
|
+
if (tok[0].quoted || !VERBS.has(verb)) return null;
|
|
59
|
+
const cmdStyle = ['del', 'erase', 'rd', 'rmdir'].includes(verb);
|
|
60
|
+
let recurse = false, literal = false, force = false, allAttrs = false;
|
|
61
|
+
const raw = [];
|
|
62
|
+
for (let i = 1; i < tok.length; i++) {
|
|
63
|
+
const t = tok[i];
|
|
64
|
+
if (t.sep) continue;
|
|
65
|
+
const low = t.v.toLowerCase();
|
|
66
|
+
if (!t.quoted && /^(-|\/[a-z]|\/[a-z]:)/i.test(t.v) && !/^\/[a-z]:?[\\/]/i.test(t.v)) {
|
|
67
|
+
if (RECURSE.has(low)) { recurse = true; continue; }
|
|
68
|
+
if (low === '-force' || low === '/f') force = true;
|
|
69
|
+
if (low === '/a') allAttrs = true;
|
|
70
|
+
if (/^\/a:[a-z-]+$/.test(low)) { allAttrs = 'filter'; continue; }
|
|
71
|
+
if (QUIET.has(low)) continue;
|
|
72
|
+
if (low === '-path') continue;
|
|
73
|
+
if (low === '-literalpath' || low === '-lp') { literal = true; continue; }
|
|
74
|
+
if (low === '-erroraction' || low === '-ea') { i++; continue; }
|
|
75
|
+
return null; // a flag we don't know: run as written
|
|
76
|
+
}
|
|
77
|
+
raw.push(t.v);
|
|
78
|
+
}
|
|
79
|
+
if (!raw.length || (cmdStyle && (verb === 'del' || verb === 'erase') && recurse)) return null; // del /s *.tmp: every subfolder
|
|
80
|
+
const targets = [];
|
|
81
|
+
// Wildcard matches the command itself would skip: hidden / system items (del without /a,
|
|
82
|
+
// Remove-Item without -Force) and read-only files (without /f or -Force). Checked by recycle.ps1.
|
|
83
|
+
const skip = cmdStyle ? `${allAttrs === true ? '' : 'hs'}${force ? '' : 'r'}` : force ? '' : 'hsr';
|
|
84
|
+
const wild = [];
|
|
85
|
+
for (let p of raw) {
|
|
86
|
+
if (/^[\\/]{2}/.test(p) || /^[a-z][a-z0-9+.-]*:\/\//i.test(p)) return null;
|
|
87
|
+
if (!literal && /[[\]]/.test(p)) return null;
|
|
88
|
+
if (p === '~' || /^~[\\/]/.test(p)) p = path.join(home, p.slice(1));
|
|
89
|
+
const abs = path.resolve(cwd, p);
|
|
90
|
+
if (!literal && hasWild(abs)) {
|
|
91
|
+
const dir = path.dirname(abs), seg = path.basename(abs);
|
|
92
|
+
// rd / rmdir take no wildcards; /a:<attributes> filters we don't copy: run as written.
|
|
93
|
+
if (hasWild(dir) || verb === 'rd' || verb === 'rmdir' || allAttrs === 'filter') return null;
|
|
94
|
+
let names;
|
|
95
|
+
try { names = fs.readdirSync(dir); } catch { return null; }
|
|
96
|
+
const re = wildRe(seg);
|
|
97
|
+
const hits = names.filter((n) => re.test(n)).map((n) => path.join(dir, n));
|
|
98
|
+
if (!hits.length) return null;
|
|
99
|
+
for (const h of hits) {
|
|
100
|
+
const k = kindOf(h);
|
|
101
|
+
if (k === 'dir') { if (verb === 'del' || verb === 'erase') continue; if (!recurse && !isEmptyDir(h)) return null; }
|
|
102
|
+
targets.push(h);
|
|
103
|
+
wild.push(h);
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const k = kindOf(abs);
|
|
108
|
+
if (!k) return null;
|
|
109
|
+
if (k === 'dir' && (verb === 'del' || verb === 'erase')) return null; // del <folder> deletes the files inside
|
|
110
|
+
if (k === 'dir' && !recurse && !isEmptyDir(abs)) return null;
|
|
111
|
+
if (k === 'file' && (verb === 'rd' || verb === 'rmdir')) return null;
|
|
112
|
+
targets.push(abs);
|
|
113
|
+
}
|
|
114
|
+
const uniq = [...new Set(targets)];
|
|
115
|
+
if (!uniq.length || uniq.length > MAX_TARGETS) return null;
|
|
116
|
+
return { verb, targets: uniq, recurse, ...(wild.length && skip ? { skip: Object.fromEntries(wild.map((h) => [h, skip])) } : {}) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const LIB = fileURLToPath(new URL('./ask-lib.ps1', import.meta.url));
|
|
120
|
+
const PS = fileURLToPath(new URL('./recycle.ps1', import.meta.url));
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Moves the paths to the Recycle Bin → [{ p, r: 'recycled' | 'nobin' | 'missing' | 'skipped' | 'error:…' }].
|
|
124
|
+
* skip: { path: 'hsr' } — leave that item alone ('skipped') when it is hidden (h) / system (s) / read-only (r).
|
|
125
|
+
*/
|
|
126
|
+
export function recycle(paths, { timeoutMs = 120000, skip = {} } = {}) {
|
|
127
|
+
if (process.platform !== 'win32') return Promise.resolve(paths.map((p) => ({ p, r: 'nobin' })));
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
const fail = (why) => resolve(paths.map((p) => ({ p, r: `error:${why}` })));
|
|
130
|
+
const chunks = [];
|
|
131
|
+
let done = false;
|
|
132
|
+
const child = spawn(POWERSHELL, psArgs(runFilesPs(LIB, PS)), { windowsHide: true, env: stripOurs(process.env), stdio: ['pipe', 'pipe', 'pipe'] });
|
|
133
|
+
const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } if (!done) { done = true; fail('timeout'); } }, timeoutMs);
|
|
134
|
+
child.stdout.on('data', (b) => { chunks.push(b); }); // decoded once at the end: a UTF-8 character may span chunks
|
|
135
|
+
child.stderr.on('data', () => {});
|
|
136
|
+
child.on('error', (e) => { clearTimeout(timer); if (!done) { done = true; fail(e.code || 'spawn'); } });
|
|
137
|
+
child.on('close', () => {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
if (done) return;
|
|
140
|
+
done = true;
|
|
141
|
+
try {
|
|
142
|
+
const r = JSON.parse(Buffer.concat(chunks).toString('utf8').trim().split(/\r?\n/).pop());
|
|
143
|
+
const list = Array.isArray(r) ? r : [r];
|
|
144
|
+
resolve(paths.map((p) => list.find((x) => x && x.p === p) || { p, r: 'error:no-answer' }));
|
|
145
|
+
} catch { fail('bad-answer'); }
|
|
146
|
+
});
|
|
147
|
+
// PowerShell gone early (EPIPE): the error is reported through 'close' / the timeout, never thrown.
|
|
148
|
+
child.stdin.on('error', () => {});
|
|
149
|
+
child.stdin.end(JSON.stringify(paths.map((p) => ({ p, skip: typeof skip[p] === 'string' ? skip[p] : '' }))));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Undo of a delete: the restored item's copy leaves the Recycle Bin too ----------------------
|
|
154
|
+
// Each recycled item is a pair in <drive>\$Recycle.Bin\<SID>\: $I<id> (original path + deletion time)
|
|
155
|
+
// and $R<id> (the item itself). Only this user's folder is readable, so only their items match.
|
|
156
|
+
const FILETIME_EPOCH_MS = 11644473600000;
|
|
157
|
+
/** $I file → { path, t } (format 1: Vista–8, 2: Windows 10+), or null. */
|
|
158
|
+
export function readBinInfo(buf) {
|
|
159
|
+
try {
|
|
160
|
+
const v = Number(buf.readBigInt64LE(0));
|
|
161
|
+
const t = Number(buf.readBigInt64LE(16) / 10000n) - FILETIME_EPOCH_MS;
|
|
162
|
+
const p = v === 2 ? buf.toString('utf16le', 28, 28 + (buf.readUInt32LE(24) - 1) * 2) : v === 1 ? buf.toString('utf16le', 24, 24 + 520) : null;
|
|
163
|
+
return p ? { path: p.replace(/\0.*$/s, ''), t } : null;
|
|
164
|
+
} catch { return null; }
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Removes from the Recycle Bin exactly the copies this bridge made: items = [{ path, bin }], bin = the
|
|
168
|
+
* $R path recycle.ps1 reported when it recycled `path`. Removed only when that pair is still there and
|
|
169
|
+
* its $I names that same original path; an item without a recorded copy is left alone (never another
|
|
170
|
+
* bin entry — it may be the person's own later deletion). → the paths whose bin copy was removed. Never throws.
|
|
171
|
+
*/
|
|
172
|
+
export function purgeFromBin(items) {
|
|
173
|
+
const gone = [];
|
|
174
|
+
for (const it of Array.isArray(items) ? items : []) {
|
|
175
|
+
try {
|
|
176
|
+
const r = typeof it?.bin === 'string' ? path.resolve(it.bin) : '', n = path.basename(r);
|
|
177
|
+
if (!/^\$R/i.test(n) || path.basename(path.dirname(path.dirname(r))).toLowerCase() !== '$recycle.bin') continue;
|
|
178
|
+
const i = path.join(path.dirname(r), `$I${n.slice(2)}`);
|
|
179
|
+
const info = readBinInfo(fs.readFileSync(i));
|
|
180
|
+
if (!info || path.resolve(info.path).toLowerCase() !== path.resolve(String(it.path)).toLowerCase()) continue;
|
|
181
|
+
fs.rmSync(r, { recursive: true, force: true }); fs.rmSync(i, { force: true }); gone.push(it.path);
|
|
182
|
+
} catch { /* gone already / in use: stays in the bin */ }
|
|
183
|
+
}
|
|
184
|
+
return gone;
|
|
185
|
+
}
|