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,673 @@
|
|
|
1
|
+
// LOGIC: see critical-logic/bridge.md ("Automations and recipes").
|
|
2
|
+
// "Save as a script and run it every morning": the AI saves a PowerShell script (its own or a recipe
|
|
3
|
+
// with parameters) into %LOCALAPPDATA%\Bridgerator\scripts — inside the always-excluded folder, so the
|
|
4
|
+
// AI changes it only through this tool — and registers it in Windows Task Scheduler (folder
|
|
5
|
+
// \Bridgerator\, this user, no elevation, runs only while the user is signed in). Windows runs it
|
|
6
|
+
// without the AI: no chat, no tool calls. Each run is logged locally (time, exit code, end of the
|
|
7
|
+
// output) by runner.ps1; the AI reads it with action "runs", the window shows it on Automations.
|
|
8
|
+
// - scheduling / changing a schedule = something that stays on the PC: an info notification after
|
|
9
|
+
// it (on by default, setting automations.notifySchedule) and, when confirmations are on and the
|
|
10
|
+
// "Scheduled scripts" category is on (off by default), a question before it;
|
|
11
|
+
// - a script that names an excluded folder or Bridgerator itself is refused like a terminal command;
|
|
12
|
+
// the terminal being off (presets Read only / Files) turns automations off too;
|
|
13
|
+
// - uninstall removes our tasks (removeTasks) and, unless --keep-config, the scripts folder.
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { POWERSHELL, WSCRIPT } from './win.mjs';
|
|
20
|
+
import { stripOurs } from './ask.mjs';
|
|
21
|
+
import { writeAtomic, clip } from './confirm.mjs';
|
|
22
|
+
import { runPs, psJson, ToolError, fail, json, needAction, str } from './tool-lib.mjs';
|
|
23
|
+
import { recipeList, buildRecipe } from './recipes.mjs';
|
|
24
|
+
|
|
25
|
+
export const TASK_PATH = '\\Bridgerator\\';
|
|
26
|
+
export const MAX_AUTOMATIONS = 50, MAX_SCRIPT = 20000, MAX_NAME = 80, TAIL = 4000;
|
|
27
|
+
const RUN_WAIT_MS = 50000, ONE_OFF_MS = 90000, STALE_RUN_MS = 3 * 3600000, WATCH_MS = 30000;
|
|
28
|
+
export const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
|
29
|
+
const PS_DAY = { mon: 'Monday', tue: 'Tuesday', wed: 'Wednesday', thu: 'Thursday', fri: 'Friday', sat: 'Saturday', sun: 'Sunday' };
|
|
30
|
+
const JS_DAY = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
|
31
|
+
const ID_RE = /^[0-9a-f]{8}$/;
|
|
32
|
+
const CTRL = /[\u0000-\u001f\u007f]/;
|
|
33
|
+
|
|
34
|
+
export const defaultDir = (env = process.env) => path.join(env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Bridgerator', 'scripts');
|
|
35
|
+
|
|
36
|
+
// ---- schedules --------------------------------------------------------------------------------------
|
|
37
|
+
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
38
|
+
/** Schedule from the AI / window → normalized { type, … } or null (no schedule). Throws BAD_SCHEDULE. */
|
|
39
|
+
export function readSchedule(s, now = Date.now(), { future = true } = {}) {
|
|
40
|
+
if (s === null || s === undefined) return null;
|
|
41
|
+
const bad = (why) => fail('BAD_SCHEDULE', `Schedule: ${why}.`, 'Examples: {"type":"daily","time":"08:00"}, {"type":"weekly","days":["mon","fri"],"time":"09:30"}, {"type":"hourly","every":2}, {"type":"logon"}, {"type":"once","at":"2026-11-01T08:00"}.');
|
|
42
|
+
if (typeof s !== 'object' || Array.isArray(s)) bad('must be an object');
|
|
43
|
+
const extra = Object.keys(s).filter((k) => !['type', 'time', 'days', 'every', 'at'].includes(k));
|
|
44
|
+
if (extra.length) bad(`unknown field(s) ${extra.join(', ')}`);
|
|
45
|
+
const time = () => { if (!TIME.test(String(s.time))) bad('time must be HH:MM (24 h), e.g. 08:00'); return s.time; };
|
|
46
|
+
switch (s.type) {
|
|
47
|
+
case 'daily': return { type: 'daily', time: time() };
|
|
48
|
+
case 'weekly': {
|
|
49
|
+
if (!Array.isArray(s.days) || !s.days.length || s.days.length > 7 || s.days.some((d) => !DAYS.includes(d)) || new Set(s.days).size !== s.days.length) bad(`days must be a list of ${DAYS.join(', ')}`);
|
|
50
|
+
return { type: 'weekly', days: DAYS.filter((d) => s.days.includes(d)), time: time() };
|
|
51
|
+
}
|
|
52
|
+
case 'hourly': if (!Number.isInteger(s.every) || s.every < 1 || s.every > 12) bad('every must be 1 to 12 (hours)'); return { type: 'hourly', every: s.every };
|
|
53
|
+
case 'logon': return { type: 'logon' };
|
|
54
|
+
case 'once': {
|
|
55
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(String(s.at));
|
|
56
|
+
if (!m) bad('at must be local date and time YYYY-MM-DDTHH:MM');
|
|
57
|
+
const t = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]).getTime();
|
|
58
|
+
if (future && (!(t > now + 60000) || t > now + 366 * 86400000)) bad('at must be in the future, within a year');
|
|
59
|
+
return { type: 'once', at: s.at };
|
|
60
|
+
}
|
|
61
|
+
default: bad('type must be daily, weekly, hourly, logon or once');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function describeSchedule(s) {
|
|
65
|
+
if (!s) return 'not scheduled (run on request)';
|
|
66
|
+
if (s.type === 'daily') return `every day at ${s.time}`;
|
|
67
|
+
if (s.type === 'weekly') return `${s.days.map((d) => d[0].toUpperCase() + d.slice(1)).join(', ')} at ${s.time}`;
|
|
68
|
+
if (s.type === 'hourly') return s.every === 1 ? 'every hour' : `every ${s.every} hours`;
|
|
69
|
+
if (s.type === 'logon') return 'at sign-in';
|
|
70
|
+
return `once on ${s.at.replace('T', ' ')}`;
|
|
71
|
+
}
|
|
72
|
+
/** Next start (ms, local time) or null (sign-in / past). Hourly repeats from 00:00 each day. */
|
|
73
|
+
export function nextRun(s, now = Date.now()) {
|
|
74
|
+
if (!s) return null;
|
|
75
|
+
const at = (d, hm) => { const [h, m] = hm.split(':').map(Number); const x = new Date(d); x.setHours(h, m, 0, 0); return x.getTime(); };
|
|
76
|
+
if (s.type === 'daily') { const t = at(now, s.time); if (t > now) return t; const d = new Date(now); d.setDate(d.getDate() + 1); return at(d.getTime(), s.time); }
|
|
77
|
+
if (s.type === 'weekly') {
|
|
78
|
+
for (let i = 0; i < 8; i++) {
|
|
79
|
+
const d = new Date(now); d.setDate(d.getDate() + i);
|
|
80
|
+
if (!s.days.some((x) => JS_DAY[x] === d.getDay())) continue;
|
|
81
|
+
const t = at(d.getTime(), s.time);
|
|
82
|
+
if (t > now) return t;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (s.type === 'hourly') {
|
|
87
|
+
const base = new Date(now); base.setHours(0, 0, 0, 0);
|
|
88
|
+
const step = s.every * 3600000;
|
|
89
|
+
const t = base.getTime() + Math.floor((now - base.getTime()) / step + 1) * step;
|
|
90
|
+
const tomorrow = new Date(base); tomorrow.setDate(tomorrow.getDate() + 1);
|
|
91
|
+
return t < tomorrow.getTime() ? t : tomorrow.getTime();
|
|
92
|
+
}
|
|
93
|
+
if (s.type === 'once') { const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(s.at); const t = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]).getTime(); return t > now ? t : null; }
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- files the scheduled task runs (written next to the scripts, from the package) -------------------
|
|
98
|
+
// runner.ps1 (inputs BRX_RUN_ID / BRX_RUN_TRIGGER / BRX_RUN_DIR in the environment): runs <id>.ps1 in its own PowerShell, appends {t, ms, code, trigger, tail} to runs/<id>.jsonl
|
|
99
|
+
// (last 50 kept), marks runs/<id>.running while it works. ASCII only.
|
|
100
|
+
export const RUNNER_PS = `# Runs one saved automation and logs the result (time, exit code, end of the output).
|
|
101
|
+
$Id = [string]$env:BRX_RUN_ID
|
|
102
|
+
$Trigger = [string]$env:BRX_RUN_TRIGGER
|
|
103
|
+
$ErrorActionPreference = 'Continue'
|
|
104
|
+
if ($Id -notmatch '^[0-9a-f]{8}$') { exit 2 }
|
|
105
|
+
if ($Trigger -notmatch '^(schedule|manual|ai)$') { $Trigger = 'schedule' }
|
|
106
|
+
$dir = [string]$env:BRX_RUN_DIR
|
|
107
|
+
if (-not $dir -or -not (Test-Path -LiteralPath $dir)) { exit 2 }
|
|
108
|
+
$file = Join-Path $dir ($Id + '.ps1')
|
|
109
|
+
$runs = Join-Path $dir 'runs'
|
|
110
|
+
[void][IO.Directory]::CreateDirectory($runs)
|
|
111
|
+
$log = Join-Path $runs ($Id + '.jsonl')
|
|
112
|
+
$mark = Join-Path $runs ($Id + '.running')
|
|
113
|
+
$utf8 = New-Object Text.UTF8Encoding $false
|
|
114
|
+
# UTF-8 between this runner and the script's own PowerShell (it inherits the console): the code page
|
|
115
|
+
# (437 / 866 / 1251) would garble any other letters in the log.
|
|
116
|
+
try { [Console]::OutputEncoding = $utf8 } catch { $null = $_ }
|
|
117
|
+
$start = Get-Date
|
|
118
|
+
[IO.File]::WriteAllText($mark, [string]$PID, $utf8)
|
|
119
|
+
$code = 0
|
|
120
|
+
$text = ''
|
|
121
|
+
try {
|
|
122
|
+
if (-not (Test-Path -LiteralPath $file)) { throw 'The script file is missing.' }
|
|
123
|
+
$ps = Join-Path $env:SystemRoot 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
|
124
|
+
# stderr lines come back as error records: keep only their text (not the runner's own position).
|
|
125
|
+
# Loaded as UTF-8 text into a script block (like every PowerShell file of the bridge, never -File): an
|
|
126
|
+
# execution policy set by the administrator for script files does not stop it, the BOM / code page don't matter.
|
|
127
|
+
$env:BRX_SCRIPT = $file
|
|
128
|
+
$text = (& $ps -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command '. ([scriptblock]::Create([IO.File]::ReadAllText($env:BRX_SCRIPT, [Text.Encoding]::UTF8)))' 2>&1 | ForEach-Object { if ($_ -is [Management.Automation.ErrorRecord]) { $_.Exception.Message } else { $_ } } | Out-String -Width 220)
|
|
129
|
+
$code = $LASTEXITCODE
|
|
130
|
+
if ($null -eq $code) { $code = 0 }
|
|
131
|
+
} catch {
|
|
132
|
+
$text = $text + ($_ | Out-String)
|
|
133
|
+
$code = 1
|
|
134
|
+
}
|
|
135
|
+
$text = $text.TrimEnd()
|
|
136
|
+
if ($text.Length -gt ${TAIL}) { $text = '...' + $text.Substring($text.Length - ${TAIL}) }
|
|
137
|
+
$rec = [ordered]@{ t = $start.ToUniversalTime().ToString('o'); ms = [int]((Get-Date) - $start).TotalMilliseconds; code = [int]$code; trigger = $Trigger; tail = $text }
|
|
138
|
+
[IO.File]::AppendAllText($log, (ConvertTo-Json -InputObject $rec -Compress) + [char]10, $utf8)
|
|
139
|
+
try {
|
|
140
|
+
$lines = [IO.File]::ReadAllLines($log, $utf8)
|
|
141
|
+
if ($lines.Count -gt 60) { [IO.File]::WriteAllLines($log, [string[]]($lines | Select-Object -Last 50), $utf8) }
|
|
142
|
+
} catch { Write-Output ('log not trimmed: ' + $_.Exception.Message) }
|
|
143
|
+
Remove-Item -LiteralPath $mark -Force -ErrorAction SilentlyContinue
|
|
144
|
+
exit $code
|
|
145
|
+
`;
|
|
146
|
+
// The runner is loaded as UTF-8 text (no -File: see runner.ps1); its path comes in BRX_RUNNER.
|
|
147
|
+
export const RUN_CMD = '. ([scriptblock]::Create([IO.File]::ReadAllText($env:BRX_RUNNER, [Text.Encoding]::UTF8)))';
|
|
148
|
+
// run-hidden.vbs <id>: the task's action (wscript, window style 0 — no console flash). UTF-16 + BOM.
|
|
149
|
+
export const RUN_VBS = [
|
|
150
|
+
"' Runs one saved automation hidden: run-hidden.vbs <id>",
|
|
151
|
+
'On Error Resume Next',
|
|
152
|
+
'If WScript.Arguments.Count < 1 Then WScript.Quit 2',
|
|
153
|
+
'id = WScript.Arguments(0)',
|
|
154
|
+
'Set re = New RegExp',
|
|
155
|
+
're.Pattern = "^[0-9a-f]{8}$"',
|
|
156
|
+
'If Not re.Test(id) Then WScript.Quit 2',
|
|
157
|
+
'Set sh = CreateObject("WScript.Shell")',
|
|
158
|
+
'Set fso = CreateObject("Scripting.FileSystemObject")',
|
|
159
|
+
'dir = fso.GetParentFolderName(WScript.ScriptFullName)',
|
|
160
|
+
'ps = sh.ExpandEnvironmentStrings("%SystemRoot%") & "\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"',
|
|
161
|
+
'Set env = sh.Environment("Process")',
|
|
162
|
+
'env("BRX_RUN_ID") = id',
|
|
163
|
+
'env("BRX_RUN_TRIGGER") = "schedule"',
|
|
164
|
+
'env("BRX_RUN_DIR") = dir',
|
|
165
|
+
'env("BRX_RUNNER") = dir & "\\runner.ps1"',
|
|
166
|
+
`rc = sh.Run("""" & ps & """ -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ${JSON.stringify(RUN_CMD).replace(/"/g, '""')}", 0, True)`,
|
|
167
|
+
// PowerShell did not start (On Error Resume Next would hide it): a log line like runner.ps1's, exit 1.
|
|
168
|
+
'If Err.Number <> 0 Then',
|
|
169
|
+
' Err.Clear',
|
|
170
|
+
' n = Now',
|
|
171
|
+
' t = Year(n) & "-" & Right("0" & Month(n), 2) & "-" & Right("0" & Day(n), 2) & "T" & Right("0" & Hour(n), 2) & ":" & Right("0" & Minute(n), 2) & ":" & Right("0" & Second(n), 2)',
|
|
172
|
+
' Set f = fso.OpenTextFile(dir & "\\runs\\" & id & ".jsonl", 8, True)',
|
|
173
|
+
' f.WriteLine "{""t"":""" & t & """,""ms"":0,""code"":1,""trigger"":""schedule"",""tail"":""PowerShell did not start (wscript error).""}"',
|
|
174
|
+
' f.Close',
|
|
175
|
+
' WScript.Quit 1',
|
|
176
|
+
'End If',
|
|
177
|
+
'WScript.Quit rc',
|
|
178
|
+
'',
|
|
179
|
+
].join('\r\n');
|
|
180
|
+
|
|
181
|
+
// ---- Task Scheduler (PowerShell ScheduledTasks module; values only through BRX_* env) ----------------
|
|
182
|
+
// Folder \\Bridgerator\\ first; if Windows refuses it (a standard user may not create task folders on
|
|
183
|
+
// some PCs) the task goes to the root as Bridgerator-auto-<id>. The first error is the one reported.
|
|
184
|
+
export const REGISTER_PS = `$s = $env:BRX_SCHED | ConvertFrom-Json
|
|
185
|
+
$me = "$env:USERDOMAIN\\$env:USERNAME"
|
|
186
|
+
$inv = [Globalization.CultureInfo]::InvariantCulture
|
|
187
|
+
switch ($s.type) {
|
|
188
|
+
'daily' { $t = New-ScheduledTaskTrigger -Daily -At ([datetime]::ParseExact($s.time, 'HH:mm', $inv)) }
|
|
189
|
+
'weekly' { $t = New-ScheduledTaskTrigger -Weekly -WeeksInterval 1 -DaysOfWeek ([string[]]$s.days) -At ([datetime]::ParseExact($s.time, 'HH:mm', $inv)) }
|
|
190
|
+
'hourly' { $t = New-ScheduledTaskTrigger -Daily -At ([datetime]::Today); $t.Repetition = (New-ScheduledTaskTrigger -Once -At ([datetime]::Today) -RepetitionInterval (New-TimeSpan -Hours ([int]$s.every)) -RepetitionDuration (New-TimeSpan -Hours 24)).Repetition }
|
|
191
|
+
'logon' { $t = New-ScheduledTaskTrigger -AtLogOn -User $me }
|
|
192
|
+
'once' { $t = New-ScheduledTaskTrigger -Once -At ([datetime]::ParseExact($s.at, 'yyyy-MM-ddTHH:mm', $inv)) }
|
|
193
|
+
default { throw 'unknown schedule type' }
|
|
194
|
+
}
|
|
195
|
+
# Local wall-clock time without a zone: a time with Z / an offset is "synchronized across time zones"
|
|
196
|
+
# and would move by an hour when daylight saving time changes.
|
|
197
|
+
$day = (Get-Date).ToString('yyyy-MM-dd', $inv)
|
|
198
|
+
if ($s.type -eq 'daily' -or $s.type -eq 'weekly') { $t.StartBoundary = $day + 'T' + $s.time + ':00' }
|
|
199
|
+
elseif ($s.type -eq 'hourly') { $t.StartBoundary = $day + 'T00:00:00' }
|
|
200
|
+
elseif ($s.type -eq 'once') { $t.StartBoundary = $s.at + ':00' }
|
|
201
|
+
$a = New-ScheduledTaskAction -Execute $env:BRX_WSCRIPT -Argument ('//B //NoLogo "' + $env:BRX_VBS + '" ' + $env:BRX_ID)
|
|
202
|
+
$set = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 2) -MultipleInstances IgnoreNew
|
|
203
|
+
$set.Enabled = ($env:BRX_ENABLED -eq '1')
|
|
204
|
+
$p = New-ScheduledTaskPrincipal -UserId $me -LogonType Interactive -RunLevel Limited
|
|
205
|
+
if (-not $env:BRX_TASKNAME -or -not $env:BRX_TASKPATH.StartsWith('\\')) { throw 'task location missing' }
|
|
206
|
+
# ArrayList: a nested @(@(a, b)) would be flattened and the loop would get single strings.
|
|
207
|
+
$where = New-Object System.Collections.ArrayList
|
|
208
|
+
[void]$where.Add(@($env:BRX_TASKPATH, $env:BRX_TASKNAME))
|
|
209
|
+
if ($env:BRX_FALLBACK -eq '1') { [void]$where.Add(@('\\', ('Bridgerator-auto-' + $env:BRX_ID))) }
|
|
210
|
+
$first = $null
|
|
211
|
+
foreach ($w in $where) {
|
|
212
|
+
try {
|
|
213
|
+
Register-ScheduledTask -TaskPath $w[0] -TaskName $w[1] -Description $env:BRX_DESC -Action $a -Trigger $t -Settings $set -Principal $p -Force -ErrorAction Stop | Out-Null
|
|
214
|
+
ConvertTo-Json -Compress @{ ok = $true; taskPath = $w[0]; taskName = $w[1] }
|
|
215
|
+
return
|
|
216
|
+
} catch { if (-not $first) { $first = $_.Exception.Message } }
|
|
217
|
+
}
|
|
218
|
+
ConvertTo-Json -Compress @{ ok = $false; error = [string]$first }`;
|
|
219
|
+
export const UNREGISTER_PS = `try {
|
|
220
|
+
Unregister-ScheduledTask -TaskPath $env:BRX_TASKPATH -TaskName $env:BRX_TASKNAME -Confirm:$false -ErrorAction Stop
|
|
221
|
+
ConvertTo-Json -Compress @{ ok = $true }
|
|
222
|
+
} catch {
|
|
223
|
+
$e = $_.Exception.Message
|
|
224
|
+
if (Get-ScheduledTask -TaskPath $env:BRX_TASKPATH -TaskName $env:BRX_TASKNAME -ErrorAction SilentlyContinue) { ConvertTo-Json -Compress @{ ok = $false; error = $e } }
|
|
225
|
+
else { ConvertTo-Json -Compress @{ ok = $true; missing = $true } }
|
|
226
|
+
}`;
|
|
227
|
+
export const ENABLE_PS = `try {
|
|
228
|
+
if ($env:BRX_ON -eq '1') { Enable-ScheduledTask -TaskPath $env:BRX_TASKPATH -TaskName $env:BRX_TASKNAME -ErrorAction Stop | Out-Null }
|
|
229
|
+
else { Disable-ScheduledTask -TaskPath $env:BRX_TASKPATH -TaskName $env:BRX_TASKNAME -ErrorAction Stop | Out-Null }
|
|
230
|
+
ConvertTo-Json -Compress @{ ok = $true }
|
|
231
|
+
} catch { ConvertTo-Json -Compress @{ ok = $false; error = $_.Exception.Message } }`;
|
|
232
|
+
// Uninstall: every task in \\Bridgerator\\ (and Bridgerator-auto-* in the root) whose action runs a file
|
|
233
|
+
// of THIS user's scripts folder; then the folder itself if it is empty (other users' tasks stay).
|
|
234
|
+
export const REMOVE_ALL_PS = `$dir = $env:BRX_DIR.ToLower()
|
|
235
|
+
$n = 0
|
|
236
|
+
$errs = @()
|
|
237
|
+
$all = @(Get-ScheduledTask -TaskPath $env:BRX_TASKPATH -ErrorAction SilentlyContinue) + @(Get-ScheduledTask -TaskPath '\\' -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -like 'Bridgerator-auto-*' })
|
|
238
|
+
foreach ($t in $all) {
|
|
239
|
+
if (((@($t.Actions) | ForEach-Object { $_.Arguments }) -join ' ').ToLower().Contains($dir)) {
|
|
240
|
+
try { Unregister-ScheduledTask -TaskPath $t.TaskPath -TaskName $t.TaskName -Confirm:$false -ErrorAction Stop; $n++ } catch { $errs += $_.Exception.Message }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
$folder = 'kept'
|
|
244
|
+
try {
|
|
245
|
+
$svc = New-Object -ComObject Schedule.Service
|
|
246
|
+
$svc.Connect()
|
|
247
|
+
$name = $env:BRX_TASKPATH.Trim('\\')
|
|
248
|
+
$f = $svc.GetFolder('\\' + $name)
|
|
249
|
+
if ($f.GetTasks(1).Count -eq 0 -and $f.GetFolders(0).Count -eq 0) { $svc.GetFolder('\\').DeleteFolder($name, 0); $folder = 'removed' }
|
|
250
|
+
} catch { $folder = 'kept: ' + $_.Exception.Message }
|
|
251
|
+
ConvertTo-Json -Compress @{ removed = $n; errors = @($errs); folder = $folder }`;
|
|
252
|
+
|
|
253
|
+
/** Uninstall: our tasks out of Task Scheduler. → { removed, errors, folder } (never throws). */
|
|
254
|
+
export async function removeTasks({ dir = defaultDir(), taskPath = TASK_PATH, ps = runPs } = {}) {
|
|
255
|
+
if (process.platform !== 'win32' && ps === runPs) return { removed: 0, errors: [], folder: 'not Windows' };
|
|
256
|
+
let r;
|
|
257
|
+
try { r = await psJson({ runPs: ps }, { script: REMOVE_ALL_PS, env: { BRX_DIR: dir, BRX_TASKPATH: taskPath }, timeout: 60000 }); } catch (e) { return { removed: 0, errors: [String(e?.message || e)], folder: 'not checked' }; }
|
|
258
|
+
// uninstall --keep-config keeps the scripts: they are no longer scheduled (a schedule can be set again).
|
|
259
|
+
const index = path.join(dir, 'automations.json');
|
|
260
|
+
try {
|
|
261
|
+
const j = JSON.parse(fs.readFileSync(index, 'utf8'));
|
|
262
|
+
if (Array.isArray(j.items)) { for (const x of j.items) { delete x.taskName; delete x.taskPath; } writeAtomic(index, JSON.stringify(j, null, 2)); }
|
|
263
|
+
} catch (e) { if (e.code !== 'ENOENT') r.errors = [...(r.errors || []), `automations.json: ${e.message}`]; }
|
|
264
|
+
return r;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The runner for "Run now" (window / AI): same runner.ps1 as the schedule, hidden. → exit promise. */
|
|
268
|
+
export function startRunner(dir, id, trigger) {
|
|
269
|
+
return new Promise((resolve) => {
|
|
270
|
+
let child;
|
|
271
|
+
const env = { ...stripOurs(process.env), BRX_RUN_ID: id, BRX_RUN_TRIGGER: trigger, BRX_RUN_DIR: dir, BRX_RUNNER: path.join(dir, 'runner.ps1') };
|
|
272
|
+
try { child = spawn(POWERSHELL, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', RUN_CMD], { windowsHide: true, stdio: 'ignore', env }); } catch (e) { resolve({ error: e }); return; }
|
|
273
|
+
child.on('error', (e) => resolve({ error: e }));
|
|
274
|
+
child.on('exit', (code) => resolve({ code }));
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- the store ----------------------------------------------------------------------------------------
|
|
279
|
+
/**
|
|
280
|
+
* dir: the scripts folder (created on the first save, never at start). ask: the bridge's question
|
|
281
|
+
* function (info notification), openWindow(page), emit(event) → relay, onChange() → mode / window.
|
|
282
|
+
*/
|
|
283
|
+
export function createAutomations({ dir = defaultDir(), log = () => {}, ask = null, openWindow = () => {}, emit = () => {}, onChange = () => {}, ps = runPs, runner = startRunner, now = Date.now, taskPath = TASK_PATH, notify = () => true } = {}) {
|
|
284
|
+
const index = path.join(dir, 'automations.json');
|
|
285
|
+
const runsDir = path.join(dir, 'runs');
|
|
286
|
+
let items = [];
|
|
287
|
+
try { const j = JSON.parse(fs.readFileSync(index, 'utf8')); if (Array.isArray(j.items)) items = j.items.filter((x) => x && ID_RE.test(x.id)); } catch (e) { if (e.code !== 'ENOENT') log(`[bridgerator] automations.json unreadable: ${e.message}`); }
|
|
288
|
+
const save = () => { fs.mkdirSync(dir, { recursive: true }); writeAtomic(index, JSON.stringify({ items }, null, 2)); onChange(); };
|
|
289
|
+
const get = (id) => items.find((x) => x.id === id) || fail('NO_AUTOMATION', `There is no automation "${id}" on this PC.`, 'action "list" shows them.');
|
|
290
|
+
const scriptFile = (id) => path.join(dir, `${id}.ps1`);
|
|
291
|
+
const readScript = (id) => { try { return fs.readFileSync(scriptFile(id), 'utf8').replace(/^/, ''); } catch { return ''; } };
|
|
292
|
+
// UTF-8 with BOM (Windows PowerShell 5.1 reads a BOM-less file as ANSI: Cyrillic would break), CRLF.
|
|
293
|
+
const writeScript = (id, text) => { fs.mkdirSync(dir, { recursive: true }); writeAtomic(scriptFile(id), `${text.replace(/\r?\n/g, '\r\n')}`); };
|
|
294
|
+
function ensureRunner() {
|
|
295
|
+
fs.mkdirSync(runsDir, { recursive: true });
|
|
296
|
+
const r = path.join(dir, 'runner.ps1'), v = path.join(dir, 'run-hidden.vbs');
|
|
297
|
+
const want = `${RUNNER_PS.replace(/\r?\n/g, '\r\n')}`;
|
|
298
|
+
try { if (fs.readFileSync(r, 'utf8') === want) { /* current */ } else writeAtomic(r, want); } catch { writeAtomic(r, want); }
|
|
299
|
+
const vb = Buffer.from(`${RUN_VBS}`, 'utf16le');
|
|
300
|
+
let same = false; try { same = fs.readFileSync(v).equals(vb); } catch { /* missing */ }
|
|
301
|
+
if (!same) writeAtomic(v, vb); // a task may be starting it right now: temp + rename
|
|
302
|
+
}
|
|
303
|
+
const readRuns = (id) => {
|
|
304
|
+
let t = '';
|
|
305
|
+
try { t = fs.readFileSync(path.join(runsDir, `${id}.jsonl`), 'utf8'); } catch { return []; }
|
|
306
|
+
const out = [];
|
|
307
|
+
for (const l of t.split('\n')) { if (!l.trim()) continue; try { const r = JSON.parse(l.replace(/^/, '')); if (typeof r.t === 'string' && Number.isInteger(r.code)) out.push({ t: r.t, ms: Number(r.ms) || 0, code: r.code, trigger: String(r.trigger || ''), tail: String(r.tail || '') }); } catch { /* a half-written line */ } }
|
|
308
|
+
return out;
|
|
309
|
+
};
|
|
310
|
+
// runs/<id>.running holds the runner's PID: a crash or a reboot leaves the file, but not the process.
|
|
311
|
+
const running = (id) => {
|
|
312
|
+
const mark = path.join(runsDir, `${id}.running`);
|
|
313
|
+
try {
|
|
314
|
+
if (now() - fs.statSync(mark).mtimeMs >= STALE_RUN_MS) return false;
|
|
315
|
+
const pid = Number.parseInt(fs.readFileSync(mark, 'utf8').replace(/^/, ''), 10);
|
|
316
|
+
if (pid > 0) { try { process.kill(pid, 0); } catch (e) { if (e.code === 'ESRCH') return false; } }
|
|
317
|
+
return true;
|
|
318
|
+
} catch { return false; }
|
|
319
|
+
};
|
|
320
|
+
// In flight in THIS bridge: a "Run now" whose runner has not written its mark yet; an automation
|
|
321
|
+
// being changed (one change at a time per automation: lock()).
|
|
322
|
+
const starting = new Set(), busy = new Set();
|
|
323
|
+
const summary = (x) => {
|
|
324
|
+
const runs = readRuns(x.id), last = runs[runs.length - 1];
|
|
325
|
+
return {
|
|
326
|
+
id: x.id, name: x.name, ...(x.recipe ? { recipe: x.recipe } : {}), schedule: x.schedule || null, scheduleText: describeSchedule(x.schedule),
|
|
327
|
+
enabled: x.enabled !== false, scheduled: !!(x.schedule && x.taskName), next: x.schedule && x.enabled !== false ? nextRun(x.schedule, now()) : null,
|
|
328
|
+
last: last ? { t: last.t, code: last.code, ms: last.ms, trigger: last.trigger } : null, running: running(x.id), by: x.by || 'ai', updatedAt: x.updatedAt,
|
|
329
|
+
...(x.taskError ? { taskError: x.taskError } : {}),
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
const loc = (x) => ({ BRX_TASKPATH: x.taskPath || taskPath, BRX_TASKNAME: x.taskName || x.id });
|
|
333
|
+
|
|
334
|
+
async function register(x) {
|
|
335
|
+
if (!x.schedule) return;
|
|
336
|
+
ensureRunner();
|
|
337
|
+
const sched = x.schedule.type === 'weekly' ? { ...x.schedule, days: x.schedule.days.map((d) => PS_DAY[d]) } : x.schedule;
|
|
338
|
+
const r = await psJson({ runPs: ps }, { script: REGISTER_PS, timeout: 60000, env: {
|
|
339
|
+
...loc(x), BRX_FALLBACK: x.taskName ? '' : '1', BRX_ID: x.id, BRX_SCHED: JSON.stringify(sched), BRX_ENABLED: x.enabled !== false ? '1' : '',
|
|
340
|
+
BRX_WSCRIPT: WSCRIPT, BRX_VBS: path.join(dir, 'run-hidden.vbs'), BRX_DESC: `Bridgerator automation "${x.name}". Log: ${path.join(runsDir, `${x.id}.jsonl`)}`,
|
|
341
|
+
} });
|
|
342
|
+
if (!r.ok) throw new ToolError('SCHEDULE_FAILED', `Windows Task Scheduler refused the task: ${String(r.error || 'unknown error').slice(0, 300)}`, 'The script is saved; it can still run on request. Try another schedule, or check Task Scheduler on the PC.');
|
|
343
|
+
if (![ '\\', taskPath, x.taskPath].filter(Boolean).includes(r.taskPath) || ![x.id, `Bridgerator-auto-${x.id}`, x.taskName].filter(Boolean).includes(r.taskName)) throw new ToolError('SCHEDULE_FAILED', 'Windows Task Scheduler gave an unexpected task location.', 'Check Task Scheduler → Bridgerator on the PC.');
|
|
344
|
+
Object.assign(x, { taskPath: r.taskPath, taskName: r.taskName });
|
|
345
|
+
delete x.taskError;
|
|
346
|
+
}
|
|
347
|
+
async function unregister(x) {
|
|
348
|
+
if (!x.taskName) return;
|
|
349
|
+
const r = await psJson({ runPs: ps }, { script: UNREGISTER_PS, env: loc(x), timeout: 60000 });
|
|
350
|
+
if (!r.ok) throw new ToolError('UNSCHEDULE_FAILED', `Windows Task Scheduler did not remove the task: ${String(r.error || '').slice(0, 300)}`, 'Remove it in Task Scheduler → Bridgerator on the PC.');
|
|
351
|
+
delete x.taskName; delete x.taskPath;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const api = {
|
|
355
|
+
dir,
|
|
356
|
+
list: () => items.map(summary),
|
|
357
|
+
get: (id) => { const x = get(id); return { ...summary(x), script: readScript(id) }; },
|
|
358
|
+
script: (id) => readScript(get(id).id),
|
|
359
|
+
runs: (id, n = 10) => readRuns(get(id).id).slice(-Math.max(1, Math.min(50, n))).reverse(),
|
|
360
|
+
counts() { const l = items.map(summary); return { count: l.length, enabled: l.filter((x) => x.scheduled && x.enabled).length, failing: l.filter((x) => x.last && x.last.code !== 0).length }; },
|
|
361
|
+
/** New or changed automation. → { item, scheduleError? } (the script is kept even when Windows refuses the task). */
|
|
362
|
+
async save({ id, name, script, recipe = null, params = null, schedule, by = 'ai' }) {
|
|
363
|
+
const t = new Date(now()).toISOString();
|
|
364
|
+
let x;
|
|
365
|
+
if (id) {
|
|
366
|
+
x = get(id);
|
|
367
|
+
if (name !== undefined) x.name = name;
|
|
368
|
+
if (script !== undefined) { writeScript(x.id, script); x.recipe = recipe || undefined; x.params = params || undefined; }
|
|
369
|
+
} else {
|
|
370
|
+
if (items.length >= MAX_AUTOMATIONS) fail('TOO_MANY', `This PC already has ${MAX_AUTOMATIONS} automations.`, 'Delete one first (action "delete").');
|
|
371
|
+
let nid; do nid = crypto.randomBytes(4).toString('hex'); while (items.some((y) => y.id === nid));
|
|
372
|
+
x = { id: nid, name, ...(recipe ? { recipe } : {}), ...(params ? { params } : {}), schedule: null, enabled: true, by, createdAt: t };
|
|
373
|
+
writeScript(nid, script);
|
|
374
|
+
items.push(x);
|
|
375
|
+
busy.add(nid); // not deletable while its task is being registered
|
|
376
|
+
}
|
|
377
|
+
x.updatedAt = t;
|
|
378
|
+
let scheduleError;
|
|
379
|
+
try {
|
|
380
|
+
if (schedule !== undefined) {
|
|
381
|
+
try { await api.setSchedule0(x, schedule); } catch (e) { scheduleError = e; x.taskError = e.message; }
|
|
382
|
+
} else if (x.taskName && script !== undefined) {
|
|
383
|
+
// The task runs <id>.ps1 itself: a new script text needs no new task, the name may.
|
|
384
|
+
try { await register(x); } catch (e) { scheduleError = e; x.taskError = e.message; }
|
|
385
|
+
}
|
|
386
|
+
} finally { if (!id) busy.delete(x.id); }
|
|
387
|
+
save();
|
|
388
|
+
return { item: summary(x), ...(scheduleError ? { scheduleError } : {}) };
|
|
389
|
+
},
|
|
390
|
+
async setSchedule0(x, schedule) {
|
|
391
|
+
if (!schedule) { await unregister(x); x.schedule = null; return; }
|
|
392
|
+
x.schedule = schedule;
|
|
393
|
+
x.enabled = true;
|
|
394
|
+
await register(x);
|
|
395
|
+
},
|
|
396
|
+
async setSchedule(id, schedule) {
|
|
397
|
+
const x = get(id);
|
|
398
|
+
try { await api.setSchedule0(x, schedule); } catch (e) { x.taskError = e.message; throw e; } finally { x.updatedAt = new Date(now()).toISOString(); save(); }
|
|
399
|
+
return summary(x);
|
|
400
|
+
},
|
|
401
|
+
async setEnabled(id, on) {
|
|
402
|
+
const x = get(id);
|
|
403
|
+
if (x.taskName) {
|
|
404
|
+
const r = await psJson({ runPs: ps }, { script: ENABLE_PS, env: { ...loc(x), BRX_ON: on ? '1' : '' }, timeout: 60000 });
|
|
405
|
+
if (!r.ok) throw new ToolError('SCHEDULE_FAILED', `Windows Task Scheduler did not ${on ? 'enable' : 'disable'} the task: ${String(r.error || '').slice(0, 300)}`, 'Try again, or check Task Scheduler on the PC.');
|
|
406
|
+
}
|
|
407
|
+
x.enabled = !!on; x.updatedAt = new Date(now()).toISOString(); save();
|
|
408
|
+
return summary(x);
|
|
409
|
+
},
|
|
410
|
+
/** → what Undo needs ({ item, script }). The task leaves Task Scheduler first. */
|
|
411
|
+
async remove(id) {
|
|
412
|
+
const x = get(id);
|
|
413
|
+
await unregister(x);
|
|
414
|
+
const removed = { item: { ...x }, script: readScript(id) };
|
|
415
|
+
items = items.filter((y) => y !== x);
|
|
416
|
+
for (const f of [scriptFile(id), path.join(runsDir, `${id}.jsonl`), path.join(runsDir, `${id}.running`)]) fs.rmSync(f, { force: true });
|
|
417
|
+
save();
|
|
418
|
+
return removed;
|
|
419
|
+
},
|
|
420
|
+
/** Undo of a delete in the window (made by the person): same id, script and schedule back. */
|
|
421
|
+
async restore(removed) {
|
|
422
|
+
const it = removed?.item;
|
|
423
|
+
if (!it || !ID_RE.test(it.id) || items.some((y) => y.id === it.id) || typeof removed.script !== 'string' || !removed.script) fail('BAD_ARGS', 'Nothing to restore.');
|
|
424
|
+
if (items.length >= MAX_AUTOMATIONS) fail('TOO_MANY', `This PC already has ${MAX_AUTOMATIONS} automations.`);
|
|
425
|
+
const x = { id: it.id, name: String(it.name || 'Automation').slice(0, MAX_NAME), ...(it.recipe ? { recipe: String(it.recipe) } : {}), ...(it.params && typeof it.params === 'object' ? { params: it.params } : {}), schedule: readSchedule(it.schedule ?? null, now(), { future: false }), enabled: it.enabled !== false, by: it.by === 'person' ? 'person' : 'ai', createdAt: String(it.createdAt || new Date(now()).toISOString()), updatedAt: new Date(now()).toISOString() };
|
|
426
|
+
writeScript(x.id, removed.script.slice(0, MAX_SCRIPT));
|
|
427
|
+
items.push(x);
|
|
428
|
+
try { if (x.schedule && !(x.schedule.type === 'once' && !nextRun(x.schedule, now()))) await register(x); else if (x.schedule?.type === 'once') x.schedule = null; } catch (e) { x.taskError = e.message; }
|
|
429
|
+
save();
|
|
430
|
+
return summary(x);
|
|
431
|
+
},
|
|
432
|
+
/** One change at a time per automation (save under its id, schedule, enable, delete). Throws BUSY. */
|
|
433
|
+
async lock(id, fn) {
|
|
434
|
+
if (busy.has(id)) fail('BUSY', 'This automation is being changed right now (another request is waiting for an answer or for Task Scheduler).', 'Try again in a moment.');
|
|
435
|
+
busy.add(id);
|
|
436
|
+
try { return await fn(); } finally { busy.delete(id); }
|
|
437
|
+
},
|
|
438
|
+
/** Runs a saved automation now (the same runner as the schedule). → { done, run? } — done:false = still running. */
|
|
439
|
+
async run(id, { trigger = 'manual', waitMs = RUN_WAIT_MS } = {}) {
|
|
440
|
+
const x = get(id);
|
|
441
|
+
if (process.platform !== 'win32' && runner === startRunner) fail('NOT_WINDOWS', 'This works only on Windows.');
|
|
442
|
+
if (running(x.id) || starting.has(x.id)) fail('RUNNING', `"${x.name}" is running right now.`, 'Check its log in a moment (action "runs").');
|
|
443
|
+
starting.add(x.id);
|
|
444
|
+
try { ensureRunner(); } catch (e) { starting.delete(x.id); throw e; }
|
|
445
|
+
const t0 = now();
|
|
446
|
+
// A runner that throws at once (not a rejected promise) must not leave the id "starting" for good.
|
|
447
|
+
const p = new Promise((res) => res(runner(dir, x.id, trigger))).finally(() => starting.delete(x.id));
|
|
448
|
+
const r = await Promise.race([p, new Promise((res) => setTimeout(() => res({ waiting: true }), waitMs).unref?.())]);
|
|
449
|
+
if (r?.error) throw new ToolError('PS_START', `PowerShell did not start: ${r.error.code || r.error.message}.`, 'Check that Windows PowerShell is not blocked on this PC.');
|
|
450
|
+
onChange();
|
|
451
|
+
if (r?.waiting) return { done: false };
|
|
452
|
+
const runs = readRuns(x.id);
|
|
453
|
+
const last = runs[runs.length - 1];
|
|
454
|
+
return { done: true, run: last && Date.parse(last.t) >= t0 - 2000 ? last : null, exit: r.code };
|
|
455
|
+
},
|
|
456
|
+
// Scheduled runs that ended with an error → an event for the relay (Telegram etc.: no names, no output).
|
|
457
|
+
seen: new Map(),
|
|
458
|
+
tick() {
|
|
459
|
+
let changed = false;
|
|
460
|
+
for (const x of items) {
|
|
461
|
+
let size = 0;
|
|
462
|
+
try { size = fs.statSync(path.join(runsDir, `${x.id}.jsonl`)).size; } catch { /* no runs yet */ }
|
|
463
|
+
const was = api.seen.get(x.id);
|
|
464
|
+
api.seen.set(x.id, size);
|
|
465
|
+
if (was === undefined || size === was) continue;
|
|
466
|
+
changed = true;
|
|
467
|
+
const runs = readRuns(x.id), last = runs[runs.length - 1];
|
|
468
|
+
if (!last || last.trigger !== 'schedule' || last.code === 0) continue;
|
|
469
|
+
// failing = failed scheduled runs in a row (whole numbers only: the relay gets no name, no output).
|
|
470
|
+
let failing = 0;
|
|
471
|
+
for (let i = runs.length - 1; i >= 0 && runs[i].code !== 0; i--) if (runs[i].trigger === 'schedule') failing++;
|
|
472
|
+
emit({ kind: 'automation_failed', code: last.code, failing });
|
|
473
|
+
}
|
|
474
|
+
if (changed) onChange();
|
|
475
|
+
},
|
|
476
|
+
start() { api.tick(); const t = setInterval(() => { try { api.tick(); } catch (e) { log(`[bridgerator] automations check failed: ${e.message}`); } }, WATCH_MS); t.unref?.(); return () => clearInterval(t); },
|
|
477
|
+
/** Fire-and-forget notice that a script was scheduled (buttons: Show / Turn off). */
|
|
478
|
+
told(x) {
|
|
479
|
+
if (!ask || !notify()) return;
|
|
480
|
+
Promise.resolve(ask({ head: 'ChatGPT scheduled a script on this PC', text: clip(`${x.name} — ${describeSchedule(x.schedule)}`, '', 140), buttons: [['open', 'Show', ''], ['off', 'Turn off', '']], secs: 120, auto: 'closes' }))
|
|
481
|
+
.then((k) => { if (k === 'open') openWindow('automations'); else if (k === 'off') return api.setEnabled(x.id, false); return null; })
|
|
482
|
+
.catch((e) => log(`[bridgerator] automation notice failed: ${e?.message || e}`));
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
return api;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---- the tool ---------------------------------------------------------------------------------------
|
|
489
|
+
const ACTIONS = ['recipes', 'list', 'save', 'run', 'runs', 'schedule', 'enable', 'disable', 'delete'];
|
|
490
|
+
const SCHEDULE = {
|
|
491
|
+
type: ['object', 'null'], additionalProperties: false,
|
|
492
|
+
description: 'daily {time "HH:MM"} · weekly {days ["mon"…"sun"], time} · hourly {every 1–12} · logon · once {at "YYYY-MM-DDTHH:MM" local}; null = no schedule',
|
|
493
|
+
properties: { type: { type: 'string', enum: ['daily', 'weekly', 'hourly', 'logon', 'once'] }, time: { type: 'string' }, days: { type: 'array', items: { type: 'string', enum: DAYS } }, every: { type: 'integer', minimum: 1, maximum: 12 }, at: { type: 'string' } },
|
|
494
|
+
};
|
|
495
|
+
export const AUTOMATION_TOOL = {
|
|
496
|
+
name: 'bridgerator_automation',
|
|
497
|
+
title: 'Automations (scheduled scripts)',
|
|
498
|
+
description: 'PowerShell scripts saved on this PC that Windows Task Scheduler runs on a schedule — without the chat and without using tool calls. Actions: recipes (ready-made scripts and their parameters), save (script or recipe + params, optional schedule; id = change an existing one), list, run (a saved id now, or a recipe once without saving), runs (last runs: time, exit code, end of the output), schedule (set, change or null), enable, disable, delete. A recipe with DryRun true only shows what it would do.',
|
|
499
|
+
inputSchema: {
|
|
500
|
+
type: 'object', additionalProperties: false, required: ['action'],
|
|
501
|
+
properties: {
|
|
502
|
+
action: { type: 'string', enum: ACTIONS },
|
|
503
|
+
id: { type: 'string', maxLength: 8 },
|
|
504
|
+
name: { type: 'string', maxLength: MAX_NAME },
|
|
505
|
+
script: { type: 'string', maxLength: MAX_SCRIPT, description: 'Windows PowerShell 5.1 script; exit code ≠ 0 = failed' },
|
|
506
|
+
recipe: { type: 'string', maxLength: 40 },
|
|
507
|
+
params: { type: 'object', description: 'recipe parameters (action "recipes" lists them)' },
|
|
508
|
+
schedule: SCHEDULE,
|
|
509
|
+
limit: { type: 'integer', minimum: 1, maximum: 50 },
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
513
|
+
};
|
|
514
|
+
export const defs = [AUTOMATION_TOOL];
|
|
515
|
+
export const settings = [
|
|
516
|
+
{ key: 'automations.notifySchedule', default: true, widensWhen: false, label: { en: 'Notify when ChatGPT schedules a script', ru: 'Уведомлять, когда ChatGPT ставит сценарий на расписание' }, hint: { en: 'A notification on this PC each time a script is scheduled or its schedule changes', ru: 'Уведомление на этом ПК при каждой постановке сценария на расписание или его изменении' } },
|
|
517
|
+
];
|
|
518
|
+
|
|
519
|
+
// One store per bridge (bridge.mjs attaches it; ctx.cfg is the same object for the bridge's lifetime).
|
|
520
|
+
const stores = new WeakMap();
|
|
521
|
+
export const attachAutomations = (cfg, store) => { stores.set(cfg, store); return store; };
|
|
522
|
+
export const automationsFor = (cfg) => stores.get(cfg) || null;
|
|
523
|
+
|
|
524
|
+
const notConfirmed = (t) => ({ content: [{ type: 'text', text: t }], isError: true, notConfirmed: true });
|
|
525
|
+
/** A script the AI wants on this PC: the same rules as a terminal command (terminal off, excluded folders, Bridgerator itself). */
|
|
526
|
+
function checkScript(ctx, script) {
|
|
527
|
+
const refusal = ctx.policy?.check('start_process', { command: script });
|
|
528
|
+
if (refusal) fail('ACCESS_DENIED', refusal.replace(/^Access denied by Bridgerator: /, ''), 'Change the script, or ask the person to change Bridgerator → Safety.');
|
|
529
|
+
}
|
|
530
|
+
function scriptOf(ctx, args) {
|
|
531
|
+
if (args.recipe !== undefined) {
|
|
532
|
+
if (args.script !== undefined) fail('BAD_ARGS', 'Give either script or recipe, not both.');
|
|
533
|
+
const b = buildRecipe(ctx, str(args, 'recipe', { required: true, max: 40 }), args.params ?? {});
|
|
534
|
+
return { script: b.script, recipe: b.recipe, params: b.params };
|
|
535
|
+
}
|
|
536
|
+
if (args.params !== undefined) fail('BAD_ARGS', 'params belong to a recipe.');
|
|
537
|
+
const s = str(args, 'script', { required: true, max: MAX_SCRIPT });
|
|
538
|
+
if (/\u0000/.test(s)) fail('BAD_ARGS', 'The script contains a NUL character.');
|
|
539
|
+
return { script: s, recipe: null, params: null };
|
|
540
|
+
}
|
|
541
|
+
const nameOf = (args, fallback) => {
|
|
542
|
+
const n = args.name === undefined ? fallback : str(args, 'name', { required: true, max: MAX_NAME }).trim();
|
|
543
|
+
if (!n || CTRL.test(n)) fail('BAD_ARGS', 'name must be a short title on one line.');
|
|
544
|
+
return n;
|
|
545
|
+
};
|
|
546
|
+
/** Scheduling = something that stays on the PC: asks first when confirmations + "Scheduled scripts" are on. */
|
|
547
|
+
async function approve(ctx, { name, script, schedule, change }) {
|
|
548
|
+
const s = ctx.gate?.settings?.();
|
|
549
|
+
if (!s?.enabled || !s.ask?.scheduled_scripts) return 'free';
|
|
550
|
+
const r = await ctx.gate.askPerson('schedule', {
|
|
551
|
+
head: change ? 'ChatGPT wants to change a scheduled script' : 'ChatGPT wants to schedule a script',
|
|
552
|
+
text: clip(`${name} · ${describeSchedule(schedule)} · ${script}`, '', 140),
|
|
553
|
+
buttons: [['allow', 'Allow', ''], ['deny', 'Deny', 'deny']], secs: s.timeoutSec, auto: 'auto-deny', ...(ctx.popup?.() || {}),
|
|
554
|
+
});
|
|
555
|
+
return r === 'allow' ? 'allowed' : r === 'cooldown' ? 'cooldown' : 'denied';
|
|
556
|
+
}
|
|
557
|
+
const deniedText = (r) => notConfirmed(r === 'cooldown' ? 'Not scheduled — the person at the PC declined a schedule a few minutes ago. Ask them first.' : 'Not scheduled — the person at the PC did not allow it (denied or no answer).');
|
|
558
|
+
|
|
559
|
+
// One recipe / script run without saving: piped to PowerShell's stdin (no file, no length limit of a command line).
|
|
560
|
+
const ONE_OFF_PS = "$ErrorActionPreference = 'Continue'\n[Console]::InputEncoding = [Text.UTF8Encoding]::new($false)\n$__s = [Console]::In.ReadToEnd()\n& ([scriptblock]::Create($__s)) 2>&1 | ForEach-Object { if ($_ -is [Management.Automation.ErrorRecord]) { 'ERROR: ' + $_.Exception.Message } else { $_ } } | Out-String -Stream -Width 220\nif ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }";
|
|
561
|
+
const tailOf = (s) => { const t = String(s || '').replace(/\s+$/, ''); return t.length > TAIL ? `...${t.slice(-TAIL)}` : t; };
|
|
562
|
+
|
|
563
|
+
/** The script on disk is still the one the person was asked about (another change can't slip in). */
|
|
564
|
+
function sameScript(a, id, script) {
|
|
565
|
+
if (id && script !== null && a.script(id) !== script) fail('CHANGED', 'The script was changed while the person was being asked; nothing was done.', 'Ask again.');
|
|
566
|
+
}
|
|
567
|
+
/** action "save" (inside the automation's lock when it has an id). */
|
|
568
|
+
async function saveAction(a, ctx, args, src, cur) {
|
|
569
|
+
const script = src ? src.script : a.script(cur.id);
|
|
570
|
+
checkScript(ctx, script);
|
|
571
|
+
const schedule = args.schedule === undefined ? undefined : readSchedule(args.schedule, Date.now());
|
|
572
|
+
const nm = nameOf(args, cur ? cur.name : src.recipe ? src.recipe : '');
|
|
573
|
+
// What runs on a schedule changes (new schedule, or a new script under an existing one) → approval.
|
|
574
|
+
const willRun = schedule !== undefined ? schedule : cur?.scheduled ? cur.schedule : null;
|
|
575
|
+
const changed = !!willRun && (schedule !== undefined || !!src);
|
|
576
|
+
let ok = 'free';
|
|
577
|
+
if (changed) { ok = await approve(ctx, { name: nm, script, schedule: willRun, change: !!cur?.scheduled }); if (ok === 'denied' || ok === 'cooldown') return deniedText(ok); }
|
|
578
|
+
if (changed) sameScript(a, cur?.id, cur ? (src ? null : script) : null);
|
|
579
|
+
const r = await a.save({ id: cur?.id, name: nm, ...(src ? { script: src.script, recipe: src.recipe, params: src.params } : {}), ...(schedule !== undefined ? { schedule } : {}) });
|
|
580
|
+
if (r.scheduleError) return { content: [{ type: 'text', text: `Saved as ${r.item.id}, but not scheduled. ${r.scheduleError.message}${r.scheduleError.hint ? ` ${r.scheduleError.hint}` : ''}` }], isError: true };
|
|
581
|
+
if (changed && r.item.scheduled && ok === 'free') a.told(r.item);
|
|
582
|
+
return json({ saved: r.item, note: r.item.scheduled ? `Windows runs it ${r.item.scheduleText}, also when no chat is open. Results: action "runs".` : 'Saved; not scheduled. Run it with action "run" or give it a schedule.' });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export async function handle(name, args, ctx) {
|
|
586
|
+
const a = automationsFor(ctx.cfg);
|
|
587
|
+
const action = needAction(args, ACTIONS);
|
|
588
|
+
if (action === 'recipes') return json({ recipes: recipeList('en'), note: 'Run one with action "run" (recipe + params) or keep it with action "save" (+ schedule). DryRun true only shows what would happen.' });
|
|
589
|
+
if (!a && !(action === 'run' && args.id === undefined)) fail('NOT_AVAILABLE', 'Automations are not available on this PC.', 'Bridgerator needs to be installed with its settings folder.');
|
|
590
|
+
const id = () => str(args, 'id', { required: true, max: 8 });
|
|
591
|
+
switch (action) {
|
|
592
|
+
case 'list': return json({ automations: a.list(), where: 'Windows Task Scheduler → Bridgerator; scripts and logs stay on this PC' });
|
|
593
|
+
case 'runs': { const x = a.get(id()); return json({ id: x.id, name: x.name, runs: a.runs(x.id, Number.isInteger(args.limit) ? args.limit : 5) }); }
|
|
594
|
+
case 'save': {
|
|
595
|
+
const src = args.script === undefined && args.recipe === undefined && args.id ? null : scriptOf(ctx, args);
|
|
596
|
+
const cur = args.id ? a.get(id()) : null;
|
|
597
|
+
// One change at a time per automation: nothing can swap its script or delete it while the person is asked.
|
|
598
|
+
return cur ? a.lock(cur.id, () => saveAction(a, ctx, args, src, a.get(cur.id))) : saveAction(a, ctx, args, src, null);
|
|
599
|
+
}
|
|
600
|
+
case 'schedule': {
|
|
601
|
+
const x = a.get(id());
|
|
602
|
+
if (args.schedule === undefined) fail('BAD_ARGS', 'schedule is required (null removes it).');
|
|
603
|
+
const schedule = readSchedule(args.schedule, Date.now());
|
|
604
|
+
return a.lock(x.id, async () => {
|
|
605
|
+
if (schedule) {
|
|
606
|
+
const script = a.script(x.id);
|
|
607
|
+
checkScript(ctx, script);
|
|
608
|
+
const ok = await approve(ctx, { name: x.name, script, schedule, change: x.scheduled });
|
|
609
|
+
if (ok === 'denied' || ok === 'cooldown') return deniedText(ok);
|
|
610
|
+
sameScript(a, x.id, script);
|
|
611
|
+
const s = await a.setSchedule(x.id, schedule);
|
|
612
|
+
if (ok === 'free') a.told({ ...s, schedule });
|
|
613
|
+
return json({ automation: s });
|
|
614
|
+
}
|
|
615
|
+
return json({ automation: await a.setSchedule(x.id, null), note: 'No schedule any more; the script is kept.' });
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
case 'enable': case 'disable': {
|
|
619
|
+
const x = a.get(id());
|
|
620
|
+
return a.lock(x.id, async () => {
|
|
621
|
+
if (action === 'enable' && x.scheduled && !x.enabled) {
|
|
622
|
+
const script = a.script(x.id);
|
|
623
|
+
checkScript(ctx, script);
|
|
624
|
+
const ok = await approve(ctx, { name: x.name, script, schedule: x.schedule, change: true });
|
|
625
|
+
if (ok === 'denied' || ok === 'cooldown') return deniedText(ok);
|
|
626
|
+
sameScript(a, x.id, script);
|
|
627
|
+
const s = await a.setEnabled(x.id, true);
|
|
628
|
+
if (ok === 'free') a.told(s);
|
|
629
|
+
return json({ automation: s });
|
|
630
|
+
}
|
|
631
|
+
return json({ automation: await a.setEnabled(x.id, action === 'enable') });
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
case 'delete': { const x = a.get(id()); await a.lock(x.id, () => a.remove(x.id)); return json({ deleted: x.id, note: 'Removed from Task Scheduler with its script and log.' }); }
|
|
635
|
+
case 'run': {
|
|
636
|
+
if (args.id === undefined) {
|
|
637
|
+
// A recipe (or a script) once, without saving it.
|
|
638
|
+
const src = scriptOf(ctx, args);
|
|
639
|
+
checkScript(ctx, src.script);
|
|
640
|
+
const stop = await ctx.gate?.gate?.('start_process', { command: src.script });
|
|
641
|
+
if (stop) return stop;
|
|
642
|
+
const r = await (ctx.runPs || runPs)({ script: ONE_OFF_PS, input: src.script, timeout: ONE_OFF_MS });
|
|
643
|
+
return json({ ...(src.recipe ? { recipe: src.recipe } : {}), code: r.code, output: tailOf(`${r.stdout}${r.stderr ? `\n${r.stderr}` : ''}`) });
|
|
644
|
+
}
|
|
645
|
+
const x = a.get(id());
|
|
646
|
+
return a.lock(x.id, async () => {
|
|
647
|
+
const script = a.script(x.id);
|
|
648
|
+
checkScript(ctx, script);
|
|
649
|
+
const stop = await ctx.gate?.gate?.('start_process', { command: script });
|
|
650
|
+
if (stop) return stop;
|
|
651
|
+
sameScript(a, x.id, script);
|
|
652
|
+
const r = await a.run(x.id, { trigger: 'ai' });
|
|
653
|
+
if (!r.done) return json({ id: x.id, running: true, note: 'Still running in the background; action "runs" shows the result when it ends.' });
|
|
654
|
+
return json({ id: x.id, run: r.run || { code: r.exit, tail: '' } });
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
default: return fail('BAD_ACTION', `action must be one of: ${ACTIONS.join(', ')}.`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Window requests (the person at the PC: no questions). */
|
|
662
|
+
export async function windowRpc(a, method, p = {}) {
|
|
663
|
+
if (!a) throw new Error('automations are not available');
|
|
664
|
+
switch (method) {
|
|
665
|
+
case 'automation.list': return a.list();
|
|
666
|
+
case 'automation.get': return { ...a.get(String(p.id)), runs: a.runs(String(p.id), 20) };
|
|
667
|
+
case 'automation.run': { const r = a.run(String(p.id), { trigger: 'manual', waitMs: 300 }); await r; return true; }
|
|
668
|
+
case 'automation.enable': return a.lock(String(p.id), () => a.setEnabled(String(p.id), p.on === true));
|
|
669
|
+
case 'automation.remove': return a.lock(String(p.id), () => a.remove(String(p.id)));
|
|
670
|
+
case 'automation.restore': return a.restore(p.removed);
|
|
671
|
+
default: throw new Error(`unknown request ${method}`);
|
|
672
|
+
}
|
|
673
|
+
}
|